1
Request
A visitor clicks Get Access in the footer and enters an email address. The
access-request function records it, and gives the same answer to everyone.2
Approve
An administrator approves or declines, either in
admin.html or from the link in the notice email.3
Code
Approving issues a single-use code tied to that email address. It is valid for fourteen days.
4
Sign up
The
access-signup function checks the code and creates the account. This is the only way in.What the schema guarantees
Each of these is enforced in the database, not by the UI.Only an administrator can read a code
Only an administrator can read a code
access_requests has one policy: select, gated on public.is_admin(). A signed-in non-administrator reads zero rows.Nobody can approve their own request
Nobody can approve their own request
There is no insert or update policy on
access_requests for any role. Every write goes through an edge function holding the service key, and access-admin checks the admin roster before doing anything. Called directly by a signed-in non-administrator, it answers 403.Nobody can make themselves an administrator
Nobody can make themselves an administrator
public.admins has no write policy at all. The roster is only changed from the SQL editor.A flag on profiles would not have worked: that table’s policy is for all, so anyone could have set their own.Closing the other doors
access-signup is only a real gate once the project stops accepting public sign-ups. Otherwise anyone can call the Supabase Auth API directly with the publishable key and skip it.
Magic-link sign-in needed the same fix. Left at its default, signInWithOtp creates an account for any address that asks. It now runs with shouldCreateUser: false.
Accounts are created already confirmed. That is the verification, not a shortcut: the code was delivered to that inbox, works once, and is refused with any other address. A confirmation email would prove nothing new.
Answering from the inbox
Each request is created with a 256-bit decision token. The notice email carries a link holding it,/decide?t=…, which opens a page showing that one request with Approve and Decline buttons. admin.html is still the queue, the audit trail and the settings page. The link is the fast path.
The link does nothing when fetched
Mail scanners fetch links. Gmail’s prefetcher has been seen spending a password-reset token eight seconds after it was sent. A link that approved onGET would be approved by a scanner before anyone read the email.
So decide.html is static. Loading the request is a POST, and the decision is a second POST behind a button. Scanner-style GET requests at the link, and a GET at the function with ?action=approve, leave the request pending. The function answers 405.
What the token can do
The token can decide its own request and nothing else:- It cannot list the queue, read settings or touch another row.
statusandsave_settingsanswer403. - Passing a different
idalongside it is ignored. The id always comes from the token’s own row. - It expires when the request is redeemed, or after 30 days.
decided_by null. That is how an inbox decision is told apart from one made while signed in.
Email delivery
Notices and codes are sent through Resend whenRESEND_API_KEY and ACCESS_MAIL_FROM are set as edge function secrets.
Where notices go is a setting, not a secret. The field on admin.html writes app_settings.access_notify_email. ADMIN_NOTIFY_EMAIL is only the fallback for a deployment that has never saved that setting.
Without email configured
Nothing breaks, and nothing pretends to work:- No notice is sent.
- Approving still works, and the row records
delivery = 'manual'. - The code is shown to whoever approved it, on the admin page or the decide page, so they can pass it on. Saying “approved” while keeping the code hidden would strand the requester.
admin.htmlshows a red warning. A queue nobody is told about is the dangerous kind of broken, because it looks like it is working.
Documentation requests
The footer’s documentation link opens a form that takes an email address. It writes to two places:
To connect the sheet:
- Paste
scripts/doc-requests-sheet.gsinto the sheet’s Apps Script editor. - Deploy it as a web app that executes as you, with access set to Anyone.
- Put the
/execURL inSHEET_WEBHOOKindist/index.html.
