@cosmicdrift/kumiko-bundled-features 0.252.1 → 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
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// the run becomes retriable via the existing jobs:write:retry gate
|
|
13
13
|
// (retry.write.ts only allows retry from "failed").
|
|
14
14
|
|
|
15
|
-
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
16
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
17
|
+
import { encryptFailureError } from "../../job-run-logger";
|
|
17
18
|
import { jobRunsTable } from "../../job-run-table";
|
|
18
19
|
|
|
19
20
|
export const STALE_JOB_RUN_ERROR =
|
|
@@ -30,22 +31,43 @@ export async function markStaleJobRunsFailed(
|
|
|
30
31
|
const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
|
|
31
32
|
const now = Temporal.Now.instant();
|
|
32
33
|
|
|
34
|
+
// Fetch the matched rows first: `error` carries the same per-triggering-
|
|
35
|
+
// user subject annotation as job-run-logger.ts's onJobFailed path
|
|
36
|
+
// (personal: { of: "triggeredById" } on job-run-table.ts), and a batch
|
|
37
|
+
// updateMany can't encrypt per-row — different matched runs can belong to
|
|
38
|
+
// different users, each under their own DEK. One updateMany per row keeps
|
|
39
|
+
// this crash-recovery sweep's write on the same encrypted footing as the
|
|
40
|
+
// normal failure path instead of writing STALE_JOB_RUN_ERROR in the clear.
|
|
41
|
+
const stale = await selectMany<{ id: string; triggeredById: string | null }>(db, jobRunsTable, {
|
|
42
|
+
status: "running",
|
|
43
|
+
startedAt: { lt: cutoff },
|
|
44
|
+
});
|
|
45
|
+
|
|
33
46
|
// duration is deliberately left untouched: we don't know when the run
|
|
34
47
|
// actually died, only that it crossed the timeout, so recording a
|
|
35
48
|
// duration would misrepresent it as measured. detail-screen/list-screen
|
|
36
49
|
// both already render a null duration as "—".
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
let runsMarkedFailed = 0;
|
|
51
|
+
for (const run of stale) {
|
|
52
|
+
const encryptedError = await encryptFailureError(STALE_JOB_RUN_ERROR, run.triggeredById);
|
|
53
|
+
const updated = await updateMany(
|
|
54
|
+
db,
|
|
55
|
+
jobRunsTable,
|
|
56
|
+
{
|
|
57
|
+
status: "failed",
|
|
58
|
+
error: encryptedError,
|
|
59
|
+
finishedAt: now,
|
|
60
|
+
modifiedAt: now,
|
|
61
|
+
modifiedById: "system",
|
|
62
|
+
},
|
|
63
|
+
// Re-assert status: "running" here (not just id) — closes the race
|
|
64
|
+
// window between the selectMany above and this write, where the run
|
|
65
|
+
// could have completed/failed normally in between and must not be
|
|
66
|
+
// clobbered back to "failed" with the stale-timeout message.
|
|
67
|
+
{ id: run.id, status: "running" },
|
|
68
|
+
);
|
|
69
|
+
runsMarkedFailed += updated.length;
|
|
70
|
+
}
|
|
49
71
|
|
|
50
|
-
return { runsMarkedFailed
|
|
72
|
+
return { runsMarkedFailed };
|
|
51
73
|
}
|
package/src/jobs/feature.ts
CHANGED
|
@@ -48,6 +48,11 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
48
48
|
r.systemScope();
|
|
49
49
|
r.storeTable(jobRunsTableMeta, {
|
|
50
50
|
reason: "direct_write.job_runs",
|
|
51
|
+
// payload/error carry personal:{of:"triggeredById"} (job-run-table.ts).
|
|
52
|
+
// job-run-logger.ts manually encrypts both before every insert/update
|
|
53
|
+
// (encryptStartedPayload/encryptFailureError) since this store bypasses
|
|
54
|
+
// the executor's automatic PII pipeline (#2243).
|
|
55
|
+
piiEncryptedOnWrite: true,
|
|
51
56
|
});
|
|
52
57
|
r.storeTable(jobRunLogsTableMeta, {
|
|
53
58
|
reason: "read_side.job_run_logs",
|
|
@@ -106,8 +106,13 @@ async function encryptLogMessages<T extends { readonly message: string }>(
|
|
|
106
106
|
// Mirrors encryptFailureError for the failure-path `error` column (#2307):
|
|
107
107
|
// stored alongside logs[].message under the same triggering user's DEK, so
|
|
108
108
|
// a log/error pair from the same failed run decrypt together or erase
|
|
109
|
-
// together.
|
|
110
|
-
|
|
109
|
+
// together. Exported so stale-run-sweep.ts's crash-recovery write (a
|
|
110
|
+
// separate, per-row batch path outside onJobFailed) applies the identical
|
|
111
|
+
// per-subject encryption instead of writing `error` in the clear.
|
|
112
|
+
export async function encryptFailureError(
|
|
113
|
+
error: string,
|
|
114
|
+
triggeredById: string | null,
|
|
115
|
+
): Promise<string> {
|
|
111
116
|
if (triggeredById === null) return error;
|
|
112
117
|
const kms = configuredPiiSubjectKms();
|
|
113
118
|
if (!kms) return error;
|
|
@@ -41,16 +41,21 @@ export type JobLogLevel = "info" | "warn" | "error";
|
|
|
41
41
|
export const jobRunEntity = createEntity({
|
|
42
42
|
table: "store_job_runs",
|
|
43
43
|
fields: {
|
|
44
|
-
jobName: createTextField({ required: true }),
|
|
45
|
-
bullJobId: createTextField({ required: true }),
|
|
46
|
-
status: createTextField({ required: true }),
|
|
47
|
-
payload
|
|
48
|
-
|
|
44
|
+
jobName: createTextField({ required: true, personal: false, reason: "system_metadata" }),
|
|
45
|
+
bullJobId: createTextField({ required: true, personal: false, reason: "system_metadata" }),
|
|
46
|
+
status: createTextField({ required: true, personal: false, reason: "system_metadata" }),
|
|
47
|
+
// The run-started payload can carry arbitrary caller-supplied data,
|
|
48
|
+
// encrypted by job-run-logger.ts under the triggering user's DEK (see
|
|
49
|
+
// jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`.
|
|
50
|
+
payload: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
|
|
51
|
+
// Failure messages can echo payload content back — same subject/DEK as
|
|
52
|
+
// `payload` (job-run-logger.ts encryptFailureError).
|
|
53
|
+
error: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
|
|
49
54
|
attempt: createNumberField({ required: true, default: 1, integer: true }),
|
|
50
55
|
startedAt: createTimestampField({ required: true }),
|
|
51
56
|
finishedAt: createTimestampField(),
|
|
52
57
|
duration: createNumberField({ integer: true }),
|
|
53
|
-
triggeredById: createTextField(),
|
|
58
|
+
triggeredById: createTextField({ personal: false, reason: "pseudonymous_fk" }),
|
|
54
59
|
},
|
|
55
60
|
});
|
|
56
61
|
|
package/src/ledger/entity.ts
CHANGED
|
@@ -19,12 +19,17 @@ export const accountEntity = createEntity({
|
|
|
19
19
|
description:
|
|
20
20
|
"One node of a tenant's chart of accounts: a name, an asset/liability/equity/income/expense type that decides how reports read its balance, an optional account code and an optional parent account forming the account tree; balances are derived from postings, never stored here.",
|
|
21
21
|
fields: {
|
|
22
|
-
name: createTextField({
|
|
22
|
+
name: createTextField({
|
|
23
|
+
required: true,
|
|
24
|
+
maxLength: 120,
|
|
25
|
+
personal: false,
|
|
26
|
+
reason: "is_business_data",
|
|
27
|
+
}),
|
|
23
28
|
type: createSelectField({ options: ACCOUNT_TYPES, required: true }),
|
|
24
29
|
// Optional account number (Kontonummer / SKR code) — free text in v1.
|
|
25
|
-
code: createTextField({ maxLength: 32 }),
|
|
30
|
+
code: createTextField({ maxLength: 32, personal: false, reason: "technical_reference" }),
|
|
26
31
|
// Parent account id, or absent for a root account. No FK (event-sourced).
|
|
27
|
-
parentId: createTextField({ maxLength: 64 }),
|
|
32
|
+
parentId: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
|
|
28
33
|
},
|
|
29
34
|
});
|
|
30
35
|
|
|
@@ -52,8 +57,13 @@ export const transactionEntity = createEntity({
|
|
|
52
57
|
personal: false,
|
|
53
58
|
reason: "is_business_data",
|
|
54
59
|
}),
|
|
55
|
-
//
|
|
56
|
-
|
|
60
|
+
// Free-text memo (a reversal or confirmed schedule period overwrites it with
|
|
61
|
+
// a system-generated id, but a manual entry can carry caller-supplied text).
|
|
62
|
+
reference: createTextField({
|
|
63
|
+
maxLength: 120,
|
|
64
|
+
personal: false,
|
|
65
|
+
reason: "is_business_data",
|
|
66
|
+
}),
|
|
57
67
|
status: createSelectField({ options: TRANSACTION_STATUS, required: true }),
|
|
58
68
|
// Posting lines are born with the entry and never change on their own —
|
|
59
69
|
// a correction is a new (reversing) entry. The embedded list validates
|
|
@@ -94,7 +104,17 @@ export const scheduleEntity = createEntity({
|
|
|
94
104
|
endDate: createDateField(),
|
|
95
105
|
interval: createSelectField({ options: SCHEDULE_INTERVALS, required: true }),
|
|
96
106
|
amount: createNumberField({ required: true, min: 1, integer: true }),
|
|
97
|
-
debitAccountId: createTextField({
|
|
98
|
-
|
|
107
|
+
debitAccountId: createTextField({
|
|
108
|
+
required: true,
|
|
109
|
+
maxLength: 64,
|
|
110
|
+
personal: false,
|
|
111
|
+
reason: "technical_reference",
|
|
112
|
+
}),
|
|
113
|
+
creditAccountId: createTextField({
|
|
114
|
+
required: true,
|
|
115
|
+
maxLength: 64,
|
|
116
|
+
personal: false,
|
|
117
|
+
reason: "technical_reference",
|
|
118
|
+
}),
|
|
99
119
|
},
|
|
100
120
|
});
|
|
@@ -38,16 +38,30 @@ export function createBrandingSettingsScreen(opts: {
|
|
|
38
38
|
layoutPreset: BRANDING_QN.layoutPreset,
|
|
39
39
|
},
|
|
40
40
|
fields: {
|
|
41
|
-
title: createTextField({ maxLength: 200 }),
|
|
41
|
+
title: createTextField({ maxLength: 200, personal: false, reason: "is_business_data" }),
|
|
42
42
|
description: createTextField({
|
|
43
43
|
maxLength: 500,
|
|
44
44
|
multiline: { rows: 3 },
|
|
45
45
|
personal: false,
|
|
46
46
|
reason: "is_business_data",
|
|
47
47
|
}),
|
|
48
|
-
siteUrl: createTextField({
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
siteUrl: createTextField({
|
|
49
|
+
maxLength: 2000,
|
|
50
|
+
format: "url",
|
|
51
|
+
personal: false,
|
|
52
|
+
reason: "is_business_data",
|
|
53
|
+
}),
|
|
54
|
+
accentColor: createTextField({
|
|
55
|
+
maxLength: 9,
|
|
56
|
+
personal: false,
|
|
57
|
+
reason: "is_business_data",
|
|
58
|
+
}),
|
|
59
|
+
logoUrl: createTextField({
|
|
60
|
+
maxLength: 2000,
|
|
61
|
+
format: "url",
|
|
62
|
+
personal: false,
|
|
63
|
+
reason: "is_business_data",
|
|
64
|
+
}),
|
|
51
65
|
layoutPreset: createSelectField({ options: LAYOUT_PRESETS }),
|
|
52
66
|
},
|
|
53
67
|
layout: {
|
|
@@ -74,7 +88,15 @@ export function createBrandingSettingsScreen(opts: {
|
|
|
74
88
|
configKeys: { ...base.configKeys, customCss: BRANDING_QN.customCss },
|
|
75
89
|
fields: {
|
|
76
90
|
...base.fields,
|
|
77
|
-
|
|
91
|
+
// Tenant-admin-authored branding CSS (raw-CSS gated behind
|
|
92
|
+
// allowCustomCss), not end-user input — same category as the other
|
|
93
|
+
// branding fields above.
|
|
94
|
+
customCss: createTextField({
|
|
95
|
+
maxLength: 8000,
|
|
96
|
+
multiline: { rows: 12 },
|
|
97
|
+
personal: false,
|
|
98
|
+
reason: "is_business_data",
|
|
99
|
+
}),
|
|
78
100
|
},
|
|
79
101
|
layout: {
|
|
80
102
|
sections: [
|
|
@@ -16,9 +16,28 @@ export const pageEntity = createEntity({
|
|
|
16
16
|
description:
|
|
17
17
|
"One tenant-editable public web page, unique per slug and language, holding a markdown body plus the title, SEO description and OG image, with a published flag that decides whether anonymous visitors are served it or get a 404.",
|
|
18
18
|
fields: {
|
|
19
|
-
slug: createTextField({
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
slug: createTextField({
|
|
20
|
+
required: true,
|
|
21
|
+
maxLength: 64,
|
|
22
|
+
sortable: true,
|
|
23
|
+
searchable: true,
|
|
24
|
+
personal: false,
|
|
25
|
+
reason: "technical_reference",
|
|
26
|
+
}),
|
|
27
|
+
lang: createTextField({
|
|
28
|
+
required: true,
|
|
29
|
+
maxLength: 8,
|
|
30
|
+
sortable: true,
|
|
31
|
+
personal: false,
|
|
32
|
+
reason: "technical_reference",
|
|
33
|
+
}),
|
|
34
|
+
title: createTextField({
|
|
35
|
+
required: true,
|
|
36
|
+
maxLength: 200,
|
|
37
|
+
searchable: true,
|
|
38
|
+
personal: false,
|
|
39
|
+
reason: "is_business_data",
|
|
40
|
+
}),
|
|
22
41
|
// Body + description sind vom Tenant-Admin authored Business-Content
|
|
23
42
|
// (Markdown), keine User-Generated-PII. `multiline` → der entityEdit-
|
|
24
43
|
// Renderer gibt ein <textarea> aus (createLongTextField hat aktuell
|
|
@@ -32,7 +51,7 @@ export const pageEntity = createEntity({
|
|
|
32
51
|
reason: "is_business_data",
|
|
33
52
|
}),
|
|
34
53
|
description: createTextField({ maxLength: 500, personal: false, reason: "is_business_data" }),
|
|
35
|
-
ogImage: createTextField({ maxLength: 2000 }),
|
|
54
|
+
ogImage: createTextField({ maxLength: 2000, personal: false, reason: "is_business_data" }),
|
|
36
55
|
published: createBooleanField({ default: false }),
|
|
37
56
|
},
|
|
38
57
|
indexes: [{ unique: true, columns: ["tenantId", "slug", "lang"], name: "read_pages_unique" }],
|
|
@@ -43,7 +43,14 @@ const tenantId = testTenantId(1);
|
|
|
43
43
|
const CONTACT_TABLE = "notes_kms_test_contacts";
|
|
44
44
|
const contactEntity = createEntity({
|
|
45
45
|
table: CONTACT_TABLE,
|
|
46
|
-
fields: {
|
|
46
|
+
fields: {
|
|
47
|
+
name: createTextField({
|
|
48
|
+
required: true,
|
|
49
|
+
maxLength: 64,
|
|
50
|
+
personal: false,
|
|
51
|
+
reason: "technical_reference",
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
47
54
|
});
|
|
48
55
|
const contactFixtureFeature = defineFeature("notes-kms-test-contact-fixture", (r) => {
|
|
49
56
|
r.entity("contact", contactEntity);
|
|
@@ -69,7 +69,14 @@ const teamOwnership: NonNullable<EntityDefinition["access"]> = {
|
|
|
69
69
|
const PROJECT_TABLE = "notes_ownership_test_projects";
|
|
70
70
|
const projectEntity: EntityDefinition = createEntity({
|
|
71
71
|
table: PROJECT_TABLE,
|
|
72
|
-
fields: {
|
|
72
|
+
fields: {
|
|
73
|
+
name: createTextField({
|
|
74
|
+
required: true,
|
|
75
|
+
maxLength: 64,
|
|
76
|
+
personal: false,
|
|
77
|
+
reason: "technical_reference",
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
73
80
|
});
|
|
74
81
|
const projectFixtureFeature = defineFeature("notes-ownership-test-project-fixture", (r) => {
|
|
75
82
|
r.entity("project", projectEntity);
|
|
@@ -48,8 +48,18 @@ function createProjectEntity(access?: EntityDefinition["access"]): EntityDefinit
|
|
|
48
48
|
return createEntity({
|
|
49
49
|
table: PROJECT_TABLE,
|
|
50
50
|
fields: {
|
|
51
|
-
teamId: createTextField({
|
|
52
|
-
|
|
51
|
+
teamId: createTextField({
|
|
52
|
+
required: true,
|
|
53
|
+
maxLength: 64,
|
|
54
|
+
personal: false,
|
|
55
|
+
reason: "technical_reference",
|
|
56
|
+
}),
|
|
57
|
+
name: createTextField({
|
|
58
|
+
required: true,
|
|
59
|
+
maxLength: 64,
|
|
60
|
+
personal: false,
|
|
61
|
+
reason: "technical_reference",
|
|
62
|
+
}),
|
|
53
63
|
},
|
|
54
64
|
access,
|
|
55
65
|
});
|
|
@@ -62,7 +72,14 @@ const guardedProjectEntity = createProjectEntity(projectOwnership);
|
|
|
62
72
|
// too, not just made-up names.
|
|
63
73
|
const contactEntity = createEntity({
|
|
64
74
|
table: "notes_pv_test_contacts",
|
|
65
|
-
fields: {
|
|
75
|
+
fields: {
|
|
76
|
+
name: createTextField({
|
|
77
|
+
required: true,
|
|
78
|
+
maxLength: 64,
|
|
79
|
+
personal: false,
|
|
80
|
+
reason: "technical_reference",
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
66
83
|
});
|
|
67
84
|
|
|
68
85
|
const fixturesFeature = defineFeature("notes-pv-test-fixtures", (r) => {
|
|
@@ -36,7 +36,14 @@ const notesHistoryFeature = createNotesHistoryFeature();
|
|
|
36
36
|
const CONTACT_TABLE = "notes_history_test_contacts";
|
|
37
37
|
const contactEntity = createEntity({
|
|
38
38
|
table: CONTACT_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 contactFixtureFeature = defineFeature("notes-history-test-contact-fixture", (r) => {
|
|
42
49
|
r.entity("contact", contactEntity);
|
|
@@ -36,9 +36,19 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
|
|
|
36
36
|
"One note attached to a host entity by entityType and entityId, holding the note body plus the id and the display name of the author as it stood when the note was written. Rows are append-only: a correction is a further note, never an edit of this one.",
|
|
37
37
|
access,
|
|
38
38
|
fields: {
|
|
39
|
-
entityType: createTextField({
|
|
39
|
+
entityType: createTextField({
|
|
40
|
+
required: true,
|
|
41
|
+
maxLength: 64,
|
|
42
|
+
personal: false,
|
|
43
|
+
reason: "technical_reference",
|
|
44
|
+
}),
|
|
40
45
|
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
41
|
-
entityId: createTextField({
|
|
46
|
+
entityId: createTextField({
|
|
47
|
+
required: true,
|
|
48
|
+
maxLength: 128,
|
|
49
|
+
personal: false,
|
|
50
|
+
reason: "technical_reference",
|
|
51
|
+
}),
|
|
42
52
|
// Never client-supplied — stamped by the deriveAuthorId preSave hook from
|
|
43
53
|
// ctx.user.id (see feature.ts), so a note can't be authored as someone
|
|
44
54
|
// else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
|
|
@@ -30,7 +30,14 @@ const other = createTestUser({ id: 2, roles: ["TenantMember"] });
|
|
|
30
30
|
const CONTACT_TABLE = "notes_user_data_test_contacts";
|
|
31
31
|
const contactEntity = createEntity({
|
|
32
32
|
table: CONTACT_TABLE,
|
|
33
|
-
fields: {
|
|
33
|
+
fields: {
|
|
34
|
+
name: createTextField({
|
|
35
|
+
required: true,
|
|
36
|
+
maxLength: 64,
|
|
37
|
+
personal: false,
|
|
38
|
+
reason: "technical_reference",
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
34
41
|
});
|
|
35
42
|
const contactFixtureFeature = defineFeature("notes-user-data-test-contact-fixture", (r) => {
|
|
36
43
|
r.entity("contact", contactEntity);
|
|
@@ -23,11 +23,15 @@ export const apiTokenEntity = createEntity({
|
|
|
23
23
|
required: true,
|
|
24
24
|
maxLength: 36,
|
|
25
25
|
access: { write: access.privileged },
|
|
26
|
+
personal: false,
|
|
27
|
+
reason: "pseudonymous_fk",
|
|
26
28
|
}),
|
|
27
29
|
tenantId: createTextField({
|
|
28
30
|
required: true,
|
|
29
31
|
maxLength: 36,
|
|
30
32
|
access: { write: access.privileged },
|
|
33
|
+
personal: false,
|
|
34
|
+
reason: "pseudonymous_fk",
|
|
31
35
|
}),
|
|
32
36
|
name: createTextField({
|
|
33
37
|
required: true,
|
|
@@ -36,19 +40,43 @@ export const apiTokenEntity = createEntity({
|
|
|
36
40
|
personal: { of: "userId" },
|
|
37
41
|
find: "none",
|
|
38
42
|
}),
|
|
43
|
+
// SHA-256 hash of the plaintext token — credential material, but kept
|
|
44
|
+
// Klasse 1 deliberately: resolver.ts point-reads this row with a raw
|
|
45
|
+
// exact-match `{ tokenHash: hashPatToken(rawToken) }` query on the hot
|
|
46
|
+
// auth path. A subject annotation here would make encryptForDirectWrite
|
|
47
|
+
// store ciphertext while the resolver keeps querying for the plaintext
|
|
48
|
+
// hash — every bearer-token request would 401 once a KMS is active.
|
|
49
|
+
// Fixing that needs a blind-index-backed lookup (like email's
|
|
50
|
+
// find:"exact" companion column), a resolver.ts rewrite out of scope
|
|
51
|
+
// here — see PR body "Offene Fragen".
|
|
39
52
|
tokenHash: createTextField({
|
|
40
53
|
required: true,
|
|
41
54
|
maxLength: 64,
|
|
42
55
|
access: { write: access.privileged },
|
|
56
|
+
personal: false,
|
|
57
|
+
reason: "credential_hash_used_as_exact_lookup_key_2809",
|
|
43
58
|
}),
|
|
59
|
+
// Short displayable prefix ("your tokens" list) — not the secret itself,
|
|
60
|
+
// but still identifies which of the user's tokens this row is. Kept
|
|
61
|
+
// Klasse 1: list.query.ts returns `r.prefix` straight through with no
|
|
62
|
+
// decrypt call (unlike `name`, which goes through decryptStoredPii) — a
|
|
63
|
+
// subject annotation would leak ciphertext to the UI once a KMS is
|
|
64
|
+
// active. See PR body "Offene Fragen".
|
|
44
65
|
prefix: createTextField({
|
|
45
66
|
required: true,
|
|
46
67
|
maxLength: 16,
|
|
47
68
|
access: { write: access.privileged },
|
|
69
|
+
personal: false,
|
|
70
|
+
reason: "credential_prefix_read_without_decrypt_2809",
|
|
48
71
|
}),
|
|
49
72
|
// JSON-encoded string[] of granted scope names — mirrors the roles-column
|
|
50
73
|
// convention (parseRoles-style text); the resolver JSON.parses it.
|
|
51
|
-
scopes: createTextField({
|
|
74
|
+
scopes: createTextField({
|
|
75
|
+
required: true,
|
|
76
|
+
access: { write: access.privileged },
|
|
77
|
+
personal: false,
|
|
78
|
+
reason: "technical_reference",
|
|
79
|
+
}),
|
|
52
80
|
createdAt: createTimestampField({ required: true, access: { write: access.privileged } }),
|
|
53
81
|
expiresAt: createTimestampField({ access: { write: access.privileged } }),
|
|
54
82
|
revokedAt: createTimestampField({ access: { write: access.privileged } }),
|
package/src/secrets/table.ts
CHANGED
|
@@ -50,7 +50,7 @@ export const tenantSecretEntity = createEntity({
|
|
|
50
50
|
description:
|
|
51
51
|
"One encrypted secret per tenant and key, holding the sealed envelope, the key-encryption-key version it was sealed under and its last rotation time; plaintext values are never stored or returned.",
|
|
52
52
|
fields: {
|
|
53
|
-
key: createTextField({ required: true }),
|
|
53
|
+
key: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
54
54
|
kekVersion: createNumberField({ required: true, integer: true }),
|
|
55
55
|
},
|
|
56
56
|
});
|
|
@@ -33,11 +33,15 @@ export const userSessionEntity = createEntity({
|
|
|
33
33
|
required: true,
|
|
34
34
|
maxLength: 36,
|
|
35
35
|
access: { write: access.privileged },
|
|
36
|
+
personal: false,
|
|
37
|
+
reason: "pseudonymous_fk",
|
|
36
38
|
}),
|
|
37
39
|
tenantId: createTextField({
|
|
38
40
|
required: true,
|
|
39
41
|
maxLength: 36,
|
|
40
42
|
access: { write: access.privileged },
|
|
43
|
+
personal: false,
|
|
44
|
+
reason: "pseudonymous_fk",
|
|
41
45
|
}),
|
|
42
46
|
createdAt: createTimestampField({
|
|
43
47
|
required: true,
|
|
@@ -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
|
});
|
|
@@ -33,7 +33,14 @@ const tagsFeature = createTagsFeature();
|
|
|
33
33
|
|
|
34
34
|
const CREDIT_TABLE = "tags_test_credits";
|
|
35
35
|
const NOTE_TABLE = "tags_test_notes";
|
|
36
|
-
const hostFields = {
|
|
36
|
+
const hostFields = {
|
|
37
|
+
name: createTextField({
|
|
38
|
+
required: true,
|
|
39
|
+
maxLength: 64,
|
|
40
|
+
personal: false,
|
|
41
|
+
reason: "technical_reference",
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
37
44
|
const creditEntity = createEntity({ table: CREDIT_TABLE, fields: hostFields });
|
|
38
45
|
const noteEntity = createEntity({ table: NOTE_TABLE, fields: hostFields });
|
|
39
46
|
const hostFixturesFeature = defineFeature("tags-test-host-fixtures", (r) => {
|
package/src/tags/entity.ts
CHANGED
|
@@ -28,11 +28,11 @@ export const tagEntity = createEntity({
|
|
|
28
28
|
reason: "catalog_label",
|
|
29
29
|
}),
|
|
30
30
|
// Optional UI hint (hex or token). No enforcement — purely for rendering.
|
|
31
|
-
color: createTextField({ maxLength: 32 }),
|
|
31
|
+
color: createTextField({ maxLength: 32, personal: false, reason: "technical_reference" }),
|
|
32
32
|
// Optional entity-type scope (GitLab project-vs-group labels): empty = global
|
|
33
33
|
// (offered on every entity); a value like "note" restricts the tag to that
|
|
34
34
|
// entityType in the picker. No enforcement on assign — purely a picker hint.
|
|
35
|
-
scope: createTextField({ maxLength: 64 }),
|
|
35
|
+
scope: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
|
|
36
36
|
},
|
|
37
37
|
});
|
|
38
38
|
|
|
@@ -63,10 +63,25 @@ export function createTagAssignmentEntity(access?: EntityDefinition["access"]) {
|
|
|
63
63
|
softDelete: true,
|
|
64
64
|
access,
|
|
65
65
|
fields: {
|
|
66
|
-
tagId: createTextField({
|
|
67
|
-
|
|
66
|
+
tagId: createTextField({
|
|
67
|
+
required: true,
|
|
68
|
+
maxLength: 64,
|
|
69
|
+
personal: false,
|
|
70
|
+
reason: "technical_reference",
|
|
71
|
+
}),
|
|
72
|
+
entityType: createTextField({
|
|
73
|
+
required: true,
|
|
74
|
+
maxLength: 64,
|
|
75
|
+
personal: false,
|
|
76
|
+
reason: "technical_reference",
|
|
77
|
+
}),
|
|
68
78
|
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
69
|
-
entityId: createTextField({
|
|
79
|
+
entityId: createTextField({
|
|
80
|
+
required: true,
|
|
81
|
+
maxLength: 128,
|
|
82
|
+
personal: false,
|
|
83
|
+
reason: "technical_reference",
|
|
84
|
+
}),
|
|
70
85
|
},
|
|
71
86
|
});
|
|
72
87
|
}
|
|
@@ -18,16 +18,17 @@ import { CONTENT_FORMATS, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } f
|
|
|
18
18
|
export const templateResourceEntity = createEntity({
|
|
19
19
|
table: "read_template_resources",
|
|
20
20
|
fields: {
|
|
21
|
-
slug: createTextField({ required: true }),
|
|
21
|
+
slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
22
22
|
kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
|
|
23
|
-
locale: createTextField({ required: true }),
|
|
23
|
+
locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
24
24
|
// Human-facing heading for kind="text-block" (legal pages, marketing
|
|
25
25
|
// copy). Nullable because render templates carry their subject inside
|
|
26
26
|
// the body; a NOT NULL here would break every existing mail row.
|
|
27
|
-
|
|
27
|
+
// Same authorship class as `content` below.
|
|
28
|
+
title: createTextField({ personal: false, reason: "is_business_data" }),
|
|
28
29
|
// Folder path for the content tree, `/`-separated ("page/marketing").
|
|
29
30
|
// null = root node.
|
|
30
|
-
folder: createTextField({}),
|
|
31
|
+
folder: createTextField({ personal: false, reason: "technical_reference" }),
|
|
31
32
|
// Template-body is authored by TenantAdmin/Operator (email-templates etc.),
|
|
32
33
|
// business data — kein end-user UGC.
|
|
33
34
|
content: createLongTextField({
|
|
@@ -35,10 +36,10 @@ export const templateResourceEntity = createEntity({
|
|
|
35
36
|
reason: "is_business_data",
|
|
36
37
|
}),
|
|
37
38
|
contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
|
|
38
|
-
variableSchema: createLongTextField({}),
|
|
39
|
-
linkedResources: createLongTextField({}),
|
|
39
|
+
variableSchema: createLongTextField({ personal: false, reason: "technical_reference" }),
|
|
40
|
+
linkedResources: createLongTextField({ personal: false, reason: "technical_reference" }),
|
|
40
41
|
scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
|
|
41
|
-
parentTemplateId: createTextField({}),
|
|
42
|
+
parentTemplateId: createTextField({ personal: false, reason: "technical_reference" }),
|
|
42
43
|
status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
|
|
43
44
|
},
|
|
44
45
|
indexes: [
|
|
@@ -25,11 +25,13 @@ export const userContentEntryEntity = createEntity({
|
|
|
25
25
|
table: "read_user_content_entries",
|
|
26
26
|
fields: {
|
|
27
27
|
ownerId: createTextField({ required: true, personal: "ref" }),
|
|
28
|
-
slug: createTextField({ required: true }),
|
|
28
|
+
slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
29
29
|
kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
|
|
30
|
-
locale: createTextField({ required: true }),
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
31
|
+
// Organizational label for the entry (e.g. "Default signature"), not the
|
|
32
|
+
// owner's PII itself — that lives in `content` below.
|
|
33
|
+
title: createTextField({ personal: false, reason: "technical_reference" }),
|
|
34
|
+
folder: createTextField({ personal: false, reason: "technical_reference" }),
|
|
33
35
|
content: createLongTextField({
|
|
34
36
|
personal: { of: "ownerId" },
|
|
35
37
|
find: "none",
|
|
@@ -61,7 +61,12 @@ export const tenantInvitationEntity = createEntity({
|
|
|
61
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
|
-
role: createTextField({
|
|
64
|
+
role: createTextField({
|
|
65
|
+
required: true,
|
|
66
|
+
maxLength: 50,
|
|
67
|
+
personal: false,
|
|
68
|
+
reason: "technical_reference",
|
|
69
|
+
}),
|
|
65
70
|
// Lifecycle-State. Default "pending"; transitions:
|
|
66
71
|
// pending → accepted | cancelled | expired
|
|
67
72
|
status: createSelectField({
|