@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
@@ -0,0 +1,74 @@
1
+ import { Hono } from "hono";
2
+ import * as z from "zod";
3
+ import { toKumikoError } from "../../errors/index.js";
4
+ import { createI18n, DEFAULT_LOCALE } from "../../i18n/index.js";
5
+ import { isValidIanaTimeZone } from "../../time/index.js";
6
+ import { Routes } from "../api-constants.js";
7
+ import { getUser } from "../auth-middleware.js";
8
+ import { requestContext } from "../request-context.js";
9
+ import { assertPayloadDepthAllowed, queryErrorResponse } from "../routes.js";
10
+ import { EXPORT_TOO_MANY_I18N_KEY, exportRejection } from "./export-errors.js";
11
+ import { readExportRows } from "./export-rows.js";
12
+ import { resolveExportPlan } from "./export-screen.js";
13
+ import { writeExportWorkbook } from "./export-xlsx-writer.js";
14
+ const XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
15
+ const MAX_ID_LENGTH = 200;
16
+ const exportBodySchema = z.object({
17
+ screen: z.string().max(MAX_ID_LENGTH),
18
+ payload: z.record(z.string(), z.unknown()).default({}),
19
+ columns: z.array(z.string().max(MAX_ID_LENGTH)).min(1).max(MAX_ID_LENGTH),
20
+ timeZone: z.string().max(64).optional(),
21
+ });
22
+ // Browser zone first (what the user saw in the list), then the profile zone, else UTC.
23
+ function exportTimeZone(requested, profile) {
24
+ for (const candidate of [requested, profile]) {
25
+ if (candidate !== undefined && isValidIanaTimeZone(candidate))
26
+ return candidate;
27
+ }
28
+ return "UTC";
29
+ }
30
+ function downloadFileName(screenShortId, now) {
31
+ const safeId = screenShortId.replace(/[^A-Za-z0-9_-]/g, "_");
32
+ return `${safeId}-${now.toISOString().slice(0, 10)}.xlsx`;
33
+ }
34
+ export function createExportRoutes(dispatcher, registry) {
35
+ const api = new Hono();
36
+ const i18n = createI18n(registry, { defaultLocale: DEFAULT_LOCALE });
37
+ api.post(Routes.exportXlsx, async (c) => {
38
+ const user = getUser(c);
39
+ try {
40
+ const parsed = exportBodySchema.safeParse(await c.req.json().catch(() => undefined));
41
+ if (!parsed.success)
42
+ throw exportRejection("export_invalid_request", "body");
43
+ const body = parsed.data;
44
+ assertPayloadDepthAllowed(body.payload);
45
+ const plan = resolveExportPlan(registry, body.screen, body.columns, user);
46
+ const result = await readExportRows(dispatcher, plan, body.payload, user);
47
+ if (result.tooMany) {
48
+ return c.json({
49
+ error: {
50
+ code: "export_too_many_rows",
51
+ httpStatus: 413,
52
+ message: `export exceeds the limit of ${plan.maxRows} rows; narrow the filter`,
53
+ i18nKey: EXPORT_TOO_MANY_I18N_KEY,
54
+ details: { maxRows: plan.maxRows },
55
+ },
56
+ }, 413);
57
+ }
58
+ const locale = requestContext.get()?.locale ?? DEFAULT_LOCALE;
59
+ const bytes = await writeExportWorkbook(plan.columns, result.rows, (key) => i18n.t(key, locale), exportTimeZone(body.timeZone, user.timezone));
60
+ return new Response(bytes, {
61
+ status: 200,
62
+ headers: {
63
+ "Content-Type": XLSX_CONTENT_TYPE,
64
+ "Cache-Control": "no-store",
65
+ "Content-Disposition": `attachment; filename="${downloadFileName(plan.screenShortId, new Date())}"`,
66
+ },
67
+ });
68
+ }
69
+ catch (e) {
70
+ return queryErrorResponse(c, toKumikoError(e), Routes.exportXlsx);
71
+ }
72
+ });
73
+ return api;
74
+ }
@@ -0,0 +1,13 @@
1
+ import type { SessionUser } from "../../engine/types/index.ts";
2
+ import type { Dispatcher } from "../../pipeline/dispatcher.ts";
3
+ import type { ExportPlan } from "./export-screen.ts";
4
+ export type ExportRow = Readonly<Record<string, unknown>>;
5
+ export type ExportRowsResult = {
6
+ readonly tooMany: false;
7
+ readonly rows: readonly ExportRow[];
8
+ } | {
9
+ readonly tooMany: true;
10
+ };
11
+ export declare function sanitizeExportPayload(clientPayload: Readonly<Record<string, unknown>>, plan: Pick<ExportPlan, "screenFilter">): Record<string, unknown>;
12
+ /** Reads the whole filtered list page by page; gives up as soon as more than `maxRows` exist. */
13
+ export declare function readExportRows(dispatcher: Pick<Dispatcher, "query">, plan: ExportPlan, clientPayload: Readonly<Record<string, unknown>>, user: SessionUser): Promise<ExportRowsResult>;
@@ -0,0 +1,62 @@
1
+ import { MAX_LIST_LIMIT } from "../../engine/entity-handlers.js";
2
+ import { InternalError } from "../../errors/index.js";
3
+ import { isPlainObject } from "../../utils/is-plain-object.js";
4
+ // Paging, sizing and aggregation belong to the server here; `filter` is replaced from the screen
5
+ // below, so a client can neither widen the fixed screen filter nor smuggle another one in.
6
+ const CLIENT_CONTROLLED_PAGING_KEYS = [
7
+ "limit",
8
+ "offset",
9
+ "cursor",
10
+ "totalCount",
11
+ "aggregate",
12
+ "includeDeleted",
13
+ "filter",
14
+ ];
15
+ export function sanitizeExportPayload(clientPayload, plan) {
16
+ const payload = { ...clientPayload };
17
+ for (const key of CLIENT_CONTROLLED_PAGING_KEYS)
18
+ delete payload[key];
19
+ if (plan.screenFilter !== undefined)
20
+ payload["filter"] = plan.screenFilter;
21
+ return payload;
22
+ }
23
+ function parsePage(result, queryType) {
24
+ if (!isPlainObject(result) || !Array.isArray(result["rows"])) {
25
+ throw new InternalError({
26
+ message: `export: query ${queryType} did not return a { rows } envelope`,
27
+ });
28
+ }
29
+ const nextCursor = result["nextCursor"];
30
+ return {
31
+ rows: result["rows"].filter(isPlainObject),
32
+ nextCursor: typeof nextCursor === "string" && nextCursor !== "" ? nextCursor : undefined,
33
+ };
34
+ }
35
+ /** Reads the whole filtered list page by page; gives up as soon as more than `maxRows` exist. */
36
+ export async function readExportRows(dispatcher, plan, clientPayload, user) {
37
+ const base = sanitizeExportPayload(clientPayload, plan);
38
+ const rows = [];
39
+ const seenCursors = new Set();
40
+ let cursor;
41
+ for (;;) {
42
+ const limit = Math.min(MAX_LIST_LIMIT, plan.maxRows + 1 - rows.length);
43
+ const pagingPayload = cursor !== undefined
44
+ ? { ...base, limit, cursor }
45
+ : rows.length > 0
46
+ ? { ...base, limit, offset: rows.length }
47
+ : { ...base, limit };
48
+ const page = parsePage(await dispatcher.query(plan.queryType, pagingPayload, user), plan.queryType);
49
+ rows.push(...page.rows);
50
+ if (rows.length > plan.maxRows)
51
+ return { tooMany: true };
52
+ // A short or empty page is the end of the list; a repeated cursor would loop forever.
53
+ if (page.rows.length < limit || page.rows.length === 0)
54
+ return { tooMany: false, rows };
55
+ if (page.nextCursor !== undefined) {
56
+ if (seenCursors.has(page.nextCursor))
57
+ return { tooMany: false, rows };
58
+ seenCursors.add(page.nextCursor);
59
+ }
60
+ cursor = page.nextCursor;
61
+ }
62
+ }
@@ -0,0 +1,12 @@
1
+ import type { Registry, ScreenFilter, SessionUser } from "../../engine/types/index.ts";
2
+ import { type ExportColumn } from "./export-columns.ts";
3
+ export type ExportPlan = {
4
+ readonly screenShortId: string;
5
+ readonly queryType: string;
6
+ readonly screenFilter: ScreenFilter | undefined;
7
+ readonly columns: readonly ExportColumn[];
8
+ readonly maxRows: number;
9
+ };
10
+ type ExportScreenRegistry = Pick<Registry, "getScreen" | "getScreenFeature" | "getEntity">;
11
+ export declare function resolveExportPlan(registry: ExportScreenRegistry, screenQn: string, requestedColumns: readonly string[], user: Pick<SessionUser, "roles">): ExportPlan;
12
+ export {};
@@ -0,0 +1,47 @@
1
+ import { hasAccess } from "../../engine/access.js";
2
+ import { toKebab } from "../../engine/qualified-name.js";
3
+ import { DEFAULT_EXPORT_MAX_ROWS } from "../../engine/types/screen.js";
4
+ import { AccessDeniedError } from "../../errors/index.js";
5
+ import { resolveExportColumns } from "./export-columns.js";
6
+ import { exportRejection } from "./export-errors.js";
7
+ const SCREEN_QN = /^[A-Za-z0-9_-]+:screen:[A-Za-z0-9_-]+$/;
8
+ function findExportScreen(registry, screenQn) {
9
+ // The client sends the feature name as authored (camelCase allowed); the registry keys are kebab.
10
+ const registryQn = toKebab(screenQn);
11
+ const screen = SCREEN_QN.test(screenQn) ? registry.getScreen(registryQn) : undefined;
12
+ if (screen === undefined || (screen.type !== "entityList" && screen.type !== "projectionList")) {
13
+ throw exportRejection("export_unknown_screen", "screen");
14
+ }
15
+ return { registryQn, screen };
16
+ }
17
+ export function resolveExportPlan(registry, screenQn, requestedColumns, user) {
18
+ const { registryQn, screen } = findExportScreen(registry, screenQn);
19
+ if (screen.table?.export?.xlsx !== true)
20
+ throw exportRejection("export_not_enabled", "screen");
21
+ if (screen.access !== undefined && !hasAccess(user, screen.access)) {
22
+ throw new AccessDeniedError({ details: { screen: screenQn } });
23
+ }
24
+ // Label keys use the feature name as authored, query types the kebab form.
25
+ const featureName = registry.getScreenFeature(registryQn) ?? "";
26
+ const screenShortId = registryQn.split(":")[2] ?? "";
27
+ const maxRows = screen.table.export.maxRows ?? DEFAULT_EXPORT_MAX_ROWS;
28
+ if (screen.type === "projectionList") {
29
+ return {
30
+ screenShortId,
31
+ queryType: screen.query,
32
+ screenFilter: screen.filter,
33
+ columns: resolveExportColumns({ featureName, columns: screen.columns, entity: undefined, entityName: undefined, user }, requestedColumns),
34
+ maxRows,
35
+ };
36
+ }
37
+ const entity = registry.getEntity(screen.entity);
38
+ if (entity === undefined)
39
+ throw exportRejection("export_unknown_screen", "screen");
40
+ return {
41
+ screenShortId,
42
+ queryType: `${toKebab(featureName)}:query:${toKebab(screen.entity)}:list`,
43
+ screenFilter: screen.filter,
44
+ columns: resolveExportColumns({ featureName, columns: screen.columns, entity, entityName: screen.entity, user }, requestedColumns),
45
+ maxRows,
46
+ };
47
+ }
@@ -0,0 +1,4 @@
1
+ import { type Translate } from "./export-cells.ts";
2
+ import type { ExportColumn } from "./export-columns.ts";
3
+ import type { ExportRow } from "./export-rows.ts";
4
+ export declare function writeExportWorkbook(columns: readonly ExportColumn[], rows: readonly ExportRow[], translate: Translate, timeZone: string): Promise<Uint8Array<ArrayBuffer>>;
@@ -0,0 +1,21 @@
1
+ import writeXlsxFile, {} from "write-excel-file/node";
2
+ import { toExportCell } from "./export-cells.js";
3
+ import { guardSheetAgainstCiphertext } from "./export-ciphertext-guard.js";
4
+ function headerLabel(column, translate) {
5
+ const translated = translate(column.headerKey);
6
+ return translated === column.headerKey ? column.headerFallback : translated;
7
+ }
8
+ export async function writeExportWorkbook(columns, rows, translate, timeZone) {
9
+ const header = columns.map((column) => ({
10
+ value: headerLabel(column, translate),
11
+ type: String,
12
+ fontWeight: "bold",
13
+ }));
14
+ const body = rows.map((row) => columns.map((column) => toExportCell(column, { row, translate, timeZone })));
15
+ const sheetData = guardSheetAgainstCiphertext([header, ...body]);
16
+ const buffer = await writeXlsxFile(sheetData, {}).toBuffer();
17
+ // Buffer is typed over ArrayBufferLike; a plain copy gives Response a BodyInit-compatible view.
18
+ const bytes = new Uint8Array(buffer.byteLength);
19
+ bytes.set(buffer);
20
+ return bytes;
21
+ }
@@ -70,6 +70,10 @@ export type SignatureExtraRouteDeps = {
70
70
  * caller's authenticity (signature, HMAC state, etc.). */
71
71
  readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
72
72
  readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
73
+ /** Same trust as dispatchSystemWrite, but runs as the tenant's system user
74
+ * with the "system" role plus exactly `roles` (no SystemAdmin), so the write
75
+ * passes app-role access checks. */
76
+ readonly dispatchWriteAs: (args: RoleDispatchArgs) => Promise<WriteResult>;
73
77
  };
74
78
  export type SignatureExtraRoute<TVerified> = {
75
79
  readonly method: HttpRouteMethod;
@@ -1,2 +1,5 @@
1
1
  import type { MiddlewareHandler } from "hono";
2
+ export declare const CIPHERTEXT_RE: RegExp;
3
+ export declare const CIPHERTEXT_REDACT_RE: RegExp;
4
+ export declare const CIPHERTEXT_REDACTION = "[pii-redacted]";
2
5
  export declare function piiCiphertextResponseGuard(): MiddlewareHandler;
@@ -8,8 +8,9 @@ const isProductionEnv = () => process.env["NODE_ENV"] === "production";
8
8
  // Bound to the full ciphertext shape (prefix, "<kind>:<id>" subject key, base64
9
9
  // blob of at least IV + GCM tag = 28 bytes) so a user-typed "kumiko-pii:v" in a
10
10
  // note or name field cannot turn every read of that record into a 500.
11
- const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^:"\s<>\\]+:[^:"\s<>\\]+:[A-Za-z0-9+/=]{40,}/;
12
- const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
11
+ export const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^:"\s<>\\]+:[^:"\s<>\\]+:[A-Za-z0-9+/=]{40,}/;
12
+ export const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
13
+ export const CIPHERTEXT_REDACTION = "[pii-redacted]";
13
14
  // A PII subject ciphertext never belongs in an API response — its presence
14
15
  // means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
15
16
  // fail loud (500) so a forgotten decrypt turns the first integration test
@@ -1,4 +1,5 @@
1
1
  import { type Context, Hono } from "hono";
2
+ import { type KumikoError } from "../errors/index.ts";
2
3
  import type { Dispatcher } from "../pipeline/dispatcher.ts";
3
4
  export declare const StreamFrame: {
4
5
  readonly chunk: "chunk";
@@ -18,4 +19,19 @@ export type SseWriter = {
18
19
  };
19
20
  export declare function pumpStream(stream: SseWriter, generator: AsyncGenerator<unknown>, heartbeatMs: number, firstPull?: Promise<IteratorResult<unknown>>): Promise<void>;
20
21
  export declare const MAX_PAYLOAD_DEPTH = 20;
22
+ export declare function assertPayloadDepthAllowed(payload: unknown): void;
23
+ export declare function queryErrorResponse(c: Context, err: KumikoError, type?: unknown): (Response & import("hono").TypedResponse<{
24
+ readonly error: {
25
+ readonly code: string;
26
+ readonly i18nKey: string;
27
+ readonly i18nParams?: {
28
+ readonly [x: string]: import("hono/utils/types").JSONValue;
29
+ } | undefined;
30
+ readonly message: string;
31
+ readonly details?: import("hono/utils/types").JSONValue | undefined;
32
+ readonly docsUrl?: string;
33
+ readonly requestId?: string;
34
+ readonly timestamp: string;
35
+ };
36
+ }, -1 | 100 | 102 | 103 | 200 | 201 | 202 | 203 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, "json">) | (Response & import("hono").TypedResponse<null, -1 | 100 | 102 | 103 | 200 | 201 | 202 | 203 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, "body">);
21
37
  export declare function handleUncaughtRouteError(err: Error, c: Context): Response | Promise<Response>;
@@ -312,7 +312,7 @@ async function readDispatchBody(c) {
312
312
  // jsonb serialization of custom-fields payloads) walks the same object
313
313
  // graph an attacker controls, so an uncapped depth is a stack-overflow /
314
314
  // CPU-DoS vector even under the 1MB body-byte limit.
315
- function assertPayloadDepthAllowed(payload) {
315
+ export function assertPayloadDepthAllowed(payload) {
316
316
  if (payloadDepth(payload, 0) > MAX_PAYLOAD_DEPTH) {
317
317
  throw new ValidationError({
318
318
  fields: [
@@ -428,7 +428,7 @@ function clientAbortResponse(c, requestId, type) {
428
428
  }
429
429
  // For /query + /command: no isSuccess on success (just { data } / {ok}), so we
430
430
  // keep the same lean shape on failure — only the `error` key.
431
- function queryErrorResponse(c, err, type) {
431
+ export function queryErrorResponse(c, err, type) {
432
432
  const requestId = requestContext.get()?.requestId;
433
433
  if (isClientAbort(err))
434
434
  return clientAbortResponse(c, requestId, type);
@@ -34,6 +34,7 @@ export function mountExtraRoutes(app, wiring) {
34
34
  }
35
35
  const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
36
36
  const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
37
+ const dispatchWriteAs = makeDispatchWriteAs(dispatcher);
37
38
  for (const route of options.extraRoutes) {
38
39
  const honoHandler = buildExtraRouteHonoHandler(route, {
39
40
  app,
@@ -42,6 +43,7 @@ export function mountExtraRoutes(app, wiring) {
42
43
  secrets: context.secrets,
43
44
  dispatchSystemWrite,
44
45
  dispatchSystemQuery,
46
+ dispatchWriteAs,
45
47
  clientIpResolver,
46
48
  });
47
49
  mountHonoRoute(app, route.method, route.path, honoHandler, bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES));
@@ -195,6 +197,7 @@ function buildExtraRouteHonoHandler(route, shared) {
195
197
  systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
196
198
  dispatchSystemWrite: shared.dispatchSystemWrite,
197
199
  dispatchSystemQuery: shared.dispatchSystemQuery,
200
+ dispatchWriteAs: shared.dispatchWriteAs,
198
201
  });
199
202
  };
200
203
  default:
@@ -10,7 +10,7 @@ export declare function mountAuthRoutes(app: Hono, wiring: Pick<ServerWiring, "o
10
10
  export declare function mountClientErrorRoute(app: Hono, wiring: Pick<ServerWiring, "rateLimitResolver" | "tracedRedis" | "clientIpResolver" | "observability" | "jwt">): void;
11
11
  export declare function mountDispatcherRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "sseBroker">): void;
12
12
  export declare function mountSchemaRoute(app: Hono, options: Pick<ServerOptions, "registry" | "dispatcherOptions" | "context">): void;
13
- export declare function mountFileRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "fileProviderResolver">): void;
13
+ export declare function mountFileRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "fileProviderResolver" | "rateLimitResolver" | "tracedRedis">): void;
14
14
  export declare function mountFeatureHttpRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "clientIpResolver" | "rateLimitResolver" | "observability">, sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[]): void;
15
15
  export declare function installNoRouteMatchMarker(app: Hono): void;
16
16
  export declare function mountHonoRoute(app: Hono, method: HttpRouteMethod, path: string, handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>, middlewares?: readonly MiddlewareHandler[]): void;
@@ -14,7 +14,8 @@ import { getUser, identifyOptionalSession } from "./auth-middleware.js";
14
14
  import { createAuthRoutes } from "./auth-routes.js";
15
15
  import { createClientErrorRoute } from "./client-error-route.js";
16
16
  import { clientIpSourceFromHonoContext } from "./client-ip.js";
17
- import { collectEntityReadAccess } from "./entity-read-access.js";
17
+ import { collectEntityReadAccess, collectEntityRowReadMaps } from "./entity-read-access.js";
18
+ import { createExportRoutes } from "./export/export-route.js";
18
19
  import { computeStrongEtag, etagMatches } from "./http-cache.js";
19
20
  import { requestContext } from "./request-context.js";
20
21
  import { buildRequestContextData } from "./request-id-middleware.js";
@@ -67,9 +68,11 @@ export function mountDispatcherRoutes(app, wiring) {
67
68
  app.route("/api", createApiRoutes(dispatcher, {
68
69
  ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
69
70
  }));
71
+ app.route("/api", createExportRoutes(dispatcher, options.registry));
70
72
  app.route("/api", createSseRoute(sseBroker, {
71
73
  anonymousLiveEntities: collectAnonymousLiveEntities(options.registry),
72
74
  entityReadAccess: collectEntityReadAccess(options.registry),
75
+ entityRowReadMaps: collectEntityRowReadMaps(options.registry),
73
76
  }));
74
77
  }
75
78
  export function mountSchemaRoute(app, options) {
@@ -122,7 +125,9 @@ export function mountSchemaRoute(app, options) {
122
125
  });
123
126
  }
124
127
  export function mountFileRoutes(app, wiring) {
125
- const { options, dispatcher, fileProviderResolver } = wiring;
128
+ const { options, dispatcher, fileProviderResolver, rateLimitResolver, tracedRedis } = wiring;
129
+ const metaBatchRateLimitResolver = rateLimitResolver ??
130
+ (tracedRedis ? createRateLimitResolver({ redis: tracedRedis }) : undefined);
126
131
  // Mount upload/download routes whenever a file provider is resolvable (a
127
132
  // file-provider plugin is mounted, or a resolver was injected). They resolve
128
133
  // the provider per-tenant through file-foundation — no `files` option; route
@@ -139,6 +144,7 @@ export function mountFileRoutes(app, wiring) {
139
144
  db: fileDb,
140
145
  registry: options.registry,
141
146
  resolveProvider: fileProviderResolver,
147
+ ...(metaBatchRateLimitResolver ? { metaBatchRateLimitResolver } : {}),
142
148
  canReadRecord,
143
149
  canWriteRecord: createRecordWriteCheck({
144
150
  registry: options.registry,
@@ -23,6 +23,7 @@ export function withFileProviderResolver(registry, context, cacheSync) {
23
23
  return context;
24
24
  if (registry.getExtensionUsages(EXT_FILE_PROVIDER).length === 0)
25
25
  return context;
26
+ const { clock } = context;
26
27
  return {
27
28
  ...context,
28
29
  _fileProviderResolver: makeFileProviderResolver({
@@ -30,6 +31,7 @@ export function withFileProviderResolver(registry, context, cacheSync) {
30
31
  _configAccessorFactory: configAccessorFactoryOf(context),
31
32
  secrets: context.secrets,
32
33
  db: context.db,
34
+ ...(clock ? { now: () => clock.now() } : {}),
33
35
  ...(cacheSync ? { cacheSync } : {}),
34
36
  }),
35
37
  };
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import type { AccessRule } from "../engine/types/index.ts";
2
+ import type { AccessRule, OwnershipMap } from "../engine/types/index.ts";
3
3
  import { type SseBroker } from "./sse-broker.ts";
4
4
  /**
5
5
  * Heartbeat cadence for SSE streams.
@@ -27,6 +27,7 @@ export declare const SSE_MAX_PENDING_WRITES_PER_CLIENT = 10000;
27
27
  export type SseRouteOptions = {
28
28
  readonly anonymousLiveEntities: ReadonlySet<string>;
29
29
  readonly entityReadAccess: ReadonlyMap<string, readonly AccessRule[]>;
30
+ readonly entityRowReadMaps?: ReadonlyMap<string, OwnershipMap>;
30
31
  readonly now?: () => number;
31
32
  };
32
33
  export declare function createSseRoute(broker: SseBroker, options: SseRouteOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -84,7 +84,17 @@ function entitySignalDetail(user, aggregateType, options) {
84
84
  const granting = (options.entityReadAccess.get(aggregateType) ?? []).filter((rule) => hasAccess(user, rule));
85
85
  if (granting.length === 0)
86
86
  return "none";
87
- return granting.some((rule) => "roles" in rule) ? "identified" : "anonymous";
87
+ if (!granting.some((rule) => "roles" in rule))
88
+ return "anonymous";
89
+ return readsEveryRow(user, options.entityRowReadMaps?.get(aggregateType))
90
+ ? "identified"
91
+ : "anonymous";
92
+ }
93
+ // Mirrors buildOwnershipClause: without a map every row is visible, otherwise only a role mapped to "all" sees them all.
94
+ function readsEveryRow(user, rowReadMap) {
95
+ if (rowReadMap === undefined)
96
+ return true;
97
+ return user.roles.some((role) => rowReadMap[role] === "all");
88
98
  }
89
99
  function decideWireFrame(event, user, options) {
90
100
  const data = event.data;
@@ -1,5 +1,5 @@
1
1
  import type { ValidateBootOptions } from "../engine/boot-validator.ts";
2
- import type { FeatureDefinition } from "../engine/types/index.ts";
2
+ import type { BundledListsOptions, FeatureDefinition } from "../engine/types/index.ts";
3
3
  export type AppInfraPorts = {
4
4
  readonly postgres?: number;
5
5
  readonly redis?: number;
@@ -35,6 +35,8 @@ export type AppConfig = {
35
35
  readonly ports: AppPorts;
36
36
  /** Passed to validateBoot by `kumiko check` and, via the app's bin files, by the runners. */
37
37
  readonly validateBootOptions?: AppValidateBootOptions;
38
+ /** One table block for every list of the bundled features. kumiko/schema.ts passes it to composeFeatures; the app's bin files pass it to the runners, like validateBootOptions. */
39
+ readonly bundledLists?: BundledListsOptions;
38
40
  };
39
41
  export declare const APP_CONFIG_FILE = "kumiko.config.ts";
40
42
  declare const INFRA_PORT_NAMES: readonly ["postgres", "redis", "meilisearch", "s3"];
@@ -17,6 +17,7 @@ export declare class PgKmsAdapter implements LocalKeyKmsAdapter {
17
17
  readonly capabilities: {
18
18
  readonly mode: "local-key";
19
19
  };
20
+ private readonly connection;
20
21
  private readonly sql;
21
22
  private readonly kek;
22
23
  private readonly kekVersion;
@@ -1,6 +1,6 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
- import postgres from "postgres";
3
- import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "../db/connection.js";
2
+ import { createDbConnection } from "../db/connection.js";
3
+ import { isUniqueViolation } from "../db/pg-error.js";
4
4
  import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdToKey, } from "./kms-adapter.js";
5
5
  const IV_LENGTH = 12;
6
6
  const TAG_LENGTH = 16;
@@ -28,21 +28,15 @@ function decodePlatformKek(base64) {
28
28
  }
29
29
  return kek;
30
30
  }
31
- const PG_UNIQUE_VIOLATION = "23505";
32
31
  // pg_advisory_xact_lock key for createSchema — serializes concurrent cold-start
33
32
  // DDL across processes sharing one subject-keys DB.
34
33
  const SCHEMA_ADVISORY_LOCK_KEY = 0x6b_6d_73_31; // 'kms1'
35
- function isPgUniqueViolation(error) {
36
- return (typeof error === "object" &&
37
- error !== null &&
38
- "code" in error &&
39
- error.code === PG_UNIQUE_VIOLATION);
40
- }
41
34
  // Default production adapter: DEKs live in a separate Postgres cluster,
42
35
  // KEK-wrapped at rest. Schema is created lazily on first use — the
43
36
  // subject-keys cluster has no drizzle migration pipeline of its own.
44
37
  export class PgKmsAdapter {
45
38
  capabilities = { mode: "local-key" };
39
+ connection;
46
40
  sql;
47
41
  kek;
48
42
  kekVersion;
@@ -60,12 +54,11 @@ export class PgKmsAdapter {
60
54
  previous.set(v, decodePlatformKek(kek));
61
55
  }
62
56
  this.previousKeks = previous;
63
- this.sql = postgres(options.databaseUrl, {
64
- max: options.maxConnections ?? 4,
65
- // The connection is exclusive to this adapter and its only DDL is
66
- // idempotent IF NOT EXISTS — notices are pure boot-log noise.
67
- onnotice: () => { },
57
+ this.connection = createDbConnection({
58
+ url: options.databaseUrl,
59
+ maxConnections: options.maxConnections ?? 4,
68
60
  });
61
+ this.sql = this.connection.client;
69
62
  }
70
63
  async createKey(subject, ctx) {
71
64
  await this.ensureSchema();
@@ -76,7 +69,7 @@ export class PgKmsAdapter {
76
69
  VALUES (${subjectIdToKey(subject)}, ${wrapped}, ${this.kekVersion}, ${ctx.userId ?? null})`;
77
70
  }
78
71
  catch (error) {
79
- if (isPgUniqueViolation(error))
72
+ if (isUniqueViolation(error))
80
73
  throw new KeyAlreadyExistsError(subject);
81
74
  throw error;
82
75
  }
@@ -128,7 +121,7 @@ export class PgKmsAdapter {
128
121
  return { ok: true, latencyMs: Date.now() - start };
129
122
  }
130
123
  async close() {
131
- await this.sql.end({ timeout: DEFAULT_DB_CLOSE_TIMEOUT_SECONDS });
124
+ await this.connection.close();
132
125
  }
133
126
  ensureSchema() {
134
127
  // A failed attempt resets the memo so a transient DB outage does not
@@ -178,7 +171,8 @@ export async function rewrapSubjectKeys(options) {
178
171
  fromKeks.set(Number(version), decodePlatformKek(kek));
179
172
  }
180
173
  const batchSize = options.batchSize ?? 200;
181
- const sql = postgres(options.databaseUrl, { max: 2, onnotice: () => { } });
174
+ const connection = createDbConnection({ url: options.databaseUrl, maxConnections: 2 });
175
+ const sql = connection.client;
182
176
  const failures = [];
183
177
  const result = { scanned: 0, rewrapped: 0, skippedErased: 0, failures };
184
178
  try {
@@ -226,7 +220,7 @@ export async function rewrapSubjectKeys(options) {
226
220
  }
227
221
  }
228
222
  finally {
229
- await sql.end();
223
+ await connection.close();
230
224
  }
231
225
  return result;
232
226
  }
@@ -12,6 +12,8 @@ export declare function isListAggregateMeasureSupported(kind: ListAggregateMeasu
12
12
  export declare function isListAggregateGroupable(fieldType: FieldType, multiple: boolean): boolean;
13
13
  export declare function listAggregateKeySql(fieldType: FieldType, columnSql: string, alias: string): string;
14
14
  export declare function listAggregateMeasureSql(kind: ListAggregateMeasureKind, fieldType: FieldType, columnSql: string, filterSql?: string): string;
15
+ export declare function isPerCurrencyMeasure(kind: ListAggregateMeasureKind, fieldType: FieldType): boolean;
16
+ export declare function listCurrencyMeasureSql(kind: ListAggregateMeasureKind, columnSql: string, filterSql: string): string;
15
17
  export declare function groupedKeyOrderLimitSql(limit: number): string;
16
18
  export declare function decodeListAggregateValue(raw: unknown): ListAggregateValue;
17
19
  export declare function emptyListAggregateValue(kind: ListAggregateMeasureKind): ListAggregateValue;
@@ -95,6 +95,16 @@ export function listAggregateMeasureSql(kind, fieldType, columnSql, filterSql) {
95
95
  return `ROUND(${fn})::float8`;
96
96
  return `(${fn})::float8`;
97
97
  }
98
+ export function isPerCurrencyMeasure(kind, fieldType) {
99
+ return fieldType === "money" && kind !== "count";
100
+ }
101
+ // No COALESCE on SUM: a currency absent from a group must read NULL so it can be left out.
102
+ export function listCurrencyMeasureSql(kind, columnSql, filterSql) {
103
+ const fn = kind === "sum"
104
+ ? `SUM(${columnSql}) FILTER (WHERE ${filterSql})`
105
+ : aggregateFunctionSql(kind, columnSql, filterSql);
106
+ return kind === "avg" ? `ROUND(${fn})::float8` : `(${fn})::float8`;
107
+ }
98
108
  export function groupedKeyOrderLimitSql(limit) {
99
109
  return ` GROUP BY 1 ORDER BY 1 ASC NULLS LAST LIMIT ${limit}`;
100
110
  }