@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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 (150) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/workflow-engine.md +2 -2
  22. package/agents/examples.md +2 -1
  23. package/agents/features.md +3 -3
  24. package/dist/api/auth-routes.d.ts +0 -1
  25. package/dist/api/auth-routes.js +44 -43
  26. package/dist/api/index.d.ts +1 -1
  27. package/dist/api/index.js +1 -1
  28. package/dist/api/server.d.ts +2 -2
  29. package/dist/api/server.js +21 -4
  30. package/dist/bun-db/connection.js +3 -16
  31. package/dist/bun-db/index.d.ts +1 -1
  32. package/dist/bun-db/index.js +1 -1
  33. package/dist/bun-db/query.d.ts +7 -0
  34. package/dist/bun-db/query.js +50 -18
  35. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  36. package/dist/crypto/owner-transition-rekey.js +7 -3
  37. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  38. package/dist/crypto/pii-field-encryption.js +9 -2
  39. package/dist/crypto/subject-resolver.d.ts +1 -0
  40. package/dist/crypto/subject-resolver.js +7 -0
  41. package/dist/db/api.d.ts +4 -3
  42. package/dist/db/apply-entity-event.js +26 -2
  43. package/dist/db/blind-index-cleanup.js +2 -1
  44. package/dist/db/bun-provider.js +9 -18
  45. package/dist/db/dialect.d.ts +1 -1
  46. package/dist/db/dialect.js +2 -0
  47. package/dist/db/eagerload.js +2 -1
  48. package/dist/db/event-store-executor-context.d.ts +1 -0
  49. package/dist/db/event-store-executor-context.js +1 -0
  50. package/dist/db/event-store-executor-write.d.ts +1 -1
  51. package/dist/db/event-store-executor-write.js +70 -47
  52. package/dist/db/index.d.ts +1 -0
  53. package/dist/db/index.js +1 -0
  54. package/dist/db/migrate-runner.d.ts +1 -0
  55. package/dist/db/migrate-runner.js +56 -31
  56. package/dist/db/queries/backfill-pii.js +63 -25
  57. package/dist/db/queries/event-consumer.js +2 -1
  58. package/dist/db/queries/event-store-transfer.js +15 -4
  59. package/dist/db/queries/event-store.js +11 -10
  60. package/dist/db/queries/pii-backfill-state.js +3 -2
  61. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  62. package/dist/db/queries/postgres-array-literal.js +6 -0
  63. package/dist/db/queries/projection-rebuild.js +3 -2
  64. package/dist/db/queries/shadow-swap.d.ts +1 -1
  65. package/dist/db/queries/shadow-swap.js +12 -8
  66. package/dist/db/query.d.ts +1 -1
  67. package/dist/db/query.js +1 -1
  68. package/dist/db/retired-framework-tables.js +22 -4
  69. package/dist/db/table-builder.js +6 -2
  70. package/dist/db/tenant-db.d.ts +2 -0
  71. package/dist/db/tenant-db.js +20 -1
  72. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  73. package/dist/derivatives/delete-stored-file.js +9 -0
  74. package/dist/derivatives/index.d.ts +1 -1
  75. package/dist/derivatives/index.js +1 -1
  76. package/dist/engine/boot-validator/entity-handler.js +3 -0
  77. package/dist/engine/boot-validator/query-refs.js +14 -2
  78. package/dist/engine/dedupe-features.js +31 -4
  79. package/dist/engine/entity-handlers.js +14 -7
  80. package/dist/engine/index.d.ts +1 -1
  81. package/dist/engine/index.js +1 -1
  82. package/dist/engine/screen-helpers.d.ts +1 -0
  83. package/dist/engine/screen-helpers.js +2 -2
  84. package/dist/engine/steps/_read-source.js +5 -2
  85. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  86. package/dist/engine/tier-resolver-extension.js +6 -0
  87. package/dist/entrypoint/index.d.ts +2 -2
  88. package/dist/errors/docs-url.js +6 -6
  89. package/dist/errors/index.d.ts +1 -1
  90. package/dist/errors/index.js +1 -1
  91. package/dist/errors/subject-erased.d.ts +1 -0
  92. package/dist/errors/subject-erased.js +6 -0
  93. package/dist/event-store/admin-api.js +3 -2
  94. package/dist/event-store/errors.d.ts +9 -0
  95. package/dist/event-store/errors.js +20 -0
  96. package/dist/event-store/event-store.d.ts +6 -0
  97. package/dist/event-store/event-store.js +28 -6
  98. package/dist/event-store/index.d.ts +1 -1
  99. package/dist/event-store/index.js +1 -1
  100. package/dist/files/create-file-ref.d.ts +2 -1
  101. package/dist/files/create-file-ref.js +16 -2
  102. package/dist/files/dispose-removed-file-refs.js +21 -6
  103. package/dist/files/feature.d.ts +1 -0
  104. package/dist/files/feature.js +2 -1
  105. package/dist/files/file-handle.js +4 -0
  106. package/dist/files/file-routes.js +9 -5
  107. package/dist/files/index.d.ts +1 -1
  108. package/dist/files/index.js +1 -1
  109. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  110. package/dist/files/resolve-content-type.js +40 -4
  111. package/dist/files/storage-tracking.js +5 -4
  112. package/dist/files/types.js +5 -8
  113. package/dist/files/zip-entry-names.d.ts +1 -0
  114. package/dist/files/zip-entry-names.js +46 -0
  115. package/dist/jobs/job-runner.d.ts +1 -0
  116. package/dist/jobs/job-runner.js +116 -33
  117. package/dist/observability/escape-hatch-report.d.ts +1 -0
  118. package/dist/observability/escape-hatch-report.js +31 -2
  119. package/dist/observability/index.d.ts +1 -1
  120. package/dist/observability/index.js +1 -1
  121. package/dist/observability/metrics-handle.d.ts +1 -0
  122. package/dist/observability/metrics-handle.js +7 -0
  123. package/dist/observability/standard-metrics.js +4 -4
  124. package/dist/pipeline/dispatch-query.d.ts +2 -1
  125. package/dist/pipeline/dispatch-query.js +6 -6
  126. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  127. package/dist/pipeline/dispatch-shared.js +41 -30
  128. package/dist/pipeline/dispatch-stream.js +10 -4
  129. package/dist/pipeline/dispatcher.js +3 -1
  130. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  131. package/dist/pipeline/event-dispatcher.js +11 -9
  132. package/dist/pipeline/event-retention.d.ts +1 -0
  133. package/dist/pipeline/event-retention.js +7 -1
  134. package/dist/pipeline/notify-scope.js +3 -1
  135. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  136. package/dist/schema-cli.js +12 -1
  137. package/dist/stack/db.d.ts +2 -2
  138. package/dist/stack/test-stack.js +2 -1
  139. package/dist/testing/index.d.ts +1 -0
  140. package/dist/testing/index.js +1 -0
  141. package/dist/testing/multipart-helper.d.ts +6 -0
  142. package/dist/testing/multipart-helper.js +1 -1
  143. package/dist/testing/zip-fixture.d.ts +1 -0
  144. package/dist/testing/zip-fixture.js +33 -0
  145. package/dist/ui-types/card-column-roles.js +4 -2
  146. package/dist/ui-types/index.d.ts +1 -1
  147. package/dist/ui-types/index.js +1 -1
  148. package/dist/upgrade-cli.js +46 -13
  149. package/package.json +4 -4
  150. package/src/changes.json +305 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  # pdf-audit-export
4
4
 
5
- Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes.
5
+ Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and uploads the bytes before the transaction (uploadFileBytesBeforeTransaction) and stores them as a FileRef (createFileRefFromUploadedBytes).
6
6
 
7
7
  ## README
8
8
 
@@ -13,7 +13,8 @@ A write handler exports tenant data as a PDF and keeps it as a FileRef.
13
13
  ## What it shows
14
14
 
15
15
  - `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
16
- - `createFileRefFromBytes` (`@cosmicdrift/kumiko-framework/files`) stores the bytes like an upload (validation, tenant-prefixed storage key, `fileRef.created` event); the tenant comes from the caller.
16
+ - The export is a `beforeTransaction` handler: it reads the rows (reads work in that phase), renders the PDF and calls `uploadFileBytesBeforeTransaction` (`@cosmicdrift/kumiko-framework/files`), which validates the bytes and writes them to the tenant-prefixed storage key. The upload runs before the transaction because storage I/O inside it would hold a pool connection for as long as the upload takes.
17
+ - The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; nothing sweeps them.
17
18
  - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
18
19
 
19
20
  ## Source
@@ -147,6 +148,21 @@ describe("export-audit", () => {
147
148
  const { text } = await extractText(proxy, { mergePages: true });
148
149
  expect(text).toContain("Keine Prüfungen im Zeitraum");
149
150
  });
151
+
152
+ test("a period with more inspections than the export holds is rejected instead of cut off", async () => {
153
+ const assetName = "Großanlage";
154
+ for (let day = 0; day < 101; day += 1) {
155
+ const inspectedOn = new Date(Date.UTC(2025, 0, 1 + day)).toISOString().slice(0, 10);
156
+ await addInspection(inspectedOn, "ok", `Prüfer-${day}`, assetName);
157
+ }
158
+
159
+ const err = await stack.http.writeErr(
160
+ "pdf-audit-export:write:export-audit",
161
+ { assetName, from: "2025-01-01", to: "2025-12-31" },
162
+ userA,
163
+ );
164
+ expect(err.httpStatus).toBe(422);
165
+ });
150
166
  });
151
167
  ```
152
168
 
@@ -156,9 +172,10 @@ describe("export-audit", () => {
156
172
  // PDF Audit Export Sample
157
173
  //
158
174
  // A write handler turns tenant data into a PDF and keeps it as a regular
159
- // FileRef: renderTablePdf draws the table (pure JS, no browser), then
160
- // createFileRefFromBytes stores the bytes through the same validation,
161
- // storage layout and fileRef.created event as an upload.
175
+ // FileRef: renderTablePdf draws the table (pure JS, no browser). The storage
176
+ // write runs in the beforeTransaction phase, so the transaction holds no pool
177
+ // connection while the bytes upload; the handler then only creates the
178
+ // fileRef row from event.prepared.
162
179
 
163
180
  import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
164
181
  import {
@@ -168,8 +185,13 @@ import {
168
185
  createSelectField,
169
186
  createTextField,
170
187
  defineFeature,
188
+ defineWriteHandler,
171
189
  } from "@cosmicdrift/kumiko-framework/engine";
172
- import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
190
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
191
+ import {
192
+ createFileRefFromUploadedBytes,
193
+ uploadFileBytesBeforeTransaction,
194
+ } from "@cosmicdrift/kumiko-framework/files";
173
195
  import * as z from "zod";
174
196
 
175
197
  export const INSPECTION_RESULTS = ["ok", "defect"] as const;
@@ -199,7 +221,8 @@ function toFileNameSegment(text: string): string {
199
221
  return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");
200
222
  }
201
223
 
202
- const AUDIT_ROW_LIMIT = 200;
224
+ // One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
225
+ const AUDIT_ROW_LIMIT = 100;
203
226
 
204
227
  const access = { access: { roles: ["Admin", "User"] } } as const;
205
228
 
@@ -220,51 +243,64 @@ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
220
243
  );
221
244
 
222
245
  r.writeHandler(
223
- "export-audit",
224
- z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
225
- async (event, ctx) => {
226
- const { assetName, from, to } = event.payload;
227
- const inspections = await inspectionExecutor.list(
228
- {
229
- filters: [
230
- { field: "assetName", op: "eq", value: assetName },
231
- { field: "inspectedOn", op: "gte", value: from },
232
- { field: "inspectedOn", op: "lte", value: to },
246
+ defineWriteHandler({
247
+ name: "export-audit",
248
+ schema: z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
249
+ access: access.access,
250
+ beforeTransaction: async (event, ctx) => {
251
+ const { assetName, from, to } = event.payload;
252
+ const inspections = await inspectionExecutor.list(
253
+ {
254
+ filters: [
255
+ { field: "assetName", op: "eq", value: assetName },
256
+ { field: "inspectedOn", op: "gte", value: from },
257
+ { field: "inspectedOn", op: "lte", value: to },
258
+ ],
259
+ sort: "inspectedOn",
260
+ sortDirection: "asc",
261
+ limit: AUDIT_ROW_LIMIT + 1,
262
+ },
263
+ event.user,
264
+ ctx.db,
265
+ );
266
+ // A protocol that silently drops inspections would look complete.
267
+ if (inspections.rows.length > AUDIT_ROW_LIMIT) {
268
+ return writeFailure(
269
+ new UnprocessableError("audit_period_too_large", {
270
+ details: { limit: AUDIT_ROW_LIMIT },
271
+ }),
272
+ );
273
+ }
274
+
275
+ const { pdfBytes } = await renderTablePdf({
276
+ title: `Prüfprotokoll ${assetName}`,
277
+ meta: [
278
+ { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
279
+ { label: "Prüfungen", value: String(inspections.rows.length) },
233
280
  ],
234
- sort: "inspectedOn",
235
- sortDirection: "asc",
236
- limit: AUDIT_ROW_LIMIT,
237
- },
238
- event.user,
239
- ctx.db,
240
- );
241
-
242
- const { pdfBytes } = await renderTablePdf({
243
- title: `Prüfprotokoll ${assetName}`,
244
- meta: [
245
- { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
246
- { label: "Prüfungen", value: String(inspections.rows.length) },
247
- ],
248
- columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
249
- rows: inspections.rows.map((row) => [
250
- formatGermanDate(String(row["inspectedOn"])),
251
- RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
252
- String(row["inspector"] ?? ""),
253
- ]),
254
- emptyText: "Keine Prüfungen im Zeitraum",
255
- footerText: `Prüfbericht ${assetName}`,
256
- pageLabel: "Seite {page} von {pages}",
257
- });
258
-
259
- const created = await createFileRefFromBytes(ctx, {
260
- fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
261
- mimeType: "application/pdf",
262
- data: pdfBytes,
263
- });
264
- if (!created.isSuccess) return created;
265
- return { isSuccess: true as const, data: { fileRefId: created.data.id } };
266
- },
267
- access,
281
+ columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
282
+ rows: inspections.rows.map((row) => [
283
+ formatGermanDate(String(row["inspectedOn"])),
284
+ RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
285
+ String(row["inspector"] ?? ""),
286
+ ]),
287
+ emptyText: "Keine Prüfungen im Zeitraum",
288
+ footerText: `Prüfbericht ${assetName}`,
289
+ pageLabel: "Seite {page} von {pages}",
290
+ });
291
+
292
+ return uploadFileBytesBeforeTransaction(ctx, {
293
+ fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
294
+ mimeType: "application/pdf",
295
+ data: pdfBytes,
296
+ });
297
+ },
298
+ handler: async (event, ctx) => {
299
+ const created = await createFileRefFromUploadedBytes(ctx, event.prepared);
300
+ if (!created.isSuccess) return created;
301
+ return { isSuccess: true as const, data: { fileRefId: created.data.id } };
302
+ },
303
+ }),
268
304
  );
269
305
  });
270
306
  ```
@@ -67,7 +67,7 @@ const server = buildServer({
67
67
  // real HTTP + DB + Redis. If a reader copies the buildServer({...}) block
68
68
  // verbatim, the resulting app behaves like this test asserts.
69
69
 
70
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
70
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
71
71
  import {
72
72
  AuthHandlers,
73
73
  createAuthEmailPasswordFeature,
@@ -81,7 +81,6 @@ import {
81
81
  import {
82
82
  type SessionCallbacks,
83
83
  userSessionEntity,
84
- userSessionTable,
85
84
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
86
85
  import {
87
86
  sessionCallbacksFromLateBound,
@@ -97,9 +96,7 @@ import {
97
96
  createUserFeature,
98
97
  UserHandlers,
99
98
  userEntity,
100
- userTable,
101
99
  } from "@cosmicdrift/kumiko-bundled-features/user";
102
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
103
100
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
104
101
  import {
105
102
  setupTestStack,
@@ -147,12 +144,6 @@ afterAll(async () => {
147
144
  await stack.cleanup();
148
145
  });
149
146
 
150
- beforeEach(async () => {
151
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
152
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
153
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
154
- });
155
-
156
147
  describe("session-revocation sample wiring", () => {
157
148
  test("end-to-end: login, use, logout, reject", async () => {
158
149
  const hash = await hashPassword("super-long-password");
@@ -130,18 +130,20 @@ import {
130
130
  tagAssignmentEntity,
131
131
  tagEntity,
132
132
  } from "@cosmicdrift/kumiko-bundled-features/tags";
133
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
134
133
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
135
134
  import {
136
135
  createTestUser,
137
136
  setupTestStack,
138
137
  type TestStack,
138
+ testTenantId,
139
139
  unsafeCreateEntityTable,
140
140
  } from "@cosmicdrift/kumiko-framework/stack";
141
141
  import { noteEntity, noteFeature } from "../feature";
142
142
  import { type TagClient, tagFlow } from "../usage";
143
143
 
144
- const admin = createTestUser({ roles: ["TenantAdmin"] });
144
+ // Each test runs in its own tenant, so rows from other tests never show up.
145
+ let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
146
+ let nextTenant = 1;
145
147
 
146
148
  // Adapt the test stack to the host-facing TagClient the docs embed uses.
147
149
  const tagClient: TagClient = {
@@ -164,11 +166,9 @@ afterAll(async () => {
164
166
  await stack.cleanup();
165
167
  });
166
168
 
167
- beforeEach(async () => {
168
- await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
169
- await asRawClient(stack.db).unsafe("DELETE FROM read_sample_tags_notes");
170
- await asRawClient(stack.db).unsafe("DELETE FROM read_tags");
171
- await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
169
+ beforeEach(() => {
170
+ nextTenant += 1;
171
+ admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
172
172
  });
173
173
 
174
174
  async function createNote(id: string, title: string) {
@@ -144,7 +144,7 @@ import {
144
144
  runForgetCleanup,
145
145
  runUserExport,
146
146
  } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
147
- import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
147
+ import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
148
148
  import { EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
149
149
  import {
150
150
  setupTestStack,
@@ -157,9 +157,13 @@ import { noteEntity, notesFeature, notesTable } from "../feature";
157
157
 
158
158
  let stack: TestStack;
159
159
 
160
- const TENANT_A = "00000000-0000-4000-8000-00000000000a";
161
160
  const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
162
- const ALICE_ID = "11111111-1111-4111-8111-000000000001";
161
+
162
+ // Each test gets its own tenant and user, so rows from other tests never show up.
163
+ let testNumber = 0;
164
+ let TENANT_A = "";
165
+ let ALICE_ID = "";
166
+ let ALICE_EMAIL = "";
163
167
 
164
168
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
165
169
  const NOW = (): Instant => getTemporal().Now.instant();
@@ -204,17 +208,23 @@ afterAll(async () => {
204
208
  await stack.cleanup();
205
209
  });
206
210
 
207
- beforeEach(async () => {
208
- await asRawClient(stack.db).unsafe(`DELETE FROM store_notes`);
209
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
210
- await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
211
+ beforeEach(() => {
212
+ testNumber += 1;
213
+ const suffix = String(testNumber).padStart(12, "0");
214
+ TENANT_A = `00000000-0000-4000-8000-${suffix}`;
215
+ ALICE_ID = `11111111-1111-4111-8111-${suffix}`;
216
+ ALICE_EMAIL = `alice-${testNumber}@recipe.test`;
211
217
  });
212
218
 
219
+ function noteId(index: number): string {
220
+ return `aaaaaaaa-aaaa-4aaa-8aaa-${String(testNumber * 10 + index).padStart(12, "0")}`;
221
+ }
222
+
213
223
  async function seedAlice(opts: { gracePeriodEnd?: Instant; status?: string } = {}): Promise<void> {
214
224
  await seedRow(stack.db, userTable, {
215
225
  id: ALICE_ID,
216
226
  tenantId: TENANT_SYSTEM,
217
- email: "alice@recipe.test",
227
+ email: ALICE_EMAIL,
218
228
  passwordHash: "h",
219
229
  displayName: "Alice",
220
230
  locale: "de",
@@ -246,8 +256,8 @@ async function seedNote(id: string, title: string): Promise<void> {
246
256
  describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domain", () => {
247
257
  test("Export-Bundle enthaelt Note-Snippet via Hook", async () => {
248
258
  await seedAlice();
249
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000001", "first");
250
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000002", "second");
259
+ await seedNote(noteId(1), "first");
260
+ await seedNote(noteId(2), "second");
251
261
 
252
262
  const bundle = await runUserExport({
253
263
  db: stack.db,
@@ -267,7 +277,7 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
267
277
 
268
278
  test("Forget-Cron strategy=delete entfernt Notes (Default-Pfad)", async () => {
269
279
  await seedAlice({ status: USER_STATUS.DeletionRequested, gracePeriodEnd: PAST() });
270
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000003", "to-delete");
280
+ await seedNote(noteId(1), "to-delete");
271
281
 
272
282
  const result = await runForgetCleanup({
273
283
  db: stack.db,
@@ -276,13 +286,13 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
276
286
  });
277
287
  expect(result.processedUserIds).toContain(ALICE_ID);
278
288
 
279
- const remaining = await asRawClient(stack.db).unsafe(`SELECT id FROM store_notes`);
289
+ const remaining = await selectMany(stack.db, notesTable, { tenantId: TENANT_A });
280
290
  expect(remaining.length).toBe(0);
281
291
  });
282
292
 
283
293
  test("Strategy=anonymize: authorId=null, Row bleibt (HR-Compliance-Pfad)", async () => {
284
294
  await seedAlice();
285
- await seedNote("aaaaaaaa-aaaa-4aaa-8aaa-000000000004", "stays-anonymous");
295
+ await seedNote(noteId(1), "stays-anonymous");
286
296
 
287
297
  const usage = stack.registry
288
298
  .getExtensionUsages(EXT_USER_DATA)
@@ -297,11 +307,9 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
297
307
  | undefined;
298
308
  await hooks?.delete?.({ db: stack.db, tenantId: TENANT_A, userId: ALICE_ID }, "anonymize");
299
309
 
300
- const after = (await asRawClient(stack.db).unsafe(
301
- `SELECT title, author_id FROM store_notes`,
302
- )) as Array<{ title: string; author_id: string | null }>;
310
+ const after = await selectMany(stack.db, notesTable, { tenantId: TENANT_A });
303
311
  expect(after).toHaveLength(1);
304
- expect(after[0]?.author_id).toBeNull();
312
+ expect(after[0]?.authorId).toBeNull();
305
313
  expect(after[0]?.title).toBe("stays-anonymous");
306
314
  });
307
315
  });
@@ -983,7 +983,7 @@ export function startCronScheduler(
983
983
 
984
984
  ```ts
985
985
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
986
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
986
+ import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
987
987
 
988
988
  export async function selectExpiredSuspensionEvents(
989
989
  db: DbRunner,
@@ -993,7 +993,7 @@ export async function selectExpiredSuspensionEvents(
993
993
  `SELECT * FROM "kumiko_events"
994
994
  WHERE "type" = ANY($1::text[])
995
995
  AND (("payload"->>'wakeAt')::timestamptz < now() OR ("payload"->>'timeoutAt')::timestamptz < now())`,
996
- [eventTypes as unknown as string[]],
996
+ [toPostgresArrayLiteral(eventTypes)],
997
997
  )) as Record<string, unknown>[];
998
998
  }
999
999
  ```
@@ -23,6 +23,7 @@
23
23
  | `currencies-per-tenant` | Tenant-scoped money fields. Commas matter. | `examples/currencies-per-tenant.md` |
24
24
  | `custom-fields-basic` | Tenant-defined extra fields on an entity without writing migrations. | `examples/custom-fields-basic.md` |
25
25
  | `custom-handlers` | Beyond CRUD — when your domain refuses to fit a table. | `examples/custom-handlers.md` |
26
+ | `dashboard-stats` | Key figures on a dashboard: stat panels backed by one counting query. | `examples/dashboard-stats.md` |
26
27
  | `delivery-notifications` | Delivery flows plus notifications. Package tracking, minus the tracking. | `examples/delivery-notifications.md` |
27
28
  | `designer-demo` | Kumiko sample feature — scaffolded by `bun kumiko create` | `examples/designer-demo.md` |
28
29
  | `e2e-generator` | generateE2ESpec + withBootValidatorFixture — Registry → JSON-Specs für externen Playwright-Worker. Zeigt die 4 Test-Kinds, Feldtyp→Interaktion-Mapping und welche Feldtypen heute noch nicht generisch unterstützt sind. | `examples/e2e-generator.md` |
@@ -46,7 +47,7 @@
46
47
  | `managed-pages` | Tenant-editable, server-rendered public pages with per-tenant branding + tier-gated sanitized custom CSS — managed-pages wired up with config + anonymousAccess. | `examples/managed-pages.md` |
47
48
  | `notes-history-basic` | Append-only note history — who wrote what, when — attached to any entity without changing it. | `examples/notes-history-basic.md` |
48
49
  | `ownership` | Entity- and Field-level Row-Ownership: from() helper, Straddle-safe multi-role checks, Field-level Read/Write filtering. | `examples/ownership.md` |
49
- | `pdf-audit-export` | Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes. | `examples/pdf-audit-export.md` |
50
+ | `pdf-audit-export` | Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and uploads the bytes before the transaction (uploadFileBytesBeforeTransaction) and stores them as a FileRef (createFileRefFromUploadedBytes). | `examples/pdf-audit-export.md` |
50
51
  | `pipeline-basics` | M.1 Tier-1 step-vocabulary showcase: every step in one inventory-management feature (aggregate.{create,update,appendEvent}, read.{findOne,findMany}, branch, forEach, compute, return, unsafeProjection{Upsert,Delete}). | `examples/pipeline-basics.md` |
51
52
  | `public-share-token` | Generic share-token entity: mint hash-stored token, anonymous read-by-token with IP rate-limit. | `examples/public-share-token.md` |
52
53
  | `rate-limiting` | End-to-end rate limiting: L1 global-IP + L2 auth-endpoints + L3 handler opt-in. | `examples/rate-limiting.md` |
@@ -538,7 +538,7 @@ Requires: `template-resolver`
538
538
 
539
539
  ## renderer-mail-html
540
540
 
541
- A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, in-code `DEFAULT_MAIL_LAYOUT_MJML` as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
541
+ A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
542
542
 
543
543
  Requires: `renderer-foundation`, `template-resolver`
544
544
 
@@ -552,7 +552,7 @@ Requires: `renderer-foundation`
552
552
 
553
553
  ## renderer-puppeteer-client
554
554
 
555
- A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
555
+ A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
556
556
 
557
557
  Requires: `renderer-foundation`
558
558
 
@@ -755,7 +755,7 @@ Requires: `user`, `auth-email-password`, `user-data-rights`, `user-data-rights-d
755
755
 
756
756
  ## waitlist
757
757
 
758
- Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response never reveals whether an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.
758
+ Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.
759
759
 
760
760
  Category: data
761
761
 
@@ -6,7 +6,6 @@ import { type AuthSessionChecker, type AuthSessionStatus, type TokenVerifier } f
6
6
  import { type ClientIpResolver } from "./client-ip.js";
7
7
  import type { JwtHelper } from "./jwt.js";
8
8
  import type { PostAuthLandingResolver } from "./post-auth-landing.js";
9
- export declare function assertRetiredCookieDomains(retiredCookieDomains: readonly string[] | undefined, cookieDomain: string | undefined): void;
10
9
  export type LoginRateLimiter = {
11
10
  check(key: string): Promise<boolean>;
12
11
  reset(key: string): Promise<void>;
@@ -61,7 +61,7 @@ function normalizeCookieDomain(domain) {
61
61
  }
62
62
  // A malformed entry would corrupt the Set-Cookie header; an entry equal to the
63
63
  // current cookieDomain would make login delete the cookie it just sets.
64
- export function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
64
+ function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
65
65
  const current = cookieDomain === undefined ? undefined : normalizeCookieDomain(cookieDomain);
66
66
  for (const entry of retiredCookieDomains ?? []) {
67
67
  if (entry === "" || normalizeCookieDomain(entry) === "") {
@@ -322,6 +322,10 @@ export function createAuthRoutes(dispatcher, jwt, config) {
322
322
  retiredCookieDomains: config.retiredCookieDomains,
323
323
  });
324
324
  assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
325
+ if (config.mfaVerifyHandler && !config.switchTenantMfaGateHandler) {
326
+ throw new Error("createAuthRoutes: mfaVerifyHandler is set but switchTenantMfaGateHandler is not; " +
327
+ "POST /auth/switch-tenant would mint admin sessions without the second factor");
328
+ }
325
329
  const retiredCookieDomains = config.retiredCookieDomains ?? [];
326
330
  // Single hop-count-aware IP getter for every rate-limit call site below —
327
331
  // see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
@@ -330,19 +334,45 @@ export function createAuthRoutes(dispatcher, jwt, config) {
330
334
  // shared "unknown" bucket.
331
335
  const clientIpResolver = config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
332
336
  const getClientIp = (c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
333
- async function switchTenantMfaGateResponse(c, user, targetTenantId) {
337
+ // undefined = no gate wired (setup without MFA). "clearedRoles" are the roles the gate decided on,
338
+ // so the minted session cannot differ from what the gate checked.
339
+ async function switchTenantMfaGate(c, user, targetTenantId) {
334
340
  if (!config.switchTenantMfaGateHandler)
335
341
  return undefined;
336
342
  const gate = await dispatcher.write(config.switchTenantMfaGateHandler, { userId: user.id, tenantId: targetTenantId }, createSystemUser(user.tenantId));
337
343
  if (!gate.isSuccess) {
338
344
  const status = gate.error.httpStatus; // @cast-boundary engine-payload
339
- return c.json({ isSuccess: false, error: gate.error }, status);
345
+ return { response: c.json({ isSuccess: false, error: gate.error }, status) };
340
346
  }
341
347
  // @cast-boundary engine-payload — the gate handler's three-shape result
342
348
  const gateData = gate.data;
343
349
  if (gateData.kind === "mfa-gate-clear")
344
- return undefined;
345
- return pendingMfaStepResponse(c, gateData);
350
+ return { clearedRoles: gateData.roles };
351
+ return { response: pendingMfaStepResponse(c, gateData) };
352
+ }
353
+ // Global roles (SystemAdmin etc.) come from the user feature when userQuery is wired, so they survive
354
+ // the tenant switch. buildSessionRoles calls stripForbiddenMembershipRoles on the membership side only — a backstop
355
+ // for a role a projection rebuild resurrected past command-time validation (engine/membership-roles).
356
+ async function resolveSwitchTenantRoles(user, membership) {
357
+ let globalRoles = [];
358
+ if (config.userQuery) {
359
+ try {
360
+ // @cast-boundary engine-payload — dispatcher.query returnt unknown,
361
+ // Per the AuthUserRow contract, userQuery returns a row with a roles column.
362
+ const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
363
+ // roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
364
+ // JSON-encoded text from pre-0.217.0 rows.
365
+ globalRoles = parseRoles(userRow?.roles ?? null);
366
+ }
367
+ catch (e) {
368
+ // Non-fatal: when global roles cannot be resolved the switch continues with tenant roles
369
+ // only. A server error carrying just the cause would hurt the UX more than a switch without
370
+ // SystemAdmin, which the user notices and reports.
371
+ if (!isUnknownHandlerError(e))
372
+ throw e;
373
+ }
374
+ }
375
+ return buildSessionRoles(globalRoles, membership.roles);
346
376
  }
347
377
  // Shared tail of every route that ends a request logged-in: create the
348
378
  // session record (if wired), sign the JWT, set the auth+csrf cookies. Was
@@ -711,13 +741,13 @@ export function createAuthRoutes(dispatcher, jwt, config) {
711
741
  }
712
742
  // Session creation + JWT sign + cookies — see mintSessionAndRespond.
713
743
  const token = await mintSessionAndRespond(c, data.session);
714
- const landingPath = landingPathFragment({
715
- flow: "signup",
744
+ const signupOutcome = {
716
745
  roles: data.session.roles,
717
746
  tenantId: data.session.tenantId,
718
747
  tenantKey: data.tenantKey,
719
748
  ...(data.handover !== undefined && { handover: data.handover }),
720
- });
749
+ };
750
+ const landingPath = landingPathFragment({ flow: "signup", ...signupOutcome });
721
751
  return c.json({
722
752
  isSuccess: true,
723
753
  token,
@@ -962,49 +992,20 @@ export function createAuthRoutes(dispatcher, jwt, config) {
962
992
  return switchTenantRejectionResponse(c, activeMembership.reason);
963
993
  }
964
994
  const membership = activeMembership.membership;
965
- // Globale Rollen aus user-feature lesen wenn userQuery wired —
966
- // tenant-unabhängige Rollen (SystemAdmin etc.) überleben so den
967
- // tenant-switch. `parseRoles` liegt utils-side, hier inline-deserialize
968
- // damit das Framework keine bundled-features-Imports kriegt.
969
- let globalRoles = [];
970
- if (config.userQuery) {
971
- try {
972
- // @cast-boundary engine-payload — dispatcher.query returnt unknown,
973
- // userQuery liefert per AuthUserRow-Contract eine row mit roles-spalte.
974
- const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
975
- // roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
976
- // JSON-encoded text from pre-0.217.0 rows.
977
- globalRoles = parseRoles(userRow?.roles ?? null);
978
- }
979
- catch (e) {
980
- // Non-fatal: globale Rollen kann nicht aufgelöst werden → switch
981
- // läuft weiter mit nur tenant-rollen. Server-error mit nur dem
982
- // Cause ohne Stack hochwerfen wäre für die UX schlimmer als ein
983
- // Tenant-Switch ohne SystemAdmin (User merkt's und meldet's). Log
984
- // it via the dispatcher so Ops sieht's.
985
- if (!isUnknownHandlerError(e))
986
- throw e;
987
- }
988
- }
995
+ const gate = await switchTenantMfaGate(c, user, targetTenantId);
996
+ if (gate !== undefined && "response" in gate)
997
+ return gate.response;
998
+ const sessionRoles = gate !== undefined ? gate.clearedRoles : await resolveSwitchTenantRoles(user, membership);
989
999
  // Issue new JWT with the target tenant and its roles. Claims MUST be
990
1000
  // recomputed for the new tenant — stale claims from the previous
991
1001
  // tenant would leak identity facts across tenancies (e.g. teamId from
992
1002
  // tenant A accidentally surviving into tenant B's session). The
993
1003
  // resolver runs each feature's r.authClaims() hook under the new
994
1004
  // TenantDb scope.
995
- // buildSessionRoles calls stripForbiddenMembershipRoles internally and
996
- // strips reserved roles from the membership side only — globalRoles
997
- // (where SystemAdmin legitimately lives) is never filtered. Backstop for a
998
- // membership role that a projection rebuild resurrected past command-time
999
- // validation (see engine/membership-roles).
1000
- const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
1001
- const pendingMfaStep = await switchTenantMfaGateResponse(c, user, targetTenantId);
1002
- if (pendingMfaStep !== undefined)
1003
- return pendingMfaStep;
1004
1005
  const targetSession = {
1005
1006
  id: user.id,
1006
1007
  tenantId: targetTenantId,
1007
- roles: mergedRoles,
1008
+ roles: sessionRoles,
1008
1009
  // Tenant-independent prefs must survive the switch (fw#2343).
1009
1010
  ...tenantIndependentPrefs(user),
1010
1011
  };
@@ -1023,7 +1024,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
1023
1024
  // fresh csrfToken is minted (inside the helper) so a compromised csrf-
1024
1025
  // value can't cross a tenant boundary.
1025
1026
  const newToken = await mintSessionAndRespond(c, sessionForJwt);
1026
- return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
1027
+ return c.json({ token: newToken, tenantId: targetTenantId, roles: sessionRoles });
1027
1028
  });
1028
1029
  return api;
1029
1030
  }
@@ -26,7 +26,7 @@ export type { MetricsRouteOptions } from "./route-registrars.js";
26
26
  export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
27
27
  export { createApiRoutes } from "./routes.js";
28
28
  export type { KumikoServer, ServerOptions } from "./server.js";
29
- export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server.js";
29
+ export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.js";
30
30
  export type { ResolvedSessionCookies, SessionCookieBase, SessionCookieNames, } from "./session-cookies.js";
31
31
  export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
32
32
  export type { SseBroker, SseClient, SseEvent } from "./sse-broker.js";
package/dist/api/index.js CHANGED
@@ -15,7 +15,7 @@ export { readonlyRequestContext as requestContext, } from "./request-context.js"
15
15
  export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
16
16
  export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
17
17
  export { createApiRoutes } from "./routes.js";
18
- export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server.js";
18
+ export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.js";
19
19
  export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
20
20
  export { createSseBroker } from "./sse-broker.js";
21
21
  export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";