@cosmicdrift/kumiko-bundled-features 0.209.0 → 0.210.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 (53) hide show
  1. package/package.json +8 -8
  2. package/src/auth-mfa/schema/user-mfa.ts +8 -5
  3. package/src/auth-mfa-user-data/hooks.ts +9 -8
  4. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
  5. package/src/billing-foundation/entities.ts +5 -3
  6. package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
  7. package/src/compliance-profiles/schema/profile-selection.ts +2 -1
  8. package/src/config/table.ts +4 -1
  9. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
  10. package/src/custom-fields/feature.ts +2 -2
  11. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  12. package/src/data-retention/schema/tenant-retention-override.ts +6 -3
  13. package/src/delivery/tables.ts +1 -1
  14. package/src/document-ingest-foundation/entity.ts +6 -3
  15. package/src/files/README.md +11 -11
  16. package/src/form-draft/entity.ts +6 -3
  17. package/src/inbound-mail-foundation/entities.ts +13 -11
  18. package/src/inbound-mail-foundation/feature.ts +5 -5
  19. package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
  20. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
  21. package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
  22. package/src/jobs/db/queries/retention.ts +44 -0
  23. package/src/jobs/events.ts +5 -8
  24. package/src/jobs/feature.ts +26 -146
  25. package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
  26. package/src/jobs/index.ts +1 -1
  27. package/src/jobs/job-run-logger.ts +112 -59
  28. package/src/jobs/job-run-table.ts +36 -28
  29. package/src/ledger/entity.ts +5 -3
  30. package/src/managed-pages/screens/branding-screen.ts +2 -1
  31. package/src/managed-pages/table.ts +3 -2
  32. package/src/notes-history/entity.ts +8 -4
  33. package/src/notes-history-user-data/hooks.ts +6 -5
  34. package/src/personal-access-tokens/schema/api-token.ts +2 -1
  35. package/src/sessions/feature.ts +0 -4
  36. package/src/sessions/schema/user-session.ts +4 -2
  37. package/src/tags/entity.ts +11 -5
  38. package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
  39. package/src/template-resolver/table.ts +4 -1
  40. package/src/template-resolver/user-content-table.ts +5 -2
  41. package/src/template-resolver-user-data/hooks.ts +6 -5
  42. package/src/tenant/invitation-table.ts +3 -3
  43. package/src/tenant-lifecycle/boot-checks.ts +2 -2
  44. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
  45. package/src/tenant-settings/feature.ts +12 -0
  46. package/src/user/schema/user.ts +5 -5
  47. package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
  48. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
  49. package/src/user-data-rights/boot-checks.ts +2 -2
  50. package/src/user-data-rights/feature.ts +4 -4
  51. package/src/user-data-rights/schema/download-token.ts +12 -10
  52. package/src/user-data-rights/schema/export-job.ts +5 -4
  53. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +1 -1
@@ -26,11 +26,12 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
26
26
  };
27
27
  };
28
28
 
29
- // Deliberate no-op: `body` is annotated `userOwned` (entity.ts), which relies
30
- // on crypto-shredding (mounted KMS) for erasure — destroying the author's
31
- // subject key makes every note-entry event AND projected row unreadable at
32
- // once, without needing a physical delete (which would also break the
33
- // bundle's append-only history for OTHER entities' co-authors reading it).
29
+ // Deliberate no-op: `body` is annotated `personal: { of: "authorId" }`
30
+ // (entity.ts), which relies on crypto-shredding (mounted KMS) for erasure —
31
+ // destroying the author's subject key makes every note-entry event AND
32
+ // projected row unreadable at once, without needing a physical delete
33
+ // (which would also break the bundle's append-only history for OTHER
34
+ // entities' co-authors reading it).
34
35
  // Same tradeoff as job-run/delivery-attempt (user-data-rights-defaults).
35
36
  // Precondition: this ONLY erases anything if the app mounts a KMS adapter —
36
37
  // without one, userOwned fields fall back to plaintext storage framework-wide
@@ -33,7 +33,8 @@ export const apiTokenEntity = createEntity({
33
33
  required: true,
34
34
  maxLength: 120,
35
35
  access: { write: access.privileged },
36
- userOwned: { ownerField: "userId" },
36
+ personal: { of: "userId" },
37
+ find: "none",
37
38
  }),
38
39
  tokenHash: createTextField({
39
40
  required: true,
@@ -245,10 +245,6 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
245
245
  type: "projectionDetail",
246
246
  query: SessionQueries.detail,
247
247
  listScreenId: SESSION_LIST_SCREEN_ID,
248
- // No write path on this screen — a "Cancel" button here would only
249
- // ever discard nothing. Back-navigation still works via listScreenId
250
- // above (resolveDetailBreadcrumb, renderer-web) — fw#2245.
251
- hideActions: true,
252
248
  layout: {
253
249
  sections: [
254
250
  {
@@ -63,12 +63,14 @@ export const userSessionEntity = createEntity({
63
63
  ip: createTextField({
64
64
  maxLength: 64,
65
65
  access: { write: access.privileged },
66
- userOwned: { ownerField: "userId" },
66
+ personal: { of: "userId" },
67
+ find: "none",
67
68
  }),
68
69
  userAgent: createTextField({
69
70
  maxLength: 512,
70
71
  access: { write: access.privileged },
71
- userOwned: { ownerField: "userId" },
72
+ personal: { of: "userId" },
73
+ find: "none",
72
74
  }),
73
75
  },
74
76
  });
@@ -7,11 +7,17 @@ export const tagEntity = createEntity({
7
7
  table: "read_tags",
8
8
  fields: {
9
9
  // Catalog labels ("urgent", "billing"), not user-identifying content —
10
- // allowPlaintext silences the user-content heuristic (456/5). A tenant
11
- // COULD name a tag after a person; if that becomes a real requirement,
12
- // this needs a userOwned annotation + forget/export hooks in the
13
- // user-data-rights pipeline (none exist for tags today).
14
- name: createTextField({ required: true, maxLength: 64, allowPlaintext: "catalog-label" }),
10
+ // `personal: false` silences the user-content heuristic (456/5). A
11
+ // tenant COULD name a tag after a person; if that becomes a real
12
+ // requirement, this needs a `personal: { of: "<f>" }` annotation +
13
+ // forget/export hooks in the user-data-rights pipeline (none exist for
14
+ // tags today).
15
+ name: createTextField({
16
+ required: true,
17
+ maxLength: 64,
18
+ personal: false,
19
+ reason: "catalog_label",
20
+ }),
15
21
  // Optional UI hint (hex or token). No enforcement — purely for rendering.
16
22
  color: createTextField({ maxLength: 32 }),
17
23
  // Optional entity-type scope (GitLab project-vs-group labels): empty = global
@@ -321,7 +321,7 @@ describe("GDPR :: the owner column is what makes erasure possible", () => {
321
321
 
322
322
  test("the tenant-wide table keeps its business-data declaration", () => {
323
323
  const content = templateResourceEntity.fields["content"];
324
- expect((content as { allowPlaintext?: string }).allowPlaintext).toBe("is-business-data");
324
+ expect((content as { allowPlaintext?: string }).allowPlaintext).toBe("is_business_data");
325
325
  expect((content as { userOwned?: unknown }).userOwned).toBeUndefined();
326
326
  });
327
327
 
@@ -30,7 +30,10 @@ export const templateResourceEntity = createEntity({
30
30
  folder: createTextField({}),
31
31
  // Template-body is authored by TenantAdmin/Operator (email-templates etc.),
32
32
  // business data — kein end-user UGC.
33
- content: createLongTextField({ allowPlaintext: "is-business-data" }),
33
+ content: createLongTextField({
34
+ personal: false,
35
+ reason: "is_business_data",
36
+ }),
34
37
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
35
38
  variableSchema: createLongTextField({}),
36
39
  linkedResources: createLongTextField({}),
@@ -24,13 +24,16 @@ import { CONTENT_FORMATS, TEMPLATE_KINDS } from "./constants";
24
24
  export const userContentEntryEntity = createEntity({
25
25
  table: "read_user_content_entries",
26
26
  fields: {
27
- ownerId: createTextField({ required: true, subjectRef: true }),
27
+ ownerId: createTextField({ required: true, personal: "ref" }),
28
28
  slug: createTextField({ required: true }),
29
29
  kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
30
30
  locale: createTextField({ required: true }),
31
31
  title: createTextField({}),
32
32
  folder: createTextField({}),
33
- content: createLongTextField({ userOwned: { ownerField: "ownerId" } }),
33
+ content: createLongTextField({
34
+ personal: { of: "ownerId" },
35
+ find: "none",
36
+ }),
34
37
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
35
38
  },
36
39
  indexes: [
@@ -29,11 +29,12 @@ export const userContentExportHook: UserDataExportHook = async (ctx) => {
29
29
  };
30
30
  };
31
31
 
32
- // Deliberate no-op: `content` is annotated `userOwned`, so erasure runs via
33
- // crypto-shredding — destroying the owner's subject key makes every event AND
34
- // the projected row unreadable at once. A physical DELETE would not be
35
- // rebuild-safe here: user-content-entry is event-sourced, so a replay would
36
- // bring the row back (with unreadable content, but back).
32
+ // Deliberate no-op: `content` is annotated `personal: { of: "ownerId" }`, so
33
+ // erasure runs via crypto-shredding — destroying the owner's subject key
34
+ // makes every event AND the projected row unreadable at once. A physical
35
+ // DELETE would not be rebuild-safe here: user-content-entry is
36
+ // event-sourced, so a replay would bring the row back (with unreadable
37
+ // content, but back).
37
38
  // Precondition: this only erases anything if the app mounts a KMS adapter —
38
39
  // without one, userOwned fields fall back to plaintext framework-wide and
39
40
  // forget is a true no-op for `content`. Same property as notes-history.
@@ -58,7 +58,7 @@ export const tenantInvitationEntity = createEntity({
58
58
  fields: {
59
59
  // Eingeladene Email — case-insensitive normalisiert beim Insert.
60
60
  // PII bis zur Annahme (danach hat der User selbst seine email in users).
61
- email: createTextField({ required: true, maxLength: 320, pii: true, lookupable: true }),
61
+ email: createTextField({ required: true, maxLength: 320, personal: "self", find: "exact" }),
62
62
  // Membership-Rolle die dem User nach Accept gegeben wird. Default
63
63
  // im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
64
64
  role: createTextField({ required: true, maxLength: 50 }),
@@ -76,8 +76,8 @@ export const tenantInvitationEntity = createEntity({
76
76
  // decrypt-and-scan (see user-data-rights-defaults' invitation hook).
77
77
  invitedBy: createTextField({
78
78
  required: true,
79
- userOwned: { ownerField: "invitedBy" },
80
- lookupable: true,
79
+ personal: { of: "invitedBy" },
80
+ find: "exact",
81
81
  }),
82
82
  // UI-Anzeige — Wahrheit liegt in Redis-TTL.
83
83
  expiresAt: createTimestampField({ required: true }),
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EXT_TENANT_DATA,
3
3
  type FeatureDefinition,
4
- type PiiAnnotations,
4
+ type ResolvedPiiFlags,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { entitiesOf } from "../shared";
7
7
 
@@ -13,7 +13,7 @@ import { entitiesOf } from "../shared";
13
13
  // original guard's "tenant-lifecycle mounted" gate exactly (unlike gating on
14
14
  // a sibling feature, which would silently narrow coverage).
15
15
 
16
- function hasTenantOwned(field: unknown): field is PiiAnnotations {
16
+ function hasTenantOwned(field: unknown): field is ResolvedPiiFlags {
17
17
  return (
18
18
  typeof field === "object" &&
19
19
  field !== null &&
@@ -0,0 +1,58 @@
1
+ // fw-i18n-funde: tenant-settings has admin-write keys at tenant-home scope
2
+ // (see config.ts) — access.admin includes SystemAdmin, which cascades a
3
+ // "set the platform default" screen up to the system audience alongside
4
+ // the tenant-home screen (buildConfigFeatureSchema's ELEVATED_ROLES). Both
5
+ // used to share the exact same `${feature}.settings` nav label, so the
6
+ // sidebar showed the identical raw i18n key twice, once under each
7
+ // audience. This test proves the real, declared translations resolve to
8
+ // actual text (not the raw key) AND that the two nav entries read
9
+ // differently — a regression guard, not an existence check.
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
13
+ import { buildConfigFeatureSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { createTenantSettingsFeature } from "../feature";
15
+
16
+ function translate(
17
+ translations: Readonly<Record<string, Readonly<Record<string, string>> | undefined>>,
18
+ key: string,
19
+ locale = "en",
20
+ ): string {
21
+ return translations[key]?.[locale] ?? key;
22
+ }
23
+
24
+ describe("tenant-settings — Settings-Hub nav/section labels", () => {
25
+ test("system + tenant scope screens both translate, and their nav labels differ", () => {
26
+ const registry = createRegistry([createConfigFeature(), createTenantSettingsFeature()]);
27
+ const schema = buildConfigFeatureSchema(registry);
28
+ const translations = registry.getFeature("tenant-settings")?.translations ?? {};
29
+
30
+ const systemNav = schema.navs.find((n) => n.id === "tenant-settings-system");
31
+ const tenantNav = schema.navs.find((n) => n.id === "tenant-settings-tenant");
32
+ expect(systemNav).toBeDefined();
33
+ expect(tenantNav).toBeDefined();
34
+ if (systemNav === undefined || tenantNav === undefined) throw new Error("unreachable");
35
+
36
+ const systemLabel = translate(translations, systemNav.label);
37
+ const tenantLabel = translate(translations, tenantNav.label);
38
+
39
+ // Both labels actually resolve to text — not an echoed raw key.
40
+ expect(systemLabel).not.toBe(systemNav.label);
41
+ expect(tenantLabel).not.toBe(tenantNav.label);
42
+ // The cross-scope duplicate this test guards against: the two nav
43
+ // entries read differently instead of repeating the same words under
44
+ // both "Platform" and "Organization".
45
+ expect(systemLabel).not.toBe(tenantLabel);
46
+
47
+ const systemScreen = schema.screens.find((s) => s.id === "tenant-settings-system");
48
+ expect(systemScreen).toBeDefined();
49
+ if (systemScreen === undefined || systemScreen.type !== "configEdit") {
50
+ throw new Error("unreachable");
51
+ }
52
+ const section = systemScreen.layout.sections[0];
53
+ const sectionTitle = section !== undefined && "title" in section ? section.title : undefined;
54
+ expect(sectionTitle).toBeDefined();
55
+ if (sectionTitle === undefined) throw new Error("unreachable");
56
+ expect(translate(translations, sectionTitle)).not.toBe(sectionTitle);
57
+ });
58
+ });
@@ -41,6 +41,18 @@ export function createTenantSettingsFeature(
41
41
  de: "Tenant-Einstellungen",
42
42
  en: "Tenant Settings",
43
43
  },
44
+ // Settings-Hub nav label + configEdit section heading (same key, both
45
+ // scopes fall back here — see buildConfigFeatureSchema).
46
+ "tenant-settings.settings": { de: "Tenant-Einstellungen", en: "Tenant Settings" },
47
+ // Both currency/locale are tenant-home with admin write access, which
48
+ // cascades a SystemAdmin-only "set the platform default" screen up to
49
+ // the system audience — scoped override so its nav entry reads
50
+ // differently from the tenant-home entry instead of repeating the
51
+ // same words under both "Platform" and "Organization".
52
+ "tenant-settings.settings.system": {
53
+ de: "Tenant-Einstellungen (Plattform-Standard)",
54
+ en: "Tenant Settings (Platform Default)",
55
+ },
44
56
  },
45
57
  });
46
58
  });
@@ -67,8 +67,8 @@ export const userEntity = createEntity({
67
67
  required: true,
68
68
  format: "email",
69
69
  maxLength: 320,
70
- pii: true,
71
- lookupable: true,
70
+ personal: "self",
71
+ find: "exact",
72
72
  access: { write: access.privileged },
73
73
  }),
74
74
 
@@ -85,8 +85,8 @@ export const userEntity = createEntity({
85
85
  displayName: createTextField({
86
86
  required: true,
87
87
  maxLength: 100,
88
- pii: true,
89
- searchable: true,
88
+ personal: "self",
89
+ find: "fuzzy",
90
90
  }),
91
91
  // No default here — tenant-settings owns the app-wide locale default; see #1637.
92
92
  locale: createTextField({ maxLength: 10 }),
@@ -169,7 +169,7 @@ export const userEntity = createEntity({
169
169
  access: { write: access.privileged },
170
170
  }),
171
171
  },
172
- // fw#2134 — email is `pii: true, lookupable: true`, so the column holds
172
+ // fw#2134 — email is `personal: "self", find: "exact"`, so the column holds
173
173
  // per-row ciphertext: a plain unique index on it can't catch duplicates.
174
174
  // `unique: true` over a lookupable column makes buildEntityTable /
175
175
  // deriveEntityTableMeta emit a second, partial unique index over the
@@ -96,7 +96,17 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
96
96
 
97
97
  test("V3: pii entity without any EXT_USER_DATA hook → throws the guard's own message", () => {
98
98
  const bad = defineFeature("crm", (r) => {
99
- r.entity("contact", createEntity({ fields: { email: createTextField({ pii: true }) } }));
99
+ r.entity(
100
+ "contact",
101
+ createEntity({
102
+ fields: {
103
+ email: createTextField({
104
+ personal: "self",
105
+ find: "none",
106
+ }),
107
+ },
108
+ }),
109
+ );
100
110
  });
101
111
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
102
112
  });
@@ -110,7 +120,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
110
120
  source: "contact",
111
121
  table: projectionTable,
112
122
  apply: { [contactCreated.name]: async () => {} },
113
- entity: createEntity({ fields: { email: createTextField({ pii: true }) } }),
123
+ entity: createEntity({
124
+ fields: {
125
+ email: createTextField({
126
+ personal: "self",
127
+ find: "none",
128
+ }),
129
+ },
130
+ }),
114
131
  });
115
132
  });
116
133
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
@@ -120,7 +137,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
120
137
  const bad = defineFeature("tasks", (r) => {
121
138
  r.entity(
122
139
  "task",
123
- createEntity({ fields: { assigneeId: createTextField({ subjectRef: true }) } }),
140
+ createEntity({
141
+ fields: {
142
+ assigneeId: createTextField({
143
+ personal: "ref",
144
+ }),
145
+ },
146
+ }),
124
147
  );
125
148
  });
126
149
  expect(() => validateBoot([...baseFeatures(), bad])).toThrow(/EXT_USER_DATA hook.*Art\.17 gap/);
@@ -131,7 +154,13 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
131
154
  r.requires("user-data-rights");
132
155
  r.entity(
133
156
  "task",
134
- createEntity({ fields: { assigneeId: createTextField({ subjectRef: true }) } }),
157
+ createEntity({
158
+ fields: {
159
+ assigneeId: createTextField({
160
+ personal: "ref",
161
+ }),
162
+ },
163
+ }),
135
164
  );
136
165
  r.useExtension(EXT_USER_DATA, "task", { export: async () => null, delete: async () => {} });
137
166
  });
@@ -148,7 +177,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
148
177
  source: "contact",
149
178
  table: projectionTable,
150
179
  apply: { [contactCreated.name]: async () => {} },
151
- entity: createEntity({ fields: { email: createTextField({ pii: true }) } }),
180
+ entity: createEntity({
181
+ fields: {
182
+ email: createTextField({
183
+ personal: "self",
184
+ find: "none",
185
+ }),
186
+ },
187
+ }),
152
188
  });
153
189
  r.useExtension(EXT_USER_DATA, "contact-summary", {
154
190
  export: async () => null,
@@ -162,7 +198,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
162
198
  const bad = defineFeature("billing", (r) => {
163
199
  r.entity(
164
200
  "subscription",
165
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
201
+ createEntity({
202
+ fields: {
203
+ providerCustomerId: createTextField({
204
+ personal: "tenant",
205
+ find: "none",
206
+ }),
207
+ },
208
+ }),
166
209
  );
167
210
  });
168
211
  expect(() => validateBoot([...minimalTenantLifecycleFeatures(), bad])).toThrow(
@@ -174,7 +217,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
174
217
  const bad = defineFeature("billing", (r) => {
175
218
  r.entity(
176
219
  "subscription",
177
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
220
+ createEntity({
221
+ fields: {
222
+ providerCustomerId: createTextField({
223
+ personal: "tenant",
224
+ find: "none",
225
+ }),
226
+ },
227
+ }),
178
228
  );
179
229
  });
180
230
  const features = minimalTenantLifecycleFeatures().concat(bad);
@@ -186,7 +236,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
186
236
  const notMounted = defineFeature("billing-standalone", (r) => {
187
237
  r.entity(
188
238
  "subscription",
189
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
239
+ createEntity({
240
+ fields: {
241
+ providerCustomerId: createTextField({
242
+ personal: "tenant",
243
+ find: "none",
244
+ }),
245
+ },
246
+ }),
190
247
  );
191
248
  });
192
249
  expect(() => validateBoot([...baseFeatures(), notMounted])).not.toThrow();
@@ -197,7 +254,14 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
197
254
  r.requires("tenant-lifecycle");
198
255
  r.entity(
199
256
  "subscription",
200
- createEntity({ fields: { providerCustomerId: createTextField({ tenantOwned: true }) } }),
257
+ createEntity({
258
+ fields: {
259
+ providerCustomerId: createTextField({
260
+ personal: "tenant",
261
+ find: "none",
262
+ }),
263
+ },
264
+ }),
201
265
  );
202
266
  r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: async () => {} });
203
267
  });
@@ -46,8 +46,8 @@ const noteEntity = createEntity({
46
46
  authorId: createTextField({ required: true }),
47
47
  body: createTextField({
48
48
  required: true,
49
- searchable: true,
50
- userOwned: { ownerField: "authorId" },
49
+ personal: { of: "authorId" },
50
+ find: "fuzzy",
51
51
  }),
52
52
  },
53
53
  });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EXT_USER_DATA,
3
3
  type FeatureDefinition,
4
- type PiiAnnotations,
4
+ type ResolvedPiiFlags,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { entitiesOf } from "../shared";
7
7
 
@@ -58,7 +58,7 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
58
58
  if (hookedEntities.has(entityName)) continue;
59
59
  const subjectFields = Object.entries(entity.fields)
60
60
  .filter(([, field]) => {
61
- const annot = field as PiiAnnotations; // @cast-boundary schema-walk
61
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
62
62
  return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
63
63
  })
64
64
  .map(([name]) => name);
@@ -134,8 +134,8 @@ export type UserDataRightsOptions = {
134
134
  };
135
135
 
136
136
  export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
137
- // One-shot operator warning (the export cron fires every minute — warn once
138
- // per process, not every run). Lives in the factory scope so the cron closure
137
+ // One-shot operator warning (the export cron fires daily — warn once per
138
+ // process, not every run). Lives in the factory scope so the cron closure
139
139
  // shares it across runs.
140
140
  let warnedMissingExportUrl = false;
141
141
  return defineFeature("user-data-rights", (r) => {
@@ -401,7 +401,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
401
401
  // S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
402
402
  r.job(
403
403
  "run-export-jobs",
404
- { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
404
+ { trigger: { cron: "0 3 * * *" }, concurrency: "skip" },
405
405
  async (_payload, ctx) => {
406
406
  if (!ctx.db || !ctx.registry) {
407
407
  throw new Error(
@@ -488,7 +488,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
488
488
  // — Art.17 wuerde nie ausgefuehrt.
489
489
  r.job(
490
490
  "run-forget-cleanup",
491
- { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
491
+ { trigger: { cron: "0 3 * * *" }, concurrency: "skip" },
492
492
  async (_payload, ctx) => {
493
493
  if (!ctx.db || !ctx.registry) {
494
494
  throw new Error(
@@ -75,21 +75,23 @@ export const exportDownloadTokenEntity = createEntity({
75
75
  // (z.B. broken Sync-Tool).
76
76
  useCount: createBigIntField({}),
77
77
 
78
- // Audit: Source-IP des letzten Downloads. Hilft DPO bei Untersuchung
79
- // ungewoehnlicher Aktivitaeten ("Token wurde von 5 verschiedenen
80
- // IPs genutzt"). Plain-IP-V4/IPv6, kein hash — DPO braucht direkt
81
- // lesbar. is-business-data weil Token-Audit kein User-PII ist
82
- // (gehoert dem Tenant-Operator).
78
+ // Audit: source IP of the last download. Helps the DPO investigate
79
+ // unusual activity ("token was used from 5 different IPs"). Plain
80
+ // IPv4/IPv6, no hash — DPO needs it directly readable.
81
+ // `reason: "is_business_data"` because token-audit data isn't user
82
+ // PII (it belongs to the tenant operator).
83
83
  lastUsedFromIp: createTextField({
84
- maxLength: 45, // IPv6 max
85
- allowPlaintext: "is-business-data",
84
+ maxLength: 45,
85
+ personal: false,
86
+ reason: "is_business_data",
86
87
  }),
87
88
 
88
- // Audit: User-Agent des letzten Downloads. Audit-Wert (Email-Client
89
- // vs Browser vs CLI-Tool unterscheidbar). is-business-data analog.
89
+ // Audit: user agent of the last download (distinguishes email
90
+ // client vs browser vs CLI tool). Same `reason` as above.
90
91
  lastUsedUserAgent: createTextField({
91
92
  maxLength: 500,
92
- allowPlaintext: "is-business-data",
93
+ personal: false,
94
+ reason: "is_business_data",
93
95
  }),
94
96
  },
95
97
 
@@ -133,11 +133,12 @@ export const exportJobEntity = createEntity({
133
133
  // verbleiben.
134
134
  expiresAt: createTimestampField({}),
135
135
 
136
- // Failed-State Diagnose. longText weil Hook-Errors mit Stack-Trace
137
- // mehrere KB werden koennen. is-business-data weil Job-Status
138
- // public im UI-Polling sichtbar (kein PII).
136
+ // Failed-state diagnostics. longText because hook errors with stack
137
+ // traces can run several KB. `reason: "is_business_data"` because
138
+ // job status is publicly visible in UI polling (no PII).
139
139
  errorMessage: createLongTextField({
140
- allowPlaintext: "is-business-data",
140
+ personal: false,
141
+ reason: "is_business_data",
141
142
  }),
142
143
 
143
144
  // Audit-Info fuer Operator: wie gross war der Export. Hilft beim
@@ -401,7 +401,7 @@ describe("delivery-attempt userData-hooks (#799)", () => {
401
401
  describe("job-run userData-hooks (#799)", () => {
402
402
  async function seedRun(id: string, triggeredById: string | null): Promise<void> {
403
403
  await asRawClient(full.db).unsafe(
404
- `INSERT INTO read_job_runs
404
+ `INSERT INTO store_job_runs
405
405
  (id, tenant_id, job_name, bull_job_id, status, payload, attempt, started_at, triggered_by_id)
406
406
  VALUES ($1::uuid, $2, 'app:job:export', $3, 'completed', '{"scope":"mine"}', 1, now(), $4)`,
407
407
  [id, TENANT_A, `bull-${id}`, triggeredById],