@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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 (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
@@ -39,6 +39,554 @@ bun run e2e # layout-parity spec + regenerates the committed screenshot
39
39
 
40
40
  ## Source files
41
41
 
42
+ ### e2e/build-server.ts
43
+
44
+ ```ts
45
+ // Tiny Bun server that builds and serves the e2e bundle — same shape as
46
+ // wizard-form/e2e/build-server.ts, radically stripped down (no Postgres, no
47
+ // auth). The MockDispatcher (fixtures/mock-dispatcher.ts) replaces the
48
+ // whole stack.
49
+ //
50
+ // Difference from the renderer/web blueprint: the AppSchema is NOT
51
+ // duplicated in the client bundle (drift risk against the real feature
52
+ // code). Instead this server process builds the schema from the real
53
+ // src/feature.ts via createRegistry + buildAppSchema (both pure, no
54
+ // DB/HTTP) and serves it as JSON over /schema.json — client.tsx fetches
55
+ // it on boot. That keeps server-side framework code (defineFeature,
56
+ // r.crud, r.screen) out of the browser bundle.
57
+ //
58
+ // Port 4189: 4172-4188 are taken by other samples/apps (see their
59
+ // playwright.config.ts/package.json), 4189 is free.
60
+ //
61
+ // /styles.css is REALLY compiled from Tailwind (not stubbed) — the layout
62
+ // parity spec checks real box-model properties (button width, footer
63
+ // alignment). Without real CSS those would be fake assertions against
64
+ // unstyled browser default flow. Entry stylesheet = fixtures/styles.css, a
65
+ // local wrapper that re-exports renderer/web's styles.css and adds an
66
+ // @source covering this e2e dir — renderer/web's own @source globs only
67
+ // scan samples/**/src/**, which misses e2e/fixtures/client.tsx.
68
+ //
69
+ // One-shot build, no watcher (the server only runs for the test duration)
70
+ // — same CLI-resolve mechanism as create-kumiko-server.ts, but without its
71
+ // non-fatal degradation: if the CLI is missing or the build fails, the
72
+ // server must die hard instead of serving an empty stylesheet stub and
73
+ // letting the layout assertions pass vacuously.
74
+
75
+ import { mkdtempSync, rmSync } from "node:fs";
76
+ import { tmpdir } from "node:os";
77
+ import { join, resolve } from "node:path";
78
+ import { buildAppSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
79
+ import {
80
+ canResolveTailwindStylesheet,
81
+ resolveTailwindCli,
82
+ } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
83
+ import { noteDeskFeature } from "../src/feature";
84
+
85
+ const HERE = resolve(import.meta.dir);
86
+ const ENTRY = resolve(HERE, "fixtures/client.tsx");
87
+ const HTML_PATH = resolve(HERE, "fixtures/index.html");
88
+
89
+ async function buildStylesheet(): Promise<string> {
90
+ const cliPath = resolveTailwindCli({ bun: Bun, cwd: HERE });
91
+ if (cliPath === undefined) {
92
+ throw new Error(
93
+ "writeform-section/e2e: @tailwindcss/cli not resolvable — run `bun install` at the repo root.",
94
+ );
95
+ }
96
+ const entryCss = resolve(HERE, "fixtures/styles.css");
97
+ if (!canResolveTailwindStylesheet(entryCss, { bun: Bun, cwd: HERE })) {
98
+ throw new Error(
99
+ `writeform-section/e2e: tailwindcss not resolvable for ${entryCss} — peer dependency missing at the stylesheet's location.`,
100
+ );
101
+ }
102
+ const outDir = mkdtempSync(join(tmpdir(), "writeform-section-e2e-tw-"));
103
+ try {
104
+ const outPath = join(outDir, "styles.css");
105
+ const bunBin = process.argv[0] ?? "bun";
106
+ const build = Bun.spawnSync([bunBin, "run", cliPath, "-i", entryCss, "-o", outPath], {
107
+ cwd: HERE,
108
+ });
109
+ if (!build.success) {
110
+ throw new Error(
111
+ `writeform-section/e2e: tailwind build failed (exit ${build.exitCode})\n${build.stderr.toString()}`,
112
+ );
113
+ }
114
+ return await Bun.file(outPath).text();
115
+ } finally {
116
+ rmSync(outDir, { recursive: true, force: true });
117
+ }
118
+ }
119
+
120
+ const css = await buildStylesheet();
121
+
122
+ const port = Number(process.env["PORT"] ?? 4189);
123
+
124
+ const registry = createRegistry([noteDeskFeature]);
125
+ const schema = buildAppSchema(registry);
126
+ const schemaJson = JSON.stringify(schema);
127
+
128
+ const built = await Bun.build({
129
+ entrypoints: [ENTRY],
130
+ target: "browser",
131
+ sourcemap: "linked",
132
+ });
133
+
134
+ if (!built.success) {
135
+ // biome-ignore lint/suspicious/noConsole: e2e build script — no logger wired
136
+ for (const log of built.logs) console.error(log);
137
+ throw new Error("writeform-section/e2e: client bundle failed");
138
+ }
139
+
140
+ const jsOutput = built.outputs.find((o) => o.path.endsWith(".js"));
141
+ const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
142
+ if (!jsOutput) throw new Error("writeform-section/e2e: bundle has no .js output");
143
+
144
+ const js = await jsOutput.text();
145
+ const map = mapOutput ? await mapOutput.text() : "";
146
+ const html = await Bun.file(HTML_PATH).text();
147
+
148
+ Bun.serve({
149
+ port,
150
+ fetch(req: Request): Response {
151
+ const url = new URL(req.url);
152
+ if (url.pathname === "/client.js") {
153
+ return new Response(js, {
154
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
155
+ });
156
+ }
157
+ if (url.pathname === "/client.js.map") {
158
+ return new Response(map, { headers: { "Content-Type": "application/json; charset=utf-8" } });
159
+ }
160
+ if (url.pathname === "/schema.json") {
161
+ return new Response(schemaJson, {
162
+ headers: { "Content-Type": "application/json; charset=utf-8" },
163
+ });
164
+ }
165
+ if (url.pathname === "/styles.css") {
166
+ return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
167
+ }
168
+ if (url.pathname === "/api/sse" || url.pathname.startsWith("/sse")) {
169
+ // Long-lived empty stream in case some primitive/hook opens an
170
+ // EventSource after all — without a correct SSE response this
171
+ // would produce a "MIME type text/html" console error instead of a
172
+ // clean no-op.
173
+ const stream = new ReadableStream<Uint8Array>({
174
+ start(controller) {
175
+ controller.enqueue(new TextEncoder().encode(": connected\n\n"));
176
+ },
177
+ });
178
+ return new Response(stream, {
179
+ headers: {
180
+ "Content-Type": "text/event-stream",
181
+ "Cache-Control": "no-cache",
182
+ Connection: "keep-alive",
183
+ },
184
+ });
185
+ }
186
+ return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
187
+ },
188
+ });
189
+
190
+ // biome-ignore lint/suspicious/noConsole: e2e build script — no logger wired
191
+ console.log(`writeform-section/e2e build-server listening on http://localhost:${port}`);
192
+ ```
193
+
194
+ ### e2e/fixtures/client.tsx
195
+
196
+ ```tsx
197
+ // Browser entry for writeform-section/e2e. Fetches the server-built
198
+ // AppSchema (see build-server.ts — built from the real src/feature.ts, not
199
+ // duplicated here) and mounts createKumikoApp with the MockDispatcher.
200
+ //
201
+ // screenQn points at note-edit, the established entityEdit screen — it's
202
+ // openToAll, so no explicit user/role is needed.
203
+ //
204
+ // The `shell` renders the entityEdit screen (passed in as `children`) next
205
+ // to a second, independently mounted `note-detail` projectionDetail screen
206
+ // (its writeForm section) — that's what makes the layout-parity comparison
207
+ // between the two form kinds possible in one page load. Per
208
+ // packages/renderer/src/web/app/create-app.tsx, shell renders inside every
209
+ // provider createKumikoApp sets up (dispatcher, primitives, locale, nav,
210
+ // tokens), so the second KumikoScreen works without any provider of its own.
211
+
212
+ import type { AppSchema } from "@cosmicdrift/kumiko-renderer";
213
+ import { createKumikoApp, KumikoScreen } from "@cosmicdrift/kumiko-renderer/web";
214
+ import type { ReactNode } from "react";
215
+ import { createMockDispatcher } from "./mock-dispatcher";
216
+
217
+ const NOTE_EDIT_SCREEN_QN = "note-desk:screen:note-edit";
218
+ const NOTE_DETAIL_SCREEN_QN = "note-desk:screen:note-detail";
219
+
220
+ function SideBySideShell({
221
+ children,
222
+ schema,
223
+ }: {
224
+ readonly children: ReactNode;
225
+ readonly schema: AppSchema;
226
+ }): ReactNode {
227
+ const feature = schema.features[0];
228
+ return (
229
+ <div data-testid="side-by-side" className="grid grid-cols-2 gap-6 p-6 items-start">
230
+ <div data-testid="established-form">{children}</div>
231
+ <div data-testid="write-form-screen">
232
+ {feature !== undefined && (
233
+ <KumikoScreen schema={feature} qn={NOTE_DETAIL_SCREEN_QN} entityId="note-1" />
234
+ )}
235
+ </div>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ async function boot(): Promise<void> {
241
+ const root = document.getElementById("root");
242
+ if (root === null) throw new Error("writeform-section/e2e: #root not found");
243
+
244
+ const response = await fetch("/schema.json");
245
+ if (!response.ok) {
246
+ throw new Error(`writeform-section/e2e: /schema.json fetch failed with ${response.status}`);
247
+ }
248
+ const schema = (await response.json()) as AppSchema;
249
+
250
+ createKumikoApp({
251
+ schema,
252
+ dispatcher: createMockDispatcher(),
253
+ shell: SideBySideShell,
254
+ screenQn: NOTE_EDIT_SCREEN_QN,
255
+ });
256
+ }
257
+
258
+ boot().catch((error: unknown) => {
259
+ // A rejected boot() would otherwise become a silent unhandled rejection —
260
+ // surface it in the DOM so a failing e2e run shows the real cause instead
261
+ // of a blank #root.
262
+ const root = document.getElementById("root");
263
+ if (root !== null) {
264
+ root.textContent = `writeform-section/e2e: boot failed — ${String(error)}`;
265
+ }
266
+ throw error;
267
+ });
268
+ ```
269
+
270
+ ### e2e/fixtures/mock-dispatcher.ts
271
+
272
+ ```ts
273
+ // In-memory dispatcher for writeform-section/e2e. Implements the dispatcher
274
+ // interface from @cosmicdrift/kumiko-renderer/headless without the HTTP layer — no
275
+ // form-draft, no batch state, just the QNs this recipe's two screens need:
276
+ //
277
+ // - "note-desk:query:note:summary" — the projectionDetail screen's own
278
+ // query, returns the same fixed row the real server handler returns.
279
+ // - "note-desk:write:note:comment" — the writeForm section's handler.
280
+ // - "note-desk:write:note:create" — the entityEdit screen's create submit
281
+ // (r.crud's generated create handler).
282
+ //
283
+ // Both writes mirror their payload into localStorage under an exported key
284
+ // so the spec can read what actually reached the "server" via
285
+ // page.evaluate(), without reaching into this module's closure.
286
+
287
+ import type {
288
+ BatchResult,
289
+ Command,
290
+ Dispatcher,
291
+ DispatcherStatus,
292
+ PendingFile,
293
+ PendingWrite,
294
+ QueryOpts,
295
+ QueryResult,
296
+ Store,
297
+ WriteOpts,
298
+ WriteResult,
299
+ } from "@cosmicdrift/kumiko-renderer/headless";
300
+ import { NOTE_DETAIL_FIELDS } from "../../src/note-fixture";
301
+
302
+ const QUERY_NOTE_DETAIL = "note-desk:query:note:summary";
303
+ const WRITE_NOTE_COMMENT = "note-desk:write:note:comment";
304
+ const WRITE_NOTE_CREATE = "note-desk:write:note:create";
305
+
306
+ export const CREATED_COMMENTS_KEY = "mock-created-comments";
307
+ export const CREATED_NOTES_KEY = "mock-created-notes";
308
+
309
+ const ONLINE_STORE: Store<DispatcherStatus> = {
310
+ getSnapshot: () => "online",
311
+ subscribe: () => () => {},
312
+ };
313
+
314
+ function mirrorToStorage(key: string, payload: unknown): void {
315
+ const list = JSON.parse(localStorage.getItem(key) ?? "[]") as unknown[];
316
+ list.push(payload);
317
+ localStorage.setItem(key, JSON.stringify(list));
318
+ }
319
+
320
+ export function createMockDispatcher(): Dispatcher {
321
+ async function write<TData = unknown>(
322
+ type: string,
323
+ payload: unknown,
324
+ _opts?: WriteOpts,
325
+ ): Promise<WriteResult<TData>> {
326
+ if (type === WRITE_NOTE_COMMENT) {
327
+ mirrorToStorage(CREATED_COMMENTS_KEY, payload);
328
+ return { isSuccess: true, data: null as unknown as TData };
329
+ }
330
+ if (type === WRITE_NOTE_CREATE) {
331
+ mirrorToStorage(CREATED_NOTES_KEY, payload);
332
+ return { isSuccess: true, data: { id: "note-new", ...(payload as object) } as TData };
333
+ }
334
+ throw new Error(`mock-dispatcher: unsupported write qn "${type}"`);
335
+ }
336
+
337
+ async function query<TData = unknown>(
338
+ type: string,
339
+ payload: unknown,
340
+ _opts?: QueryOpts,
341
+ ): Promise<QueryResult<TData>> {
342
+ if (type === QUERY_NOTE_DETAIL) {
343
+ const data = (payload ?? {}) as Record<string, unknown>; // @cast-boundary mock-dispatcher wire payload
344
+ const id = (data["id"] as string | undefined) ?? "note-1";
345
+ return { isSuccess: true, data: { id, ...NOTE_DETAIL_FIELDS } as unknown as TData };
346
+ }
347
+ throw new Error(`mock-dispatcher: unsupported query qn "${type}"`);
348
+ }
349
+
350
+ async function batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult> {
351
+ const results: WriteResult[] = [];
352
+ for (const cmd of commands) results.push(await write(cmd.type, cmd.payload, opts));
353
+ return { isSuccess: true, results };
354
+ }
355
+
356
+ return {
357
+ write,
358
+ query,
359
+ batch,
360
+ statusStore: ONLINE_STORE,
361
+ async *stream() {},
362
+ pendingWrites: (): readonly PendingWrite[] => [],
363
+ pendingFiles: (): readonly PendingFile[] => [],
364
+ };
365
+ }
366
+ ```
367
+
368
+ ### e2e/playwright.config.ts
369
+
370
+ ```ts
371
+ // Playwright config for writeform-section/e2e. Boots the minimal
372
+ // build-server (see e2e/build-server.ts) — a real browser + real React
373
+ // bundle against a MockDispatcher, no full Kumiko stack. Pattern copied
374
+ // from samples/recipes/wizard-form/e2e/playwright.config.ts.
375
+ //
376
+ // Port 4189: see the build-server.ts comment for the port allocation
377
+ // overview of the other samples/apps ports.
378
+ //
379
+ // Config lives IN e2e/ itself — Playwright's webServer.command runs by
380
+ // default with cwd = the config's directory, i.e. relative to e2e/ itself
381
+ // (not to the package root). testDir therefore stays "." instead of
382
+ // "./e2e", and webServer.command calls build-server.ts without an "e2e/"
383
+ // prefix.
384
+
385
+ import { defineConfig, devices } from "@playwright/test";
386
+
387
+ const PORT = 4189;
388
+ const BASE_URL = `http://localhost:${PORT}`;
389
+
390
+ export default defineConfig({
391
+ testDir: ".",
392
+ // build-server.ts + fixtures/* are not a test — Playwright must ignore the build path.
393
+ testIgnore: ["**/fixtures/**", "build-server.ts"],
394
+ fullyParallel: false,
395
+ forbidOnly: !!process.env["CI"],
396
+ retries: 0,
397
+ workers: 1,
398
+ reporter: [["list"]],
399
+ timeout: 15_000,
400
+ expect: { timeout: 3_000 },
401
+
402
+ use: {
403
+ baseURL: BASE_URL,
404
+ trace: "retain-on-failure",
405
+ screenshot: "only-on-failure",
406
+ actionTimeout: 5_000,
407
+ navigationTimeout: 10_000,
408
+ },
409
+
410
+ projects: [
411
+ {
412
+ name: "chromium",
413
+ use: { ...devices["Desktop Chrome"], viewport: { width: 1920, height: 1080 } },
414
+ },
415
+ ],
416
+
417
+ webServer: {
418
+ command: "bun build-server.ts",
419
+ url: BASE_URL,
420
+ env: { PORT: String(PORT) },
421
+ reuseExistingServer: !process.env["CI"],
422
+ timeout: 30_000,
423
+ stdout: "pipe",
424
+ stderr: "pipe",
425
+ },
426
+ });
427
+ ```
428
+
429
+ ### e2e/writeform-parity.spec.ts
430
+
431
+ ```ts
432
+ // Layout-parity spec (fw#2680): renders the projectionDetail `writeForm`
433
+ // section next to the established `entityEdit` form for the same fields
434
+ // (see fixtures/client.tsx's SideBySideShell) and measures both submit
435
+ // buttons + footers from the SAME page load. No hardcoded pixel positions
436
+ // — every assertion is a comparison between the two live box models, so it
437
+ // catches a layout regression (fw#2681: inline, full-width writeForm
438
+ // button instead of the established right-aligned footer treatment)
439
+ // without depending on a screenshot baseline.
440
+
441
+ import { mkdirSync, statSync } from "node:fs";
442
+ import { resolve } from "node:path";
443
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
444
+ import type { Locator, Page } from "@playwright/test";
445
+ import { CREATED_COMMENTS_KEY } from "./fixtures/mock-dispatcher";
446
+
447
+ const SCREENSHOT_DIR =
448
+ process.env["SCREENSHOT_DIR"] ?? resolve(import.meta.dirname, "../screenshots");
449
+
450
+ async function gotoBothForms(page: Page): Promise<void> {
451
+ await page.goto("/");
452
+ await expect(page.getByTestId("established-form")).toBeVisible();
453
+ await expect(page.getByTestId("write-form-screen")).toBeVisible();
454
+ // The established entityEdit form's submit is disabled until
455
+ // snapshot.isUnchanged flips to false. Typing into the empty create-mode
456
+ // field makes the form dirty so both submits are measured and screenshot
457
+ // in the same enabled state.
458
+ await page
459
+ .getByTestId("established-form")
460
+ .getByTestId("field-title")
461
+ .locator("input")
462
+ .fill("Sample note");
463
+ }
464
+
465
+ function establishedSubmit(page: Page): Locator {
466
+ return page.getByTestId("established-form").getByTestId("render-edit-submit");
467
+ }
468
+
469
+ function writeFormSubmit(page: Page): Locator {
470
+ return page.getByTestId("write-form-screen").getByTestId("write-form-section-submit");
471
+ }
472
+
473
+ async function closestFormRect(
474
+ locator: Locator,
475
+ ): Promise<{ readonly left: number; readonly right: number; readonly width: number }> {
476
+ const rect = await locator.evaluate((el) => {
477
+ const form = el.closest("form");
478
+ if (form === null) return null;
479
+ const r = form.getBoundingClientRect();
480
+ return { left: r.left, right: r.right, width: r.width };
481
+ });
482
+ if (rect === null) throw new Error("writeform-parity: button has no ancestor <form>");
483
+ return rect;
484
+ }
485
+
486
+ test.describe("writeform-section — layout parity with the established entityEdit form", () => {
487
+ test("both submit buttons are visible", async ({ page }) => {
488
+ await gotoBothForms(page);
489
+ await expect(establishedSubmit(page)).toBeVisible();
490
+ await expect(writeFormSubmit(page)).toBeVisible();
491
+ });
492
+
493
+ test("both submit buttons render the same width — same label, same icon, same Button primitive", async ({
494
+ page,
495
+ }) => {
496
+ await gotoBothForms(page);
497
+ const established = await establishedSubmit(page).boundingBox();
498
+ const writeForm = await writeFormSubmit(page).boundingBox();
499
+ if (established === null || writeForm === null) {
500
+ throw new Error("writeform-parity: submit button has no bounding box");
501
+ }
502
+ expect(Math.abs(established.width - writeForm.width)).toBeLessThanOrEqual(2);
503
+ });
504
+
505
+ test("neither submit button stretches full-width inside its form", async ({ page }) => {
506
+ await gotoBothForms(page);
507
+ for (const submit of [establishedSubmit(page), writeFormSubmit(page)]) {
508
+ const box = await submit.boundingBox();
509
+ const formRect = await closestFormRect(submit);
510
+ if (box === null) throw new Error("writeform-parity: submit button has no bounding box");
511
+ expect(box.width / formRect.width).toBeLessThan(0.5);
512
+ }
513
+ });
514
+
515
+ // The entityEdit form's footer is a bar spanning the whole screen,
516
+ // while the writeForm section's Save stays in the section header above its
517
+ // fields column. Both must sit at the right edge of their own frame: the
518
+ // footer bar for entityEdit, the fields column for the section.
519
+ test("entityEdit submit sits right-aligned in its footer, the writeForm submit at the right edge of its fields column", async ({
520
+ page,
521
+ }) => {
522
+ await gotoBothForms(page);
523
+
524
+ const establishedBox = await establishedSubmit(page).boundingBox();
525
+ const establishedForm = await closestFormRect(establishedSubmit(page));
526
+ if (establishedBox === null) {
527
+ throw new Error("writeform-parity: submit button has no bounding box");
528
+ }
529
+ const establishedGap = establishedForm.right - (establishedBox.x + establishedBox.width);
530
+ expect(establishedGap).toBeLessThan(establishedForm.width * 0.2);
531
+
532
+ const writeFormBox = await writeFormSubmit(page).boundingBox();
533
+ const fieldsBox = await page
534
+ .getByTestId("write-form-screen")
535
+ .getByTestId("field-title")
536
+ .boundingBox();
537
+ if (writeFormBox === null || fieldsBox === null) {
538
+ throw new Error("writeform-parity: submit button or fields column has no bounding box");
539
+ }
540
+ const writeFormGap = fieldsBox.x + fieldsBox.width - (writeFormBox.x + writeFormBox.width);
541
+ expect(Math.abs(writeFormGap)).toBeLessThanOrEqual(4);
542
+ });
543
+
544
+ test("both submit buttons are text-only, no icon on either", async ({ page }) => {
545
+ await gotoBothForms(page);
546
+ await expect(establishedSubmit(page)).toBeVisible();
547
+ await expect(writeFormSubmit(page)).toBeVisible();
548
+ expect(await establishedSubmit(page).locator("svg").count()).toBe(0);
549
+ expect(await writeFormSubmit(page).locator("svg").count()).toBe(0);
550
+ });
551
+
552
+ test("submitting the writeForm section dispatches the entered values to its own handler", async ({
553
+ page,
554
+ }) => {
555
+ await gotoBothForms(page);
556
+ const writeFormScreen = page.getByTestId("write-form-screen");
557
+ await writeFormScreen.getByTestId("field-title").locator("input").fill("Layout looks off");
558
+ await writeFormScreen
559
+ .getByTestId("field-body")
560
+ .locator("input")
561
+ .fill("Save button is full-width.");
562
+ await writeFormSubmit(page).click();
563
+
564
+ await expect
565
+ .poll(() => page.evaluate((key) => localStorage.getItem(key), CREATED_COMMENTS_KEY))
566
+ .not.toBeNull();
567
+
568
+ const comments = await page.evaluate((key) => {
569
+ const raw = localStorage.getItem(key);
570
+ return raw === null ? [] : (JSON.parse(raw) as Record<string, unknown>[]);
571
+ }, CREATED_COMMENTS_KEY);
572
+ expect(comments).toHaveLength(1);
573
+ expect(comments[0]).toMatchObject({
574
+ title: "Layout looks off",
575
+ body: "Save button is full-width.",
576
+ });
577
+ });
578
+
579
+ test("writeForm section beside the established entityEdit form", async ({ page }) => {
580
+ await gotoBothForms(page);
581
+ mkdirSync(SCREENSHOT_DIR, { recursive: true });
582
+ const path = resolve(SCREENSHOT_DIR, "writeform-vs-entityedit.png");
583
+ // animations: "disabled" jumps to end-state at the engine level — immune to CSS specificity, unlike an addStyleTag injection
584
+ await page.screenshot({ path, fullPage: true, animations: "disabled" });
585
+ expect(statSync(path).size).toBeGreaterThan(5 * 1024);
586
+ });
587
+ });
588
+ ```
589
+
42
590
  ### src/__tests__/feature.test.ts
43
591
 
44
592
  ```ts
@@ -78,7 +78,7 @@ Runtime on/off switch for the auth-email-password self-signup flow. Handler-less
78
78
 
79
79
  ## billing-foundation
80
80
 
81
- Plugin host for subscription billing — manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers and a dormant billing-plans dashboard screen/panel from the catalog. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event — catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block — use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.
81
+ Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-dodo` unless you are writing a new payment provider.
82
82
 
83
83
  Category: billing
84
84
 
@@ -112,7 +112,7 @@ Requires: `delivery`, `secrets`
112
112
 
113
113
  ## channel-email
114
114
 
115
- Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
115
+ Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set, but then only notifications declared with `tenantTransport: true` are delivered: every other notification (password reset, sign-in, invitations) fails with `missing_credentials`, so an app without an app SMTP server must mark every notification that should go out or keep an app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
116
116
 
117
117
  Category: notifications
118
118
 
@@ -616,6 +616,23 @@ Category: infrastructure
616
616
 
617
617
  Requires: `secrets`
618
618
 
619
+ ## subscription-dodo
620
+
621
+ Dodo Payments provider plugin for `billing-foundation`. Dodo acts as merchant of record: it sells to the customer and issues invoice and VAT, so the consumer-protection texts for this provider come from `consumerProtection.providerTexts`. Reads its API key and webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant), plus a `billingLive` master switch and an `environment` key (test | live). `environment` is its own key because Dodo serves test and live mode from different API hosts and a live key must never hit the test host or the other way round; `billingLive` only decides whether checkouts are offered at all. All four are runtime config, so keys rotate and prod goes live without a redeploy, and the `mask` on each derives the sysadmin settings screen and nav. Mount via `createSubscriptionDodoFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env to secrets bridge fallbacks (`DODO_PAYMENTS_API_KEY`, `DODO_PAYMENTS_WEBHOOK_KEY`). The plugin always mounts; `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true. Webhooks arrive at `/api/subscription/webhook/dodo` and are verified per the Standard Webhooks spec (HMAC-SHA256 over id, timestamp and the raw body, 5 minute tolerance); events for products outside `priceToTier` or without tenant metadata are dropped. Implements hosted checkout, customer portal, cancel and resume, `retrievePrices` (10-minute TTL cache), `retrieveSubscription` for the sync job, and an in-app plan switch: `previewPlanSwitch` shows the proration before the customer confirms, `applyPlanSwitch` changes the plan without a hosted page and waits for the `plan_changed` webhook to move the tier. A plan switch in Dodo requires all plan products to belong to one Product Collection.
622
+
623
+ Category: billing
624
+
625
+ Requires: `billing-foundation`, `config`, `secrets`
626
+
627
+ Config keys:
628
+
629
+ | Key | Type | Scope | Default |
630
+ | --- | --- | --- | --- |
631
+ | `api-key` | text | system | |
632
+ | `billing-live` | boolean | system | `false` |
633
+ | `environment` | select | system | `test` |
634
+ | `webhook-secret` | text | system | |
635
+
619
636
  ## subscription-mollie
620
637
 
621
638
  Mollie payment provider plugin for `billing-foundation`, covering the DACH/EU mid-market use case. Mount via `createSubscriptionMollieFeature({ apiKey, webhookUrl, priceToTier, priceToConfig })` — the factory validates that `priceToTier` and `priceToConfig` keys are identical at boot time. Implements `verifyAndParseWebhook` (lazy Mollie-API fetch + heuristic event-type mapping) and `createCheckoutSession` (customer + first-payment with `sequenceType="first"`); `createPortalSession` and `cancelSubscription` are not available because Mollie has no customer portal and requires a `customerId` that the plugin contract does not carry.
package/agents/widgets.md CHANGED
@@ -16,6 +16,7 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
16
16
  | `BareFormProvider` | |
17
17
  | `BooleanField` | Checkbox/Switch = Field(layout:"inline") + Input(kind:"boolean"). |
18
18
  | `Card` | |
19
+ | `ChoiceCards` | Card-styled radio group for mutually exclusive choices that need a description or price line. |
19
20
  | `CollapsibleSection` | Collapsible section using native <details> with React-controlled state; otherwise the open attribute resets to its initial value on every parent re-render. |
20
21
  | `ComparisonTable` | Transposed comparison table: row = metric, column = variant, with the best column optionally highlighted for each metric. |
21
22
  | `DateField` | Date field = Field + Input(kind:"date"), ISO yyyy-mm-dd. |
@@ -37,8 +37,13 @@ function inMemoryIpRateLimit(clientIpResolver, now) {
37
37
  windows.delete(ip);
38
38
  }
39
39
  }
40
- if (windows.size > MAX_TRACKED_IPS)
41
- windows.clear();
40
+ // Map keeps insertion order and touched IPs are re-inserted, so the front is the least recently
41
+ // seen; clear() would let an attacker rotating addresses reset every other IP's window.
42
+ for (const ip of windows.keys()) {
43
+ if (windows.size <= MAX_TRACKED_IPS)
44
+ break;
45
+ windows.delete(ip);
46
+ }
42
47
  }
43
48
  return async (c, next) => {
44
49
  const ip = clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
@@ -47,6 +52,7 @@ function inMemoryIpRateLimit(clientIpResolver, now) {
47
52
  const existing = windows.get(ip);
48
53
  const window = existing && nowMs - existing.startedAt < windowMs ? existing : { count: 0, startedAt: nowMs };
49
54
  window.count += 1;
55
+ windows.delete(ip);
50
56
  windows.set(ip, window);
51
57
  if (window.count > REPORTS_PER_WINDOW) {
52
58
  const resetAtMs = window.startedAt + windowMs;
@@ -27,7 +27,9 @@ export type { MetricsRouteOptions } from "./route-registrars.ts";
27
27
  export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.ts";
28
28
  export { createApiRoutes } from "./routes.ts";
29
29
  export type { KumikoServer, ServerOptions } from "./server.ts";
30
- export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.ts";
30
+ export { buildServer } from "./server.ts";
31
+ export { makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server-extra-routes.ts";
32
+ export { withFileProviderResolver } from "./server-wiring.ts";
31
33
  export type { ResolvedSessionCookies, SessionCookieBase, SessionCookieNames, } from "./session-cookies.ts";
32
34
  export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.ts";
33
35
  export type { SseBroker, SseClient, SseEvent } from "./sse-broker.ts";
package/dist/api/index.js CHANGED
@@ -16,7 +16,9 @@ export { readonlyRequestContext as requestContext, } from "./request-context.js"
16
16
  export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
17
17
  export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
18
18
  export { createApiRoutes } from "./routes.js";
19
- export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.js";
19
+ export { buildServer } from "./server.js";
20
+ export { makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server-extra-routes.js";
21
+ export { withFileProviderResolver } from "./server-wiring.js";
20
22
  export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
21
23
  export { createSseBroker } from "./sse-broker.js";
22
24
  export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
@@ -41,6 +41,6 @@ export declare function runWithOrigin<T>(origin: {
41
41
  readonly risk: AgentRisk;
42
42
  };
43
43
  }, fn: () => T): T;
44
- export declare function runAsDirectCallEntry<T>(fn: () => T): T;
44
+ export declare function runAsAgentToolCallEntry<T>(fn: () => T): T;
45
45
  export declare function isAgentToolCallScope(): boolean;
46
46
  export declare function runWithWriteOrigin<T>(origin: WriteOrigin, fn: () => T): T;