@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.
- package/package.json +8 -8
- package/src/auth-mfa/schema/user-mfa.ts +8 -5
- package/src/auth-mfa-user-data/hooks.ts +9 -8
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
- package/src/billing-foundation/entities.ts +5 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
- package/src/compliance-profiles/schema/profile-selection.ts +2 -1
- package/src/config/table.ts +4 -1
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
- package/src/custom-fields/feature.ts +2 -2
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
- package/src/data-retention/schema/tenant-retention-override.ts +6 -3
- package/src/delivery/tables.ts +1 -1
- package/src/document-ingest-foundation/entity.ts +6 -3
- package/src/files/README.md +11 -11
- package/src/form-draft/entity.ts +6 -3
- package/src/inbound-mail-foundation/entities.ts +13 -11
- package/src/inbound-mail-foundation/feature.ts +5 -5
- package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
- package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
- package/src/jobs/db/queries/retention.ts +44 -0
- package/src/jobs/events.ts +5 -8
- package/src/jobs/feature.ts +26 -146
- package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-run-logger.ts +112 -59
- package/src/jobs/job-run-table.ts +36 -28
- package/src/ledger/entity.ts +5 -3
- package/src/managed-pages/screens/branding-screen.ts +2 -1
- package/src/managed-pages/table.ts +3 -2
- package/src/notes-history/entity.ts +8 -4
- package/src/notes-history-user-data/hooks.ts +6 -5
- package/src/personal-access-tokens/schema/api-token.ts +2 -1
- package/src/sessions/feature.ts +0 -4
- package/src/sessions/schema/user-session.ts +4 -2
- package/src/tags/entity.ts +11 -5
- package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
- package/src/template-resolver/table.ts +4 -1
- package/src/template-resolver/user-content-table.ts +5 -2
- package/src/template-resolver-user-data/hooks.ts +6 -5
- package/src/tenant/invitation-table.ts +3 -3
- package/src/tenant-lifecycle/boot-checks.ts +2 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
- package/src/tenant-settings/feature.ts +12 -0
- package/src/user/schema/user.ts +5 -5
- package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
- package/src/user-data-rights/boot-checks.ts +2 -2
- package/src/user-data-rights/feature.ts +4 -4
- package/src/user-data-rights/schema/download-token.ts +12 -10
- package/src/user-data-rights/schema/export-job.ts +5 -4
- 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 `
|
|
30
|
-
// on crypto-shredding (mounted KMS) for erasure —
|
|
31
|
-
// subject key makes every note-entry event AND
|
|
32
|
-
// once, without needing a physical delete
|
|
33
|
-
// bundle's append-only history for OTHER
|
|
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
|
-
|
|
36
|
+
personal: { of: "userId" },
|
|
37
|
+
find: "none",
|
|
37
38
|
}),
|
|
38
39
|
tokenHash: createTextField({
|
|
39
40
|
required: true,
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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
|
-
|
|
66
|
+
personal: { of: "userId" },
|
|
67
|
+
find: "none",
|
|
67
68
|
}),
|
|
68
69
|
userAgent: createTextField({
|
|
69
70
|
maxLength: 512,
|
|
70
71
|
access: { write: access.privileged },
|
|
71
|
-
|
|
72
|
+
personal: { of: "userId" },
|
|
73
|
+
find: "none",
|
|
72
74
|
}),
|
|
73
75
|
},
|
|
74
76
|
});
|
package/src/tags/entity.ts
CHANGED
|
@@ -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
|
-
//
|
|
11
|
-
// COULD name a tag after a person; if that becomes a real
|
|
12
|
-
// this needs a
|
|
13
|
-
// user-data-rights pipeline (none exist for
|
|
14
|
-
|
|
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("
|
|
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({
|
|
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,
|
|
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({
|
|
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 `
|
|
33
|
-
// crypto-shredding — destroying the owner's subject key
|
|
34
|
-
// the projected row unreadable at once. A physical
|
|
35
|
-
// rebuild-safe here: user-content-entry is
|
|
36
|
-
// bring the row back (with unreadable
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
});
|
package/src/user/schema/user.ts
CHANGED
|
@@ -67,8 +67,8 @@ export const userEntity = createEntity({
|
|
|
67
67
|
required: true,
|
|
68
68
|
format: "email",
|
|
69
69
|
maxLength: 320,
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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 `
|
|
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(
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
});
|
package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts
CHANGED
|
@@ -46,8 +46,8 @@ const noteEntity = createEntity({
|
|
|
46
46
|
authorId: createTextField({ required: true }),
|
|
47
47
|
body: createTextField({
|
|
48
48
|
required: true,
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
138
|
-
//
|
|
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
|
|
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
|
|
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:
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// (
|
|
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,
|
|
85
|
-
|
|
84
|
+
maxLength: 45,
|
|
85
|
+
personal: false,
|
|
86
|
+
reason: "is_business_data",
|
|
86
87
|
}),
|
|
87
88
|
|
|
88
|
-
// Audit:
|
|
89
|
-
// vs
|
|
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
|
-
|
|
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-
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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
|
-
|
|
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
|
package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts
CHANGED
|
@@ -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
|
|
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],
|