@cosmicdrift/kumiko-samples 0.352.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 (186) 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 +26 -7
  5. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  6. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  7. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  8. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  9. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  10. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  11. package/packages/bundled-features/src/audit/changes.json +6 -0
  12. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  13. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  18. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  19. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  21. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  23. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  24. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  25. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  26. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  27. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  30. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  31. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  32. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  33. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  34. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  35. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  36. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  37. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  39. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  40. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  41. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
  42. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  43. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  44. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  45. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  46. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  48. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  49. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  50. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  51. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  52. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  53. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  54. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  55. package/packages/bundled-features/src/delivery/changes.json +6 -0
  56. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  57. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  58. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  59. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  60. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  61. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  62. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  63. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  64. package/packages/bundled-features/src/files/changes.json +24 -0
  65. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  66. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  67. package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
  68. package/packages/bundled-features/src/folders/changes.json +6 -0
  69. package/packages/bundled-features/src/folders/feature.ts +2 -12
  70. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  71. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  72. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  73. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  74. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  75. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  76. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  77. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  78. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  79. package/packages/bundled-features/src/jobs/changes.json +30 -0
  80. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  81. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  82. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  83. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  84. package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
  85. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  86. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  87. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  88. package/packages/bundled-features/src/jobs/index.ts +2 -0
  89. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  90. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  91. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  92. package/packages/bundled-features/src/ledger/changes.json +23 -0
  93. package/packages/bundled-features/src/ledger/entity.ts +17 -5
  94. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  95. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  96. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  97. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  98. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  99. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  100. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  101. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  102. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  103. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  104. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  105. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  106. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  107. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  108. package/packages/bundled-features/src/seo/changes.json +5 -0
  109. package/packages/bundled-features/src/seo/feature.ts +23 -7
  110. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  111. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  112. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  113. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  114. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  115. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  116. package/packages/bundled-features/src/shared/index.ts +4 -2
  117. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  118. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  119. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  120. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  121. package/packages/bundled-features/src/tags/feature.ts +2 -12
  122. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  123. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  125. package/packages/bundled-features/src/tenant/changes.json +19 -0
  126. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  127. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  128. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  129. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  130. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  131. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  132. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  133. package/packages/bundled-features/src/tenant/index.ts +1 -0
  134. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  135. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  136. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  137. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  138. package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
  139. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  140. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  141. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  145. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
  146. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
  147. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
  148. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  149. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  150. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  151. package/packages/bundled-features/src/user/changes.json +15 -0
  152. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  153. package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
  154. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  155. package/packages/bundled-features/src/user/seeding.ts +34 -0
  156. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  157. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  158. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  159. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  160. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  161. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  162. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  163. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  164. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  165. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
  166. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
  167. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  168. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  169. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  170. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  171. package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
  172. package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
  173. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  174. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  175. package/samples/apps/ui-walkthrough/package.json +3 -0
  176. package/samples/apps/use-all-bundled/README.md +1 -2
  177. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  178. package/samples/apps/user-data-rights-demo/README.md +1 -3
  179. package/samples/recipes/legal-pages/README.md +3 -4
  180. package/samples/recipes/managed-pages/README.md +2 -2
  181. package/samples/recipes/public-share-token/README.md +4 -5
  182. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  183. package/samples/recipes/security-baseline/README.md +6 -0
  184. package/samples/recipes/seed-migration/README.md +0 -2
  185. package/samples/recipes/tenant-settings/README.md +1 -1
  186. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -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";
@@ -55,6 +55,7 @@ import {
55
55
  type StorageProviderHookCtx,
56
56
  type TenantDataDestroyHook,
57
57
  type TenantDataHookCtx,
58
+ type TenantDestroyHookResult,
58
59
  type TenantId,
59
60
  } from "@cosmicdrift/kumiko-framework/engine";
60
61
  import {
@@ -64,6 +65,7 @@ import {
64
65
  storageKeyStemPrefix,
65
66
  tenantStoragePrefixes,
66
67
  } from "@cosmicdrift/kumiko-framework/files";
68
+ import { type ChunkedMigrationResult, runChunkedMigration } from "../shared/index.js";
67
69
 
68
70
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
69
71
 
@@ -72,6 +74,8 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
72
74
  // or a single unbounded array for a tenant with a huge prefix.
73
75
  const SURVIVOR_CHECK_BATCH_SIZE = 500;
74
76
 
77
+ const DESTROY_CHUNK_SIZE = 500;
78
+
75
79
  function chunk<T>(items: readonly T[], size: number): T[][] {
76
80
  const chunks: T[][] = [];
77
81
  for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
@@ -82,27 +86,54 @@ function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): bool
82
86
  return prefixes.some((prefix) => storageKey.startsWith(prefix));
83
87
  }
84
88
 
89
+ function toDestroyHookResult(
90
+ result: ChunkedMigrationResult,
91
+ failureMessage: () => string | undefined,
92
+ ): TenantDestroyHookResult {
93
+ // A failure on the last item ends the run as "done" (nothing left to batch),
94
+ // so the failed count, not the stop reason, decides fail-closed.
95
+ if (result.failed > 0) {
96
+ throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
97
+ }
98
+ return { done: result.stoppedReason === "done", processed: result.migrated };
99
+ }
100
+
85
101
  export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
86
- const rows = await ctx.db.selectMany<{ id: string; storageKey: string }>(fileRefsTable, {
87
- tenantId: ctx.tenantId,
88
- });
89
102
  const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
90
103
  const user = createSystemUser(ctx.tenantId);
91
- for (const row of rows) {
92
- if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
93
- await deleteHandedOverBinary(ctx, row.storageKey);
94
- }
95
- const result = await crud.forget({ id: row.id }, user, ctx.db);
96
- // Executor writes return {isSuccess:false} instead of throwing — a
97
- // discarded result would report this destroy stage "succeeded" while the
98
- // row (and its PII fileName) survives. Throw so the pipeline's
99
- // retry/abandon handling sees it.
100
- if (!result.isSuccess) {
101
- throw new Error(
102
- `files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${result.error.message}`,
103
- );
104
- }
105
- }
104
+ let firstFailure: string | undefined;
105
+ // Converges without a cursor: forget() hard-deletes, so each batch query
106
+ // returns the next rows.
107
+ const result = await runChunkedMigration<{ id: string; storageKey: string }>({
108
+ nextBatch: () =>
109
+ ctx.db.selectMany<{ id: string; storageKey: string }>(
110
+ fileRefsTable,
111
+ { tenantId: ctx.tenantId },
112
+ { limit: DESTROY_CHUNK_SIZE },
113
+ ),
114
+ migrateRow: async (row) => {
115
+ if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
116
+ await deleteHandedOverBinary(ctx, row.storageKey);
117
+ }
118
+ const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
119
+ // Executor writes return {isSuccess:false} instead of throwing — a
120
+ // discarded result would report this destroy stage "succeeded" while the
121
+ // row (and its PII fileName) survives. Throw so the pipeline's
122
+ // retry/abandon handling sees it.
123
+ if (!forgotten.isSuccess) {
124
+ throw new Error(
125
+ `files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`,
126
+ );
127
+ }
128
+ return "migrated";
129
+ },
130
+ maxFailures: 1,
131
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
132
+ onRowError: (_row, err) => {
133
+ firstFailure = err instanceof Error ? err.message : String(err);
134
+ },
135
+ });
136
+ return toDestroyHookResult(result, () => firstFailure);
106
137
  };
107
138
 
108
139
  // The bytes live under the tenant that uploaded them (`<tenantId>/…` or
@@ -170,17 +201,36 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
170
201
  }
171
202
  // Provider resolved — a list()/delete() failure from here IS fail-closed:
172
203
  // the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
173
- // it, and the next sweep tick retries (list+delete are idempotent, so this
174
- // converges rather than double-deleting or erroring on a missing key).
204
+ // it and retries up to the stage's attempt cap before abandoning the destroy
205
+ // (list+delete are idempotent, so a retry converges rather than
206
+ // double-deleting or erroring on a missing key).
207
+ let processed = 0;
175
208
  for (const prefix of tenantStoragePrefixes(tenantId)) {
176
209
  const keys = await provider.list(prefix);
177
210
  const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
178
- for (const key of keys) {
179
- if (keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix))) continue;
180
- assertSafeStorageKey(key);
181
- await provider.delete(key);
182
- }
211
+ const deletableKeys = keys.filter(
212
+ (key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)),
213
+ );
214
+ const pending = [...deletableKeys];
215
+ let firstFailure: string | undefined;
216
+ const result = await runChunkedMigration<string>({
217
+ nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
218
+ migrateRow: async (key) => {
219
+ assertSafeStorageKey(key);
220
+ await provider.delete(key);
221
+ return "migrated";
222
+ },
223
+ maxFailures: 1,
224
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
225
+ onRowError: (_key, err) => {
226
+ firstFailure = err instanceof Error ? err.message : String(err);
227
+ },
228
+ });
229
+ const hookResult = toDestroyHookResult(result, () => firstFailure);
230
+ processed += result.migrated;
231
+ if (!hookResult.done) return { done: false, processed };
183
232
  }
233
+ return { done: true, processed };
184
234
  };
185
235
 
186
236
  // Any listed key that is itself a surviving row's exact storageKey means a
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "folder:delete removes assignments whose host entity is gone instead of refusing forever",
6
+ "detail": "An assignment pointing at a hard-deleted, soft-deleted or unregistered host blocked folder:delete permanently with folder_has_assignments, because clear-folder needs a visible host and could not remove it. folder:delete now removes such assignments through the event-store executor (normal delete events) before the check, without the caller's read gate. Assignments of live hosts, including ones the caller cannot see, still block with folder_has_assignments. A refused delete removes nothing. The cleanup runs lazily at folder:delete, not in a host-delete hook, so restoring a soft-deleted host before then keeps its folder."
7
+ },
2
8
  {
3
9
  "version": "0.246.0",
4
10
  "type": "improvement",
@@ -25,7 +25,7 @@ import {
25
25
  type EntityDefinition,
26
26
  type FeatureRegistrar,
27
27
  } from "@cosmicdrift/kumiko-framework/engine";
28
- import { hasWhereRule } from "../shared/index.js";
28
+ import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
29
29
  import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants.js";
30
30
  import { createFolderAssignmentEntity, folderEntity } from "./entity.js";
31
31
  import { createClearFolderHandler } from "./handlers/clear-folder.write.js";
@@ -166,17 +166,7 @@ export function createFoldersFeature(opts: FoldersFeatureOptions = {}): typeof f
166
166
  ) {
167
167
  return foldersFeature;
168
168
  }
169
- if (hasWhereRule(opts.ownership?.write)) {
170
- throw new Error(
171
- "createFoldersFeature({ ownership }): ownership.write must not contain a " +
172
- '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
173
- "layer (the read path, via buildOwnershipClause). Write paths that " +
174
- "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
175
- "evaluate them, so such a rule can only ever deny — boot validation " +
176
- "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
177
- "leave it unset.",
178
- );
179
- }
169
+ assertNoWhereRuleInOwnershipWrite("createFoldersFeature", opts.ownership?.write);
180
170
  if (opts.parents !== undefined && opts.parents.length === 0) {
181
171
  throw new Error(
182
172
  "createFoldersFeature({ parents }): parents must not be an empty array — " +