@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,300 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/anonymous-access. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# anonymous-access
|
|
4
|
+
|
|
5
|
+
Public endpoints (anonymous role) for shop / CMS-style apps. Tenant resolution via header, cookie, or default.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Anonymous Access
|
|
10
|
+
|
|
11
|
+
Public endpoints without a logged-in user.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- `access: "anonymous"` on handlers that must stay public
|
|
16
|
+
- Rate limits that still bind anonymous callers (`per: "ip+handler"`)
|
|
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
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Single-tenant shop wiring: defaultTenantId is the simplest setup. No
|
|
37
|
+
// resolver, no header, no cookie — anonymous requests just work. Suitable
|
|
38
|
+
// for WordPress-clones, SaaS landing pages, and standalone shops.
|
|
39
|
+
//
|
|
40
|
+
// For multi-tenant deployments, swap defaultTenantId for a tenantResolver
|
|
41
|
+
// that parses the host (acme.shop.com → tenantId) plus a tenantExists
|
|
42
|
+
// callback that confirms the tenant is real (against DB or cache).
|
|
43
|
+
|
|
44
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
45
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
46
|
+
import { ANONYMOUS_USER_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
47
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
48
|
+
import {
|
|
49
|
+
setupTestStack,
|
|
50
|
+
type TestStack,
|
|
51
|
+
TestUsers,
|
|
52
|
+
unsafeCreateEntityTable,
|
|
53
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
54
|
+
import {
|
|
55
|
+
anonymousAccessFeature,
|
|
56
|
+
guestOrderEntity,
|
|
57
|
+
guestOrderTable,
|
|
58
|
+
productEntity,
|
|
59
|
+
productTable,
|
|
60
|
+
} from "../feature";
|
|
61
|
+
|
|
62
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
63
|
+
|
|
64
|
+
let stack: TestStack;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
stack = await setupTestStack({
|
|
68
|
+
features: [anonymousAccessFeature],
|
|
69
|
+
// The single-tenant shortcut: every anonymous request lands on this
|
|
70
|
+
// tenant. No header, cookie, or resolver needed.
|
|
71
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
72
|
+
});
|
|
73
|
+
await unsafeCreateEntityTable(stack.db, productEntity);
|
|
74
|
+
await unsafeCreateEntityTable(stack.db, guestOrderEntity);
|
|
75
|
+
await createEventsTable(stack.db);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await stack?.cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
|
|
84
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${guestOrderTable.tableName}"`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("public read: anonymous + authenticated share the listing", () => {
|
|
88
|
+
test("anonymous caller sees products without any auth headers", async () => {
|
|
89
|
+
await stack.http.writeOk(
|
|
90
|
+
"shop:write:product:create",
|
|
91
|
+
{ name: "Espresso Beans", priceCents: "1500" },
|
|
92
|
+
TestUsers.admin,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
96
|
+
type: "shop:query:product:list",
|
|
97
|
+
payload: {},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
const body = (await res.json()) as { data: Array<{ name: string }> };
|
|
102
|
+
expect(body.data).toHaveLength(1);
|
|
103
|
+
expect(body.data[0]?.name).toBe("Espresso Beans");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("authenticated user reaches the same handler via JWT", async () => {
|
|
107
|
+
await stack.http.writeOk(
|
|
108
|
+
"shop:write:product:create",
|
|
109
|
+
{ name: "Espresso Beans", priceCents: "1500" },
|
|
110
|
+
TestUsers.admin,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const res = await stack.http.query("shop:query:product:list", {}, TestUsers.user);
|
|
114
|
+
expect(res.status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("guest checkout: anonymous write lands with placedBy=anonymous", () => {
|
|
119
|
+
test("anonymous order is stored and tagged with the anonymous user-id", async () => {
|
|
120
|
+
await stack.http.writeOk(
|
|
121
|
+
"shop:write:product:create",
|
|
122
|
+
{ name: "Espresso Beans", priceCents: "1500" },
|
|
123
|
+
TestUsers.admin,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
127
|
+
type: "shop:write:guest-order:place",
|
|
128
|
+
payload: {
|
|
129
|
+
productId: "00000000-0000-4000-8000-000000000099",
|
|
130
|
+
email: "guest@example.com",
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(res.status).toBe(200);
|
|
135
|
+
|
|
136
|
+
const orders = await selectMany(stack.db, guestOrderTable);
|
|
137
|
+
expect(orders).toHaveLength(1);
|
|
138
|
+
expect(orders[0]?.placedBy).toBe(ANONYMOUS_USER_ID);
|
|
139
|
+
expect(orders[0]?.email).toBe("guest@example.com");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("regression guards", () => {
|
|
144
|
+
test("openToAll handler rejects anonymous (was the silent-public bug)", async () => {
|
|
145
|
+
// Enabling anonymousAccess must NOT turn every existing openToAll endpoint
|
|
146
|
+
// public. hasAccess rejects anonymous on openToAll, and the dispatcher
|
|
147
|
+
// answers 401: the caller has to sign in, not ask for another role.
|
|
148
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
149
|
+
type: "shop:query:product:authenticated-only",
|
|
150
|
+
payload: {},
|
|
151
|
+
});
|
|
152
|
+
expect(res.status).toBe(401);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("admin-only handler rejects anonymous", async () => {
|
|
156
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
157
|
+
type: "shop:write:product:create",
|
|
158
|
+
payload: { name: "Tea Set", priceCents: "1000" },
|
|
159
|
+
});
|
|
160
|
+
expect(res.status).toBe(401);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### src/feature.ts
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// Anonymous-Access Sample
|
|
169
|
+
//
|
|
170
|
+
// Most Kumiko apps put every endpoint behind a JWT — fine for CRMs, ERPs,
|
|
171
|
+
// and back-office tools where every caller is a known user. Shops, CMSs,
|
|
172
|
+
// marketing-microsites have a different shape: 50%+ of traffic is anonymous
|
|
173
|
+
// (visitors, scrapers, search engines) and the public surface is a feature,
|
|
174
|
+
// not a hack.
|
|
175
|
+
//
|
|
176
|
+
// This recipe wires that public surface in two ways:
|
|
177
|
+
//
|
|
178
|
+
// 1. `roles: ["anonymous"]` on a handler → unauthenticated callers reach it.
|
|
179
|
+
// The framework synthesises a SessionUser with id="anonymous" + that
|
|
180
|
+
// single role. `access.anonymous` is the typed shorthand for the role.
|
|
181
|
+
//
|
|
182
|
+
// 2. Combined with authenticated roles → the same handler serves both
|
|
183
|
+
// audiences, with the handler-body branching on `event.user.id` if
|
|
184
|
+
// personalisation is needed.
|
|
185
|
+
//
|
|
186
|
+
// `openToAll` is intentionally NOT public — it still means "any
|
|
187
|
+
// authenticated user", and the framework rejects anonymous callers there.
|
|
188
|
+
// Without that guard, enabling anonymousAccess on the server would silently
|
|
189
|
+
// expose every existing openToAll handler. The `product:authenticated-only`
|
|
190
|
+
// query below proves this.
|
|
191
|
+
|
|
192
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
193
|
+
import {
|
|
194
|
+
access,
|
|
195
|
+
createEntity,
|
|
196
|
+
createTextField,
|
|
197
|
+
defineFeature,
|
|
198
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
199
|
+
import { z } from "zod";
|
|
200
|
+
|
|
201
|
+
export const productEntity = createEntity({
|
|
202
|
+
table: "shop_products",
|
|
203
|
+
fields: {
|
|
204
|
+
name: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
205
|
+
priceCents: createTextField({ personal: false, reason: "is_business_data", default: "0" }),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
export const guestOrderEntity = createEntity({
|
|
210
|
+
table: "shop_guest_orders",
|
|
211
|
+
fields: {
|
|
212
|
+
productId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
|
|
213
|
+
email: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
214
|
+
placedBy: createTextField({ personal: "ref", default: "" }),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export const productTable = buildEntityTable("product", productEntity);
|
|
219
|
+
export const guestOrderTable = buildEntityTable("guest-order", guestOrderEntity);
|
|
220
|
+
|
|
221
|
+
export const anonymousAccessFeature = defineFeature("shop", (r) => {
|
|
222
|
+
r.entity("product", productEntity);
|
|
223
|
+
r.entity("guest-order", guestOrderEntity);
|
|
224
|
+
|
|
225
|
+
// Public listing: anonymous + authenticated customers see it. Combining
|
|
226
|
+
// the roles in one handler is preferred over duplicating the read path —
|
|
227
|
+
// the handler body can branch on `event.user.id === "anonymous"` if it
|
|
228
|
+
// needs to personalise (e.g. show a sign-in CTA, hide a wishlist button).
|
|
229
|
+
r.queryHandler(
|
|
230
|
+
"product:list",
|
|
231
|
+
z.object({}),
|
|
232
|
+
async (_event, ctx) => ctx.db.selectMany(productTable),
|
|
233
|
+
{
|
|
234
|
+
access: { roles: [...access.anonymous, "User", "Admin"] },
|
|
235
|
+
rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Authenticated-only listing — same data, different access rule. The
|
|
240
|
+
// openToAll declaration here is the regression-guard: even with
|
|
241
|
+
// anonymousAccess enabled on the server, this endpoint stays gated to
|
|
242
|
+
// logged-in users.
|
|
243
|
+
r.queryHandler(
|
|
244
|
+
"product:authenticated-only",
|
|
245
|
+
z.object({}),
|
|
246
|
+
async (_event, ctx) => ctx.db.selectMany(productTable),
|
|
247
|
+
{
|
|
248
|
+
access: {
|
|
249
|
+
openToAll: {
|
|
250
|
+
reason:
|
|
251
|
+
"any signed-in (non-anonymous) user may list products here; this handler is " +
|
|
252
|
+
"the regression-guard proving anonymousAccess never silently exposes an authenticated-only endpoint",
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Anonymous + authenticated guest checkout. Stores the synthesised user-id
|
|
259
|
+
// ("anonymous") in `placedBy` so audit-trail / support queries can tell
|
|
260
|
+
// an authenticated order from a walk-in. Rate-limited per IP because every
|
|
261
|
+
// anonymous caller shares user.id="anonymous" — a per-user rate-limit
|
|
262
|
+
// would be a single global tap any caller could drain (the boot-validator
|
|
263
|
+
// refuses the misconfiguration).
|
|
264
|
+
const guestOrderExecutor = createEventStoreExecutor(guestOrderTable, guestOrderEntity, {
|
|
265
|
+
entityName: "guest-order",
|
|
266
|
+
});
|
|
267
|
+
r.writeHandler(
|
|
268
|
+
"guest-order:place",
|
|
269
|
+
z.object({
|
|
270
|
+
productId: z.uuid(),
|
|
271
|
+
email: z.string().email(),
|
|
272
|
+
}),
|
|
273
|
+
async (event, ctx) =>
|
|
274
|
+
guestOrderExecutor.create({ ...event.payload, placedBy: event.user.id }, event.user, ctx.db),
|
|
275
|
+
{
|
|
276
|
+
// `email` is recordOwned PII (personal: { of: "id" }) written by an
|
|
277
|
+
// anonymous root handler — the runtime personal-data gate
|
|
278
|
+
// (write-origin.ts) requires this explicit opt-in, the same way the
|
|
279
|
+
// boot validator would reject an openToAll declaration missing it.
|
|
280
|
+
access: { roles: [...access.anonymous, "User"], personalData: "public-intake" },
|
|
281
|
+
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Admin-only product CRUD — proves role-gated handlers still reject
|
|
286
|
+
// anonymous callers. Same shape as any other handler in the framework:
|
|
287
|
+
// anonymous is just a regular role, not a special case.
|
|
288
|
+
r.writeHandler(
|
|
289
|
+
"product:create",
|
|
290
|
+
z.object({ name: z.string().min(1), priceCents: z.string() }),
|
|
291
|
+
async (event, ctx) => {
|
|
292
|
+
const exec = createEventStoreExecutor(productTable, productEntity, {
|
|
293
|
+
entityName: "product",
|
|
294
|
+
});
|
|
295
|
+
return exec.create(event.payload, event.user, ctx.db);
|
|
296
|
+
},
|
|
297
|
+
{ access: { roles: ["Admin"] } },
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
```
|