@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +39 -6
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +7 -1
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +36 -4
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +78 -42
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +10 -2
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +24 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +37 -5
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +21 -1
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +53 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +136 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +33 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +70 -40
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +44 -4
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +13 -3
- package/dist/engine/index.d.ts +9 -3
- package/dist/engine/index.js +7 -2
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +11 -3
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +49 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- 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.js +13 -7
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/files/types.js +21 -1
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +228 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EntityDefinition } from "./types/index.js";
|
|
2
|
+
export declare const MAX_TRANSFER_DEPTH = 5;
|
|
3
|
+
export type TransferEdgeLink = {
|
|
4
|
+
readonly kind: "parentRef";
|
|
5
|
+
readonly typeField: string;
|
|
6
|
+
readonly idField: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: "reference";
|
|
9
|
+
readonly field: string;
|
|
10
|
+
};
|
|
11
|
+
export type TransferEdge = {
|
|
12
|
+
readonly entityName: string;
|
|
13
|
+
readonly entity: EntityDefinition;
|
|
14
|
+
/** The already-resolved type this edge hangs off — its moved row ids are
|
|
15
|
+
* the parent ids the mover matches this edge's rows against. */
|
|
16
|
+
readonly parentEntityName: string;
|
|
17
|
+
readonly link: TransferEdgeLink;
|
|
18
|
+
};
|
|
19
|
+
/** Outgoing edges per type: keyed by the type an edge hangs off, so the mover
|
|
20
|
+
* can ask "what hangs off the rows I just moved" for any type it reaches, at
|
|
21
|
+
* any depth, as often as it reaches it. */
|
|
22
|
+
export type TransferAdjacency = ReadonlyMap<string, readonly TransferEdge[]>;
|
|
23
|
+
export declare function resolveTransferAdjacency(entities: ReadonlyMap<string, EntityDefinition>, rootEntityType?: string): TransferAdjacency;
|
|
24
|
+
export declare function findOverDeepTransferChain(adjacency: TransferAdjacency, onPath: readonly string[]): readonly string[] | undefined;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { parseRefTargetEntityName } from "./parse-ref-target.js";
|
|
2
|
+
// The single definition of the limit. It lives here rather than next to the
|
|
3
|
+
// resolver in bundled-features because the dependency only runs that way:
|
|
4
|
+
// bundled-features imports from the framework, never the reverse.
|
|
5
|
+
//
|
|
6
|
+
// Guards against a schema whose reference edges span more levels than anyone
|
|
7
|
+
// intended — a runaway graph would move rows an operator never associated with
|
|
8
|
+
// the handover. Deliberately a constant and not per-feature config: the limit
|
|
9
|
+
// is a safety net, and a configurable one gets raised by whoever trips it
|
|
10
|
+
// instead of prompting them to reconsider their graph.
|
|
11
|
+
export const MAX_TRANSFER_DEPTH = 5;
|
|
12
|
+
// A `multiple` reference stores a jsonb array rather than a single id column,
|
|
13
|
+
// which the mover's `= ANY($ids)` match cannot address. The boot validator
|
|
14
|
+
// (boot-validator/transfer-graph.ts) rejects that combination on transferable
|
|
15
|
+
// entities, so skipping it here can only ever hit an entity that is not
|
|
16
|
+
// transferable — never a silent partial move.
|
|
17
|
+
// `handover: "stay"` is the declared opt-out: the reference crosses the tenant
|
|
18
|
+
// boundary after a handover on purpose, so the edge is not walked.
|
|
19
|
+
function referenceEdgesFrom(entityName, entity) {
|
|
20
|
+
const edges = [];
|
|
21
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
22
|
+
if (field.type !== "reference")
|
|
23
|
+
continue;
|
|
24
|
+
if (field.multiple === true)
|
|
25
|
+
continue;
|
|
26
|
+
if (field.handover === "stay")
|
|
27
|
+
continue;
|
|
28
|
+
edges.push({
|
|
29
|
+
entityName,
|
|
30
|
+
entity,
|
|
31
|
+
parentEntityName: parseRefTargetEntityName(field.entity),
|
|
32
|
+
link: { kind: "reference", field: fieldName },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return edges;
|
|
36
|
+
}
|
|
37
|
+
// A parentRef names the types it accepts; without `allowedTypes` it accepts
|
|
38
|
+
// any of them, so the edge is recorded against every registered type.
|
|
39
|
+
function parentRefEdgesFrom(entityName, entity, allEntityNames) {
|
|
40
|
+
const parentRef = entity.parentRef;
|
|
41
|
+
if (!parentRef)
|
|
42
|
+
return [];
|
|
43
|
+
const link = {
|
|
44
|
+
kind: "parentRef",
|
|
45
|
+
typeField: parentRef.entityTypeField,
|
|
46
|
+
idField: parentRef.entityIdField,
|
|
47
|
+
};
|
|
48
|
+
return (parentRef.allowedTypes ?? allEntityNames).map((parentEntityName) => ({
|
|
49
|
+
entityName,
|
|
50
|
+
entity,
|
|
51
|
+
parentEntityName,
|
|
52
|
+
link,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
// The one definition of the transfer graph, shared by the boot validator
|
|
56
|
+
// (depth limit) and the tenant-handover mover (traversal), so the limit that is
|
|
57
|
+
// checked at boot is measured over exactly the edges that are walked at claim
|
|
58
|
+
// time. Edges are kept for every entity, transferable or not: a
|
|
59
|
+
// non-transferable node in the middle of a chain is still a hop the mover
|
|
60
|
+
// takes before it fails the claim.
|
|
61
|
+
//
|
|
62
|
+
// A type can legitimately be reached by more than one edge — `note.vehicleId`
|
|
63
|
+
// and `note.campaignId` are distinct, and collapsing them by entity name would
|
|
64
|
+
// leave one edge's rows behind, the silent partial move #3088 exists to end.
|
|
65
|
+
export function resolveTransferAdjacency(entities, rootEntityType) {
|
|
66
|
+
const allEntityNames = [...entities.keys()];
|
|
67
|
+
const byParent = new Map();
|
|
68
|
+
for (const [entityName, entity] of entities) {
|
|
69
|
+
// skip: the root is never collected as someone else's descendant. A self
|
|
70
|
+
// reference would otherwise drag unrelated rows of the root's own type
|
|
71
|
+
// across the tenant boundary along with the one that was claimed.
|
|
72
|
+
if (entityName === rootEntityType)
|
|
73
|
+
continue;
|
|
74
|
+
for (const edge of [
|
|
75
|
+
...parentRefEdgesFrom(entityName, entity, allEntityNames),
|
|
76
|
+
...referenceEdgesFrom(entityName, entity),
|
|
77
|
+
]) {
|
|
78
|
+
const edges = byParent.get(edge.parentEntityName) ?? [];
|
|
79
|
+
edges.push(edge);
|
|
80
|
+
byParent.set(edge.parentEntityName, edges);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return byParent;
|
|
84
|
+
}
|
|
85
|
+
// `onPath` counts NODES, the limit counts EDGES: a chain of exactly
|
|
86
|
+
// MAX_TRANSFER_DEPTH edges holds MAX_TRANSFER_DEPTH + 1 entities, and the
|
|
87
|
+
// mover runs MAX_TRANSFER_DEPTH rounds of one hop each, so it still walks that
|
|
88
|
+
// chain whole. Returns the first path that exceeds the limit.
|
|
89
|
+
export function findOverDeepTransferChain(adjacency, onPath) {
|
|
90
|
+
if (onPath.length > MAX_TRANSFER_DEPTH + 1)
|
|
91
|
+
return onPath;
|
|
92
|
+
const startName = onPath[onPath.length - 1];
|
|
93
|
+
if (startName === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
for (const edge of adjacency.get(startName) ?? []) {
|
|
96
|
+
// skip: a cycle revisits a type already on this path, and schema depth
|
|
97
|
+
// cannot measure how far it actually runs — that depends on the rows, not
|
|
98
|
+
// the declaration. The mover carries this one instead, failing with
|
|
99
|
+
// `transfer_graph_too_deep` when its rounds run out (#3131).
|
|
100
|
+
if (onPath.includes(edge.entityName))
|
|
101
|
+
continue;
|
|
102
|
+
const deeper = findOverDeepTransferChain(adjacency, [...onPath, edge.entityName]);
|
|
103
|
+
if (deeper !== undefined)
|
|
104
|
+
return deeper;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
@@ -17,9 +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
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// keyword. Returns an empty set when nothing is derivable; callers that need
|
|
21
|
+
// the column-aware check must treat that as "unknown" (see
|
|
22
|
+
// assertQualifiedWhereFragment, which fails closed on it).
|
|
23
23
|
export function tableColumnSqlNames(table) {
|
|
24
24
|
if (table === null || typeof table !== "object")
|
|
25
25
|
return new Set();
|
|
@@ -182,6 +182,9 @@ export function assertQualifiedWhereFragment(sqlText, columnSqlNames, scope) {
|
|
|
182
182
|
// the fail-open shape this lint targets is impossible here.
|
|
183
183
|
if (!/\bSELECT\b/i.test(cleaned))
|
|
184
184
|
return;
|
|
185
|
+
if (columnSqlNames.size === 0) {
|
|
186
|
+
throw new Error(`[Kumiko Ownership] ${scope}: cannot verify where-rule SQL containing a subquery — the table's column set is unknown, so unqualified column references cannot be detected.`);
|
|
187
|
+
}
|
|
185
188
|
const unqualified = findUnqualifiedColumnReference(cleaned, columnSqlNames);
|
|
186
189
|
if (unqualified !== null) {
|
|
187
190
|
throw new Error(`[Kumiko Ownership] ${scope}: where-rule SQL references column "${unqualified}" unqualified inside a subquery. Postgres binds an unqualified name to the innermost table first, which silently turns the ownership predicate into a tautology (fail-open). Qualify it with the outer table, e.g. \`\${ctx.tableName}.${unqualified}\`.`);
|
|
@@ -30,6 +30,8 @@ type JobsBlock = {
|
|
|
30
30
|
readonly onJobStart?: JobRunnerOptions["onJobStart"];
|
|
31
31
|
readonly onJobComplete?: JobRunnerOptions["onJobComplete"];
|
|
32
32
|
readonly onJobFailed?: JobRunnerOptions["onJobFailed"];
|
|
33
|
+
readonly onJobQueued?: JobRunnerOptions["onJobQueued"];
|
|
34
|
+
readonly onJobDropped?: JobRunnerOptions["onJobDropped"];
|
|
33
35
|
};
|
|
34
36
|
export type ApiEntrypointOptions = BaseEntrypointOptions & {
|
|
35
37
|
readonly auth?: AuthRoutesConfig;
|
package/dist/entrypoint/index.js
CHANGED
|
@@ -176,6 +176,8 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
|
|
|
176
176
|
onJobStart: jobs.onJobStart,
|
|
177
177
|
onJobComplete: jobs.onJobComplete,
|
|
178
178
|
onJobFailed: jobs.onJobFailed,
|
|
179
|
+
onJobQueued: jobs.onJobQueued,
|
|
180
|
+
onJobDropped: jobs.onJobDropped,
|
|
179
181
|
});
|
|
180
182
|
lifecycle.registerShutdownHook(hookName, async () => {
|
|
181
183
|
await jobRunner.stop();
|
package/dist/env/index.d.ts
CHANGED
|
@@ -88,6 +88,10 @@ export type ParseEnvOptions = {
|
|
|
88
88
|
/** When set, error suggestions include `pulumi config set <prefix>...`. */
|
|
89
89
|
readonly pulumiPrefix?: string;
|
|
90
90
|
};
|
|
91
|
+
/** Validates `env` against `schema` and throws KumikoBootError on any issue.
|
|
92
|
+
* A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
|
|
93
|
+
* is `undefined` in the result despite its `string` type — it is only set once
|
|
94
|
+
* the boot-time decrypt (resolvePlatformKeks) has run. */
|
|
91
95
|
export declare function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S, env: Record<string, string | undefined>, options?: ParseEnvOptions): z.infer<S>;
|
|
92
96
|
/** Dry-Run-Gegenstück zu parseEnv (KUMIKO_DRY_RUN_ENV-Pfad): parst jedes
|
|
93
97
|
* Feld einzeln und liefert ein ehrliches Partial — vorhandene Werte kommen
|
package/dist/env/index.js
CHANGED
|
@@ -135,6 +135,42 @@ export function withOptionalEnvKeys(schema, keys) {
|
|
|
135
135
|
}
|
|
136
136
|
return Object.keys(relaxed).length === 0 ? schema : schema.safeExtend(relaxed);
|
|
137
137
|
}
|
|
138
|
+
// The very same field instance is a shared fragment (several features read one
|
|
139
|
+
// variable), not a conflict; the first declaring feature owns it.
|
|
140
|
+
function isSharedFeatureFragment(featureFields, key, field) {
|
|
141
|
+
return featureFields.get(key) === field;
|
|
142
|
+
}
|
|
143
|
+
// Required beats optional, independent of feature order: a feature outside
|
|
144
|
+
// optionalFeatures must not end up with a silently optional slot.
|
|
145
|
+
function keepStrictestOfSharedFragment(merged, key, field, declaringFeatureIsOptional) {
|
|
146
|
+
if (!declaringFeatureIsOptional)
|
|
147
|
+
merged[key] = field;
|
|
148
|
+
}
|
|
149
|
+
function envVarConflictError(key, firstOwner, secondOwner) {
|
|
150
|
+
return new KumikoBootError([
|
|
151
|
+
{
|
|
152
|
+
name: key,
|
|
153
|
+
kind: "invalid",
|
|
154
|
+
message: `env-var conflict: "${key}" declared by both ` +
|
|
155
|
+
`"${firstOwner}" and "${secondOwner}" — pick one owner.`,
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
// The twin follows from the slot, so a versioned family (`…_V2`) needs no
|
|
160
|
+
// second declaration. An already declared twin (apps that predate this) stays.
|
|
161
|
+
function declareKmsCiphertextTwins(merged, sources, kmsFields) {
|
|
162
|
+
for (const { name, source } of kmsFields) {
|
|
163
|
+
const twin = `${name}_CIPHERTEXT`;
|
|
164
|
+
if (merged[twin] !== undefined)
|
|
165
|
+
continue;
|
|
166
|
+
merged[twin] = z
|
|
167
|
+
.string()
|
|
168
|
+
.min(1)
|
|
169
|
+
.optional()
|
|
170
|
+
.describe(`Key-Manager ciphertext of ${name}; used when ${name} is unset.`);
|
|
171
|
+
sources[twin] = source;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
138
174
|
export function composeEnvSchema(options) {
|
|
139
175
|
const optionalSet = new Set(options.optionalFeatures ?? []);
|
|
140
176
|
const merged = {};
|
|
@@ -157,22 +193,20 @@ export function composeEnvSchema(options) {
|
|
|
157
193
|
noteKms(key, field, "framework-core");
|
|
158
194
|
}
|
|
159
195
|
}
|
|
196
|
+
const featureFields = new Map();
|
|
160
197
|
for (const feature of options.features) {
|
|
161
198
|
if (!feature.envSchema)
|
|
162
199
|
continue;
|
|
163
|
-
const shape = zodShape(feature.envSchema);
|
|
164
200
|
const wrap = optionalSet.has(feature.name);
|
|
165
|
-
for (const [key, field] of Object.entries(
|
|
201
|
+
for (const [key, field] of Object.entries(zodShape(feature.envSchema))) {
|
|
166
202
|
if (merged[key] !== undefined) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`"${sources[key]}" and "${feature.name}" — pick one owner.`,
|
|
173
|
-
},
|
|
174
|
-
]);
|
|
203
|
+
if (!isSharedFeatureFragment(featureFields, key, field)) {
|
|
204
|
+
throw envVarConflictError(key, sources[key], feature.name);
|
|
205
|
+
}
|
|
206
|
+
keepStrictestOfSharedFragment(merged, key, field, wrap);
|
|
207
|
+
continue;
|
|
175
208
|
}
|
|
209
|
+
featureFields.set(key, field);
|
|
176
210
|
merged[key] = wrap ? optionalKeepingKmsMeta(field) : field;
|
|
177
211
|
sources[key] = feature.name;
|
|
178
212
|
noteKms(key, field, feature.name);
|
|
@@ -195,19 +229,7 @@ export function composeEnvSchema(options) {
|
|
|
195
229
|
noteKms(key, field, "app");
|
|
196
230
|
}
|
|
197
231
|
}
|
|
198
|
-
|
|
199
|
-
// second declaration. An already declared twin (apps that predate this) stays.
|
|
200
|
-
for (const { name, source } of kmsFields) {
|
|
201
|
-
const twin = `${name}_CIPHERTEXT`;
|
|
202
|
-
if (merged[twin] !== undefined)
|
|
203
|
-
continue;
|
|
204
|
-
merged[twin] = z
|
|
205
|
-
.string()
|
|
206
|
-
.min(1)
|
|
207
|
-
.optional()
|
|
208
|
-
.describe(`Key-Manager ciphertext of ${name}; used when ${name} is unset.`);
|
|
209
|
-
sources[twin] = source;
|
|
210
|
-
}
|
|
232
|
+
declareKmsCiphertextTwins(merged, sources, kmsFields);
|
|
211
233
|
return {
|
|
212
234
|
schema: z.object(merged),
|
|
213
235
|
sources,
|
|
@@ -259,6 +281,10 @@ function safeParseReportingThrows(schema, input) {
|
|
|
259
281
|
]);
|
|
260
282
|
}
|
|
261
283
|
}
|
|
284
|
+
/** Validates `env` against `schema` and throws KumikoBootError on any issue.
|
|
285
|
+
* A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
|
|
286
|
+
* is `undefined` in the result despite its `string` type — it is only set once
|
|
287
|
+
* the boot-time decrypt (resolvePlatformKeks) has run. */
|
|
262
288
|
export function parseEnv(schema, env, options = {}) {
|
|
263
289
|
// Filter undefined values to keep Zod's required-vs-invalid signal clean.
|
|
264
290
|
// (process.env returns string|undefined; passing undefined would parse
|
package/dist/errors/classes.d.ts
CHANGED
|
@@ -98,6 +98,14 @@ export declare class InternalError extends KumikoError {
|
|
|
98
98
|
readonly httpStatus = 500;
|
|
99
99
|
constructor(opts?: Pick<ErrorOpts, "message" | "i18nKey" | "i18nParams" | "details" | "cause">);
|
|
100
100
|
}
|
|
101
|
+
export declare class MemberReadTimeoutError extends KumikoError {
|
|
102
|
+
readonly code = "member_read_timeout";
|
|
103
|
+
readonly httpStatus = 504;
|
|
104
|
+
constructor(opts: {
|
|
105
|
+
readonly timeoutMs: number;
|
|
106
|
+
readonly cause?: Error;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
101
109
|
export type RateLimitDetails = {
|
|
102
110
|
readonly bucket: string;
|
|
103
111
|
readonly limit: number;
|
package/dist/errors/classes.js
CHANGED
|
@@ -189,6 +189,19 @@ export class InternalError extends KumikoError {
|
|
|
189
189
|
});
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
+
// A ctx.queryAsMember read ran into its statement timeout (SQLSTATE 57014). Reuses the generic
|
|
193
|
+
// errors.internal i18n key: the client can do nothing but retry, and the code is for log/alert routing.
|
|
194
|
+
export class MemberReadTimeoutError extends KumikoError {
|
|
195
|
+
code = "member_read_timeout";
|
|
196
|
+
httpStatus = 504;
|
|
197
|
+
constructor(opts) {
|
|
198
|
+
super({
|
|
199
|
+
message: `ctx.queryAsMember read exceeded its ${opts.timeoutMs}ms statement timeout`,
|
|
200
|
+
i18nKey: "errors.internal",
|
|
201
|
+
...(opts.cause && { cause: opts.cause }),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
192
205
|
export class RateLimitError extends KumikoError {
|
|
193
206
|
code = "rate_limited";
|
|
194
207
|
httpStatus = 429;
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
|
|
2
|
-
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
3
3
|
export type { ErrorDocsConfig } from "./docs-url.js";
|
|
4
4
|
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
5
5
|
export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
|
|
@@ -9,6 +9,7 @@ export type { AgentReason, FrameworkReason } from "./reasons.js";
|
|
|
9
9
|
export { AgentReasons, FrameworkReasons } from "./reasons.js";
|
|
10
10
|
export type { ErrorLogEntry, ErrorResponseBody } from "./serialize.js";
|
|
11
11
|
export { buildErrorLog, serializeError } from "./serialize.js";
|
|
12
|
+
export { subjectErasedConflict } from "./subject-erased.js";
|
|
12
13
|
export { toKumikoError } from "./to-kumiko-error.js";
|
|
13
14
|
export type { InvalidTransitionDetails } from "./transition-details.js";
|
|
14
15
|
export { buildInvalidTransitionDetails } from "./transition-details.js";
|
package/dist/errors/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
1
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
2
|
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
3
3
|
export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
4
4
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
|
5
5
|
export { AgentReasons, FrameworkReasons } from "./reasons.js";
|
|
6
6
|
export { buildErrorLog, serializeError } from "./serialize.js";
|
|
7
|
+
export { subjectErasedConflict } from "./subject-erased.js";
|
|
7
8
|
export { toKumikoError } from "./to-kumiko-error.js";
|
|
8
9
|
export { buildInvalidTransitionDetails } from "./transition-details.js";
|
|
9
10
|
export { failNotFound, failTransition, failUnprocessable, reraiseAsKumikoError, toWriteErrorInfo, writeFailure, } from "./write-error-info.js";
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare const FrameworkReasons: {
|
|
|
12
12
|
readonly publicIntakeRequired: "public_intake_required";
|
|
13
13
|
readonly irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk";
|
|
14
14
|
readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
|
|
15
|
+
readonly subjectErased: "subject_erased";
|
|
15
16
|
};
|
|
16
17
|
export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
|
|
17
18
|
export declare const AgentReasons: {
|
package/dist/errors/reasons.js
CHANGED
|
@@ -61,6 +61,9 @@ export const FrameworkReasons = {
|
|
|
61
61
|
// delegation via ctx.write/writeAs from a lower-risk handler does not
|
|
62
62
|
// inherit the gate.
|
|
63
63
|
instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk",
|
|
64
|
+
// ConflictError: a write touched a personal-data field of a data subject whose
|
|
65
|
+
// key was erased (crypto-shredding); the subject can no longer receive data.
|
|
66
|
+
subjectErased: "subject_erased",
|
|
64
67
|
};
|
|
65
68
|
// Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
|
|
66
69
|
// sister catalog to FrameworkReasons instead of extending it — see the
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ConflictError } from "./classes.js";
|
|
2
|
+
import { FrameworkReasons } from "./reasons.js";
|
|
3
|
+
export function subjectErasedConflict(cause) {
|
|
4
|
+
return new ConflictError({
|
|
5
|
+
message: "the data subject was erased — its personal data can no longer be written",
|
|
6
|
+
details: { reason: FrameworkReasons.subjectErased },
|
|
7
|
+
...(cause instanceof Error && { cause }),
|
|
8
|
+
});
|
|
9
|
+
}
|
package/dist/es-ops/context.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// SystemUser bypassed Access-Checks (Standard-Seed-Pattern, siehe
|
|
7
7
|
// config-seed.ts:40). Events haben createdBy = SYSTEM_TENANT_ID-User
|
|
8
8
|
// → audit-fähig.
|
|
9
|
+
import { configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto/index.js";
|
|
9
10
|
import { selectAllTenants, selectMembershipsOfUser, selectTemplateResources, selectUserByEmail, } from "../db/queries/seed-context.js";
|
|
10
11
|
import { createSystemUser, SYSTEM_TENANT_ID } from "../engine/index.js";
|
|
11
12
|
import { parseStringArrayJson } from "../utils/parse-string-array-json.js";
|
|
@@ -63,7 +64,24 @@ export function createSeedMigrationContext(args) {
|
|
|
63
64
|
},
|
|
64
65
|
findTenants: async () => {
|
|
65
66
|
const rows = await selectAllTenants(args.dbRunner);
|
|
66
|
-
|
|
67
|
+
const kms = configuredPiiSubjectKms();
|
|
68
|
+
// Sequential on purpose: each decrypt borrows from the KMS adapter's small pool.
|
|
69
|
+
const tenants = [];
|
|
70
|
+
for (const r of rows) {
|
|
71
|
+
// tenant.name is ciphertext at rest under an active KMS
|
|
72
|
+
const decrypted = kms
|
|
73
|
+
? await decryptPiiFieldValues({ name: r.name }, ["name"], kms, {
|
|
74
|
+
requestId: "es-ops:find-tenants",
|
|
75
|
+
})
|
|
76
|
+
: { name: r.name };
|
|
77
|
+
const name = decrypted["name"];
|
|
78
|
+
tenants.push({
|
|
79
|
+
id: r.id,
|
|
80
|
+
name: typeof name === "string" ? name : r.name,
|
|
81
|
+
tenantKey: r.tenant_key,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return tenants;
|
|
67
85
|
},
|
|
68
86
|
findTemplateResources: async (filter) => {
|
|
69
87
|
const rows = await selectTemplateResources(args.dbRunner, {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Allowlist: samples/*/migration/, scripts/migrations/, die Definition
|
|
9
9
|
// selbst, das Guard-Script selbst.
|
|
10
10
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
11
|
-
import { claimXactId } from "../db/queries/event-store.js";
|
|
11
|
+
import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
|
|
12
12
|
import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
|
|
13
13
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
14
14
|
function mapEventUniqueViolation(e, event) {
|
|
@@ -26,13 +26,15 @@ export async function appendRaw(runner, event) {
|
|
|
26
26
|
try {
|
|
27
27
|
// See db/queries/event-store.ts's claimXactId — gap-finality needs a
|
|
28
28
|
// real xact id assigned before this holder's insert.
|
|
29
|
-
await
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
await runAtomicEventWrite(runner, async (tx) => {
|
|
30
|
+
await claimXactId(tx);
|
|
31
|
+
if (event.expectedVersion === 0) {
|
|
32
|
+
await insertRawFirst(tx, event, newVersion, eventVersion);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
await insertRawSubsequent(tx, event, newVersion, eventVersion);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
36
38
|
}
|
|
37
39
|
catch (e) {
|
|
38
40
|
if (isUniqueViolation(e)) {
|
|
@@ -90,8 +92,10 @@ export async function appendRawBatch(runner, events) {
|
|
|
90
92
|
return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
|
|
91
93
|
});
|
|
92
94
|
try {
|
|
93
|
-
await
|
|
94
|
-
|
|
95
|
+
await runAtomicEventWrite(runner, async (tx) => {
|
|
96
|
+
await claimXactId(tx);
|
|
97
|
+
await insertRawEventBatch(tx, valuesClauses.join(", "), params);
|
|
98
|
+
});
|
|
95
99
|
}
|
|
96
100
|
catch (e) {
|
|
97
101
|
if (isUniqueViolation(e)) {
|
|
@@ -4,7 +4,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
|
4
4
|
import { requestContext } from "../api/request-context.js";
|
|
5
5
|
import { encryptEventPayloadPii } from "../crypto/event-pii.js";
|
|
6
6
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
7
|
-
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
|
|
7
|
+
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
|
|
8
8
|
import { insertOne, selectMany } from "../db/query.js";
|
|
9
9
|
import { isStreamArchived } from "./archive.js";
|
|
10
10
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
@@ -50,14 +50,16 @@ export async function append(db, event) {
|
|
|
50
50
|
const newVersion = toStore.expectedVersion + 1;
|
|
51
51
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
52
52
|
try {
|
|
53
|
-
await
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
const row = await runAtomicEventWrite(db, async (runner) => {
|
|
54
|
+
await claimXactId(runner);
|
|
55
|
+
const inserted = toStore.expectedVersion === 0
|
|
56
|
+
? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
|
|
57
|
+
: await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
|
|
58
|
+
// NOTIFY after the INSERT and inside the same transaction: PG delivers it on
|
|
59
|
+
// commit, so the dispatcher cannot wake before the row is visible.
|
|
60
|
+
await notifyPgChannel(runner, EVENTS_PUBSUB_CHANNEL);
|
|
61
|
+
return inserted;
|
|
62
|
+
});
|
|
61
63
|
return buildStoredEvent(toStore, newVersion, eventVersion, row);
|
|
62
64
|
}
|
|
63
65
|
catch (e) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// sql now comes from native dialect
|
|
2
2
|
import { bigserial, index, instant, integer, jsonb, table as pgTable, sql, text, uniqueIndex, uuid, } from "../db/dialect.js";
|
|
3
3
|
import { tableExists } from "../db/index.js";
|
|
4
|
-
import { ensureIdempotencyKeyIndex } from "../db/queries/event-store.js";
|
|
4
|
+
import { ensureIdempotencyKeyIndex, ensureMembershipPayloadTenantIndex, } from "../db/queries/event-store.js";
|
|
5
5
|
import { unsafePushTables } from "../stack/index.js";
|
|
6
6
|
import { createArchivedStreamsTable } from "./archive.js";
|
|
7
7
|
import { createSnapshotsTable } from "./snapshot.js";
|
|
@@ -61,6 +61,7 @@ export async function createEventsTable(db) {
|
|
|
61
61
|
// that predate the idempotency-key index get healed the same way
|
|
62
62
|
// ensureSnapshotVersionColumn heals kumiko_snapshots.
|
|
63
63
|
await ensureIdempotencyKeyIndex(db);
|
|
64
|
+
await ensureMembershipPayloadTenantIndex(db);
|
|
64
65
|
await createArchivedStreamsTable(db);
|
|
65
66
|
await createSnapshotsTable(db);
|
|
66
67
|
}
|
|
@@ -2,6 +2,6 @@ import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-han
|
|
|
2
2
|
import type { FileStorageProvider } from "./types.js";
|
|
3
3
|
export type { FileContext, FileHandle };
|
|
4
4
|
export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
|
|
5
|
-
export declare function createFileContext(resolve: () => Promise<FileStorageProvider
|
|
5
|
+
export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
|
|
6
6
|
export declare function deriveKey(key: string, suffix: string): string;
|
|
7
7
|
export declare function storageKeyStemPrefix(key: string): string;
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
// the bytes, and big files stay in the storage layer where they belong.
|
|
8
8
|
//
|
|
9
9
|
// `derive(suffix)` is the primitive for thumbnail/variant keys: it inserts a
|
|
10
|
-
// suffix before the file extension — `foo/bar.jpg` + `"medium"` →
|
|
11
|
-
// `foo/bar.medium
|
|
10
|
+
// suffix before the file extension — `foo/bar.jpg` + `"medium-<16 hex>"` →
|
|
11
|
+
// `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
|
|
12
|
+
// the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
|
|
13
|
+
import { isDerivativeSuffix } from "../derivatives/variant-key.js";
|
|
12
14
|
// `getProvider` is a lazily-resolved, memoized accessor — the provider is
|
|
13
15
|
// resolved (config + s3.secretAccessKey secret read) only when a handle method
|
|
14
16
|
// actually does I/O, never on every request that merely builds a ctx.
|
|
@@ -19,7 +21,15 @@ export function createFileHandle(key, getProvider) {
|
|
|
19
21
|
write: async (data, mimeType) => (await getProvider()).write(key, data, mimeType),
|
|
20
22
|
delete: async () => (await getProvider()).delete(key),
|
|
21
23
|
exists: async () => (await getProvider()).exists(key),
|
|
22
|
-
derive: (suffix) =>
|
|
24
|
+
derive: (suffix) => {
|
|
25
|
+
// A suffix outside the derivative grammar would write a key that the
|
|
26
|
+
// forget/tenant-destroy sweep (isDerivativeKeyOf) can never recognize,
|
|
27
|
+
// so the binary would survive erasure.
|
|
28
|
+
if (!isDerivativeSuffix(suffix)) {
|
|
29
|
+
throw new Error(`FileHandle.derive: suffix "${suffix}" must be <name>-<16 hex chars>; build it with variantSuffix(name, spec)`);
|
|
30
|
+
}
|
|
31
|
+
return createFileHandle(deriveKey(key, suffix), getProvider);
|
|
32
|
+
},
|
|
23
33
|
};
|
|
24
34
|
}
|
|
25
35
|
// `resolve` is a tenant-bound thunk (the caller binds the tenant). The result
|
|
@@ -27,15 +37,29 @@ export function createFileHandle(key, getProvider) {
|
|
|
27
37
|
// produces — never a process-global cache, since each FileContext is bound to a
|
|
28
38
|
// single request/event tenant; sharing would leak one tenant's provider (and
|
|
29
39
|
// its bucket/credentials) to another.
|
|
30
|
-
|
|
40
|
+
//
|
|
41
|
+
// With `tenantId`, `ref` and `list` only accept keys/prefixes inside `${tenantId}/` —
|
|
42
|
+
// the key space is the isolation boundary in a store shared by several tenants, and
|
|
43
|
+
// a request-derived key or prefix (or "") would otherwise reach every tenant's keys.
|
|
44
|
+
export function createFileContext(resolve, tenantId) {
|
|
31
45
|
let cached;
|
|
32
46
|
const getProvider = () => {
|
|
33
47
|
cached ??= resolve();
|
|
34
48
|
return cached;
|
|
35
49
|
};
|
|
36
50
|
return {
|
|
37
|
-
ref: (key) =>
|
|
38
|
-
|
|
51
|
+
ref: (key) => {
|
|
52
|
+
if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
|
|
53
|
+
throw new Error(`FileContext.ref: key must start with "${tenantId}/"`);
|
|
54
|
+
}
|
|
55
|
+
return createFileHandle(key, getProvider);
|
|
56
|
+
},
|
|
57
|
+
list: async (prefix) => {
|
|
58
|
+
if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
|
|
59
|
+
throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
|
|
60
|
+
}
|
|
61
|
+
return (await getProvider()).list(prefix);
|
|
62
|
+
},
|
|
39
63
|
};
|
|
40
64
|
}
|
|
41
65
|
// Splits a key into the part before the last dot of its last path segment
|