@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -0,0 +1,24 @@
1
+ import type { EntityDefinition } from "./types/index.js";
2
+ export declare const MAX_TRANSFER_DEPTH = 5;
3
+ export type TransferEdgeLink = {
4
+ readonly kind: "parentRef";
5
+ readonly typeField: string;
6
+ readonly idField: string;
7
+ } | {
8
+ readonly kind: "reference";
9
+ readonly field: string;
10
+ };
11
+ export type TransferEdge = {
12
+ readonly entityName: string;
13
+ readonly entity: EntityDefinition;
14
+ /** The already-resolved type this edge hangs off — its moved row ids are
15
+ * the parent ids the mover matches this edge's rows against. */
16
+ readonly parentEntityName: string;
17
+ readonly link: TransferEdgeLink;
18
+ };
19
+ /** Outgoing edges per type: keyed by the type an edge hangs off, so the mover
20
+ * can ask "what hangs off the rows I just moved" for any type it reaches, at
21
+ * any depth, as often as it reaches it. */
22
+ export type TransferAdjacency = ReadonlyMap<string, readonly TransferEdge[]>;
23
+ export declare function resolveTransferAdjacency(entities: ReadonlyMap<string, EntityDefinition>, rootEntityType?: string): TransferAdjacency;
24
+ export declare function findOverDeepTransferChain(adjacency: TransferAdjacency, onPath: readonly string[]): readonly string[] | undefined;
@@ -0,0 +1,107 @@
1
+ import { parseRefTargetEntityName } from "./parse-ref-target.js";
2
+ // The single definition of the limit. It lives here rather than next to the
3
+ // resolver in bundled-features because the dependency only runs that way:
4
+ // bundled-features imports from the framework, never the reverse.
5
+ //
6
+ // Guards against a schema whose reference edges span more levels than anyone
7
+ // intended — a runaway graph would move rows an operator never associated with
8
+ // the handover. Deliberately a constant and not per-feature config: the limit
9
+ // is a safety net, and a configurable one gets raised by whoever trips it
10
+ // instead of prompting them to reconsider their graph.
11
+ export const MAX_TRANSFER_DEPTH = 5;
12
+ // A `multiple` reference stores a jsonb array rather than a single id column,
13
+ // which the mover's `= ANY($ids)` match cannot address. The boot validator
14
+ // (boot-validator/transfer-graph.ts) rejects that combination on transferable
15
+ // entities, so skipping it here can only ever hit an entity that is not
16
+ // transferable — never a silent partial move.
17
+ // `handover: "stay"` is the declared opt-out: the reference crosses the tenant
18
+ // boundary after a handover on purpose, so the edge is not walked.
19
+ function referenceEdgesFrom(entityName, entity) {
20
+ const edges = [];
21
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
22
+ if (field.type !== "reference")
23
+ continue;
24
+ if (field.multiple === true)
25
+ continue;
26
+ if (field.handover === "stay")
27
+ continue;
28
+ edges.push({
29
+ entityName,
30
+ entity,
31
+ parentEntityName: parseRefTargetEntityName(field.entity),
32
+ link: { kind: "reference", field: fieldName },
33
+ });
34
+ }
35
+ return edges;
36
+ }
37
+ // A parentRef names the types it accepts; without `allowedTypes` it accepts
38
+ // any of them, so the edge is recorded against every registered type.
39
+ function parentRefEdgesFrom(entityName, entity, allEntityNames) {
40
+ const parentRef = entity.parentRef;
41
+ if (!parentRef)
42
+ return [];
43
+ const link = {
44
+ kind: "parentRef",
45
+ typeField: parentRef.entityTypeField,
46
+ idField: parentRef.entityIdField,
47
+ };
48
+ return (parentRef.allowedTypes ?? allEntityNames).map((parentEntityName) => ({
49
+ entityName,
50
+ entity,
51
+ parentEntityName,
52
+ link,
53
+ }));
54
+ }
55
+ // The one definition of the transfer graph, shared by the boot validator
56
+ // (depth limit) and the tenant-handover mover (traversal), so the limit that is
57
+ // checked at boot is measured over exactly the edges that are walked at claim
58
+ // time. Edges are kept for every entity, transferable or not: a
59
+ // non-transferable node in the middle of a chain is still a hop the mover
60
+ // takes before it fails the claim.
61
+ //
62
+ // A type can legitimately be reached by more than one edge — `note.vehicleId`
63
+ // and `note.campaignId` are distinct, and collapsing them by entity name would
64
+ // leave one edge's rows behind, the silent partial move #3088 exists to end.
65
+ export function resolveTransferAdjacency(entities, rootEntityType) {
66
+ const allEntityNames = [...entities.keys()];
67
+ const byParent = new Map();
68
+ for (const [entityName, entity] of entities) {
69
+ // skip: the root is never collected as someone else's descendant. A self
70
+ // reference would otherwise drag unrelated rows of the root's own type
71
+ // across the tenant boundary along with the one that was claimed.
72
+ if (entityName === rootEntityType)
73
+ continue;
74
+ for (const edge of [
75
+ ...parentRefEdgesFrom(entityName, entity, allEntityNames),
76
+ ...referenceEdgesFrom(entityName, entity),
77
+ ]) {
78
+ const edges = byParent.get(edge.parentEntityName) ?? [];
79
+ edges.push(edge);
80
+ byParent.set(edge.parentEntityName, edges);
81
+ }
82
+ }
83
+ return byParent;
84
+ }
85
+ // `onPath` counts NODES, the limit counts EDGES: a chain of exactly
86
+ // MAX_TRANSFER_DEPTH edges holds MAX_TRANSFER_DEPTH + 1 entities, and the
87
+ // mover runs MAX_TRANSFER_DEPTH rounds of one hop each, so it still walks that
88
+ // chain whole. Returns the first path that exceeds the limit.
89
+ export function findOverDeepTransferChain(adjacency, onPath) {
90
+ if (onPath.length > MAX_TRANSFER_DEPTH + 1)
91
+ return onPath;
92
+ const startName = onPath[onPath.length - 1];
93
+ if (startName === undefined)
94
+ return undefined;
95
+ for (const edge of adjacency.get(startName) ?? []) {
96
+ // skip: a cycle revisits a type already on this path, and schema depth
97
+ // cannot measure how far it actually runs — that depends on the rows, not
98
+ // the declaration. The mover carries this one instead, failing with
99
+ // `transfer_graph_too_deep` when its rounds run out (#3131).
100
+ if (onPath.includes(edge.entityName))
101
+ continue;
102
+ const deeper = findOverDeepTransferChain(adjacency, [...onPath, edge.entityName]);
103
+ if (deeper !== undefined)
104
+ return deeper;
105
+ }
106
+ return undefined;
107
+ }
@@ -17,9 +17,9 @@ const SELF_COMPARISON_RE = new RegExp(`(${QUALIFIED_SRC})\\s*(?<![!<>=])=(?!=)\\
17
17
  const QUALIFIED_PARSE_RE = new RegExp(`^(${IDENT_SRC})(?:\\.(${IDENT_SRC}))?$`);
18
18
  // Resolve the SQL column names a table exposes, so the lint can tell a
19
19
  // real column reference apart from a table alias / function name / SQL
20
- // keyword. Returns an empty set when nothing is derivable; the column-aware
21
- // check then finds no bare column and is effectively skipped (only the
22
- // self-comparison check still applies).
20
+ // keyword. Returns an empty set when nothing is derivable; callers that need
21
+ // the column-aware check must treat that as "unknown" (see
22
+ // assertQualifiedWhereFragment, which fails closed on it).
23
23
  export function tableColumnSqlNames(table) {
24
24
  if (table === null || typeof table !== "object")
25
25
  return new Set();
@@ -182,6 +182,9 @@ export function assertQualifiedWhereFragment(sqlText, columnSqlNames, scope) {
182
182
  // the fail-open shape this lint targets is impossible here.
183
183
  if (!/\bSELECT\b/i.test(cleaned))
184
184
  return;
185
+ if (columnSqlNames.size === 0) {
186
+ throw new Error(`[Kumiko Ownership] ${scope}: cannot verify where-rule SQL containing a subquery — the table's column set is unknown, so unqualified column references cannot be detected.`);
187
+ }
185
188
  const unqualified = findUnqualifiedColumnReference(cleaned, columnSqlNames);
186
189
  if (unqualified !== null) {
187
190
  throw new Error(`[Kumiko Ownership] ${scope}: where-rule SQL references column "${unqualified}" unqualified inside a subquery. Postgres binds an unqualified name to the innermost table first, which silently turns the ownership predicate into a tautology (fail-open). Qualify it with the outer table, e.g. \`\${ctx.tableName}.${unqualified}\`.`);
@@ -30,6 +30,8 @@ type JobsBlock = {
30
30
  readonly onJobStart?: JobRunnerOptions["onJobStart"];
31
31
  readonly onJobComplete?: JobRunnerOptions["onJobComplete"];
32
32
  readonly onJobFailed?: JobRunnerOptions["onJobFailed"];
33
+ readonly onJobQueued?: JobRunnerOptions["onJobQueued"];
34
+ readonly onJobDropped?: JobRunnerOptions["onJobDropped"];
33
35
  };
34
36
  export type ApiEntrypointOptions = BaseEntrypointOptions & {
35
37
  readonly auth?: AuthRoutesConfig;
@@ -176,6 +176,8 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
176
176
  onJobStart: jobs.onJobStart,
177
177
  onJobComplete: jobs.onJobComplete,
178
178
  onJobFailed: jobs.onJobFailed,
179
+ onJobQueued: jobs.onJobQueued,
180
+ onJobDropped: jobs.onJobDropped,
179
181
  });
180
182
  lifecycle.registerShutdownHook(hookName, async () => {
181
183
  await jobRunner.stop();
@@ -88,6 +88,10 @@ export type ParseEnvOptions = {
88
88
  /** When set, error suggestions include `pulumi config set <prefix>...`. */
89
89
  readonly pulumiPrefix?: string;
90
90
  };
91
+ /** Validates `env` against `schema` and throws KumikoBootError on any issue.
92
+ * A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
93
+ * is `undefined` in the result despite its `string` type — it is only set once
94
+ * the boot-time decrypt (resolvePlatformKeks) has run. */
91
95
  export declare function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S, env: Record<string, string | undefined>, options?: ParseEnvOptions): z.infer<S>;
92
96
  /** Dry-Run-Gegenstück zu parseEnv (KUMIKO_DRY_RUN_ENV-Pfad): parst jedes
93
97
  * Feld einzeln und liefert ein ehrliches Partial — vorhandene Werte kommen
package/dist/env/index.js CHANGED
@@ -135,6 +135,42 @@ export function withOptionalEnvKeys(schema, keys) {
135
135
  }
136
136
  return Object.keys(relaxed).length === 0 ? schema : schema.safeExtend(relaxed);
137
137
  }
138
+ // The very same field instance is a shared fragment (several features read one
139
+ // variable), not a conflict; the first declaring feature owns it.
140
+ function isSharedFeatureFragment(featureFields, key, field) {
141
+ return featureFields.get(key) === field;
142
+ }
143
+ // Required beats optional, independent of feature order: a feature outside
144
+ // optionalFeatures must not end up with a silently optional slot.
145
+ function keepStrictestOfSharedFragment(merged, key, field, declaringFeatureIsOptional) {
146
+ if (!declaringFeatureIsOptional)
147
+ merged[key] = field;
148
+ }
149
+ function envVarConflictError(key, firstOwner, secondOwner) {
150
+ return new KumikoBootError([
151
+ {
152
+ name: key,
153
+ kind: "invalid",
154
+ message: `env-var conflict: "${key}" declared by both ` +
155
+ `"${firstOwner}" and "${secondOwner}" — pick one owner.`,
156
+ },
157
+ ]);
158
+ }
159
+ // The twin follows from the slot, so a versioned family (`…_V2`) needs no
160
+ // second declaration. An already declared twin (apps that predate this) stays.
161
+ function declareKmsCiphertextTwins(merged, sources, kmsFields) {
162
+ for (const { name, source } of kmsFields) {
163
+ const twin = `${name}_CIPHERTEXT`;
164
+ if (merged[twin] !== undefined)
165
+ continue;
166
+ merged[twin] = z
167
+ .string()
168
+ .min(1)
169
+ .optional()
170
+ .describe(`Key-Manager ciphertext of ${name}; used when ${name} is unset.`);
171
+ sources[twin] = source;
172
+ }
173
+ }
138
174
  export function composeEnvSchema(options) {
139
175
  const optionalSet = new Set(options.optionalFeatures ?? []);
140
176
  const merged = {};
@@ -157,22 +193,20 @@ export function composeEnvSchema(options) {
157
193
  noteKms(key, field, "framework-core");
158
194
  }
159
195
  }
196
+ const featureFields = new Map();
160
197
  for (const feature of options.features) {
161
198
  if (!feature.envSchema)
162
199
  continue;
163
- const shape = zodShape(feature.envSchema);
164
200
  const wrap = optionalSet.has(feature.name);
165
- for (const [key, field] of Object.entries(shape)) {
201
+ for (const [key, field] of Object.entries(zodShape(feature.envSchema))) {
166
202
  if (merged[key] !== undefined) {
167
- throw new KumikoBootError([
168
- {
169
- name: key,
170
- kind: "invalid",
171
- message: `env-var conflict: "${key}" declared by both ` +
172
- `"${sources[key]}" and "${feature.name}" — pick one owner.`,
173
- },
174
- ]);
203
+ if (!isSharedFeatureFragment(featureFields, key, field)) {
204
+ throw envVarConflictError(key, sources[key], feature.name);
205
+ }
206
+ keepStrictestOfSharedFragment(merged, key, field, wrap);
207
+ continue;
175
208
  }
209
+ featureFields.set(key, field);
176
210
  merged[key] = wrap ? optionalKeepingKmsMeta(field) : field;
177
211
  sources[key] = feature.name;
178
212
  noteKms(key, field, feature.name);
@@ -195,19 +229,7 @@ export function composeEnvSchema(options) {
195
229
  noteKms(key, field, "app");
196
230
  }
197
231
  }
198
- // The twin follows from the slot, so a versioned family (`…_V2`) needs no
199
- // second declaration. An already declared twin (apps that predate this) stays.
200
- for (const { name, source } of kmsFields) {
201
- const twin = `${name}_CIPHERTEXT`;
202
- if (merged[twin] !== undefined)
203
- continue;
204
- merged[twin] = z
205
- .string()
206
- .min(1)
207
- .optional()
208
- .describe(`Key-Manager ciphertext of ${name}; used when ${name} is unset.`);
209
- sources[twin] = source;
210
- }
232
+ declareKmsCiphertextTwins(merged, sources, kmsFields);
211
233
  return {
212
234
  schema: z.object(merged),
213
235
  sources,
@@ -259,6 +281,10 @@ function safeParseReportingThrows(schema, input) {
259
281
  ]);
260
282
  }
261
283
  }
284
+ /** Validates `env` against `schema` and throws KumikoBootError on any issue.
285
+ * A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
286
+ * is `undefined` in the result despite its `string` type — it is only set once
287
+ * the boot-time decrypt (resolvePlatformKeks) has run. */
262
288
  export function parseEnv(schema, env, options = {}) {
263
289
  // Filter undefined values to keep Zod's required-vs-invalid signal clean.
264
290
  // (process.env returns string|undefined; passing undefined would parse
@@ -98,6 +98,14 @@ export declare class InternalError extends KumikoError {
98
98
  readonly httpStatus = 500;
99
99
  constructor(opts?: Pick<ErrorOpts, "message" | "i18nKey" | "i18nParams" | "details" | "cause">);
100
100
  }
101
+ export declare class MemberReadTimeoutError extends KumikoError {
102
+ readonly code = "member_read_timeout";
103
+ readonly httpStatus = 504;
104
+ constructor(opts: {
105
+ readonly timeoutMs: number;
106
+ readonly cause?: Error;
107
+ });
108
+ }
101
109
  export type RateLimitDetails = {
102
110
  readonly bucket: string;
103
111
  readonly limit: number;
@@ -189,6 +189,19 @@ export class InternalError extends KumikoError {
189
189
  });
190
190
  }
191
191
  }
192
+ // A ctx.queryAsMember read ran into its statement timeout (SQLSTATE 57014). Reuses the generic
193
+ // errors.internal i18n key: the client can do nothing but retry, and the code is for log/alert routing.
194
+ export class MemberReadTimeoutError extends KumikoError {
195
+ code = "member_read_timeout";
196
+ httpStatus = 504;
197
+ constructor(opts) {
198
+ super({
199
+ message: `ctx.queryAsMember read exceeded its ${opts.timeoutMs}ms statement timeout`,
200
+ i18nKey: "errors.internal",
201
+ ...(opts.cause && { cause: opts.cause }),
202
+ });
203
+ }
204
+ }
192
205
  export class RateLimitError extends KumikoError {
193
206
  code = "rate_limited";
194
207
  httpStatus = 429;
@@ -1,5 +1,5 @@
1
1
  export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
2
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
3
3
  export type { ErrorDocsConfig } from "./docs-url.js";
4
4
  export { resolveErrorDocsUrl } from "./docs-url.js";
5
5
  export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
@@ -9,6 +9,7 @@ export type { AgentReason, FrameworkReason } from "./reasons.js";
9
9
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
10
10
  export type { ErrorLogEntry, ErrorResponseBody } from "./serialize.js";
11
11
  export { buildErrorLog, serializeError } from "./serialize.js";
12
+ export { subjectErasedConflict } from "./subject-erased.js";
12
13
  export { toKumikoError } from "./to-kumiko-error.js";
13
14
  export type { InvalidTransitionDetails } from "./transition-details.js";
14
15
  export { buildInvalidTransitionDetails } from "./transition-details.js";
@@ -1,9 +1,10 @@
1
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
1
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
2
  export { resolveErrorDocsUrl } from "./docs-url.js";
3
3
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
4
4
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
5
5
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
6
6
  export { buildErrorLog, serializeError } from "./serialize.js";
7
+ export { subjectErasedConflict } from "./subject-erased.js";
7
8
  export { toKumikoError } from "./to-kumiko-error.js";
8
9
  export { buildInvalidTransitionDetails } from "./transition-details.js";
9
10
  export { failNotFound, failTransition, failUnprocessable, reraiseAsKumikoError, toWriteErrorInfo, writeFailure, } from "./write-error-info.js";
@@ -12,6 +12,7 @@ export declare const FrameworkReasons: {
12
12
  readonly publicIntakeRequired: "public_intake_required";
13
13
  readonly irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk";
14
14
  readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
15
+ readonly subjectErased: "subject_erased";
15
16
  };
16
17
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
17
18
  export declare const AgentReasons: {
@@ -61,6 +61,9 @@ export const FrameworkReasons = {
61
61
  // delegation via ctx.write/writeAs from a lower-risk handler does not
62
62
  // inherit the gate.
63
63
  instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk",
64
+ // ConflictError: a write touched a personal-data field of a data subject whose
65
+ // key was erased (crypto-shredding); the subject can no longer receive data.
66
+ subjectErased: "subject_erased",
64
67
  };
65
68
  // Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
66
69
  // sister catalog to FrameworkReasons instead of extending it — see the
@@ -0,0 +1,2 @@
1
+ import { ConflictError } from "./classes.js";
2
+ export declare function subjectErasedConflict(cause?: unknown): ConflictError;
@@ -0,0 +1,9 @@
1
+ import { ConflictError } from "./classes.js";
2
+ import { FrameworkReasons } from "./reasons.js";
3
+ export function subjectErasedConflict(cause) {
4
+ return new ConflictError({
5
+ message: "the data subject was erased — its personal data can no longer be written",
6
+ details: { reason: FrameworkReasons.subjectErased },
7
+ ...(cause instanceof Error && { cause }),
8
+ });
9
+ }
@@ -6,6 +6,7 @@
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 { configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto/index.js";
9
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";
@@ -63,7 +64,24 @@ 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;
67
85
  },
68
86
  findTemplateResources: async (filter) => {
69
87
  const rows = await selectTemplateResources(args.dbRunner, {
@@ -8,7 +8,7 @@
8
8
  // Allowlist: samples/*/migration/, scripts/migrations/, die Definition
9
9
  // selbst, das Guard-Script selbst.
10
10
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
11
- import { claimXactId } from "../db/queries/event-store.js";
11
+ import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
12
12
  import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
13
13
  import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
14
14
  function mapEventUniqueViolation(e, event) {
@@ -26,13 +26,15 @@ export async function appendRaw(runner, event) {
26
26
  try {
27
27
  // See db/queries/event-store.ts's claimXactId — gap-finality needs a
28
28
  // real xact id assigned before this holder's insert.
29
- await claimXactId(runner);
30
- if (event.expectedVersion === 0) {
31
- await insertRawFirst(runner, event, newVersion, eventVersion);
32
- }
33
- else {
34
- await insertRawSubsequent(runner, event, newVersion, eventVersion);
35
- }
29
+ await runAtomicEventWrite(runner, async (tx) => {
30
+ await claimXactId(tx);
31
+ if (event.expectedVersion === 0) {
32
+ await insertRawFirst(tx, event, newVersion, eventVersion);
33
+ }
34
+ else {
35
+ await insertRawSubsequent(tx, event, newVersion, eventVersion);
36
+ }
37
+ });
36
38
  }
37
39
  catch (e) {
38
40
  if (isUniqueViolation(e)) {
@@ -90,8 +92,10 @@ export async function appendRawBatch(runner, events) {
90
92
  return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
91
93
  });
92
94
  try {
93
- await claimXactId(runner);
94
- await insertRawEventBatch(runner, valuesClauses.join(", "), params);
95
+ await runAtomicEventWrite(runner, async (tx) => {
96
+ await claimXactId(tx);
97
+ await insertRawEventBatch(tx, valuesClauses.join(", "), params);
98
+ });
95
99
  }
96
100
  catch (e) {
97
101
  if (isUniqueViolation(e)) {
@@ -4,7 +4,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
6
6
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
7
- import { claimXactId, insertSubsequentEventRow, notifyPgChannel, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
7
+ import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
8
8
  import { insertOne, selectMany } from "../db/query.js";
9
9
  import { isStreamArchived } from "./archive.js";
10
10
  import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
@@ -50,14 +50,16 @@ export async function append(db, event) {
50
50
  const newVersion = toStore.expectedVersion + 1;
51
51
  const eventVersion = toStore.eventVersion ?? 1;
52
52
  try {
53
- await claimXactId(db);
54
- const row = toStore.expectedVersion === 0
55
- ? await insertFirstEvent(db, toStore, newVersion, eventVersion, seedCreatedAt)
56
- : await insertSubsequentEvent(db, toStore, newVersion, eventVersion, seedCreatedAt);
57
- // NOTIFY after the INSERT: outside a transaction each statement commits
58
- // on its own, so a NOTIFY sent first would wake the dispatcher before
59
- // the row exists.
60
- await notifyPgChannel(db, EVENTS_PUBSUB_CHANNEL);
53
+ const row = await runAtomicEventWrite(db, async (runner) => {
54
+ await claimXactId(runner);
55
+ const inserted = toStore.expectedVersion === 0
56
+ ? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
57
+ : await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
58
+ // NOTIFY after the INSERT and inside the same transaction: PG delivers it on
59
+ // commit, so the dispatcher cannot wake before the row is visible.
60
+ await notifyPgChannel(runner, EVENTS_PUBSUB_CHANNEL);
61
+ return inserted;
62
+ });
61
63
  return buildStoredEvent(toStore, newVersion, eventVersion, row);
62
64
  }
63
65
  catch (e) {
@@ -1,7 +1,7 @@
1
1
  // sql now comes from native dialect
2
2
  import { bigserial, index, instant, integer, jsonb, table as pgTable, sql, text, uniqueIndex, uuid, } from "../db/dialect.js";
3
3
  import { tableExists } from "../db/index.js";
4
- import { ensureIdempotencyKeyIndex } from "../db/queries/event-store.js";
4
+ import { ensureIdempotencyKeyIndex, ensureMembershipPayloadTenantIndex, } from "../db/queries/event-store.js";
5
5
  import { unsafePushTables } from "../stack/index.js";
6
6
  import { createArchivedStreamsTable } from "./archive.js";
7
7
  import { createSnapshotsTable } from "./snapshot.js";
@@ -61,6 +61,7 @@ export async function createEventsTable(db) {
61
61
  // that predate the idempotency-key index get healed the same way
62
62
  // ensureSnapshotVersionColumn heals kumiko_snapshots.
63
63
  await ensureIdempotencyKeyIndex(db);
64
+ await ensureMembershipPayloadTenantIndex(db);
64
65
  await createArchivedStreamsTable(db);
65
66
  await createSnapshotsTable(db);
66
67
  }
@@ -2,6 +2,6 @@ import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-han
2
2
  import type { FileStorageProvider } from "./types.js";
3
3
  export type { FileContext, FileHandle };
4
4
  export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
5
- export declare function createFileContext(resolve: () => Promise<FileStorageProvider>): FileContext;
5
+ export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
6
6
  export declare function deriveKey(key: string, suffix: string): string;
7
7
  export declare function storageKeyStemPrefix(key: string): string;
@@ -7,8 +7,10 @@
7
7
  // the bytes, and big files stay in the storage layer where they belong.
8
8
  //
9
9
  // `derive(suffix)` is the primitive for thumbnail/variant keys: it inserts a
10
- // suffix before the file extension — `foo/bar.jpg` + `"medium"` →
11
- // `foo/bar.medium.jpg`. Stable, reversible, no extra lookup tables.
10
+ // suffix before the file extension — `foo/bar.jpg` + `"medium-<16 hex>"` →
11
+ // `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
12
+ // the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
13
+ import { isDerivativeSuffix } from "../derivatives/variant-key.js";
12
14
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
13
15
  // resolved (config + s3.secretAccessKey secret read) only when a handle method
14
16
  // actually does I/O, never on every request that merely builds a ctx.
@@ -19,7 +21,15 @@ export function createFileHandle(key, getProvider) {
19
21
  write: async (data, mimeType) => (await getProvider()).write(key, data, mimeType),
20
22
  delete: async () => (await getProvider()).delete(key),
21
23
  exists: async () => (await getProvider()).exists(key),
22
- derive: (suffix) => createFileHandle(deriveKey(key, suffix), getProvider),
24
+ derive: (suffix) => {
25
+ // A suffix outside the derivative grammar would write a key that the
26
+ // forget/tenant-destroy sweep (isDerivativeKeyOf) can never recognize,
27
+ // so the binary would survive erasure.
28
+ if (!isDerivativeSuffix(suffix)) {
29
+ throw new Error(`FileHandle.derive: suffix "${suffix}" must be <name>-<16 hex chars>; build it with variantSuffix(name, spec)`);
30
+ }
31
+ return createFileHandle(deriveKey(key, suffix), getProvider);
32
+ },
23
33
  };
24
34
  }
25
35
  // `resolve` is a tenant-bound thunk (the caller binds the tenant). The result
@@ -27,15 +37,29 @@ export function createFileHandle(key, getProvider) {
27
37
  // produces — never a process-global cache, since each FileContext is bound to a
28
38
  // single request/event tenant; sharing would leak one tenant's provider (and
29
39
  // its bucket/credentials) to another.
30
- export function createFileContext(resolve) {
40
+ //
41
+ // With `tenantId`, `ref` and `list` only accept keys/prefixes inside `${tenantId}/` —
42
+ // the key space is the isolation boundary in a store shared by several tenants, and
43
+ // a request-derived key or prefix (or "") would otherwise reach every tenant's keys.
44
+ export function createFileContext(resolve, tenantId) {
31
45
  let cached;
32
46
  const getProvider = () => {
33
47
  cached ??= resolve();
34
48
  return cached;
35
49
  };
36
50
  return {
37
- ref: (key) => createFileHandle(key, getProvider),
38
- list: async (prefix) => (await getProvider()).list(prefix),
51
+ ref: (key) => {
52
+ if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
53
+ throw new Error(`FileContext.ref: key must start with "${tenantId}/"`);
54
+ }
55
+ return createFileHandle(key, getProvider);
56
+ },
57
+ list: async (prefix) => {
58
+ if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
59
+ throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
60
+ }
61
+ return (await getProvider()).list(prefix);
62
+ },
39
63
  };
40
64
  }
41
65
  // Splits a key into the part before the last dot of its last path segment