@cosmicdrift/kumiko-bundled-features 0.165.3 → 0.166.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.165.3",
3
+ "version": "0.166.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -119,11 +119,11 @@
119
119
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
120
120
  },
121
121
  "dependencies": {
122
- "@cosmicdrift/kumiko-dispatcher-live": "0.165.3",
123
- "@cosmicdrift/kumiko-framework": "0.165.3",
124
- "@cosmicdrift/kumiko-headless": "0.165.3",
125
- "@cosmicdrift/kumiko-renderer": "0.165.3",
126
- "@cosmicdrift/kumiko-renderer-web": "0.165.3",
122
+ "@cosmicdrift/kumiko-dispatcher-live": "0.166.0",
123
+ "@cosmicdrift/kumiko-framework": "0.166.0",
124
+ "@cosmicdrift/kumiko-headless": "0.166.0",
125
+ "@cosmicdrift/kumiko-renderer": "0.166.0",
126
+ "@cosmicdrift/kumiko-renderer-web": "0.166.0",
127
127
  "@mollie/api-client": "^4.5.0",
128
128
  "imapflow": "^1.3.3",
129
129
  "mailparser": "^3.9.8",
@@ -148,7 +148,7 @@
148
148
  "LICENSE"
149
149
  ],
150
150
  "peerDependencies": {
151
- "@cosmicdrift/kumiko-types": "^0.165.3"
151
+ "@cosmicdrift/kumiko-types": "^0.166.0"
152
152
  },
153
153
  "devDependencies": {
154
154
  "@testing-library/user-event": "^14.6.1",
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveMfaTokenSecrets } from "../token-secrets";
3
+
4
+ const JWT_SECRET = "app-jwt-secret-for-tests";
5
+
6
+ describe("resolveMfaTokenSecrets", () => {
7
+ // A setup token proves "enrolling a factor", a challenge token proves
8
+ // "passed step one of login". One shared key would let the first be
9
+ // replayed as the second.
10
+ test("derives two different secrets", () => {
11
+ const { setupTokenSecret, challengeTokenSecret } = resolveMfaTokenSecrets(JWT_SECRET);
12
+
13
+ expect(setupTokenSecret).not.toBe(challengeTokenSecret);
14
+ expect(setupTokenSecret).not.toBe(JWT_SECRET);
15
+ });
16
+
17
+ // The reason this helper exists: prod entrypoint and dev server each resolve
18
+ // the secrets, and a purpose string drifting between them would invalidate
19
+ // every token minted by the other.
20
+ test("is stable across calls, so two boot files agree", () => {
21
+ expect(resolveMfaTokenSecrets(JWT_SECRET)).toEqual(resolveMfaTokenSecrets(JWT_SECRET));
22
+ });
23
+
24
+ test("an explicit override wins over derivation", () => {
25
+ const resolved = resolveMfaTokenSecrets(JWT_SECRET, { setupTokenSecret: "explicit-setup" });
26
+
27
+ expect(resolved.setupTokenSecret).toBe("explicit-setup");
28
+ expect(resolved.challengeTokenSecret).toBe(
29
+ resolveMfaTokenSecrets(JWT_SECRET).challengeTokenSecret,
30
+ );
31
+ });
32
+
33
+ test("rotating the master rotates both", () => {
34
+ const before = resolveMfaTokenSecrets(JWT_SECRET);
35
+ const after = resolveMfaTokenSecrets(`${JWT_SECRET}-rotated`);
36
+
37
+ expect(after.setupTokenSecret).not.toBe(before.setupTokenSecret);
38
+ expect(after.challengeTokenSecret).not.toBe(before.challengeTokenSecret);
39
+ });
40
+ });
@@ -17,3 +17,8 @@ export {
17
17
  } from "./feature";
18
18
  export type { MfaStatusChecker, MfaStatusCheckResult } from "./mfa-status-checker";
19
19
  export { userMfaEntity, userMfaTable } from "./schema/user-mfa";
20
+ export {
21
+ type MfaTokenSecretOverrides,
22
+ type ResolvedMfaTokenSecrets,
23
+ resolveMfaTokenSecrets,
24
+ } from "./token-secrets";
@@ -0,0 +1,39 @@
1
+ import { derivePurposeSecret } from "@cosmicdrift/kumiko-framework/secrets";
2
+
3
+ export type MfaTokenSecretOverrides = {
4
+ readonly setupTokenSecret?: string;
5
+ readonly challengeTokenSecret?: string;
6
+ };
7
+
8
+ export type ResolvedMfaTokenSecrets = {
9
+ readonly setupTokenSecret: string;
10
+ readonly challengeTokenSecret: string;
11
+ };
12
+
13
+ // The two HKDF purposes auth-mfa needs. They live here rather than at each
14
+ // call site because an app typically resolves them twice — once in the prod
15
+ // entrypoint against a validated JWT_SECRET, once in the dev server against
16
+ // its fallback — and a purpose string that drifts between those two files
17
+ // invalidates every token issued by the other.
18
+ //
19
+ // Setup and challenge stay separate on purpose: a setup token proves "this
20
+ // user is enrolling a factor", a challenge token proves "this user passed
21
+ // step one of login". Sharing one key would let the first be replayed as the
22
+ // second.
23
+ const SETUP_TOKEN_PURPOSE = "mfa-setup-token-v1";
24
+ const CHALLENGE_TOKEN_PURPOSE = "mfa-challenge-token-v1";
25
+
26
+ /** Derives both MFA token secrets from the app's master secret. Pass explicit
27
+ * overrides only when a deployment needs its own key for one of them —
28
+ * otherwise deriving keeps a single env var authoritative. */
29
+ export function resolveMfaTokenSecrets(
30
+ masterSecret: string,
31
+ overrides: MfaTokenSecretOverrides = {},
32
+ ): ResolvedMfaTokenSecrets {
33
+ return {
34
+ setupTokenSecret:
35
+ overrides.setupTokenSecret ?? derivePurposeSecret(masterSecret, SETUP_TOKEN_PURPOSE),
36
+ challengeTokenSecret:
37
+ overrides.challengeTokenSecret ?? derivePurposeSecret(masterSecret, CHALLENGE_TOKEN_PURPOSE),
38
+ };
39
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.165.3",
4
+ "type": "improvement",
5
+ "title": "Searchable subject-PII via derived Meili index (fw#1610).",
6
+ "detail": "Fields may be searchable + userOwned/tenantOwned: the search consumer decrypts into Meili (ciphertext stays at rest). Forget erases the subject key and purgeSearchDocumentsForSubject removes Meili docs (ciphertext LIKE + ownership for post-anonymize). sortable + subject remains forbidden; sensitive + searchable is rejected at boot."
7
+ },
2
8
  {
3
9
  "version": "0.119.0",
4
10
  "type": "improvement",
@@ -11,7 +11,10 @@ import {
11
11
  type InMemoryFileProvider,
12
12
  } from "@cosmicdrift/kumiko-framework/files";
13
13
  import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
14
15
  import { createConfigFeature } from "../../config";
16
+ import { createTenantFeature } from "../../tenant/feature";
17
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
15
18
  import { documentIngestFoundationFeature } from "../feature";
16
19
 
17
20
  let stack: TestStack;
@@ -36,7 +39,13 @@ const textBytes = new TextEncoder().encode("plain text, not a supported mime typ
36
39
  beforeAll(async () => {
37
40
  provider = createInMemoryFileProvider();
38
41
  stack = await setupTestStack({
39
- features: [createConfigFeature(), documentIngestFoundationFeature],
42
+ features: [
43
+ createConfigFeature(),
44
+ createTenantFeature(),
45
+ createComplianceProfilesFeature(),
46
+ createTenantLifecycleFeature(),
47
+ documentIngestFoundationFeature,
48
+ ],
40
49
  files: { storageProvider: provider },
41
50
  });
42
51
  });
@@ -5,6 +5,7 @@
5
5
  // by feature.integration.test.ts.
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
+ import { EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
8
9
  import { documentExtractEntity } from "../entity";
9
10
  import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "../events";
10
11
  import { documentIngestFoundationFeature } from "../feature";
@@ -18,13 +19,24 @@ describe("documentIngestFoundationFeature — shape", () => {
18
19
  expect(documentIngestFoundationFeature.requires).toContain("config");
19
20
  });
20
21
 
22
+ test("declares tenant-lifecycle as a hard requirement — it hosts EXT_TENANT_DATA (#1621)", () => {
23
+ expect(documentIngestFoundationFeature.requires).toContain("tenant-lifecycle");
24
+ });
25
+
21
26
  test("registers the documentExtract entity as an implicit projection", () => {
22
27
  expect(Object.keys(documentIngestFoundationFeature.entities ?? {})).toEqual([
23
28
  "documentExtract",
24
29
  ]);
25
30
  });
26
31
 
27
- test("documentExtract entity pins table name, field set, and pages encryption (#1501)", () => {
32
+ test("registers an entity-exact EXT_TENANT_DATA destroy hook (#1621)", () => {
33
+ const usage = documentIngestFoundationFeature.extensionUsages.find(
34
+ (u) => u.extensionName === EXT_TENANT_DATA && u.entityName === "documentExtract",
35
+ );
36
+ expect(typeof usage?.options?.["destroy"]).toBe("function");
37
+ });
38
+
39
+ test("documentExtract entity pins table name, field set, and pages subject-encryption (#1621)", () => {
28
40
  expect(documentExtractEntity.table).toBe("read_document_extracts");
29
41
  expect(Object.keys(documentExtractEntity.fields)).toEqual([
30
42
  "fileRefId",
@@ -32,10 +44,12 @@ describe("documentIngestFoundationFeature — shape", () => {
32
44
  "pages",
33
45
  "meta",
34
46
  ]);
47
+ // tenantOwned, not `encrypted: true` — the master-key path shreds nothing.
35
48
  expect(documentExtractEntity.fields.pages).toMatchObject({
36
49
  type: "longText",
37
- encrypted: true,
50
+ tenantOwned: true,
38
51
  });
52
+ expect(documentExtractEntity.fields.pages).not.toHaveProperty("encrypted");
39
53
  expect(documentExtractEntity.fields.meta).toMatchObject({ type: "jsonb" });
40
54
  });
41
55
 
@@ -0,0 +1,138 @@
1
+ // The point of #1621: `documentExtract.pages` carries the full extracted
2
+ // text of invoices, IDs and contracts, and used to be `encrypted: true` —
3
+ // master-key ciphertext with no erasure subject, so no destroy path could
4
+ // ever make it unreadable. These tests pin the three properties the swap to
5
+ // `tenantOwned` buys: tenant-subject ciphertext at rest, a plaintext
6
+ // round-trip through the executor, and actual shredding when the tenant
7
+ // subject key dies.
8
+
9
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import {
12
+ configurePiiSubjectKms,
13
+ InMemoryKmsAdapter,
14
+ PII_ERASED_SENTINEL,
15
+ resetPiiSubjectKmsForTests,
16
+ } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
18
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
19
+ import {
20
+ setupTestStack,
21
+ type TestStack,
22
+ testTenantId,
23
+ unsafeCreateEntityTable,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { createConfigFeature } from "../../config";
26
+ import { documentExtractEntity, documentExtractsTable } from "../entity";
27
+ import { readIngestPages, writeIngestPages } from "../pages";
28
+ import { documentExtractTenantDestroyHook } from "../tenant-destroy-hook";
29
+
30
+ let stack: TestStack;
31
+ let kms: InMemoryKmsAdapter;
32
+
33
+ const tenantId = testTenantId(1);
34
+ const actor = createSystemUser(tenantId);
35
+
36
+ const executor = createEventStoreExecutor(documentExtractsTable, documentExtractEntity, {
37
+ entityName: "document-extract",
38
+ });
39
+
40
+ const pages = [
41
+ { pageNumber: 1, text: "Rechnung Nr. 4711, Mieterin: Erika Mustermann" },
42
+ { pageNumber: 2, text: "IBAN DE02120300000000202051" },
43
+ ];
44
+
45
+ beforeAll(async () => {
46
+ stack = await setupTestStack({ features: [createConfigFeature()] });
47
+ await unsafeCreateEntityTable(stack.db, documentExtractEntity);
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await stack.cleanup();
52
+ });
53
+
54
+ beforeEach(async () => {
55
+ kms = new InMemoryKmsAdapter();
56
+ configurePiiSubjectKms(kms);
57
+ await asRawClient(stack.db).unsafe(`TRUNCATE read_document_extracts`);
58
+ });
59
+
60
+ afterEach(() => {
61
+ resetPiiSubjectKmsForTests();
62
+ });
63
+
64
+ async function createExtract(fileRefId: string): Promise<string> {
65
+ const result = await executor.create(
66
+ {
67
+ fileRefId,
68
+ storageKey: `s3://bucket/${fileRefId}`,
69
+ pages: writeIngestPages(pages),
70
+ meta: { provider: "test", ms: 1, needsOcr: false, pagesParsed: 2, totalPages: 2 },
71
+ },
72
+ actor,
73
+ createTenantDb(stack.db, tenantId),
74
+ );
75
+ if (!result.isSuccess) throw new Error(`create failed: ${result.error.message}`);
76
+ return String(result.data.id);
77
+ }
78
+
79
+ async function rawPages(id: string): Promise<string> {
80
+ const rows = (await asRawClient(stack.db).unsafe(
81
+ `SELECT pages FROM read_document_extracts WHERE id = $1`,
82
+ [id],
83
+ )) as ReadonlyArray<{ pages: string }>;
84
+ return String(rows[0]?.pages);
85
+ }
86
+
87
+ describe("documentExtract.pages — tenant-subject encryption (#1621)", () => {
88
+ test("stored as tenant-subject ciphertext, not master-key ciphertext", async () => {
89
+ const id = await createExtract("file-1");
90
+
91
+ const stored = await rawPages(id);
92
+ expect(stored.startsWith(`kumiko-pii:v2:tenant:${tenantId}:`)).toBe(true);
93
+ expect(stored).not.toContain("Erika Mustermann");
94
+ });
95
+
96
+ test("round-trips back to the original pages through the executor", async () => {
97
+ const id = await createExtract("file-2");
98
+
99
+ const row = await executor.detail({ id }, actor, createTenantDb(stack.db, tenantId));
100
+ expect(readIngestPages(row?.["pages"])).toEqual(pages);
101
+ });
102
+
103
+ test("erasing the tenant subject key makes the extracted text unreadable", async () => {
104
+ const id = await createExtract("file-3");
105
+
106
+ await kms.eraseKey({ kind: "tenant", tenantId });
107
+
108
+ const row = await executor.detail({ id }, actor, createTenantDb(stack.db, tenantId));
109
+ expect(row?.["pages"]).toBe(PII_ERASED_SENTINEL);
110
+ expect(readIngestPages(row?.["pages"])).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe("documentExtractTenantDestroyHook (#1621)", () => {
115
+ test("drops the tenant's extract rows and leaves other tenants alone", async () => {
116
+ const mine = await createExtract("file-mine");
117
+ const otherTenantId = testTenantId(2);
118
+ const otherResult = await executor.create(
119
+ {
120
+ fileRefId: "file-theirs",
121
+ storageKey: "s3://bucket/file-theirs",
122
+ pages: writeIngestPages(pages),
123
+ meta: {},
124
+ },
125
+ createSystemUser(otherTenantId),
126
+ createTenantDb(stack.db, otherTenantId),
127
+ );
128
+ if (!otherResult.isSuccess) throw new Error("setup create failed");
129
+
130
+ await documentExtractTenantDestroyHook({ db: stack.db, tenantId });
131
+
132
+ const remaining = (await asRawClient(stack.db).unsafe(
133
+ `SELECT id FROM read_document_extracts`,
134
+ )) as ReadonlyArray<{ id: string }>;
135
+ expect(remaining.map((row) => row.id)).not.toContain(mine);
136
+ expect(remaining).toHaveLength(1);
137
+ });
138
+ });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.166.0",
4
+ "type": "breaking",
5
+ "title": "documentExtract.pages: tenantOwned instead of encrypted, and the feature now requires tenant-lifecycle",
6
+ "detail": "`pages` holds the full extracted text of ingested documents (invoices, IDs, contracts) and was `encrypted: true` — app-master-key ciphertext with no erasure subject, so no Art. 17 path could ever make it unreadable. It is now `tenantOwned: true`, which binds it to the tenant subject key that tenant-destroy's eraseSubjectKeys shreds. The feature registers an EXT_TENANT_DATA destroy hook for it, and since tenant-lifecycle hosts that extension point, `document-ingest-foundation` now declares it as a hard requirement — mounting the feature without tenant-lifecycle (plus its own tenant + compliance-profiles requires) makes createRegistry throw (#1621).",
7
+ "migration": "Mount createTenantFeature(), createComplianceProfilesFeature() and createTenantLifecycleFeature() alongside documentIngestFoundationFeature. Rows written before this version carry master-key envelope ciphertext that the subject-decrypt path does not recognise — readIngestPages returns [] for them. There is no reencrypt job; if you have existing extracts you care about, decrypt and rewrite them before upgrading."
8
+ }
9
+ ]
@@ -1,3 +1,4 @@
1
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
1
2
  import {
2
3
  createEntity,
3
4
  createJsonbField,
@@ -11,7 +12,8 @@ import {
11
12
  // jsonb has no encryption support in the engine, and this column holds the
12
13
  // full extracted text of ingested documents (invoices, IDs, contracts).
13
14
  // Writers/readers MUST use writeIngestPages / readIngestPages — do not pass
14
- // a raw IngestPage[] into executor.create (encrypted longText requires string).
15
+ // a raw IngestPage[] into executor.create (the encryption hook requires a
16
+ // string).
15
17
  export type IngestPage = {
16
18
  readonly pageNumber: number;
17
19
  readonly text: string;
@@ -35,9 +37,16 @@ export const documentExtractEntity = createEntity({
35
37
  fields: {
36
38
  fileRefId: createTextField({ required: true }),
37
39
  storageKey: createTextField({ required: true }),
38
- // Encrypted — holds the full extracted document text (PII). meta is
39
- // provider telemetry only and stays plaintext jsonb.
40
- pages: createLongTextField({ encrypted: true }),
40
+ // Holds the full extracted document text (PII). `tenantOwned`, NOT
41
+ // `encrypted: true` (#1621): the master-key path has no erasure subject,
42
+ // so nothing here would ever be shreddable. Tenant-subject ciphertext
43
+ // dies with eraseSubjectKeys on tenant-destroy (#800 pattern). Whose
44
+ // subject a third party named inside a document is stays open, same as
45
+ // inbound-mail-foundation (#957). meta is provider telemetry only and
46
+ // stays plaintext jsonb.
47
+ pages: createLongTextField({ tenantOwned: true }),
41
48
  meta: createJsonbField(),
42
49
  },
43
50
  });
51
+
52
+ export const documentExtractsTable = buildEntityTable("documentExtract", documentExtractEntity);
@@ -12,7 +12,12 @@
12
12
  // CosmicDriftGameStudio/kumiko-framework#1495 for the full phase breakdown.
13
13
 
14
14
  import { entityEventName } from "@cosmicdrift/kumiko-framework/db";
15
- import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ access,
17
+ createTenantConfig,
18
+ defineFeature,
19
+ EXT_TENANT_DATA,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
16
21
  import { z } from "zod";
17
22
  import { documentExtractEntity } from "./entity";
18
23
  import {
@@ -21,6 +26,7 @@ import {
21
26
  DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
22
27
  documentIngestRequestedPayloadSchema,
23
28
  } from "./events";
29
+ import { documentExtractTenantDestroyHook } from "./tenant-destroy-hook";
24
30
 
25
31
  const FEATURE_NAME = "document-ingest-foundation";
26
32
 
@@ -59,9 +65,14 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
59
65
  category: "storage",
60
66
  recommended: false,
61
67
  });
62
- r.requires("config");
68
+ // tenant-lifecycle hosts EXT_TENANT_DATA — documentExtract.pages is
69
+ // tenant-subject ciphertext and needs the destroy hook below (#1621).
70
+ r.requires("config", "tenant-lifecycle");
63
71
 
64
72
  r.entity("documentExtract", documentExtractEntity);
73
+ r.useExtension(EXT_TENANT_DATA, "documentExtract", {
74
+ destroy: documentExtractTenantDestroyHook,
75
+ });
65
76
 
66
77
  const ocrLanguageConfigKey = r.config(
67
78
  "ocrLanguage",
@@ -1,6 +1,11 @@
1
1
  // Public API of the document-ingest-foundation bundled-feature.
2
2
 
3
- export { type DocumentExtractMeta, documentExtractEntity, type IngestPage } from "./entity";
3
+ export {
4
+ type DocumentExtractMeta,
5
+ documentExtractEntity,
6
+ documentExtractsTable,
7
+ type IngestPage,
8
+ } from "./entity";
4
9
  export {
5
10
  DOCUMENT_INGEST_AGGREGATE_TYPE,
6
11
  DOCUMENT_INGEST_REQUESTED_EVENT_QN,
@@ -0,0 +1,49 @@
1
+ // Tenant-destroy hook for the documentExtract entity-projection (#1621).
2
+ //
3
+ // Per-row forget() through the executor, not a bulk deleteMany: documentExtract
4
+ // is an ES-managed implicit projection, so a store-table write here would be
5
+ // eventless and a later rebuild would replay the historical create events and
6
+ // resurrect every row this hook removed. forget() (Art. 17 hard-purge) is
7
+ // replayed by the implicit projection itself, which keeps the erasure
8
+ // rebuild-safe — the same reasoning as tenant-lifecycle's membership stage,
9
+ // and the reason the feature owns an r.entity instead of an r.projection
10
+ // (kumiko-framework#1495). The `pages` ciphertext dies separately when the
11
+ // pipeline's later `subject-keys` stage erases the tenant subject key.
12
+
13
+ import {
14
+ createEventStoreExecutor,
15
+ createTenantDb,
16
+ type DbRunner,
17
+ selectMany,
18
+ } from "@cosmicdrift/kumiko-framework/db";
19
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { documentExtractEntity, documentExtractsTable } from "./entity";
21
+
22
+ const executor = createEventStoreExecutor(documentExtractsTable, documentExtractEntity, {
23
+ entityName: "document-extract",
24
+ });
25
+
26
+ type DestroyCtx = {
27
+ readonly db: DbRunner;
28
+ readonly tenantId: TenantId;
29
+ };
30
+
31
+ export async function documentExtractTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
32
+ const rows = await selectMany<{ id: string }>(ctx.db, documentExtractsTable, {
33
+ tenantId: ctx.tenantId,
34
+ });
35
+ const user = createSystemUser(ctx.tenantId);
36
+ const db = createTenantDb(ctx.db, ctx.tenantId, "system");
37
+ for (const row of rows) {
38
+ const result = await executor.forget({ id: row.id }, user, db);
39
+ // Executor writes return {isSuccess:false} instead of throwing — a
40
+ // discarded result would report this destroy stage "succeeded" while the
41
+ // extracted document text survives. Throw so the pipeline's retry/abandon
42
+ // handling sees it.
43
+ if (!result.isSuccess) {
44
+ throw new Error(
45
+ `document-ingest-foundation: failed to forget documentExtract ${row.id} for tenant ${ctx.tenantId}: ${result.error.message}`,
46
+ );
47
+ }
48
+ }
49
+ }
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.165.3",
4
+ "type": "improvement",
5
+ "title": "user.displayName is searchable via derived Meili (fw#1610).",
6
+ "detail": "displayName is userOwned PII and now searchable through the decrypt-into-Meili path; forget purges the index entry with the subject key."
7
+ }
8
+ ]
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.165.3",
4
+ "type": "improvement",
5
+ "title": "Forget cleanup purges derived Meili docs for searchable subject-PII (fw#1610).",
6
+ "detail": "run-forget-cleanup (and the forget cron path) call purgeSearchDocumentsForSubject after crypto-shred so searchable userOwned fields do not linger in Meili as plaintext after Art. 17."
7
+ },
2
8
  {
3
9
  "version": "0.165.0",
4
10
  "type": "breaking",