@cosmicdrift/kumiko-samples 0.356.0 → 0.358.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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +71 -21
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +99 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +143 -43
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +18 -32
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
- package/packages/bundled-features/src/cap-counter/index.ts +5 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -25
- package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +15 -1
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
- package/packages/bundled-features/src/delivery/constants.ts +5 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
- package/packages/bundled-features/src/delivery/feature.ts +7 -1
- package/packages/bundled-features/src/delivery/jobs.ts +27 -1
- package/packages/bundled-features/src/delivery/testing.ts +1 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -15
- package/packages/bundled-features/src/jobs/job-run-table.ts +10 -3
- package/packages/bundled-features/src/notes-history/changes.json +6 -0
- package/packages/bundled-features/src/notes-history/entity.ts +9 -7
- package/packages/bundled-features/src/notes-history-user-data/hooks.ts +84 -76
- package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
- package/packages/bundled-features/src/sessions/screens.ts +2 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +6 -3
- package/packages/bundled-features/src/tenant/feature.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
- package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
- package/packages/bundled-features/src/tenant/screens.ts +9 -6
- package/packages/bundled-features/src/user/screens.ts +8 -1
- package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
- package/packages/bundled-features/src/waitlist/screens.ts +8 -1
- package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
- package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
KeyErasedError,
|
|
15
15
|
type LocalKeyKmsAdapter,
|
|
16
16
|
PII_ERASED_SENTINEL,
|
|
17
|
+
type SubjectId,
|
|
17
18
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
18
19
|
import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
19
20
|
import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -71,6 +72,15 @@ const DEFAULT_CACHE_MAX_ENTRIES = 10_000;
|
|
|
71
72
|
// to DB-lookup if actually needed.
|
|
72
73
|
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
73
74
|
|
|
75
|
+
// Run rows hang on SYSTEM_TENANT_ID and a null trigger is a cron/system run;
|
|
76
|
+
// job-run-table.ts declares whenAbsent: "tenant" for exactly that case, so the
|
|
77
|
+
// logger must resolve the same subject the entity chain would (fw#3654).
|
|
78
|
+
function jobRunSubject(triggeredById: string | null): SubjectId {
|
|
79
|
+
return triggeredById === null
|
|
80
|
+
? { kind: "tenant", tenantId: SYSTEM_TENANT_ID }
|
|
81
|
+
: { kind: "user", userId: triggeredById };
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
// Same per-subject encryption as encryptFailureError/encryptStartedPayload,
|
|
75
85
|
// applied to a single value — shared so payload/message/error all go
|
|
76
86
|
// through the identical erased-key fallback. A key erased right before (or
|
|
@@ -81,14 +91,14 @@ const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
|
81
91
|
// whole run row.
|
|
82
92
|
async function encryptOrSentinel(
|
|
83
93
|
kms: LocalKeyKmsAdapter,
|
|
84
|
-
|
|
94
|
+
subject: SubjectId,
|
|
85
95
|
value: string,
|
|
86
96
|
field: string,
|
|
87
97
|
): Promise<string> {
|
|
88
98
|
try {
|
|
89
99
|
return await encryptPiiValueForSubject(
|
|
90
100
|
kms,
|
|
91
|
-
|
|
101
|
+
subject,
|
|
92
102
|
value,
|
|
93
103
|
{ requestId: "jobs:job-run-logger" },
|
|
94
104
|
field,
|
|
@@ -104,20 +114,20 @@ async function encryptOrSentinel(
|
|
|
104
114
|
// jobRunLogsTable — that table is unmanaged/direct-write, so there is no
|
|
105
115
|
// event-piiFields catalog to lean on here either. "message" is the AAD
|
|
106
116
|
// field name and must match the field passed to decryptStoredPii on read
|
|
107
|
-
// (detail.query.ts) or decrypt fails loud. Same skip
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
117
|
+
// (detail.query.ts) or decrypt fails loud. Same skip rule as the payload:
|
|
118
|
+
// absent KMS (rollout mode) stays plaintext. Bounded concurrency (#2307) —
|
|
119
|
+
// unbounded Promise.all would fire one getOrCreateDek per log line at once
|
|
120
|
+
// against the KMS adapter's pool.
|
|
111
121
|
async function encryptLogMessages<T extends { readonly message: string }>(
|
|
112
122
|
logs: readonly T[],
|
|
113
123
|
triggeredById: string | null,
|
|
114
124
|
): Promise<T[]> {
|
|
115
|
-
if (triggeredById === null) return [...logs];
|
|
116
125
|
const kms = configuredPiiSubjectKms();
|
|
117
126
|
if (!kms) return [...logs];
|
|
127
|
+
const subject = jobRunSubject(triggeredById);
|
|
118
128
|
return mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => ({
|
|
119
129
|
...log,
|
|
120
|
-
message: await encryptOrSentinel(kms,
|
|
130
|
+
message: await encryptOrSentinel(kms, subject, log.message, "message"),
|
|
121
131
|
}));
|
|
122
132
|
}
|
|
123
133
|
|
|
@@ -131,29 +141,27 @@ export async function encryptFailureError(
|
|
|
131
141
|
error: string,
|
|
132
142
|
triggeredById: string | null,
|
|
133
143
|
): Promise<string> {
|
|
134
|
-
if (triggeredById === null) return error;
|
|
135
144
|
const kms = configuredPiiSubjectKms();
|
|
136
145
|
if (!kms) return error;
|
|
137
|
-
return encryptOrSentinel(kms, triggeredById, error, "error");
|
|
146
|
+
return encryptOrSentinel(kms, jobRunSubject(triggeredById), error, "error");
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
// The run-started payload can carry arbitrary user data; triggeredById
|
|
141
150
|
// names its owning user. No event-PII catalog involved (#2243 removed the
|
|
142
151
|
// jobRun r.defineEvent registrations, so there is nothing to catalog) —
|
|
143
152
|
// the subject is known statically, so we encrypt directly. A null subject
|
|
144
|
-
// (system cron runs, recipient-less triggers)
|
|
145
|
-
//
|
|
146
|
-
// rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
|
|
153
|
+
// (system cron runs, recipient-less triggers) encrypts under the system
|
|
154
|
+
// tenant key. Absent KMS adapter stays plaintext (rollout mode, unchanged).
|
|
147
155
|
// A key already erased by the time the trigger lands (#2307) must not fail
|
|
148
156
|
// the whole run-start write — sentinel like the log/error paths.
|
|
149
157
|
async function encryptStartedPayload(
|
|
150
158
|
payload: string | null,
|
|
151
159
|
triggeredById: string | null,
|
|
152
160
|
): Promise<string | null> {
|
|
153
|
-
if (payload === null
|
|
161
|
+
if (payload === null) return payload;
|
|
154
162
|
const kms = configuredPiiSubjectKms();
|
|
155
163
|
if (!kms) return payload;
|
|
156
|
-
return encryptOrSentinel(kms, triggeredById, payload, "payload");
|
|
164
|
+
return encryptOrSentinel(kms, jobRunSubject(triggeredById), payload, "payload");
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
@@ -46,11 +46,18 @@ export const jobRunEntity = createEntity({
|
|
|
46
46
|
status: createTextField({ required: true, personal: false, reason: "system_metadata" }),
|
|
47
47
|
// The run-started payload can carry arbitrary caller-supplied data,
|
|
48
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
|
-
|
|
49
|
+
// jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`;
|
|
50
|
+
// cron runs have none and use the system tenant key (whenAbsent).
|
|
51
|
+
payload: createTextField({
|
|
52
|
+
personal: { of: "triggeredById", whenAbsent: "tenant" },
|
|
53
|
+
find: "none",
|
|
54
|
+
}),
|
|
51
55
|
// Failure messages can echo payload content back — same subject/DEK as
|
|
52
56
|
// `payload` (job-run-logger.ts encryptFailureError).
|
|
53
|
-
error: createTextField({
|
|
57
|
+
error: createTextField({
|
|
58
|
+
personal: { of: "triggeredById", whenAbsent: "tenant" },
|
|
59
|
+
find: "none",
|
|
60
|
+
}),
|
|
54
61
|
attempt: createNumberField({ required: true, default: 1, integer: true }),
|
|
55
62
|
startedAt: createTimestampField({ required: true }),
|
|
56
63
|
finishedAt: createTimestampField(),
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.358.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "forget-subject shreds note bodies even when the host entity has anonymize or blockDelete retention",
|
|
6
|
+
"detail": "Note bodies that @-mention the forgotten subject or hang on it as host entity were skipped when the host entity's retention strategy was anonymize or blockDelete, so the note text stayed readable while the host's own PII fields were anonymized. The note text is free-form PII, and a retention strategy protects the row, not its content, so these bodies are now always crypto-shredded. This applies to the manual `forget-subject` command and to the automated Art. 17 delete hook."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -26,17 +26,18 @@ import {
|
|
|
26
26
|
//
|
|
27
27
|
// `body` is instead `personal: { of: "id" }` (Row-Subject, fw#2596/#2786):
|
|
28
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
|
|
31
|
-
//
|
|
32
|
-
//
|
|
29
|
+
// per-note key that nothing else shares. That key gets erased only when the
|
|
30
|
+
// note is reached by a forgotten subject: `note-mention` (below) names the
|
|
31
|
+
// subject, or the note hangs on the subject as host (entityId, plus entityType
|
|
32
|
+
// for record subjects). See `notes-history-user-data/hooks.ts`; any other note
|
|
33
|
+
// is untouched. GDPR erasure of the AUTHOR still separately
|
|
33
34
|
// shreds `authorName` (`personal: { of: "authorId" }`, the name as stamped at
|
|
34
35
|
// write time); the two erasures are independent.
|
|
35
36
|
//
|
|
36
37
|
// Known gap (fw#2596, not fixed by this entity): prose that names a third
|
|
37
38
|
// party without a structured @-mention is not tracked — regex/NLP over
|
|
38
|
-
// free text is unreliable.
|
|
39
|
-
//
|
|
39
|
+
// free text is unreliable. An operator must erase such a note's own record
|
|
40
|
+
// subject (`forget-subject` kind "record", entity "note-entry") to cover it.
|
|
40
41
|
export function createNoteEntryEntity(
|
|
41
42
|
access?: EntityDefinition["access"],
|
|
42
43
|
parents?: readonly string[],
|
|
@@ -76,9 +77,10 @@ export function createNoteEntryEntity(
|
|
|
76
77
|
}),
|
|
77
78
|
// Stamped once at write time, never re-resolved later — the history
|
|
78
79
|
// needs the name as it was then, not whatever the account is called now.
|
|
80
|
+
// whenAbsent: authorless notes (system-written) encrypt under the tenant key.
|
|
79
81
|
authorName: createTextField({
|
|
80
82
|
maxLength: 200,
|
|
81
|
-
personal: { of: "authorId" },
|
|
83
|
+
personal: { of: "authorId", whenAbsent: "tenant" },
|
|
82
84
|
find: "none",
|
|
83
85
|
}),
|
|
84
86
|
body: createLongTextField({
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
|
|
5
5
|
// export-only, erasure via crypto-shredding.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
|
|
9
|
+
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type {
|
|
11
|
+
TenantId,
|
|
12
|
+
UserDataDeleteHook,
|
|
13
|
+
UserDataExportHook,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
15
|
import { noteEntryTable, noteMentionTable } from "../notes-history/index.js";
|
|
14
|
-
import { policyToStrategy } from "../user-data-rights/index.js";
|
|
15
16
|
|
|
16
17
|
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
17
18
|
// per-user content, so the export filters by authorId directly.
|
|
@@ -32,95 +33,102 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
|
32
33
|
};
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
type NoteRow = {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly tenantId: TenantId;
|
|
39
|
+
readonly entityType: string;
|
|
40
|
+
readonly entityId: string;
|
|
41
|
+
readonly body: unknown;
|
|
42
|
+
readonly insertedAt: unknown;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// What erasing a subject's key reaches: notes that @-mention the subject plus notes
|
|
46
|
+
// attached to it as host. Export and erasure both go through this, so Art. 15
|
|
47
|
+
// lists exactly what Art. 17 shreds.
|
|
48
|
+
async function selectNotesReachingSubject(
|
|
49
|
+
db: DbRunner | TenantDb,
|
|
50
|
+
subject: SubjectId,
|
|
51
|
+
): Promise<readonly NoteRow[]> {
|
|
52
|
+
if (subject.kind === "tenant") return [];
|
|
53
|
+
const subjectId = subject.kind === "user" ? subject.userId : subject.id;
|
|
54
|
+
const attachedWhere =
|
|
55
|
+
subject.kind === "record"
|
|
56
|
+
? { entityType: subject.entity, entityId: subjectId }
|
|
57
|
+
: { entityId: subjectId };
|
|
58
|
+
|
|
59
|
+
const mentions = await selectMany<{ noteId: string }>(db, noteMentionTable, { subjectId });
|
|
60
|
+
const mentioned =
|
|
61
|
+
mentions.length === 0
|
|
62
|
+
? []
|
|
63
|
+
: await selectMany<NoteRow>(db, noteEntryTable, {
|
|
64
|
+
id: { in: [...new Set(mentions.map((m) => m.noteId))] },
|
|
65
|
+
});
|
|
66
|
+
const attached = await selectMany<NoteRow>(db, noteEntryTable, attachedWhere);
|
|
67
|
+
|
|
68
|
+
const byId = new Map<string, NoteRow>();
|
|
69
|
+
for (const note of [...mentioned, ...attached]) byId.set(note.id, note);
|
|
70
|
+
return [...byId.values()];
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
// `authorName` is separately annotated `personal: { of: "authorId" }`
|
|
36
74
|
// (entity.ts), so forgetting the AUTHOR already crypto-shreds that field on
|
|
37
|
-
// its own
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// that's the documented gap in entity.ts, not a bug here.
|
|
75
|
+
// its own. `body` is Row-Subject (`personal: { of: "id" }`): its key is per
|
|
76
|
+
// note, so forgetting a subject only reaches a body when the note structurally
|
|
77
|
+
// @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
|
|
78
|
+
// Free-text mentions are NOT detected — the documented gap in entity.ts.
|
|
42
79
|
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// other entity. The `strategy` this hook itself is called with is resolved
|
|
47
|
-
// for note-entry, not the host, so it is deliberately unused here.
|
|
80
|
+
// Reached bodies are shredded regardless of the host entity's retention strategy:
|
|
81
|
+
// the note text is free-form PII, and a host's anonymize/blockDelete protects the
|
|
82
|
+
// row, not its content, just like the host's own PII fields get anonymized.
|
|
48
83
|
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
84
|
+
// Without a mounted KMS this erases nothing: record-owned fields fall back to
|
|
85
|
+
// plaintext framework-wide (see pii-field-encryption.ts), so apps that need
|
|
86
|
+
// Art. 17 coverage must mount one.
|
|
87
|
+
export async function eraseNoteBodiesReachingSubject(args: {
|
|
88
|
+
readonly db: DbRunner | TenantDb;
|
|
89
|
+
readonly subject: SubjectId;
|
|
90
|
+
readonly erasedBy: string;
|
|
91
|
+
readonly requestId: string;
|
|
92
|
+
}): Promise<void> {
|
|
56
93
|
const kms = configuredPiiSubjectKms();
|
|
57
|
-
// skip: no KMS adapter mounted —
|
|
94
|
+
// skip: no KMS adapter mounted — forget is a true no-op
|
|
58
95
|
if (!kms) return;
|
|
59
96
|
|
|
60
|
-
const
|
|
61
|
-
subjectId: ctx.userId,
|
|
62
|
-
});
|
|
63
|
-
// skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
|
|
64
|
-
if (mentions.length === 0) return;
|
|
65
|
-
|
|
66
|
-
const noteIds = new Set(mentions.map((m) => m.noteId));
|
|
67
|
-
// Once per hook run: the preset depends on the tenant only, not the note.
|
|
68
|
-
const tenantPreset = await resolveTenantRetentionPreset({
|
|
69
|
-
db: ctx.db,
|
|
70
|
-
registry: ctx.registry,
|
|
71
|
-
tenantId: ctx.tenantId,
|
|
72
|
-
});
|
|
73
|
-
// Append-only rows are never hard-deleted, so every mentioned note should
|
|
74
|
-
// resolve; a missing one has no host and thus no retention policy to consult.
|
|
75
|
-
const notes = await ctx.db.selectMany<{ id: string; entityType: string }>(noteEntryTable, {
|
|
76
|
-
id: { in: [...noteIds] },
|
|
77
|
-
});
|
|
78
|
-
const strategyByEntityType = new Map<string, ReturnType<typeof policyToStrategy>>();
|
|
97
|
+
const notes = await selectNotesReachingSubject(args.db, args.subject);
|
|
79
98
|
for (const note of notes) {
|
|
80
|
-
let strategy = strategyByEntityType.get(note.entityType);
|
|
81
|
-
if (strategy === undefined) {
|
|
82
|
-
const hostPolicy = await resolveRetentionPolicyForTenant({
|
|
83
|
-
db: ctx.db,
|
|
84
|
-
registry: ctx.registry,
|
|
85
|
-
tenantId: ctx.tenantId,
|
|
86
|
-
entityName: note.entityType,
|
|
87
|
-
preloadedTenantPreset: tenantPreset,
|
|
88
|
-
});
|
|
89
|
-
strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
|
|
90
|
-
strategyByEntityType.set(note.entityType, strategy);
|
|
91
|
-
}
|
|
92
|
-
if (strategy === "anonymize") continue;
|
|
93
|
-
const noteId = note.id;
|
|
94
|
-
|
|
95
99
|
await kms.eraseKey(
|
|
96
|
-
{ kind: "record", entity: "note-entry", id:
|
|
100
|
+
{ kind: "record", entity: "note-entry", id: note.id },
|
|
97
101
|
{
|
|
98
|
-
requestId:
|
|
99
|
-
userId:
|
|
100
|
-
eraseReason: "notes-history:
|
|
102
|
+
requestId: args.requestId,
|
|
103
|
+
userId: args.erasedBy,
|
|
104
|
+
eraseReason: "notes-history:subject-forget",
|
|
101
105
|
},
|
|
102
106
|
);
|
|
103
107
|
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
111
|
+
await eraseNoteBodiesReachingSubject({
|
|
112
|
+
db: ctx.db,
|
|
113
|
+
subject: { kind: "user", userId: ctx.userId },
|
|
114
|
+
erasedBy: ctx.userId,
|
|
115
|
+
requestId: "notes-history-user-data:forget-reached-notes",
|
|
116
|
+
});
|
|
104
117
|
};
|
|
105
118
|
|
|
106
|
-
// Erasure reaches every note that
|
|
107
|
-
//
|
|
119
|
+
// Erasure reaches every note that mentions the user or hangs on the user as host
|
|
120
|
+
// (see eraseNoteBodiesReachingSubject), so the Art. 15/20 export lists those too.
|
|
108
121
|
export const noteMentionExportHook: UserDataExportHook = async (ctx) => {
|
|
109
|
-
const
|
|
110
|
-
subjectId: ctx.userId,
|
|
111
|
-
});
|
|
112
|
-
if (mentions.length === 0) return null;
|
|
113
|
-
const noteIds = [...new Set(mentions.map((m) => m.noteId))];
|
|
114
|
-
const notes = await ctx.db.selectMany(noteEntryTable, { id: { in: noteIds } });
|
|
122
|
+
const notes = await selectNotesReachingSubject(ctx.db, { kind: "user", userId: ctx.userId });
|
|
115
123
|
if (notes.length === 0) return null;
|
|
116
124
|
return {
|
|
117
125
|
entity: "note-mention",
|
|
118
126
|
rows: notes.map((note) => ({
|
|
119
|
-
noteId: note
|
|
120
|
-
entityType: note
|
|
121
|
-
entityId: note
|
|
122
|
-
body: note
|
|
123
|
-
insertedAt: note
|
|
127
|
+
noteId: note.id,
|
|
128
|
+
entityType: note.entityType,
|
|
129
|
+
entityId: note.entityId,
|
|
130
|
+
body: note.body,
|
|
131
|
+
insertedAt: note.insertedAt,
|
|
124
132
|
})),
|
|
125
133
|
};
|
|
126
134
|
};
|
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
noteMentionExportHook,
|
|
14
14
|
} from "./hooks.js";
|
|
15
15
|
|
|
16
|
+
export { eraseNoteBodiesReachingSubject } from "./hooks.js";
|
|
17
|
+
|
|
16
18
|
export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
|
|
17
19
|
r.describe(
|
|
18
|
-
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook
|
|
20
|
+
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (regardless of the host entity's retention strategy: the note text is free PII, the host's anonymize/blockDelete protects the row, not the content), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
|
|
19
21
|
);
|
|
20
22
|
// user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
|
|
21
23
|
// is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
|
|
@@ -60,11 +60,13 @@ export const patListScreen: ProjectionListScreenDefinition = {
|
|
|
60
60
|
{
|
|
61
61
|
field: "createdAt",
|
|
62
62
|
label: i18nKey("pat.list.col.created"),
|
|
63
|
+
hideOnNarrow: true,
|
|
63
64
|
renderer: { format: "timestamp" },
|
|
64
65
|
},
|
|
65
66
|
{
|
|
66
67
|
field: "expiresAt",
|
|
67
68
|
label: i18nKey("pat.list.col.expires"),
|
|
69
|
+
hideOnNarrow: true,
|
|
68
70
|
renderer: { format: "timestamp" },
|
|
69
71
|
},
|
|
70
72
|
],
|
|
@@ -48,6 +48,7 @@ export const createSessionListScreen = (
|
|
|
48
48
|
{
|
|
49
49
|
field: "revokedAt",
|
|
50
50
|
label: i18nKey("sessions.list.col.revokedAt"),
|
|
51
|
+
hideOnNarrow: true,
|
|
51
52
|
renderer: { format: "timestamp" },
|
|
52
53
|
},
|
|
53
54
|
],
|
|
@@ -123,6 +124,7 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
|
123
124
|
{
|
|
124
125
|
field: "current",
|
|
125
126
|
label: i18nKey("sessions.mine.col.current"),
|
|
127
|
+
hideOnNarrow: true,
|
|
126
128
|
renderer: { format: "boolean" },
|
|
127
129
|
},
|
|
128
130
|
],
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The member roles form only offers roles the signed-in admin may assign",
|
|
6
|
+
"detail": "New query tenant:query:member-role-availability; TenantAdmin still sees every role."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -26,9 +26,10 @@ export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
|
|
|
26
26
|
// Admin (rank 2) shares the invite-create screen with TenantAdmin — TenantAdmin
|
|
27
27
|
// must not appear here or Admin sees an unassignable option (fw#2414).
|
|
28
28
|
export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"] as const;
|
|
29
|
-
// Full
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// Full rank list for memberRolesEditScreen: it is prefilled with a member's
|
|
30
|
+
// *current* roles, so it must represent every rank a membership can hold,
|
|
31
|
+
// including TenantAdmin (fw#2452). The screen hides what the caller may not
|
|
32
|
+
// assign via TenantQueries.memberRoleAvailability.
|
|
32
33
|
export const OWNER_INVITE_ROLE_OPTIONS = [
|
|
33
34
|
"User",
|
|
34
35
|
"Member",
|
|
@@ -67,6 +68,8 @@ export const TenantQueries = {
|
|
|
67
68
|
invitations: "tenant:query:invitations",
|
|
68
69
|
// Combined active-members + pending-invitations list backing /members.
|
|
69
70
|
teamList: "tenant:query:team:list",
|
|
71
|
+
// Per-role assignability for the caller, mirrors the rank rule of updateMemberRoles.
|
|
72
|
+
memberRoleAvailability: "tenant:query:member-role-availability",
|
|
70
73
|
} as const;
|
|
71
74
|
|
|
72
75
|
// Error codes
|
|
@@ -20,6 +20,7 @@ import { invitationsQuery } from "./handlers/invitations.query.js";
|
|
|
20
20
|
import { listQuery } from "./handlers/list.query.js";
|
|
21
21
|
import { meQuery } from "./handlers/me.query.js";
|
|
22
22
|
import { memberDirectoryQuery } from "./handlers/member-directory.query.js";
|
|
23
|
+
import { memberRoleAvailabilityQuery } from "./handlers/member-role-availability.query.js";
|
|
23
24
|
import { membersQuery } from "./handlers/members.query.js";
|
|
24
25
|
import { membershipsQuery } from "./handlers/memberships.query.js";
|
|
25
26
|
import { removeMemberWrite } from "./handlers/remove-member.write.js";
|
|
@@ -166,6 +167,7 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
|
|
|
166
167
|
resolveUserIds: r.queryHandler(resolveUserIdsQuery),
|
|
167
168
|
invitations: r.queryHandler(invitationsQuery),
|
|
168
169
|
teamList: r.queryHandler(teamListQuery),
|
|
170
|
+
memberRoleAvailability: r.queryHandler(memberRoleAvailabilityQuery),
|
|
169
171
|
};
|
|
170
172
|
|
|
171
173
|
// Entity-convention handlers for the SystemAdmin entityList/entityEdit
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
assignableAppRolesOf,
|
|
4
|
+
canActorAssignRole,
|
|
5
|
+
defineQueryHandler,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { memberRoleOptions } from "../member-role-options.js";
|
|
9
|
+
|
|
10
|
+
export const memberRoleAvailabilityQuery = defineQueryHandler({
|
|
11
|
+
name: "member-role-availability",
|
|
12
|
+
schema: z.object({}),
|
|
13
|
+
access: { roles: access.admin },
|
|
14
|
+
description:
|
|
15
|
+
"Lists every role the member roles form offers and whether the caller's own rank may assign it; the form hides the roles marked disabled.",
|
|
16
|
+
handler: async (query, ctx) => {
|
|
17
|
+
const assignableAppRoles = assignableAppRolesOf(ctx.registry);
|
|
18
|
+
return {
|
|
19
|
+
rows: memberRoleOptions(assignableAppRoles).map((value) => ({
|
|
20
|
+
value,
|
|
21
|
+
disabled: !canActorAssignRole(query.user.roles, value, assignableAppRoles),
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AssignableAppRoles } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { OWNER_INVITE_ROLE_OPTIONS } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
export function memberRoleOptions(assignableAppRoles: AssignableAppRoles): readonly string[] {
|
|
5
|
+
return [...OWNER_INVITE_ROLE_OPTIONS, ...assignableAppRoles.keys()];
|
|
6
|
+
}
|
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
16
16
|
MEMBER_STATUS_CELL_COMPONENT,
|
|
17
17
|
MEMBERS_SCREEN_ID,
|
|
18
|
-
OWNER_INVITE_ROLE_OPTIONS,
|
|
19
18
|
TenantHandlers,
|
|
20
19
|
TenantQueries,
|
|
21
20
|
} from "./constants.js";
|
|
21
|
+
import { memberRoleOptions } from "./member-role-options.js";
|
|
22
22
|
|
|
23
23
|
// Cross-tenant SystemAdmin platform view of the tenants themselves. The tenant
|
|
24
24
|
// feature runs with `r.systemScope()`, so the entityList returns every tenant.
|
|
@@ -107,6 +107,7 @@ export function createMembersScreen(options?: {
|
|
|
107
107
|
field: "lastSeenAt",
|
|
108
108
|
label: i18nKey("tenant.members.col.lastActivity"),
|
|
109
109
|
renderer: { format: "timestamp" },
|
|
110
|
+
hideOnNarrow: true,
|
|
110
111
|
},
|
|
111
112
|
],
|
|
112
113
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
@@ -199,13 +200,15 @@ export function createMemberRolesEditScreen(assignableAppRoles: AssignableAppRol
|
|
|
199
200
|
cancelTarget: MEMBERS_SCREEN_ID,
|
|
200
201
|
fields: {
|
|
201
202
|
userId: { type: "text", required: true },
|
|
202
|
-
// Prefilled with the member's current roles, so the
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
203
|
+
// Prefilled with the member's current roles, so the static options must
|
|
204
|
+
// represent every rank (DEFAULT_INVITE_ROLE_OPTIONS omits TenantAdmin and
|
|
205
|
+
// would silently strip it on submit); the availability query hides what the
|
|
206
|
+
// caller may not assign. Enforcement stays server-side via
|
|
207
|
+
// findForbiddenRoleAssignment (update-member-roles.write.ts).
|
|
206
208
|
roles: {
|
|
207
209
|
type: "multiSelect",
|
|
208
|
-
options:
|
|
210
|
+
options: memberRoleOptions(assignableAppRoles),
|
|
211
|
+
optionsAvailabilityQuery: TenantQueries.memberRoleAvailability,
|
|
209
212
|
required: true,
|
|
210
213
|
},
|
|
211
214
|
},
|
|
@@ -17,7 +17,14 @@ export const userListScreen: EntityListScreenDefinition = {
|
|
|
17
17
|
id: "user-list",
|
|
18
18
|
type: "entityList",
|
|
19
19
|
entity: "user",
|
|
20
|
-
columns: [
|
|
20
|
+
columns: [
|
|
21
|
+
"email",
|
|
22
|
+
"displayName",
|
|
23
|
+
"roles",
|
|
24
|
+
"tenants",
|
|
25
|
+
"status",
|
|
26
|
+
{ field: "emailVerified", hideOnNarrow: true },
|
|
27
|
+
],
|
|
21
28
|
rowActions: [
|
|
22
29
|
{
|
|
23
30
|
kind: "navigate",
|
|
@@ -68,7 +68,14 @@ export const downloadAttemptListScreen: EntityListScreenDefinition = {
|
|
|
68
68
|
id: "download-attempt-list",
|
|
69
69
|
type: "entityList",
|
|
70
70
|
entity: "download-attempt",
|
|
71
|
-
columns: [
|
|
71
|
+
columns: [
|
|
72
|
+
"attemptedAt",
|
|
73
|
+
"result",
|
|
74
|
+
"via",
|
|
75
|
+
"ip",
|
|
76
|
+
{ field: "attemptedByUserId", hideOnNarrow: true },
|
|
77
|
+
{ field: "jobId", hideOnNarrow: true },
|
|
78
|
+
],
|
|
72
79
|
searchable: false,
|
|
73
80
|
access: { roles: access.systemAdmin },
|
|
74
81
|
// Inert until an app navs it (see file header) — no nav area to resolve
|
|
@@ -6,7 +6,14 @@ export const waitlistListScreen: EntityListScreenDefinition = {
|
|
|
6
6
|
id: "waitlist-list",
|
|
7
7
|
type: "entityList",
|
|
8
8
|
entity: "waitlistEntry",
|
|
9
|
-
columns: [
|
|
9
|
+
columns: [
|
|
10
|
+
"name",
|
|
11
|
+
"email",
|
|
12
|
+
"company",
|
|
13
|
+
"status",
|
|
14
|
+
"locale",
|
|
15
|
+
{ field: "submittedAt", hideOnNarrow: true },
|
|
16
|
+
],
|
|
10
17
|
defaultSort: { field: "submittedAt", dir: "desc" },
|
|
11
18
|
access: adminAccess,
|
|
12
19
|
searchable: true,
|
|
@@ -116,10 +116,10 @@ export const assetListScreen: EntityListScreenDefinition = {
|
|
|
116
116
|
"status",
|
|
117
117
|
"department",
|
|
118
118
|
"owner",
|
|
119
|
-
"location",
|
|
120
|
-
"vendor",
|
|
121
|
-
"purchaseDate",
|
|
122
|
-
"warrantyUntil",
|
|
119
|
+
{ field: "location", hideOnNarrow: true },
|
|
120
|
+
{ field: "vendor", hideOnNarrow: true },
|
|
121
|
+
{ field: "purchaseDate", hideOnNarrow: true },
|
|
122
|
+
{ field: "warrantyUntil", hideOnNarrow: true },
|
|
123
123
|
],
|
|
124
124
|
pageSize: 25,
|
|
125
125
|
defaultSort: { field: "name", dir: "asc" },
|
|
@@ -123,10 +123,10 @@ export const ticketListScreen: EntityListScreenDefinition = {
|
|
|
123
123
|
"severity",
|
|
124
124
|
"status",
|
|
125
125
|
"department",
|
|
126
|
-
"assignee",
|
|
127
|
-
"reporter",
|
|
128
|
-
"dueDate",
|
|
129
|
-
"spentMinutes",
|
|
126
|
+
{ field: "assignee", hideOnNarrow: true },
|
|
127
|
+
{ field: "reporter", hideOnNarrow: true },
|
|
128
|
+
{ field: "dueDate", hideOnNarrow: true },
|
|
129
|
+
{ field: "spentMinutes", hideOnNarrow: true },
|
|
130
130
|
],
|
|
131
131
|
pageSize: 25,
|
|
132
132
|
defaultSort: { field: "severity", dir: "desc" },
|