@cosmicdrift/kumiko-framework 0.264.1 → 0.266.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +4 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +21 -2
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +4 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +4 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +4 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +4 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +4 -0
  9. package/src/api/__tests__/server-boot-guards.test.ts +60 -0
  10. package/src/api/auth-middleware.ts +2 -7
  11. package/src/api/auth-routes.ts +28 -14
  12. package/src/api/server.ts +14 -1
  13. package/src/changes.json +27 -0
  14. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +84 -3
  15. package/src/engine/active-membership.ts +56 -0
  16. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
  17. package/src/engine/boot-validator/access-declarations.ts +101 -27
  18. package/src/engine/boot-validator/zod-shape.ts +92 -1
  19. package/src/engine/extension-names.ts +16 -0
  20. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
  21. package/src/engine/feature-ast/extractors/hooks.ts +8 -4
  22. package/src/engine/index.ts +15 -0
  23. package/src/engine/types/index.ts +4 -0
  24. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +38 -0
  25. package/src/pipeline/active-membership.ts +178 -0
  26. package/src/pipeline/dispatch-shared.ts +14 -1
  27. package/src/pipeline/dispatcher.ts +22 -1
  28. package/src/pipeline/system-identity-switch.ts +44 -13
  29. package/src/testing/handler-context.ts +4 -0
  30. package/src/ui-types/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.264.1",
3
+ "version": "0.266.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.264.1",
201
+ "@cosmicdrift/kumiko-types": "0.266.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.264.1",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.266.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -636,6 +636,10 @@ describe("POST /api/stream pre-pull race", () => {
636
636
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
637
637
  return {};
638
638
  },
639
+ // Not exercised by this route — always "not_a_member" is fine.
640
+ async resolveActiveMembership() {
641
+ return { kind: "rejected", reason: "not_a_member" };
642
+ },
639
643
  };
640
644
  }
641
645
 
@@ -20,7 +20,7 @@ import { createJwtHelper } from "../jwt";
20
20
  const JWT_SECRET = "auth-routes-cookie-test-secret-min-32-characters";
21
21
 
22
22
  function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
23
- const base: Dispatcher = {
23
+ const merged: Dispatcher = {
24
24
  async write(): Promise<WriteResult> {
25
25
  // Explicit `const: WriteResult` locks the `isSuccess: true` branch of
26
26
  // the union without an `as`-cast (which widens + silences the
@@ -46,8 +46,27 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
46
46
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
47
47
  return {};
48
48
  },
49
+ // Delegates to `merged.query` (not to a pre-override `base`) so the
50
+ // switch-tenant test's `query` override — which answers
51
+ // "tenant:query:memberships" — is what this sees too.
52
+ async resolveActiveMembership(userId, tenantId) {
53
+ const memberships = (await merged.query(
54
+ "tenant:query:memberships",
55
+ { userId },
56
+ TestUsers.user,
57
+ )) as ReadonlyArray<{
58
+ tenantId: string;
59
+ roles: readonly string[];
60
+ }>;
61
+ const membership = memberships.find((m) => m.tenantId === tenantId);
62
+ if (!membership) {
63
+ return { kind: "rejected", reason: "not_a_member" };
64
+ }
65
+ return { kind: "active", membership: { tenantId, roles: membership.roles } };
66
+ },
67
+ ...overrides,
49
68
  };
50
- return { ...base, ...overrides };
69
+ return merged;
51
70
  }
52
71
 
53
72
  async function buildApp(
@@ -33,6 +33,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
33
33
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
34
34
  return {};
35
35
  },
36
+ // Not exercised by these routes — always "not_a_member" is fine.
37
+ async resolveActiveMembership() {
38
+ return { kind: "rejected", reason: "not_a_member" };
39
+ },
36
40
  // Stream API added with r.streamHandler (#1446) — stub unused in these routes.
37
41
  async *stream(): AsyncGenerator<unknown> {},
38
42
  };
@@ -45,6 +45,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
45
45
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
46
46
  return {};
47
47
  },
48
+ // Not exercised by these routes — always "not_a_member" is fine.
49
+ async resolveActiveMembership() {
50
+ return { kind: "rejected", reason: "not_a_member" };
51
+ },
48
52
  };
49
53
  return { ...base, ...overrides };
50
54
  }
@@ -46,6 +46,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
46
46
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
47
47
  return {};
48
48
  },
49
+ // Not exercised by these routes — always "not_a_member" is fine.
50
+ async resolveActiveMembership() {
51
+ return { kind: "rejected", reason: "not_a_member" };
52
+ },
49
53
  };
50
54
  return { ...base, ...overrides };
51
55
  }
@@ -45,6 +45,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
45
45
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
46
46
  return {};
47
47
  },
48
+ // Not exercised by these routes — always "not_a_member" is fine.
49
+ async resolveActiveMembership() {
50
+ return { kind: "rejected", reason: "not_a_member" };
51
+ },
48
52
  };
49
53
  return { ...base, ...overrides };
50
54
  }
@@ -38,6 +38,10 @@ function failingDispatcher(): Dispatcher {
38
38
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
39
39
  return {};
40
40
  },
41
+ // Not exercised by these routes — always "not_a_member" is fine.
42
+ async resolveActiveMembership() {
43
+ return { kind: "rejected", reason: "not_a_member" };
44
+ },
41
45
  };
42
46
  }
43
47
 
@@ -1,12 +1,15 @@
1
1
  // buildServer boot-time guards + httpRoute verb wiring (PUT branch).
2
2
 
3
3
  import { describe, expect, spyOn, test } from "bun:test";
4
+ import { z } from "zod";
4
5
  import {
5
6
  createEntity,
6
7
  createFileField,
7
8
  createRegistry,
8
9
  createTextField,
9
10
  defineFeature,
11
+ defineQueryHandler,
12
+ EXT_PRINCIPAL_STATUS,
10
13
  } from "../../engine";
11
14
  import { createInMemorySearchAdapter } from "../../search";
12
15
  import { buildServer } from "../server";
@@ -203,3 +206,60 @@ describe("buildServer — search-adapter boot warning (#2051)", () => {
203
206
  }
204
207
  });
205
208
  });
209
+
210
+ describe("buildServer — auth membershipQuery requires a principalStatus provider", () => {
211
+ const MEMBERSHIP_QN = "tenant:query:memberships";
212
+
213
+ // The registry auto-qualifies a SHORT handler name as `<feature>:query:<name>`,
214
+ // so the feature must be named "tenant" for MEMBERSHIP_QN to come out right.
215
+ const membershipQueryFeature = defineFeature("tenant", (r) => {
216
+ r.queryHandler(
217
+ defineQueryHandler({
218
+ name: "memberships",
219
+ schema: z.object({ userId: z.string() }),
220
+ handler: async () => [],
221
+ access: { roles: ["all"] },
222
+ }),
223
+ );
224
+ });
225
+
226
+ const principalStatusFeature = defineFeature("has-principal-status", (r) => {
227
+ r.extendsRegistrar(EXT_PRINCIPAL_STATUS, {});
228
+ r.useExtension(EXT_PRINCIPAL_STATUS, "has-principal-status", {
229
+ resolveStatus: async () => "active" as const,
230
+ });
231
+ });
232
+
233
+ test("throws when membershipQuery is registered but no feature provides principalStatus", () => {
234
+ expect(() =>
235
+ buildServer({
236
+ registry: createRegistry([membershipQueryFeature]),
237
+ context: {},
238
+ jwtSecret: JWT_SECRET,
239
+ auth: { membershipQuery: MEMBERSHIP_QN },
240
+ }),
241
+ ).toThrow(/no feature provides the "principalStatus" contract/);
242
+ });
243
+
244
+ test("boots when a feature provides principalStatus", () => {
245
+ expect(() =>
246
+ buildServer({
247
+ registry: createRegistry([membershipQueryFeature, principalStatusFeature]),
248
+ context: {},
249
+ jwtSecret: JWT_SECRET,
250
+ auth: { membershipQuery: MEMBERSHIP_QN },
251
+ }),
252
+ ).not.toThrow();
253
+ });
254
+
255
+ test("boots when the membershipQuery handler isn't registered at all (e.g. features:[] tests)", () => {
256
+ expect(() =>
257
+ buildServer({
258
+ registry: createRegistry([]),
259
+ context: {},
260
+ jwtSecret: JWT_SECRET,
261
+ auth: { membershipQuery: MEMBERSHIP_QN },
262
+ }),
263
+ ).not.toThrow();
264
+ });
265
+ });
@@ -1,5 +1,6 @@
1
1
  import type { Context, Next } from "hono";
2
2
  import { getCookie } from "hono/cookie";
3
+ import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership";
3
4
  import { createAnonymousUser } from "../engine/system-user";
4
5
  import type { SessionUser, TenantId } from "../engine/types";
5
6
  import { parseTenantId } from "../engine/types/identifiers";
@@ -592,12 +593,6 @@ type RejectArgs = {
592
593
  details?: Record<string, unknown>;
593
594
  };
594
595
 
595
- const TENANT_LIFECYCLE_BLOCKED = new Set([
596
- "destroyRequested",
597
- "destroying",
598
- "destroyFailed",
599
- "destroyed",
600
- ]);
601
596
  const TENANT_LIFECYCLE_CANCEL_QN = "tenant-lifecycle:write:cancel-destruction";
602
597
 
603
598
  async function requestsCancelDestruction(c: Context): Promise<boolean> {
@@ -634,7 +629,7 @@ async function rejectIfTenantTeardown(
634
629
  ): Promise<Response | undefined> {
635
630
  if (!resolveTenantLifecycleStatus) return undefined;
636
631
  const lifecycle = await resolveTenantLifecycleStatus(tenantId);
637
- if (!lifecycle || !TENANT_LIFECYCLE_BLOCKED.has(lifecycle.status)) return undefined;
632
+ if (!lifecycle || !TENANT_TEARDOWN_STATUSES.has(lifecycle.status)) return undefined;
638
633
  if (lifecycle.status === "destroyRequested" && (await requestsCancelDestruction(c))) {
639
634
  return undefined;
640
635
  }
@@ -6,9 +6,16 @@ import type Redis from "ioredis";
6
6
  import { z } from "zod";
7
7
  import { buildSessionRoles } from "../engine/membership-roles";
8
8
  import { createSystemUser } from "../engine/system-user";
9
- import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
9
+ import {
10
+ type ActiveMembershipRejection,
11
+ type ActiveMembershipResult,
12
+ type SessionUser,
13
+ SYSTEM_TENANT_ID,
14
+ type TenantId,
15
+ } from "../engine/types";
10
16
  import { NotFoundError } from "../errors";
11
17
  import type { Dispatcher } from "../pipeline/dispatcher";
18
+ import { assertUnreachable } from "../utils";
12
19
  import { parseRoles } from "../utils/serialization";
13
20
  import { Routes } from "./api-constants";
14
21
  import {
@@ -167,6 +174,19 @@ function isUnknownHandlerError(e: unknown): boolean {
167
174
  return details?.entity === "handler";
168
175
  }
169
176
 
177
+ function switchTenantRejectionResponse(c: Context, reason: ActiveMembershipRejection): Response {
178
+ switch (reason) {
179
+ case "not_a_member":
180
+ return c.json({ error: "not_a_member" }, 403);
181
+ case "principal_blocked":
182
+ return c.json({ error: "principal_blocked" }, 403);
183
+ case "tenant_teardown":
184
+ return c.json({ error: "tenant_unavailable" }, 410);
185
+ default:
186
+ return assertUnreachable(reason, "ActiveMembershipRejection");
187
+ }
188
+ }
189
+
170
190
  type MembershipRow = {
171
191
  userId: string;
172
192
  tenantId: TenantId;
@@ -1324,17 +1344,11 @@ export function createAuthRoutes(
1324
1344
  return c.json({ error: "already_in_tenant" }, 400);
1325
1345
  }
1326
1346
 
1327
- // Check membership — uses the system identity because membershipQuery is
1328
- // locked to the system role. The auth-route is trusted server code; it
1329
- // asks the question on the user's behalf, not as the user.
1330
- let memberships: MembershipRow[];
1347
+ // Membership + principal-blocked + tenant-teardown in one building
1348
+ // block — trusted server code asking on the user's behalf, same trust boundary as before.
1349
+ let activeMembership: ActiveMembershipResult;
1331
1350
  try {
1332
- // @cast-boundary engine-payload — generic dispatcher.query result
1333
- memberships = (await dispatcher.query(
1334
- config.membershipQuery,
1335
- { userId: user.id },
1336
- createSystemUser(user.tenantId),
1337
- )) as MembershipRow[];
1351
+ activeMembership = await dispatcher.resolveActiveMembership(user.id, targetTenantId);
1338
1352
  } catch (e) {
1339
1353
  // No membershipQuery wired → switching tenants is just not offered in
1340
1354
  // this deployment. Any other error propagates so a broken query handler
@@ -1343,10 +1357,10 @@ export function createAuthRoutes(
1343
1357
  return c.json({ error: "tenant_switch_not_available" }, 400);
1344
1358
  }
1345
1359
 
1346
- const membership = memberships.find((m) => m.tenantId === targetTenantId);
1347
- if (!membership) {
1348
- return c.json({ error: "not_a_member" }, 403);
1360
+ if (activeMembership.kind === "rejected") {
1361
+ return switchTenantRejectionResponse(c, activeMembership.reason);
1349
1362
  }
1363
+ const membership = activeMembership.membership;
1350
1364
 
1351
1365
  // Globale Rollen aus user-feature lesen wenn userQuery wired —
1352
1366
  // tenant-unabhängige Rollen (SystemAdmin etc.) überleben so den
package/src/api/server.ts CHANGED
@@ -2,7 +2,7 @@ import { Hono } from "hono";
2
2
  import type { DbConnection, PgClient } from "../db/connection";
3
3
  import { createTenantDb } from "../db/tenant-db";
4
4
  import { createDerivativesContext } from "../derivatives/derivatives-context";
5
- import { EXT_FILE_PROVIDER } from "../engine/extension-names";
5
+ import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names";
6
6
  import { runsInLane } from "../engine/run-in";
7
7
  import { createAnonymousUser } from "../engine/system-user";
8
8
  import {
@@ -443,6 +443,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
443
443
  ...options.dispatcherOptions,
444
444
  lifecycle,
445
445
  sseBroker,
446
+ ...(options.auth ? { membershipQuery: options.auth.membershipQuery } : {}),
446
447
  });
447
448
 
448
449
  // Async event-dispatcher — the replacement for the old transactional
@@ -756,6 +757,18 @@ export function buildServer(options: ServerOptions): KumikoServer {
756
757
  // Public auth routes (login) need to be registered BEFORE the generic
757
758
  // api routes so Hono matches them first.
758
759
  if (options.auth) {
760
+ // A membershipQuery handler is registered but nothing fulfils
761
+ // principalStatus — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
762
+ if (
763
+ options.registry.getQueryHandler(options.auth.membershipQuery) &&
764
+ options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length === 0
765
+ ) {
766
+ throw new Error(
767
+ `[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but no feature ` +
768
+ 'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
769
+ "need it to reject blocked principals).",
770
+ );
771
+ }
759
772
  app.route("/api", createAuthRoutes(dispatcher, jwt, options.auth));
760
773
  }
761
774
  app.route(
package/src/changes.json CHANGED
@@ -1,4 +1,31 @@
1
1
  [
2
+ {
3
+ "version": "0.266.0",
4
+ "type": "breaking",
5
+ "title": "`publicIntake` is removed; an openToAll write handler declares unbound personal data with `openToAll: { reason, personalData: \"tenant-members\" }`.",
6
+ "detail": "`OpenToAllAccessRule.publicIntake` (`@cosmicdrift/kumiko-types/handlers`) no longer exists. `OpenToAllDeclaration` gains `personalData?: OpenToAllPersonalData`, a named union (currently only `\"tenant-members\"`, exported from `@cosmicdrift/kumiko-framework/engine` and `/ui-types`): signed-in tenant members may write personal data that no owner rule binds to the caller. The deprecated `openToAll: true` form cannot carry it. `validateAccessDeclarations` requires `personalData: \"tenant-members\"` for a write handler with `openToAll` whose input accepts a personal-data field that is not owner- or self-bound, rejects `personalData` on query and stream handlers, and rejects any other `personalData` value from untyped sources. `openToAll` never admits anonymous callers, so there is no public-intake value; anonymous personal-data intake is tracked separately. The feature-AST extractor reads `openToAll.personalData` and no longer reads `publicIntake`. Bundled `user:update` now declares `openToAll: { reason, personalData: \"tenant-members\" }`: `userEntity` has no entity-level `access.write`, and privileged actors may edit any user.",
7
+ "migration": "Replace `access: { openToAll: { reason: \"...\" }, publicIntake: true }` (or `openToAll: true, publicIntake: true`) with `access: { openToAll: { reason: \"<why any signed-in tenant member may write this personal data>\", personalData: \"tenant-members\" } }`. Handlers whose personal-data fields are owner- or self-bound through the entity's `access.write` need no declaration. A `publicIntake` key left in an untyped access object (pattern JSON, Designer) is ignored, so the handler fails boot until migrated. No known consumer used `publicIntake`."
8
+ },
9
+ {
10
+ "version": "0.266.0",
11
+ "type": "breaking",
12
+ "title": "The openToAll personal-data boot check now finds personal-data fields nested anywhere in a write handler's input schema.",
13
+ "detail": "`validateAccessDeclarations` (`engine/boot-validator/access-declarations.ts`) previously compared only the top-level keys of a `z.object` input schema (unwrapping `.optional()`/`.nullable()`/`.default()`) against the handler entity's personal-data fields (pii / userOwned / recordOwned); any other schema shape was skipped. It now collects object keys at every depth via `collectZodObjectKeys` (`engine/boot-validator/zod-shape.ts`): through `z.intersection`, `z.union`/`z.discriminatedUnion`/`z.xor`, `.transform()`/`z.preprocess()`/`.pipe()`/codecs, `.optional()`/`.nullable()`/`.default()`/`.prefault()`/`.nonoptional()`/`.readonly()`/`.catch()`/`z.lazy()`, nested objects (e.g. the update shape `{ id, version, changes: {...} }`), arrays, tuples and records. An update handler that accepted the same personal-data field as its create handler used to pass boot while the create handler failed. A schema with more than 10000 distinct nodes (typically a `z.lazy()` getter that returns a new schema on every call) throws at boot instead of looping. Which entity's fields are compared is unchanged: the handler's mapped entity, or every entity of the feature for an unmapped handler.",
14
+ "migration": "A write handler with `openToAll` whose input accepts a personal-data field of its entity inside an intersection, union, pipe/transform, nested object (update `changes`), array or record now fails boot like a top-level field always did. Known: kumiko-enterprise kumiko-credit `update` (intersection) and `bauspar:update` (`changes.name`) under `trustDomain: \"tenant\"` or with an `access` map that grants any role `\"all\"`. For each affected handler, bind the entity's rows to the caller (owner-binding entry in this release), restrict `access` to roles, or declare `openToAll: { reason: \"...\", personalData: \"tenant-members\" }`."
15
+ },
16
+ {
17
+ "version": "0.266.0",
18
+ "type": "improvement",
19
+ "title": "Owner- and self-bound personal-data fields no longer need a declaration on openToAll write handlers.",
20
+ "detail": "A personal-data field is exempt from the openToAll personal-data boot check when it is bound to the caller: the handler maps to exactly one entity, that entity's `access.write` is a non-empty `OwnershipMap` in which every role's rule is `from(\"user:id\", <column>)` on the binding column, the handler declares no `escapeHatch`, and its feature is not `r.systemScope()`. The binding column is the owner field for `personal: { of: \"<ownerField>\" }` (userOwned), and `id` for `personal: \"self\"` and `personal: { of: \"id\" }` — there the row itself is the caller. The event-store executor enforces such a map on every write: create rejects a row whose binding column is not the caller (`ownership_denied`), update checks the old and the new row, delete/restore the existing row. Not exempt: a missing or empty `access.write`, any `\"all\"` rule (also next to binding rules), a rule on another column, `user:tenantId` or claim refs, `where` rules, `self` fields on an entity without `from(\"user:id\", \"id\")`, handlers not mapped to a single entity, handlers with `escapeHatch` and handlers of `r.systemScope()` features (both can write around the executor via `db.global()`, `systemDb` or a SYSTEM identity)."
21
+ },
22
+ {
23
+ "version": "0.265.0",
24
+ "type": "improvement",
25
+ "title": "Active-membership building block (resolveActiveMembership) composes membership + principalStatus + tenant-lifecycle checks (fw#2872).",
26
+ "detail": "New `dispatcher.resolveActiveMembership(userId, tenantId)` / `ctx.resolveActiveMembership` returns an `ActiveMembershipResult` — either `active` with the raw membership roles, or `rejected` with reason `not_a_member` | `principal_blocked` | `tenant_teardown` (membership is checked first, so a non-member can't learn a foreign tenant's lifecycle/blocked state). It composes two new framework contracts, `EXT_PRINCIPAL_STATUS` (fulfilled by the bundled `user` feature) and `EXT_TENANT_LIFECYCLE_STATUS` (fulfilled by `tenant-lifecycle`), plus `TENANT_TEARDOWN_STATUSES` and the default `TENANT_MEMBERSHIPS_QUERY` handler name, and is now used by switch-tenant, login and auth-mfa verify/enable-confirm-preauth instead of each independently composing membership + status checks. `ctx.resolveActiveMembership` requires the same SYSTEM-identity grant as `ctx.queryAs`/`ctx.writeAs` with a system user (`r.systemScope()` or a declared `escapeHatch`), since it queries memberships as SYSTEM internally. Behaviourally: `POST /api/auth/switch-tenant` now answers 403 `principal_blocked` for a blocked principal and 410 `tenant_unavailable` for a target tenant in teardown (a tenant in `destroyRequested` is still allowed so its owner can cancel destruction); login skips a last-active tenant that is in teardown and falls through to the next active membership; MFA verify and enable-confirm-preauth reject a tenant in teardown; `buildServer` now throws at boot when the auth `membershipQuery` handler is registered but no feature provides the `principalStatus` contract (mount the bundled `user` feature); and `isPrincipalBlocked` now lives in `user` (still re-exported from `sessions` for existing importers).",
27
+ "migration": "No action for apps mounting the bundled `user` feature (runProdApp/runDevApp always do). A custom stack that registers the membership query without the `user` feature must mount `user` or register an `r.useExtension(EXT_PRINCIPAL_STATUS, ...)` provider, otherwise buildServer throws at boot. Clients of POST /api/auth/switch-tenant should handle the new 403 `principal_blocked` and 410 `tenant_unavailable` responses. A handler calling `ctx.resolveActiveMembership` needs an `escapeHatch` declaration (or `r.systemScope()`) the same way it would for a SYSTEM `queryAs`/`writeAs` call."
28
+ },
2
29
  {
3
30
  "version": "0.263.0",
4
31
  "type": "breaking",
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { AccessDeniedError } from "../../errors";
3
- import { createGatedIdentitySwitch, type QueryAsFn } from "../../pipeline/system-identity-switch";
2
+ import { AccessDeniedError, FrameworkReasons } from "../../errors";
3
+ import {
4
+ createGatedIdentitySwitch,
5
+ type QueryAsFn,
6
+ type ResolveActiveMembershipFn,
7
+ } from "../../pipeline/system-identity-switch";
4
8
  import { createEntity, createRegistry, createSystemUser, defineFeature } from "../index";
5
- import type { AppContext, PostSaveHookFn, SaveContext } from "../types";
9
+ import type { ActiveMembershipResult, AppContext, PostSaveHookFn, SaveContext } from "../types";
6
10
  import type { TenantId } from "../types/identifiers";
7
11
 
8
12
  // fw#2859: r.hook({ allOf }, ...) must get the same identity-switch gate as the handler-keyed path.
@@ -102,4 +106,81 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
102
106
  });
103
107
  }).toThrow(/non-empty string/);
104
108
  });
109
+
110
+ test("entity-wide hook WITHOUT escapeHatch does not inherit the handler's resolveActiveMembership grant", async () => {
111
+ const activeResult: ActiveMembershipResult = {
112
+ kind: "active",
113
+ membership: { tenantId: TENANT, roles: [] },
114
+ };
115
+ const ungatedResolveActiveMembership = mock(async () => activeResult);
116
+ const handlerCtx = {
117
+ ...createGatedIdentitySwitch('handler "test:write:thing:create"', true, {
118
+ queryAs: mock(async () => "ok") as QueryAsFn,
119
+ writeAs: async () => ({ isSuccess: true as const, data: null }),
120
+ }),
121
+ resolveActiveMembership: ungatedResolveActiveMembership as ResolveActiveMembershipFn,
122
+ };
123
+
124
+ let caught: unknown;
125
+ const feature = defineFeature("test", (r) => {
126
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
127
+ const hookFn: PostSaveHookFn = async (_result, ctx) => {
128
+ const asContext = ctx as unknown as { resolveActiveMembership: ResolveActiveMembershipFn };
129
+ try {
130
+ await asContext.resolveActiveMembership("user-1", TENANT);
131
+ } catch (err) {
132
+ caught = err;
133
+ }
134
+ };
135
+ r.hook("postSave", { allOf: thing }, hookFn);
136
+ });
137
+
138
+ const registry = createRegistry([feature]);
139
+ const hooks = registry.getEntityPostSaveHooks("thing");
140
+ expect(hooks).toHaveLength(1);
141
+
142
+ await hooks[0]?.(dummySaveContext, handlerCtx as unknown as AppContext);
143
+
144
+ expect(caught).toBeInstanceOf(AccessDeniedError);
145
+ expect((caught as AccessDeniedError).details).toEqual({
146
+ reason: FrameworkReasons.systemIdentitySwitchDenied,
147
+ });
148
+ expect(ungatedResolveActiveMembership).not.toHaveBeenCalled();
149
+ });
150
+
151
+ test("entity-wide hook WITH escapeHatch reaches the original resolveActiveMembership even when the handler's own grant was false", async () => {
152
+ const activeResult: ActiveMembershipResult = {
153
+ kind: "active",
154
+ membership: { tenantId: TENANT, roles: ["User"] },
155
+ };
156
+ const ungatedResolveActiveMembership = mock(async () => activeResult);
157
+ const handlerCtx = {
158
+ ...createGatedIdentitySwitch('handler "test:write:thing:create"', false, {
159
+ queryAs: mock(async () => "ok") as QueryAsFn,
160
+ writeAs: async () => ({ isSuccess: true as const, data: null }),
161
+ }),
162
+ resolveActiveMembership: ungatedResolveActiveMembership as ResolveActiveMembershipFn,
163
+ };
164
+
165
+ let resolveResult: unknown;
166
+ const feature = defineFeature("test", (r) => {
167
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
168
+ const hookFn: PostSaveHookFn = async (_result, ctx) => {
169
+ const asContext = ctx as unknown as { resolveActiveMembership: ResolveActiveMembershipFn };
170
+ resolveResult = await asContext.resolveActiveMembership("user-1", TENANT);
171
+ };
172
+ r.hook("postSave", { allOf: thing }, hookFn, {
173
+ escapeHatch: { reason: "test: allOf hook needs SYSTEM" },
174
+ });
175
+ });
176
+
177
+ const registry = createRegistry([feature]);
178
+ const hooks = registry.getEntityPostSaveHooks("thing");
179
+ expect(hooks).toHaveLength(1);
180
+
181
+ await hooks[0]?.(dummySaveContext, handlerCtx as unknown as AppContext);
182
+
183
+ expect(resolveResult).toEqual(activeResult);
184
+ expect(ungatedResolveActiveMembership).toHaveBeenCalledWith("user-1", TENANT);
185
+ });
105
186
  });
@@ -0,0 +1,56 @@
1
+ // principalStatus / tenantLifecycleStatus contracts consumed by
2
+ // pipeline/active-membership.ts. Kept in engine/ (extension-registry surface), same split as tier-resolver-extension.ts.
3
+
4
+ import type { DbConnection } from "../db/connection";
5
+ import type { TenantId } from "./types/identifiers";
6
+
7
+ // "unknown" = no persisted principal row for this userId — the caller
8
+ // (INTERACTIVE_SIGN_IN_POLICY.allowUnknownPrincipal) decides whether that
9
+ // counts as active or blocked; a background resolver for a stored userId
10
+ // with a since-deleted row should not silently treat it as active.
11
+ export type PrincipalStatus = "active" | "blocked" | "unknown";
12
+
13
+ // Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin).
14
+ export type PrincipalStatusPlugin = {
15
+ readonly resolveStatus: (
16
+ userId: string,
17
+ deps: { readonly db: DbConnection },
18
+ ) => Promise<PrincipalStatus>;
19
+ };
20
+
21
+ // `null` means the tenant has no lifecycle row (never entered teardown).
22
+ export type TenantLifecycleStatusPlugin = {
23
+ readonly resolveStatus: (
24
+ tenantId: TenantId,
25
+ deps: { readonly db: DbConnection },
26
+ ) => Promise<{ readonly status: string } | null>;
27
+ };
28
+
29
+ // extension-usage `options` is engine-payload (unknown) — structurally
30
+ // validate instead of casting blind, same pattern as isFileProviderPlugin.
31
+ function hasResolveStatusFn(v: unknown): v is { readonly resolveStatus: unknown } {
32
+ return (
33
+ typeof v === "object" &&
34
+ v !== null &&
35
+ "resolveStatus" in v &&
36
+ typeof v.resolveStatus === "function"
37
+ );
38
+ }
39
+
40
+ export function isPrincipalStatusPlugin(v: unknown): v is PrincipalStatusPlugin {
41
+ return hasResolveStatusFn(v);
42
+ }
43
+
44
+ export function isTenantLifecycleStatusPlugin(v: unknown): v is TenantLifecycleStatusPlugin {
45
+ return hasResolveStatusFn(v);
46
+ }
47
+
48
+ // Same values auth-middleware.ts used to keep as its own private
49
+ // TENANT_LIFECYCLE_BLOCKED set — moved here so the framework's active-
50
+ // membership check and the request-level 410 gate can't drift apart.
51
+ export const TENANT_TEARDOWN_STATUSES: ReadonlySet<string> = new Set([
52
+ "destroyRequested",
53
+ "destroying",
54
+ "destroyFailed",
55
+ "destroyed",
56
+ ]);