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:
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:
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:
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.