@cosmicdrift/kumiko-samples 0.393.0 → 0.395.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 (226) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +143 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -2
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +238 -89
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +36 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +61 -136
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +4 -6
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
  133. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  134. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  135. package/packages/bundled-features/src/jobs/changes.json +18 -0
  136. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  137. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  138. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  139. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  140. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  141. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  142. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  143. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  144. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  145. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  146. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  147. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  148. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  149. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  150. package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
  151. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  152. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  153. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  154. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  155. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  156. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  157. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  158. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  159. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  160. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  161. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  162. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  163. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  164. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  165. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  166. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  167. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  168. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  169. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  170. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  171. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  172. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  173. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  174. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  175. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  176. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
  177. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  178. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  179. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
  180. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  181. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  182. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  183. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  184. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  186. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
  188. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  189. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  190. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  191. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  193. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  194. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  195. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  196. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  197. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  198. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  199. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  200. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  201. package/samples/apps/use-all-bundled/feature-manifest.json +89 -40
  202. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  203. package/samples/apps/use-all-bundled/package.json +1 -0
  204. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  205. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  206. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  207. package/samples/apps/use-all-bundled/src/app/client.tsx +8 -1
  208. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  209. package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
  210. package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
  211. package/samples/recipes/cross-feature-events/README.md +10 -0
  212. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  213. package/samples/recipes/dashboard-stats/README.md +8 -0
  214. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  215. package/samples/recipes/env-composition/README.md +2 -2
  216. package/samples/recipes/env-composition/src/env.ts +2 -2
  217. package/samples/recipes/inbound-mail-triage/README.md +8 -0
  218. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  219. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  220. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  221. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  222. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  223. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  224. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  225. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  226. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -0,0 +1,203 @@
1
+ // Call-time key/flag resolution for the subscription-paddle plugin, mirroring
2
+ // subscription-stripe/runtime.ts: credentials come from backing:"secrets" config
3
+ // keys under SYSTEM_TENANT_ID, so rotation and go-live need no redeploy.
4
+ // The factory options survive as env-bridge fallbacks.
5
+ //
6
+ // Two read paths: post-tenant handlers read the key audited through the
7
+ // HandlerContext; the pre-tenant webhook reads secrets un-audited from the
8
+ // system SecretsContext the foundation passes in.
9
+
10
+ import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
11
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
12
+ import {
13
+ type ConfigKeyHandle,
14
+ type HandlerContext,
15
+ SYSTEM_TENANT_ID,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
18
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
19
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
20
+ import { createPaddleHttp, type PaddleRequest, type PaddleSend } from "./client.ts";
21
+ import {
22
+ PADDLE_BASE_URLS,
23
+ type PaddleEnvironment,
24
+ PaddleEnvironments,
25
+ SUBSCRIPTION_PADDLE_FEATURE,
26
+ } from "./constants.ts";
27
+
28
+ export type PaddlePluginMethod<TName extends keyof SubscriptionProviderPlugin> = NonNullable<
29
+ SubscriptionProviderPlugin[TName]
30
+ >;
31
+
32
+ export type PaddleFallbacks = {
33
+ readonly apiKey?: string;
34
+ readonly webhookSecret?: string;
35
+ readonly tenantBindingKey?: string;
36
+ readonly clientToken?: string;
37
+ };
38
+
39
+ export type PaddleRuntimeDeps = {
40
+ readonly apiKeyHandle: ConfigKeyHandle<"text">;
41
+ readonly webhookSecretHandle: ConfigKeyHandle<"text">;
42
+ readonly tenantBindingKeyHandle: ConfigKeyHandle<"text">;
43
+ readonly clientTokenHandle: ConfigKeyHandle<"text">;
44
+ readonly billingLiveHandle: ConfigKeyHandle<"boolean">;
45
+ readonly environmentHandle: ConfigKeyHandle<"select">;
46
+ readonly send: PaddleSend;
47
+ readonly fallback: PaddleFallbacks;
48
+ };
49
+
50
+ // backing:"secrets" values are stored JSON-serialized, so the store read has to
51
+ // be parsed back; the env fallback is a plain string and stays raw. The error
52
+ // must not carry the input: the JSON parser's own message would repeat the
53
+ // credential into logs and, for webhooks, into the 401 body.
54
+ function parseStoredSecret(raw: string | undefined): string | undefined {
55
+ if (raw === undefined || raw.length === 0) return undefined;
56
+ const parsed = parseJsonSafe<unknown>(raw, undefined);
57
+ if (parsed === undefined) {
58
+ throw new Error("subscription-paddle credential (backing:secrets) is not valid JSON");
59
+ }
60
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
61
+ }
62
+
63
+ function hintFor(shortName: string): string {
64
+ return `Set it via config:write:set on \`${SUBSCRIPTION_PADDLE_FEATURE}:config:${shortName}\` (or seed it from the matching PADDLE_* variable during the env bridge).`;
65
+ }
66
+
67
+ export type PaddleCtxRuntime = {
68
+ readonly call: (ctx: HandlerContext, request: PaddleRequest) => Promise<unknown>;
69
+ readonly assertBillingLive: (ctx: HandlerContext, handlerName?: string) => Promise<void>;
70
+ readonly isBillingEnabled: (ctx: HandlerContext) => Promise<boolean>;
71
+ readonly tenantBindingKey: (ctx: HandlerContext) => Promise<string>;
72
+ };
73
+
74
+ export type PaddleWebhookRuntime = {
75
+ readonly resolveWebhookSecret: (systemSecrets?: SecretsContext) => Promise<string>;
76
+ readonly resolveTenantBindingKey: (systemSecrets?: SecretsContext) => Promise<string>;
77
+ };
78
+
79
+ export type PaddleCheckoutConfig = {
80
+ readonly clientToken: string | null;
81
+ readonly environment: PaddleEnvironment;
82
+ };
83
+
84
+ export type PaddleRuntimes = {
85
+ readonly ctx: PaddleCtxRuntime;
86
+ readonly webhook: PaddleWebhookRuntime;
87
+ readonly readCheckoutConfig: (
88
+ ctx: Pick<HandlerContext, "config">,
89
+ ) => Promise<PaddleCheckoutConfig>;
90
+ };
91
+
92
+ function environmentOf(value: string | undefined): PaddleEnvironment {
93
+ return value === PaddleEnvironments.live ? PaddleEnvironments.live : PaddleEnvironments.sandbox;
94
+ }
95
+
96
+ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
97
+ const http = createPaddleHttp(deps.send);
98
+
99
+ async function ctxSecret(
100
+ ctx: HandlerContext,
101
+ handle: ConfigKeyHandle<"text">,
102
+ fallback: string | undefined,
103
+ shortName: string,
104
+ ): Promise<string> {
105
+ let value: string | undefined;
106
+ if (ctx.services.secrets) {
107
+ const got = await requireSecretsContext(
108
+ { secrets: ctx.services.secrets, user: ctx.user },
109
+ SUBSCRIPTION_PADDLE_FEATURE,
110
+ ).get(SYSTEM_TENANT_ID, handle.name);
111
+ value = parseStoredSecret(got?.reveal());
112
+ }
113
+ if (!value && fallback && fallback.length > 0) value = fallback;
114
+ if (!value) {
115
+ throw new UnconfiguredError({
116
+ feature: SUBSCRIPTION_PADDLE_FEATURE,
117
+ key: handle.name,
118
+ hint: hintFor(shortName),
119
+ });
120
+ }
121
+ return value;
122
+ }
123
+
124
+ async function webhookSecret(
125
+ systemSecrets: SecretsContext | undefined,
126
+ handle: ConfigKeyHandle<"text">,
127
+ fallback: string | undefined,
128
+ ): Promise<string> {
129
+ if (systemSecrets) {
130
+ // No auditCtx: every webhook would otherwise spam the audit trail.
131
+ const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
132
+ const stored = parseStoredSecret(got?.reveal());
133
+ if (stored) return stored;
134
+ }
135
+ if (fallback && fallback.length > 0) return fallback;
136
+ throw new Error(
137
+ `subscription-paddle: '${handle.name}' unresolved — no system-secret set and no factory fallback. Webhook cannot verify until it is configured.`,
138
+ );
139
+ }
140
+
141
+ async function hasSecret(
142
+ ctx: HandlerContext,
143
+ handle: ConfigKeyHandle<"text">,
144
+ fallback: string | undefined,
145
+ ): Promise<boolean> {
146
+ if (fallback !== undefined && fallback.length > 0) return true;
147
+ // Existence-only probe on every billing-plans render: must stay un-audited.
148
+ return ctx.services.secrets
149
+ ? await ctx.services.secrets.has(SYSTEM_TENANT_ID, handle.name)
150
+ : false;
151
+ }
152
+
153
+ async function baseUrlFor(ctx: HandlerContext): Promise<string> {
154
+ const environment = ctx.config ? await ctx.config(deps.environmentHandle) : undefined;
155
+ return PADDLE_BASE_URLS[environmentOf(environment)];
156
+ }
157
+
158
+ return {
159
+ ctx: {
160
+ call: async (ctx, request) =>
161
+ http(
162
+ {
163
+ baseUrl: await baseUrlFor(ctx),
164
+ apiKey: await ctxSecret(ctx, deps.apiKeyHandle, deps.fallback.apiKey, "api-key"),
165
+ },
166
+ request,
167
+ ),
168
+ assertBillingLive: async (ctx, handlerName = "create-checkout-session") => {
169
+ const live = ctx.config ? await ctx.config(deps.billingLiveHandle) : undefined;
170
+ if (live !== true) {
171
+ throw new FeatureDisabledError(SUBSCRIPTION_PADDLE_FEATURE, handlerName);
172
+ }
173
+ },
174
+ isBillingEnabled: async (ctx) => {
175
+ const live = ctx.config ? await ctx.config(deps.billingLiveHandle) : undefined;
176
+ if (live !== true) return false;
177
+ return (
178
+ (await hasSecret(ctx, deps.apiKeyHandle, deps.fallback.apiKey)) &&
179
+ (await hasSecret(ctx, deps.tenantBindingKeyHandle, deps.fallback.tenantBindingKey))
180
+ );
181
+ },
182
+ tenantBindingKey: (ctx) =>
183
+ ctxSecret(
184
+ ctx,
185
+ deps.tenantBindingKeyHandle,
186
+ deps.fallback.tenantBindingKey,
187
+ "tenant-binding-key",
188
+ ),
189
+ },
190
+ webhook: {
191
+ resolveWebhookSecret: (systemSecrets) =>
192
+ webhookSecret(systemSecrets, deps.webhookSecretHandle, deps.fallback.webhookSecret),
193
+ resolveTenantBindingKey: (systemSecrets) =>
194
+ webhookSecret(systemSecrets, deps.tenantBindingKeyHandle, deps.fallback.tenantBindingKey),
195
+ },
196
+ readCheckoutConfig: async (ctx) => {
197
+ const stored = ctx.config ? await ctx.config(deps.clientTokenHandle) : undefined;
198
+ const environment = ctx.config ? await ctx.config(deps.environmentHandle) : undefined;
199
+ const clientToken = stored || deps.fallback.clientToken || null;
200
+ return { clientToken, environment: environmentOf(environment) };
201
+ },
202
+ };
203
+ }
@@ -0,0 +1,73 @@
1
+ // Paddle's subscription entity and its mapping onto the foundation's state.
2
+ // Shared by the webhook and retrieveSubscription so both map identically.
3
+
4
+ import {
5
+ type SubscriptionStatus,
6
+ SubscriptionStatuses,
7
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
8
+ import * as z from "zod";
9
+
10
+ const PADDLE_SUBSCRIPTION_STATUSES: Readonly<Record<string, SubscriptionStatus>> = {
11
+ active: SubscriptionStatuses.active,
12
+ trialing: SubscriptionStatuses.trialing,
13
+ past_due: SubscriptionStatuses.pastDue,
14
+ paused: SubscriptionStatuses.pastDue,
15
+ canceled: SubscriptionStatuses.canceled,
16
+ };
17
+
18
+ const SCHEDULED_CANCEL_ACTION = "cancel";
19
+
20
+ const instantSchema = z.iso.datetime({ offset: true });
21
+
22
+ export const paddleCustomDataSchema = z.record(z.string(), z.unknown()).nullish();
23
+
24
+ export const paddleSubscriptionSchema = z.object({
25
+ id: z.string().min(1),
26
+ status: z.string(),
27
+ customer_id: z.string().min(1),
28
+ items: z.array(
29
+ z.object({
30
+ price: z.object({ id: z.string().min(1) }),
31
+ quantity: z.number().int().positive().optional(),
32
+ }),
33
+ ),
34
+ custom_data: paddleCustomDataSchema,
35
+ current_billing_period: z.object({ ends_at: instantSchema }).nullish(),
36
+ next_billed_at: instantSchema.nullish(),
37
+ scheduled_change: z.object({ action: z.string(), effective_at: instantSchema }).nullish(),
38
+ canceled_at: instantSchema.nullish(),
39
+ });
40
+ export type PaddleSubscription = z.infer<typeof paddleSubscriptionSchema>;
41
+
42
+ export type MappedPaddleSubscriptionState = {
43
+ readonly status: SubscriptionStatus;
44
+ readonly tier: string;
45
+ readonly currentPeriodEnd: string;
46
+ readonly cancelAt: string | null;
47
+ };
48
+
49
+ /** Null when the status is unknown, no item's price is in `priceToTier`
50
+ * (SubscriptionEvent.tier is required) or no period end can be derived. */
51
+ export function mapPaddleSubscriptionState(
52
+ sub: PaddleSubscription,
53
+ priceToTier: Readonly<Record<string, string>>,
54
+ ): MappedPaddleSubscriptionState | null {
55
+ const status = Object.hasOwn(PADDLE_SUBSCRIPTION_STATUSES, sub.status)
56
+ ? PADDLE_SUBSCRIPTION_STATUSES[sub.status]
57
+ : undefined;
58
+ const tier = sub.items
59
+ .map((item) =>
60
+ Object.hasOwn(priceToTier, item.price.id) ? priceToTier[item.price.id] : undefined,
61
+ )
62
+ .find((name) => name);
63
+ const currentPeriodEnd =
64
+ sub.current_billing_period?.ends_at ?? sub.next_billed_at ?? sub.canceled_at;
65
+ if (!status || !tier || !currentPeriodEnd) return null;
66
+ const scheduled = sub.scheduled_change;
67
+ return {
68
+ status,
69
+ tier,
70
+ currentPeriodEnd,
71
+ cancelAt: scheduled?.action === SCHEDULED_CANCEL_ACTION ? scheduled.effective_at : null,
72
+ };
73
+ }
@@ -0,0 +1,34 @@
1
+ // Paddle.js runs with a public client token and can open checkouts with any
2
+ // `customData` from the browser, so `custom_data.tenantId` alone proves nothing.
3
+ // The binding is an HMAC only the server can produce: webhooks whose tenantId
4
+ // does not carry a valid binding are ignored.
5
+
6
+ import { createHmac, timingSafeEqual } from "node:crypto";
7
+ import { parseProviderConsentId } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
8
+
9
+ const BINDING_CONTEXT = "kumiko-paddle-tenant:v1:";
10
+
11
+ export function signTenantBinding(key: string, tenantId: string): string {
12
+ return createHmac("sha256", key).update(`${BINDING_CONTEXT}${tenantId}`).digest("base64url");
13
+ }
14
+
15
+ export function isValidTenantBinding(key: string, tenantId: string, binding: string): boolean {
16
+ const expected = Buffer.from(signTenantBinding(key, tenantId));
17
+ const candidate = Buffer.from(binding);
18
+ return candidate.length === expected.length && timingSafeEqual(candidate, expected);
19
+ }
20
+
21
+ export type BoundTenant = { readonly tenantId: string; readonly consentId?: string };
22
+
23
+ /** Null unless `customData` names a tenant together with a binding that `key` signed. */
24
+ export function boundTenantOf(
25
+ customData: Readonly<Record<string, unknown>> | null | undefined,
26
+ key: string,
27
+ ): BoundTenant | null {
28
+ const tenantId = customData?.["tenantId"];
29
+ const binding = customData?.["tenantBinding"];
30
+ if (typeof tenantId !== "string" || tenantId.length === 0) return null;
31
+ if (typeof binding !== "string" || !isValidTenantBinding(key, tenantId, binding)) return null;
32
+ const consentId = parseProviderConsentId(customData?.["consentId"]);
33
+ return { tenantId, ...(consentId !== undefined && { consentId }) };
34
+ }
@@ -0,0 +1,61 @@
1
+ // Paddle webhook signature check: `Paddle-Signature: ts=<unix>;h1=<hex>[;h1=<hex>]`,
2
+ // HMAC-SHA256 over `${ts}:${rawBody}` keyed with the raw secret string.
3
+ //
4
+ // Verification always runs against the raw body string; re-serialized JSON
5
+ // would change whitespace and break the HMAC. It happens before any parsing,
6
+ // so no path bypasses it.
7
+
8
+ import { createHmac, timingSafeEqual } from "node:crypto";
9
+
10
+ // Paddle's own SDK accepts 5 s. 300 s matches Stripe here: the foundation
11
+ // de-duplicates by event_id and pod clocks drift.
12
+ export const PADDLE_WEBHOOK_TOLERANCE_SECONDS = 300;
13
+
14
+ const SIGNATURE_HEADER = "paddle-signature";
15
+ const TIMESTAMP_PATTERN = /^\d+$/;
16
+ const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i;
17
+
18
+ type ParsedSignatureHeader = { readonly timestamp: string; readonly signatures: readonly string[] };
19
+
20
+ function parseSignatureHeader(header: string): ParsedSignatureHeader | null {
21
+ let timestamp: string | undefined;
22
+ const signatures: string[] = [];
23
+ for (const part of header.split(";")) {
24
+ const separator = part.indexOf("=");
25
+ if (separator < 0) continue;
26
+ const name = part.slice(0, separator).trim();
27
+ const value = part.slice(separator + 1).trim();
28
+ if (name === "ts") timestamp = value;
29
+ else if (name === "h1") signatures.push(value);
30
+ }
31
+ return timestamp === undefined ? null : { timestamp, signatures };
32
+ }
33
+
34
+ /** Throws unless one `h1` signature over `rawBody` is valid and the timestamp is
35
+ * within the tolerance in both directions. Header names must be lower-case. */
36
+ export function verifyPaddleSignature(
37
+ rawBody: string,
38
+ headers: Readonly<Record<string, string>>,
39
+ secret: string,
40
+ nowSeconds: number,
41
+ ): void {
42
+ const header = headers[SIGNATURE_HEADER];
43
+ if (!header) throw new Error("subscription-paddle: paddle-signature header missing");
44
+ const parsed = parseSignatureHeader(header);
45
+ if (!parsed || parsed.signatures.length === 0) {
46
+ throw new Error("subscription-paddle: paddle-signature header is malformed");
47
+ }
48
+ if (!TIMESTAMP_PATTERN.test(parsed.timestamp)) {
49
+ throw new Error("subscription-paddle: webhook ts is not a Unix timestamp in seconds");
50
+ }
51
+ if (Math.abs(nowSeconds - Number(parsed.timestamp)) > PADDLE_WEBHOOK_TOLERANCE_SECONDS) {
52
+ throw new Error("subscription-paddle: webhook timestamp outside the tolerance window");
53
+ }
54
+ const expected = createHmac("sha256", secret).update(`${parsed.timestamp}:${rawBody}`).digest();
55
+ const matches = parsed.signatures.some((signature) => {
56
+ if (!SHA256_HEX_PATTERN.test(signature)) return false;
57
+ const candidate = Buffer.from(signature, "hex");
58
+ return candidate.length === expected.length && timingSafeEqual(candidate, expected);
59
+ });
60
+ if (!matches) throw new Error("subscription-paddle: webhook signature verify failed");
61
+ }
@@ -0,0 +1,172 @@
1
+ // Paddle webhook: signature check, then mapping of Paddle's event envelope onto
2
+ // the foundation's normalized events. Events without a valid tenant binding are
3
+ // dropped (200 ignored): see tenant-binding.ts.
4
+
5
+ import {
6
+ BillingEventKinds,
7
+ type PaymentEvent,
8
+ type SubscriptionEvent,
9
+ type SubscriptionEventType,
10
+ SubscriptionEventTypes,
11
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
12
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
13
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
14
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
15
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
16
+ import * as z from "zod";
17
+ import { PADDLE_PROVIDER_NAME } from "./constants.ts";
18
+ import type { PaddleWebhookRuntime } from "./runtime.ts";
19
+ import {
20
+ mapPaddleSubscriptionState,
21
+ paddleCustomDataSchema,
22
+ paddleSubscriptionSchema,
23
+ } from "./subscription-state.ts";
24
+ import { boundTenantOf } from "./tenant-binding.ts";
25
+ import { verifyPaddleSignature } from "./verify-webhook.ts";
26
+
27
+ const PADDLE_SUBSCRIPTION_EVENT_TYPES: Readonly<Record<string, SubscriptionEventType>> = {
28
+ "subscription.created": SubscriptionEventTypes.created,
29
+ "subscription.activated": SubscriptionEventTypes.updated,
30
+ "subscription.updated": SubscriptionEventTypes.updated,
31
+ "subscription.trialing": SubscriptionEventTypes.updated,
32
+ "subscription.paused": SubscriptionEventTypes.updated,
33
+ "subscription.resumed": SubscriptionEventTypes.updated,
34
+ "subscription.past_due": SubscriptionEventTypes.invoicePaymentFailed,
35
+ "subscription.canceled": SubscriptionEventTypes.canceled,
36
+ };
37
+
38
+ const TRANSACTION_COMPLETED = "transaction.completed";
39
+
40
+ const envelopeSchema = z.object({
41
+ event_id: z.string().min(1),
42
+ event_type: z.string(),
43
+ occurred_at: z.iso.datetime({ offset: true }),
44
+ data: z.unknown(),
45
+ });
46
+
47
+ const paddleTransactionSchema = z.object({
48
+ id: z.string().min(1),
49
+ subscription_id: z.string().nullish(),
50
+ customer_id: z.string().min(1).nullish(),
51
+ custom_data: paddleCustomDataSchema,
52
+ items: z.array(
53
+ z.object({
54
+ price: z.object({ id: z.string().min(1) }),
55
+ quantity: z.number().int().positive().optional(),
56
+ }),
57
+ ),
58
+ });
59
+
60
+ export type PaddleWebhookOptions = {
61
+ readonly priceToTier: Readonly<Record<string, string>>;
62
+ /** Test seam: Unix seconds. Defaults to the wall clock. */
63
+ readonly nowSeconds?: () => number;
64
+ };
65
+
66
+ function currentUnixSeconds(): number {
67
+ return Math.floor(Temporal.Now.instant().epochMilliseconds / 1000);
68
+ }
69
+
70
+ function parsePaymentEvent(
71
+ providerEventId: string,
72
+ data: unknown,
73
+ bindingKey: string,
74
+ ): PaymentEvent | null {
75
+ const parsed = paddleTransactionSchema.safeParse(data);
76
+ if (!parsed.success) return null;
77
+ const transaction = parsed.data;
78
+ // A subscription's own payments are covered by its subscription.* events.
79
+ if (transaction.subscription_id) return null;
80
+ const priceId = transaction.items[0]?.price.id;
81
+ const quantity = transaction.items[0]?.quantity ?? 1;
82
+ const bound = boundTenantOf(transaction.custom_data, bindingKey);
83
+ if (!bound || !priceId || !transaction.customer_id) return null;
84
+ return {
85
+ kind: BillingEventKinds.payment,
86
+ providerEventId,
87
+ providerName: PADDLE_PROVIDER_NAME,
88
+ tenantId: bound.tenantId,
89
+ ...(bound.consentId !== undefined && { consentId: bound.consentId }),
90
+ providerCheckoutId: transaction.id,
91
+ providerCustomerId: transaction.customer_id,
92
+ priceId,
93
+ quantity,
94
+ };
95
+ }
96
+
97
+ // Subscription prices are meant to be quantity 1; the tier is still set, but a
98
+ // different quantity is worth an operator's attention. Event id and quantity only.
99
+ function warnOnUnexpectedQuantity(
100
+ providerEventId: string,
101
+ items: readonly { readonly quantity?: number | undefined }[],
102
+ ): void {
103
+ for (const item of items) {
104
+ if (item.quantity !== undefined && item.quantity !== 1) {
105
+ createFallbackLogger("subscription-paddle").warn(
106
+ "paddle subscription item quantity is not 1; the tier is still set",
107
+ { providerEventId, quantity: item.quantity },
108
+ );
109
+ }
110
+ }
111
+ }
112
+
113
+ function parseSubscriptionEvent(
114
+ providerEventId: string,
115
+ type: SubscriptionEventType,
116
+ providerEventAt: string,
117
+ data: unknown,
118
+ options: { readonly priceToTier: Readonly<Record<string, string>>; readonly bindingKey: string },
119
+ ): SubscriptionEvent | null {
120
+ const parsed = paddleSubscriptionSchema.safeParse(data);
121
+ if (!parsed.success) return null;
122
+ const sub = parsed.data;
123
+ const bound = boundTenantOf(sub.custom_data, options.bindingKey);
124
+ const state = mapPaddleSubscriptionState(sub, options.priceToTier);
125
+ if (!bound || !state) return null;
126
+ warnOnUnexpectedQuantity(providerEventId, sub.items);
127
+ return {
128
+ providerEventId,
129
+ providerName: PADDLE_PROVIDER_NAME,
130
+ type,
131
+ tenantId: bound.tenantId,
132
+ ...(bound.consentId !== undefined && { consentId: bound.consentId }),
133
+ providerCustomerId: sub.customer_id,
134
+ providerSubscriptionId: sub.id,
135
+ providerEventAt,
136
+ ...state,
137
+ };
138
+ }
139
+
140
+ export function verifyAndParsePaddleWebhook(
141
+ runtime: PaddleWebhookRuntime,
142
+ options: PaddleWebhookOptions,
143
+ ): (
144
+ rawBody: string,
145
+ headers: Record<string, string>,
146
+ systemSecrets?: SecretsContext,
147
+ ) => Promise<SubscriptionEvent | PaymentEvent | null> {
148
+ const nowSeconds = options.nowSeconds ?? currentUnixSeconds;
149
+ return async (rawBody, headers, systemSecrets) => {
150
+ const secret = await runtime.resolveWebhookSecret(systemSecrets);
151
+ verifyPaddleSignature(rawBody, headers, secret, nowSeconds());
152
+ // After verification only: an unauthenticated request must not trigger a second secret read.
153
+ const bindingKey = await runtime.resolveTenantBindingKey(systemSecrets);
154
+
155
+ // Past verification the body is trusted to be Paddle's; a malformed shape is
156
+ // schema drift, so it is dropped (200 ignored) instead of answered with a 401.
157
+ const envelope = envelopeSchema.safeParse(
158
+ parseJsonOrThrow<unknown>(rawBody, "subscription-paddle webhook body"),
159
+ );
160
+ if (!envelope.success) return null;
161
+
162
+ const { event_id: providerEventId, event_type: type, occurred_at, data } = envelope.data;
163
+ if (type === TRANSACTION_COMPLETED) return parsePaymentEvent(providerEventId, data, bindingKey);
164
+ if (!Object.hasOwn(PADDLE_SUBSCRIPTION_EVENT_TYPES, type)) return null;
165
+ const normalizedType = PADDLE_SUBSCRIPTION_EVENT_TYPES[type];
166
+ if (!normalizedType) return null;
167
+ return parseSubscriptionEvent(providerEventId, normalizedType, occurred_at, data, {
168
+ priceToTier: options.priceToTier,
169
+ bindingKey,
170
+ });
171
+ };
172
+ }
@@ -232,8 +232,8 @@ including SSE refresh, and an editor target with the `collectionId`. The app pas
232
232
  under an unmounted feature causes boot to fail instead of silently disappearing from the sidebar. Node
233
233
  visibility follows `access` unless `nav.access` specifies otherwise.
234
234
 
235
- A "+" on the node requires an explicit `nav.createAction`, whose target is usually
236
- `treeHandle.create`; without it, the collection only lists existing items. `nav.actions` adds hover actions to the row.
235
+ A "+" on the node opens the built-in create form (`template-resolver:create`) unless
236
+ `nav.createAction` points elsewhere, usually at `treeHandle.create`. `nav.actions` adds hover actions to the row.
237
237
 
238
238
  Saving **publishes immediately**, with status `active` and an empty `variableSchema`.
239
239
  Anyone who needs a draft stage or a variable schema uses
@@ -1,4 +1,24 @@
1
1
  [
2
+ {
3
+ "version": "0.395.0",
4
+ "type": "breaking",
5
+ "title": "Content collections edit in plain or document format; rich and markdown are gone",
6
+ "detail": "ContentEditorFormat is \"plain\" | \"document\". RichContentEditor and the old tiptap editor are removed from @cosmicdrift/kumiko-renderer/web; the document editor is DocumentContentEditor from @cosmicdrift/kumiko-bundled-features/template-resolver/web. A collection's storage format comes from its declaration on the server (plain stores plain, document stores html sanitized with sanitizeDocumentHtml for every collection kind) and the collection write no longer takes contentFormat. Saved content with {{name}} as text opens with variable nodes. Every collection gets a create form behind a \"+\" in its nav (unless it declares its own createAction); the collection write takes createOnly and answers an existing slug with the field error slug_exists. The text block editor and the secrets edit screen use FormScreenLayout, which takes an optional unsavedCount. useDispatchTarget is exported from @cosmicdrift/kumiko-renderer/web.",
7
+ "migration": "Run bun kumiko-upgrade --apply. It rewrites contentFormat \"rich\" and \"markdown\" in collection declarations to \"document\". Replace imports of RichContentEditor with DocumentContentEditor from @cosmicdrift/kumiko-bundled-features/template-resolver/web, and drop contentFormat from collection write payloads.",
8
+ "codemod": "scripts/codemod/content-collection-format.ts"
9
+ },
10
+ {
11
+ "version": "0.394.0",
12
+ "type": "fix",
13
+ "title": "collection trees and Save follow the server's access rules",
14
+ "detail": "textBlocksClient({ tenantId }) no longer sends tenantIdOverride for `ownership: \"user\"` collections, and a tenant-wide tree that the server answers with tenant_override_requires_system_admin reloads on the session tenant, so members see and edit their own entries instead of an error. The Save button follows the collection's own access rule (DEFAULT_COLLECTION_ACCESS when none is declared), the same rule the list, item and set handlers check; the public text-block pair keeps TenantAdmin or SystemAdmin. A save reloads the tree of its collection, so a new per-user entry shows without a page reload. The editor sets the page header title to the entry title, so the breadcrumb no longer shows the screen behind it."
15
+ },
16
+ {
17
+ "version": "0.394.0",
18
+ "type": "fix",
19
+ "title": "user-owned collection entries no longer fail with pii_ciphertext_leak",
20
+ "detail": "With a PII key service configured, the list and item queries of an `ownership: \"user\"` collection (signatures) returned the stored ciphertext, so the API rejected the response and the editor showed \"Could not load block\". Both queries now decrypt `content` for the owner before returning it."
21
+ },
2
22
  {
3
23
  "version": "0.390.0",
4
24
  "type": "fix",
@@ -299,6 +299,18 @@ function isSanitizedHtmlKind(kind: string): boolean {
299
299
  return kind === "snippet" || isOneOf(DOCUMENT_HTML_KINDS, kind);
300
300
  }
301
301
 
302
+ type SanitizeBodyInput = {
303
+ readonly kind: string;
304
+ readonly contentFormat: ContentFormat;
305
+ readonly content: string | null;
306
+ /** A document collection stores html whatever its kind, so its html is always rebuilt. */
307
+ readonly alwaysSanitizeHtml?: boolean;
308
+ };
309
+
310
+ function isSanitizedBody(input: SanitizeBodyInput): boolean {
311
+ return input.alwaysSanitizeHtml === true || isSanitizedHtmlKind(input.kind);
312
+ }
313
+
302
314
  const HTML_ONLY_KINDS: ReadonlySet<string> = new Set(DOCUMENT_HTML_KINDS);
303
315
 
304
316
  function requiredFormatViolation(
@@ -319,14 +331,10 @@ const IMAGES_BLOCK_KIND = "document-pdf";
319
331
 
320
332
  // The node is part of the shared document model, so a mail body would keep it after the
321
333
  // rebuild; only a PDF resolves the record's images.
322
- function imagesBlockViolation(input: {
323
- readonly kind: string;
324
- readonly contentFormat: ContentFormat;
325
- readonly content: string | null;
326
- }): DocumentViolation | null {
334
+ function imagesBlockViolation(input: SanitizeBodyInput): DocumentViolation | null {
327
335
  if (
328
336
  input.kind === IMAGES_BLOCK_KIND ||
329
- !isSanitizedHtmlKind(input.kind) ||
337
+ !isSanitizedBody(input) ||
330
338
  input.contentFormat !== "html" ||
331
339
  input.content === null
332
340
  ) {
@@ -345,20 +353,12 @@ function imagesBlockViolation(input: {
345
353
  : null;
346
354
  }
347
355
 
348
- export function sanitizeTemplateBody(input: {
349
- readonly kind: string;
350
- readonly contentFormat: ContentFormat;
351
- readonly content: string | null;
352
- }): SanitizeBodyResult {
356
+ export function sanitizeTemplateBody(input: SanitizeBodyInput): SanitizeBodyResult {
353
357
  const formatViolation = requiredFormatViolation(input.kind, input.contentFormat);
354
358
  if (formatViolation !== null) return { ok: false, violations: [formatViolation] };
355
359
  const imagesViolation = imagesBlockViolation(input);
356
360
  if (imagesViolation !== null) return { ok: false, violations: [imagesViolation] };
357
- if (
358
- !isSanitizedHtmlKind(input.kind) ||
359
- input.contentFormat !== "html" ||
360
- input.content === null
361
- ) {
361
+ if (!isSanitizedBody(input) || input.contentFormat !== "html" || input.content === null) {
362
362
  return { ok: true, content: input.content };
363
363
  }
364
364
  const result = sanitizeDocumentHtml(input.content);