okengine 0.21.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -20
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +18 -1
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +132 -19
- package/src/client/types.ts +29 -3
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-Bgt9bq2r.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BMHs-IzK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-DMbnAe3f.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Htk8Cm9t.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CFMPWX4f.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CBYl0LW_.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +191 -18
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.ts +1 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx.ts +5 -1
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/manifest/types.ts +8 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
* completed journal steps replay instead of re-running).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import {
|
|
11
|
+
createPostgresIdempotencyStore,
|
|
12
|
+
IDEM_ATTACH_SQL,
|
|
13
|
+
IDEM_COMPLETE_SQL,
|
|
14
|
+
IDEM_DELETE_EXPIRED_ONE_SQL,
|
|
15
|
+
IDEM_FORFEIT_SQL,
|
|
16
|
+
IDEM_INSERT_SQL,
|
|
17
|
+
IDEM_PURGE_SQL,
|
|
18
|
+
IDEM_RECLAIM_SQL,
|
|
19
|
+
IDEM_REMOVE_SQL,
|
|
20
|
+
IDEM_RENEW_SQL,
|
|
21
|
+
IDEM_SELECT_SQL,
|
|
22
|
+
} from "../kernel/idempotency-store.ts";
|
|
10
23
|
import {
|
|
11
24
|
JOURNAL_DEFAULT_LEASE_MS,
|
|
12
25
|
type JournalEntry,
|
|
@@ -199,6 +212,26 @@ async function ensureSchema(sql: PostgresJournalSql): Promise<void> {
|
|
|
199
212
|
await sql.exec(
|
|
200
213
|
`CREATE INDEX IF NOT EXISTS oke_journal_runs_lease ON oke_journal_runs (status, lease_expires_at)`,
|
|
201
214
|
);
|
|
215
|
+
await sql.exec(`CREATE TABLE IF NOT EXISTS oke_idempotency (
|
|
216
|
+
tenant TEXT NOT NULL DEFAULT '',
|
|
217
|
+
principal TEXT NOT NULL,
|
|
218
|
+
flow TEXT NOT NULL,
|
|
219
|
+
key TEXT NOT NULL,
|
|
220
|
+
fingerprint TEXT NOT NULL,
|
|
221
|
+
status TEXT NOT NULL,
|
|
222
|
+
claim_token TEXT NOT NULL,
|
|
223
|
+
lease_expires_at BIGINT NOT NULL,
|
|
224
|
+
run_id TEXT,
|
|
225
|
+
response_status INTEGER,
|
|
226
|
+
response_headers TEXT,
|
|
227
|
+
response_body TEXT,
|
|
228
|
+
created_at BIGINT NOT NULL,
|
|
229
|
+
expires_at BIGINT NOT NULL,
|
|
230
|
+
PRIMARY KEY (tenant, principal, flow, key)
|
|
231
|
+
)`);
|
|
232
|
+
await sql.exec(
|
|
233
|
+
`CREATE INDEX IF NOT EXISTS oke_idempotency_expires ON oke_idempotency (expires_at)`,
|
|
234
|
+
);
|
|
202
235
|
}
|
|
203
236
|
|
|
204
237
|
/**
|
|
@@ -208,9 +241,25 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
208
241
|
/** Force-kill mid-transaction (drops uncommitted state). */
|
|
209
242
|
killActiveTransaction(): void;
|
|
210
243
|
} {
|
|
211
|
-
type
|
|
244
|
+
type IdemDbRow = {
|
|
245
|
+
tenant: string;
|
|
246
|
+
principal: string;
|
|
247
|
+
flow: string;
|
|
248
|
+
key: string;
|
|
249
|
+
fingerprint: string;
|
|
250
|
+
status: string;
|
|
251
|
+
claim_token: string;
|
|
252
|
+
lease_expires_at: number;
|
|
253
|
+
run_id: string | null;
|
|
254
|
+
response_status: number | null;
|
|
255
|
+
response_headers: string | null;
|
|
256
|
+
response_body: string | null;
|
|
257
|
+
created_at: number;
|
|
258
|
+
expires_at: number;
|
|
259
|
+
};
|
|
260
|
+
type State = { rows: JournalDbRow[]; idem: IdemDbRow[] };
|
|
212
261
|
|
|
213
|
-
let committed: State = { rows: [] };
|
|
262
|
+
let committed: State = { rows: [], idem: [] };
|
|
214
263
|
let active: { state: State; locked: Set<string>; done: boolean } | null = null;
|
|
215
264
|
/** Run ids held by other active transactions (SKIP LOCKED). */
|
|
216
265
|
const heldByTxn = new Set<string>();
|
|
@@ -222,7 +271,19 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
222
271
|
}
|
|
223
272
|
|
|
224
273
|
function cloneState(s: State): State {
|
|
225
|
-
return {
|
|
274
|
+
return {
|
|
275
|
+
rows: s.rows.map((r) => ({ ...r })),
|
|
276
|
+
idem: s.idem.map((r) => ({ ...r })),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function idemPk(row: IdemDbRow, params: readonly unknown[], offset = 0): boolean {
|
|
281
|
+
return (
|
|
282
|
+
row.tenant === String(params[offset]) &&
|
|
283
|
+
row.principal === String(params[offset + 1]) &&
|
|
284
|
+
row.flow === String(params[offset + 2]) &&
|
|
285
|
+
row.key === String(params[offset + 3])
|
|
286
|
+
);
|
|
226
287
|
}
|
|
227
288
|
|
|
228
289
|
function claimable(r: JournalDbRow, holder: string, cutoff: number): boolean {
|
|
@@ -309,6 +370,12 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
309
370
|
return state.rows.map((r) => ({ ...r }));
|
|
310
371
|
}
|
|
311
372
|
|
|
373
|
+
if (text === IDEM_SELECT_SQL) {
|
|
374
|
+
return state.idem
|
|
375
|
+
.filter((row) => idemPk(row, params))
|
|
376
|
+
.map((row) => ({ ...row }) as Record<string, unknown>);
|
|
377
|
+
}
|
|
378
|
+
|
|
312
379
|
throw new Error(`postgres journal fake: unsupported query: ${sql}`);
|
|
313
380
|
},
|
|
314
381
|
async exec(sql, params = []) {
|
|
@@ -388,6 +455,103 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
388
455
|
return { changes: 0 };
|
|
389
456
|
}
|
|
390
457
|
|
|
458
|
+
if (text === IDEM_PURGE_SQL) {
|
|
459
|
+
const now = Number(params[0]);
|
|
460
|
+
const before = state.idem.length;
|
|
461
|
+
state.idem = state.idem.filter((row) => row.expires_at > now);
|
|
462
|
+
return { changes: before - state.idem.length };
|
|
463
|
+
}
|
|
464
|
+
if (text === IDEM_DELETE_EXPIRED_ONE_SQL) {
|
|
465
|
+
const now = Number(params[4]);
|
|
466
|
+
const idx = state.idem.findIndex((row) => idemPk(row, params) && row.expires_at <= now);
|
|
467
|
+
if (idx < 0) return { changes: 0 };
|
|
468
|
+
state.idem.splice(idx, 1);
|
|
469
|
+
return { changes: 1 };
|
|
470
|
+
}
|
|
471
|
+
if (text === IDEM_INSERT_SQL) {
|
|
472
|
+
if (state.idem.some((row) => idemPk(row, params))) return { changes: 0 };
|
|
473
|
+
state.idem.push({
|
|
474
|
+
tenant: String(params[0]),
|
|
475
|
+
principal: String(params[1]),
|
|
476
|
+
flow: String(params[2]),
|
|
477
|
+
key: String(params[3]),
|
|
478
|
+
fingerprint: String(params[4]),
|
|
479
|
+
status: "in_progress",
|
|
480
|
+
claim_token: String(params[5]),
|
|
481
|
+
lease_expires_at: Number(params[6]),
|
|
482
|
+
run_id: null,
|
|
483
|
+
response_status: null,
|
|
484
|
+
response_headers: null,
|
|
485
|
+
response_body: null,
|
|
486
|
+
created_at: Number(params[7]),
|
|
487
|
+
expires_at: Number(params[8]),
|
|
488
|
+
});
|
|
489
|
+
return { changes: 1 };
|
|
490
|
+
}
|
|
491
|
+
if (text === IDEM_RECLAIM_SQL) {
|
|
492
|
+
const row = state.idem.find(
|
|
493
|
+
(candidate) =>
|
|
494
|
+
idemPk(candidate, params, 2) && candidate.claim_token === String(params[6]),
|
|
495
|
+
);
|
|
496
|
+
if (row === undefined || row.status !== "in_progress") return { changes: 0 };
|
|
497
|
+
row.claim_token = String(params[0]);
|
|
498
|
+
row.lease_expires_at = Number(params[1]);
|
|
499
|
+
return { changes: 1 };
|
|
500
|
+
}
|
|
501
|
+
if (text === IDEM_COMPLETE_SQL) {
|
|
502
|
+
const row = state.idem.find(
|
|
503
|
+
(candidate) =>
|
|
504
|
+
idemPk(candidate, params, 4) && candidate.claim_token === String(params[8]),
|
|
505
|
+
);
|
|
506
|
+
if (row === undefined) return { changes: 0 };
|
|
507
|
+
row.status = "completed";
|
|
508
|
+
row.response_status = Number(params[0]);
|
|
509
|
+
row.response_headers = params[1] === null ? null : String(params[1]);
|
|
510
|
+
row.response_body = params[2] === null ? null : String(params[2]);
|
|
511
|
+
if (params[3] !== null && params[3] !== undefined) row.run_id = String(params[3]);
|
|
512
|
+
return { changes: 1 };
|
|
513
|
+
}
|
|
514
|
+
if (text === IDEM_REMOVE_SQL) {
|
|
515
|
+
const idx = state.idem.findIndex(
|
|
516
|
+
(candidate) => idemPk(candidate, params) && candidate.claim_token === String(params[4]),
|
|
517
|
+
);
|
|
518
|
+
if (idx < 0) return { changes: 0 };
|
|
519
|
+
state.idem.splice(idx, 1);
|
|
520
|
+
return { changes: 1 };
|
|
521
|
+
}
|
|
522
|
+
if (text === IDEM_RENEW_SQL) {
|
|
523
|
+
const row = state.idem.find(
|
|
524
|
+
(candidate) =>
|
|
525
|
+
idemPk(candidate, params, 1) &&
|
|
526
|
+
candidate.claim_token === String(params[5]) &&
|
|
527
|
+
candidate.status === "in_progress",
|
|
528
|
+
);
|
|
529
|
+
if (row === undefined) return { changes: 0 };
|
|
530
|
+
row.lease_expires_at = Number(params[0]);
|
|
531
|
+
return { changes: 1 };
|
|
532
|
+
}
|
|
533
|
+
if (text === IDEM_ATTACH_SQL) {
|
|
534
|
+
const row = state.idem.find(
|
|
535
|
+
(candidate) =>
|
|
536
|
+
idemPk(candidate, params, 1) && candidate.claim_token === String(params[5]),
|
|
537
|
+
);
|
|
538
|
+
if (row === undefined) return { changes: 0 };
|
|
539
|
+
row.run_id = String(params[0]);
|
|
540
|
+
return { changes: 1 };
|
|
541
|
+
}
|
|
542
|
+
if (text === IDEM_FORFEIT_SQL) {
|
|
543
|
+
const row = state.idem.find(
|
|
544
|
+
(candidate) =>
|
|
545
|
+
idemPk(candidate, params, 1) &&
|
|
546
|
+
candidate.claim_token === String(params[5]) &&
|
|
547
|
+
candidate.status === "in_progress",
|
|
548
|
+
);
|
|
549
|
+
if (row === undefined) return { changes: 0 };
|
|
550
|
+
row.claim_token = String(params[0]);
|
|
551
|
+
row.lease_expires_at = 0;
|
|
552
|
+
return { changes: 1 };
|
|
553
|
+
}
|
|
554
|
+
|
|
391
555
|
throw new Error(`postgres journal fake: unsupported exec: ${sql}`);
|
|
392
556
|
},
|
|
393
557
|
async begin(fn) {
|
|
@@ -460,6 +624,7 @@ export async function createPostgresJournalStore(
|
|
|
460
624
|
|
|
461
625
|
const store: PostgresJournalStore = {
|
|
462
626
|
sql,
|
|
627
|
+
idempotency: createPostgresIdempotencyStore(sql),
|
|
463
628
|
async get(runId) {
|
|
464
629
|
const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [runId]);
|
|
465
630
|
if (!rows[0]) return undefined;
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
TextIndexStore,
|
|
23
23
|
} from "./types.ts";
|
|
24
24
|
import { hostFromUrl } from "./external.ts";
|
|
25
|
+
import { requestSignal } from "../kernel/abort-scope.ts";
|
|
25
26
|
|
|
26
27
|
/** Error thrown when the remote Meilisearch is unreachable / unhealthy / errors. */
|
|
27
28
|
export class MeilisearchUnavailableError extends Error {
|
|
@@ -134,6 +135,7 @@ export async function openMeilisearchIndex(
|
|
|
134
135
|
res = await fetchFn(`${url}${path}`, {
|
|
135
136
|
...init,
|
|
136
137
|
headers: { ...headers, ...(init?.headers ?? {}) },
|
|
138
|
+
signal: requestSignal(init?.signal ?? null),
|
|
137
139
|
});
|
|
138
140
|
} catch (err) {
|
|
139
141
|
throw new MeilisearchUnavailableError(
|
|
@@ -115,4 +115,14 @@ describe("builtin vault driver", () => {
|
|
|
115
115
|
await rm(dir, { recursive: true, force: true });
|
|
116
116
|
}
|
|
117
117
|
}, 15_000);
|
|
118
|
+
|
|
119
|
+
test("webhook audit without a URL fails open instead of degrading", async () => {
|
|
120
|
+
await expect(
|
|
121
|
+
builtinVaultDriver.open({
|
|
122
|
+
connection: sharedConn,
|
|
123
|
+
env: {},
|
|
124
|
+
audit: { sink: "webhook" },
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow(/webhookUrl/);
|
|
127
|
+
});
|
|
118
128
|
});
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
sqlConnectionAsExec,
|
|
19
19
|
type BuiltinVaultAdapter,
|
|
20
20
|
} from "../elements/vault/builtin-adapter.ts";
|
|
21
|
+
import { isVaultError } from "../elements/vault/errors.ts";
|
|
21
22
|
import type { SqlConnection } from "./types.ts";
|
|
22
23
|
import type { VaultBag, VaultDriver, VaultOpenOptions } from "./vault-types.ts";
|
|
23
24
|
|
|
@@ -39,6 +40,10 @@ export interface OpenBuiltinVaultOptions {
|
|
|
39
40
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
40
41
|
/** DEKs re-wrapped per master-rotation batch. */
|
|
41
42
|
readonly kekRewrapBatchSize?: number;
|
|
43
|
+
/** `vault.audit` from config. Omitted means the SQL hash chain. */
|
|
44
|
+
readonly audit?: import("../elements/vault/types.ts").VaultAuditConfig;
|
|
45
|
+
/** Fetch override for the webhook audit sink (tests). */
|
|
46
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/** An adapter plus the connection this module opened for it, if any. */
|
|
@@ -76,6 +81,8 @@ export async function openBuiltinVaultAdapter(
|
|
|
76
81
|
...(options.kekRewrapBatchSize === undefined
|
|
77
82
|
? {}
|
|
78
83
|
: { kekRewrapBatchSize: options.kekRewrapBatchSize }),
|
|
84
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
85
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
79
86
|
});
|
|
80
87
|
|
|
81
88
|
const masterKey = options.masterKey ?? env[VAULT_MASTER_KEY_ENV];
|
|
@@ -114,6 +121,8 @@ export const builtinVaultDriver: VaultDriver = {
|
|
|
114
121
|
opened = await openBuiltinVaultAdapter({
|
|
115
122
|
env,
|
|
116
123
|
...(options.connection !== undefined ? { connection: options.connection } : { url }),
|
|
124
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
125
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
117
126
|
});
|
|
118
127
|
if (opened.adapter.getUnsealer() !== null) {
|
|
119
128
|
for (const entry of await opened.adapter.list()) {
|
|
@@ -121,7 +130,10 @@ export const builtinVaultDriver: VaultDriver = {
|
|
|
121
130
|
if (secret) map.set(entry.path, secret.value);
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
|
-
} catch {
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// A bad audit sink is a config error — do not hide it behind the
|
|
135
|
+
// uninitialized-backend fallback.
|
|
136
|
+
if (isVaultError(err, "MISSING_PEER")) throw err;
|
|
125
137
|
// Uninitialized / sealed / unreachable: fall back to the seed bag so
|
|
126
138
|
// boot reports missing contracts instead of a driver stack trace.
|
|
127
139
|
await opened?.close().catch(() => undefined);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { VaultError } from "../elements/vault/errors.ts";
|
|
11
|
+
import { requestSignal } from "../kernel/abort-scope.ts";
|
|
11
12
|
import type { VaultBag } from "./vault-types.ts";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -183,7 +184,10 @@ export async function vaultHttpJson(
|
|
|
183
184
|
): Promise<VaultHttpJsonResult> {
|
|
184
185
|
let response: Response;
|
|
185
186
|
try {
|
|
186
|
-
response = await fetchFn(url,
|
|
187
|
+
response = await fetchFn(url, {
|
|
188
|
+
...init,
|
|
189
|
+
signal: requestSignal(init.signal),
|
|
190
|
+
});
|
|
187
191
|
} catch (error) {
|
|
188
192
|
throw asRemoteVaultError(error, message);
|
|
189
193
|
}
|
|
@@ -46,8 +46,13 @@ export interface VaultOpenOptions {
|
|
|
46
46
|
readonly region?: string;
|
|
47
47
|
/** Seed map for `memory` / `managed` drivers. */
|
|
48
48
|
readonly secrets?: Readonly<Record<string, string>>;
|
|
49
|
-
/** HTTP fetch override for remote drivers (tests). */
|
|
49
|
+
/** HTTP fetch override for remote drivers and the webhook audit sink (tests). */
|
|
50
50
|
readonly fetch?: typeof globalThis.fetch;
|
|
51
|
+
/**
|
|
52
|
+
* Built-in vault audit block (`vault.audit`). Honored by the `vault` driver.
|
|
53
|
+
* Omitted means the SQL hash chain.
|
|
54
|
+
*/
|
|
55
|
+
readonly audit?: import("../elements/vault/types.ts").VaultAuditConfig;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/** Resolved secret bag — names → cleartext values. Never logged. */
|
|
@@ -519,12 +519,12 @@ function emitTableBlock(table: SchemaTableDecl, dialect: SqlDialect): string {
|
|
|
519
519
|
const exportName = exportNameForTable(sqlName);
|
|
520
520
|
const cols = `{\n${lines.join("\n")}\n}`;
|
|
521
521
|
const policies = table.policies ?? [];
|
|
522
|
+
const ctor = table.rls === true ? "pgTable.withRLS" : "pgTable";
|
|
522
523
|
if (policies.length === 0) {
|
|
523
|
-
const ctor = table.rls === true ? "pgTable.withRLS" : "pgTable";
|
|
524
524
|
return `export const ${exportName} = ${ctor}(${JSON.stringify(sqlName)}, ${cols});`;
|
|
525
525
|
}
|
|
526
526
|
const extras = policies.map((policy) => ` ${emitPolicyExtra(policy, sqlName)},`).join("\n");
|
|
527
|
-
return `export const ${exportName} =
|
|
527
|
+
return `export const ${exportName} = ${ctor}(${JSON.stringify(sqlName)}, ${cols}, (_t) => [\n${extras}\n]);`;
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
function emitPolicyExtra(policy: SchemaPolicyDecl, table: string): string {
|
|
@@ -88,6 +88,7 @@ describe("project-wide store.live default — real boot", () => {
|
|
|
88
88
|
});
|
|
89
89
|
try {
|
|
90
90
|
await createTestApp(app, {
|
|
91
|
+
capability: "open",
|
|
91
92
|
boot: {
|
|
92
93
|
stores: [db],
|
|
93
94
|
config: { drivers: { store: { sql: { test: "pglite" } } } },
|
|
@@ -127,6 +128,7 @@ describe("project-wide store.live default — real boot", () => {
|
|
|
127
128
|
});
|
|
128
129
|
try {
|
|
129
130
|
await createTestApp(app, {
|
|
131
|
+
capability: "open",
|
|
130
132
|
boot: {
|
|
131
133
|
stores: [db],
|
|
132
134
|
config: { drivers: { store: { sql: { test: "pglite" } } } },
|
|
@@ -124,6 +124,7 @@ describe("manual live flow (.live(table) + liveQuery) — real boot", () => {
|
|
|
124
124
|
// SQL unless boot.config.drivers.store.sql is set explicitly.
|
|
125
125
|
try {
|
|
126
126
|
await createTestApp(app, {
|
|
127
|
+
capability: "open",
|
|
127
128
|
boot: {
|
|
128
129
|
stores: [db],
|
|
129
130
|
config: { drivers: { store: { sql: { test: "pglite" } } } },
|
|
@@ -40,7 +40,7 @@ describe("resource-list-docs fixtures — documented behaviors stay real", () =>
|
|
|
40
40
|
// (and cleared by the global afterEach registry reset).
|
|
41
41
|
Object.assign(app.$options, { env: "test", stores: [docsListItemsStore] });
|
|
42
42
|
|
|
43
|
-
const t = await createTestApp(app);
|
|
43
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
44
44
|
|
|
45
45
|
const bad = await app.fetch(new Request("http://localhost/docs-restricted?secret=eq.x"));
|
|
46
46
|
expect(bad.status).toBe(422);
|
|
@@ -70,7 +70,7 @@ describe("resource-list-docs fixtures — documented behaviors stay real", () =>
|
|
|
70
70
|
const app = oke({ name: "docs-admin-test" }).adopt({ docs: exactMounted });
|
|
71
71
|
Object.assign(app.$options, { env: "test", stores: [docsListItemsStore] });
|
|
72
72
|
|
|
73
|
-
const t = await createTestApp(app);
|
|
73
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
74
74
|
const created = await app.fetch(
|
|
75
75
|
new Request("http://localhost/docs-admin", {
|
|
76
76
|
method: "POST",
|
|
@@ -156,7 +156,7 @@ describe("resource HTTP — exact keys + PII with differing TS/SQL names", () =>
|
|
|
156
156
|
|
|
157
157
|
test("app.fetch list/get/create/update: only createdAt + masked email, never raw SQL keys", async () => {
|
|
158
158
|
const app = buildApp();
|
|
159
|
-
const t = await createTestApp(app);
|
|
159
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
160
160
|
|
|
161
161
|
// create → 201
|
|
162
162
|
const created = await app.fetch(
|
|
@@ -234,7 +234,7 @@ describe("resource HTTP — exact keys + PII with differing TS/SQL names", () =>
|
|
|
234
234
|
|
|
235
235
|
test("list cursor meta walks next and previous", async () => {
|
|
236
236
|
const app = buildApp();
|
|
237
|
-
const t = await createTestApp(app);
|
|
237
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
238
238
|
for (const email of ["a@example.com", "b@example.com", "c@example.com"]) {
|
|
239
239
|
const created = await app.fetch(
|
|
240
240
|
new Request("http://localhost/contacts", {
|
|
@@ -317,7 +317,7 @@ describe("resource HTTP — exact keys + PII with differing TS/SQL names", () =>
|
|
|
317
317
|
config: { drivers: { store: { sql: { test: "pglite" } } } },
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
-
const t = await createTestApp(app);
|
|
320
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
321
321
|
const created = await app.fetch(
|
|
322
322
|
new Request("http://localhost/people", {
|
|
323
323
|
method: "POST",
|
|
@@ -289,6 +289,7 @@ describe("store.schema RLS extras", () => {
|
|
|
289
289
|
"scope_booking_create_insert",
|
|
290
290
|
]);
|
|
291
291
|
const src = emitDrizzleSource([bookings], "postgres");
|
|
292
|
+
expect(src).toContain("pgTable.withRLS");
|
|
292
293
|
expect(src).toContain("pgPolicy");
|
|
293
294
|
expect(src).toContain("oke.gate() = ");
|
|
294
295
|
expect(src).toContain("oke.user()");
|
|
@@ -215,6 +215,8 @@ function compileSqlNode(node: unknown): CompiledNode {
|
|
|
215
215
|
|
|
216
216
|
// Group level: only structural parens and ` and ` / ` or ` separators may
|
|
217
217
|
// appear next to nested conditions — anything else is a loud failure.
|
|
218
|
+
// `1.0.0-rc.5-5935859` glues the parens onto the separator (`"(("`,
|
|
219
|
+
// `") and ("`, `"))`).
|
|
218
220
|
let joiner: "AND" | "OR" | undefined;
|
|
219
221
|
for (const chunk of chunks) {
|
|
220
222
|
if (isDrizzleSql(chunk)) continue;
|
|
@@ -222,14 +224,13 @@ function compileSqlNode(node: unknown): CompiledNode {
|
|
|
222
224
|
if (text === undefined) {
|
|
223
225
|
throw new TypeError("sql where: unsupported mixed condition");
|
|
224
226
|
}
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
if (joiner !== undefined && joiner !== next) {
|
|
227
|
+
const sep = groupSeparator(text);
|
|
228
|
+
if (sep === "skip") continue;
|
|
229
|
+
if (sep === "AND" || sep === "OR") {
|
|
230
|
+
if (joiner !== undefined && joiner !== sep) {
|
|
230
231
|
throw new TypeError("sql where: mixed AND/OR at one level — parenthesize explicitly");
|
|
231
232
|
}
|
|
232
|
-
joiner =
|
|
233
|
+
joiner = sep;
|
|
233
234
|
continue;
|
|
234
235
|
}
|
|
235
236
|
throw new TypeError(`sql where: unsupported fragment ${JSON.stringify(text)}`);
|
|
@@ -329,6 +330,22 @@ function compileLeaf(chunks: readonly unknown[]): CompiledNode {
|
|
|
329
330
|
};
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Classify a group-level string chunk.
|
|
335
|
+
*
|
|
336
|
+
* Empty or parenthesis-only text is structural. `and` / `or` may sit inside
|
|
337
|
+
* the parentheses (`") and ("`). Anything else is not a separator.
|
|
338
|
+
*
|
|
339
|
+
* @param text - Raw `StringChunk` text
|
|
340
|
+
*/
|
|
341
|
+
function groupSeparator(text: string): "skip" | "AND" | "OR" | undefined {
|
|
342
|
+
const token = text.trim().toLowerCase().replace(/[()]/g, "").trim();
|
|
343
|
+
if (token === "") return "skip";
|
|
344
|
+
if (token === "and") return "AND";
|
|
345
|
+
if (token === "or") return "OR";
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
332
349
|
function chunksOf(node: unknown): readonly unknown[] {
|
|
333
350
|
return (node as { queryChunks: unknown[] }).queryChunks;
|
|
334
351
|
}
|
|
@@ -353,7 +370,7 @@ function isStringChunk(chunk: object): boolean {
|
|
|
353
370
|
/**
|
|
354
371
|
* Joined text of a Drizzle `StringChunk`.
|
|
355
372
|
*
|
|
356
|
-
* drizzle-orm `1.0.0-rc.5-
|
|
373
|
+
* drizzle-orm `1.0.0-rc.5-5935859` stores `value` as a `string`. Earlier
|
|
357
374
|
* snapshots (and the synthetic chunks in `resource.ts`) store `string[]`.
|
|
358
375
|
*/
|
|
359
376
|
function chunkText(chunk: unknown): string | undefined {
|
|
@@ -65,7 +65,7 @@ describe("fx.store(db).upsert via app.fetch (real boot)", () => {
|
|
|
65
65
|
gate: { policies: [gate.public] },
|
|
66
66
|
}).adopt({ seed });
|
|
67
67
|
Object.assign(app.$options, { env: "test", stores: [db], unguardedHttp: "allow" });
|
|
68
|
-
await createTestApp(app);
|
|
68
|
+
await createTestApp(app, { capability: "open" });
|
|
69
69
|
|
|
70
70
|
async function post(body: unknown) {
|
|
71
71
|
const res = await app.fetch(
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit sinks — secret-free stdout/webhook payloads, and chain commands
|
|
3
|
+
* that exist only for the `db` sink.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createAuditSink, type AuditEntry } from "./audit.ts";
|
|
8
|
+
import { buildVaultBootChain } from "./boot-chain.ts";
|
|
9
|
+
import { createBuiltinVaultAdapter, sqlConnectionAsExec } from "./builtin-adapter.ts";
|
|
10
|
+
import { isVaultError } from "./errors.ts";
|
|
11
|
+
import { createMemoryVaultSql } from "./test-helpers.ts";
|
|
12
|
+
|
|
13
|
+
const ENTRY: AuditEntry = {
|
|
14
|
+
action: "set",
|
|
15
|
+
path: "prod/api",
|
|
16
|
+
actorType: "flow",
|
|
17
|
+
actorId: "orders.place",
|
|
18
|
+
success: true,
|
|
19
|
+
requestId: "req-1",
|
|
20
|
+
at: new Date("2026-01-02T03:04:05.000Z"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SECRET = "sk_live_do_not_log_me";
|
|
24
|
+
|
|
25
|
+
describe("webhook audit sink", () => {
|
|
26
|
+
test("missing or non-http webhookUrl fails at construction", () => {
|
|
27
|
+
expect(() => createAuditSink("webhook")).toThrow(/webhookUrl/);
|
|
28
|
+
expect(
|
|
29
|
+
isVaultError(
|
|
30
|
+
catchError(() => createAuditSink("webhook")),
|
|
31
|
+
"MISSING_PEER",
|
|
32
|
+
),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
expect(
|
|
35
|
+
isVaultError(
|
|
36
|
+
catchError(() => createAuditSink("webhook", { webhookUrl: "/relative" })),
|
|
37
|
+
"MISSING_PEER",
|
|
38
|
+
),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
expect(
|
|
41
|
+
isVaultError(
|
|
42
|
+
catchError(() => createAuditSink("webhook", { webhookUrl: "ftp://audit.example/hook" })),
|
|
43
|
+
"MISSING_PEER",
|
|
44
|
+
),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("POSTs the stdout-shaped JSON, refuses redirects, and omits secret values", async () => {
|
|
49
|
+
const bodies: string[] = [];
|
|
50
|
+
const inits: RequestInit[] = [];
|
|
51
|
+
const sink = createAuditSink("webhook", {
|
|
52
|
+
webhookUrl: "https://audit.example/hook",
|
|
53
|
+
fetch: (async (_url, init) => {
|
|
54
|
+
inits.push(init ?? {});
|
|
55
|
+
bodies.push(String(init?.body ?? ""));
|
|
56
|
+
return new Response(null, { status: 204 });
|
|
57
|
+
}) as typeof fetch,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await sink.append(ENTRY);
|
|
61
|
+
|
|
62
|
+
expect(inits[0]?.method).toBe("POST");
|
|
63
|
+
expect(inits[0]?.redirect).toBe("error");
|
|
64
|
+
expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
|
|
65
|
+
const parsed = JSON.parse(bodies[0] ?? "{}") as Record<string, unknown>;
|
|
66
|
+
expect(parsed).toEqual({
|
|
67
|
+
sink: "oke.vault.audit",
|
|
68
|
+
action: "set",
|
|
69
|
+
path: "prod/api",
|
|
70
|
+
actorType: "flow",
|
|
71
|
+
actorId: "orders.place",
|
|
72
|
+
success: true,
|
|
73
|
+
errorCode: null,
|
|
74
|
+
errorMessage: null,
|
|
75
|
+
requestId: "req-1",
|
|
76
|
+
createdAt: "2026-01-02T03:04:05.000Z",
|
|
77
|
+
});
|
|
78
|
+
expect(bodies[0]).not.toContain(SECRET);
|
|
79
|
+
expect(parsed).not.toHaveProperty("value");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a non-2xx response rejects without echoing the body", async () => {
|
|
83
|
+
const sink = createAuditSink("webhook", {
|
|
84
|
+
webhookUrl: "https://audit.example/hook",
|
|
85
|
+
fetch: (async () => new Response(SECRET, { status: 502 })) as unknown as typeof fetch,
|
|
86
|
+
});
|
|
87
|
+
const failure = await sink.append(ENTRY).catch((err: unknown) => err);
|
|
88
|
+
expect(isVaultError(failure, "BACKEND_ERROR")).toBe(true);
|
|
89
|
+
expect((failure as Error).message).not.toContain(SECRET);
|
|
90
|
+
expect((failure as Error).message).toContain("502");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("vault boot chain", () => {
|
|
95
|
+
test("forwards vault.audit only onto the builtin driver layer", () => {
|
|
96
|
+
const audit = { sink: "webhook" as const, webhookUrl: "https://audit.example/hook" };
|
|
97
|
+
const chain = buildVaultBootChain({ driverId: "vault", audit });
|
|
98
|
+
expect(chain[0]?.driver.id).toBe("vault");
|
|
99
|
+
expect(chain[0]?.options?.audit).toEqual(audit);
|
|
100
|
+
expect(chain.slice(1).every((layer) => layer.options?.audit === undefined)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("non-db audit commands", () => {
|
|
105
|
+
test("verify refuses a webhook sink before touching the chain", async () => {
|
|
106
|
+
const sql = createMemoryVaultSql();
|
|
107
|
+
try {
|
|
108
|
+
const adapter = createBuiltinVaultAdapter({
|
|
109
|
+
db: sqlConnectionAsExec(sql),
|
|
110
|
+
audit: { sink: "webhook", webhookUrl: "https://audit.example/hook" },
|
|
111
|
+
fetch: (async () => new Response(null, { status: 500 })) as unknown as typeof fetch,
|
|
112
|
+
});
|
|
113
|
+
const failure = await adapter.verifyAudit().catch((err: unknown) => err);
|
|
114
|
+
expect(isVaultError(failure, "UNSUPPORTED")).toBe(true);
|
|
115
|
+
expect((failure as Error).message).toContain('audit.sink "db"');
|
|
116
|
+
|
|
117
|
+
const init = await adapter.initialize();
|
|
118
|
+
expect(init.masterKey.length).toBeGreaterThan(0);
|
|
119
|
+
} finally {
|
|
120
|
+
await sql.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Capture a thrown construction error.
|
|
127
|
+
*
|
|
128
|
+
* @param fn - Throwing call
|
|
129
|
+
*/
|
|
130
|
+
function catchError(fn: () => unknown): unknown {
|
|
131
|
+
try {
|
|
132
|
+
fn();
|
|
133
|
+
return undefined;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return err;
|
|
136
|
+
}
|
|
137
|
+
}
|