@cosmicdrift/kumiko-framework 0.165.0 → 0.165.1
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/package.json +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +146 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +178 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +103 -35
- package/src/api/server.ts +26 -2
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +8 -1
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -5
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/tenant-db.ts +2 -4
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +4 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +5 -5
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +43 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +28 -39
- package/src/time/tz-context.ts +30 -24
- package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
- package/src/utils/safe-json.ts +3 -2
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/define-handler";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/define-handler";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/event-type-map";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/event-type-map";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/feature";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/feature";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/fields";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/fields";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/handlers";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/hooks";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/hooks";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/http-route";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/http-route";
|
package/src/engine/types/nav.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/nav";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/nav";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/ownership";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/ownership";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/projection";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/projection";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/relations";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/relations";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/screen";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/screen";
|
package/src/engine/types/step.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/step";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/step";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/target-ref";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/target-ref";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "@cosmicdrift/kumiko-types/tree-node";
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/tree-node";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/workspace";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/workspace";
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
// caught by this validator. A future lint-rule will enforce the contract
|
|
24
24
|
// statically; today it lives in this comment + the StepBuilder doc.
|
|
25
25
|
|
|
26
|
+
import {
|
|
27
|
+
KUMIKO_META_SYMBOL,
|
|
28
|
+
KUMIKO_NAME_SYMBOL,
|
|
29
|
+
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
26
30
|
import { getStep } from "./define-step";
|
|
27
31
|
import { buildPipelineSteps } from "./pipeline";
|
|
28
32
|
import type { FeatureDefinition, SessionUser, TenantId, WriteEvent } from "./types";
|
|
@@ -55,13 +59,9 @@ function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance,
|
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
// @cast-boundary drizzle-bridge — reads table name from a Symbol without
|
|
59
|
-
// importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
|
|
60
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
61
62
|
// table()/buildEntityTable spread column handles as enumerable props, so an
|
|
62
63
|
// entity field named `tableName`/`source` would shadow the matching meta key —
|
|
63
|
-
// the canonical meta under
|
|
64
|
-
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
64
|
+
// the canonical meta under KUMIKO_META_SYMBOL is the only collision-safe source.
|
|
65
65
|
|
|
66
66
|
function resolveTableNameFromStep(table: unknown): string {
|
|
67
67
|
if (typeof table === "object" && table !== null) {
|
package/src/errors/classes.ts
CHANGED
|
@@ -138,6 +138,27 @@ export class VersionConflictError extends ConflictError {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// The caller retried a write with an idempotencyKey it had already used —
|
|
142
|
+
// the event-store's partial unique index on metadata.idempotencyKey caught
|
|
143
|
+
// the duplicate. Distinct from unique_violation/version_conflict: this is
|
|
144
|
+
// not an error the caller should retry, it means the earlier attempt
|
|
145
|
+
// already succeeded and this one is a no-op replay.
|
|
146
|
+
export class IdempotentReplayError extends ConflictError {
|
|
147
|
+
override readonly code: string = "idempotent_replay";
|
|
148
|
+
|
|
149
|
+
constructor(
|
|
150
|
+
details: { readonly idempotencyKey: string },
|
|
151
|
+
opts?: Pick<ErrorOpts, "i18nKey" | "cause">,
|
|
152
|
+
) {
|
|
153
|
+
super({
|
|
154
|
+
message: `event with idempotencyKey "${details.idempotencyKey}" was already appended`,
|
|
155
|
+
i18nKey: opts?.i18nKey ?? "errors.idempotentReplay",
|
|
156
|
+
details,
|
|
157
|
+
...(opts?.cause && { cause: opts.cause }),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
// Entity-level unique-index violation. Distinct from VersionConflictError:
|
|
142
163
|
// version_conflict means "two writers raced on the events_aggregate_version
|
|
143
164
|
// _uq index" (optimistic-concurrency); unique_violation means "you tried to
|
package/src/errors/index.ts
CHANGED
|
@@ -7,8 +7,12 @@ import { KumikoError } from "./kumiko-error";
|
|
|
7
7
|
import { FrameworkReasons } from "./reasons";
|
|
8
8
|
import { buildInvalidTransitionDetails } from "./transition-details";
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
10
|
+
// Re-exported so a caller importing this module directly (instead of via
|
|
11
|
+
// the `./errors` barrel, which already re-exports these by name) still
|
|
12
|
+
// gets the types — explicit, not a wildcard: this module isn't a public
|
|
13
|
+
// subpath export (see package.json `exports`), so the only readers are
|
|
14
|
+
// framework-internal.
|
|
15
|
+
export type { WriteErrorInfo, WriteFailure };
|
|
12
16
|
|
|
13
17
|
// Convenience for call sites that return a failed WriteResult. Keeps the
|
|
14
18
|
// pattern `return writeFailure(new NotFoundError(...))` compact so handlers
|
|
@@ -15,7 +15,7 @@ import { asRawClient, selectMany } from "../../db/query";
|
|
|
15
15
|
import { createTestDb, type TestDb } from "../../stack";
|
|
16
16
|
import { generateId as uuid } from "../../utils";
|
|
17
17
|
import { appendRaw, appendRawBatch, type RawEventToAppend } from "../admin-api";
|
|
18
|
-
import { VersionConflictError } from "../errors";
|
|
18
|
+
import { IdempotentAppendConflictError, VersionConflictError } from "../errors";
|
|
19
19
|
import { append, loadAggregate } from "../event-store";
|
|
20
20
|
import { createEventsTable, eventsTable } from "../events-schema";
|
|
21
21
|
|
|
@@ -187,6 +187,32 @@ describe("appendRaw — single event", () => {
|
|
|
187
187
|
).rejects.toBeInstanceOf(VersionConflictError);
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
test("appendRaw distinguishes an idempotency-key replay from a version conflict (#1499)", async () => {
|
|
191
|
+
const idempotencyKey = uuid();
|
|
192
|
+
await appendRaw(
|
|
193
|
+
testDb.db,
|
|
194
|
+
makeEvent({
|
|
195
|
+
aggregateId: uuid(),
|
|
196
|
+
expectedVersion: 0,
|
|
197
|
+
metadata: { userId: userMigration, idempotencyKey },
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// A second, different aggregate reusing the same idempotencyKey hits
|
|
202
|
+
// the tenant-scoped partial unique index — distinct from a version
|
|
203
|
+
// race on the same aggregate, and must not be reported as one.
|
|
204
|
+
await expect(
|
|
205
|
+
appendRaw(
|
|
206
|
+
testDb.db,
|
|
207
|
+
makeEvent({
|
|
208
|
+
aggregateId: uuid(),
|
|
209
|
+
expectedVersion: 0,
|
|
210
|
+
metadata: { userId: userMigration, idempotencyKey },
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
).rejects.toBeInstanceOf(IdempotentAppendConflictError);
|
|
214
|
+
});
|
|
215
|
+
|
|
190
216
|
test("version_conflict on missing predecessor (appendRaw v=5 without v=1..4)", async () => {
|
|
191
217
|
const aggregateId = uuid();
|
|
192
218
|
// Try to write version=5 (expectedVersion=4) against an empty stream.
|
|
@@ -60,6 +60,38 @@ describe("event-store: append + load", () => {
|
|
|
60
60
|
expect(events[0]?.metadata.userId).toBe(userA);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("kumiko-framework#1490: subsequent append doesn't rely on globalThis.Temporal", async () => {
|
|
64
|
+
const aggregateId = uuid();
|
|
65
|
+
const first = await append(testDb.db, {
|
|
66
|
+
aggregateId,
|
|
67
|
+
aggregateType: "task",
|
|
68
|
+
tenantId: tenantA,
|
|
69
|
+
expectedVersion: 0,
|
|
70
|
+
type: "task.created",
|
|
71
|
+
payload: { title: "Buy milk" },
|
|
72
|
+
metadata: { userId: userA },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
76
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
77
|
+
try {
|
|
78
|
+
const second = await append(testDb.db, {
|
|
79
|
+
aggregateId,
|
|
80
|
+
aggregateType: "task",
|
|
81
|
+
tenantId: tenantA,
|
|
82
|
+
expectedVersion: first.version,
|
|
83
|
+
type: "task.updated",
|
|
84
|
+
payload: { title: "Buy oat milk" },
|
|
85
|
+
metadata: { userId: userA },
|
|
86
|
+
});
|
|
87
|
+
expect(second.version).toBe(2);
|
|
88
|
+
expect(second.createdAt).toBeDefined();
|
|
89
|
+
} finally {
|
|
90
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
91
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
63
95
|
test("subsequent appends increment version and are ordered", async () => {
|
|
64
96
|
const aggregateId = uuid();
|
|
65
97
|
const base = {
|
|
@@ -12,8 +12,6 @@ const ALLOWED_FILES = new Set([
|
|
|
12
12
|
"packages/framework/src/event-store/index.ts",
|
|
13
13
|
"packages/bundled-features/src/tenant/seeding.ts",
|
|
14
14
|
"packages/bundled-features/src/tier-engine/feature.ts",
|
|
15
|
-
"packages/bundled-features/src/auth-email-password/__tests__/email-verification.integration.test.ts",
|
|
16
|
-
"packages/bundled-features/src/auth-email-password/__tests__/password-reset.integration.test.ts",
|
|
17
15
|
"packages/framework/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts",
|
|
18
16
|
]);
|
|
19
17
|
|
|
@@ -21,11 +19,31 @@ const REPO_ROOT = `${import.meta.dir}/../../../../..`;
|
|
|
21
19
|
|
|
22
20
|
describe("unscoped stream primitives — caller allowlist", () => {
|
|
23
21
|
test("only seed/system-internal paths reference the existence-oracle primitives", async () => {
|
|
24
|
-
|
|
22
|
+
// Widened from {framework,bundled-features} — the primitives are exported
|
|
23
|
+
// from the public @cosmicdrift/kumiko-framework/event-store barrel, so any
|
|
24
|
+
// package (server-runtime, dispatcher-live, headless, renderer-web, cli, ...)
|
|
25
|
+
// can import and call them; the allowlist must cover all of them, not just
|
|
26
|
+
// the two packages that happened to have callers when this guard was written.
|
|
27
|
+
const glob = new Glob("packages/*/src/**/*.ts");
|
|
25
28
|
const matches = new Set<string>();
|
|
26
29
|
for await (const relPath of glob.scan({ cwd: REPO_ROOT })) {
|
|
27
30
|
const content = await Bun.file(`${REPO_ROOT}/${relPath}`).text();
|
|
28
|
-
|
|
31
|
+
// Strip full-line `//` comments before scanning — a bare
|
|
32
|
+
// `content.includes(symbol)` also matches a symbol only mentioned in
|
|
33
|
+
// prose (e.g. explaining why a helper isn't needed), which would force
|
|
34
|
+
// an unrelated file into the allowlist for a reference that isn't an
|
|
35
|
+
// actual import or call.
|
|
36
|
+
const code = content
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter((line) => !line.trim().startsWith("//"))
|
|
39
|
+
.join("\n");
|
|
40
|
+
const referencesSymbol = (symbol: string): boolean =>
|
|
41
|
+
new RegExp(`\\b${symbol}\\s*\\(`).test(code) ||
|
|
42
|
+
// Covers both `import { symbol } from "..."` and a re-export barrel
|
|
43
|
+
// (`export { symbol } from "./event-store"`) — a new barrel that
|
|
44
|
+
// surfaces the oracle primitive must widen the allowlist too.
|
|
45
|
+
new RegExp(`\\b(import|export)\\b[^;]*\\b${symbol}\\b`).test(code);
|
|
46
|
+
if (RESTRICTED_SYMBOLS.some(referencesSymbol)) {
|
|
29
47
|
matches.add(relPath);
|
|
30
48
|
}
|
|
31
49
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// selbst, das Guard-Script selbst.
|
|
9
9
|
|
|
10
10
|
import type { DbRunner } from "../db";
|
|
11
|
-
import { isUniqueViolation } from "../db/pg-error";
|
|
11
|
+
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
12
12
|
import {
|
|
13
13
|
eventPredecessorExists,
|
|
14
14
|
findExistingEventVersion,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
insertRawSubsequentEvent,
|
|
18
18
|
} from "../db/queries/event-store-admin";
|
|
19
19
|
import type { TenantId } from "../engine/types";
|
|
20
|
-
import { VersionConflictError } from "./errors";
|
|
20
|
+
import { IdempotentAppendConflictError, VersionConflictError } from "./errors";
|
|
21
21
|
import type { EventMetadata } from "./event-store";
|
|
22
22
|
|
|
23
23
|
export type RawEventToAppend = {
|
|
@@ -39,6 +39,13 @@ export type RawEventToAppend = {
|
|
|
39
39
|
readonly createdBy: string;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
function mapEventUniqueViolation(e: unknown, event: RawEventToAppend): Error {
|
|
43
|
+
if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
|
|
44
|
+
return new IdempotentAppendConflictError(event.tenantId, event.metadata.idempotencyKey);
|
|
45
|
+
}
|
|
46
|
+
return new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
// Mirrors append()'s two-path structure: typed builder equivalent for v=0,
|
|
43
50
|
// INSERT … SELECT … WHERE EXISTS gate for v>0. Caller-supplied createdAt +
|
|
44
51
|
// createdBy skip the usual userResolver/now() paths.
|
|
@@ -54,7 +61,7 @@ export async function appendRaw(runner: DbRunner, event: RawEventToAppend): Prom
|
|
|
54
61
|
}
|
|
55
62
|
} catch (e) {
|
|
56
63
|
if (isUniqueViolation(e)) {
|
|
57
|
-
throw
|
|
64
|
+
throw mapEventUniqueViolation(e, event);
|
|
58
65
|
}
|
|
59
66
|
throw e;
|
|
60
67
|
}
|
|
@@ -143,7 +150,7 @@ export async function appendRawBatch(
|
|
|
143
150
|
if (isUniqueViolation(e)) {
|
|
144
151
|
// Pre-flight ran but lost a race against a concurrent writer. Rare for
|
|
145
152
|
// migration (single-runner) but possible; we can't name the exact row.
|
|
146
|
-
throw
|
|
153
|
+
throw mapEventUniqueViolation(e, firstEvent);
|
|
147
154
|
}
|
|
148
155
|
throw e;
|
|
149
156
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
// Value-only import, aliased to avoid shadowing the ambient global
|
|
3
|
+
// `Temporal` TYPE this file's other Temporal.Instant annotations resolve
|
|
4
|
+
// against (StoredEvent.createdAt et al. — importing the bare name here
|
|
5
|
+
// conflicts with that ambient type, see #1438). Bun doesn't expose Temporal
|
|
6
|
+
// as a runtime value on globalThis, so the un-aliased call below crashed
|
|
7
|
+
// with "Temporal is not defined" outside boot paths that install it (#1480).
|
|
8
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
2
9
|
import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
3
10
|
import type { DbRunner } from "../db";
|
|
4
11
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
@@ -151,12 +158,20 @@ async function insertSubsequentEvent(
|
|
|
151
158
|
expectedVersion: event.expectedVersion,
|
|
152
159
|
});
|
|
153
160
|
if (!row) throw new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
161
|
+
const createdAt =
|
|
162
|
+
row.created_at instanceof Date
|
|
163
|
+
? TemporalPolyfill.Instant.fromEpochMilliseconds(row.created_at.getTime())
|
|
164
|
+
: TemporalPolyfill.Instant.from(row.created_at);
|
|
154
165
|
return {
|
|
155
166
|
id: typeof row.id === "bigint" ? row.id : BigInt(row.id),
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
|
|
168
|
+
// at runtime, two separate .d.ts sources (temporal-polyfill's bundled
|
|
169
|
+
// types vs the ambient temporal-spec global) disagree on a couple of
|
|
170
|
+
// method-overload signatures (until/equals argument types), so TS treats
|
|
171
|
+
// them as nominally distinct. Safe: this value is only ever consumed as
|
|
172
|
+
// a Temporal.Instant (comparisons, .toString(), arithmetic), never
|
|
173
|
+
// narrowed on the specific overload shape that differs.
|
|
174
|
+
createdAt: createdAt as unknown as Temporal.Instant,
|
|
160
175
|
};
|
|
161
176
|
}
|
|
162
177
|
|
package/src/event-store/types.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
-
export * from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
export type * from "@cosmicdrift/kumiko-types/event-store-types";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildStorageKey } from "../types";
|
|
3
|
+
|
|
4
|
+
describe("buildStorageKey", () => {
|
|
5
|
+
test("uses the lowercased extension for a normal filename", () => {
|
|
6
|
+
const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "logo.PNG", "u1");
|
|
7
|
+
expect(key).toBe("T1/invoice/1/attachment/u1.png");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses the sole segment as extension when there is no dot", () => {
|
|
11
|
+
const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "noext", "u1");
|
|
12
|
+
expect(key).toBe("T1/invoice/1/attachment/u1.noext");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects a path-traversal filename and falls back to bin instead of leaking the payload", () => {
|
|
16
|
+
const key = buildStorageKey(
|
|
17
|
+
"T1" as never,
|
|
18
|
+
"unattached",
|
|
19
|
+
"file",
|
|
20
|
+
"u",
|
|
21
|
+
"a.b/../../../../evil",
|
|
22
|
+
"u1",
|
|
23
|
+
);
|
|
24
|
+
expect(key).toBe("T1/unattached/file/u/u1.bin");
|
|
25
|
+
expect(key).not.toContain("..");
|
|
26
|
+
expect(key.split("/")).toHaveLength(5);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createLocalProvider } from "../local-provider";
|
|
6
|
+
|
|
7
|
+
describe("createLocalProvider path-traversal guard", () => {
|
|
8
|
+
const basePath = join(tmpdir(), `kumiko-local-provider-test-${Date.now()}`);
|
|
9
|
+
const provider = createLocalProvider(basePath);
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await rm(basePath, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects a key with a `..` segment even when it resolves inside basePath", async () => {
|
|
16
|
+
await expect(
|
|
17
|
+
provider.write("T1/e/1/f/x.png/../../../../T2/y.png", new Uint8Array([1])),
|
|
18
|
+
).rejects.toThrow(/path-traversal/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects a `..` segment on read/delete too, and exists() reports false instead of throwing", async () => {
|
|
22
|
+
await expect(provider.read("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
|
|
23
|
+
await expect(provider.delete("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
|
|
24
|
+
expect(await provider.exists("a/../../etc/passwd")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("still allows a normal, contained key", async () => {
|
|
28
|
+
await provider.write("T1/entity/1/field/file.png", new Uint8Array([1, 2, 3]));
|
|
29
|
+
expect(await provider.exists("T1/entity/1/field/file.png")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -153,10 +153,10 @@ describe("FileStorageProvider.writeStream — local-filesystem", () => {
|
|
|
153
153
|
const escapingKey = "../../etc/passwd";
|
|
154
154
|
|
|
155
155
|
await expect(provider.write(escapingKey, new Uint8Array([1]))).rejects.toThrow(
|
|
156
|
-
/
|
|
156
|
+
/path-traversal/,
|
|
157
157
|
);
|
|
158
|
-
await expect(provider.read(escapingKey)).rejects.toThrow(/
|
|
159
|
-
await expect(provider.delete(escapingKey)).rejects.toThrow(/
|
|
158
|
+
await expect(provider.read(escapingKey)).rejects.toThrow(/path-traversal/);
|
|
159
|
+
await expect(provider.delete(escapingKey)).rejects.toThrow(/path-traversal/);
|
|
160
160
|
// exists() treats any stat failure (missing file, escaping key) as "not there".
|
|
161
161
|
expect(await provider.exists(escapingKey)).toBe(false);
|
|
162
162
|
});
|
package/src/files/index.ts
CHANGED
|
@@ -34,6 +34,6 @@ export type {
|
|
|
34
34
|
SignedUrlOptions,
|
|
35
35
|
WriteStreamOptions,
|
|
36
36
|
} from "./types";
|
|
37
|
-
export { buildStorageKey, parseMaxSize, validateFile } from "./types";
|
|
37
|
+
export { assertSafeStorageKey, buildStorageKey, parseMaxSize, validateFile } from "./types";
|
|
38
38
|
export type { ZipEntry } from "./zip-stream";
|
|
39
39
|
export { createZipStream } from "./zip-stream";
|
|
@@ -2,7 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
|
-
import type
|
|
5
|
+
import { assertSafeStorageKey, type FileStorageProvider } from "./types";
|
|
6
6
|
|
|
7
7
|
// Local-filesystem backend — intended for dev + tests. Production deploys
|
|
8
8
|
// pick an object-store provider (S3/R2/…). mimeType is ignored here; the
|
|
@@ -16,6 +16,11 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
|
16
16
|
// segment that slipped past an upstream check), regardless of the key's
|
|
17
17
|
// source.
|
|
18
18
|
function resolveContainedPath(key: string): string {
|
|
19
|
+
assertSafeStorageKey(key);
|
|
20
|
+
// assertSafeStorageKey rejects every literal "."/".." segment, which
|
|
21
|
+
// covers all string-based traversal a join+resolve can produce; this
|
|
22
|
+
// startsWith check is a second, independent line of defense in case a
|
|
23
|
+
// future caller feeds resolveContainedPath something pre-resolved.
|
|
19
24
|
const filePath = resolve(join(basePath, key));
|
|
20
25
|
if (filePath !== resolvedBase && !filePath.startsWith(resolvedBase + sep)) {
|
|
21
26
|
throw new Error(`storage key escapes basePath: "${key}"`);
|
package/src/files/types.ts
CHANGED
|
@@ -84,6 +84,12 @@ export function validateFile(
|
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export function assertSafeStorageKey(key: string): void {
|
|
88
|
+
if (key.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
89
|
+
throw new Error(`storage key contains a path-traversal segment: "${key}"`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
87
93
|
export function buildStorageKey(
|
|
88
94
|
tenantId: TenantId,
|
|
89
95
|
entityType: string,
|
|
@@ -92,6 +98,7 @@ export function buildStorageKey(
|
|
|
92
98
|
fileName: string,
|
|
93
99
|
uniqueId: string,
|
|
94
100
|
): string {
|
|
95
|
-
const
|
|
101
|
+
const rawExt = fileName.split(".").pop() ?? "";
|
|
102
|
+
const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
|
|
96
103
|
return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
|
|
97
104
|
}
|