@cosmicdrift/kumiko-bundled-features 0.252.1 → 0.254.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/billing-foundation/handlers/process-event.write.ts +1 -0
- package/src/billing-foundation/handlers/process-payment-event.write.ts +1 -0
- 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/inbound-mail-foundation/handlers/connect-account.write.ts +8 -4
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +3 -4
- 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 +63 -19
- package/src/notes-history/executor.ts +6 -1
- package/src/notes-history/feature.ts +7 -1
- package/src/notes-history/handlers/add-note.write.ts +19 -3
- package/src/notes-history/index.ts +7 -2
- package/src/notes-history/schemas.ts +5 -0
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
- package/src/notes-history-user-data/__tests__/mention-forget.integration.test.ts +244 -0
- package/src/notes-history-user-data/hooks.ts +69 -16
- package/src/notes-history-user-data/index.ts +11 -2
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- 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/sessions/session-callbacks.ts +1 -0
- package/src/shared/encrypt-for-direct-write.ts +9 -3
- 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__/run-user-export.integration.test.ts +4 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
- package/src/user-data-rights/index.ts +6 -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
|
@@ -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
|
|
|
@@ -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);
|
|
@@ -17,18 +17,26 @@ import {
|
|
|
17
17
|
// that is the whole point of a note *history* instead of the single
|
|
18
18
|
// overwritable textarea this bundle replaces (solon#13).
|
|
19
19
|
//
|
|
20
|
-
// `body` is about the HOST entity (entityType/entityId), not the author —
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// `
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
20
|
+
// `body` is about the HOST entity (entityType/entityId), not the author — the
|
|
21
|
+
// author is never the data subject of a note's content, so it is deliberately
|
|
22
|
+
// NOT `personal: { of: "authorId" }`. That axis would crypto-shred every
|
|
23
|
+
// note's content the moment its author's data-rights key is destroyed (an
|
|
24
|
+
// employee leaving triggers a forget), garbling unrelated business notes
|
|
25
|
+
// about customers/projects/etc. — data loss, not privacy.
|
|
26
|
+
//
|
|
27
|
+
// `body` is instead `personal: { of: "id" }` (Row-Subject, fw#2596/#2786):
|
|
28
|
+
// the note's OWN row is its content's subject, so `body` is encrypted under a
|
|
29
|
+
// per-note key that nothing else shares. That key gets erased only when
|
|
30
|
+
// `note-mention` (below) names this note as reached by a forgotten subject
|
|
31
|
+
// (see `notes-history-user-data/hooks.ts`) — a note with no mention on the
|
|
32
|
+
// forgotten subject is untouched. GDPR erasure of the AUTHOR still separately
|
|
33
|
+
// shreds `authorName` (`personal: { of: "authorId" }`, the name as stamped at
|
|
34
|
+
// write time); the two erasures are independent.
|
|
35
|
+
//
|
|
36
|
+
// Known gap (fw#2596, not fixed by this entity): prose that names a third
|
|
37
|
+
// party without a structured @-mention is not tracked — regex/NLP over
|
|
38
|
+
// free text is unreliable. The operator's manual `forget-subject` path
|
|
39
|
+
// covers what this structured trigger misses.
|
|
32
40
|
export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
|
|
33
41
|
return createEntity({
|
|
34
42
|
table: "read_note_entries",
|
|
@@ -36,9 +44,19 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
|
|
|
36
44
|
"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
45
|
access,
|
|
38
46
|
fields: {
|
|
39
|
-
entityType: createTextField({
|
|
47
|
+
entityType: createTextField({
|
|
48
|
+
required: true,
|
|
49
|
+
maxLength: 64,
|
|
50
|
+
personal: false,
|
|
51
|
+
reason: "technical_reference",
|
|
52
|
+
}),
|
|
40
53
|
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
41
|
-
entityId: createTextField({
|
|
54
|
+
entityId: createTextField({
|
|
55
|
+
required: true,
|
|
56
|
+
maxLength: 128,
|
|
57
|
+
personal: false,
|
|
58
|
+
reason: "technical_reference",
|
|
59
|
+
}),
|
|
42
60
|
// Never client-supplied — stamped by the deriveAuthorId preSave hook from
|
|
43
61
|
// ctx.user.id (see feature.ts), so a note can't be authored as someone
|
|
44
62
|
// else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
|
|
@@ -56,14 +74,40 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
|
|
|
56
74
|
body: createLongTextField({
|
|
57
75
|
required: true,
|
|
58
76
|
maxLength: 20_000,
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// user
|
|
62
|
-
personal:
|
|
63
|
-
|
|
77
|
+
// Row-Subject (fw#2596/#2786): the note itself is the subject, so its
|
|
78
|
+
// key is erased per-note via `note-mention` lookups, not tied to any
|
|
79
|
+
// user's own forget.
|
|
80
|
+
personal: { of: "id" },
|
|
81
|
+
find: "none",
|
|
64
82
|
}),
|
|
65
83
|
},
|
|
66
84
|
});
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
export const noteEntryEntity = createNoteEntryEntity();
|
|
88
|
+
|
|
89
|
+
// note-mention — join row recording that a note names a subject via a
|
|
90
|
+
// structured @-mention in the editor (never parsed out of `body` text — see
|
|
91
|
+
// the header comment above). One row per (note, mentioned subject); a note
|
|
92
|
+
// with several mentions gets several rows.
|
|
93
|
+
//
|
|
94
|
+
// `subjectId` is the plain FK to the mentioned user's id — `personal: "ref"`
|
|
95
|
+
// marks it as a subject reference for the GDPR-hook-coverage boot guard
|
|
96
|
+
// (same role as `authorId` on note-entry above), not itself encrypted.
|
|
97
|
+
// `notes-history-user-data/hooks.ts` looks this table up by `subjectId` when
|
|
98
|
+
// a user is forgotten, to find every note whose row-subject key must be
|
|
99
|
+
// erased; see that file for the cascade and the retention-consult step it
|
|
100
|
+
// runs first.
|
|
101
|
+
export function createNoteMentionEntity() {
|
|
102
|
+
return createEntity({
|
|
103
|
+
table: "read_note_mentions",
|
|
104
|
+
description:
|
|
105
|
+
"One row recording that a note names a subject via a structured @-mention, keyed by noteId and the mentioned subject's id.",
|
|
106
|
+
fields: {
|
|
107
|
+
noteId: createTextField({ required: true, maxLength: 64 }),
|
|
108
|
+
subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const noteMentionEntity = createNoteMentionEntity();
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { noteEntryEntity } from "./entity";
|
|
2
|
+
import { noteEntryEntity, noteMentionEntity } from "./entity";
|
|
3
3
|
|
|
4
4
|
export const { executor: noteEntryExecutor, table: noteEntryTable } = createEntityExecutor(
|
|
5
5
|
"note-entry",
|
|
6
6
|
noteEntryEntity,
|
|
7
7
|
);
|
|
8
|
+
|
|
9
|
+
export const { executor: noteMentionExecutor, table: noteMentionTable } = createEntityExecutor(
|
|
10
|
+
"note-mention",
|
|
11
|
+
noteMentionEntity,
|
|
12
|
+
);
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
23
|
import { hasWhereRule } from "../shared";
|
|
24
24
|
import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants";
|
|
25
|
-
import { createNoteEntryEntity } from "./entity";
|
|
25
|
+
import { createNoteEntryEntity, noteMentionEntity } from "./entity";
|
|
26
26
|
import { createAddNoteHandler } from "./handlers/add-note.write";
|
|
27
27
|
import { NOTES_HISTORY_FEATURE_I18N } from "./i18n";
|
|
28
28
|
|
|
@@ -43,6 +43,12 @@ function registerNotesHistory(
|
|
|
43
43
|
|
|
44
44
|
const entity = createNoteEntryEntity(ownership);
|
|
45
45
|
r.entity("note-entry", entity);
|
|
46
|
+
// No write/query handler of its own — populated only as a side effect of
|
|
47
|
+
// add-note (see handlers/add-note.write.ts), looked up by
|
|
48
|
+
// notes-history-user-data's forget cascade. Registering the entity (not
|
|
49
|
+
// just building it inline in the handler) is what makes it visible to the
|
|
50
|
+
// registry-wide GDPR boot guards and to executor.ts's table/projection setup.
|
|
51
|
+
r.entity("note-mention", noteMentionEntity);
|
|
46
52
|
|
|
47
53
|
r.writeHandler(createAddNoteHandler(access, parents));
|
|
48
54
|
r.queryHandler(
|
|
@@ -4,7 +4,7 @@ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/error
|
|
|
4
4
|
import { decryptStoredPii, parentRowIsVisible } from "../../shared";
|
|
5
5
|
import { userTable } from "../../user";
|
|
6
6
|
import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
7
|
-
import { noteEntryExecutor } from "../executor";
|
|
7
|
+
import { noteEntryExecutor, noteMentionExecutor } from "../executor";
|
|
8
8
|
import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
9
9
|
|
|
10
10
|
// add-note — appends a note-entry to (entityType, entityId). authorId is
|
|
@@ -69,11 +69,27 @@ export function createAddNoteHandler(
|
|
|
69
69
|
authorName = null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const { mentions, ...notePayload } = payload;
|
|
73
|
+
const created = await noteEntryExecutor.create(
|
|
74
|
+
{ ...notePayload, authorId: event.user.id, authorName },
|
|
74
75
|
event.user,
|
|
75
76
|
ctx.db,
|
|
76
77
|
);
|
|
78
|
+
if (!created.isSuccess) return created;
|
|
79
|
+
|
|
80
|
+
// Same tx as the note create above (ctx.db) — a mention-row failure
|
|
81
|
+
// rolls the note back with it, same as the framework's own nested-write
|
|
82
|
+
// parent+child pattern (dispatch-write.ts).
|
|
83
|
+
for (const subjectId of new Set(mentions ?? [])) {
|
|
84
|
+
const mentionResult = await noteMentionExecutor.create(
|
|
85
|
+
{ noteId: created.data.id, subjectId },
|
|
86
|
+
event.user,
|
|
87
|
+
ctx.db,
|
|
88
|
+
);
|
|
89
|
+
if (!mentionResult.isSuccess) return mentionResult;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return created;
|
|
77
93
|
},
|
|
78
94
|
};
|
|
79
95
|
}
|
|
@@ -6,8 +6,13 @@ export {
|
|
|
6
6
|
NotesHistoryHandlers,
|
|
7
7
|
NotesHistoryQueries,
|
|
8
8
|
} from "./constants";
|
|
9
|
-
export { noteEntryEntity } from "./entity";
|
|
10
|
-
export {
|
|
9
|
+
export { noteEntryEntity, noteMentionEntity } from "./entity";
|
|
10
|
+
export {
|
|
11
|
+
noteEntryExecutor,
|
|
12
|
+
noteEntryTable,
|
|
13
|
+
noteMentionExecutor,
|
|
14
|
+
noteMentionTable,
|
|
15
|
+
} from "./executor";
|
|
11
16
|
export {
|
|
12
17
|
createNotesHistoryFeature,
|
|
13
18
|
type NotesHistoryFeatureOptions,
|
|
@@ -4,9 +4,14 @@ import { z } from "zod";
|
|
|
4
4
|
// needed here since note-entry has no deterministic aggregate-id derived
|
|
5
5
|
// from these fields (see aggregate-id note in entity.ts): a literal value
|
|
6
6
|
// can't shift stream-id tuple boundaries when there is no such tuple.
|
|
7
|
+
// `mentions` carries structured @-mentions from the editor as user ids —
|
|
8
|
+
// never parsed out of `body` text (regex/NLP over free text is unreliable,
|
|
9
|
+
// see entity.ts). Capped well above any realistic note; a client sending more
|
|
10
|
+
// is a bug, not a use case worth accommodating.
|
|
7
11
|
export const addNotePayloadSchema = z.object({
|
|
8
12
|
entityType: z.string().min(1).max(64),
|
|
9
13
|
entityId: z.string().min(1).max(128),
|
|
10
14
|
body: z.string().trim().min(1).max(20_000),
|
|
15
|
+
mentions: z.array(z.uuid()).max(50).optional(),
|
|
11
16
|
});
|
|
12
17
|
export type AddNotePayload = z.infer<typeof addNotePayloadSchema>;
|
|
@@ -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);
|