@cosmicdrift/kumiko-samples 0.353.0 → 0.354.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 (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
  5. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  6. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  7. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  8. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  14. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
  17. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  19. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  21. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  22. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  23. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  24. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  25. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  26. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  27. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  28. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  29. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  30. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  31. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  32. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  33. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  35. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
  36. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  37. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  38. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  39. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  40. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  41. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  42. package/packages/bundled-features/src/delivery/changes.json +6 -0
  43. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  44. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  45. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  47. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  48. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  50. package/packages/bundled-features/src/files/changes.json +24 -0
  51. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  52. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
  54. package/packages/bundled-features/src/folders/changes.json +6 -0
  55. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  56. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  57. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  58. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  59. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  60. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  61. package/packages/bundled-features/src/jobs/changes.json +30 -0
  62. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  63. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  64. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  65. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  66. package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
  67. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  68. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  69. package/packages/bundled-features/src/jobs/index.ts +2 -0
  70. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  71. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  72. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  73. package/packages/bundled-features/src/ledger/changes.json +23 -0
  74. package/packages/bundled-features/src/ledger/entity.ts +13 -5
  75. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  76. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  77. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  78. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  79. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  80. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  81. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  82. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  83. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  84. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  85. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  86. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  87. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  88. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  89. package/packages/bundled-features/src/shared/index.ts +2 -1
  90. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  91. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  92. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  93. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  94. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  95. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  96. package/packages/bundled-features/src/tenant/changes.json +12 -0
  97. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  98. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  100. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  101. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  102. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  104. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  105. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  106. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  107. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  108. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
  109. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
  110. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
  111. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  112. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  113. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  114. package/packages/bundled-features/src/user/changes.json +15 -0
  115. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  116. package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
  117. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  118. package/packages/bundled-features/src/user/seeding.ts +34 -0
  119. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  120. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  121. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  122. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  123. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  124. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
  125. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
  126. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  127. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  128. package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
  129. package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
  130. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  131. package/samples/apps/ui-walkthrough/package.json +3 -0
  132. package/samples/apps/use-all-bundled/README.md +1 -2
  133. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  134. package/samples/apps/user-data-rights-demo/README.md +1 -3
  135. package/samples/recipes/legal-pages/README.md +3 -4
  136. package/samples/recipes/managed-pages/README.md +2 -2
  137. package/samples/recipes/seed-migration/README.md +0 -2
@@ -22,6 +22,7 @@ import {
22
22
  isTenantResourceExtensionHooks,
23
23
  type Registry,
24
24
  type TenantDataHookCtx,
25
+ type TenantDestroyHookResult,
25
26
  type TenantId,
26
27
  type TenantResourceExtensionName,
27
28
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -61,12 +62,18 @@ export type DestructionStageCtx = {
61
62
  // EXT_TENANT_DATA usage's declared escapeHatch.
62
63
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
63
64
  readonly actor?: string;
65
+ // Epoch ms the stage must hand back by returning { done: false }.
66
+ readonly deadlineAt: number;
64
67
  };
65
68
 
69
+ export type StageRunOutcome = { readonly done: boolean; readonly processed: number };
70
+
71
+ const STAGE_DONE: StageRunOutcome = { done: true, processed: 0 };
72
+
66
73
  export type DestructionStage = {
67
74
  readonly name: TenantDestructionStageName;
68
75
  readonly maxAttempts: number;
69
- readonly run: (ctx: DestructionStageCtx) => Promise<void>;
76
+ readonly run: (ctx: DestructionStageCtx) => Promise<StageRunOutcome>;
70
77
  };
71
78
 
72
79
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
@@ -78,24 +85,44 @@ const tenantMembershipCrud = createEventStoreExecutor(
78
85
  },
79
86
  );
80
87
 
88
+ // Every hook still runs each tick (they are idempotent), so a later hook is not
89
+ // starved by an earlier one that keeps reporting done:false.
90
+ function newOutcomeAccumulator(): {
91
+ add: (result: TenantDestroyHookResult | void) => void;
92
+ outcome: () => StageRunOutcome;
93
+ } {
94
+ let allDone = true;
95
+ let processed = 0;
96
+ return {
97
+ add: (result) => {
98
+ if (result?.done === false) allDone = false;
99
+ processed += result?.processed ?? 0;
100
+ },
101
+ outcome: () => ({ done: allDone, processed }),
102
+ };
103
+ }
104
+
81
105
  async function runExtensionDestroyHooks(
82
106
  registry: Registry,
83
107
  extensionName: TenantResourceExtensionName,
84
108
  ctx: DestructionStageCtx,
85
- ): Promise<void> {
109
+ ): Promise<StageRunOutcome> {
86
110
  const usages = registry.getExtensionUsages(extensionName);
111
+ const total = newOutcomeAccumulator();
87
112
  for (const usage of usages) {
88
113
  if (!isTenantResourceExtensionHooks(usage.options)) {
89
114
  throw new Error(
90
115
  `${extensionName} registration for "${usage.entityName}" has no destroy function`,
91
116
  );
92
117
  }
93
- await usage.options.destroyTenant(ctx.tenantId, ctx);
118
+ total.add(await usage.options.destroyTenant(ctx.tenantId, ctx));
94
119
  }
120
+ return total.outcome();
95
121
  }
96
122
 
97
- async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
123
+ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
98
124
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
125
+ const total = newOutcomeAccumulator();
99
126
  for (const usage of usages) {
100
127
  if (!isTenantDataExtensionHooks(usage.options)) {
101
128
  throw new Error(
@@ -117,19 +144,21 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
117
144
  }),
118
145
  registry: ctx.registry,
119
146
  tenantId: ctx.tenantId,
147
+ deadlineAt: ctx.deadlineAt,
120
148
  fileProviderResolver: ctx.fileProviderResolver,
121
149
  log: ctx.log,
122
150
  };
123
- await destroy(hookCtx);
151
+ total.add(await destroy(hookCtx));
124
152
  }
153
+ return total.outcome();
125
154
  }
126
155
 
127
- async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
156
+ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
128
157
  const kms = configuredPiiSubjectKms();
129
158
  if (!kms) {
130
159
  ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
131
160
  // skip: KMS optional — apps without crypto-shredding still run other destroy stages
132
- return;
161
+ return STAGE_DONE;
133
162
  }
134
163
  const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
135
164
  tenantId: ctx.tenantId,
@@ -155,15 +184,17 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
155
184
  tenantRecordSubject,
156
185
  );
157
186
  }
187
+ return STAGE_DONE;
158
188
  }
159
189
 
160
- async function purgeTenantCache(ctx: DestructionStageCtx): Promise<void> {
190
+ async function purgeTenantCache(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
161
191
  // ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
162
192
  // then this stage is a documented no-op (no cache layer in test stack).
163
193
  ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
194
+ return STAGE_DONE;
164
195
  }
165
196
 
166
- async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
197
+ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
167
198
  const now = getTemporal().Now.instant();
168
199
  const user = createSystemUser(ctx.tenantId);
169
200
  const db = createTenantDb(ctx.db, ctx.tenantId, "system");
@@ -202,6 +233,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
202
233
  { skipOptimisticLock: true },
203
234
  );
204
235
  invalidateTenantLifecycleGate(ctx.tenantId);
236
+ return STAGE_DONE;
205
237
  }
206
238
 
207
239
  export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
@@ -0,0 +1,58 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { append, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
5
+ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
6
+ import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
7
+ import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
8
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
9
+ import { tenantTable } from "../tenant/index.js";
10
+ import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_STARTED_EVENT_QN } from "./constants.js";
11
+ import { runTenantDestructionSweep } from "./run-tenant-destroy.js";
12
+
13
+ // Sidesteps the `request-destruction` write handler (needs user/auth/sessions
14
+ // features wired) by seeding the same "destroying" state it would produce.
15
+ export async function seedDestroyingTenant(db: DbConnection, tenantId: TenantId): Promise<void> {
16
+ const now = getTemporal().Now.instant();
17
+ await updateRows(
18
+ db,
19
+ tenantTable,
20
+ { status: "destroying", destroyStartedAt: now },
21
+ { id: tenantId },
22
+ );
23
+ await append(db, {
24
+ aggregateId: tenantId,
25
+ aggregateType: TENANT_AGGREGATE_TYPE,
26
+ tenantId,
27
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
28
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
29
+ payload: { startedAt: now.toString() },
30
+ metadata: { userId: "system", requestId: "test:destruction-started" },
31
+ });
32
+ }
33
+
34
+ export async function driveDestructionToCompletion(
35
+ stack: TestStack,
36
+ db: DbConnection,
37
+ tenantId: TenantId,
38
+ searchAdapter?: SearchAdapter,
39
+ ): Promise<string> {
40
+ const farFuture = getTemporal()
41
+ .Now.instant()
42
+ .add({ hours: 24 * 3650 });
43
+ let status = "";
44
+ for (let i = 0; i < 20; i++) {
45
+ await runTenantDestructionSweep({
46
+ db: stack.db,
47
+ registry: stack.registry,
48
+ now: farFuture,
49
+ searchAdapter,
50
+ });
51
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
52
+ status = String(rows[0]?.["status"]);
53
+ if (status === "destroyed" || status === "destroyFailed") break;
54
+ }
55
+ return status;
56
+ }
57
+
58
+ export { runTenantDestructionSweep };
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "tier-engine:write:set-tenant-tier resolves agent risk high"
6
+ },
2
7
  {
3
8
  "version": "0.352.0",
4
9
  "type": "improvement",
@@ -67,6 +67,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
67
67
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
68
68
  }),
69
69
  access: { roles: ["SystemAdmin"] },
70
+ agent: { risk: "high" },
70
71
  escapeHatch: {
71
72
  grants: ["unsafeRaw"],
72
73
  reason: SET_TENANT_TIER_REASON,
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "user:write:user:update resolves agent risk high"
6
+ },
7
+ {
8
+ "version": "0.354.0",
9
+ "type": "fix",
10
+ "title": "user:query:user:list skips the tenants label when the tenant feature is not mounted"
11
+ },
12
+ {
13
+ "version": "0.354.0",
14
+ "type": "fix",
15
+ "title": "user:query:user:detail skips the tenants label when the tenant feature is not mounted"
16
+ },
2
17
  {
3
18
  "version": "0.340.0",
4
19
  "type": "improvement",
@@ -25,6 +25,7 @@ export const detailQuery = {
25
25
  ) => {
26
26
  const result = await baseDetail.handler?.(query, ctx);
27
27
  if (result === null || typeof result !== "object") return result;
28
+ if (!ctx.registry.features.has("tenant")) return result;
28
29
  const [enriched] = await attachTenantLabels(
29
30
  [result as Record<string, unknown>],
30
31
  dbForList(ctx),
@@ -120,6 +120,9 @@ export const listQuery: QueryHandlerDef = {
120
120
  return result;
121
121
  }
122
122
  const envelope = result as { rows: readonly Record<string, unknown>[] };
123
+ // Without the tenant feature there are no membership tables to join; with
124
+ // it, a failing lookup must surface instead of rendering a silent empty column.
125
+ if (!ctx.registry.features.has("tenant")) return result;
123
126
  const rows = await attachTenantLabels(envelope.rows, dbForList(ctx));
124
127
  return { ...envelope, rows };
125
128
  },
@@ -59,6 +59,7 @@ export const updateWrite = defineWriteHandler({
59
59
  },
60
60
  description:
61
61
  "Changes a user's display name, locale, timezone, email, verification flag, last active tenant or global roles against the version the caller read; callers may edit themselves, while editing someone else or granting roles needs a privileged actor.",
62
+ agent: { risk: "high" },
62
63
  escapeHatch: {
63
64
  reason: SYSTEM_ADMIN_DEMOTION_COUNT_REASON,
64
65
  },
@@ -125,6 +125,40 @@ export async function reconcileSeededUserEmailVerified(
125
125
  }
126
126
  }
127
127
 
128
+ // Re-writes `email` through the executor so the PII write pipeline recomputes
129
+ // `email_bidx`. Repairs rows whose blind index is NULL or stale (written before
130
+ // the key existed, key rotated, subject-key erased), which would otherwise stay
131
+ // invisible to every `fetchOne(userTable, { email })` lookup.
132
+ export async function rewriteSeededUserEmail(
133
+ db: DbConnection,
134
+ existingRow: Record<string, unknown>,
135
+ email: string,
136
+ by: SessionUser,
137
+ ): Promise<void> {
138
+ const id = existingRow["id"];
139
+ if (typeof id !== "string") {
140
+ throw new Error("rewriteSeededUserEmail: existing user row has no string id");
141
+ }
142
+ const tdb = createTenantDb(db, by.tenantId, "system");
143
+ const result = await userExecutor.update(
144
+ { id, version: existingRow["version"] as number, changes: { email } },
145
+ by,
146
+ tdb,
147
+ );
148
+ // version_conflict: a concurrent write already changed the row — fine for a seed helper.
149
+ // unique_violation: a duplicate row already holds the blind index; the repair
150
+ // has to wait for a manual duplicate cleanup.
151
+ if (
152
+ !result.isSuccess &&
153
+ result.error.code !== "version_conflict" &&
154
+ result.error.code !== "unique_violation"
155
+ ) {
156
+ throw new Error(
157
+ `rewriteSeededUserEmail failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
158
+ );
159
+ }
160
+ }
161
+
128
162
  // Extrahiert die `id`-Spalte aus dem executor.create-Result. Der
129
163
  // Executor liefert ein Record<string, unknown> (die Projection-Row), in
130
164
  // das die DB die Aggregat-id reinschreibt — wir prüfen runtime statt
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "Row-bound grants (deletion token, tenant handover) refuse a colon in anchor or subject",
6
+ "migration": "signRowBoundGrant now throws when the anchor or subject contains \":\", and redeem returns FAILED for such a token. Use colon-free ids (uuids) for anchor and subject; a purpose may still contain a colon."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "fix",
@@ -51,6 +51,7 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
51
51
  },
52
52
  },
53
53
  escapeHatch: {
54
+ grants: ["systemIdentity", "unsafeRaw"],
54
55
  reason:
55
56
  "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
56
57
  "...) to compute the grace period end — the calling user's own identity has no read " +
@@ -1,3 +1,4 @@
1
+ export { TENANT_MODEL_CONFIG_KEY } from "./constants.js";
1
2
  export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature.js";
2
3
  export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write.js";
3
4
  export {
@@ -35,16 +35,24 @@ export const downloadAttemptEntity = createEntity({
35
35
  jobId: createTextField({ personal: false, reason: "technical_reference" }),
36
36
  // User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
37
37
  attemptedByUserId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
38
- // Same reasoning as download-token.ts's lastUsedFromIp/lastUsedUserAgent
39
- // (sibling entity, same feature): DPO brute-force-audit data belongs to
40
- // the tenant operator, not the (often anonymous) requester. Also avoids
41
- // a real hazard on this buildEntityTable-managed entity: attemptedByUserId
42
- // is null-by-design for the anonymous token path, so a subject annotation
43
- // (`personal: { of: "attemptedByUserId" }`) would throw
44
- // SubjectResolutionError on every such row once a KMS is configured
38
+ // No subject annotation on this buildEntityTable-managed entity:
39
+ // attemptedByUserId is null-by-design for the anonymous token path, so
40
+ // `personal: { of: "attemptedByUserId" }` would throw SubjectResolutionError
41
+ // on every such row once a KMS is configured
45
42
  // (crypto/subject-resolver.ts resolveUserOwnedSubject).
46
- ip: createTextField({ maxLength: 64, personal: false, reason: "is_business_data" }),
47
- userAgent: createTextField({ maxLength: 256, personal: false, reason: "is_business_data" }),
43
+ // The job path knows the requester and an IP is personal data, so this is a
44
+ // deliberate waiver, not business data: a user forget does not shred these
45
+ // values, the 90d hardDelete below is what removes them.
46
+ ip: createTextField({
47
+ maxLength: 64,
48
+ personal: false,
49
+ reason: "security_audit_retention_bounded",
50
+ }),
51
+ userAgent: createTextField({
52
+ maxLength: 256,
53
+ personal: false,
54
+ reason: "security_audit_retention_bounded",
55
+ }),
48
56
  attemptedAt: createTimestampField({ required: true }),
49
57
  },
50
58
  // 90d hardDelete: unbounded growth = disk-bomb genau gegen das System
@@ -1,5 +1,4 @@
1
1
  import {
2
- access,
3
2
  createSystemUser,
4
3
  defineWriteHandler,
5
4
  SYSTEM_TENANT_ID,
@@ -36,8 +35,15 @@ export const changeEmailWrite = defineWriteHandler({
36
35
  currentPassword: z.string().min(1),
37
36
  newEmail: z.email(),
38
37
  }),
39
- access: { roles: access.authenticated },
38
+ access: {
39
+ openToAll: {
40
+ reason:
41
+ "self-service for any signed-in user, including tenant members whose only role is " +
42
+ "TenantAdmin; the handler only touches event.user.id and re-checks the caller's password",
43
+ },
44
+ },
40
45
  escapeHatch: {
46
+ grants: ["systemIdentity"],
41
47
  reason:
42
48
  "Reads the caller's own passwordHash via ctx.queryAs(SYSTEM, user:findForAuth), checks " +
43
49
  "email uniqueness the same way, and writes the new email via ctx.writeAs(SYSTEM, " +
@@ -12,13 +12,20 @@
12
12
  import type {
13
13
  FeatureRegistrar,
14
14
  MultiStreamProjectionDefinition,
15
+ StepInstance,
15
16
  WorkflowDefinition,
16
17
  WriteEvent,
17
18
  } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import {
20
+ buildPipelineSteps,
21
+ createSystemUser,
19
22
  describeWorkflowStepError,
23
+ getStep,
24
+ SYSTEM_TENANT_ID,
25
+ type TenantId,
20
26
  WORKFLOW_AGGREGATE_TYPE,
21
27
  WORKFLOW_RUN_FAILED_TYPE,
28
+ WorkflowStepError,
22
29
  } from "@cosmicdrift/kumiko-framework/engine";
23
30
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
24
31
  import { workflowRunAggregateId } from "./aggregate-id.js";
@@ -32,6 +39,60 @@ import { registerWorkflow } from "./workflow-registry.js";
32
39
 
33
40
  const log = createFallbackLogger("workflow-runner");
34
41
 
42
+ // The MSP apply context only offers unsafeAppendEvent/loadAggregate; these steps reach for
43
+ // ctx.db/write/writeAs and would crash on the first run instead of at registration.
44
+ const STEP_KINDS_NEEDING_HANDLER_CONTEXT: ReadonlySet<string> = new Set([
45
+ "aggregate.create",
46
+ "aggregate.update",
47
+ "callFeature",
48
+ "read.findOne",
49
+ "read.findMany",
50
+ "unsafeProjectionUpsert",
51
+ "unsafeProjectionDelete",
52
+ ]);
53
+
54
+ function* walkStepInstances(steps: readonly StepInstance[]): Generator<StepInstance, void, void> {
55
+ for (const step of steps) {
56
+ yield step;
57
+ const { args } = step;
58
+ if (typeof args !== "object" || args === null) continue;
59
+ for (const path of getStep(step.kind)?.subPaths ?? []) {
60
+ const nested: unknown = path in args ? Reflect.get(args, path) : undefined;
61
+ if (Array.isArray(nested)) yield* walkStepInstances(nested);
62
+ }
63
+ }
64
+ }
65
+
66
+ export function assertEventTriggerStepsSupported(workflow: WorkflowDefinition): void {
67
+ let steps: readonly StepInstance[];
68
+ try {
69
+ // Same contract as the boot validator for handler pipelines: the closure must build its
70
+ // step list from the step builder alone, so an empty event is enough to enumerate kinds.
71
+ steps = buildPipelineSteps(workflow.pipelineDef, {
72
+ type: "workflow-registration-probe",
73
+ payload: {},
74
+ user: createSystemUser(SYSTEM_TENANT_ID as TenantId),
75
+ });
76
+ } catch (error) {
77
+ throw new Error(
78
+ `Workflow "${workflow.name}" pipeline closure threw at registration: ${String(error)}. ` +
79
+ "Build the step list without reading event payload fields; read them inside step resolvers.",
80
+ { cause: error },
81
+ );
82
+ }
83
+ for (const step of walkStepInstances(steps)) {
84
+ if (getStep(step.kind) === undefined) {
85
+ throw new Error(`Workflow "${workflow.name}" uses unknown step kind "${step.kind}"`);
86
+ }
87
+ if (STEP_KINDS_NEEDING_HANDLER_CONTEXT.has(step.kind)) {
88
+ throw new Error(
89
+ `Workflow "${workflow.name}" is event-triggered but uses step "${step.kind}", which needs a ` +
90
+ "handler context (db/write) the event-trigger projection does not provide.",
91
+ );
92
+ }
93
+ }
94
+ }
95
+
35
96
  export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
36
97
  // Populate the workflow-registry unconditionally, before the event-trigger
37
98
  // guard below — resume-run (framework#2513 Phase 2) looks workflows up by
@@ -47,6 +108,8 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
47
108
  // need a scheduler, not an MSP, so there is nothing to register here.
48
109
  if (workflow.trigger.kind !== "event") return;
49
110
 
111
+ assertEventTriggerStepsSupported(workflow);
112
+
50
113
  const eventType = workflow.trigger.eventType;
51
114
 
52
115
  r.multiStreamProjection({
@@ -60,12 +123,6 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
60
123
  // closure does not carry that narrowing, and re-narrowing here keeps
61
124
  // `trigger.filter` below typed without a cast.
62
125
  if (workflow.trigger.kind !== "event") return;
63
- if (workflow.trigger.filter) {
64
- const matches = workflow.trigger.filter(event as never);
65
- // skip: the workflow's own trigger filter rejected this event — not
66
- // this run's concern, so no run is started and nothing is recorded.
67
- if (!matches) return;
68
- }
69
126
 
70
127
  // @cast-boundary msp-to-write-event — the MSP delivers a StoredEvent
71
128
  // (event-store shape); the workflow runner expects a WriteEvent
@@ -73,6 +130,9 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
73
130
  // overlap exactly — the missing `.user` field is acceptable because
74
131
  // workflow triggers run system-level, not user-scoped.
75
132
  const triggerEvent = event as unknown as WriteEvent;
133
+ // skip: the workflow's own trigger filter rejected this event — not this run's
134
+ // concern, so no run is started and nothing is recorded.
135
+ if (workflow.trigger.filter && !workflow.trigger.filter(triggerEvent)) return;
76
136
  let idempotencyKey: string | undefined;
77
137
  if (typeof workflow.idempotencyKey === "function") {
78
138
  idempotencyKey = workflow.idempotencyKey(triggerEvent);
@@ -95,17 +155,15 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
95
155
  version: event.version,
96
156
  },
97
157
  ...(idempotencyKey && { idempotencyKey }),
98
- // @cast-boundary msp-to-handler-ctx — MultiStreamApplyContext only
99
- // exposes unsafeAppendEvent/loadAggregate, a subset of
100
- // HandlerContext. The step vocabulary this runner drives (wait,
101
- // compute, return) only ever calls unsafeAppendEvent on it.
102
- handlerCtx: ctx as never,
158
+ handlerCtx: ctx,
103
159
  });
104
160
  } catch (error) {
105
- // Only the suspension error knows its step; a throw inside a step
106
- // carries no index, so it still reports 0.
161
+ // Failures outside the step loop (run-started append) have no step yet, so 0.
107
162
  const stepIndex =
108
- error instanceof WorkflowSuspensionUnsupportedError ? error.stepIndex : 0;
163
+ error instanceof WorkflowStepError ||
164
+ error instanceof WorkflowSuspensionUnsupportedError
165
+ ? error.stepIndex
166
+ : 0;
109
167
  log.warn("workflow run failed", {
110
168
  runId,
111
169
  workflowName: workflow.name,
@@ -48,6 +48,7 @@ import {
48
48
  WORKFLOW_RUN_STARTED_TYPE,
49
49
  WORKFLOW_WAITING_FOR_EVENT_TYPE,
50
50
  type WorkflowDefinition,
51
+ WorkflowStepError,
51
52
  type WriteEvent,
52
53
  type WriteHandlerDef,
53
54
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -392,9 +393,13 @@ async function runResumedPipeline(ctx: HandlerContext, input: ResumedPipelineInp
392
393
  return { isSuccess: true as const, data: { outcome: "completed" as const } };
393
394
  } catch (error) {
394
395
  log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
396
+ // The input stepIndex is the suspended step; the failure happened at the step the resumed pass reached.
395
397
  const failedPayload: WorkflowRunFailedPayload = {
396
398
  workflowName,
397
- stepIndex,
399
+ stepIndex:
400
+ error instanceof WorkflowStepError || error instanceof WorkflowSuspensionUnsupportedError
401
+ ? error.stepIndex
402
+ : stepIndex,
398
403
  error: describeWorkflowStepError(error),
399
404
  };
400
405
  await appendRunFailed(ctx, runId, failedPayload);
@@ -75,6 +75,8 @@ export type WorkflowRunFailedPayload = {
75
75
  readonly reason?: string;
76
76
  };
77
77
 
78
+ export type WorkflowRunContext = Pick<HandlerContext, "unsafeAppendEvent">;
79
+
78
80
  export class WorkflowSuspensionUnsupportedError extends Error {
79
81
  constructor(
80
82
  readonly workflowName: string,
@@ -102,7 +104,7 @@ export async function startAndRunWorkflow(args: {
102
104
  readonly triggerEvent: WriteEvent;
103
105
  readonly triggerEventRef: TriggerEventRef;
104
106
  readonly idempotencyKey?: string;
105
- readonly handlerCtx: HandlerContext;
107
+ readonly handlerCtx: WorkflowRunContext;
106
108
  }): Promise<{ readonly outcome: "completed" | "suspended" }> {
107
109
  const fingerprint = computeDefinitionFingerprint(args.workflow);
108
110
 
@@ -123,10 +125,14 @@ export async function startAndRunWorkflow(args: {
123
125
 
124
126
  const steps = buildPipelineSteps(args.workflow.pipelineDef, args.triggerEvent);
125
127
 
128
+ // @cast-boundary msp-to-handler-ctx — event-trigger hands over the narrower
129
+ // MultiStreamApplyContext (no query/write/writeAs). The step vocabulary this
130
+ // runner drives (wait, compute, return) only ever calls unsafeAppendEvent.
131
+ const stepCtx = args.handlerCtx as unknown as HandlerContext;
126
132
  const outcome = await runStepList(
127
133
  steps,
128
134
  args.triggerEvent,
129
- args.handlerCtx,
135
+ stepCtx,
130
136
  {},
131
137
  {},
132
138
  {
@@ -8,6 +8,7 @@
8
8
  // hängt hier die Handler dran und ruft r.screen/r.nav.
9
9
 
10
10
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
11
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
11
12
  import { itemsTranslations } from "./i18n";
12
13
  import {
13
14
  itemActiveScreen,
@@ -29,7 +30,7 @@ const open = {
29
30
 
30
31
  // The boot validator checks server-registered keys, while the client bundle is
31
32
  // locale-first — flip it rather than maintaining the same strings twice.
32
- function keyFirst(byLocale: typeof itemsTranslations): Record<string, Record<string, string>> {
33
+ function keyFirst(byLocale: TranslationsByLocale): Record<string, Record<string, string>> {
33
34
  const out: Record<string, Record<string, string>> = {};
34
35
  for (const [locale, entries] of Object.entries(byLocale)) {
35
36
  for (const [key, value] of Object.entries(entries)) {