@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.
- package/package.json +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
type RowClassification = "rotate" | "current" | "unrecognized";
|
|
53
|
+
|
|
54
|
+
function classifyRow(value: string, targetVersion: number): RowClassification {
|
|
55
|
+
let parsed: unknown;
|
|
54
56
|
try {
|
|
55
|
-
|
|
56
|
-
if (!isStoredEnvelope(parsed)) return true;
|
|
57
|
-
return parsed.kekVersion !== targetVersion;
|
|
57
|
+
parsed = JSON.parse(value);
|
|
58
58
|
} catch {
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
151
|
+
const classification = classifyRow(row.value, targetVersion);
|
|
152
|
+
if (classification === "current") {
|
|
149
153
|
alreadyCurrent++;
|
|
150
154
|
return "skipped";
|
|
151
155
|
}
|
|
152
156
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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> —
|
|
78
|
-
// at the MSP boundary (
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
|
@@ -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
|
+
}
|