@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
@@ -3,6 +3,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -78,6 +81,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
78
81
  readonly type: "text";
79
82
  readonly description?: string;
80
83
  readonly maxLength?: number;
84
+ readonly minLength?: number;
81
85
  readonly required?: boolean;
82
86
  readonly searchable?: boolean;
83
87
  readonly sortable?: boolean;
@@ -99,6 +103,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
99
103
  readonly type: "text";
100
104
  readonly description?: string;
101
105
  readonly maxLength?: number;
106
+ readonly minLength?: number;
102
107
  readonly required?: boolean;
103
108
  readonly searchable?: boolean;
104
109
  readonly sortable?: boolean;
@@ -120,6 +125,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
120
125
  readonly type: "text";
121
126
  readonly description?: string;
122
127
  readonly maxLength?: number;
128
+ readonly minLength?: number;
123
129
  readonly required?: boolean;
124
130
  readonly searchable?: boolean;
125
131
  readonly sortable?: boolean;
@@ -3,6 +3,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -78,6 +81,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
78
81
  readonly type: "text";
79
82
  readonly description?: string;
80
83
  readonly maxLength?: number;
84
+ readonly minLength?: number;
81
85
  readonly required?: boolean;
82
86
  readonly searchable?: boolean;
83
87
  readonly sortable?: boolean;
@@ -99,6 +103,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
99
103
  readonly type: "text";
100
104
  readonly description?: string;
101
105
  readonly maxLength?: number;
106
+ readonly minLength?: number;
102
107
  readonly required?: boolean;
103
108
  readonly searchable?: boolean;
104
109
  readonly sortable?: boolean;
@@ -120,6 +125,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
120
125
  readonly type: "text";
121
126
  readonly description?: string;
122
127
  readonly maxLength?: number;
128
+ readonly minLength?: number;
123
129
  readonly required?: boolean;
124
130
  readonly searchable?: boolean;
125
131
  readonly sortable?: boolean;
@@ -13,7 +13,7 @@ import { buildContentDispositionHeader } from "./content-disposition.js";
13
13
  import { createFileContext } from "./file-handle.js";
14
14
  import { fileRefEntity } from "./file-ref-entity.js";
15
15
  import { fileRefsTable } from "./file-ref-table.js";
16
- import { resolveContentType } from "./resolve-content-type.js";
16
+ import { neutralFileNameForMimeType, resolveContentType } from "./resolve-content-type.js";
17
17
  import { buildStorageKey, parseMaxSize, resolveServedContentType, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
18
18
  const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
19
19
  // 15 minutes — long enough for a download to start, short enough that a
@@ -47,7 +47,7 @@ function resolveAttachedField(registry, entityType, fieldName) {
47
47
  return { kind: "unattached" };
48
48
  const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
49
49
  const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
50
- if (!entityType || !fieldName || !entity || !fieldDef)
50
+ if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
51
51
  return { kind: "unresolvable" };
52
52
  return { kind: "resolved", fieldDef };
53
53
  }
@@ -97,6 +97,7 @@ export function createFileRoutes(options) {
97
97
  const api = new Hono();
98
98
  const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
99
99
  const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
100
+ const fileNameIsPersonal = piiSubjectFields.includes("fileName");
100
101
  function kmsContextFor() {
101
102
  return { requestId: requestContext.get()?.requestId ?? "file-routes" };
102
103
  }
@@ -168,10 +169,10 @@ export function createFileRoutes(options) {
168
169
  const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
169
170
  if (attachedField.kind === "unresolvable") {
170
171
  return c.json({
171
- error: "unresolvable_field: entityType/fieldName must resolve to a registered entity field, or both must be omitted for an unattached upload",
172
+ error: "unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload",
172
173
  }, 400);
173
174
  }
174
- if (attachedField.kind === "resolved" && isFileField(attachedField.fieldDef)) {
175
+ if (attachedField.kind === "resolved") {
175
176
  if (attachedField.fieldDef.maxSize)
176
177
  maxSize = attachedField.fieldDef.maxSize;
177
178
  if (attachedField.fieldDef.accept)
@@ -311,7 +312,7 @@ export function createFileRoutes(options) {
311
312
  }
312
313
  // Built per request: createFileContext caches the resolved provider, so
313
314
  // one shared across requests would serve tenant A's store to tenant B.
314
- const files = createFileContext(() => options.resolveProvider(user.tenantId));
315
+ const files = createFileContext(() => options.resolveProvider(user.tenantId), user.tenantId);
315
316
  const derivatives = createDerivativesContext({
316
317
  files,
317
318
  registry,
@@ -397,10 +398,15 @@ export function createFileRoutes(options) {
397
398
  const expiresInSeconds = SIGNED_URL_DEFAULT_EXPIRY_SECONDS;
398
399
  const url = await storageProvider.getSignedUrl(fileRef.storageKey, expiresInSeconds, {
399
400
  // Hint the provider to set Content-Disposition so the browser prompts
400
- // with the original filename instead of the UUID-based storage key.
401
+ // with a readable filename instead of the UUID-based storage key.
401
402
  // Sanitised via buildContentDispositionHeader — the same attacker-
402
403
  // controlled fileName reaches the provider's presigned response.
403
- contentDisposition: buildContentDispositionHeader(await resolveFileName(fileRef)),
404
+ // A personal fileName never goes into the URL: the signed query string
405
+ // lands in storage access logs and proxies and outlives a crypto-shred.
406
+ // The streaming route keeps the real name in a response header only.
407
+ contentDisposition: buildContentDispositionHeader(fileNameIsPersonal
408
+ ? neutralFileNameForMimeType(fileRef.mimeType)
409
+ : await resolveFileName(fileRef)),
404
410
  });
405
411
  const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
406
412
  return c.json({ url, expiresAt });
@@ -1,3 +1,5 @@
1
+ /** A name derived from the MIME type alone, for places a personal file name must not appear. */
2
+ export declare function neutralFileNameForMimeType(mimeType: string): string;
1
3
  /**
2
4
  * Content type for a file whose sender may not have declared a useful one
3
5
  * (mail attachments commonly arrive as application/octet-stream). A concrete
@@ -26,6 +26,14 @@ const EXTENSION_FALLBACK = {
26
26
  csv: "text/csv",
27
27
  txt: "text/plain",
28
28
  };
29
+ const EXTENSION_BY_MIME_TYPE = new Map(Object.entries(EXTENSION_FALLBACK)
30
+ .reverse()
31
+ .map(([extension, mime]) => [mime, extension]));
32
+ /** A name derived from the MIME type alone, for places a personal file name must not appear. */
33
+ export function neutralFileNameForMimeType(mimeType) {
34
+ const extension = EXTENSION_BY_MIME_TYPE.get(normalizeMimeType(mimeType));
35
+ return extension === undefined ? "download" : `download.${extension}`;
36
+ }
29
37
  function hasPrefix(bytes, prefix) {
30
38
  return prefix.every((byte, i) => bytes[i] === byte);
31
39
  }
@@ -75,6 +75,7 @@ function extensionsForMimeType(mimeType) {
75
75
  // application/octet-stream in resolveServedContentType below — this also
76
76
  // guarantees text/html and image/svg+xml are never served inline, even for
77
77
  // an honestly-declared upload.
78
+ const DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
78
79
  const MAGIC_BYTE_SIGNATURES = [
79
80
  {
80
81
  mimeType: "image/png",
@@ -99,10 +100,22 @@ const MAGIC_BYTE_SIGNATURES = [
99
100
  // ZIP local-file-header signature — .docx/.xlsx/.pptx/plain .zip all share
100
101
  // it; same caveat as OLE above.
101
102
  {
102
- mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
103
+ mimeType: DOCX_MIME_TYPE,
103
104
  matches: (bytes) => startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]),
104
105
  },
105
106
  ];
107
+ // ZIP entry names are stored uncompressed in the local and central headers,
108
+ // so a byte search finds the docx part directory without unzipping. The main
109
+ // part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
110
+ const DOCX_PART_PREFIX = "word/";
111
+ function containsAscii(bytes, ascii) {
112
+ const first = ascii.charCodeAt(0);
113
+ for (let i = bytes.indexOf(first); i !== -1; i = bytes.indexOf(first, i + 1)) {
114
+ if (startsWithAscii(bytes, ascii, i))
115
+ return true;
116
+ }
117
+ return false;
118
+ }
106
119
  function startsWithBytes(bytes, signature) {
107
120
  if (bytes.length < signature.length)
108
121
  return false;
@@ -220,6 +233,13 @@ export function validateFileContent(fileName, content, accept) {
220
233
  if (signatureMimeTypes.length === 0)
221
234
  return { kind: "ok" };
222
235
  const sniffed = sniffMimeType(content);
236
+ // sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
237
+ if (sniffed === DOCX_MIME_TYPE && !containsAscii(content, DOCX_PART_PREFIX)) {
238
+ return {
239
+ kind: "rejected",
240
+ error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
241
+ };
242
+ }
223
243
  if (sniffed && signatureMimeTypes.includes(sniffed))
224
244
  return { kind: "ok" };
225
245
  if (sniffed) {
@@ -10,6 +10,8 @@ export declare const PROJECTION_DETAIL_ENTITY = "__projection-detail__";
10
10
  * drawn from the host record's display entity). */
11
11
  export declare const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
12
12
  export declare function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string;
13
+ export declare function handlerTitleKey(handlerQn: string): string;
14
+ export declare function handlerFieldLabelKey(handlerQn: string, fieldName: string): string;
13
15
  export declare function booleanFacetOptionKeys(featureName: string, entityName: string, fieldName: string): readonly string[];
14
16
  export declare function selectFacetOptionKey(featureName: string, entityName: string, fieldName: string, value: string): string;
15
17
  export declare function screenTitleKey(screenId: string): string;
@@ -13,6 +13,12 @@ export const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
13
13
  export function fieldLabelKey(featureName, entityName, fieldName) {
14
14
  return `${featureName}:entity:${entityName}:field:${fieldName}`;
15
15
  }
16
+ export function handlerTitleKey(handlerQn) {
17
+ return `${handlerQn}:title`;
18
+ }
19
+ export function handlerFieldLabelKey(handlerQn, fieldName) {
20
+ return `${handlerQn}:field:${fieldName}`;
21
+ }
16
22
  export function booleanFacetOptionKeys(featureName, entityName, fieldName) {
17
23
  const base = `${featureName}:entity:${entityName}:field:${fieldName}:option`;
18
24
  return [`${base}:true`, `${base}:false`];
@@ -116,6 +122,8 @@ function pushDashboardPanelKeys(out, panel) {
116
122
  function pushDashboardChartRangeKeys(out, panel) {
117
123
  for (const option of panel.ranges?.options ?? [])
118
124
  pushKey(out, option.label);
125
+ for (const kind of Object.values(panel.markerKinds ?? {}))
126
+ pushKey(out, kind.label);
119
127
  }
120
128
  function pushDashboardFilterKeys(out, filter) {
121
129
  pushKey(out, filter.label);
@@ -1,2 +1,2 @@
1
- export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
1
+ export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
2
2
  export { createJobRunner, serializeJobSubject } from "./job-runner.js";
@@ -19,6 +19,14 @@ export type JobMeta = {
19
19
  payload?: string | undefined;
20
20
  attempt?: number | undefined;
21
21
  priority?: number | undefined;
22
+ tenantVisibleRun?: {
23
+ readonly tenantId: string;
24
+ readonly subject: string | null;
25
+ } | undefined;
26
+ };
27
+ export type JobQueuedMeta = {
28
+ readonly tenantId: string;
29
+ readonly subject: string | null;
22
30
  };
23
31
  export type JobOutcomeMeta = {
24
32
  readonly tenantId: string;
@@ -27,6 +35,9 @@ export type JobOutcomeMeta = {
27
35
  readonly subject: string | null;
28
36
  readonly messageKey: string | null;
29
37
  } | undefined;
38
+ readonly tenantVisibleRun?: {
39
+ readonly subject: string | null;
40
+ } | undefined;
30
41
  };
31
42
  export type JobSubjectValue = string | number | boolean | null;
32
43
  export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
@@ -53,5 +64,7 @@ export type JobRunnerOptions = {
53
64
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
54
65
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
55
66
  onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
67
+ onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
68
+ onJobDropped?: (jobName: string, jobId: string) => void;
56
69
  };
57
70
  export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
@@ -134,14 +134,14 @@ export function serializeJobSubject(subject) {
134
134
  // with the same subject values produce the same string and the later one
135
135
  // replaces the earlier record. Non-primitives would serialize into something
136
136
  // no caller can reconstruct for a lookup, so they fail the run loudly.
137
- function jobSubjectKey(jobName, payload, fields) {
137
+ function jobSubjectKey(jobName, payload, fields, declaration) {
138
138
  if (fields === undefined || fields.length === 0)
139
139
  return null;
140
140
  const subject = {};
141
141
  for (const field of fields) {
142
142
  const value = payload[field] ?? null;
143
143
  if (value !== null && typeof value === "object") {
144
- throw new Error(`Job "${jobName}": tenantVisibleFailure.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
144
+ throw new Error(`Job "${jobName}": ${declaration}.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
145
145
  }
146
146
  subject[field] = value;
147
147
  }
@@ -516,12 +516,72 @@ export function createJobRunner(options) {
516
516
  }
517
517
  return false;
518
518
  }
519
+ // Only err.message, never payload or the logs array: job payloads can carry PII.
520
+ function logFailedAttempt(entry) {
521
+ errorLogger.error("job failed", {
522
+ job: entry.job,
523
+ jobId: entry.jobId,
524
+ attempt: entry.attempt,
525
+ // BullMQ never retries an UnrecoverableError, whatever attempts remain.
526
+ final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
527
+ error: entry.err instanceof Error ? entry.err.message : String(entry.err),
528
+ });
529
+ }
519
530
  async function reportPreRunFailure(jobName, bullJob, err) {
520
531
  const message = err instanceof Error ? err.message : String(err);
521
532
  await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
522
533
  { level: "error", message, timestamp: Temporal.Now.instant() },
523
534
  ]);
524
535
  }
536
+ function handlerPayloadOf(data) {
537
+ const payload = {};
538
+ for (const [k, v] of Object.entries(data)) {
539
+ if (!k.startsWith("_"))
540
+ payload[k] = v;
541
+ }
542
+ return payload;
543
+ }
544
+ function tenantIdOfJobData(data) {
545
+ return (data["_tenantId"] ?? // @cast-boundary dynamic-key
546
+ data["tenantId"] ?? // @cast-boundary dynamic-key
547
+ SYSTEM_TENANT_ID);
548
+ }
549
+ // Tenant-visible queued state. A callback or subject problem must never fail
550
+ // the enqueue; a bad subject config surfaces as a failed run instead.
551
+ async function announceQueued(jobName, jobDef, bullJobId, data) {
552
+ const declaration = jobDef.tenantVisibleRun;
553
+ // skip: job did not opt in, no logger wired, or BullMQ returned no id
554
+ if (!declaration || !options.onJobQueued || bullJobId === undefined)
555
+ return;
556
+ const tenantId = tenantIdOfJobData(data);
557
+ // skip: tenant-less run — no tenant-scoped query could ever read it
558
+ if (tenantId === SYSTEM_TENANT_ID)
559
+ return;
560
+ try {
561
+ const subject = jobSubjectKey(jobName, handlerPayloadOf(data), declaration.subjectFields, "tenantVisibleRun");
562
+ await options.onJobQueued(jobName, bullJobId, { tenantId, subject });
563
+ }
564
+ catch (err) {
565
+ errorLogger.warn("tenant-visible queued state not recorded", {
566
+ job: jobName,
567
+ error: err instanceof Error ? err.message : String(err),
568
+ });
569
+ }
570
+ }
571
+ async function announceDropped(jobName, jobDef, bullJobId) {
572
+ // skip: job did not opt in or no logger wired
573
+ if (!jobDef.tenantVisibleRun || !options.onJobDropped)
574
+ return;
575
+ try {
576
+ await options.onJobDropped(jobName, bullJobId);
577
+ }
578
+ catch (err) {
579
+ errorLogger.warn("tenant-visible queued state not cleared", {
580
+ job: jobName,
581
+ error: err instanceof Error ? err.message : String(err),
582
+ });
583
+ }
584
+ }
525
585
  async function handleJob(bullJob) {
526
586
  const rawName = bullJob.name;
527
587
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
@@ -556,7 +616,8 @@ export function createJobRunner(options) {
556
616
  // for that edge case instead: enqueue plainly, same as before.
557
617
  const wrapperJobId = bullJob.id;
558
618
  for (const tenantId of tenantIds) {
559
- await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
619
+ const childData = { ...bullJob.data, _tenantId: tenantId };
620
+ const child = await targetQueue.add(actualName, childData, {
560
621
  ...buildRetryBullOpts(actualDef),
561
622
  // Dedup over wrapper retries only holds as long as the children
562
623
  // stay in Redis for the wrapper's whole retry window — see the
@@ -565,13 +626,22 @@ export function createJobRunner(options) {
565
626
  ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
566
627
  : {}),
567
628
  });
629
+ await announceQueued(actualName, actualDef, child.id, childData);
568
630
  }
569
631
  }
570
632
  catch (err) {
571
633
  // A retry may still succeed, and a succeeding wrapper reports no completion
572
634
  // that could clear an earlier failure, so only the final attempt is reported.
573
635
  const retries = allJobs.get(actualName)?.retries ?? 0;
574
- if (bullJob.attemptsMade + 1 >= retries + 1) {
636
+ const finalAttempt = bullJob.attemptsMade + 1 >= retries + 1;
637
+ logFailedAttempt({
638
+ job: rawName,
639
+ jobId: bullJob.id ?? "unknown",
640
+ attempt: bullJob.attemptsMade + 1,
641
+ finalAttempt,
642
+ err,
643
+ });
644
+ if (finalAttempt) {
575
645
  await reportPreRunFailure(rawName, bullJob, err);
576
646
  }
577
647
  throw err;
@@ -583,6 +653,14 @@ export function createJobRunner(options) {
583
653
  const jobDef = allJobs.get(jobName);
584
654
  if (!jobDef) {
585
655
  const err = new Error(`Unknown job: ${jobName}`);
656
+ // Final regardless of attempts left: a retry cannot make the job exist.
657
+ logFailedAttempt({
658
+ job: jobName,
659
+ jobId: bullJob.id ?? "unknown",
660
+ attempt: bullJob.attemptsMade + 1,
661
+ finalAttempt: true,
662
+ err,
663
+ });
586
664
  await reportPreRunFailure(jobName, bullJob, err);
587
665
  throw err;
588
666
  }
@@ -605,10 +683,15 @@ export function createJobRunner(options) {
605
683
  // Attempts already spent travel in _priorAttempts so a lock conflict
606
684
  // on a retry cannot hand out a fresh budget and exceed `retries`.
607
685
  const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
608
- await queues[laneForJob(jobDef)].add(jobName, { ...bullJob.data, _priorAttempts: priorAttempts }, {
686
+ const requeuedData = { ...bullJob.data, _priorAttempts: priorAttempts };
687
+ const requeued = await queues[laneForJob(jobDef)].add(jobName, requeuedData, {
609
688
  delay: SEQUENTIAL_RETRY_DELAY_MS,
610
689
  ...buildRetryBullOpts(jobDef, priorAttempts),
611
690
  });
691
+ if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
692
+ await announceDropped(jobName, jobDef, bullJob.id);
693
+ }
694
+ await announceQueued(jobName, jobDef, requeued.id, requeuedData);
612
695
  // skip: lock taken, work re-enqueued with delay, current invocation done
613
696
  return;
614
697
  }
@@ -629,27 +712,31 @@ export function createJobRunner(options) {
629
712
  attempt,
630
713
  };
631
714
  // Build handler payload (without internal meta fields)
632
- const payload = {};
633
- for (const [k, v] of Object.entries(rawData)) {
634
- if (!k.startsWith("_"))
635
- payload[k] = v;
636
- }
715
+ const payload = handlerPayloadOf(rawData);
637
716
  // Determine tenantId and triggeredBy from meta
638
- const tenantId = rawData["_tenantId"] ?? // @cast-boundary dynamic-key
639
- payload["tenantId"] ?? // @cast-boundary dynamic-key
640
- SYSTEM_TENANT_ID;
717
+ const tenantId = tenantIdOfJobData(rawData);
641
718
  const triggeredById = rawData["_triggeredById"] ?? null; // @cast-boundary dynamic-key
642
719
  // Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
643
720
  // subject is read from the handler payload, so it is resolved here where
644
721
  // the payload is built, not in the callbacks.
645
722
  const tenantVisibleDecl = jobDef.tenantVisibleFailure;
723
+ const tenantVisibleRunDecl = jobDef.tenantVisibleRun;
646
724
  // A bad subject config must surface as a failed run, so it is thrown from
647
725
  // inside runInSpan below instead of here, before any run record exists.
648
726
  let tenantVisibleSubject = null;
727
+ let tenantVisibleRunSubject = null;
649
728
  let subjectConfigError;
650
- if (tenantVisibleDecl) {
729
+ if (tenantVisibleRunDecl) {
651
730
  try {
652
- tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields);
731
+ tenantVisibleRunSubject = jobSubjectKey(jobName, payload, tenantVisibleRunDecl.subjectFields, "tenantVisibleRun");
732
+ }
733
+ catch (err) {
734
+ subjectConfigError = err instanceof Error ? err : new Error(String(err));
735
+ }
736
+ }
737
+ if (tenantVisibleDecl && !subjectConfigError) {
738
+ try {
739
+ tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields, "tenantVisibleFailure");
653
740
  }
654
741
  catch (err) {
655
742
  subjectConfigError = err instanceof Error ? err : new Error(String(err));
@@ -658,13 +745,16 @@ export function createJobRunner(options) {
658
745
  // BullMQ stops retrying once attemptsMade reaches the configured attempts
659
746
  // (`retries + 1`), so this is the attempt whose failure is final.
660
747
  const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
661
- const outcomeMeta = (messageKey) => ({
748
+ const outcomeMeta = (messageKey, unrecoverable = false) => ({
662
749
  tenantId,
663
- finalAttempt,
750
+ // BullMQ never retries an UnrecoverableError, whatever attempts are left.
751
+ finalAttempt: finalAttempt || unrecoverable,
664
752
  ...(tenantVisibleDecl &&
665
753
  !subjectConfigError && {
666
754
  tenantVisible: { subject: tenantVisibleSubject, messageKey },
667
755
  }),
756
+ ...(tenantVisibleRunDecl &&
757
+ !subjectConfigError && { tenantVisibleRun: { subject: tenantVisibleRunSubject } }),
668
758
  });
669
759
  // Carry `_triggerName` from rawData when set — handleEvent injects it on
670
760
  // multi-trigger dispatch; exposed as jobContext.triggerName so handlers
@@ -693,7 +783,9 @@ export function createJobRunner(options) {
693
783
  // through the same _fileProviderResolver for jobs as for write-handlers,
694
784
  // otherwise event-triggered jobs silently get an unresolved ctx.files.
695
785
  const fileResolver = context._fileProviderResolver;
696
- const files = fileResolver ? createFileContext(() => fileResolver(tenantId)) : context.files;
786
+ const files = fileResolver
787
+ ? createFileContext(() => fileResolver(tenantId), tenantId)
788
+ : context.files;
697
789
  const jobSystemUser = createSystemUser(tenantId);
698
790
  // Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
699
791
  // must resolve for jobs the same way they do for write-handlers, or an
@@ -813,6 +905,9 @@ export function createJobRunner(options) {
813
905
  return memberReader(userId, qn, payload);
814
906
  },
815
907
  };
908
+ if (tenantVisibleRunDecl && !subjectConfigError) {
909
+ meta.tenantVisibleRun = { tenantId, subject: tenantVisibleRunSubject };
910
+ }
816
911
  await options.onJobStart?.(jobName, jobId, meta);
817
912
  // Cross-process trace continuation: if the enqueuing code captured a
818
913
  // parent span, start the job.execute span as its child. Works for event
@@ -865,7 +960,8 @@ export function createJobRunner(options) {
865
960
  catch (err) {
866
961
  const errorMsg = err instanceof Error ? err.message : String(err);
867
962
  logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
868
- await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl)));
963
+ logFailedAttempt({ job: jobName, jobId, attempt, finalAttempt, err });
964
+ await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl), err instanceof UnrecoverableError));
869
965
  throw err;
870
966
  }
871
967
  };
@@ -938,8 +1034,7 @@ export function createJobRunner(options) {
938
1034
  ]);
939
1035
  }
940
1036
  catch (err) {
941
- // The worker is already consuming here; leaving its Redis connections
942
- // open would keep the event loop alive and turn the aborted boot into
1037
+ // Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
943
1038
  // a hanging process instead of a non-zero exit.
944
1039
  await worker?.close();
945
1040
  worker = null;
@@ -965,6 +1060,9 @@ export function createJobRunner(options) {
965
1060
  worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
966
1061
  connection: redisOpts,
967
1062
  concurrency: 5,
1063
+ // Not consuming until the boot gates passed: a job already queued from a
1064
+ // previous run must not execute against an unmet boot precondition.
1065
+ autorun: false,
968
1066
  });
969
1067
  worker.on("error", (err) => {
970
1068
  const log = stopping ? errorLogger.debug : errorLogger.error;
@@ -989,6 +1087,11 @@ export function createJobRunner(options) {
989
1087
  // Ahead of cron/runOnBoot wiring on purpose: a failed gate must not
990
1088
  // leave recurring schedulers behind.
991
1089
  await runBootGates(consumerLane);
1090
+ // Floating on purpose: run() resolves only when the worker closes.
1091
+ void worker.run().catch((err) => {
1092
+ const log = stopping ? errorLogger.debug : errorLogger.error;
1093
+ log("worker run loop failed", { error: err instanceof Error ? err.message : String(err) });
1094
+ });
992
1095
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
993
1096
  // assigned to the other lane get their cron/boot wiring from the
994
1097
  // runner running on that lane. Running both here would double-fire.
@@ -1142,6 +1245,7 @@ export function createJobRunner(options) {
1142
1245
  for (const j of waiting) {
1143
1246
  if (j.name === jobName && j.id) {
1144
1247
  await j.remove();
1248
+ await announceDropped(jobName, jobDef, j.id);
1145
1249
  }
1146
1250
  }
1147
1251
  break;
@@ -1182,6 +1286,7 @@ export function createJobRunner(options) {
1182
1286
  data["_correlationId"] = reqCtx.correlationId;
1183
1287
  stampGatedWriteOrigin(data);
1184
1288
  const job = await targetQueue.add(jobName, data, bullOpts);
1289
+ await announceQueued(jobName, jobDef, job.id, data);
1185
1290
  return job.id ?? "unknown";
1186
1291
  },
1187
1292
  async handleEvent(eventName, payload, user, triggerEvent) {
@@ -1230,7 +1335,8 @@ export function createJobRunner(options) {
1230
1335
  stampGatedWriteOrigin(data);
1231
1336
  // Route to the job's declared lane, not a fixed queue — that's
1232
1337
  // the whole reason both queues are held.
1233
- await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
1338
+ const job = await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
1339
+ await announceQueued(name, jobDef, job.id, data);
1234
1340
  }
1235
1341
  },
1236
1342
  attachDispatcher(ref) {