@cosmicdrift/kumiko-samples 0.356.0 → 0.357.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 (38) 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/feature.ts +7 -1
  7. package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
  8. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  9. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +16 -1
  10. package/packages/bundled-features/src/delivery/changes.json +6 -0
  11. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
  12. package/packages/bundled-features/src/delivery/constants.ts +5 -0
  13. package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
  14. package/packages/bundled-features/src/delivery/feature.ts +7 -1
  15. package/packages/bundled-features/src/delivery/jobs.ts +27 -1
  16. package/packages/bundled-features/src/delivery/testing.ts +1 -0
  17. package/packages/bundled-features/src/notes-history/entity.ts +7 -6
  18. package/packages/bundled-features/src/notes-history-user-data/hooks.ts +109 -62
  19. package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
  20. package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
  21. package/packages/bundled-features/src/sessions/screens.ts +2 -0
  22. package/packages/bundled-features/src/tenant/changes.json +6 -0
  23. package/packages/bundled-features/src/tenant/constants.ts +6 -3
  24. package/packages/bundled-features/src/tenant/feature.ts +2 -0
  25. package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
  26. package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
  27. package/packages/bundled-features/src/tenant/screens.ts +9 -6
  28. package/packages/bundled-features/src/user/screens.ts +8 -1
  29. package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
  30. package/packages/bundled-features/src/waitlist/screens.ts +8 -1
  31. package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
  32. package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
  33. package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
  34. package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
  35. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  36. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
  37. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
  38. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.356.0",
3
+ "version": "0.357.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.356.0",
3
+ "version": "0.357.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "improvement",
5
+ "title": "MFA enable asks to confirm saved recovery codes, code field autocompletes one-time codes",
6
+ "detail": "secretMint `reveal.acknowledge` (i18n key) renders a checkbox on the reveal card that must be ticked before the confirm step or the acknowledge button can be used; the tick never reaches the payload. Text fields accept an `autoComplete` token that reaches the input. The MFA enable screen uses both: a \"saved my recovery codes\" gate and `one-time-code` on the code field"
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",
@@ -28,6 +28,7 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
28
28
  "mfa.enable.reveal.qr": { en: "Scan this QR code" },
29
29
  "mfa.enable.reveal.secret": { en: "Or enter this code manually" },
30
30
  "mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
31
+ "mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
31
32
 
32
33
  "mfa.enable.confirm.submit": { en: "Enable" },
33
34
  "mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
@@ -34,6 +34,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
34
34
  reveal: {
35
35
  title: i18nKey("mfa.enable.reveal.title"),
36
36
  warning: i18nKey("mfa.enable.reveal.warning"),
37
+ acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
37
38
  fields: [
38
39
  {
39
40
  field: "otpauthUri",
@@ -58,7 +59,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
58
59
  confirm: {
59
60
  handler: AuthMfaHandlers.enableConfirm,
60
61
  fields: {
61
- code: { type: "text", required: true, minLength: 6, maxLength: 6 },
62
+ code: {
63
+ type: "text",
64
+ required: true,
65
+ minLength: 6,
66
+ maxLength: 6,
67
+ autoComplete: "one-time-code",
68
+ },
62
69
  },
63
70
  layout: { sections: [{ fields: ["code"] }] },
64
71
  carry: ["setupToken"],
@@ -119,7 +119,13 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
119
119
  id: CAP_COUNTER_LIST_SCREEN_ID,
120
120
  type: "entityList",
121
121
  entity: "cap-counter",
122
- columns: ["tenantId", "capName", "value", "periodStart", "lastSoftWarnedAt"],
122
+ columns: [
123
+ "tenantId",
124
+ "capName",
125
+ "value",
126
+ "periodStart",
127
+ { field: "lastSoftWarnedAt", hideOnNarrow: true },
128
+ ],
123
129
  defaultSort: { field: "capName", dir: "asc" },
124
130
  // Search resolves against the caller's own tenant stream, so on this
125
131
  // cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
@@ -31,10 +31,12 @@ export function createTenantCapListScreen(
31
31
  { field: "name", label: "cap-overview.list.col.name" },
32
32
  { field: "tier", label: "cap-overview.list.col.tier" },
33
33
  { field: "billing", label: "cap-overview.list.col.billing" },
34
- ...listedCaps.map((cap) => ({
34
+ // Name is the title; tier, billing and the first cap fill the card's three meta rows.
35
+ ...listedCaps.map((cap, index) => ({
35
36
  field: capFieldName(cap.id),
36
37
  label: cap.label,
37
38
  renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
39
+ ...(index > 0 && { hideOnNarrow: true }),
38
40
  })),
39
41
  ],
40
42
  searchable: true,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "improvement",
5
+ "title": "forget-subject shreds note bodies attached to the subject and notes that mention it",
6
+ "detail": "`forget-subject` on a user or record subject now also erases the per-note keys of every `note-entry` that hangs on the subject as host entity (entityId, plus entityType for record subjects) or @-mentions it, unless the host entity's retention strategy is anonymize/blockDelete. The automated Art. 17 delete hook reaches notes on the user as host too, and the export lists them. Before, `forget-subject(contact)` left the note bodies readable. Consumer tests that expect readable note bodies after `forget-subject` on a contact (e.g. solon contact-notes-forget) now see the erased sentinel. Forgetting only the author still leaves the bodies readable."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -40,6 +40,7 @@ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/se
40
40
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
41
41
  import * as z from "zod";
42
42
  import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
43
+ import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
43
44
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
44
45
  import { USER_STATUS } from "../../user/index.js";
45
46
  import {
@@ -300,6 +301,7 @@ async function sweepDerivedSubjectData(
300
301
  subject: SubjectId,
301
302
  subjectKey: string,
302
303
  crossTenantRunner: DbRunner,
304
+ audit: { readonly requestId: string; readonly erasedBy: string },
303
305
  ): Promise<string | undefined> {
304
306
  try {
305
307
  await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
@@ -318,6 +320,17 @@ async function sweepDerivedSubjectData(
318
320
  subject,
319
321
  );
320
322
  }
323
+
324
+ // Note bodies are keyed per note, so the subject's own key never reaches them.
325
+ if (ctx.registry.features.has("notes-history")) {
326
+ await eraseNoteBodiesReachingSubject({
327
+ db: crossTenantSubjectRunner,
328
+ registry: ctx.registry,
329
+ subject,
330
+ erasedBy: audit.erasedBy,
331
+ requestId: audit.requestId,
332
+ });
333
+ }
321
334
  });
322
335
  return undefined;
323
336
  } catch (err) {
@@ -471,8 +484,9 @@ export const forgetSubjectWrite = defineWriteHandler({
471
484
  // but no event exists — a retry is a no-op erase plus the event. The
472
485
  // reverse order could leave an audit trail claiming a shred that never
473
486
  // happened.
487
+ const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
474
488
  await kms.eraseKey(subject, {
475
- requestId: requestContext.get()?.requestId ?? "crypto-shredding:forget-subject",
489
+ requestId,
476
490
  userId: event.user.id,
477
491
  eraseReason: event.payload.reason,
478
492
  });
@@ -482,6 +496,7 @@ export const forgetSubjectWrite = defineWriteHandler({
482
496
  subject,
483
497
  subjectKey,
484
498
  ctx.db.unsafeRaw(),
499
+ { requestId, erasedBy: event.user.id },
485
500
  );
486
501
 
487
502
  // User subject: close the login door. DEK-erase makes the passwordHash
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "improvement",
5
+ "title": "Delivery idempotencyKey is released when nothing was delivered",
6
+ "detail": "A retry of a failed notify() with the same idempotencyKey is no longer skipped as a duplicate. The key is deleted when no recipient got a `sent` or `queued` delivery, also when notify() throws; one successful recipient keeps it, even if notify() throws afterwards (a retry would re-send to them). For queued channels the send/render job deletes the key after its final failed attempt, not per retry. That reference is only attached when a `route` call has exactly one channel address; `to` calls (user lists) and multi-channel routes keep the key after queueing. The job payload gains an optional `idempotencyKey`, backward compatible. `createDeliveryTestContext` accepts `idempotencyRedis`."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",
@@ -57,7 +57,7 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
57
57
  function policyFor(request: ChatWebhookRequest, target: URL): EgressPolicy {
58
58
  return request.requireHttps
59
59
  ? { kind: "external" }
60
- : { kind: "internal", allowHosts: [target.hostname] };
60
+ : { kind: "internal", allowOrigins: [target.origin] };
61
61
  }
62
62
 
63
63
  function hostMatches(hostname: string, allowedHost: string): boolean {
@@ -26,6 +26,11 @@ export {
26
26
  type DeliveryStatusValue,
27
27
  } from "./public-names.js";
28
28
 
29
+ // Single source for claim (delivery-service) and terminal-failure release (jobs).
30
+ export function deliveryIdempotencyRedisKey(tenantId: string, key: string): string {
31
+ return `delivery:idem:${tenantId}:${key}`;
32
+ }
33
+
29
34
  // notify() priority → BullMQ job priority. Lower number = processed first; all
30
35
  // > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
31
36
  // FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
@@ -6,6 +6,7 @@ import type {
6
6
  EscapeHatchAuditSink,
7
7
  NotifyDelivery,
8
8
  NotifyJobDispatcher,
9
+ NotifyOptions,
9
10
  NotifyPriority,
10
11
  Registry,
11
12
  TenantId,
@@ -21,7 +22,12 @@ import { redactErrorText } from "../shared/index.js";
21
22
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
22
23
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
23
24
  import { buildChannelContext } from "./channel-context.js";
24
- import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
25
+ import {
26
+ DELIVERY_CHANNEL_EXTENSION,
27
+ DeliveryJobs,
28
+ deliveryIdempotencyRedisKey,
29
+ deliveryPriorityRank,
30
+ } from "./constants.js";
25
31
  import {
26
32
  type AddressOptOutKey,
27
33
  addressOptOutKey,
@@ -171,11 +177,43 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
171
177
  "Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured",
172
178
  );
173
179
  }
174
- const k = `delivery:idem:${tenantId}:${key}`;
175
- const res = await idemRedis.set(k, "1", "EX", ttlSec, "NX");
180
+ const res = await idemRedis.set(
181
+ deliveryIdempotencyRedisKey(tenantId, key),
182
+ "1",
183
+ "EX",
184
+ ttlSec,
185
+ "NX",
186
+ );
176
187
  return res === "OK";
177
188
  }
178
189
 
190
+ function isDeliveredOrQueued(delivery: NotifyDelivery): boolean {
191
+ return delivery.status === "sent" || delivery.status === "queued";
192
+ }
193
+
194
+ // A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
195
+ // Partial success keeps it: a retry would re-send to those who got it.
196
+ async function releaseIdempotencyIfNothingDelivered(
197
+ tenantId: TenantId,
198
+ key: string | undefined,
199
+ run: NotifyRun,
200
+ ): Promise<void> {
201
+ // skip: no key claimed, or a recipient already got the notification
202
+ if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
203
+ await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
204
+ }
205
+
206
+ // Only a single-channel route lets the queued job free the key on terminal failure
207
+ // without risking a duplicate send on another channel.
208
+ function jobIdempotencyKeyFor(
209
+ idempotencyKey: string | undefined,
210
+ route: NotifyOptions["route"],
211
+ ): string | undefined {
212
+ if (!idempotencyKey || !route) return undefined;
213
+ const routedChannelCount = channels.filter((channel) => route[channel.name]).length;
214
+ return routedChannelCount === 1 ? idempotencyKey : undefined;
215
+ }
216
+
179
217
  async function resolveUserIdsForTenant(tenantId: TenantId): Promise<readonly string[]> {
180
218
  if (!tenantUserIdsQuery) {
181
219
  throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
@@ -233,6 +271,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
233
271
  readonly deliveries: NotifyDelivery[];
234
272
  readonly jobDispatcher: NotifyJobDispatcher | undefined;
235
273
  readonly locale: string | undefined;
274
+ // Set only when exactly one channel carries the call: the queued job may then free
275
+ // the key on terminal failure without risking a duplicate send on another channel.
276
+ readonly jobIdempotencyKey: string | undefined;
236
277
  };
237
278
 
238
279
  function recordDelivery(
@@ -343,6 +384,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
343
384
  deliveryAttemptId,
344
385
  priority,
345
386
  message,
387
+ ...(run.jobIdempotencyKey !== undefined && { idempotencyKey: run.jobIdempotencyKey }),
346
388
  },
347
389
  { priority: deliveryPriorityRank[priority] },
348
390
  );
@@ -745,14 +787,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
745
787
  }
746
788
  }
747
789
 
790
+ async function dispatchNotification(
791
+ notificationType: string,
792
+ options: NotifyOptions,
793
+ tenantId: TenantId,
794
+ priority: NotifyPriority,
795
+ run: NotifyRun,
796
+ ): Promise<void> {
797
+ const { to, route, data } = options;
798
+ if (route) {
799
+ await deliverDirect(
800
+ route,
801
+ notificationType,
802
+ data,
803
+ tenantId,
804
+ priority,
805
+ options.recipientId ?? null,
806
+ run,
807
+ );
808
+ // skip: explicit route is delivered directly, no recipient resolution needed
809
+ return;
810
+ }
811
+
812
+ if (to !== undefined) {
813
+ let userIds: readonly string[];
814
+
815
+ if (typeof to === "string") {
816
+ userIds = [to];
817
+ } else if ("tenant" in to) {
818
+ userIds = await resolveUserIdsForTenant(to.tenant);
819
+ } else {
820
+ userIds = to;
821
+ }
822
+
823
+ const prefetchedAddresses =
824
+ priority === "critical"
825
+ ? new Map<string, string | null>()
826
+ : await prefetchUserAddresses(userIds, tenantId, notificationType);
827
+ const suppressed = await loadSuppressedAddresses(
828
+ tenantId,
829
+ notificationType,
830
+ priority,
831
+ [...prefetchedAddresses.values()].filter((address): address is string => !!address),
832
+ );
833
+
834
+ for (const userId of userIds) {
835
+ await deliverToUser(
836
+ userId,
837
+ notificationType,
838
+ data,
839
+ tenantId,
840
+ priority,
841
+ run,
842
+ prefetchedAddresses,
843
+ suppressed,
844
+ );
845
+ }
846
+ }
847
+ }
848
+
748
849
  return {
749
850
  async notify(notificationType, options, _user, tenantId, jobDispatcher) {
750
- const { to, route, data, idempotencyKey } = options;
851
+ const { route, idempotencyKey } = options;
751
852
  const priority: NotifyPriority = options.priority ?? "normal";
752
853
  const run: NotifyRun = {
753
854
  deliveries: [],
754
855
  jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
755
856
  locale: options.locale,
857
+ jobIdempotencyKey: jobIdempotencyKeyFor(idempotencyKey, route),
756
858
  };
757
859
 
758
860
  if (idempotencyKey) {
@@ -772,54 +874,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
772
874
  }
773
875
  }
774
876
 
775
- if (route) {
776
- await deliverDirect(
777
- route,
778
- notificationType,
779
- data,
780
- tenantId,
781
- priority,
782
- options.recipientId ?? null,
783
- run,
784
- );
785
- return { deliveries: run.deliveries };
786
- }
787
-
788
- if (to !== undefined) {
789
- let userIds: readonly string[];
790
-
791
- if (typeof to === "string") {
792
- userIds = [to];
793
- } else if ("tenant" in to) {
794
- userIds = await resolveUserIdsForTenant(to.tenant);
795
- } else {
796
- userIds = to;
797
- }
798
-
799
- const prefetchedAddresses =
800
- priority === "critical"
801
- ? new Map<string, string | null>()
802
- : await prefetchUserAddresses(userIds, tenantId, notificationType);
803
- const suppressed = await loadSuppressedAddresses(
804
- tenantId,
805
- notificationType,
806
- priority,
807
- [...prefetchedAddresses.values()].filter((address): address is string => !!address),
877
+ try {
878
+ await dispatchNotification(notificationType, options, tenantId, priority, run);
879
+ } catch (err) {
880
+ // A failing release must not replace the dispatch error the caller retries on.
881
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
882
+ (releaseErr: unknown) => {
883
+ logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
884
+ notificationType,
885
+ });
886
+ },
808
887
  );
809
-
810
- for (const userId of userIds) {
811
- await deliverToUser(
812
- userId,
813
- notificationType,
814
- data,
815
- tenantId,
816
- priority,
817
- run,
818
- prefetchedAddresses,
819
- suppressed,
820
- );
821
- }
888
+ throw err;
822
889
  }
890
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
823
891
  return { deliveries: run.deliveries };
824
892
  },
825
893
  };
@@ -247,15 +247,21 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
247
247
  label: i18nKey("delivery.log.col.channel"),
248
248
  renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
249
249
  },
250
- { field: "recipientLabel", label: i18nKey("delivery.log.col.recipient") },
250
+ {
251
+ field: "recipientLabel",
252
+ label: i18nKey("delivery.log.col.recipient"),
253
+ hideOnNarrow: true,
254
+ },
251
255
  {
252
256
  field: "status",
253
257
  label: i18nKey("delivery.log.col.status"),
258
+ hideOnNarrow: true,
254
259
  renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
255
260
  },
256
261
  {
257
262
  field: "error",
258
263
  label: i18nKey("delivery.log.col.error"),
264
+ hideOnNarrow: true,
259
265
  renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
260
266
  },
261
267
  ],
@@ -18,7 +18,7 @@ import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
18
18
  import * as z from "zod";
19
19
  import { appendAttemptEvent } from "./attempt-log.js";
20
20
  import { buildChannelContext } from "./channel-context.js";
21
- import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
21
+ import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.js";
22
22
  import { collectChannels, redactedMessageOf } from "./delivery-service.js";
23
23
  import type {
24
24
  ChannelMessage,
@@ -44,6 +44,7 @@ const renderJobPayloadSchema = z.object({
44
44
  deliveryAttemptId: z.string(),
45
45
  priority: z.enum(["critical", "normal", "low"]),
46
46
  message: channelMessageSchema,
47
+ idempotencyKey: z.string().optional(),
47
48
  });
48
49
 
49
50
  const sendJobPayloadSchema = renderJobPayloadSchema.extend({
@@ -110,6 +111,30 @@ function entryFor(
110
111
  };
111
112
  }
112
113
 
114
+ // Only the final retry frees the key: earlier failures are retried by the runner and
115
+ // must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
116
+ // wires the same instance into both.
117
+ async function releaseIdempotencyOnTerminalFailure(
118
+ ctx: JobContext,
119
+ p: RenderJobPayload,
120
+ ): Promise<void> {
121
+ // skip: nothing to release before the last attempt or without an idempotency key
122
+ if (!ctx.finalAttempt || !p.idempotencyKey) return;
123
+ if (!ctx.redis) {
124
+ ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
125
+ notificationType: p.notificationType,
126
+ });
127
+ return;
128
+ }
129
+ try {
130
+ await ctx.redis.del(deliveryIdempotencyRedisKey(p.tenantId, p.idempotencyKey));
131
+ } catch (err) {
132
+ ctx.log.warn(`delivery: idempotency key release failed: ${redactedMessageOf(err)}`, {
133
+ notificationType: p.notificationType,
134
+ });
135
+ }
136
+ }
137
+
113
138
  // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
114
139
  // the stack trace in Redis in plain text, so the rethrown error carries only the code.
115
140
  // The redacted message goes to the log.
@@ -132,6 +157,7 @@ async function failAttempt(
132
157
  p.deliveryAttemptId,
133
158
  entryFor(p, "failed", code, p.address),
134
159
  );
160
+ await releaseIdempotencyOnTerminalFailure(ctx, p);
135
161
  throw new Error(code);
136
162
  }
137
163
 
@@ -18,6 +18,7 @@ export type CreateDeliveryTestContextOptions = {
18
18
  readonly log?: Logger;
19
19
  readonly jobRunner?: JobRunner;
20
20
  readonly secrets?: SecretsContext;
21
+ readonly idempotencyRedis?: Redis;
21
22
  };
22
23
 
23
24
  /**
@@ -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[],
@@ -4,9 +4,17 @@
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";
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
+ Registry,
12
+ TenantId,
13
+ UserDataDeleteHook,
14
+ UserDataExportHook,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
9
16
  import {
17
+ type RetentionPresetKey,
10
18
  resolveRetentionPolicyForTenant,
11
19
  resolveTenantRetentionPreset,
12
20
  } from "../data-retention/index.js";
@@ -32,95 +40,134 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
32
40
  };
33
41
  };
34
42
 
43
+ type NoteRow = {
44
+ readonly id: string;
45
+ readonly tenantId: TenantId;
46
+ readonly entityType: string;
47
+ readonly entityId: string;
48
+ readonly body: unknown;
49
+ readonly insertedAt: unknown;
50
+ };
51
+
52
+ // What erasing a subject's key reaches: notes that @-mention the subject plus notes
53
+ // attached to it as host. Export and erasure both go through this, so Art. 15
54
+ // lists exactly what Art. 17 shreds.
55
+ async function selectNotesReachingSubject(
56
+ db: DbRunner | TenantDb,
57
+ subject: SubjectId,
58
+ ): Promise<readonly NoteRow[]> {
59
+ if (subject.kind === "tenant") return [];
60
+ const subjectId = subject.kind === "user" ? subject.userId : subject.id;
61
+ const attachedWhere =
62
+ subject.kind === "record"
63
+ ? { entityType: subject.entity, entityId: subjectId }
64
+ : { entityId: subjectId };
65
+
66
+ const mentions = await selectMany<{ noteId: string }>(db, noteMentionTable, { subjectId });
67
+ const mentioned =
68
+ mentions.length === 0
69
+ ? []
70
+ : await selectMany<NoteRow>(db, noteEntryTable, {
71
+ id: { in: [...new Set(mentions.map((m) => m.noteId))] },
72
+ });
73
+ const attached = await selectMany<NoteRow>(db, noteEntryTable, attachedWhere);
74
+
75
+ const byId = new Map<string, NoteRow>();
76
+ for (const note of [...mentioned, ...attached]) byId.set(note.id, note);
77
+ return [...byId.values()];
78
+ }
79
+
35
80
  // `authorName` is separately annotated `personal: { of: "authorId" }`
36
81
  // (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.
82
+ // its own. `body` is Row-Subject (`personal: { of: "id" }`): its key is per
83
+ // note, so forgetting a subject only reaches a body when the note structurally
84
+ // @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
85
+ // Free-text mentions are NOT detected — the documented gap in entity.ts.
42
86
  //
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.
87
+ // Per reached note the HOST entity's retention strategy (entityType, resolved
88
+ // for the note's own tenant — the manual forget-subject path runs cross-tenant)
89
+ // is consulted first: anonymize/blockDelete win over erasure, same rule the
90
+ // automated forget-cleanup applies to every other entity.
48
91
  //
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) => {
92
+ // Without a mounted KMS this erases nothing: record-owned fields fall back to
93
+ // plaintext framework-wide (see pii-field-encryption.ts), so apps that need
94
+ // Art. 17 coverage must mount one.
95
+ export async function eraseNoteBodiesReachingSubject(args: {
96
+ readonly db: DbRunner | TenantDb;
97
+ readonly registry: Registry;
98
+ readonly subject: SubjectId;
99
+ readonly erasedBy: string;
100
+ readonly requestId: string;
101
+ }): Promise<void> {
56
102
  const kms = configuredPiiSubjectKms();
57
- // skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
103
+ // skip: no KMS adapter mounted — forget is a true no-op
58
104
  if (!kms) return;
59
105
 
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>>();
106
+ const notes = await selectNotesReachingSubject(args.db, args.subject);
107
+ const dataRetentionMounted = args.registry.features.has("data-retention");
108
+ const presetByTenant = new Map<string, RetentionPresetKey | null>();
109
+ const strategyByTenantAndEntityType = new Map<string, ReturnType<typeof policyToStrategy>>();
79
110
  for (const note of notes) {
80
- let strategy = strategyByEntityType.get(note.entityType);
111
+ const strategyKey = `${note.tenantId}:${note.entityType}`;
112
+ let strategy = strategyByTenantAndEntityType.get(strategyKey);
81
113
  if (strategy === undefined) {
114
+ let tenantPreset = presetByTenant.get(note.tenantId);
115
+ if (tenantPreset === undefined) {
116
+ tenantPreset = await resolveTenantRetentionPreset({
117
+ db: args.db,
118
+ registry: args.registry,
119
+ tenantId: note.tenantId,
120
+ });
121
+ presetByTenant.set(note.tenantId, tenantPreset);
122
+ }
82
123
  const hostPolicy = await resolveRetentionPolicyForTenant({
83
- db: ctx.db,
84
- registry: ctx.registry,
85
- tenantId: ctx.tenantId,
124
+ db: args.db,
125
+ registry: args.registry,
126
+ tenantId: note.tenantId,
86
127
  entityName: note.entityType,
87
128
  preloadedTenantPreset: tenantPreset,
129
+ // Without data-retention there is no override table to read.
130
+ ...(!dataRetentionMounted && { preloadedOverride: null }),
88
131
  });
89
132
  strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
90
- strategyByEntityType.set(note.entityType, strategy);
133
+ strategyByTenantAndEntityType.set(strategyKey, strategy);
91
134
  }
92
135
  if (strategy === "anonymize") continue;
93
- const noteId = note.id;
94
136
 
95
137
  await kms.eraseKey(
96
- { kind: "record", entity: "note-entry", id: noteId },
138
+ { kind: "record", entity: "note-entry", id: note.id },
97
139
  {
98
- requestId: "notes-history-user-data:forget-mentioned-notes",
99
- userId: ctx.userId,
100
- eraseReason: "notes-history:mention-forget",
140
+ requestId: args.requestId,
141
+ userId: args.erasedBy,
142
+ eraseReason: "notes-history:subject-forget",
101
143
  },
102
144
  );
103
145
  }
146
+ }
147
+
148
+ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
149
+ await eraseNoteBodiesReachingSubject({
150
+ db: ctx.db,
151
+ registry: ctx.registry,
152
+ subject: { kind: "user", userId: ctx.userId },
153
+ erasedBy: ctx.userId,
154
+ requestId: "notes-history-user-data:forget-reached-notes",
155
+ });
104
156
  };
105
157
 
106
- // Erasure reaches every note that structurally mentions the user (see
107
- // noteEntryDeleteHook), so the Art. 15/20 export must list those notes too.
158
+ // Erasure reaches every note that mentions the user or hangs on the user as host
159
+ // (see eraseNoteBodiesReachingSubject), so the Art. 15/20 export lists those too.
108
160
  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 } });
161
+ const notes = await selectNotesReachingSubject(ctx.db, { kind: "user", userId: ctx.userId });
115
162
  if (notes.length === 0) return null;
116
163
  return {
117
164
  entity: "note-mention",
118
165
  rows: notes.map((note) => ({
119
- noteId: note["id"],
120
- entityType: note["entityType"],
121
- entityId: note["entityId"],
122
- body: note["body"],
123
- insertedAt: note["insertedAt"],
166
+ noteId: note.id,
167
+ entityType: note.entityType,
168
+ entityId: note.entityId,
169
+ body: note.body,
170
+ insertedAt: note.insertedAt,
124
171
  })),
125
172
  };
126
173
  };
@@ -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 (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), 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" },
@@ -102,7 +102,7 @@ export const itemListScreen: EntityListScreenDefinition = {
102
102
  "dueDate",
103
103
  // Tier 2.7e-4 Demo — Reference-Spalte. Renderer macht Bulk-Lookup
104
104
  // auf item:list (limit:200), zeigt parentId als parent.title.
105
- "parentId",
105
+ { field: "parentId", hideOnNarrow: true },
106
106
  ],
107
107
  // Server-side Pagination Demo — Showcase seedet ~200 items, der
108
108
  // Pager hat 4 Seiten zum Durchklicken bei pageSize: 50.
@@ -228,9 +228,9 @@ export const demoFeature = defineFeature("styleguide", (r) => {
228
228
  "status",
229
229
  "isActive",
230
230
  "quantity",
231
- "rating",
232
- "publishedAt",
233
- "price",
231
+ { field: "rating", hideOnNarrow: true },
232
+ { field: "publishedAt", hideOnNarrow: true },
233
+ { field: "price", hideOnNarrow: true },
234
234
  ],
235
235
  pagination: "pages",
236
236
  pageSize: 25,
@@ -331,7 +331,7 @@ export function Gallery(): ReactNode {
331
331
  {/* Standalone <Section> (outside Form) = the calculator-result-card
332
332
  standard. title-only = existing consumer (publicstatus/tier-admin),
333
333
  must NOT regress from the border-b removal; subtitle +
334
- actions footer = new. No divider under the title (shadcn pattern). */}
334
+ actions (title row, top right) = new. No divider under the title (shadcn pattern). */}
335
335
  <div className="flex max-w-md flex-col gap-6">
336
336
  <Section title="Affordable rate / month">
337
337
  <div className="text-2xl font-semibold tabular-nums">€2,500.00</div>
@@ -27,7 +27,7 @@ const OPEN_FILL_COLUMNS = [
27
27
  "einheit",
28
28
  "liegenschaft",
29
29
  "beginn",
30
- "ende",
30
+ { field: "ende", hideOnNarrow: true },
31
31
  "status",
32
32
  ] as const;
33
33
 
@@ -145,7 +145,11 @@ const POSITION_COLUMNS = [
145
145
  label: "rental:entity:leasePosition:field:gueltigVon",
146
146
  sortable: true,
147
147
  },
148
- { field: "gueltigBis", label: "rental:entity:leasePosition:field:gueltigBis" },
148
+ {
149
+ field: "gueltigBis",
150
+ label: "rental:entity:leasePosition:field:gueltigBis",
151
+ hideOnNarrow: true,
152
+ },
149
153
  ] as const;
150
154
 
151
155
  export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
@@ -31,7 +31,14 @@ export const vehicleListScreen: EntityListScreenDefinition = {
31
31
  id: "vehicle-list",
32
32
  type: "entityList",
33
33
  entity: "vehicle",
34
- columns: ["marke", "modell", "baujahr", "preis", "zustand", "scheckheftGepflegt"],
34
+ columns: [
35
+ "marke",
36
+ "modell",
37
+ "baujahr",
38
+ "preis",
39
+ "zustand",
40
+ { field: "scheckheftGepflegt", hideOnNarrow: true },
41
+ ],
35
42
  searchPlaceholder: "vehicles.search.placeholder",
36
43
  defaultSort: { field: "marke", dir: "asc" },
37
44
  rowActions: [
@@ -129,7 +136,7 @@ export const campaignListScreen: EntityListScreenDefinition = {
129
136
  "aktionStartet",
130
137
  "aktionEndet",
131
138
  "meta",
132
- "gepostet",
139
+ { field: "gepostet", hideOnNarrow: true },
133
140
  { field: "gestartetAm", hideOnNarrow: true },
134
141
  ],
135
142
  searchPlaceholder: "vehicles.search.placeholder",
@@ -1954,7 +1954,7 @@
1954
1954
  },
1955
1955
  {
1956
1956
  "name": "notes-history-user-data",
1957
- "description": "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`.",
1957
+ "description": "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 (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), 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`.",
1958
1958
  "toggleableDefault": null,
1959
1959
  "requires": [
1960
1960
  "user-data-rights"