@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- 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/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- 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 +20 -10
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +5 -2
- package/dist/api/sse-route.js +71 -11
- 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 +32 -11
- 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 +45 -38
- 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/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 +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +53 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +125 -46
- 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 -20
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +39 -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.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- 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/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +34 -7
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/dry-run.js +10 -3
- 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 +10 -2
- package/dist/event-store/provenance-append.js +4 -1
- 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 +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- 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 +172 -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 +2 -1
- package/dist/observability/index.js +2 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -0
- 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 +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +30 -12
- 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 +19 -20
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-origin.js +13 -6
- 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-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +55 -13
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- 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 +264 -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 +111 -44
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
- 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
|
@@ -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/dry-run.js
CHANGED
|
@@ -105,13 +105,19 @@ function renderJson(fields, options) {
|
|
|
105
105
|
withDefault: withDefault.map(toEntry),
|
|
106
106
|
}, null, 2)}\n`;
|
|
107
107
|
}
|
|
108
|
+
// A newline in a `.describe()` text would end the `#` comment, so the rest of
|
|
109
|
+
// the description would run as a shell command or break the YAML.
|
|
110
|
+
function oneLineDescription(f) {
|
|
111
|
+
return f.description?.replace(/\s*\r?\n\s*/g, " ");
|
|
112
|
+
}
|
|
108
113
|
function pulumiConfigSetLine(f, options) {
|
|
109
114
|
const meta = readKumikoMeta(f.field);
|
|
110
115
|
const key = pulumiConfigKey(f.name, f.field, options.pulumiPrefix);
|
|
111
116
|
const secretFlag = meta.pulumi?.secret ? " --secret" : "";
|
|
112
117
|
const value = meta.pulumi?.generator ? `"$(${meta.pulumi.generator})"` : `"<set-me>"`;
|
|
113
|
-
const
|
|
114
|
-
|
|
118
|
+
const description = oneLineDescription(f);
|
|
119
|
+
const comment = description
|
|
120
|
+
? ` # ${f.name} (${f.source}): ${description}`
|
|
115
121
|
: ` # ${f.name} (${f.source})`;
|
|
116
122
|
return `pulumi config set${secretFlag} ${key} ${value}${comment}`;
|
|
117
123
|
}
|
|
@@ -150,7 +156,8 @@ function renderK8s(fields, options) {
|
|
|
150
156
|
if (optional.length > 0) {
|
|
151
157
|
lines.push(" # Optional (uncomment and set to enable):");
|
|
152
158
|
for (const f of optional) {
|
|
153
|
-
const
|
|
159
|
+
const description = oneLineDescription(f);
|
|
160
|
+
const comment = description ? ` # (${f.source}): ${description}` : ` # (${f.source})`;
|
|
154
161
|
lines.push(` # ${f.name}: "<set-me>"${comment}`);
|
|
155
162
|
}
|
|
156
163
|
}
|
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",
|
|
@@ -173,14 +173,22 @@ function buildStoredEvent(event, newVersion, eventVersion, row) {
|
|
|
173
173
|
// aggregateId alone, matching prior behavior. Callers that share id
|
|
174
174
|
// generation across multiple aggregate types on the same id (e.g. a solon-
|
|
175
175
|
// style collision) must pass it to avoid folding a foreign type's events
|
|
176
|
-
// into the loaded stream.
|
|
176
|
+
// into the loaded stream. The aggregateType condition stays an unindexed
|
|
177
|
+
// post-filter on purpose: events_aggregate_version_uq already narrows the scan
|
|
178
|
+
// to one aggregate's rows, so an index including aggregate_type would only add
|
|
179
|
+
// write cost.
|
|
177
180
|
export async function loadAggregate(db, aggregateId, tenantId, options) {
|
|
181
|
+
// "" must not fall through to the unfiltered load: a dynamically computed
|
|
182
|
+
// type that came out empty would silently fold in foreign aggregate types.
|
|
183
|
+
if (options?.aggregateType === "") {
|
|
184
|
+
throw new Error("loadAggregate: aggregateType must be a non-empty string when provided");
|
|
185
|
+
}
|
|
178
186
|
if (!options?.includeArchived) {
|
|
179
187
|
const archived = await isStreamArchived(db, tenantId, aggregateId);
|
|
180
188
|
if (archived)
|
|
181
189
|
return [];
|
|
182
190
|
}
|
|
183
|
-
const rows = await selectMany(db, eventsTable, options?.aggregateType
|
|
191
|
+
const rows = await selectMany(db, eventsTable, options?.aggregateType !== undefined
|
|
184
192
|
? { aggregateId, tenantId, aggregateType: options.aggregateType }
|
|
185
193
|
: { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
|
|
186
194
|
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
|
});
|
|
@@ -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;
|
|
@@ -50,8 +50,27 @@ export async function createFileProviderForTenant(ctx, tenantId, handlerName = "
|
|
|
50
50
|
throw new Error(`${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
|
|
51
51
|
`extension options must be a FileProviderPlugin.`);
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
const built = await usage.options.build(ctx, tenantId);
|
|
54
|
+
// Extension payloads are never type-checked, so a plugin predating a
|
|
55
|
+
// required method (list) would otherwise surface as a misleading
|
|
56
|
+
// "missing s3:ListBucket permission" hint during erasure.
|
|
57
|
+
for (const method of REQUIRED_PROVIDER_METHODS) {
|
|
58
|
+
if (typeof built[method] !== "function") {
|
|
59
|
+
throw new Error(`${FEATURE_NAME}: provider "${provider}" built without the required method ${method}() — ` +
|
|
60
|
+
`update the file-provider plugin to the current FileStorageProvider contract.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return built;
|
|
54
64
|
}
|
|
65
|
+
const REQUIRED_PROVIDER_METHODS = [
|
|
66
|
+
"write",
|
|
67
|
+
"writeStream",
|
|
68
|
+
"read",
|
|
69
|
+
"readStream",
|
|
70
|
+
"delete",
|
|
71
|
+
"exists",
|
|
72
|
+
"list",
|
|
73
|
+
];
|
|
55
74
|
// Builds the resolver from the ambient AppContext fields — the framework-side
|
|
56
75
|
// equivalent of the GDPR `makeTenantStorageProviderResolver`. The config
|
|
57
76
|
// accessor (and therefore the s3.secretAccessKey secret-read) runs under
|
package/dist/files/types.js
CHANGED
|
@@ -207,6 +207,15 @@ export function validateFileContent(fileName, content, accept) {
|
|
|
207
207
|
const ext = extractExtension(fileName);
|
|
208
208
|
if (!ext)
|
|
209
209
|
return { kind: "ok" };
|
|
210
|
+
// validateFile accepts aliased declared MIMEs (csv as vnd.ms-excel) on
|
|
211
|
+
// metadata alone, so the byte check has to live on this exported path too:
|
|
212
|
+
// a text-format extension with an alias must never carry a binary signature.
|
|
213
|
+
if (Object.hasOwn(EXTENSION_MIME_ALIASES, ext) && sniffMimeType(content) !== null) {
|
|
214
|
+
return {
|
|
215
|
+
kind: "rejected",
|
|
216
|
+
error: `content_mismatch: ".${ext}" upload bytes are a binary format`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
210
219
|
const signatureMimeTypes = signatureMimeTypesForExtension(ext);
|
|
211
220
|
if (signatureMimeTypes.length === 0)
|
|
212
221
|
return { kind: "ok" };
|
|
@@ -261,9 +270,15 @@ extensionOverride) {
|
|
|
261
270
|
const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
|
|
262
271
|
return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
|
|
263
272
|
}
|
|
273
|
+
const EXPORTS_PREFIX_SEGMENT = "exports";
|
|
264
274
|
/** Fixed leading segment so one S3 lifecycle rule (Prefix: "exports/") can expire export bundles for every tenant without matching a buildStorageKey() upload. */
|
|
265
275
|
export function tenantExportPrefix(tenantId) {
|
|
266
|
-
|
|
276
|
+
// A tenant named like the reserved segment would make the sweep prefix
|
|
277
|
+
// "exports/" match every tenant's export bundles.
|
|
278
|
+
if (tenantId === EXPORTS_PREFIX_SEGMENT) {
|
|
279
|
+
throw new Error(`tenantId "${tenantId}" collides with the reserved storage segment`);
|
|
280
|
+
}
|
|
281
|
+
return `${EXPORTS_PREFIX_SEGMENT}/${tenantId}/`;
|
|
267
282
|
}
|
|
268
283
|
/**
|
|
269
284
|
* Every storage-key prefix a tenant's binaries can live under. A tenant-destroy
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export declare const DEFAULT_LOCALE = "en";
|
|
2
2
|
export declare function isValidLocaleTag(value: string): boolean;
|
|
3
|
-
/** BCP-47 is case-insensitive; canonicalize the
|
|
4
|
-
* so registry lookups (`mailT`, `hasMailTranslations`)
|
|
3
|
+
/** BCP-47 is case-insensitive; canonicalize to the RFC 5646 conventional form
|
|
4
|
+
* (`de-AT`, `zh-Hant-TW`) so registry lookups (`mailT`, `hasMailTranslations`)
|
|
5
|
+
* hit registered keys regardless of header casing. */
|
|
5
6
|
export declare function canonicalizeLocaleTag(tag: string): string;
|
|
6
7
|
/**
|
|
7
8
|
* Picks the best well-formed tag from an Accept-Language header (RFC 9110
|
|
@@ -14,13 +14,23 @@ const MAX_LOCALE_TAG_LENGTH = 35;
|
|
|
14
14
|
export function isValidLocaleTag(value) {
|
|
15
15
|
return value.length <= MAX_LOCALE_TAG_LENGTH && LOCALE_TAG_RE.test(value);
|
|
16
16
|
}
|
|
17
|
-
/** BCP-47 is case-insensitive; canonicalize the
|
|
18
|
-
* so registry lookups (`mailT`, `hasMailTranslations`)
|
|
17
|
+
/** BCP-47 is case-insensitive; canonicalize to the RFC 5646 conventional form
|
|
18
|
+
* (`de-AT`, `zh-Hant-TW`) so registry lookups (`mailT`, `hasMailTranslations`)
|
|
19
|
+
* hit registered keys regardless of header casing. */
|
|
19
20
|
export function canonicalizeLocaleTag(tag) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
return tag
|
|
22
|
+
.split("-")
|
|
23
|
+
.map((subtag, index) => {
|
|
24
|
+
if (index === 0)
|
|
25
|
+
return subtag.toLowerCase();
|
|
26
|
+
if (/^[A-Za-z]{2}$/.test(subtag))
|
|
27
|
+
return subtag.toUpperCase();
|
|
28
|
+
if (/^[A-Za-z]{4}$/.test(subtag)) {
|
|
29
|
+
return subtag.charAt(0).toUpperCase() + subtag.slice(1).toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
return subtag.toLowerCase();
|
|
32
|
+
})
|
|
33
|
+
.join("-");
|
|
24
34
|
}
|
|
25
35
|
/**
|
|
26
36
|
* Picks the best well-formed tag from an Accept-Language header (RFC 9110
|
|
@@ -46,6 +46,24 @@ function pushSectionTitles(out, section, treatAsKey = false) {
|
|
|
46
46
|
for (const group of section.groups ?? [])
|
|
47
47
|
pushKey(out, group.title, treatAsKey);
|
|
48
48
|
}
|
|
49
|
+
// Field labels and section titles of a step rendered through the action-form shim.
|
|
50
|
+
function pushActionFormShapedKeys(out, featureName, step) {
|
|
51
|
+
for (const fieldName of Object.keys(step.fields)) {
|
|
52
|
+
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
53
|
+
}
|
|
54
|
+
for (const section of step.layout.sections) {
|
|
55
|
+
if (isExtensionEditSection(section)) {
|
|
56
|
+
pushKey(out, section.title);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (section.kind === "relatedList")
|
|
60
|
+
continue; // rejected at boot, unreachable here
|
|
61
|
+
pushSectionTitles(out, section);
|
|
62
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
63
|
+
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, editFieldName(f)));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
49
67
|
function pushRowActionKeys(out, action) {
|
|
50
68
|
pushKey(out, action.label);
|
|
51
69
|
if (action.kind === "writeHandler" || action.kind === undefined) {
|
|
@@ -242,26 +260,9 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
242
260
|
break;
|
|
243
261
|
}
|
|
244
262
|
case "secretMint": {
|
|
245
|
-
// Mirrors the actionForm case above (submitLabel, fields/section titles
|
|
246
|
-
// via ACTION_FORM_ENTITY) plus the reveal-phase's own i18n surface.
|
|
247
263
|
const mint = screen;
|
|
248
264
|
pushKey(out, mint.submitLabel);
|
|
249
|
-
|
|
250
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
251
|
-
}
|
|
252
|
-
for (const section of mint.layout.sections) {
|
|
253
|
-
if (isExtensionEditSection(section)) {
|
|
254
|
-
pushKey(out, section.title);
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (section.kind === "relatedList")
|
|
258
|
-
continue; // rejected at boot, unreachable here
|
|
259
|
-
pushSectionTitles(out, section);
|
|
260
|
-
for (const f of sectionFieldSpecs(section)) {
|
|
261
|
-
const fieldName = editFieldName(f);
|
|
262
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
+
pushActionFormShapedKeys(out, featureName, mint);
|
|
265
266
|
pushKey(out, mint.reveal.title);
|
|
266
267
|
pushKey(out, mint.reveal.warning);
|
|
267
268
|
pushKey(out, mint.reveal.confirmLabel);
|
|
@@ -271,22 +272,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
271
272
|
if (mint.confirm !== undefined) {
|
|
272
273
|
pushKey(out, mint.confirm.submitLabel);
|
|
273
274
|
pushKey(out, mint.confirm.doneMessage);
|
|
274
|
-
|
|
275
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
276
|
-
}
|
|
277
|
-
for (const section of mint.confirm.layout.sections) {
|
|
278
|
-
if (isExtensionEditSection(section)) {
|
|
279
|
-
pushKey(out, section.title);
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
if (section.kind === "relatedList")
|
|
283
|
-
continue; // rejected at boot, unreachable here
|
|
284
|
-
pushSectionTitles(out, section);
|
|
285
|
-
for (const f of sectionFieldSpecs(section)) {
|
|
286
|
-
const fieldName = editFieldName(f);
|
|
287
|
-
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
288
|
-
}
|
|
289
|
-
}
|
|
275
|
+
pushActionFormShapedKeys(out, featureName, mint.confirm);
|
|
290
276
|
}
|
|
291
277
|
break;
|
|
292
278
|
}
|
|
@@ -353,6 +339,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
353
339
|
}
|
|
354
340
|
if (isWriteFormEditSection(section)) {
|
|
355
341
|
pushKey(out, section.title);
|
|
342
|
+
pushKey(out, section.description);
|
|
356
343
|
pushKey(out, section.submitLabel);
|
|
357
344
|
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
358
345
|
for (const f of section.fields) {
|
|
@@ -404,7 +391,8 @@ function collectEntityListFilterKeys(feature, out) {
|
|
|
404
391
|
out.add(key);
|
|
405
392
|
}
|
|
406
393
|
}
|
|
407
|
-
else if (def.type === "select"
|
|
394
|
+
else if ((def.type === "select" || def.type === "multiSelect") &&
|
|
395
|
+
Array.isArray(def.options)) {
|
|
408
396
|
for (const value of def.options) {
|
|
409
397
|
out.add(selectFacetOptionKey(feature.name, screen.entity, fieldName, value));
|
|
410
398
|
}
|
|
@@ -39,5 +39,8 @@ export const SETTINGS_HUB_I18N = {
|
|
|
39
39
|
"config.secrets.placeholder": { en: "Enter a value" },
|
|
40
40
|
"config.secrets.replacePlaceholder": { en: "Enter a new value to replace it" },
|
|
41
41
|
"config.secrets.delete": { en: "Remove" },
|
|
42
|
+
"config.secrets.deleteConfirm": {
|
|
43
|
+
en: "Remove this secret? Anything using it stops working, and it cannot be restored.",
|
|
44
|
+
},
|
|
42
45
|
"screen:secrets.title": { en: "Secrets" },
|
|
43
46
|
};
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, } from "./job-runner.js";
|
|
2
|
-
export { createJobRunner } from "./job-runner.js";
|
|
1
|
+
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
2
|
+
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
package/dist/jobs/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createJobRunner } from "./job-runner.js";
|
|
1
|
+
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
|
@@ -28,6 +28,8 @@ export type JobOutcomeMeta = {
|
|
|
28
28
|
readonly messageKey: string | null;
|
|
29
29
|
} | undefined;
|
|
30
30
|
};
|
|
31
|
+
export type JobSubjectValue = string | number | boolean | null;
|
|
32
|
+
export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
|
|
31
33
|
export type JobRunner = {
|
|
32
34
|
start(): Promise<void>;
|
|
33
35
|
stop(): Promise<void>;
|