@cosmicdrift/kumiko-framework 0.157.2 → 0.159.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.
Files changed (110) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
  4. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  5. package/src/api/__tests__/jwt.test.ts +150 -1
  6. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +66 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  8. package/src/api/api-constants.ts +4 -0
  9. package/src/api/auth-middleware.ts +48 -59
  10. package/src/api/auth-routes.ts +83 -17
  11. package/src/api/index.ts +8 -4
  12. package/src/api/jwt.ts +148 -7
  13. package/src/api/pii-leak-guard.ts +5 -2
  14. package/src/api/server.ts +19 -5
  15. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  16. package/src/bun-db/query.ts +34 -2
  17. package/src/consumer-cli.ts +87 -0
  18. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  19. package/src/crypto/blind-index.ts +8 -4
  20. package/src/crypto/event-pii.ts +1 -0
  21. package/src/crypto/pii-field-encryption.ts +49 -15
  22. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  25. package/src/db/blind-index-cleanup.ts +3 -1
  26. package/src/db/connection.ts +3 -11
  27. package/src/db/encryption.ts +2 -3
  28. package/src/db/entity-table-meta-types.ts +92 -0
  29. package/src/db/entity-table-meta.ts +16 -90
  30. package/src/db/queries/backfill-pii.ts +1 -0
  31. package/src/db/queries/event-consumer.ts +35 -2
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  33. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  34. package/src/engine/__tests__/define-roles.test.ts +21 -0
  35. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  36. package/src/engine/__tests__/store-table.test.ts +12 -0
  37. package/src/engine/boot-validator/action-wiring.ts +1 -1
  38. package/src/engine/boot-validator/boot-check.ts +21 -0
  39. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  40. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  41. package/src/engine/boot-validator/index.ts +3 -9
  42. package/src/engine/boot-validator/screens.ts +1 -1
  43. package/src/engine/define-feature.ts +1 -0
  44. package/src/engine/define-handler.ts +10 -91
  45. package/src/engine/entity-handlers.ts +15 -27
  46. package/src/engine/feature-builder-state.ts +3 -0
  47. package/src/engine/feature-config-events-jobs.ts +1 -1
  48. package/src/engine/feature-entity-handlers.ts +1 -1
  49. package/src/engine/feature-ui-extensions.ts +5 -1
  50. package/src/engine/field-helpers.ts +31 -0
  51. package/src/engine/handler-helpers.ts +26 -0
  52. package/src/engine/hook-helpers.ts +14 -0
  53. package/src/engine/index.ts +2 -2
  54. package/src/engine/ownership.ts +22 -76
  55. package/src/engine/registry-validate.ts +1 -1
  56. package/src/engine/screen-helpers.ts +54 -0
  57. package/src/engine/tier-resolver-extension.ts +3 -2
  58. package/src/engine/types/define-handler.ts +94 -0
  59. package/src/engine/types/entity-handlers.ts +30 -0
  60. package/src/engine/types/event-type-map.ts +1 -37
  61. package/src/engine/types/feature.ts +45 -0
  62. package/src/engine/types/fields.ts +19 -31
  63. package/src/engine/types/handlers.ts +7 -26
  64. package/src/engine/types/hooks.ts +1 -15
  65. package/src/engine/types/http-route.ts +1 -72
  66. package/src/engine/types/identifiers.ts +1 -47
  67. package/src/engine/types/index.ts +34 -9
  68. package/src/engine/types/ownership.ts +83 -0
  69. package/src/engine/types/relations.ts +1 -51
  70. package/src/engine/types/screen.ts +0 -46
  71. package/src/engine/types/target-ref.ts +1 -21
  72. package/src/engine/types/tree-node.ts +1 -129
  73. package/src/entrypoint/index.ts +2 -2
  74. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  75. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  76. package/src/event-store/event-store.ts +28 -32
  77. package/src/event-store/events-schema.ts +1 -10
  78. package/src/event-store/index.ts +3 -2
  79. package/src/event-store/types.ts +22 -0
  80. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  81. package/src/files/file-handle.ts +2 -19
  82. package/src/i18n/required-surface-keys.ts +1 -1
  83. package/src/logging/types.ts +1 -7
  84. package/src/observability/types/index.ts +1 -29
  85. package/src/observability/types/metric.ts +1 -56
  86. package/src/observability/types/provider.ts +1 -32
  87. package/src/observability/types/span.ts +1 -58
  88. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  89. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  90. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  91. package/src/pipeline/dispatch-shared.ts +12 -2
  92. package/src/pipeline/entity-cache.ts +2 -33
  93. package/src/pipeline/event-consumer-state.ts +28 -3
  94. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  95. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  96. package/src/pipeline/event-dispatcher.ts +27 -1
  97. package/src/pipeline/system-hooks.ts +7 -0
  98. package/src/search/types.ts +1 -39
  99. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  100. package/src/secrets/__tests__/envelope.test.ts +1 -1
  101. package/src/secrets/envelope-cipher.ts +13 -39
  102. package/src/stack/__tests__/event-collector.test.ts +42 -0
  103. package/src/testing/__tests__/late-bound.test.ts +25 -0
  104. package/src/testing/__tests__/wait-for.test.ts +53 -0
  105. package/src/testing/boot-validator-fixture.ts +1 -1
  106. package/src/testing/file-provider-contract.ts +84 -0
  107. package/src/testing/handler-context.ts +1 -1
  108. package/src/testing/index.ts +1 -0
  109. package/src/time/geo-tz.ts +1 -32
  110. package/src/ui-types/index.ts +7 -7
@@ -10,7 +10,8 @@ import {
10
10
  sql,
11
11
  text,
12
12
  } from "../db/dialect";
13
- import { tableExists } from "../db/schema-inspection";
13
+ import { alterTableAddColumn } from "../db/queries/test-stack";
14
+ import { columnNamesOf, tableExists } from "../db/schema-inspection";
14
15
  import { unsafePushTables } from "../stack";
15
16
 
16
17
  // Reserved sentinel used in the instance_id column for consumers whose
@@ -72,6 +73,14 @@ export const eventConsumerStateTable = pgTable(
72
73
  .default(sql`0`),
73
74
  status: text("status").notNull().default("idle"),
74
75
  attempts: integer("attempts").notNull().default(0),
76
+ // Counts automatic dead→idle revivals since the last delivery that
77
+ // advanced the cursor (event-dispatcher.ts's cooldown re-arm, see
78
+ // acquireConsumerState). A poison event never advances the cursor, so
79
+ // this climbs to maxRearmCount and then stays dead permanently. A
80
+ // successful delivery resets it to 0 (proof the consumer isn't
81
+ // poisoned), as does a manual restartConsumer()/enableConsumer()/
82
+ // skipPoisonEvent() — an operator vouching the consumer is healthy.
83
+ rearmCount: integer("rearm_count").notNull().default(0),
75
84
  lastError: text("last_error"),
76
85
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
77
86
  },
@@ -99,7 +108,23 @@ export type ConsumerStatus = (typeof ConsumerStatuses)[keyof typeof ConsumerStat
99
108
  //
100
109
  // guard:dup-ok — intentionale Parallele zu createProjectionStateTable; symmetrische State-Tabellen by design
101
110
  export async function createEventConsumerStateTable(db: DbConnection): Promise<void> {
102
- // skip: table already exists — bootstrap is called from multiple paths
103
- if (await tableExists(db, "public.kumiko_event_consumers")) return;
111
+ // skip: table already exists — bootstrap is called from multiple paths.
112
+ // Still check for columns added after the table's first deploy (e.g.
113
+ // rearm_count) so an older DB catches up without a dedicated migration.
114
+ if (await tableExists(db, "public.kumiko_event_consumers")) {
115
+ const cols = await columnNamesOf(db, "kumiko_event_consumers");
116
+ if (!cols.has("rearm_count")) {
117
+ await alterTableAddColumn(
118
+ db,
119
+ "kumiko_event_consumers",
120
+ "rearm_count",
121
+ "integer",
122
+ " DEFAULT 0",
123
+ " NOT NULL",
124
+ );
125
+ }
126
+ // skip: table (+ any missing column) is already up to date
127
+ return;
128
+ }
104
129
  await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
105
130
  }
@@ -34,6 +34,7 @@ function normalizeConsumerState(row: ConsumerStateRowShape): ConsumerRecoverySta
34
34
  status: row.status,
35
35
  lastProcessedEventId: row.lastProcessedEventId,
36
36
  attempts: row.attempts,
37
+ rearmCount: row.rearmCount,
37
38
  lastError: row.lastError,
38
39
  updatedAt: row.updatedAt,
39
40
  };
@@ -45,6 +46,7 @@ export type ConsumerRecoveryState = {
45
46
  readonly status: string;
46
47
  readonly lastProcessedEventId: bigint;
47
48
  readonly attempts: number;
49
+ readonly rearmCount: number;
48
50
  readonly lastError: string | null;
49
51
  readonly updatedAt: Temporal.Instant;
50
52
  };
@@ -170,6 +172,7 @@ export async function getConsumerState(
170
172
  readonly status: string;
171
173
  readonly lastProcessedEventId: bigint;
172
174
  readonly attempts: number;
175
+ readonly rearmCount: number;
173
176
  readonly lastError: string | null;
174
177
  readonly updatedAt: Temporal.Instant;
175
178
  } | null> {
@@ -184,6 +187,7 @@ export async function getConsumerState(
184
187
  status: row.status,
185
188
  lastProcessedEventId: row.lastProcessedEventId,
186
189
  attempts: row.attempts,
190
+ rearmCount: row.rearmCount,
187
191
  lastError: row.lastError,
188
192
  updatedAt: row.updatedAt,
189
193
  };
@@ -3,6 +3,7 @@ import type { DbConnection, DbTx } from "../db/connection";
3
3
  import {
4
4
  insertConsumerIfAbsent,
5
5
  markConsumerProcessing,
6
+ rearmDeadConsumer,
6
7
  selectConsumerForUpdateSkipLocked,
7
8
  updateConsumerDeliveryOutcome,
8
9
  } from "../db/queries/event-consumer";
@@ -38,12 +39,13 @@ export type ConsumerStateRowShape = {
38
39
  readonly lastProcessedEventId: bigint;
39
40
  readonly status: string;
40
41
  readonly attempts: number;
42
+ readonly rearmCount: number;
41
43
  readonly lastError: string | null;
42
44
  readonly updatedAt: Temporal.Instant;
43
45
  };
44
46
  export type ConsumerStateRow = ConsumerStateRowShape;
45
47
 
46
- type StoredEventRow = {
48
+ export type StoredEventRow = {
47
49
  readonly id: bigint;
48
50
  readonly aggregateId: string;
49
51
  readonly aggregateType: string;
@@ -80,6 +82,8 @@ export async function acquireConsumerState(
80
82
  tx: DbTx,
81
83
  name: string,
82
84
  instanceId: string,
85
+ rearmCooldownMs: number,
86
+ maxRearmCount: number,
83
87
  ): Promise<AcquireOutcome> {
84
88
  const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
85
89
 
@@ -100,7 +104,28 @@ export async function acquireConsumerState(
100
104
  }
101
105
 
102
106
  if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
103
- if (state.status === ConsumerStatuses.dead) return { state: null, skip: "dead" };
107
+ if (state.status === ConsumerStatuses.dead) {
108
+ // Bounded auto-revival: a transient failure (e.g. a Meilisearch blip)
109
+ // shouldn't need an operator to notice and run restartConsumer() once
110
+ // the cause is long gone. Cooldown since the last write (the death or
111
+ // a prior re-arm) gates the retry; maxRearmCount stops a poison event
112
+ // from looping forever (re-arm → same event fails → dead → re-arm →
113
+ // ...) — after the cap it stays dead until a human intervenes.
114
+ const cooldownDeadline = Temporal.Now.instant().subtract({ milliseconds: rearmCooldownMs });
115
+ const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
116
+ if (cooldownElapsed && state.rearmCount < maxRearmCount) {
117
+ const rearmed = await rearmDeadConsumer(tx, name, instanceId);
118
+ const rearmedState =
119
+ rearmed &&
120
+ (coerceRow(rearmed, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
121
+ if (rearmedState) return { state: rearmedState, skip: null };
122
+ }
123
+ // ponytail: no log/metric fires when the rearm budget is exhausted here
124
+ // (the exact "braucht manuellen Eingriff" moment) — queryable via
125
+ // getConsumerState but silent otherwise. Add an emitDispatcherError-style
126
+ // signal if ops needs a push instead of a dead+lag poll.
127
+ return { state: null, skip: "dead" };
128
+ }
104
129
  return { state, skip: null };
105
130
  }
106
131
 
@@ -183,6 +208,7 @@ export async function deliverEvents(
183
208
  let attempts = state.attempts;
184
209
  let lastError: string | null = state.lastError ?? null;
185
210
  let deadLettered = false;
211
+ const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
186
212
  let processed = 0;
187
213
  let failed = 0;
188
214
 
@@ -231,7 +257,7 @@ export async function deliverEvents(
231
257
  attempts += 1;
232
258
  lastError = errMessage;
233
259
  failed += 1;
234
- if (attempts >= maxAttempts) deadLettered = true;
260
+ if (attempts >= effectiveMaxAttempts) deadLettered = true;
235
261
  break;
236
262
  }
237
263
  }
@@ -66,6 +66,11 @@ export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Prom
66
66
  // the EventConsumer (see api/server.ts MSP wiring).
67
67
  export type EventConsumerErrorPolicy = {
68
68
  readonly skipApplyErrors?: boolean;
69
+ // Per-consumer override of EventDispatcherOptions.maxAttempts. A consumer
70
+ // that depends on infra which may still be provisioning at boot (search
71
+ // adapter, external APIs) needs more retry headroom than the dispatcher-
72
+ // wide default before it gets dead-lettered.
73
+ readonly maxAttempts?: number;
69
74
  };
70
75
 
71
76
  export type EventConsumer = {
@@ -129,6 +134,17 @@ export type EventDispatcherOptions = {
129
134
  readonly batchSize?: number;
130
135
  readonly pollIntervalMs?: number;
131
136
  readonly maxAttempts?: number;
137
+ // Bounded auto-revival of a "dead" consumer. A dead consumer whose last
138
+ // write is older than rearmCooldownMs gets reset to idle and retried.
139
+ // rearmCount tracks re-arms since the last delivery that actually
140
+ // advanced the cursor — a poison event never advances it, so it climbs
141
+ // 1→maxRearmCount and then stays dead permanently; a transient failure
142
+ // that later delivers successfully resets it back to 0, so an unrelated
143
+ // future outage gets its own fresh budget. restartConsumer()/
144
+ // enableConsumer()/skipPoisonEvent() also reset it (an operator vouching
145
+ // the consumer is healthy again).
146
+ readonly rearmCooldownMs?: number;
147
+ readonly maxRearmCount?: number;
132
148
  readonly tracer?: Tracer;
133
149
  readonly meter?: Meter;
134
150
  // Identifies THIS dispatcher process in the consumer-state table. Used as
@@ -150,6 +166,8 @@ export type EventDispatcherOptions = {
150
166
  const DEFAULT_BATCH_SIZE = 200;
151
167
  const DEFAULT_POLL_MS = 100;
152
168
  const DEFAULT_MAX_ATTEMPTS = 10;
169
+ const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
170
+ const DEFAULT_MAX_REARM_COUNT = 3;
153
171
 
154
172
  export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
155
173
  const {
@@ -159,6 +177,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
159
177
  batchSize = DEFAULT_BATCH_SIZE,
160
178
  pollIntervalMs = DEFAULT_POLL_MS,
161
179
  maxAttempts = DEFAULT_MAX_ATTEMPTS,
180
+ rearmCooldownMs = DEFAULT_REARM_COOLDOWN_MS,
181
+ maxRearmCount = DEFAULT_MAX_REARM_COUNT,
162
182
  } = options;
163
183
 
164
184
  // Fail-fast on misconfigured per-instance wiring. Catching this at
@@ -273,7 +293,13 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
273
293
 
274
294
  try {
275
295
  await db.begin(async (tx: DbTx) => {
276
- const acquired = await acquireConsumerState(tx, consumer.name, instanceId);
296
+ const acquired = await acquireConsumerState(
297
+ tx,
298
+ consumer.name,
299
+ instanceId,
300
+ rearmCooldownMs,
301
+ maxRearmCount,
302
+ );
277
303
  // skip: another instance holds the lock, or the consumer is
278
304
  // disabled/dead. Nothing to deliver this pass.
279
305
  if (acquired.skip !== null) {
@@ -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,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,12 +1,8 @@
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";
10
6
  import { InternalError } from "../errors/classes";
11
7
  import type { DekCache } from "./dek-cache";
12
8
  import { createDekCache, withDekCache } from "./dek-cache";
@@ -15,9 +11,6 @@ import { decodeStoredEnvelope, encodeStoredEnvelope, isStoredEnvelope } from "./
15
11
  import type { KeyScope, MasterKeyProvider } from "./types";
16
12
 
17
13
  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
14
  // Shared DEK cache — pass the app-wide instance so config/entity reads
22
15
  // amortise KEK unwraps together with ctx.secrets.
23
16
  readonly dekCache?: DekCache;
@@ -28,13 +21,6 @@ export type EnvelopeCipher = {
28
21
  decrypt(stored: string, scope?: KeyScope): Promise<string>;
29
22
  };
30
23
 
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
24
  export function createEnvelopeCipher(
39
25
  provider: MasterKeyProvider,
40
26
  opts: EnvelopeCipherOptions = {},
@@ -48,34 +34,22 @@ export function createEnvelopeCipher(
48
34
  },
49
35
 
50
36
  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);
37
+ let parsed: unknown;
38
+ try {
39
+ parsed = JSON.parse(stored);
40
+ } catch {
41
+ throw new InternalError({
42
+ message: "[envelope-cipher] stored value is not valid JSON",
43
+ i18nKey: "secrets.errors.envelope_malformed",
44
+ });
68
45
  }
69
-
70
- if (!opts.legacy) {
46
+ if (!isStoredEnvelope(parsed)) {
71
47
  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",
48
+ message: "[envelope-cipher] stored JSON is not a StoredEnvelope",
49
+ i18nKey: "secrets.errors.envelope_malformed",
76
50
  });
77
51
  }
78
- return opts.legacy.decrypt(stored);
52
+ return decryptValue(decodeStoredEnvelope(parsed), cached, scope);
79
53
  },
80
54
  };
81
55
  }
@@ -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 {
@@ -0,0 +1,84 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import type { FileStorageProvider } from "../files/types";
3
+
4
+ const bytes = (s: string) => new TextEncoder().encode(s);
5
+ const decode = (u: Uint8Array) => new TextDecoder().decode(u);
6
+
7
+ async function* fromChunks(chunks: Uint8Array[]): AsyncIterable<Uint8Array> {
8
+ for (const c of chunks) yield c;
9
+ }
10
+
11
+ // Mirrors describeKmsAdapterContract (crypto/__tests__/kms-adapter-contract.ts):
12
+ // pins the FileStorageProvider contract against whatever implementation the
13
+ // factory returns. Provider-specific behavior (error messages, buffer
14
+ // copies, multipart details) stays in each provider's dedicated tests.
15
+ export function describeFileProviderContract(
16
+ name: string,
17
+ factory: () => FileStorageProvider | Promise<FileStorageProvider>,
18
+ ): void {
19
+ describe(`${name} — FileStorageProvider contract`, () => {
20
+ let provider: FileStorageProvider;
21
+
22
+ beforeEach(async () => {
23
+ provider = await factory();
24
+ });
25
+
26
+ test("write + read roundtrip preserves bytes", async () => {
27
+ const key = `contract/${crypto.randomUUID()}.bin`;
28
+ await provider.write(key, bytes("hello contract"));
29
+ expect(decode(await provider.read(key))).toBe("hello contract");
30
+ });
31
+
32
+ test("write on an existing key overwrites it (last-write-wins)", async () => {
33
+ const key = `contract/${crypto.randomUUID()}.bin`;
34
+ await provider.write(key, bytes("first"));
35
+ await provider.write(key, bytes("second"));
36
+ expect(decode(await provider.read(key))).toBe("second");
37
+ });
38
+
39
+ test("read throws for a missing key", async () => {
40
+ await expect(provider.read(`contract/missing-${crypto.randomUUID()}`)).rejects.toThrow();
41
+ });
42
+
43
+ test("exists reflects write + delete", async () => {
44
+ const key = `contract/${crypto.randomUUID()}.txt`;
45
+ expect(await provider.exists(key)).toBe(false);
46
+ await provider.write(key, bytes("x"));
47
+ expect(await provider.exists(key)).toBe(true);
48
+ await provider.delete(key);
49
+ expect(await provider.exists(key)).toBe(false);
50
+ });
51
+
52
+ test("delete on a missing key is a no-op", async () => {
53
+ await expect(
54
+ provider.delete(`contract/never-existed-${crypto.randomUUID()}`),
55
+ ).resolves.toBeUndefined();
56
+ });
57
+
58
+ test("writeStream + readStream roundtrip preserves bytes", async () => {
59
+ const key = `contract/${crypto.randomUUID()}.stream`;
60
+ await provider.writeStream(key, fromChunks([bytes("foo"), bytes("bar")]));
61
+ let out = "";
62
+ for await (const chunk of provider.readStream(key)) out += decode(chunk);
63
+ expect(out).toBe("foobar");
64
+ });
65
+
66
+ test("readStream on a missing key throws on the first chunk pull", async () => {
67
+ const it = provider
68
+ .readStream(`contract/missing-${crypto.randomUUID()}`)
69
+ [Symbol.asyncIterator]();
70
+ await expect(it.next()).rejects.toThrow();
71
+ });
72
+
73
+ test("getSignedUrl, when implemented, returns a URL string", async () => {
74
+ // skip: getSignedUrl is optional on the contract — feature-detected
75
+ if (!provider.getSignedUrl) return;
76
+
77
+ const key = `contract/${crypto.randomUUID()}.txt`;
78
+ await provider.write(key, bytes("signed"));
79
+ const url = await provider.getSignedUrl(key, 60);
80
+ expect(typeof url).toBe("string");
81
+ expect(url.length).toBeGreaterThan(0);
82
+ });
83
+ });
84
+ }
@@ -117,7 +117,7 @@ export function bridgeStub(opts?: {
117
117
  ) as unknown as HandlerContext["resolveAuthClaims"],
118
118
  // Stub defaults to always-enabled — matches the dispatcher's behaviour
119
119
  // when no effectiveFeatures resolver is wired (tests without toggles).
120
- hasFeature: () => true,
120
+ hasFeature: async () => true,
121
121
  metrics: createNoopMetricsHandle(),
122
122
  tracer: noopTracer,
123
123
  // Echter TzContext, kein notAvailable — Test-Code nutzt ctx.tz häufig
@@ -15,6 +15,7 @@ export {
15
15
  generateZodFixture,
16
16
  } from "./e2e-generator";
17
17
  export { expectErrorIncludes } from "./expect-error";
18
+ export { describeFileProviderContract } from "./file-provider-contract";
18
19
  export { bridgeStub } from "./handler-context";
19
20
  export {
20
21
  getSetCookieRaw,