@cosmicdrift/kumiko-samples 0.355.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 (53) 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/compliance-profiles/handlers/set-profile.write.ts +18 -24
  9. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  10. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +16 -1
  11. package/packages/bundled-features/src/custom-fields/handlers/define-system-field.write.ts +10 -17
  12. package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +5 -2
  13. package/packages/bundled-features/src/custom-fields/lib/define-or-resurrect.ts +12 -3
  14. package/packages/bundled-features/src/delivery/changes.json +6 -0
  15. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
  16. package/packages/bundled-features/src/delivery/constants.ts +5 -0
  17. package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
  18. package/packages/bundled-features/src/delivery/feature.ts +7 -1
  19. package/packages/bundled-features/src/delivery/jobs.ts +27 -1
  20. package/packages/bundled-features/src/delivery/testing.ts +1 -0
  21. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +8 -25
  22. package/packages/bundled-features/src/notes-history/entity.ts +7 -6
  23. package/packages/bundled-features/src/notes-history-user-data/hooks.ts +109 -62
  24. package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
  25. package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
  26. package/packages/bundled-features/src/sessions/screens.ts +2 -0
  27. package/packages/bundled-features/src/shared/index.ts +1 -0
  28. package/packages/bundled-features/src/shared/require-for-tenant.ts +18 -0
  29. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -4
  30. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -7
  31. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +7 -6
  32. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +7 -4
  33. package/packages/bundled-features/src/tenant/cancel-pending-invitation.ts +3 -1
  34. package/packages/bundled-features/src/tenant/changes.json +6 -0
  35. package/packages/bundled-features/src/tenant/constants.ts +6 -3
  36. package/packages/bundled-features/src/tenant/feature.ts +2 -0
  37. package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
  38. package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +22 -8
  39. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +19 -11
  40. package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
  41. package/packages/bundled-features/src/tenant/screens.ts +9 -6
  42. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
  43. package/packages/bundled-features/src/user/screens.ts +8 -1
  44. package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
  45. package/packages/bundled-features/src/waitlist/screens.ts +8 -1
  46. package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
  47. package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
  48. package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
  49. package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
  50. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  51. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
  52. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
  53. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
@@ -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
  /**
@@ -1,7 +1,8 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import * as z from "zod";
5
+ import { requireForTenant } from "../../shared/index.js";
5
6
  import { type PageRow, pageEntity, pagesTable } from "../table.js";
6
7
 
7
8
  const slugSchema = z
@@ -18,9 +19,6 @@ const langSchema = z
18
19
 
19
20
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
20
21
 
21
- const SET_PAGE_TENANT_OVERRIDE_REASON =
22
- "SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
23
-
24
22
  // Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
25
23
  // Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
26
24
  // für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
@@ -43,29 +41,12 @@ export const setWrite = defineWriteHandler({
43
41
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
44
42
  description:
45
43
  "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
46
- escapeHatch: {
47
- grants: ["unsafeRaw"],
48
- reason: SET_PAGE_TENANT_OVERRIDE_REASON,
49
- },
50
44
  handler: async (event, ctx) => {
51
- const db = ctx.db;
52
45
  const override = event.payload.tenantIdOverride;
53
- const tenantId = override ?? event.user.tenantId;
54
- // override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
55
- const executorUser =
56
- override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
57
-
46
+ const tenantId = (override ?? event.user.tenantId) as TenantId; // @cast-boundary engine-bridge
58
47
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
59
48
  // on both the existing-check and the executor's stream reads; re-scope to the target.
60
- // Safe: the override branch is SystemAdmin-gated above.
61
- const scopedDb =
62
- override !== undefined
63
- ? createTenantDb(
64
- db.unsafeRaw(),
65
- override as TenantId, // @cast-boundary engine-bridge
66
- "tenant",
67
- )
68
- : db;
49
+ const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
69
50
  const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
70
51
  tenantId,
71
52
  slug: event.payload.slug,
@@ -89,8 +70,9 @@ export const setWrite = defineWriteHandler({
89
70
  event.payload.published !== undefined ? event.payload.published : existing.published,
90
71
  },
91
72
  },
92
- executorUser,
73
+ event.user,
93
74
  scopedDb,
75
+ { streamTenantId },
94
76
  );
95
77
  if (!result.isSuccess) return result;
96
78
  return {
@@ -110,8 +92,9 @@ export const setWrite = defineWriteHandler({
110
92
  published: event.payload.published ?? false,
111
93
  tenantId,
112
94
  },
113
- executorUser,
95
+ event.user,
114
96
  scopedDb,
97
+ { streamTenantId },
115
98
  );
116
99
  if (!result.isSuccess) return result;
117
100
  return {
@@ -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
  ],
@@ -33,6 +33,7 @@ export {
33
33
  redactErrorText,
34
34
  redactUrls,
35
35
  } from "./redact.js";
36
+ export { requireForTenant } from "./require-for-tenant.js";
36
37
  export {
37
38
  type RowBoundGrantResult,
38
39
  redeemRowBoundGrant,
@@ -0,0 +1,18 @@
1
+ import type {
2
+ HandlerContext,
3
+ TenantId,
4
+ TenantWriteTarget,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
+
8
+ // ctx.forTenant is absent for r.systemScope() handlers and without a db; a write handler that
9
+ // needs a cross-tenant target must fail loudly instead of falling back to ctx.db.
10
+ export function requireForTenant(
11
+ ctx: Pick<HandlerContext, "forTenant">,
12
+ targetTenantId: TenantId,
13
+ ): TenantWriteTarget {
14
+ if (!ctx.forTenant) {
15
+ throw new InternalError({ message: "ctx.forTenant is not available in this handler context" });
16
+ }
17
+ return ctx.forTenant(targetTenantId);
18
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import * as z from "zod";
8
+ import { requireForTenant } from "../../shared/index.js";
8
9
  import {
9
10
  type CollectionEntryRow,
10
11
  collectionStore,
@@ -39,12 +40,11 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
39
40
  ? `Creates or overwrites the caller's own "${collection.id}" entry at a slug and locale, live on save with no draft stage; it always writes the acting user's entry, never another user's.`
40
41
  : `Creates or overwrites the tenant-wide "${collection.id}" entry at a slug and locale, live on save with no draft stage; the change is visible to everyone who may reach this shared collection.`,
41
42
  handler: async (event, ctx) => {
42
- const db = ctx.db;
43
43
  const override = event.payload.tenantIdOverride;
44
44
  // @cast-boundary engine-payload — override is a zod-validated string, the
45
45
  // user's own tenantId is already TenantId-branded.
46
46
  const tenantId = (override ?? event.user.tenantId) as TenantId;
47
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
47
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
48
48
  // Scoped to the acting user even under a tenant override: a SystemAdmin
49
49
  // writing into another tenant still writes their own entry, never
50
50
  // someone else's signature.
@@ -73,8 +73,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
73
73
  folder: event.payload.folder ?? null,
74
74
  },
75
75
  },
76
- executorUser,
76
+ event.user,
77
77
  db,
78
+ { streamTenantId },
78
79
  );
79
80
  if (!result.isSuccess) return result;
80
81
  return {
@@ -96,8 +97,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
96
97
  ...store.createDefaults(tenantId),
97
98
  ...owner,
98
99
  },
99
- executorUser,
100
+ event.user,
100
101
  db,
102
+ { streamTenantId },
101
103
  );
102
104
  if (!result.isSuccess) return result;
103
105
  return {
@@ -5,6 +5,7 @@ import {
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import * as z from "zod";
8
+ import { requireForTenant } from "../../shared/index.js";
8
9
  import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants.js";
9
10
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
10
11
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.js";
@@ -48,15 +49,11 @@ export const setWrite = defineWriteHandler({
48
49
  description:
49
50
  "Creates or overwrites one content-tree resource addressed by slug, kind and locale, carrying a title and folder and going live on save with no draft stage; a SystemAdmin may target the system tenant, where shared legal and marketing copy lives.",
50
51
  handler: async (event, ctx) => {
51
- const db = ctx.db;
52
52
  const override = event.payload.tenantIdOverride;
53
53
  // @cast-boundary engine-payload — override is a zod-validated string, the
54
54
  // user's own tenantId is already TenantId-branded.
55
55
  const tenantId = (override ?? event.user.tenantId) as TenantId;
56
- // With an override the executor user has to move to the target tenant too,
57
- // otherwise the event-store stream lookup runs against event.user.tenantId
58
- // and reports a version conflict although the projection row exists.
59
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
56
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
60
57
 
61
58
  const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
62
59
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
@@ -98,8 +95,9 @@ export const setWrite = defineWriteHandler({
98
95
  folder: fields.folder,
99
96
  },
100
97
  },
101
- executorUser,
98
+ event.user,
102
99
  db,
100
+ { streamTenantId },
103
101
  );
104
102
  if (!result.isSuccess) return result;
105
103
  return {
@@ -108,7 +106,9 @@ export const setWrite = defineWriteHandler({
108
106
  };
109
107
  }
110
108
 
111
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
109
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
110
+ streamTenantId,
111
+ });
112
112
  if (!result.isSuccess) return result;
113
113
  return {
114
114
  isSuccess: true as const,
@@ -4,6 +4,7 @@ import {
4
4
  SYSTEM_TENANT_ID,
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { requireForTenant } from "../../shared/index.js";
7
8
  import type { TemplateResourceRow } from "../table.js";
8
9
  import { templateResourcesTable } from "../table.js";
9
10
  import { executor, upsertPayloadSchema } from "./shared.js";
@@ -19,13 +20,10 @@ export const upsertSystemWrite = defineWriteHandler({
19
20
  description:
20
21
  "Creates or overwrites a system-default template under the system tenant with its full variable schema and linked resources, active straight away with no draft stage; SystemAdmin only, since tenants shadow these defaults through upsert-tenant instead.",
21
22
  handler: async (event, ctx) => {
22
- const db = ctx.db;
23
23
  // @cast-boundary engine-payload — SYSTEM_TENANT_ID is a UUID literal,
24
24
  // asserted to the branded TenantId (parseTenantId equivalent).
25
25
  const tenantId = SYSTEM_TENANT_ID as TenantId;
26
- // The executor user must carry SYSTEM_TENANT as tenantId, otherwise the
27
- // event store looks up the stream under user.tenantId → conflict.
28
- const executorUser = { ...event.user, tenantId };
26
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
29
27
 
30
28
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
31
29
  tenantId,
@@ -53,8 +51,9 @@ export const upsertSystemWrite = defineWriteHandler({
53
51
  if (existing) {
54
52
  const result = await executor.update(
55
53
  { id: existing.id, version: existing.version, changes: fields },
56
- executorUser,
54
+ event.user,
57
55
  db,
56
+ { streamTenantId },
58
57
  );
59
58
  if (!result.isSuccess) return result;
60
59
  return {
@@ -63,7 +62,9 @@ export const upsertSystemWrite = defineWriteHandler({
63
62
  };
64
63
  }
65
64
 
66
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
65
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
66
+ streamTenantId,
67
+ });
67
68
  if (!result.isSuccess) return result;
68
69
  // @cast-boundary db-row — executor.create returnt Record-row aus
69
70
  // INSERT RETURNING; shape { id } ist garantiert weil PK in der
@@ -6,6 +6,7 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
+ import { requireForTenant } from "../../shared/index.js";
9
10
  import type { TemplateResourceRow } from "../table.js";
10
11
  import { templateResourcesTable } from "../table.js";
11
12
  import { executor, upsertPayloadSchema } from "./shared.js";
@@ -25,7 +26,6 @@ export const upsertTenantWrite = defineWriteHandler({
25
26
  description:
26
27
  "Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live; it refuses a system-tenant target, which is what upsert-system is for.",
27
28
  handler: async (event, ctx) => {
28
- const db = ctx.db;
29
29
  const override = event.payload.tenantIdOverride;
30
30
  // upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
31
31
  // scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
@@ -43,7 +43,7 @@ export const upsertTenantWrite = defineWriteHandler({
43
43
  // event.user.tenantId schon TenantId-branded; union als TenantId casten
44
44
  // ist legit (override ist UUID-Format-validiert in schema).
45
45
  const tenantId = (override ?? event.user.tenantId) as TenantId;
46
- const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
46
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
47
47
 
48
48
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
49
49
  tenantId,
@@ -70,8 +70,9 @@ export const upsertTenantWrite = defineWriteHandler({
70
70
  if (existing) {
71
71
  const result = await executor.update(
72
72
  { id: existing.id, version: existing.version, changes: fields },
73
- executorUser,
73
+ event.user,
74
74
  db,
75
+ { streamTenantId },
75
76
  );
76
77
  if (!result.isSuccess) return result;
77
78
  return {
@@ -80,7 +81,9 @@ export const upsertTenantWrite = defineWriteHandler({
80
81
  };
81
82
  }
82
83
 
83
- const result = await executor.create({ ...fields, tenantId }, executorUser, db);
84
+ const result = await executor.create({ ...fields, tenantId }, event.user, db, {
85
+ streamTenantId,
86
+ });
84
87
  if (!result.isSuccess) return result;
85
88
  // @cast-boundary db-row — executor.create returnt Record-row aus
86
89
  // INSERT RETURNING; shape { id } ist garantiert weil PK in der