Skip to content

feat(beyondleetcode): C35a Users: create an Invite link - #191

Open
GauranshMathur wants to merge 6 commits into
mainfrom
C35a-invite-link
Open

GauranshMathur wants to merge 6 commits into
mainfrom
C35a-invite-link

Conversation

@GauranshMathur

@GauranshMathur GauranshMathur commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Admin creates an Invite on the Users page and is shown the link once; the database holds only the SHA-256 of the token. A pending Invite offers "Cancel invite" only. Per the orchestrator's decisions: multiUser gains invites (built in createApp from db and the ORIGIN already validated in buildAuth), set on locals.invites in Multi-user Mode only.

Checklist

  • Invite table + migration (verified with prisma migrate diff: no difference), invites.ts + tests
  • Seam: multiUser.invites in app.ts, handle.ts, app.d.ts
  • Users page: create, cancel, pending rows (AD2 board)
  • Self-review; Codex second opinion: no findings
  • CI

Notes for the security review

Behaviour beyond the card's wording:

  • invites.ts itself checks role === 'admin' in create/listPending/cancel (throws NotAdminError), in addition to the actions' check. create resolves the creating User from Identity's learnerId.
  • lookup returns null for a token not shaped like 43 base64url characters without touching the database.
  • cancel only deletes unused rows (usedAt: null) and returns false for unknown/used/already-cancelled; the action answers all of those with the fixed 404 message "That invite is no longer pending."
  • Cache-Control: no-store is set once, in the hook (handle.ts), on every /users response (page, data request, action result, error); see the security-review fix below. The link is in the action result only; the load returns pending {id, createdAt, expiresAt}.
  • Errors log only the error name, never the message.
  • Without JavaScript the link shows in a read-only field with no Copy button; "Done" hides it (JS only). The link also disappears on any later action result.
  • Expired and used Invites are not listed (the board draws only pending).
  • Tests: invites.test.ts 13, users.test.ts +10 cases, handle/app tests +2; full suite 964 passing; svelte-check and biome clean.

Reviews: self-review ran; Codex (read-only, diff only): no findings.

Security-review fix (no-JS 500)

A no-JS POST to ?/invite runs the action and then the load in one request; both called setHeaders('Cache-Control'), so SvelteKit threw and the request answered 500 after the row was written. Fix: the per-route setters are removed and handle.ts sets no-store on the resolved response when event.route.id === '/users'. Why the hook: one place, so the header cannot be set twice, and it covers every kind of answer (HTML, enhanced action result, __data.json, error pages), where setHeaders only reaches the ones the route code sets. Gate redirects and refusals already carried no-store. Also moved the "A refused or failed switch" comment back above switchRegistration.
Tests: new e2e (JavaScript disabled) creates an Invite, sees 200, no-store and the link; it fails on the old code with "Cache-Control" header is already set (checked). A unit test pins the hook setting no-store on 200 and 500 /users answers; the existing tests now assert the header on the real response. Codex run skipped for this fix: the orchestrator re-runs the two-reader review.

🤖 Generated with Claude Code

GauranshMathur and others added 2 commits October 16, 2026 14:10
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@GauranshMathur
GauranshMathur marked this pull request as ready for review October 16, 2026 14:17
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@GauranshMathur

Copy link
Copy Markdown
Owner Author

Security review (orchestrator-dispatched, 2026-10-17)

Verdict: fix first. Two readers on one shared brief (the card's decisions as a checklist), both read-only, cwd the C35a worktree at 461cb44:

  • Fable advisor: agree.
  • Codex (read-only codex exec): concerns, one finding, confirmed by the orchestrator against the installed SvelteKit.

(An earlier Codex run on 2026-10-16 never read the code: its sandbox could not start after a reboot. That run is not counted.)

Must fix

  1. Without JavaScript, creating an Invite loses the link. A native form POST to ?/invite runs the action, which calls setHeaders({ 'Cache-Control': 'no-store' }) (src/routes/users/+page.server.ts:70), and then the page load in the same request, which sets the same header again (:22). SvelteKit throws "cache-control" header is already set (@sveltejs/kit/src/runtime/server/respond.js:199), so the request answers 500 after the Invite row has been written, and the one response that would carry the link never arrives. With use:enhance the load re-runs as a separate request, so the current tests pass. This breaks decision 3 (the link is shown once, at creation) for the no-JS path that the PR body describes.
    • Suggested fix (Codex): set no-store once, for every Users response (HTML, enhanced action results and errors), for example in the hook for the /users route, and remove the per-route setters. Add a no-JavaScript creation test that sees the link.

Checked and fine (both readers)

  • Admin-only at three layers: the gate (any non-admin under /users, POSTs included), each action's identity?.role !== 'admin' check before touching invites, and requireAdmin inside createInvites. Learner tests confirm nothing is written. Local Mode has multiUser: null, so locals.invites is never set and the actions 404.
  • Only the SHA-256 is stored. listPending selects id, createdAt, expiresAt only, and the load's JSON is asserted to hold neither token nor hash. Errors log thrown.name only.
  • 32 random bytes → 43-character base64url. TOKEN_SHAPE rejects malformed input before hashing. Unsalted SHA-256 is right for a 256-bit random secret. The schema and migration match decision 1. chooseMultiUser builds Invites from the validated ORIGIN exactly when Better Auth exists (decision 6).

Not blocking (Fable)

  • The stale comment "A refused or failed switch changed nothing" in +page.svelte now sits above the invite state; move it back above switchRegistration.
  • cancel filters on usedAt: null only, so cancelling an already-expired Invite deletes it and returns true. Harmless.
  • For C35b (appended to that card): Referrer-Policy: no-referrer on /invite, never log the path, and the claim as one conditional update on { id, usedAt: null, expiresAt > now }, where a count of 0 means refused.

GauranshMathur and others added 3 commits October 17, 2026 14:01
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The previous commit was reformatted by the wrong tool; this one restores the project's formatting.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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