@cosmicdrift/kumiko-bundled-features 0.145.1 → 0.146.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 (37) hide show
  1. package/package.json +7 -6
  2. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +15 -55
  3. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
  4. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +24 -79
  5. package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
  6. package/src/custom-fields/events.ts +4 -9
  7. package/src/custom-fields/feature.ts +8 -0
  8. package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
  9. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -27
  10. package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
  11. package/src/custom-fields/lib/value-schema.ts +1 -1
  12. package/src/custom-fields/schemas.ts +9 -9
  13. package/src/custom-fields/wire-for-entity.ts +8 -15
  14. package/src/custom-fields/wire-user-data-rights.ts +5 -54
  15. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +199 -0
  16. package/src/inbound-mail-foundation/feature.ts +58 -0
  17. package/src/inbound-mail-foundation/retention-sweep.ts +155 -0
  18. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +26 -0
  19. package/src/managed-pages/feature.ts +3 -1
  20. package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
  21. package/src/page-render/__tests__/layout.test.ts +53 -0
  22. package/src/page-render/index.ts +1 -1
  23. package/src/page-render/layout.ts +15 -1
  24. package/src/seo/__tests__/llms-txt.test.ts +38 -0
  25. package/src/seo/__tests__/robots-txt.test.ts +18 -0
  26. package/src/seo/__tests__/schema-builders.test.ts +57 -0
  27. package/src/seo/__tests__/seo.integration.test.ts +258 -0
  28. package/src/seo/__tests__/sitemap.test.ts +38 -0
  29. package/src/seo/constants.ts +60 -0
  30. package/src/seo/feature.ts +313 -0
  31. package/src/seo/handlers/seo-config.query.ts +35 -0
  32. package/src/seo/index.ts +19 -0
  33. package/src/seo/llms-txt.ts +33 -0
  34. package/src/seo/robots-txt.ts +13 -0
  35. package/src/seo/schema-builders.ts +57 -0
  36. package/src/seo/sitemap.ts +30 -0
  37. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +39 -62
@@ -1,32 +1,30 @@
1
- // DSGVO forget hook-ordering — the custom-fields anonymize strip must run
2
- // before any host hook that nulls the owner column it filters on, regardless
3
- // of feature registration order.
1
+ // DSGVO forget hook-ordering — a redaction hook with order
2
+ // EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER must run before any host hook that
3
+ // nulls the owner column it filters on, regardless of feature registration
4
+ // order. (Originally proven via the custom-fields sensitive strip; that strip
5
+ // was removed in #972, so this test pins the generic runner guarantee with a
6
+ // synthetic redact hook.)
4
7
  //
5
8
  // Two host entities are wired with the OPPOSITE EXT_USER_DATA registration
6
- // order: one registers the custom-fields strip first (the order that happened
7
- // to be safe), the other registers the owner-nulling host hook first (the
8
- // order that exposed the bug). Both run through the real `runForgetCleanup`
9
- // with strategy=anonymize. With the order-sentinel fix both strip the
10
- // sensitive jsonb key; without it the host-first entity would leave it behind
11
- // (the strip matches 0 rows after the owner column is nulled).
12
- //
13
- // This drives the full runner on purpose: the existing custom-fields
14
- // user-data-rights test invokes the strip hook in isolation
15
- // (getExtensionUsages + direct call) and is structurally blind to ordering.
9
+ // order: one registers the redact hook first (the order that happened to be
10
+ // safe), the other registers the owner-nulling host hook first (the order
11
+ // that exposed the bug). Both run through the real `runForgetCleanup` with
12
+ // strategy=anonymize. With the order-sentinel both strip the jsonb key;
13
+ // without it the host-first entity would leave it behind (the redact matches
14
+ // 0 rows after the owner column is nulled).
16
15
 
17
16
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
17
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
19
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
20
18
  import {
21
19
  createEntity,
22
20
  createTextField,
23
21
  defineFeature,
24
22
  EXT_USER_DATA,
23
+ EXT_USER_DATA_ORDER,
25
24
  type UserDataDeleteHook,
26
25
  } from "@cosmicdrift/kumiko-framework/engine";
27
26
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
28
27
  import {
29
- createTestUser,
30
28
  resetEventStore,
31
29
  setupTestStack,
32
30
  type TestStack,
@@ -34,10 +32,7 @@ import {
34
32
  } from "@cosmicdrift/kumiko-framework/stack";
35
33
  import { seedRow as seedProjectionRow } from "@cosmicdrift/kumiko-framework/testing";
36
34
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
37
- import { fieldDefinitionEntity } from "../../custom-fields/entity";
38
- import { createCustomFieldsFeature } from "../../custom-fields/feature";
39
35
  import { customFieldsField } from "../../custom-fields/wire-for-entity";
40
- import { wireCustomFieldsUserDataRightsFor } from "../../custom-fields/wire-user-data-rights";
41
36
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
42
37
  import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
43
38
  import { createSessionsFeature } from "../../sessions";
@@ -70,6 +65,20 @@ function makeHostDeleteHook(tableName: string): UserDataDeleteHook {
70
65
  };
71
66
  }
72
67
 
68
+ // Owner-keyed jsonb redaction with the REDACT_BEFORE_OWNER order sentinel —
69
+ // the shape the removed custom-fields strip had, kept synthetic here.
70
+ function makeRedactHook(tableName: string): UserDataDeleteHook {
71
+ return async (ctx, strategy) => {
72
+ // skip: delete strategy removes rows wholesale — redaction N/A.
73
+ if (strategy === "delete") return;
74
+ await asRawClient(ctx.db).unsafe(
75
+ `UPDATE ${tableName} SET custom_fields = custom_fields - 'ssn'
76
+ WHERE inserted_by_id = $1 AND tenant_id = $2`,
77
+ [ctx.userId, ctx.tenantId],
78
+ );
79
+ };
80
+ }
81
+
73
82
  interface HostSpec {
74
83
  readonly entityName: string;
75
84
  readonly tableName: string;
@@ -99,19 +108,16 @@ function makeEntity(tableName: string) {
99
108
 
100
109
  const cfFirstEntity = makeEntity(CF_FIRST.tableName);
101
110
  const hostFirstEntity = makeEntity(HOST_FIRST.tableName);
102
- const cfFirstTable = buildEntityTable(CF_FIRST.entityName, cfFirstEntity);
103
- const hostFirstTable = buildEntityTable(HOST_FIRST.entityName, hostFirstEntity);
104
111
 
105
- // Registration order is the whole point: cfFirst registers the strip BEFORE the
106
- // owner-nulling host hook; hostFirst registers the host hook FIRST (the order
107
- // that exposed the bug). The order-sentinel must make both correct.
112
+ // Registration order is the whole point: cfFirst registers the redact hook
113
+ // BEFORE the owner-nulling host hook; hostFirst registers the host hook FIRST
114
+ // (the order that exposed the bug). The order-sentinel must make both correct.
108
115
  const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
109
116
  r.entity(CF_FIRST.entityName, cfFirstEntity);
110
- r.requires("custom-fields");
111
- wireCustomFieldsUserDataRightsFor(r, {
112
- entityName: CF_FIRST.entityName,
113
- entityTable: cfFirstTable,
114
- userIdColumn: "inserted_by_id",
117
+ r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
118
+ export: async () => null,
119
+ delete: makeRedactHook(CF_FIRST.tableName),
120
+ order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
115
121
  });
116
122
  r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
117
123
  export: async () => null,
@@ -121,19 +127,17 @@ const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
121
127
 
122
128
  const hostFirstFeature = defineFeature(HOST_FIRST.featureName, (r) => {
123
129
  r.entity(HOST_FIRST.entityName, hostFirstEntity);
124
- r.requires("custom-fields");
125
130
  r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
126
131
  export: async () => null,
127
132
  delete: makeHostDeleteHook(HOST_FIRST.tableName),
128
133
  });
129
- wireCustomFieldsUserDataRightsFor(r, {
130
- entityName: HOST_FIRST.entityName,
131
- entityTable: hostFirstTable,
132
- userIdColumn: "inserted_by_id",
134
+ r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
135
+ export: async () => null,
136
+ delete: makeRedactHook(HOST_FIRST.tableName),
137
+ order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
133
138
  });
134
139
  });
135
140
 
136
- const admin = createTestUser({ id: 1, roles: ["TenantAdmin"], tenantId: TENANT });
137
141
  const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000a1";
138
142
 
139
143
  let stack: TestStack;
@@ -147,14 +151,12 @@ beforeAll(async () => {
147
151
  createSessionsFeature(),
148
152
  createDataRetentionFeature(),
149
153
  createComplianceProfilesFeature(),
150
- createCustomFieldsFeature(),
151
154
  createUserDataRightsFeature(),
152
155
  cfFirstFeature,
153
156
  hostFirstFeature,
154
157
  ],
155
158
  });
156
159
  await unsafeCreateEntityTable(stack.db, userEntity);
157
- await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
158
160
  await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
159
161
  await unsafeCreateEntityTable(stack.db, cfFirstEntity);
160
162
  await unsafeCreateEntityTable(stack.db, hostFirstEntity);
@@ -166,21 +168,6 @@ afterAll(async () => {
166
168
  await stack.cleanup();
167
169
  });
168
170
 
169
- async function defineSensitiveField(entityName: string, fieldKey: string, sensitive: boolean) {
170
- await stack.http.writeOk(
171
- "custom-fields:write:define-tenant-field",
172
- {
173
- entityName,
174
- fieldKey,
175
- serializedField: { type: "text", sensitive },
176
- required: false,
177
- searchable: false,
178
- displayOrder: 0,
179
- },
180
- admin,
181
- );
182
- }
183
-
184
171
  async function seedAnonymizeOverride(entityName: string) {
185
172
  await seedProjectionRow(stack.db, tenantRetentionOverrideTable, {
186
173
  entityName,
@@ -217,25 +204,15 @@ beforeEach(async () => {
217
204
  await resetEventStore(stack);
218
205
  await asRawClient(stack.db).unsafe(`DELETE FROM ${CF_FIRST.tableName}`);
219
206
  await asRawClient(stack.db).unsafe(`DELETE FROM ${HOST_FIRST.tableName}`);
220
- await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
221
207
  await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_retention_overrides`);
222
208
  await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
223
209
  });
224
210
 
225
211
  describe("forget hook-ordering :: redaction runs before owner-nulling regardless of registration order", () => {
226
- test("both registration orders strip the sensitive jsonb key and null the owner column", async () => {
227
- // Field defs (sensitive ssn, non-sensitive safe) for both entities.
212
+ test("both registration orders strip the redacted jsonb key and null the owner column", async () => {
228
213
  for (const spec of [CF_FIRST, HOST_FIRST]) {
229
- await defineSensitiveField(spec.entityName, "ssn", true);
230
- await defineSensitiveField(spec.entityName, "safe", false);
231
214
  await seedAnonymizeOverride(spec.entityName);
232
215
  }
233
- // Project the field definitions. Assert the pass had no failures: a silent
234
- // projection failure would leave read_custom_field_definitions empty, make
235
- // loadSensitiveFieldKeys return [], and turn the strip into a no-op —
236
- // verifying the test exercises the strip for the right reason.
237
- const pass = await stack.eventDispatcher?.runOnce();
238
- expect(pass?.failed ?? 0).toBe(0);
239
216
 
240
217
  const cfRowId = "dddddddd-dddd-4ddd-8ddd-000000000001";
241
218
  const hostRowId = "dddddddd-dddd-4ddd-8ddd-000000000002";