@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
package/AGENTS.md CHANGED
@@ -208,7 +208,12 @@ produces code that compiles and then fails in production.
208
208
  one page (default 50, `limit` at most 200). Pass
209
209
  `filter: { field, op, value }` (`eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`)
210
210
  so the database filters, and `totalCount: true` to get `total` over all
211
- matching rows; `limit: 1, totalCount: true` is a count query.
211
+ matching rows; `limit: 1, totalCount: true` is a count query. Groups and
212
+ sums are the same: pass `aggregate: { groupBy?, measures: [{ field, kind }] }`
213
+ (`kind` is `sum`, `avg`, `count`, `min` or `max`) and the list answers with
214
+ `{ groups: [{ key, label?, count, measures }] }` over the same filters,
215
+ search and ownership, at most 200 groups (`truncated: true` beyond that).
216
+ Custom handlers call `executor.aggregate(...)` or `answerListAggregate`.
212
217
  - Never change an event schema in place once it has been emitted. Version it
213
218
  (see `agents/examples/event-sourcing.md`).
214
219
  - Never read or write another tenant's data. `ctx.db` is tenant scoped on
@@ -60,7 +60,7 @@ import {
60
60
 
61
61
  const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
62
62
 
63
- // Unique per test: the tenant is fixed, so rows are told apart by value, not by reset.
63
+ // Each value is written by exactly one test; the tenant is fixed, so rows are told apart by value, not by reset.
64
64
  const PRODUCT_NAME = "Espresso Beans (anonymous read)";
65
65
  const GUEST_EMAIL = "guest-checkout@example.com";
66
66
 
@@ -41,6 +41,210 @@ bun run screenshot # → screenshots/landing.png + lightbox.png
41
41
 
42
42
  ## Source files
43
43
 
44
+ ### e2e/header-layout.spec.ts
45
+
46
+ ```ts
47
+ // Regression for issue #2038: below the 1120px .container breakpoint, the
48
+ // header's `<div class="container nav">` lost its horizontal padding because
49
+ // `.nav`'s `padding` shorthand (same specificity, declared later) zeroed out
50
+ // the inline padding `.container` set. Verified against a real narrow
51
+ // viewport, not derived from source — matches how the bug was first found.
52
+
53
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
54
+ import { renderLanding, SAMPLE_PLANS } from "../src/feature";
55
+
56
+ test("header keeps the page's horizontal padding below 1120px (#2038)", async ({ page }) => {
57
+ await page.setViewportSize({ width: 390, height: 844 });
58
+ await page.setContent(renderLanding({ plans: SAMPLE_PLANS }), {
59
+ waitUntil: "domcontentloaded",
60
+ url: "http://localhost/",
61
+ });
62
+
63
+ const headerContainer = page.locator("header .container.nav");
64
+ const footerContainer = page.locator("footer .container");
65
+
66
+ const [headerPadding, footerPadding] = await Promise.all([
67
+ headerContainer.evaluate((el) => {
68
+ const s = getComputedStyle(el);
69
+ return { left: s.paddingLeft, right: s.paddingRight };
70
+ }),
71
+ footerContainer.evaluate((el) => {
72
+ const s = getComputedStyle(el);
73
+ return { left: s.paddingLeft, right: s.paddingRight };
74
+ }),
75
+ ]);
76
+
77
+ // The header is indented exactly like the rest of the page — not 0.
78
+ expect(headerPadding.left).not.toBe("0px");
79
+ expect(headerPadding).toEqual(footerPadding);
80
+
81
+ // The nav's rightmost action stays inside the viewport, not clipped at the edge.
82
+ const actionBox = await page.locator("header .nav-actions a").last().boundingBox();
83
+ expect(actionBox).not.toBeNull();
84
+ expect(actionBox!.x + actionBox!.width).toBeLessThanOrEqual(390);
85
+ });
86
+ ```
87
+
88
+ ### e2e/screenshot.spec.ts
89
+
90
+ ```ts
91
+ // E2E screenshot renderer for the Apex landing example. Renders the sample page
92
+ // to HTML, paints it in a real browser and shoots a full-page PNG. The output is
93
+ // what the docs guide embeds — regenerate with `bun run screenshot`, never hand-
94
+ // captured, so it can't drift from the renderer.
95
+ //
96
+ // SCREENSHOT_DIR overrides the output dir (CI writes straight into the docs repo).
97
+
98
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
99
+ import { resolve } from "node:path";
100
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
101
+ import type { Page } from "@playwright/test";
102
+ import { HERO_SCREENSHOT } from "../src/constants";
103
+ import { renderLanding, SAMPLE_PLANS } from "../src/feature";
104
+
105
+ const OUT_DIR = process.env["SCREENSHOT_DIR"] ?? resolve(import.meta.dirname, "../screenshots");
106
+ const HERO_APP_PNG = resolve(OUT_DIR, "hero-app.png");
107
+ const LANDING_PNG = resolve(OUT_DIR, "landing.png");
108
+ const LIGHTBOX_PNG = resolve(OUT_DIR, "lightbox.png");
109
+
110
+ /** Minimal in-app board mock — hero frame shows product UI, not the full landing PNG. */
111
+ const HERO_APP_HTML = `<!doctype html>
112
+ <html lang="en">
113
+ <head>
114
+ <meta charset="utf-8" />
115
+ <style>
116
+ body { margin: 0; background: #f4f4f5; font-family: system-ui, sans-serif; }
117
+ #board {
118
+ width: 720px; padding: 16px; background: #fff; border-radius: 12px;
119
+ border: 1px solid #e4e4e7; box-sizing: border-box;
120
+ }
121
+ .top { display: flex; justify-content: space-between; margin-bottom: 16px; font-weight: 600; }
122
+ .cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
123
+ .col { background: #fafafa; border-radius: 8px; padding: 10px; min-height: 140px; }
124
+ .label { font-size: 11px; color: #71717a; margin-bottom: 8px; }
125
+ .card {
126
+ background: #fff; border: 1px solid #e4e4e7; border-radius: 6px;
127
+ padding: 8px; margin-bottom: 8px; font-size: 13px;
128
+ }
129
+ .tag {
130
+ display: inline-block; font-size: 10px; padding: 2px 6px; border-radius: 4px;
131
+ background: #ede9fe; color: #5b21b6; margin-left: 4px;
132
+ }
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <div id="board">
137
+ <div class="top"><span>Tasklane — Q3 roadmap</span><span style="color:#71717a;font-weight:400">3 projects</span></div>
138
+ <div class="cols">
139
+ <div class="col">
140
+ <div class="label">Backlog</div>
141
+ <div class="card">Auth hardening<span class="tag">P1</span></div>
142
+ <div class="card">Export CSV</div>
143
+ </div>
144
+ <div class="col">
145
+ <div class="label">In progress</div>
146
+ <div class="card">Board filters</div>
147
+ <div class="card">Invite flow</div>
148
+ </div>
149
+ <div class="col">
150
+ <div class="label">Done</div>
151
+ <div class="card">Dark mode</div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </body>
156
+ </html>`;
157
+
158
+ function heroForMount(): typeof HERO_SCREENSHOT {
159
+ const b64 = readFileSync(HERO_APP_PNG).toString("base64");
160
+ return { ...HERO_SCREENSHOT, src: `data:image/png;base64,${b64}` };
161
+ }
162
+
163
+ async function assertHeroImageLoaded(page: Page): Promise<void> {
164
+ const img = page.locator(".shot-frame img");
165
+ await expect(img).toBeVisible();
166
+ await expect
167
+ .poll(async () => img.evaluate((el) => (el as HTMLImageElement).naturalWidth))
168
+ .toBeGreaterThan(0);
169
+ }
170
+
171
+ /** setContent does not reliably fetch routed /screenshots/* — inline data: URL instead. */
172
+ async function mountLanding(page: Page): Promise<void> {
173
+ if (!existsSync(HERO_APP_PNG)) {
174
+ throw new Error("hero-app.png missing — run hero-app test first (serial suite)");
175
+ }
176
+ await page.setContent(renderLanding({ plans: SAMPLE_PLANS, heroScreenshot: heroForMount() }), {
177
+ waitUntil: "networkidle",
178
+ url: "http://localhost/",
179
+ });
180
+ await assertHeroImageLoaded(page);
181
+ }
182
+
183
+ test.describe.configure({ mode: "serial" });
184
+
185
+ test("hero-app — product screenshot asset", async ({ page }) => {
186
+ mkdirSync(OUT_DIR, { recursive: true });
187
+ await page.setContent(HERO_APP_HTML, { waitUntil: "networkidle" });
188
+ await page.locator("#board").screenshot({ path: HERO_APP_PNG });
189
+ expect(statSync(HERO_APP_PNG).size).toBeGreaterThan(5 * 1024);
190
+ });
191
+
192
+ test("apex landing — full page", async ({ page }) => {
193
+ await mountLanding(page);
194
+ await page.screenshot({ path: LANDING_PNG, fullPage: true });
195
+ expect(statSync(LANDING_PNG).size).toBeGreaterThan(5 * 1024);
196
+ });
197
+
198
+ test("apex lightbox — open (screenshot)", async ({ page }) => {
199
+ await mountLanding(page);
200
+ await page.locator(".shot-frame img").click();
201
+ await expect(page.locator("#apex-lightbox")).toBeVisible();
202
+ await expect
203
+ .poll(async () =>
204
+ page.locator("#apex-lightbox img").evaluate((el) => (el as HTMLImageElement).naturalWidth),
205
+ )
206
+ .toBeGreaterThan(0);
207
+ await page.screenshot({ path: LIGHTBOX_PNG });
208
+ expect(statSync(LIGHTBOX_PNG).size).toBeGreaterThan(5 * 1024);
209
+ });
210
+
211
+ test("apex lightbox — closes on button", async ({ page }) => {
212
+ await mountLanding(page);
213
+ await page.locator(".shot-frame img").click();
214
+ await expect(page.locator("#apex-lightbox")).toBeVisible();
215
+ await page.locator(".apex-lightbox__close").click();
216
+ await expect(page.locator("#apex-lightbox")).not.toBeVisible();
217
+ });
218
+ ```
219
+
220
+ ### playwright.config.ts
221
+
222
+ ```ts
223
+ import { defineConfig, devices } from "@playwright/test";
224
+
225
+ // No webServer: the Apex renderer is a pure function, so the runner feeds its
226
+ // HTML straight into the page via setContent — nothing to boot.
227
+ export default defineConfig({
228
+ testDir: "./e2e",
229
+ fullyParallel: false,
230
+ forbidOnly: !!process.env["CI"],
231
+ retries: 0,
232
+ workers: 1,
233
+ reporter: [["list"]],
234
+ timeout: 60_000,
235
+ use: {
236
+ viewport: { width: 1920, height: 1080 },
237
+ deviceScaleFactor: 2,
238
+ },
239
+ projects: [
240
+ {
241
+ name: "chromium",
242
+ use: { ...devices["Desktop Chrome"], viewport: { width: 1920, height: 1080 } },
243
+ },
244
+ ],
245
+ });
246
+ ```
247
+
44
248
  ### src/__tests__/landing.test.ts
45
249
 
46
250
  ```ts
@@ -85,6 +85,70 @@ before the worker picks up the job and writes the fulfillment back.
85
85
 
86
86
  ## Source files
87
87
 
88
+ ### bin/api.ts
89
+
90
+ ```ts
91
+ // API process — `runSingleInstance: false` makes this process API-only:
92
+ // HTTP + event writes + BullMQ ENQUEUE, no worker-lane consumption, no
93
+ // multiStreamProjection application. A dedicated worker MUST run next to
94
+ // it (bin/worker.ts) or the read-side stays empty.
95
+ import { frameworkCoreEnvSchema } from "@cosmicdrift/kumiko-dev-server/env-schema";
96
+ import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
97
+ import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
98
+ import { createApiWorkerSplitFeature } from "../src/feature";
99
+
100
+ const features = [createApiWorkerSplitFeature()];
101
+
102
+ await runProdApp({
103
+ features,
104
+ envSchema: composeEnvSchema({ core: frameworkCoreEnvSchema, features }),
105
+ runSingleInstance: false,
106
+ // worker-lane queue prefix; must match bin/worker.ts.
107
+ jobs: { queueNamePrefix: "api-worker-split" },
108
+ });
109
+ ```
110
+
111
+ ### bin/kumiko.ts
112
+
113
+ ```ts
114
+ #!/usr/bin/env bun
115
+ // Schema CLI for the sample: creates the framework infra tables
116
+ // (idempotent) and applies kumiko/migrations. Requires DATABASE_URL.
117
+ import { runStandaloneSchemaCli } from "@cosmicdrift/kumiko-dev-server/schema-apply";
118
+ import { createApiWorkerSplitFeature } from "../src/feature";
119
+
120
+ await runStandaloneSchemaCli({ features: [createApiWorkerSplitFeature()] });
121
+ ```
122
+
123
+ ### bin/worker.ts
124
+
125
+ ```ts
126
+ // Worker process — consumes the worker-lane BullMQ queue and applies the
127
+ // multiStreamProjections the API skipped. No HTTP surface.
128
+ //
129
+ // wireComponents runs after the entrypoint is up and hands over
130
+ // `dispatchSystemWrite` — a dispatcher-scoped system write. The job's
131
+ // write-bridge is wired here (fw#1717): JobContext has no write/query, so
132
+ // background components persist through this dispatcher instead.
133
+ //
134
+ // ponytail: BullMQ may pop jobs between entrypoint.start() and this hook —
135
+ // those hits throw "no fulfill-write bridge wired" and rely on BullMQ retry
136
+ // until the bridge is set. Upgrade: defer job registration until after wire,
137
+ // or hold fulfillWrite behind a deferred Promise.
138
+ import { runWorkerApp } from "@cosmicdrift/kumiko-server-runtime";
139
+ import { createApiWorkerSplitFeature, setOrderFulfillWrite } from "../src/feature";
140
+
141
+ await runWorkerApp({
142
+ features: [createApiWorkerSplitFeature()],
143
+ jobs: { queueNamePrefix: "api-worker-split" },
144
+ wireComponents: async ({ dispatchSystemWrite }) => {
145
+ setOrderFulfillWrite(({ handlerQn, payload, tenantId }) =>
146
+ dispatchSystemWrite({ handlerQn, payload, tenantId }),
147
+ );
148
+ },
149
+ });
150
+ ```
151
+
88
152
  ### src/__tests__/feature.integration.test.ts
89
153
 
90
154
  ```ts
@@ -37,6 +37,20 @@ for the files around it.
37
37
 
38
38
  ## Source files
39
39
 
40
+ ### kumiko.config.ts
41
+
42
+ ```ts
43
+ import { defineAppConfig } from "@cosmicdrift/kumiko-framework/config";
44
+ import { auditExportFeature, notesFeature } from "./src/feature";
45
+
46
+ export default defineAppConfig({
47
+ features: [notesFeature],
48
+ prodFeatures: [auditExportFeature],
49
+ client: { entries: ["src/client.tsx"] },
50
+ ports: { dev: 4510, e2e: 4511, infra: { postgres: 15510 } },
51
+ });
52
+ ```
53
+
40
54
  ### src/__tests__/app-config.test.ts
41
55
 
42
56
  ```ts
@@ -83,11 +83,16 @@ features: [..., createSessionsFeature(), mfaFeature, createAccountSecurityFeatur
83
83
  5. **MFA state changes revoke every OTHER live session** (stolen-session defense) — never the session that made the change.
84
84
  6. **Enforcement is opt-in.** Without a tenant enabling `auth-mfa:config:required`, users choose for themselves whether to enroll.
85
85
 
86
+ ## Confirmation code input
87
+
88
+ `src/web/confirmation-code-step.tsx` shows the `Input kind="code"` renderer primitive outside the MFA screen, for example an e-mail or invite confirmation code. Pass `length` (here 6), `autoFocus` and `onComplete`. The input accepts digits only, so pasting "123 456" yields `123456`, and `onComplete` fires once when the value reaches `length`. `autoComplete` defaults to `one-time-code`, so mobile keyboards offer the code from the SMS or mail. After a wrong code the step clears the value and bumps a `key`, which remounts the input and returns focus to it.
89
+
86
90
  ## Tests
87
91
 
88
92
  - **Baseline:** a user without MFA enrolled still gets a JWT straight from `/auth/login`.
89
93
  - **End-to-end:** enroll via `enable-start`/`enable-confirm`, log in (gets a challenge, no JWT), complete via `/auth/mfa/verify` (gets a JWT) — copies the `buildServer({ auth: ... })` block from the sample comment verbatim.
90
94
  - **Account security:** the composed screen boot-validates next to `auth-mfa` and `sessions` and shows a sidebar entry once `nav` is set.
95
+ - **Confirmation code step:** typing or pasting six digits calls `onConfirm` once; a rejected code shows the error, clears the field and refocuses it.
91
96
 
92
97
  ## Source files
93
98
 
@@ -388,3 +393,113 @@ export {
388
393
  } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
389
394
  export { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
390
395
  ```
396
+
397
+ ### src/web/__tests__/confirmation-code-step.test.tsx
398
+
399
+ ```tsx
400
+ import { describe, expect, mock, test } from "bun:test";
401
+ import { PrimitivesProvider } from "@cosmicdrift/kumiko-renderer";
402
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer/web";
403
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
404
+ import { ConfirmationCodeStep } from "../confirmation-code-step.tsx";
405
+
406
+ function renderStep(onConfirm: (code: string) => Promise<boolean>): void {
407
+ render(
408
+ <PrimitivesProvider value={defaultPrimitives}>
409
+ <ConfirmationCodeStep onConfirm={onConfirm} />
410
+ </PrimitivesProvider>,
411
+ );
412
+ }
413
+
414
+ function type(value: string): void {
415
+ fireEvent.change(screen.getByTestId("confirmation-code"), { target: { value } });
416
+ }
417
+
418
+ describe("ConfirmationCodeStep", () => {
419
+ test("focuses the input on mount", () => {
420
+ renderStep(async () => true);
421
+ expect(document.activeElement).toBe(screen.getByTestId("confirmation-code"));
422
+ });
423
+
424
+ test("does not confirm before six digits", () => {
425
+ const onConfirm = mock(async (_code: string) => true);
426
+ renderStep(onConfirm);
427
+ type("12345");
428
+ expect(onConfirm).not.toHaveBeenCalled();
429
+ });
430
+
431
+ test("pasted '123 456' confirms once with 123456", async () => {
432
+ const onConfirm = mock(async (_code: string) => true);
433
+ renderStep(onConfirm);
434
+ type("123 456");
435
+ await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
436
+ expect(onConfirm).toHaveBeenCalledWith("123456");
437
+ expect(screen.queryByRole("alert")).toBeNull();
438
+ });
439
+
440
+ test("a rejected code shows the error, clears the field and refocuses", async () => {
441
+ const onConfirm = mock(async (_code: string) => false);
442
+ renderStep(onConfirm);
443
+ type("123456");
444
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
445
+ const input = screen.getByTestId("confirmation-code");
446
+ expect(input).toHaveProperty("value", "");
447
+ await waitFor(() => expect(document.activeElement).toBe(input));
448
+ expect(onConfirm).toHaveBeenCalledTimes(1);
449
+ });
450
+ });
451
+ ```
452
+
453
+ ### src/web/confirmation-code-step.tsx
454
+
455
+ ```tsx
456
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
457
+ import { type ReactNode, useState } from "react";
458
+
459
+ const CONFIRMATION_CODE_LENGTH = 6;
460
+
461
+ export type ConfirmationCodeStepProps = {
462
+ /** Resolves true when the server accepted the code. */
463
+ readonly onConfirm: (code: string) => Promise<boolean>;
464
+ };
465
+
466
+ export function ConfirmationCodeStep({ onConfirm }: ConfirmationCodeStepProps): ReactNode {
467
+ const { Input, Banner } = usePrimitives();
468
+ const [code, setCode] = useState("");
469
+ const [attempt, setAttempt] = useState(0);
470
+ const [pending, setPending] = useState(false);
471
+ const [rejected, setRejected] = useState(false);
472
+
473
+ const confirm = async (submittedCode: string): Promise<void> => {
474
+ if (pending) return;
475
+ setPending(true);
476
+ setRejected(false);
477
+ const accepted = await onConfirm(submittedCode).finally(() => setPending(false));
478
+ if (accepted) return;
479
+ setRejected(true);
480
+ setCode("");
481
+ // The input was disabled while pending and lost focus; remounting restores autoFocus.
482
+ setAttempt((n) => n + 1);
483
+ };
484
+
485
+ return (
486
+ <>
487
+ <Input
488
+ key={attempt}
489
+ kind="code"
490
+ length={CONFIRMATION_CODE_LENGTH}
491
+ id="confirmation-code"
492
+ name="confirmation-code"
493
+ testId="confirmation-code"
494
+ value={code}
495
+ onChange={setCode}
496
+ onComplete={(next) => void confirm(next)}
497
+ disabled={pending}
498
+ hasError={rejected}
499
+ autoFocus
500
+ />
501
+ {rejected ? <Banner variant="error">That code is not valid. Try again.</Banner> : null}
502
+ </>
503
+ );
504
+ }
505
+ ```
@@ -18,7 +18,8 @@ instead of keeping a hand-written copy of every feature's variables.
18
18
  `kmsEnvSchema` and `s3EnvSchema`. `composeEnvSchema` collects them and
19
19
  records which feature declared each variable.
20
20
  - **App-level blocks in `extend`**: `buildInfoEnvSchema`, `demoAdminEnvSchema`,
21
- `stripeEnvSchema({ prices })` and `createS3EnvSchema({ prefix })` for a
21
+ `stripeEnvSchema({ prices })`, `dodoEnvSchema({ prices })` and
22
+ `createS3EnvSchema({ prefix })` for a
22
23
  second bucket. These belong to no mounted feature, so the app adds them.
23
24
  - **Typed result**: `parseEnv(composed.schema, …)` returns the merged type,
24
25
  so `parsed.S3_BUCKET` is `string | undefined` and `parsed.APP_PUBLIC_URL` is
@@ -34,6 +35,7 @@ instead of keeping a hand-written copy of every feature's variables.
34
35
  | `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
35
36
  | `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
36
37
  | `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
38
+ | `dodoEnvSchema({ prices })` | `subscription-dodo` | app (`extend`); the feature registers the bridge keys |
37
39
  | `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
38
40
  | `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
39
41
  | `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
@@ -45,6 +47,26 @@ Every app that boots with `runProdApp`. Pass the composed schema as
45
47
 
46
48
  ## Source files
47
49
 
50
+ ### bin/server.ts
51
+
52
+ ```ts
53
+ // Production entrypoint. The env schema covers the same feature list that
54
+ // runProdApp mounts, so `KUMIKO_DRY_RUN_ENV=boot bun bin/server.ts` lists
55
+ // every variable of this app without connecting anywhere.
56
+ import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
57
+ import { composeAppEnv, createEnvCompositionFeatures } from "../src/env";
58
+
59
+ const features = createEnvCompositionFeatures();
60
+
61
+ await runProdApp({
62
+ features: [...features],
63
+ envSchema: composeAppEnv(features),
64
+ // The recipe declares no PII fields; crypto-shredding is mounted only for
65
+ // its env block.
66
+ allowPlaintextPii: "env-composition recipe has no PII fields",
67
+ });
68
+ ```
69
+
48
70
  ### src/__tests__/dry-run-boot.test.ts
49
71
 
50
72
  ```ts
@@ -175,6 +197,7 @@ describe("env composition", () => {
175
197
  expect(sources["S3_BUCKET"]).toBe("file-provider-s3-env");
176
198
  expect(sources["RELEASES_S3_BUCKET"]).toBe("app");
177
199
  expect(sources["STRIPE_PRICE_PRO"]).toBe("app");
200
+ expect(sources["DODO_PRODUCT_PRO"]).toBe("app");
178
201
  });
179
202
 
180
203
  test("parses into a typed env", () => {
@@ -183,6 +206,7 @@ describe("env composition", () => {
183
206
  ...minimalValidValues,
184
207
  S3_BUCKET: "uploads",
185
208
  STRIPE_PRICE_PRO: "price_pro",
209
+ DODO_PRODUCT_PRO: "pdt_pro",
186
210
  });
187
211
 
188
212
  const bucket: string | undefined = parsed.S3_BUCKET;
@@ -190,6 +214,7 @@ describe("env composition", () => {
190
214
  expect(bucket).toBe("uploads");
191
215
  expect(publicUrl).toBe("https://app.example.com");
192
216
  expect(parsed.KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION).toBe("1");
217
+ expect(parsed.DODO_PRODUCT_PRO).toBe("pdt_pro");
193
218
  });
194
219
 
195
220
  test("reports every missing required variable with its source", () => {
@@ -245,6 +270,7 @@ import {
245
270
  demoAdminEnvSchema,
246
271
  } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
247
272
  import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
273
+ import { dodoEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-dodo";
248
274
  import { stripeEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
249
275
  import { frameworkCoreEnvSchema } from "@cosmicdrift/kumiko-dev-server/env-schema";
250
276
  import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
@@ -266,6 +292,7 @@ const appEnvSchema = z.object({
266
292
  ...buildInfoEnvSchema.shape,
267
293
  ...demoAdminEnvSchema.shape,
268
294
  ...stripeEnvSchema({ prices: ["PRO", "TEAM"] }).shape,
295
+ ...dodoEnvSchema({ prices: ["PRO", "TEAM"] }).shape,
269
296
  ...createS3EnvSchema({ prefix: "RELEASES_S3_" }).shape,
270
297
  APP_PUBLIC_URL: z.url().describe("Public origin of the app, used in mail links."),
271
298
  });
@@ -80,7 +80,7 @@ await runProdApp({
80
80
  // Required (1): routes run anonymous, need tenant resolution
81
81
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
82
82
  // Required (2): boot check + internal lookup use ctx.templateResolver
83
- extraContext: ({ db }) => ({
83
+ extraContext: ({ db, registry }) => ({
84
84
  templateResolver: createTemplateResolverApi(db, registry),
85
85
  }),
86
86
  });
@@ -57,6 +57,22 @@ bun test samples/recipes/prod-boot-options
57
57
 
58
58
  ## Source files
59
59
 
60
+ ### bin/server.ts
61
+
62
+ ```ts
63
+ import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
64
+ import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
65
+ import { appFeatures, bootOptions } from "../src/app";
66
+
67
+ const features = appFeatures();
68
+
69
+ await runProdApp({
70
+ features,
71
+ envSchema: composeEnvSchema({ features }),
72
+ ...bootOptions(process.env),
73
+ });
74
+ ```
75
+
60
76
  ### src/__tests__/boot-options.test.ts
61
77
 
62
78
  ```ts
@@ -36,6 +36,24 @@ exhausted) and no bucket is charged. Each `per` may appear once; an empty list f
36
36
  An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
37
37
  stays a single declaration.
38
38
 
39
+ ## Cost per payload element
40
+
41
+ A bulk handler books one token per item instead of one per request. Declare `rateLimitCost`
42
+ next to `rateLimit`:
43
+
44
+ ```ts illustration
45
+ r.writeHandler({
46
+ // ...
47
+ rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
48
+ rateLimitCost: (payload) => payload.files.length,
49
+ });
50
+ ```
51
+
52
+ The cost is computed from the validated payload and booked on every declared bucket, so an
53
+ invalid payload costs nothing and a rejected request consumes nothing. Results below 1 count
54
+ as 1. Because it books after the parse, boot rejects `rateLimitCost` on anonymous or ip-limited
55
+ handlers.
56
+
39
57
  ## Per-payload limits
40
58
 
41
59
  A public write handler that mails an address typed by the caller is also limited per
@@ -229,6 +247,24 @@ describe("L2 — auth-endpoints middleware (tighter cap on /api/auth/*)", () =>
229
247
  expect(body.error.details.bucket).toBe("l2");
230
248
  });
231
249
  });
250
+
251
+ describe("L3 — cost per payload element (bulk-upload)", () => {
252
+ const bulkUpload = "rl-showcase:write:bulk-upload";
253
+ const files = (count: number): { files: string[] } => ({
254
+ files: Array.from({ length: count }, (_, index) => `file-${index}.pdf`),
255
+ });
256
+
257
+ test("a request books one token per file; a request over the remaining budget is rejected", async () => {
258
+ expect((await stack.http.write(bulkUpload, files(10), admin)).status).toBe(200);
259
+ expect((await stack.http.write(bulkUpload, files(15), admin)).status).toBe(429);
260
+ // The rejected request consumed nothing: 10 more still fit.
261
+ expect((await stack.http.write(bulkUpload, files(10), admin)).status).toBe(200);
262
+ const blocked = await stack.http.write(bulkUpload, files(1), admin);
263
+ expect(blocked.status).toBe(429);
264
+ const body = (await blocked.json()) as { error: { code: string } };
265
+ expect(body.error.code).toBe("rate_limited");
266
+ });
267
+ });
232
268
  ```
233
269
 
234
270
  ### src/feature.ts
@@ -269,6 +305,18 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
269
305
  { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
270
306
  ],
271
307
  });
308
+
309
+ // Bulk upload: a request books one token per item instead of one per request. The cost
310
+ // comes from the validated payload, so a schema-invalid request costs nothing; a request
311
+ // that would exceed the remaining budget is rejected and consumes nothing.
312
+ r.writeHandler({
313
+ name: "bulk-upload",
314
+ schema: z.object({ files: z.array(z.string()).min(1).max(20) }),
315
+ handler: async () => ({ isSuccess: true as const, data: {} }),
316
+ access: { roles: ["Admin", "User"] },
317
+ rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
318
+ rateLimitCost: (payload) => payload.files.length,
319
+ });
272
320
  });
273
321
  }
274
322
  ```