@cosmicdrift/kumiko-framework 0.388.0 → 0.389.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 (194) hide show
  1. package/AGENTS.md +3 -0
  2. package/agents/examples/golden-path.md +13 -1
  3. package/agents/examples/lifecycle-hooks.md +55 -2
  4. package/agents/examples/list-table-options.md +466 -0
  5. package/agents/examples/pdf-audit-export.md +4 -2
  6. package/agents/examples/state-machine.md +3 -2
  7. package/agents/examples/tenant-isolation.md +73 -1
  8. package/agents/examples.md +1 -0
  9. package/agents/widgets.md +1 -0
  10. package/dist/api/api-constants.d.ts +1 -0
  11. package/dist/api/api-constants.js +2 -0
  12. package/dist/api/auth-routes-shared.d.ts +2 -0
  13. package/dist/api/auth-routes-shared.js +5 -0
  14. package/dist/api/client-error-route.js +2 -3
  15. package/dist/api/entity-read-access.d.ts +2 -1
  16. package/dist/api/entity-read-access.js +14 -0
  17. package/dist/api/export/export-cells-reference.d.ts +9 -0
  18. package/dist/api/export/export-cells-reference.js +21 -0
  19. package/dist/api/export/export-cells.d.ts +11 -0
  20. package/dist/api/export/export-cells.js +136 -0
  21. package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
  22. package/dist/api/export/export-ciphertext-guard.js +27 -0
  23. package/dist/api/export/export-columns.d.ts +29 -0
  24. package/dist/api/export/export-columns.js +85 -0
  25. package/dist/api/export/export-errors.d.ts +4 -0
  26. package/dist/api/export/export-errors.js +14 -0
  27. package/dist/api/export/export-route.d.ts +4 -0
  28. package/dist/api/export/export-route.js +74 -0
  29. package/dist/api/export/export-rows.d.ts +13 -0
  30. package/dist/api/export/export-rows.js +62 -0
  31. package/dist/api/export/export-screen.d.ts +12 -0
  32. package/dist/api/export/export-screen.js +47 -0
  33. package/dist/api/export/export-xlsx-writer.d.ts +4 -0
  34. package/dist/api/export/export-xlsx-writer.js +21 -0
  35. package/dist/api/extra-route.d.ts +4 -0
  36. package/dist/api/pii-leak-guard.d.ts +3 -0
  37. package/dist/api/pii-leak-guard.js +3 -2
  38. package/dist/api/routes.d.ts +16 -0
  39. package/dist/api/routes.js +2 -2
  40. package/dist/api/server-extra-routes.js +3 -0
  41. package/dist/api/server-routes.d.ts +1 -1
  42. package/dist/api/server-routes.js +8 -2
  43. package/dist/api/server-wiring.js +2 -0
  44. package/dist/api/sse-route.d.ts +2 -1
  45. package/dist/api/sse-route.js +11 -1
  46. package/dist/config/index.d.ts +3 -1
  47. package/dist/crypto/pg-kms-adapter.d.ts +1 -0
  48. package/dist/crypto/pg-kms-adapter.js +12 -18
  49. package/dist/db/aggregate-sql.d.ts +2 -0
  50. package/dist/db/aggregate-sql.js +10 -0
  51. package/dist/db/closed-connection.js +12 -19
  52. package/dist/db/connection.d.ts +4 -5
  53. package/dist/db/connection.js +29 -20
  54. package/dist/db/driver-row-coercion.js +4 -4
  55. package/dist/db/driver-value-preparation.js +6 -4
  56. package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
  57. package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
  58. package/dist/db/event-store-executor-read-aggregate.js +36 -5
  59. package/dist/db/event-store-executor-read-filter.js +6 -3
  60. package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
  61. package/dist/db/event-store-executor-write-atomic.js +41 -0
  62. package/dist/db/event-store-executor-write-steps.d.ts +0 -3
  63. package/dist/db/event-store-executor-write-steps.js +0 -5
  64. package/dist/db/event-store-executor-write.js +15 -15
  65. package/dist/db/event-store-executor.js +2 -1
  66. package/dist/db/executor-managed-tables.js +1 -1
  67. package/dist/db/index.d.ts +1 -1
  68. package/dist/db/index.js +1 -1
  69. package/dist/db/migrate-runner.js +3 -3
  70. package/dist/db/pg-error.js +26 -21
  71. package/dist/db/queries/backfill-pii.js +3 -6
  72. package/dist/db/queries/database-now.js +2 -1
  73. package/dist/db/queries/shadow-swap.js +2 -3
  74. package/dist/db/query.d.ts +14 -0
  75. package/dist/db/query.js +68 -43
  76. package/dist/db/reference-data.js +14 -2
  77. package/dist/db/statement-timeout.js +2 -1
  78. package/dist/db/tenant-db-upsert.d.ts +5 -0
  79. package/dist/db/tenant-db-upsert.js +91 -0
  80. package/dist/db/tenant-db.js +11 -1
  81. package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
  82. package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
  83. package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
  84. package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
  85. package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
  86. package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
  87. package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
  88. package/dist/engine/boot-validator/list-column-layout.js +41 -0
  89. package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
  90. package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
  91. package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
  92. package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
  93. package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
  94. package/dist/engine/boot-validator/list-table-export-options.js +27 -0
  95. package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
  96. package/dist/engine/boot-validator/list-table-options.js +77 -0
  97. package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
  98. package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
  99. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  100. package/dist/engine/boot-validator/projection-list-screens.js +3 -13
  101. package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
  102. package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
  103. package/dist/engine/boot-validator/screens-kind-entity-list.js +7 -0
  104. package/dist/engine/boot-validator/screens-kind-projection-list.js +4 -0
  105. package/dist/engine/boot-validator/screens-lists.js +12 -2
  106. package/dist/engine/build-app-schema.d.ts +2 -1
  107. package/dist/engine/build-app-schema.js +9 -1
  108. package/dist/engine/build-config-feature-schema.js +6 -1
  109. package/dist/engine/define-feature.js +4 -0
  110. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  111. package/dist/engine/feature-ast/extractors/index.js +1 -1
  112. package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
  113. package/dist/engine/feature-ast/extractors/round1.js +6 -0
  114. package/dist/engine/feature-ast/index.d.ts +1 -1
  115. package/dist/engine/feature-ast/parse.js +3 -1
  116. package/dist/engine/feature-ast/patch.d.ts +2 -0
  117. package/dist/engine/feature-ast/patch.js +1 -0
  118. package/dist/engine/feature-ast/pattern-change-schema.d.ts +7 -245
  119. package/dist/engine/feature-ast/pattern-change-schema.js +9 -163
  120. package/dist/engine/feature-ast/pattern-id-matchers.js +1 -0
  121. package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
  122. package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
  123. package/dist/engine/feature-ast/patterns.d.ts +5 -1
  124. package/dist/engine/feature-ast/patterns.js +1 -0
  125. package/dist/engine/feature-ast/render.js +4 -0
  126. package/dist/engine/feature-builder-state.d.ts +1 -0
  127. package/dist/engine/feature-builder-state.js +1 -0
  128. package/dist/engine/feature-manifest.d.ts +1 -0
  129. package/dist/engine/feature-manifest.js +1 -0
  130. package/dist/engine/field-access.d.ts +1 -0
  131. package/dist/engine/field-access.js +12 -0
  132. package/dist/engine/index.d.ts +2 -2
  133. package/dist/engine/index.js +1 -1
  134. package/dist/engine/pattern-library/library.js +2 -1
  135. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  136. package/dist/engine/pattern-library/static-schemas.js +9 -0
  137. package/dist/engine/screen-filter-ops.js +2 -0
  138. package/dist/engine/strip-disabled-features-from-app-schema.js +7 -3
  139. package/dist/engine/types/feature.d.ts +1 -0
  140. package/dist/engine/types/index.d.ts +4 -3
  141. package/dist/engine/types/screen.d.ts +1 -1
  142. package/dist/engine/types/screen.js +1 -1
  143. package/dist/entrypoint/index.d.ts +2 -0
  144. package/dist/entrypoint/index.js +3 -0
  145. package/dist/event-store/event-store.d.ts +1 -0
  146. package/dist/event-store/event-store.js +7 -4
  147. package/dist/event-store/provenance-append.js +1 -1
  148. package/dist/files/create-file-ref-from-bytes.js +3 -0
  149. package/dist/files/create-file-ref.d.ts +3 -0
  150. package/dist/files/create-file-ref.js +11 -2
  151. package/dist/files/file-routes.d.ts +2 -0
  152. package/dist/files/file-routes.js +29 -2
  153. package/dist/files/provider-resolver.d.ts +1 -0
  154. package/dist/files/provider-resolver.js +1 -1
  155. package/dist/files/record-write-check.d.ts +6 -0
  156. package/dist/files/record-write-check.js +13 -0
  157. package/dist/files/upload-ledger.d.ts +2 -2
  158. package/dist/files/upload-ledger.js +4 -4
  159. package/dist/i18n/required-surface-keys.js +7 -2
  160. package/dist/internal/testing.js +1 -1
  161. package/dist/jobs/job-runner-scope.js +6 -6
  162. package/dist/jobs/job-runner.js +3 -0
  163. package/dist/observability/escape-hatch-report.js +9 -2
  164. package/dist/pipeline/after-rollback-sink.d.ts +6 -0
  165. package/dist/pipeline/after-rollback-sink.js +32 -0
  166. package/dist/pipeline/dispatch-batch.js +8 -1
  167. package/dist/pipeline/dispatch-shared.js +4 -0
  168. package/dist/pipeline/event-dispatcher-delivery.js +3 -1
  169. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  170. package/dist/pipeline/event-dispatcher.js +6 -9
  171. package/dist/pipeline/handler-context-dispatch.d.ts +2 -0
  172. package/dist/pipeline/handler-context-dispatch.js +13 -1
  173. package/dist/pipeline/handler-context-events.d.ts +2 -4
  174. package/dist/pipeline/handler-context-events.js +6 -4
  175. package/dist/pipeline/system-hooks.d.ts +3 -1
  176. package/dist/pipeline/system-hooks.js +55 -6
  177. package/dist/pipeline/write-result-masking.js +13 -10
  178. package/dist/schema-cli/baseline-command.js +1 -1
  179. package/dist/schema-cli/status-command.js +1 -1
  180. package/dist/search/reindex-entity.js +15 -5
  181. package/dist/ui-types/index.d.ts +2 -2
  182. package/dist/ui-types/index.js +1 -1
  183. package/dist/upgrade-changelog.d.ts +2 -0
  184. package/dist/upgrade-changelog.js +14 -0
  185. package/dist/upgrade-cli.d.ts +3 -12
  186. package/dist/upgrade-cli.js +22 -76
  187. package/dist/upgrade-marker-parse.d.ts +32 -0
  188. package/dist/upgrade-marker-parse.js +73 -0
  189. package/package.json +9 -6
  190. package/src/changes.json +245 -0
  191. package/src/scripts/codemod/codemod-support.ts +1 -1
  192. package/src/scripts/codemod/db-factory.ts +1 -1
  193. package/dist/engine/provider-plugin-context.d.ts +0 -3
  194. package/dist/engine/provider-plugin-context.js +0 -28
@@ -13,6 +13,7 @@ Multi-tenant data isolation by default.
13
13
  ## What it shows
14
14
 
15
15
  - Tenant filter on ordinary handlers
16
+ - `ctx.db.upsert`: insert-or-update on a tenant-scoped, non-event-sourced table. `conflictTarget` must contain `tenantId` and match a declared unique index (here `tenant_id, key`); event-fed tables are rejected
16
17
  - What breaks if you forget tenancy (and how the framework prevents it)
17
18
 
18
19
  ## Source
@@ -37,6 +38,7 @@ bun test
37
38
  // Proves: tenant_id scopes all data, cross-tenant access returns nothing
38
39
 
39
40
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
41
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
40
42
  import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
41
43
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
42
44
  import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
@@ -45,7 +47,7 @@ import {
45
47
  type TestStack,
46
48
  unsafeCreateEntityTable,
47
49
  } from "@cosmicdrift/kumiko-testing/stack";
48
- import { noteEntity, noteFeature } from "../feature";
50
+ import { noteEntity, noteFeature, preferenceTable } from "../feature";
49
51
 
50
52
  let stack: TestStack;
51
53
 
@@ -136,12 +138,48 @@ describe("tenant isolation", () => {
136
138
  expect(listB.rows.some((r) => r["title"] === "A-Note")).toBe(false);
137
139
  });
138
140
  });
141
+
142
+ describe("ctx.db.upsert", () => {
143
+ const setPreference = (user: typeof tenantBAdmin, key: string, value: string) =>
144
+ stack.http.writeOk<{ value: string }>("notes:write:preference:set", { key, value }, user);
145
+
146
+ test("a second upsert with the same key updates the tenant's row instead of inserting", async () => {
147
+ await setPreference(tenantAAdmin, "theme", "light");
148
+ const second = await setPreference(tenantAAdmin, "theme", "dark");
149
+
150
+ expect(second.value).toBe("dark");
151
+ const rows = await selectMany(stack.db, preferenceTable, {
152
+ tenantId: tenantAAdmin.tenantId,
153
+ key: "theme",
154
+ });
155
+ expect(rows).toHaveLength(1);
156
+ expect(rows[0]?.["value"]).toBe("dark");
157
+ });
158
+
159
+ test("the same key in another tenant creates a separate row and leaves A's value alone", async () => {
160
+ await setPreference(tenantAAdmin, "language", "de");
161
+ await setPreference(tenantBAdmin, "language", "en");
162
+
163
+ const rowsA = await selectMany(stack.db, preferenceTable, {
164
+ tenantId: tenantAAdmin.tenantId,
165
+ key: "language",
166
+ });
167
+ const rowsB = await selectMany(stack.db, preferenceTable, {
168
+ tenantId: tenantBAdmin.tenantId,
169
+ key: "language",
170
+ });
171
+ expect(rowsA.map((row) => row["value"])).toEqual(["de"]);
172
+ expect(rowsB.map((row) => row["value"])).toEqual(["en"]);
173
+ });
174
+ });
139
175
  ```
140
176
 
141
177
  ### src/feature.ts
142
178
 
143
179
  ```ts
180
+ import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
144
181
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
182
+ import * as z from "zod";
145
183
 
146
184
  export const noteEntity = createEntity({
147
185
  table: "read_sample_notes",
@@ -151,6 +189,26 @@ export const noteEntity = createEntity({
151
189
  },
152
190
  });
153
191
 
192
+ // Not event-sourced: ctx.db.upsert rejects executor-managed tables.
193
+ export const preferenceTable = defineUnmanagedTable({
194
+ tableName: "sample_tenant_preferences",
195
+ columns: [
196
+ {
197
+ name: "id",
198
+ pgType: "uuid",
199
+ notNull: true,
200
+ primaryKey: true,
201
+ defaultSql: "gen_random_uuid()",
202
+ },
203
+ { name: "tenant_id", pgType: "uuid", notNull: true },
204
+ { name: "key", pgType: "text", notNull: true },
205
+ { name: "value", pgType: "text", notNull: true },
206
+ ],
207
+ indexes: [
208
+ { name: "sample_tenant_preferences_tenant_key", columns: ["tenant_id", "key"], unique: true },
209
+ ],
210
+ });
211
+
154
212
  const adminWrite = { access: { roles: ["Admin"] } } as const;
155
213
  const openRead = {
156
214
  access: {
@@ -168,5 +226,19 @@ export const noteFeature = defineFeature("notes", (r) => {
168
226
  read: openRead,
169
227
  verbs: { update: false, delete: false, restore: false },
170
228
  });
229
+
230
+ r.storeTable(preferenceTable, { reason: "settings.tenant_preferences" });
231
+
232
+ r.writeHandler({
233
+ name: "preference:set",
234
+ schema: z.object({ key: z.string().min(1), value: z.string() }),
235
+ handler: async (event, ctx) => {
236
+ const row = await ctx.db.upsert<{ value: string }>(preferenceTable, event.payload, {
237
+ conflictTarget: ["tenantId", "key"],
238
+ });
239
+ return { isSuccess: true, data: { value: row?.value } };
240
+ },
241
+ ...adminWrite,
242
+ });
171
243
  });
172
244
  ```
@@ -48,6 +48,7 @@
48
48
  | `ledger-invoicing` | Accrual invoicing on the ledger primitive — revenue at invoice time, VAT as a liability, receivable cleared on payment. | `examples/ledger-invoicing.md` |
49
49
  | `legal-pages` | Auto-rendered Impressum + Datenschutzerklärung pages — template-resolver + legal-pages bundled features wired up with anonymousAccess for DACH-compliance. | `examples/legal-pages.md` |
50
50
  | `lifecycle-hooks` | preSave, postSave, and the rest. Your logic, our plumbing. | `examples/lifecycle-hooks.md` |
51
+ | `list-table-options` | Opt-in table behaviour of a list: remembered search/sort/filters, one reset button, column picker with hidden columns, row density, server-side groups with sums, a totals row and a number range facet. | `examples/list-table-options.md` |
51
52
  | `managed-config` | Managed config: one declaration provisions storage + masking + settings-hub, across backings (secrets vs config) and scopes (system vs tenant). | `examples/managed-config.md` |
52
53
  | `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` |
53
54
  | `notes-history-basic` | Append-only note history — who wrote what, when — attached to any entity without changing it. | `examples/notes-history-basic.md` |
package/agents/widgets.md CHANGED
@@ -143,6 +143,7 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
143
143
  | `buildFileUploadFormData` | |
144
144
  | `buildNavRegistrySlice` | |
145
145
  | `buildNavRegistrySliceForApp` | |
146
+ | `clearRememberedListState` | Forgets all remembered list state and the identity marker. |
146
147
  | `cn` | Standard shadcn helper: `clsx` combines conditional classes, while `tailwind-merge` resolves conflicts (for example, `px-2 px-4` → `px-4`). |
147
148
  | `createBrowserDraftStorage` | Default DraftStorage when createKumikoApp boots without one. |
148
149
  | `createBrowserLocaleResolver` | Default resolver when createKumikoApp is booted without a `locale` option. |
@@ -8,6 +8,7 @@ export declare const Routes: {
8
8
  readonly command: "/command";
9
9
  readonly sse: "/sse";
10
10
  readonly stream: "/stream";
11
+ readonly exportXlsx: "/export/xlsx";
11
12
  readonly auth: "/auth";
12
13
  readonly authLogin: "/auth/login";
13
14
  readonly authMfaVerify: "/auth/mfa/verify";
@@ -10,6 +10,7 @@ export const Routes = {
10
10
  command: "/command",
11
11
  sse: "/sse",
12
12
  stream: "/stream",
13
+ exportXlsx: "/export/xlsx",
13
14
  auth: "/auth",
14
15
  authLogin: "/auth/login",
15
16
  authMfaVerify: "/auth/mfa/verify",
@@ -76,6 +77,7 @@ export const NON_PUBLIC_API_PATHS = new Set([
76
77
  `/api${Routes.command}`,
77
78
  `/api${Routes.sse}`,
78
79
  `/api${Routes.stream}`,
80
+ `/api${Routes.exportXlsx}`,
79
81
  // Namespace prefix used only for body-limit registration
80
82
  // (`/api/auth/*` in route-registrars.ts) — never dispatched as its own
81
83
  // route, so it carries no auth bypass either way. Classified non-public
@@ -8,6 +8,7 @@ export declare const SignupRequestBody: z.ZodObject<{
8
8
  entityType: z.ZodString;
9
9
  token: z.ZodString;
10
10
  }, z.core.$strip>>;
11
+ returnTo: z.ZodCatch<z.ZodOptional<z.ZodString>>;
11
12
  }, z.core.$strip>;
12
13
  export declare function isUnknownHandlerError(e: unknown): boolean;
13
14
  export type PendingMfaStep = {
@@ -29,5 +30,6 @@ export declare function registerTokenRequestRoute(opts: {
29
30
  }): void;
30
31
  declare const RequestTokenBody: z.ZodObject<{
31
32
  email: z.ZodEmail;
33
+ returnTo: z.ZodCatch<z.ZodOptional<z.ZodString>>;
32
34
  }, z.core.$strip>;
33
35
  export {};
@@ -4,6 +4,9 @@ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
4
4
  import { NotFoundError } from "../errors/index.js";
5
5
  import { createFallbackLogger } from "../logging/utils.js";
6
6
  const log = createFallbackLogger("kumiko");
7
+ // Content is checked where the mail link is built (bundled-features knows the path rules); a bad
8
+ // value must never void the whole body, or the request would silently send no mail.
9
+ const returnToField = z.string().max(2048).optional().catch(undefined);
7
10
  // Cross-device try-first handover: an optional tenant-handover grant the signup-request
8
11
  // route forwards to the handler for server-side verification and binding —
9
12
  // the grant itself never appears anywhere else (URL, mail, log, response).
@@ -15,6 +18,7 @@ export const SignupRequestBody = z.object({
15
18
  token: z.string().min(1).max(4096),
16
19
  })
17
20
  .optional(),
21
+ returnTo: returnToField,
18
22
  });
19
23
  // Shape guard for "handler not registered" — the only legitimate reason to
20
24
  // fall back to a single-tenant reply on /auth/tenants or /auth/switch-tenant.
@@ -82,4 +86,5 @@ export function registerTokenRequestRoute(opts) {
82
86
  // gains optional fields (locale, deviceId, …).
83
87
  const RequestTokenBody = z.object({
84
88
  email: z.email(),
89
+ returnTo: returnToField,
85
90
  });
@@ -73,6 +73,8 @@ export function createClientErrorRoute(options) {
73
73
  const app = new Hono();
74
74
  const logger = createFallbackLogger("client-errors");
75
75
  const routePath = `/api${Routes.clientErrors}`;
76
+ // Always on: the shared limiter fails open when Redis is down, this per-process window is the floor.
77
+ app.post(Routes.clientErrors, inMemoryIpRateLimit(options.clientIpResolver, options.now ?? Date.now));
76
78
  if (options.resolver) {
77
79
  app.post(Routes.clientErrors, httpRouteRateLimit({
78
80
  resolver: options.resolver,
@@ -82,9 +84,6 @@ export function createClientErrorRoute(options) {
82
84
  ...(options.meter && { meter: options.meter }),
83
85
  }));
84
86
  }
85
- else {
86
- app.post(Routes.clientErrors, inMemoryIpRateLimit(options.clientIpResolver, options.now ?? Date.now));
87
- }
88
87
  app.post(Routes.clientErrors, bodyLimit({
89
88
  maxSize: MAX_BODY_BYTES,
90
89
  onError: (c) => c.json({ error: { code: "payload_too_large" } }, 413),
@@ -1,2 +1,3 @@
1
- import type { AccessRule, Registry } from "../engine/types/index.ts";
1
+ import type { AccessRule, OwnershipMap, Registry } from "../engine/types/index.ts";
2
2
  export declare function collectEntityReadAccess(registry: Registry): ReadonlyMap<string, readonly AccessRule[]>;
3
+ export declare function collectEntityRowReadMaps(registry: Registry): ReadonlyMap<string, OwnershipMap>;
@@ -25,3 +25,17 @@ export function collectEntityReadAccess(registry) {
25
25
  }
26
26
  return rulesByEntity;
27
27
  }
28
+ // Entities whose own access.read narrows rows per role (e.g. Driver sees only assigned rows).
29
+ // A signal for such an entity must not name ids to a role that does not read every row.
30
+ export function collectEntityRowReadMaps(registry) {
31
+ const mapsByEntity = new Map();
32
+ for (const [entityName, entity] of registry.getAllEntities()) {
33
+ const readMap = entity.access?.read;
34
+ if (readMap === undefined || Object.keys(readMap).length === 0)
35
+ continue;
36
+ for (const aggregateType of new Set([entityName, toKebabCase(entityName)])) {
37
+ mapsByEntity.set(aggregateType, readMap);
38
+ }
39
+ }
40
+ return mapsByEntity;
41
+ }
@@ -0,0 +1,9 @@
1
+ import type { Cell } from "write-excel-file/node";
2
+ import type { ExportRow } from "./export-rows.ts";
3
+ export type ReferenceCellKind = {
4
+ readonly kind: "reference";
5
+ readonly labelField: string;
6
+ readonly multiple: boolean;
7
+ };
8
+ /** Labels come from the list row's eager-loaded `_refs`; a missing target shows its raw id. */
9
+ export declare function referenceCell(cell: ReferenceCellKind, field: string, row: ExportRow): Cell;
@@ -0,0 +1,21 @@
1
+ import { isPlainObject } from "../../utils/is-plain-object.js";
2
+ function labelOf(ref, labelField) {
3
+ if (!isPlainObject(ref))
4
+ return undefined;
5
+ const label = ref[labelField];
6
+ return typeof label === "string" ? label : undefined;
7
+ }
8
+ function refsOf(row, field) {
9
+ const refs = row["_refs"];
10
+ return isPlainObject(refs) ? refs[field] : undefined;
11
+ }
12
+ /** Labels come from the list row's eager-loaded `_refs`; a missing target shows its raw id. */
13
+ export function referenceCell(cell, field, row) {
14
+ const ids = cell.multiple && Array.isArray(row[field]) ? row[field] : [row[field]];
15
+ const ref = refsOf(row, field);
16
+ const labels = ids.map((id) => {
17
+ const target = Array.isArray(ref) ? ref.find((r) => isPlainObject(r) && r["id"] === id) : ref;
18
+ return labelOf(target, cell.labelField) ?? String(id);
19
+ });
20
+ return { value: labels.join(", "), type: String };
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { Cell } from "write-excel-file/node";
2
+ import type { ExportColumn } from "./export-columns.ts";
3
+ import type { ExportRow } from "./export-rows.ts";
4
+ export type Translate = (key: string) => string;
5
+ export type ExportCellContext = {
6
+ readonly row: ExportRow;
7
+ readonly translate: Translate;
8
+ readonly timeZone: string;
9
+ };
10
+ /** Never emits a formula cell and never reinterprets strings: text always stays text. */
11
+ export declare function toExportCell(column: ExportColumn, context: ExportCellContext): Cell;
@@ -0,0 +1,136 @@
1
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ import { referenceCell } from "./export-cells-reference.js";
3
+ const DATE_FORMAT = "yyyy-mm-dd";
4
+ const TIMESTAMP_FORMAT = "yyyy-mm-dd hh:mm";
5
+ const PLAIN_MONEY_FORMAT = "#,##0.00";
6
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
7
+ const CURRENCY_CODE = /^[A-Z]{3}$/;
8
+ function isNullish(value) {
9
+ return value === null || value === undefined;
10
+ }
11
+ function textCell(value) {
12
+ return { value, type: String };
13
+ }
14
+ function numberCell(value, format) {
15
+ return { value, type: Number, ...(format !== undefined && { format }) };
16
+ }
17
+ function toFiniteNumber(value) {
18
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
19
+ return value !== "" && Number.isFinite(n) ? n : undefined;
20
+ }
21
+ function stringify(value) {
22
+ if (typeof value === "string")
23
+ return value;
24
+ if (typeof value === "object" && value !== null) {
25
+ // Temporal values stringify to ISO via toString; plain data goes through JSON.
26
+ const text = String(value);
27
+ return text === "[object Object]" ? JSON.stringify(value) : text;
28
+ }
29
+ return String(value);
30
+ }
31
+ function fromEpochMillis(value) {
32
+ if (value instanceof Date)
33
+ return Number.isNaN(value.getTime()) ? undefined : value;
34
+ if (typeof value === "object" && value !== null && "epochMilliseconds" in value) {
35
+ return fromEpochMillis(new Date(Number(value.epochMilliseconds)));
36
+ }
37
+ if (typeof value === "string" || typeof value === "number") {
38
+ return fromEpochMillis(new Date(value));
39
+ }
40
+ return undefined;
41
+ }
42
+ // Date-only values are anchored at UTC midnight, never parsed through the local zone.
43
+ function toUtcDate(value) {
44
+ const text = typeof value === "string" ? value : isNullish(value) ? "" : stringify(value);
45
+ if (ISO_DATE.test(text))
46
+ return fromEpochMillis(new Date(`${text}T00:00:00Z`));
47
+ return undefined;
48
+ }
49
+ function dateCell(value) {
50
+ const date = toUtcDate(value) ?? fromEpochMillis(value);
51
+ return date === undefined
52
+ ? textCell(stringify(value))
53
+ : { value: date, type: Date, format: DATE_FORMAT };
54
+ }
55
+ // The sheet has no time zone, so the instant is shifted to the wall clock of `timeZone` and
56
+ // stored as if that wall clock were UTC.
57
+ function timestampCell(value, timeZone) {
58
+ const date = fromEpochMillis(value);
59
+ if (date === undefined)
60
+ return textCell(stringify(value));
61
+ const wall = Temporal.Instant.fromEpochMilliseconds(date.getTime()).toZonedDateTimeISO(timeZone);
62
+ const shifted = new Date(Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second));
63
+ return { value: shifted, type: Date, format: TIMESTAMP_FORMAT };
64
+ }
65
+ // `at` is the wall clock in the record's own zone without offset; reading it as UTC keeps the
66
+ // digits the user saw in the list.
67
+ function locatedTimestampCell(value) {
68
+ const at = typeof value === "object" && value !== null && "at" in value ? String(value.at) : undefined;
69
+ const date = at === undefined ? undefined : fromEpochMillis(new Date(`${at}Z`));
70
+ return date === undefined
71
+ ? textCell(stringify(value))
72
+ : { value: date, type: Date, format: TIMESTAMP_FORMAT };
73
+ }
74
+ function moneyCell(value) {
75
+ if (typeof value !== "object" || value === null || !("amount" in value)) {
76
+ const n = toFiniteNumber(value);
77
+ return n === undefined ? textCell(stringify(value)) : numberCell(n, PLAIN_MONEY_FORMAT);
78
+ }
79
+ const amount = toFiniteNumber(value.amount);
80
+ if (amount === undefined)
81
+ return textCell(stringify(value.amount));
82
+ const currency = "currency" in value ? String(value.currency) : "";
83
+ const format = CURRENCY_CODE.test(currency)
84
+ ? `${PLAIN_MONEY_FORMAT} "${currency}"`
85
+ : PLAIN_MONEY_FORMAT;
86
+ return numberCell(amount, format);
87
+ }
88
+ function integerCell(value, kind) {
89
+ const n = toFiniteNumber(value);
90
+ if (n === undefined)
91
+ return textCell(stringify(value));
92
+ // A bigInt beyond 2^53 would lose digits as a spreadsheet number.
93
+ if (kind === "bigInt" && !Number.isSafeInteger(n))
94
+ return textCell(stringify(value));
95
+ return numberCell(n);
96
+ }
97
+ function optionLabel(prefix, raw, translate) {
98
+ const text = String(raw);
99
+ const label = translate(`${prefix}${text}`);
100
+ return label === `${prefix}${text}` ? text : label;
101
+ }
102
+ function autoCell(value) {
103
+ if (typeof value === "number" && Number.isFinite(value))
104
+ return numberCell(value);
105
+ if (typeof value === "boolean")
106
+ return { value, type: Boolean };
107
+ return textCell(stringify(value));
108
+ }
109
+ function optionCell(cell, value, translate) {
110
+ const values = cell.kind === "multiSelect" && Array.isArray(value) ? value : [value];
111
+ return textCell(values.map((v) => optionLabel(cell.optionLabelKeyPrefix, v, translate)).join(", "));
112
+ }
113
+ const SIMPLE_CELL_BUILDERS = {
114
+ number: (value) => integerCell(value, "number"),
115
+ decimal: (value) => integerCell(value, "decimal"),
116
+ bigInt: (value) => integerCell(value, "bigInt"),
117
+ money: (value) => moneyCell(value),
118
+ date: (value) => dateCell(value),
119
+ timestamp: timestampCell,
120
+ locatedTimestamp: (value) => locatedTimestampCell(value),
121
+ boolean: (value) => typeof value === "boolean" ? { value, type: Boolean } : textCell(stringify(value)),
122
+ auto: (value) => autoCell(value),
123
+ text: (value) => textCell(stringify(value)),
124
+ };
125
+ /** Never emits a formula cell and never reinterprets strings: text always stays text. */
126
+ export function toExportCell(column, context) {
127
+ const { cell, field } = column;
128
+ const value = context.row[field];
129
+ if (isNullish(value))
130
+ return null;
131
+ if (cell.kind === "reference")
132
+ return referenceCell(cell, field, context.row);
133
+ if ("optionLabelKeyPrefix" in cell)
134
+ return optionCell(cell, value, context.translate);
135
+ return SIMPLE_CELL_BUILDERS[cell.kind](value, context.timeZone);
136
+ }
@@ -0,0 +1,2 @@
1
+ import type { SheetData } from "write-excel-file/node";
2
+ export declare function guardSheetAgainstCiphertext(sheet: SheetData, isProduction?: boolean): SheetData;
@@ -0,0 +1,27 @@
1
+ import { InternalError } from "../../errors/index.js";
2
+ import { createFallbackLogger } from "../../logging/utils.js";
3
+ import { CIPHERTEXT_RE, CIPHERTEXT_REDACT_RE, CIPHERTEXT_REDACTION } from "../pii-leak-guard.js";
4
+ const log = createFallbackLogger("kumiko:export-xlsx");
5
+ // The response guard only scans JSON, so a workbook needs its own check: a ciphertext in a cell
6
+ // means a raw DB read reached the export. Same policy as the guard: loud outside production,
7
+ // redacted and logged in production.
8
+ function guardText(text, isProduction) {
9
+ if (!CIPHERTEXT_RE.test(text))
10
+ return text;
11
+ const detail = "XLSX export contains a PII ciphertext — a raw DB read leaked into the sheet. " +
12
+ "Decrypt before returning (decryptStoredPii / executor read path).";
13
+ if (!isProduction)
14
+ throw new InternalError({ message: detail });
15
+ log.error(detail);
16
+ return text.replace(CIPHERTEXT_REDACT_RE, CIPHERTEXT_REDACTION);
17
+ }
18
+ function guardCell(cell, isProduction) {
19
+ if (typeof cell !== "object" || cell === null || !("value" in cell))
20
+ return cell;
21
+ if (typeof cell.value !== "string")
22
+ return cell;
23
+ return { ...cell, value: guardText(cell.value, isProduction) };
24
+ }
25
+ export function guardSheetAgainstCiphertext(sheet, isProduction = process.env["NODE_ENV"] === "production") {
26
+ return sheet.map((row) => row.map((cell) => guardCell(cell, isProduction)));
27
+ }
@@ -0,0 +1,29 @@
1
+ import type { EntityDefinition, ListColumnSpec, SessionUser } from "../../engine/types/index.ts";
2
+ import type { ReferenceCellKind } from "./export-cells-reference.ts";
3
+ export type SimpleCellKind = "text" | "number" | "decimal" | "bigInt" | "money" | "date" | "timestamp" | "locatedTimestamp" | "boolean" | "auto";
4
+ export type ExportCellKind = {
5
+ readonly kind: SimpleCellKind;
6
+ } | ReferenceCellKind | {
7
+ readonly kind: "select" | "multiSelect";
8
+ /** `<feature>:entity:<entity>:field:<field>:option:` — the option value is appended. */
9
+ readonly optionLabelKeyPrefix: string;
10
+ };
11
+ export type ExportColumn = {
12
+ readonly field: string;
13
+ readonly headerKey: string;
14
+ readonly headerFallback: string;
15
+ readonly cell: ExportCellKind;
16
+ };
17
+ export type ExportColumnSource = {
18
+ readonly featureName: string;
19
+ readonly columns: readonly ListColumnSpec[];
20
+ readonly entity: EntityDefinition | undefined;
21
+ readonly entityName: string | undefined;
22
+ readonly user: Pick<SessionUser, "roles">;
23
+ };
24
+ /**
25
+ * Resolves the requested column ids against the screen's declared columns, in client order.
26
+ * Virtual columns and, on an entityList, encrypted/sensitive fields are dropped without error:
27
+ * the zip response bypasses the PII-leak guard, so this is the only place that keeps them out.
28
+ */
29
+ export declare function resolveExportColumns(source: ExportColumnSource, requested: readonly string[]): ExportColumn[];
@@ -0,0 +1,85 @@
1
+ import { holdsCiphertextOrSecret } from "../../db/entity-field-encryption.js";
2
+ import { userMayReadFieldInBulk } from "../../engine/field-access.js";
3
+ import { normalizeListColumn } from "../../engine/screen-helpers.js";
4
+ import { exportRejection } from "./export-errors.js";
5
+ const PROJECTION_PSEUDO_ENTITY = "__projection__";
6
+ const FIELD_KIND_BY_TYPE = {
7
+ number: "number",
8
+ decimal: "decimal",
9
+ bigInt: "bigInt",
10
+ money: "money",
11
+ date: "date",
12
+ timestamp: "timestamp",
13
+ locatedTimestamp: "locatedTimestamp",
14
+ boolean: "boolean",
15
+ };
16
+ function fieldLabelKey(featureName, entityName, field) {
17
+ return `${featureName}:entity:${entityName}:field:${field}`;
18
+ }
19
+ function entityCellKind(source, entity, field) {
20
+ const def = entity.fields[field];
21
+ if (def?.type === "reference") {
22
+ return {
23
+ kind: "reference",
24
+ labelField: def.labelField ?? "id",
25
+ multiple: def.multiple === true,
26
+ };
27
+ }
28
+ const type = def?.type ?? "text";
29
+ if (type === "select" || type === "multiSelect") {
30
+ return {
31
+ kind: type,
32
+ optionLabelKeyPrefix: `${fieldLabelKey(source.featureName, source.entityName ?? "", field)}:option:`,
33
+ };
34
+ }
35
+ return { kind: FIELD_KIND_BY_TYPE[type] ?? "text" };
36
+ }
37
+ function projectionCellKind(valueType) {
38
+ if (valueType === "number" || valueType === "decimal" || valueType === "bigInt") {
39
+ return { kind: valueType };
40
+ }
41
+ return { kind: valueType === "money" ? "money" : "auto" };
42
+ }
43
+ function isExportableOnEntity(source, entity, field, virtual) {
44
+ if (virtual || !userMayReadFieldInBulk(entity, field, source.user))
45
+ return false;
46
+ return !holdsCiphertextOrSecret(entity, field);
47
+ }
48
+ /**
49
+ * Resolves the requested column ids against the screen's declared columns, in client order.
50
+ * Virtual columns and, on an entityList, encrypted/sensitive fields are dropped without error:
51
+ * the zip response bypasses the PII-leak guard, so this is the only place that keeps them out.
52
+ */
53
+ export function resolveExportColumns(source, requested) {
54
+ const declared = new Map(source.columns.map((spec) => normalizeListColumn(spec)).map((c) => [c.field, c]));
55
+ const seen = new Set();
56
+ const resolved = [];
57
+ for (const field of requested) {
58
+ const column = declared.get(field);
59
+ if (column === undefined) {
60
+ throw exportRejection("export_unknown_column", "columns", { column: field });
61
+ }
62
+ if (seen.has(field))
63
+ continue;
64
+ seen.add(field);
65
+ const virtual = column.virtual === true;
66
+ const { entity } = source;
67
+ if (entity !== undefined && !isExportableOnEntity(source, entity, field, virtual))
68
+ continue;
69
+ if (entity === undefined && virtual)
70
+ continue;
71
+ const cell = entity !== undefined
72
+ ? entityCellKind(source, entity, field)
73
+ : projectionCellKind(column.valueType);
74
+ const headerEntity = source.entityName ?? PROJECTION_PSEUDO_ENTITY;
75
+ resolved.push({
76
+ field,
77
+ headerKey: column.label ?? fieldLabelKey(source.featureName, headerEntity, field),
78
+ headerFallback: column.label ?? field,
79
+ cell,
80
+ });
81
+ }
82
+ if (resolved.length === 0)
83
+ throw exportRejection("export_no_columns", "columns");
84
+ return resolved;
85
+ }
@@ -0,0 +1,4 @@
1
+ import { ValidationError } from "../../errors/index.ts";
2
+ export declare const EXPORT_TOO_MANY_I18N_KEY = "kumiko.list.export-too-many";
3
+ export type ExportRejectReason = "export_invalid_request" | "export_unknown_screen" | "export_not_enabled" | "export_unknown_column" | "export_no_columns";
4
+ export declare function exportRejection(reason: ExportRejectReason, path: string, params?: Readonly<Record<string, unknown>>): ValidationError;
@@ -0,0 +1,14 @@
1
+ import { ValidationError } from "../../errors/index.js";
2
+ export const EXPORT_TOO_MANY_I18N_KEY = "kumiko.list.export-too-many";
3
+ export function exportRejection(reason, path, params) {
4
+ return new ValidationError({
5
+ fields: [
6
+ {
7
+ path,
8
+ code: reason,
9
+ i18nKey: `kumiko.list.${reason.replace(/_/g, "-")}`,
10
+ ...(params !== undefined && { params }),
11
+ },
12
+ ],
13
+ });
14
+ }
@@ -0,0 +1,4 @@
1
+ import { Hono } from "hono";
2
+ import type { Registry } from "../../engine/types/index.ts";
3
+ import type { Dispatcher } from "../../pipeline/dispatcher.ts";
4
+ export declare function createExportRoutes(dispatcher: Dispatcher, registry: Registry): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;