feat(widget): scope sessions by audience (upstream batch D) - #34
Merged
Merged
Conversation
* feat(widget): scope sessions by audience Widget-minted sessions shared one credential universe with dashboard sessions, so the identify-time staff guard was the only thing stopping a widget token from satisfying team/permission gates. A principal promoted after identify kept its 7-day widget session and the promoted role then passed requireAuth/requireWorkspaceRole. Add an explicit session audience (dashboard | widget | portal): - session.scope column + 0280 migration with a replay-safe backfill - /api/widget/identify mints scope=widget and never reuses dashboard sessions - anonymous widget mints are tagged widget via a Better Auth create hook - the OTT handoff promotes the verified session to portal before the cookie - permission/team gates require dashboard; widget auth masks team roles for non-dashboard audiences; chat stream, admin upload and OAuth linking re-check Tests: promotion-after-mint regression, handoff scope promotion, replay-safe backfill, migration contract update. * fix(widget): strip team authority from non-dashboard sessions Address review feedback: - bare requireAuth()/getOptionalAuth() now return role 'user' and no permissions for widget/portal audiences, so downstream policy actors and isTeamMember checks cannot read team data with a promoted principal - backfill existing anonymous-user sessions to scope=widget in 0280 (the anonymous mint has no widget_identified_session provenance row) - fix the handoff test's eq import and the migrator-gate migration list * test(chat): mock session scope and cover non-dashboard stream sessions * fix(widget): close remaining non-dashboard authority leaks Address re-review: - bootstrap normalizes the SSR userRole through sessionRole, so a promoted principal on a widget/portal session is presented as portal-tier - 0280 also backfills sessions that predate the user's first account: the anonymous→signup absorb has already cleared is_anonymous by upgrade time - centralize the role rule in sessionRole() and cover it with tests * fix(widget): keep stream tokens and raw-role paths portal-tier Address re-review: - bind the session audience into minted stream tokens; a promoted principal's widget/portal token can no longer claim inbox/presence/ticket scopes - reject non-dashboard sessions in validateApiWorkspaceAccess (import/export) and the onboarding admin gates, and normalize getCurrentUserRole - cover the token audience path and update partial session mocks * fix(widget): reject audience-less legacy stream tokens Fail closed during rolling deploys: a pre-scope token upgraded to dashboard would preserve the bypass the scope binding closes. The client re-mints on reconnect, so the cost is one handshake. * fix(widget): re-scope rolling-deploy identifies and portal access Address re-review: - add a widget_identified_session trigger that re-scopes a dashboard session to widget when provenance lands, covering old replicas minting during a rolling deploy after the one-time backfill - normalize the role used by resolvePortalAccessForRequest through sessionRole so a promoted principal on a widget/portal session cannot take the team portal-access branch (cherry picked from commit 99fb7a1)
QuackbackIO#547 is picked. Its migration keeps upstream's number and timestamp: our journal ends at idx 256 / `0279_better_auth_17` and so does upstream's, because the two files we renumbered and the one we skipped cancel out. The numbering has re-converged, so 0280 needs no renumbering for the first time since #29. `615e4da2b` is skipped. It fixes the bug this fork already fixed in #14 eight days earlier, and fixed more completely: the same root cause, plus the swallowed rejection beside it, with diff coverage and a mutation score. Upstream's version carries one thing ours does not — it validates each page, not only that `pages` is an array — and that check is worth having on its own terms rather than as a re-import of a fix we already shipped. Nothing in the running service changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream's `toSessionScope` answers `dashboard` for anything it does not recognise, so a session whose scope column was written by something that predates the column — or got it wrong — comes back with full team authority. This fork answers with the least authority an audience can carry instead. Every writer of the column produces one of the three audiences, so a fourth value means one of them went wrong, and the cost of reading it as portal is one sign-in rather than an open dashboard. `mintStreamToken` loses its `scope` default for the same reason: there is one caller and it passes the audience it authenticated, so the compiler can hold that instead of falling back to the most privileged answer. The batch's contract list R1-R13 goes into `functions/__tests__/auth-scope.test.ts` and every test names its number. Reaching the numbers needed three things beyond assertions. The `session.create.before` hook moves out of the Better Auth options object into `auth/session-scope.ts`, the way `guardBetterAuthUserCreation` already sits in `signup-policy.ts` — inlined there, no test can reach it and nothing would notice if it were never wired in, which the new suite now asserts against the options the real `createAuth()` builds. The migration's backfill and its rolling-deploy trigger get a real-Postgres suite: the sibling checks read the SQL or compare catalogues, and every claim 0280 makes is about rows. And the handoff's "promote before the cookie, not after" is read off the production file, because the suite next to it exercises a hand-kept mirror that cannot hold an ordering. `inbox-list-cache` adopts the one thing upstream's own fix for the same bug (615e4da) has and ours did not: the shape check descends into the pages, so an entry whose `pages` array holds something other than post pages is handed back instead of throwing one line further in. What an operator notices: a session carrying an audience nothing wrote loses staff authority until the next sign-in. Nothing else changes — every gate in the product asks `=== 'dashboard'`, so the new fallback is behaviourally identical to the two audiences that already existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four files join the mutation manifest: the audience helpers themselves, the session-create rule, the stream token, and the import/export gate. The first run scored 83% with 34 uncaught, and the survivors were worth having — the stream token's suite proved the round trip and almost nothing else, so dropping the domain-separation tag, the length check in front of `timingSafeEqual`, the expiry comparison and the guard that refuses a token naming no principal all went unnoticed. Each of those now has a test, built by signing a payload by hand rather than by minting one, because half of what verification promises is about payloads the minter would never write. The import/export gate's two refusal branches were never executed, and its principal lookup passed with the condition removed, which a mock that ignores its arguments will always do. Both are pinned now, the second by reading the `where` the query was built with. Thirteen mutants are recorded as equivalent with their reasons. Eight are log metadata, following the records the GitLab integration already carries. Three are the `<= 0` guards in the token verifier, where the inputs that skip the guard are refused one branch further down — the reasoning is written out, because "defensive guard" on its own is how an allowlist entry gets waved through. One is the unreachable `catch` around a `Buffer.from` that does not throw. The last is the deliberate redundancy in `toSessionScope`, where naming `portal` explicitly and falling back to it produce the same string. Final: 5 files, 201 mutants, 93.53%, no unjustified survivors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UPSTREAM.md: QuackbackIO#547's row now names the one place the fork answers differently from upstream, and `615e4da2b`'s row says which part of it was adopted rather than only that it was skipped. SELF-IMPROVE.md: three new entries and one counter. `afterAll(fixture.close)` written inside a describe closes the pool for every later describe in the file, and the error it produces names the guard the suite already has. A rule inlined in the Better Auth options object is executed by no test and still counted by the diff-coverage gate, and delegating from an inline arrow does not fix it — only passing the function by reference does. And a column with a permissive default turns every shared fixture into a caller of that default, which is why flipping one turned nine tests red through a single `mockSession`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The diff-coverage gate named three lines this batch added that no test
executed, and all three are the same kind of line: a place where the
audience decides whether something happens.
- `conversation.ts:847`, the stream-token mint. The new suite keeps the
signing module real and reads the token back through
`verifyStreamToken`, because a test that only inspected the call's
arguments would pass just as happily if the token never carried them.
- `oauth-handlers.ts:177-178`, the credential-linking callback. It never
goes through `requireAuth` — it is a raw Request handler and reads the
Better Auth session itself — so the audience check is written out there
and had to be held there. One case lets a dashboard session through to
the next gate, so the refusals cannot be read as "refuses everyone".
- the handoff route's promotion to the portal audience. The suite beside
it exercises a hand-kept mirror and could only hold R6 by reading the
source as text; the production handler is now captured from
`createServerFn` and driven, with the promotion and the Set-Cookie
forward recorded in one ordered log. Both checks stay: a source-text
check survives a refactor of the test double, a behavioural one
survives a rewording of the source.
The last of those also records, rather than asserts as desirable, that a
failed promotion write is swallowed and the handoff continues.
Nothing in the running service changes.
Diff coverage is now 59 of 59 added lines executed. The mutation gate is
unchanged at 93.53% over 201 mutants, because the manifest's graded set did
not move — `conversation.ts`, `oauth-handlers.ts` and the route file stay
ungraded on purpose, being far larger than this change and mostly untouched
by it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes in the running service
Upstream QuackbackIO#547 (
99fb7a1d2), cherry-picked with-x. Every session now carriesthe audience it was minted for — dashboard, widget or portal — and only a
dashboard session can satisfy a team or permission gate.
The bug it closes: a widget-minted session shared one credential universe with
dashboard sessions, so the identify-time staff guard was the only thing stopping
a widget token from passing
requireAuthandrequireWorkspaceRole. Aprincipal promoted to staff after identify kept its seven-day widget session,
and the promoted role then passed those gates.
0280_widget_session_scopeaddssession.scope, backfills it, andinstalls a trigger that re-stamps a session when its widget provenance lands.
/api/widget/identifymintsscope=widgetand never reuses a dashboardsession; the lazy anonymous sign-in is tagged through a Better Auth hook.
portalbefore the cookie.requireAuth()/getOptionalAuth()present a widget or portal session'sprincipal as an ordinary user with no permissions.
non-dashboard session.
After deploying, some sessions end. The backfill is deliberately
conservative: a false positive costs a re-login, a false negative leaves a widget
token dashboard-capable.
One deliberate divergence from upstream, on request
Upstream's
toSessionScopeanswersdashboardfor any value it does notrecognise. That makes the most privileged audience the fallback: a session whose
scopecolumn was written by a replica that predates the column, or writtenwrongly, comes back with full team authority. Here it answers with the least
authority an audience can carry instead.
What an operator notices: a session carrying an audience nothing wrote loses
staff authority until the next sign-in. Nothing else changes — every gate in the
product asks
=== 'dashboard', so the new fallback behaves exactly like the twoaudiences that already existed, and there is no path in the product that writes
a fourth value.
portalrather than a new member of the union, because the typetravels into the client bootstrap payload and into the signed stream token.
mintStreamTokenloses itsscopedefault for the same reason: upstreamdefaults it to
dashboard, so a caller that forgets the argument mints the mostprivileged token. There is one caller and it passes the audience it
authenticated, so the compiler holds it instead.
Migration numbering has re-converged
0280keeps upstream's number and itswhen. Our journal ends at idx 256 /0279_better_auth_17and so does upstream's — the two files we renumbered andthe one we skipped cancel out. First pick since #29 that needed no renumbering.
615e4da2bis skippedIt fixes the bug this fork already fixed in #14 (
d20bda577) eight days earlierand more completely: same root cause, plus the swallowed rejection beside it,
with diff coverage and a mutation score. Upstream's version carries one thing
ours did not — it validates each page, not only that
pagesis an array — andthat check is adopted here on its own terms rather than as a re-import (R13).
The contract
Confirmed before the tests were written; the list is verbatim in
lib/server/functions/__tests__/auth-scope.test.tsand every test names itsnumber.
auth/__tests__/session-scope-on-create.test.ts,session-scope-backfill.db.test.tsauth-scope.test.ts,workspace-utils.redirect.test.ts,upload/__tests__/image.test.ts,chat/__tests__/stream.test.ts,oauth-callback-audience.test.tsauth-scope.test.ts,widget-auth.test.tswidget/__tests__/identify-session-audience.test.tssession-scope-on-create.test.tsauth.widget-handoff-promotion.test.ts,routes/__tests__/auth.widget-handoff.test.tsauth-scope.test.ts,resolve-portal-access.test.tsrealtime/__tests__/stream-token.test.ts,conversation-stream-token.fn.test.tsworkspace-api-access.test.ts,onboarding-admin-promotion.fn.test.ts,oauth-callback-audience.test.tssession-scope-backfill.db.test.tssession-scope-backfill.db.test.tsauth-scope.test.ts,roles.test.ts,oauth-callback-audience.test.tsmutations/__tests__/inbox-list-cache.test.tsThree of those needed more than assertions.
the
betterAuth({ … })literal, and nothing in the repository executes a hookbody there — measured, no statement between lines 540 and 660 of
auth/index.tsis covered by any of the 43 suites in that directory. It movesinto
auth/session-scope.tsand is passed by reference, the wayguardBetterAuthUserCreationalready is; the new suite asserts both the ruleand that the options object really carries it.
replay-safety.test.tsreads the SQL;lineage-double-apply.db.test.tsre-applies every safe migration and compares catalogues. So the backfill and
the trigger get a real-Postgres suite that seeds each kind of session, forces
the column back to its default, and runs the migration's own
DOblock —taken from the file rather than restated, since a copy would agree with itself.
the route rather than the route — a mirror can agree with a test while
disagreeing with the code it mirrors. The mirror keeps the branch cases, and
the production handler is now captured from
createServerFnand driven, withthe promotion and the
Set-Cookieforward recorded in one ordered log. Thesource-text check stays alongside it: the two fail for different reasons, one
surviving a refactor of the test double and the other a rewording of the
source.
One thing to decide, not changed here
The handoff's promotion to
portalis best-effort — upstream wraps it in atry/catchthat logs and carries on, so a failed update still installs thecookie. It is currently harmless: the session being promoted was minted by
widget identify, which already writes
scope: 'widget', so a failed promotionleaves it non-dashboard and every gate still refuses it. Worth knowing rather
than fixing blind.
Gates
Every number below was measured on this branch, not carried over.
bun run typecheckbun run lintbun scripts/i18n-check.tsbunx tsc --noEmit -p scripts/tsconfig.jsonbun run --cwd packages/db typecheckbun run db:check-driftCONTRACT.mdThe first coverage figure came from a full four-shard run; the three holes it
named were real rather than shard flake, and the last commit closes them.
Fifteen touched files are named by the mutation gate as not graded, and stay
that way. That is the gate working as designed rather than a gap being
skipped: an entry in
mutation-manifest.jsonasserts that the listed suites pinthe whole file, and these are files this change passes through rather than
files it is about —
conversation.tsis 1,749 lines and this batch touches oneof them. The exception worth naming is
functions/auth-helpers.ts, which iswhere the R12 flip actually lands: its wiring is held behaviourally by
auth-scope.test.ts, and the decision it wires up is graded at 98.11% inroles.ts. Grading the file itself would mean writing tests for itscookie-sniffing and settings-read halves, which this change never touches, and
widening a repository-wide gate as a side effect of a pick.
Six suites went red in the local four-shard run and none of them belong to this
branch. Five are the
11xentry inSELF-IMPROVE.md—settings.test.ts,email-inbound-route.test.ts,archive.test.ts,module-state.test.tsandsingletons-not-shared.test.tseach hit a 20-to-36-second timeout underparallel load — and all five pass when the six are re-run together on their own.
The sixth,
sns-signature.test.ts, fails for an environmental reason withnothing to do with the change: this Fedora host's OpenSSL policy refuses
RSA-SHA1, so signing the fixture throws
invalid digest. CI's runner does nothave that policy.
🤖 Generated with Claude Code