@cosmicdrift/kumiko-framework 0.266.0 → 0.269.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 (56) 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/__tests__/stream-escape-hatch.integration.test.ts +203 -0
  11. package/src/api/jwt.ts +8 -0
  12. package/src/changes.json +28 -0
  13. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  14. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  15. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  16. package/src/db/entity-table-meta.ts +7 -0
  17. package/src/db/event-store-executor-context.ts +19 -0
  18. package/src/db/event-store-executor-write.ts +16 -0
  19. package/src/db/index.ts +3 -1
  20. package/src/db/table-builder.ts +27 -0
  21. package/src/db/tenant-db.ts +83 -10
  22. package/src/engine/__tests__/extends-registrar.test.ts +1 -0
  23. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
  24. package/src/engine/active-membership.ts +15 -1
  25. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
  26. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  27. package/src/engine/boot-validator/access-declarations.ts +6 -1
  28. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  29. package/src/engine/boot-validator/index.ts +2 -0
  30. package/src/engine/feature-entity-handlers.ts +7 -1
  31. package/src/engine/feature-ui-extensions.ts +2 -2
  32. package/src/engine/index.ts +3 -0
  33. package/src/engine/registry-validate.ts +6 -6
  34. package/src/engine/steps/call-feature.ts +1 -1
  35. package/src/engine/types/index.ts +2 -0
  36. package/src/entrypoint/index.ts +2 -13
  37. package/src/errors/i18n/de.yaml +36 -0
  38. package/src/errors/i18n/en.yaml +34 -0
  39. package/src/errors/reasons.ts +13 -0
  40. package/src/jobs/job-runner.ts +14 -0
  41. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  42. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  43. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
  44. package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
  45. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  46. package/src/pipeline/active-membership.ts +3 -1
  47. package/src/pipeline/dispatch-shared.ts +107 -20
  48. package/src/pipeline/dispatch-stream.ts +9 -5
  49. package/src/pipeline/dispatch-write.ts +7 -0
  50. package/src/pipeline/dispatcher-utils.ts +1 -0
  51. package/src/pipeline/dispatcher.ts +18 -0
  52. package/src/pipeline/index.ts +1 -1
  53. package/src/pipeline/member-reader.ts +108 -0
  54. package/src/pipeline/system-identity-switch.ts +142 -25
  55. package/src/stack/test-stack.ts +10 -1
  56. package/src/testing/handler-context.ts +3 -0
@@ -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) {
@@ -228,7 +228,11 @@ export function buildEntityHandlerMethods<TName extends string>(
228
228
  nameOrDef: string | StreamHandlerDefinition<TName, TSchema>,
229
229
  schema?: TSchema,
230
230
  handler?: StreamHandlerFn<z.infer<TSchema>>,
231
- options?: { access: AccessRule; rateLimit?: RateLimitOption },
231
+ options?: {
232
+ access: AccessRule;
233
+ rateLimit?: RateLimitOption;
234
+ escapeHatch?: EscapeHatchDeclaration;
235
+ },
232
236
  ): HandlerRef {
233
237
  if (typeof nameOrDef === "object") {
234
238
  const def = nameOrDef;
@@ -239,6 +243,7 @@ export function buildEntityHandlerMethods<TName extends string>(
239
243
  handler: def.handler as StreamHandlerFn, // @cast-boundary engine-bridge
240
244
  access: def.access,
241
245
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
246
+ ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
242
247
  };
243
248
  return { name: def.name };
244
249
  }
@@ -249,6 +254,7 @@ export function buildEntityHandlerMethods<TName extends string>(
249
254
  handler: inline.handler as StreamHandlerFn, // @cast-boundary engine-bridge
250
255
  access: inline.options.access,
251
256
  ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
257
+ ...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
252
258
  };
253
259
  return { name: nameOrDef };
254
260
  },
@@ -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,
@@ -1,6 +1,6 @@
1
1
  // r.step.callFeature — typed sub-command on another Kumiko feature.
2
2
  // Tier-2: requires r.requires.step("callFeature"). Sync (no dispatcher).
3
- // Cross-tenant via opts.as (Sysadmin-role-checked at the dispatcher layer).
3
+ // opts.as other than the caller itself needs escapeHatch on the handler (system-identity-switch.ts).
4
4
 
5
5
  import { defineStep } from "../define-step";
6
6
  import type { SessionUser } from "../types";
@@ -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,42 @@ 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
+ identity_switch_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`: ein Handler oder Hook hat `ctx.queryAs`/`ctx.writeAs`
106
+ mit einer anderen Identität als dem eigenen Caller aufgerufen — andere
107
+ User-ID, anderer Tenant, andere Claims oder Origin, oder Rollen, die der
108
+ Caller nicht hat — ohne `r.systemScope()`-Handler, Job zu sein oder
109
+ `escapeHatch: { reason }` zu deklarieren. Der Wechsel auf den Caller selbst
110
+ oder eine Teilmenge seiner Rollen bleibt frei.
111
+
112
+ Deklariere `escapeHatch: { reason: "..." }` am Write- oder Query-Handler
113
+ (oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
114
+ oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
115
+
116
+ member_resolution_denied:
117
+ endUser: |
118
+ Diese Aktion ist nicht erlaubt.
119
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
120
+ developer: |
121
+ `AccessDeniedError`: `ctx.queryAsMember(userId, ...)` konnte für `userId`
122
+ keine aktive Mitgliedschaft im Tenant des Aufrufers auflösen (kein
123
+ Mitglied, Principal gesperrt, oder der Tenant befindet sich im Teardown).
124
+ Bewusst generisch — `details` verrät nie, welche Prüfung fehlgeschlagen ist.
125
+
126
+ member_resolution_read_only:
127
+ endUser: |
128
+ Diese Aktion ist nicht erlaubt.
129
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
130
+ developer: |
131
+ `AccessDeniedError`: ein über `ctx.queryAsMember` erreichter Query-Handler
132
+ hat versucht zu schreiben (`ctx.write`/`writeAs`/`appendEvent`/
133
+ `jobRunner`/...). Aufgelöste Member-Principals sind nur lesend — es gibt
134
+ kein `writeAsMember`.
135
+
100
136
  agent.tool_not_allowed:
101
137
  endUser: |
102
138
  Dieses Tool ist im aktuellen Modus nicht verfügbar.
@@ -94,6 +94,40 @@ 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
+ identity_switch_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`: a handler or hook called `ctx.queryAs`/`ctx.writeAs`
103
+ with an identity other than its own caller — a different user id, tenant,
104
+ claims or origin, or roles the caller does not have — without being an
105
+ `r.systemScope()` handler, a job, or declaring `escapeHatch: { reason }`.
106
+ Switching to the caller itself or to a subset of its roles stays free.
107
+
108
+ Declare `escapeHatch: { reason: "..." }` on the write or query handler
109
+ (or on the `r.hook(...)` call, if the switch happens inside a hook), or
110
+ move the logic into an `r.systemScope()` feature or a job.
111
+
112
+ member_resolution_denied:
113
+ endUser: |
114
+ This action isn't allowed.
115
+ Please contact an administrator if you believe this is a mistake.
116
+ developer: |
117
+ `AccessDeniedError`: `ctx.queryAsMember(userId, ...)` could not resolve an
118
+ active membership for `userId` in the caller's tenant (not a member,
119
+ principal blocked, or the tenant is in teardown). Deliberately generic —
120
+ `details` never says which check failed.
121
+
122
+ member_resolution_read_only:
123
+ endUser: |
124
+ This action isn't allowed.
125
+ Please contact an administrator if you believe this is a mistake.
126
+ developer: |
127
+ `AccessDeniedError`: a query handler reached via `ctx.queryAsMember` tried
128
+ to write (`ctx.write`/`writeAs`/`appendEvent`/`jobRunner`/...). Resolved
129
+ member principals are read-only — there is no `writeAsMember`.
130
+
97
131
  agent.tool_not_allowed:
98
132
  endUser: |
99
133
  This tool is not available in the current mode.
@@ -42,6 +42,19 @@ 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: a handler/hook called ctx.queryAs/ctx.writeAs with a
47
+ // non-SYSTEM identity other than its own caller (different user, tenant,
48
+ // claims, origin or extra roles) without r.systemScope() or escapeHatch.
49
+ identitySwitchDenied: "identity_switch_denied",
50
+
51
+ // AccessDeniedError: ctx.queryAsMember's userId is not an active member of
52
+ // ctx's tenant. Deliberately generic — a caller can't probe which check failed.
53
+ memberResolutionDenied: "member_resolution_denied",
54
+
55
+ // AccessDeniedError: a query handler invoked via ctx.queryAsMember tried to
56
+ // write — a resolved member principal is read-only by construction.
57
+ memberResolutionReadOnly: "member_resolution_read_only",
45
58
  } as const;
46
59
 
47
60
  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 };