@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,351 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/auth-claims. Do not edit. -->
2
+
3
+ # auth-claims
4
+
5
+ r.authClaims() — features contribute identity facts into the JWT at login time with auto-prefixed keys.
6
+
7
+ ## README
8
+
9
+ # Auth Claims
10
+
11
+ Features inject identity facts into the JWT.
12
+
13
+ ## What it shows
14
+
15
+ - `r.authClaims(fn)` contributing to `SessionUser.claims`
16
+ - Claims recomputed on login and tenant switch
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
25
+
26
+ ```bash
27
+ cd samples/recipes/auth-claims
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Auth Claims Sample — Integration Test
37
+ //
38
+ // Drives the resolver end-to-end: features register r.claimKey() + r.authClaims(),
39
+ // the resolver runs the hooks in parallel, merges the results under auto-prefixed
40
+ // keys, and readClaim(user, handle) retrieves them with proper JS typing.
41
+
42
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
43
+ import type { AuthClaimsContext } from "@cosmicdrift/kumiko-framework/engine";
44
+ import { defineFeature, readClaim } from "@cosmicdrift/kumiko-framework/engine";
45
+ import { resolveAuthClaims } from "@cosmicdrift/kumiko-framework/pipeline";
46
+ import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
47
+ import {
48
+ type BetaFlagStore,
49
+ fakeSession,
50
+ makeBetaFlagsFeature,
51
+ makeBrokenFeature,
52
+ makeDriftFeature,
53
+ makeTeamsFeature,
54
+ type TeamStore,
55
+ } from "../feature";
56
+
57
+ // Stores are mutated in place across tests (cleared in beforeEach). The
58
+ // features capture them by closure at registration time.
59
+ const teamStore: TeamStore = new Map();
60
+ const betaFlagStore: BetaFlagStore = new Map();
61
+ const driftState = { value: false };
62
+
63
+ // Build the features once so we can reach the typed handles via the
64
+ // returned `exports.Claims` maps. The test suite references these handles
65
+ // when asserting — no hand-written qualified strings.
66
+ const teamsFeature = makeTeamsFeature(teamStore);
67
+ const betaFlagsFeature = makeBetaFlagsFeature(betaFlagStore);
68
+ const brokenFeature = makeBrokenFeature();
69
+ const driftFeature = makeDriftFeature(driftState);
70
+
71
+ const TeamsClaims = teamsFeature.exports.Claims;
72
+ const BetaClaims = betaFlagsFeature.exports.Claims;
73
+
74
+ let stack: TestStack;
75
+
76
+ beforeAll(async () => {
77
+ stack = await setupTestStack({
78
+ features: [teamsFeature, betaFlagsFeature, brokenFeature, driftFeature],
79
+ });
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await stack.cleanup();
84
+ });
85
+
86
+ beforeEach(() => {
87
+ teamStore.clear();
88
+ betaFlagStore.clear();
89
+ driftState.value = false;
90
+ });
91
+
92
+ // The resolver needs an AuthClaimsContext per hook run. This sample doesn't
93
+ // exercise ctx.db or ctx.queryAs — real features would, and that's the
94
+ // point of the fresh TenantDb the dispatcher builds. A stub is fine here.
95
+ const stubContext: AuthClaimsContext = {
96
+ db: {} as AuthClaimsContext["db"],
97
+ queryAs: async () => {
98
+ throw new Error("queryAs not exercised in this sample");
99
+ },
100
+ };
101
+
102
+ const tenantA = testTenantId(1);
103
+ const userAlice = "11111111-0000-4000-8000-000000000001";
104
+
105
+ describe("scenario 1: typed readClaim access", () => {
106
+ test("feature hook sets the value → readClaim returns it narrowed to the handle's JS type", async () => {
107
+ teamStore.set(userAlice, "eng");
108
+ betaFlagStore.set(userAlice, ["dark-mode", "new-checkout"]);
109
+
110
+ const user = fakeSession(userAlice, tenantA);
111
+ const claims = await resolveAuthClaims({
112
+ user,
113
+ hooks: stack.registry.getAuthClaimsHooks(),
114
+ contextFactory: () => stubContext,
115
+ });
116
+ const userWithClaims = { ...user, claims };
117
+
118
+ // readClaim narrows to `string | undefined` for the teamId handle — no
119
+ // manual `as string`, no magic key-string.
120
+ const teamId = readClaim(userWithClaims, TeamsClaims.teamId);
121
+ expect(teamId).toBe("eng");
122
+
123
+ // readClaim narrows to `readonly string[] | undefined` for flags.
124
+ const flags = readClaim(userWithClaims, BetaClaims.flags);
125
+ expect(flags).toEqual(["dark-mode", "new-checkout"]);
126
+ });
127
+
128
+ test("feature hook returns empty → readClaim returns undefined", async () => {
129
+ const user = fakeSession(userAlice, tenantA);
130
+ const claims = await resolveAuthClaims({
131
+ user,
132
+ hooks: stack.registry.getAuthClaimsHooks(),
133
+ contextFactory: () => stubContext,
134
+ });
135
+ const userWithClaims = { ...user, claims };
136
+
137
+ expect(readClaim(userWithClaims, TeamsClaims.teamId)).toBeUndefined();
138
+ expect(readClaim(userWithClaims, BetaClaims.flags)).toBeUndefined();
139
+ });
140
+ });
141
+
142
+ describe("scenario 2: best-effort policy — broken hook does not fail the login", () => {
143
+ test("broken feature throws → other features still contribute their claims", async () => {
144
+ teamStore.set(userAlice, "eng");
145
+
146
+ const user = fakeSession(userAlice, tenantA);
147
+ const claims = await resolveAuthClaims({
148
+ user,
149
+ hooks: stack.registry.getAuthClaimsHooks(),
150
+ contextFactory: () => stubContext,
151
+ });
152
+ const userWithClaims = { ...user, claims };
153
+
154
+ expect(readClaim(userWithClaims, TeamsClaims.teamId)).toBe("eng");
155
+ expect("broken:anything" in claims).toBe(false);
156
+ });
157
+ });
158
+
159
+ describe("scenario 3: drift-warning when a hook returns an undeclared key", () => {
160
+ test("declared teamId + undeclared 'rouge' → warn logged, both land in JWT (best-effort)", async () => {
161
+ driftState.value = true;
162
+
163
+ const warn = mock();
164
+ const log = {
165
+ warn,
166
+ info: mock(),
167
+ error: mock(),
168
+ debug: mock(),
169
+ child: () => log,
170
+ };
171
+
172
+ const user = fakeSession(userAlice, tenantA);
173
+ const claims = await resolveAuthClaims({
174
+ user,
175
+ hooks: stack.registry.getAuthClaimsHooks(),
176
+ contextFactory: () => stubContext,
177
+ log,
178
+ });
179
+
180
+ // Both claims are present — the drift-check is advisory, not strict.
181
+ expect(claims["drift:teamId"]).toBe("declared");
182
+ expect(claims["drift:rouge"]).toBe("undeclared");
183
+
184
+ const driftWarns = warn.mock.calls.filter(
185
+ (c) => (c[1] as { featureName?: string })?.featureName === "drift",
186
+ );
187
+ expect(driftWarns).toHaveLength(1);
188
+ expect(driftWarns[0]?.[1]).toMatchObject({
189
+ featureName: "drift",
190
+ undeclaredKey: "rouge",
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("scenario 4: auto-prefix prevents cross-feature collisions on same inner key", () => {
196
+ test("two features returning the SAME inner key `teamId` coexist under separate prefixes", async () => {
197
+ const billingWithTeamId = defineFeature("billing", (r) => {
198
+ const teamId = r.claimKey("teamId", { type: "string" });
199
+ r.authClaims(async (user) => (user.id === userAlice ? { teamId: "billing-A" } : {}));
200
+ return { Claims: { teamId } as const };
201
+ });
202
+
203
+ teamStore.set(userAlice, "eng");
204
+
205
+ const collisionStack = await setupTestStack({
206
+ features: [makeTeamsFeature(teamStore), billingWithTeamId],
207
+ });
208
+ try {
209
+ const user = fakeSession(userAlice, tenantA);
210
+ const claims = await resolveAuthClaims({
211
+ user,
212
+ hooks: collisionStack.registry.getAuthClaimsHooks(),
213
+ contextFactory: () => stubContext,
214
+ });
215
+
216
+ expect(claims["teams:teamId"]).toBe("eng");
217
+ expect(claims["billing:teamId"]).toBe("billing-A");
218
+ } finally {
219
+ await collisionStack.cleanup();
220
+ }
221
+ });
222
+ });
223
+ ```
224
+
225
+ ### src/feature.ts
226
+
227
+ ```ts
228
+ // Auth Claims Sample
229
+ //
230
+ // Shows how features contribute identity facts into SessionUser.claims via
231
+ // `r.authClaims()`, and how handlers read those facts back with typed
232
+ // handles from `r.claimKey()` + the `readClaim(user, handle)` helper.
233
+ //
234
+ // Why the handle pattern: reading `user.claims["teams:teamId"] as string`
235
+ // hand-codes the qualified name (magic string, breaks under a rename) and
236
+ // casts without the compiler's help. A declared handle threads the qualified
237
+ // name and JS type through `readClaim<T>(user, handle)`, so a rename in
238
+ // `r.claimKey("teamId", ...)` cascades to every call site, and `readClaim`
239
+ // narrows the return type to `string | undefined` automatically.
240
+ //
241
+ // Design rules the sample bakes in:
242
+ // 1. Auto-prefix means cross-feature keys cannot collide. Feature "teams"
243
+ // returning `{ teamId: ... }` lands at `"teams:teamId"`. Feature
244
+ // "billing" returning the same inner key lands at `"billing:teamId"`.
245
+ // Both coexist.
246
+ // 2. Best-effort errors: if one feature's hook throws, that feature's
247
+ // claims are missing from the JWT, but the login still succeeds.
248
+ // Identity-facts are convenience, not access-gates.
249
+ // 3. Hook context is trimmed (AuthClaimsContext, not HandlerContext) —
250
+ // login is a read, so appendEvent/archive/tz aren't available. queryAs
251
+ // + db cover the cross-feature lookups a claims-hook realistically
252
+ // needs.
253
+ // 4. Declaring claim keys via r.claimKey also turns on a runtime drift-
254
+ // check: if a hook returns an undeclared inner-key, the resolver logs
255
+ // a warning (the claim still lands in the JWT — this is typo
256
+ // protection, not strict schema).
257
+
258
+ import {
259
+ createEntity,
260
+ createTextField,
261
+ defineFeature,
262
+ type SessionUser,
263
+ type TenantId,
264
+ } from "@cosmicdrift/kumiko-framework/engine";
265
+
266
+ export const teamMembershipEntity = createEntity({
267
+ table: "read_sample_auth_team_memberships",
268
+ fields: {
269
+ userId: createTextField({ personal: "ref", required: true }),
270
+ teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
271
+ },
272
+ });
273
+
274
+ export const betaFlagsEntity = createEntity({
275
+ table: "read_sample_auth_beta_flags",
276
+ fields: {
277
+ userId: createTextField({ personal: "ref", required: true }),
278
+ flag: createTextField({ personal: false, reason: "is_business_data", required: true }),
279
+ },
280
+ });
281
+
282
+ // Plain in-memory state that the hooks read from — keeps the sample focused
283
+ // on the claim mechanism, not on DB seeding. A real feature would SELECT
284
+ // from its own table via ctx.db.
285
+ export type TeamStore = Map<string, string>;
286
+ export type BetaFlagStore = Map<string, readonly string[]>;
287
+
288
+ // Feature: Teams. Declares a typed `teamId` claim and a hook that fills it.
289
+ // The setup callback returns an `exports` object carrying the handles — the
290
+ // test (or any other feature) imports them via `teamsFeature.exports.Claims`
291
+ // without knowing or re-typing the qualified name.
292
+ export function makeTeamsFeature(store: TeamStore) {
293
+ return defineFeature("teams", (r) => {
294
+ const teamId = r.claimKey("teamId", { type: "string" });
295
+
296
+ r.authClaims(async (user: SessionUser) => {
297
+ const value = store.get(user.id);
298
+ return value ? { teamId: value } : {};
299
+ });
300
+
301
+ return { Claims: { teamId } as const };
302
+ });
303
+ }
304
+
305
+ // Feature: BetaFlags. Array claim — shows the handle's type parameter
306
+ // narrowing for non-scalar values.
307
+ export function makeBetaFlagsFeature(store: BetaFlagStore) {
308
+ return defineFeature("betaFlags", (r) => {
309
+ const flags = r.claimKey("flags", { type: "string[]" });
310
+
311
+ r.authClaims(async (user: SessionUser) => {
312
+ const value = store.get(user.id);
313
+ return value && value.length > 0 ? { flags: value } : {};
314
+ });
315
+
316
+ return { Claims: { flags } as const };
317
+ });
318
+ }
319
+
320
+ // Feature: Broken. Demonstrates the best-effort policy — this hook always
321
+ // throws. The tests assert that a broken hook does not fail the login and
322
+ // the other features' claims still land in the JWT.
323
+ export function makeBrokenFeature() {
324
+ return defineFeature("broken", (r) => {
325
+ r.authClaims(async () => {
326
+ throw new Error("pretend the DB just disappeared");
327
+ });
328
+ });
329
+ }
330
+
331
+ // Feature: Drift — declares `teamId` but its hook returns `rouge` as well.
332
+ // Exercises the drift-warning that r.claimKey unlocks: the resolver logs a
333
+ // warn for `rouge` but still merges both into the JWT (best-effort).
334
+ export function makeDriftFeature(shouldFire: { value: boolean }) {
335
+ return defineFeature("drift", (r) => {
336
+ r.claimKey("teamId", { type: "string" });
337
+
338
+ r.authClaims(async () => {
339
+ if (!shouldFire.value) return {};
340
+ return { teamId: "declared", rouge: "undeclared" };
341
+ });
342
+ });
343
+ }
344
+
345
+ // Helper used by tests to build a fake session for a given user/tenant.
346
+ // The sample avoids requiring the full auth-email-password feature —
347
+ // the claim-resolver mechanism is tested directly.
348
+ export function fakeSession(userId: string, tenantId: TenantId): SessionUser {
349
+ return { id: userId, tenantId, roles: ["User"] };
350
+ }
351
+ ```
@@ -0,0 +1,155 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/auth-foundation-providers. Do not edit. -->
2
+
3
+ # auth-foundation-providers
4
+
5
+ Register custom tenantResolver / tenantExistence via auth-foundation extension points (no anonymousAccess callbacks).
6
+
7
+ ## README
8
+
9
+ # Auth Foundation Providers
10
+
11
+ Pluggable auth providers on top of auth-foundation.
12
+
13
+ ## What it shows
14
+
15
+ - Provider registration shape for email/password (and peers)
16
+ - How foundation wires sessions + JWT issuance
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ ```bash
25
+ cd samples/recipes/auth-foundation-providers
26
+ bun test
27
+ ```
28
+
29
+ ## Source files
30
+
31
+ ### src/__tests__/feature.test.ts
32
+
33
+ ```ts
34
+ import { describe, expect, test } from "bun:test";
35
+ import {
36
+ authFoundationFeature,
37
+ resolveAnonymousAccessFromRegistry,
38
+ resolveTenantExistence,
39
+ resolveTenantResolver,
40
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
41
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
42
+ import { createRegistry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
43
+ import { createSubdomainTenantRoutingFeature } from "../feature";
44
+
45
+ const TENANT = "00000000-0000-4000-8000-0000000000aa" as TenantId;
46
+ const fakeDb = {} as DbConnection;
47
+
48
+ describe("auth-foundation-providers recipe", () => {
49
+ test("useExtension registers resolver + existence; boot merge fills anonymousAccess", async () => {
50
+ const routing = createSubdomainTenantRoutingFeature({
51
+ lookupBySubdomain: async (sub) => (sub === "acme" ? TENANT : null),
52
+ existsById: async (id) => id === TENANT,
53
+ trust: "authoritative",
54
+ });
55
+ const registry = createRegistry([authFoundationFeature, routing]);
56
+
57
+ const resolved = await resolveTenantResolver({ db: fakeDb, registry });
58
+ expect(resolved?.trust).toBe("authoritative");
59
+ expect(
60
+ await resolved?.resolve({
61
+ req: { header: (n: string) => (n === "Host" ? "acme.shop.com" : undefined) },
62
+ }),
63
+ ).toBe(TENANT);
64
+
65
+ const exists = await resolveTenantExistence({ db: fakeDb, registry });
66
+ expect(await exists?.(TENANT)).toBe(true);
67
+
68
+ const anon = await resolveAnonymousAccessFromRegistry({}, { db: fakeDb, registry });
69
+ expect(anon?.resolverTrust).toBe("authoritative");
70
+ expect(anon?.tenantResolver).toBeDefined();
71
+ expect(anon?.tenantExists).toBeDefined();
72
+ });
73
+ });
74
+ ```
75
+
76
+ ### src/feature.ts
77
+
78
+ ```ts
79
+ // Auth-foundation providers — custom tenantResolver + tenantExistence
80
+ //
81
+ // After #1374, apps do NOT pass tenantResolver/tenantExists on
82
+ // anonymousAccess. They mount a feature that registers providers via
83
+ // r.useExtension(EXT_TENANT_RESOLVER / EXT_TENANT_EXISTENCE). Boot
84
+ // (runProdApp / runDevApp) merges them through
85
+ // resolveAnonymousAccessFromRegistry.
86
+ //
87
+ // What to copy:
88
+ //
89
+ // composeFeatures([
90
+ // authFoundationFeature,
91
+ // createSubdomainTenantRoutingFeature({ lookupBySubdomain, existsById }),
92
+ // …appFeatures,
93
+ // ], { includeBundled: true })
94
+ //
95
+ // anonymousAccess: { /* defaultTenantId only, if single-tenant */ }
96
+ //
97
+ // Pair with samples/recipes/anonymous-access-multitenant for the
98
+ // subdomain+cache lookup helpers (reuse createSubdomainResolver inside
99
+ // the provider build).
100
+
101
+ import {
102
+ EXT_TENANT_EXISTENCE,
103
+ EXT_TENANT_RESOLVER,
104
+ type TenantExistenceProvider,
105
+ type TenantResolverProvider,
106
+ } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
107
+ import {
108
+ defineFeature,
109
+ type FeatureDefinition,
110
+ type TenantId,
111
+ } from "@cosmicdrift/kumiko-framework/engine";
112
+
113
+ export type SubdomainTenantRoutingOptions = {
114
+ readonly lookupBySubdomain: (subdomain: string) => Promise<TenantId | null>;
115
+ readonly existsById: (tenantId: TenantId) => Promise<boolean>;
116
+ readonly reservedSubdomains?: readonly string[];
117
+ /** Provider trust — usually "authoritative" for Host-derived tenants. */
118
+ readonly trust?: "authoritative" | "fallback-only";
119
+ };
120
+
121
+ export function createSubdomainTenantRoutingFeature(
122
+ opts: SubdomainTenantRoutingOptions,
123
+ ): FeatureDefinition {
124
+ const trust = opts.trust ?? "authoritative";
125
+ const reserved = new Set(opts.reservedSubdomains ?? ["www", "app", "api", "admin"]);
126
+
127
+ return defineFeature("sample-subdomain-tenant-routing", (r) => {
128
+ r.requires("auth-foundation");
129
+
130
+ const resolverPlugin: TenantResolverProvider = {
131
+ trust,
132
+ build: () => async (c: unknown) => {
133
+ const ctx = c as {
134
+ req: { header: (n: string) => string | undefined };
135
+ };
136
+ const host = ctx.req.header("Host");
137
+ if (!host) return null;
138
+ const noPort = host.split(":")[0] ?? host;
139
+ const labels = noPort.split(".");
140
+ if (labels.length < 3) return null;
141
+ const subdomain = labels[0] ?? null;
142
+ if (subdomain === null || reserved.has(subdomain)) return null;
143
+ return opts.lookupBySubdomain(subdomain);
144
+ },
145
+ };
146
+
147
+ const existencePlugin: TenantExistenceProvider = {
148
+ build: () => (tenantId) => opts.existsById(tenantId as TenantId),
149
+ };
150
+
151
+ r.useExtension(EXT_TENANT_RESOLVER, "subdomain", resolverPlugin);
152
+ r.useExtension(EXT_TENANT_EXISTENCE, "db", existencePlugin);
153
+ });
154
+ }
155
+ ```