@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
@@ -67,7 +67,7 @@ export type MfaSetupPreauthScreenProps = {
67
67
  readonly subtitle?: ReactNode;
68
68
  /** Fired once confirm succeeds — the caller owns session refresh, see the
69
69
  * file-level comment above. */
70
- readonly onSuccess?: () => void;
70
+ readonly onSuccess?: (landingPath?: string) => void;
71
71
  readonly onCancel?: () => void;
72
72
  };
73
73
 
@@ -146,7 +146,7 @@ export function MfaSetupPreauthScreen({
146
146
  }
147
147
  return;
148
148
  }
149
- onSuccess?.();
149
+ onSuccess?.(res.data.landingPath);
150
150
  } catch {
151
151
  setError("setup_failed");
152
152
  } finally {
@@ -173,9 +173,9 @@ export function MfaSetupPreauthScreen({
173
173
  ) : null}
174
174
 
175
175
  {!setup && (
176
- <Section
177
- testId="mfa-setup-preauth-intro"
178
- actions={
176
+ <Section testId="mfa-setup-preauth-intro">
177
+ <span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
178
+ <div className="flex justify-end">
179
179
  <Button
180
180
  variant="primary"
181
181
  onClick={() => void startSetup()}
@@ -184,27 +184,13 @@ export function MfaSetupPreauthScreen({
184
184
  >
185
185
  {t("auth.mfa.setup.start")}
186
186
  </Button>
187
- }
188
- >
189
- <span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
187
+ </div>
190
188
  </Section>
191
189
  )}
192
190
 
193
191
  {setup && (
194
192
  <Form onSubmit={onSubmit}>
195
- <Section
196
- testId="mfa-setup-preauth-setup"
197
- actions={
198
- <Button
199
- type="submit"
200
- variant="primary"
201
- loading={busy}
202
- disabled={busy || !acknowledged || code.length !== 6}
203
- >
204
- {t("auth.mfa.setup.confirm")}
205
- </Button>
206
- }
207
- >
193
+ <Section testId="mfa-setup-preauth-setup">
208
194
  <div className="flex flex-col items-center gap-2 text-center">
209
195
  <span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
210
196
  {/* qrcode's own SVG string output, not user input — safe to inline */}
@@ -251,6 +237,17 @@ export function MfaSetupPreauthScreen({
251
237
  autoComplete="one-time-code"
252
238
  />
253
239
  </Field>
240
+
241
+ <div className="flex justify-end">
242
+ <Button
243
+ type="submit"
244
+ variant="primary"
245
+ loading={busy}
246
+ disabled={busy || !acknowledged || code.length !== 6}
247
+ >
248
+ {t("auth.mfa.setup.confirm")}
249
+ </Button>
250
+ </div>
254
251
  </Section>
255
252
  </Form>
256
253
  )}
@@ -20,7 +20,7 @@ export type MfaVerifyScreenProps = {
20
20
  /** Called after the server confirms the code and the session state has
21
21
  * refreshed. Optional — apps that swap this screen back out purely on
22
22
  * session.status changing to "authenticated" don't need it. */
23
- readonly onSuccess?: () => void;
23
+ readonly onSuccess?: (landingPath?: string) => void;
24
24
  // Some failures (challenge_expired, too_many_attempts) have no retry path —
25
25
  // the challengeToken is dead. Without this, the host has no way back to
26
26
  // login short of a full page reload. Optional: apps that always mount
@@ -68,7 +68,7 @@ export function MfaVerifyScreen({
68
68
  if (res.kind === "success") {
69
69
  await session.refresh();
70
70
  setSubmitting(false);
71
- onSuccess?.();
71
+ onSuccess?.(res.data.landingPath);
72
72
  return;
73
73
  }
74
74
  setSubmitting(false);
@@ -39,18 +39,18 @@ export type BillingInfo<TTier extends string> = {
39
39
  readonly tier: string;
40
40
  readonly providerName: string;
41
41
  } | null;
42
- readonly prices: Readonly<Partial<Record<string, string>>>;
42
+ readonly prices: TierPrices<TTier>;
43
43
  };
44
44
 
45
+ export type TierPrices<TTier extends string> = { readonly [K in TTier]?: string };
46
+
45
47
  export type BillingInfoQueryDeps<TTier extends string> = {
46
48
  readonly roles: readonly string[];
47
49
  readonly resolveTier: (db: TenantDb, tenantId: TenantId) => Promise<TTier>;
48
50
  // ctx is the same HandlerContext the returned handler receives — apps read
49
51
  // their extraContext fields (e.g. `billingPrices`) off it and validate them
50
52
  // at that boundary.
51
- readonly getBillingPrices: (
52
- ctx: HandlerContext,
53
- ) => Readonly<Partial<Record<string, string>>> | null;
53
+ readonly getBillingPrices: (ctx: HandlerContext) => TierPrices<TTier> | null;
54
54
  };
55
55
 
56
56
  export function createBillingInfoQueryConfig<TTier extends string>(
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "BillingInfo.prices and getBillingPrices are keyed by the tier union",
6
+ "migration": "A getBillingPrices that returns keys outside your tier union no longer compiles; remove or rename those keys."
7
+ },
2
8
  {
3
9
  "version": "0.346.0",
4
10
  "type": "fix",
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "in_app_messages gets a tenant, user, created_at index",
6
+ "migration": "Run `kumiko schema generate`; it emits an in-place CREATE INDEX with no DROP and no rebuild."
7
+ }
8
+ ]
@@ -24,7 +24,7 @@ export const inAppChannel: DeliveryChannel = {
24
24
  });
25
25
 
26
26
  if (ctx.sseBroker) {
27
- ctx.sseBroker.pushToChannel(tenantChannel(ctx.tenantId), {
27
+ await ctx.sseBroker.pushToChannel(tenantChannel(ctx.tenantId), {
28
28
  type: "channel-in-app:event:delivered",
29
29
  data: {
30
30
  id: row?.id,
@@ -2,6 +2,7 @@ import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  boolean,
4
4
  type EntityTableMeta,
5
+ index,
5
6
  instant,
6
7
  table as pgTable,
7
8
  serial,
@@ -10,18 +11,23 @@ import {
10
11
  uuid,
11
12
  } from "@cosmicdrift/kumiko-framework/db";
12
13
 
13
- export const inAppMessagesTable = pgTable("in_app_messages", {
14
- id: serial("id").primaryKey(),
15
- tenantId: uuid("tenant_id").notNull(),
16
- userId: text("user_id").notNull(),
17
- notificationType: text("notification_type").notNull(),
18
- title: text("title").notNull(),
19
- body: text("body"),
20
- data: text("data"), // JSON string for action, screen, etc.
21
- isRead: boolean("is_read").default(false).notNull(),
22
- readAt: instant("read_at"),
23
- createdAt: instant("created_at").default(sql`now()`).notNull(),
24
- });
14
+ export const inAppMessagesTable = pgTable(
15
+ "in_app_messages",
16
+ {
17
+ id: serial("id").primaryKey(),
18
+ tenantId: uuid("tenant_id").notNull(),
19
+ userId: text("user_id").notNull(),
20
+ notificationType: text("notification_type").notNull(),
21
+ title: text("title").notNull(),
22
+ body: text("body"),
23
+ data: text("data"), // JSON string for action, screen, etc.
24
+ isRead: boolean("is_read").default(false).notNull(),
25
+ readAt: instant("read_at"),
26
+ createdAt: instant("created_at").default(sql`now()`).notNull(),
27
+ },
28
+ // Every inbox, unread-count, mark-all-read and userData export/delete read filters by tenant + user.
29
+ (t) => [index("in_app_messages_tenant_user_created_idx").on(t.tenantId, t.userId, t.createdAt)],
30
+ );
25
31
 
26
32
  // Derived from inAppMessagesTable (table() attaches the meta as a Symbol) so
27
33
  // the two can't drift, instead of hand-duplicating the column list.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "forget-subject: the SUBJECT_FORGOTTEN audit now commits before the handler tx does and records cleanupError when a derived-data sweep fails",
6
+ "detail": "The SUBJECT_FORGOTTEN audit event is appended through the outside-transaction db, so it commits before the handler transaction does and survives a later rollback. If the blind-index sweep or search purge fails, the event carries cleanupError (error code or name, never the message) and the handler then fails; retry to finish the cleanup. The user-lifecycle step runs in a savepoint and no longer aborts the handler transaction."
7
+ },
8
+ {
9
+ "version": "0.354.0",
10
+ "type": "fix",
11
+ "title": "Writing a personal field of an erased data subject answers 409 subject_erased instead of 500",
12
+ "detail": "Writing PII to an erased data subject answers 409 `subject_erased`, rebinding a hook's unsafeRaw grant no longer inherits db.global() writes, jobs wait for boot gates before consuming\nA write that has to encrypt a personal field for a subject whose key was erased (crypto-shredding) now fails with a `ConflictError` (409, reason `subject_erased`, i18n in en and de) instead of a 500, on the executor write path and in `encryptForDirectWrite`. A hook's own `unsafeRaw` grant (`withUnsafeRawGrant`) no longer carries the handler's `globalWrites` grant. The job runner starts its BullMQ worker only after the boot gates passed, so a job queued by a previous run cannot execute against an unmet boot precondition. `buildServer` warns at boot when `r.systemScope()` features exist but no RateLimitResolver is configured. The auth, MFA, PAT, change-email and request-deletion handlers declare only the escape-hatch grants they use."
13
+ },
2
14
  {
3
15
  "version": "0.338.0",
4
16
  "type": "improvement",
@@ -1,5 +1,6 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
3
+ import { runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
3
4
  import {
4
5
  computeBlindIndex,
5
6
  configuredBlindIndexKey,
@@ -31,8 +32,10 @@ import {
31
32
  type WriteFailure,
32
33
  writeFailure,
33
34
  } from "@cosmicdrift/kumiko-framework/errors";
34
- import { append } from "@cosmicdrift/kumiko-framework/event-store";
35
- import { assertIrreversibleOperationAllowed } from "@cosmicdrift/kumiko-framework/pipeline";
35
+ import {
36
+ appendDomainEventCore,
37
+ assertIrreversibleOperationAllowed,
38
+ } from "@cosmicdrift/kumiko-framework/pipeline";
36
39
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
37
40
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
38
41
  import * as z from "zod";
@@ -46,6 +49,7 @@ import {
46
49
  } from "../../user-data-rights/index.js";
47
50
  import {
48
51
  CRYPTO_SHREDDING_AGGREGATE_TYPE,
52
+ CRYPTO_SHREDDING_FEATURE_NAME,
49
53
  RECORD_ENTITY_NOT_REGISTERED,
50
54
  SUBJECT_FORGET_DENIED_EVENT_NAME,
51
55
  SUBJECT_FORGOTTEN_EVENT_NAME,
@@ -65,6 +69,9 @@ export const subjectForgottenSchema = z.object({
65
69
  subjectKey: z.string().min(1),
66
70
  reason: z.string().min(10),
67
71
  forgottenBy: z.string().min(1),
72
+ // Error code or name of a failed derived-data sweep, never its message (which
73
+ // can name the subject). Present only when the handler failed after the erase.
74
+ cleanupError: z.string().min(1).optional(),
68
75
  });
69
76
 
70
77
  export const subjectForgetDeniedSchema = z.object({
@@ -234,16 +241,8 @@ async function appendDenialAuditEvent(
234
241
  }),
235
242
  );
236
243
  }
237
- const eventDef = ctx.registry.getEvent(SUBJECT_FORGET_DENIED_EVENT_NAME);
238
- if (!eventDef) {
239
- return writeFailure(
240
- new InternalError({
241
- message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
242
- }),
243
- );
244
- }
245
244
  const blindIndexKey = configuredBlindIndexKey();
246
- const payload = subjectForgetDeniedSchema.parse({
245
+ const payload: z.infer<typeof subjectForgetDeniedSchema> = {
247
246
  // The denial lands in the REQUESTING actor's own tenant-scoped stream —
248
247
  // it must never materialise the foreign subject's identifiers there. A
249
248
  // plaintext subjectKey/aggregateId would survive as a permanent record
@@ -260,31 +259,132 @@ async function appendDenialAuditEvent(
260
259
  forgottenBy: event.user.id,
261
260
  actorTenantId: event.user.tenantId,
262
261
  denial: denialCode,
263
- });
264
- const reqCtx = requestContext.get();
265
- // This event is the only proof the denial happened. Skipping
266
- // runProjectionsForEvent here is fine — nothing projects
267
- // crypto-shredding:event:forget-denied.
268
- await append(denialAuditRunner, {
269
- aggregateId: generateId(),
270
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
271
- // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
272
- // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
273
- tenantId: event.user.tenantId,
274
- expectedVersion: 0,
275
- type: SUBJECT_FORGET_DENIED_EVENT_NAME,
276
- eventVersion: eventDef.version,
277
- payload,
278
- metadata: {
279
- userId: event.user.id,
280
- ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
281
- ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
282
- ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
262
+ };
263
+ await appendDomainEventCore(
264
+ {
265
+ registry: ctx.registry,
266
+ db: denialAuditRunner,
267
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
268
+ // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
269
+ tenantId: event.user.tenantId,
270
+ userId: String(event.user.id),
271
+ callSiteLabel: "forget-subject denial audit",
272
+ callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
283
273
  },
284
- });
274
+ {
275
+ // Fresh stream per denial: no version to read, nothing to conflict with.
276
+ aggregateId: generateId(),
277
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
278
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
279
+ payload,
280
+ },
281
+ );
285
282
  return null;
286
283
  }
287
284
 
285
+ // Name or driver code of the error, never its message: a message can carry the
286
+ // subject key or row values and would land in a permanent audit event.
287
+ function sweepErrorLabel(err: unknown): string {
288
+ if (typeof err === "object" && err !== null && "code" in err) {
289
+ const code = err.code;
290
+ if (typeof code === "string" && code.length > 0) return code;
291
+ }
292
+ return err instanceof Error && err.name.length > 0 ? err.name : "UnknownError";
293
+ }
294
+
295
+ // The key is already erased when this runs, so a failing sweep must not skip the
296
+ // audit event: the failure is returned as a label for the event instead of thrown.
297
+ // The savepoint keeps a database error from aborting the handler transaction.
298
+ async function sweepDerivedSubjectData(
299
+ ctx: HandlerContext,
300
+ subject: SubjectId,
301
+ subjectKey: string,
302
+ crossTenantRunner: DbRunner,
303
+ ): Promise<string | undefined> {
304
+ try {
305
+ await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
306
+ // Blind-index sweep (#818): nulls bidx columns now so the deterministic
307
+ // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
308
+ await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
309
+
310
+ // Derived search index still holds plaintext (#1610) — purge next to the
311
+ // blind-index sweep. No adapter → no-op (apps without search).
312
+ if (ctx.searchAdapter) {
313
+ await purgeSearchDocumentsForSubject(
314
+ crossTenantSubjectRunner,
315
+ ctx.registry.features,
316
+ ctx.searchAdapter,
317
+ subjectKey,
318
+ subject,
319
+ );
320
+ }
321
+ });
322
+ return undefined;
323
+ } catch (err) {
324
+ return sweepErrorLabel(err);
325
+ }
326
+ }
327
+
328
+ // Graceful fail: email-subscribers and other non-user entities may use
329
+ // user-style subject keys without having an actual user row — the key erase is
330
+ // the important part for GDPR compliance; the lifecycle update is best-effort for
331
+ // real users. The savepoint confines the failure so the handler transaction
332
+ // stays usable for the audit event.
333
+ async function closeUserLoginDoorBestEffort(
334
+ ctx: HandlerContext,
335
+ userId: string,
336
+ lifecycleRunner: DbRunner,
337
+ ): Promise<void> {
338
+ try {
339
+ await runInSavepointIfSupported(lifecycleRunner, async (userLifecycleRunner) => {
340
+ await updateUserLifecycle(userLifecycleRunner, userId, { status: USER_STATUS.Deleted });
341
+ if (ctx.registry.features.has("personal-access-tokens")) {
342
+ await revokeAllPatTokensForUser(userLifecycleRunner, userId);
343
+ }
344
+ });
345
+ } catch {
346
+ // skip: no user row for this subject key (e.g. email subscribers); key erase and sweeps already ran.
347
+ }
348
+ }
349
+
350
+ // Through the outside-transaction db when available, so the audit event commits
351
+ // even though the handler may fail afterwards (sweep error) and roll back its own
352
+ // transaction. Without that source the event stays in the handler transaction.
353
+ async function appendSubjectForgotten(
354
+ ctx: HandlerContext,
355
+ event: WriteEvent<z.infer<typeof forgetSubjectSchema>>,
356
+ aggregateId: string,
357
+ payload: z.infer<typeof subjectForgottenSchema>,
358
+ outsideRunner: DbRunner | undefined,
359
+ ): Promise<void> {
360
+ if (!outsideRunner) {
361
+ await ctx.unsafeAppendEvent({
362
+ aggregateId,
363
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
364
+ type: SUBJECT_FORGOTTEN_EVENT_NAME,
365
+ payload,
366
+ });
367
+ } else {
368
+ await appendDomainEventCore(
369
+ {
370
+ registry: ctx.registry,
371
+ db: outsideRunner,
372
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw bypasses TenantDb's scoping (fw#2452).
373
+ tenantId: event.user.tenantId,
374
+ userId: String(event.user.id),
375
+ callSiteLabel: "forget-subject audit",
376
+ callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
377
+ },
378
+ {
379
+ aggregateId,
380
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
381
+ type: SUBJECT_FORGOTTEN_EVENT_NAME,
382
+ payload,
383
+ },
384
+ );
385
+ }
386
+ }
387
+
288
388
  // Manual crypto-shred for a DPO / platform operator: erases the subject's
289
389
  // DEK immediately (all its PII ciphertext becomes unreadable, reads render
290
390
  // "[[erased]]") and appends the audit event. Forget is final — the adapter
@@ -306,7 +406,7 @@ export const forgetSubjectWrite = defineWriteHandler({
306
406
  escapeHatch: {
307
407
  grants: ["unsafeRaw"],
308
408
  reason:
309
- "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
409
+ "the denial and SUBJECT_FORGOTTEN audit appends name the caller's own tenant stream on the outside-transaction db; " +
310
410
  "the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
311
411
  "the blind-index sweep and search purge address the subject across tenants; the user " +
312
412
  "lifecycle update and PAT revoke run on the SYSTEM user stream.",
@@ -377,22 +477,12 @@ export const forgetSubjectWrite = defineWriteHandler({
377
477
  eraseReason: event.payload.reason,
378
478
  });
379
479
 
380
- // Blind-index sweep (#818): nulls bidx columns now so the deterministic
381
- // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
382
- const crossTenantSubjectRunner = ctx.db.unsafeRaw();
383
- await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
384
-
385
- // Derived search index still holds plaintext (#1610) — purge next to the
386
- // blind-index sweep. No adapter → no-op (apps without search).
387
- if (ctx.searchAdapter) {
388
- await purgeSearchDocumentsForSubject(
389
- crossTenantSubjectRunner,
390
- ctx.registry.features,
391
- ctx.searchAdapter,
392
- subjectKey,
393
- subject,
394
- );
395
- }
480
+ const cleanupError = await sweepDerivedSubjectData(
481
+ ctx,
482
+ subject,
483
+ subjectKey,
484
+ ctx.db.unsafeRaw(),
485
+ );
396
486
 
397
487
  // User subject: close the login door. DEK-erase makes the passwordHash
398
488
  // ciphertext unreadable, but status + PATs are standalone credentials —
@@ -409,33 +499,28 @@ export const forgetSubjectWrite = defineWriteHandler({
409
499
  // crash recovery is safe. User-feature guard: without the user feature
410
500
  // read_users doesn't exist (crypto-only stack). Tenant subjects have no
411
501
  // credentials.
412
- // Graceful fail: email-subscribers and other non-user entities may use
413
- // user-style subject keys without having an actual user row — the key
414
- // erase above is the important part for GDPR compliance; the lifecycle
415
- // update is best-effort for real users.
416
502
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
417
- try {
418
- const userLifecycleRunner = ctx.db.unsafeRaw();
419
- await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
420
- if (ctx.registry.features.has("personal-access-tokens")) {
421
- await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
422
- }
423
- } catch {
424
- // User row may not exist (e.g. email subscribers with user-style
425
- // subject keys). Key erase + blind-index sweep above already ran.
426
- }
503
+ await closeUserLoginDoorBestEffort(ctx, raw.userId, ctx.db.unsafeRaw());
427
504
  }
428
505
 
429
- await ctx.unsafeAppendEvent({
430
- aggregateId: subjectAggregateId(raw),
431
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
432
- type: SUBJECT_FORGOTTEN_EVENT_NAME,
433
- payload: {
506
+ await appendSubjectForgotten(
507
+ ctx,
508
+ event,
509
+ subjectAggregateId(raw),
510
+ {
434
511
  subjectKey,
435
512
  reason: event.payload.reason,
436
513
  forgottenBy: event.user.id,
514
+ ...(cleanupError !== undefined && { cleanupError }),
437
515
  },
438
- });
516
+ ctx.dbOutsideTransaction?.unsafeRaw(),
517
+ );
518
+
519
+ if (cleanupError !== undefined) {
520
+ throw new InternalError({
521
+ message: `[crypto-shredding] forget-subject erased the key but a derived-data sweep failed (${cleanupError}); retry to finish the cleanup.`,
522
+ });
523
+ }
439
524
 
440
525
  return { isSuccess: true as const, data: { subjectKey } };
441
526
  },
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "POST /resubscribe requires the undo token from the POST /unsubscribe response",
6
+ "migration": "Clients that called the resubscribe route with the unsubscribe token must send the undo token instead: read it from the hidden token field of the page POST /unsubscribe returns (aud kumiko:resubscribe, valid for 1 hour). The unsubscribe token is rejected on /resubscribe with 400, and the undo token is rejected on /unsubscribe."
7
+ },
2
8
  {
3
9
  "version": "0.352.0",
4
10
  "type": "breaking",
@@ -1,23 +1,29 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { notificationAddressOptOutsTable } from "../../tables.js";
5
+
6
+ export type AddressOptOutKey = string;
7
+
8
+ export function addressOptOutKey(addressHash: string, channel: string): AddressOptOutKey {
9
+ return `${addressHash}|${channel}`;
10
+ }
4
11
 
5
12
  // Exact match only — unlike notification-preferences there is no wildcard
6
13
  // ("*") semantics for address opt-outs, the token that creates a row always
7
- // carries one concrete notificationType/channel pair.
8
- export async function isAddressOptedOut(
14
+ // carries one concrete notificationType/channel pair. One query covers every
15
+ // recipient of a notify() call.
16
+ export async function selectOptedOutAddresses(
9
17
  db: DbConnection,
10
18
  tenantId: TenantId,
11
- addressHash: string,
12
19
  notificationType: string,
13
- channel: string,
14
- ): Promise<boolean> {
15
- const rows = await unsafeReadRetrying<{ readonly id: string }>(
20
+ addressHashes: readonly string[],
21
+ ): Promise<ReadonlySet<AddressOptOutKey>> {
22
+ if (addressHashes.length === 0) return new Set();
23
+ const rows = await selectMany<{ readonly addressHash: string; readonly channel: string }>(
16
24
  db,
17
- `SELECT id FROM read_notification_address_opt_outs
18
- WHERE tenant_id = $1 AND address_hash = $2 AND notification_type = $3 AND channel = $4
19
- LIMIT 1`,
20
- [tenantId, addressHash, notificationType, channel],
25
+ notificationAddressOptOutsTable,
26
+ { tenantId, notificationType, addressHash: { in: addressHashes } },
21
27
  );
22
- return rows.length > 0;
28
+ return new Set(rows.map((row) => addressOptOutKey(row.addressHash, row.channel)));
23
29
  }