@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,7 +22,11 @@ import { hashUnsubscribeAddress } from "./address-opt-out.js";
22
22
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
23
23
  import { buildChannelContext } from "./channel-context.js";
24
24
  import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
25
- import { isAddressOptedOut } from "./db/queries/address-opt-outs.js";
25
+ import {
26
+ type AddressOptOutKey,
27
+ addressOptOutKey,
28
+ selectOptedOutAddresses,
29
+ } from "./db/queries/address-opt-outs.js";
26
30
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
27
31
  import {
28
32
  type ChannelContext,
@@ -411,16 +415,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
411
415
 
412
416
  // Shared by deliverToUser (resolved account address) and deliverDirect (a
413
417
  // route address with no user account) — same suppression rule either way:
414
- // no blind-index key configured means no hash, so nothing to look up.
415
- async function isAddressSuppressed(
418
+ // no blind-index key configured means no hash, so nothing to look up. One
419
+ // query per notify() call, not per recipient and channel.
420
+ async function loadSuppressedAddresses(
421
+ tenantId: TenantId,
422
+ notificationType: string,
423
+ priority: NotifyPriority,
424
+ addresses: Iterable<string>,
425
+ ): Promise<ReadonlySet<AddressOptOutKey>> {
426
+ // Critical priority skips address opt-outs, same rule as user preferences.
427
+ if (priority === "critical") return new Set();
428
+ const addressHashes = new Set<string>();
429
+ for (const address of addresses) {
430
+ const addressHash = hashUnsubscribeAddress(address);
431
+ if (addressHash !== undefined) addressHashes.add(addressHash);
432
+ }
433
+ return selectOptedOutAddresses(db, tenantId, notificationType, [...addressHashes]);
434
+ }
435
+
436
+ function isAddressSuppressed(
437
+ suppressed: ReadonlySet<AddressOptOutKey>,
416
438
  address: string,
439
+ channelName: string,
440
+ ): boolean {
441
+ const addressHash = hashUnsubscribeAddress(address);
442
+ return addressHash !== undefined && suppressed.has(addressOptOutKey(addressHash, channelName));
443
+ }
444
+
445
+ const recipientChannelKey = (userId: string, channelName: string) => `${userId}|${channelName}`;
446
+
447
+ async function isChannelDeliverable(
448
+ userId: string,
417
449
  tenantId: TenantId,
418
450
  notificationType: string,
419
451
  channelName: string,
420
452
  ): Promise<boolean> {
421
- const addressHash = hashUnsubscribeAddress(address);
422
- if (addressHash === undefined) return false;
423
- return isAddressOptedOut(db, tenantId, addressHash, notificationType, channelName);
453
+ if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) return false;
454
+ return isChannelEnabled(userId, tenantId, notificationType, channelName);
455
+ }
456
+
457
+ // Resolves every user's channel addresses up front so the opt-out lookup can
458
+ // cover them all at once. A resolve that throws stays out of the map: the
459
+ // delivery loop resolves it again and reports channel_error as before.
460
+ // Channels the kill switch or the user's preferences disable are never resolved
461
+ // here: resolve may have side effects and deliverToUser skips those channels.
462
+ async function prefetchUserAddresses(
463
+ userIds: readonly string[],
464
+ tenantId: TenantId,
465
+ notificationType: string,
466
+ ): Promise<Map<string, string | null>> {
467
+ const addresses = new Map<string, string | null>();
468
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
469
+ for (const userId of userIds) {
470
+ for (const channel of channels) {
471
+ if (!channel.resolve) continue;
472
+ try {
473
+ if (!(await isChannelDeliverable(userId, tenantId, notificationType, channel.name))) {
474
+ continue;
475
+ }
476
+ addresses.set(
477
+ recipientChannelKey(userId, channel.name),
478
+ await channel.resolve(userId, channelCtx),
479
+ );
480
+ } catch {
481
+ // skip: re-resolved (and logged) by deliverToUser
482
+ }
483
+ }
484
+ }
485
+ return addresses;
424
486
  }
425
487
 
426
488
  // Check if user has disabled this notification+channel combo.
@@ -466,6 +528,8 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
466
528
  tenantId: TenantId,
467
529
  priority: NotifyPriority,
468
530
  run: NotifyRun,
531
+ prefetchedAddresses: ReadonlyMap<string, string | null>,
532
+ suppressed: ReadonlySet<AddressOptOutKey>,
469
533
  ): Promise<void> {
470
534
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
471
535
 
@@ -530,7 +594,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
530
594
  }
531
595
 
532
596
  try {
533
- const address = await channel.resolve(userId, channelCtx);
597
+ const prefetchKey = recipientChannelKey(userId, channel.name);
598
+ const address = prefetchedAddresses.has(prefetchKey)
599
+ ? prefetchedAddresses.get(prefetchKey)
600
+ : await channel.resolve(userId, channelCtx);
534
601
  if (!address) {
535
602
  await logDelivery(run, {
536
603
  tenantId,
@@ -545,10 +612,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
545
612
  continue;
546
613
  }
547
614
 
548
- if (
549
- priority !== "critical" &&
550
- (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
551
- ) {
615
+ if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
552
616
  await logDelivery(run, {
553
617
  tenantId,
554
618
  notificationType,
@@ -602,6 +666,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
602
666
  run: NotifyRun,
603
667
  ): Promise<void> {
604
668
  const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
669
+ const suppressed = await loadSuppressedAddresses(
670
+ tenantId,
671
+ notificationType,
672
+ priority,
673
+ Object.values(route),
674
+ );
605
675
 
606
676
  // Direct routing skips preferences (no user account) but NOT rate limit
607
677
  // — direct sends can still be abused (webhook replays, test harnesses).
@@ -612,10 +682,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
612
682
 
613
683
  // Address opt-out (critical priority skips it, same rule as user
614
684
  // preferences).
615
- if (
616
- priority !== "critical" &&
617
- (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
618
- ) {
685
+ if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
619
686
  await logDelivery(run, {
620
687
  tenantId,
621
688
  notificationType,
@@ -729,8 +796,28 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
729
796
  userIds = to;
730
797
  }
731
798
 
799
+ const prefetchedAddresses =
800
+ priority === "critical"
801
+ ? new Map<string, string | null>()
802
+ : await prefetchUserAddresses(userIds, tenantId, notificationType);
803
+ const suppressed = await loadSuppressedAddresses(
804
+ tenantId,
805
+ notificationType,
806
+ priority,
807
+ [...prefetchedAddresses.values()].filter((address): address is string => !!address),
808
+ );
809
+
732
810
  for (const userId of userIds) {
733
- await deliverToUser(userId, notificationType, data, tenantId, priority, run);
811
+ await deliverToUser(
812
+ userId,
813
+ notificationType,
814
+ data,
815
+ tenantId,
816
+ priority,
817
+ run,
818
+ prefetchedAddresses,
819
+ suppressed,
820
+ );
734
821
  }
735
822
  }
736
823
  return { deliveries: run.deliveries };
@@ -71,6 +71,11 @@ export type UnsubscribeRouteOptions = {
71
71
  };
72
72
 
73
73
  const UNSUBSCRIBE_EXPIRY = "7d";
74
+ const RESUBSCRIBE_EXPIRY = "1h";
75
+ const UNSUBSCRIBE_ISSUER = "kumiko:unsubscribe";
76
+ const RESUBSCRIBE_AUDIENCE = "kumiko:resubscribe";
77
+
78
+ type TokenPurpose = "unsubscribe" | "resubscribe";
74
79
  const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
75
80
 
76
81
  function assertUnsubscribeSecret(secret: string, caller: string): void {
@@ -98,7 +103,7 @@ export async function signUnsubscribeToken(
98
103
  })
99
104
  .setProtectedHeader({ alg: "HS256" })
100
105
  .setSubject(String(payload.userId))
101
- .setIssuer("kumiko:unsubscribe")
106
+ .setIssuer(UNSUBSCRIBE_ISSUER)
102
107
  .setIssuedAt()
103
108
  .setExpirationTime(UNSUBSCRIBE_EXPIRY)
104
109
  .sign(encodedSecret);
@@ -133,12 +138,10 @@ export async function signAddressUnsubscribeToken(
133
138
  })
134
139
  .setProtectedHeader({ alg: "HS256" })
135
140
  .setSubject(addressHash)
136
- .setIssuer("kumiko:unsubscribe")
141
+ .setIssuer(UNSUBSCRIBE_ISSUER)
137
142
  .setIssuedAt()
138
- // No expiry, unlike the user token above: this same token also signs
139
- // the resubscribe (undo) direction, and an unsubscribe link mailed out
140
- // today must keep working indefinitely — there's no signed-in flow for
141
- // a no-account address to request a fresh link from.
143
+ // Only opt-out, so no expiry: a link mailed out today must keep working,
144
+ // there's no signed-in flow for a no-account address to request a fresh one.
142
145
  .sign(encodedSecret)
143
146
  );
144
147
  }
@@ -168,6 +171,35 @@ const CONFIRMATION_PAGE_HEADERS = {
168
171
  "Referrer-Policy": "no-referrer",
169
172
  } as const;
170
173
 
174
+ // Undo token, handed out only in the response of a successful unsubscribe. A
175
+ // separate audience and a short expiry keep the long-lived mailed unsubscribe
176
+ // link from being able to undo an opt-out (and vice versa).
177
+ async function signResubscribeToken(
178
+ verified: VerifiedUnsubscribe,
179
+ encodedSecret: Uint8Array,
180
+ ): Promise<string> {
181
+ const claims = {
182
+ tenantId: verified.tenantId,
183
+ notificationType: verified.notificationType,
184
+ channel: verified.channel,
185
+ };
186
+ const jwt =
187
+ verified.kind === "address"
188
+ ? new jose.SignJWT({
189
+ ...claims,
190
+ addressHash: verified.addressHash,
191
+ kind: "address",
192
+ }).setSubject(verified.addressHash)
193
+ : new jose.SignJWT(claims).setSubject(verified.userId);
194
+ return jwt
195
+ .setProtectedHeader({ alg: "HS256" })
196
+ .setIssuer(UNSUBSCRIBE_ISSUER)
197
+ .setAudience(RESUBSCRIBE_AUDIENCE)
198
+ .setIssuedAt()
199
+ .setExpirationTime(RESUBSCRIBE_EXPIRY)
200
+ .sign(encodedSecret);
201
+ }
202
+
171
203
  // Every throw here must become the same ExtraRouteRejection(400,
172
204
  // unsubscribe_token_invalid) — anything else falls through to the
173
205
  // framework's generic 401 extra_route_signature_invalid mapping, which
@@ -175,6 +207,7 @@ const CONFIRMATION_PAGE_HEADERS = {
175
207
  async function verifyUnsubscribeToken(
176
208
  token: string | undefined,
177
209
  encodedSecret: Uint8Array,
210
+ purpose: TokenPurpose,
178
211
  ): Promise<VerifiedUnsubscribe> {
179
212
  try {
180
213
  if (!token) {
@@ -185,9 +218,22 @@ async function verifyUnsubscribeToken(
185
218
  );
186
219
  }
187
220
 
188
- const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
189
- issuer: "kumiko:unsubscribe",
190
- });
221
+ const { payload: verifiedPayload } =
222
+ purpose === "resubscribe"
223
+ ? await jose.jwtVerify(token, encodedSecret, {
224
+ issuer: UNSUBSCRIBE_ISSUER,
225
+ audience: RESUBSCRIBE_AUDIENCE,
226
+ requiredClaims: ["exp"],
227
+ })
228
+ : await jose.jwtVerify(token, encodedSecret, { issuer: UNSUBSCRIBE_ISSUER });
229
+ // jose only checks `aud` when asked to, so an unsubscribe route would accept an undo token.
230
+ if (purpose === "unsubscribe" && verifiedPayload.aud !== undefined) {
231
+ throw new ExtraRouteRejection(
232
+ 400,
233
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
234
+ "resubscribe token used as unsubscribe token",
235
+ );
236
+ }
191
237
 
192
238
  const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
193
239
  if (addressAttempt.success) {
@@ -329,6 +375,24 @@ function confirmationPage(token: string): string {
329
375
  </html>`;
330
376
  }
331
377
 
378
+ function unsubscribedPage(resubscribeToken: string): string {
379
+ return `<!doctype html>
380
+ <html>
381
+ <head>
382
+ <meta charset="utf-8">
383
+ <meta name="robots" content="noindex">
384
+ <title>Unsubscribed</title>
385
+ </head>
386
+ <body>
387
+ <p>You have been unsubscribed.</p>
388
+ <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
389
+ <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
390
+ <button type="submit">Undo</button>
391
+ </form>
392
+ </body>
393
+ </html>`;
394
+ }
395
+
332
396
  // One-click clients put the token in the query string; only the
333
397
  // confirmation-page form submits it in the body.
334
398
  function tokenFromPostRequest(request: SignatureExtraRouteVerifyRequest): string | undefined {
@@ -377,7 +441,7 @@ export function createUnsubscribeRoutes(
377
441
  "missing unsubscribe token",
378
442
  );
379
443
  }
380
- await verifyUnsubscribeToken(token, encodedSecret);
444
+ await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
381
445
  return { token };
382
446
  },
383
447
  handler: async (c, { token }) =>
@@ -388,7 +452,8 @@ export function createUnsubscribeRoutes(
388
452
  method: "POST",
389
453
  path: DELIVERY_UNSUBSCRIBE_PATH,
390
454
  entry: "signature",
391
- verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret),
455
+ verify: async (request) =>
456
+ verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
392
457
  handler: async (c, verified, deps) => {
393
458
  // Token-verify passed — everything below is a legitimate write. Don't
394
459
  // swallow write-errors as "invalid token", that would mask real bugs
@@ -397,7 +462,9 @@ export function createUnsubscribeRoutes(
397
462
  if (!succeeded) {
398
463
  return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
399
464
  }
400
- return c.html("You have been unsubscribed.", 200, { "Cache-Control": "no-store" });
465
+ return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
466
+ "Cache-Control": "no-store",
467
+ });
401
468
  },
402
469
  });
403
470
 
@@ -408,7 +475,8 @@ export function createUnsubscribeRoutes(
408
475
  method: "POST",
409
476
  path: DELIVERY_RESUBSCRIBE_PATH,
410
477
  entry: "signature",
411
- verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
478
+ verify: async (request) =>
479
+ verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
412
480
  handler: async (c, verified, deps) => {
413
481
  const outcome = await dispatchResubscribeWrite(verified, deps);
414
482
  if (outcome === "limit_reached") {
@@ -108,9 +108,8 @@ export function createFeatureTogglesFeature(
108
108
  // process's handler calls runtime.broadcastToggle, which publishes on
109
109
  // GlobalFeatureToggleRuntime's syncSignal so every process (including
110
110
  // the one that won the cursor) applies the flip to its own snapshot —
111
- // see toggle-runtime.ts and toggle-sync-signal.ts. Without a syncSignal
112
- // configured (no REDIS_URL — single-process dev/test), broadcastToggle
113
- // applies directly; there is nobody else to reach. Named "cache-sync"
111
+ // see toggle-runtime.ts and toggle-sync-signal.ts. The signal defaults to
112
+ // the cache-sync bus (Redis with REDIS_URL, process-local without). Named "cache-sync"
114
113
  // (not "projection" or "audit") because it's side-effect-only
115
114
  // infrastructure.
116
115
  //
@@ -5,7 +5,15 @@ import {
5
5
  type Registry,
6
6
  type ToggleReader,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
9
+ import {
10
+ type ClosableCacheSyncBus,
11
+ createDefaultCacheSyncBus,
12
+ } from "@cosmicdrift/kumiko-framework/redis";
8
13
  import { globalFeatureStateTable } from "./global-feature-state-table.js";
14
+ import { createCacheSyncToggleSignal } from "./toggle-sync-signal.js";
15
+
16
+ const log = createFallbackLogger("kumiko:feature-toggles");
9
17
 
10
18
  // Cross-replica transport for a toggle flip (fw#2625). Deliberately narrow
11
19
  // (featureName + enabled, not a generic channel/payload pair) so a caller
@@ -15,6 +23,8 @@ import { globalFeatureStateTable } from "./global-feature-state-table.js";
15
23
  export type ToggleSyncSignal = {
16
24
  publish(featureName: string, enabled: boolean): void;
17
25
  onMessage(listener: (featureName: string, enabled: boolean) => void): void;
26
+ // Fires after the transport lost and restored its subscription: flips sent meanwhile are gone.
27
+ onResync?(listener: () => void): void;
18
28
  };
19
29
 
20
30
  // Holds the current global-override snapshot in memory and exposes a
@@ -38,6 +48,7 @@ export class GlobalFeatureToggleRuntime {
38
48
  private readonly db: DbConnection,
39
49
  private readonly registry: Registry,
40
50
  private readonly syncSignal?: ToggleSyncSignal,
51
+ private readonly ownedBus?: ClosableCacheSyncBus,
41
52
  ) {
42
53
  // Every process, including the one that calls broadcastToggle, learns
43
54
  // of a flip through this same subscription — mirrors the SSE broker's
@@ -46,6 +57,16 @@ export class GlobalFeatureToggleRuntime {
46
57
  this.syncSignal?.onMessage((featureName, enabled) => {
47
58
  this.apply(featureName, enabled);
48
59
  });
60
+ this.syncSignal?.onResync?.(() => {
61
+ this.refresh().catch((err: unknown) => {
62
+ log.error("snapshot reload after sync reconnect failed", { err });
63
+ });
64
+ });
65
+ }
66
+
67
+ // Only the runtime built by createFeatureToggleRuntime without an explicit signal owns one.
68
+ async close(): Promise<void> {
69
+ await this.ownedBus?.close();
49
70
  }
50
71
 
51
72
  async initialize(): Promise<void> {
@@ -121,15 +142,23 @@ export class GlobalFeatureToggleRuntime {
121
142
  };
122
143
  }
123
144
 
124
- // Factory for app-boot wiring: instantiate, initialize, return both the
125
- // runtime (for the set-handler to refresh) and the callback (for
126
- // createDispatcher's effectiveFeatures option).
145
+ // Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
146
+ // set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
147
+ // syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
148
+ // REDIS_URL is set, process-local otherwise. close() releases a bus it created.
127
149
  export async function createFeatureToggleRuntime(
128
150
  db: DbConnection,
129
151
  registry: Registry,
130
152
  syncSignal?: ToggleSyncSignal,
131
153
  ): Promise<GlobalFeatureToggleRuntime> {
132
- const runtime = new GlobalFeatureToggleRuntime(db, registry, syncSignal);
133
- await runtime.initialize();
154
+ const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
155
+ const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
156
+ const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
157
+ try {
158
+ await runtime.initialize();
159
+ } catch (err) {
160
+ await runtime.close();
161
+ throw err;
162
+ }
134
163
  return runtime;
135
164
  }
@@ -1,12 +1,15 @@
1
1
  import {
2
+ CACHE_SYNC_TOPICS,
3
+ type CacheSyncBus,
2
4
  createRedisPubSubSignal,
3
5
  type RedisClientOptions,
6
+ redisChannelPrefixFromEnv,
4
7
  } from "@cosmicdrift/kumiko-framework/redis";
5
8
  import type { ToggleSyncSignal } from "./toggle-runtime.js";
6
9
 
7
10
  // Single fixed channel — unlike the SSE broker there's no per-tenant/
8
11
  // per-user variance to multiplex, every toggle flip is global.
9
- const TOGGLE_SYNC_CHANNEL = "kumiko:feature-toggles:cache-sync";
12
+ const TOGGLE_SYNC_CHANNEL_NAME = "kumiko:feature-toggles:cache-sync";
10
13
 
11
14
  export type RedisToggleSyncSignal = ToggleSyncSignal & {
12
15
  // Not on ToggleSyncSignal — the in-memory/no-signal path (GlobalFeature
@@ -27,31 +30,31 @@ function isTogglePayload(value: unknown): value is { featureName: string; enable
27
30
  // fw#2625: gives toggle-cache-sync the cross-replica transport its shared
28
31
  // dispatcher cursor needs — without this, only the one process that won
29
32
  // the shared cursor's toggle-set event would ever learn about a flip.
30
- // Same REDIS_URL-gated assumption as the SSE broker: a deployment with
31
- // replicas > 1 is expected to set REDIS_URL, so app-boot code should build
32
- // this only when REDIS_URL is present and pass it into
33
- // createFeatureToggleRuntime.
33
+ // Standalone Redis transport for callers that want their own connections;
34
+ // createFeatureToggleRuntime builds a cache-sync-bus signal by default.
34
35
  export function createRedisToggleSyncSignal(
35
36
  redisUrl: string,
36
37
  clientOptions?: RedisClientOptions,
38
+ channelPrefix: string = redisChannelPrefixFromEnv(),
37
39
  ): RedisToggleSyncSignal {
40
+ const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
38
41
  const signal = createRedisPubSubSignal({
39
42
  redisUrl,
40
- channelPattern: TOGGLE_SYNC_CHANNEL,
43
+ channelPattern: toggleSyncChannel,
41
44
  label: "feature-toggles",
42
45
  ...(clientOptions ? { clientOptions } : {}),
43
46
  });
44
47
 
45
48
  return {
46
49
  publish(featureName, enabled) {
47
- signal.publish(TOGGLE_SYNC_CHANNEL, { featureName, enabled });
50
+ signal.publish(toggleSyncChannel, { featureName, enabled });
48
51
  },
49
52
  onMessage(listener) {
50
53
  signal.onMessage((_channel, payload) => {
51
54
  if (!isTogglePayload(payload)) {
52
55
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.log in a raw PubSubSignal handler.
53
56
  console.error(
54
- `[kumiko:feature-toggles] dropping malformed cache-sync message on "${TOGGLE_SYNC_CHANNEL}"`,
57
+ `[kumiko:feature-toggles] dropping malformed cache-sync message on "${toggleSyncChannel}"`,
55
58
  );
56
59
  // skip: malformed message already logged above, nothing to apply
57
60
  return;
@@ -62,3 +65,23 @@ export function createRedisToggleSyncSignal(
62
65
  close: signal.close,
63
66
  };
64
67
  }
68
+
69
+ // Reuses the framework cache-sync bus (local without REDIS_URL, Redis-backed with it) so
70
+ // toggles need no Pub/Sub wiring of their own. The bus applies a publish locally first.
71
+ export function createCacheSyncToggleSignal(bus: CacheSyncBus): ToggleSyncSignal {
72
+ return {
73
+ publish(featureName, enabled) {
74
+ bus.publish(CACHE_SYNC_TOPICS.featureToggle, { featureName, enabled });
75
+ },
76
+ onMessage(listener) {
77
+ bus.subscribe(CACHE_SYNC_TOPICS.featureToggle, (payload) => {
78
+ // skip: foreign or malformed payload on the topic
79
+ if (!isTogglePayload(payload)) return;
80
+ listener(payload.featureName, payload.enabled);
81
+ });
82
+ },
83
+ onResync(listener) {
84
+ bus.onResync(listener);
85
+ },
86
+ };
87
+ }
@@ -42,7 +42,10 @@ type FileRefRow = {
42
42
  };
43
43
 
44
44
  // Declared variants are implicitly public once the entityType check passes — `fieldName`/`variant` allow per-variant opt-out.
45
+ // entityType/entityId/fieldName of a FileRef are client-supplied at upload, so a predicate that
46
+ // serves "the public image of X" must also match `fileRefId` against the row it trusts.
45
47
  export type DerivativePublicPredicateArgs = {
48
+ readonly fileRefId: string;
46
49
  readonly entityId: string;
47
50
  readonly tenantId: TenantId;
48
51
  readonly fieldName: string;
@@ -119,7 +122,13 @@ export const publicVariantQuery = defineQueryHandler({
119
122
  }
120
123
 
121
124
  const isPublic = await usage.options.isPublic(
122
- { entityId, tenantId: ctx.user.tenantId, fieldName, variant: query.payload.variant },
125
+ {
126
+ fileRefId: query.payload.fileRefId,
127
+ entityId,
128
+ tenantId: ctx.user.tenantId,
129
+ fieldName,
130
+ variant: query.payload.variant,
131
+ },
123
132
  ctx,
124
133
  );
125
134
  if (!isPublic) return null;
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "FileHandle.derive throws for suffixes outside <name>-<16 hex>",
6
+ "detail": "Final review batch D.\n- `FileHandle.derive(suffix)` now throws unless the suffix is `<name>-<16 hex>` (build it with `variantSuffix(name, spec)`). Free-form suffixes such as `derive(\"thumb\")` wrote keys the forget/tenant-destroy sweep could not recognize, so those binaries survived erasure. Callers passing a bare name must switch to `variantSuffix`.\n- `resolveKmsWiring` / `requireKmsWiring` throw when a `*_CIPHERTEXT` slot is set without its plaintext, pointing at the async entry points, instead of a misleading \"trio required\" error or a silent plaintext-PII fallback.\n- Event-PII owner/`whenAbsent` resolution now also runs without a configured KMS, so a missing owner fails in dev like in prod.\n- PII event backfill queues rebuilds under a per-run migration id, so a peer replica's re-queue is no longer cleared by another run.\n- `seedAdminGuarded` re-writes the canonical admin's email so the blind index is repaired, and skips undecryptable foreign user rows instead of aborting the boot seed.\n- A tenant-bound `FileContext.list` refuses prefixes outside the tenant's key space.\n- An entityList whose create screen was dropped for the caller's roles now carries `createUnavailable`, so the renderer no longer falls back to a generic create form.\n- Boot validation rejects an object-form `redirect` with `idFrom` whose same-feature target screen carries no id.\n- `defineFeature` throws when `dedupeOptions` holds a nested object (it can never compare equal across two mounts).\n- `FileContext.ref` and `list` (tenant-bound) refuse keys outside `${tenantId}/`; list columns accept `virtual: true`; `httpRoute` session-only routes now pass the global IP rate limit; tenant timezone cache TTL is 30 s; text fields without a personal stance warn at boot; the orphan-derivative sweep keeps derivatives whose original still exists; `piiFields` owner fields that can never yield a string throw unless `whenAbsent` is set.",
7
+ "migration": "Build the suffix with variantSuffix(name, spec) instead of passing a free-form string."
8
+ },
9
+ {
10
+ "version": "0.354.0",
11
+ "type": "breaking",
12
+ "title": "A tenant-bound FileContext.list throws for prefixes outside the tenant's key space",
13
+ "migration": "Pass a prefix that starts with `${tenantId}/`."
14
+ },
15
+ {
16
+ "version": "0.354.0",
17
+ "type": "breaking",
18
+ "title": "A tenant-bound FileContext.ref throws for keys outside the tenant's key space",
19
+ "migration": "Use keys that start with `${tenantId}/`."
20
+ },
21
+ {
22
+ "version": "0.354.0",
23
+ "type": "fix",
24
+ "title": "The signed download URL carries a neutral file name instead of the personal fileName"
25
+ },
2
26
  {
3
27
  "version": "0.341.0",
4
28
  "type": "improvement",
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "The orphan-derivative sweep keeps derivatives whose original still exists in storage"
6
+ },
2
7
  {
3
8
  "version": "0.275.0",
4
9
  "type": "breaking",
@@ -140,6 +140,10 @@ export async function sweepOrphanedDerivativesJob(
140
140
  { limit: 1 },
141
141
  );
142
142
  if (owners.length > 0) return "skipped";
143
+ // Binaries written through ctx.files.ref(key) (and their derive()d
144
+ // variants) never get a fileRef row; an original still sitting in storage
145
+ // means the derivative is not an orphan.
146
+ if (await candidate.provider.exists(candidate.originalKey)) return "skipped";
143
147
  if (dryRun) {
144
148
  dryRunWouldDelete++;
145
149
  return "skipped";