@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.
Files changed (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. 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
- AppendEventArgs,
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
- // Minimal, read+write surface handed to a MultiStreamProjection's apply()
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 RateLimitDecision = {
112
- readonly allowed: boolean;
113
- readonly limit: number;
114
- readonly remaining: number;
115
- readonly retryAfterSeconds: number;
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;
@@ -1,39 +1 @@
1
- import type { EntityId, TenantId } from "../engine/types/identifiers";
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 routes to the legacy branch and throws without a legacy key", async () => {
55
+ test("empty string is not valid JSON", async () => {
84
56
  const cipher = createEnvelopeCipher(makeProvider());
85
- await expect(cipher.decrypt("")).rejects.toThrow(/legacy/);
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[0]! ^ 0xff]),
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 — unlike the legacy createEncryptionProvider format (raw
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 { EncryptionProvider } from "../db/encryption";
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 { KeyScope, MasterKeyProvider } from "./types";
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
- if (isEnvelopeFormat(stored)) {
52
- let parsed: unknown;
53
- try {
54
- parsed = JSON.parse(stored);
55
- } catch {
56
- throw new InternalError({
57
- message: "[envelope-cipher] stored value looks like an envelope but is not valid JSON",
58
- i18nKey: "secrets.errors.envelope_malformed",
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
- "[envelope-cipher] value is in the legacy single-key format but no legacy key is configured — " +
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 opts.legacy.decrypt(stored);
50
+ return decryptValue(decodeStoredEnvelope(parsed), cached, scope);
79
51
  },
80
52
  };
81
53
  }
@@ -1,177 +1,2 @@
1
- // Envelope Encryption types. Separating DEK (per-value) from KEK (central)
2
- // is what makes key rotation cheap: on rotation we only re-wrap the small
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 {