@cosmicdrift/kumiko-samples 0.365.1 → 0.367.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 (208) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/admin-shell/feature.ts +9 -9
  4. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +87 -5
  6. package/packages/bundled-features/src/audit/feature.ts +1 -1
  7. package/packages/bundled-features/src/auth-email-password/changes.json +13 -0
  8. package/packages/bundled-features/src/auth-email-password/constants.ts +0 -2
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +3 -3
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-mfa/feature.ts +4 -4
  12. package/packages/bundled-features/src/auth-mfa-user-data/index.ts +1 -1
  13. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +1 -1
  14. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  15. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +89 -28
  16. package/packages/bundled-features/src/cap-counter/changes.json +7 -0
  17. package/packages/bundled-features/src/cap-counter/constants.ts +3 -0
  18. package/packages/bundled-features/src/cap-counter/feature.ts +2 -2
  19. package/packages/bundled-features/src/cap-counter/tables.ts +5 -2
  20. package/packages/bundled-features/src/cap-overview/feature.ts +3 -3
  21. package/packages/bundled-features/src/channel-discord/feature.ts +2 -2
  22. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  23. package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
  24. package/packages/bundled-features/src/channel-email/feature.ts +2 -2
  25. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
  26. package/packages/bundled-features/src/channel-push/feature.ts +1 -1
  27. package/packages/bundled-features/src/channel-slack/feature.ts +2 -2
  28. package/packages/bundled-features/src/channel-teams/feature.ts +2 -2
  29. package/packages/bundled-features/src/channel-telegram/feature.ts +2 -2
  30. package/packages/bundled-features/src/compliance-profiles/feature.ts +1 -1
  31. package/packages/bundled-features/src/compliance-profiles-ops/index.ts +3 -3
  32. package/packages/bundled-features/src/config/feature.ts +1 -1
  33. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  34. package/packages/bundled-features/src/custom-fields/feature.ts +2 -2
  35. package/packages/bundled-features/src/custom-fields/wire-for-entity.ts +17 -13
  36. package/packages/bundled-features/src/data-retention/feature.ts +1 -1
  37. package/packages/bundled-features/src/data-retention/presets.ts +4 -4
  38. package/packages/bundled-features/src/delivery/changes.json +18 -0
  39. package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
  40. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +6 -2
  41. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +4 -4
  42. package/packages/bundled-features/src/delivery/delivery-service.ts +71 -4
  43. package/packages/bundled-features/src/delivery/feature.ts +10 -6
  44. package/packages/bundled-features/src/delivery/handlers/resubscribe-address.write.ts +12 -8
  45. package/packages/bundled-features/src/delivery/handlers/resubscribe-user.write.ts +3 -1
  46. package/packages/bundled-features/src/delivery/handlers/set-preference.write.ts +3 -1
  47. package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +13 -6
  48. package/packages/bundled-features/src/delivery/handlers/unsubscribe-user.write.ts +3 -1
  49. package/packages/bundled-features/src/delivery/index.ts +6 -0
  50. package/packages/bundled-features/src/delivery/notification-type-aliases.ts +13 -0
  51. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  52. package/packages/bundled-features/src/delivery/types.ts +4 -0
  53. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
  54. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
  55. package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
  56. package/packages/bundled-features/src/derivatives-sharp/feature.ts +1 -1
  57. package/packages/bundled-features/src/document-ingest-foundation/feature.ts +2 -2
  58. package/packages/bundled-features/src/file-derivatives/feature.ts +3 -3
  59. package/packages/bundled-features/src/file-foundation/feature.ts +1 -1
  60. package/packages/bundled-features/src/file-provider-inmemory/feature.ts +2 -2
  61. package/packages/bundled-features/src/file-provider-s3/feature.ts +3 -3
  62. package/packages/bundled-features/src/file-provider-s3-env/feature.ts +2 -2
  63. package/packages/bundled-features/src/files/README.md +1 -1
  64. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  65. package/packages/bundled-features/src/folders/feature.ts +2 -2
  66. package/packages/bundled-features/src/folders-user-data/index.ts +1 -1
  67. package/packages/bundled-features/src/form-draft/feature.ts +2 -2
  68. package/packages/bundled-features/src/form-draft-user-data/index.ts +1 -1
  69. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +9 -1
  71. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  72. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +19 -7
  73. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +21 -2
  74. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -2
  75. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +1 -1
  76. package/packages/bundled-features/src/ledger/feature.ts +5 -5
  77. package/packages/bundled-features/src/legal-pages/feature.ts +1 -1
  78. package/packages/bundled-features/src/mail-foundation/feature.ts +51 -10
  79. package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
  80. package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +2 -2
  81. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +3 -3
  82. package/packages/bundled-features/src/managed-pages/css-gate.ts +1 -1
  83. package/packages/bundled-features/src/managed-pages/feature.ts +2 -2
  84. package/packages/bundled-features/src/metrics/feature.ts +2 -2
  85. package/packages/bundled-features/src/notes-history/feature.ts +2 -2
  86. package/packages/bundled-features/src/notes-history-user-data/index.ts +1 -1
  87. package/packages/bundled-features/src/personal-access-tokens/feature.ts +15 -11
  88. package/packages/bundled-features/src/readiness/feature.ts +2 -2
  89. package/packages/bundled-features/src/renderer-foundation/README.md +1 -1
  90. package/packages/bundled-features/src/renderer-foundation/feature.ts +1 -1
  91. package/packages/bundled-features/src/renderer-pdf/feature.ts +1 -1
  92. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  93. package/packages/bundled-features/src/seo/feature.ts +1 -1
  94. package/packages/bundled-features/src/sessions/feature.ts +17 -13
  95. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -1
  96. package/packages/bundled-features/src/subscription-mollie/feature.ts +1 -1
  97. package/packages/bundled-features/src/subscription-stripe/feature.ts +3 -3
  98. package/packages/bundled-features/src/tags/feature.ts +2 -2
  99. package/packages/bundled-features/src/template-resolver/changes.json +25 -0
  100. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
  101. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
  102. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
  103. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  104. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
  105. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
  106. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
  107. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
  108. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
  109. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
  110. package/packages/bundled-features/src/template-resolver/index.ts +4 -0
  111. package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
  112. package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
  113. package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
  114. package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
  115. package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
  116. package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
  117. package/packages/bundled-features/src/template-resolver-user-data/index.ts +1 -1
  118. package/packages/bundled-features/src/tenant/feature.ts +4 -4
  119. package/packages/bundled-features/src/tenant/seeding.ts +1 -1
  120. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +2 -3
  121. package/packages/bundled-features/src/tenant-settings/feature.ts +1 -1
  122. package/packages/bundled-features/src/tier-engine/feature.ts +36 -27
  123. package/packages/bundled-features/src/user/feature.ts +1 -1
  124. package/packages/bundled-features/src/user-data-rights/README.md +1 -1
  125. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  126. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -4
  127. package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +2 -2
  128. package/packages/bundled-features/src/user-profile/feature.ts +4 -4
  129. package/packages/bundled-features/src/waitlist/feature.ts +2 -2
  130. package/samples/apps/cap-billing-demo/README.md +1 -1
  131. package/samples/apps/cap-billing-demo/src/feature.ts +4 -4
  132. package/samples/apps/cap-billing-demo/src/run-config.ts +1 -1
  133. package/samples/apps/config-demo/src/features/demo/feature.ts +2 -2
  134. package/samples/apps/styleguide/src/features/ai-settings/feature.ts +43 -28
  135. package/samples/apps/styleguide/src/features/demo/feature.ts +18 -18
  136. package/samples/apps/styleguide/src/features/widgets/ai-text-demo.ts +10 -12
  137. package/samples/apps/styleguide/src/features/widgets/feature.ts +90 -108
  138. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  139. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
  140. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
  141. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
  142. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +36 -36
  143. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +6 -6
  144. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +2 -2
  145. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  146. package/samples/apps/use-all-bundled/package.json +1 -0
  147. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +7 -7
  148. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +3 -3
  149. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +1 -1
  150. package/samples/apps/user-data-rights-demo/src/feature.ts +5 -5
  151. package/samples/recipes/access-control/src/feature.ts +21 -25
  152. package/samples/recipes/anonymous-access/src/feature.ts +36 -43
  153. package/samples/recipes/anonymous-access-multitenant/src/feature.ts +14 -16
  154. package/samples/recipes/auth-foundation-providers/src/feature.ts +1 -1
  155. package/samples/recipes/basic-entity/README.md +1 -1
  156. package/samples/recipes/boot-check/README.md +1 -1
  157. package/samples/recipes/boot-check/src/feature.ts +2 -2
  158. package/samples/recipes/chat-channels/src/feature.ts +1 -1
  159. package/samples/recipes/cross-feature-events/src/feature.ts +7 -7
  160. package/samples/recipes/currencies-global/src/feature.ts +3 -3
  161. package/samples/recipes/currencies-per-tenant/src/feature.ts +2 -2
  162. package/samples/recipes/custom-fields-basic/README.md +1 -1
  163. package/samples/recipes/custom-fields-basic/src/feature.ts +3 -2
  164. package/samples/recipes/custom-handlers/README.md +3 -3
  165. package/samples/recipes/custom-handlers/src/feature.ts +37 -41
  166. package/samples/recipes/dashboard-stats/src/feature.ts +6 -6
  167. package/samples/recipes/delivery-notifications/README.md +1 -1
  168. package/samples/recipes/delivery-notifications/src/feature.ts +8 -8
  169. package/samples/recipes/designer-demo/src/feature.ts +2 -1
  170. package/samples/recipes/embedded/src/feature.ts +1 -1
  171. package/samples/recipes/embedded-entity-form/src/feature.ts +13 -13
  172. package/samples/recipes/encrypted-tenant-config/src/feature.ts +1 -1
  173. package/samples/recipes/error-contract/src/feature.ts +25 -25
  174. package/samples/recipes/event-sourcing/src/feature.ts +70 -76
  175. package/samples/recipes/feature-toggles/src/feature.ts +28 -20
  176. package/samples/recipes/files-post-processing/src/feature.ts +22 -26
  177. package/samples/recipes/idempotency/src/feature.ts +16 -18
  178. package/samples/recipes/inbound-mail-triage/src/feature.ts +1 -1
  179. package/samples/recipes/lane-routing/src/feature.ts +10 -12
  180. package/samples/recipes/legal-pages/src/feature.ts +1 -1
  181. package/samples/recipes/lifecycle-hooks/README.md +4 -4
  182. package/samples/recipes/lifecycle-hooks/src/feature.ts +55 -35
  183. package/samples/recipes/managed-config/src/feature.ts +8 -8
  184. package/samples/recipes/managed-pages/src/feature.ts +1 -1
  185. package/samples/recipes/notes-history-basic/README.md +1 -1
  186. package/samples/recipes/notes-history-basic/src/feature.ts +2 -2
  187. package/samples/recipes/pdf-audit-export/src/feature.ts +7 -7
  188. package/samples/recipes/pipeline-basics/README.md +1 -1
  189. package/samples/recipes/pipeline-basics/src/feature.ts +7 -7
  190. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  191. package/samples/recipes/rate-limiting/src/feature.ts +7 -9
  192. package/samples/recipes/record-detail-layout/src/feature.ts +18 -18
  193. package/samples/recipes/reference-data/src/feature.ts +1 -1
  194. package/samples/recipes/relations/src/feature.ts +2 -2
  195. package/samples/recipes/screens-nav/src/feature.ts +1 -1
  196. package/samples/recipes/search/src/feature.ts +17 -19
  197. package/samples/recipes/security-baseline/README.md +1 -1
  198. package/samples/recipes/state-machine/src/feature.ts +1 -1
  199. package/samples/recipes/tags-basic/README.md +1 -1
  200. package/samples/recipes/tags-basic/src/feature.ts +2 -2
  201. package/samples/recipes/tenant-settings/README.md +1 -1
  202. package/samples/recipes/tenant-settings/src/feature.ts +1 -1
  203. package/samples/recipes/timezones/src/feature.ts +12 -12
  204. package/samples/recipes/user-data-rights/README.md +2 -2
  205. package/samples/recipes/user-data-rights/src/feature.ts +1 -1
  206. package/samples/recipes/user-profile/src/feature.ts +1 -1
  207. package/samples/recipes/webhook-step/src/feature.ts +1 -1
  208. package/samples/recipes/writeform-section/src/feature.ts +12 -12
@@ -2,14 +2,20 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { upsertAddressOptOut } from "../address-opt-out.js";
5
+ import { currentNotificationType } from "../notification-type-aliases.js";
6
+
7
+ // Also the payload an app job receives with `trigger: { on: DeliveryHandlers.unsubscribeAddress }`
8
+ // after a committed address opt-out (the tenant is the job's `ctx.triggeredBy.tenantId`).
9
+ export const unsubscribeAddressPayloadSchema = z.object({
10
+ addressHash: z.string().min(1),
11
+ notificationType: z.string().min(1),
12
+ channel: z.string().min(1),
13
+ });
14
+ export type UnsubscribeAddressPayload = z.infer<typeof unsubscribeAddressPayloadSchema>;
5
15
 
6
16
  export const unsubscribeAddressWrite = defineWriteHandler({
7
17
  name: "unsubscribeAddress",
8
- schema: z.object({
9
- addressHash: z.string().min(1),
10
- notificationType: z.string().min(1),
11
- channel: z.string().min(1),
12
- }),
18
+ schema: unsubscribeAddressPayloadSchema,
13
19
  access: { roles: access.systemAdmin },
14
20
  // Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
15
21
  // verify() has already proven the address-token signature — not a surface
@@ -18,7 +24,8 @@ export const unsubscribeAddressWrite = defineWriteHandler({
18
24
  description:
19
25
  "Records a no-account address opt-out for one notificationType/channel combination; dispatched by the signed unsubscribe route, not meant for UI callers.",
20
26
  handler: async (event, ctx) => {
21
- const { addressHash, notificationType, channel } = event.payload;
27
+ const { addressHash, channel } = event.payload;
28
+ const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
22
29
  const { tenantId } = event.user;
23
30
 
24
31
  if (!ctx.systemDb) {
@@ -1,6 +1,7 @@
1
1
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { currentNotificationType } from "../notification-type-aliases.js";
4
5
  import { upsertPreference } from "../upsert-preference.js";
5
6
 
6
7
  export const unsubscribeUserWrite = defineWriteHandler({
@@ -20,7 +21,8 @@ export const unsubscribeUserWrite = defineWriteHandler({
20
21
  handler: async (event, ctx) => {
21
22
  // userId comes from the payload, not event.user.id — the caller here is
22
23
  // the system user dispatchSystemWrite runs as, not the user unsubscribing.
23
- const { userId, notificationType, channel } = event.payload;
24
+ const { userId, channel } = event.payload;
25
+ const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
24
26
  const { tenantId } = event.user;
25
27
 
26
28
  if (!ctx.systemDb) {
@@ -63,6 +63,10 @@ export {
63
63
  type RateLimitConfig,
64
64
  } from "./delivery-service.js";
65
65
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
66
+ export {
67
+ type UnsubscribeAddressPayload,
68
+ unsubscribeAddressPayloadSchema,
69
+ } from "./handlers/unsubscribe-address.write.js";
66
70
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
67
71
  export {
68
72
  deliveryAttemptsTable,
@@ -94,3 +98,5 @@ export {
94
98
  type UnsubscribeRouteOptions,
95
99
  type UnsubscribeTokenPayload,
96
100
  } from "./unsubscribe.js";
101
+ export type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
102
+ export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -0,0 +1,13 @@
1
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export function notificationTypeNames(registry: Registry, type: string): readonly string[] {
4
+ const renamedFrom = registry.getAllNotifications().get(type)?.renamedFrom ?? [];
5
+ return [type, ...renamedFrom];
6
+ }
7
+
8
+ export function currentNotificationType(registry: Registry, type: string): string {
9
+ for (const [name, definition] of registry.getAllNotifications()) {
10
+ if (definition.renamedFrom?.includes(type)) return name;
11
+ }
12
+ return type;
13
+ }
@@ -15,6 +15,7 @@ import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js"
15
15
  import { collectChannels, createDeliveryService } from "./delivery-service.js";
16
16
  import { createDeliveryNotifyFactory } from "./notify-factory.js";
17
17
  import type { DeliveryService } from "./types.js";
18
+ import type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
18
19
 
19
20
  export type CreateDeliveryTestContextOptions = {
20
21
  readonly tenantUserIdsQuery?: string;
@@ -27,6 +28,7 @@ export type CreateDeliveryTestContextOptions = {
27
28
  readonly idempotencyRedis?: Redis;
28
29
  readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
29
30
  readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
31
+ readonly unsubscribeLinks?: UnsubscribeLinkConfig;
30
32
  };
31
33
 
32
34
  /**
@@ -1,6 +1,7 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type {
4
+ ConfigAccessor,
4
5
  DeliveryErrorCode,
5
6
  NotifyDeliveryStatus,
6
7
  NotifyDispatched,
@@ -39,6 +40,9 @@ export type ChannelContext = {
39
40
  // Chat channels read their per-tenant webhook URL / bot token from it.
40
41
  // Absent when no secrets feature/KEK is wired.
41
42
  readonly secrets?: SecretsContext | undefined;
43
+ // The tenant's own config accessor, built server-side for this tenantId. Absent when the
44
+ // delivery service has no tenantConfigFor and the job context carries no config.
45
+ readonly config?: ConfigAccessor | undefined;
42
46
  // Renders a stored mail template, the tenant's own or the system fallback.
43
47
  readonly renderMail?: (payload: MailPayload) => Promise<RenderedMail>;
44
48
  // Reads the bytes behind an attachment ref at send time. Absent when no file storage is wired.
@@ -0,0 +1,73 @@
1
+ import { publicOriginOf } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { hashUnsubscribeAddress } from "./address-opt-out.js";
4
+ import { DELIVERY_UNSUBSCRIBE_PATH } from "./constants.js";
5
+ import type { ChannelContext } from "./types.js";
6
+ import { signAddressUnsubscribeToken, signUnsubscribeToken } from "./unsubscribe.js";
7
+
8
+ /** Secret shared with `createUnsubscribeRoutes` plus the public origin the links point at. */
9
+ export type UnsubscribeLinkConfig = {
10
+ readonly secret: string;
11
+ readonly baseUrl: string;
12
+ /** The sending tenant's own public origin (subdomain or custom domain), null when it has
13
+ * none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
14
+ * fall back to `baseUrl`, so a missing tenant origin never fails a send. */
15
+ readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
16
+ };
17
+
18
+ export type UnsubscribeLinkRecipient =
19
+ | { readonly kind: "user"; readonly userId: string }
20
+ | { readonly kind: "address"; readonly address: string };
21
+
22
+ // https only, unless the app itself runs on plain http (local dev): a tenant origin must not
23
+ // downgrade the link that carries the token.
24
+ function isUsableTenantOrigin(origin: string, baseUrl: string): boolean {
25
+ return (
26
+ origin.startsWith("https://") || (baseUrl.startsWith("http://") && origin.startsWith("http://"))
27
+ );
28
+ }
29
+
30
+ export async function unsubscribeLinkOrigin(
31
+ config: UnsubscribeLinkConfig,
32
+ ctx: ChannelContext,
33
+ onFallback: (reason: string) => void,
34
+ ): Promise<string> {
35
+ if (config.tenantOrigin === undefined) return config.baseUrl;
36
+ let resolved: string | null | undefined;
37
+ try {
38
+ resolved = await config.tenantOrigin(ctx);
39
+ } catch (err) {
40
+ onFallback(`tenantOrigin threw (${err instanceof Error ? err.name : typeof err})`);
41
+ return config.baseUrl;
42
+ }
43
+ if (resolved === null || resolved === undefined) return config.baseUrl;
44
+ const origin = publicOriginOf(resolved);
45
+ if (origin === undefined || !isUsableTenantOrigin(origin, config.baseUrl)) {
46
+ onFallback("tenantOrigin returned no usable https origin");
47
+ return config.baseUrl;
48
+ }
49
+ return origin;
50
+ }
51
+
52
+ // Undefined means "no link": without a blind-index key an address opt-out could never be
53
+ // matched again, and a mail without a link is better than a failed send.
54
+ export async function buildUnsubscribeUrl(
55
+ config: UnsubscribeLinkConfig,
56
+ origin: string,
57
+ recipient: UnsubscribeLinkRecipient,
58
+ scope: {
59
+ tenantId: TenantId;
60
+ notificationType: string;
61
+ channel: string;
62
+ locale: string | undefined;
63
+ },
64
+ ): Promise<string | undefined> {
65
+ if (recipient.kind === "address" && hashUnsubscribeAddress(recipient.address) === undefined) {
66
+ return undefined;
67
+ }
68
+ const token =
69
+ recipient.kind === "user"
70
+ ? await signUnsubscribeToken({ ...scope, userId: recipient.userId }, config.secret)
71
+ : await signAddressUnsubscribeToken({ ...scope, address: recipient.address }, config.secret);
72
+ return `${origin}${DELIVERY_UNSUBSCRIBE_PATH}?token=${encodeURIComponent(token)}`;
73
+ }
@@ -0,0 +1,17 @@
1
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
2
+
3
+ // The pages behind a mailed unsubscribe link speak the mail's language, so their texts live in
4
+ // the mail translations: English here, other languages in the locale packages.
5
+ export const UNSUBSCRIBE_PAGE_EN = {
6
+ "mail.unsubscribe.confirmTitle": "Unsubscribe",
7
+ "mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?",
8
+ "mail.unsubscribe.confirmButton": "Unsubscribe",
9
+ "mail.unsubscribe.doneTitle": "Unsubscribed",
10
+ "mail.unsubscribe.doneMessage": "You have been unsubscribed.",
11
+ "mail.unsubscribe.undoButton": "Undo",
12
+ "mail.unsubscribe.resubscribedTitle": "Subscribed again",
13
+ "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
14
+ "mail.unsubscribe.failed": "That did not work. Please try again later.",
15
+ } as const;
16
+
17
+ registerMailTranslations("en", UNSUBSCRIBE_PAGE_EN);
@@ -6,6 +6,7 @@ import {
6
6
  signatureRoute,
7
7
  } from "@cosmicdrift/kumiko-framework/api";
8
8
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
9
10
  import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
10
11
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
11
12
  import * as jose from "jose";
@@ -17,6 +18,14 @@ import {
17
18
  DeliveryErrors,
18
19
  DeliveryHandlers,
19
20
  } from "./constants.js";
21
+ import "./unsubscribe-page-strings.js";
22
+
23
+ // The mail's locale, so the pages behind the link speak the mail's language. Optional: links
24
+ // signed before the claim existed keep verifying and fall back to English.
25
+ const localeClaimSchema = z
26
+ .string()
27
+ .regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/)
28
+ .optional();
20
29
 
21
30
  // Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
22
31
  // wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
@@ -32,6 +41,7 @@ const unsubscribeJwtPayloadSchema = z.object({
32
41
  tenantId: z.string().min(1),
33
42
  notificationType: z.string().min(1),
34
43
  channel: z.string().min(1),
44
+ locale: localeClaimSchema,
35
45
  });
36
46
 
37
47
  // Address-token payload. `sub` and `addressHash` are the same value (the
@@ -45,6 +55,7 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
45
55
  notificationType: z.string().min(1),
46
56
  channel: z.string().min(1),
47
57
  addressHash: z.string().min(1),
58
+ locale: localeClaimSchema,
48
59
  });
49
60
 
50
61
  export type UnsubscribeTokenPayload = {
@@ -52,6 +63,7 @@ export type UnsubscribeTokenPayload = {
52
63
  readonly tenantId: TenantId;
53
64
  readonly notificationType: string;
54
65
  readonly channel: string;
66
+ readonly locale?: string | undefined;
55
67
  };
56
68
 
57
69
  export type AddressUnsubscribeTokenPayload = {
@@ -59,6 +71,7 @@ export type AddressUnsubscribeTokenPayload = {
59
71
  readonly address: string;
60
72
  readonly notificationType: string;
61
73
  readonly channel: string;
74
+ readonly locale?: string | undefined;
62
75
  };
63
76
 
64
77
  /**
@@ -87,6 +100,11 @@ function assertUnsubscribeSecret(secret: string, caller: string): void {
87
100
  }
88
101
  }
89
102
 
103
+ // An unusable locale is left out instead of failing the send; the pages then fall back to English.
104
+ function localeClaim(locale: string | undefined): { locale?: string } {
105
+ return locale !== undefined && localeClaimSchema.safeParse(locale).success ? { locale } : {};
106
+ }
107
+
90
108
  const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
91
109
  error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
92
110
  } as const;
@@ -101,6 +119,7 @@ export async function signUnsubscribeToken(
101
119
  tenantId: payload.tenantId,
102
120
  notificationType: payload.notificationType,
103
121
  channel: payload.channel,
122
+ ...localeClaim(payload.locale),
104
123
  })
105
124
  .setProtectedHeader({ alg: "HS256" })
106
125
  .setSubject(String(payload.userId))
@@ -136,6 +155,7 @@ export async function signAddressUnsubscribeToken(
136
155
  channel: payload.channel,
137
156
  addressHash,
138
157
  kind: "address",
158
+ ...localeClaim(payload.locale),
139
159
  })
140
160
  .setProtectedHeader({ alg: "HS256" })
141
161
  .setSubject(addressHash)
@@ -154,6 +174,7 @@ type VerifiedUnsubscribe =
154
174
  readonly addressHash: string;
155
175
  readonly notificationType: string;
156
176
  readonly channel: string;
177
+ readonly locale: string | undefined;
157
178
  }
158
179
  | {
159
180
  readonly kind: "user";
@@ -161,6 +182,7 @@ type VerifiedUnsubscribe =
161
182
  readonly userId: string;
162
183
  readonly notificationType: string;
163
184
  readonly channel: string;
185
+ readonly locale: string | undefined;
164
186
  };
165
187
 
166
188
  // RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
@@ -183,6 +205,7 @@ async function signResubscribeToken(
183
205
  tenantId: verified.tenantId,
184
206
  notificationType: verified.notificationType,
185
207
  channel: verified.channel,
208
+ ...localeClaim(verified.locale),
186
209
  };
187
210
  const jwt =
188
211
  verified.kind === "address"
@@ -254,6 +277,7 @@ async function verifyUnsubscribeToken(
254
277
  addressHash: parsed.addressHash,
255
278
  notificationType: parsed.notificationType,
256
279
  channel: parsed.channel,
280
+ locale: parsed.locale,
257
281
  };
258
282
  }
259
283
 
@@ -274,6 +298,7 @@ async function verifyUnsubscribeToken(
274
298
  userId: parsed.sub,
275
299
  notificationType: parsed.notificationType,
276
300
  channel: parsed.channel,
301
+ locale: parsed.locale,
277
302
  };
278
303
  } catch (err) {
279
304
  if (err instanceof ExtraRouteRejection) throw err;
@@ -358,40 +383,68 @@ async function dispatchResubscribeWrite(
358
383
  return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
359
384
  }
360
385
 
361
- function confirmationPage(token: string): string {
386
+ function pageLocale(locale: string | undefined): string {
387
+ return resolveMailLocale(locale ?? "en");
388
+ }
389
+
390
+ function htmlPage(locale: string, title: string, bodyHtml: string): string {
362
391
  return `<!doctype html>
363
- <html>
392
+ <html lang="${escapeHtmlAttr(locale)}">
364
393
  <head>
365
394
  <meta charset="utf-8">
366
395
  <meta name="robots" content="noindex">
367
- <title>Unsubscribe</title>
396
+ <meta name="viewport" content="width=device-width, initial-scale=1">
397
+ <title>${escapeHtmlAttr(title)}</title>
368
398
  </head>
369
399
  <body>
370
- <p>Unsubscribe from these emails?</p>
371
- <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
372
- <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
373
- <button type="submit">Unsubscribe</button>
374
- </form>
400
+ ${bodyHtml}
375
401
  </body>
376
402
  </html>`;
377
403
  }
378
404
 
379
- function unsubscribedPage(resubscribeToken: string): string {
380
- return `<!doctype html>
381
- <html>
382
- <head>
383
- <meta charset="utf-8">
384
- <meta name="robots" content="noindex">
385
- <title>Unsubscribed</title>
386
- </head>
387
- <body>
388
- <p>You have been unsubscribed.</p>
405
+ function confirmationPage(token: string, locale: string | undefined): string {
406
+ const lang = pageLocale(locale);
407
+ return htmlPage(
408
+ lang,
409
+ mailT(lang, "mail.unsubscribe.confirmTitle"),
410
+ `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmQuestion"))}</p>
411
+ <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
412
+ <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
413
+ <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
414
+ </form>`,
415
+ );
416
+ }
417
+
418
+ function unsubscribedPage(resubscribeToken: string, locale: string | undefined): string {
419
+ const lang = pageLocale(locale);
420
+ return htmlPage(
421
+ lang,
422
+ mailT(lang, "mail.unsubscribe.doneTitle"),
423
+ `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
389
424
  <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
390
425
  <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
391
- <button type="submit">Undo</button>
392
- </form>
393
- </body>
394
- </html>`;
426
+ <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
427
+ </form>`,
428
+ );
429
+ }
430
+
431
+ function resubscribedPage(locale: string | undefined): string {
432
+ const lang = pageLocale(locale);
433
+ return htmlPage(
434
+ lang,
435
+ mailT(lang, "mail.unsubscribe.resubscribedTitle"),
436
+ `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`,
437
+ );
438
+ }
439
+
440
+ function failedPage(locale: string | undefined): string {
441
+ const lang = pageLocale(locale);
442
+ const message = mailT(lang, "mail.unsubscribe.failed");
443
+ return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
444
+ }
445
+
446
+ function isFormRequest(contentType: string | undefined): boolean {
447
+ return contentType?.includes("application/x-www-form-urlencoded") === true;
395
448
  }
396
449
 
397
450
  // One-click clients put the token in the query string; only the
@@ -431,7 +484,7 @@ export function createUnsubscribeRoutes(
431
484
  assertUnsubscribeSecret(options.secret, "createUnsubscribeRoutes");
432
485
  const encodedSecret = new TextEncoder().encode(options.secret);
433
486
 
434
- const confirmRoute = signatureRoute<{ token: string }>({
487
+ const confirmRoute = signatureRoute<{ token: string; locale: string | undefined }>({
435
488
  method: "GET",
436
489
  path: DELIVERY_UNSUBSCRIBE_PATH,
437
490
  entry: "signature",
@@ -444,11 +497,11 @@ export function createUnsubscribeRoutes(
444
497
  "missing unsubscribe token",
445
498
  );
446
499
  }
447
- await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
448
- return { token };
500
+ const verified = await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
501
+ return { token, locale: verified.locale };
449
502
  },
450
- handler: async (c, { token }) =>
451
- c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
503
+ handler: async (c, { token, locale }) =>
504
+ c.html(confirmationPage(token, locale), 200, CONFIRMATION_PAGE_HEADERS),
452
505
  });
453
506
 
454
507
  const writeRoute = signatureRoute<VerifiedUnsubscribe>({
@@ -463,11 +516,13 @@ export function createUnsubscribeRoutes(
463
516
  // (e.g. events-table missing, DB down) behind a misleading 400.
464
517
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
465
518
  if (!succeeded) {
466
- return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
519
+ return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
467
520
  }
468
- return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
469
- "Cache-Control": "no-store",
470
- });
521
+ return c.html(
522
+ unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale),
523
+ 200,
524
+ { "Cache-Control": "no-store" },
525
+ );
471
526
  },
472
527
  });
473
528
 
@@ -482,6 +537,14 @@ export function createUnsubscribeRoutes(
482
537
  verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
483
538
  handler: async (c, verified, deps) => {
484
539
  const outcome = await dispatchResubscribeWrite(verified, deps);
540
+ // The undo button on the unsubscribed page posts a form; a browser should land on a page.
541
+ if (isFormRequest(c.req.header("content-type"))) {
542
+ return outcome === "success"
543
+ ? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
544
+ : c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
545
+ "Cache-Control": "no-store",
546
+ });
547
+ }
485
548
  if (outcome === "limit_reached") {
486
549
  return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
487
550
  "Cache-Control": "no-store",
@@ -15,7 +15,7 @@ export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
15
15
  category: "storage",
16
16
  recommended: false,
17
17
  });
18
- r.requires("file-derivatives");
18
+ r.requires({ features: ["file-derivatives"] });
19
19
 
20
20
  const plugin: DerivativeRendererPlugin = { render: renderImage };
21
21
  r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
@@ -132,9 +132,9 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
132
132
  });
133
133
  // tenant-lifecycle hosts EXT_TENANT_DATA — documentExtract.pages is
134
134
  // tenant-subject ciphertext and needs the destroy hook below.
135
- r.requires("config", "tenant-lifecycle");
135
+ r.requires({ features: ["config", "tenant-lifecycle"] });
136
136
 
137
- r.entity("documentExtract", documentExtractEntity);
137
+ r.entity({ name: "documentExtract", ...documentExtractEntity });
138
138
  r.useExtension(EXT_TENANT_DATA, "documentExtract", {
139
139
  destroy: documentExtractTenantDestroyHook,
140
140
  });
@@ -111,12 +111,12 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
111
111
  });
112
112
  // Needs a storage provider to read the original + write the variant —
113
113
  // without file-foundation there's nothing to derive from or write to.
114
- r.requires("file-foundation");
114
+ r.requires({ features: ["file-foundation"] });
115
115
  // variant() reads fileRefsTable, which "files" (not file-foundation)
116
116
  // owns — without this, mounting file-derivatives + file-foundation
117
117
  // alone boots fine but fails at request-time with a SQL error the first
118
118
  // time variant() runs.
119
- r.requires("files");
119
+ r.requires({ features: ["files"] });
120
120
 
121
121
  r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
122
122
  onRegister: () => {
@@ -164,7 +164,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
164
164
  // tenant's public variants from staying reachable — require the
165
165
  // dependency so a missing mount fails boot, not the tenant check
166
166
  // silently no-op-ing at request time.
167
- r.requires("tenant");
167
+ r.requires({ features: ["tenant"] });
168
168
  r.queryHandler(publicVariantByFileRefQuery);
169
169
  }
170
170
 
@@ -46,7 +46,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
46
46
  category: "storage",
47
47
  recommended: false,
48
48
  });
49
- r.requires("config");
49
+ r.requires({ features: ["config"] });
50
50
 
51
51
  r.extendsRegistrar(EXT_FILE_PROVIDER, {
52
52
  onRegister: () => {
@@ -68,10 +68,10 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
68
68
  category: "storage",
69
69
  recommended: false,
70
70
  });
71
- // Kein r.requires("config") + kein r.requires("secrets") — der
71
+ // Kein requires auf "config" oder "secrets" — der
72
72
  // In-Memory-Provider hat keine Config + kein Secret. Nur die
73
73
  // file-foundation muss da sein (Plugin-extension-point).
74
- r.requires("file-foundation");
74
+ r.requires({ features: ["file-foundation"] });
75
75
 
76
76
  const plugin: FileProviderPlugin = {
77
77
  build: async (_ctx: FileProviderContext, tenantId: string): Promise<FileStorageProvider> => {
@@ -47,9 +47,9 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
47
47
  category: "storage",
48
48
  recommended: false,
49
49
  });
50
- r.requires("config");
51
- r.requires("secrets");
52
- r.requires("file-foundation");
50
+ r.requires({ features: ["config"] });
51
+ r.requires({ features: ["secrets"] });
52
+ r.requires({ features: ["file-foundation"] });
53
53
 
54
54
  const secretAccessKey = r.secret("s3.secretAccessKey", {
55
55
  label: { en: "S3 secret access key" },
@@ -42,10 +42,10 @@ export const fileProviderS3EnvFeature = defineFeature(FEATURE_NAME, (r) => {
42
42
  category: "storage",
43
43
  recommended: false,
44
44
  });
45
- // No r.requires("config") / r.requires("secrets") — credentials come from
45
+ // No requires on "config" or "secrets" — credentials come from
46
46
  // env, not the per-tenant config + secrets store. Only the file-foundation
47
47
  // plugin-extension-point must be mounted.
48
- r.requires("file-foundation");
48
+ r.requires({ features: ["file-foundation"] });
49
49
 
50
50
  const plugin: FileProviderPlugin = {
51
51
  // tenantId ignored: one shared bucket serves all tenants (isolation via
@@ -5,7 +5,7 @@ Sprint-1.5 Refactor (Pre-Sprint-2).
5
5
 
6
6
  ## Was es macht
7
7
 
8
- Deklariert `r.entity("fileRef", ...)` für die DB-Tabelle die das
8
+ Deklariert `r.entity({ name: "fileRef", ... })` für die DB-Tabelle die das
9
9
  Framework via `createFileRoutes` (multipart-Upload + binary-Download)
10
10
  bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
11
11
 
@@ -39,7 +39,7 @@ export function createFilesTenantDataFeature(): FeatureDefinition {
39
39
  category: "compliance",
40
40
  recommended: false,
41
41
  });
42
- r.requires("tenant-lifecycle");
42
+ r.requires({ features: ["tenant-lifecycle"] });
43
43
  r.optionalRequires("files");
44
44
 
45
45
  r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy: fileRefTenantDestroyHook });
@@ -58,8 +58,8 @@ function registerFolders(
58
58
  if (toggleable !== undefined) r.toggleable(toggleable);
59
59
 
60
60
  const folderAssignmentEntity = createFolderAssignmentEntity(ownership, parents);
61
- r.entity("folder", folderEntity);
62
- r.entity("folder-assignment", folderAssignmentEntity);
61
+ r.entity({ name: "folder", ...folderEntity });
62
+ r.entity({ name: "folder-assignment", ...folderAssignmentEntity });
63
63
 
64
64
  // Folder catalog — plain CRUD, no custom logic. update is rename (and, in a
65
65
  // later stage, reparent: it accepts changes.parentId, optimistic-locked).
@@ -20,7 +20,7 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
20
20
  // OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
21
21
  // tier), a hard r.requires would throw an "effectively disabled" boot
22
22
  // warning even though the folder entities exist and the hooks work fine.
23
- r.requires("user-data-rights");
23
+ r.requires({ features: ["user-data-rights"] });
24
24
  r.optionalRequires("folders");
25
25
  r.useExtension(EXT_USER_DATA, "folder", {
26
26
  export: folderExportHook,
@@ -30,11 +30,11 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
30
30
  // config is optional: the retention-days key only resolves when the
31
31
  // config feature is mounted (ctx.configResolver present) — the cleanup
32
32
  // job already falls back to FORM_DRAFT_DEFAULT_RETENTION_DAYS otherwise
33
- // (see handlers/cleanup.job.ts). A hard r.requires("config") would break
33
+ // (see handlers/cleanup.job.ts). A hard r.requires({ features: ["config"] }) would break
34
34
  // every app that mounts form-draft without config, which is a breaking
35
35
  // change no minor/patch release should introduce silently.
36
36
  r.optionalRequires("config");
37
- r.entity("form-draft", formDraftEntity);
37
+ r.entity({ name: "form-draft", ...formDraftEntity });
38
38
 
39
39
  r.writeHandler(saveDraftWrite);
40
40
  r.writeHandler(discardDraftWrite);
@@ -12,7 +12,7 @@ export const formDraftUserDataFeature = defineFeature("form-draft-user-data", (r
12
12
  r.describe(
13
13
  "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `form-draft` feature's `form-draft` entity. Mounts the EXT_USER_DATA export hook so a user's saved drafts are included in the user-data export bundle, and a delete hook that physically removes the user's own draft rows on forget. Kept separate from `form-draft` so form-draft consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `form-draft`.",
14
14
  );
15
- r.requires("user-data-rights");
15
+ r.requires({ features: ["user-data-rights"] });
16
16
  r.optionalRequires("form-draft");
17
17
  r.useExtension(EXT_USER_DATA, "form-draft", {
18
18
  export: formDraftExportHook,