@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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  4. package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
  5. package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
  6. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +71 -21
  7. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +99 -0
  8. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +143 -43
  9. package/packages/bundled-features/src/cap-counter/changes.json +7 -0
  10. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +18 -32
  11. package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
  12. package/packages/bundled-features/src/cap-counter/index.ts +5 -0
  13. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -25
  14. package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
  15. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  16. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +15 -1
  17. package/packages/bundled-features/src/delivery/changes.json +6 -0
  18. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
  19. package/packages/bundled-features/src/delivery/constants.ts +5 -0
  20. package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
  21. package/packages/bundled-features/src/delivery/feature.ts +7 -1
  22. package/packages/bundled-features/src/delivery/jobs.ts +27 -1
  23. package/packages/bundled-features/src/delivery/testing.ts +1 -0
  24. package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -15
  25. package/packages/bundled-features/src/jobs/job-run-table.ts +10 -3
  26. package/packages/bundled-features/src/notes-history/changes.json +6 -0
  27. package/packages/bundled-features/src/notes-history/entity.ts +9 -7
  28. package/packages/bundled-features/src/notes-history-user-data/hooks.ts +84 -76
  29. package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
  30. package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
  31. package/packages/bundled-features/src/sessions/screens.ts +2 -0
  32. package/packages/bundled-features/src/tenant/changes.json +6 -0
  33. package/packages/bundled-features/src/tenant/constants.ts +6 -3
  34. package/packages/bundled-features/src/tenant/feature.ts +2 -0
  35. package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
  36. package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
  37. package/packages/bundled-features/src/tenant/screens.ts +9 -6
  38. package/packages/bundled-features/src/user/screens.ts +8 -1
  39. package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
  40. package/packages/bundled-features/src/waitlist/screens.ts +8 -1
  41. package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
  42. package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
  43. package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
  44. package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
  45. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  46. package/samples/apps/styleguide/src/features/widgets/feature.ts +1 -0
  47. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
  48. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
  49. 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
- subjectUserId: string,
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
- { kind: "user", userId: subjectUserId },
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 rules as the payload:
108
- // null subject (system/cron runs) and absent KMS (rollout mode) both stay
109
- // plaintext. Bounded concurrency (#2307) — unbounded Promise.all would fire
110
- // one getOrCreateDek per log line at once against the KMS adapter's pool.
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, triggeredById, log.message, "message"),
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) stays plaintext: there is no
145
- // user key to shred, mirroring the previous event-pii catalog's own skip
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 || triggeredById === null) return payload;
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
- payload: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
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({ personal: { of: "triggeredById" }, find: "none" }),
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 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
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. The operator's manual `forget-subject` path
39
- // covers what this structured trigger misses.
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 { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
8
- import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
9
- import {
10
- resolveRetentionPolicyForTenant,
11
- resolveTenantRetentionPreset,
12
- } from "../data-retention/index.js";
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 — unrelated to this hook. `body` is Row-Subject (`personal: { of:
38
- // "id" }`): its key is per-note, not per-user, so forgetting a user only
39
- // reaches a note's body when that note structurally @-mentions the user via
40
- // a `note-mention` row (fw#2787). Free-text mentions are NOT detected —
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
- // Per reached note: consult the HOST entity's (entityType, not note-entry's
44
- // own) retention strategy first — blockDelete/anonymize must win over
45
- // erasure, same rule the automated forget-cleanup pipeline applies to every
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
- // Precondition: this ONLY erases anything if the app mounts a KMS adapter —
50
- // without one, record-owned fields fall back to plaintext storage
51
- // framework-wide (see pii-field-encryption.ts) and forget is a true no-op.
52
- // That gap is a property of the framework's crypto-shredding design, not
53
- // specific to this hook; apps that need Art.17 coverage without KMS must
54
- // mount one.
55
- export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
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 — record-owned fields stay plaintext framework-wide, forget is a true no-op
94
+ // skip: no KMS adapter mounted — forget is a true no-op
58
95
  if (!kms) return;
59
96
 
60
- const mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
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: noteId },
100
+ { kind: "record", entity: "note-entry", id: note.id },
97
101
  {
98
- requestId: "notes-history-user-data:forget-mentioned-notes",
99
- userId: ctx.userId,
100
- eraseReason: "notes-history:mention-forget",
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 structurally mentions the user (see
107
- // noteEntryDeleteHook), so the Art. 15/20 export must list those notes too.
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 mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
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["id"],
120
- entityType: note["entityType"],
121
- entityId: note["entityId"],
122
- body: note["body"],
123
- insertedAt: note["insertedAt"],
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 looks up `note-mention` for notes that structurally @-mention the forgotten user and crypto-shreds each reached note's row-subject key (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for notes without such a 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`.",
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 assignable rank list, used by memberRolesEditScreen: that screen is
30
- // prefilled with a member's *current* roles, so it must be able to represent
31
- // every rank a membership can actually hold, including TenantAdmin (fw#2452).
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 option list must be
203
- // able to represent every assignable rank — DEFAULT_INVITE_ROLE_OPTIONS
204
- // omits TenantAdmin and would silently strip it on submit. Escalation
205
- // still stays server-side via findForbiddenRoleAssignment (update-member-roles.write.ts).
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: [...OWNER_INVITE_ROLE_OPTIONS, ...assignableAppRoles.keys()],
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: ["email", "displayName", "roles", "tenants", "status", "emailVerified"],
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: ["attemptedAt", "result", "via", "ip", "attemptedByUserId", "jobId"],
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: ["name", "email", "company", "status", "locale", "submittedAt"],
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" },