Repository navigation
Commit 9cb9d95
feat(spec,plugin-auth): a short-lived principal-session mint, IAuthService.mintPrincipalSession, implemented by AuthManager (#23786)
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](https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 46ca793 commit 9cb9d95
12 files changed
Lines changed: 964 additions & 2 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- plugins/plugin-auth/src
- spec
- api-surface
- export-origins
- src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
191 | 191 | | |
192 | 192 | | |
193 | 193 | | |
| 194 | + | |
194 | 195 | | |
195 | 196 | | |
196 | 197 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
55 | 62 | | |
56 | 63 | | |
57 | 64 | | |
| |||
95 | 102 | | |
96 | 103 | | |
97 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
98 | 110 | | |
99 | 111 | | |
100 | 112 | | |
| |||
7837 | 7849 | | |
7838 | 7850 | | |
7839 | 7851 | | |
| 7852 | + | |
| 7853 | + | |
| 7854 | + | |
| 7855 | + | |
| 7856 | + | |
| 7857 | + | |
| 7858 | + | |
| 7859 | + | |
| 7860 | + | |
| 7861 | + | |
| 7862 | + | |
| 7863 | + | |
| 7864 | + | |
| 7865 | + | |
| 7866 | + | |
| 7867 | + | |
| 7868 | + | |
| 7869 | + | |
| 7870 | + | |
| 7871 | + | |
| 7872 | + | |
| 7873 | + | |
| 7874 | + | |
| 7875 | + | |
| 7876 | + | |
7840 | 7877 | | |
7841 | 7878 | | |
7842 | 7879 | | |
| |||
7938 | 7975 | | |
7939 | 7976 | | |
7940 | 7977 | | |
| 7978 | + | |
| 7979 | + | |
| 7980 | + | |
| 7981 | + | |
7941 | 7982 | | |
7942 | 7983 | | |
7943 | 7984 | | |
| |||
8289 | 8330 | | |
8290 | 8331 | | |
8291 | 8332 | | |
| 8333 | + | |
| 8334 | + | |
| 8335 | + | |
| 8336 | + | |
| 8337 | + | |
| 8338 | + | |
| 8339 | + | |
| 8340 | + | |
| 8341 | + | |
| 8342 | + | |
| 8343 | + | |
| 8344 | + | |
| 8345 | + | |
| 8346 | + | |
| 8347 | + | |
| 8348 | + | |
| 8349 | + | |
| 8350 | + | |
| 8351 | + | |
| 8352 | + | |
| 8353 | + | |
| 8354 | + | |
8292 | 8355 | | |
8293 | 8356 | | |
8294 | 8357 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
0 commit comments