@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.
- package/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +14 -7
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +1 -1
- package/dist/api/sse-route.js +39 -6
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +11 -1
- package/dist/db/eagerload.js +2 -1
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +39 -36
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/config-deps.js +13 -0
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +44 -4
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +41 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +138 -36
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +65 -19
- package/dist/engine/build-config-feature-schema.js +106 -22
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/config-helpers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +7 -2
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +38 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/feature-entity-handlers.js +22 -0
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +40 -0
- package/dist/engine/index.d.ts +5 -4
- package/dist/engine/index.js +4 -3
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +2 -0
- package/dist/engine/schema-builder.js +20 -3
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +22 -3
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +6 -1
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +28 -35
- package/dist/i18n/settings-hub-keys.js +21 -4
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +150 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/pipeline/dispatch-batch.js +8 -3
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +27 -7
- package/dist/pipeline/dispatcher-utils.js +4 -1
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +26 -11
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +9 -7
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-result-masking.d.ts +6 -0
- package/dist/pipeline/write-result-masking.js +36 -0
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-jobs.js +24 -12
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +218 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +56 -19
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- 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
|
-
|
|
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 (
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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") &&
|
|
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" &&
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
21
|
-
//
|
|
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)
|
|
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;
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -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];
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
//
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
113
|
-
|
|
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
|
|
404
|
-
if (fileRef.tenantId !== user.tenantId
|
|
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
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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(
|
|
112
|
+
entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
|
|
111
113
|
}
|
|
112
114
|
catch (err) {
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
if (!key.startsWith(prefix))
|
|
122
|
+
if (!entry.isFile())
|
|
121
123
|
continue;
|
|
122
|
-
|
|
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;
|