Skip to main content
Sign-up is invite only. There is no way to create an account without a code that an administrator issued.
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.
access_requests has one policy: select, gated on public.is_admin(). A signed-in non-administrator reads zero rows.
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.
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.
To add an administrator:

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.
In the Supabase dashboard, turn off Allow new users to sign up under Authentication → Sign In / Providers. Sign-up through the app keeps working, because the admin API does not check that switch. Until it is off, admin.html says so in red, based on a live read of /auth/v1/settings.
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. 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 on GET 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. status and save_settings answer 403.
  • Passing a different id alongside it is ignored. The id always comes from the token’s own row.
  • It expires when the request is redeemed, or after 30 days.
Decisions made this way leave 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 when RESEND_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.html shows 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:
  1. Paste scripts/doc-requests-sheet.gs into the sheet’s Apps Script editor.
  2. Deploy it as a web app that executes as you, with access set to Anyone.
  3. Put the /exec URL in SHEET_WEBHOOK in dist/index.html.
To find requests that never reached the sheet: