@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/public-share-token. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# public-share-token
|
|
4
|
+
|
|
5
|
+
Generic share-token entity: mint hash-stored token, anonymous read-by-token with IP rate-limit.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Public share token
|
|
10
|
+
|
|
11
|
+
Minimal pattern for **tokenized anonymous read surfaces**: authenticated users
|
|
12
|
+
mint a link; visitors resolve it without login.
|
|
13
|
+
|
|
14
|
+
Domain-agnostic — no credit/folder logic. A full consumer adds snapshots,
|
|
15
|
+
layout templates and tier gates on top.
|
|
16
|
+
|
|
17
|
+
## What it shows
|
|
18
|
+
|
|
19
|
+
- **`createEntity` + event-sourced CRUD** — `tokenHash` unique, plain token show-once
|
|
20
|
+
- **`defineWriteHandler` / `defineQueryHandler`** — create, revoke, read-by-token
|
|
21
|
+
- **Anonymous query** — `roles: ["anonymous", …]` + `rateLimit: { per: "ip+handler" }`
|
|
22
|
+
- **404 ohne Leak** — invalid / expired / revoked → same `NotFoundError` shape
|
|
23
|
+
- **`anonymousAccess`** on test stack (see integration test)
|
|
24
|
+
|
|
25
|
+
Client wiring: [`recipes/apex-surface-auth`](../apex-surface-auth/) or a gate
|
|
26
|
+
before `createKumikoApp` (a small gate component that renders the public view when
|
|
27
|
+
the URL carries a share token).
|
|
28
|
+
|
|
29
|
+
## Handlers
|
|
30
|
+
|
|
31
|
+
| QN | Access |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `public-share:write:share-link:create` | authenticated (`openToAll`) |
|
|
34
|
+
| `public-share:write:share-link:revoke` | owner |
|
|
35
|
+
| `public-share:query:share-by-token` | anonymous + authenticated |
|
|
36
|
+
|
|
37
|
+
## Run tests
|
|
38
|
+
|
|
39
|
+
From repo root (needs `TEST_DATABASE_URL` + `REDIS_URL`):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cd kumiko-framework/samples/recipes/public-share-token
|
|
43
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Source files
|
|
47
|
+
|
|
48
|
+
### src/__tests__/feature.integration.test.ts
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
52
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
53
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
54
|
+
import {
|
|
55
|
+
createTestUser,
|
|
56
|
+
setupTestStack,
|
|
57
|
+
type TestStack,
|
|
58
|
+
unsafeCreateEntityTable,
|
|
59
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
60
|
+
import { publicShareTokenFeature, shareLinkEntity } from "../feature";
|
|
61
|
+
|
|
62
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
63
|
+
|
|
64
|
+
let stack: TestStack;
|
|
65
|
+
|
|
66
|
+
const owner = createTestUser({ id: 1, tenantId: TENANT_ID, roles: ["TenantAdmin"] });
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
stack = await setupTestStack({
|
|
70
|
+
features: [publicShareTokenFeature],
|
|
71
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
72
|
+
});
|
|
73
|
+
await unsafeCreateEntityTable(stack.db, shareLinkEntity);
|
|
74
|
+
await createEventsTable(stack.db);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
await stack?.cleanup();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("public-share-token recipe", () => {
|
|
82
|
+
test("create → anonymous share-by-token returns payload", async () => {
|
|
83
|
+
const created = await stack.http.writeOk<{
|
|
84
|
+
id: string;
|
|
85
|
+
plainToken: string;
|
|
86
|
+
}>(
|
|
87
|
+
"public-share:write:share-link:create",
|
|
88
|
+
{ label: "Demo", payload: { note: "hello" }, expiresInDays: 7 },
|
|
89
|
+
owner,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(created.plainToken.startsWith("sh_")).toBe(true);
|
|
93
|
+
|
|
94
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
95
|
+
type: "public-share:query:share-by-token",
|
|
96
|
+
payload: { token: created.plainToken },
|
|
97
|
+
});
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
const body = (await res.json()) as { data: { label: string; payload: { note: string } } };
|
|
100
|
+
expect(body.data.label).toBe("Demo");
|
|
101
|
+
expect(body.data.payload.note).toBe("hello");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("revoke → share-by-token 404", async () => {
|
|
105
|
+
const created = await stack.http.writeOk<{ id: string; plainToken: string }>(
|
|
106
|
+
"public-share:write:share-link:create",
|
|
107
|
+
{ label: "Revoke me", expiresInDays: 7 },
|
|
108
|
+
owner,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
await stack.http.writeOk("public-share:write:share-link:revoke", { id: created.id }, owner);
|
|
112
|
+
|
|
113
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
114
|
+
type: "public-share:query:share-by-token",
|
|
115
|
+
payload: { token: created.plainToken },
|
|
116
|
+
});
|
|
117
|
+
expect(res.status).toBe(404);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("random token → 404", async () => {
|
|
121
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
122
|
+
type: "public-share:query:share-by-token",
|
|
123
|
+
payload: { token: "sh_not-a-real-token" },
|
|
124
|
+
});
|
|
125
|
+
expect(res.status).toBe(404);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("public-share-token recipe: expiry", () => {
|
|
130
|
+
test("expired token → share-by-token 404", async () => {
|
|
131
|
+
const created = await stack.http.writeOk<{ id: string; plainToken: string }>(
|
|
132
|
+
"public-share:write:share-link:create",
|
|
133
|
+
{ label: "Expires soon", expiresInDays: 1 },
|
|
134
|
+
owner,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await stack.db.unsafe(
|
|
138
|
+
"UPDATE read_public_share_links SET expires_at = now() - interval '1 day' WHERE id = $1",
|
|
139
|
+
[created.id],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
143
|
+
type: "public-share:query:share-by-token",
|
|
144
|
+
payload: { token: created.plainToken },
|
|
145
|
+
});
|
|
146
|
+
expect(res.status).toBe(404);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### src/feature.ts
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// Public share token — generic recipe (no domain payload).
|
|
155
|
+
//
|
|
156
|
+
// Pattern: authenticated create → plain token show-once → anonymous
|
|
157
|
+
// read-by-token with SHA-256 lookup. Revoke sets revokedAt via executor.
|
|
158
|
+
//
|
|
159
|
+
// A full consumer adds domain snapshots, layouts and tier gates on top.
|
|
160
|
+
|
|
161
|
+
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
162
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
163
|
+
import {
|
|
164
|
+
createEntity,
|
|
165
|
+
createEntityExecutor,
|
|
166
|
+
createJsonbField,
|
|
167
|
+
createTextField,
|
|
168
|
+
createTimestampField,
|
|
169
|
+
defineFeature,
|
|
170
|
+
defineQueryHandler,
|
|
171
|
+
defineWriteHandler,
|
|
172
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
173
|
+
import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
|
|
174
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
175
|
+
import { z } from "zod";
|
|
176
|
+
|
|
177
|
+
const TOKEN_PREFIX = "sh_";
|
|
178
|
+
|
|
179
|
+
const SHARE_BY_TOKEN_REASON =
|
|
180
|
+
"resolves the share link by token hash for callers whose dispatch tenant is not the link owner's";
|
|
181
|
+
|
|
182
|
+
export const shareLinkEntity = createEntity({
|
|
183
|
+
table: "read_public_share_links",
|
|
184
|
+
idType: "uuid",
|
|
185
|
+
fields: {
|
|
186
|
+
tokenHash: createTextField({
|
|
187
|
+
personal: false,
|
|
188
|
+
reason: "is_token_hash",
|
|
189
|
+
required: true,
|
|
190
|
+
maxLength: 64,
|
|
191
|
+
}),
|
|
192
|
+
label: createTextField({
|
|
193
|
+
personal: false,
|
|
194
|
+
reason: "is_business_data",
|
|
195
|
+
required: true,
|
|
196
|
+
maxLength: 200,
|
|
197
|
+
}),
|
|
198
|
+
payload: createJsonbField(),
|
|
199
|
+
expiresAt: createTimestampField({ required: true }),
|
|
200
|
+
revokedAt: createTimestampField({}),
|
|
201
|
+
},
|
|
202
|
+
indexes: [
|
|
203
|
+
{
|
|
204
|
+
unique: true,
|
|
205
|
+
columns: ["tokenHash"],
|
|
206
|
+
name: "read_public_share_links_hash_unique",
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const { executor, table } = createEntityExecutor("share-link", shareLinkEntity);
|
|
212
|
+
|
|
213
|
+
async function hashToken(plain: string): Promise<string> {
|
|
214
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain));
|
|
215
|
+
let hex = "";
|
|
216
|
+
for (const byte of new Uint8Array(digest)) {
|
|
217
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
218
|
+
}
|
|
219
|
+
return hex;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function mintToken(): Promise<{ plain: string; hash: string }> {
|
|
223
|
+
const plain = `${TOKEN_PREFIX}${generateToken()}`;
|
|
224
|
+
return { plain, hash: await hashToken(plain) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const createSchema = z.object({
|
|
228
|
+
label: z.string().min(1).max(200),
|
|
229
|
+
payload: z.record(z.string(), z.unknown()).optional(),
|
|
230
|
+
expiresInDays: z.number().int().min(1).max(90).default(30),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export const shareLinkCreateWrite = defineWriteHandler({
|
|
234
|
+
name: "share-link:create",
|
|
235
|
+
schema: createSchema,
|
|
236
|
+
access: {
|
|
237
|
+
openToAll: {
|
|
238
|
+
reason:
|
|
239
|
+
"any signed-in user may create a share link for their own tenant; the link " +
|
|
240
|
+
"itself is only reachable via its unguessable token",
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
|
|
244
|
+
handler: async (event, ctx) => {
|
|
245
|
+
const { plain, hash } = await mintToken();
|
|
246
|
+
const now = getTemporal().Now.instant();
|
|
247
|
+
const expiresAt = now.add({ hours: 24 * event.payload.expiresInDays });
|
|
248
|
+
|
|
249
|
+
const result = await executor.create(
|
|
250
|
+
{
|
|
251
|
+
tokenHash: hash,
|
|
252
|
+
label: event.payload.label,
|
|
253
|
+
payload: event.payload.payload ?? {},
|
|
254
|
+
expiresAt,
|
|
255
|
+
revokedAt: null,
|
|
256
|
+
tenantId: event.user.tenantId,
|
|
257
|
+
},
|
|
258
|
+
event.user,
|
|
259
|
+
ctx.db,
|
|
260
|
+
);
|
|
261
|
+
if (!result.isSuccess) return result;
|
|
262
|
+
|
|
263
|
+
const id = String((result.data as { id: unknown }).id);
|
|
264
|
+
return {
|
|
265
|
+
isSuccess: true as const,
|
|
266
|
+
data: { id, plainToken: plain, expiresAt: expiresAt.toString() },
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
export const shareLinkRevokeWrite = defineWriteHandler({
|
|
272
|
+
name: "share-link:revoke",
|
|
273
|
+
schema: z.object({ id: z.uuid() }),
|
|
274
|
+
access: {
|
|
275
|
+
openToAll: {
|
|
276
|
+
reason:
|
|
277
|
+
"any signed-in user may attempt to revoke a share link, but only the link's " +
|
|
278
|
+
"own creator succeeds; the handler checks ownerId against the caller and " +
|
|
279
|
+
"returns not-found otherwise",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
handler: async (event, ctx) => {
|
|
283
|
+
const rows = await ctx.db.selectMany(
|
|
284
|
+
table,
|
|
285
|
+
{ id: event.payload.id, tenantId: event.user.tenantId },
|
|
286
|
+
{ limit: 1 },
|
|
287
|
+
);
|
|
288
|
+
const row = rows[0];
|
|
289
|
+
const ownerId = String(row?.["insertedById"] ?? row?.["inserted_by_id"] ?? "");
|
|
290
|
+
if (!row || ownerId !== event.user.id) {
|
|
291
|
+
throw new NotFoundError("share-link");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return executor.update(
|
|
295
|
+
{
|
|
296
|
+
id: event.payload.id,
|
|
297
|
+
version: Number(row["version"]),
|
|
298
|
+
changes: { revokedAt: getTemporal().Now.instant() },
|
|
299
|
+
},
|
|
300
|
+
event.user,
|
|
301
|
+
ctx.db,
|
|
302
|
+
);
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
interface ShareLinkRow {
|
|
307
|
+
readonly id: string;
|
|
308
|
+
readonly label: string;
|
|
309
|
+
readonly payload: unknown;
|
|
310
|
+
readonly expiresAt: Temporal.Instant;
|
|
311
|
+
readonly revokedAt: Temporal.Instant | null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export const shareByTokenQuery = defineQueryHandler({
|
|
315
|
+
name: "share-by-token",
|
|
316
|
+
schema: z.object({ token: z.string().min(1) }),
|
|
317
|
+
access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
|
|
318
|
+
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
|
|
319
|
+
escapeHatch: {
|
|
320
|
+
grants: ["unsafeRaw"],
|
|
321
|
+
reason: SHARE_BY_TOKEN_REASON,
|
|
322
|
+
},
|
|
323
|
+
handler: async (query, ctx) => {
|
|
324
|
+
const hash = await hashToken(query.payload.token);
|
|
325
|
+
const row = await fetchOne<ShareLinkRow>(ctx.db.unsafeRaw(), table, {
|
|
326
|
+
tokenHash: hash,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (!row) {
|
|
330
|
+
throw new NotFoundError("share-link");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const now = getTemporal().Now.instant();
|
|
334
|
+
if (row.revokedAt != null || row.expiresAt.epochMilliseconds <= now.epochMilliseconds) {
|
|
335
|
+
throw new NotFoundError("share-link");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
label: row.label,
|
|
340
|
+
payload: row.payload,
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
export const publicShareTokenFeature = defineFeature("public-share", (r) => {
|
|
346
|
+
r.entity("share-link", shareLinkEntity);
|
|
347
|
+
r.writeHandler(shareLinkCreateWrite);
|
|
348
|
+
r.writeHandler(shareLinkRevokeWrite);
|
|
349
|
+
r.queryHandler(shareByTokenQuery);
|
|
350
|
+
});
|
|
351
|
+
```
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/rate-limiting. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# rate-limiting
|
|
4
|
+
|
|
5
|
+
End-to-end rate limiting: L1 global-IP + L2 auth-endpoints + L3 handler opt-in.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Rate Limiting
|
|
10
|
+
|
|
11
|
+
End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Handler `rateLimit: { per, limit, windowSeconds }`
|
|
16
|
+
- How L1/L2 from `buildServer` stack with L3
|
|
17
|
+
|
|
18
|
+
## Per-payload limits
|
|
19
|
+
|
|
20
|
+
A public write handler that mails an address typed by the caller is also limited per
|
|
21
|
+
address, so rotating IPs does not help against mail flooding. Declare it next to `rateLimit`:
|
|
22
|
+
|
|
23
|
+
```ts illustration
|
|
24
|
+
r.writeHandler({
|
|
25
|
+
// ...
|
|
26
|
+
access: { roles: ["anonymous"] },
|
|
27
|
+
rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
|
|
28
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`additionalRateLimits` only complements `rateLimit`: an anonymous handler still needs a real,
|
|
33
|
+
ip-keyed `rateLimit` (boot fails otherwise), and `payloadField` must be a top-level string field
|
|
34
|
+
of the handler's Zod object schema. The value is trimmed, lowercased and HMAC-hashed into the
|
|
35
|
+
bucket key, so Redis never holds the address in plaintext. The check runs after schema
|
|
36
|
+
validation and before the handler, identically for matching and non-matching values, and is
|
|
37
|
+
skipped for system callers. Exceeding it answers 429 `rate_limited`.
|
|
38
|
+
|
|
39
|
+
## Source
|
|
40
|
+
|
|
41
|
+
Feature entry point: `src/feature.ts`.
|
|
42
|
+
|
|
43
|
+
## Tests
|
|
44
|
+
|
|
45
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cd samples/recipes/rate-limiting
|
|
49
|
+
bun test
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Source files
|
|
53
|
+
|
|
54
|
+
### src/__tests__/feature.integration.test.ts
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// Rate-Limiting Showcase — End-to-end proof that all three layers stack.
|
|
58
|
+
//
|
|
59
|
+
// The boot config below is the same shape a real app would use:
|
|
60
|
+
// buildServer({ ..., rateLimit: { global: ..., auth: ... } })
|
|
61
|
+
// L3 comes from the feature itself (handler-declared `rateLimit:`).
|
|
62
|
+
//
|
|
63
|
+
// One TestStack hosts all three layers. Each test uses a distinct
|
|
64
|
+
// bucket dimension (per-user vs per-IP vs per-IP+path) so the layers
|
|
65
|
+
// don't shadow each other; beforeEach flushes Redis so counters start
|
|
66
|
+
// clean. Limits are picked so the 4th/3rd/21st call is the first 429
|
|
67
|
+
// in each layer's test.
|
|
68
|
+
|
|
69
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
70
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
71
|
+
import { createRateLimitShowcaseFeature } from "../feature";
|
|
72
|
+
|
|
73
|
+
let stack: TestStack;
|
|
74
|
+
const admin = TestUsers.admin;
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
stack = await setupTestStack({
|
|
78
|
+
features: [createRateLimitShowcaseFeature()],
|
|
79
|
+
// The L1/L2 tests below simulate distinct client IPs via
|
|
80
|
+
// X-Forwarded-For. Without a trusted proxy hop configured, the
|
|
81
|
+
// client-ip resolver now ignores X-Forwarded-For by design (it can't
|
|
82
|
+
// tell a real proxy from a spoofing client) and every call falls into
|
|
83
|
+
// the shared "unknown" bucket — trust one hop so the showcase actually
|
|
84
|
+
// demonstrates per-IP buckets, the way a real app behind one proxy would.
|
|
85
|
+
trustedProxyHops: 1,
|
|
86
|
+
rateLimit: {
|
|
87
|
+
// L1: 20/min per IP. High enough that L3 tests (which loop 4×
|
|
88
|
+
// with their own IP) don't accidentally trip L1 first.
|
|
89
|
+
global: { limit: 20, windowSeconds: 60 },
|
|
90
|
+
// L2: 2/min per (IP + path). Default `path: "/api/auth/*"` —
|
|
91
|
+
// tight limit because login brute-force is the canonical use case.
|
|
92
|
+
auth: { path: "/api/auth/*", limit: 2, windowSeconds: 60 },
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterAll(async () => {
|
|
98
|
+
await stack.cleanup();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
beforeEach(async () => {
|
|
102
|
+
await stack.redis.flushNamespace();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("L3 — handler opt-in (per-user budget on expensive-search)", () => {
|
|
106
|
+
test("4th call from same user returns 429 with user-bucket", async () => {
|
|
107
|
+
for (let i = 0; i < 3; i++) {
|
|
108
|
+
const ok = await stack.http.queryOk<{ q: string; hits: number }>(
|
|
109
|
+
"rl-showcase:query:expensive-search",
|
|
110
|
+
{ q: "kumiko" },
|
|
111
|
+
admin,
|
|
112
|
+
);
|
|
113
|
+
expect(ok.q).toBe("kumiko");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// queryOk would throw on non-2xx; query() returns the raw response so
|
|
117
|
+
// the wire-shape of the 429 body is observable.
|
|
118
|
+
const blocked = await stack.http.query(
|
|
119
|
+
"rl-showcase:query:expensive-search",
|
|
120
|
+
{ q: "kumiko" },
|
|
121
|
+
admin,
|
|
122
|
+
);
|
|
123
|
+
expect(blocked.status).toBe(429);
|
|
124
|
+
const body = (await blocked.json()) as {
|
|
125
|
+
error: { code: string; details?: { bucket?: string } };
|
|
126
|
+
};
|
|
127
|
+
expect(body.error.code).toBe("rate_limited");
|
|
128
|
+
expect(body.error.details?.bucket).toBe("user");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("L1 — global IP middleware (covers every /api/* request)", () => {
|
|
133
|
+
test("21st request from same IP returns 429 with l1:ip bucket", async () => {
|
|
134
|
+
// L1 sits ahead of auth + every /api/* route. Target an authless,
|
|
135
|
+
// routeless path: pre-21st calls pass through L1 and surface a 401
|
|
136
|
+
// from jwtGuard (no token attached). The response code itself
|
|
137
|
+
// doesn't matter — what matters is that the bucket counter advances.
|
|
138
|
+
const ipHeader = { "x-forwarded-for": "10.99.0.1" };
|
|
139
|
+
for (let i = 0; i < 20; i++) {
|
|
140
|
+
const res = await stack.app.request("/api/_probe", { headers: ipHeader });
|
|
141
|
+
expect(res.headers.get("X-RateLimit-Remaining")).toBe(String(19 - i));
|
|
142
|
+
}
|
|
143
|
+
const blocked = await stack.app.request("/api/_probe", { headers: ipHeader });
|
|
144
|
+
expect(blocked.status).toBe(429);
|
|
145
|
+
expect(blocked.headers.get("Retry-After")).toBeTruthy();
|
|
146
|
+
const body = (await blocked.json()) as {
|
|
147
|
+
error: { code: string; details: { bucket: string } };
|
|
148
|
+
};
|
|
149
|
+
expect(body.error.code).toBe("rate_limited");
|
|
150
|
+
expect(body.error.details.bucket).toBe("l1");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("L2 — auth-endpoints middleware (tighter cap on /api/auth/*)", () => {
|
|
155
|
+
test("3rd POST to /api/auth/login from same IP returns 429 with l2:ip:path bucket", async () => {
|
|
156
|
+
const ipHeader = { "x-forwarded-for": "10.99.0.2" };
|
|
157
|
+
|
|
158
|
+
// L2 fires before the route resolves. Without an authConfig wired
|
|
159
|
+
// these POSTs would 404, but L2 still counts them — that's the
|
|
160
|
+
// whole point: gate before the request body even gets parsed.
|
|
161
|
+
for (let i = 0; i < 2; i++) {
|
|
162
|
+
const res = await stack.app.request("/api/auth/login", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: ipHeader,
|
|
165
|
+
});
|
|
166
|
+
expect(res.headers.get("X-RateLimit-Remaining")).toBe(String(1 - i));
|
|
167
|
+
}
|
|
168
|
+
const blocked = await stack.app.request("/api/auth/login", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: ipHeader,
|
|
171
|
+
});
|
|
172
|
+
expect(blocked.status).toBe(429);
|
|
173
|
+
const body = (await blocked.json()) as {
|
|
174
|
+
error: { code: string; details: { bucket: string } };
|
|
175
|
+
};
|
|
176
|
+
expect(body.error.code).toBe("rate_limited");
|
|
177
|
+
expect(body.error.details.bucket).toBe("l2");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### src/feature.ts
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// Rate-Limiting Showcase — minimal feature
|
|
186
|
+
//
|
|
187
|
+
// Declares one query handler with an L3 rateLimit option so the
|
|
188
|
+
// dispatcher gates calls before the handler body runs. The integration
|
|
189
|
+
// test pairs this with L1+L2 middleware wired via buildServer's
|
|
190
|
+
// `rateLimit` option to prove all three layers stack.
|
|
191
|
+
|
|
192
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
193
|
+
import { z } from "zod";
|
|
194
|
+
|
|
195
|
+
export function createRateLimitShowcaseFeature(): FeatureDefinition {
|
|
196
|
+
return defineFeature("rl-showcase", (r) => {
|
|
197
|
+
// Per-user budget. Real apps tune `limit` to actual handler cost — a
|
|
198
|
+
// search call against a sharded index might warrant 5/min, a full
|
|
199
|
+
// export 1/min. The bucket is `user:<userId>` (see rate-limit/bucket.ts).
|
|
200
|
+
r.queryHandler(
|
|
201
|
+
"expensive-search",
|
|
202
|
+
z.object({ q: z.string().min(1) }),
|
|
203
|
+
async ({ payload }) => ({ q: payload.q, hits: 0 }),
|
|
204
|
+
{
|
|
205
|
+
access: { roles: ["Admin", "User"] },
|
|
206
|
+
rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
```
|