@cosmicdrift/kumiko-framework 0.351.0 → 0.353.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 (66) hide show
  1. package/dist/api/pii-leak-guard.js +3 -0
  2. package/dist/api/route-registrars.d.ts +2 -1
  3. package/dist/api/route-registrars.js +8 -0
  4. package/dist/api/server.js +11 -3
  5. package/dist/api/sse-route.js +11 -1
  6. package/dist/bun-db/query.js +7 -2
  7. package/dist/compliance/sub-processors.js +5 -0
  8. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  9. package/dist/crypto/pii-field-encryption.js +2 -3
  10. package/dist/db/entity-table-meta.js +11 -0
  11. package/dist/db/event-store-executor-context.js +20 -25
  12. package/dist/db/migrate-generator.js +9 -1
  13. package/dist/db/queries/backfill-pii.d.ts +1 -0
  14. package/dist/db/queries/backfill-pii.js +13 -0
  15. package/dist/db/queries/seed-context.d.ts +17 -0
  16. package/dist/db/queries/seed-context.js +26 -0
  17. package/dist/db/queries/shadow-swap.js +5 -2
  18. package/dist/engine/boot-validator/access-declarations.js +16 -1
  19. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  20. package/dist/engine/boot-validator/entity-handler.js +30 -0
  21. package/dist/engine/boot-validator/index.js +2 -1
  22. package/dist/engine/boot-validator/query-refs.js +7 -2
  23. package/dist/engine/boot-validator/screens.js +60 -22
  24. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  25. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  26. package/dist/engine/build-app-schema.js +33 -2
  27. package/dist/engine/build-config-feature-schema.js +6 -0
  28. package/dist/engine/dedupe-features.js +17 -1
  29. package/dist/engine/entity-handlers.js +15 -6
  30. package/dist/engine/feature-builder-state.d.ts +2 -7
  31. package/dist/engine/feature-config-events-jobs.js +3 -0
  32. package/dist/engine/feature-entity-handlers.js +1 -1
  33. package/dist/engine/i18n-key.js +14 -3
  34. package/dist/engine/index.d.ts +1 -1
  35. package/dist/engine/index.js +1 -1
  36. package/dist/engine/pipeline.js +18 -1
  37. package/dist/engine/project-app-schema-for-roles.js +8 -2
  38. package/dist/engine/role-assignment.d.ts +2 -0
  39. package/dist/engine/role-assignment.js +2 -0
  40. package/dist/engine/schema-builder.js +8 -0
  41. package/dist/engine/where-rule-lint.js +6 -3
  42. package/dist/env/index.d.ts +4 -0
  43. package/dist/env/index.js +4 -0
  44. package/dist/es-ops/context.js +34 -2
  45. package/dist/es-ops/index.d.ts +1 -1
  46. package/dist/es-ops/types.d.ts +20 -0
  47. package/dist/event-store/index.d.ts +1 -0
  48. package/dist/event-store/index.js +1 -0
  49. package/dist/event-store/tenant-db-append.d.ts +8 -0
  50. package/dist/event-store/tenant-db-append.js +16 -0
  51. package/dist/files/file-ref-entity.d.ts +6 -0
  52. package/dist/files/file-ref-table.d.ts +6 -0
  53. package/dist/files/file-routes.js +3 -3
  54. package/dist/files/types.js +21 -1
  55. package/dist/observability/metrics-wiring.d.ts +1 -1
  56. package/dist/observability/metrics-wiring.js +2 -2
  57. package/dist/pipeline/dispatch-batch.js +5 -1
  58. package/dist/pipeline/dispatch-write.js +36 -82
  59. package/dist/pipeline/member-reader.js +1 -0
  60. package/dist/pipeline/pre-handler-gates.d.ts +9 -0
  61. package/dist/pipeline/pre-handler-gates.js +104 -0
  62. package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
  63. package/dist/pipeline/pre-transaction-reservations.js +34 -21
  64. package/dist/testing/shared-entities.d.ts +10 -0
  65. package/package.json +4 -4
  66. package/src/changes.json +57 -0
@@ -6,7 +6,8 @@
6
6
  // SystemUser bypassed Access-Checks (Standard-Seed-Pattern, siehe
7
7
  // config-seed.ts:40). Events haben createdBy = SYSTEM_TENANT_ID-User
8
8
  // → audit-fähig.
9
- import { selectAllTenants, selectMembershipsOfUser, selectUserByEmail, } from "../db/queries/seed-context.js";
9
+ import { configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto/index.js";
10
+ import { selectAllTenants, selectMembershipsOfUser, selectTemplateResources, selectUserByEmail, } from "../db/queries/seed-context.js";
10
11
  import { createSystemUser, SYSTEM_TENANT_ID } from "../engine/index.js";
11
12
  import { parseStringArrayJson } from "../utils/parse-string-array-json.js";
12
13
  /** Builder: gibt eine factory-function zurück die der Runner pro-Migration
@@ -63,7 +64,38 @@ export function createSeedMigrationContext(args) {
63
64
  },
64
65
  findTenants: async () => {
65
66
  const rows = await selectAllTenants(args.dbRunner);
66
- return rows.map((r) => ({ id: r.id, name: r.name, tenantKey: r.tenant_key }));
67
+ const kms = configuredPiiSubjectKms();
68
+ // Sequential on purpose: each decrypt borrows from the KMS adapter's small pool.
69
+ const tenants = [];
70
+ for (const r of rows) {
71
+ // tenant.name is ciphertext at rest under an active KMS
72
+ const decrypted = kms
73
+ ? await decryptPiiFieldValues({ name: r.name }, ["name"], kms, {
74
+ requestId: "es-ops:find-tenants",
75
+ })
76
+ : { name: r.name };
77
+ const name = decrypted["name"];
78
+ tenants.push({
79
+ id: r.id,
80
+ name: typeof name === "string" ? name : r.name,
81
+ tenantKey: r.tenant_key,
82
+ });
83
+ }
84
+ return tenants;
85
+ },
86
+ findTemplateResources: async (filter) => {
87
+ const rows = await selectTemplateResources(args.dbRunner, {
88
+ ...filter,
89
+ tenantId: filter?.tenantId ?? SYSTEM_TENANT_ID,
90
+ });
91
+ return rows.map((r) => ({
92
+ id: r.id,
93
+ tenantId: r.tenant_id,
94
+ slug: r.slug,
95
+ kind: r.kind,
96
+ locale: r.locale,
97
+ status: r.status,
98
+ }));
67
99
  },
68
100
  db: args.dbRunner,
69
101
  };
@@ -1,4 +1,4 @@
1
1
  export { type CreateSeedMigrationContextArgs, createSeedMigrationContext, } from "./context.js";
2
2
  export { createEsOperationsTable, type EsOperationAppliedBy, type EsOperationType, esOperationsTable, } from "./operations-schema.js";
3
3
  export { type RunPendingSeedMigrationsArgs, type RunPendingSeedMigrationsResult, runPendingSeedMigrations, } from "./runner.js";
4
- export type { SeedMembershipRow, SeedMigration, SeedMigrationContext, SeedTenantRow, SeedUserRow, } from "./types.js";
4
+ export type { SeedMembershipRow, SeedMigration, SeedMigrationContext, SeedTemplateResourceFilter, SeedTemplateResourceRow, SeedTenantRow, SeedUserRow, } from "./types.js";
@@ -51,6 +51,23 @@ export type SeedTenantRow = {
51
51
  readonly name: string;
52
52
  readonly tenantKey: string;
53
53
  };
54
+ /** Read-shape of a template-resolver row as exposed to seeds. */
55
+ export type SeedTemplateResourceRow = {
56
+ readonly id: string;
57
+ readonly tenantId: string;
58
+ readonly slug: string;
59
+ readonly kind: string;
60
+ readonly locale: string;
61
+ readonly status: string;
62
+ };
63
+ export type SeedTemplateResourceFilter = {
64
+ /** Defaults to the system tenant, where seeded system templates live. */
65
+ readonly tenantId?: TenantId;
66
+ readonly slug?: string;
67
+ readonly kind?: string;
68
+ readonly status?: string;
69
+ readonly locale?: string;
70
+ };
54
71
  export type SeedMigrationContext = {
55
72
  /** Event-Store-konformer Write via existing write-handler. System-User
56
73
  * als Executor bypassed Access-Check (Standard-Seed-Pattern). Events
@@ -84,6 +101,9 @@ export type SeedMigrationContext = {
84
101
  readonly findUserByEmail: (email: string) => Promise<SeedUserRow | null>;
85
102
  readonly findMembershipsOfUser: (userId: string) => Promise<readonly SeedMembershipRow[]>;
86
103
  readonly findTenants: () => Promise<readonly SeedTenantRow[]>;
104
+ /** Template rows ordered by slug, locale. Empty list when the
105
+ * template-resolver feature (its table) is not present. */
106
+ readonly findTemplateResources: (filter?: SeedTemplateResourceFilter) => Promise<readonly SeedTemplateResourceRow[]>;
87
107
  /** Escape-Hatch — direkter DB-Zugang. Nur für READ-only Lookups die der
88
108
  * Context nicht standard-mäßig anbietet. WRITES via systemWriteAs!
89
109
  * Type ist DbRunner (Connection oder aktive Tx) weil der Runner den
@@ -9,6 +9,7 @@ export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadL
9
9
  export { toStoredEvent } from "./row-to-stored-event.js";
10
10
  export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
11
11
  export { createSnapshotsTable, type LoadAggregateWithSnapshotOptions, type LoadAggregateWithSnapshotResult, loadAggregateWithSnapshot, loadLatestSnapshot, type SaveSnapshotArgs, type Snapshot, type SnapshotReducer, saveSnapshot, snapshotsTable, } from "./snapshot.js";
12
+ export { type AppendEventInTenantDbOptions, appendEventInTenantDb, getStreamVersionInTenantDb, } from "./tenant-db-append.js";
12
13
  export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, type TransferAggregateStreamsArgs, transferAggregateStreams, } from "./transfer.js";
13
14
  export { type EventUpcasters, makeUpcastCtx, type UpcasterErrorPolicy, type UpcastOptions, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
14
15
  export { createUpcasterDeadLetterTable, type DeadLetterRow, listDeadLetters, recordUpcasterDeadLetter, upcasterDeadLetterTable, } from "./upcaster-dead-letter.js";
@@ -9,6 +9,7 @@ export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetter
9
9
  export { toStoredEvent } from "./row-to-stored-event.js";
10
10
  export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
11
11
  export { createSnapshotsTable, loadAggregateWithSnapshot, loadLatestSnapshot, saveSnapshot, snapshotsTable, } from "./snapshot.js";
12
+ export { appendEventInTenantDb, getStreamVersionInTenantDb, } from "./tenant-db-append.js";
12
13
  export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, transferAggregateStreams, } from "./transfer.js";
13
14
  export { makeUpcastCtx, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
14
15
  export { createUpcasterDeadLetterTable, listDeadLetters, recordUpcasterDeadLetter, upcasterDeadLetterTable, } from "./upcaster-dead-letter.js";
@@ -0,0 +1,8 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
+ import type { Registry } from "../engine/types/index.js";
3
+ import { type EventToAppend, type StoredEvent } from "./event-store.js";
4
+ export type AppendEventInTenantDbOptions = {
5
+ readonly registry?: Registry;
6
+ };
7
+ export declare function appendEventInTenantDb(tenantDb: TenantDb, event: Omit<EventToAppend, "tenantId">, options?: AppendEventInTenantDbOptions): Promise<StoredEvent>;
8
+ export declare function getStreamVersionInTenantDb(tenantDb: TenantDb, aggregateId: string): Promise<number>;
@@ -0,0 +1,16 @@
1
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
2
+ import { runProjectionsForEvent } from "../pipeline/projections-runner.js";
3
+ import { append, getStreamVersion } from "./event-store.js";
4
+ // Appends on the connection or transaction the TenantDb is bound to. The tenant is always the
5
+ // TenantDb's own, so a caller cannot write into another tenant's stream; a TenantDb the framework
6
+ // did not build (or a fail-closed systemScope guard) is rejected by tenantDbRunner.
7
+ export async function appendEventInTenantDb(tenantDb, event, options = {}) {
8
+ const runner = tenantDbRunner(tenantDb);
9
+ const stored = await append(runner, { ...event, tenantId: tenantDb.tenantId });
10
+ if (options.registry)
11
+ await runProjectionsForEvent(stored, options.registry, runner);
12
+ return stored;
13
+ }
14
+ export async function getStreamVersionInTenantDb(tenantDb, aggregateId) {
15
+ return getStreamVersion(tenantDbRunner(tenantDb), aggregateId, tenantDb.tenantId);
16
+ }
@@ -3,6 +3,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -78,6 +81,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
78
81
  readonly type: "text";
79
82
  readonly description?: string;
80
83
  readonly maxLength?: number;
84
+ readonly minLength?: number;
81
85
  readonly required?: boolean;
82
86
  readonly searchable?: boolean;
83
87
  readonly sortable?: boolean;
@@ -99,6 +103,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
99
103
  readonly type: "text";
100
104
  readonly description?: string;
101
105
  readonly maxLength?: number;
106
+ readonly minLength?: number;
102
107
  readonly required?: boolean;
103
108
  readonly searchable?: boolean;
104
109
  readonly sortable?: boolean;
@@ -120,6 +125,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
120
125
  readonly type: "text";
121
126
  readonly description?: string;
122
127
  readonly maxLength?: number;
128
+ readonly minLength?: number;
123
129
  readonly required?: boolean;
124
130
  readonly searchable?: boolean;
125
131
  readonly sortable?: boolean;
@@ -3,6 +3,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -78,6 +81,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
78
81
  readonly type: "text";
79
82
  readonly description?: string;
80
83
  readonly maxLength?: number;
84
+ readonly minLength?: number;
81
85
  readonly required?: boolean;
82
86
  readonly searchable?: boolean;
83
87
  readonly sortable?: boolean;
@@ -99,6 +103,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
99
103
  readonly type: "text";
100
104
  readonly description?: string;
101
105
  readonly maxLength?: number;
106
+ readonly minLength?: number;
102
107
  readonly required?: boolean;
103
108
  readonly searchable?: boolean;
104
109
  readonly sortable?: boolean;
@@ -120,6 +125,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
120
125
  readonly type: "text";
121
126
  readonly description?: string;
122
127
  readonly maxLength?: number;
128
+ readonly minLength?: number;
123
129
  readonly required?: boolean;
124
130
  readonly searchable?: boolean;
125
131
  readonly sortable?: boolean;
@@ -47,7 +47,7 @@ function resolveAttachedField(registry, entityType, fieldName) {
47
47
  return { kind: "unattached" };
48
48
  const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
49
49
  const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
50
- if (!entityType || !fieldName || !entity || !fieldDef)
50
+ if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
51
51
  return { kind: "unresolvable" };
52
52
  return { kind: "resolved", fieldDef };
53
53
  }
@@ -168,10 +168,10 @@ export function createFileRoutes(options) {
168
168
  const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
169
169
  if (attachedField.kind === "unresolvable") {
170
170
  return c.json({
171
- error: "unresolvable_field: entityType/fieldName must resolve to a registered entity field, or both must be omitted for an unattached upload",
171
+ error: "unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload",
172
172
  }, 400);
173
173
  }
174
- if (attachedField.kind === "resolved" && isFileField(attachedField.fieldDef)) {
174
+ if (attachedField.kind === "resolved") {
175
175
  if (attachedField.fieldDef.maxSize)
176
176
  maxSize = attachedField.fieldDef.maxSize;
177
177
  if (attachedField.fieldDef.accept)
@@ -75,6 +75,7 @@ function extensionsForMimeType(mimeType) {
75
75
  // application/octet-stream in resolveServedContentType below — this also
76
76
  // guarantees text/html and image/svg+xml are never served inline, even for
77
77
  // an honestly-declared upload.
78
+ const DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
78
79
  const MAGIC_BYTE_SIGNATURES = [
79
80
  {
80
81
  mimeType: "image/png",
@@ -99,10 +100,22 @@ const MAGIC_BYTE_SIGNATURES = [
99
100
  // ZIP local-file-header signature — .docx/.xlsx/.pptx/plain .zip all share
100
101
  // it; same caveat as OLE above.
101
102
  {
102
- mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
103
+ mimeType: DOCX_MIME_TYPE,
103
104
  matches: (bytes) => startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]),
104
105
  },
105
106
  ];
107
+ // ZIP entry names are stored uncompressed in the local and central headers,
108
+ // so a byte search finds the docx part directory without unzipping. The main
109
+ // part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
110
+ const DOCX_PART_PREFIX = "word/";
111
+ function containsAscii(bytes, ascii) {
112
+ const first = ascii.charCodeAt(0);
113
+ for (let i = bytes.indexOf(first); i !== -1; i = bytes.indexOf(first, i + 1)) {
114
+ if (startsWithAscii(bytes, ascii, i))
115
+ return true;
116
+ }
117
+ return false;
118
+ }
106
119
  function startsWithBytes(bytes, signature) {
107
120
  if (bytes.length < signature.length)
108
121
  return false;
@@ -220,6 +233,13 @@ export function validateFileContent(fileName, content, accept) {
220
233
  if (signatureMimeTypes.length === 0)
221
234
  return { kind: "ok" };
222
235
  const sniffed = sniffMimeType(content);
236
+ // sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
237
+ if (sniffed === DOCX_MIME_TYPE && !containsAscii(content, DOCX_PART_PREFIX)) {
238
+ return {
239
+ kind: "rejected",
240
+ error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
241
+ };
242
+ }
223
243
  if (sniffed && signatureMimeTypes.includes(sniffed))
224
244
  return { kind: "ok" };
225
245
  if (sniffed) {
@@ -2,7 +2,7 @@ import * as z from "zod";
2
2
  import { type PrometheusMeter } from "./prometheus-meter.js";
3
3
  import type { ObservabilityProvider } from "./types/index.js";
4
4
  export declare const prometheusMetricsEnvSchema: z.ZodObject<{
5
- PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodString>;
5
+ PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodLiteral<"">, z.ZodTransform<undefined, "">>, z.ZodString]>>;
6
6
  }, z.core.$strip>;
7
7
  type PrometheusObservabilityProvider = ObservabilityProvider & {
8
8
  readonly meter: PrometheusMeter;
@@ -2,9 +2,9 @@ import * as z from "zod";
2
2
  import { createNoopProvider } from "./noop-provider.js";
3
3
  import { createPrometheusMeter } from "./prometheus-meter.js";
4
4
  export const prometheusMetricsEnvSchema = z.object({
5
+ // An empty value (blank .env line, empty k8s secret) means "unset", not "invalid".
5
6
  PROMETHEUS_METRICS_TOKEN: z
6
- .string()
7
- .min(32)
7
+ .union([z.literal("").transform(() => undefined), z.string().min(32)])
8
8
  .optional()
9
9
  .describe("Bearer token for /metrics; unset keeps the endpoint off.")
10
10
  .meta({ kumiko: { pulumi: { secret: true, generator: "openssl rand -base64 32" } } }),
@@ -18,12 +18,16 @@ function rewrapHooksWithOrigin(afterCommitHooks, fromIndex, origin) {
18
18
  afterCommitHooks[i] = () => runWithWriteOrigin(origin, original);
19
19
  }
20
20
  }
21
+ function toReservedScope(reservations, index, cmd) {
22
+ const reservation = reservations.reservedCommands.get(index);
23
+ return reservation && { type: cmd.type, reservation };
24
+ }
21
25
  async function runReservedCommand(scope, cmd, index, tx) {
22
26
  const { ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks } = scope;
23
27
  const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
24
28
  origins.push(origin);
25
29
  const hookStart = afterCommitHooks.length;
26
- const res = await runAsCommand(reservations.reservedIndexes.has(index) ? cmd.type : undefined, () => runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
30
+ const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
27
31
  rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
28
32
  return res;
29
33
  }
@@ -3,18 +3,16 @@ import { asEntityTableMeta, selectMany } from "../db/query.js";
3
3
  import { buildEntityTable, toSnakeCase } from "../db/table-builder.js";
4
4
  import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
5
5
  import { tenantDbRunner } from "../db/tenant-db-runner.js";
6
- import { hasAccess } from "../engine/access.js";
7
6
  import { ConfigScopes } from "../engine/constants.js";
8
- import { checkWriteFieldOwnership, checkWriteFieldRoles, maskWriteOnlyFields, } from "../engine/field-access.js";
7
+ import { checkWriteFieldOwnership, maskWriteOnlyFields } from "../engine/field-access.js";
9
8
  import { defineTransitions, guardTransition } from "../engine/state-machine.js";
10
9
  import { HookPhases } from "../engine/types/index.js";
11
- import { runValidation } from "../engine/validation.js";
12
- import { AccessDeniedError, FrameworkReasons, InternalError, isKumikoError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
10
+ import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, writeFailure, } from "../errors/index.js";
13
11
  import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
14
12
  import { assertNoSecretLeak } from "../secrets/index.js";
15
- import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, checkFeatureEnabled, enforcePayloadRateLimits, enforceRateLimit, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
13
+ import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
16
14
  import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
17
- import { handlerAccessError } from "./handler-access-error.js";
15
+ import { runPreHandlerGates } from "./pre-handler-gates.js";
18
16
  import { consumeReservation } from "./pre-transaction-reservations.js";
19
17
  import { runProjections } from "./projections-runner.js";
20
18
  function getTable(ctx, entityName) {
@@ -306,6 +304,22 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
306
304
  }
307
305
  return parentResult;
308
306
  }
307
+ async function confirmReservation(reservation, tx, user) {
308
+ if (!reservation.confirmInTransaction)
309
+ return undefined;
310
+ if (!tx) {
311
+ return writeFailure(new InternalError({
312
+ message: "a reservation that settles in the transaction needs a transaction",
313
+ }));
314
+ }
315
+ try {
316
+ await reservation.confirmInTransaction({ tx, tenantId: user.tenantId });
317
+ }
318
+ catch (e) {
319
+ return writeFailure(wrapToKumiko(e));
320
+ }
321
+ return undefined;
322
+ }
309
323
  async function executeWriteInner(ctx, type, payload, user, origin, tx, afterCommitHooks) {
310
324
  const { registry, jobRunner } = ctx;
311
325
  const handler = registry.getWriteHandler(type);
@@ -316,87 +330,22 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
316
330
  if (isMemberResolutionPrincipal(user)) {
317
331
  return writeFailure(memberResolutionReadOnlyDenied());
318
332
  }
319
- // Feature-toggle gate: disabled handlers must short-circuit before any
320
- // rate-limit/access/validation work — see executeQueryInner comment.
321
- const disabledErr = await checkFeatureEnabled(ctx, type, user.tenantId);
322
- if (disabledErr)
323
- return writeFailure(disabledErr);
324
- // Rate-limit gate before access (same reasoning as in executeQueryInner).
325
- // Throws RateLimitError; the outer wrapper turns it into a 429
326
- // WriteFailure via toWriteErrorInfo. Apps that don't use L3 pay zero cost
327
- // for non-systemScope handlers with no rateLimit declared; systemScope
328
- // handlers pay one isHandlerSystemScoped lookup to check whether the
329
- // default per-tenant limit applies.
330
- try {
331
- await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
332
- }
333
- catch (e) {
334
- if (isKumikoError(e))
335
- return writeFailure(e);
336
- throw e;
337
- }
338
- // Default-deny: missing access rule is treated as "no one has access".
339
- // The registry boot-validator refuses to register handlers without one,
340
- // so in normal boots this branch shouldn't fire — the guard is belt-and-
341
- // suspenders in case a handler sneaks through (e.g. runtime injection).
342
- if (!hasAccess(user, handler.access)) {
343
- return writeFailure(handlerAccessError(user, type));
344
- }
345
- const parsed = handler.schema.safeParse(payload);
346
- if (!parsed.success) {
347
- return writeFailure(validationErrorFromZod(parsed.error));
348
- }
333
+ // A command the dispatcher reserved for was already billed against the rate limits; the
334
+ // gates only run again against the in-transaction state.
335
+ const reservation = handler.reserveBeforeTransaction ? consumeReservation(type) : undefined;
336
+ const gates = await runPreHandlerGates(ctx, handler, type, payload, user, reservation ? "recheck" : "charge");
337
+ if (!gates.isSuccess)
338
+ return gates;
339
+ const parsedPayload = gates.payload;
349
340
  // A handler that reserves before the transaction cannot do so from inside an open one; running
350
341
  // it unreserved would skip the check entirely, so a nested ctx.write of a different capped
351
342
  // handler is rejected instead.
352
- if (handler.reserveBeforeTransaction && !consumeReservation(type)) {
343
+ if (handler.reserveBeforeTransaction && !reservation) {
353
344
  return writeFailure(new InternalError({
354
345
  message: `handler "${type}" reserves before the transaction and cannot run as a nested write unless the top-level command reserved it`,
355
346
  }));
356
347
  }
357
- try {
358
- await enforcePayloadRateLimits(ctx, handler.additionalRateLimits, type, user, parsed.data);
359
- }
360
- catch (e) {
361
- if (isKumikoError(e))
362
- return writeFailure(e);
363
- throw e;
364
- }
365
- const hookErrors = runValidation(registry, type, parsed.data); // @cast-boundary engine-payload
366
- if (hookErrors) {
367
- return writeFailure(new ValidationError({
368
- fields: hookErrors.map((e) => ({
369
- path: e.field,
370
- code: e.error,
371
- i18nKey: `errors.validation.${e.error}`,
372
- })),
373
- }));
374
- }
375
- // Field-level write access check
376
348
  const entityName = registry.getHandlerEntity(type);
377
- if (entityName) {
378
- const entity = registry.getEntity(entityName);
379
- if (entity) {
380
- const fieldsToCheck = parsed.data["changes"]; // @cast-boundary engine-payload
381
- const writePayload = fieldsToCheck ?? parsed.data; // @cast-boundary engine-payload
382
- // Pre-handler check: role-only gate. Ownership-level row-match runs
383
- // later in the executor where oldRow is loaded — that split lets
384
- // updates with partial changes still pass the pre-handler check and
385
- // get their full evaluation at save time.
386
- const deniedField = checkWriteFieldRoles(entity, writePayload, user);
387
- if (deniedField) {
388
- return writeFailure(new AccessDeniedError({
389
- message: `field access denied: ${deniedField}`,
390
- i18nKey: "errors.access.fieldDenied",
391
- details: {
392
- reason: FrameworkReasons.fieldAccessDenied,
393
- field: deniedField,
394
- handler: type,
395
- },
396
- }));
397
- }
398
- }
399
- }
400
349
  const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks);
401
350
  // Auto transition guard: if entity has transitions and handler doesn't skip it.
402
351
  // Reads via the guard's own db handle — for r.systemScope() handlers
@@ -409,7 +358,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
409
358
  if (entityName && !handler.unsafeSkipTransitionGuard) {
410
359
  const entity = registry.getEntity(entityName);
411
360
  if (entity?.transitions && transitionGuardDb) {
412
- const parsedData = parsed.data; // @cast-boundary engine-payload
361
+ const parsedData = parsedPayload; // @cast-boundary engine-payload
413
362
  const changes = parsedData["changes"] ?? parsedData; // @cast-boundary engine-payload
414
363
  const id = parsedData["id"] ?? undefined; // @cast-boundary engine-payload
415
364
  for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
@@ -454,7 +403,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
454
403
  // writeFailure, not via a rethrow) so batches roll back naturally.
455
404
  let result;
456
405
  try {
457
- result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
406
+ result = await handler.handler({ type, payload: parsedPayload, user }, handlerContext);
458
407
  }
459
408
  catch (e) {
460
409
  return writeFailure(wrapToKumiko(e));
@@ -472,6 +421,11 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
472
421
  `Use defineWriteHandler() or wrap the return as { isSuccess: true as const, data: ... }.`,
473
422
  }));
474
423
  }
424
+ if (result.isSuccess && reservation) {
425
+ const confirmFailure = await confirmReservation(reservation, tx, user);
426
+ if (confirmFailure)
427
+ return confirmFailure;
428
+ }
475
429
  if (result.isSuccess) {
476
430
  try {
477
431
  const runner = resolveDbSource(ctx, tx);
@@ -483,7 +437,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
483
437
  // jobRunner has external side-effects (BullMQ enqueue) — must NOT
484
438
  // fire for rolled-back writes. Defer to afterCommit.
485
439
  if (jobRunner) {
486
- const eventData = (parsed.data ?? {}); // @cast-boundary engine-payload
440
+ const eventData = (parsedPayload ?? {}); // @cast-boundary engine-payload
487
441
  afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
488
442
  }
489
443
  invalidateTenantTimezoneCache(ctx, type, user, result);
@@ -67,6 +67,7 @@ export function createMemberReaderFn(ctx, tenantId, tx) {
67
67
  }
68
68
  // Only successful resolutions are cached — a rejected/failed resolution
69
69
  // deletes its own entry so a transient error can't poison the rest of the run.
70
+ // No TTL: a membership revoked mid-run is only noticed by the next reader.
70
71
  const cache = new Map();
71
72
  function resolve(userId) {
72
73
  const cached = cache.get(userId);
@@ -0,0 +1,9 @@
1
+ import type { SessionUser, WriteHandlerDef } from "../engine/types/index.js";
2
+ import { type WriteFailure } from "../errors/index.js";
3
+ import type { DispatchContext } from "./dispatch-shared.js";
4
+ export type PreHandlerGateMode = "charge" | "recheck";
5
+ export type PreHandlerGateResult = {
6
+ readonly isSuccess: true;
7
+ readonly payload: unknown;
8
+ } | WriteFailure;
9
+ export declare function runPreHandlerGates(ctx: DispatchContext, handler: WriteHandlerDef, type: string, rawPayload: unknown, user: SessionUser, mode: PreHandlerGateMode): Promise<PreHandlerGateResult>;
@@ -0,0 +1,104 @@
1
+ import { hasAccess } from "../engine/access.js";
2
+ import { checkWriteFieldRoles } from "../engine/field-access.js";
3
+ import { runValidation } from "../engine/validation.js";
4
+ import { AccessDeniedError, FrameworkReasons, isKumikoError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
5
+ import { checkFeatureEnabled, enforcePayloadRateLimits, enforceRateLimit, } from "./dispatch-shared.js";
6
+ import { handlerAccessError } from "./handler-access-error.js";
7
+ async function rejectDisabledFeature(ctx, type, user) {
8
+ const disabledErr = await checkFeatureEnabled(ctx, type, user.tenantId);
9
+ return disabledErr ? writeFailure(disabledErr) : undefined;
10
+ }
11
+ async function rejectRateLimited(ctx, handler, type, user) {
12
+ try {
13
+ await enforceRateLimit(ctx, handler.rateLimit, type, user, ctx.registry.isHandlerSystemScoped(type));
14
+ }
15
+ catch (e) {
16
+ if (isKumikoError(e))
17
+ return writeFailure(e);
18
+ throw e;
19
+ }
20
+ return undefined;
21
+ }
22
+ async function rejectPayloadRateLimited(ctx, handler, type, user, payload) {
23
+ try {
24
+ await enforcePayloadRateLimits(ctx, handler.additionalRateLimits, type, user, payload);
25
+ }
26
+ catch (e) {
27
+ if (isKumikoError(e))
28
+ return writeFailure(e);
29
+ throw e;
30
+ }
31
+ return undefined;
32
+ }
33
+ function rejectInvalidByHooks(ctx, type, payload) {
34
+ const hookErrors = runValidation(ctx.registry, type, payload); // @cast-boundary engine-payload
35
+ if (!hookErrors)
36
+ return undefined;
37
+ return writeFailure(new ValidationError({
38
+ fields: hookErrors.map((e) => ({
39
+ path: e.field,
40
+ code: e.error,
41
+ i18nKey: `errors.validation.${e.error}`,
42
+ })),
43
+ }));
44
+ }
45
+ // Role-only gate; ownership-level row matching runs later in the executor where oldRow is loaded,
46
+ // so updates with partial changes still pass here and get their full evaluation at save time.
47
+ function rejectDeniedFieldRoles(ctx, type, payload, user) {
48
+ const { registry } = ctx;
49
+ const entityName = registry.getHandlerEntity(type);
50
+ const entity = entityName ? registry.getEntity(entityName) : undefined;
51
+ if (!entity)
52
+ return undefined;
53
+ const fieldsToCheck = payload["changes"]; // @cast-boundary engine-payload
54
+ const writePayload = fieldsToCheck ?? payload; // @cast-boundary engine-payload
55
+ const deniedField = checkWriteFieldRoles(entity, writePayload, user);
56
+ if (!deniedField)
57
+ return undefined;
58
+ return writeFailure(new AccessDeniedError({
59
+ message: `field access denied: ${deniedField}`,
60
+ i18nKey: "errors.access.fieldDenied",
61
+ details: {
62
+ reason: FrameworkReasons.fieldAccessDenied,
63
+ field: deniedField,
64
+ handler: type,
65
+ },
66
+ }));
67
+ }
68
+ async function rejectBeforeParse(ctx, handler, type, user, mode) {
69
+ const disabled = await rejectDisabledFeature(ctx, type, user);
70
+ if (disabled)
71
+ return disabled;
72
+ if (mode === "charge") {
73
+ const rateLimited = await rejectRateLimited(ctx, handler, type, user);
74
+ if (rateLimited)
75
+ return rateLimited;
76
+ }
77
+ // Default-deny: boot validation refuses handlers without an access rule, so this only guards
78
+ // against a handler injected at runtime.
79
+ if (!hasAccess(user, handler.access))
80
+ return writeFailure(handlerAccessError(user, type));
81
+ return undefined;
82
+ }
83
+ async function rejectAfterParse(ctx, handler, type, user, payload, mode) {
84
+ if (mode === "charge") {
85
+ const rateLimited = await rejectPayloadRateLimited(ctx, handler, type, user, payload);
86
+ if (rateLimited)
87
+ return rateLimited;
88
+ }
89
+ return (rejectInvalidByHooks(ctx, type, payload) ?? rejectDeniedFieldRoles(ctx, type, payload, user));
90
+ }
91
+ // Every check that runs before a write handler may do work, in dispatch order. A handler that
92
+ // reserves capacity must only reserve for callers who passed all of them.
93
+ export async function runPreHandlerGates(ctx, handler, type, rawPayload, user, mode) {
94
+ const beforeParse = await rejectBeforeParse(ctx, handler, type, user, mode);
95
+ if (beforeParse)
96
+ return beforeParse;
97
+ const parsed = handler.schema.safeParse(rawPayload);
98
+ if (!parsed.success)
99
+ return writeFailure(validationErrorFromZod(parsed.error));
100
+ const afterParse = await rejectAfterParse(ctx, handler, type, user, parsed.data, mode);
101
+ if (afterParse)
102
+ return afterParse;
103
+ return { isSuccess: true, payload: parsed.data };
104
+ }