Skip to content

feat(spec,plugin-auth): a short-lived principal-session mint, IAuthService.mintPrincipalSession, implemented by AuthManager - #23786

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23772-principal-session-mint
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23772-principal-session-mint

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 optional IAuthService member, mintPrincipalSession(context, options?), its types (PrincipalSession, PrincipalSessionCookie, PrincipalSessionMintOptions), and PRINCIPAL_SESSION_MAX_TTL_SECONDS = 120. The api-surface/ and export-origins/ shards are regenerated: 4 exports added, 0 removed.
  • @objectstack/plugin-auth: AuthManager.mintPrincipalSession, which delegates to the new principal-session-mint.ts. That module owns the refusals, the TTL, the non-extension guard and the revoke. composeDatabaseHooks gains one session.update.before hook. A host hook runs first and its false is honoured.
  • No HTTP route. The impersonation paths are untouched.

The mechanism, measured against better-auth 1.7.3 (node_modules, not the range)

Zone 2 hypothesis Verdict
1. Only two internalAdapter.createSession callers Holds, re-measured. admin-impersonate-endpoint.ts:325 (it was :228) and impersonation-bearer-rotation.ts:251. Both were read for the shape. The mint calls createSession(userId, false, { token, expiresAt, activeOrganizationId }, true). overrideAll is required because createSession writes expiresAt after the override spread (dist/db/internal-adapter.mjs:261-277). Neither impersonation path is used. impersonatedBy stays unset.
2. The refresh trap, on both lanes Holds, and the bearer lane is worse than the card said. get-session renews when expiresAt - expiresIn + updateAge is in the past (dist/api/routes/session.mjs:180), and a 120 s session meets that on its first read. It then writes now + expiresIn (:200). Only a signed dont-remember cookie or disableRefresh skips 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.updateSession runs session.update.before before it writes, under secondary storage too (dist/db/with-hooks.mjs:43-60). holdPrincipalSessionExpiry pins a minted session's expiresAt to its stored value. A minted session is identified by its token prefix psm_, 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.
3. TTL short, fixed maximum 120 s, a ceiling the caller cannot raise. A larger ttlSeconds is 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.
4. Revoke reads as no session on both lanes Holds. revoke() is deleteSession, bound to the one token and idempotent. Afterwards both HTTP lanes answer 401 UNAUTHENTICATED, the platform's ruled no-session answer (anonymous-session-refusal.ts). The in-process read answers null, and the row is gone.
5. Refusals before any mint; the predicate reused Holds. No ExecutionContext-level "is human" predicate exists. git grep finds only isGuestPrincipal (plugin-security and plugin-sharing) and isPerfDisclosurePrincipal. The judgement is therefore the spec's own declaration: principalKind absent means human, plus isSystem. On the row it reuses isHumanUserRow (the predicate the audience gate and dev seed read). On posture it reuses judgeHeldSession, the same judge the impersonation endpoint uses, over the carried authGate and a live resolveUserAuthGates. 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).
6. Cookie names honour cookiePrefix / __Secure- Holds. Name and attributes are authContext.authCookies.sessionToken, better-auth's derivation from the advanced.cookiePrefix / useSecureCookies that AuthManager passes (auth-manager.ts advanced block). Nothing is restated. Pinned: better-auth.session_token on http; __Secure-acme.session_token (secure) under prefix acme, and that cookie authenticates.
7. Clause-② on two surfaces; existing implementers compile Holds. No class in packages/ declares implements 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 assigns AuthManager.mintPrincipalSession to the contract's own member type (IAuthService with every member required, indexed at mintPrincipalSession), so the two cannot drift.

Refusal order, all thrown as PrincipalSessionMintRefusalError (code + status) before createSession is reached:

  1. No user, isSystem, or a non-human principalKind: PERMISSION_DENIED / 403.
  2. ttlSeconds outside 1..120, or not an integer: VALIDATION_ERROR / 400.
  3. A carried authGate: 403, with the posture code.
  4. User row missing, not human, or banned with no lapse: PERMISSION_DENIED / 403.
  5. A live posture: 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 asserts code + status, that createSession was never called, and that no sys_session row 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 with const held: any = undefined;. The anchor went 1 to 0, blob 00e04f1c266a to eb8c5dc5e4fc. The tests import the module from src (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 HEAD 00e04f1c266a and git diff HEAD is empty.

Tests and gates

All readings are on this branch. Code and tests were measured at e3db58a8, and the final head 53e173d2 adds 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.
  • The three mint files alone: 60/60, then 61/61 after the contract-assignability pin.
  • pnpm --filter @objectstack/plugin-auth typecheck: exit 0. That covers tsc on src, the examples config, and check: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 merging origin/main: all 14 artifacts are up to date. Before that, --fix regenerated only the two shards it proved stale.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the final head: 111 families. --ran reconciliation: 111 derived, 107 run, 4 NOT MEASURED, 0 unrun. Every one of the 107 that ran exited 0.
    • node scripts/check-system-context-census.mjs first exited 1 with a real finding: the mint's system-context refusal had no row on content/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.
    • NOT MEASURED, exit 3 (the gate refused its own prerequisite): 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), and pnpm --filter @objectstack/spec run check:skill-examples (another package's declarations are absent). CI runs all three.
    • NOT MEASURED, exit 124: pnpm check:type-check-debt hit a 300 s local timeout before the whole-workspace ratchet reached a verdict. CI runs it.
  • Local-only 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

  • A minted session fires the existing session.create.after audit writer, so each mint leaves a login row 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.
  • While a minted session lives (at most 120 s), ADR-0069 D4's concurrent-session cap sweep counts it if the user signs in elsewhere in that window. This is an observation.
  • The line [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.
  • Line budget: +963 / -2 (965) against the card's estimate of about 560–640. The excess is test and module prose: three pin files plus a shared fixture (about 480 lines), and a module header that states each measured trap.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/spec, touching 50 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

48 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0a168a33ea06b92e1e6f45bc38e2cd20b665d6a1.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 36 pages), sys_user (literal, 41 pages)
  • 13 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0a168a33ea06b92e1e6f45bc38e2cd20b665d6a1 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bdee5d38afb5fdaa9b92ece59190952c2dc12ac5 — the merge of head 53e173d2a79755eadcead2145dbb82bd236e7673 into base 0a168a33ea06b92e1e6f45bc38e2cd20b665d6a1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0a168a33ea06b92e1e6f45bc38e2cd20b665d6a1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · PR #23786 @ 53e173d2a7 (#23772) · domain:spec seat 1 · os-elon-musk · session session_01HjR8RUwTPQ3TgbfKihmxp9 · 2026-10-16T02:22Z

Reviewer of record. Every item below was checked against GitHub, not against the report (6206536077 on #23772).

  • Shape:
    • Draft, base main. The first line is Fixes #23772, the body's only closing keyword, then Clause-②: yes, matching claim 6205711728 and the changeset.
    • 12 files, +964/−2, none governed (check-governed-merges: 0 of 12 paths, 966 lines ≤ 3000). Assignee os-elon-musk.
  • The governing rulings, read in the diff:
  • The card's pins:
    • Never renewed, on both lanes: one session.update.before guard, composed after the host's hook (a host false still aborts), clamps a branded session's expiresAt to its minted ceiling. It covers the cookie lane, the bearer lane over HTTP and the in-process bearer read. Each lane file carries a control: an unbranded 120 s session IS renewed by the same read.
    • Ablation: neutralising the guard reds exactly the three lane pins (3 failed / 24 passed). The restore is byte-identical.
    • Revoke: bound to the minted token and idempotent. After it, neither the token nor the cookie resolves, over HTTP and in-process.
    • The cookie: better-auth's own authCookies.sessionToken name and attributes, so cookiePrefix and __Secure- follow the deployment.
    • The brand: psm_ is server-written, with 32 random characters after it. A client cannot mint a branded token, and a spoofed prefix could only stop its own session's renewal.
  • Outside the claimed file surface, accepted:
    • content/docs/permissions/system-context.mdx row 61b. The derived check-system-context-census requires a row for the new isSystem refusal site; it first exited 1 and was re-run to 0.
    • The shared test fixture principal-session-mint-test-support.ts (the audience-gate-test-support.ts precedent).
  • The report's open questions, the seat's calls: all A.
    • An API-key context may mint: ADR-0090 D10 makes a key its owner's credential, and B would widen ExecutionContext without a pull.
    • The fixed 120 s ceiling, refused above.
    • The token brand over a sys_session column: no schema change, and every node agrees.
    • The line count: the true figure is +964/−2 (966). The body's 965 predates the census row. The excess over the card's 560–640 estimate is pins and the module's measured-trap header.
  • Carried to print page ③ of #8346: a render service with one headless-Chromium driver renders a print page under the requesting user's principal and archives the PDF as a sys_file (M2) #22269, the consumer (Acceptance notes, no producer here):
    • Each mint fires the existing session.create.after audit writer, so a render leaves a login row.
    • ExecutionContext carries no impersonation marker. A mint made while an admin impersonates the user is therefore logged as the user's own session, with no impersonated_by. Whether a render under impersonation mints at all, or how it is attributed, is the consumer's call at its door.
  • Changeset: @objectstack/spec and @objectstack/plugin-auth, both minor, Clause-②: yes (widening). The sentences were checked against the code: every refusal and its code, "refused, never clamped", the cookie attributes, and the revoke contract.
  • Before the queue: a CONTRACT_REVIEW_TIER contract review (face packages/spec/src/contracts/**, and the widening Clause-②: yes declares), and every check green on the head. print page ③ of #8346: a render service with one headless-Chromium driver renders a print page under the requesting user's principal and archives the PDF as a sys_file (M2) #22269 lands after this one.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 53e173d2a79755eadcead2145dbb82bd236e7673
Local-runs: none
Scope: packages/spec/src/contracts/auth-service.ts + packages/plugins/plugin-auth/src/principal-session-mint.ts, packages/plugins/plugin-auth/src/auth-manager.ts (composeDatabaseHooks's new session.update.before, AuthManager.mintPrincipalSession), packages/plugins/plugin-auth/src/principal-session-mint.test.ts, principal-session-mint.cookie-lane.pin.test.ts, principal-session-mint.bearer-lane.pin.test.ts and principal-session-mint-test-support.ts, packages/spec/src/contracts/auth-service.test.ts, .changeset/23772-principal-session-mint.md, packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json, content/docs/permissions/system-context.mdx (row 61b); and, because every guarantee the contract states is a claim about the pinned dependency, better-auth 1.7.3 as the PR head declares it (packages/plugins/plugin-auth/package.json), read as files out of the pnpm content store, never installed, built or run: dist/api/routes/session.mjs, dist/api/routes/update-session.mjs, dist/db/with-hooks.mjs, dist/db/internal-adapter.mjs, dist/plugins/bearer/index.mjs, dist/context/create-context.mjs, and @better-auth/core 1.7.3 dist/utils/id.mjs.

Inputs: card #23772 (body and all three comments), PR #23786 (body, 12-file list, net diff against main at the head: +964 / -2), and the check-runs on the head. Read-only throughout: the branch was fetched, nothing was checked out, built, tested or re-run.

① Derived judgments

Public-surface changes the diff implies, each judged:

  1. IAuthService.mintPrincipalSession?(context, options?) — an optional member. Right. No class in packages/ declares implements IAuthService (re-grepped at the head); the readers that name the type take an optional member unchanged. The spec test pins that a minimal implementation still satisfies the contract, and principal-session-mint.test.ts pins that AuthManager's method is assignable to the member's own type, so the two cannot drift.
  2. Four new exports from @objectstack/spec/contracts: PRINCIPAL_SESSION_MAX_TTL_SECONDS (a constant, 120), PrincipalSession, PrincipalSessionCookie, PrincipalSessionMintOptions. Right. The api-surface and export-origins shards add exactly those four and remove nothing, matching the hunks; a constant carries no logic, so Prime Directive ✨ Set up Copilot instructions #2 holds.
  3. AuthManager.mintPrincipalSession becomes public (AuthManager is re-exported by the package index). Right, and deliberately narrow: principal-session-mint.ts is not re-exported, so PrincipalSessionMintRefusalError, holdPrincipalSessionExpiry and the psm_ brand stay package-internal and a consumer reads a refusal by its code and status alone, as the contract text says.
  4. One session.update.before database hook on every session. Right. For a session without the brand the guard returns undefined and the write is byte-identical to before; a host hook chains first, its false aborts, its { data } is merged exactly as better-auth's own loop merges (with-hooks.mjs:55-58), and the host's update.after is kept.
  5. No accept-set change. Right: no Zod schema, no authorable key, no HTTP route, no env var, no impersonation path (admin-impersonate-endpoint.ts and impersonation-bearer-rotation.ts are not in the file list). Row 61b on the system-context register is the census gate's requirement for the new isSystem refusal site. Right.

Each guarantee the contract states, judged against the implementation and the pinned better-auth:

  • The refusals, all before any mint. refuseUpFront runs before the auth context is even fetched: no userId, isSystem, or a principalKind other than human (the enum is exactly human | agent | service | guest | system, execution-context.zod.ts:181, absent reads as human) answer PERMISSION_DENIED / 403; a ttlSeconds that is not an integer in 1..120 answers VALIDATION_ERROR / 400; a carried authGate answers 403 with the posture's own code through judgeHeldSession, the same judge the platform-admin surface uses. Then the row: missing, not isHumanUserRow (the one predicate the audience gate and the dev seed read), or banned without a lapsed banExpires, answer 403; then the posture computed now, resolveUserAuthGates(userId, { organizationId: tenantId }), answers 403 with its code. createSession is reached only after all five. Right. Every refusal is pinned with the envelope and with "createSession never called, no sys_session row appeared", and each of the four non-human kinds is pinned separately.
  • The TTL ceiling. expiresAt = now + ttl seconds, written through createSession(userId, false, { token, expiresAt, activeOrganizationId }, true). In 1.7.3 the adapter's defaults for expiresAt and token are spread before ...overrideAll ? rest : {} (internal-adapter.mjs:261-277), so the short life holds only with overrideAll, and the mint passes true. An ask above 120, below 1, fractional or NaN is refused, never clamped. Right. If the store returned a different token the stray would be deleted and INTERNAL_ERROR / 500 thrown. Right.
  • Never renewed by a read on either lane. Verified against the 1.7.3 source, not the PR body. The only writers that raise a session's expiresAt in the whole dist are get-session (session.mjs:199-202); update-session.mjs:37 writes only the additional fields parseSessionInput admits plus updatedAt, and the organization plugin writes activeOrganizationId / activeTeamId only. internalAdapter.updateSession(token, data) takes no context argument: updateWithHooks (with-hooks.mjs:43-60) fetches the endpoint context from async-local storage (tryGetCurrentAuthEndpointContext), runs session.update.before with (data, context) before the database write and before the secondary-storage custom function alike, and merges a returned { data } into what is written. get-session sets ctx.context.session to the session it found (session.mjs:154) before the renewal write, and sessionMiddleware sets it (:274) for update-session, so for every caller in 1.7.3 the session on the context IS the session being updated, which is the guard's one assumption. The brand is read off the stored token; generateId draws from a-z A-Z 0-9 only (@better-auth/core utils/id.mjs), so a random token can never start with psm_, and no client can write a token onto a row. Right. Pinned on the cookie lane, the bearer lane over HTTP and the in-process bearer read (the one lane inProcessSessionReadInput cannot shield, since disableRefresh rides only a cookie request), each file with a control proving the same fixture renews an unbranded 120-second session. The dev's one-shot ablation of the guard call reddened exactly those three pins; reported, not re-run here.
  • The revoke. deleteSession(rawToken), bound to the one minted token; the revoked latch is set only after the delete returns, so a failed delete is retryable and a second call is a no-op. Under secondary storage deleteSession removes the key entry too (internal-adapter.mjs:451 onward). Pinned: afterwards both HTTP lanes answer 401 UNAUTHENTICATED and the in-process read answers null. Right.
  • The cookie attributes. Name and attributes are authContext.authCookies.sessionToken, better-auth's derivation from the advanced.cookiePrefix / useSecureCookies that AuthManager passes; nothing restated. Pinned: better-auth.session_token not Secure on http; __Secure-acme.session_token, Secure and HttpOnly under prefix acme, and that cookie authenticates. The value is better-call's signed form, which the bearer plugin takes as already signed when it contains a dot and HMAC-verifies (bearer/index.mjs:34-40), so one value serves both lanes. Right.
  • The active organization. The context's tenantId is copied onto the session; defaultActiveOrg keeps a present override. No membership check is made at the mint: the context is server-constructed and its tenantId came from the user's own admitted session or key. Right under the platform's existing context trust; named under ③.

Adversarial paths looked for, each closed:

  • Mint for someone other than the caller. Only by constructing an ExecutionContext that names another user, which is the same in-process trust every data-engine call already extends; there is no HTTP door, and the one context that legitimately names any user, isSystem, is refused.
  • A session outliving its ceiling. The renewal is the only raise and is pinned on the write; AuthManager drops session.cookieCache by construction (its own comment block), so no cached answer outlives a revoke; get-session deletes an expired row on sight (session.mjs:153-163). After a pinned renewal better-auth re-sets the session cookie with a 7-day Max-Age: a carrier attribute only, the row still ends at the ceiling.
  • A refusal bypassed. Number.isInteger refuses strings and NaN; an undeclared principalKind cannot be built from the Zod schema; a lapsed ban mints, which is what better-auth's own sign-in does.
  • The guard harming another session. It acts only when the context's session is branded and the update raises expiresAt above the stored value, and every 1.7.3 caller updates the context's own session, so it can never pin a different session's expiry.

② Semver level

.changeset/23772-principal-session-mint.md: @objectstack/spec minor, @objectstack/plugin-auth minor. Both packages publish (private unset, 18.0.0-next.1). The PR body declares Clause-②: yes; the changeset carries Clause-②: yes (widening), one arm from the closed pair, and yes takes at least minor: met. Nothing is removed or renamed, so no migration text and no ADR-0087 disposition is owed. Check Changeset is green on the head. Right. No skip-changeset label, correctly.

③ Boundary flags

The dev's open questions, answered:

  1. An API-key-authenticated context can mint; keep (A) or refuse keys (B)? A, answered. Measured at the head: resolve-authz-context.ts:530-536 resolves a key as its owner and ADDS the key's scopes to the owner's permissions as a seed, never subtracts, so a key-admitted context is at least the owner's reach and a minted session (the owner's plain session) never exceeds its key. ResolvedAuthzContext.credential exists at the authz layer but is not assembled onto ExecutionContext, so B is a spec widening with no pull. ADR-0090 D10 holds as the dev read it.
  2. TTL: a fixed 120-second ceiling, refused above, default equal to the ceiling. A, answered. A constant in spec is within Prime Directive ✨ Set up Copilot instructions #2; refuse-over-clamp is Prime Directive Add comprehensive test suite for Zod schema validation #12's direction; a setting without a named deployment is scope.
  3. A token brand rather than a sys_session column. A, answered. The alphabet proof above makes the brand unforgeable by chance, and only the server writes tokens.
  4. Line budget 966 against 560-640; body says 965. A, answered. The excess is the three pin files and their fixture (about 480 lines) plus the module header; the body figure predates the one-line docs commit. Note only.

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 e3db58a8 with the docs-only commit's families re-run on the final head, and the check-runs on the final head are the verdicts, right; the six commits carry exactly the session-URL and co-author trailer pair AGENTS.md prescribes (its line 461), right.

The dev's observations, confirmed: a mint fires the session.create.after login audit writer (and a revoke fires the session.delete.after logout writer) with no endpoint path, carried to #22269 by the lane ACCEPT, the right carrier; enforceConcurrentCap runs only in the /sign-in/email after-hook (auth-manager.ts:2640), so a mint never evicts a session and only a sign-in inside the 120 seconds counts it; the Failed to find admin session line predates the PR.

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: claude/issue-23772-principal-session-mint
Reviewed-by: session_01HjR8RUwTPQ3TgbfKihmxp9

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants