Skip to content

Enable the site layer

Available now

The site layer is the optional part of OHM that tracks who visited the site and how it looks — a visitor gate, batched telemetry, whitelabel theme config, and the Operator Tools page. It is off by default, and an instance that never enables it is not degraded; it is the normal deployment.

It never grants application permissions. Whether you may create a design or manage API keys comes from your OHM API key, not from anything here. See the architecture note for why that boundary matters.

Everything below runs in the Supabase SQL editor. None of it is reachable from a browser client — that is deliberate.

1. Create a project and run the schema

Create a Supabase project, open the SQL editor, and run supabase/schema.sql in full.

It creates four tables — ohmgr_visitors, ohmgr_telemetry_events, ohmgr_site_config, ohmgr_admin_secrets — with row-level security on all four, and the SECURITY DEFINER functions that are the only way in. The script is idempotent, so re-running it is safe.

Already running an earlier version? Re-run it. ohmgr_admin_events now returns the props column, so the events an operator reads carry their outcomes. Without the re-run the panel still works and simply shows no outcome figures. The file drops that one function before recreating it, because Postgres will not let create or replace change a return type.

Confirm it landed:

select table_name
from information_schema.tables
where table_schema = 'public' and table_name like 'ohmgr_%'
order by table_name;
-- expect: ohmgr_admin_secrets, ohmgr_site_config,
--         ohmgr_telemetry_events, ohmgr_visitors

Check the functions too. The tables can land while the functions do not, and the client's 404 is on a function, so the query above alone would not catch it:

select routine_name
from information_schema.routines
where routine_schema = 'public' and routine_name like 'ohmgr_%'
order by routine_name;
-- expect ohmgr_track, ohmgr_gate_signin and ohmgr_is_admin among them

Finally, confirm the anon key may actually call it. A function that exists but was never granted to anon returns the same 404 through PostgREST, from a different cause:

select has_function_privilege('anon', 'public.ohmgr_track(jsonb)', 'execute');
-- expect: true

2. Set the operator token

The operator token is what unlocks unmasked visitor records and config publishing. Only its SHA-256 hash is stored, so the token itself exists nowhere in the database.

Generate a long random string yourself, then:

insert into public.ohmgr_admin_secrets (id, token_hash)
values (1, encode(sha256(convert_to('PASTE-A-LONG-RANDOM-TOKEN-HERE', 'UTF8')), 'hex'))
on conflict (id) do update
  set token_hash = excluded.token_hash, updated_at = now();

This is core sha256(), not pgcrypto's digest(), and needs no extension. The two produce the same bytes, but on Supabase pgcrypto is installed into the extensions schema, so digest does not resolve from the SECURITY DEFINER functions — they pin search_path = public precisely so nothing can be shadowed. ohmgr_check_admin hashes the same way, so a token stored by any other expression will not compare equal.

Verify the token works — this returns true only for the right string:

select public.ohmgr_check_admin('PASTE-THE-SAME-TOKEN-HERE');

Keep the token in a password manager. The UI holds it in sessionStorage for one tab and never persists it.

3. Grant an operator marker (optional)

is_admin on a visitor row is a marker, not a credential. It is a label Operator Tools renders beside a name, and nothing more — it unlocks no view and authorises no call. The token from step 2 is the only thing that does.

That distinction is load-bearing rather than pedantic: gate emails are never verified, so anyone can type any address. If the marker granted anything, knowing an operator's email address would be enough to have it. Operator Tools therefore offers the token field to everyone, signed in or not, and decides what to show from what the server accepts.

The marker is grantable only from here:

update public.ohmgr_visitors
   set is_admin = true
 where email = 'you@example.com';

The visitor row is created when that email first signs in at the gate, so sign in once before running this.

4. Point the app at the project

Set both variables on your deployment and rebuild:

NEXT_PUBLIC_OHM_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_OHM_SUPABASE_ANON_KEY=<the anon key>

They are NEXT_PUBLIC_ because the anon key is public by design: with RLS on every table, it can only call the whitelisted RPCs, and the privileged ones check the operator token server-side before returning anything.

5. Write the gate (optional)

Once the layer is on, opening /operator-tools without a visitor record on that device raises the sign-in gate. Nothing else is gated: the dashboard, designs, facilities and matching stay open to everyone, because site sign-in is not an OHM permission.

The wording is yours. Empty strings mean "use the built-in copy", so you can set one field and leave the rest:

select public.ohmgr_publish_config('PASTE-THE-SAME-TOKEN-HERE', jsonb_build_object(
  'gate', jsonb_build_object(
    'enabled', true,
    'title',   'Sign in to Operator Tools',
    'body',    'So your visit has a record you own.',
    'fine',    'Unverified, kept on this device, and no permissions in OHM.'
  )
));

Set "enabled": false if this instance should ask nobody to sign in. Operator Tools then shows the unsigned view with no dialog and no sign-in button.

6. What Operator Tools shows

The page is composed from the tier you hold. The two tiers are independent doors, not a ladder — signing in never makes you an operator, and unlocking never requires a visitor record.

Nobody Visitor (signed in at the gate) Operator (holds the token)
My record name, first/last seen, rename, erase same, if also signed in
Visitors 200 most recent, addresses masked a***@e*** 100 most recent, real addresses
Telemetry 200 most recent events, addresses masked, no outcomes same, plus the address, session, and outcome behind each
Mutations own row only rename, admin marker, delete any visitor; purge by retention window
Operator field offered offered Lock

The masked reads withhold the raw address at the function level, not in the browser — ohmgr_visitors_masked and ohmgr_events_masked never return one, so there is nothing in a visitor's page to un-mask. User agents are returned to nobody.

Erasure is self-service. A visitor can delete their own row and every telemetry event attributed to it, without asking an operator. That is the point of showing people their own record.

Retention is a window, not a button. The purge control takes a number of days to keep and reports how many rows went, so the ordinary operation is "keep 30 days" rather than "delete everything".

Unmet demand is the actionable one. The Telemetry panel leads with counts rather than a log, and the figure worth opening the page for is the list of designs someone matched here that came back with nothing. Every other number says what the instance did; that one says what it could not do, and names the capability gap to go and fill. It needs the operator token — outcomes live in the props column, which only ohmgr_admin_events returns.

Outcome figures are withheld from the self-service tier rather than shown as zero. A masked read returns no props, so "0 of 3" would mean "cannot see" while reading as "none failed".

None of these views cache. The app persists its other queries to storage so a reload starts warm; these panels deliberately opt out, because an operator's unmasked read would otherwise be spooled onto the device. The token itself lives in sessionStorage for one tab and is verified server-side before it is stored at all.

7. Verify the boundary holds

Run these as the anon role (a fresh SQL editor session is not anon — use the REST endpoint with the anon key, or the API docs' "Run" button):

-- Privileged RPCs must refuse without the token.
select public.ohmgr_admin_visitors('wrong-token');   -- expect: unauthorized
select public.ohmgr_admin_stats('wrong-token');      -- expect: unauthorized

-- Masked reads must never return a raw email.
select * from public.ohmgr_visitors_masked('you@example.com');

-- Direct table access must be refused by RLS.
select * from public.ohmgr_visitors;                 -- expect: no rows / denied

A visitor can only mutate their own row:

select public.ohmgr_update_own_name('you@example.com', 'New name');  -- ok
select public.ohmgr_update_own_name('someone@else.com', 'Nope');     -- refused

Turning it off

Unset the two environment variables and rebuild. The app returns to its default posture: no gate, no telemetry, /operator-tools 404s, no nav entry, and theme and mode continue to work from the device. The Supabase client is code-split, so a build with the layer off never fetches it.

Troubleshooting

404 on /rest/v1/rpc/ohmgr_track — the environment variables are set but the schema has not been run in that project. Run step 1, then check both verification queries there: the function may be missing, or present but not granted to anon, and PostgREST reports the two identically.

The client goes dormant after the first 404 rather than retrying every page view, so one such error in the console is expected until the schema lands. That dormancy is per page load, not per session — hard-reload after running the schema, or a tab that was open beforehand stays quiet and keeps looking broken.

unauthorized from every operator call — the token in the browser does not hash to what is stored. Re-run step 2 and paste the same string into Mission Control. Operator Tools reports this as "that operator token was not accepted"; a rejected token is never stored, so the next call does not silently retry with it.

Signed in as a visitor with is_admin, but the panels are still masked — working as intended. The marker is a label; unlock with the token from step 2.

Telemetry silently absent — expected when the layer is off. Telemetry is fail-soft by design and must never break a page; matching designs to facilities is the job that matters.