@cosmicdrift/kumiko-bundled-features 0.252.0 → 0.253.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 +9 -9
- package/src/agent-tools/__tests__/agent-manifest.test.ts +2 -2
- package/src/agent-tools/__tests__/agent-optout.test.ts +22 -3
- package/src/agent-tools/__tests__/agent-tools.integration.test.ts +7 -1
- package/src/agent-tools/__tests__/tool-catalog.test.ts +11 -4
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +13 -2
- package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
- package/src/audit/__tests__/audit.integration.test.ts +2 -2
- package/src/billing-foundation/entities.ts +30 -5
- package/src/cap-counter/entity.ts +8 -1
- package/src/config/table.ts +4 -2
- package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +141 -1
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +1 -1
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +42 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +1 -1
- package/src/custom-fields/entity.ts +24 -4
- package/src/data-retention/__tests__/resolver.test.ts +6 -4
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +3 -3
- package/src/delivery/tables.ts +6 -2
- package/src/document-ingest-foundation/entity.ts +6 -2
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +12 -2
- package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -1
- package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +12 -2
- package/src/folders/__tests__/folders.integration.test.ts +8 -1
- package/src/folders/entity.ts +27 -5
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +8 -1
- package/src/form-draft/entity.ts +6 -1
- package/src/inbound-mail-foundation/entities.ts +57 -12
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +111 -1
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +6 -1
- package/src/jobs/db/queries/stale-run-sweep.ts +36 -14
- package/src/jobs/feature.ts +5 -0
- package/src/jobs/job-run-logger.ts +7 -2
- package/src/jobs/job-run-table.ts +11 -6
- package/src/ledger/entity.ts +27 -7
- package/src/managed-pages/screens/branding-screen.ts +27 -5
- package/src/managed-pages/table.ts +23 -4
- package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +8 -1
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +8 -1
- package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +20 -3
- package/src/notes-history/__tests__/notes-history.integration.test.ts +8 -1
- package/src/notes-history/entity.ts +12 -2
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
- package/src/personal-access-tokens/schema/api-token.ts +29 -1
- package/src/secrets/table.ts +1 -1
- package/src/sessions/schema/user-session.ts +4 -0
- package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +12 -2
- package/src/tags/__tests__/tags.integration.test.ts +8 -1
- package/src/tags/entity.ts +20 -5
- package/src/template-resolver/table.ts +8 -7
- package/src/template-resolver/user-content-table.ts +6 -4
- package/src/tenant/invitation-table.ts +6 -1
- package/src/tenant/membership-table.ts +2 -2
- package/src/tenant/schema/tenant.ts +22 -3
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +1 -1
- package/src/tier-engine/entity.ts +12 -2
- package/src/user/schema/user.ts +15 -1
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
- package/src/user-data-rights/schema/download-attempt.ts +25 -7
- package/src/user-data-rights/schema/download-token.ts +4 -0
- package/src/user-data-rights/schema/export-job.ts +6 -0
|
@@ -61,15 +61,30 @@ export const fieldDefinitionEntity = createEntity({
|
|
|
61
61
|
fields: {
|
|
62
62
|
// Ziel-Entity-Name, für die dieses Field definiert wird (z.B. "property",
|
|
63
63
|
// "customer"). Max 64 char passt zu Kumiko's entity-name-Convention.
|
|
64
|
-
entityName: createTextField({
|
|
64
|
+
entityName: createTextField({
|
|
65
|
+
required: true,
|
|
66
|
+
maxLength: 64,
|
|
67
|
+
personal: false,
|
|
68
|
+
reason: "technical_reference",
|
|
69
|
+
}),
|
|
65
70
|
|
|
66
71
|
// Field-Key (z.B. "internalNumber", "vipFlag") — kebab-case oder camelCase
|
|
67
72
|
// erlaubt; UI-rendering nutzt label statt fieldKey.
|
|
68
|
-
fieldKey: createTextField({
|
|
73
|
+
fieldKey: createTextField({
|
|
74
|
+
required: true,
|
|
75
|
+
maxLength: 64,
|
|
76
|
+
personal: false,
|
|
77
|
+
reason: "technical_reference",
|
|
78
|
+
}),
|
|
69
79
|
|
|
70
80
|
// Field-Type aus dem SUPPORTED_FIELD_TYPES-Set. Validiert via Zod im
|
|
71
81
|
// write-handler.
|
|
72
|
-
type: createTextField({
|
|
82
|
+
type: createTextField({
|
|
83
|
+
required: true,
|
|
84
|
+
maxLength: 16,
|
|
85
|
+
personal: false,
|
|
86
|
+
reason: "technical_reference",
|
|
87
|
+
}),
|
|
73
88
|
|
|
74
89
|
// Required-Flag — wird beim Entity-Write gegen den value gecheckt
|
|
75
90
|
// (Stammfeld-identische Semantik, Spec-Promise Z.4).
|
|
@@ -87,6 +102,11 @@ export const fieldDefinitionEntity = createEntity({
|
|
|
87
102
|
// defaultValue, fieldAccess, i18n-labels. Beim Write-Validation
|
|
88
103
|
// dehydriert der handler dies zurück zu einer r.field.X()-Instanz und
|
|
89
104
|
// nutzt deren .schema für value-Validation.
|
|
90
|
-
serializedField: createTextField({
|
|
105
|
+
serializedField: createTextField({
|
|
106
|
+
required: true,
|
|
107
|
+
maxLength: 65536,
|
|
108
|
+
personal: false,
|
|
109
|
+
reason: "technical_reference",
|
|
110
|
+
}),
|
|
91
111
|
},
|
|
92
112
|
});
|
|
@@ -8,7 +8,7 @@ import { resolveRetentionPolicy } from "../resolver";
|
|
|
8
8
|
describe("resolveRetentionPolicy — Layer-Resolution", () => {
|
|
9
9
|
test("Layer 1 Entity-Default greift wenn weder Preset noch Override", () => {
|
|
10
10
|
const entity = createEntity({
|
|
11
|
-
fields: { foo: createTextField() },
|
|
11
|
+
fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
|
|
12
12
|
retention: { keepFor: "30d", strategy: "hardDelete", reference: "createdAt" },
|
|
13
13
|
});
|
|
14
14
|
|
|
@@ -29,7 +29,7 @@ describe("resolveRetentionPolicy — Layer-Resolution", () => {
|
|
|
29
29
|
|
|
30
30
|
test("Layer 2 Preset überschreibt Entity-Default", () => {
|
|
31
31
|
const entity = createEntity({
|
|
32
|
-
fields: { foo: createTextField() },
|
|
32
|
+
fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
|
|
33
33
|
retention: { keepFor: "7d", strategy: "hardDelete" },
|
|
34
34
|
});
|
|
35
35
|
|
|
@@ -74,7 +74,9 @@ describe("resolveRetentionPolicy — Layer-Resolution", () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test("Entity ohne retention + kein Preset + kein Override → policy=null + source=none", () => {
|
|
77
|
-
const entity = createEntity({
|
|
77
|
+
const entity = createEntity({
|
|
78
|
+
fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
|
|
79
|
+
});
|
|
78
80
|
|
|
79
81
|
const result = resolveRetentionPolicy({
|
|
80
82
|
entityName: "ticket",
|
|
@@ -172,7 +174,7 @@ describe("resolveRetentionPolicy — override-incomplete-Guard", () => {
|
|
|
172
174
|
describe("resolveRetentionPolicy — Edge-Cases", () => {
|
|
173
175
|
test("default-Preset ist leer → fällt zurück auf entity-default", () => {
|
|
174
176
|
const entity = createEntity({
|
|
175
|
-
fields: { foo: createTextField() },
|
|
177
|
+
fields: { foo: createTextField({ personal: false, reason: "technical_reference" }) },
|
|
176
178
|
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
177
179
|
});
|
|
178
180
|
|
|
@@ -28,7 +28,9 @@ import { runRetentionCleanup } from "../run-retention-cleanup";
|
|
|
28
28
|
// hardDelete, default-reference (createdAt → alias insertedAt → Spalte inserted_at).
|
|
29
29
|
const widgetEntity = createEntity({
|
|
30
30
|
table: "read_c7_widget",
|
|
31
|
-
fields: {
|
|
31
|
+
fields: {
|
|
32
|
+
label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
|
|
33
|
+
},
|
|
32
34
|
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
33
35
|
});
|
|
34
36
|
|
|
@@ -36,21 +38,27 @@ const widgetEntity = createEntity({
|
|
|
36
38
|
const gadgetEntity = createEntity({
|
|
37
39
|
table: "read_c7_gadget",
|
|
38
40
|
softDelete: true,
|
|
39
|
-
fields: {
|
|
41
|
+
fields: {
|
|
42
|
+
label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
|
|
43
|
+
},
|
|
40
44
|
retention: { keepFor: "30d", strategy: "softDelete" },
|
|
41
45
|
});
|
|
42
46
|
|
|
43
47
|
// Keine Policy → darf nie angefasst werden.
|
|
44
48
|
const plainEntity = createEntity({
|
|
45
49
|
table: "read_c7_plain",
|
|
46
|
-
fields: {
|
|
50
|
+
fields: {
|
|
51
|
+
label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
|
|
52
|
+
},
|
|
47
53
|
});
|
|
48
54
|
|
|
49
55
|
// reference="lastSeenAt" ist Boot-valide (Framework-Timestamp-Allowlist), aber
|
|
50
56
|
// die Spalte existiert auf diesem Entity nicht → Guard muss skippen.
|
|
51
57
|
const staleEntity = createEntity({
|
|
52
58
|
table: "read_c7_stale",
|
|
53
|
-
fields: {
|
|
59
|
+
fields: {
|
|
60
|
+
label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
|
|
61
|
+
},
|
|
54
62
|
retention: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
55
63
|
});
|
|
56
64
|
|
|
@@ -181,9 +181,9 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
181
181
|
// Feature with CRUD entity + declarative r.notification()
|
|
182
182
|
const ticketEntity = createEntity({
|
|
183
183
|
fields: {
|
|
184
|
-
title: createTextField({ required: true }),
|
|
185
|
-
assigneeId: createTextField(),
|
|
186
|
-
status: createTextField({ required: true }),
|
|
184
|
+
title: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
185
|
+
assigneeId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
|
|
186
|
+
status: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
187
187
|
},
|
|
188
188
|
});
|
|
189
189
|
const ticketTable = buildEntityTable("ticket", ticketEntity);
|
package/src/delivery/tables.ts
CHANGED
|
@@ -77,8 +77,12 @@ export const notificationPreferenceEntity = createEntity({
|
|
|
77
77
|
table: "read_notification_preferences",
|
|
78
78
|
fields: {
|
|
79
79
|
userId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
|
|
80
|
-
notificationType: createTextField({
|
|
81
|
-
|
|
80
|
+
notificationType: createTextField({
|
|
81
|
+
required: true,
|
|
82
|
+
personal: false,
|
|
83
|
+
reason: "technical_reference",
|
|
84
|
+
}), // qualified name or "*"
|
|
85
|
+
channel: createTextField({ required: true, personal: false, reason: "technical_reference" }), // "inApp", "email", "push", or "*"
|
|
82
86
|
enabled: createBooleanField({ default: true }),
|
|
83
87
|
},
|
|
84
88
|
});
|
|
@@ -35,8 +35,12 @@ export type DocumentExtractMeta = {
|
|
|
35
35
|
export const documentExtractEntity = createEntity({
|
|
36
36
|
table: "read_document_extracts",
|
|
37
37
|
fields: {
|
|
38
|
-
fileRefId: createTextField({ required: true }),
|
|
39
|
-
storageKey: createTextField({
|
|
38
|
+
fileRefId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
|
|
39
|
+
storageKey: createTextField({
|
|
40
|
+
required: true,
|
|
41
|
+
personal: false,
|
|
42
|
+
reason: "technical_reference",
|
|
43
|
+
}),
|
|
40
44
|
// Holds the full extracted document text (PII). `personal: "tenant"`,
|
|
41
45
|
// NOT `encrypted: true` (#1621): the master-key path has no erasure subject,
|
|
42
46
|
// so nothing here would ever be shreddable. Tenant-subject ciphertext
|
|
@@ -42,7 +42,12 @@ import { createRedisToggleSyncSignal, type RedisToggleSyncSignal } from "../togg
|
|
|
42
42
|
const widgetEntity = createEntity({
|
|
43
43
|
table: "read_widgets",
|
|
44
44
|
fields: {
|
|
45
|
-
name: createTextField({
|
|
45
|
+
name: createTextField({
|
|
46
|
+
required: true,
|
|
47
|
+
maxLength: 100,
|
|
48
|
+
personal: false,
|
|
49
|
+
reason: "technical_reference",
|
|
50
|
+
}),
|
|
46
51
|
active: createBooleanField({ default: true }),
|
|
47
52
|
},
|
|
48
53
|
});
|
|
@@ -88,7 +93,12 @@ function widgetFeature(): FeatureDefinition {
|
|
|
88
93
|
const widgetAuditEntity = createEntity({
|
|
89
94
|
table: "widget_audits",
|
|
90
95
|
fields: {
|
|
91
|
-
widgetName: createTextField({
|
|
96
|
+
widgetName: createTextField({
|
|
97
|
+
required: true,
|
|
98
|
+
maxLength: 100,
|
|
99
|
+
personal: false,
|
|
100
|
+
reason: "technical_reference",
|
|
101
|
+
}),
|
|
92
102
|
},
|
|
93
103
|
});
|
|
94
104
|
const widgetAuditTable = buildEntityTable("widget-audit", widgetAuditEntity);
|
|
@@ -30,7 +30,13 @@ describe("Sprint 8a: registered.query SYSTEM_TENANT_ID convention", () => {
|
|
|
30
30
|
const observed: string[] = [];
|
|
31
31
|
|
|
32
32
|
const dummy = defineFeature("dummy", (r) => {
|
|
33
|
-
r.entity(
|
|
33
|
+
r.entity(
|
|
34
|
+
"widget",
|
|
35
|
+
createEntity({
|
|
36
|
+
table: "Widgets",
|
|
37
|
+
fields: { name: createTextField({ personal: false, reason: "technical_reference" }) },
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
34
40
|
});
|
|
35
41
|
|
|
36
42
|
const runtime: GlobalFeatureToggleRuntime | null = null;
|
|
@@ -47,8 +47,18 @@ const projectOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
|
47
47
|
const projectEntity = createEntity({
|
|
48
48
|
table: PROJECT_TABLE,
|
|
49
49
|
fields: {
|
|
50
|
-
teamId: createTextField({
|
|
51
|
-
|
|
50
|
+
teamId: createTextField({
|
|
51
|
+
required: true,
|
|
52
|
+
maxLength: 64,
|
|
53
|
+
personal: false,
|
|
54
|
+
reason: "technical_reference",
|
|
55
|
+
}),
|
|
56
|
+
name: createTextField({
|
|
57
|
+
required: true,
|
|
58
|
+
maxLength: 64,
|
|
59
|
+
personal: false,
|
|
60
|
+
reason: "technical_reference",
|
|
61
|
+
}),
|
|
52
62
|
},
|
|
53
63
|
access: projectOwnership,
|
|
54
64
|
});
|
|
@@ -36,7 +36,14 @@ const foldersFeature = createFoldersFeature();
|
|
|
36
36
|
const CREDIT_TABLE = "folders_test_credits";
|
|
37
37
|
const creditEntity = createEntity({
|
|
38
38
|
table: CREDIT_TABLE,
|
|
39
|
-
fields: {
|
|
39
|
+
fields: {
|
|
40
|
+
name: createTextField({
|
|
41
|
+
required: true,
|
|
42
|
+
maxLength: 64,
|
|
43
|
+
personal: false,
|
|
44
|
+
reason: "technical_reference",
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
40
47
|
});
|
|
41
48
|
const hostFixturesFeature = defineFeature("folders-test-host-fixtures", (r) => {
|
|
42
49
|
r.entity("credit", creditEntity);
|
package/src/folders/entity.ts
CHANGED
|
@@ -10,10 +10,17 @@ export const folderEntity = createEntity({
|
|
|
10
10
|
description:
|
|
11
11
|
"One folder of a tenant's hierarchical catalog: a name plus an optional parentId pointing at another folder, so the rows together form a tree whose roots have no parent.",
|
|
12
12
|
fields: {
|
|
13
|
-
|
|
13
|
+
// Catalog label, same reasoning as tags/entity.ts name: no author to
|
|
14
|
+
// anchor a subject key to, not user-identifying content.
|
|
15
|
+
name: createTextField({
|
|
16
|
+
required: true,
|
|
17
|
+
maxLength: 64,
|
|
18
|
+
personal: false,
|
|
19
|
+
reason: "catalog_label",
|
|
20
|
+
}),
|
|
14
21
|
// Parent folder id, or absent for a root folder. No FK (event-sourced); a
|
|
15
22
|
// dangling parentId renders the folder at root — folders-view guards cycles.
|
|
16
|
-
parentId: createTextField({ maxLength: 64 }),
|
|
23
|
+
parentId: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
|
|
17
24
|
},
|
|
18
25
|
});
|
|
19
26
|
|
|
@@ -38,9 +45,24 @@ export const folderAssignmentEntity = createEntity({
|
|
|
38
45
|
"The membership row recording which folder one host entity, addressed by entityType and entityId, is filed in. At most one row exists per entity, so filing it elsewhere changes this row's folderId rather than adding a second.",
|
|
39
46
|
softDelete: true,
|
|
40
47
|
fields: {
|
|
41
|
-
folderId: createTextField({
|
|
42
|
-
|
|
48
|
+
folderId: createTextField({
|
|
49
|
+
required: true,
|
|
50
|
+
maxLength: 64,
|
|
51
|
+
personal: false,
|
|
52
|
+
reason: "technical_reference",
|
|
53
|
+
}),
|
|
54
|
+
entityType: createTextField({
|
|
55
|
+
required: true,
|
|
56
|
+
maxLength: 64,
|
|
57
|
+
personal: false,
|
|
58
|
+
reason: "technical_reference",
|
|
59
|
+
}),
|
|
43
60
|
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
44
|
-
entityId: createTextField({
|
|
61
|
+
entityId: createTextField({
|
|
62
|
+
required: true,
|
|
63
|
+
maxLength: 128,
|
|
64
|
+
personal: false,
|
|
65
|
+
reason: "technical_reference",
|
|
66
|
+
}),
|
|
45
67
|
},
|
|
46
68
|
});
|
|
@@ -26,7 +26,14 @@ import { folderAssignmentExportHook } from "../hooks";
|
|
|
26
26
|
const CREDIT_TABLE = "folders_export_test_credits";
|
|
27
27
|
const creditEntity = createEntity({
|
|
28
28
|
table: CREDIT_TABLE,
|
|
29
|
-
fields: {
|
|
29
|
+
fields: {
|
|
30
|
+
name: createTextField({
|
|
31
|
+
required: true,
|
|
32
|
+
maxLength: 64,
|
|
33
|
+
personal: false,
|
|
34
|
+
reason: "technical_reference",
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
30
37
|
});
|
|
31
38
|
const hostFixturesFeature = defineFeature("folders-export-test-host-fixtures", (r) => {
|
|
32
39
|
r.entity("credit", creditEntity);
|
package/src/form-draft/entity.ts
CHANGED
|
@@ -28,7 +28,12 @@ export const formDraftEntity = createEntity({
|
|
|
28
28
|
personal: "ref",
|
|
29
29
|
required: true,
|
|
30
30
|
}),
|
|
31
|
-
draftKey: createTextField({
|
|
31
|
+
draftKey: createTextField({
|
|
32
|
+
required: true,
|
|
33
|
+
maxLength: FORM_DRAFT_KEY_MAX_LENGTH,
|
|
34
|
+
personal: false,
|
|
35
|
+
reason: "technical_reference",
|
|
36
|
+
}),
|
|
32
37
|
// The blob shape is fixed by issue #1889, not left to the caller:
|
|
33
38
|
// { values: Record<string, unknown>, stepIndex: number, savedAt: string }.
|
|
34
39
|
// Free-form because `values` mirrors whatever fields the in-progress
|
|
@@ -34,13 +34,23 @@ import {
|
|
|
34
34
|
export const mailAccountEntity = createEntity({
|
|
35
35
|
table: "read_mail_accounts",
|
|
36
36
|
fields: {
|
|
37
|
-
provider: createTextField({
|
|
38
|
-
|
|
37
|
+
provider: createTextField({
|
|
38
|
+
required: true,
|
|
39
|
+
maxLength: 50,
|
|
40
|
+
personal: false,
|
|
41
|
+
reason: "technical_reference",
|
|
42
|
+
}),
|
|
43
|
+
authMethod: createTextField({
|
|
44
|
+
required: true,
|
|
45
|
+
maxLength: 30,
|
|
46
|
+
personal: false,
|
|
47
|
+
reason: "technical_reference",
|
|
48
|
+
}),
|
|
39
49
|
// null = tenant-geteiltes Postfach (info@), gesetzt = persönliches
|
|
40
50
|
// Postfach dieses Users. Sichtbarkeits-Filter in den list-queries:
|
|
41
51
|
// Owner + TenantAdmin (Compliance); KEIN Crypto-Subject-Wechsel in
|
|
42
52
|
// V1 (Subject bleibt Tenant, siehe Header).
|
|
43
|
-
ownerUserId: createTextField({ maxLength: 36 }),
|
|
53
|
+
ownerUserId: createTextField({ maxLength: 36, personal: false, reason: "pseudonymous_fk" }),
|
|
44
54
|
// Tenant-authored mailbox label, not a person's name.
|
|
45
55
|
displayName: createTextField({
|
|
46
56
|
maxLength: 200,
|
|
@@ -49,8 +59,13 @@ export const mailAccountEntity = createEntity({
|
|
|
49
59
|
}),
|
|
50
60
|
// Postfach-Adresse — PII des Tenants.
|
|
51
61
|
address: createTextField({ required: true, maxLength: 1000, personal: "tenant", find: "none" }),
|
|
52
|
-
status: createTextField({
|
|
53
|
-
|
|
62
|
+
status: createTextField({
|
|
63
|
+
required: true,
|
|
64
|
+
maxLength: 30,
|
|
65
|
+
personal: false,
|
|
66
|
+
reason: "technical_reference",
|
|
67
|
+
}),
|
|
68
|
+
watchState: createTextField({ maxLength: 100, personal: false, reason: "technical_reference" }),
|
|
54
69
|
connectedAt: createTimestampField({ required: true }),
|
|
55
70
|
},
|
|
56
71
|
});
|
|
@@ -61,13 +76,28 @@ export const mailAccountEntity = createEntity({
|
|
|
61
76
|
export const inboundMessageEntity = createEntity({
|
|
62
77
|
table: "read_inbound_messages",
|
|
63
78
|
fields: {
|
|
64
|
-
accountId: createTextField({
|
|
79
|
+
accountId: createTextField({
|
|
80
|
+
required: true,
|
|
81
|
+
maxLength: 36,
|
|
82
|
+
personal: false,
|
|
83
|
+
reason: "technical_reference",
|
|
84
|
+
}),
|
|
65
85
|
// Scope-Vererbung vom Account zum Ingest-Zeitpunkt — Messages eines
|
|
66
86
|
// persönlichen Postfachs sind nur für den Owner (+ TenantAdmin)
|
|
67
87
|
// sichtbar, ohne Join auf read_mail_accounts.
|
|
68
|
-
ownerUserId: createTextField({ maxLength: 36 }),
|
|
69
|
-
messageIdHeader: createTextField({
|
|
70
|
-
|
|
88
|
+
ownerUserId: createTextField({ maxLength: 36, personal: false, reason: "pseudonymous_fk" }),
|
|
89
|
+
messageIdHeader: createTextField({
|
|
90
|
+
required: true,
|
|
91
|
+
maxLength: 500,
|
|
92
|
+
personal: false,
|
|
93
|
+
reason: "technical_reference",
|
|
94
|
+
}),
|
|
95
|
+
threadKey: createTextField({
|
|
96
|
+
required: true,
|
|
97
|
+
maxLength: 500,
|
|
98
|
+
personal: false,
|
|
99
|
+
reason: "technical_reference",
|
|
100
|
+
}),
|
|
71
101
|
from: createTextField({ required: true, maxLength: 2000, personal: "tenant", find: "none" }),
|
|
72
102
|
// JSON-stringified string[] — als Ganzes encrypted.
|
|
73
103
|
to: createTextField({ maxLength: 8000, personal: "tenant", find: "none" }),
|
|
@@ -75,8 +105,8 @@ export const inboundMessageEntity = createEntity({
|
|
|
75
105
|
subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
|
|
76
106
|
snippet: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
|
|
77
107
|
receivedAt: createTimestampField({ required: true }),
|
|
78
|
-
bodyRef: createTextField({ maxLength: 500 }),
|
|
79
|
-
scope: createTextField({ maxLength: 200 }),
|
|
108
|
+
bodyRef: createTextField({ maxLength: 500, personal: false, reason: "technical_reference" }),
|
|
109
|
+
scope: createTextField({ maxLength: 200, personal: false, reason: "technical_reference" }),
|
|
80
110
|
},
|
|
81
111
|
});
|
|
82
112
|
|
|
@@ -85,7 +115,12 @@ export const inboundMessageEntity = createEntity({
|
|
|
85
115
|
export const mailThreadEntity = createEntity({
|
|
86
116
|
table: "read_mail_threads",
|
|
87
117
|
fields: {
|
|
88
|
-
threadKey: createTextField({
|
|
118
|
+
threadKey: createTextField({
|
|
119
|
+
required: true,
|
|
120
|
+
maxLength: 500,
|
|
121
|
+
personal: false,
|
|
122
|
+
reason: "technical_reference",
|
|
123
|
+
}),
|
|
89
124
|
subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
|
|
90
125
|
lastMessageAt: createTimestampField({ required: true }),
|
|
91
126
|
messageCount: createNumberField({ required: true, integer: true }),
|
|
@@ -123,16 +158,22 @@ export const syncCursorEntity = createEntity({
|
|
|
123
158
|
required: true,
|
|
124
159
|
maxLength: 36,
|
|
125
160
|
access: { write: access.privileged },
|
|
161
|
+
personal: false,
|
|
162
|
+
reason: "technical_reference",
|
|
126
163
|
}),
|
|
127
164
|
scope: createTextField({
|
|
128
165
|
required: true,
|
|
129
166
|
maxLength: 200,
|
|
130
167
|
access: { write: access.privileged },
|
|
168
|
+
personal: false,
|
|
169
|
+
reason: "technical_reference",
|
|
131
170
|
}),
|
|
132
171
|
cursor: createTextField({
|
|
133
172
|
required: true,
|
|
134
173
|
maxLength: 2000,
|
|
135
174
|
access: { write: access.privileged },
|
|
175
|
+
personal: false,
|
|
176
|
+
reason: "technical_reference",
|
|
136
177
|
}),
|
|
137
178
|
updatedAt: createTimestampField({
|
|
138
179
|
required: true,
|
|
@@ -153,11 +194,15 @@ export const seenMessageEntity = createEntity({
|
|
|
153
194
|
required: true,
|
|
154
195
|
maxLength: 36,
|
|
155
196
|
access: { write: access.privileged },
|
|
197
|
+
personal: false,
|
|
198
|
+
reason: "technical_reference",
|
|
156
199
|
}),
|
|
157
200
|
providerMessageId: createTextField({
|
|
158
201
|
required: true,
|
|
159
202
|
maxLength: 500,
|
|
160
203
|
access: { write: access.privileged },
|
|
204
|
+
personal: false,
|
|
205
|
+
reason: "technical_reference",
|
|
161
206
|
}),
|
|
162
207
|
seenAt: createTimestampField({
|
|
163
208
|
required: true,
|
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
isPiiCiphertext,
|
|
15
15
|
PII_ERASED_SENTINEL,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
-
import {
|
|
17
|
+
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import {
|
|
19
|
+
createRegistry,
|
|
20
|
+
defineFeature,
|
|
21
|
+
SYSTEM_TENANT_ID,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
23
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
24
|
import {
|
|
20
25
|
createTestDb,
|
|
@@ -29,9 +34,12 @@ import {
|
|
|
29
34
|
import {
|
|
30
35
|
resetPiiSubjectKmsForTests,
|
|
31
36
|
resetTestTables,
|
|
37
|
+
seedRow,
|
|
32
38
|
sleep,
|
|
33
39
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
40
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
34
41
|
import { JobHandlers, JobQueries } from "../constants";
|
|
42
|
+
import { markStaleJobRunsFailed, STALE_JOB_RUN_ERROR } from "../db/queries/stale-run-sweep";
|
|
35
43
|
import { createJobsFeature } from "../feature";
|
|
36
44
|
import { createJobRunLogger } from "../job-run-logger";
|
|
37
45
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
@@ -331,6 +339,108 @@ describe("jobs run-failed error column under KMS (#2307)", () => {
|
|
|
331
339
|
});
|
|
332
340
|
});
|
|
333
341
|
|
|
342
|
+
// markStaleJobRunsFailed (stale-run-sweep.ts) is a second, separate writer
|
|
343
|
+
// of the `error` column outside onJobFailed's per-row encrypt above — a
|
|
344
|
+
// crash-recovery batch sweep that can match many rows belonging to
|
|
345
|
+
// different triggering users in one pass. It must encrypt `error` per row
|
|
346
|
+
// under each row's own subject instead of writing STALE_JOB_RUN_ERROR in
|
|
347
|
+
// the clear, or a live KMS would leave plaintext sitting next to
|
|
348
|
+
// ciphertext siblings and detail.query.ts's decrypt would fail loud on it.
|
|
349
|
+
describe("jobs stale-run sweep error column under KMS", () => {
|
|
350
|
+
async function seedStaleRunning(triggeredById?: string): Promise<string> {
|
|
351
|
+
const id = generateId();
|
|
352
|
+
await seedRow(testDb.db, jobRunsTable, {
|
|
353
|
+
id,
|
|
354
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
355
|
+
insertedById: "system",
|
|
356
|
+
jobName: "app:job:export",
|
|
357
|
+
bullJobId: `bull-${id}`,
|
|
358
|
+
status: "running",
|
|
359
|
+
attempt: 1,
|
|
360
|
+
startedAt: sql`now() - interval '48 hours'`,
|
|
361
|
+
...(triggeredById !== undefined ? { triggeredById } : {}),
|
|
362
|
+
});
|
|
363
|
+
return id;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
test("stale row's error is stored as ciphertext under its own triggering user, not the sentinel", async () => {
|
|
367
|
+
const id = await seedStaleRunning(USER_ID);
|
|
368
|
+
|
|
369
|
+
const result = await markStaleJobRunsFailed(testDb.db, 24);
|
|
370
|
+
expect(result.runsMarkedFailed).toBe(1);
|
|
371
|
+
|
|
372
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id });
|
|
373
|
+
expect(row?.["status"]).toBe("failed");
|
|
374
|
+
expect(isPiiCiphertext(row?.["error"])).toBe(true);
|
|
375
|
+
expect(String(row?.["error"])).toContain(`user:${USER_ID}`);
|
|
376
|
+
|
|
377
|
+
const back = await decryptPiiFieldValues({ error: row?.["error"] }, ["error"], kms, {
|
|
378
|
+
requestId: "t",
|
|
379
|
+
});
|
|
380
|
+
expect(back["error"]).toBe(STALE_JOB_RUN_ERROR);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("two stale rows from different users each decrypt under their own key", async () => {
|
|
384
|
+
const OTHER_USER_ID = "u-pii-stale-other";
|
|
385
|
+
const idA = await seedStaleRunning(USER_ID);
|
|
386
|
+
const idB = await seedStaleRunning(OTHER_USER_ID);
|
|
387
|
+
|
|
388
|
+
const result = await markStaleJobRunsFailed(testDb.db, 24);
|
|
389
|
+
expect(result.runsMarkedFailed).toBe(2);
|
|
390
|
+
|
|
391
|
+
const rowA = await fetchOne(testDb.db, jobRunsTable, { id: idA });
|
|
392
|
+
const rowB = await fetchOne(testDb.db, jobRunsTable, { id: idB });
|
|
393
|
+
expect(String(rowA?.["error"])).toContain(`user:${USER_ID}`);
|
|
394
|
+
expect(String(rowB?.["error"])).toContain(`user:${OTHER_USER_ID}`);
|
|
395
|
+
|
|
396
|
+
const backB = await decryptPiiFieldValues({ error: rowB?.["error"] }, ["error"], kms, {
|
|
397
|
+
requestId: "t",
|
|
398
|
+
});
|
|
399
|
+
expect(backB["error"]).toBe(STALE_JOB_RUN_ERROR);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("erase subject key before the sweep runs → error lands as the erased sentinel, sweep still succeeds", async () => {
|
|
403
|
+
// Create the subject's key first — eraseKey is a no-op on an unknown
|
|
404
|
+
// subject (InMemoryKmsAdapter's tombstone contract), same as the
|
|
405
|
+
// onJobFailed case above. A payload is required here: onJobStart only
|
|
406
|
+
// touches the KMS (creating the subject's key) when there's a payload
|
|
407
|
+
// to encrypt (see encryptStartedPayload's null short-circuit).
|
|
408
|
+
await logger.onJobStart?.("app:job:export", "bull-warm-key", {
|
|
409
|
+
triggeredById: USER_ID,
|
|
410
|
+
payload: SECRET_PAYLOAD,
|
|
411
|
+
});
|
|
412
|
+
await kms.eraseKey({ kind: "user", userId: USER_ID });
|
|
413
|
+
|
|
414
|
+
const id = await seedStaleRunning(USER_ID);
|
|
415
|
+
|
|
416
|
+
const result = await markStaleJobRunsFailed(testDb.db, 24);
|
|
417
|
+
expect(result.runsMarkedFailed).toBe(1);
|
|
418
|
+
|
|
419
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id });
|
|
420
|
+
expect(row?.["status"]).toBe("failed");
|
|
421
|
+
expect(row?.["error"]).toBe(PII_ERASED_SENTINEL);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("system-triggered stale row (no triggeredById) keeps a plaintext error", async () => {
|
|
425
|
+
const id = await seedStaleRunning();
|
|
426
|
+
|
|
427
|
+
await markStaleJobRunsFailed(testDb.db, 24);
|
|
428
|
+
|
|
429
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id });
|
|
430
|
+
expect(row?.["error"]).toBe(STALE_JOB_RUN_ERROR);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("without a KMS the error column stays plaintext (rollout mode)", async () => {
|
|
434
|
+
const id = await seedStaleRunning(USER_ID);
|
|
435
|
+
resetPiiSubjectKmsForTests();
|
|
436
|
+
|
|
437
|
+
await markStaleJobRunsFailed(testDb.db, 24);
|
|
438
|
+
|
|
439
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { id });
|
|
440
|
+
expect(row?.["error"]).toBe(STALE_JOB_RUN_ERROR);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
334
444
|
// The suite above proves the write side (row is ciphertext) and decrypts by
|
|
335
445
|
// calling decryptPiiFieldValues directly in the test body — neither touches
|
|
336
446
|
// detail.query.ts's own decrypt wrapper. This block dispatches the real
|
|
@@ -49,8 +49,8 @@ import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
|
49
49
|
const itemEntity = createEntity({
|
|
50
50
|
table: "read_rebuild_items",
|
|
51
51
|
fields: {
|
|
52
|
-
groupId: createTextField({ required: true }),
|
|
53
|
-
name: createTextField({ required: true }),
|
|
52
|
+
groupId: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
53
|
+
name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
54
54
|
},
|
|
55
55
|
});
|
|
56
56
|
const itemTable = buildEntityTable("rebuild-item", itemEntity);
|
|
@@ -38,7 +38,12 @@ const REINDEX_ENTITY_JOB = "jobs:job:reindex-entity";
|
|
|
38
38
|
const widgetEntity = createEntity({
|
|
39
39
|
table: "read_reindex_job_widgets",
|
|
40
40
|
fields: {
|
|
41
|
-
name: createTextField({
|
|
41
|
+
name: createTextField({
|
|
42
|
+
required: true,
|
|
43
|
+
searchable: true,
|
|
44
|
+
personal: false,
|
|
45
|
+
reason: "technical_reference",
|
|
46
|
+
}),
|
|
42
47
|
},
|
|
43
48
|
});
|
|
44
49
|
|