@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,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
|
+
```
|