Skip to content

Commit 8ed3786

Browse files
refactor(plugin-hono-server): the /auth/me door calls the one request-to-ExecutionContext composition, resolveRequestExecutionContext (#24437)
Fixes #23217 Clause-②: no Ruling-ref: 6257816853 Basis for the `Clause-②` line: ruling record 6227928430's "later slices `Clause-②: no`", and ruling record 6257816853 item 1. One answer of the `/auth/me` door changes: a session read that carries `session.userId` and no `user.id` was answered as signed out and is now signed in as that user, the answer the four converted doors already give. The ruling records that no producer yields that read, in this repository or in cloud's control plane. No export or published surface of `@objectstack/plugin-hono-server` moves. Slice 5 of #23217, the last door, under ruling record 6257816853 (batch #367 item 2, slice 5 letter A): the `/auth/me` door, `makeExecutionContextResolver` in `packages/plugins/plugin-hono-server/src/current-user-endpoints.ts`, calls the one `resolveRequestExecutionContext` (`@objectstack/core`) with its existing parameters and adopts the composition's reading of who a session names. `@objectstack/core`'s function is not touched. There is no new named parameter (B), no door-local copy (C) and no strict reading for everyone (D). With this slice all five doors call the one composition. Storage's ADR-0069 gate reader stays a hand-written door fact under its parity pin, as ruling record 6257816853 item 2 (letter (i)) closes it. ## What changed ### The door `makeExecutionContextResolver` keeps its door facts and calls the function once. Its parameters are slice 1's row for this door, which report 6255071471 re-measured on the hand-written door over 18 wirings: | parameter | the `/auth/me` door passes | what the hand-written door did | |:--|:--|:--| | `anonymous` | `'fail-closed'` | a request with no `userId` got no context, and each route answered its own signed-out body | | `authGate` | `'withhold'` | never carried; these routes are allow-listed for a gated user | | `credentials` | `'session-only'` | `headers: undefined` to the resolver, so no key was extracted and no admission ran | | `sessionApi` | `'required'` | no auth service, no `api` or `getApi()`, or no `getSession`: no context | | `session` | `'request'` | its own read under the in-process rule (`inProcessSessionReadInput`) | | `tenancy` | `undefined` | no posture, so the session's claimed organization stood | | `localization`, `requestLocale` | `'withhold'`, `undefined` | none; the endpoints read the cascade themselves | | `accessToken` | `'withhold'` | never carried | | `oauth`, `onAdmission` | `undefined` | none | These door facts stay at the door: - the per-request `ctx.getService('auth')` read and the guarded `getObjectQL()`; - the kernel-resolver seam (`resolveRequestContext`, which picks the locator the resolver is built over); - the fail-closed `catch` with `rethrowAuthzStoreUnavailable`, which also answers the function's propagated `getApi()` rejection as no context, as the door always did; - the perf-disclosure gate. It now reads the assembled context. `isPerfDisclosurePrincipal` reads `isSystem`, `principalKind` and `posture`, which carry `false`, `'human'` and the same posture, so its value is unchanged. `resolveAuthzContext`, `assembleExecutionContext`, `userAuthGateSourceOf`, `apiKeyLifecyclePolicySourceOf`, `inProcessSessionReadInput` and `IAuthService` left the door's imports. `resolveRequestExecutionContext` and the `RequestAuthService` type came in. The door's comments now give each parameter's reason where the hand-written composition gave each omission's. ### The one changed answer (ruling record 6257816853 item 1) The hand-written door's `readSession` dropped a session read with no `user.id` (`return session?.user?.id ? session : null;`). The composition reads `user.id ?? session.userId` (`packages/core/src/security/resolve-authz-context.ts`). The three routes now answer signed in as that user, instead of signed out, when the auth service's session read carries `session.userId` with no user, with a user that has no id, or with `user.id: null`. REST, the runtime dispatcher, the settings routes and the storage download already gave that answer. An empty-string `user.id` still names nobody, at every door. No producer yields the shape. better-auth's session read returns no session when the session row joins no user. plugin-auth's `customSession` passes the read through. Cloud's control plane uses the same plugin-auth read (ruling record 6257816853, read at cloud `9099bd1ea646`). ### Readers moved in the same PR - `packages/core/src/security/authz-store-unavailable.test.ts`: - The hono row of `TRANSPORT_LEDGER` left, because the file no longer holds `resolveAuthzContext({`. 7 rows in 6 packages remain. - The composition's comment names the fifth door. - The scanner CONTROL's floor went from 8 to 7, with the reason in its comment. The floor is the ledger's size. Set equality already fails a walk that loses any listed file. The floor and the known-member assertion refuse the one case set equality cannot see: a broken walk and a gutted ledger together. Every door now calls the composition, so no later conversion moves it. - `scripts/check-single-authz-resolver.mjs`: `DELEGATORS` gained the hono file (`resolveRequestExecutionContext`). The gate reads `all 6 entry points delegate`. - `packages/runtime/src/security/request-execution-context.conformance.test.ts`: - A `HONO_ROW`. Its drive registers the current-user endpoints on a `HonoHttpServer` over the world's services plus a metadata service and a security service. It sends `GET /api/v1/auth/me/permissions` and reads the context the route hands to `security.resolvePermissionSetsForContext`. - `expectedPrincipal` learned `credentials`: a request carrying no session resolves nobody at a session-only door, so an API key alone admits nobody there. - One request joined the six: a session read naming its user by `session.userId` alone. It holds all five doors to one reading of who a session names, and all five resolve `u_ses`. It sits before the last request, which the CONTROLs read by position. - The hono CONTROL tells apart `anonymous`, `authGate`, `accessToken`, `localization`, `requestLocale` and `credentials`. For `tenancy` it adds a session claiming an organization its user is not a member of, under the walled posture: the claim stands at this door, and the walled posture drops the claim and keeps the caller. It also states that `sessionApi` is not observable at a session-only door: with no session read the door reads no credential at all, so `'optional'` admits nobody either. - No alias: runtime's `KNOWN_UNALIASED_TEST_IMPORTS` entry already lists `@objectstack/plugin-hono-server`. The row reads the door from `dist/`, and `check:test-source-alias` stays green. - Core's module docblock table (`request-execution-context.ts`), read row by row against the converted door: no row turns false. Every row already names the `/auth/me` endpoints for the value this door passes. Not edited. - `scripts/slot-lookup-baseline.json` does not name the hono file. Not touched. - The 14 hono test files that import `./current-user-endpoints`: none reads its source text. The 11 that fake a session read answer with `user.id` or with nothing, so none moved. ### The pins - `packages/plugins/plugin-hono-server/src/current-user-endpoints.request-context-door.pin.test.ts` (ruling record 6227928430 item 2) boots the real `HonoServerPlugin` through `init()`, `start()` and its `kernel:ready` hooks. It drives the three real routes and reads each body and what the security service received. 9 rows: - five ADR-0096 rows: no credential, an unknown cookie, a throwing session read, an API key alone, and no auth service. Each gets every route's own signed-out body, and the security service is never asked; - one ADR-0069 row: a held session is served on these allow-listed routes, and its context carries no `authGate`; - one outage row: all three routes answer the host's `503 SERVICE_UNAVAILABLE`; - the ruled reading: a `session.userId`-only read is served as that user; - a CONTROL: an ungated session is served, and its context carries no bearer, locale or time zone, although the session carries a bearer, the request a language and the host a settings service. - `packages/plugins/plugin-hono-server/src/current-user-endpoints.resolver-wirings.pin.test.ts` is the earlier dev's 18-wiring parity probe, committed. Each wiring pins who the door names: no context, the caller with its organization, or the outage. Each also requires the composition, under the parameters this door declares, to give the same answer. The one declared difference is the door's own `catch`, and the file's CONTROL shows the composition rejecting with `getApi down` there. The three `session.userId`-only wirings assert the composition's answer, signed in as that user. The other 15 assert their unchanged answers. ## Evidence All on a shared container; lock seconds are shared-box readings. - Before, on the hand-written door (door blob `c0e104d2c`, `main` `8baeda520`, pins at `348b11ba7`): the two pin files read `Tests 4 failed | 25 passed (29)`. The four failures are exactly the ruled-reading rows (three wirings and the route-level row), where the door answered no context and `{ authenticated: false }`. - After, on the converted door (blob `e7cc167d5`): the whole hono suite reads `Test Files 34 passed (34)`, `Tests 422 passed (422)`, both pins included. - ABLATION, declared direction RED, at `86cd71673` (door blob `e7cc167d5`): two legs in one verify-lock call, through `scripts/ablation-replace.mjs`. Each leg rebuilt `@objectstack/plugin-hono-server` and proved its marker in `dist/` with `scripts/ablation-dist-preflight.mjs`, because the conformance row reads the door from `dist/`. Each restore was rebuilt and preflighted with `--absent`. - Leg 1 reverts the door to its hand-written composition: the import block through `makeExecutionContextResolver` replaced by `main`'s text. Marker `session?.user?.id ? session : null`, present in 2 built files. Wirings pin `Tests 3 failed | 17 passed (20)`, route pin `Tests 1 failed | 8 passed (9)`, conformance `Tests 1 failed | 12 passed (13)`. Each failure is a ruled-reading row or the `session.userId`-only request. - Leg 2 changes `credentials: 'session-only'` to `'api-key-and-session'`. Marker `credentials: "api-key-and-session"` in `dist/`. Wirings pin `2 failed | 18 passed (20)`: the API key alone, and the key beside a session. Route pin `1 failed | 8 passed (9)`: the API key alone. Conformance `3 failed | 10 passed (13)`: the API key, the ex-member's key, and the hono CONTROL. - Each restore: the blob equals HEAD (`e7cc167d5`), `git diff HEAD` is empty, and the dist marker is absent after the rebuild. `git status --porcelain` is empty at the end. Observed direction equals declared. ## Tests (HEAD `2f7f09a8e`, after merging `origin/main` `e22defb13`) - `@objectstack/plugin-hono-server`: `Test Files 34 passed (34)`, `Tests 422 passed (422)`. `typecheck` exit 0 (test layer 0 / 0 / 0), and `tsc -p tsconfig.test.json --listFiles` includes both new pins. - `@objectstack/runtime`: the conformance test reads `Test Files 1 passed (1)`, `Tests 13 passed (13)` (7 requests, the identity test, 5 CONTROLs). `typecheck` exit 0 (test layer 27 files / 190 errors / 68 signatures held, none in the conformance file). The rest of the runtime suite was not run locally: the conformance file is the only runtime file touched, and CI's Test Core runs the suite. - `@objectstack/core`: `src/security/` reads `Test Files 43 passed (43)`, `Tests 961 passed (961)`; the ledger file reads 36, which is 37 minus the hono row. `typecheck` exit 0 (test layer 4 / 4 / 4 held). Core's source is untouched. - Build after the merge: `turbo run build --filter=!@objectstack/docs --concurrency=2`, `76 successful, 76 total`. ## Gates (HEAD `2f7f09a8e`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 90 commands. Each ran with its exit code captured before any pipe, and all exited 0. `pnpm check:type-check-debt` ran under the verify lock: exit 0, `none above its recorded number`. - `--ran` reads `90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. - Among the derived commands: - `check:authz-resolver`: `all 6 entry points delegate`; - `check:test-source-alias`; - `check:slot-lookup`: `100 unswept site(s) in 25 file(s), none new`; - `check:cross-package-test-inputs`, `check:route-envelope`, `check:dual-build-cjs-loads` (on the built tree), `check:nul-bytes`, `check:adr-0087-registration`, `check:empty-changeset` and `check:pm-dispatch-gates`. - The declared wide population, all 10 families, all exit 0: `check:init-service-contract`, `check:live-db-isolation`, `check:meta-type-normalized`, `check:optional-error-sink`, `check:resume-authority-declared`, `check:runner-env-posture`, `check:settings-bind-window`, `check:startup-registry-verdict`, `check:verify-stand-in` and `check:wildcard-fallthrough`. - Edited gate script: `node scripts/check-single-authz-resolver.mjs` exit 0, and `--self-test` exit 0. - `pnpm exec eslint --no-inline-config` over the five changed `.ts` / `.mjs` files: exit 0, no output. ## The card's acceptance, measured (HEAD `2f7f09a8e`) `git grep -c -F` over the five door files: | line | rest | runtime | settings | storage | hono | |:--|:-:|:-:|:-:|:-:|:-:| | `resolveAuthzContext(` | 0 | 0 | 0 | 0 | 0 | | `assembleExecutionContext(` / `assembleExecutionContextOrGuest(` | 0 | 0 | 0 | 0 | 0 | | `classifyAdmissionTenancyPosture(` | 0 | 0 | 1 | 1 | 0 | | `inProcessSessionReadInput(` | 0 | 0 | 0 | 1 | 0 | | `resolveApiKeyAdmission(` | 0 | 0 | 0 | 1 | 0 | | `resolveRequestExecutionContext(` | 1 | 1 | 1 | 1 | 1 | - The hono door reaches 0 copies and adopts the composition's reading. - Storage's three hits are its ADR-0069 gate reader, the acceptance line's one exception (copy + parity pin): the key arm (`resolveApiKeyAdmission(`, about `:1283`), the gate reader's posture read (`resolveAdmissionTenancyPosture`, about `:1432`), and `buildGetSession` (about `:973`), the session read the gate reader shares with the upload session resolver. - The settings hit (`resolveAdmissionTenancyPosture`, about `:469`) feeds the settings SERVICE's own `tenancyPosture` option, not the HTTP door. The door calls the composition (slice 3), and that method's docblock records why the resolution stayed there. - The ADR-0096 refusal and the ADR-0069 allow-list read the same on this door before and after: 8 of the route pin's 9 rows were green on both trees. The ninth is the ruled reading. ## Acceptance notes - This PR closes the card on the reading above. If the seat reads the settings service-option hit or storage's `buildGetSession` as outside the acceptance line's one exception, the first line should read `Part of #23217` instead. The seat writes that change from the report, since this body is written once. - Out of lane: `packages/core/src/security/authz-store-unavailable.test.ts` (declared on #6367, comment `6254881518`) and `scripts/check-single-authz-resolver.mjs` (declared on #6017, comment `6254886923`). Every other path is in `domain:cli` or is the changeset. - Nothing filed: no defect, contract violation or authoring trap turned up outside this slice. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9d41dd commit 8ed3786

7 files changed

Lines changed: 851 additions & 153 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
The `/auth/me` endpoints resolve their caller through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition
6+
7+
Clause-②: no
8+
9+
- `makeExecutionContextResolver`, which `GET /auth/me/permissions`, `GET /auth/me/localization` and `GET /me/apps` call, resolves the caller by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'withhold'`, `credentials: 'session-only'`, `sessionApi: 'required'`, `session: 'request'`, no tenancy posture, `localization: 'withhold'`, no request locale, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The per-request service reads, the environment-kernel resolution and the fail-closed `catch` stay at the door. Every platform door that resolves a request's caller now calls the one composition.
10+
- One answer changes. When the auth service's session read carries `session.userId` but no `user.id` (no user object, a user without an id, or `user.id: null`), these endpoints answered as signed out; they now answer as signed in as that user, which is how the runtime dispatcher, REST, the settings routes and the storage download already read it. No session producer yields that shape: in this repository better-auth's session read returns no session when the session's user is missing and plugin-auth passes its read through, and cloud's control plane uses the same plugin-auth read.
11+
- Every other answer is unchanged: no credential, an unrecognised cookie, a failing session read, an API key alone and a host with no auth service get each route's signed-out body; a session the authentication policy holds is still served on these allow-listed routes, with no `authGate` on its context; an unreadable permission store is still the host's `503 SERVICE_UNAVAILABLE`.
12+
- Nothing to do for a consumer: no export, route, configuration key or response shape changed.

‎packages/core/src/security/authz-store-unavailable.test.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,14 @@ const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
183183
// letter A). It catches nothing around the resolver: every door that calls
184184
// it — the runtime dispatcher's `resolve-execution-context.ts` first, REST's
185185
// `rest-server.ts` second, the settings door's `settings-service-plugin.ts`
186-
// third and the storage download door's `storage-service-plugin.ts` fourth,
187-
// each of the last three through a fail-closed `catch` that re-raises the
188-
// outage — receives the outage and answers it at its own net.
186+
// third, the storage download door's `storage-service-plugin.ts` fourth and
187+
// the `/auth/me` door's `current-user-endpoints.ts` fifth (ruling record
188+
// 6257816853), each of the last four through a fail-closed `catch` that
189+
// re-raises the outage — receives the outage and answers it at its own net.
189190
'packages/core/src/security/request-execution-context.ts': 'propagates',
190191
'packages/mcp/src/plugin.ts': 'propagates',
191192
'packages/services/service-datasource/src/admin-routes.ts': 'guarded',
192193
'packages/plugins/plugin-sharing/src/sharing-plugin.ts': 'guarded',
193-
'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts': 'guarded',
194194
'packages/cloud-connection/src/marketplace-install-local-plugin.ts': 'guarded',
195195
'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded',
196196
// The print door: its caller-resolution `catch` relays the outage with its
@@ -297,8 +297,15 @@ describe('[#13279] every transport that authorizes through resolveAuthzContext',
297297
it('CONTROL: the scanner finds transports at all, and finds THIS repo', () => {
298298
// Without this, a broken walk would return [] and the set-equality audit
299299
// below would be comparing two empty sets and passing.
300+
//
301+
// [#23217 slice 5] The floor is the ledger's size, 7 rows in 6 packages
302+
// since the last door moved onto the one composition. Set equality below
303+
// already fails a walk that loses any listed file; the floor, with the
304+
// known member asserted next, refuses the one case set equality cannot
305+
// see: a broken walk and a gutted ledger together. Every door now calls
306+
// the composition, so no further conversion moves this number.
300307
const found = discoverTransports();
301-
expect(found.length).toBeGreaterThanOrEqual(8);
308+
expect(found.length).toBeGreaterThanOrEqual(7);
302309
// A transport this repo is known to hold. [#23217 slice 2] It named
303310
// `rest-server.ts` until that door moved onto the one composition, which
304311
// is the call site REST's answers now come from.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ruling record 6227928430, letter A item 2; ruling record 6257816853, slice
5+
* 5 letter A] The `/auth/me` door, proven on the one request-to-
6+
* `ExecutionContext` composition by this pin plus its existing ones.
7+
*
8+
* The door is `makeExecutionContextResolver`, which all three current-user
9+
* routes call before they answer. It is driven here through the routes
10+
* themselves, `GET /auth/me/permissions`, `GET /auth/me/localization` and
11+
* `GET /me/apps`, on the real `HonoServerPlugin` booted through `init()`,
12+
* `start()` and its `kernel:ready` hooks, with no seam captured. Each row reads
13+
* what the routes answered and what reached the security service: the
14+
* context `/auth/me/permissions` and `/me/apps` hand to
15+
* `resolvePermissionSetsForContext` for the caller's sets.
16+
*
17+
* - ADR-0096: a request that resolves no principal is answered by each
18+
* route's own signed-out body, and the security service is never asked:
19+
* no credential, a cookie the auth service does not recognise, a session
20+
* read that throws, an API key alone (this door never opened an API-key
21+
* door), and a host with no auth service.
22+
* - ADR-0069: these routes are allow-listed for a gated user (they
23+
* bootstrap the remediation UI), so a session the authentication policy
24+
* holds is served, and the context the door hands on carries no
25+
* `authGate`.
26+
* - An authorization store that cannot be read is the host's `503`, never a
27+
* signed-out body.
28+
* - The composition's reading (ruling record 6257816853 item 1): a session
29+
* read that carries `session.userId` and no `user.id` is served as that
30+
* user, as at every other door.
31+
*
32+
* The CONTROL row proves the door admits somebody and withholds what it
33+
* declares: an ungated session is served, and its context carries no session
34+
* bearer, locale or time zone, although the session carries a bearer, the
35+
* request a language and the host a settings service.
36+
*
37+
* Every row but the ruled reading's was green on the door's hand-written
38+
* composition; all of them are green on the shared one.
39+
*/
40+
41+
import { describe, expect, it } from 'vitest';
42+
import { AUTHZ_STORE_UNAVAILABLE_CODE, AUTHZ_STORE_UNAVAILABLE_STATUS, hashApiKey } from '@objectstack/core';
43+
import { HonoServerPlugin } from './hono-plugin';
44+
45+
const ORG = 'org_alpha';
46+
const COOKIE = 'better-auth.session_token=tok_auth_me_door.c2ln';
47+
const HELD_COOKIE = 'better-auth.session_token=tok_auth_me_door_held.c2ln';
48+
const SESSION_USER_ID_COOKIE = 'better-auth.session_token=tok_auth_me_door_session_user_id.c2ln';
49+
const STRANGER_COOKIE = 'better-auth.session_token=tok_auth_me_door_unknown.c2ln';
50+
const KEY = 'osk_auth_me_door_key';
51+
const FUTURE = '2999-01-01T00:00:00Z';
52+
const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' };
53+
54+
const PERMISSIONS = '/api/v1/auth/me/permissions';
55+
const LOCALIZATION = '/api/v1/auth/me/localization';
56+
const APPS = '/api/v1/me/apps';
57+
58+
/** The one app the engine's registry holds: a signed-in caller is shown it, a signed-out one never is. */
59+
const APP = { name: 'door_app', label: 'Door App' };
60+
61+
interface Host {
62+
/** The auth service: one with a session read, or none at all. */
63+
auth: 'session' | 'none';
64+
/** The auth service's session read throws for every request. */
65+
sessionReadThrows?: true;
66+
/** The permission store's membership read fails. */
67+
storeDown?: true;
68+
}
69+
70+
/**
71+
* One deployment: an engine answering the grant tables and holding one app,
72+
* a metadata service, a settings service with a time zone, a security service
73+
* that records every context it is asked about, and the host's auth service.
74+
* A gate is active throughout, so a held session is held by policy.
75+
*/
76+
function services(host: Host, asked: unknown[]): Record<string, unknown> {
77+
const tables: Record<string, Array<Record<string, unknown>>> = {
78+
sys_api_key: [{ id: 'k_live', key: hashApiKey(KEY), revoked: false, user_id: 'u_key', active_organization_id: ORG, expires_at: FUTURE }],
79+
sys_member: [
80+
{ user_id: 'u_key', organization_id: ORG, role: 'member' },
81+
{ user_id: 'u_ses', organization_id: ORG, role: 'member' },
82+
{ user_id: 'u_held', organization_id: ORG, role: 'member' },
83+
],
84+
};
85+
const matches = (row: Record<string, unknown>, where: Record<string, unknown> | undefined) =>
86+
Object.entries(where ?? {}).every(([k, v]) => {
87+
if (k.startsWith('$')) throw new Error(`fixture: unsupported combinator ${k}`);
88+
if (v !== null && typeof v === 'object') {
89+
const list = (v as { $in?: unknown[] }).$in;
90+
if (!Array.isArray(list)) throw new Error(`fixture: unsupported operator on ${k}`);
91+
return list.includes(row[k] ?? null);
92+
}
93+
return (v ?? null) === (row[k] ?? null);
94+
});
95+
const objectql = {
96+
async find(object: string, q: { where?: Record<string, unknown>; limit?: number } = {}) {
97+
if (host.storeDown && object === 'sys_member') throw new Error('connection refused');
98+
const rows = (tables[object] ?? []).filter((row) => matches(row, q.where));
99+
return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows;
100+
},
101+
registry: { getAllApps: () => [APP], getAllObjects: () => [] },
102+
getSchema: () => undefined,
103+
};
104+
105+
const registered: Record<string, unknown> = {
106+
objectql,
107+
metadata: { list: async () => [] },
108+
settings: {
109+
async get(namespace: string, key: string) {
110+
return { value: `${namespace}.${key}` === 'localization.timezone' ? 'Europe/Paris' : undefined };
111+
},
112+
},
113+
security: {
114+
async resolvePermissionSetsForContext(context: unknown) {
115+
asked.push(context);
116+
return [];
117+
},
118+
},
119+
};
120+
if (host.auth === 'session') {
121+
const sessions: Record<string, unknown> = {
122+
[COOKIE]: { user: { id: 'u_ses' }, session: { id: 'sess_ses', userId: 'u_ses', token: 'bearer_ses', activeOrganizationId: ORG } },
123+
[HELD_COOKIE]: {
124+
user: { id: 'u_held', authGate: PASSWORD_EXPIRED },
125+
session: { id: 'sess_held', userId: 'u_held', token: 'bearer_held', activeOrganizationId: ORG },
126+
},
127+
[SESSION_USER_ID_COOKIE]: { session: { id: 'sess_uid', userId: 'u_ses', token: 'bearer_uid', activeOrganizationId: ORG } },
128+
};
129+
registered.auth = {
130+
api: {
131+
async getSession(input: { headers: Headers }) {
132+
if (host.sessionReadThrows) throw new Error('session backend unavailable');
133+
return sessions[input.headers.get('cookie') ?? ''] ?? null;
134+
},
135+
},
136+
isAuthGateActive: () => true,
137+
resolveUserAuthGates: async () => [],
138+
};
139+
}
140+
return registered;
141+
}
142+
143+
/** Boot the real plugin on `host` and answer the three routes for one request. */
144+
async function boot(host: Host, headers: Record<string, string>) {
145+
const asked: unknown[] = [];
146+
const registered: Record<string, unknown> = services(host, asked);
147+
const readyHooks: Array<() => Promise<void> | void> = [];
148+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, fatal() {}, child() { return quiet; } };
149+
const ctx: any = {
150+
logger: quiet,
151+
registerService: (name: string, value: unknown) => { registered[name] = value; },
152+
// The kernel's locator: an absent slot THROWS.
153+
getService: <T,>(name: string): T => {
154+
if (!(name in registered)) throw new Error(`[Kernel] Service '${name}' not found`);
155+
return registered[name] as T;
156+
},
157+
getKernel: () => ({ hasPlugin: () => false }),
158+
hook: (event: string, fn: () => Promise<void> | void) => { if (event === 'kernel:ready') readyHooks.push(fn); },
159+
};
160+
const plugin = new HonoServerPlugin({ port: 0, cors: false });
161+
await plugin.init(ctx);
162+
await plugin.start(ctx);
163+
for (const fn of readyHooks) await fn();
164+
const app = (plugin as any).server.getRawApp();
165+
166+
const answers: Record<string, { status: number; body: any }> = {};
167+
for (const route of [PERMISSIONS, LOCALIZATION, APPS]) {
168+
const res: Response = await app.request(`http://localhost${route}`, { headers });
169+
answers[route] = { status: res.status, body: await res.json() };
170+
}
171+
return { answers, asked: asked as Array<Record<string, unknown>> };
172+
}
173+
174+
const SIGNED_OUT = {
175+
[PERMISSIONS]: { authenticated: false },
176+
[LOCALIZATION]: { authenticated: false },
177+
[APPS]: { apps: [] },
178+
};
179+
180+
const ROWS: Array<{
181+
name: string;
182+
host: Host;
183+
headers: Record<string, string>;
184+
/** Each route's own signed-out body, and the security service never asked. */
185+
signedOut?: true;
186+
/** The host's outage answer on every route, and the security service never asked. */
187+
outage?: true;
188+
/** Who the security service was asked about, by both routes that ask it. */
189+
principal?: string;
190+
}> = [
191+
{ name: 'ADR-0096: no credential is signed out on every route', host: { auth: 'session' }, headers: {}, signedOut: true },
192+
{
193+
name: 'ADR-0096: a cookie the auth service does not recognise is signed out on every route',
194+
host: { auth: 'session' }, headers: { cookie: STRANGER_COOKIE }, signedOut: true,
195+
},
196+
{
197+
name: 'ADR-0096: a session read that throws resolved no caller, so it is signed out and not an outage',
198+
host: { auth: 'session', sessionReadThrows: true }, headers: { cookie: COOKIE }, signedOut: true,
199+
},
200+
{
201+
name: 'ADR-0096: an API key alone is signed out, because this door never opened an API-key door',
202+
host: { auth: 'session' }, headers: { 'x-api-key': KEY }, signedOut: true,
203+
},
204+
{
205+
name: 'ADR-0096: a host with no auth service signs nobody in',
206+
host: { auth: 'none' }, headers: { cookie: COOKIE }, signedOut: true,
207+
},
208+
{
209+
name: 'ADR-0069: a held session is served on these allow-listed routes, and its context carries no authGate',
210+
host: { auth: 'session' }, headers: { cookie: HELD_COOKIE }, principal: 'u_held',
211+
},
212+
{
213+
name: "a permission store that cannot be read is the host's 503 on every route, never a signed-out body",
214+
host: { auth: 'session', storeDown: true }, headers: { cookie: COOKIE }, outage: true,
215+
},
216+
{
217+
name: 'the ruled reading: a session read carrying session.userId and no user.id is served as that user',
218+
host: { auth: 'session' }, headers: { cookie: SESSION_USER_ID_COOKIE }, principal: 'u_ses',
219+
},
220+
{
221+
name: 'CONTROL: an ungated session is served, and its context carries no session bearer, locale or time zone',
222+
host: { auth: 'session' }, headers: { cookie: COOKIE, 'accept-language': 'zh-CN' }, principal: 'u_ses',
223+
},
224+
];
225+
226+
describe('[ruling 6227928430 item 2, ruling 6257816853] the /auth/me door: the ADR-0096 principal-less refusal and the ADR-0069 allow-list on its three routes', () => {
227+
it.each(ROWS)('$name', async (row) => {
228+
const { answers, asked } = await boot(row.host, row.headers);
229+
230+
if (row.signedOut) {
231+
for (const [route, body] of Object.entries(SIGNED_OUT)) {
232+
expect(answers[route], route).toEqual({ status: 200, body });
233+
}
234+
expect(asked).toHaveLength(0);
235+
return;
236+
}
237+
if (row.outage) {
238+
for (const route of [PERMISSIONS, LOCALIZATION, APPS]) {
239+
expect(answers[route]!.status, route).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
240+
expect(answers[route]!.body?.error?.code, route).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
241+
}
242+
expect(asked).toHaveLength(0);
243+
return;
244+
}
245+
246+
expect(answers[PERMISSIONS]).toMatchObject({ status: 200, body: { authenticated: true, userId: row.principal, tenantId: ORG } });
247+
expect(answers[LOCALIZATION]).toMatchObject({ status: 200, body: { authenticated: true } });
248+
expect(answers[APPS]).toEqual({ status: 200, body: { apps: [APP] } });
249+
// `/auth/me/permissions` and `/me/apps` each ask once, about this caller.
250+
expect(asked).toHaveLength(2);
251+
for (const context of asked) {
252+
expect(context).toMatchObject({ userId: row.principal, tenantId: ORG, principalKind: 'human', isSystem: false });
253+
// Withheld by this door: the ADR-0069 posture, the session bearer and
254+
// the localization the endpoints resolve for themselves.
255+
expect(context.authGate).toBeUndefined();
256+
expect(context.accessToken).toBeUndefined();
257+
expect(context.locale).toBeUndefined();
258+
expect(context.timezone).toBeUndefined();
259+
}
260+
});
261+
});

0 commit comments

Comments
 (0)