@cosmicdrift/kumiko-framework 0.266.0 → 0.268.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 (48) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
  9. package/src/api/__tests__/jwt.test.ts +8 -0
  10. package/src/api/jwt.ts +8 -0
  11. package/src/changes.json +15 -0
  12. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  13. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  14. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  15. package/src/db/entity-table-meta.ts +7 -0
  16. package/src/db/event-store-executor-context.ts +19 -0
  17. package/src/db/event-store-executor-write.ts +16 -0
  18. package/src/db/index.ts +3 -1
  19. package/src/db/table-builder.ts +27 -0
  20. package/src/db/tenant-db.ts +83 -10
  21. package/src/engine/active-membership.ts +15 -1
  22. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  23. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  24. package/src/engine/boot-validator/index.ts +2 -0
  25. package/src/engine/feature-ui-extensions.ts +2 -2
  26. package/src/engine/index.ts +3 -0
  27. package/src/engine/registry-validate.ts +6 -6
  28. package/src/engine/types/index.ts +2 -0
  29. package/src/entrypoint/index.ts +2 -13
  30. package/src/errors/i18n/de.yaml +20 -0
  31. package/src/errors/i18n/en.yaml +19 -0
  32. package/src/errors/reasons.ts +8 -0
  33. package/src/jobs/job-runner.ts +14 -0
  34. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  35. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  36. package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
  37. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  38. package/src/pipeline/active-membership.ts +3 -1
  39. package/src/pipeline/dispatch-shared.ts +89 -10
  40. package/src/pipeline/dispatch-stream.ts +9 -5
  41. package/src/pipeline/dispatch-write.ts +7 -0
  42. package/src/pipeline/dispatcher-utils.ts +1 -0
  43. package/src/pipeline/dispatcher.ts +18 -0
  44. package/src/pipeline/index.ts +1 -1
  45. package/src/pipeline/member-reader.ts +108 -0
  46. package/src/pipeline/system-identity-switch.ts +73 -13
  47. package/src/stack/test-stack.ts +10 -1
  48. package/src/testing/handler-context.ts +3 -0
@@ -29,6 +29,7 @@ import {
29
29
  moneyAmount,
30
30
  table as pgTable,
31
31
  plainDate,
32
+ type SchemaTable,
32
33
  SQL_EXPR_BRAND,
33
34
  type SqlExpression,
34
35
  serial,
@@ -505,6 +506,32 @@ function stampGlobalTenancyMeta<E extends EntityDefinition>(
505
506
  };
506
507
  }
507
508
 
509
+ /**
510
+ * Stamps `tenancy: "global"` onto a `table()`/SchemaTable's meta in place.
511
+ * Throws if the table has a `tenant_id` column or no EntityTableMeta to stamp.
512
+ */
513
+ export function declareGlobalTenancy<T extends SchemaTable>(table: T): T & TenancyBrand<"global"> {
514
+ const meta = (table as unknown as Record<symbol, EntityTableMeta | undefined>)[
515
+ KUMIKO_META_SYMBOL
516
+ ];
517
+ if (!meta) {
518
+ throw new Error("declareGlobalTenancy(): table has no EntityTableMeta to stamp.");
519
+ }
520
+ if (meta.columns.some((c) => c.name === "tenant_id")) {
521
+ throw new Error(
522
+ `declareGlobalTenancy("${meta.tableName}"): tables with a "tenant_id" column cannot be ` +
523
+ 'declared tenancy: "global" — a global table\'s rows carry the system tenant, not a ' +
524
+ "per-row tenant identity.",
525
+ );
526
+ }
527
+ (table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL] = {
528
+ ...meta,
529
+ tenancy: "global",
530
+ };
531
+ // @cast-boundary type-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
532
+ return table as T & TenancyBrand<"global">;
533
+ }
534
+
508
535
  export function buildEntityTable<E extends EntityDefinition>(
509
536
  entityName: string,
510
537
  entity: E,
@@ -137,17 +137,48 @@ function tableNameOf(table: Table | EntityTableMeta): string {
137
137
  return asEntityTableMeta(table)?.tableName ?? "<unknown>";
138
138
  }
139
139
 
140
+ // A grant is only real when its reason is non-empty — `{ reason: "" }` must not silently unlock it.
141
+ function hasGrant(decl: EscapeHatchDeclaration | undefined): boolean {
142
+ return decl !== undefined && decl.reason.trim().length > 0;
143
+ }
144
+
145
+ function isForeignTenantId(tenantIdValue: unknown): boolean {
146
+ return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
147
+ }
148
+
140
149
  // Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
141
150
  // or a plain deriveEntityTableMeta/defineUnmanagedTable result), not a direct
142
151
  // `table.tenantId` property read — the latter only exists on branded EntityTables
143
152
  // and silently returned false (no tenant filter!) for plain EntityTableMeta
144
153
  // tables like unmanaged direct-write stores, e.g. userSessionTable.
145
- function hasTenantColumn(table: Table): boolean {
154
+ function hasTenantColumn(table: Table | EntityTableMeta): boolean {
146
155
  const meta = asEntityTableMeta(table);
147
156
  if (meta) return meta.columns.some((c) => c.name === "tenant_id");
148
157
  return (table as Record<string, unknown>)["tenantId"] !== undefined;
149
158
  }
150
159
 
160
+ // Grants for TenantDb's two escape hatches: db.global()'s write methods
161
+ // (write-handler-only) and ctx.db.unsafeRaw() (handler or hook, re-granted per hook).
162
+ export type TenantDbGrants = {
163
+ readonly globalWrites?: EscapeHatchDeclaration;
164
+ readonly unsafeRaw?: EscapeHatchDeclaration;
165
+ };
166
+
167
+ const unsafeRawRebinders = new WeakMap<
168
+ TenantDb,
169
+ (grant: EscapeHatchDeclaration | undefined) => TenantDb
170
+ >();
171
+
172
+ // Rebinds tenantDb's unsafeRaw grant (e.g. a hook's own escapeHatch); inputs not built by
173
+ // createTenantDb pass through unchanged, without touching any property (ctx.db may be a throwing Proxy).
174
+ export function withUnsafeRawGrant(
175
+ tenantDb: TenantDb,
176
+ grant: EscapeHatchDeclaration | undefined,
177
+ ): TenantDb {
178
+ const rebind = unsafeRawRebinders.get(tenantDb);
179
+ return rebind ? rebind(grant) : tenantDb;
180
+ }
181
+
151
182
  export function createTenantDb(
152
183
  db: DbRunner,
153
184
  tenantId: TenantId,
@@ -155,8 +186,7 @@ export function createTenantDb(
155
186
  tracer?: Tracer,
156
187
  meter?: Meter,
157
188
  signal?: AbortSignal,
158
- // Gates db.global(table)'s write methods; write-path-only (see dispatch-shared.ts buildHandlerContext).
159
- escapeHatch?: EscapeHatchDeclaration,
189
+ grants?: TenantDbGrants,
160
190
  ): TenantDb {
161
191
  if (meter) registerStandardMetrics(meter);
162
192
 
@@ -215,7 +245,7 @@ export function createTenantDb(
215
245
  // (e.g. exclude SYSTEM reference rows at the DB instead of post-filtering
216
246
  // after a limit). Values outside the scope are dropped; if nothing valid
217
247
  // remains, the full enforced scope applies — widening is never possible.
218
- function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
248
+ function readWhere(table: Table | EntityTableMeta, where?: WhereObject): WhereObject | undefined {
219
249
  if (!hasTenantColumn(table) || mode === "system") return where;
220
250
  const allowed = [tenantId, SYSTEM_TENANT_ID];
221
251
  const requested = where?.["tenantId"];
@@ -242,7 +272,7 @@ export function createTenantDb(
242
272
  }
243
273
 
244
274
  function missingEscapeHatch(table: Table | EntityTableMeta): AccessDeniedError | undefined {
245
- if (escapeHatch && escapeHatch.reason.trim().length > 0) return undefined;
275
+ if (hasGrant(grants?.globalWrites)) return undefined;
246
276
  return new AccessDeniedError({
247
277
  message:
248
278
  `db.global(${tableNameOf(table)}): write rejected — declare ` +
@@ -251,6 +281,15 @@ export function createTenantDb(
251
281
  });
252
282
  }
253
283
 
284
+ function foreignTenantOnGlobalWrite(
285
+ table: Table | EntityTableMeta,
286
+ tenantIdValue: unknown,
287
+ message: string = `db.global(${tableNameOf(table)}): tenantId "${String(tenantIdValue)}" is not SYSTEM_TENANT_ID — a "global" table's rows must carry the system tenant, not an arbitrary tenant's id.`,
288
+ ): AccessDeniedError | undefined {
289
+ if (!isForeignTenantId(tenantIdValue)) return undefined;
290
+ return new AccessDeniedError({ message });
291
+ }
292
+
254
293
  function globalTable<TTable extends (SchemaTable | EntityTableMeta) & TenancyBrand<"global">>(
255
294
  table: TTable,
256
295
  ): GlobalTableDb<TTable> {
@@ -273,7 +312,8 @@ export function createTenantDb(
273
312
  insertOne<T = Record<string, unknown>>(
274
313
  values: Record<string, unknown>,
275
314
  ): Promise<T | undefined> {
276
- const denied = missingEscapeHatch(table);
315
+ const denied =
316
+ missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
277
317
  if (denied) return Promise.reject(denied);
278
318
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
279
319
  },
@@ -281,7 +321,8 @@ export function createTenantDb(
281
321
  set: Record<string, unknown>,
282
322
  where: WhereObject,
283
323
  ): Promise<readonly T[]> {
284
- const denied = missingEscapeHatch(table);
324
+ const denied =
325
+ missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, set["tenantId"]);
285
326
  if (denied) return Promise.reject(denied);
286
327
  if (!where || Object.keys(where).length === 0) {
287
328
  return Promise.reject(
@@ -308,14 +349,28 @@ export function createTenantDb(
308
349
  } as GlobalTableDb<TTable>;
309
350
  }
310
351
 
311
- return {
352
+ const tenantDb: TenantDb = {
312
353
  tenantId,
313
354
  mode,
314
355
  raw: db,
315
356
  global: globalTable,
316
357
 
358
+ unsafeRaw(reason: string): DbRunner {
359
+ if (reason.trim().length === 0) {
360
+ throw new Error("unsafeRaw requires a non-empty reason");
361
+ }
362
+ if (!hasGrant(grants?.unsafeRaw)) {
363
+ throw new AccessDeniedError({
364
+ message:
365
+ 'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
366
+ "the handler or hook to allow unsafeRaw.",
367
+ });
368
+ }
369
+ return db;
370
+ },
371
+
317
372
  selectMany<T = Record<string, unknown>>(
318
- table: Table,
373
+ table: Table | EntityTableMeta,
319
374
  where?: WhereObject,
320
375
  options?: SelectOptions,
321
376
  ): Promise<readonly T[]> {
@@ -324,7 +379,7 @@ export function createTenantDb(
324
379
  },
325
380
 
326
381
  fetchOne<T = Record<string, unknown>>(
327
- table: Table,
382
+ table: Table | EntityTableMeta,
328
383
  where: WhereObject,
329
384
  ): Promise<T | undefined> {
330
385
  const filter = readWhere(table, where) ?? {};
@@ -335,6 +390,19 @@ export function createTenantDb(
335
390
  table: Table,
336
391
  values: Record<string, unknown>,
337
392
  ): Promise<T | undefined> {
393
+ if (
394
+ mode === "tenant" &&
395
+ hasTenantColumn(table) &&
396
+ asEntityTableMeta(table)?.tenancy === "global"
397
+ ) {
398
+ const denied = foreignTenantOnGlobalWrite(
399
+ table,
400
+ tenantId,
401
+ `insertOne(${tableNameOf(table)}): "global" table rows must carry SYSTEM_TENANT_ID; ` +
402
+ "use db.global(table) with escapeHatch instead.",
403
+ );
404
+ if (denied) return Promise.reject(denied);
405
+ }
338
406
  const data = insertValues(table, values);
339
407
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
340
408
  },
@@ -367,6 +435,11 @@ export function createTenantDb(
367
435
  return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
368
436
  },
369
437
  };
438
+
439
+ unsafeRawRebinders.set(tenantDb, (grant) =>
440
+ createTenantDb(db, tenantId, mode, tracer, meter, signal, { ...grants, unsafeRaw: grant }),
441
+ );
442
+ return tenantDb;
370
443
  }
371
444
 
372
445
  export { asRawClient };
@@ -10,12 +10,26 @@ import type { TenantId } from "./types/identifiers";
10
10
  // with a since-deleted row should not silently treat it as active.
11
11
  export type PrincipalStatus = "active" | "blocked" | "unknown";
12
12
 
13
+ // Global-roles/timezone/locale snapshot ctx.queryAsMember needs to mint a
14
+ // resolved principal's SessionUser, without exposing its own table.
15
+ export type PrincipalProfile = {
16
+ readonly globalRoles: readonly string[];
17
+ readonly timezone?: string;
18
+ readonly locale?: string;
19
+ };
20
+
13
21
  // Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin).
14
22
  export type PrincipalStatusPlugin = {
15
23
  readonly resolveStatus: (
16
24
  userId: string,
17
25
  deps: { readonly db: DbConnection },
18
26
  ) => Promise<PrincipalStatus>;
27
+ // Required so a missing provider fails closed (InternalError) instead of
28
+ // silently minting empty global roles for ctx.queryAsMember.
29
+ readonly resolveProfile: (
30
+ userId: string,
31
+ deps: { readonly db: DbConnection },
32
+ ) => Promise<PrincipalProfile | null>;
19
33
  };
20
34
 
21
35
  // `null` means the tenant has no lifecycle row (never entered teardown).
@@ -38,7 +52,7 @@ function hasResolveStatusFn(v: unknown): v is { readonly resolveStatus: unknown
38
52
  }
39
53
 
40
54
  export function isPrincipalStatusPlugin(v: unknown): v is PrincipalStatusPlugin {
41
- return hasResolveStatusFn(v);
55
+ return hasResolveStatusFn(v) && "resolveProfile" in v && typeof v.resolveProfile === "function";
42
56
  }
43
57
 
44
58
  export function isTenantLifecycleStatusPlugin(v: unknown): v is TenantLifecycleStatusPlugin {
@@ -0,0 +1,58 @@
1
+ // fw#2858 — a `tenancy: "global"` entity's event stream must live on
2
+ // SYSTEM_TENANT_ID (systemStream: true); otherwise it would silently fork
3
+ // one stream per creating tenant while still being writable/readable
4
+ // cross-tenant via db.global(table).
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { defineFeature } from "../../define-feature";
8
+ import { createEntity, createTextField } from "../../factories";
9
+ import { validateGlobalTenancyEntities } from "../global-tenancy";
10
+
11
+ const textField = () =>
12
+ createTextField({
13
+ required: true,
14
+ maxLength: 64,
15
+ personal: false,
16
+ reason: "technical_reference",
17
+ });
18
+
19
+ describe("validateGlobalTenancyEntities", () => {
20
+ test("a tenancy: 'global' entity WITHOUT systemStream: true throws", () => {
21
+ const entity = createEntity({
22
+ table: "read_fw2858_global_no_stream",
23
+ tenancy: "global",
24
+ fields: { name: textField() },
25
+ });
26
+ const feature = defineFeature("fw2858-global-no-stream", (r) => {
27
+ r.entity("fw2858GlobalNoStream", entity);
28
+ });
29
+
30
+ expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemStream/);
31
+ });
32
+
33
+ test("a tenancy: 'global' entity WITH systemStream: true passes", () => {
34
+ const entity = createEntity({
35
+ table: "read_fw2858_global_with_stream",
36
+ tenancy: "global",
37
+ systemStream: true,
38
+ fields: { name: textField() },
39
+ });
40
+ const feature = defineFeature("fw2858-global-with-stream", (r) => {
41
+ r.entity("fw2858GlobalWithStream", entity);
42
+ });
43
+
44
+ expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
45
+ });
46
+
47
+ test("a tenant-tenancy entity without systemStream is never checked", () => {
48
+ const entity = createEntity({
49
+ table: "read_fw2858_tenant_entity",
50
+ fields: { name: textField() },
51
+ });
52
+ const feature = defineFeature("fw2858-tenant-entity", (r) => {
53
+ r.entity("fw2858TenantEntity", entity);
54
+ });
55
+
56
+ expect(() => validateGlobalTenancyEntities([feature])).not.toThrow();
57
+ });
58
+ });
@@ -0,0 +1,17 @@
1
+ import type { FeatureDefinition } from "../types";
2
+
3
+ // Boot-time twin of event-store-executor-context.ts's buildExecutorContext check —
4
+ // surfaces the systemStream invariant once across all features before any dispatch.
5
+ export function validateGlobalTenancyEntities(features: readonly FeatureDefinition[]): void {
6
+ for (const feature of features) {
7
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
8
+ if (entity.tenancy === "global" && entity.systemStream !== true) {
9
+ throw new Error(
10
+ `Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities must ` +
11
+ "also declare systemStream: true — a global aggregate's event stream must live on " +
12
+ "the system tenant, not the creating tenant's.",
13
+ );
14
+ }
15
+ }
16
+ }
17
+ }
@@ -36,6 +36,7 @@ import {
36
36
  } from "./entity-handler";
37
37
  import { validateEntityListScreens } from "./entity-list-screens";
38
38
  import { validateGdprStoragePersistence } from "./gdpr-storage";
39
+ import { validateGlobalTenancyEntities } from "./global-tenancy";
39
40
  import { validateI18nSurfaceKeys } from "./i18n-keys";
40
41
  import {
41
42
  collectKnownRoles,
@@ -238,6 +239,7 @@ export function validateBoot(
238
239
  validateRelatedListSectionQueries(features);
239
240
  validateExtensionPreSaveWiring(features);
240
241
  validateGdprStoragePersistence(features);
242
+ validateGlobalTenancyEntities(features);
241
243
  validateFeatureBootChecks(features);
242
244
 
243
245
  if (hasEncryptedFields) {
@@ -1,5 +1,5 @@
1
1
  import type { EntityTableMeta } from "../db/entity-table-meta";
2
- import { bindHookIdentitySwitchGrant } from "../pipeline/system-identity-switch";
2
+ import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
3
  import { LifecycleHookTypes } from "./constants";
4
4
  import type { FeatureBuilderState } from "./feature-builder-state";
5
5
  import { resolveName } from "./handler-helpers";
@@ -143,7 +143,7 @@ export function buildUiExtensionsMethods<TName extends string>(
143
143
  const wrapped: LifecycleHookFn | ValidationHookFn =
144
144
  type === "validation"
145
145
  ? fn
146
- : bindHookIdentitySwitchGrant(fn as LifecycleHookFn, hookLabel, options?.escapeHatch); // @cast-boundary engine-bridge
146
+ : bindHookEscapeHatchGrant(fn as LifecycleHookFn, hookLabel, options?.escapeHatch); // @cast-boundary engine-bridge
147
147
 
148
148
  // Entity-wide target ("all write/query handlers of this entity") —
149
149
  // replaces the old r.entityHook(type, entity, fn).
@@ -4,6 +4,7 @@ export { hasAccess } from "./access";
4
4
  export {
5
5
  isPrincipalStatusPlugin,
6
6
  isTenantLifecycleStatusPlugin,
7
+ type PrincipalProfile,
7
8
  type PrincipalStatus,
8
9
  type PrincipalStatusPlugin,
9
10
  TENANT_TEARDOWN_STATUSES,
@@ -410,6 +411,7 @@ export type {
410
411
  ListSortSpec,
411
412
  LongTextFindability,
412
413
  ManyToManyRelation,
414
+ MemberReader,
413
415
  MetricNavigate,
414
416
  MetricSpec,
415
417
  MspErrorMode,
@@ -478,6 +480,7 @@ export type {
478
480
  SecretsEditSection,
479
481
  SelectFieldDef,
480
482
  SessionUser,
483
+ SessionUserOrigin,
481
484
  StreamHandlerDef,
482
485
  StreamHandlerFn,
483
486
  Subscribe,
@@ -1,5 +1,5 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
- import { bindHookIdentitySwitchGrant } from "../pipeline/system-identity-switch";
2
+ import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
3
  import { resolveName } from "./handler-helpers";
4
4
  import type {
5
5
  RegistryState,
@@ -126,10 +126,10 @@ export function applyExtensionUsages(state: RegistryState): void {
126
126
  // RegistrarExtensionRegistration and use that here.
127
127
  const extOwner = "*";
128
128
  // Extensions have no declaration site for escapeHatch, so SYSTEM is always denied
129
- // (system-identity-switch.ts) — bindHookIdentitySwitchGrant(..., undefined) below.
129
+ // (system-identity-switch.ts) — bindHookEscapeHatchGrant(..., undefined) below.
130
130
  if (ext.hooks) {
131
131
  if (ext.hooks.postSave) {
132
- const wrapped = bindHookIdentitySwitchGrant(
132
+ const wrapped = bindHookEscapeHatchGrant(
133
133
  ext.hooks.postSave,
134
134
  `postSave hook of extension "${usage.extensionName}"`,
135
135
  undefined,
@@ -143,7 +143,7 @@ export function applyExtensionUsages(state: RegistryState): void {
143
143
  state.entityPostSaveHooks.set(usage.entityName, existing);
144
144
  }
145
145
  if (ext.hooks.preDelete) {
146
- const wrapped = bindHookIdentitySwitchGrant(
146
+ const wrapped = bindHookEscapeHatchGrant(
147
147
  ext.hooks.preDelete,
148
148
  `preDelete hook of extension "${usage.extensionName}"`,
149
149
  undefined,
@@ -157,7 +157,7 @@ export function applyExtensionUsages(state: RegistryState): void {
157
157
  state.entityPreDeleteHooks.set(usage.entityName, existing);
158
158
  }
159
159
  if (ext.hooks.postDelete) {
160
- const wrapped = bindHookIdentitySwitchGrant(
160
+ const wrapped = bindHookEscapeHatchGrant(
161
161
  ext.hooks.postDelete,
162
162
  `postDelete hook of extension "${usage.extensionName}"`,
163
163
  undefined,
@@ -172,7 +172,7 @@ export function applyExtensionUsages(state: RegistryState): void {
172
172
  }
173
173
  // preSave on extensions: store as handler hook for all CRUD handlers of this entity
174
174
  if (ext.hooks.preSave) {
175
- const wrapped = bindHookIdentitySwitchGrant(
175
+ const wrapped = bindHookEscapeHatchGrant(
176
176
  ext.hooks.preSave,
177
177
  `preSave hook of extension "${usage.extensionName}"`,
178
178
  undefined,
@@ -171,6 +171,7 @@ export type {
171
171
  HandlerRef,
172
172
  JobContext,
173
173
  JobRunnerRef,
174
+ MemberReader,
174
175
  NameOrRef,
175
176
  NotifyFactory,
176
177
  NotifyFn,
@@ -186,6 +187,7 @@ export type {
186
187
  RateLimitOption,
187
188
  RateLimitPer,
188
189
  SessionUser,
190
+ SessionUserOrigin,
189
191
  StreamHandlerDef,
190
192
  StreamHandlerFn,
191
193
  TryAppendEventFn,
@@ -38,7 +38,7 @@ import { buildServer, withFileProviderResolver } from "../api/server";
38
38
  import type { SseBroker } from "../api/sse-broker";
39
39
  import type { PgClient } from "../db/connection";
40
40
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
41
- import type { AppContext, DispatchWriteRef, JobRunIn, Registry, RunIn } from "../engine/types";
41
+ import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
42
42
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
43
43
  import { createJobRunner } from "../jobs/job-runner";
44
44
  import type { Lifecycle } from "../lifecycle";
@@ -47,6 +47,7 @@ import type { ObservabilityOptions, ObservabilityProvider } from "../observabili
47
47
  import { createNoopProvider } from "../observability";
48
48
  import type { EventDedup, EventDispatcher } from "../pipeline";
49
49
  import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher";
50
+ import { dispatcherToWriteRef } from "../pipeline/dispatcher";
50
51
  import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
51
52
 
52
53
  // Shared fields across all three modes. A caller that swaps between
@@ -206,18 +207,6 @@ function contextWithObservability(
206
207
  };
207
208
  }
208
209
 
209
- // Adapts the command-dispatcher's positional (type, payload, user) calls to
210
- // DispatchWriteRef's (user, qn, payload) shape — JobContext.write/queryAs
211
- // pass identity explicitly per call (boot-time singleton), while Dispatcher
212
- // takes it last (request-scoped closure caller). Same underlying pipeline,
213
- // different argument order.
214
- function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
215
- return {
216
- write: (user, qn, payload) => dispatcher.write(qn, payload, user),
217
- queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
218
- };
219
- }
220
-
221
210
  // buildApiServer shapes ServerOptions from API-mode caller-options.
222
211
  // AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
223
212
  // subtyping makes the all-in-one path a valid caller without an explicit
@@ -97,6 +97,26 @@ system_identity_switch_denied:
97
97
  (oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
98
98
  oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
99
99
 
100
+ member_resolution_denied:
101
+ endUser: |
102
+ Diese Aktion ist nicht erlaubt.
103
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
104
+ developer: |
105
+ `AccessDeniedError`: `ctx.queryAsMember(userId, ...)` konnte für `userId`
106
+ keine aktive Mitgliedschaft im Tenant des Aufrufers auflösen (kein
107
+ Mitglied, Principal gesperrt, oder der Tenant befindet sich im Teardown).
108
+ Bewusst generisch — `details` verrät nie, welche Prüfung fehlgeschlagen ist.
109
+
110
+ member_resolution_read_only:
111
+ endUser: |
112
+ Diese Aktion ist nicht erlaubt.
113
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
114
+ developer: |
115
+ `AccessDeniedError`: ein über `ctx.queryAsMember` erreichter Query-Handler
116
+ hat versucht zu schreiben (`ctx.write`/`writeAs`/`appendEvent`/
117
+ `jobRunner`/...). Aufgelöste Member-Principals sind nur lesend — es gibt
118
+ kein `writeAsMember`.
119
+
100
120
  agent.tool_not_allowed:
101
121
  endUser: |
102
122
  Dieses Tool ist im aktuellen Modus nicht verfügbar.
@@ -94,6 +94,25 @@ system_identity_switch_denied:
94
94
  (or on the `r.hook(...)` call, if the switch happens inside a hook), or
95
95
  move the logic into an `r.systemScope()` feature or a job.
96
96
 
97
+ member_resolution_denied:
98
+ endUser: |
99
+ This action isn't allowed.
100
+ Please contact an administrator if you believe this is a mistake.
101
+ developer: |
102
+ `AccessDeniedError`: `ctx.queryAsMember(userId, ...)` could not resolve an
103
+ active membership for `userId` in the caller's tenant (not a member,
104
+ principal blocked, or the tenant is in teardown). Deliberately generic —
105
+ `details` never says which check failed.
106
+
107
+ member_resolution_read_only:
108
+ endUser: |
109
+ This action isn't allowed.
110
+ Please contact an administrator if you believe this is a mistake.
111
+ developer: |
112
+ `AccessDeniedError`: a query handler reached via `ctx.queryAsMember` tried
113
+ to write (`ctx.write`/`writeAs`/`appendEvent`/`jobRunner`/...). Resolved
114
+ member principals are read-only — there is no `writeAsMember`.
115
+
97
116
  agent.tool_not_allowed:
98
117
  endUser: |
99
118
  This tool is not available in the current mode.
@@ -42,6 +42,14 @@ export const FrameworkReasons = {
42
42
  // AccessDeniedError: a handler/hook called ctx.queryAs/ctx.writeAs with a
43
43
  // SYSTEM identity without an r.systemScope() feature or a declared escapeHatch.
44
44
  systemIdentitySwitchDenied: "system_identity_switch_denied",
45
+
46
+ // AccessDeniedError: ctx.queryAsMember's userId is not an active member of
47
+ // ctx's tenant. Deliberately generic — a caller can't probe which check failed.
48
+ memberResolutionDenied: "member_resolution_denied",
49
+
50
+ // AccessDeniedError: a query handler invoked via ctx.queryAsMember tried to
51
+ // write — a resolved member principal is read-only by construction.
52
+ memberResolutionReadOnly: "member_resolution_read_only",
45
53
  } as const;
46
54
 
47
55
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
@@ -10,6 +10,7 @@ import {
10
10
  type DispatchWriteRef,
11
11
  type JobContext,
12
12
  type JobRunIn,
13
+ type MemberReader,
13
14
  type Registry,
14
15
  type SessionUser,
15
16
  SYSTEM_TENANT_ID,
@@ -552,6 +553,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
552
553
  tenantId,
553
554
  })
554
555
  : context.derivatives;
556
+ // Per-job-run reader, created lazily on queryAsMember's first call and
557
+ // cached for the rest of this run.
558
+ let memberReader: MemberReader | undefined;
555
559
  const jobContext: JobContext = {
556
560
  ...context,
557
561
  // Same union as configDb above — job runners are always constructed
@@ -597,6 +601,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
597
601
  }
598
602
  return dispatchWriteRef.queryAs(user, qn, payload);
599
603
  },
604
+ queryAsMember: (userId: string, qn: string, payload: unknown) => {
605
+ if (!dispatchWriteRef) {
606
+ throw new Error(
607
+ "JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first",
608
+ );
609
+ }
610
+ // Lazy per-run reader, mirroring the handler-context reader.
611
+ memberReader ??= dispatchWriteRef.createMemberReader(tenantId);
612
+ return memberReader(userId, qn, payload);
613
+ },
600
614
  };
601
615
 
602
616
  await options.onJobStart?.(jobName, jobId, meta);
@@ -51,6 +51,23 @@ describe("dispatcherSpanAttributes", () => {
51
51
  "kumiko.feature": "feat",
52
52
  });
53
53
  });
54
+
55
+ test("includes kumiko.user_origin when the user has one", () => {
56
+ const user = {
57
+ id: "u1",
58
+ tenantId: "tenant-1",
59
+ roles: [],
60
+ origin: "member-resolution" as const,
61
+ };
62
+ const attrs = dispatcherSpanAttributes("feat:query:task:list", "query", user, "feat");
63
+ expect(attrs["kumiko.user_origin"]).toBe("member-resolution");
64
+ });
65
+
66
+ test("omits kumiko.user_origin when the user has none", () => {
67
+ const user = createSystemUser("tenant-1");
68
+ const attrs = dispatcherSpanAttributes("feat:query:task:list", "query", user, "feat");
69
+ expect(attrs["kumiko.user_origin"]).toBeUndefined();
70
+ });
54
71
  });
55
72
 
56
73
  describe("prefixValidationPath", () => {
@@ -19,7 +19,6 @@ const globalStoreTable = defineUnmanagedTable({
19
19
  primaryKey: true,
20
20
  defaultSql: "gen_random_uuid()",
21
21
  },
22
- { name: "tenant_id", pgType: "uuid", notNull: true },
23
22
  { name: "note", pgType: "text", notNull: true },
24
23
  ],
25
24
  });
@@ -36,7 +35,6 @@ const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
36
35
  escapeHatch: { reason: "fw#2855 integration test — declared cross-tenant write" },
37
36
  handler: async (event, ctx) => {
38
37
  const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
39
- tenantId: event.user.tenantId,
40
38
  note: event.payload.note,
41
39
  });
42
40
  return { isSuccess: true as const, data: row };
@@ -49,7 +47,6 @@ const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
49
47
  access: { roles: ["User"] },
50
48
  handler: async (event, ctx) => {
51
49
  const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
52
- tenantId: event.user.tenantId,
53
50
  note: event.payload.note,
54
51
  });
55
52
  return { isSuccess: true as const, data: row };