@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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 (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -17,8 +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 (fail-open on
21
- // the lint itself would be worse than skipping it).
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).
22
23
  export function tableColumnSqlNames(table) {
23
24
  if (table === null || typeof table !== "object")
24
25
  return new Set();
@@ -131,16 +132,33 @@ function findSelfComparison(cleaned) {
131
132
  // column on the OUTER table is the fail-open shape — Postgres resolves it
132
133
  // against the innermost table first. A qualifier (`t.x`) or a table/alias
133
134
  // name immediately followed by `.` is not a column reference and is
134
- // ignored.
135
+ // ignored. Bare identifiers on the outer level bind to the outer table and
136
+ // are fine, so only tokens inside a `( SELECT ...` parenthesis block count.
137
+ const SUBQUERY_OPEN_RE = /^\s*SELECT\b/i;
135
138
  function findUnqualifiedColumnReference(cleaned, columnSqlNames) {
139
+ const parenIsSubquery = [];
140
+ let subqueryDepth = 0;
141
+ let scanned = 0;
136
142
  TOKEN_RE.lastIndex = 0;
137
143
  let match = TOKEN_RE.exec(cleaned);
138
144
  while (match !== null) {
139
145
  const start = match.index;
140
146
  const end = start + match[0].length;
147
+ for (; scanned < start; scanned++) {
148
+ const ch = cleaned[scanned];
149
+ if (ch === "(") {
150
+ const opensSubquery = SUBQUERY_OPEN_RE.test(cleaned.slice(scanned + 1));
151
+ parenIsSubquery.push(opensSubquery);
152
+ if (opensSubquery)
153
+ subqueryDepth++;
154
+ }
155
+ else if (ch === ")" && parenIsSubquery.pop() === true) {
156
+ subqueryDepth--;
157
+ }
158
+ }
141
159
  const precededByDot = start > 0 && cleaned[start - 1] === ".";
142
160
  const followedByDot = cleaned[end] === ".";
143
- if (!precededByDot && !followedByDot) {
161
+ if (subqueryDepth > 0 && !precededByDot && !followedByDot) {
144
162
  const normalized = normalizeIdent(match[0]);
145
163
  if (columnSqlNames.has(normalized))
146
164
  return normalized;
@@ -105,13 +105,19 @@ function renderJson(fields, options) {
105
105
  withDefault: withDefault.map(toEntry),
106
106
  }, null, 2)}\n`;
107
107
  }
108
+ // A newline in a `.describe()` text would end the `#` comment, so the rest of
109
+ // the description would run as a shell command or break the YAML.
110
+ function oneLineDescription(f) {
111
+ return f.description?.replace(/\s*\r?\n\s*/g, " ");
112
+ }
108
113
  function pulumiConfigSetLine(f, options) {
109
114
  const meta = readKumikoMeta(f.field);
110
115
  const key = pulumiConfigKey(f.name, f.field, options.pulumiPrefix);
111
116
  const secretFlag = meta.pulumi?.secret ? " --secret" : "";
112
117
  const value = meta.pulumi?.generator ? `"$(${meta.pulumi.generator})"` : `"<set-me>"`;
113
- const comment = f.description
114
- ? ` # ${f.name} (${f.source}): ${f.description}`
118
+ const description = oneLineDescription(f);
119
+ const comment = description
120
+ ? ` # ${f.name} (${f.source}): ${description}`
115
121
  : ` # ${f.name} (${f.source})`;
116
122
  return `pulumi config set${secretFlag} ${key} ${value}${comment}`;
117
123
  }
@@ -150,7 +156,8 @@ function renderK8s(fields, options) {
150
156
  if (optional.length > 0) {
151
157
  lines.push(" # Optional (uncomment and set to enable):");
152
158
  for (const f of optional) {
153
- const comment = f.description ? ` # (${f.source}): ${f.description}` : ` # (${f.source})`;
159
+ const description = oneLineDescription(f);
160
+ const comment = description ? ` # (${f.source}): ${description}` : ` # (${f.source})`;
154
161
  lines.push(` # ${f.name}: "<set-me>"${comment}`);
155
162
  }
156
163
  }
package/dist/env/index.js CHANGED
@@ -57,6 +57,9 @@ export function kmsSlotsOf(schema) {
57
57
  // only exists after the boot-time decrypt, so requiring it here would reject
58
58
  // exactly the deployment this meta enables. safeExtend, not extend: Zod 4 throws
59
59
  // on extend for schemas carrying refinements (app-level superRefine).
60
+ // Caveat: refinements run against the relaxed object, so they see such a slot as
61
+ // `undefined` despite `z.infer<S>` promising a string. A refinement that reads the
62
+ // slot must tolerate that; a throw from it is reported by parseEnv as a boot error.
60
63
  function relaxCiphertextOnlySlots(schema, env) {
61
64
  const shape = zodShape(schema);
62
65
  const relaxed = {};
@@ -227,6 +230,23 @@ export class KumikoBootError extends Error {
227
230
  return formatBootError(this.errors);
228
231
  }
229
232
  }
233
+ // safeParse does not catch exceptions thrown inside refinements; a refinement
234
+ // reading a ciphertext-only slot (undefined here) would otherwise escape as a raw
235
+ // TypeError past the KumikoBootError mapping.
236
+ function safeParseReportingThrows(schema, input) {
237
+ try {
238
+ return schema.safeParse(input);
239
+ }
240
+ catch (error) {
241
+ throw new KumikoBootError([
242
+ {
243
+ name: "<schema refinement>",
244
+ kind: "invalid",
245
+ message: `an env schema refinement threw: ${error instanceof Error ? error.message : String(error)}. If it reads a kms slot delivered only as <NAME>_CIPHERTEXT, the value is undefined at this point.`,
246
+ },
247
+ ]);
248
+ }
249
+ }
230
250
  export function parseEnv(schema, env, options = {}) {
231
251
  // Filter undefined values to keep Zod's required-vs-invalid signal clean.
232
252
  // (process.env returns string|undefined; passing undefined would parse
@@ -236,7 +256,7 @@ export function parseEnv(schema, env, options = {}) {
236
256
  if (v !== undefined)
237
257
  cleaned[k] = v;
238
258
  }
239
- const result = relaxCiphertextOnlySlots(schema, cleaned).safeParse(cleaned);
259
+ const result = safeParseReportingThrows(relaxCiphertextOnlySlots(schema, cleaned), cleaned);
240
260
  if (result.success) {
241
261
  // @cast-boundary schema-walk — z.infer<S> erasure across safeParse result
242
262
  return result.data;
@@ -17,7 +17,6 @@ export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkRe
17
17
  export declare const AgentReasons: {
18
18
  readonly toolNotAllowed: "agent.tool_not_allowed";
19
19
  readonly iterationLimit: "agent.iteration_limit";
20
- readonly permissionDenied: "agent.permission_denied";
21
20
  readonly highRiskNoAlways: "agent.high_risk_no_always";
22
21
  };
23
22
  export type AgentReason = (typeof AgentReasons)[keyof typeof AgentReasons];
@@ -13,8 +13,8 @@
13
13
  // reasons surface from the AI-agent runtime (tool dispatch, permission and
14
14
  // risk gates), not from a handler's own domain logic, and the `agent.`
15
15
  // prefix keeps them visibly distinct from FrameworkReasons in error payloads.
16
- // Both the docs site (`errors/` pages) and the enterprise repo read this same
17
- // source, so the prefix stays a naming convention here, not a new registry.
16
+ // The docs site (`errors/` pages) reads this source; the enterprise repo keeps
17
+ // its own AGENT_REASONS, so only reasons it actually emits belong in both.
18
18
  export const FrameworkReasons = {
19
19
  // ConflictError: atomic UPDATE lost the race (another writer moved the row
20
20
  // between our snapshot and our WHERE clause). Client SDK default: toast +
@@ -73,9 +73,6 @@ export const AgentReasons = {
73
73
  // Not thrown: the run ends with a `clarify` turn (`payload.reason`) once
74
74
  // MAX_AGENT_ROUNDS is reached.
75
75
  iterationLimit: "agent.iteration_limit",
76
- // AccessDeniedError: the tool exists, but the caller's roles don't cover
77
- // the handler behind it.
78
- permissionDenied: "agent.permission_denied",
79
76
  // UnprocessableError: a permission rule with `always` was requested for an
80
77
  // `agent: { risk: "high" }` handler; `details.handlerQn` names it.
81
78
  highRiskNoAlways: "agent.high_risk_no_always",
@@ -173,14 +173,22 @@ function buildStoredEvent(event, newVersion, eventVersion, row) {
173
173
  // aggregateId alone, matching prior behavior. Callers that share id
174
174
  // generation across multiple aggregate types on the same id (e.g. a solon-
175
175
  // style collision) must pass it to avoid folding a foreign type's events
176
- // into the loaded stream.
176
+ // into the loaded stream. The aggregateType condition stays an unindexed
177
+ // post-filter on purpose: events_aggregate_version_uq already narrows the scan
178
+ // to one aggregate's rows, so an index including aggregate_type would only add
179
+ // write cost.
177
180
  export async function loadAggregate(db, aggregateId, tenantId, options) {
181
+ // "" must not fall through to the unfiltered load: a dynamically computed
182
+ // type that came out empty would silently fold in foreign aggregate types.
183
+ if (options?.aggregateType === "") {
184
+ throw new Error("loadAggregate: aggregateType must be a non-empty string when provided");
185
+ }
178
186
  if (!options?.includeArchived) {
179
187
  const archived = await isStreamArchived(db, tenantId, aggregateId);
180
188
  if (archived)
181
189
  return [];
182
190
  }
183
- const rows = await selectMany(db, eventsTable, options?.aggregateType
191
+ const rows = await selectMany(db, eventsTable, options?.aggregateType !== undefined
184
192
  ? { aggregateId, tenantId, aggregateType: options.aggregateType }
185
193
  : { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
186
194
  return rows.map(toStoredEvent);
@@ -1,6 +1,7 @@
1
1
  import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii.js";
2
2
  import { runInSavepointIfSupported } from "../db/query.js";
3
3
  import { unsafeRawForDeclaredStep, withUnsafeRawGrant } from "../db/tenant-db.js";
4
+ import { qnScope } from "../engine/qualified-name.js";
4
5
  import { AccessDeniedError, InternalError } from "../errors/index.js";
5
6
  import { append, getStreamVersion } from "./event-store.js";
6
7
  // Fixed by the framework, not the caller — the point of this entry point is
@@ -14,7 +15,9 @@ export async function appendProvenanceEvent(db, event) {
14
15
  message: `appendProvenanceEvent("${event.type}") — the "${SYSTEM_EVENT_PREFIX}" namespace is framework-internal and is not reachable through this entry point.`,
15
16
  });
16
17
  }
17
- if (!event.type.includes(":")) {
18
+ // qnScope is the same owner definition appendDomainEventCore uses: a leading
19
+ // ":" (empty owner) counts as unowned there, so it must here too.
20
+ if (!qnScope(event.type) || event.type.endsWith(":")) {
18
21
  throw new InternalError({
19
22
  message: `appendProvenanceEvent("${event.type}") — event types must be owner-qualified ("<feature>:<name>"). Unowned types are not allowed here.`,
20
23
  });
@@ -14,7 +14,6 @@ export type FileRef = {
14
14
  entityId: string | null;
15
15
  fieldName: string | null;
16
16
  insertedById: string | null;
17
- isDeleted: boolean;
18
17
  };
19
18
  export type FileAccessGuard = (args: {
20
19
  readonly fileRef: FileRef;
@@ -3,7 +3,7 @@ import { bodyLimit } from "hono/body-limit";
3
3
  import { getUser } from "../api/auth-middleware.js";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { selectMany } from "../bun-db/index.js";
6
- import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, } from "../crypto/index.js";
6
+ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
7
7
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
8
8
  import { createTenantDb } from "../db/tenant-db.js";
9
9
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer, } from "../derivatives/index.js";
@@ -106,11 +106,27 @@ export function createFileRoutes(options) {
106
106
  if (!isPiiCiphertext(fileRef.fileName))
107
107
  return fileRef.fileName;
108
108
  const kms = configuredPiiSubjectKms();
109
+ // skip: the framework has no logger at this route layer and the file bytes are
110
+ // still served; the "download" fallback only hides the stored ciphertext name.
109
111
  if (!kms) {
110
112
  return "download";
111
113
  }
112
- const decrypted = await decryptPiiFieldValues({ fileName: fileRef.fileName }, piiSubjectFields, kms, kmsContextFor());
113
- return typeof decrypted["fileName"] === "string" ? decrypted["fileName"] : "download";
114
+ try {
115
+ const decrypted = await decryptPiiFieldValues({ fileName: fileRef.fileName }, piiSubjectFields, kms, kmsContextFor());
116
+ const name = decrypted["fileName"];
117
+ // decryptPiiFieldValues returns the row untouched when fileName isn't
118
+ // annotated as PII (annotation drift), so ciphertext/erased sentinel
119
+ // can still be here and must never reach a header.
120
+ if (typeof name !== "string" || isPiiCiphertext(name) || name === PII_ERASED_SENTINEL) {
121
+ return "download";
122
+ }
123
+ return name;
124
+ }
125
+ catch {
126
+ // skip: broken crypto metadata (malformed ciphertext, auth failure, missing key)
127
+ // must not make intact bytes unreachable; the route layer has no logger.
128
+ return "download";
129
+ }
114
130
  }
115
131
  // Runs before parseBody buffers the request below: checks Content-Length
116
132
  // upfront, counts chunked bodies while streaming.
@@ -397,11 +413,10 @@ export function createFileRoutes(options) {
397
413
  if (!row)
398
414
  return c.json({ error: "not_found" }, 404);
399
415
  const fileRef = row; // @cast-boundary db-row (decrypted via executor.detail)
400
- // detail()'s "pass"-ownership read widens to tenantId IN (self, SYSTEM)
401
- // and doesn't filter isDeleted (list() does, detail() doesn't) — both
416
+ // detail()'s "pass"-ownership read widens to tenantId IN (self, SYSTEM),
402
417
  // narrower in loadFileForTenant's selectMany. Restore that here so meta
403
- // doesn't leak reference-tenant or soft-deleted rows.
404
- if (fileRef.tenantId !== user.tenantId || fileRef.isDeleted) {
418
+ // doesn't leak reference-tenant rows.
419
+ if (fileRef.tenantId !== user.tenantId) {
405
420
  return c.json({ error: "not_found" }, 404);
406
421
  }
407
422
  const decision = await guard({ fileRef, user, operation: "read" });
@@ -1,6 +1,6 @@
1
1
  import { createReadStream, createWriteStream } from "node:fs";
2
2
  import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
- import { dirname, join, resolve, sep } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { assertSafeStorageKey } from "./types.js";
6
6
  // Local-filesystem backend — intended for dev + tests. Production deploys
@@ -101,25 +101,28 @@ export function createLocalProvider(basePath) {
101
101
  }
102
102
  },
103
103
  async list(prefix) {
104
- // recursive:true returns POSIX- or OS-sep-joined relative paths for
105
- // both files and directories; normalize to "/" (storage keys are
106
- // always "/"-joined, matching S3) before the prefix match, then stat
107
- // only the (few) matches to drop directory entries.
104
+ // Walk only the directory part of the prefix: a tenant-scoped sweep must
105
+ // not traverse every other tenant's files in a shared storage root.
106
+ // Keys are normalized to "/" (storage keys are always "/"-joined,
107
+ // matching S3) before the final prefix match.
108
+ const prefixDir = prefix.slice(0, prefix.lastIndexOf("/") + 1);
109
+ const walkRoot = prefixDir === "" ? resolvedBase : resolveContainedPath(prefixDir);
108
110
  let entries;
109
111
  try {
110
- entries = await readdir(resolvedBase, { recursive: true });
112
+ entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
111
113
  }
112
114
  catch (err) {
113
- if (err.code === "ENOENT")
115
+ const code = err.code;
116
+ if (code === "ENOENT" || code === "ENOTDIR")
114
117
  return [];
115
118
  throw err;
116
119
  }
117
120
  const results = [];
118
121
  for (const entry of entries) {
119
- const key = entry.split(sep).join("/");
120
- if (!key.startsWith(prefix))
122
+ if (!entry.isFile())
121
123
  continue;
122
- if ((await stat(join(resolvedBase, entry))).isFile())
124
+ const key = relative(resolvedBase, join(entry.parentPath, entry.name)).split(sep).join("/");
125
+ if (key.startsWith(prefix))
123
126
  results.push(key);
124
127
  }
125
128
  return results;
@@ -50,8 +50,27 @@ export async function createFileProviderForTenant(ctx, tenantId, handlerName = "
50
50
  throw new Error(`${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
51
51
  `extension options must be a FileProviderPlugin.`);
52
52
  }
53
- return usage.options.build(ctx, tenantId);
53
+ const built = await usage.options.build(ctx, tenantId);
54
+ // Extension payloads are never type-checked, so a plugin predating a
55
+ // required method (list) would otherwise surface as a misleading
56
+ // "missing s3:ListBucket permission" hint during erasure.
57
+ for (const method of REQUIRED_PROVIDER_METHODS) {
58
+ if (typeof built[method] !== "function") {
59
+ throw new Error(`${FEATURE_NAME}: provider "${provider}" built without the required method ${method}() — ` +
60
+ `update the file-provider plugin to the current FileStorageProvider contract.`);
61
+ }
62
+ }
63
+ return built;
54
64
  }
65
+ const REQUIRED_PROVIDER_METHODS = [
66
+ "write",
67
+ "writeStream",
68
+ "read",
69
+ "readStream",
70
+ "delete",
71
+ "exists",
72
+ "list",
73
+ ];
55
74
  // Builds the resolver from the ambient AppContext fields — the framework-side
56
75
  // equivalent of the GDPR `makeTenantStorageProviderResolver`. The config
57
76
  // accessor (and therefore the s3.secretAccessKey secret-read) runs under
@@ -207,6 +207,15 @@ export function validateFileContent(fileName, content, accept) {
207
207
  const ext = extractExtension(fileName);
208
208
  if (!ext)
209
209
  return { kind: "ok" };
210
+ // validateFile accepts aliased declared MIMEs (csv as vnd.ms-excel) on
211
+ // metadata alone, so the byte check has to live on this exported path too:
212
+ // a text-format extension with an alias must never carry a binary signature.
213
+ if (Object.hasOwn(EXTENSION_MIME_ALIASES, ext) && sniffMimeType(content) !== null) {
214
+ return {
215
+ kind: "rejected",
216
+ error: `content_mismatch: ".${ext}" upload bytes are a binary format`,
217
+ };
218
+ }
210
219
  const signatureMimeTypes = signatureMimeTypesForExtension(ext);
211
220
  if (signatureMimeTypes.length === 0)
212
221
  return { kind: "ok" };
@@ -261,9 +270,15 @@ extensionOverride) {
261
270
  const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
262
271
  return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
263
272
  }
273
+ const EXPORTS_PREFIX_SEGMENT = "exports";
264
274
  /** Fixed leading segment so one S3 lifecycle rule (Prefix: "exports/") can expire export bundles for every tenant without matching a buildStorageKey() upload. */
265
275
  export function tenantExportPrefix(tenantId) {
266
- return `exports/${tenantId}/`;
276
+ // A tenant named like the reserved segment would make the sweep prefix
277
+ // "exports/" match every tenant's export bundles.
278
+ if (tenantId === EXPORTS_PREFIX_SEGMENT) {
279
+ throw new Error(`tenantId "${tenantId}" collides with the reserved storage segment`);
280
+ }
281
+ return `${EXPORTS_PREFIX_SEGMENT}/${tenantId}/`;
267
282
  }
268
283
  /**
269
284
  * Every storage-key prefix a tenant's binaries can live under. A tenant-destroy
@@ -1,7 +1,8 @@
1
1
  export declare const DEFAULT_LOCALE = "en";
2
2
  export declare function isValidLocaleTag(value: string): boolean;
3
- /** BCP-47 is case-insensitive; canonicalize the primary subtag to lowercase
4
- * so registry lookups (`mailT`, `hasMailTranslations`) hit registered keys. */
3
+ /** BCP-47 is case-insensitive; canonicalize to the RFC 5646 conventional form
4
+ * (`de-AT`, `zh-Hant-TW`) so registry lookups (`mailT`, `hasMailTranslations`)
5
+ * hit registered keys regardless of header casing. */
5
6
  export declare function canonicalizeLocaleTag(tag: string): string;
6
7
  /**
7
8
  * Picks the best well-formed tag from an Accept-Language header (RFC 9110
@@ -14,13 +14,23 @@ const MAX_LOCALE_TAG_LENGTH = 35;
14
14
  export function isValidLocaleTag(value) {
15
15
  return value.length <= MAX_LOCALE_TAG_LENGTH && LOCALE_TAG_RE.test(value);
16
16
  }
17
- /** BCP-47 is case-insensitive; canonicalize the primary subtag to lowercase
18
- * so registry lookups (`mailT`, `hasMailTranslations`) hit registered keys. */
17
+ /** BCP-47 is case-insensitive; canonicalize to the RFC 5646 conventional form
18
+ * (`de-AT`, `zh-Hant-TW`) so registry lookups (`mailT`, `hasMailTranslations`)
19
+ * hit registered keys regardless of header casing. */
19
20
  export function canonicalizeLocaleTag(tag) {
20
- const dash = tag.indexOf("-");
21
- if (dash === -1)
22
- return tag.toLowerCase();
23
- return `${tag.slice(0, dash).toLowerCase()}${tag.slice(dash)}`;
21
+ return tag
22
+ .split("-")
23
+ .map((subtag, index) => {
24
+ if (index === 0)
25
+ return subtag.toLowerCase();
26
+ if (/^[A-Za-z]{2}$/.test(subtag))
27
+ return subtag.toUpperCase();
28
+ if (/^[A-Za-z]{4}$/.test(subtag)) {
29
+ return subtag.charAt(0).toUpperCase() + subtag.slice(1).toLowerCase();
30
+ }
31
+ return subtag.toLowerCase();
32
+ })
33
+ .join("-");
24
34
  }
25
35
  /**
26
36
  * Picks the best well-formed tag from an Accept-Language header (RFC 9110
@@ -46,6 +46,24 @@ function pushSectionTitles(out, section, treatAsKey = false) {
46
46
  for (const group of section.groups ?? [])
47
47
  pushKey(out, group.title, treatAsKey);
48
48
  }
49
+ // Field labels and section titles of a step rendered through the action-form shim.
50
+ function pushActionFormShapedKeys(out, featureName, step) {
51
+ for (const fieldName of Object.keys(step.fields)) {
52
+ out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
53
+ }
54
+ for (const section of step.layout.sections) {
55
+ if (isExtensionEditSection(section)) {
56
+ pushKey(out, section.title);
57
+ continue;
58
+ }
59
+ if (section.kind === "relatedList")
60
+ continue; // rejected at boot, unreachable here
61
+ pushSectionTitles(out, section);
62
+ for (const f of sectionFieldSpecs(section)) {
63
+ out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, editFieldName(f)));
64
+ }
65
+ }
66
+ }
49
67
  function pushRowActionKeys(out, action) {
50
68
  pushKey(out, action.label);
51
69
  if (action.kind === "writeHandler" || action.kind === undefined) {
@@ -242,26 +260,9 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
242
260
  break;
243
261
  }
244
262
  case "secretMint": {
245
- // Mirrors the actionForm case above (submitLabel, fields/section titles
246
- // via ACTION_FORM_ENTITY) plus the reveal-phase's own i18n surface.
247
263
  const mint = screen;
248
264
  pushKey(out, mint.submitLabel);
249
- for (const fieldName of Object.keys(mint.fields)) {
250
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
251
- }
252
- for (const section of mint.layout.sections) {
253
- if (isExtensionEditSection(section)) {
254
- pushKey(out, section.title);
255
- continue;
256
- }
257
- if (section.kind === "relatedList")
258
- continue; // rejected at boot, unreachable here
259
- pushSectionTitles(out, section);
260
- for (const f of sectionFieldSpecs(section)) {
261
- const fieldName = editFieldName(f);
262
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
263
- }
264
- }
265
+ pushActionFormShapedKeys(out, featureName, mint);
265
266
  pushKey(out, mint.reveal.title);
266
267
  pushKey(out, mint.reveal.warning);
267
268
  pushKey(out, mint.reveal.confirmLabel);
@@ -271,22 +272,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
271
272
  if (mint.confirm !== undefined) {
272
273
  pushKey(out, mint.confirm.submitLabel);
273
274
  pushKey(out, mint.confirm.doneMessage);
274
- for (const fieldName of Object.keys(mint.confirm.fields)) {
275
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
276
- }
277
- for (const section of mint.confirm.layout.sections) {
278
- if (isExtensionEditSection(section)) {
279
- pushKey(out, section.title);
280
- continue;
281
- }
282
- if (section.kind === "relatedList")
283
- continue; // rejected at boot, unreachable here
284
- pushSectionTitles(out, section);
285
- for (const f of sectionFieldSpecs(section)) {
286
- const fieldName = editFieldName(f);
287
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
288
- }
289
- }
275
+ pushActionFormShapedKeys(out, featureName, mint.confirm);
290
276
  }
291
277
  break;
292
278
  }
@@ -353,6 +339,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
353
339
  }
354
340
  if (isWriteFormEditSection(section)) {
355
341
  pushKey(out, section.title);
342
+ pushKey(out, section.description);
356
343
  pushKey(out, section.submitLabel);
357
344
  // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
358
345
  for (const f of section.fields) {
@@ -404,7 +391,8 @@ function collectEntityListFilterKeys(feature, out) {
404
391
  out.add(key);
405
392
  }
406
393
  }
407
- else if (def.type === "select" && Array.isArray(def.options)) {
394
+ else if ((def.type === "select" || def.type === "multiSelect") &&
395
+ Array.isArray(def.options)) {
408
396
  for (const value of def.options) {
409
397
  out.add(selectFacetOptionKey(feature.name, screen.entity, fieldName, value));
410
398
  }
@@ -39,5 +39,8 @@ export const SETTINGS_HUB_I18N = {
39
39
  "config.secrets.placeholder": { en: "Enter a value" },
40
40
  "config.secrets.replacePlaceholder": { en: "Enter a new value to replace it" },
41
41
  "config.secrets.delete": { en: "Remove" },
42
+ "config.secrets.deleteConfirm": {
43
+ en: "Remove this secret? Anything using it stops working, and it cannot be restored.",
44
+ },
42
45
  "screen:secrets.title": { en: "Secrets" },
43
46
  };
@@ -1,2 +1,2 @@
1
- export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, } from "./job-runner.js";
2
- export { createJobRunner } from "./job-runner.js";
1
+ export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
2
+ export { createJobRunner, serializeJobSubject } from "./job-runner.js";
@@ -1 +1 @@
1
- export { createJobRunner } from "./job-runner.js";
1
+ export { createJobRunner, serializeJobSubject } from "./job-runner.js";
@@ -28,6 +28,8 @@ export type JobOutcomeMeta = {
28
28
  readonly messageKey: string | null;
29
29
  } | undefined;
30
30
  };
31
+ export type JobSubjectValue = string | number | boolean | null;
32
+ export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
31
33
  export type JobRunner = {
32
34
  start(): Promise<void>;
33
35
  stop(): Promise<void>;