@cosmicdrift/kumiko-framework 0.158.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.
- package/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +4 -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/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +34 -2
- 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/pii-field-encryption.ts +49 -15
- 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 +305 -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/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +92 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- 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__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- 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-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 +1 -0
- package/src/engine/define-handler.ts +10 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +1 -1
- 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 +2 -2
- package/src/engine/ownership.ts +22 -76
- 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/define-handler.ts +94 -0
- package/src/engine/types/entity-handlers.ts +30 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +45 -0
- package/src/engine/types/fields.ts +19 -31
- package/src/engine/types/handlers.ts +7 -26
- package/src/engine/types/hooks.ts +1 -15
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +34 -9
- package/src/engine/types/ownership.ts +83 -0
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +0 -46
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/entrypoint/index.ts +2 -2
- 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/event-store.ts +28 -32
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/types.ts +22 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/i18n/required-surface-keys.ts +1 -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 +38 -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/dispatch-shared.ts +12 -2
- 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/system-hooks.ts +7 -0
- 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 +13 -39
- 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/ui-types/index.ts +7 -7
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
sql,
|
|
11
11
|
text,
|
|
12
12
|
} from "../db/dialect";
|
|
13
|
-
import {
|
|
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
|
-
|
|
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)
|
|
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 >=
|
|
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(
|
|
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();
|
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,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
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
|
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
|
package/src/testing/index.ts
CHANGED
|
@@ -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,
|