@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -1,50 +1,12 @@
|
|
|
1
|
+
import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
|
|
1
2
|
import type { DbRunner } from "../db/connection";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
AppendEventFn,
|
|
5
|
-
KumikoEventTypeMap,
|
|
6
|
-
Registry,
|
|
7
|
-
TenantId,
|
|
8
|
-
UnsafeAppendEventFn,
|
|
9
|
-
} from "../engine/types";
|
|
10
|
-
import { loadAggregate, loadAggregateAsOf, type StoredEvent } from "../event-store/event-store";
|
|
3
|
+
import type { AppendEventArgs, AppendEventFn, Registry, TenantId } from "../engine/types";
|
|
4
|
+
import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
|
|
11
5
|
import { upcastStoredEvents } from "../event-store/upcaster";
|
|
12
6
|
import type { FileContext } from "../files/file-handle";
|
|
13
7
|
import { appendDomainEventCore } from "./append-event-core";
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
// when it needs to produce follow-up events (saga / process-manager
|
|
17
|
-
// pattern). Keeps the MSP feature-decoupled: applies don't reach into
|
|
18
|
-
// handler-bridge (no query/write/writeAs), they just read the aggregate
|
|
19
|
-
// stream and append new events — Marten's session scope for projections.
|
|
20
|
-
//
|
|
21
|
-
// TMap propagates the strict event-type-map (see HandlerContext). Default
|
|
22
|
-
// matches the global KumikoEventTypeMap; runtime-pluggable callers route
|
|
23
|
-
// through unsafeAppendEvent.
|
|
24
|
-
export type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
|
|
25
|
-
// Append a domain event onto an aggregate stream in the CURRENT tx.
|
|
26
|
-
// Schema-validated, archive-guarded, stream-version derived. Metadata
|
|
27
|
-
// inherits from the triggering event (correlationId) + requestContext
|
|
28
|
-
// (causationId is already set to the triggering event.id by the
|
|
29
|
-
// dispatcher wrap). Strict against KumikoEventTypeMap — same contract
|
|
30
|
-
// as HandlerContext.appendEvent (compile-time-validated payload).
|
|
31
|
-
readonly appendEvent: AppendEventFn<TMap>;
|
|
32
|
-
// Escape hatch for runtime-pluggable events without compile-time
|
|
33
|
-
// augmentation. Same runtime semantics; type-surface is `payload: unknown`.
|
|
34
|
-
readonly unsafeAppendEvent: UnsafeAppendEventFn;
|
|
35
|
-
// Read an aggregate stream — useful when a saga needs to inspect the
|
|
36
|
-
// current state of a different aggregate before deciding what to emit.
|
|
37
|
-
readonly loadAggregate: (
|
|
38
|
-
aggregateId: string,
|
|
39
|
-
options?: { readonly asOf?: Temporal.Instant },
|
|
40
|
-
) => Promise<readonly StoredEvent[]>;
|
|
41
|
-
// Binary storage handle factory, mirrors AppContext.files. Present when
|
|
42
|
-
// the app booted with `files.storageProvider`; undefined otherwise.
|
|
43
|
-
// Post-processing MSPs (resize, EXIF-strip, virus-scan) read bytes via
|
|
44
|
-
// `ctx.files.ref(payload.storageKey).read()` and write derivates via
|
|
45
|
-
// `.derive("thumb").write(...)` — binaries never ride through events.
|
|
46
|
-
readonly files?: FileContext;
|
|
47
|
-
};
|
|
9
|
+
export type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
|
|
48
10
|
|
|
49
11
|
export type MultiStreamApplyContextDeps = {
|
|
50
12
|
readonly registry: Registry;
|
|
@@ -41,6 +41,13 @@ export function createSearchEventConsumer(
|
|
|
41
41
|
): EventConsumer {
|
|
42
42
|
return {
|
|
43
43
|
name: SEARCH_CONSUMER_NAME,
|
|
44
|
+
// ponytail: count-based maxAttempts, not real backoff — retries hammer
|
|
45
|
+
// the search adapter every pollIntervalMs during an outage instead of
|
|
46
|
+
// spacing out. 1200 attempts * 100ms default poll ~= 2min budget for a
|
|
47
|
+
// still-provisioning Meilisearch to come up at boot (was 10 = ~1s,
|
|
48
|
+
// killing the consumer near-instantly). Upgrade path: time-based
|
|
49
|
+
// dead-lettering in event-dispatcher-delivery.ts if 2min isn't enough.
|
|
50
|
+
errorPolicy: { maxAttempts: 1200 },
|
|
44
51
|
handler: async (event) => {
|
|
45
52
|
const entityName = event.aggregateType;
|
|
46
53
|
const verb = event.type.split(".").pop();
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RateLimitConfig,
|
|
3
|
+
RateLimitDecision,
|
|
4
|
+
RateLimitResolver,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
1
6
|
import type Redis from "ioredis";
|
|
2
7
|
import { RateLimitError } from "../errors";
|
|
3
8
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
@@ -108,36 +113,11 @@ end
|
|
|
108
113
|
return { math.floor(tokens), retryAfterMs }
|
|
109
114
|
`;
|
|
110
115
|
|
|
111
|
-
export type
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
readonly windowSeconds: number;
|
|
117
|
-
readonly resetAt: Temporal.Instant;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
export type RateLimitConfig = {
|
|
121
|
-
readonly limit: number;
|
|
122
|
-
readonly windowSeconds: number;
|
|
123
|
-
readonly cost?: number;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
export type RateLimitResolver = {
|
|
127
|
-
// Atomic check + deduct. Returns the decision and current bucket state
|
|
128
|
-
// — caller decides whether to throw RateLimitError or proceed.
|
|
129
|
-
check(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision>;
|
|
130
|
-
|
|
131
|
-
// Convenience: throws RateLimitError when blocked. Useful inside the
|
|
132
|
-
// dispatcher / middleware code-paths where the failure shape is fixed.
|
|
133
|
-
enforce(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision>;
|
|
134
|
-
|
|
135
|
-
// Read-only inspection: returns the same shape as check() but never
|
|
136
|
-
// mutates the bucket — no token deduction, no refill-timestamp update.
|
|
137
|
-
// Use for ops/status queries (e.g. "kumiko rl status user:42") that
|
|
138
|
-
// must observe the bucket without disturbing it.
|
|
139
|
-
peek(bucket: string, config: Omit<RateLimitConfig, "cost">): Promise<RateLimitDecision>;
|
|
140
|
-
};
|
|
116
|
+
export type {
|
|
117
|
+
RateLimitConfig,
|
|
118
|
+
RateLimitDecision,
|
|
119
|
+
RateLimitResolver,
|
|
120
|
+
} from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
141
121
|
|
|
142
122
|
export type RateLimitResolverOptions = {
|
|
143
123
|
readonly redis: Redis;
|
package/src/search/types.ts
CHANGED
|
@@ -1,39 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export type SearchAdapterConfig = {
|
|
4
|
-
searchableFields: readonly string[];
|
|
5
|
-
rankingFields?: readonly string[];
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
export type SearchDocument = {
|
|
9
|
-
entityType: string;
|
|
10
|
-
entityId: EntityId;
|
|
11
|
-
weight: number;
|
|
12
|
-
fields: Record<string, unknown>;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type SearchResult = {
|
|
16
|
-
entityType: string;
|
|
17
|
-
entityId: EntityId;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type SearchOptions = {
|
|
21
|
-
limit?: number;
|
|
22
|
-
filterType?: string;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export type SearchAdapter = {
|
|
26
|
-
configure(tenantId: TenantId, config: SearchAdapterConfig): Promise<void>;
|
|
27
|
-
index(tenantId: TenantId, doc: SearchDocument): Promise<void>;
|
|
28
|
-
search(tenantId: TenantId, query: string, options?: SearchOptions): Promise<SearchResult[]>;
|
|
29
|
-
remove(tenantId: TenantId, entityType: string, entityId: EntityId): Promise<void>;
|
|
30
|
-
// Bulk variants. Default implementations loop over the single-doc methods —
|
|
31
|
-
// adapters should override when the backend supports a real batch call
|
|
32
|
-
// (Meilisearch, Elasticsearch, Typesense all do). Cuts a batch-write from
|
|
33
|
-
// N sequential HTTP + waitTask round-trips to one.
|
|
34
|
-
indexBatch?(tenantId: TenantId, docs: readonly SearchDocument[]): Promise<void>;
|
|
35
|
-
removeBatch?(
|
|
36
|
-
tenantId: TenantId,
|
|
37
|
-
items: readonly { entityType: string; entityId: EntityId }[],
|
|
38
|
-
): Promise<void>;
|
|
39
|
-
};
|
|
1
|
+
export * from "@cosmicdrift/kumiko-types/search-adapter";
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { createEncryptionProvider } from "../../db/encryption";
|
|
4
3
|
import { createEnvMasterKeyProvider } from "../env-master-key-provider";
|
|
5
4
|
import { createEnvelopeCipher } from "../envelope-cipher";
|
|
6
5
|
import { isStoredEnvelope } from "../stored-envelope";
|
|
@@ -42,33 +41,6 @@ describe("envelope-cipher — encrypt/decrypt", () => {
|
|
|
42
41
|
});
|
|
43
42
|
});
|
|
44
43
|
|
|
45
|
-
describe("envelope-cipher — legacy fallback", () => {
|
|
46
|
-
test("decrypts legacy single-key values when the legacy provider is configured", async () => {
|
|
47
|
-
const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
48
|
-
const legacyStored = legacy.encrypt("pre-envelope value");
|
|
49
|
-
// legacy wire format is base64 — never starts with "{"
|
|
50
|
-
expect(legacyStored.startsWith("{")).toBe(false);
|
|
51
|
-
|
|
52
|
-
const cipher = createEnvelopeCipher(makeProvider(), { legacy });
|
|
53
|
-
expect(await cipher.decrypt(legacyStored)).toBe("pre-envelope value");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("throws with a remediation message when a legacy value has no legacy key", async () => {
|
|
57
|
-
const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
58
|
-
const legacyStored = legacy.encrypt("orphaned");
|
|
59
|
-
|
|
60
|
-
const cipher = createEnvelopeCipher(makeProvider());
|
|
61
|
-
await expect(cipher.decrypt(legacyStored)).rejects.toThrow(/legacy/);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("never encrypts into the legacy format even when a legacy key is present", async () => {
|
|
65
|
-
const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
66
|
-
const cipher = createEnvelopeCipher(makeProvider(), { legacy });
|
|
67
|
-
const stored = await cipher.encrypt("always-envelope");
|
|
68
|
-
expect(stored.startsWith("{")).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
44
|
describe("envelope-cipher — malformed input", () => {
|
|
73
45
|
test("rejects invalid JSON that looks like an envelope", async () => {
|
|
74
46
|
const cipher = createEnvelopeCipher(makeProvider());
|
|
@@ -80,8 +52,8 @@ describe("envelope-cipher — malformed input", () => {
|
|
|
80
52
|
await expect(cipher.decrypt('{"foo":"bar"}')).rejects.toThrow(/not a StoredEnvelope/);
|
|
81
53
|
});
|
|
82
54
|
|
|
83
|
-
test("empty string
|
|
55
|
+
test("empty string is not valid JSON", async () => {
|
|
84
56
|
const cipher = createEnvelopeCipher(makeProvider());
|
|
85
|
-
await expect(cipher.decrypt("")).rejects.toThrow(/
|
|
57
|
+
await expect(cipher.decrypt("")).rejects.toThrow(/not valid JSON/);
|
|
86
58
|
});
|
|
87
59
|
});
|
|
@@ -53,7 +53,7 @@ describe("envelope — encryptValue/decryptValue", () => {
|
|
|
53
53
|
...envelope,
|
|
54
54
|
ciphertext: Buffer.concat([
|
|
55
55
|
envelope.ciphertext.subarray(0, 1),
|
|
56
|
-
Buffer.from([envelope.ciphertext[
|
|
56
|
+
Buffer.from([envelope.ciphertext[1]! ^ 0xff]),
|
|
57
57
|
envelope.ciphertext.subarray(2),
|
|
58
58
|
]),
|
|
59
59
|
};
|
|
@@ -1,40 +1,24 @@
|
|
|
1
1
|
// String-in/string-out envelope encryption for TEXT-column stores (config
|
|
2
2
|
// values, entity fields). The stored string is JSON of StoredEnvelope; the
|
|
3
3
|
// kekVersion inside makes every value rotatable via the MasterKeyProvider
|
|
4
|
-
// keyring
|
|
5
|
-
// base64(iv+tag+ct), no key id), which this cipher still DECRYPTS through
|
|
6
|
-
// the optional legacy provider so pre-envelope rows stay readable until a
|
|
7
|
-
// re-encrypt job has migrated them.
|
|
4
|
+
// keyring.
|
|
8
5
|
|
|
9
|
-
import type {
|
|
6
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-types/envelope-cipher-types";
|
|
10
7
|
import { InternalError } from "../errors/classes";
|
|
11
8
|
import type { DekCache } from "./dek-cache";
|
|
12
9
|
import { createDekCache, withDekCache } from "./dek-cache";
|
|
13
10
|
import { decryptValue, encryptValue } from "./envelope";
|
|
14
11
|
import { decodeStoredEnvelope, encodeStoredEnvelope, isStoredEnvelope } from "./stored-envelope";
|
|
15
|
-
import type {
|
|
12
|
+
import type { MasterKeyProvider } from "./types";
|
|
13
|
+
|
|
14
|
+
export type { EnvelopeCipher } from "@cosmicdrift/kumiko-types/envelope-cipher-types";
|
|
16
15
|
|
|
17
16
|
export type EnvelopeCipherOptions = {
|
|
18
|
-
// Decrypt-only fallback for legacy createEncryptionProvider ciphertexts
|
|
19
|
-
// (CONFIG_ENCRYPTION_KEY / ENCRYPTION_KEY era). Never used for encrypt.
|
|
20
|
-
readonly legacy?: EncryptionProvider;
|
|
21
17
|
// Shared DEK cache — pass the app-wide instance so config/entity reads
|
|
22
18
|
// amortise KEK unwraps together with ctx.secrets.
|
|
23
19
|
readonly dekCache?: DekCache;
|
|
24
20
|
};
|
|
25
21
|
|
|
26
|
-
export type EnvelopeCipher = {
|
|
27
|
-
encrypt(plaintext: string, scope?: KeyScope): Promise<string>;
|
|
28
|
-
decrypt(stored: string, scope?: KeyScope): Promise<string>;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
// Format detection: envelope values are JSON objects, so they start with
|
|
32
|
-
// "{" — a character the base64 alphabet of the legacy format can never
|
|
33
|
-
// produce. No version byte or prefix marker needed.
|
|
34
|
-
function isEnvelopeFormat(stored: string): boolean {
|
|
35
|
-
return stored.startsWith("{");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
22
|
export function createEnvelopeCipher(
|
|
39
23
|
provider: MasterKeyProvider,
|
|
40
24
|
opts: EnvelopeCipherOptions = {},
|
|
@@ -48,34 +32,22 @@ export function createEnvelopeCipher(
|
|
|
48
32
|
},
|
|
49
33
|
|
|
50
34
|
async decrypt(stored, scope) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
if (!isStoredEnvelope(parsed)) {
|
|
62
|
-
throw new InternalError({
|
|
63
|
-
message: "[envelope-cipher] stored JSON is not a StoredEnvelope",
|
|
64
|
-
i18nKey: "secrets.errors.envelope_malformed",
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
return decryptValue(decodeStoredEnvelope(parsed), cached, scope);
|
|
35
|
+
let parsed: unknown;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(stored);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new InternalError({
|
|
40
|
+
message: "[envelope-cipher] stored value is not valid JSON",
|
|
41
|
+
i18nKey: "secrets.errors.envelope_malformed",
|
|
42
|
+
});
|
|
68
43
|
}
|
|
69
|
-
|
|
70
|
-
if (!opts.legacy) {
|
|
44
|
+
if (!isStoredEnvelope(parsed)) {
|
|
71
45
|
throw new InternalError({
|
|
72
|
-
message:
|
|
73
|
-
|
|
74
|
-
"provision the legacy key (CONFIG_ENCRYPTION_KEY / ENCRYPTION_KEY) or run the re-encrypt job first",
|
|
75
|
-
i18nKey: "secrets.errors.legacy_key_missing",
|
|
46
|
+
message: "[envelope-cipher] stored JSON is not a StoredEnvelope",
|
|
47
|
+
i18nKey: "secrets.errors.envelope_malformed",
|
|
76
48
|
});
|
|
77
49
|
}
|
|
78
|
-
return
|
|
50
|
+
return decryptValue(decodeStoredEnvelope(parsed), cached, scope);
|
|
79
51
|
},
|
|
80
52
|
};
|
|
81
53
|
}
|
package/src/secrets/types.ts
CHANGED
|
@@ -1,177 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// encryptedDek, never touch the ciphertext.
|
|
4
|
-
|
|
5
|
-
import type { TenantId } from "../engine";
|
|
6
|
-
|
|
7
|
-
// Plaintext-secret wrapper (branded). Carries the actual string internally
|
|
8
|
-
// but the nominal typing stops it from landing in an HTTP response by
|
|
9
|
-
// accident — a response-serializer guard + the reveal() cost make the leak
|
|
10
|
-
// intentional. Framework code that sees `Secret<string>` knows the caller
|
|
11
|
-
// has already gone through the audited ctx.secrets.get path.
|
|
12
|
-
//
|
|
13
|
-
// The brand is a real (non-registered) Symbol so it exists at runtime for
|
|
14
|
-
// isSecret() without clashing with user-land symbols of the same name.
|
|
15
|
-
const SecretBrand: unique symbol = Symbol("kumiko.secret");
|
|
16
|
-
|
|
17
|
-
export type Secret<T = string> = {
|
|
18
|
-
readonly [SecretBrand]: true;
|
|
19
|
-
readonly reveal: () => T;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// Implementation helper — bundled-features uses this to wrap a plaintext after
|
|
23
|
-
// decryption. Kept in the framework so both sides share one canonical brand.
|
|
24
|
-
export function createSecret<T>(value: T): Secret<T> {
|
|
25
|
-
return {
|
|
26
|
-
[SecretBrand]: true as const,
|
|
27
|
-
reveal: () => value,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// True for any object carrying the Secret brand. Used by the response guard
|
|
32
|
-
// to reject leaks before serialization.
|
|
33
|
-
export function isSecret(v: unknown): v is Secret<unknown> {
|
|
34
|
-
return typeof v === "object" && v !== null && SecretBrand in v;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// --- Compile-time response guard (R6) --------------------------------------
|
|
38
|
-
//
|
|
39
|
-
// ContainsSecret<T> is `true` only when a Secret<> is DEFINITELY present
|
|
40
|
-
// somewhere in T. The handler-registration guard (defineWriteHandler/
|
|
41
|
-
// defineQueryHandler) turns a `true` into a compile error — the static twin of
|
|
42
|
-
// assertNoSecretLeak's runtime walk.
|
|
43
|
-
//
|
|
44
|
-
// Biased to `false`: anything it cannot inspect — a bare generic type param (a
|
|
45
|
-
// handler generic over its response), `unknown`/`any`, `never` — resolves to
|
|
46
|
-
// `false` = allowed, with the runtime guard as the backstop. The alternative
|
|
47
|
-
// (default-to-leak) false-flags every legitimate generic-over-response handler.
|
|
48
|
-
//
|
|
49
|
-
// Branch order is load-bearing: never/unknown/any first (uninspectable), then
|
|
50
|
-
// Secret, then primitives (covers branded primitives like TenantId without
|
|
51
|
-
// enumerating them), then the SafeLeaf allowlist (opaque class instances that
|
|
52
|
-
// blind `{ [K in keyof T] }` recursion would mangle — the type-level mirror of
|
|
53
|
-
// leak-guard.ts skipping non-plain objects), then arrays, then a "does any
|
|
54
|
-
// field contain a secret" fold over plain objects.
|
|
55
|
-
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
|
|
56
|
-
|
|
57
|
-
// Opaque built-in leaves a response legitimately carries; never recurse into
|
|
58
|
-
// them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
|
|
59
|
-
// Map/Set (556/2): `keyof Map<K,V>` yields method names, not V, so these
|
|
60
|
-
// already fell through to `false` via the object-mapped-type branch — listed
|
|
61
|
-
// explicitly here so the compile-time treatment matches leak-guard.ts's
|
|
62
|
-
// runtime `instanceof Map`/`instanceof Set` branch (walk entries separately)
|
|
63
|
-
// instead of looking like an oversight.
|
|
64
|
-
type SafeLeaf =
|
|
65
|
-
| Date
|
|
66
|
-
| RegExp
|
|
67
|
-
| Temporal.Instant
|
|
68
|
-
| Temporal.ZonedDateTime
|
|
69
|
-
| Temporal.PlainDate
|
|
70
|
-
| Temporal.PlainDateTime
|
|
71
|
-
| Temporal.PlainTime
|
|
72
|
-
| Temporal.PlainYearMonth
|
|
73
|
-
| Temporal.PlainMonthDay
|
|
74
|
-
| Temporal.Duration
|
|
75
|
-
| Map<unknown, unknown>
|
|
76
|
-
| Set<unknown>;
|
|
77
|
-
|
|
78
|
-
export type ContainsSecret<T> = [T] extends [never]
|
|
79
|
-
? false
|
|
80
|
-
: unknown extends T
|
|
81
|
-
? false
|
|
82
|
-
: T extends Secret<unknown>
|
|
83
|
-
? true
|
|
84
|
-
: T extends Primitive
|
|
85
|
-
? false
|
|
86
|
-
: T extends SafeLeaf
|
|
87
|
-
? false
|
|
88
|
-
: T extends readonly (infer U)[]
|
|
89
|
-
? ContainsSecret<U>
|
|
90
|
-
: T extends object
|
|
91
|
-
? true extends { [K in keyof T]-?: ContainsSecret<T[K]> }[keyof T]
|
|
92
|
-
? true
|
|
93
|
-
: false
|
|
94
|
-
: false;
|
|
95
|
-
|
|
96
|
-
// Per-read audit context. Populated by requireSecretsContext() wrapper so
|
|
97
|
-
// handlers don't need to pass userId/handlerName manually on every call.
|
|
98
|
-
// Undefined for framework-internal reads (rotation job, tests) — the audit
|
|
99
|
-
// table stays a "who touched this credential" log, not a crash-report sink.
|
|
100
|
-
export type SecretAuditContext = {
|
|
101
|
-
readonly userId: string;
|
|
102
|
-
readonly handlerName: string;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
// Feature code can pass either the raw qualified-name string or a typed
|
|
106
|
-
// handle returned by r.secret. The handle form is safer — renaming the
|
|
107
|
-
// r.secret call updates all references through the import graph.
|
|
108
|
-
export type SecretKeyRef = string | { readonly name: string };
|
|
109
|
-
|
|
110
|
-
// The ctx.secrets contract. Concrete implementation lives in bundled-features
|
|
111
|
-
// (createSecretsContext) where the DB and MasterKeyProvider are known. This
|
|
112
|
-
// lean interface is what the framework's HandlerContext carries so engine
|
|
113
|
-
// code can talk about it without pulling in bundled-features.
|
|
114
|
-
export interface SecretsContext {
|
|
115
|
-
get(
|
|
116
|
-
tenantId: TenantId,
|
|
117
|
-
key: SecretKeyRef,
|
|
118
|
-
auditCtx?: SecretAuditContext,
|
|
119
|
-
): Promise<Secret<string> | undefined>;
|
|
120
|
-
// Metadata-only existence probe: no decryption, no read-audit event.
|
|
121
|
-
// For readiness checks — use get() when the value itself is needed.
|
|
122
|
-
has(tenantId: TenantId, key: SecretKeyRef): Promise<boolean>;
|
|
123
|
-
set(
|
|
124
|
-
tenantId: TenantId,
|
|
125
|
-
key: SecretKeyRef,
|
|
126
|
-
value: string,
|
|
127
|
-
opts?: { redact?: (plaintext: string) => string; hint?: string; updatedBy?: string },
|
|
128
|
-
): Promise<void>;
|
|
129
|
-
delete(tenantId: TenantId, key: SecretKeyRef, opts?: { deletedBy?: string }): Promise<boolean>;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export type Envelope = {
|
|
133
|
-
// AES-256-GCM ciphertext of the plaintext, keyed with a DEK.
|
|
134
|
-
readonly ciphertext: Buffer;
|
|
135
|
-
// GCM nonce (12 bytes). Generated fresh per encryption.
|
|
136
|
-
readonly iv: Buffer;
|
|
137
|
-
// GCM auth tag (16 bytes). Guarantees the ciphertext wasn't tampered.
|
|
138
|
-
readonly authTag: Buffer;
|
|
139
|
-
// DEK wrapped with the current KEK. Decryption needs provider.unwrapDek
|
|
140
|
-
// with the kekVersion to recover the DEK.
|
|
141
|
-
readonly encryptedDek: Buffer;
|
|
142
|
-
// Which KEK version was used to wrap the DEK. On rotation, rows with old
|
|
143
|
-
// versions still decrypt — the provider keeps a keyring of historical KEKs.
|
|
144
|
-
readonly kekVersion: number;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
// BYOK hook: callers pass the tenant a value belongs to; a per-tenant-KMS
|
|
148
|
-
// provider keys its wrap/unwrap on it. EnvMasterKeyProvider (app-wide
|
|
149
|
-
// keyring) ignores it — the param exists so the contract doesn't have to
|
|
150
|
-
// break when a tenant-scoped provider ships.
|
|
151
|
-
export type KeyScope = {
|
|
152
|
-
readonly tenantId?: TenantId;
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
// The contract a KEK backend must fulfil. The framework sees only this
|
|
156
|
-
// interface; concrete implementations live in separate packages
|
|
157
|
-
// (@cosmicdrift/kumiko-secrets-vault, @cosmicdrift/kumiko-secrets-aws-kms, ...). The default is
|
|
158
|
-
// EnvMasterKeyProvider which reads keys from environment variables.
|
|
159
|
-
export interface MasterKeyProvider {
|
|
160
|
-
// Wrap a fresh DEK with the current KEK. Returns the wrapped bytes + the
|
|
161
|
-
// KEK version used — the version ends up in the Envelope so decryption
|
|
162
|
-
// later knows which KEK to ask for.
|
|
163
|
-
wrapDek(dek: Buffer, scope?: KeyScope): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
|
|
164
|
-
|
|
165
|
-
// Unwrap a previously-wrapped DEK. During rotation the provider must
|
|
166
|
-
// accept older kekVersion values (2-version window minimum), otherwise
|
|
167
|
-
// old rows become unreadable.
|
|
168
|
-
unwrapDek(encryptedDek: Buffer, kekVersion: number, scope?: KeyScope): Promise<Buffer>;
|
|
169
|
-
|
|
170
|
-
// Which KEK version new wraps use. Rotation flips this to a new value
|
|
171
|
-
// and older-version reads continue to work until rows are re-wrapped.
|
|
172
|
-
currentVersion(): number;
|
|
173
|
-
|
|
174
|
-
// Health check: can the provider talk to its backend? Used by
|
|
175
|
-
// /health/ready. Cheap probe, no KEK material read.
|
|
176
|
-
isAvailable(): Promise<boolean>;
|
|
177
|
-
}
|
|
1
|
+
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
|
+
export * from "@cosmicdrift/kumiko-types/secrets-types";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SseEvent } from "../../api/sse-broker";
|
|
3
|
+
import type { SaveContext } from "../../engine/types";
|
|
4
|
+
import { createEventCollector } from "../event-collector";
|
|
5
|
+
|
|
6
|
+
const probeSseEvent: SseEvent = { type: "probe", data: {} };
|
|
7
|
+
const probeSaveContext: SaveContext = {
|
|
8
|
+
kind: "save",
|
|
9
|
+
id: "e1",
|
|
10
|
+
data: {},
|
|
11
|
+
changes: {},
|
|
12
|
+
previous: {},
|
|
13
|
+
isNew: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("createEventCollector", () => {
|
|
17
|
+
test("starts empty", () => {
|
|
18
|
+
const collector = createEventCollector();
|
|
19
|
+
expect(collector.sse).toEqual([]);
|
|
20
|
+
expect(collector.postSave).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("reset() clears both arrays in place, keeping the same references", () => {
|
|
24
|
+
const collector = createEventCollector();
|
|
25
|
+
const sseRef = collector.sse;
|
|
26
|
+
const postSaveRef = collector.postSave;
|
|
27
|
+
collector.sse.push(probeSseEvent);
|
|
28
|
+
collector.postSave.push(probeSaveContext);
|
|
29
|
+
|
|
30
|
+
expect(collector.sse).toHaveLength(1);
|
|
31
|
+
expect(collector.postSave).toHaveLength(1);
|
|
32
|
+
|
|
33
|
+
collector.reset();
|
|
34
|
+
|
|
35
|
+
expect(collector.sse).toHaveLength(0);
|
|
36
|
+
expect(collector.postSave).toHaveLength(0);
|
|
37
|
+
// Same array instances — callers that captured a reference before reset
|
|
38
|
+
// still see the cleared state, not a stale snapshot.
|
|
39
|
+
expect(collector.sse).toBe(sseRef);
|
|
40
|
+
expect(collector.postSave).toBe(postSaveRef);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createLateBoundHolder } from "../late-bound";
|
|
3
|
+
|
|
4
|
+
describe("createLateBoundHolder", () => {
|
|
5
|
+
test("isReady is false and get() throws before set()", () => {
|
|
6
|
+
const holder = createLateBoundHolder<number>("thing");
|
|
7
|
+
expect(holder.isReady()).toBe(false);
|
|
8
|
+
expect(() => holder.get()).toThrow(/thing accessed before set\(\) was called/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("get() returns the stored value after set()", () => {
|
|
12
|
+
const holder = createLateBoundHolder<{ n: number }>();
|
|
13
|
+
const value = { n: 42 };
|
|
14
|
+
holder.set(value);
|
|
15
|
+
expect(holder.isReady()).toBe(true);
|
|
16
|
+
expect(holder.get()).toBe(value);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("set() can be called again to replace the value", () => {
|
|
20
|
+
const holder = createLateBoundHolder<string>();
|
|
21
|
+
holder.set("first");
|
|
22
|
+
holder.set("second");
|
|
23
|
+
expect(holder.get()).toBe("second");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { waitFor } from "../wait-for";
|
|
3
|
+
|
|
4
|
+
describe("waitFor", () => {
|
|
5
|
+
test("returns immediately once fn succeeds on the first attempt", async () => {
|
|
6
|
+
let calls = 0;
|
|
7
|
+
await waitFor(
|
|
8
|
+
() => {
|
|
9
|
+
calls++;
|
|
10
|
+
},
|
|
11
|
+
{ delays: [1, 1, 1] },
|
|
12
|
+
);
|
|
13
|
+
expect(calls).toBe(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("retries on failure and succeeds once fn passes", async () => {
|
|
17
|
+
let calls = 0;
|
|
18
|
+
await waitFor(
|
|
19
|
+
() => {
|
|
20
|
+
calls++;
|
|
21
|
+
if (calls < 3) throw new Error(`not yet (${calls})`);
|
|
22
|
+
},
|
|
23
|
+
{ delays: [1, 1, 1] },
|
|
24
|
+
);
|
|
25
|
+
expect(calls).toBe(3);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("throws the last error once every attempt in the schedule fails", async () => {
|
|
29
|
+
let calls = 0;
|
|
30
|
+
await expect(
|
|
31
|
+
waitFor(
|
|
32
|
+
() => {
|
|
33
|
+
calls++;
|
|
34
|
+
throw new Error(`fail-${calls}`);
|
|
35
|
+
},
|
|
36
|
+
{ delays: [1, 1] },
|
|
37
|
+
),
|
|
38
|
+
).rejects.toThrow("fail-2");
|
|
39
|
+
expect(calls).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("supports an async fn", async () => {
|
|
43
|
+
let calls = 0;
|
|
44
|
+
await waitFor(
|
|
45
|
+
async () => {
|
|
46
|
+
calls++;
|
|
47
|
+
if (calls < 2) throw new Error("not yet");
|
|
48
|
+
},
|
|
49
|
+
{ delays: [1, 1] },
|
|
50
|
+
);
|
|
51
|
+
expect(calls).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { SEARCHABLE_FALSE_WHITELIST } from "../engine/boot-validator/entity-list-screens";
|
|
2
|
+
import { normalizeListColumn } from "../engine/screen-helpers";
|
|
2
3
|
import type {
|
|
3
4
|
EntityDefinition,
|
|
4
5
|
EntityListScreenDefinition,
|
|
5
6
|
FeatureDefinition,
|
|
6
7
|
TranslationEntry,
|
|
7
8
|
} from "../engine/types";
|
|
8
|
-
import { normalizeListColumn } from "../engine/types/screen";
|
|
9
9
|
import { featureHasI18nSurface, requiredKeysFromFeature } from "../i18n/required-surface-keys";
|
|
10
10
|
|
|
11
11
|
function ensureEntityListSortable(feature: FeatureDefinition): FeatureDefinition {
|