@cosmicdrift/kumiko-framework 0.270.0 → 0.273.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 (104) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +91 -0
  13. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  14. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  15. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  16. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  17. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  18. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  19. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  20. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  21. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  22. package/src/db/apply-entity-event.ts +1 -1
  23. package/src/db/assert-exists-in.ts +1 -1
  24. package/src/db/event-store-executor-context.ts +5 -2
  25. package/src/db/event-store-executor-read.ts +6 -4
  26. package/src/db/event-store-executor-write.ts +24 -18
  27. package/src/db/queries/shadow-swap.ts +36 -0
  28. package/src/db/replay-migration-sql.ts +5 -5
  29. package/src/db/tenant-db-runner.ts +23 -0
  30. package/src/db/tenant-db.ts +97 -24
  31. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  32. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  33. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  34. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  35. package/src/engine/__tests__/boot-validator.test.ts +51 -0
  36. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  37. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  38. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  39. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  40. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  41. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  42. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  43. package/src/engine/__tests__/screen.test.ts +150 -0
  44. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  45. package/src/engine/__tests__/steps-read.test.ts +204 -8
  46. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  47. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  48. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  49. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  50. package/src/engine/boot-validator/entity-handler.ts +21 -0
  51. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  52. package/src/engine/boot-validator/parent-ref.ts +1 -1
  53. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  54. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  55. package/src/engine/boot-validator/screens.ts +154 -31
  56. package/src/engine/build-config-feature-schema.ts +7 -4
  57. package/src/engine/entity-handlers.ts +71 -19
  58. package/src/engine/feature-entity-handlers.ts +4 -4
  59. package/src/engine/index.ts +5 -0
  60. package/src/engine/registry-validate.ts +12 -4
  61. package/src/engine/schema-builder.ts +6 -0
  62. package/src/engine/screen-helpers.ts +52 -0
  63. package/src/engine/steps/_read-source.ts +12 -0
  64. package/src/engine/steps/read-find-many.ts +8 -2
  65. package/src/engine/steps/read-find-one.ts +11 -8
  66. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  67. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  68. package/src/engine/types/index.ts +8 -1
  69. package/src/errors/i18n/de.yaml +14 -0
  70. package/src/errors/i18n/en.yaml +13 -0
  71. package/src/errors/reasons.ts +4 -0
  72. package/src/i18n/required-surface-keys.ts +6 -2
  73. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  74. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  75. package/src/jobs/job-runner.ts +35 -5
  76. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  77. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  78. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  79. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  80. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  81. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  82. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  83. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  84. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  85. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  86. package/src/pipeline/dispatch-query.ts +27 -8
  87. package/src/pipeline/dispatch-shared.ts +141 -57
  88. package/src/pipeline/dispatch-stream.ts +1 -3
  89. package/src/pipeline/dispatch-write.ts +62 -14
  90. package/src/pipeline/dispatcher.ts +20 -0
  91. package/src/pipeline/escape-hatch-report.ts +127 -0
  92. package/src/pipeline/index.ts +8 -0
  93. package/src/pipeline/member-read-only-transaction.ts +75 -0
  94. package/src/pipeline/msp-rebuild.ts +2 -0
  95. package/src/pipeline/projection-rebuild.ts +2 -0
  96. package/src/pipeline/projections-runner.ts +15 -13
  97. package/src/pipeline/system-identity-switch.ts +121 -9
  98. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  99. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  100. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  101. package/src/ui-types/index.ts +3 -0
  102. package/src/utils/__tests__/ids.test.ts +29 -1
  103. package/src/utils/ids.ts +10 -1
  104. package/src/utils/index.ts +1 -1
@@ -23,7 +23,7 @@ import type {
23
23
  HandlerRef,
24
24
  NameOrRef,
25
25
  QueryHandlerFn,
26
- RateLimitOption,
26
+ RateLimitDeclaration,
27
27
  RelationDefinition,
28
28
  StreamHandlerFn,
29
29
  WriteHandlerFn,
@@ -124,7 +124,7 @@ export function buildEntityHandlerMethods<TName extends string>(
124
124
  handler?: WriteHandlerFn<z.infer<TSchema>>,
125
125
  options?: {
126
126
  access: AccessRule;
127
- rateLimit?: RateLimitOption;
127
+ rateLimit?: RateLimitDeclaration;
128
128
  description?: string;
129
129
  agent?: AgentHandlerHints;
130
130
  escapeHatch?: EscapeHatchDeclaration;
@@ -181,7 +181,7 @@ export function buildEntityHandlerMethods<TName extends string>(
181
181
  handler?: QueryHandlerFn<z.infer<TSchema>>,
182
182
  options?: {
183
183
  access: AccessRule;
184
- rateLimit?: RateLimitOption;
184
+ rateLimit?: RateLimitDeclaration;
185
185
  outputSchema?: ZodType;
186
186
  description?: string;
187
187
  agent?: AgentHandlerHints;
@@ -230,7 +230,7 @@ export function buildEntityHandlerMethods<TName extends string>(
230
230
  handler?: StreamHandlerFn<z.infer<TSchema>>,
231
231
  options?: {
232
232
  access: AccessRule;
233
- rateLimit?: RateLimitOption;
233
+ rateLimit?: RateLimitDeclaration;
234
234
  escapeHatch?: EscapeHatchDeclaration;
235
235
  },
236
236
  ): HandlerRef {
@@ -374,7 +374,12 @@ export type {
374
374
  EntityListScreenDefinition,
375
375
  EntityRef,
376
376
  EntityRelations,
377
+ EscapeHatchAuditSink,
377
378
  EscapeHatchDeclaration,
379
+ EscapeHatchKind,
380
+ EscapeHatchReporter,
381
+ EscapeHatchTarget,
382
+ EscapeHatchUseEvent,
378
383
  EventDef,
379
384
  FeatureDefinition,
380
385
  FeatureRegistrar,
@@ -25,7 +25,7 @@ import type {
25
25
  PreSaveHookFn,
26
26
  ReferenceFieldDef,
27
27
  } from "./types";
28
- import { HookPhases } from "./types";
28
+ import { HookPhases, isRateLimitDisabled } from "./types";
29
29
 
30
30
  function allHandlerQns(state: RegistryState): ReadonlySet<string> {
31
31
  return new Set([...state.writeHandlerMap.keys(), ...state.queryHandlerMap.keys()]);
@@ -721,10 +721,18 @@ export function validateExtensionUsageTargets(state: RegistryState): void {
721
721
  export function computeHasRateLimitedHandler(state: RegistryState): void {
722
722
  // Pre-compute: any handler with a rateLimit option? Keeps the boot
723
723
  // path able to short-circuit the RateLimitResolver wiring (and its
724
- // Lua-script registration on Redis) when nobody opted in.
724
+ // Lua-script registration on Redis) when nobody opted in. Deliberately
725
+ // stays limited to explicit, non-disabled `rateLimit` declarations —
726
+ // `{ disabled: true }` must not auto-wire a resolver for an app that only
727
+ // opted OUT, and fw#2861's systemScope default only fires once a resolver
728
+ // already exists, so it must not force wantsL3 either.
725
729
  state.hasRateLimitedHandlerCached = (() => {
726
- for (const h of state.writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
727
- for (const h of state.queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
730
+ for (const h of state.writeHandlerMap.values()) {
731
+ if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
732
+ }
733
+ for (const h of state.queryHandlerMap.values()) {
734
+ if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
735
+ }
728
736
  return false;
729
737
  })();
730
738
  }
@@ -380,6 +380,12 @@ export function buildInsertSchema(
380
380
  shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
381
381
  }
382
382
 
383
+ // Caller-chosen aggregate id for idempotent creates (deterministic-id
384
+ // patterns). Accepting it here only lets it survive parsing — the create
385
+ // handler still honors it only for a system-identity caller (jobs/hooks
386
+ // via createSystemUser), never for an ordinary HTTP-authenticated user.
387
+ shape["id"] = z.uuid().optional();
388
+
383
389
  return applyTotalsMatchRefinements(entity, z.object(shape));
384
390
  }
385
391
 
@@ -11,10 +11,62 @@ import type {
11
11
  FormatSpec,
12
12
  ListColumnSpec,
13
13
  ProjectionDetailScreenDefinition,
14
+ RowAction,
14
15
  ScreenDefinition,
15
16
  SecretMintScreenDefinition,
17
+ ToolbarAction,
16
18
  } from "./types/screen";
17
19
 
20
+ // Every screen type carrying `listScreenId` declares it identically; centralizing
21
+ // here keeps the switch the only place that grows for a new screen type.
22
+ export function explicitListScreenId(screen: ScreenDefinition): string | undefined {
23
+ switch (screen.type) {
24
+ case "custom":
25
+ case "projectionDetail":
26
+ case "entityEdit":
27
+ case "actionForm":
28
+ case "secretMint":
29
+ return screen.listScreenId;
30
+ default:
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ // Shared by the renderer breadcrumb and the boot-validator so both resolve
36
+ // parents the same way; `getId` lets each caller normalize `screen.id`.
37
+ export function resolveNavParentScreen(
38
+ screens: readonly ScreenDefinition[],
39
+ detail: ScreenDefinition,
40
+ getId: (screen: ScreenDefinition) => string,
41
+ ): ScreenDefinition | undefined {
42
+ const detailScreenId = getId(detail);
43
+
44
+ const listFromExplicit = ((): ScreenDefinition | undefined => {
45
+ const explicitId = explicitListScreenId(detail);
46
+ return explicitId !== undefined ? screens.find((s) => getId(s) === explicitId) : undefined;
47
+ })();
48
+
49
+ // rowActions, toolbarActions, and drawer-kind actions (which mount inline
50
+ // and have no page of their own) all count as "reached from" this list.
51
+ const navigatesToDetail = (
52
+ actions: readonly (RowAction | ToolbarAction)[] | undefined,
53
+ ): boolean =>
54
+ (actions ?? []).some(
55
+ (a) => (a.kind === "navigate" || a.kind === "drawer") && a.screen === detailScreenId,
56
+ );
57
+ const listFromRowAction = screens.find((s) => {
58
+ if (s.type !== "entityList" && s.type !== "projectionList") return false;
59
+ return navigatesToDetail(s.rowActions) || navigatesToDetail(s.toolbarActions);
60
+ });
61
+
62
+ const listFromEntity =
63
+ detail.type === "entityEdit"
64
+ ? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
65
+ : undefined;
66
+
67
+ return listFromExplicit ?? listFromRowAction ?? listFromEntity;
68
+ }
69
+
18
70
  export type EditLayoutScreen =
19
71
  | ProjectionDetailScreenDefinition
20
72
  | EntityEditScreenDefinition
@@ -0,0 +1,12 @@
1
+ import type { DbRunner } from "../../db/connection";
2
+ import { type TenantDb, unsafeRawForDeclaredStep } from "../../db/tenant-db";
3
+ import type { PipelineCtx } from "../types/step";
4
+
5
+ // unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
6
+ export function readSourceFor(
7
+ ctx: PipelineCtx,
8
+ unsafeAllTenants: { readonly reason: string } | undefined,
9
+ ): TenantDb | DbRunner {
10
+ if (!unsafeAllTenants) return ctx.db;
11
+ return unsafeRawForDeclaredStep(ctx.systemDb ?? ctx.db, unsafeAllTenants.reason);
12
+ }
@@ -1,6 +1,8 @@
1
1
  // r.step.read.findMany — load multiple rows from a projection table.
2
2
  //
3
- // Sibling to read.findOne — same tenant-filter caveat (caller-owned).
3
+ // Sibling to read.findOne — same tenant-filtering: own tenant +
4
+ // SYSTEM_TENANT_ID reference rows by default, `unsafeAllTenants: { reason }`
5
+ // plus `escapeHatch: { reason }` on the handler to read across tenants.
4
6
  // Resolves to a row-array (possibly empty), landed under steps.<name>.
5
7
  //
6
8
  // Optional `limit` — defaults to no-limit (caller-chosen, NOT a
@@ -11,6 +13,7 @@
11
13
  import { selectMany, type WhereObject } from "../../db/query";
12
14
  import { defineStep } from "../define-step";
13
15
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
16
+ import { readSourceFor } from "./_read-source";
14
17
  import { resolveOptional } from "./_resolver-utils";
15
18
 
16
19
  type ReadFindManyArgs = {
@@ -18,6 +21,7 @@ type ReadFindManyArgs = {
18
21
  readonly table: unknown;
19
22
  readonly where?: StepResolver<WhereObject | undefined>;
20
23
  readonly limit?: number;
24
+ readonly unsafeAllTenants?: { readonly reason: string };
21
25
  };
22
26
 
23
27
  defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
@@ -26,8 +30,9 @@ defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
26
30
  resultKey: (args) => args.name,
27
31
  run: async (args, ctx: PipelineCtx) => {
28
32
  const where = resolveOptional(args.where, ctx);
33
+ const source = readSourceFor(ctx, args.unsafeAllTenants);
29
34
  const rows = await selectMany(
30
- ctx.db.raw,
35
+ source,
31
36
  args.table,
32
37
  where,
33
38
  args.limit !== undefined ? { limit: args.limit } : undefined,
@@ -42,6 +47,7 @@ export function buildReadFindManyStep(
42
47
  readonly table: unknown;
43
48
  readonly where?: StepResolver<WhereObject | undefined>;
44
49
  readonly limit?: number;
50
+ readonly unsafeAllTenants?: { readonly reason: string };
45
51
  },
46
52
  ): StepInstance {
47
53
  return {
@@ -1,13 +1,12 @@
1
1
  // r.step.read.findOne — load a single row from a projection table.
2
2
  //
3
3
  // Thin wrapper on selectMany(db, table, where, { limit: 1 }) (bun-db).
4
- // Resolves to the first row or null. Tenant-isolation: the caller's
5
- // `where` clause is responsible for any tenantId filter — read.findOne
6
- // does NOT auto-inject one (different from ctx.queryProjection which
7
- // does). That's deliberate: most read-step uses are aggregate-lookups
8
- // where the where-clause already pins a uuid that's globally unique;
9
- // auto-tenant-filtering would be redundant and would surprise users
10
- // who pass an explicit tenantId.
4
+ // Resolves to the first row or null. Tenant-filtered like ctx.db
5
+ // method-form reads (own tenant + SYSTEM_TENANT_ID reference rows); a
6
+ // foreign `where.tenantId` is narrowed to the caller's own scope.
7
+ // Cross-tenant reads require `unsafeAllTenants: { reason }` here AND
8
+ // `escapeHatch: { reason }` on the handler — reported as an "unsafe-raw"
9
+ // escape-hatch audit event.
11
10
  //
12
11
  // Use when a subsequent step needs a row from the read-side. For
13
12
  // cross-feature reads, prefer `r.step.callFeature(...)` (M.2) so the
@@ -23,12 +22,14 @@
23
22
  import { selectMany, type WhereObject } from "../../db/query";
24
23
  import { defineStep } from "../define-step";
25
24
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
25
+ import { readSourceFor } from "./_read-source";
26
26
  import { resolveRequired } from "./_resolver-utils";
27
27
 
28
28
  type ReadFindOneArgs = {
29
29
  readonly name: string;
30
30
  readonly table: unknown;
31
31
  readonly where: StepResolver<WhereObject | undefined>;
32
+ readonly unsafeAllTenants?: { readonly reason: string };
32
33
  };
33
34
 
34
35
  defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
@@ -37,7 +38,8 @@ defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
37
38
  resultKey: (args) => args.name,
38
39
  run: async (args, ctx: PipelineCtx) => {
39
40
  const where = resolveRequired(args.where, ctx);
40
- const rows = await selectMany(ctx.db.raw, args.table, where, { limit: 1 });
41
+ const source = readSourceFor(ctx, args.unsafeAllTenants);
42
+ const rows = await selectMany(source, args.table, where, { limit: 1 });
41
43
  return (rows[0] as Record<string, unknown> | undefined) ?? null;
42
44
  },
43
45
  });
@@ -47,6 +49,7 @@ export function buildReadFindOneStep(
47
49
  opts: {
48
50
  readonly table: unknown;
49
51
  readonly where: StepResolver<WhereObject | undefined>;
52
+ readonly unsafeAllTenants?: { readonly reason: string };
50
53
  },
51
54
  ): StepInstance {
52
55
  return {
@@ -18,6 +18,7 @@
18
18
 
19
19
  import type { EntityTableMeta } from "../../db/entity-table-meta";
20
20
  import { deleteMany, type WhereObject } from "../../db/query";
21
+ import { tenantDbRunner } from "../../db/tenant-db-runner";
21
22
  import { defineStep } from "../define-step";
22
23
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
23
24
  import { resolveRequired } from "./_resolver-utils";
@@ -40,7 +41,7 @@ defineStep<UnsafeProjectionDeleteArgs, void>({
40
41
  // The `unsafe*`-step IS the sanctioned direct projection write — it exists
41
42
  // to bypass the executor deliberately (boot-validated allowlist + reviewer
42
43
  // gate). The brand-strip cast makes that explicit at the one blessed seam.
43
- await deleteMany(ctx.db.raw, args.table as EntityTableMeta, where);
44
+ await deleteMany(tenantDbRunner(ctx.db), args.table as EntityTableMeta, where);
44
45
  },
45
46
  });
46
47
 
@@ -14,6 +14,7 @@
14
14
  import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
15
15
  import { extractTableName } from "../../db";
16
16
  import { executeRawQuery } from "../../db/queries/raw-sql";
17
+ import { tenantDbRunner } from "../../db/tenant-db-runner";
17
18
  import { defineStep } from "../define-step";
18
19
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
19
20
  import { resolveRequired } from "./_resolver-utils";
@@ -78,7 +79,7 @@ defineStep<UnsafeProjectionUpsertArgs, void>({
78
79
  `INSERT INTO ${quoteIdent(tableName)} (${colNames.join(", ")}) VALUES (${placeholders.join(", ")}) ` +
79
80
  `ON CONFLICT (${conflictCols}) DO UPDATE SET ${setClauses.join(", ")}`;
80
81
 
81
- await executeRawQuery(ctx.db.raw, sqlText, params);
82
+ await executeRawQuery(tenantDbRunner(ctx.db), sqlText, params);
82
83
  },
83
84
  });
84
85
 
@@ -159,7 +159,12 @@ export type {
159
159
  DeclarativeEventMigration,
160
160
  DispatchWriteRef,
161
161
  EntityRef,
162
+ EscapeHatchAuditSink,
162
163
  EscapeHatchDeclaration,
164
+ EscapeHatchKind,
165
+ EscapeHatchReporter,
166
+ EscapeHatchTarget,
167
+ EscapeHatchUseEvent,
163
168
  EventDef,
164
169
  EventMigrationDef,
165
170
  EventPiiFields,
@@ -184,6 +189,8 @@ export type {
184
189
  QueryEvent,
185
190
  QueryHandlerDef,
186
191
  QueryHandlerFn,
192
+ RateLimitDeclaration,
193
+ RateLimitDisabled,
187
194
  RateLimitOption,
188
195
  RateLimitPer,
189
196
  SessionUser,
@@ -198,7 +205,7 @@ export type {
198
205
  WriteHandlerFn,
199
206
  WriteResult,
200
207
  } from "@cosmicdrift/kumiko-types/handlers";
201
- export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
208
+ export { isOpenToAllGranted, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
202
209
  export type {
203
210
  DeleteContext,
204
211
  EntityHookMap,
@@ -113,6 +113,20 @@ identity_switch_denied:
113
113
  (oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
114
114
  oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
115
115
 
116
+ unsafe_all_tenants_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`: ein Handler oder Hook hat
122
+ `ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })`
123
+ aufgerufen, ohne `r.systemScope()`-Handler zu sein oder
124
+ `escapeHatch: { reason }` zu deklarieren.
125
+
126
+ Deklariere `escapeHatch: { reason: "..." }` am Write-, Query- oder
127
+ Stream-Handler (oder am `r.hook(...)`-Aufruf, wenn der Read in einem Hook
128
+ passiert), oder verlagere die Logik in ein `r.systemScope()`-Feature.
129
+
116
130
  member_resolution_denied:
117
131
  endUser: |
118
132
  Diese Aktion ist nicht erlaubt.
@@ -109,6 +109,19 @@ identity_switch_denied:
109
109
  (or on the `r.hook(...)` call, if the switch happens inside a hook), or
110
110
  move the logic into an `r.systemScope()` feature or a job.
111
111
 
112
+ unsafe_all_tenants_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`: a handler or hook called
118
+ `ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })` without
119
+ being an `r.systemScope()` handler or declaring `escapeHatch: { reason }`.
120
+
121
+ Declare `escapeHatch: { reason: "..." }` on the write, query or stream
122
+ handler (or on the `r.hook(...)` call, if the read happens inside a
123
+ hook), or move the logic into an `r.systemScope()` feature.
124
+
112
125
  member_resolution_denied:
113
126
  endUser: |
114
127
  This action isn't allowed.
@@ -48,6 +48,10 @@ export const FrameworkReasons = {
48
48
  // claims, origin or extra roles) without r.systemScope() or escapeHatch.
49
49
  identitySwitchDenied: "identity_switch_denied",
50
50
 
51
+ // AccessDeniedError: a handler/hook called ctx.queryProjection(..., { unsafeAllTenants: true })
52
+ // without an r.systemScope() feature or a declared escapeHatch.
53
+ unsafeAllTenantsDenied: "unsafe_all_tenants_denied",
54
+
51
55
  // AccessDeniedError: ctx.queryAsMember's userId is not an active member of
52
56
  // ctx's tenant. Deliberately generic — a caller can't probe which check failed.
53
57
  memberResolutionDenied: "member_resolution_denied",
@@ -196,7 +196,9 @@ export function requiredKeysFromScreen(
196
196
  const form = screen as ActionFormScreenDefinition;
197
197
  pushKey(out, form.submitLabel);
198
198
  for (const fieldName of Object.keys(form.fields)) {
199
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
199
+ const override = form.fieldLabels?.[fieldName];
200
+ if (override !== undefined) pushKey(out, override);
201
+ else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
200
202
  }
201
203
  for (const section of form.layout.sections) {
202
204
  if (isExtensionEditSection(section)) {
@@ -207,7 +209,9 @@ export function requiredKeysFromScreen(
207
209
  pushKey(out, section.title);
208
210
  for (const f of section.fields) {
209
211
  const fieldName = editFieldName(f);
210
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
212
+ const override = form.fieldLabels?.[fieldName];
213
+ if (override !== undefined) pushKey(out, override);
214
+ else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
211
215
  }
212
216
  }
213
217
  break;
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
3
+ import { tenantDbRunner } from "../../db/tenant-db-runner";
3
4
  import { createRegistry, defineFeature, type Registry } from "../../engine";
4
5
  import { createTestRedis, type TestRedis, testTenantId } from "../../stack";
5
6
  import { waitFor } from "../../testing";
@@ -12,9 +13,9 @@ import { createJobRunner, type JobRunner } from "../job-runner";
12
13
  type JobRunResult = {
13
14
  readonly name: "system" | "tenant" | "system-per-tenant";
14
15
  readonly present: boolean;
15
- // assertTenantMatch() must return a TenantDb whose `.raw` is the SAME
16
- // underlying DbConnection ctx.db carries — proves systemDb is bound to
17
- // the job's own tenant-scoped db, not a separate instance.
16
+ // assertTenantMatch() must return a TenantDb bound (via tenantDbRunner) to
17
+ // the SAME underlying DbConnection ctx.db carries — proves systemDb is
18
+ // bound to the job's own tenant-scoped db, not a separate instance.
18
19
  readonly boundToRawDb: boolean | undefined;
19
20
  // A foreign tenantId must throw fail-closed (AccessDeniedError), same as
20
21
  // the HandlerContext.systemDb self-check.
@@ -48,7 +49,7 @@ const systemScopedFeature = defineFeature("jobsystemdb-system", (r) => {
48
49
  results.push({
49
50
  name: "system",
50
51
  present: true,
51
- boundToRawDb: checked.raw === ctx.db,
52
+ boundToRawDb: tenantDbRunner(checked) === ctx.db,
52
53
  foreignTenantThrew,
53
54
  });
54
55
  });
@@ -31,6 +31,10 @@ let redisUrl: string;
31
31
  // Track which jobs ran and when
32
32
  const jobLog: Array<{ name: string; payload: Record<string, unknown>; timestamp: number }> = [];
33
33
 
34
+ // Counts eventRetrySucceeds' own attempts — throws once on attempt 1, then
35
+ // succeeds, so a real retry must have happened for the job to ever reach 2.
36
+ let eventRetryAttemptCount = 0;
37
+
34
38
  function clearLog() {
35
39
  jobLog.length = 0;
36
40
  }
@@ -140,6 +144,25 @@ const testFeature = defineFeature("test", (r) => {
140
144
  },
141
145
  );
142
146
 
147
+ r.writeHandler(
148
+ "retry-event-trigger",
149
+ z.object({ n: z.number() }),
150
+ async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
151
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
152
+ );
153
+ // Event-triggered job with retries — proves handleEvent passes
154
+ // retries/backoff to the queue the same way dispatch() does (fw
155
+ // job-runner retries regression, solon).
156
+ r.job(
157
+ "eventRetrySucceeds",
158
+ { trigger: { on: "test:write:retry-event-trigger" }, retries: 2 },
159
+ async (payload) => {
160
+ eventRetryAttemptCount += 1;
161
+ jobLog.push({ name: "test:job:event-retry-succeeds", payload, timestamp: Date.now() });
162
+ if (eventRetryAttemptCount === 1) throw new Error("fails on first attempt");
163
+ },
164
+ );
165
+
143
166
  // Job that fails
144
167
  r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
145
168
  throw new Error("intentional failure");
@@ -1197,6 +1220,24 @@ describe("handleEvent maxPerTenant", () => {
1197
1220
  });
1198
1221
  });
1199
1222
 
1223
+ describe("handleEvent retries", () => {
1224
+ test("an event-triggered job with retries retries on failure and eventually succeeds", async () => {
1225
+ clearLog();
1226
+ eventRetryAttemptCount = 0;
1227
+ await withRunner(async (runner) => {
1228
+ const user = { ...TestUsers.admin, tenantId: "retry-tenant-evt" };
1229
+ await runner.handleEvent("test:write:retry-event-trigger", { n: 1 }, user);
1230
+ // Without retries/backoff reaching the queue, the job fails on
1231
+ // attempt 1 and BullMQ never retries — this would time out.
1232
+ await waitFor(() => {
1233
+ const entries = jobLog.filter((e) => e.name === "test:job:event-retry-succeeds");
1234
+ expect(entries.length).toBe(2);
1235
+ });
1236
+ });
1237
+ expect(eventRetryAttemptCount).toBe(2);
1238
+ });
1239
+ });
1240
+
1200
1241
  // --- Registry ---
1201
1242
 
1202
1243
  describe("job registry", () => {
@@ -9,6 +9,7 @@ import {
9
9
  type AppContext,
10
10
  type DispatchWriteRef,
11
11
  type JobContext,
12
+ type JobDefinition,
12
13
  type JobRunIn,
13
14
  type MemberReader,
14
15
  type Registry,
@@ -27,6 +28,7 @@ import {
27
28
  type Tracer,
28
29
  } from "../observability";
29
30
  import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
31
+ import { createEscapeHatchReporter } from "../pipeline/escape-hatch-report";
30
32
  import { RedisKeys } from "../pipeline/redis-keys";
31
33
  import { bridgeStub } from "../testing/handler-context";
32
34
 
@@ -251,6 +253,16 @@ function timeoutReject(
251
253
  };
252
254
  }
253
255
 
256
+ // Shared by dispatch() and handleEvent() — an event-triggered job must retry
257
+ // on failure the same way a directly-dispatched one does; a duplicated
258
+ // inline computation in handleEvent previously dropped both options.
259
+ function buildRetryBullOpts(jobDef: JobDefinition): Record<string, unknown> {
260
+ const opts: Record<string, unknown> = {};
261
+ if (jobDef.retries !== undefined) opts["attempts"] = jobDef.retries + 1;
262
+ if (jobDef.backoff) opts["backoff"] = { type: jobDef.backoff };
263
+ return opts;
264
+ }
265
+
254
266
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
255
267
  const { registry, context, redisUrl, consumerLane } = options;
256
268
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
@@ -282,12 +294,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
282
294
  return async () => {
283
295
  const systemUser = createSystemUser(SYSTEM_TENANT_ID);
284
296
  const systemModeDb = createTenantDb(db, SYSTEM_TENANT_ID, "system");
297
+ const reportEscapeHatch = createEscapeHatchReporter({
298
+ handler: ACTIVE_TENANT_IDS_QUERY_NAME,
299
+ tenantId: SYSTEM_TENANT_ID,
300
+ actor: systemUser.id,
301
+ sink: context._escapeHatchAuditSink,
302
+ log: context.log,
303
+ });
285
304
  const result = await handler.handler(
286
305
  { type: ACTIVE_TENANT_IDS_QUERY_NAME, payload: {}, user: systemUser },
287
306
  {
288
307
  db: systemModeDb,
289
308
  dbOutsideTransaction: systemModeDb,
290
- systemDb: createUncheckedSystemDb(systemModeDb),
309
+ systemDb: createUncheckedSystemDb(systemModeDb, undefined, reportEscapeHatch),
291
310
  registry,
292
311
  ...bridgeStub(),
293
312
  },
@@ -533,7 +552,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
533
552
  const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
534
553
  const isSystemJob = registry.isJobSystemScoped(jobName);
535
554
  const systemDb =
536
- isSystemJob && tenantScopedDb ? createUncheckedSystemDb(tenantScopedDb) : undefined;
555
+ isSystemJob && tenantScopedDb
556
+ ? createUncheckedSystemDb(
557
+ tenantScopedDb,
558
+ undefined,
559
+ createEscapeHatchReporter({
560
+ handler: jobName,
561
+ tenantId,
562
+ actor: jobSystemUser.id,
563
+ sink: context._escapeHatchAuditSink,
564
+ log: context.log,
565
+ }),
566
+ )
567
+ : undefined;
537
568
  const config =
538
569
  context._configAccessorFactory && tenantScopedDb
539
570
  ? context._configAccessorFactory({
@@ -883,8 +914,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
883
914
  break;
884
915
  }
885
916
 
886
- if (jobDef.retries !== undefined) bullOpts["attempts"] = jobDef.retries + 1;
887
- if (jobDef.backoff) bullOpts["backoff"] = { type: jobDef.backoff };
917
+ Object.assign(bullOpts, buildRetryBullOpts(jobDef));
888
918
  if (jobDef.timeout) bullOpts["timeout"] = jobDef.timeout;
889
919
  if (meta?.priority !== undefined) bullOpts["priority"] = meta.priority;
890
920
 
@@ -948,7 +978,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
948
978
  }
949
979
  // Route to the job's declared lane, not a fixed queue — that's
950
980
  // the whole reason both queues are held.
951
- await queues[laneForJob(jobDef)].add(name, data);
981
+ await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
952
982
  }
953
983
  },
954
984
  attachDispatcher(ref: DispatchWriteRef): void {