Repository navigation
feat(spec,plugin-auth): a short-lived principal-session mint, IAuthService.mintPrincipalSession, implemented by AuthManager - #23786
Conversation
Claude-Session: https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j Co-authored-by: Claude <noreply@anthropic.com>
…refusals and its revoke Claude-Session: https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j Co-authored-by: Claude <noreply@anthropic.com>
…pal-session mint Claude-Session: https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j Co-authored-by: Claude <noreply@anthropic.com>
…vice member Claude-Session: https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j Co-authored-by: Claude <noreply@anthropic.com>
…incipal-session-mint
…ssion mint's system refusal Claude-Session: https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 48 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bdee5d38afb5fdaa9b92ece59190952c2dc12ac5 && git checkout bdee5d38afb5fdaa9b92ece59190952c2dc12ac5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0a168a33ea06b92e1e6f45bc38e2cd20b665d6a1 53e173d2a79755eadcead2145dbb82bd236e7673 && git checkout -B drift-repro 0a168a33ea06b92e1e6f45bc38e2cd20b665d6a1 && git merge --no-ff 53e173d2a79755eadcead2145dbb82bd236e7673
node scripts/docs-audit/affected-docs.mjs --json 0a168a33ea06b92e1e6f45bc38e2cd20b665d6a1
|
席位核对 · ACCEPT · PR #23786 @
|
Contract reviewServed-tier: Inputs: card #23772 (body and all three comments), PR #23786 (body, 12-file list, net diff against ① Derived judgmentsPublic-surface changes the diff implies, each judged:
Each guarantee the contract states, judged against the implementation and the pinned better-auth:
Adversarial paths looked for, each closed:
② Semver level
③ Boundary flagsThe dev's open questions, answered:
The dev's deviations: row 61b is the census gate's requirement, right; the test-only fixture is imported by nothing in the package and not re-exported, right; the gates ran on the merge head The dev's observations, confirmed: a mint fires the Escalated, none blocking:
Check-runs on the head, all completed before this record was posted: 36 success, 5 skipped (opt-in gates and matrix placeholders), no failure; the six Test Core shards, both Type Check jobs, Check Changeset, Spec property liveness and the Governed Surface Queue Guard are among the green. Those conclusions are the gate verdicts; nothing was re-run here. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23772
Clause-②: yes
What this does
S2 of #22269: a short-lived session for the requesting user's OWN principal, so an in-process service (the print service's headless browser, local or remote) can load a page as that user, never as a system principal (#8346 ruling B′, M2).
@objectstack/spec(contracts/auth-service.ts): one optionalIAuthServicemember,mintPrincipalSession(context, options?), its types (PrincipalSession,PrincipalSessionCookie,PrincipalSessionMintOptions), andPRINCIPAL_SESSION_MAX_TTL_SECONDS = 120. Theapi-surface/andexport-origins/shards are regenerated: 4 exports added, 0 removed.@objectstack/plugin-auth:AuthManager.mintPrincipalSession, which delegates to the newprincipal-session-mint.ts. That module owns the refusals, the TTL, the non-extension guard and the revoke.composeDatabaseHooksgains onesession.update.beforehook. A host hook runs first and itsfalseis honoured.The mechanism, measured against better-auth 1.7.3 (
node_modules, not the range)internalAdapter.createSessioncallersadmin-impersonate-endpoint.ts:325(it was:228) andimpersonation-bearer-rotation.ts:251. Both were read for the shape. The mint callscreateSession(userId, false, { token, expiresAt, activeOrganizationId }, true).overrideAllis required becausecreateSessionwritesexpiresAtafter the override spread (dist/db/internal-adapter.mjs:261-277). Neither impersonation path is used.impersonatedBystays unset.get-sessionrenews whenexpiresAt - expiresIn + updateAgeis in the past (dist/api/routes/session.mjs:180), and a 120 s session meets that on its first read. It then writesnow + expiresIn(:200). Only a signed dont-remember cookie ordisableRefreshskips this (:170).bearer()rewrites only the session-token cookie (dist/plugins/bearer/index.mjs:46), and the in-process bearer read renews by design (inProcessSessionReadInput). So a client-side defence covers the cookie lane only. The defence is server-side, on the one write every renewal makes:internalAdapter.updateSessionrunssession.update.beforebefore it writes, under secondary storage too (dist/db/with-hooks.mjs:43-60).holdPrincipalSessionExpirypins a minted session'sexpiresAtto its stored value. A minted session is identified by its token prefixpsm_, which the server sets and stores on the row. The non-extension is pinned on each lane: cookie over HTTP, bearer over HTTP, and the in-process bearer read.ttlSecondsis refused (VALIDATION_ERROR/400), not clamped (axis 3: a loud contract over silent tolerance). 120 s is the design report's number: a local render measured about 1.3 s (launch 954 ms, render 341 ms), and the rest is slack for the remote placement and page load.revoke()isdeleteSession, bound to the one token and idempotent. Afterwards both HTTP lanes answer401 UNAUTHENTICATED, the platform's ruled no-session answer (anonymous-session-refusal.ts). The in-process read answersnull, and the row is gone.ExecutionContext-level "is human" predicate exists.git grepfinds onlyisGuestPrincipal(plugin-security and plugin-sharing) andisPerfDisclosurePrincipal. The judgement is therefore the spec's own declaration:principalKindabsent meanshuman, plusisSystem. On the row it reusesisHumanUserRow(the predicate the audience gate and dev seed read). On posture it reusesjudgeHeldSession, the same judge the impersonation endpoint uses, over the carriedauthGateand a liveresolveUserAuthGates. An API key is not told apart: ADR-0090 D10 rules a key a credential of its owner, so its context is the owner's human principal (see the open questions).cookiePrefix/__Secure-authContext.authCookies.sessionToken, better-auth's derivation from theadvanced.cookiePrefix/useSecureCookiesthatAuthManagerpasses (auth-manager.tsadvancedblock). Nothing is restated. Pinned:better-auth.session_tokenon http;__Secure-acme.session_token(secure) under prefixacme, and that cookie authenticates.packages/declaresimplements IAuthService. The 8 files that name the type are readers (IAuthService | undefined) or the slot map (core-service-contracts.ts), and an optional member changes nothing for them. A compile-time pin assignsAuthManager.mintPrincipalSessionto the contract's own member type (IAuthServicewith every member required, indexed atmintPrincipalSession), so the two cannot drift.Refusal order, all thrown as
PrincipalSessionMintRefusalError(code+status) beforecreateSessionis reached:isSystem, or a non-humanprincipalKind:PERMISSION_DENIED/403.ttlSecondsoutside 1..120, or not an integer:VALIDATION_ERROR/400.authGate:403, with the posture code.PERMISSION_DENIED/403.403, with the posture code.Pins
principal-session-mint.cookie-lane.pin.test.ts: a control (an unbranded 120 s session IS renewed by the same read); the minted cookie authenticates and its expiry does not move over 3 reads, in the row and in the answer; revoke.principal-session-mint.bearer-lane.pin.test.ts: a control; HTTP bearer over 3 reads; the in-process bearer read; revoke on both.principal-session-mint.test.ts: every refusal assertscode+status, thatcreateSessionwas never called, and that nosys_sessionrow appeared. Also: TTL default and a shorter ask, the active organization, the brand, the cookie on http, the prefixed__Secure-cookie, the guard function itself, and the contract-assignability pin.packages/spec/src/contracts/auth-service.test.ts: the member stays optional; the ceiling is 120.Ablation (one-shot,
scripts/ablation-replace.mjs, on the committed fix).const held = holdPrincipalSessionExpiry(draft, ctx);was replaced withconst held: any = undefined;. The anchor went 1 to 0, blob00e04f1c266atoeb8c5dc5e4fc. The tests import the module fromsrc(relative), so no dist is involved. Result: 3 failed / 24 passed. All three non-extension pins went red (cookie, bearer HTTP, bearer in-process); the controls and revokes stayed green. Restore was proven: the blob equals HEAD00e04f1c266aandgit diff HEADis empty.Tests and gates
All readings are on this branch. Code and tests were measured at
e3db58a8, and the final head53e173d2adds one docs row on top.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2(whole package): 149 files, 2921 passed, 10 skipped, 0 failed.pnpm --filter @objectstack/plugin-auth typecheck: exit 0. That coverstscon src, the examples config, andcheck:test-typecheck(OK; the debt is unchanged at 10 files / 88 errors / 23 pinned signatures, and the new tests add none).pnpm --filter @objectstack/spec exec vitest run src/contracts/auth-service.test.ts: 9/9.pnpm --filter @objectstack/spec typecheck: exit 0.pnpm --filter @objectstack/spec build && … check:generated, re-run after mergingorigin/main: all 14 artifacts are up to date. Before that,--fixregenerated only the two shards it proved stale.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the final head: 111 families.--ranreconciliation: 111 derived, 107 run, 4 NOT MEASURED, 0 unrun. Every one of the 107 that ran exited 0.node scripts/check-system-context-census.mjsfirst exited 1 with a real finding: the mint's system-context refusal had no row oncontent/docs/permissions/system-context.mdx. Row 61b was added and the census now passes. That docs page is outside the claimed file surface; the gate requires it.check-plugin-teardown-shape.mjs --self-test(its fixture commit is outside this shallow clone),pnpm check:dual-build-cjs-loads(41 workspace packages have no local dist), andpnpm --filter @objectstack/spec run check:skill-examples(another package's declarations are absent). CI runs all three.pnpm check:type-check-debthit a 300 s local timeout before the whole-workspace ratchet reached a verdict. CI runs it.pnpm check:dev-prereqs: exit 1, because 2 unrelated packages (cli,organizations) have no local dist. It does not flag spec, and it is not a reading of this diff.Acceptance notes
session.create.afteraudit writer, so each mint leaves aloginrow for the user with no endpoint in its context. This is an observation; the consumer card may want to say whether a render should be audited differently.[AuthManager] better-auth returned error: 500 {"message":"Failed to find admin session"}appears once per fixture boot in these suites and 14 times across the existing plugin-auth suite. It predates this PR.Generated by Claude Code