@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -57,9 +57,12 @@ const keyDef = createSystemConfig("text", {
57
57
  write: access.systemAdmin,
58
58
  });
59
59
 
60
+ const GARBAGE_KEY = "kek-rot:config:garbage-value";
61
+ const GARBAGE_VALUE = "not-json-and-not-an-envelope";
62
+
60
63
  const rotFeature = defineFeature("kek-rot", (r) => {
61
64
  r.requires("config");
62
- r.config({ keys: { "secret-pass": keyDef } });
65
+ r.config({ keys: { "secret-pass": keyDef, "garbage-value": keyDef } });
63
66
  });
64
67
 
65
68
  const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
@@ -80,10 +83,21 @@ async function seedV1Row(): Promise<void> {
80
83
  if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
81
84
  }
82
85
 
86
+ async function seedGarbageRow(): Promise<void> {
87
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
88
+ const tdb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
89
+ const result = await executor.create(
90
+ { key: GARBAGE_KEY, value: GARBAGE_VALUE, tenantId: SYSTEM_TENANT_ID, userId: null },
91
+ systemUser,
92
+ tdb,
93
+ );
94
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
95
+ }
96
+
83
97
  type RawConfigRow = { value: string };
84
98
 
85
- async function readRawValue(): Promise<RawConfigRow> {
86
- const rows = await selectMany<RawConfigRow>(stack.db, configValuesTable, { key: KEY });
99
+ async function readRawValue(key: string = KEY): Promise<RawConfigRow> {
100
+ const rows = await selectMany<RawConfigRow>(stack.db, configValuesTable, { key });
87
101
  const row = rows[0];
88
102
  if (!row) throw new Error("no config row");
89
103
  return row;
@@ -97,13 +111,35 @@ const noopLog = {
97
111
  child: () => noopLog,
98
112
  };
99
113
 
100
- function jobCtx(): Parameters<typeof reencryptJob>[1] {
114
+ type CapturedLog = { info: string[]; warn: string[] };
115
+
116
+ type TestJobLog = {
117
+ info: (msg: string) => void;
118
+ warn: (msg: string) => void;
119
+ error: () => void;
120
+ debug: () => void;
121
+ child: () => TestJobLog;
122
+ };
123
+
124
+ function capturingLog(captured: CapturedLog): TestJobLog {
125
+ const log: TestJobLog = {
126
+ info: (msg) => captured.info.push(msg),
127
+ warn: (msg) => captured.warn.push(msg),
128
+ error: () => {},
129
+ debug: () => {},
130
+ child: () => log,
131
+ };
132
+ return log;
133
+ }
134
+
135
+ function jobCtx(captured?: CapturedLog): Parameters<typeof reencryptJob>[1] {
136
+ const log = captured ? capturingLog(captured) : noopLog;
101
137
  return {
102
138
  db: stack.db,
103
139
  registry: stack.registry,
104
140
  masterKeyProvider: mutableProvider,
105
141
  configEncryption: cipher,
106
- log: noopLog,
142
+ log,
107
143
  } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
108
144
  }
109
145
 
@@ -115,12 +151,49 @@ beforeAll(async () => {
115
151
  });
116
152
  await unsafePushTables(stack.db, { configValuesTable });
117
153
  await seedV1Row();
154
+ await seedGarbageRow();
118
155
  });
119
156
 
120
157
  afterAll(async () => {
121
158
  await stack.cleanup();
122
159
  });
123
160
 
161
+ describe("config KEK-rotation job — unrecognized values are not silently skipped (kumiko-framework#1513)", () => {
162
+ test("a non-envelope row is counted failed, never alreadyCurrent, and is left untouched", async () => {
163
+ const beforeRow = await readRawValue(GARBAGE_KEY);
164
+ expect(beforeRow.value).toBe(GARBAGE_VALUE);
165
+
166
+ // Runs before the #1187 rotation test below flips the master-key
167
+ // version, so counts are deterministic: the V1 row is current, the
168
+ // garbage row is unrecognized, nothing needs rotating yet.
169
+ const captured: CapturedLog = { info: [], warn: [] };
170
+ await reencryptJob({}, jobCtx(captured));
171
+
172
+ const completeLine = captured.info.find((line) =>
173
+ line.includes("[config:reencrypt] complete:"),
174
+ );
175
+ if (!completeLine) throw new Error("job did not log a completion summary");
176
+ const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
177
+ migrated: number;
178
+ failed: number;
179
+ alreadyCurrent: number;
180
+ };
181
+ expect(result.alreadyCurrent).toBe(1);
182
+ expect(result.migrated).toBe(0);
183
+ expect(result.failed).toBe(1);
184
+
185
+ const rejectedWarning = captured.warn.find((line) =>
186
+ line.includes("not a current-cipher envelope"),
187
+ );
188
+ expect(rejectedWarning).toBeDefined();
189
+
190
+ // Never written: an unreadable row must not be rewritten as if it
191
+ // decrypted cleanly, and must not be reported as already current.
192
+ const afterRow = await readRawValue(GARBAGE_KEY);
193
+ expect(afterRow.value).toBe(GARBAGE_VALUE);
194
+ });
195
+ });
196
+
124
197
  describe("config KEK-rotation job — version-N to version-N+1 (kumiko-framework#1187)", () => {
125
198
  test("rotation rewraps the value onto the current KEK, and a full projection rebuild still lands on it", async () => {
126
199
  const beforeRow = await readRawValue();
@@ -1,5 +1,5 @@
1
1
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
5
5
  export type ConfigRow = {
@@ -11,7 +11,7 @@ export type ConfigRow = {
11
11
  };
12
12
 
13
13
  export async function selectConfigRowsForScope(
14
- db: DbRunner,
14
+ db: DbRunner | TenantDb,
15
15
  systemTenantId: TenantId,
16
16
  tenantId: TenantId,
17
17
  userId: string,
@@ -27,7 +27,7 @@ export async function selectConfigRowsForScope(
27
27
  }
28
28
 
29
29
  export async function selectConfigRowsForKeys(
30
- db: DbRunner,
30
+ db: DbRunner | TenantDb,
31
31
  keys: readonly string[],
32
32
  systemTenantId: TenantId,
33
33
  tenantId: TenantId,
@@ -1,19 +1,20 @@
1
- // Re-encrypt job for `encrypted: true` config values. Two jobs in one,
2
- // because format detection makes them the same loop:
3
- // - MIGRATION: legacy CONFIG_ENCRYPTION_KEY values (base64 blob, no key
4
- // id) → envelope format under the current master key. After a clean
5
- // run the legacy key can be dropped from the environment.
6
- // - KEK-ROTATION: envelope values wrapped under an older kekVersion →
7
- // re-encrypted under provider.currentVersion(). Config has no
8
- // kek_version column (values live in a TEXT column), so unlike the
9
- // secrets rotate job the version check parses the stored JSON.
1
+ // KEK-rotation job for `encrypted: true` config values: envelope values
2
+ // wrapped under an older kekVersion get re-encrypted under
3
+ // provider.currentVersion(). Config has no kek_version column (values live
4
+ // in a TEXT column), so unlike the secrets rotate job the version check
5
+ // parses the stored JSON.
6
+ //
7
+ // A row whose value isn't a current-cipher envelope (malformed JSON, or
8
+ // any pre-envelope format) isn't a supported input here — cipher.decrypt
9
+ // rejects it, so it's counted `failed` rather than silently treated as
10
+ // already current. There is no legacy single-key decrypt path anymore.
10
11
  //
11
12
  // Idempotent: a re-run skips rows already on the current version. Every
12
13
  // write goes through the event-store executor (config values are
13
14
  // entity-backed — raw UPDATEs would be wiped by a projection rebuild),
14
- // so each migration appends a normal `.updated` event whose payload
15
- // carries the NEW envelope: after a full run even a from-scratch rebuild
16
- // no longer needs the legacy key for the final state.
15
+ // so each rotation appends a normal `.updated` event whose payload carries
16
+ // the NEW envelope: after a full run even a from-scratch rebuild only
17
+ // ever sees the current-KEK envelope.
17
18
 
18
19
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
19
20
  import {
@@ -48,17 +49,19 @@ export type ReencryptJobResult = {
48
49
  readonly stoppedReason: ChunkedMigrationStopReason;
49
50
  };
50
51
 
51
- function needsReencrypt(value: string, targetVersion: number): boolean {
52
- // legacy single-key format (base64 — can never start with "{")
53
- if (!value.startsWith("{")) return true;
52
+ type RowClassification = "rotate" | "current" | "unrecognized";
53
+
54
+ function classifyRow(value: string, targetVersion: number): RowClassification {
55
+ let parsed: unknown;
54
56
  try {
55
- const parsed: unknown = JSON.parse(value);
56
- if (!isStoredEnvelope(parsed)) return true;
57
- return parsed.kekVersion !== targetVersion;
57
+ parsed = JSON.parse(value);
58
58
  } catch {
59
- // malformed JSON — let the decrypt attempt surface the real error
60
- return true;
59
+ // not JSON at all — no supported format decrypts this; let
60
+ // cipher.decrypt reject it and count the row as failed.
61
+ return "unrecognized";
61
62
  }
63
+ if (!isStoredEnvelope(parsed)) return "unrecognized";
64
+ return parsed.kekVersion === targetVersion ? "current" : "rotate";
62
65
  }
63
66
 
64
67
  export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
@@ -145,14 +148,23 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
145
148
 
146
149
  async function migrateRow(row: ConfigRow): Promise<"migrated" | "skipped" | "failed"> {
147
150
  if (row.value === null || row.value === undefined) return "skipped";
148
- if (!needsReencrypt(row.value, targetVersion)) {
151
+ const classification = classifyRow(row.value, targetVersion);
152
+ if (classification === "current") {
149
153
  alreadyCurrent++;
150
154
  return "skipped";
151
155
  }
152
156
 
153
- // decrypt failure (missing legacy key, unknown kekVersion, tamper)
154
- // throws → counted as failed via onRowError; the row stays untouched —
155
- // never write anything we couldn't read.
157
+ if (classification === "unrecognized") {
158
+ // Not a current-cipher envelope (malformed JSON, or any pre-envelope
159
+ // format) — never a supported re-encrypt input, so fail loudly
160
+ // instead of relying on cipher.decrypt to reject it. Log only the
161
+ // row id, never the value (ciphertext-adjacent).
162
+ ctx.log?.warn?.(
163
+ `[config:reencrypt] row ${row.id} is not a current-cipher envelope, not re-encryptable`,
164
+ );
165
+ return "failed";
166
+ }
167
+
156
168
  const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
157
169
  const plaintext = await cipher.decrypt(row.value, { tenantId });
158
170
  const reencrypted = await cipher.encrypt(plaintext, { tenantId });
@@ -518,6 +518,20 @@ describe("custom-fields integration — no PII: `sensitive` rejected, every valu
518
518
  });
519
519
  });
520
520
 
521
+ describe("custom-fields integration — registry facade: getMultiStreamProjectionFeature", () => {
522
+ test("resolves the owning feature name for a registered multi-stream projection", () => {
523
+ const qualifiedName = "property-test:projection:custom-fields-property-projection";
524
+
525
+ expect(stack.registry.getAllMultiStreamProjections().has(qualifiedName)).toBe(true);
526
+ expect(stack.registry.getMultiStreamProjectionFeature(qualifiedName)).toBe("property-test");
527
+ // Missing MSP → undefined (not a fallback string). server.ts only spreads
528
+ // featureName onto the EventConsumer when this returns a defined value.
529
+ expect(
530
+ stack.registry.getMultiStreamProjectionFeature("does-not-exist:projection:nope"),
531
+ ).toBeUndefined();
532
+ });
533
+ });
534
+
521
535
  describe("custom-fields integration — value validation (Builder-Reuse)", () => {
522
536
  async function setErr(entityId: string, fieldKey: string, value: unknown) {
523
537
  return stack.http.writeErr(
@@ -5,6 +5,7 @@
5
5
  // by feature.integration.test.ts.
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
+ import { documentExtractEntity } from "../entity";
8
9
  import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "../events";
9
10
  import { documentIngestFoundationFeature } from "../feature";
10
11
 
@@ -23,6 +24,27 @@ describe("documentIngestFoundationFeature — shape", () => {
23
24
  ]);
24
25
  });
25
26
 
27
+ test("documentExtract entity pins table name, field set, and pages encryption (#1501)", () => {
28
+ expect(documentExtractEntity.table).toBe("read_document_extracts");
29
+ expect(Object.keys(documentExtractEntity.fields)).toEqual([
30
+ "fileRefId",
31
+ "storageKey",
32
+ "pages",
33
+ "meta",
34
+ ]);
35
+ expect(documentExtractEntity.fields.pages).toMatchObject({
36
+ type: "longText",
37
+ encrypted: true,
38
+ });
39
+ expect(documentExtractEntity.fields.meta).toMatchObject({ type: "jsonb" });
40
+ });
41
+
42
+ test("exports writeIngestPages / readIngestPages for the encrypted pages wire format (#1549)", async () => {
43
+ const { readIngestPages, writeIngestPages } = await import("../pages");
44
+ const pages = [{ pageNumber: 1, text: "x" }];
45
+ expect(readIngestPages(writeIngestPages(pages))).toEqual(pages);
46
+ });
47
+
26
48
  test("registers NO write/query handlers — only entity, config, event, and MSP", () => {
27
49
  expect(Object.keys(documentIngestFoundationFeature.writeHandlers)).toHaveLength(0);
28
50
  expect(Object.keys(documentIngestFoundationFeature.queryHandlers)).toHaveLength(0);
@@ -64,4 +86,20 @@ describe("documentIngestFoundationFeature.exports — typed config handles", ()
64
86
  expect(key.name).toBe("document-ingest-foundation:config:max-pages-per-file");
65
87
  expect(documentIngestFoundationFeature.configKeys["maxPagesPerFile"]?.default).toBe(50);
66
88
  });
89
+
90
+ test("ocrLanguage rejects a value that isn't a Tesseract -l argument (#1501)", () => {
91
+ const pattern = documentIngestFoundationFeature.configKeys["ocrLanguage"]?.pattern;
92
+ expect(pattern).toBeDefined();
93
+ const re = new RegExp(pattern!.regex, pattern!.flags);
94
+ expect(re.test("deu+eng")).toBe(true);
95
+ expect(re.test("eng")).toBe(true);
96
+ expect(re.test("")).toBe(false);
97
+ expect(re.test("EU")).toBe(false);
98
+ expect(re.test("rm -rf /")).toBe(false);
99
+ });
100
+
101
+ test("maxPagesPerFile is bounded so a tenant can't disable or unbound the cap (#1501)", () => {
102
+ const bounds = documentIngestFoundationFeature.configKeys["maxPagesPerFile"]?.bounds;
103
+ expect(bounds).toEqual({ min: 1, max: 500 });
104
+ });
67
105
  });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { IngestPage } from "../entity";
3
+ import { readIngestPages, writeIngestPages } from "../pages";
4
+
5
+ const SAMPLE: readonly IngestPage[] = [
6
+ { pageNumber: 1, text: "Hello" },
7
+ { pageNumber: 2, text: "World" },
8
+ ];
9
+
10
+ describe("writeIngestPages / readIngestPages", () => {
11
+ test("round-trips an IngestPage[] through the longText wire format", () => {
12
+ const raw = writeIngestPages(SAMPLE);
13
+ expect(typeof raw).toBe("string");
14
+ expect(readIngestPages(raw)).toEqual([...SAMPLE]);
15
+ });
16
+
17
+ test("writeIngestPages emits JSON an encrypted longText field can store", () => {
18
+ expect(writeIngestPages([])).toBe("[]");
19
+ expect(JSON.parse(writeIngestPages(SAMPLE))).toEqual([...SAMPLE]);
20
+ });
21
+
22
+ test("readIngestPages accepts an already-parsed array (legacy jsonb / raw SQL)", () => {
23
+ expect(readIngestPages([...SAMPLE])).toEqual([...SAMPLE]);
24
+ });
25
+
26
+ test("readIngestPages drops malformed entries and tolerates null/garbage", () => {
27
+ expect(readIngestPages(null)).toEqual([]);
28
+ expect(readIngestPages(undefined)).toEqual([]);
29
+ expect(readIngestPages("{not-json")).toEqual([]);
30
+ expect(readIngestPages({ pageNumber: 1 })).toEqual([]);
31
+ expect(
32
+ readIngestPages(
33
+ JSON.stringify([{ pageNumber: 1, text: "ok" }, { pageNumber: "x" }, null, "nope"]),
34
+ ),
35
+ ).toEqual([{ pageNumber: 1, text: "ok" }]);
36
+ });
37
+ });
@@ -1,12 +1,17 @@
1
1
  import {
2
2
  createEntity,
3
3
  createJsonbField,
4
+ createLongTextField,
4
5
  createTextField,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
7
 
7
8
  // Phase-1 (MVP) page shape — LiteParse fills this in (kumiko-enterprise#273).
8
- // Kept minimal on purpose: downstream themes (provider, recipe) extend it,
9
- // this skeleton only needs a stable name for the jsonb `pages` column.
9
+ // Kept minimal on purpose: downstream themes (provider, recipe) extend it.
10
+ // Stored as JSON.stringify(IngestPage[]) in the encrypted `pages` column —
11
+ // jsonb has no encryption support in the engine, and this column holds the
12
+ // full extracted text of ingested documents (invoices, IDs, contracts).
13
+ // Writers/readers MUST use writeIngestPages / readIngestPages — do not pass
14
+ // a raw IngestPage[] into executor.create (encrypted longText requires string).
10
15
  export type IngestPage = {
11
16
  readonly pageNumber: number;
12
17
  readonly text: string;
@@ -30,7 +35,9 @@ export const documentExtractEntity = createEntity({
30
35
  fields: {
31
36
  fileRefId: createTextField({ required: true }),
32
37
  storageKey: createTextField({ required: true }),
33
- pages: createJsonbField(),
38
+ // Encrypted — holds the full extracted document text (PII). meta is
39
+ // provider telemetry only and stays plaintext jsonb.
40
+ pages: createLongTextField({ encrypted: true }),
34
41
  meta: createJsonbField(),
35
42
  },
36
43
  });
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { entityEventName } from "@cosmicdrift/kumiko-framework/db";
15
15
  import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { z } from "zod";
16
17
  import { documentExtractEntity } from "./entity";
17
18
  import {
18
19
  DOCUMENT_INGEST_AGGREGATE_TYPE,
@@ -38,6 +39,17 @@ const ALLOWED_MIME_TYPES = new Set(["application/pdf", "image/png", "image/jpeg"
38
39
  // default (10mb); tests raise maxUploadSize instead of shrinking this.
39
40
  const MAX_FILE_BYTES = 25 * 1024 * 1024;
40
41
 
42
+ // Narrow fileRef.created's generic entity payload at the MSP boundary.
43
+ // Missing/invalid fields → skip (return), never cast — a bad size would
44
+ // otherwise pass `undefined > MAX_FILE_BYTES` and a later appendDomainEvent
45
+ // schema failure would dead the request-ingest consumer for all tenants.
46
+ const fileRefCreatedPayloadSchema = z.object({
47
+ storageKey: z.string().min(1),
48
+ fileName: z.string().min(1),
49
+ mimeType: z.string().min(1),
50
+ size: z.number().int().min(0),
51
+ });
52
+
41
53
  export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
42
54
  r.describe(
43
55
  "Shared PDF/Scan/Image → normalized-text ingest primitive. Owns the `documentExtract` entity (fileRefId, storageKey, per-page text + metadata) as an implicit entity-projection, the per-tenant `ocrLanguage`/`maxPagesPerFile` config keys, and a fileRef.created trigger that validates mime/size and requests ingest via `documentIngest.requested`. LiteParse provider wiring lands in follow-up features.",
@@ -55,6 +67,10 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
55
67
  "ocrLanguage",
56
68
  createTenantConfig("text", {
57
69
  default: "deu+eng",
70
+ // Tesseract -l argument syntax: one or more 3-letter language codes
71
+ // joined by "+" (e.g. "deu+eng"). Unconstrained free text here would
72
+ // reach the OCR provider call unvalidated (#1501).
73
+ pattern: { regex: "^[a-z]{3}(\\+[a-z]{3})*$" },
58
74
  write: access.roles("TenantAdmin", "SystemAdmin"),
59
75
  read: access.roles("TenantAdmin", "SystemAdmin", "User"),
60
76
  }),
@@ -63,6 +79,10 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
63
79
  "maxPagesPerFile",
64
80
  createTenantConfig("number", {
65
81
  default: 50,
82
+ // Without a bound a tenant could set 0 (silently kills ingest) or an
83
+ // unbounded value (OCR worker runs per-file for as long as the PDF
84
+ // has pages — job-queue starvation) (#1501).
85
+ bounds: { min: 1, max: 500 },
66
86
  write: access.roles("TenantAdmin", "SystemAdmin"),
67
87
  read: access.roles("TenantAdmin", "SystemAdmin", "User"),
68
88
  }),
@@ -74,14 +94,12 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
74
94
  name: "request-ingest",
75
95
  apply: {
76
96
  [FILE_REF_CREATED]: async (event, _tx, ctx) => {
77
- // entity-event payloads are generic Record<string, unknown> — narrow
78
- // at the MSP boundary (files-post-processing pattern).
79
- const payload = event.payload as {
80
- readonly storageKey: string;
81
- readonly fileName: string;
82
- readonly mimeType: string;
83
- readonly size: number;
84
- };
97
+ // entity-event payloads are generic Record<string, unknown> — parse
98
+ // at the MSP boundary (same pattern as storage-tracking's readNumber).
99
+ const parsed = fileRefCreatedPayloadSchema.safeParse(event.payload);
100
+ // skip: malformed / incomplete payload — don't poison the consumer
101
+ if (!parsed.success) return;
102
+ const payload = parsed.data;
85
103
 
86
104
  // skip: over the fixed cap — no ingest requested, upload itself already succeeded
87
105
  if (payload.size > MAX_FILE_BYTES) return;
@@ -9,3 +9,4 @@ export {
9
9
  documentIngestRequestedPayloadSchema,
10
10
  } from "./events";
11
11
  export { documentIngestFoundationFeature } from "./feature";
12
+ export { readIngestPages, writeIngestPages } from "./pages";
@@ -0,0 +1,34 @@
1
+ // Wire-format helpers for `documentExtract.pages` — the column is encrypted
2
+ // longText (jsonb has no encryption support), so writers must persist
3
+ // JSON.stringify(IngestPage[]) and readers must parse back. Centralising
4
+ // the codec here keeps liteparse + recipe consumers lockstep with the
5
+ // entity shape (kumiko-framework#1549/1).
6
+
7
+ import type { IngestPage } from "./entity";
8
+
9
+ export function writeIngestPages(pages: readonly IngestPage[]): string {
10
+ return JSON.stringify(pages);
11
+ }
12
+
13
+ export function readIngestPages(raw: unknown): IngestPage[] {
14
+ if (raw == null) return [];
15
+ // Executor decrypt returns the plaintext string; raw SQL / stale jsonb
16
+ // rows may still surface as an already-parsed array — accept both.
17
+ const parsed: unknown = typeof raw === "string" ? parseJson(raw) : raw;
18
+ if (!Array.isArray(parsed)) return [];
19
+ return parsed.filter(isIngestPage);
20
+ }
21
+
22
+ function parseJson(raw: string): unknown {
23
+ try {
24
+ return JSON.parse(raw);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function isIngestPage(value: unknown): value is IngestPage {
31
+ if (value === null || typeof value !== "object") return false;
32
+ const page = value as { pageNumber?: unknown; text?: unknown };
33
+ return typeof page.pageNumber === "number" && typeof page.text === "string";
34
+ }