@cosmicdrift/kumiko-bundled-features 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 (259) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +17 -5
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
  9. package/dist/agent-tools/manifest-deny-qns.js +17 -0
  10. package/dist/agent-tools/tool-catalog.d.ts +2 -1
  11. package/dist/agent-tools/tool-catalog.js +4 -1
  12. package/dist/agent-tools/tool-dispatch.js +1 -1
  13. package/dist/audit/handlers/list.query.js +13 -8
  14. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  15. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  16. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  17. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  18. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  19. package/dist/auth-email-password/handlers/login.write.js +1 -0
  20. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  21. package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
  22. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  23. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  24. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  25. package/dist/auth-email-password/index.d.ts +2 -0
  26. package/dist/auth-email-password/index.js +2 -0
  27. package/dist/auth-email-password/seeding.d.ts +2 -0
  28. package/dist/auth-email-password/seeding.js +30 -8
  29. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  30. package/dist/auth-email-password/signup-activation.js +41 -0
  31. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  32. package/dist/auth-email-password/signup-token-store.js +8 -3
  33. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  34. package/dist/auth-email-password/web/auth-gate.js +5 -5
  35. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  36. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  37. package/dist/auth-email-password/web/login-screen.js +2 -2
  38. package/dist/auth-email-password/web/user-menu.js +1 -1
  39. package/dist/auth-mfa/feature.js +5 -0
  40. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  41. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  42. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  43. package/dist/auth-mfa/screens.js +2 -2
  44. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  45. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  46. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  47. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  48. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  49. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  50. package/dist/billing-foundation/checkout-core.d.ts +7 -0
  51. package/dist/billing-foundation/checkout-core.js +7 -2
  52. package/dist/billing-foundation/entities.d.ts +8 -0
  53. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
  54. package/dist/billing-foundation/projection.d.ts +8 -0
  55. package/dist/cap-counter/entity.d.ts +1 -0
  56. package/dist/cap-overview/access.js +2 -9
  57. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
  58. package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
  59. package/dist/channel-in-app/in-app-channel.js +1 -1
  60. package/dist/channel-in-app/tables.js +4 -2
  61. package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
  62. package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
  63. package/dist/config/handlers/values.query.d.ts +3 -1
  64. package/dist/config/handlers/values.query.js +7 -2
  65. package/dist/config/table.d.ts +6 -0
  66. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  67. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  68. package/dist/custom-fields/entity.d.ts +4 -0
  69. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  70. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  71. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  72. package/dist/delivery/delivery-service.js +60 -12
  73. package/dist/delivery/public-names.js +1 -1
  74. package/dist/delivery/tables.d.ts +6 -0
  75. package/dist/delivery/unsubscribe.js +66 -14
  76. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  77. package/dist/feature-toggles/feature.js +2 -3
  78. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  79. package/dist/feature-toggles/toggle-runtime.js +30 -6
  80. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  81. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  82. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  83. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  84. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  85. package/dist/files-tenant-data/hooks.js +61 -24
  86. package/dist/folders/entity.d.ts +8 -0
  87. package/dist/folders/feature.js +2 -10
  88. package/dist/folders/handlers/delete-folder.write.js +31 -4
  89. package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
  90. package/dist/form-draft/db/queries/cleanup.js +4 -3
  91. package/dist/form-draft/entity.d.ts +2 -0
  92. package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
  93. package/dist/form-draft/handlers/cleanup.job.js +11 -5
  94. package/dist/foundation-shared/index.d.ts +1 -1
  95. package/dist/foundation-shared/index.js +1 -1
  96. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  97. package/dist/foundation-shared/mail-host-policy.js +13 -4
  98. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  99. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  100. package/dist/inbound-provider-imap/feature.js +2 -0
  101. package/dist/inbound-provider-imap/imap-client.js +3 -4
  102. package/dist/jobs/constants.d.ts +1 -0
  103. package/dist/jobs/constants.js +1 -0
  104. package/dist/jobs/db/queries/retention.d.ts +1 -0
  105. package/dist/jobs/db/queries/retention.js +5 -0
  106. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  107. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  108. package/dist/jobs/feature.js +7 -1
  109. package/dist/jobs/handlers/list.query.d.ts +2 -1
  110. package/dist/jobs/handlers/list.query.js +40 -15
  111. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  112. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  113. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  114. package/dist/jobs/i18n.js +1 -0
  115. package/dist/jobs/index.d.ts +2 -0
  116. package/dist/jobs/index.js +1 -0
  117. package/dist/jobs/job-run-logger.d.ts +2 -2
  118. package/dist/jobs/job-run-logger.js +98 -9
  119. package/dist/jobs/job-run-table.d.ts +6 -0
  120. package/dist/jobs/tenant-job-failure-table.js +15 -2
  121. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  122. package/dist/jobs/tenant-job-run-table.js +34 -0
  123. package/dist/ledger/entity.d.ts +24 -8
  124. package/dist/ledger/entity.js +17 -5
  125. package/dist/ledger/feature.js +3 -0
  126. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  127. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  128. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  129. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  130. package/dist/ledger/schedule-subject-pair.js +24 -0
  131. package/dist/ledger/schemas.js +8 -2
  132. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  133. package/dist/mail-transport-smtp/feature.js +4 -13
  134. package/dist/managed-pages/feature.js +28 -10
  135. package/dist/managed-pages/table.d.ts +12 -0
  136. package/dist/notes-history/entity.d.ts +12 -0
  137. package/dist/notes-history/entity.js +4 -0
  138. package/dist/notes-history/feature.js +2 -10
  139. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  140. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  141. package/dist/personal-access-tokens/screens.js +14 -3
  142. package/dist/secrets/table.d.ts +1 -0
  143. package/dist/seo/feature.js +28 -6
  144. package/dist/sessions/feature.d.ts +1 -0
  145. package/dist/sessions/feature.js +9 -8
  146. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  147. package/dist/sessions/handlers/detail.query.js +2 -2
  148. package/dist/sessions/handlers/list.query.d.ts +1 -1
  149. package/dist/sessions/handlers/list.query.js +5 -3
  150. package/dist/sessions/schema/user-session.d.ts +4 -0
  151. package/dist/sessions/screens.d.ts +2 -2
  152. package/dist/sessions/screens.js +6 -7
  153. package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
  154. package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
  155. package/dist/shared/encrypt-for-direct-write.js +10 -2
  156. package/dist/shared/index.d.ts +4 -3
  157. package/dist/shared/index.js +4 -3
  158. package/dist/shared/parent-visibility.d.ts +2 -1
  159. package/dist/shared/parent-visibility.js +21 -0
  160. package/dist/shared/rate-limited-text-response.d.ts +2 -0
  161. package/dist/shared/rate-limited-text-response.js +9 -0
  162. package/dist/shared/row-bound-grant.js +16 -2
  163. package/dist/shared/session-locale-field.d.ts +1 -0
  164. package/dist/shared/session-locale-field.js +6 -0
  165. package/dist/tags/entity.d.ts +9 -0
  166. package/dist/tags/feature.js +2 -10
  167. package/dist/template-resolver/api.d.ts +2 -2
  168. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  169. package/dist/template-resolver/handlers/list.query.js +2 -2
  170. package/dist/template-resolver/table.d.ts +48 -30
  171. package/dist/template-resolver/user-content-table.d.ts +34 -20
  172. package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
  173. package/dist/tenant/decrypt-tenant-names.js +10 -0
  174. package/dist/tenant/feature.js +7 -1
  175. package/dist/tenant/handlers/me.query.js +5 -1
  176. package/dist/tenant/handlers/memberships.query.js +3 -2
  177. package/dist/tenant/handlers/tenant-directory.query.js +9 -3
  178. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  179. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  180. package/dist/tenant/index.d.ts +1 -0
  181. package/dist/tenant/index.js +1 -0
  182. package/dist/tenant/invitation-table.d.ts +54 -8
  183. package/dist/tenant/invitation-table.js +6 -1
  184. package/dist/tenant/invited-membership.d.ts +8 -6
  185. package/dist/tenant/invited-membership.js +28 -7
  186. package/dist/tenant/membership-table.d.ts +4 -0
  187. package/dist/tenant/schema/tenant.d.ts +18 -10
  188. package/dist/tenant/schema/tenant.js +4 -6
  189. package/dist/tenant/screens.js +1 -1
  190. package/dist/tenant-handover/move-entity-graph.js +11 -18
  191. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  192. package/dist/tenant-handover/transfer-graph.js +8 -75
  193. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  194. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  195. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  196. package/dist/tenant-lifecycle/constants.js +5 -0
  197. package/dist/tenant-lifecycle/events.d.ts +5 -0
  198. package/dist/tenant-lifecycle/events.js +5 -0
  199. package/dist/tenant-lifecycle/feature.js +6 -3
  200. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
  201. package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
  202. package/dist/tenant-lifecycle/stages.d.ts +8 -1
  203. package/dist/tenant-lifecycle/stages.js +33 -4
  204. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  205. package/dist/tenant-lifecycle/testing.js +42 -0
  206. package/dist/tier-engine/entity.d.ts +2 -0
  207. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  208. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  209. package/dist/user/handlers/detail.query.js +2 -0
  210. package/dist/user/handlers/list.query.js +6 -2
  211. package/dist/user/handlers/update.write.js +1 -0
  212. package/dist/user/schema/user.d.ts +22 -8
  213. package/dist/user/seeding.d.ts +1 -0
  214. package/dist/user/seeding.js +20 -0
  215. package/dist/user-data-rights/boot-checks.js +5 -0
  216. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
  217. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  218. package/dist/user-data-rights/index.d.ts +1 -0
  219. package/dist/user-data-rights/index.js +1 -0
  220. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  221. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  222. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  223. package/dist/user-data-rights/schema/export-job.d.ts +16 -8
  224. package/dist/user-profile/handlers/change-email.write.js +8 -2
  225. package/dist/waitlist/entity.d.ts +22 -6
  226. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  227. package/dist/workflow-runner/event-trigger.js +63 -15
  228. package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
  229. package/dist/workflow-runner/runner.d.ts +2 -1
  230. package/dist/workflow-runner/runner.js +5 -1
  231. package/package.json +35 -10
  232. package/src/agent-tools/changes.json +12 -0
  233. package/src/audit/changes.json +6 -0
  234. package/src/auth-email-password/changes.json +24 -0
  235. package/src/auth-mfa/changes.json +7 -0
  236. package/src/billing-foundation/changes.json +6 -0
  237. package/src/channel-in-app/changes.json +8 -1
  238. package/src/crypto-shredding/changes.json +12 -0
  239. package/src/delivery/changes.json +6 -0
  240. package/src/files/changes.json +24 -0
  241. package/src/files-tenant-data/changes.json +5 -0
  242. package/src/folders/changes.json +6 -0
  243. package/src/form-draft/changes.json +7 -1
  244. package/src/inbound-provider-imap/changes.json +6 -0
  245. package/src/jobs/changes.json +30 -0
  246. package/src/ledger/changes.json +23 -0
  247. package/src/managed-pages/changes.json +6 -0
  248. package/src/notes-history/changes.json +13 -0
  249. package/src/seo/changes.json +5 -0
  250. package/src/template-resolver/changes.json +5 -0
  251. package/src/tenant/changes.json +19 -0
  252. package/src/tenant-handover/changes.json +12 -0
  253. package/src/tenant-lifecycle/changes.json +22 -0
  254. package/src/tier-engine/changes.json +5 -0
  255. package/src/user/changes.json +15 -0
  256. package/src/user-data-rights/changes.json +6 -0
  257. package/src/workflow-runner/changes.json +5 -0
  258. package/dist/shared/has-where-rule.d.ts +0 -2
  259. package/dist/shared/has-where-rule.js +0 -10
@@ -32,6 +32,9 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
32
32
  addressHash: z.string().min(1),
33
33
  });
34
34
  const UNSUBSCRIBE_EXPIRY = "7d";
35
+ const RESUBSCRIBE_EXPIRY = "1h";
36
+ const UNSUBSCRIBE_ISSUER = "kumiko:unsubscribe";
37
+ const RESUBSCRIBE_AUDIENCE = "kumiko:resubscribe";
35
38
  const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
36
39
  function assertUnsubscribeSecret(secret, caller) {
37
40
  if (secret.length < MIN_UNSUBSCRIBE_SECRET_LENGTH) {
@@ -51,7 +54,7 @@ export async function signUnsubscribeToken(payload, secret) {
51
54
  })
52
55
  .setProtectedHeader({ alg: "HS256" })
53
56
  .setSubject(String(payload.userId))
54
- .setIssuer("kumiko:unsubscribe")
57
+ .setIssuer(UNSUBSCRIBE_ISSUER)
55
58
  .setIssuedAt()
56
59
  .setExpirationTime(UNSUBSCRIBE_EXPIRY)
57
60
  .sign(encodedSecret);
@@ -79,12 +82,10 @@ export async function signAddressUnsubscribeToken(payload, secret) {
79
82
  })
80
83
  .setProtectedHeader({ alg: "HS256" })
81
84
  .setSubject(addressHash)
82
- .setIssuer("kumiko:unsubscribe")
85
+ .setIssuer(UNSUBSCRIBE_ISSUER)
83
86
  .setIssuedAt()
84
- // No expiry, unlike the user token above: this same token also signs
85
- // the resubscribe (undo) direction, and an unsubscribe link mailed out
86
- // today must keep working indefinitely — there's no signed-in flow for
87
- // a no-account address to request a fresh link from.
87
+ // Only opt-out, so no expiry: a link mailed out today must keep working,
88
+ // there's no signed-in flow for a no-account address to request a fresh one.
88
89
  .sign(encodedSecret));
89
90
  }
90
91
  // RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
@@ -94,18 +95,50 @@ const CONFIRMATION_PAGE_HEADERS = {
94
95
  "Cache-Control": "no-store",
95
96
  "Referrer-Policy": "no-referrer",
96
97
  };
98
+ // Undo token, handed out only in the response of a successful unsubscribe. A
99
+ // separate audience and a short expiry keep the long-lived mailed unsubscribe
100
+ // link from being able to undo an opt-out (and vice versa).
101
+ async function signResubscribeToken(verified, encodedSecret) {
102
+ const claims = {
103
+ tenantId: verified.tenantId,
104
+ notificationType: verified.notificationType,
105
+ channel: verified.channel,
106
+ };
107
+ const jwt = verified.kind === "address"
108
+ ? new jose.SignJWT({
109
+ ...claims,
110
+ addressHash: verified.addressHash,
111
+ kind: "address",
112
+ }).setSubject(verified.addressHash)
113
+ : new jose.SignJWT(claims).setSubject(verified.userId);
114
+ return jwt
115
+ .setProtectedHeader({ alg: "HS256" })
116
+ .setIssuer(UNSUBSCRIBE_ISSUER)
117
+ .setAudience(RESUBSCRIBE_AUDIENCE)
118
+ .setIssuedAt()
119
+ .setExpirationTime(RESUBSCRIBE_EXPIRY)
120
+ .sign(encodedSecret);
121
+ }
97
122
  // Every throw here must become the same ExtraRouteRejection(400,
98
123
  // unsubscribe_token_invalid) — anything else falls through to the
99
124
  // framework's generic 401 extra_route_signature_invalid mapping, which
100
125
  // would leak jose's internal error text into the response body.
101
- async function verifyUnsubscribeToken(token, encodedSecret) {
126
+ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
102
127
  try {
103
128
  if (!token) {
104
129
  throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
105
130
  }
106
- const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
107
- issuer: "kumiko:unsubscribe",
108
- });
131
+ const { payload: verifiedPayload } = purpose === "resubscribe"
132
+ ? await jose.jwtVerify(token, encodedSecret, {
133
+ issuer: UNSUBSCRIBE_ISSUER,
134
+ audience: RESUBSCRIBE_AUDIENCE,
135
+ requiredClaims: ["exp"],
136
+ })
137
+ : await jose.jwtVerify(token, encodedSecret, { issuer: UNSUBSCRIBE_ISSUER });
138
+ // jose only checks `aud` when asked to, so an unsubscribe route would accept an undo token.
139
+ if (purpose === "unsubscribe" && verifiedPayload.aud !== undefined) {
140
+ throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "resubscribe token used as unsubscribe token");
141
+ }
109
142
  const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
110
143
  if (addressAttempt.success) {
111
144
  const parsed = addressAttempt.data;
@@ -211,6 +244,23 @@ function confirmationPage(token) {
211
244
  </body>
212
245
  </html>`;
213
246
  }
247
+ function unsubscribedPage(resubscribeToken) {
248
+ return `<!doctype html>
249
+ <html>
250
+ <head>
251
+ <meta charset="utf-8">
252
+ <meta name="robots" content="noindex">
253
+ <title>Unsubscribed</title>
254
+ </head>
255
+ <body>
256
+ <p>You have been unsubscribed.</p>
257
+ <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
258
+ <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
259
+ <button type="submit">Undo</button>
260
+ </form>
261
+ </body>
262
+ </html>`;
263
+ }
214
264
  // One-click clients put the token in the query string; only the
215
265
  // confirmation-page form submits it in the body.
216
266
  function tokenFromPostRequest(request) {
@@ -251,7 +301,7 @@ export function createUnsubscribeRoutes(options) {
251
301
  if (!token) {
252
302
  throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
253
303
  }
254
- await verifyUnsubscribeToken(token, encodedSecret);
304
+ await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
255
305
  return { token };
256
306
  },
257
307
  handler: async (c, { token }) => c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
@@ -260,7 +310,7 @@ export function createUnsubscribeRoutes(options) {
260
310
  method: "POST",
261
311
  path: DELIVERY_UNSUBSCRIBE_PATH,
262
312
  entry: "signature",
263
- verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret),
313
+ verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
264
314
  handler: async (c, verified, deps) => {
265
315
  // Token-verify passed — everything below is a legitimate write. Don't
266
316
  // swallow write-errors as "invalid token", that would mask real bugs
@@ -269,7 +319,9 @@ export function createUnsubscribeRoutes(options) {
269
319
  if (!succeeded) {
270
320
  return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
271
321
  }
272
- return c.html("You have been unsubscribed.", 200, { "Cache-Control": "no-store" });
322
+ return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
323
+ "Cache-Control": "no-store",
324
+ });
273
325
  },
274
326
  });
275
327
  // Undo direction: token only from the JSON/form body, never the query
@@ -279,7 +331,7 @@ export function createUnsubscribeRoutes(options) {
279
331
  method: "POST",
280
332
  path: DELIVERY_RESUBSCRIBE_PATH,
281
333
  entry: "signature",
282
- verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
334
+ verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
283
335
  handler: async (c, verified, deps) => {
284
336
  const outcome = await dispatchResubscribeWrite(verified, deps);
285
337
  if (outcome === "limit_reached") {
@@ -14,6 +14,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
14
14
  readonly type: "text";
15
15
  readonly description?: string;
16
16
  readonly maxLength?: number;
17
+ readonly minLength?: number;
17
18
  readonly required?: boolean;
18
19
  readonly searchable?: boolean;
19
20
  readonly sortable?: boolean;
@@ -35,6 +36,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
35
36
  readonly type: "text";
36
37
  readonly description?: string;
37
38
  readonly maxLength?: number;
39
+ readonly minLength?: number;
38
40
  readonly required?: boolean;
39
41
  readonly searchable?: boolean;
40
42
  readonly sortable?: boolean;
@@ -75,6 +77,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
75
77
  readonly type: "text";
76
78
  readonly description?: string;
77
79
  readonly maxLength?: number;
80
+ readonly minLength?: number;
78
81
  readonly required?: boolean;
79
82
  readonly searchable?: boolean;
80
83
  readonly sortable?: boolean;
@@ -96,6 +99,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
96
99
  readonly type: "text";
97
100
  readonly description?: string;
98
101
  readonly maxLength?: number;
102
+ readonly minLength?: number;
99
103
  readonly required?: boolean;
100
104
  readonly searchable?: boolean;
101
105
  readonly sortable?: boolean;
@@ -64,9 +64,8 @@ export function createFeatureTogglesFeature(options = {}) {
64
64
  // process's handler calls runtime.broadcastToggle, which publishes on
65
65
  // GlobalFeatureToggleRuntime's syncSignal so every process (including
66
66
  // the one that won the cursor) applies the flip to its own snapshot —
67
- // see toggle-runtime.ts and toggle-sync-signal.ts. Without a syncSignal
68
- // configured (no REDIS_URL — single-process dev/test), broadcastToggle
69
- // applies directly; there is nobody else to reach. Named "cache-sync"
67
+ // see toggle-runtime.ts and toggle-sync-signal.ts. The signal defaults to
68
+ // the cache-sync bus (Redis with REDIS_URL, process-local without). Named "cache-sync"
70
69
  // (not "projection" or "audit") because it's side-effect-only
71
70
  // infrastructure.
72
71
  //
@@ -1,16 +1,20 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
3
4
  export type ToggleSyncSignal = {
4
5
  publish(featureName: string, enabled: boolean): void;
5
6
  onMessage(listener: (featureName: string, enabled: boolean) => void): void;
7
+ onResync?(listener: () => void): void;
6
8
  };
7
9
  export declare class GlobalFeatureToggleRuntime {
8
10
  private readonly db;
9
11
  private readonly registry;
10
12
  private readonly syncSignal?;
13
+ private readonly ownedBus?;
11
14
  private snapshot;
12
15
  private appliedDuringLoad;
13
- constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined);
16
+ constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined, ownedBus?: ClosableCacheSyncBus | undefined);
17
+ close(): Promise<void>;
14
18
  initialize(): Promise<void>;
15
19
  refresh(): Promise<void>;
16
20
  apply(featureName: string, enabled: boolean): void;
@@ -1,6 +1,10 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { computeEffectiveFeatures, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { createDefaultCacheSyncBus, } from "@cosmicdrift/kumiko-framework/redis";
3
5
  import { globalFeatureStateTable } from "./global-feature-state-table.js";
6
+ import { createCacheSyncToggleSignal } from "./toggle-sync-signal.js";
7
+ const log = createFallbackLogger("kumiko:feature-toggles");
4
8
  // Holds the current global-override snapshot in memory and exposes a
5
9
  // synchronous reader — the dispatcher's feature-gate calls it on every
6
10
  // handler invocation, so this must not do I/O on the hot path. The
@@ -16,14 +20,16 @@ export class GlobalFeatureToggleRuntime {
16
20
  db;
17
21
  registry;
18
22
  syncSignal;
23
+ ownedBus;
19
24
  snapshot = new Map();
20
25
  // Flips applied while a snapshot load is in flight. The load's SELECT may have
21
26
  // started before the flip committed, so its rows can be older than these.
22
27
  appliedDuringLoad;
23
- constructor(db, registry, syncSignal) {
28
+ constructor(db, registry, syncSignal, ownedBus) {
24
29
  this.db = db;
25
30
  this.registry = registry;
26
31
  this.syncSignal = syncSignal;
32
+ this.ownedBus = ownedBus;
27
33
  // Every process, including the one that calls broadcastToggle, learns
28
34
  // of a flip through this same subscription — mirrors the SSE broker's
29
35
  // publish-then-echo-to-self pattern instead of a separate local-apply
@@ -31,6 +37,15 @@ export class GlobalFeatureToggleRuntime {
31
37
  this.syncSignal?.onMessage((featureName, enabled) => {
32
38
  this.apply(featureName, enabled);
33
39
  });
40
+ this.syncSignal?.onResync?.(() => {
41
+ this.refresh().catch((err) => {
42
+ log.error("snapshot reload after sync reconnect failed", { err });
43
+ });
44
+ });
45
+ }
46
+ // Only the runtime built by createFeatureToggleRuntime without an explicit signal owns one.
47
+ async close() {
48
+ await this.ownedBus?.close();
34
49
  }
35
50
  async initialize() {
36
51
  const pendingFlips = new Map();
@@ -99,11 +114,20 @@ export class GlobalFeatureToggleRuntime {
99
114
  return computeEffectiveFeatures(this.registry, reader);
100
115
  };
101
116
  }
102
- // Factory for app-boot wiring: instantiate, initialize, return both the
103
- // runtime (for the set-handler to refresh) and the callback (for
104
- // createDispatcher's effectiveFeatures option).
117
+ // Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
118
+ // set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
119
+ // syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
120
+ // REDIS_URL is set, process-local otherwise. close() releases a bus it created.
105
121
  export async function createFeatureToggleRuntime(db, registry, syncSignal) {
106
- const runtime = new GlobalFeatureToggleRuntime(db, registry, syncSignal);
107
- await runtime.initialize();
122
+ const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
123
+ const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
124
+ const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
125
+ try {
126
+ await runtime.initialize();
127
+ }
128
+ catch (err) {
129
+ await runtime.close();
130
+ throw err;
131
+ }
108
132
  return runtime;
109
133
  }
@@ -1,6 +1,7 @@
1
- import { type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
1
+ import { type CacheSyncBus, type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
2
2
  import type { ToggleSyncSignal } from "./toggle-runtime.js";
3
3
  export type RedisToggleSyncSignal = ToggleSyncSignal & {
4
4
  close(): Promise<void>;
5
5
  };
6
- export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions): RedisToggleSyncSignal;
6
+ export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions, channelPrefix?: string): RedisToggleSyncSignal;
7
+ export declare function createCacheSyncToggleSignal(bus: CacheSyncBus): ToggleSyncSignal;
@@ -1,7 +1,7 @@
1
- import { createRedisPubSubSignal, } from "@cosmicdrift/kumiko-framework/redis";
1
+ import { CACHE_SYNC_TOPICS, createRedisPubSubSignal, redisChannelPrefixFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
2
2
  // Single fixed channel — unlike the SSE broker there's no per-tenant/
3
3
  // per-user variance to multiplex, every toggle flip is global.
4
- const TOGGLE_SYNC_CHANNEL = "kumiko:feature-toggles:cache-sync";
4
+ const TOGGLE_SYNC_CHANNEL_NAME = "kumiko:feature-toggles:cache-sync";
5
5
  function isTogglePayload(value) {
6
6
  return (typeof value === "object" &&
7
7
  value !== null &&
@@ -11,26 +11,25 @@ function isTogglePayload(value) {
11
11
  // fw#2625: gives toggle-cache-sync the cross-replica transport its shared
12
12
  // dispatcher cursor needs — without this, only the one process that won
13
13
  // the shared cursor's toggle-set event would ever learn about a flip.
14
- // Same REDIS_URL-gated assumption as the SSE broker: a deployment with
15
- // replicas > 1 is expected to set REDIS_URL, so app-boot code should build
16
- // this only when REDIS_URL is present and pass it into
17
- // createFeatureToggleRuntime.
18
- export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
14
+ // Standalone Redis transport for callers that want their own connections;
15
+ // createFeatureToggleRuntime builds a cache-sync-bus signal by default.
16
+ export function createRedisToggleSyncSignal(redisUrl, clientOptions, channelPrefix = redisChannelPrefixFromEnv()) {
17
+ const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
19
18
  const signal = createRedisPubSubSignal({
20
19
  redisUrl,
21
- channelPattern: TOGGLE_SYNC_CHANNEL,
20
+ channelPattern: toggleSyncChannel,
22
21
  label: "feature-toggles",
23
22
  ...(clientOptions ? { clientOptions } : {}),
24
23
  });
25
24
  return {
26
25
  publish(featureName, enabled) {
27
- signal.publish(TOGGLE_SYNC_CHANNEL, { featureName, enabled });
26
+ signal.publish(toggleSyncChannel, { featureName, enabled });
28
27
  },
29
28
  onMessage(listener) {
30
29
  signal.onMessage((_channel, payload) => {
31
30
  if (!isTogglePayload(payload)) {
32
31
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.log in a raw PubSubSignal handler.
33
- console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${TOGGLE_SYNC_CHANNEL}"`);
32
+ console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${toggleSyncChannel}"`);
34
33
  // skip: malformed message already logged above, nothing to apply
35
34
  return;
36
35
  }
@@ -40,3 +39,23 @@ export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
40
39
  close: signal.close,
41
40
  };
42
41
  }
42
+ // Reuses the framework cache-sync bus (local without REDIS_URL, Redis-backed with it) so
43
+ // toggles need no Pub/Sub wiring of their own. The bus applies a publish locally first.
44
+ export function createCacheSyncToggleSignal(bus) {
45
+ return {
46
+ publish(featureName, enabled) {
47
+ bus.publish(CACHE_SYNC_TOPICS.featureToggle, { featureName, enabled });
48
+ },
49
+ onMessage(listener) {
50
+ bus.subscribe(CACHE_SYNC_TOPICS.featureToggle, (payload) => {
51
+ // skip: foreign or malformed payload on the topic
52
+ if (!isTogglePayload(payload))
53
+ return;
54
+ listener(payload.featureName, payload.enabled);
55
+ });
56
+ },
57
+ onResync(listener) {
58
+ bus.onResync(listener);
59
+ },
60
+ };
61
+ }
@@ -1,6 +1,7 @@
1
1
  import { EXT_DERIVATIVE_PUBLIC_PREDICATE, type HandlerContext, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  export type DerivativePublicPredicateArgs = {
4
+ readonly fileRefId: string;
4
5
  readonly entityId: string;
5
6
  readonly tenantId: TenantId;
6
7
  readonly fieldName: string;
@@ -78,7 +78,13 @@ export const publicVariantQuery = defineQueryHandler({
78
78
  if (!isDerivativePublicPredicatePlugin(usage.options)) {
79
79
  throw new Error(`file-derivatives: "${usage.entityName}" registered ${EXT_DERIVATIVE_PUBLIC_PREDICATE} without an isPublic(args, ctx) — extension options must be a DerivativePublicPredicatePlugin.`);
80
80
  }
81
- const isPublic = await usage.options.isPublic({ entityId, tenantId: ctx.user.tenantId, fieldName, variant: query.payload.variant }, ctx);
81
+ const isPublic = await usage.options.isPublic({
82
+ fileRefId: query.payload.fileRefId,
83
+ entityId,
84
+ tenantId: ctx.user.tenantId,
85
+ fieldName,
86
+ variant: query.payload.variant,
87
+ }, ctx);
82
88
  if (!isPublic)
83
89
  return null;
84
90
  // ctx.files/ctx.derivatives are optional on HandlerContext (only wired
@@ -99,6 +99,11 @@ export async function sweepOrphanedDerivativesJob(rawPayload, ctx, db) {
99
99
  const owners = await selectMany(db, fileRefsTable, { tenantId: candidate.tenantId, storageKey: candidate.originalKey }, { limit: 1 });
100
100
  if (owners.length > 0)
101
101
  return "skipped";
102
+ // Binaries written through ctx.files.ref(key) (and their derive()d
103
+ // variants) never get a fileRef row; an original still sitting in storage
104
+ // means the derivative is not an orphan.
105
+ if (await candidate.provider.exists(candidate.originalKey))
106
+ return "skipped";
102
107
  if (dryRun) {
103
108
  dryRunWouldDelete++;
104
109
  return "skipped";
@@ -49,11 +49,13 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
49
49
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
50
50
  import { createSystemUser, parseTenantId, } from "@cosmicdrift/kumiko-framework/engine";
51
51
  import { assertSafeStorageKey, fileRefEntity, fileRefsTable, storageKeyStemPrefix, tenantStoragePrefixes, } from "@cosmicdrift/kumiko-framework/files";
52
+ import { runChunkedMigration } from "../shared/index.js";
52
53
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
53
54
  // Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
54
55
  // — bounds each query instead of one `storage_key = ANY(...)` per listed key,
55
56
  // or a single unbounded array for a tenant with a huge prefix.
56
57
  const SURVIVOR_CHECK_BATCH_SIZE = 500;
58
+ const DESTROY_CHUNK_SIZE = 500;
57
59
  function chunk(items, size) {
58
60
  const chunks = [];
59
61
  for (let i = 0; i < items.length; i += size)
@@ -63,25 +65,43 @@ function chunk(items, size) {
63
65
  function isUnderOwnPrefix(storageKey, prefixes) {
64
66
  return prefixes.some((prefix) => storageKey.startsWith(prefix));
65
67
  }
68
+ function toDestroyHookResult(result, failureMessage) {
69
+ // A failure on the last item ends the run as "done" (nothing left to batch),
70
+ // so the failed count, not the stop reason, decides fail-closed.
71
+ if (result.failed > 0) {
72
+ throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
73
+ }
74
+ return { done: result.stoppedReason === "done", processed: result.migrated };
75
+ }
66
76
  export const fileRefTenantDestroyHook = async (ctx) => {
67
- const rows = await ctx.db.selectMany(fileRefsTable, {
68
- tenantId: ctx.tenantId,
69
- });
70
77
  const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
71
78
  const user = createSystemUser(ctx.tenantId);
72
- for (const row of rows) {
73
- if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
74
- await deleteHandedOverBinary(ctx, row.storageKey);
75
- }
76
- const result = await crud.forget({ id: row.id }, user, ctx.db);
77
- // Executor writes return {isSuccess:false} instead of throwing — a
78
- // discarded result would report this destroy stage "succeeded" while the
79
- // row (and its PII fileName) survives. Throw so the pipeline's
80
- // retry/abandon handling sees it.
81
- if (!result.isSuccess) {
82
- throw new Error(`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${result.error.message}`);
83
- }
84
- }
79
+ let firstFailure;
80
+ // Converges without a cursor: forget() hard-deletes, so each batch query
81
+ // returns the next rows.
82
+ const result = await runChunkedMigration({
83
+ nextBatch: () => ctx.db.selectMany(fileRefsTable, { tenantId: ctx.tenantId }, { limit: DESTROY_CHUNK_SIZE }),
84
+ migrateRow: async (row) => {
85
+ if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
86
+ await deleteHandedOverBinary(ctx, row.storageKey);
87
+ }
88
+ const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
89
+ // Executor writes return {isSuccess:false} instead of throwing — a
90
+ // discarded result would report this destroy stage "succeeded" while the
91
+ // row (and its PII fileName) survives. Throw so the pipeline's
92
+ // retry/abandon handling sees it.
93
+ if (!forgotten.isSuccess) {
94
+ throw new Error(`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`);
95
+ }
96
+ return "migrated";
97
+ },
98
+ maxFailures: 1,
99
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
100
+ onRowError: (_row, err) => {
101
+ firstFailure = err instanceof Error ? err.message : String(err);
102
+ },
103
+ });
104
+ return toDestroyHookResult(result, () => firstFailure);
85
105
  };
86
106
  // The bytes live under the tenant that uploaded them (`<tenantId>/…` or
87
107
  // `exports/<tenantId>/…`), so with per-tenant storage config that tenant's
@@ -137,18 +157,35 @@ export const fileRefStorageDestroyHook = async (tenantId, ctx) => {
137
157
  }
138
158
  // Provider resolved — a list()/delete() failure from here IS fail-closed:
139
159
  // the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
140
- // it, and the next sweep tick retries (list+delete are idempotent, so this
141
- // converges rather than double-deleting or erroring on a missing key).
160
+ // it and retries up to the stage's attempt cap before abandoning the destroy
161
+ // (list+delete are idempotent, so a retry converges rather than
162
+ // double-deleting or erroring on a missing key).
163
+ let processed = 0;
142
164
  for (const prefix of tenantStoragePrefixes(tenantId)) {
143
165
  const keys = await provider.list(prefix);
144
166
  const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
145
- for (const key of keys) {
146
- if (keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)))
147
- continue;
148
- assertSafeStorageKey(key);
149
- await provider.delete(key);
150
- }
167
+ const deletableKeys = keys.filter((key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)));
168
+ const pending = [...deletableKeys];
169
+ let firstFailure;
170
+ const result = await runChunkedMigration({
171
+ nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
172
+ migrateRow: async (key) => {
173
+ assertSafeStorageKey(key);
174
+ await provider.delete(key);
175
+ return "migrated";
176
+ },
177
+ maxFailures: 1,
178
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
179
+ onRowError: (_key, err) => {
180
+ firstFailure = err instanceof Error ? err.message : String(err);
181
+ },
182
+ });
183
+ const hookResult = toDestroyHookResult(result, () => firstFailure);
184
+ processed += result.migrated;
185
+ if (!hookResult.done)
186
+ return { done: false, processed };
151
187
  }
188
+ return { done: true, processed };
152
189
  };
153
190
  // Any listed key that is itself a surviving row's exact storageKey means a
154
191
  // foreign tenant still owns it (see header comment) — its stem prefix then
@@ -4,6 +4,7 @@ export declare const folderEntity: EntityDefinition<{
4
4
  readonly type: "text";
5
5
  readonly description?: string;
6
6
  readonly maxLength?: number;
7
+ readonly minLength?: number;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -25,6 +26,7 @@ export declare const folderEntity: EntityDefinition<{
25
26
  readonly type: "text";
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
29
+ readonly minLength?: number;
28
30
  readonly required?: boolean;
29
31
  readonly searchable?: boolean;
30
32
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
48
50
  readonly type: "text";
49
51
  readonly description?: string;
50
52
  readonly maxLength?: number;
53
+ readonly minLength?: number;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -69,6 +72,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
69
72
  readonly type: "text";
70
73
  readonly description?: string;
71
74
  readonly maxLength?: number;
75
+ readonly minLength?: number;
72
76
  readonly required?: boolean;
73
77
  readonly searchable?: boolean;
74
78
  readonly sortable?: boolean;
@@ -90,6 +94,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
90
94
  readonly type: "text";
91
95
  readonly description?: string;
92
96
  readonly maxLength?: number;
97
+ readonly minLength?: number;
93
98
  readonly required?: boolean;
94
99
  readonly searchable?: boolean;
95
100
  readonly sortable?: boolean;
@@ -113,6 +118,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
113
118
  readonly type: "text";
114
119
  readonly description?: string;
115
120
  readonly maxLength?: number;
121
+ readonly minLength?: number;
116
122
  readonly required?: boolean;
117
123
  readonly searchable?: boolean;
118
124
  readonly sortable?: boolean;
@@ -134,6 +140,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
134
140
  readonly type: "text";
135
141
  readonly description?: string;
136
142
  readonly maxLength?: number;
143
+ readonly minLength?: number;
137
144
  readonly required?: boolean;
138
145
  readonly searchable?: boolean;
139
146
  readonly sortable?: boolean;
@@ -155,6 +162,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
155
162
  readonly type: "text";
156
163
  readonly description?: string;
157
164
  readonly maxLength?: number;
165
+ readonly minLength?: number;
158
166
  readonly required?: boolean;
159
167
  readonly searchable?: boolean;
160
168
  readonly sortable?: boolean;
@@ -15,7 +15,7 @@
15
15
  // Deferred: reparenting with cycle-check (folder:update CAN change parentId, but
16
16
  // no UI exposes it in v1 — folders-view guards cycles defensively).
17
17
  import { defineEntityCreateHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
18
- import { hasWhereRule } from "../shared/index.js";
18
+ import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
19
19
  import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants.js";
20
20
  import { createFolderAssignmentEntity, folderEntity } from "./entity.js";
21
21
  import { createClearFolderHandler } from "./handlers/clear-folder.write.js";
@@ -80,15 +80,7 @@ export function createFoldersFeature(opts = {}) {
80
80
  opts.parents === undefined) {
81
81
  return foldersFeature;
82
82
  }
83
- if (hasWhereRule(opts.ownership?.write)) {
84
- throw new Error("createFoldersFeature({ ownership }): ownership.write must not contain a " +
85
- '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
86
- "layer (the read path, via buildOwnershipClause). Write paths that " +
87
- "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
88
- "evaluate them, so such a rule can only ever deny — boot validation " +
89
- "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
90
- "leave it unset.");
91
- }
83
+ assertNoWhereRuleInOwnershipWrite("createFoldersFeature", opts.ownership?.write);
92
84
  if (opts.parents !== undefined && opts.parents.length === 0) {
93
85
  throw new Error("createFoldersFeature({ parents }): parents must not be an empty array — " +
94
86
  "an empty allowlist rejects every set-folder call and hides every " +