@cosmicdrift/kumiko-framework 0.362.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,323 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/auth-mfa. Do not edit. -->
2
+
3
+ # auth-mfa
4
+
5
+ createAuthMfaFeature — TOTP/2FA enrollment, login challenge, and recovery codes wired into auth-email-password.
6
+
7
+ ## README
8
+
9
+ # Sample: Auth MFA (TOTP/2FA)
10
+
11
+ **I want users to be able to enable TOTP-based 2FA, and login to become a two-step challenge once they have.**
12
+
13
+ ## What this sample shows
14
+
15
+ The production wiring shape: mount `createAuthMfaFeature()` next to `createAuthEmailPasswordFeature()`, thread its status-checker into the login handler, and give `auth: { mfaVerifyHandler: ... }` to `buildServer`. From there the framework handles the rest — a password-only login for an MFA-enrolled user returns a challenge token instead of a JWT, and `POST /auth/mfa/verify` completes it with a TOTP or recovery code.
16
+
17
+ ## The wiring in 4 sentences
18
+
19
+ 1. `createAuthMfaFeature({ setupTokenSecret, challengeTokenSecret, issuer })` builds the feature; `mfaStatusCheckerFromFeature(mfaFeature)` reads its login-status callback back off.
20
+ 2. That callback goes into `createAuthEmailPasswordFeature({ mfaStatusChecker: ... })` — `login.write.ts` calls it after the password check to decide whether to mint a JWT or a challenge.
21
+ 3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify } })` mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
22
+ 4. If `sessions` is mounted too, `bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers)` wires "enable/disable/regenerate signs out every other session."
23
+
24
+ ## What to copy into your app
25
+
26
+ ```ts illustration
27
+ const mfaFeature = createAuthMfaFeature({
28
+ setupTokenSecret: config.mfaSetupSecret,
29
+ challengeTokenSecret: config.mfaChallengeSecret,
30
+ issuer: "Your App",
31
+ });
32
+ const sessionCallbacks = createSessionCallbacks({ db });
33
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
34
+ sessionCallbacks.sessionRevokeAllOthers,
35
+ );
36
+
37
+ const server = buildServer({
38
+ registry: buildRegistry([
39
+ createAuthEmailPasswordFeature({
40
+ mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature),
41
+ }),
42
+ createSessionsFeature({
43
+ autoRevokeOnPasswordChange: sessionCallbacks.sessionMassRevoker,
44
+ }),
45
+ mfaFeature,
46
+ // ... other features
47
+ ]),
48
+ context: { db, /* ... */ },
49
+ jwtSecret: process.env.JWT_SECRET,
50
+ auth: {
51
+ membershipQuery: "tenant:query:memberships",
52
+ loginHandler: "auth-email-password:[REDACTED:API key param]",
53
+ mfaVerifyHandler: AuthMfaHandlers.verify,
54
+ sessionCreator: sessionCallbacks.sessionCreator,
55
+ sessionRevoker: sessionCallbacks.sessionRevoker,
56
+ sessionChecker: sessionCallbacks.sessionChecker,
57
+ },
58
+ });
59
+ ```
60
+
61
+ Apps built via `runDevApp`/`runProdApp` (the dev-server bootstrap used by `create-kumiko-app`) get all of this automatically — just add `createAuthMfaFeature(...)` to `APP_FEATURES`. `composeFeatures` threads the status-checker and the bootstrap wires the session-revoke bind and the verify route for you. This sample shows the raw contract those helpers implement.
62
+
63
+ ## Design rules
64
+
65
+ 1. **Enrollment is stateless until confirmed.** `enable-start` returns a signed setup token (QR + secret + 8 recovery codes) — nothing is persisted until `enable-confirm` proves the user scanned it with a valid TOTP code.
66
+ 2. **The TOTP secret is envelope-encrypted at rest**, same `MasterKeyProvider` as `secrets`/`config` — no separate crypto story to maintain.
67
+ 3. **`/auth/mfa/verify` is a framework route, not a dispatcher handler.** It runs before a JWT exists, so it gets its own IP-keyed rate limiter (`mfaVerifyRateLimit`), independent of the login rate limiter.
68
+ 4. **Recovery codes are single-use.** Each of the 8 generated codes completes exactly one login challenge, then it's burned.
69
+ 5. **MFA state changes revoke every OTHER live session** (stolen-session defense) — never the session that made the change.
70
+ 6. **Enforcement is opt-in.** Without a tenant enabling `auth-mfa:config:required`, users choose for themselves whether to enroll.
71
+
72
+ ## Tests
73
+
74
+ - **Baseline:** a user without MFA enrolled still gets a JWT straight from `/auth/login`.
75
+ - **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.
76
+
77
+ ## Source files
78
+
79
+ ### src/__tests__/feature.integration.test.ts
80
+
81
+ ```ts
82
+ // Auth MFA Sample — Integration Test
83
+ //
84
+ // Drives the wiring from the sample's comment snippet end-to-end against
85
+ // real HTTP + DB. If a reader copies the buildServer({...}) block verbatim,
86
+ // the resulting app behaves like this test asserts: login is two-step once
87
+ // MFA is enabled, and a valid TOTP code completes it.
88
+
89
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
90
+ import {
91
+ AuthHandlers,
92
+ createAuthEmailPasswordFeature,
93
+ hashPassword,
94
+ } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
95
+ import {
96
+ configValuesTable,
97
+ createConfigFeature,
98
+ } from "@cosmicdrift/kumiko-bundled-features/config";
99
+ import {
100
+ createTenantFeature,
101
+ tenantEntity,
102
+ tenantMembershipsTable,
103
+ } from "@cosmicdrift/kumiko-bundled-features/tenant";
104
+ import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/seeding";
105
+ import {
106
+ createUserFeature,
107
+ UserHandlers,
108
+ userEntity,
109
+ userTable,
110
+ } from "@cosmicdrift/kumiko-bundled-features/user";
111
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
112
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
113
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
114
+ import {
115
+ setupTestStack,
116
+ type TestStack,
117
+ TestUsers,
118
+ testTenantId,
119
+ unsafeCreateEntityTable,
120
+ unsafePushTables,
121
+ } from "@cosmicdrift/kumiko-framework/stack";
122
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
123
+ import {
124
+ AuthMfaHandlers,
125
+ base32Decode,
126
+ createAuthMfaFeature,
127
+ currentTotpCode,
128
+ mfaStatusCheckerFromFeature,
129
+ userMfaEntity,
130
+ } from "../feature";
131
+
132
+ const TENANT: TenantId = testTenantId(1);
133
+ const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
134
+ const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
135
+
136
+ let stack: TestStack;
137
+
138
+ beforeAll(async () => {
139
+ configureEntityFieldEncryption(createTestEnvelopeCipher());
140
+
141
+ const mfaFeature = createAuthMfaFeature({
142
+ setupTokenSecret: SETUP_TOKEN_SECRET,
143
+ challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
144
+ issuer: "Kumiko Sample",
145
+ });
146
+
147
+ stack = await setupTestStack({
148
+ features: [
149
+ createConfigFeature(),
150
+ createUserFeature(),
151
+ createTenantFeature(),
152
+ createAuthEmailPasswordFeature({
153
+ mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature),
154
+ }),
155
+ mfaFeature,
156
+ ],
157
+ authConfig: {
158
+ membershipQuery: "tenant:query:memberships",
159
+ loginHandler: AuthHandlers.login,
160
+ mfaVerifyHandler: AuthMfaHandlers.verify,
161
+ },
162
+ });
163
+
164
+ await unsafeCreateEntityTable(stack.db, userEntity);
165
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
166
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
167
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
168
+ });
169
+
170
+ afterAll(async () => {
171
+ await stack.cleanup();
172
+ configureEntityFieldEncryption(undefined);
173
+ });
174
+
175
+ beforeEach(async () => {
176
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
177
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
178
+ });
179
+
180
+ describe("auth-mfa sample wiring", () => {
181
+ test("login without MFA enrolled returns a JWT directly", async () => {
182
+ const hash = await hashPassword("super-long-password");
183
+ const created = await stack.http.writeOk<{ id: string }>(
184
+ UserHandlers.create,
185
+ { email: "plain@example.com", passwordHash: hash, displayName: "Plain" },
186
+ TestUsers.systemAdmin,
187
+ );
188
+ await seedTenantMembership(stack.db, { userId: created.id, tenantId: TENANT, roles: ["User"] });
189
+
190
+ const res = await stack.http.raw("POST", "/api/auth/login", {
191
+ email: "plain@example.com",
192
+ password: "super-long-password",
193
+ });
194
+ expect(res.status).toBe(200);
195
+ const body = (await res.json()) as { token?: string; mfaRequired?: boolean };
196
+ expect(body.token).toBeTypeOf("string");
197
+ expect(body.mfaRequired).toBeUndefined();
198
+ });
199
+
200
+ test("login with MFA enrolled challenges, then verify mints the JWT", async () => {
201
+ const hash = await hashPassword("super-long-password");
202
+ const created = await stack.http.writeOk<{ id: string }>(
203
+ UserHandlers.create,
204
+ { email: "mfa@example.com", passwordHash: hash, displayName: "Mfa" },
205
+ TestUsers.systemAdmin,
206
+ );
207
+ await seedTenantMembership(stack.db, { userId: created.id, tenantId: TENANT, roles: ["User"] });
208
+ const user = { ...TestUsers.user, id: created.id, tenantId: TENANT };
209
+
210
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
211
+ AuthMfaHandlers.enableStart,
212
+ { accountLabel: "mfa@example.com" },
213
+ user,
214
+ );
215
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
216
+ const secret = base32Decode(secretParam);
217
+ await stack.http.writeOk(
218
+ AuthMfaHandlers.enableConfirm,
219
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
220
+ user,
221
+ );
222
+
223
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
224
+ email: "mfa@example.com",
225
+ password: "super-long-password",
226
+ });
227
+ expect(loginRes.status).toBe(200);
228
+ const challenge = (await loginRes.json()) as {
229
+ token?: string;
230
+ mfaRequired?: boolean;
231
+ challengeToken?: string;
232
+ };
233
+ expect(challenge.token).toBeUndefined();
234
+ expect(challenge.mfaRequired).toBe(true);
235
+ expect(challenge.challengeToken).toBeTypeOf("string");
236
+
237
+ const verifyRes = await stack.http.raw("POST", "/api/auth/mfa/verify", {
238
+ challengeToken: challenge.challengeToken,
239
+ code: currentTotpCode(secret),
240
+ });
241
+ expect(verifyRes.status).toBe(200);
242
+ const { token } = (await verifyRes.json()) as { token: string };
243
+ expect(token).toBeTypeOf("string");
244
+ });
245
+ });
246
+ ```
247
+
248
+ ### src/feature.ts
249
+
250
+ ```ts
251
+ // Auth MFA (TOTP/2FA) Sample
252
+ //
253
+ // Shows the production-wiring shape: mount `createAuthMfaFeature()` next to
254
+ // `createAuthEmailPasswordFeature()`, thread its status-checker into the
255
+ // login handler, and (if `sessions` is mounted) bind its revoke-all-other-
256
+ // sessions callback. From there the framework handles the rest — login
257
+ // returns a challenge instead of a JWT when MFA is enabled, POST
258
+ // /auth/mfa/verify completes it, and every OTHER live session gets signed
259
+ // out whenever the account's MFA state changes (enable/disable/regenerate).
260
+ //
261
+ // What to copy into your own app:
262
+ //
263
+ // const mfaFeature = createAuthMfaFeature({
264
+ // setupTokenSecret: config.mfaSetupSecret,
265
+ // challengeTokenSecret: config.mfaChallengeSecret,
266
+ // issuer: "Your App",
267
+ // });
268
+ // const sessionCallbacks = createSessionCallbacks({ db });
269
+ // bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
270
+ // sessionCallbacks.sessionRevokeAllOthers,
271
+ // );
272
+ //
273
+ // const server = buildServer({
274
+ // registry: buildRegistry([
275
+ // createAuthEmailPasswordFeature({
276
+ // mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature),
277
+ // }),
278
+ // createSessionsFeature({
279
+ // autoRevokeOnPasswordChange: sessionCallbacks.sessionMassRevoker,
280
+ // }),
281
+ // mfaFeature,
282
+ // // ... other features
283
+ // ]),
284
+ // context: { db, ... },
285
+ // jwtSecret: config.jwtSecret,
286
+ // auth: {
287
+ // membershipQuery: "tenant:query:memberships",
288
+ // loginHandler: "auth-email-password:[REDACTED:API key param]",
289
+ // mfaVerifyHandler: AuthMfaHandlers.verify,
290
+ // sessionCreator: sessionCallbacks.sessionCreator,
291
+ // sessionRevoker: sessionCallbacks.sessionRevoker,
292
+ // sessionChecker: sessionCallbacks.sessionChecker,
293
+ // },
294
+ // });
295
+ //
296
+ // Apps built via runDevApp/runProdApp (the dev-server bootstrap) get all of
297
+ // this for free just by adding `createAuthMfaFeature(...)` to APP_FEATURES —
298
+ // composeFeatures threads the status-checker and the bootstrap wraps the
299
+ // session-revoke bind and the /auth/mfa/verify route automatically. This
300
+ // sample shows the raw wiring contract those helpers implement.
301
+ //
302
+ // Design rules the sample demonstrates:
303
+ //
304
+ // 1. Login becomes two-step once MFA is enabled. `POST /auth/login` returns
305
+ // `{ mfaRequired: true, challengeToken }` instead of a JWT; the client
306
+ // completes with `POST /auth/mfa/verify`.
307
+ // 2. Enrollment is stateless until confirmed. `enable-start` returns a
308
+ // signed setup token (QR + recovery codes) — nothing is persisted until
309
+ // `enable-confirm` proves the user scanned it with a valid TOTP code.
310
+ // 3. Recovery codes are single-use. Each of the 8 generated codes can
311
+ // complete exactly one login challenge, then it's burned.
312
+ // 4. Every MFA state change signs out every OTHER live session (stolen-
313
+ // session defense) — but never the session that made the change.
314
+ export {
315
+ AuthMfaHandlers,
316
+ base32Decode,
317
+ bindMfaRevokeAllOtherSessionsFromFeature,
318
+ createAuthMfaFeature,
319
+ mfaStatusCheckerFromFeature,
320
+ userMfaEntity,
321
+ } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
322
+ export { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
323
+ ```