@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,443 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/anonymous-access-multitenant. Do not edit. -->
2
+
3
+ # anonymous-access-multitenant
4
+
5
+ Subdomain-based tenant resolution for anonymous-access — SaaS shop / CMS pattern with per-tenant public surface.
6
+
7
+ ## README
8
+
9
+ # Anonymous Access Multitenant
10
+
11
+ Anonymous access that still resolves a tenant.
12
+
13
+ ## What it shows
14
+
15
+ - Public write/read paths in a multi-tenant app
16
+ - Tenant resolution without a session user
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/anonymous-access-multitenant
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Multi-tenant anonymous access: resolver + tenantExists + cookie
37
+ // persistence working together. The test shop has two real tenants
38
+ // (acme + globex) and one bogus subdomain to prove the 404 path.
39
+
40
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
41
+ import { setTenantCookie } from "@cosmicdrift/kumiko-framework/api";
42
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
43
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
44
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
45
+ import {
46
+ setupTestStack,
47
+ type TestStack,
48
+ TestUsers,
49
+ unsafeCreateEntityTable,
50
+ } from "@cosmicdrift/kumiko-framework/stack";
51
+ import { multiTenantShopFeature, productEntity, productTable } from "../feature";
52
+ import { createSubdomainResolver, extractSubdomain } from "../subdomain-resolver";
53
+
54
+ const ACME_TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
55
+ const GLOBEX_TENANT_ID = "00000000-0000-4000-8000-000000000002" as TenantId;
56
+
57
+ // App-supplied lookup. In a real deployment this hits the tenants table;
58
+ // the in-memory map keeps the recipe focused on the resolution chain.
59
+ const KNOWN_TENANTS: Record<string, TenantId> = {
60
+ acme: ACME_TENANT_ID,
61
+ globex: GLOBEX_TENANT_ID,
62
+ };
63
+
64
+ let stack: TestStack;
65
+ let resolver: ReturnType<typeof createSubdomainResolver>;
66
+ // Counters prove that the cache actually hits — one DB lookup per
67
+ // subdomain in a tight loop is the difference between a working shop
68
+ // under load and one that buckles.
69
+ let lookupCount = 0;
70
+ let existsCount = 0;
71
+
72
+ beforeAll(async () => {
73
+ resolver = createSubdomainResolver({
74
+ lookupBySubdomain: async (sub) => {
75
+ lookupCount++;
76
+ return KNOWN_TENANTS[sub] ?? null;
77
+ },
78
+ existsById: async (id) => {
79
+ existsCount++;
80
+ return id === ACME_TENANT_ID || id === GLOBEX_TENANT_ID;
81
+ },
82
+ cacheTtlSeconds: 60,
83
+ });
84
+
85
+ stack = await setupTestStack({
86
+ features: [multiTenantShopFeature],
87
+ anonymousAccess: {
88
+ tenantResolver: resolver.tenantResolver,
89
+ tenantExists: resolver.tenantExists,
90
+ // This recipe teaches "client X-Tenant header wins over the resolver"
91
+ // as the intended fallback-convenience pattern — see the test below
92
+ // asserting the header takes precedence and the resolver is skipped.
93
+ resolverTrust: "fallback-only",
94
+ },
95
+ });
96
+ await unsafeCreateEntityTable(stack.db, productEntity);
97
+ await createEventsTable(stack.db);
98
+
99
+ // Stamp test routes that exercise setTenantCookie. The framework gives
100
+ // apps the helper; the route is app-territory.
101
+ stack.app.get("/api/_set-tenant-cookie", (c) => {
102
+ const tenantId = c.req.query("tenantId") as TenantId | undefined;
103
+ if (!tenantId) return c.json({ error: "missing tenantId" }, 400);
104
+ setTenantCookie(c, tenantId);
105
+ return c.json({ ok: true });
106
+ });
107
+ });
108
+
109
+ afterAll(async () => {
110
+ await stack?.cleanup();
111
+ });
112
+
113
+ beforeEach(async () => {
114
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
115
+ resolver.invalidateAll();
116
+ lookupCount = 0;
117
+ existsCount = 0;
118
+ });
119
+
120
+ describe("subdomain resolution", () => {
121
+ test("acme.shop.com → acme tenant", async () => {
122
+ // Seed a product into acme — it should be visible to anonymous
123
+ // visitors who land on acme.shop.com.
124
+ await stack.http.writeOk(
125
+ "mtshop:write:product:create",
126
+ { name: "Acme Anvil" },
127
+ { ...TestUsers.admin, tenantId: ACME_TENANT_ID },
128
+ );
129
+
130
+ const res = await stack.http.raw(
131
+ "POST",
132
+ "/api/query",
133
+ { type: "mtshop:query:product:list", payload: {} },
134
+ { Host: "acme.shop.com" },
135
+ );
136
+
137
+ expect(res.status).toBe(200);
138
+ const body = (await res.json()) as { data: Array<{ name: string }> };
139
+ expect(body.data.map((p) => p.name)).toEqual(["Acme Anvil"]);
140
+ });
141
+
142
+ test("unknown subdomain → 400 tenant_required (resolver returned null)", async () => {
143
+ const res = await stack.http.raw(
144
+ "POST",
145
+ "/api/query",
146
+ { type: "mtshop:query:product:list", payload: {} },
147
+ { Host: "bogus.shop.com" },
148
+ );
149
+ expect(res.status).toBe(400);
150
+ const body = (await res.json()) as { error: { code: string } };
151
+ expect(body.error.code).toBe("tenant_required");
152
+ });
153
+
154
+ test("reserved subdomain (www) → 400 tenant_required", async () => {
155
+ // www.shop.com is the marketing landing — it must NOT silently fall
156
+ // onto whatever tenant happens to be first in the DB.
157
+ const res = await stack.http.raw(
158
+ "POST",
159
+ "/api/query",
160
+ { type: "mtshop:query:product:list", payload: {} },
161
+ { Host: "www.shop.com" },
162
+ );
163
+ expect(res.status).toBe(400);
164
+ });
165
+
166
+ test("apex host without subdomain → 400 tenant_required", async () => {
167
+ const res = await stack.http.raw(
168
+ "POST",
169
+ "/api/query",
170
+ { type: "mtshop:query:product:list", payload: {} },
171
+ { Host: "shop.com" },
172
+ );
173
+ expect(res.status).toBe(400);
174
+ });
175
+ });
176
+
177
+ describe("cache behaviour", () => {
178
+ test("repeated requests to the same subdomain hit the cache (1 DB lookup)", async () => {
179
+ for (let i = 0; i < 5; i++) {
180
+ const res = await stack.http.raw(
181
+ "POST",
182
+ "/api/query",
183
+ { type: "mtshop:query:product:list", payload: {} },
184
+ { Host: "acme.shop.com" },
185
+ );
186
+ expect(res.status).toBe(200);
187
+ }
188
+ expect(lookupCount).toBe(1);
189
+ });
190
+
191
+ test("invalidate() drops the cache entry → next request hits DB", async () => {
192
+ // Warm the cache.
193
+ await stack.http.raw(
194
+ "POST",
195
+ "/api/query",
196
+ { type: "mtshop:query:product:list", payload: {} },
197
+ { Host: "acme.shop.com" },
198
+ );
199
+ expect(lookupCount).toBe(1);
200
+
201
+ // Simulate a tenant-disable flow: invalidate the cache entry, the
202
+ // next request must consult the DB again.
203
+ resolver.invalidate("acme");
204
+ await stack.http.raw(
205
+ "POST",
206
+ "/api/query",
207
+ { type: "mtshop:query:product:list", payload: {} },
208
+ { Host: "acme.shop.com" },
209
+ );
210
+ expect(lookupCount).toBe(2);
211
+ });
212
+ });
213
+
214
+ describe("cookie persistence", () => {
215
+ test("X-Tenant header for known tenant routes through tenantExists", async () => {
216
+ // Header-supplied tenants don't go through tenantResolver — they go
217
+ // through tenantExists. This proves the second cache works.
218
+ for (let i = 0; i < 3; i++) {
219
+ const res = await stack.http.raw(
220
+ "POST",
221
+ "/api/query",
222
+ { type: "mtshop:query:product:list", payload: {} },
223
+ { Host: "anything.shop.com", "X-Tenant": ACME_TENANT_ID },
224
+ );
225
+ expect(res.status).toBe(200);
226
+ }
227
+ // tenantResolver was NOT called (header took precedence); tenantExists
228
+ // was called once and then served from cache.
229
+ expect(lookupCount).toBe(0);
230
+ expect(existsCount).toBe(1);
231
+ });
232
+
233
+ test("X-Tenant for unknown id → 404 tenant_not_found", async () => {
234
+ const res = await stack.http.raw(
235
+ "POST",
236
+ "/api/query",
237
+ { type: "mtshop:query:product:list", payload: {} },
238
+ {
239
+ Host: "anything.shop.com",
240
+ "X-Tenant": "00000000-0000-4000-8000-deadbeefdead",
241
+ },
242
+ );
243
+ expect(res.status).toBe(404);
244
+ const body = (await res.json()) as { error: { code: string } };
245
+ expect(body.error.code).toBe("tenant_not_found");
246
+ });
247
+ });
248
+
249
+ describe("extractSubdomain (unit)", () => {
250
+ test("typical SaaS host", () => {
251
+ expect(extractSubdomain("acme.shop.com")).toBe("acme");
252
+ });
253
+ test("strips port", () => {
254
+ expect(extractSubdomain("acme.shop.com:443")).toBe("acme");
255
+ });
256
+ test("apex returns null", () => {
257
+ expect(extractSubdomain("shop.com")).toBeNull();
258
+ });
259
+ test("localhost returns null", () => {
260
+ expect(extractSubdomain("localhost")).toBeNull();
261
+ });
262
+ });
263
+ ```
264
+
265
+ ### src/feature.ts
266
+
267
+ ```ts
268
+ // Multi-Tenant Anonymous Access — Subdomain-Resolver Pattern
269
+ //
270
+ // Use case: SaaS deployment where every customer gets `<tenant>.shop.com`.
271
+ // Anonymous visitors land on the right tenant via the host header. The
272
+ // recipe shows the three pieces an app needs:
273
+ //
274
+ // 1. tenantResolver — parses the Host header, looks up the tenantId in
275
+ // a small cache + DB. Returns null on misses (404 to the visitor).
276
+ // 2. tenantExists — guards header/cookie-supplied ids the same cache.
277
+ // 3. setTenantCookie — once the resolver decided, persist on the visitor
278
+ // so subsequent requests skip the parse + DB lookup.
279
+ //
280
+ // The product/order entities are minimal — the focus of this recipe is
281
+ // the resolution chain, not the domain logic. Pair this with the
282
+ // single-tenant recipe (samples/recipes/anonymous-access/) for
283
+ // comparison.
284
+
285
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
286
+ import {
287
+ access,
288
+ createEntity,
289
+ createTextField,
290
+ defineFeature,
291
+ } from "@cosmicdrift/kumiko-framework/engine";
292
+ import { z } from "zod";
293
+
294
+ export const productEntity = createEntity({
295
+ table: "mt_products",
296
+ fields: {
297
+ name: createTextField({ personal: false, reason: "is_business_data", required: true }),
298
+ },
299
+ });
300
+
301
+ export const productTable = buildEntityTable("product", productEntity);
302
+
303
+ export const multiTenantShopFeature = defineFeature("mtshop", (r) => {
304
+ r.entity("product", productEntity);
305
+
306
+ // Public listing — every tenant has its own products, scoped by tenantId
307
+ // automatically via the framework's tenant-isolation. Anonymous visitors
308
+ // see only the tenant they landed on.
309
+ r.queryHandler(
310
+ "product:list",
311
+ z.object({}),
312
+ async (_event, ctx) => ctx.db.selectMany(productTable),
313
+ {
314
+ access: { roles: [...access.anonymous, "User", "Admin"] },
315
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
316
+ },
317
+ );
318
+
319
+ // Admin-only — proves role-gating still works on top of the multi-tenant
320
+ // anonymous resolution.
321
+ r.writeHandler(
322
+ "product:create",
323
+ z.object({ name: z.string().min(1) }),
324
+ async (event, ctx) => {
325
+ const exec = createEventStoreExecutor(productTable, productEntity, {
326
+ entityName: "product",
327
+ });
328
+ return exec.create(event.payload, event.user, ctx.db);
329
+ },
330
+ { access: { roles: ["Admin"] } },
331
+ );
332
+ });
333
+ ```
334
+
335
+ ### src/subdomain-resolver.ts
336
+
337
+ ```ts
338
+ // Subdomain-based tenantResolver + tenantExists wired against an LRU cache.
339
+ // Apps drop this into anonymousAccess: { tenantResolver, tenantExists, … }.
340
+ //
341
+ // Cache strategy: 5-minute TTL is the sweet spot — it absorbs traffic
342
+ // bursts (a Reddit hug peaks for ~10 minutes; cache means one DB lookup
343
+ // for the entire wave) without holding stale data so long that a freshly
344
+ // disabled tenant keeps serving content. Apps with stricter SLA can drop
345
+ // the TTL or call invalidateTenantCache(slug) from their disable-tenant
346
+ // handler.
347
+
348
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
349
+
350
+ export type TenantLookup = (subdomain: string) => Promise<TenantId | null>;
351
+ export type TenantExistsLookup = (tenantId: TenantId) => Promise<boolean>;
352
+
353
+ export type SubdomainResolverOptions = {
354
+ // Subdomain → tenantId. Typically queries the tenants table for an
355
+ // active row matching the slug (acme.shop.com → "acme"). Returning null
356
+ // is the legitimate "no such tenant" answer — throw only on infra
357
+ // failures.
358
+ readonly lookupBySubdomain: TenantLookup;
359
+ // tenantId → exists?. Called when a visitor presents an X-Tenant header
360
+ // or kumiko_tenant cookie (so a cookie that survives a server restart
361
+ // still re-validates against the DB on first use). Apps usually wrap
362
+ // the same tenants table — keep the implementation symmetric to
363
+ // lookupBySubdomain.
364
+ readonly existsById: TenantExistsLookup;
365
+ // Apex / non-tenant hosts that should NOT be resolved. Common pattern:
366
+ // `shop.com` is the marketing site, `app.shop.com` is the admin app —
367
+ // anonymous traffic to those should 404 rather than land on a default.
368
+ readonly reservedSubdomains?: readonly string[];
369
+ // TTL in seconds. Default 300s (5min) — see top-of-file rationale.
370
+ readonly cacheTtlSeconds?: number;
371
+ };
372
+
373
+ type CacheEntry<T> = { value: T; expiresAt: number };
374
+
375
+ // Builds resolver + tenantExists that share independent caches by lookup
376
+ // key (subdomain vs tenantId), so the two callbacks each amortise their
377
+ // own DB load. Apps drop the returned object into
378
+ // `anonymousAccess: { tenantResolver: r.tenantResolver, tenantExists: r.tenantExists }`.
379
+ export function createSubdomainResolver(opts: SubdomainResolverOptions): {
380
+ tenantResolver: (c: {
381
+ req: { url: string; header: (name: string) => string | undefined };
382
+ }) => Promise<TenantId | null>;
383
+ tenantExists: (id: TenantId) => Promise<boolean>;
384
+ invalidate: (subdomainOrTenantId: string) => void;
385
+ invalidateAll: () => void;
386
+ } {
387
+ const ttlMs = (opts.cacheTtlSeconds ?? 300) * 1000;
388
+ const reserved = new Set(opts.reservedSubdomains ?? ["www", "app", "api", "admin"]);
389
+ const subdomainCache = new Map<string, CacheEntry<TenantId | null>>();
390
+ const existsCache = new Map<TenantId, CacheEntry<boolean>>();
391
+
392
+ async function lookupBySubdomainCached(subdomain: string): Promise<TenantId | null> {
393
+ const now = Date.now();
394
+ const cached = subdomainCache.get(subdomain);
395
+ if (cached && cached.expiresAt > now) return cached.value;
396
+
397
+ const tenantId = await opts.lookupBySubdomain(subdomain);
398
+ subdomainCache.set(subdomain, { value: tenantId, expiresAt: now + ttlMs });
399
+ return tenantId;
400
+ }
401
+
402
+ async function existsByIdCached(tenantId: TenantId): Promise<boolean> {
403
+ const now = Date.now();
404
+ const cached = existsCache.get(tenantId);
405
+ if (cached && cached.expiresAt > now) return cached.value;
406
+
407
+ const exists = await opts.existsById(tenantId);
408
+ existsCache.set(tenantId, { value: exists, expiresAt: now + ttlMs });
409
+ return exists;
410
+ }
411
+
412
+ return {
413
+ tenantResolver: async (c) => {
414
+ const host = c.req.header("Host");
415
+ if (!host) return null;
416
+ const subdomain = extractSubdomain(host);
417
+ if (subdomain === null) return null;
418
+ if (reserved.has(subdomain)) return null;
419
+ return lookupBySubdomainCached(subdomain);
420
+ },
421
+ tenantExists: existsByIdCached,
422
+ invalidate: (key) => {
423
+ subdomainCache.delete(key);
424
+ existsCache.delete(key as TenantId);
425
+ },
426
+ invalidateAll: () => {
427
+ subdomainCache.clear();
428
+ existsCache.clear();
429
+ },
430
+ };
431
+ }
432
+
433
+ // Extracts the leftmost DNS label, dropping the port and assuming the
434
+ // host has at least 3 labels (subdomain.domain.tld). Bare hosts like
435
+ // "localhost" or "shop.com" return null so the caller falls through to
436
+ // the "no tenant" branch.
437
+ export function extractSubdomain(host: string): string | null {
438
+ const noPort = host.split(":")[0] ?? host;
439
+ const labels = noPort.split(".");
440
+ if (labels.length < 3) return null;
441
+ return labels[0] ?? null;
442
+ }
443
+ ```