@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.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- 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
|
+
```
|