Skip to content

feat(auth): real accounts — gate the app behind Supabase Auth (008) - #28

Merged
rghamilton3 merged 4 commits into
mainfrom
feat/008-auth
Jul 27, 2026
Merged

rghamilton3 merged 4 commits into
mainfrom
feat/008-auth

Conversation

@rghamilton3

Copy link
Copy Markdown
Contributor

Feature 008: the app is gated behind real Supabase Auth. Two commits — the feature, then fixes from a multi-agent review of it.

What ships

  • Sign-in wall. src/routes/+layout.svelte redirects to /signin until a session exists, remembering the intended path in ?next=. Rendering is gated on ready so a returning user never sees a flash of the wall while the session restores.
  • $lib/auth — a thin wrapper over Supabase Auth. Every credential operation delegates to supabase-js; no hand-rolled hashing, tokens or storage (INV-SEC-001). authErrors translates AuthError into the product's calm voice, preferring code over message text.
  • $lib/session now only observes the session. ensureSession() (which minted anonymous sessions) is replaced by currentUser() across all six data services; anonymous sign-ins are off in config.toml.
  • AuthForm / AccountPanel — sign in, sign up, password reset, password change, sign out.
  • No email-confirmation callback route — supabase-js reads the session out of the redirect URL fragment (detectSessionInUrl), so confirmation and recovery links only need site_url / additional_redirect_urls pointed at the app.

Review fixes (second commit)

Seven review agents ran over the feature commit. The findings worth acting on:

  • Sign-out was global. supabase-js defaults to scope: 'global', revoking refresh tokens for every session on the account — signing out on a phone signed you out of your laptop, contradicting the sign-up screen's "keeps everything in sync across your devices". Now scope: 'local'.
  • Duplicate sign-up stranded the user. With confirmations on, Supabase answers an existing address with a 200 carrying an obfuscated user and sends no mail. Reported as confirmation-required, that told people to wait for an email nobody sent. Production-only — locally enable_confirmations = false returns a real error, so the whole test suite exercised the working path.
  • Sign-up leaked account existence while the reset flow deliberately avoided it. Both flows are now non-committal, and the property is pinned by tests rather than by copy.
  • Expired sessions dead-ended. A non-terminal refresh failure left a stale session with no SIGNED_OUT: the UI looked signed in while every request failed 401/PGRST301 and mapped to generic "please try again" copy that retrying could never fix. A fetch wrapper clears the session on a non-auth 401 so the guard routes to /signin?next=<path>.
  • The ?next= guard was string-based. /\evil.com passed startsWith('/') && !startsWith('//') and resolved to a foreign origin. Not an exploitable open redirect — SvelteKit refuses off-origin navigation — but the rejection was voided, stranding a signed-in user on the sign-in wall with no error. Now parsed and origin-compared, with both goto calls reporting failures.
  • No auth error was logged anywhere, so the least-recognised failures were also the least diagnosable.
  • Plus: restoreSession() catches a thrown getSession(); --create-user is create-or-reuse rather than a no-op when any account exists, and the seed script refuses non-localhost hosts; dead code removed; sessionState exported readonly; two inverted comments corrected.

One correction worth recording: an earlier reported "broken password recovery" was not a bug. GoTrue's IsRedirectURLValid matches site_url on scheme + hostname + port and never compares the path, so ${origin}/account is accepted and the allow-list is never consulted.

Verification

svelte-check 0 errors / 0 warnings (543 files) · prettier + eslint clean · 333 unit tests · 40 e2e.

Not included

Specs for session.svelte.ts and AccountPanel.svelte, and an e2e driving PASSWORD_RECOVERY through to a completed password change — the largest remaining coverage gaps. Production also needs SMTP configured in Supabase before confirmations are switched on, or confirmation mail never sends.

https://claude.ai/code/session_017nHLGE2QXdBnW5tLxUotrC

Closes the anonymous-only session model. Every route except /signin now
requires a permanent account; sessions come from Supabase Auth
email/password rather than signInAnonymously().

- config: enable_anonymous_sign_ins = false, minimum_password_length = 8,
  and site_url fixed (was 127.0.0.1:3000, but vite dev serves 5173 and
  preview 4173 — confirmation redirects would have landed nowhere).
- $lib/auth: thin wrapper over Supabase Auth (signUp / signInWithPassword /
  signOut / reset / resend), error→product-voice mapping, and client-side
  pre-checks. No hashing, tokens or storage of our own (INV-SEC-001).
- session.svelte.ts now observes rather than creates a session;
  ensureSession() → currentUser() across the six data services.
- /signin wall + /account (email, password change, sign-out). The layout
  guard remembers the intended path in ?next= and refuses off-site targets.
- No email-confirmation callback route: supabase-js reads the session from
  the redirect URL fragment (detectSessionInUrl defaults to true). signUp()
  handles both the auto-confirm and check-your-inbox outcomes.

Also fixes a raw NUL byte in shoppingListIntegration.ts's mergeKey template
literal (now \0), which made the file read as binary and silently excluded
it from grep/rg.

Tests: 324 unit (49 new), 40 e2e (11 new auth specs); the four existing
suites now sign in first.

Refs #19
…ration

Follow-up to 21bb654, from a multi-agent review of the 008 branch.

- signOut() now uses scope 'local'. supabase-js defaults to 'global', which
  revokes refresh tokens for every session on the account, so signing out on a
  phone silently signed the user out of their laptop too — the opposite of what
  the sign-up screen promises.

- signUp() no longer strands a duplicate sign-up. With confirmations on,
  Supabase answers an existing address with a 200 carrying an obfuscated user
  (no identities, no session, no mail sent); reported as "confirmation-required"
  that told the user to wait for an email nobody sent. Now detected explicitly
  and folded together with the confirmations-off error path, so behaviour no
  longer changes with project configuration.

- Sign-up no longer reveals whether an address is registered, matching the
  stance the reset flow already took. Duplicate outcomes render identically to
  new sign-ups, the enumerating strings are gone from authErrors, a failed
  resend is acknowledged neutrally, and authErrorMessage refuses to pass
  Supabase's raw "User already registered" text through.

- Expired sessions recover instead of dead-ending. A non-terminal refresh
  failure left the client holding a stale session with no SIGNED_OUT: the UI
  looked signed in while every request failed 401/PGRST301, mapped to generic
  "please try again" copy that retrying could never fix. A fetch wrapper in
  supabaseClient clears the session on a non-auth 401, so the layout guard
  routes to /signin?next=<path> and the user resumes where they left off.

- The ?next= guard parses instead of prefix-matching. `/\evil.com` passed
  startsWith('/') && !startsWith('//') and resolved to a foreign origin;
  SvelteKit refused the navigation, but the rejection was voided, stranding a
  signed-in user on the sign-in wall. Both goto calls now report failures.

- restoreSession() catches a thrown getSession(), which previously escaped as an
  unhandled rejection and made services throw raw Errors instead of their own
  domain errors, defeating instanceof checks downstream.

- Auth failures are logged with their cause. None of the four catch blocks
  recorded anything, so the least-recognised errors were also the least
  diagnosable in production.

- seed-dev: --create-user is create-or-reuse by email rather than a silent no-op
  whenever any account already exists, and the script refuses to run against a
  non-localhost host, since it provisions a known-password account.

- Dead code removed (isSignedIn, currentUserId), sessionState exported readonly,
  and two comments corrected that described the opposite of what the code does.

Gates: svelte-check 0 errors/0 warnings, prettier + eslint clean, 333 unit
tests, 40 e2e.

Claude-Session: https://claude.ai/code/session_017nHLGE2QXdBnW5tLxUotrC
The workflow was succeeding while posting nothing. Without `track_progress`,
a `prompt` on a pull_request event runs the action in agent mode, which
provisions no GitHub comment-posting tools. The review ran to completion
(14 turns, ~15 min) and then every attempt to publish it was denied —
run 30229845477 on PR #28 ended with permission_denials_count: 69 and
"No buffered inline comments", leaving zero comments on the PR.

`track_progress: true` forces tag mode, which creates the tracking comment
and preserves GitHub context. This matches the working workflows in
lattice, spacelab, and spacelab-hud.

Also bumps actions/checkout v4 -> v6 to clear the Node 20 deprecation
warning. ci.yml and claude.yml still pin v4.

Claude-Session: https://claude.ai/code/session_017nHLGE2QXdBnW5tLxUotrC
Clears the Node 20 deprecation warning in the three ci.yml jobs and in
claude.yml, matching the bump already applied to claude-code-review.yml.
No actions/checkout@v4 references remain.

Claude-Session: https://claude.ai/code/session_017nHLGE2QXdBnW5tLxUotrC
@rghamilton3
rghamilton3 merged commit 823082b into main Jul 27, 2026
3 checks passed
@rghamilton3
rghamilton3 deleted the feat/008-auth branch July 27, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant