Skip to content

feat(widget): scope sessions by audience (upstream batch D) - #34

Merged
theRealBithive merged 6 commits into
mainfrom
feat/upstream-batch-d
Sep 16, 2026
Merged

theRealBithive merged 6 commits into
mainfrom
feat/upstream-batch-d

Conversation

@theRealBithive

@theRealBithive theRealBithive commented Sep 15, 2026

Copy link
Copy Markdown
Owner

What changes in the running service

Upstream QuackbackIO#547 (99fb7a1d2), cherry-picked with -x. Every session now carries
the 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 requireAuth and requireWorkspaceRole. A
principal promoted to staff after identify kept its seven-day widget session,
and the promoted role then passed those gates.

  • Migration 0280_widget_session_scope adds session.scope, backfills it, and
    installs a trigger that re-stamps a session when its widget provenance lands.
  • /api/widget/identify mints scope=widget and never reuses a dashboard
    session; the lazy anonymous sign-in is tagged through a Better Auth hook.
  • The one-time-token handoff promotes the session to portal before the cookie.
  • Bare requireAuth() / getOptionalAuth() present a widget or portal session's
    principal as an ordinary user with no permissions.
  • Stream tokens bind the audience, and a token that names none is refused.
  • The import/export API and the onboarding administrator gates refuse a
    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 toSessionScope answers dashboard for any value it does not
recognise. That makes the most privileged audience the fallback: a session whose
scope column was written by a replica that predates the column, or written
wrongly, 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 two
audiences that already existed, and there is no path in the product that writes
a fourth value. portal rather than a new member of the union, because the type
travels into the client bootstrap payload and into the signed stream token.

mintStreamToken loses its scope default for the same reason: upstream
defaults it to dashboard, so a caller that forgets the argument mints the most
privileged token. There is one caller and it passes the audience it
authenticated, so the compiler holds it instead.

Migration numbering has re-converged

0280 keeps upstream's number and its when. Our journal ends at idx 256 /
0279_better_auth_17 and so does upstream's — the two files we renumbered and
the one we skipped cancel out. First pick since #29 that needed no renumbering.

615e4da2b is skipped

It fixes the bug this fork already fixed in #14 (d20bda577) eight days earlier
and 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 pages is an array — and
that 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.ts and every test names its
number.

# Guarantee Held in
R1 A session is stamped with the audience it was minted for auth/__tests__/session-scope-on-create.test.ts, session-scope-backfill.db.test.ts
R2 Only a dashboard session satisfies a team or permission gate auth-scope.test.ts, workspace-utils.redirect.test.ts, upload/__tests__/image.test.ts, chat/__tests__/stream.test.ts, oauth-callback-audience.test.ts
R3 A principal promoted after the widget minted its session gains no dashboard authority on it auth-scope.test.ts, widget-auth.test.ts
R4 A widget identify never reuses a dashboard session widget/__tests__/identify-session-audience.test.ts
R5 The lazy anonymous sign-in gets a widget session session-scope-on-create.test.ts
R6 The handoff promotes to portal before the cookie is set auth.widget-handoff-promotion.test.ts, routes/__tests__/auth.widget-handoff.test.ts
R7 A bare auth helper presents a non-dashboard principal as an ordinary user auth-scope.test.ts, resolve-portal-access.test.ts
R8 A stream token carries its audience; one naming none is refused realtime/__tests__/stream-token.test.ts, conversation-stream-token.fn.test.ts
R9 Import/export and the onboarding admin gates refuse a non-dashboard session workspace-api-access.test.ts, onboarding-admin-promotion.fn.test.ts, oauth-callback-audience.test.ts
R10 Re-running the migration changes nothing, and each kind of session lands on the right audience session-scope-backfill.db.test.ts
R11 A session minted by an old replica is re-stamped when its provenance row lands session-scope-backfill.db.test.ts
R12 An unrecognised audience carries no dashboard authority auth-scope.test.ts, roles.test.ts, oauth-callback-audience.test.ts
R13 A cached inbox entry is patched only when it really is a page of posts mutations/__tests__/inbox-list-cache.test.ts

Three of those needed more than assertions.

  • R5 could not be reached at all. Upstream's rule is an arrow function inside
    the betterAuth({ … }) literal, and nothing in the repository executes a hook
    body there — measured, no statement between lines 540 and 660 of
    auth/index.ts is covered by any of the 43 suites in that directory. It moves
    into auth/session-scope.ts and is passed by reference, the way
    guardBetterAuthUserCreation already is; the new suite asserts both the rule
    and that the options object really carries it.
  • R10 and R11 are claims about rows, and the checks next to them are not.
    replay-safety.test.ts reads the SQL; lineage-double-apply.db.test.ts
    re-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 DO block —
    taken from the file rather than restated, since a copy would agree with itself.
  • R6 is an ordering, and the suite beside it exercises a hand-kept mirror of
    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 createServerFn and driven, with
    the promotion and the Set-Cookie forward recorded in one ordered log. The
    source-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 portal is best-effort — upstream wraps it in a
try/catch that logs and carries on, so a failed update still installs the
cookie. It is currently harmless: the session being promoted was minted by
widget identify, which already writes scope: 'widget', so a failed promotion
leaves 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.

Gate Result
Diff coverage PASS — 59 of 59 added lines executed, 0 never executed, across 21 judged files
Mutation PASS — 5 graded files, 201 mutants, 93.53%, 13 excused as equivalent, 0 unjustified survivors (1m51s)
bun run typecheck 0 errors
bun run lint 0 errors (48 pre-existing warnings)
bun scripts/i18n-check.ts PASS
bunx tsc --noEmit -p scripts/tsconfig.json 0 errors
bun run --cwd packages/db typecheck 0 errors
bun run db:check-drift "No drift: migrations and TS schema are in sync."
CONTRACT.md regenerated; the diff is the scanned count and nothing else
Prettier clean on every changed and new file

The 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.json asserts that the listed suites pin
the whole file, and these are files this change passes through rather than
files it is about — conversation.ts is 1,749 lines and this batch touches one
of them. The exception worth naming is functions/auth-helpers.ts, which is
where 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% in
roles.ts. Grading the file itself would mean writing tests for its
cookie-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 11x entry in SELF-IMPROVE.mdsettings.test.ts,
email-inbound-route.test.ts, archive.test.ts, module-state.test.ts and
singletons-not-shared.test.ts each hit a 20-to-36-second timeout under
parallel 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 with
nothing 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 not
have that policy.

🤖 Generated with Claude Code

mortondev and others added 6 commits September 15, 2026 21:26
* 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>
@theRealBithive
theRealBithive marked this pull request as ready for review September 16, 2026 03:11
@theRealBithive
theRealBithive merged commit 9484465 into main Sep 16, 2026
11 checks passed
@theRealBithive
theRealBithive deleted the feat/upstream-batch-d branch September 16, 2026 09:03
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.

3 participants