@cosmicdrift/kumiko-framework 0.333.0 → 0.335.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 (172) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/eagerload.js +2 -1
  23. package/dist/db/entity-table-meta.js +5 -3
  24. package/dist/db/event-store-executor-context.js +13 -19
  25. package/dist/db/event-store-executor-read.js +39 -36
  26. package/dist/db/event-store-executor-write.js +15 -17
  27. package/dist/db/migrate-generator.js +27 -28
  28. package/dist/db/pg-error.js +9 -5
  29. package/dist/db/queries/backfill-pii.js +15 -5
  30. package/dist/db/queries/shadow-swap.js +5 -4
  31. package/dist/db/table-builder.d.ts +1 -0
  32. package/dist/db/table-builder.js +7 -15
  33. package/dist/db/tenant-db.js +18 -2
  34. package/dist/derivatives/variant-key.js +3 -1
  35. package/dist/engine/boot-validator/access-declarations.js +12 -3
  36. package/dist/engine/boot-validator/config-deps.js +13 -0
  37. package/dist/engine/boot-validator/entity-handler.js +13 -1
  38. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  39. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  40. package/dist/engine/boot-validator/index.js +6 -6
  41. package/dist/engine/boot-validator/nav.d.ts +2 -1
  42. package/dist/engine/boot-validator/nav.js +10 -20
  43. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  44. package/dist/engine/boot-validator/ownership.js +2 -1
  45. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  46. package/dist/engine/boot-validator/pii-retention.js +44 -4
  47. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  48. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  49. package/dist/engine/boot-validator/screens.d.ts +2 -2
  50. package/dist/engine/boot-validator/screens.js +138 -36
  51. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  52. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  53. package/dist/engine/boot-validator/zod-shape.js +17 -4
  54. package/dist/engine/build-app-schema.js +65 -19
  55. package/dist/engine/build-config-feature-schema.js +106 -22
  56. package/dist/engine/changeset-changes.js +24 -10
  57. package/dist/engine/config-helpers.d.ts +2 -2
  58. package/dist/engine/entity-handlers.js +7 -2
  59. package/dist/engine/feature-ast/extractors/events.js +11 -8
  60. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  61. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  62. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  63. package/dist/engine/feature-ast/parse.d.ts +1 -0
  64. package/dist/engine/feature-ast/parse.js +18 -3
  65. package/dist/engine/feature-ast/patch.js +80 -61
  66. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  67. package/dist/engine/feature-ast/patcher.js +2 -1
  68. package/dist/engine/feature-ast/render.js +1 -1
  69. package/dist/engine/feature-entity-handlers.js +22 -0
  70. package/dist/engine/field-access.d.ts +2 -0
  71. package/dist/engine/field-access.js +40 -0
  72. package/dist/engine/index.d.ts +5 -4
  73. package/dist/engine/index.js +4 -3
  74. package/dist/engine/ownership.d.ts +1 -0
  75. package/dist/engine/ownership.js +15 -2
  76. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  77. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  78. package/dist/engine/pattern-library/shared-fields.js +17 -0
  79. package/dist/engine/registry-facade.js +3 -0
  80. package/dist/engine/role-assignment.d.ts +7 -1
  81. package/dist/engine/role-assignment.js +27 -7
  82. package/dist/engine/schema-builder.d.ts +2 -0
  83. package/dist/engine/schema-builder.js +20 -3
  84. package/dist/engine/screen-helpers.js +15 -4
  85. package/dist/engine/steps/_event-match.js +22 -3
  86. package/dist/engine/steps/_read-source.js +8 -0
  87. package/dist/engine/types/index.d.ts +1 -1
  88. package/dist/engine/where-rule-lint.js +22 -4
  89. package/dist/env/index.js +21 -1
  90. package/dist/errors/reasons.d.ts +0 -1
  91. package/dist/errors/reasons.js +2 -5
  92. package/dist/event-store/event-store.js +6 -1
  93. package/dist/event-store/provenance-append.js +4 -1
  94. package/dist/files/file-ref-entity.d.ts +6 -0
  95. package/dist/files/file-ref-table.d.ts +6 -0
  96. package/dist/files/file-routes.d.ts +0 -1
  97. package/dist/files/file-routes.js +22 -7
  98. package/dist/files/local-provider.js +13 -10
  99. package/dist/files/provider-resolver.js +20 -1
  100. package/dist/files/types.js +16 -1
  101. package/dist/i18n/request-locale.d.ts +3 -2
  102. package/dist/i18n/request-locale.js +16 -6
  103. package/dist/i18n/required-surface-keys.js +28 -35
  104. package/dist/i18n/settings-hub-keys.js +21 -4
  105. package/dist/jobs/index.d.ts +2 -2
  106. package/dist/jobs/index.js +1 -1
  107. package/dist/jobs/job-runner.d.ts +2 -0
  108. package/dist/jobs/job-runner.js +150 -59
  109. package/dist/migrations/index.d.ts +1 -1
  110. package/dist/migrations/index.js +1 -1
  111. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  112. package/dist/migrations/pending-rebuilds.js +23 -1
  113. package/dist/observability/escape-hatch-report.d.ts +1 -0
  114. package/dist/observability/escape-hatch-report.js +3 -0
  115. package/dist/observability/index.d.ts +1 -0
  116. package/dist/observability/index.js +1 -0
  117. package/dist/pipeline/dispatch-batch.js +8 -3
  118. package/dist/pipeline/dispatch-query.d.ts +1 -1
  119. package/dist/pipeline/dispatch-query.js +2 -2
  120. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  121. package/dist/pipeline/dispatch-shared.js +14 -9
  122. package/dist/pipeline/dispatch-stream.js +2 -5
  123. package/dist/pipeline/dispatch-write.js +27 -7
  124. package/dist/pipeline/dispatcher-utils.js +4 -1
  125. package/dist/pipeline/dispatcher.js +1 -2
  126. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  127. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  128. package/dist/pipeline/event-dispatcher.js +26 -11
  129. package/dist/pipeline/index.d.ts +1 -1
  130. package/dist/pipeline/index.js +1 -1
  131. package/dist/pipeline/member-read-only-transaction.js +1 -14
  132. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  133. package/dist/pipeline/msp-rebuild.js +1 -0
  134. package/dist/pipeline/system-hooks.d.ts +1 -1
  135. package/dist/pipeline/system-hooks.js +9 -7
  136. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  137. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  138. package/dist/pipeline/write-result-masking.d.ts +6 -0
  139. package/dist/pipeline/write-result-masking.js +36 -0
  140. package/dist/redis/pubsub-signal.d.ts +2 -0
  141. package/dist/redis/pubsub-signal.js +2 -2
  142. package/dist/schema-cli.js +4 -14
  143. package/dist/search/meilisearch-adapter.js +17 -6
  144. package/dist/search/purge-subject.js +8 -3
  145. package/dist/stack/drain-jobs.js +24 -12
  146. package/dist/stack/table-helpers.js +10 -8
  147. package/dist/testing/closed-connection-error.js +2 -3
  148. package/dist/testing/shared-entities.d.ts +10 -0
  149. package/dist/testing/wait-for.js +1 -0
  150. package/dist/ui-types/index.d.ts +2 -2
  151. package/dist/ui-types/index.js +1 -1
  152. package/dist/upgrade-cli.d.ts +1 -0
  153. package/dist/upgrade-cli.js +36 -15
  154. package/package.json +4 -4
  155. package/src/changes.json +218 -0
  156. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  157. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  158. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  159. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  160. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  161. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  162. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  163. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  164. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  165. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  166. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  167. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  168. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  169. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  170. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  171. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  172. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -1,4 +1,5 @@
1
1
  import { ROLES } from "../auth/roles.js";
2
+ import { EXT_ASSIGNABLE_ROLE } from "./extension-names.js";
2
3
  import { isForbiddenMembershipRole } from "./membership-roles.js";
3
4
  const PLATFORM_ROLE_NAMES = new Set(Object.values(ROLES));
4
5
  // Prototype-free rank table — Object literals leak Object.prototype
@@ -15,18 +16,19 @@ const ROLE_RANKS = new Map([
15
16
  ["SystemAdmin", 4],
16
17
  ["system", 5],
17
18
  ]);
18
- // App roles are ordinary, non-privileged tiers: whoever may manage members
19
- // (access.admin) may grant them; an app raises the bar via assignableFrom.
20
- export const DEFAULT_APP_ROLE_ASSIGNABLE_FROM = "Admin";
21
- const NO_ASSIGNABLE_APP_ROLES = new Map();
22
- const ASSIGNABLE_FROM_ROLES = new Set([
19
+ const ASSIGNABLE_FROM_ROLE_LIST = [
23
20
  "User",
24
21
  "Member",
25
22
  "Editor",
26
23
  "Admin",
27
24
  "TenantAdmin",
28
25
  "SystemAdmin",
29
- ]);
26
+ ];
27
+ // App roles are ordinary, non-privileged tiers: whoever may manage members
28
+ // (access.admin) may grant them; an app raises the bar via assignableFrom.
29
+ export const DEFAULT_APP_ROLE_ASSIGNABLE_FROM = "Admin";
30
+ const NO_ASSIGNABLE_APP_ROLES = new Map();
31
+ const ASSIGNABLE_FROM_ROLES = new Set(ASSIGNABLE_FROM_ROLE_LIST);
30
32
  function isAssignableFromRole(value) {
31
33
  return typeof value === "string" && ASSIGNABLE_FROM_ROLES.has(value);
32
34
  }
@@ -88,6 +90,24 @@ function requiredAssignRank(role, assignableAppRoles) {
88
90
  const assignableFrom = assignableAppRoles.get(role);
89
91
  return assignableFrom === undefined ? Number.POSITIVE_INFINITY : getRoleRank(assignableFrom);
90
92
  }
93
+ export function canActorAssignRole(actorRoles, role, assignableAppRoles = NO_ASSIGNABLE_APP_ROLES) {
94
+ return requiredAssignRank(role, assignableAppRoles) <= maxRoleRank(actorRoles);
95
+ }
96
+ // An actor only removes what it could also grant; clients (the member form)
97
+ // cannot render roles outside their option list, so those must survive a save.
98
+ export function mergeAssignedRoles(actorRoles, assignedRoles, targetCurrentRoles, assignableAppRoles) {
99
+ const preserved = targetCurrentRoles.filter((role) => !assignedRoles.includes(role) && !canActorAssignRole(actorRoles, role, assignableAppRoles));
100
+ return [...new Set([...assignedRoles, ...preserved])];
101
+ }
102
+ const assignableAppRolesByRegistry = new WeakMap();
103
+ export function assignableAppRolesOf(registry) {
104
+ const cached = assignableAppRolesByRegistry.get(registry);
105
+ if (cached !== undefined)
106
+ return cached;
107
+ const built = assignableAppRolesFromUsages(registry.getExtensionUsages(EXT_ASSIGNABLE_ROLE));
108
+ assignableAppRolesByRegistry.set(registry, built);
109
+ return built;
110
+ }
91
111
  export function findForbiddenRoleAssignment(actorRoles, assignedRoles,
92
112
  // Required so callers cannot accidentally disable the downgrade/
93
113
  // takeover guard by omitting the third argument (new users: pass []).
@@ -96,7 +116,7 @@ targetCurrentRoles, assignableAppRoles = NO_ASSIGNABLE_APP_ROLES) {
96
116
  // Assign path: fail-closed on unknown / above-actor roles — except unranked
97
117
  // app roles the target already holds (round-trip restore after strip).
98
118
  const forbiddenAssigned = assignedRoles.find((role) => {
99
- if (requiredAssignRank(role, assignableAppRoles) <= actorRank)
119
+ if (canActorAssignRole(actorRoles, role, assignableAppRoles))
100
120
  return false;
101
121
  if (knownRoleRank(role) === undefined && targetCurrentRoles.includes(role))
102
122
  return false;
@@ -3,7 +3,9 @@ import type { EntityDefinition, FieldDefinition } from "./types/index.js";
3
3
  export declare function isRepresentableAtScale(n: number, scale: number): boolean;
4
4
  export declare function fieldToZod(field: FieldDefinition, currencies: readonly string[], opts?: {
5
5
  readonly applyDefaults?: boolean;
6
+ readonly isUpdate?: boolean;
6
7
  }): z.ZodTypeAny;
8
+ export declare const SYSTEM_ONLY_JSON_SCHEMA_KEY = "x-kumiko-system-only";
7
9
  export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[], options?: {
8
10
  readonly allowCallerId?: boolean;
9
11
  }): z.ZodObject<Record<string, z.ZodTypeAny>>;
@@ -87,6 +87,13 @@ export function fieldToZod(field, currencies, opts = {}) {
87
87
  let schema = z.string();
88
88
  if (field.maxLength)
89
89
  schema = schema.max(field.maxLength);
90
+ if (field.writeOnly === true) {
91
+ // Update: "" = unchanged (dropped before the executor), null = clear,
92
+ // unless required. Create: "" would leave a required field unset.
93
+ if (opts.isUpdate !== true)
94
+ return field.required ? schema.min(1) : schema;
95
+ return field.required ? schema : schema.nullable();
96
+ }
90
97
  if (field.format === "email")
91
98
  schema = schema.email();
92
99
  if (field.format === "url")
@@ -366,6 +373,7 @@ function applyTotalsMatchRefinements(entity, schema) {
366
373
  function notWritableField() {
367
374
  return z.never().optional();
368
375
  }
376
+ export const SYSTEM_ONLY_JSON_SCHEMA_KEY = "x-kumiko-system-only";
369
377
  export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = [], options = {}) {
370
378
  const shape = {};
371
379
  for (const [name, field] of Object.entries(entity.fields)) {
@@ -380,8 +388,14 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
380
388
  }
381
389
  // Opt-in only for the generic create verb, which honors the id for system identities;
382
390
  // other schema consumers must keep stripping an unknown `id`.
383
- if (options.allowCallerId)
384
- shape["id"] = z.uuid().optional();
391
+ if (options.allowCallerId) {
392
+ // Marked so agent-facing exports can drop it: agent dispatch never runs as
393
+ // a system identity, so a caller-chosen id would be silently discarded.
394
+ shape["id"] = z
395
+ .uuid()
396
+ .optional()
397
+ .meta({ [SYSTEM_ONLY_JSON_SCHEMA_KEY]: true });
398
+ }
385
399
  return applyTotalsMatchRefinements(entity, z.object(shape));
386
400
  }
387
401
  export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
@@ -399,7 +413,10 @@ export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES],
399
413
  // fieldToZod still knows the default for its "" → default mapping
400
414
  // (e.g. select) — applyDefaults: false only suppresses the schema-level
401
415
  // `.default(...)` fallback for a genuinely omitted key.
402
- shape[name] = fieldToZod(field, currencies, { applyDefaults: false }).optional();
416
+ shape[name] = fieldToZod(field, currencies, {
417
+ applyDefaults: false,
418
+ isUpdate: true,
419
+ }).optional();
403
420
  }
404
421
  return applyTotalsMatchRefinements(entity, z.object(shape));
405
422
  }
@@ -12,21 +12,32 @@ export function explicitListScreenId(screen) {
12
12
  return undefined;
13
13
  }
14
14
  }
15
+ // Screen refs may be short ids or `<feature>:screen:<id>` QNs (boot-validator
16
+ // accepts both for navigate targets and listScreenId); compare on the short id.
17
+ function screenShortId(ref) {
18
+ return ref.slice(ref.lastIndexOf(":") + 1);
19
+ }
15
20
  // Shared by the renderer breadcrumb and the boot-validator so both resolve
16
21
  // parents the same way; `getId` lets each caller normalize `screen.id`.
17
22
  export function resolveNavParentScreen(screens, detail, getId) {
18
- const detailScreenId = getId(detail);
23
+ const detailScreenId = screenShortId(getId(detail));
19
24
  const listFromExplicit = (() => {
20
25
  const explicitId = explicitListScreenId(detail);
21
- return explicitId !== undefined ? screens.find((s) => getId(s) === explicitId) : undefined;
26
+ return explicitId !== undefined
27
+ ? screens.find((s) => screenShortId(getId(s)) === screenShortId(explicitId))
28
+ : undefined;
22
29
  })();
23
30
  // rowActions, toolbarActions, and drawer-kind actions (which mount inline
24
31
  // and have no page of their own) all count as "reached from" this list.
25
- const navigatesToDetail = (actions) => (actions ?? []).some((a) => (a.kind === "navigate" || a.kind === "drawer") && a.screen === detailScreenId);
32
+ const navigatesToDetail = (actions) => (actions ?? []).some((a) => (a.kind === "navigate" || a.kind === "drawer") &&
33
+ a.screen !== undefined &&
34
+ screenShortId(a.screen) === detailScreenId);
26
35
  // The create button of an entityList navigates to its createScreen, so the
27
36
  // list is that screen's parent just like for a rowAction target.
28
37
  const listFromRowAction = screens.find((s) => {
29
- if (s.type === "entityList" && s.createScreen === detailScreenId)
38
+ if (s.type === "entityList" &&
39
+ s.createScreen !== undefined &&
40
+ screenShortId(s.createScreen) === detailScreenId)
30
41
  return true;
31
42
  if (s.type !== "entityList" && s.type !== "projectionList")
32
43
  return false;
@@ -48,6 +48,8 @@ function evaluateOp(op, actual) {
48
48
  case "ne":
49
49
  return actual !== op.value;
50
50
  case "in":
51
+ if (!Array.isArray(op.values))
52
+ throw malformed(`"in" op has no "values" array`);
51
53
  return op.values.some((value) => value === actual);
52
54
  case "gt":
53
55
  case "gte":
@@ -66,13 +68,28 @@ function evaluateOp(op, actual) {
66
68
  }
67
69
  }
68
70
  }
71
+ // The AST round-trips through jsonb, so a truncated or hand-edited row must
72
+ // fail with a readable format error rather than a bare TypeError.
73
+ function malformed(what) {
74
+ return new Error(`Malformed EventMatch: ${what}`);
75
+ }
69
76
  function evaluateExpr(expr, payload) {
77
+ if (!isRecord(expr))
78
+ throw malformed("expr is not an object");
70
79
  switch (expr.kind) {
71
80
  case "and":
72
- return expr.nodes.every((node) => evaluateExpr(node, payload));
73
- case "or":
74
- return expr.nodes.some((node) => evaluateExpr(node, payload));
81
+ case "or": {
82
+ if (!Array.isArray(expr.nodes))
83
+ throw malformed(`"${expr.kind}" expr has no "nodes" array`);
84
+ return expr.kind === "and"
85
+ ? expr.nodes.every((node) => evaluateExpr(node, payload))
86
+ : expr.nodes.some((node) => evaluateExpr(node, payload));
87
+ }
75
88
  case "atom":
89
+ if (!Array.isArray(expr.path))
90
+ throw malformed(`"atom" expr has no "path" array`);
91
+ if (!isRecord(expr.op))
92
+ throw malformed(`"atom" expr has no "op" object`);
76
93
  return evaluateOp(expr.op, resolvePath(payload, expr.path));
77
94
  default: {
78
95
  const unrecognized = expr;
@@ -81,6 +98,8 @@ function evaluateExpr(expr, payload) {
81
98
  }
82
99
  }
83
100
  export function evaluateEventMatch(match, payload) {
101
+ if (!isRecord(match))
102
+ throw malformed("match is not an object");
84
103
  if (match.version !== 1) {
85
104
  throw new Error(`Unsupported EventMatch version ${JSON.stringify(match.version)} — expected 1`);
86
105
  }
@@ -1,7 +1,15 @@
1
1
  import { unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
2
+ import { InternalError } from "../../errors/classes.js";
2
3
  // unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
3
4
  export function readSourceFor(ctx, unsafeAllTenants) {
4
5
  if (!unsafeAllTenants)
5
6
  return ctx.db;
7
+ // Step args come from untyped call sites too; a boolean `true` (the shape other
8
+ // framework APIs use) must fail as a framework error, not a TypeError on `.reason`.
9
+ if (typeof unsafeAllTenants !== "object" || typeof unsafeAllTenants.reason !== "string") {
10
+ throw new InternalError({
11
+ message: "unsafeAllTenants on a read step must be { reason: string }",
12
+ });
13
+ }
6
14
  return unsafeRawForDeclaredStep(ctx.systemDb ?? ctx.db, unsafeAllTenants.reason);
7
15
  }
@@ -6,7 +6,7 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
6
6
  export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
7
7
  export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
- export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
9
+ export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
10
  export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
11
  export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
@@ -17,8 +17,9 @@ const SELF_COMPARISON_RE = new RegExp(`(${QUALIFIED_SRC})\\s*(?<![!<>=])=(?!=)\\
17
17
  const QUALIFIED_PARSE_RE = new RegExp(`^(${IDENT_SRC})(?:\\.(${IDENT_SRC}))?$`);
18
18
  // Resolve the SQL column names a table exposes, so the lint can tell a
19
19
  // real column reference apart from a table alias / function name / SQL
20
- // keyword. Returns an empty set when nothing is derivable (fail-open on
21
- // the lint itself would be worse than skipping it).
20
+ // keyword. Returns an empty set when nothing is derivable; the column-aware
21
+ // check then finds no bare column and is effectively skipped (only the
22
+ // self-comparison check still applies).
22
23
  export function tableColumnSqlNames(table) {
23
24
  if (table === null || typeof table !== "object")
24
25
  return new Set();
@@ -131,16 +132,33 @@ function findSelfComparison(cleaned) {
131
132
  // column on the OUTER table is the fail-open shape — Postgres resolves it
132
133
  // against the innermost table first. A qualifier (`t.x`) or a table/alias
133
134
  // name immediately followed by `.` is not a column reference and is
134
- // ignored.
135
+ // ignored. Bare identifiers on the outer level bind to the outer table and
136
+ // are fine, so only tokens inside a `( SELECT ...` parenthesis block count.
137
+ const SUBQUERY_OPEN_RE = /^\s*SELECT\b/i;
135
138
  function findUnqualifiedColumnReference(cleaned, columnSqlNames) {
139
+ const parenIsSubquery = [];
140
+ let subqueryDepth = 0;
141
+ let scanned = 0;
136
142
  TOKEN_RE.lastIndex = 0;
137
143
  let match = TOKEN_RE.exec(cleaned);
138
144
  while (match !== null) {
139
145
  const start = match.index;
140
146
  const end = start + match[0].length;
147
+ for (; scanned < start; scanned++) {
148
+ const ch = cleaned[scanned];
149
+ if (ch === "(") {
150
+ const opensSubquery = SUBQUERY_OPEN_RE.test(cleaned.slice(scanned + 1));
151
+ parenIsSubquery.push(opensSubquery);
152
+ if (opensSubquery)
153
+ subqueryDepth++;
154
+ }
155
+ else if (ch === ")" && parenIsSubquery.pop() === true) {
156
+ subqueryDepth--;
157
+ }
158
+ }
141
159
  const precededByDot = start > 0 && cleaned[start - 1] === ".";
142
160
  const followedByDot = cleaned[end] === ".";
143
- if (!precededByDot && !followedByDot) {
161
+ if (subqueryDepth > 0 && !precededByDot && !followedByDot) {
144
162
  const normalized = normalizeIdent(match[0]);
145
163
  if (columnSqlNames.has(normalized))
146
164
  return normalized;
package/dist/env/index.js CHANGED
@@ -57,6 +57,9 @@ export function kmsSlotsOf(schema) {
57
57
  // only exists after the boot-time decrypt, so requiring it here would reject
58
58
  // exactly the deployment this meta enables. safeExtend, not extend: Zod 4 throws
59
59
  // on extend for schemas carrying refinements (app-level superRefine).
60
+ // Caveat: refinements run against the relaxed object, so they see such a slot as
61
+ // `undefined` despite `z.infer<S>` promising a string. A refinement that reads the
62
+ // slot must tolerate that; a throw from it is reported by parseEnv as a boot error.
60
63
  function relaxCiphertextOnlySlots(schema, env) {
61
64
  const shape = zodShape(schema);
62
65
  const relaxed = {};
@@ -227,6 +230,23 @@ export class KumikoBootError extends Error {
227
230
  return formatBootError(this.errors);
228
231
  }
229
232
  }
233
+ // safeParse does not catch exceptions thrown inside refinements; a refinement
234
+ // reading a ciphertext-only slot (undefined here) would otherwise escape as a raw
235
+ // TypeError past the KumikoBootError mapping.
236
+ function safeParseReportingThrows(schema, input) {
237
+ try {
238
+ return schema.safeParse(input);
239
+ }
240
+ catch (error) {
241
+ throw new KumikoBootError([
242
+ {
243
+ name: "<schema refinement>",
244
+ kind: "invalid",
245
+ message: `an env schema refinement threw: ${error instanceof Error ? error.message : String(error)}. If it reads a kms slot delivered only as <NAME>_CIPHERTEXT, the value is undefined at this point.`,
246
+ },
247
+ ]);
248
+ }
249
+ }
230
250
  export function parseEnv(schema, env, options = {}) {
231
251
  // Filter undefined values to keep Zod's required-vs-invalid signal clean.
232
252
  // (process.env returns string|undefined; passing undefined would parse
@@ -236,7 +256,7 @@ export function parseEnv(schema, env, options = {}) {
236
256
  if (v !== undefined)
237
257
  cleaned[k] = v;
238
258
  }
239
- const result = relaxCiphertextOnlySlots(schema, cleaned).safeParse(cleaned);
259
+ const result = safeParseReportingThrows(relaxCiphertextOnlySlots(schema, cleaned), cleaned);
240
260
  if (result.success) {
241
261
  // @cast-boundary schema-walk — z.infer<S> erasure across safeParse result
242
262
  return result.data;
@@ -17,7 +17,6 @@ export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkRe
17
17
  export declare const AgentReasons: {
18
18
  readonly toolNotAllowed: "agent.tool_not_allowed";
19
19
  readonly iterationLimit: "agent.iteration_limit";
20
- readonly permissionDenied: "agent.permission_denied";
21
20
  readonly highRiskNoAlways: "agent.high_risk_no_always";
22
21
  };
23
22
  export type AgentReason = (typeof AgentReasons)[keyof typeof AgentReasons];
@@ -13,8 +13,8 @@
13
13
  // reasons surface from the AI-agent runtime (tool dispatch, permission and
14
14
  // risk gates), not from a handler's own domain logic, and the `agent.`
15
15
  // prefix keeps them visibly distinct from FrameworkReasons in error payloads.
16
- // Both the docs site (`errors/` pages) and the enterprise repo read this same
17
- // source, so the prefix stays a naming convention here, not a new registry.
16
+ // The docs site (`errors/` pages) reads this source; the enterprise repo keeps
17
+ // its own AGENT_REASONS, so only reasons it actually emits belong in both.
18
18
  export const FrameworkReasons = {
19
19
  // ConflictError: atomic UPDATE lost the race (another writer moved the row
20
20
  // between our snapshot and our WHERE clause). Client SDK default: toast +
@@ -73,9 +73,6 @@ export const AgentReasons = {
73
73
  // Not thrown: the run ends with a `clarify` turn (`payload.reason`) once
74
74
  // MAX_AGENT_ROUNDS is reached.
75
75
  iterationLimit: "agent.iteration_limit",
76
- // AccessDeniedError: the tool exists, but the caller's roles don't cover
77
- // the handler behind it.
78
- permissionDenied: "agent.permission_denied",
79
76
  // UnprocessableError: a permission rule with `always` was requested for an
80
77
  // `agent: { risk: "high" }` handler; `details.handlerQn` names it.
81
78
  highRiskNoAlways: "agent.high_risk_no_always",
@@ -175,12 +175,17 @@ function buildStoredEvent(event, newVersion, eventVersion, row) {
175
175
  // style collision) must pass it to avoid folding a foreign type's events
176
176
  // into the loaded stream.
177
177
  export async function loadAggregate(db, aggregateId, tenantId, options) {
178
+ // "" must not fall through to the unfiltered load: a dynamically computed
179
+ // type that came out empty would silently fold in foreign aggregate types.
180
+ if (options?.aggregateType === "") {
181
+ throw new Error("loadAggregate: aggregateType must be a non-empty string when provided");
182
+ }
178
183
  if (!options?.includeArchived) {
179
184
  const archived = await isStreamArchived(db, tenantId, aggregateId);
180
185
  if (archived)
181
186
  return [];
182
187
  }
183
- const rows = await selectMany(db, eventsTable, options?.aggregateType
188
+ const rows = await selectMany(db, eventsTable, options?.aggregateType !== undefined
184
189
  ? { aggregateId, tenantId, aggregateType: options.aggregateType }
185
190
  : { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
186
191
  return rows.map(toStoredEvent);
@@ -1,6 +1,7 @@
1
1
  import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii.js";
2
2
  import { runInSavepointIfSupported } from "../db/query.js";
3
3
  import { unsafeRawForDeclaredStep, withUnsafeRawGrant } from "../db/tenant-db.js";
4
+ import { qnScope } from "../engine/qualified-name.js";
4
5
  import { AccessDeniedError, InternalError } from "../errors/index.js";
5
6
  import { append, getStreamVersion } from "./event-store.js";
6
7
  // Fixed by the framework, not the caller — the point of this entry point is
@@ -14,7 +15,9 @@ export async function appendProvenanceEvent(db, event) {
14
15
  message: `appendProvenanceEvent("${event.type}") — the "${SYSTEM_EVENT_PREFIX}" namespace is framework-internal and is not reachable through this entry point.`,
15
16
  });
16
17
  }
17
- if (!event.type.includes(":")) {
18
+ // qnScope is the same owner definition appendDomainEventCore uses: a leading
19
+ // ":" (empty owner) counts as unowned there, so it must here too.
20
+ if (!qnScope(event.type) || event.type.endsWith(":")) {
18
21
  throw new InternalError({
19
22
  message: `appendProvenanceEvent("${event.type}") — event types must be owner-qualified ("<feature>:<name>"). Unowned types are not allowed here.`,
20
23
  });
@@ -9,6 +9,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -29,6 +30,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
29
30
  readonly filterable?: boolean;
30
31
  readonly encrypted?: boolean;
31
32
  readonly sensitive?: boolean;
33
+ readonly writeOnly?: boolean;
32
34
  readonly readAsInstruction?: boolean;
33
35
  readonly format?: "email" | "url" | "phone" | "password";
34
36
  readonly default?: string;
@@ -49,6 +51,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
49
51
  readonly filterable?: boolean;
50
52
  readonly encrypted?: boolean;
51
53
  readonly sensitive?: boolean;
54
+ readonly writeOnly?: boolean;
52
55
  readonly readAsInstruction?: boolean;
53
56
  readonly format?: "email" | "url" | "phone" | "password";
54
57
  readonly default?: string;
@@ -81,6 +84,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
81
84
  readonly filterable?: boolean;
82
85
  readonly encrypted?: boolean;
83
86
  readonly sensitive?: boolean;
87
+ readonly writeOnly?: boolean;
84
88
  readonly readAsInstruction?: boolean;
85
89
  readonly format?: "email" | "url" | "phone" | "password";
86
90
  readonly default?: string;
@@ -101,6 +105,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
101
105
  readonly filterable?: boolean;
102
106
  readonly encrypted?: boolean;
103
107
  readonly sensitive?: boolean;
108
+ readonly writeOnly?: boolean;
104
109
  readonly readAsInstruction?: boolean;
105
110
  readonly format?: "email" | "url" | "phone" | "password";
106
111
  readonly default?: string;
@@ -121,6 +126,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
121
126
  readonly filterable?: boolean;
122
127
  readonly encrypted?: boolean;
123
128
  readonly sensitive?: boolean;
129
+ readonly writeOnly?: boolean;
124
130
  readonly readAsInstruction?: boolean;
125
131
  readonly format?: "email" | "url" | "phone" | "password";
126
132
  readonly default?: string;
@@ -9,6 +9,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -29,6 +30,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
29
30
  readonly filterable?: boolean;
30
31
  readonly encrypted?: boolean;
31
32
  readonly sensitive?: boolean;
33
+ readonly writeOnly?: boolean;
32
34
  readonly readAsInstruction?: boolean;
33
35
  readonly format?: "email" | "url" | "phone" | "password";
34
36
  readonly default?: string;
@@ -49,6 +51,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
49
51
  readonly filterable?: boolean;
50
52
  readonly encrypted?: boolean;
51
53
  readonly sensitive?: boolean;
54
+ readonly writeOnly?: boolean;
52
55
  readonly readAsInstruction?: boolean;
53
56
  readonly format?: "email" | "url" | "phone" | "password";
54
57
  readonly default?: string;
@@ -81,6 +84,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
81
84
  readonly filterable?: boolean;
82
85
  readonly encrypted?: boolean;
83
86
  readonly sensitive?: boolean;
87
+ readonly writeOnly?: boolean;
84
88
  readonly readAsInstruction?: boolean;
85
89
  readonly format?: "email" | "url" | "phone" | "password";
86
90
  readonly default?: string;
@@ -101,6 +105,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
101
105
  readonly filterable?: boolean;
102
106
  readonly encrypted?: boolean;
103
107
  readonly sensitive?: boolean;
108
+ readonly writeOnly?: boolean;
104
109
  readonly readAsInstruction?: boolean;
105
110
  readonly format?: "email" | "url" | "phone" | "password";
106
111
  readonly default?: string;
@@ -121,6 +126,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
121
126
  readonly filterable?: boolean;
122
127
  readonly encrypted?: boolean;
123
128
  readonly sensitive?: boolean;
129
+ readonly writeOnly?: boolean;
124
130
  readonly readAsInstruction?: boolean;
125
131
  readonly format?: "email" | "url" | "phone" | "password";
126
132
  readonly default?: string;
@@ -14,7 +14,6 @@ export type FileRef = {
14
14
  entityId: string | null;
15
15
  fieldName: string | null;
16
16
  insertedById: string | null;
17
- isDeleted: boolean;
18
17
  };
19
18
  export type FileAccessGuard = (args: {
20
19
  readonly fileRef: FileRef;
@@ -3,7 +3,7 @@ import { bodyLimit } from "hono/body-limit";
3
3
  import { getUser } from "../api/auth-middleware.js";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { selectMany } from "../bun-db/index.js";
6
- import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, } from "../crypto/index.js";
6
+ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
7
7
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
8
8
  import { createTenantDb } from "../db/tenant-db.js";
9
9
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer, } from "../derivatives/index.js";
@@ -106,11 +106,27 @@ export function createFileRoutes(options) {
106
106
  if (!isPiiCiphertext(fileRef.fileName))
107
107
  return fileRef.fileName;
108
108
  const kms = configuredPiiSubjectKms();
109
+ // skip: the framework has no logger at this route layer and the file bytes are
110
+ // still served; the "download" fallback only hides the stored ciphertext name.
109
111
  if (!kms) {
110
112
  return "download";
111
113
  }
112
- const decrypted = await decryptPiiFieldValues({ fileName: fileRef.fileName }, piiSubjectFields, kms, kmsContextFor());
113
- return typeof decrypted["fileName"] === "string" ? decrypted["fileName"] : "download";
114
+ try {
115
+ const decrypted = await decryptPiiFieldValues({ fileName: fileRef.fileName }, piiSubjectFields, kms, kmsContextFor());
116
+ const name = decrypted["fileName"];
117
+ // decryptPiiFieldValues returns the row untouched when fileName isn't
118
+ // annotated as PII (annotation drift), so ciphertext/erased sentinel
119
+ // can still be here and must never reach a header.
120
+ if (typeof name !== "string" || isPiiCiphertext(name) || name === PII_ERASED_SENTINEL) {
121
+ return "download";
122
+ }
123
+ return name;
124
+ }
125
+ catch {
126
+ // skip: broken crypto metadata (malformed ciphertext, auth failure, missing key)
127
+ // must not make intact bytes unreachable; the route layer has no logger.
128
+ return "download";
129
+ }
114
130
  }
115
131
  // Runs before parseBody buffers the request below: checks Content-Length
116
132
  // upfront, counts chunked bodies while streaming.
@@ -397,11 +413,10 @@ export function createFileRoutes(options) {
397
413
  if (!row)
398
414
  return c.json({ error: "not_found" }, 404);
399
415
  const fileRef = row; // @cast-boundary db-row (decrypted via executor.detail)
400
- // detail()'s "pass"-ownership read widens to tenantId IN (self, SYSTEM)
401
- // and doesn't filter isDeleted (list() does, detail() doesn't) — both
416
+ // detail()'s "pass"-ownership read widens to tenantId IN (self, SYSTEM),
402
417
  // narrower in loadFileForTenant's selectMany. Restore that here so meta
403
- // doesn't leak reference-tenant or soft-deleted rows.
404
- if (fileRef.tenantId !== user.tenantId || fileRef.isDeleted) {
418
+ // doesn't leak reference-tenant rows.
419
+ if (fileRef.tenantId !== user.tenantId) {
405
420
  return c.json({ error: "not_found" }, 404);
406
421
  }
407
422
  const decision = await guard({ fileRef, user, operation: "read" });
@@ -1,6 +1,6 @@
1
1
  import { createReadStream, createWriteStream } from "node:fs";
2
2
  import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
- import { dirname, join, resolve, sep } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { assertSafeStorageKey } from "./types.js";
6
6
  // Local-filesystem backend — intended for dev + tests. Production deploys
@@ -101,25 +101,28 @@ export function createLocalProvider(basePath) {
101
101
  }
102
102
  },
103
103
  async list(prefix) {
104
- // recursive:true returns POSIX- or OS-sep-joined relative paths for
105
- // both files and directories; normalize to "/" (storage keys are
106
- // always "/"-joined, matching S3) before the prefix match, then stat
107
- // only the (few) matches to drop directory entries.
104
+ // Walk only the directory part of the prefix: a tenant-scoped sweep must
105
+ // not traverse every other tenant's files in a shared storage root.
106
+ // Keys are normalized to "/" (storage keys are always "/"-joined,
107
+ // matching S3) before the final prefix match.
108
+ const prefixDir = prefix.slice(0, prefix.lastIndexOf("/") + 1);
109
+ const walkRoot = prefixDir === "" ? resolvedBase : resolveContainedPath(prefixDir);
108
110
  let entries;
109
111
  try {
110
- entries = await readdir(resolvedBase, { recursive: true });
112
+ entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
111
113
  }
112
114
  catch (err) {
113
- if (err.code === "ENOENT")
115
+ const code = err.code;
116
+ if (code === "ENOENT" || code === "ENOTDIR")
114
117
  return [];
115
118
  throw err;
116
119
  }
117
120
  const results = [];
118
121
  for (const entry of entries) {
119
- const key = entry.split(sep).join("/");
120
- if (!key.startsWith(prefix))
122
+ if (!entry.isFile())
121
123
  continue;
122
- if ((await stat(join(resolvedBase, entry))).isFile())
124
+ const key = relative(resolvedBase, join(entry.parentPath, entry.name)).split(sep).join("/");
125
+ if (key.startsWith(prefix))
123
126
  results.push(key);
124
127
  }
125
128
  return results;