Appearance
Admin Panel MVP: Stage 0.5 spike results
Measured on 2026-09-28, on the local Supabase stack and with one read of Dev. These are the Stage 0.5 spikes of the Admin Panel MVP assessment (§9.3):
- (b) the revocation primitive;
- (c) staff principal creation;
- (d) the JWT signing mode;
- (e) the Users directory at 100,000 principals.
Spike (a), the ZeptoMail invite, was not executed (§6). The owner decided on these results the same day (§8.1). Programme row: QRS-1404. Every
QRS-###here is a row in the tracker.
How to read the evidence. Each claim carries one of these labels, and this page never upgrades one:
| Label | Means |
|---|---|
| measured | a command run on the local stack (or, where it says so, the one read of Dev), with its trimmed output |
| repo-read | read from a repository file, with file:line. It says what the repo declares, not what a live project runs |
| cache-read | read from the local Deno module cache, which holds the Edge Functions' pinned client (not a repo file) |
| expected, unverified | an inference that nothing here checked |
| unknown | cannot be answered by anything that was run |
1 · Status and environment
| Status | Spikes (b), (c), (d) and (e) are measured, each with a verdict below. Spike (a) was not executed: it needs a real inbox and the owner's go-ahead (§6). |
| What was touched | The local Supabase stack only. It was already running and was not started, stopped or reset. One hosted call: an anonymous GET of Dev's public JWKS (§4). Nothing reached Prod (ikkwqowfnbhdasfejojg) or the backup (ygmqxyrbnemhwkiyoboc). No repository file other than this page was written. |
| Local stack | The supabase_*_qrsetu containers, checked with docker ps. This is not the digious-portal stack that shares the ports. |
| Versions | GoTrue v2.197.0 · PostgreSQL 17.6 · Supabase CLI 2.118.0 · supabase-js and auth-js 2.111.0 at the repo root. The Edge Functions pin supabase-js 2.39.7; its cached esm.sh bundle imports gotrue-js 2.62.2 (cache-read). |
| Migrations | 118 of 118 applied locally, max 20260928130000, the same as the repo (measured). |
| Auth configuration | supabase/config.toml:51-52: jwt_expiry = 3600, enable_signup = true (repo-read). Local GoTrue (measured):
|
| Local signing mode | ES256, one EC P-256 key in the local JWKS (measured). |
| Artefacts | The scripts and raw outputs are in the session scratchpad, which is not in the repo and not durable: b_revocation.mjs, b8_session_liveness.sql, c_staff.mjs, c9_insert_visibility.mjs, c10_trigger_branch.sql, d_impersonation.mjs, e_directory.sql, e_directory_b.sql and z_cleanup_proof.mjs, with their .out files. |
| Secrets | None on this page. A read of the local GoTrue environment printed the local CLI stack's demo signing key. It is local-only and is not reproduced. |
2 · Spike (b): the revocation primitive
Verdict: measured. It feeds QRS-1415 (the primitive) and QRS-1413 (the manage-account fix, F2); F3 is QRS-1433.
Commands.
node b_revocation.mjs: one GoTrue user,spike-admin-b@example.test, signed in twice as two devices.docker exec -i supabase_db_qrsetu psql -U postgres -d postgres -X < b8_session_liveness.sql: rolled back.
Setup.
- The RPC under test is
public.get_my_context(), last defined at20260909150000_v2_context_projects_avatar.sql:45and granted toauthenticated(repo-read). - The access token has header
{"alg":"ES256","kid":"b81269f1-…"},exp - iat = 3600, asession_idclaim andamr: password(measured).
| # | Step | Result (measured, trimmed) |
|---|---|---|
| 1 | The access token on PostgREST rpc/get_my_context | 200 |
| 1 | The access token on GoTrue GET /auth/v1/user. requireAuth makes this call (supabase/functions/_shared/auth.ts:61-66, repo-read; getUser is GET /user in the pinned gotrue-js 2.62.2, cache-read) | 200 |
| 2 | Ban: PUT /auth/v1/admin/users/{id} with {"ban_duration":"24h"} | 200. Sessions before and after: 2 and 2. Live refresh tokens: 2 and 2. The ban deletes nothing. |
| 3 | Refresh grant while banned | 400 user_banned "Invalid Refresh Token: User Banned". Password sign-in: 400 user_banned "User is banned" |
| 4 | The same access token on PostgREST, while banned | 200: it still works |
| 4 | The same access token on GoTrue /user, while banned | 403 user_banned |
| 5 | auth.admin.signOut(<user id>, "global"), the shape at manage-account/index.ts:138 | 403 bad_jwt "invalid JWT: unable to parse or verify signature, token is malformed: token contains an invalid number of segments". Sessions unchanged. |
| 5 | Raw POST /auth/v1/logout?scope=global with the user id as the bearer | The same 403 bad_jwt |
| 5 | auth.admin.signOut(<the user's access token>, "local") while banned | 403 user_banned: refused |
| 6 | SQL as postgres: delete the user's auth.refresh_tokens rows, then their auth.sessions rows | 2 + 2 rows deleted. Refresh: 400 refresh_token_not_found. The access token on PostgREST: 200. On GoTrue /user: 403 session_not_found "Session from session_id claim in JWT does not exist" |
| 7 | Unban: {"ban_duration":"none"} | 200, banned_until: null. Password sign-in returns 200 and the new token works. The old refresh tokens stay refresh_token_not_found. |
| I1 | auth.admin.signOut(<device 3's token>, "global"), not banned, two devices | error=null; sessions 2, then 0. Device 4's refresh: refresh_token_not_found. Its access token: 200 on PostgREST, 403 session_not_found on GoTrue. |
| I2 | Step 6's SQL delete on an unbanned user | Refresh refresh_token_not_found, PostgREST 200, GoTrue 403 session_not_found: the same as I1 |
| I3 | Ban alone, then unban | Refresh while banned: 400 user_banned. After the unban, the pre-ban refresh token works again: 200, new tokens. |
| I4 | Ban, then the SQL delete, then unban | Refresh refresh_token_not_found: no revival. The last access token still gets 200 on PostgREST, with exp - now = 3598 s. |
| b.8 | Prototype, rolled back: a definer check that auth.sessions still holds the token's session_id | Live session t, deleted session f. 10,000 calls in 301.5 ms, about 30 µs each (Index Only Scan using sessions_pkey). authenticated reading auth.sessions directly gets permission denied, so the check must live in a definer. |
Conclusions.
auth.admin.signOuttakes the user's own access token, never a user id.- auth-js 2.111.0 declares
signOut(jwt: string, scope?)(node_modules/@supabase/auth-js/dist/module/GoTrueAdminApi.d.ts:63) and sends that string as the bearer (lib/fetch.js:91-92). The Edge Functions' gotrue-js 2.62.2 does the same:signOut(e, t="global")sendsjwt: e(cache-read). - GoTrue also refuses it for a banned user (step 5).
manage-account/index.ts:138passesuser.id, so it fails on every call and only logs a warning (F2, QRS-1413).
- auth-js 2.111.0 declares
The primitive for "sign out all devices" by user id is a service-role-only definer function:
delete from auth.sessions where user_id = $1. The refresh tokens follow throughrefresh_tokens_session_id_fkey ON DELETE CASCADE, measured on the local catalogue.delete from auth.refresh_tokens where user_id = $1::text, for any row without a session.
It has the same effect as GoTrue's own global logout (I1 and I2 agree).
postgresholdsDELETEon both tables locally (measured); on Dev it is expected, unverified.A ban is not revocation, and an unban revives every pre-ban device (I3, F3, QRS-1433). Suspend must be a ban plus the session delete; lifting it is an unban alone (I4).
The residual window after revocation.
- Edge Functions: 0 s.
requireAuthasks GoTrue, which answersuser_bannedorsession_not_foundimmediately. This was measured at the GoTrue endpoint; no Edge Function was run. Moving the kit to local JWT verification would lose it (expected, unverified). - PostgREST: up to the access-token lifetime, 3600 s locally (
jwt_expiry, and the tokens'exp - iat). Every RPC granted toauthenticatedsits inside that window, the writing ones included. Dev'sjwt_expirywas not read: expected 3600, unverified.
- Edge Functions: 0 s.
Closing the PostgREST window. Either add b.8's liveness check to the definer
my_*helpers (about 30 µs per call, prototype only), or shortenjwt_expiryproject-wide, which costs every merchant more refreshes.A correction to the assessment's B2 (§7). "Live access tokens keep working for up to 1 h" holds for PostgREST only. Edge Functions refuse at once.
3 · Spike (c): staff principal creation
Verdict: measured. It feeds QRS-1414 (staff provisioning); F4, F5 and F11 are QRS-1434.
Commands.
node c_staff.mjs.node c9_insert_visibility.mjs. It uses a temporary, session-scopedtest_decodingreplication slot, dropped at the end.docker exec -i supabase_db_qrsetu psql -U postgres -d postgres -X < c10_trigger_branch.sql: rolled back.
The trigger (repo-read, confirmed live).
- Definition. The latest
handle_new_useris20260905233000_v2_every_account_has_an_address.sql:187-235. The only other definitions, in20260808230000and20260904124512, are earlier. - Firing. It runs from
on_auth_user_created AFTER INSERT ON auth.users(measured on the local catalogue). - Accepted
primary_contextvalues:- the key absent gives
business; business;individual;- anything else raises
22023, andusers_primary_context_checkallows only those two values.
- the key absent gives
- Measured with
'staff':- public signUp:
500 unexpected_failure"Database error saving new user"; - admin
createUser: supabase-js returnedAuthRetryableFetchError, status 500, message{}(F9); - no row persisted either time.
- public signUp:
| Call (measured) | public.users.primary_context | Address (public.slugs) |
|---|---|---|
createUser with email_confirm: true and a full_name | business, the display name taken from the metadata | qr-11ea27e3, opaque and active |
createUser with app_metadata: {principal_kind: "staff"} | business | qr-c1172d5c. The metadata is stored, but the address already exists. |
inviteUserByEmail with redirectTo http://localhost:3000/admin/set-password | business, created at invite time (invited_at set, no password yet) | qr-5e4eecb8, also at invite time (F11) |
Public POST /auth/v1/signup with data: {primary_context: "individual", principal_kind: "staff", full_name}, and app_metadata in the body | individual | spike-signup, seeded from the name |
What the AFTER INSERT trigger sees (measured by logical decoding). Each GoTrue call is one transaction:
createUser(xid 5583).- The
auth.usersINSERT carriesraw_app_meta_data = {"provider":"email","providers":["email"]}, withinvited_atnull androleempty. - The trigger inserts
public.usersand the addressqr-d1e66ac0. - Only then does GoTrue run
UPDATE role,UPDATE raw_app_meta_data(which adds"principal_kind":"staff"),UPDATE email_confirmed_atandUPDATE raw_user_meta_data, and commit.
- The
inviteUserByEmail(xid 5585).- The INSERT has
invited_atnull. - The trigger inserts
public.usersandqr-f2ae00c2. - Then
UPDATE role,UPDATE invited_atand aone_time_tokensinsert, and commit.
- The INSERT has
- Corroboration.
pg_stat_statementsshows one extraUPDATE "users" … SET "raw_app_meta_data"whenapp_metadatais passed, and none without it.
What a normal client can set (measured).
- signUp's
datalands verbatim inraw_user_meta_data. app_metadatain the signup body is ignored, and so isapp_metadatain a raw/invitebody.- After signing up,
PUT /auth/v1/userwithdata: {principal_kind: "staff", role: "super-admin"}returns200, and it is stored (F8). PUT /auth/v1/userwithapp_metadatareturns403 not_admin"Updating app_metadata requires admin privileges".
The staff branch, prototyped. Everything was rolled back: the live body's md5 stayed 457ceef53a3490c60b25dc69c8259a32 and no object was left. The test rows are shaped like GoTrue's INSERT as measured above.
- S4 · a pre-registration row keyed by the INSERT's email, which only the service role can write and which the trigger consumes.
- The pre-registered email gets a
public.usersrow and no address. - A forged
user_metadata.principal_kindstill gets an address (qr-4c470fa6). - An expired pre-registration still gets one (
qr-b200dab9). - The row is single-use.
- The pre-registered email gets a
- S2b · a
DEFERRABLE INITIALLY DEFERREDconstraint trigger that re-readsapp_metadataat commit.- Its notice showed
NEW.raw_app_meta_dataas{"provider": "email", "providers": ["email"]}, and the re-read at commit as{"provider": "email", "providers": ["email"], "principal_kind": "staff"}. - The staff row got no address and an ordinary row got
qr-f24aaea1. NEWalone is not enough: the row has to be re-read.
- Its notice showed
- Unsafe:
raw_user_meta_data, which any client writes, at signup and afterwards. - No effect in today's AFTER INSERT trigger:
raw_app_meta_dataandinvited_at, because both arrive by a later UPDATE.
Email uniqueness (measured).
createUserwith an existing email:422 email_exists"A user with this email address has already been registered".- The same email upper-cased: the same
422. The match is case-insensitive. inviteUserByEmailto a confirmed email: the same422.- A re-invite of a still-invited email succeeds and resends the mail.
- Public signUp with an existing email:
422 user_already_exists"User already registered". That is the local stack with autoconfirm on. On Dev, with email confirmations on, the answer may differ (expected, unverified).
The invite mail (local Mailpit, measured).
- Arrival. Both invites arrived, from
admin@email.comwith the subject "You've been invited". These are GoTrue's defaults, becauseconfig.tomlwires none of the repo's 13 templates (F10). - The link is
http://127.0.0.1:54321/auth/v1/verify?token=…&type=invite&redirect_to=…. - An unlisted redirect. A
redirect_toofhttps://admin-devv.qrsetu.com/admin/set-password, which is not on the local allow-list, still returned200, and the mail's link carried the site URLhttp://localhost:3000instead (F4). - First use:
303to…/admin/set-password#access_token=…&refresh_token=…&type=invite, withamr: otpand 3600 s. - Second use:
303witherror=access_denied&error_code=otp_expired&error_description=Email link is invalid or has expired. A used link and an expired one give one code (F5). - Setting a password.
PUT /auth/v1/userwith a password, using the invite session, returns200, and password sign-in then works. - A weak password.
abc123was accepted: the local policy is 6 characters with no classes (F7).
4 · Spike (d): the JWT signing mode
Verdict: measured, from one hosted read plus the local mechanics. It feeds QRS-1418 (view as user); F6 is QRS-1432.
The one hosted call, with no auth header (/auth/v1/jwks was not needed):
$ curl -sS -D d_jwks_dev.headers -o d_jwks_dev.body https://dyhjofjjuazhyqcvlrkx.supabase.co/auth/v1/.well-known/jwks.json
HTTP/1.1 200 OK
Cache-Control: public, max-age=600
key count = 1
{"kid":"47d99656-560f-49b3-99c0-db2b8befc870","kty":"EC","alg":"ES256","crv":"P-256","use":"sig","key_ops":["verify"],"has_private_d":false}Local comparison (measured). The local stack runs the same mode: one EC P-256 ES256 key in its JWKS, and ES256 user tokens. The local anon and service keys are legacy HS256 tokens, and local GoTrue accepts HS256, RS256 and ES256.
The local mechanics (node d_impersonation.mjs, measured).
- The magic link.
auth.admin.generateLink({type: "magiclink"})returnsaction_link,email_otp,hashed_token,redirect_toandverification_type, and sends no email (Mailpit's count stayed at 4). - Redeeming it server-side.
POST /auth/v1/verifywith{type: "magiclink", token_hash}returns200with an access token and a refresh token:- ES256,
subequal to the target,amr: otp, a realsession_id, 3600 s, and no impersonation claim; - the target's
last_sign_in_atwent from null to stamped; - one session row and two GoTrue audit rows appeared.
- ES256,
- It is not read-only. With that session,
rpc/set_my_display_namereturned200and the display name changed.PUT /auth/v1/userreturned200, and refresh returned200. It is a full, writable, renewable session. - Self-minted tokens. A HS256 token minted with the local stack's published demo secret and no
session_idgot200from PostgREST and from GoTrue/user. With an unknownsession_idit got403 session_not_found(F6).
What this means.
- We cannot mint a user token on Dev. Dev signs with ES256 and publishes a verify-only key, so the private key stays with Supabase (measured: the JWKS carries no private component). The only route would be importing a signing key we hold into the project (expected, unverified).
- The legacy HS256 secret: unknown. A JWKS never lists symmetric keys, so whether Dev still trusts its old secret cannot be read from it. If it does, whoever holds that secret can mint any user's token with no session, and deleting sessions cannot revoke it (the mechanism is measured locally). QRS-1432 tracks reading this in the dashboard.
- The only supported "session for another user" is
generateLinkof typemagiclinkplus a server-side verify, and it is a full session (measured). Read-only access, a 25-minute cap, a marker and "do not stamp last sign-in" would all be ours to build:- a custom access-token hook that adds a claim (expected, unverified);
- write refusal in
requireAuthand in every writing RPC granted toauthenticated. The assessment's §7.1 lists five (repo-read); this spike measured onlyset_my_display_nameaccepting a write; - the session delete from §2 for the cap, plus the PostgREST window;
- a way round the
last_sign_in_atstamp, which corruptsget_my_context'sis_returningand any lifecycle metric.
- Verdict: a read-only "view as user" is infeasible with supported primitives alone. The owner's decision is in §8.1.
5 · Spike (e): the Users directory at 100,000 principals
Verdict: measured, on the local stack inside BEGIN … ROLLBACK. It feeds the directory decision (the assessment's I5, increment 2; QRS-1404).
Commands.
- Pass 1, for A and C:
docker exec -i supabase_db_qrsetu psql -U postgres -d postgres -X -v N=100000 < e_directory.sql. It ran 08:22:18 to 08:24:44 UTC and exited 0. - Pass 2, for B and the signup-trigger cost:
… < e_directory_b.sql. It ran 08:47:27 to 08:48:58 UTC and exited 0.
Data (measured).
- Principals. 100,000
auth.usersrows, inserted aspostgresthrough the real signup trigger, plus 200 more. 80% have an email, all have a phone and 70% have alast_sign_in_at. - Users.
public.usersholds 66,667 business and 33,333 individual principals. - Addresses. 110,008 rows: 66,674 opaque and 33,334 name-seeded, 2,445 of those suffixed.
- Workspaces. 10,000, each with an owner membership and a workspace address.
- Sizes.
auth.users58 MB,public.users16 MB,public.slugs18 MB.
Path used: the trigger path for every row, with one stated deviation.
- The first 1,000 signups ran on the repo's address functions: 8.39 s.
- The rest ran with
is_slug_reservedandresolve_slug_statuspatched inside the transaction to compare withOPERATOR(extensions.=)(F1): 1,000 in 0.45 s, then 98,000 in 70.1 s, or 0.72 ms per signup. - At full scale, with the repo definitions restored, 200 signups took 37.8 s, 189 ms each.
| Query, 100k principals | A · direct join, as is (pass 1) | B · direct join plus indexes on our own users columns (pass 2 †) | C · read model (pass 1) |
|---|---|---|---|
Page 1, created_at desc, id desc, 25 rows | 20.3 ms, page-first, as a Parallel Seq Scan with a top-N sort. 48.7 ms when the enrichment runs before the LIMIT (it sorts to disk) | 0.73 ms | 0.10 ms, Index Scan |
| Deep keyset page (cursor at row 50,100) | 15.8 ms: a Seq Scan removing 50,101 rows | 0.25 ms | 0.10 ms |
| Faceted page: business, owns a workspace, signed in within 30 days | 21.5 ms | 12.7 ms. The sign-in facet still scans auth.users | 4.9 ms |
| Exact phone | 0.10 ms, on GoTrue's users_phone_key | the same index | 0.06 ms |
| Exact email, case-insensitive | 51.6 ms, a Seq Scan of auth.users | 179.6 ms †, the same Seq Scan | 0.05 ms |
| Exact email, byte-equal | 11.1 ms Seq Scan. It is 0.10 ms only with is_sso_user = false written out, because users_email_partial_key is a partial index | not run | not applicable |
Name prefix pri (3,304 matches) | 70.6 ms, Seq Scan | 1.7 ms | 0.93 ms |
Rare prefix prinav (184 matches) | 70.4 ms | 2.2 ms | 0.86 ms, trigram |
| One search box over name, email and handle | 49.9 ms | 14.6 ms | 1.27 ms |
count(*), facet on the users columns (67% of rows) | 14.7 ms | 44.2 ms † | 18.1 ms, a Seq Scan, which is correct at 67% |
count(*), business and signed in within 30 days | 30.2 ms | 125.0 ms † | 2.5 ms, a bitmap on the sign-in index |
count(*) of the prefix matches | 70.4 ms | 7.3 ms | 4.1 ms |
| Facet panel: context by "owns a workspace" | 39.4 ms | not run | 34.0 ms, Seq Scan |
| Approximate total | not applicable | not applicable | reltuples = 100,200, no scan |
† Pass 2 ran the same fully cached sequential scans 3 to 3.5 times slower than pass 1: the same email scan took 179.6 ms against 51.6 ms. The cause was not measured. Read column B by the rows its indexes serve.
What the read model costs (measured).
- Build. The join-and-insert of 100,200 rows took 3.41 s, then 7 indexes at 0.08 to 0.75 s each. Size: an 18 MB heap plus 29 MB of indexes, 47 MB in all.
- Keeping
last_sign_in_atcurrent.Trigger spike_dir_sync_signin: time=384.605 calls=2000, or 0.19 ms per sign-in. - Keeping it current on signup. Four alternating legs of 1,000 signups took 1.36, 1.77, 1.76 and 1.38 s (without, with, without, with): no measurable overhead at this noise level.
- We cannot index
auth.users.create index … on auth.usersreturnsERROR: must be owner of table users.
Method notes.
- Pass 1's B numbers are discarded. Its three indexes on
public.userswere used by no B plan. Two reproductions did not repeat that (the index was used, withindcheckxmin = f), and the cause is not established. - Pass 1's signup-trigger comparison is discarded as noise: the leg without the trigger was the slower one. It was re-measured in pass 2 as A/B/A/B.
- Pass 2 stalled three times and was cancelled, with
pg_cancel_backendon this spike's own backend; each rollback was verified.- WAL fell to 608 and 728 kB per 8 s, against 11 MB per 8 s once fixed.
- The likely cause (inferred, not captured) is query plans cached on the first calls against freshly vacuumed, near-empty tables, which stayed sequential scans while the tables grew.
- The fix was
SET LOCAL enable_seqscan = offfor the load only, switched back on before any measured query: 98,000 rows then loaded in 58.1 s. - It is an artefact of a single-transaction load. It matters to any backfill that calls these functions row by row inside one transaction.
- The † rows are the contended pass described under the table.
Recommendation: the read model (C).
- Indexes on our own
public.users(B) fix paging and name search. - But email search, the sign-in facet and any count that touches sign-in activity still scan
auth.users, which we cannot index. Those took 11 to 52 ms at 100k in pass 1, and 12.7 to 180 ms in the contended pass 2. They grow linearly: about ten times at 1M by extrapolation (expected, unverified). - The read model serves 10 of the 12 measured shapes in 5 ms or less.
- Its two full-population facet counts take 18 and 34 ms as sequential scans, and want approximate counts or a counts table before 1M.
6 · Spike (a): the ZeptoMail invite, not executed
It needs a real inbox and the owner's go-ahead. The row is QRS-285.
What the owner must provide or check on Dev (dyhjofjjuazhyqcvlrkx):
- Auth → SMTP settings. Custom SMTP on, host
smtp.zeptomail.in, port 587 (STARTTLS) or 465, senderno-reply@qrsetu.com, a sender name, and the ZeptoMail SMTP username and password. The host, port and username conventions are expected, unverified; the record's 2026-08-01 defect wassmtp.hostinger.com. - Auth → Rate limits. The email-send limit and the per-user resend interval; they must cover invites, resends and admin resets. The limit is expected to be 30 per hour with custom SMTP (unverified).
- Auth → URL configuration. Dev's site URL, because an unlisted redirect silently falls back to it (measured locally, F4). Allow-list entries for the set-password route on each admin origin:
https://admin-devv.qrsetu.com/**on Dev;https://admin-uatt.qrsetu.com/**on whichever project serves UAT;https://admin.qrsetu.com/**on Prod, not Dev.
- A test inbox that shows raw headers. Accept only on ZeptoMail's Sent counter moving from 0 to 1, plus
dkim=passforqrsetu.comand a DMARC pass. - Auth → Email templates. Whether the branded invite and reset-password templates are pasted into Dev; the dashboard holds the live copy.
What the repo holds (repo-read).
- Templates.
supabase/email-templates/rendered/holds 13 templates and amanifest.json. None is wired intoconfig.toml.- Six action templates:
confirm-signup,sign-in-otp(the magic link),reset-password(recovery),change-email,reauthenticationandinvite. - Seven notifications.
- Six action templates:
- Their links.
invite.htmlandreset-password.htmlboth use{{ .ConfirmationURL }}. The invite copy is written for merchants ("You've been invited to create a QR setu account"). - Who uses them. A search of
apps/,packages/andsupabase/functionsfinds no caller ofresetPasswordForEmail,inviteUserByEmailorgenerateLink, and notype: 'recovery'or'invite'. The'invite'hits are the consumer invitation card kind. So no merchant or consumer flow uses the invite or recovery templates today, and staff can own both. - The caveats.
- The merchant web console calls
signInWithOtp({ email }), which shares the magic-link template, andsignInWithPassword(apps/web/src/tiers/merchant/features/auth/AuthAccountPane.tsx:347,374). manage-accounthaschange_password(updateUser({ password }),supabase/functions/manage-account/index.ts:80).- Merchants can therefore hold passwords, and a merchant reset flow, if one is ever built, would share
recovery.
- The merchant web console calls
7 · Findings
Every finding is measured on the local stack unless its row says otherwise.
| # | Finding | Tracker |
|---|---|---|
| F1 | A citext comparison inside a SET search_path = public function compares as text. The citext = operator lives in schema extensions, so the citext index is unusable and the comparison becomes case-sensitive. Details below. | QRS-1431 |
| F2 | manage-account/index.ts:138 fails on every call (403 bad_jwt), so deleting an account never revokes a session. | QRS-1413 (existing) |
| F3 | An unban revives every pre-ban session (§2, I3). | QRS-1433 |
| F4 | An unlisted redirect_to is silently replaced by the site URL, and the invite still returns 200. | QRS-1434 |
| F5 | A used invite link and an expired one return the same otp_expired code, so the page cannot tell them apart. | QRS-1434 |
| F6 | GoTrue accepts a token with no session_id: a self-minted HS256 token passed locally, and session deletion cannot revoke it. Whether Dev still trusts its legacy HS256 secret is unknown. | QRS-1432 |
| F7 | The local password policy accepted abc123 through updateUser (the assessment's I1). Dev's policy was not read. | QRS-1435 |
| F8 | A signed-in user can write anything into their own user_metadata, including role: "super-admin", so it must never authorize anything. | none assigned; it is why the §8.2 branch never reads user_metadata (QRS-1414) |
| F9 | supabase-js reports a createUser 500 as AuthRetryableFetchError with the message {}, so the reason is lost. | QRS-1435 |
| F10 | The repo's email templates are not wired into the local config.toml, so local tests never exercise the branded mail. | QRS-1435 |
| F11 | An invite creates the principal and its address at once. An abandoned invite leaves a principal behind, and deleting it leaves a released address. | QRS-1434 |
F1 in detail (QRS-1431).
- Measured:
- under
search_path = public,'ABC'::extensions.citext = 'abc'::extensions.citextreturnsf, and the plan isSeq Scan … Filter: ((slug)::text = 'qr-abc'::text); - at 110,008 addresses, one lookup costs 13.9 ms, against 0.044 ms (Index Only Scan) with
OPERATOR(extensions.=); - one repo
resolve_slug_statuscall costs 104 ms, against 0.097 ms patched; - one signup costs 189 ms, against 0.72 ms patched.
- under
- Correctness holds today only because the inputs are
lower()ed and stored slugs are lowercase. - The call sites, found by a heuristic match of a citext column compared with
lower(btrim(p_slug)). The first two are measured, the rest are not:is_slug_reserved,resolve_slug_status;resolve_slug_owner_kind,get_public_setu_card,get_public_catalogue,get_consumer_vendor;resolve_setu_card_payment_readiness,setu_cards_register_slug,verify_qr_lookups;get_public_biodata,record_biodata_open,get_biodata_photo_keys.
- The citext columns are
public.reserved_slugs.slug,public.slugs.slug,public.setu_cards.slug,public.setu_cards.public_emailandpublic.workspaces.contact_email. - The fix, measured to give an index scan:
OPERATOR(extensions.=), which keeps ADR-0014'sSET search_path = public.
8 · Decisions this enables
8.1 The owner's decisions on these results (2026-09-28)
- Holds. Suspend and Block are both a ban plus ending every session: the primitive of §2. The account holder sees one generic state, with no reason and no kind. At the GoTrue boundary this already holds: a ban answers
user_bannedwhichever kind it is (measured, §2). The full text, with the workspace hold, is item 17 of the owner's decision list. - View as user becomes a read-only support view inside the admin panel, with its own design round and ADR-0038, shipping last. A real impersonated session is not built. This is item 18 of the same list.
8.2 ADR-0035: the staff provisioning branch (QRS-1414)
ADR-0035 is the place for the branch:
- Branch on a pre-registration row (S4).
- The staff Edge Function writes it with the service role, keyed by
lower(email)with an expiry, before calling GoTrue. handle_new_userconsumes it at INSERT and gives the principal itspublic.usersrow, which is an FK target (for exampleaudit_log.actor_user_id), but no address.- It is measured to work for
createUserand forinviteUserByEmail, to be single-use, and to ignore a forgeduser_metadata.
- The staff Edge Function writes it with the service role, keyed by
- Never branch on
raw_user_meta_data: any client can write it (measured, F8). raw_app_meta_dataandinvited_atare not in the INSERT (measured). A deferred trigger that re-readsapp_metadataat commit works (measured), but it leans on GoTrue v2.197's internal statement order, so it is a fallback only.- Keep
primary_context = 'business'for staff unless a core-entity proposal widens the CHECK.'staff'is refused today with22023, which surfaces as a 500. - Staff need a work email that no account holds. Any existing email returns
422 email_exists, case-insensitively, and the flow must design that state. - Facts the set-password flow (G2) needs:
- one "link invalid or expired" state, because
otp_expiredcovers both; - the redirect allow-list is mandatory (F4);
- the principal exists from the moment of the invite (F11).
- one "link invalid or expired" state, because
8.3 ADR-0036: the revocation primitive and its residual window (QRS-1415, QRS-1413)
ADR-0036 is the place for the primitive:
The primitive is a service-role-only definer function that deletes
auth.sessions(and any orphanauth.refresh_tokens) for a user id. It is equivalent to GoTrue's global logout (measured) and replacesmanage-account/index.ts:138(F2).Suspend and Block are each a ban plus the primitive (owner decision 1). Lifting either is an unban only. A hold that is only a ban must never ship (F3).
Order and atomicity (the assessment's B6):
- write the intent row;
- ban;
- delete the sessions;
- write the completion row.
A lift must re-drive an incomplete suspend before it unbans, or it revives sessions.
The residual window, to be stated in the ADR:
- Edge Functions: 0 s, as long as the kit keeps asking GoTrue;
- PostgREST: up to 3600 s, for reads and for the writing RPCs granted to
authenticated.
The session-liveness check (about 30 µs per call, prototyped) in the definer helpers takes the PostgREST window to 0 s. A shorter
jwt_expiryalso narrows it, but it is project-wide, needs a Change Record and costs merchants more refreshes. Dev'sjwt_expirystill needs a read (expected 3600, unverified).
8.4 View as user (QRS-1418)
- Infeasible as a platform primitive (measured, §4):
- Dev signs with ES256, so we cannot mint user tokens;
- the supported path is a full, writable, refreshable session that stamps
last_sign_in_atand carries no marker.
- The owner decided on the read-only support view inside admin (§8.1, decision 2), with its own design round and ADR-0038. The support view reads through operator-checked RPCs. It never creates a session for the target, so none of the four controls in §4 is needed and the target's
last_sign_in_atis left alone. - Separately, and whatever the support view becomes, QRS-1432 reads Dev's legacy JWT secret status and revokes the secret if it is still trusted (F6).
8.5 The Users directory: the read model
- Keeping it current. Triggers on
public.usersinsert and update, and onauth.usersinsert and update of email, phone andlast_sign_in_at(0.19 ms per sign-in, measured). - Indexes:
- keyset on
(created_at desc, user_id desc); - trigram on the lowercased name;
- exact E.164 phone;
- a
lower(email)pattern index; - facet and sign-in indexes.
- keyset on
- Counts. Approximate totals come from
reltuples; exact facet counts take 18 to 34 ms at 100k. - Budget: about 47 MB per 100k principals.
- Why not the direct join: the shapes bound to
auth.userscannot be made fast, because we cannot indexauth.users(measured). - Not measured here: the masked phone and email projections that the assessment's I4 requires. They are a design point.
9 · Cleanup proof
node z_cleanup_proof.mjs, pasted:
[proof] spike-admin-* principals in auth.users = 0
[proof] table counts = {"auth.users":0,"public.users":0,"auth.sessions":0,"auth.refresh_tokens":0,"auth.identities":0,
"auth.one_time_tokens":0,"public.workspaces":0,"public.workspace_members":0,"public.slugs":9,"auth.audit_log_entries":51}
[proof] prototype objects (spike_* relations/functions/triggers, pg_trgm) = {"relations":0,"functions":0,"triggers":0,"pg_trgm_installed":0}
[proof] function bodies = repo: {"handle_new_user_md5":"457ceef53a3490c60b25dc69c8259a32","resolve_slug_status_has_patch":false,"is_slug_reserved_has_patch":false}
[proof] replication slots = [{"slot":"cainophile_u65wcmz3","temporary":true}]
[proof] open transactions from other backends = 0
[proof] planner stats (reltuples) = {"auth.users":0,"users":0,"workspaces":0,"workspace_members":0,"slugs":9}
[proof] migrations applied = 118 / max 20260928130000
[mail] Mailpit total before = 4 ; addressed only to spike-admin-* = 4
[mail] DELETE /api/v1/messages (spike ids only) -> 200
[mail] Mailpit total after = 0- Principals. GoTrue persisted 9 spike principals, and all 9 were deleted through
DELETE /auth/v1/admin/users/{id}(each200). Two more signups were refused by the trigger and never persisted. - The replication slot that remains is Realtime's own. This spike's temporary slot was dropped.
- Transactions. Every SQL prototype and both 100k passes ran inside
BEGIN … ROLLBACK, or were cancelled and rolled back by the server. The zero counts, the unchanged md5 and the zerospike_*objects above prove it. - The only committed SQL writes are the brief's step 6 and isolations I2 and I4: deleting
spike-admin-b's ownauth.sessionsandauth.refresh_tokensrows, because GoTrue cannot see a rolled-back delete. Apart from those there is only non-destructive maintenance:ANALYZE, to restore the planner statistics that a rollback leaves inflated in place, and aVACUUMof this spike's dead tuples. - Residue left on purpose, because the brief keeps every SQL change inside a rollback:
- 9 released addresses:
qr-11ea27e3,qr-5e4eecb8,qr-b5385785,qr-c1172d5c,qr-cbeb5811,qr-d1e66ac0,qr-d63abcf4,qr-f2ae00c2,spike-signup. Keeping a released name is the registry's designed behaviour. - 51 rows in GoTrue's
auth.audit_log_entries. - None of this can break
npm run test:db: its three slug counts are scoped to their own fixtures (slug_registry_test.sql:198,241,biodata_projection_test.sql:78, repo-read). - The addresses are local-only and removable with one scoped
delete … where state = 'released' and slug in (…).
- 9 released addresses:
- Lock windows.
- Pass 1 held a lock on
public.usersfrom its first index build toROLLBACK, and onauth.usersfor its last seconds (the sign-in trigger prototype). GoTrue's log for 08:22 to 08:26 UTC shows only this spike's own 8 requests, each served in 47 to 236 ms, so no other sign-in waited. - Pass 2 kept DDL on shared tables to its final seconds: the four signup legs, 6.3 s together, and the probe index.
- Pass 1 held a lock on