@cosmicdrift/kumiko-framework 0.362.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,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
+ ```