Submitter accounts
With accounts on, submitting means being signed in: a visitor gets a one-time link by email, or
continues with Google, and /account/ lists everything they submitted with its review status.
features.accounts: true in site.config.ts turns it on; Google is optional on top.
-
Switch accounts on, next to submissions.
site.config.ts features: { submissions: true, accounts: true },Terminal pnpm check:configsite.config.ts OK — <your site name> (https://your-domain.com). -
Apply the migration that adds
users,sessions,login_tokensandlistings.user_id.Terminal pnpm devLocally,
pnpm devapplies it before the dev server starts and prints adev: applied …line. In production:pnpm db:migrate:remote, orpnpm cf:setup, which runs it. -
Deploy, then open
/submit/signed out.Terminal pnpm deployThe browser is sent to
/login/?next=%2Fsubmit%2F, and the header now carries an “Account” link. Sign-in by emailed link is complete at this point; it sends through whateverproviders.emailis — Email. -
Google, optionally: create the OAuth client.
Google Cloud console → APIs & Services → OAuth consent screen. The site asks for the
openid,emailandprofilescopes; a consent screen left in Testing admits only the testers listed on it. Then Credentials → Create credentials → OAuth client ID → Web application, with the authorised redirect URI:https://<your domain>/auth/google/callback/Keep the trailing slash: the callback lives at that exact path, and the slash-less spelling is a redirect. Copy the client id (it ends in
.apps.googleusercontent.com) and the secret (it begins withGOCSPX-). -
Put both on the Worker.
Terminal pnpm cf:secrets GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRETOne prompt each, nothing echoed. A swapped pair is refused before upload: “this is the client id — it belongs in GOOGLE_CLIENT_ID; the secret begins with GOCSPX-”.
/login/now shows “Continue with Google”.
Verify
Section titled “Verify”| What to check | Where | You should see |
|---|---|---|
| The gate | /account/ or /submit/, signed out |
a 302 to /login/?next=… with the page you came from |
| The sign-in page | /login/ |
200, Cache-Control: no-store, an email field; “Continue with Google” once both secrets are set |
| A link | ask for one, then email_outbox |
a login-link row whose payload url is /auth/callback/?token=…; opening it lands on the next page or /account/ |
| Single use | open the same link again | 410 and “This link no longer works” |
/auth/google/ |
a 303 to Google’s consent screen, then back through /auth/google/callback/ to /account/ |
|
| A submission | /submit/, signed in |
“Submitting as” your address; the row’s user_id is set; /account/ lists it with a Details link |
| Sign out | the button on /account/ |
you land on /login/, and /account/ gates again |
How it works
Section titled “How it works”
The link. /login/ posts an address to auth.requestLink. The server stores the SHA-256
of a random token in login_tokens with the return path, queues a login-link mail carrying
/auth/callback/?token=…, and flushes the outbox at once so the link arrives now rather than at
the next hourly tick. The link works for twenty minutes and once: the row is claimed before the
session is created, so two clicks at the same moment sign in once. One address may ask for five
links an hour; the page says “Check your inbox” whatever happened, so the form cannot reveal
which addresses exist. A used, expired or forged link gets “This link no longer works” with a
410.
The session. Signing in writes a sessions row and a zd_session cookie — HttpOnly,
SameSite=Lax, Secure on HTTPS — that lasts 30 days. The cookie carries a random value and
the row its SHA-256, so a copy of the database cannot be turned into a cookie. It is read only
when the cookie is present; anonymous page views cost nothing. Sign out deletes the row and
expires the cookie. The hourly job (sweepAuth) deletes expired sessions and links.
Google. /auth/google/ sets a zd_oauth cookie for ten minutes holding a random state
and the return path, then sends the browser to Google’s consent screen with
prompt=select_account. The callback checks the state against the cookie, exchanges the code,
fetches the profile, and only accepts an address Google marks email_verified; anything else is
“Sign-in did not complete” with a 400. The session it starts is the same one a link starts, and
the users row is the same too — one row per address, whichever way it was first verified,
with the name and picture filled in when Google supplies them.
The form and the account page. With a session, the address on a submission comes from the
account — a hidden field, never typed — and the row records user_id. Receipts and status
pages are unchanged. /account/ shows who is signed in, a Sign out button, and the latest
submissions: name (linked once live), plan, date, “payment not completed” where it applies, the
status as In review, Live or Not listed, and a Details link to the status page with a token
minted for that render, so no internal id is ever shown.
Reserved paths. login, account and auth cannot be used as a routes.*Base;
robots.txt disallows /login/, /account/ and /auth/; every response on those paths
carries X-Robots-Tag: noindex and Cache-Control: no-store.
Switching off. features.accounts: false makes the pages answer 404 and the form ask for
an email again. The tables stay — migrations only add — and existing user_id values are kept.
When it is off or degraded
Section titled “When it is off or degraded”| Configuration | What a visitor sees | What /admin/ reports |
Fix |
|---|---|---|---|
features.accounts: false |
/login/, /account/ and /auth/callback/ answer 404; /auth/google/ redirects to /login/ |
nothing | set it to true |
| one or both Google secrets missing | no “Continue with Google” button; /auth/google/ answers a 303 to /login/ |
nothing — this pair is deliberately not a configuration gap | pnpm cf:secrets GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET |
providers.email: 'console' |
no link arrives; the log line names the recipient and subject but not the link — read url from the login-link row in email_outbox |
warning: providers.email is "console": submitters get no mail, it is only logged. |
|
| the Google address is not verified | “Sign-in did not complete” | nothing | sign in with the emailed link |
| more than five links an hour for one address | “Check your inbox”, and nothing is sent | nothing | wait an hour |
| the migration is not applied | the pages fail with a no such table error |
nothing | pnpm db:migrate:local, or pnpm db:migrate:remote |
Configuration
Section titled “Configuration”| Field | Default | What it changes |
|---|---|---|
features.accounts |
false |
the gate on /submit/, the pages under /login/, /account/ and /auth/, the header link |
providers.email |
'console' |
the provider that carries the sign-in link |
site.url |
— | the redirect URI is built from it, so it must be the domain the OAuth client knows |
Reference: features, providers. The two Google secrets are runtime secrets — Environment and secrets.
- site.config.ts
features.accounts - .dev.vars.example the two Google keys and where each comes from
- drizzle/0001_accounts.sql the migration
Directorysrc/
- db/schema.ts
users,sessions,login_tokens,listings.user_id - middleware.ts reads the session, gates
/account/ Directoryserver/auth/
- index.ts links, sessions, the Google exchange,
sweepAuth - cookies.ts
Set-Cookiestrings intoAstro.cookies
- index.ts links, sessions, the Google exchange,
- actions/auth.ts
auth.requestLinkandauth.logout Directorypages/
- login/index.astro
- account/index.astro
- auth/callback.astro where a link lands
- auth/google/index.astro to Google
- auth/google/callback.astro back from Google
Directorytemplates/account/ LoginPage, AccountPage, LinkExpiredPage
- …
- server/email/templates/login-link.ts the mail
- db/schema.ts
- tests/e2e/accounts.spec.ts the gate, one link, sign out, in a browser