@cosmicdrift/kumiko-bundled-features 0.209.1 → 0.211.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/file-derivatives/changes.json +9 -1
- 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/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/changes.json +7 -0
- 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/changes.json +9 -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/__tests__/members-screens.boot.test.ts +79 -4
- package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
- package/src/tenant/changes.json +6 -0
- package/src/tenant/constants.ts +17 -4
- package/src/tenant/feature.ts +28 -11
- package/src/tenant/handlers/team-list.query.ts +250 -0
- package/src/tenant/i18n.ts +20 -0
- package/src/tenant/invitation-table.ts +3 -3
- package/src/tenant/screens.ts +111 -3
- package/src/tenant/web/client-plugin.tsx +11 -6
- package/src/tenant/web/index.ts +2 -2
- package/src/tenant/web/member-roles-cell.tsx +12 -0
- package/src/tenant/web/member-status-cell.tsx +23 -0
- 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/schema/download-token.ts +12 -10
- package/src/user-data-rights/schema/export-job.ts +5 -4
- package/src/tenant/web/i18n.ts +0 -24
- package/src/tenant/web/members-screen.tsx +0 -270
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.211.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.211.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.211.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.211.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.211.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.211.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.211.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,6 +160,6 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.211.0"
|
|
164
164
|
}
|
|
165
165
|
}
|
|
@@ -16,15 +16,17 @@ export const userMfaEntity = createEntity({
|
|
|
16
16
|
// FK to the user row, not content — PII-naming heuristic would flag
|
|
17
17
|
// "userId" as content; it's a pseudonymous reference, matching the same
|
|
18
18
|
// annotation `config`'s userId column uses.
|
|
19
|
-
userId: createTextField({ required: true,
|
|
19
|
+
userId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
|
|
20
20
|
// Envelope-encrypted via the same MasterKeyProvider as secrets/config
|
|
21
21
|
// (entity-field-encryption.ts) — no manual wrapDek/unwrapDek needed.
|
|
22
|
-
//
|
|
23
|
-
// user's 2FA on GDPR forget", which is the correct
|
|
22
|
+
// `personal: { of: "userId" }`: crypto-shredding this field is exactly
|
|
23
|
+
// "revoke this user's 2FA on GDPR forget", which is the correct
|
|
24
|
+
// behavior anyway.
|
|
24
25
|
totpSecret: createTextField({
|
|
25
26
|
required: true,
|
|
26
27
|
encrypted: true,
|
|
27
|
-
|
|
28
|
+
personal: { of: "userId" },
|
|
29
|
+
find: "none",
|
|
28
30
|
}),
|
|
29
31
|
// JSON-stringified `{ hashes: string[] }` (argon2id hashes of unredeemed
|
|
30
32
|
// recovery codes) — both encryption layers (entity-field envelope +
|
|
@@ -39,7 +41,8 @@ export const userMfaEntity = createEntity({
|
|
|
39
41
|
recoveryCodes: createTextField({
|
|
40
42
|
required: true,
|
|
41
43
|
encrypted: true,
|
|
42
|
-
|
|
44
|
+
personal: { of: "userId" },
|
|
45
|
+
find: "none",
|
|
43
46
|
}),
|
|
44
47
|
enabledAt: createTimestampField({ required: true }),
|
|
45
48
|
lastUsedAt: createTimestampField(),
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// EXT_USER_DATA hooks for userMfa (GDPR Art. 15/17/20). Both totpSecret and
|
|
2
|
-
// recoveryCodes are
|
|
3
|
-
// forget, the row must actually disappear — a
|
|
4
|
-
// recovery-code hash under an anonymized user is
|
|
5
|
-
// nobody can rotate. Mirrors
|
|
6
|
-
// delete (rebuild-safe: forget
|
|
7
|
-
// resurrected by a
|
|
2
|
+
// recoveryCodes are annotated `personal: { of: "userId" }` in the schema
|
|
3
|
+
// for exactly this: on forget, the row must actually disappear — a
|
|
4
|
+
// leftover TOTP secret or recovery-code hash under an anonymized user is
|
|
5
|
+
// still a live credential nobody can rotate. Mirrors
|
|
6
|
+
// folders-user-data/hooks.ts's executor-based delete (rebuild-safe: forget
|
|
7
|
+
// replays via the event, a raw DELETE would be resurrected by a
|
|
8
|
+
// projection rebuild).
|
|
8
9
|
|
|
9
10
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
11
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
@@ -36,8 +37,8 @@ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
|
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
// Always a real hard-delete regardless of the incoming strategy — a 2FA
|
|
39
|
-
// secret can't be anonymized, and
|
|
40
|
-
// to exactly one user (never shared tenant data).
|
|
40
|
+
// secret can't be anonymized, and `personal: { of: "userId" }` already
|
|
41
|
+
// means this row belongs to exactly one user (never shared tenant data).
|
|
41
42
|
export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
42
43
|
const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
|
|
43
44
|
userId: ctx.userId,
|
|
@@ -119,8 +119,8 @@ beforeAll(async () => {
|
|
|
119
119
|
// gepusht (r.projection mit `table`-Property → auto-push).
|
|
120
120
|
await createEventsTable(db);
|
|
121
121
|
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
122
|
-
// providerCustomerId/providerSubscriptionId are `
|
|
123
|
-
// fields — no executor wires the KMS automatically here (raw r.projection,
|
|
122
|
+
// providerCustomerId/providerSubscriptionId are `personal: "tenant"`
|
|
123
|
+
// PII-subject fields — no executor wires the KMS automatically here (raw r.projection,
|
|
124
124
|
// see feature.ts), so process-event.write.ts calls configuredPiiSubjectKms()
|
|
125
125
|
// directly and needs one configured, same as run{Prod,Dev}App do at boot.
|
|
126
126
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
@@ -40,7 +40,7 @@ export const subscriptionEntity = createEntity({
|
|
|
40
40
|
table: "read_subscriptions",
|
|
41
41
|
fields: {
|
|
42
42
|
providerName: createTextField({ required: true, maxLength: 50 }),
|
|
43
|
-
//
|
|
43
|
+
// `personal: "tenant"` (not `encrypted`): the field must crypto-shred when
|
|
44
44
|
// eraseSubjectKeys erases the tenant's subject key on tenant-destroy
|
|
45
45
|
// (#800). `encrypted: true` uses the app-wide master key instead — that
|
|
46
46
|
// key is never erased per-tenant, so it would only add encryption-at-
|
|
@@ -52,12 +52,14 @@ export const subscriptionEntity = createEntity({
|
|
|
52
52
|
providerCustomerId: createTextField({
|
|
53
53
|
required: true,
|
|
54
54
|
maxLength: 1000,
|
|
55
|
-
|
|
55
|
+
personal: "tenant",
|
|
56
|
+
find: "none",
|
|
56
57
|
}),
|
|
57
58
|
providerSubscriptionId: createTextField({
|
|
58
59
|
required: true,
|
|
59
60
|
maxLength: 1000,
|
|
60
|
-
|
|
61
|
+
personal: "tenant",
|
|
62
|
+
find: "none",
|
|
61
63
|
}),
|
|
62
64
|
status: createTextField({ required: true, maxLength: 30 }),
|
|
63
65
|
tier: createTextField({ required: true, maxLength: 50 }),
|
|
@@ -6,7 +6,7 @@ import { subscriptionAggregateId } from "./aggregate-id";
|
|
|
6
6
|
import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
|
|
7
7
|
import { subscriptionsProjectionTable } from "./projection";
|
|
8
8
|
|
|
9
|
-
// providerCustomerId/providerSubscriptionId are `
|
|
9
|
+
// providerCustomerId/providerSubscriptionId are `personal: "tenant"` on the
|
|
10
10
|
// entity (envelope-encrypted with the tenant subject key, see entities.ts) —
|
|
11
11
|
// eraseSubjectKeys crypto-shreds them for tenants that hard-delete. HGB
|
|
12
12
|
// can not: it needs the accounting facts (status/tier/period) to survive, so
|
|
@@ -34,7 +34,8 @@ export const tenantComplianceProfileEntity = createEntity({
|
|
|
34
34
|
// bedeutet "Default-Profile, keine Override". Validiert beim
|
|
35
35
|
// set-profile-Handler via Zod.
|
|
36
36
|
override: createLongTextField({
|
|
37
|
-
|
|
37
|
+
personal: false,
|
|
38
|
+
reason: "is_business_data",
|
|
38
39
|
}),
|
|
39
40
|
},
|
|
40
41
|
indexes: [
|
package/src/config/table.ts
CHANGED
|
@@ -25,7 +25,10 @@ export const configValueEntity = createEntity({
|
|
|
25
25
|
// breaking the null-vs-missing distinction the resolver already draws.
|
|
26
26
|
value: createTextField({}),
|
|
27
27
|
// user-scope row: userId populated. tenant- / system-scope: null.
|
|
28
|
-
userId: createTextField({
|
|
28
|
+
userId: createTextField({
|
|
29
|
+
personal: false,
|
|
30
|
+
reason: "pseudonymous_fk",
|
|
31
|
+
}),
|
|
29
32
|
},
|
|
30
33
|
indexes: [
|
|
31
34
|
{ unique: true, columns: ["key", "tenantId", "userId"], name: "read_config_values_unique" },
|
|
@@ -185,14 +185,14 @@ describe("crypto-shredding :: forget-subject purges the derived search index (#1
|
|
|
185
185
|
userNote: createTextField({
|
|
186
186
|
required: true,
|
|
187
187
|
maxLength: 100,
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
personal: { of: "ownerId" },
|
|
189
|
+
find: "fuzzy",
|
|
190
190
|
}),
|
|
191
191
|
tenantNote: createTextField({
|
|
192
192
|
required: true,
|
|
193
193
|
maxLength: 100,
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
personal: "tenant",
|
|
195
|
+
find: "fuzzy",
|
|
196
196
|
}),
|
|
197
197
|
},
|
|
198
198
|
});
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// nicht für personenbezogene Daten. Das frühere `sensitive`-Flag wurde
|
|
7
7
|
// entfernt; das Schema rejected es und `parseSerializedField` wirft auf
|
|
8
8
|
// Alt-Definitionen. Werte stehen vollständig im Event-Log (rebuild-safe).
|
|
9
|
-
// PII
|
|
10
|
-
//
|
|
9
|
+
// PII belongs in schema-entity fields with a `personal` annotation
|
|
10
|
+
// (crypto-shredding, event ciphertext, forget pipeline).
|
|
11
11
|
//
|
|
12
12
|
// **Was diese Feature liefert (B1 + B2, 2026-05-23):**
|
|
13
13
|
// 1. r.entity("field-definition") — Definition-Storage (event-sourced).
|
|
@@ -61,7 +61,10 @@ const staleEntity = createEntity({
|
|
|
61
61
|
const retainedEntity = createEntity({
|
|
62
62
|
table: "read_c7_retained",
|
|
63
63
|
fields: {
|
|
64
|
-
|
|
64
|
+
// No subject flag on the old fixture either — anonymize alone has no
|
|
65
|
+
// PersonalAnnotations arm, so this stays a spread instead of a direct
|
|
66
|
+
// createTextField(...) override (kumiko-framework#2250).
|
|
67
|
+
label: { ...createTextField({ required: true }), anonymize: () => "[ANONYMIZED]" },
|
|
65
68
|
},
|
|
66
69
|
retention: { keepFor: "30d", strategy: "blockDelete" },
|
|
67
70
|
});
|
|
@@ -71,8 +74,11 @@ const retainedEntity = createEntity({
|
|
|
71
74
|
const anonEntity = createEntity({
|
|
72
75
|
table: "read_c7_anon",
|
|
73
76
|
fields: {
|
|
74
|
-
label: createTextField({ required: true, anonymize: () => "[ANONYMIZED]" }
|
|
75
|
-
note: createTextField({
|
|
77
|
+
label: { ...createTextField({ required: true }), anonymize: () => "[ANONYMIZED]" },
|
|
78
|
+
note: createTextField({
|
|
79
|
+
personal: false,
|
|
80
|
+
reason: "is_business_data",
|
|
81
|
+
}),
|
|
76
82
|
},
|
|
77
83
|
retention: { keepFor: "30d", strategy: "anonymize" },
|
|
78
84
|
});
|
|
@@ -81,7 +87,9 @@ const anonEntity = createEntity({
|
|
|
81
87
|
// muss das als skip melden statt still nichts zu tun.
|
|
82
88
|
const bareEntity = createEntity({
|
|
83
89
|
table: "read_c7_bare",
|
|
84
|
-
fields: {
|
|
90
|
+
fields: {
|
|
91
|
+
label: createTextField({ required: true, personal: false, reason: "is_business_data" }),
|
|
92
|
+
},
|
|
85
93
|
retention: { keepFor: "30d", strategy: "anonymize" },
|
|
86
94
|
});
|
|
87
95
|
|
|
@@ -26,16 +26,19 @@ export const tenantRetentionOverrideEntity = createEntity({
|
|
|
26
26
|
entityName: createTextField({
|
|
27
27
|
required: true,
|
|
28
28
|
maxLength: 100,
|
|
29
|
-
|
|
29
|
+
personal: false,
|
|
30
|
+
reason: "is_business_data",
|
|
30
31
|
}),
|
|
31
32
|
config: createLongTextField({
|
|
32
33
|
required: true,
|
|
33
|
-
|
|
34
|
+
personal: false,
|
|
35
|
+
reason: "is_business_data",
|
|
34
36
|
}),
|
|
35
37
|
reason: createTextField({
|
|
36
38
|
required: true,
|
|
37
39
|
maxLength: 500,
|
|
38
|
-
|
|
40
|
+
personal: false,
|
|
41
|
+
reason: "is_business_data",
|
|
39
42
|
}),
|
|
40
43
|
},
|
|
41
44
|
indexes: [{ unique: true, columns: ["tenantId", "entityName"] }],
|
package/src/delivery/tables.ts
CHANGED
|
@@ -76,7 +76,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
|
76
76
|
export const notificationPreferenceEntity = createEntity({
|
|
77
77
|
table: "read_notification_preferences",
|
|
78
78
|
fields: {
|
|
79
|
-
userId: createTextField({ required: true,
|
|
79
|
+
userId: createTextField({ required: true, personal: false, reason: "pseudonymous_fk" }),
|
|
80
80
|
notificationType: createTextField({ required: true }), // qualified name or "*"
|
|
81
81
|
channel: createTextField({ required: true }), // "inApp", "email", "push", or "*"
|
|
82
82
|
enabled: createBooleanField({ default: true }),
|
|
@@ -37,14 +37,17 @@ export const documentExtractEntity = createEntity({
|
|
|
37
37
|
fields: {
|
|
38
38
|
fileRefId: createTextField({ required: true }),
|
|
39
39
|
storageKey: createTextField({ required: true }),
|
|
40
|
-
// Holds the full extracted document text (PII). `
|
|
41
|
-
// `encrypted: true` (#1621): the master-key path has no erasure subject,
|
|
40
|
+
// Holds the full extracted document text (PII). `personal: "tenant"`,
|
|
41
|
+
// NOT `encrypted: true` (#1621): the master-key path has no erasure subject,
|
|
42
42
|
// so nothing here would ever be shreddable. Tenant-subject ciphertext
|
|
43
43
|
// dies with eraseSubjectKeys on tenant-destroy (#800 pattern). Whose
|
|
44
44
|
// subject a third party named inside a document is stays open, same as
|
|
45
45
|
// inbound-mail-foundation (#957). meta is provider telemetry only and
|
|
46
46
|
// stays plaintext jsonb.
|
|
47
|
-
pages: createLongTextField({
|
|
47
|
+
pages: createLongTextField({
|
|
48
|
+
personal: "tenant",
|
|
49
|
+
find: "none",
|
|
50
|
+
}),
|
|
48
51
|
meta: createJsonbField(),
|
|
49
52
|
},
|
|
50
53
|
});
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.194.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "PRESET_VARIANT_NAMES removed from public exports; publicVariantQuery accepts any name.",
|
|
6
|
+
"detail": "The public GET /media/:fileRefId/:variant route now resolves the variant spec from the FileRef's field declaration (createImageField({ variants: {...} })) instead of a fixed spec table, so an app can declare and publicly serve its own variant names, and a field overriding a built-in preset name (thumb/card/hero/full) is served with its own spec instead of the frozen preset size. publicVariantQuery's schema now accepts any variant name (z.string().min(1).max(64)) instead of z.enum(PRESET_VARIANT_NAMES); resolution requires an exact match against the field's declared variants keys, and an unresolvable name answers 404, same as an unknown FileRef. The route's pre-DB path-param gate is now purely syntactic ([a-zA-Z0-9_-]{1,64}) instead of a name allow-list; a known-valid preset name plus a random fileRefId reached the same DB read as any other name, so the list only blocked the cheaper of two equally-costly attacks, and the actual defense is the existing per-IP rate limit and the UUID guard, both unchanged.",
|
|
7
|
+
"migration": "PRESET_VARIANT_NAMES only ever named the allow-list this release deletes, so there is nothing to migrate to. thumb/card/hero/full remain as ready-made specs to spread into a field's own variants."
|
|
8
|
+
}
|
|
9
|
+
]
|
package/src/files/README.md
CHANGED
|
@@ -28,19 +28,19 @@ bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
|
|
|
28
28
|
## PII-Annotations (Sprint 0.1+0.7)
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
|
-
fileName
|
|
32
|
-
storageKey
|
|
33
|
-
mimeType
|
|
34
|
-
size
|
|
35
|
-
entityType
|
|
36
|
-
entityId
|
|
37
|
-
fieldName
|
|
38
|
-
insertedAt
|
|
39
|
-
insertedById →
|
|
31
|
+
fileName → personal: "self", find: "none" (Originalname enthält oft Personen-Bezug)
|
|
32
|
+
storageKey → kein PII-Marker (interner UUID-Key, trifft die Heuristik nicht)
|
|
33
|
+
mimeType → kein PII-Marker
|
|
34
|
+
size → kein PII-Marker
|
|
35
|
+
entityType → kein PII-Marker
|
|
36
|
+
entityId → kein PII-Marker
|
|
37
|
+
fieldName → kein PII-Marker
|
|
38
|
+
insertedAt → kein PII-Marker (Audit-Timestamp, Framework-managed Base-Column)
|
|
39
|
+
insertedById → kein PII-Marker (User-Reference, Framework-managed Base-Column, kein Eigen-PII)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
`fileName:
|
|
43
|
-
mit dem Author-Subject-Key
|
|
42
|
+
`fileName: personal: "self"` heißt: Sprint 3 Crypto-Shredding verschlüsselt den
|
|
43
|
+
Wert mit dem Author-Subject-Key (für File-INHALTE: separates
|
|
44
44
|
Subject-Resolver-Pattern via `subjectField` — siehe storage-encryption.md
|
|
45
45
|
Sprint 4).
|
|
46
46
|
|
package/src/form-draft/entity.ts
CHANGED
|
@@ -24,13 +24,16 @@ export const formDraftEntity = createEntity({
|
|
|
24
24
|
// feeds the GDPR-hook-coverage boot guard (it's a plain FK into `user`,
|
|
25
25
|
// not content of its own) — see ../form-draft-user-data for the
|
|
26
26
|
// required export/delete hook coverage.
|
|
27
|
-
ownerId: createTextField({
|
|
27
|
+
ownerId: createTextField({
|
|
28
|
+
personal: "ref",
|
|
29
|
+
required: true,
|
|
30
|
+
}),
|
|
28
31
|
draftKey: createTextField({ required: true, maxLength: FORM_DRAFT_KEY_MAX_LENGTH }),
|
|
29
32
|
// The blob shape is fixed by issue #1889, not left to the caller:
|
|
30
33
|
// { values: Record<string, unknown>, stepIndex: number, savedAt: string }.
|
|
31
34
|
// Free-form because `values` mirrors whatever fields the in-progress
|
|
32
|
-
// form has, so it can carry arbitrary user-entered PII. NOT
|
|
33
|
-
// `
|
|
35
|
+
// form has, so it can carry arbitrary user-entered PII. NOT given a
|
|
36
|
+
// `personal` annotation — the framework's PII field-encryption engine only
|
|
34
37
|
// accepts string values (pii-field-encryption.ts throws on a non-string
|
|
35
38
|
// field), and this is a jsonb object. Erasure instead runs as a real
|
|
36
39
|
// physical row-delete on forget (see form-draft-user-data/hooks.ts),
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
// Source-of-truth sind die event-store-Streams `mail-account` /
|
|
18
18
|
// `inbound-message` / `mail-thread` — die Tabellen sind rebuild-fähig.
|
|
19
19
|
//
|
|
20
|
-
// **PII
|
|
21
|
-
// crypto-
|
|
22
|
-
// tenant-destroy
|
|
20
|
+
// **PII convention:** `personal: "tenant"` (not `encrypted`): fields must
|
|
21
|
+
// crypto-shred when eraseSubjectKeys deletes the tenant subject key on
|
|
22
|
+
// tenant-destroy (#800 pattern from billing-foundation).
|
|
23
23
|
// maxLength jeweils Ciphertext-budgetiert (`kumiko-pii:v1:<subject>:
|
|
24
24
|
// <blob>` — Plaintext×~2.3 + Envelope-Overhead).
|
|
25
25
|
//
|
|
@@ -41,12 +41,14 @@ export const mailAccountEntity = createEntity({
|
|
|
41
41
|
// Owner + TenantAdmin (Compliance); KEIN Crypto-Subject-Wechsel in
|
|
42
42
|
// V1 (Subject bleibt Tenant, siehe Header).
|
|
43
43
|
ownerUserId: createTextField({ maxLength: 36 }),
|
|
44
|
+
// Tenant-authored mailbox label, not a person's name.
|
|
44
45
|
displayName: createTextField({
|
|
45
46
|
maxLength: 200,
|
|
46
|
-
|
|
47
|
+
personal: false,
|
|
48
|
+
reason: "tenant_config_not_personal",
|
|
47
49
|
}),
|
|
48
50
|
// Postfach-Adresse — PII des Tenants.
|
|
49
|
-
address: createTextField({ required: true, maxLength: 1000,
|
|
51
|
+
address: createTextField({ required: true, maxLength: 1000, personal: "tenant", find: "none" }),
|
|
50
52
|
status: createTextField({ required: true, maxLength: 30 }),
|
|
51
53
|
watchState: createTextField({ maxLength: 100 }),
|
|
52
54
|
connectedAt: createTimestampField({ required: true }),
|
|
@@ -66,12 +68,12 @@ export const inboundMessageEntity = createEntity({
|
|
|
66
68
|
ownerUserId: createTextField({ maxLength: 36 }),
|
|
67
69
|
messageIdHeader: createTextField({ required: true, maxLength: 500 }),
|
|
68
70
|
threadKey: createTextField({ required: true, maxLength: 500 }),
|
|
69
|
-
from: createTextField({ required: true, maxLength: 2000,
|
|
71
|
+
from: createTextField({ required: true, maxLength: 2000, personal: "tenant", find: "none" }),
|
|
70
72
|
// JSON-stringified string[] — als Ganzes encrypted.
|
|
71
|
-
to: createTextField({ maxLength: 8000,
|
|
72
|
-
cc: createTextField({ maxLength: 8000,
|
|
73
|
-
subject: createTextField({ maxLength: 4000,
|
|
74
|
-
snippet: createTextField({ maxLength: 4000,
|
|
73
|
+
to: createTextField({ maxLength: 8000, personal: "tenant", find: "none" }),
|
|
74
|
+
cc: createTextField({ maxLength: 8000, personal: "tenant", find: "none" }),
|
|
75
|
+
subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
|
|
76
|
+
snippet: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
|
|
75
77
|
receivedAt: createTimestampField({ required: true }),
|
|
76
78
|
bodyRef: createTextField({ maxLength: 500 }),
|
|
77
79
|
scope: createTextField({ maxLength: 200 }),
|
|
@@ -84,7 +86,7 @@ export const mailThreadEntity = createEntity({
|
|
|
84
86
|
table: "read_mail_threads",
|
|
85
87
|
fields: {
|
|
86
88
|
threadKey: createTextField({ required: true, maxLength: 500 }),
|
|
87
|
-
subject: createTextField({ maxLength: 4000,
|
|
89
|
+
subject: createTextField({ maxLength: 4000, personal: "tenant", find: "none" }),
|
|
88
90
|
lastMessageAt: createTimestampField({ required: true }),
|
|
89
91
|
messageCount: createNumberField({ required: true, integer: true }),
|
|
90
92
|
},
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
// Supervisor-storeBody-Hook, Event trägt nur bodyRef.
|
|
36
36
|
// - Kein IMAP-Flag-Write-back (V1) — Inbox-Status lebt beim Consumer.
|
|
37
37
|
//
|
|
38
|
-
// **PII/
|
|
39
|
-
// encrypted
|
|
40
|
-
//
|
|
41
|
-
// (crypto-shredding,
|
|
42
|
-
//
|
|
38
|
+
// **PII/GDPR:** address/from/to/cc/subject/snippet are `personal: "tenant"`
|
|
39
|
+
// — encrypted BEFORE every event append (the only write path), event log AND
|
|
40
|
+
// projections carry ciphertext; tenant-destroy shreds the subject key
|
|
41
|
+
// (crypto-shredding, same pattern as billing-foundation #800). The destroy
|
|
42
|
+
// hook additionally archives all streams + deletes the rows.
|
|
43
43
|
|
|
44
44
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
45
45
|
import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
package/src/ledger/entity.ts
CHANGED
|
@@ -41,11 +41,12 @@ export const transactionEntity = createEntity({
|
|
|
41
41
|
fields: {
|
|
42
42
|
date: createDateField({ required: true }),
|
|
43
43
|
// Journal narration ("Miete Januar", "Storno: …") is accounting data, not
|
|
44
|
-
// user-generated PII →
|
|
44
|
+
// user-generated PII → `personal: false` silences the user-content heuristic.
|
|
45
45
|
description: createTextField({
|
|
46
46
|
required: true,
|
|
47
47
|
maxLength: 200,
|
|
48
|
-
|
|
48
|
+
personal: false,
|
|
49
|
+
reason: "is_business_data",
|
|
49
50
|
}),
|
|
50
51
|
// For a Storno entry this points at the reversed transaction's id.
|
|
51
52
|
reference: createTextField({ maxLength: 120 }),
|
|
@@ -79,7 +80,8 @@ export const scheduleEntity = createEntity({
|
|
|
79
80
|
description: createTextField({
|
|
80
81
|
required: true,
|
|
81
82
|
maxLength: 200,
|
|
82
|
-
|
|
83
|
+
personal: false,
|
|
84
|
+
reason: "is_business_data",
|
|
83
85
|
}),
|
|
84
86
|
startDate: createDateField({ required: true }),
|
|
85
87
|
// Absent → open-ended (projects to the window's end).
|
|
@@ -42,7 +42,8 @@ export function createBrandingSettingsScreen(opts: {
|
|
|
42
42
|
description: createTextField({
|
|
43
43
|
maxLength: 500,
|
|
44
44
|
multiline: { rows: 3 },
|
|
45
|
-
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "is_business_data",
|
|
46
47
|
}),
|
|
47
48
|
siteUrl: createTextField({ maxLength: 2000, format: "url" }),
|
|
48
49
|
accentColor: createTextField({ maxLength: 9 }),
|
|
@@ -26,9 +26,10 @@ export const pageEntity = createEntity({
|
|
|
26
26
|
body: createTextField({
|
|
27
27
|
multiline: { rows: 16 },
|
|
28
28
|
maxLength: 100_000,
|
|
29
|
-
|
|
29
|
+
personal: false,
|
|
30
|
+
reason: "is_business_data",
|
|
30
31
|
}),
|
|
31
|
-
description: createTextField({ maxLength: 500,
|
|
32
|
+
description: createTextField({ maxLength: 500, personal: false, reason: "is_business_data" }),
|
|
32
33
|
ogImage: createTextField({ maxLength: 2000 }),
|
|
33
34
|
published: createBooleanField({ default: false }),
|
|
34
35
|
},
|
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
// registered (see feature.ts). A correction is a new entry, not an edit —
|
|
16
16
|
// that is the whole point of a note *history* instead of the single
|
|
17
17
|
// overwritable textarea this bundle replaces (solon#13). GDPR erasure of the
|
|
18
|
-
// author still works without a delete path: `body` is `
|
|
19
|
-
// shredding on the author's subject key), not
|
|
18
|
+
// author still works without a delete path: `body` is `personal: { of:
|
|
19
|
+
// "authorId" }` (crypto-shredding on the author's subject key), not
|
|
20
|
+
// `personal: "self"` on the entity itself.
|
|
20
21
|
export const noteEntryEntity = createEntity({
|
|
21
22
|
table: "read_note_entries",
|
|
22
23
|
fields: {
|
|
@@ -27,11 +28,14 @@ export const noteEntryEntity = createEntity({
|
|
|
27
28
|
// ctx.user.id (see feature.ts), so a note can't be authored as someone
|
|
28
29
|
// else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
|
|
29
30
|
// FK into `user`, not content of its own).
|
|
30
|
-
authorId: createTextField({
|
|
31
|
+
authorId: createTextField({
|
|
32
|
+
personal: "ref",
|
|
33
|
+
}),
|
|
31
34
|
body: createLongTextField({
|
|
32
35
|
required: true,
|
|
33
36
|
maxLength: 20_000,
|
|
34
|
-
|
|
37
|
+
personal: { of: "authorId" },
|
|
38
|
+
find: "none",
|
|
35
39
|
}),
|
|
36
40
|
},
|
|
37
41
|
});
|
|
@@ -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
|
|
@@ -4,5 +4,12 @@
|
|
|
4
4
|
"type": "improvement",
|
|
5
5
|
"title": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated vi…",
|
|
6
6
|
"detail": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated via the tier-engine (mirrors ledger/tags). Pass `{ toggleable: { default: false } }` for fail-closed gating — PAT is then off until a tier lists `\"personal-access-tokens\"` in its features. Omitting the option keeps PAT always-on (no behaviour change for existing consumers)."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.202.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "personal-access-tokens: `write:create` now requires `currentPassword` (+ `mfaCode` if MFA enrolled) (e91d4cb).",
|
|
12
|
+
"detail": "`personal-access-tokens:write:create` now requires `currentPassword` (verified against the caller's password hash) before minting a token, and rejects when the caller has MFA enrolled and `mfaCode` is missing or wrong — a session cookie alone is no longer enough to stand up a durable API credential. `expiresInDays` now defaults to 90 days instead of never-expiring when omitted (the existing 3650-day cap is unchanged, so a genuinely long-lived token is still possible if requested explicitly). Changing a user's password, or enabling/disabling MFA, now revokes all of that user's live PAT tokens — mirrors the existing session auto-revoke-on-password-change behavior. `run-prod-app`/`run-dev-app` wire the new MFA↔PAT revoke callback automatically when both `auth-mfa` and `personal-access-tokens` are mounted; no app-level change needed for that part.",
|
|
13
|
+
"migration": "Apps that already mint PATs (their own client code, scripts, or tests) need to add `currentPassword` to the `create` request payload — this is a breaking change to the `create` request shape despite the minor bump (bundled-features doesn't follow strict semver across its handler schemas yet). If the caller has MFA enrolled, also include a valid `mfaCode`."
|
|
7
14
|
}
|
|
8
15
|
]
|
|
@@ -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
|