@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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 (157) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/entity-table-meta.js +5 -3
  23. package/dist/db/event-store-executor-context.js +13 -19
  24. package/dist/db/event-store-executor-read.js +39 -36
  25. package/dist/db/event-store-executor-write.js +15 -17
  26. package/dist/db/migrate-generator.js +27 -28
  27. package/dist/db/pg-error.js +9 -5
  28. package/dist/db/queries/backfill-pii.js +15 -5
  29. package/dist/db/queries/shadow-swap.js +5 -4
  30. package/dist/db/table-builder.d.ts +1 -0
  31. package/dist/db/table-builder.js +7 -15
  32. package/dist/db/tenant-db.js +18 -2
  33. package/dist/derivatives/variant-key.js +3 -1
  34. package/dist/engine/boot-validator/access-declarations.js +12 -3
  35. package/dist/engine/boot-validator/entity-handler.js +13 -1
  36. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  37. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  38. package/dist/engine/boot-validator/index.js +6 -6
  39. package/dist/engine/boot-validator/nav.d.ts +2 -1
  40. package/dist/engine/boot-validator/nav.js +10 -20
  41. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  42. package/dist/engine/boot-validator/ownership.js +2 -1
  43. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  44. package/dist/engine/boot-validator/pii-retention.js +5 -2
  45. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  46. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  47. package/dist/engine/boot-validator/screens.d.ts +2 -2
  48. package/dist/engine/boot-validator/screens.js +103 -33
  49. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  50. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  51. package/dist/engine/boot-validator/zod-shape.js +17 -4
  52. package/dist/engine/build-app-schema.js +62 -19
  53. package/dist/engine/build-config-feature-schema.js +8 -1
  54. package/dist/engine/changeset-changes.js +24 -10
  55. package/dist/engine/feature-ast/extractors/events.js +11 -8
  56. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  57. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  58. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  59. package/dist/engine/feature-ast/parse.d.ts +1 -0
  60. package/dist/engine/feature-ast/parse.js +18 -3
  61. package/dist/engine/feature-ast/patch.js +80 -61
  62. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  63. package/dist/engine/feature-ast/patcher.js +2 -1
  64. package/dist/engine/feature-ast/render.js +1 -1
  65. package/dist/engine/index.d.ts +4 -3
  66. package/dist/engine/index.js +3 -2
  67. package/dist/engine/ownership.d.ts +1 -0
  68. package/dist/engine/ownership.js +15 -2
  69. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  70. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  71. package/dist/engine/pattern-library/shared-fields.js +17 -0
  72. package/dist/engine/registry-facade.js +3 -0
  73. package/dist/engine/role-assignment.d.ts +7 -1
  74. package/dist/engine/role-assignment.js +27 -7
  75. package/dist/engine/schema-builder.d.ts +1 -0
  76. package/dist/engine/schema-builder.js +9 -2
  77. package/dist/engine/screen-helpers.js +15 -4
  78. package/dist/engine/steps/_event-match.js +22 -3
  79. package/dist/engine/steps/_read-source.js +8 -0
  80. package/dist/engine/where-rule-lint.js +22 -4
  81. package/dist/env/index.js +21 -1
  82. package/dist/errors/reasons.d.ts +0 -1
  83. package/dist/errors/reasons.js +2 -5
  84. package/dist/event-store/event-store.js +6 -1
  85. package/dist/event-store/provenance-append.js +4 -1
  86. package/dist/files/file-routes.d.ts +0 -1
  87. package/dist/files/file-routes.js +22 -7
  88. package/dist/files/local-provider.js +13 -10
  89. package/dist/files/provider-resolver.js +20 -1
  90. package/dist/files/types.js +16 -1
  91. package/dist/i18n/request-locale.d.ts +3 -2
  92. package/dist/i18n/request-locale.js +16 -6
  93. package/dist/i18n/required-surface-keys.js +23 -35
  94. package/dist/i18n/settings-hub-keys.js +3 -0
  95. package/dist/jobs/index.d.ts +2 -2
  96. package/dist/jobs/index.js +1 -1
  97. package/dist/jobs/job-runner.d.ts +2 -0
  98. package/dist/jobs/job-runner.js +150 -59
  99. package/dist/migrations/index.d.ts +1 -1
  100. package/dist/migrations/index.js +1 -1
  101. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  102. package/dist/migrations/pending-rebuilds.js +23 -1
  103. package/dist/observability/escape-hatch-report.d.ts +1 -0
  104. package/dist/observability/escape-hatch-report.js +3 -0
  105. package/dist/observability/index.d.ts +1 -0
  106. package/dist/observability/index.js +1 -0
  107. package/dist/pipeline/dispatch-query.d.ts +1 -1
  108. package/dist/pipeline/dispatch-query.js +2 -2
  109. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  110. package/dist/pipeline/dispatch-shared.js +14 -9
  111. package/dist/pipeline/dispatch-stream.js +2 -5
  112. package/dist/pipeline/dispatch-write.js +22 -5
  113. package/dist/pipeline/dispatcher.js +1 -2
  114. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  115. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  116. package/dist/pipeline/event-dispatcher.js +26 -11
  117. package/dist/pipeline/index.d.ts +1 -1
  118. package/dist/pipeline/index.js +1 -1
  119. package/dist/pipeline/member-read-only-transaction.js +1 -14
  120. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  121. package/dist/pipeline/msp-rebuild.js +1 -0
  122. package/dist/pipeline/system-hooks.d.ts +1 -1
  123. package/dist/pipeline/system-hooks.js +9 -7
  124. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  125. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  126. package/dist/redis/pubsub-signal.d.ts +2 -0
  127. package/dist/redis/pubsub-signal.js +2 -2
  128. package/dist/schema-cli.js +4 -14
  129. package/dist/search/meilisearch-adapter.js +17 -6
  130. package/dist/search/purge-subject.js +8 -3
  131. package/dist/stack/drain-jobs.js +24 -12
  132. package/dist/stack/table-helpers.js +10 -8
  133. package/dist/testing/closed-connection-error.js +2 -3
  134. package/dist/testing/wait-for.js +1 -0
  135. package/dist/ui-types/index.d.ts +1 -1
  136. package/dist/ui-types/index.js +1 -1
  137. package/dist/upgrade-cli.d.ts +1 -0
  138. package/dist/upgrade-cli.js +36 -15
  139. package/package.json +4 -4
  140. package/src/changes.json +206 -0
  141. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  142. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  143. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  144. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  145. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  146. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  147. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  148. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  149. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  150. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  151. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  152. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  153. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  154. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  155. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  156. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  157. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -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>;