okengine 0.11.1 → 0.11.2
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 +1 -1
- package/site/content/docs/elements/ai.mdx +22 -1
- package/site/content/docs/elements/store.mdx +3 -1
- package/site/content/docs/elements/vault.mdx +19 -11
- package/site/content/docs/get-started/installation.mdx +7 -1
- package/site/content/docs/recipes/llama-cpp.mdx +10 -9
- package/site/content/docs/reference/cli.md +6 -2
- package/site/content/docs/reference/environment-variables.mdx +9 -9
- package/src/cli/ai-setup/ai-setup.test.ts +3 -1
- package/src/cli/ai-setup/apply.ts +61 -1
- package/src/cli/ask-seed.test.ts +4 -3
- package/src/cli/ask-seed.ts +5 -6
- package/src/cli/client-add.test.ts +2 -1
- package/src/cli/dev.test.ts +116 -0
- package/src/cli/dev.ts +107 -18
- package/src/cli/project-state.test.ts +50 -0
- package/src/cli/project-state.ts +123 -0
- package/src/cli/vault-cmd.test.ts +47 -18
- package/src/cli/vault-cmd.ts +2 -1
- package/src/compiler/extract.ts +12 -1
- package/src/console/server/console.test.ts +3 -1
- package/src/console/server/operator-db.test.ts +48 -17
- package/src/console/server/operator-db.ts +5 -1
- package/src/docker/derive.ts +24 -3
- package/src/docker/docker.test.ts +4 -2
- package/src/docker/index.ts +1 -0
- package/src/docker/recipes/index.ts +1 -0
- package/src/docker/recipes/llama-cpp.ts +20 -4
- package/src/drivers/ai-openai-compatible.ts +15 -3
- package/src/drivers/vault-builtin.test.ts +50 -42
- package/src/elements/ai/declare.ts +73 -3
- package/src/elements/ai/errors.test.ts +35 -0
- package/src/elements/ai/errors.ts +139 -0
- package/src/elements/ai/eval.ts +26 -1
- package/src/elements/ai/runtime.ts +140 -80
- package/src/elements/ai/tools.test.ts +1 -1
- package/src/elements/ai.test.ts +99 -2
- package/src/elements/ai.ts +11 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/store/index-boot.test.ts +23 -6
- package/src/elements/store/resource.test.ts +38 -19
- package/src/elements/store/sql-session.test.ts +55 -58
- package/src/elements/vault/builtin-adapter.test.ts +115 -58
- package/src/elements/vault/builtin-adapter.ts +241 -47
- package/src/elements/vault/chaos-child.ts +424 -0
- package/src/elements/vault/chaos.test.ts +651 -0
- package/src/elements/vault/resilience.ts +6 -1
- package/src/elements/vault/security-checklist.test.ts +10 -8
- package/src/elements/vault/storage.ts +130 -27
- package/src/elements/vault/test-helpers.ts +368 -0
- package/src/elements/vault.ts +6 -0
- package/src/index.ts +2 -0
- package/src/kernel/app.ts +83 -10
- package/src/kernel/auto-registry.test.ts +52 -1
- package/src/kernel/element-registries.ts +19 -4
- package/src/kernel/errors.ts +3 -3
- package/src/kernel/fx.test.ts +25 -0
- package/src/kernel/fx.ts +4 -1
- package/src/manifest/types.ts +4 -0
- package/src/test/create-test-app.ts +16 -11
- package/src/test/reset-element-registries.ts +17 -9
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Phase 10 security checklist — threat-model assertions for the built-in vault.
|
|
3
|
+
*
|
|
4
|
+
* SQL-backed cases use the in-memory Vault SQL fake: these tests assert seal
|
|
5
|
+
* physics and audit integrity, not Postgres dialect / pgvector behavior.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import { connectPglite } from "../../drivers/pglite.ts";
|
|
7
9
|
import type { SqlConnection } from "../../drivers/types.ts";
|
|
8
10
|
import {
|
|
9
11
|
createBuiltinVaultAdapter,
|
|
@@ -23,16 +25,17 @@ import {
|
|
|
23
25
|
import { VaultError } from "./errors.ts";
|
|
24
26
|
import { canonicalizePath } from "./path.ts";
|
|
25
27
|
import type { SqlExec } from "./storage.ts";
|
|
28
|
+
import { createMemoryVaultSql } from "./test-helpers.ts";
|
|
26
29
|
import { createMemoryUnsealer } from "./unseal.ts";
|
|
27
30
|
|
|
28
|
-
/** Fresh initialized + unsealed adapter. */
|
|
31
|
+
/** Fresh initialized + unsealed adapter over the in-memory Vault SQL fake. */
|
|
29
32
|
async function harness(): Promise<{
|
|
30
33
|
adapter: BuiltinVaultAdapter;
|
|
31
34
|
db: SqlExec;
|
|
32
35
|
conn: SqlConnection;
|
|
33
36
|
close(): Promise<void>;
|
|
34
37
|
}> {
|
|
35
|
-
const conn =
|
|
38
|
+
const conn = createMemoryVaultSql();
|
|
36
39
|
const db = sqlConnectionAsExec(conn);
|
|
37
40
|
const adapter = createBuiltinVaultAdapter({ db });
|
|
38
41
|
const init = await adapter.initialize!();
|
|
@@ -53,12 +56,11 @@ describe("vault security checklist", () => {
|
|
|
53
56
|
try {
|
|
54
57
|
const secret = `sk_live_${crypto.randomUUID()}`;
|
|
55
58
|
await h.adapter.set("prod/api/stripe", secret);
|
|
56
|
-
const rows = await h.conn.query(
|
|
57
|
-
"select encode(encrypted_value, 'escape') as blob from oke_vault_secrets",
|
|
58
|
-
);
|
|
59
|
+
const rows = await h.conn.query("select encrypted_value from oke_vault_secrets");
|
|
59
60
|
for (const row of rows) {
|
|
60
|
-
const
|
|
61
|
-
|
|
61
|
+
const raw = (row as { encrypted_value?: unknown }).encrypted_value;
|
|
62
|
+
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array();
|
|
63
|
+
expect(Buffer.from(bytes).toString("utf8").includes(secret)).toBe(false);
|
|
62
64
|
}
|
|
63
65
|
} finally {
|
|
64
66
|
await h.close();
|
|
@@ -35,6 +35,27 @@ export interface SqlExec {
|
|
|
35
35
|
* @param params - Positional parameters
|
|
36
36
|
*/
|
|
37
37
|
execute(sql: string, params?: unknown[]): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Optional transactional scope — required for `FOR UPDATE` / `SKIP LOCKED`
|
|
40
|
+
* lease and audit serialization (Clock / Signal class).
|
|
41
|
+
*
|
|
42
|
+
* @param fn - Work that must see a single snapshot under row locks
|
|
43
|
+
*/
|
|
44
|
+
begin?<T>(fn: (tx: SqlExec) => Promise<T>): Promise<T>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run `fn` inside {@link SqlExec.begin} when available.
|
|
49
|
+
*
|
|
50
|
+
* @param db - SQL surface
|
|
51
|
+
* @param fn - Transactional work
|
|
52
|
+
*/
|
|
53
|
+
export async function withSqlTransaction<T>(
|
|
54
|
+
db: SqlExec,
|
|
55
|
+
fn: (tx: SqlExec) => Promise<T>,
|
|
56
|
+
): Promise<T> {
|
|
57
|
+
if (db.begin) return db.begin(fn);
|
|
58
|
+
return fn(db);
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
/** Encrypted secret versions. One row per `(path, version)`. */
|
|
@@ -122,10 +143,21 @@ CREATE TABLE IF NOT EXISTS oke_vault_status (
|
|
|
122
143
|
rewrap_checkpoint text,
|
|
123
144
|
rewrap_target_kek_version integer,
|
|
124
145
|
rewrap_key_hash text,
|
|
146
|
+
rotate_locked_by text,
|
|
147
|
+
rotate_lease_expires_at bigint,
|
|
125
148
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
126
149
|
CONSTRAINT oke_vault_status_singleton CHECK (id = 1)
|
|
127
150
|
)`;
|
|
128
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Claim the rotate-master lease — same predicate as Clock/Signal
|
|
154
|
+
* (`FOR UPDATE SKIP LOCKED` + lease-expiry reclaim).
|
|
155
|
+
*/
|
|
156
|
+
export const CLAIM_ROTATE_LEASE_SQL: string = `SELECT id FROM oke_vault_status WHERE id = 1 AND ((rotate_locked_by IS NULL) OR (rotate_locked_by = $1) OR (rotate_lease_expires_at IS NULL) OR (rotate_lease_expires_at <= $2)) FOR UPDATE SKIP LOCKED`;
|
|
157
|
+
|
|
158
|
+
/** Default rotate-master lease TTL (ms) — lazy reclaim, no sweeper. */
|
|
159
|
+
export const DEFAULT_ROTATE_LEASE_MS: number = 30_000;
|
|
160
|
+
|
|
129
161
|
/** Index / seed statements applied after the tables exist. */
|
|
130
162
|
const POST_DDL_STATEMENTS: readonly string[] = [
|
|
131
163
|
`CREATE INDEX IF NOT EXISTS oke_vault_secrets_path_idx ON oke_vault_secrets (path) WHERE deleted_at IS NULL`,
|
|
@@ -134,6 +166,8 @@ const POST_DDL_STATEMENTS: readonly string[] = [
|
|
|
134
166
|
`CREATE INDEX IF NOT EXISTS oke_vault_keys_kek_version_idx ON oke_vault_keys (kek_version)`,
|
|
135
167
|
`ALTER TABLE oke_vault_audit ADD COLUMN IF NOT EXISTS seq bigserial NOT NULL`,
|
|
136
168
|
`ALTER TABLE oke_vault_status ADD COLUMN IF NOT EXISTS rewrap_key_hash text`,
|
|
169
|
+
`ALTER TABLE oke_vault_status ADD COLUMN IF NOT EXISTS rotate_locked_by text`,
|
|
170
|
+
`ALTER TABLE oke_vault_status ADD COLUMN IF NOT EXISTS rotate_lease_expires_at bigint`,
|
|
137
171
|
`CREATE INDEX IF NOT EXISTS oke_vault_audit_seq_idx ON oke_vault_audit (seq)`,
|
|
138
172
|
`CREATE INDEX IF NOT EXISTS oke_vault_audit_created_idx ON oke_vault_audit (created_at, id)`,
|
|
139
173
|
`CREATE INDEX IF NOT EXISTS oke_vault_audit_path_idx ON oke_vault_audit (path)`,
|
|
@@ -391,13 +425,78 @@ export async function verifyAuditChain(db: SqlExec): Promise<AuditChainResult> {
|
|
|
391
425
|
return { ok: true };
|
|
392
426
|
}
|
|
393
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Acquire the rotate-master lease (Clock/Signal SKIP LOCKED + lease-expiry).
|
|
430
|
+
*
|
|
431
|
+
* Exactly one concurrent claimant wins; losers get `false` immediately
|
|
432
|
+
* (no wait). A crashed holder's lease is reclaimed lazily when
|
|
433
|
+
* `rotate_lease_expires_at <= now`.
|
|
434
|
+
*
|
|
435
|
+
* @param db - SQL surface (uses {@link SqlExec.begin} when present)
|
|
436
|
+
* @param holderId - Claimant id (per rotation attempt)
|
|
437
|
+
* @param now - Epoch-ms
|
|
438
|
+
* @param leaseMs - Lease TTL
|
|
439
|
+
*/
|
|
440
|
+
export async function acquireRotateLease(
|
|
441
|
+
db: SqlExec,
|
|
442
|
+
holderId: string,
|
|
443
|
+
now: number,
|
|
444
|
+
leaseMs: number = DEFAULT_ROTATE_LEASE_MS,
|
|
445
|
+
): Promise<boolean> {
|
|
446
|
+
return withSqlTransaction(db, async (tx) => {
|
|
447
|
+
const claimed = await tx.query<{ id: number | string }>(CLAIM_ROTATE_LEASE_SQL, [
|
|
448
|
+
holderId,
|
|
449
|
+
now,
|
|
450
|
+
]);
|
|
451
|
+
if (!claimed[0]) return false;
|
|
452
|
+
await tx.execute(
|
|
453
|
+
`UPDATE oke_vault_status
|
|
454
|
+
SET rotate_locked_by = $1, rotate_lease_expires_at = $2, updated_at = now()
|
|
455
|
+
WHERE id = 1`,
|
|
456
|
+
[holderId, now + leaseMs],
|
|
457
|
+
);
|
|
458
|
+
return true;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Drop the rotate-master lease when this holder still owns it.
|
|
464
|
+
*
|
|
465
|
+
* @param db - SQL surface
|
|
466
|
+
* @param holderId - Holder that acquired the lease
|
|
467
|
+
*/
|
|
468
|
+
export async function releaseRotateLease(db: SqlExec, holderId: string): Promise<void> {
|
|
469
|
+
await db.execute(
|
|
470
|
+
`UPDATE oke_vault_status
|
|
471
|
+
SET rotate_locked_by = NULL, rotate_lease_expires_at = NULL, updated_at = now()
|
|
472
|
+
WHERE id = 1 AND rotate_locked_by = $1`,
|
|
473
|
+
[holderId],
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Renew the rotate-master lease for a long rewrap.
|
|
479
|
+
*
|
|
480
|
+
* @param db - SQL surface
|
|
481
|
+
* @param holderId - Current holder
|
|
482
|
+
* @param now - Epoch-ms
|
|
483
|
+
* @param leaseMs - Lease TTL
|
|
484
|
+
*/
|
|
485
|
+
export async function renewRotateLease(
|
|
486
|
+
db: SqlExec,
|
|
487
|
+
holderId: string,
|
|
488
|
+
now: number,
|
|
489
|
+
leaseMs: number = DEFAULT_ROTATE_LEASE_MS,
|
|
490
|
+
): Promise<boolean> {
|
|
491
|
+
return acquireRotateLease(db, holderId, now, leaseMs);
|
|
492
|
+
}
|
|
493
|
+
|
|
394
494
|
/**
|
|
395
495
|
* Backend-side {@link AuditWriter} over `oke_vault_audit`.
|
|
396
496
|
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* appends would otherwise share a `prev_hash`.
|
|
497
|
+
* Appends run inside a transaction that locks the status singleton with
|
|
498
|
+
* `SELECT … FOR UPDATE` so concurrent writers cannot share a `prev_hash`
|
|
499
|
+
* (same exclusivity class as Signal competing consumers).
|
|
401
500
|
*
|
|
402
501
|
* @param db - SQL surface
|
|
403
502
|
*/
|
|
@@ -407,29 +506,33 @@ export function createSqlAuditWriter(db: SqlExec): AuditWriter {
|
|
|
407
506
|
const createdAt = entry.at ?? new Date();
|
|
408
507
|
const payload = toAuditHashPayload(entry, createdAt);
|
|
409
508
|
try {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
509
|
+
await withSqlTransaction(db, async (tx) => {
|
|
510
|
+
// Serialize writers on the singleton — FOR UPDATE waits, does not skip.
|
|
511
|
+
await tx.query(`SELECT id FROM oke_vault_status WHERE id = 1 FOR UPDATE`);
|
|
512
|
+
const head = await tx.query<{ row_hash: string }>(
|
|
513
|
+
`SELECT row_hash FROM oke_vault_audit ORDER BY seq DESC LIMIT 1`,
|
|
514
|
+
);
|
|
515
|
+
const prevHash = head[0]?.row_hash ?? AUDIT_GENESIS_HASH;
|
|
516
|
+
const rowHash = await computeAuditRowHash(prevHash, payload);
|
|
517
|
+
await tx.execute(
|
|
518
|
+
`INSERT INTO oke_vault_audit
|
|
519
|
+
(action, path, actor_type, actor_id, success, error_code, error_message, request_id, prev_hash, row_hash, created_at)
|
|
520
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
521
|
+
[
|
|
522
|
+
payload.action,
|
|
523
|
+
payload.path,
|
|
524
|
+
payload.actorType,
|
|
525
|
+
payload.actorId,
|
|
526
|
+
payload.success,
|
|
527
|
+
payload.errorCode,
|
|
528
|
+
payload.errorMessage,
|
|
529
|
+
payload.requestId,
|
|
530
|
+
prevHash,
|
|
531
|
+
rowHash,
|
|
532
|
+
createdAt,
|
|
533
|
+
],
|
|
534
|
+
);
|
|
535
|
+
});
|
|
433
536
|
} catch (error) {
|
|
434
537
|
if (error instanceof VaultError) throw error;
|
|
435
538
|
throw new VaultError("BACKEND_ERROR", "vault: failed to append audit row");
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Vault SQL fake + shared-PGlite helpers for Vault tests.
|
|
3
|
+
*
|
|
4
|
+
* - {@link createMemoryVaultSql} — logic-only unit tests (no WASM).
|
|
5
|
+
* - {@link resetVaultTables} — cheap isolation between dialect tests that
|
|
6
|
+
* share one warmed PGlite connection per file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SqlConnection, SqlRow } from "../../drivers/types.ts";
|
|
10
|
+
|
|
11
|
+
/** Vault tables truncated between shared-PGlite dialect tests. */
|
|
12
|
+
const VAULT_TABLES = [
|
|
13
|
+
"oke_vault_keys",
|
|
14
|
+
"oke_vault_secrets",
|
|
15
|
+
"oke_vault_audit",
|
|
16
|
+
"oke_vault_master",
|
|
17
|
+
"oke_vault_status",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wipe every Vault table on a live connection and restart identity columns.
|
|
22
|
+
*
|
|
23
|
+
* Used when dialect tests share one warmed PGlite instance: TRUNCATE is
|
|
24
|
+
* milliseconds; a fresh `PGlite.create` is hundreds of ms to multi-second.
|
|
25
|
+
* No-ops when tables do not exist yet (first harness before DDL).
|
|
26
|
+
*
|
|
27
|
+
* @param conn - Open SQL connection (typically shared file-scoped PGlite)
|
|
28
|
+
*/
|
|
29
|
+
export async function resetVaultTables(conn: SqlConnection): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
await conn.exec(`TRUNCATE ${VAULT_TABLES.join(", ")} RESTART IDENTITY CASCADE`);
|
|
32
|
+
} catch {
|
|
33
|
+
// Tables not created yet — ensureVaultTables will seed on first use.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One row in an in-memory table. */
|
|
38
|
+
type Row = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create an in-process {@link SqlConnection} that speaks the Vault SQL
|
|
42
|
+
* surface (Postgres `$n` placeholders, `bytea` as `Uint8Array`, auto `seq`).
|
|
43
|
+
*
|
|
44
|
+
* Pair with {@link import("./builtin-adapter.ts").sqlConnectionAsExec} when
|
|
45
|
+
* the adapter needs a {@link import("./storage.ts").SqlExec}.
|
|
46
|
+
*
|
|
47
|
+
* @returns Fresh isolated connection (empty vault tables)
|
|
48
|
+
*/
|
|
49
|
+
export function createMemoryVaultSql(): SqlConnection {
|
|
50
|
+
const secrets: Row[] = [];
|
|
51
|
+
const keys: Row[] = [];
|
|
52
|
+
const audit: Row[] = [];
|
|
53
|
+
const master: Row[] = [];
|
|
54
|
+
let status: Row = defaultStatus();
|
|
55
|
+
let auditSeq = 0;
|
|
56
|
+
|
|
57
|
+
async function query(sql: string, params: readonly unknown[] = []): Promise<SqlRow[]> {
|
|
58
|
+
const text = normalize(sql);
|
|
59
|
+
const p = [...params];
|
|
60
|
+
|
|
61
|
+
if (isDdl(text) || text.startsWith("create index")) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (text === "begin" || text === "commit" || text === "rollback") {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (text.startsWith("insert into oke_vault_status") && text.includes("on conflict")) {
|
|
70
|
+
// Singleton already seeded in {@link defaultStatus}.
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (text.startsWith("select id from oke_vault_status where id = 1 for update")) {
|
|
75
|
+
return [{ id: 1 }];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
text.startsWith("select id from oke_vault_status where id = 1 and") &&
|
|
80
|
+
text.includes("rotate_locked_by") &&
|
|
81
|
+
text.includes("for update skip locked")
|
|
82
|
+
) {
|
|
83
|
+
const holderId = p[0];
|
|
84
|
+
const now = Number(p[1]);
|
|
85
|
+
const lockedBy = status.rotate_locked_by;
|
|
86
|
+
const expires = status.rotate_lease_expires_at;
|
|
87
|
+
const free =
|
|
88
|
+
lockedBy == null || lockedBy === holderId || expires == null || Number(expires) <= now;
|
|
89
|
+
return free ? [{ id: 1 }] : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (text.startsWith("select sealed, initialized, master_key_present")) {
|
|
93
|
+
return [{ ...status }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
text.startsWith("select key_hash, kek_version from oke_vault_master") &&
|
|
98
|
+
text.includes("order by created_at asc")
|
|
99
|
+
) {
|
|
100
|
+
const sorted = [...master].sort((a, b) => toTime(a.created_at) - toTime(b.created_at));
|
|
101
|
+
const first = sorted[0];
|
|
102
|
+
return first ? [{ key_hash: first.key_hash, kek_version: first.kek_version }] : [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (text.startsWith("select count(distinct path) as count from oke_vault_secrets")) {
|
|
106
|
+
const live = secrets.filter((r) => r.deleted_at == null);
|
|
107
|
+
return [{ count: new Set(live.map((r) => r.path)).size }];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (text.startsWith("select row_hash from oke_vault_audit order by seq desc limit 1")) {
|
|
111
|
+
const last = [...audit].sort((a, b) => Number(b.seq) - Number(a.seq))[0];
|
|
112
|
+
return last ? [{ row_hash: last.row_hash }] : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
text.startsWith("select id, action, path, actor_type") &&
|
|
117
|
+
text.includes("from oke_vault_audit") &&
|
|
118
|
+
text.includes("order by seq asc")
|
|
119
|
+
) {
|
|
120
|
+
return [...audit].sort((a, b) => Number(a.seq) - Number(b.seq)).map((r) => ({ ...r }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (text.startsWith("select max(version) + 1 as next from oke_vault_secrets where path")) {
|
|
124
|
+
const path = p[0];
|
|
125
|
+
const versions = secrets.filter((r) => r.path === path).map((r) => Number(r.version));
|
|
126
|
+
const max = versions.length === 0 ? null : Math.max(...versions);
|
|
127
|
+
return [{ next: max === null ? null : max + 1 }];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
text.startsWith("insert into oke_vault_secrets") &&
|
|
132
|
+
text.includes("returning id, created_at, updated_at")
|
|
133
|
+
) {
|
|
134
|
+
const now = new Date();
|
|
135
|
+
const row: Row = {
|
|
136
|
+
id: crypto.randomUUID(),
|
|
137
|
+
path: p[0],
|
|
138
|
+
encrypted_value: asBytes(p[1]),
|
|
139
|
+
iv: asBytes(p[2]),
|
|
140
|
+
auth_tag: asBytes(p[3]),
|
|
141
|
+
version: p[4],
|
|
142
|
+
metadata: typeof p[5] === "string" ? p[5] : JSON.stringify(p[5] ?? {}),
|
|
143
|
+
algorithm: p[6],
|
|
144
|
+
kek_version: p[7],
|
|
145
|
+
expires_at: p[8] ?? null,
|
|
146
|
+
created_at: now,
|
|
147
|
+
updated_at: now,
|
|
148
|
+
deleted_at: null,
|
|
149
|
+
};
|
|
150
|
+
secrets.push(row);
|
|
151
|
+
return [{ id: row.id as string, created_at: now, updated_at: now }];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (text.startsWith("insert into oke_vault_keys")) {
|
|
155
|
+
keys.push({
|
|
156
|
+
id: crypto.randomUUID(),
|
|
157
|
+
secret_id: p[0],
|
|
158
|
+
encrypted_dek: asBytes(p[1]),
|
|
159
|
+
dek_iv: asBytes(p[2]),
|
|
160
|
+
dek_auth_tag: asBytes(p[3]),
|
|
161
|
+
algorithm: p[4],
|
|
162
|
+
kek_version: p[5],
|
|
163
|
+
created_at: new Date(),
|
|
164
|
+
});
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (text.startsWith("insert into oke_vault_master")) {
|
|
169
|
+
const now = new Date();
|
|
170
|
+
master.push({
|
|
171
|
+
id: crypto.randomUUID(),
|
|
172
|
+
key_hash: p[0],
|
|
173
|
+
kek_version: p[1] ?? 1,
|
|
174
|
+
created_at: now,
|
|
175
|
+
updated_at: now,
|
|
176
|
+
});
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (text.startsWith("insert into oke_vault_audit")) {
|
|
181
|
+
auditSeq += 1;
|
|
182
|
+
audit.push({
|
|
183
|
+
id: crypto.randomUUID(),
|
|
184
|
+
seq: auditSeq,
|
|
185
|
+
action: p[0],
|
|
186
|
+
path: p[1] ?? null,
|
|
187
|
+
actor_type: p[2],
|
|
188
|
+
actor_id: p[3] ?? null,
|
|
189
|
+
success: p[4],
|
|
190
|
+
error_code: p[5] ?? null,
|
|
191
|
+
error_message: p[6] ?? null,
|
|
192
|
+
request_id: p[7] ?? null,
|
|
193
|
+
prev_hash: p[8] ?? null,
|
|
194
|
+
row_hash: p[9],
|
|
195
|
+
created_at: p[10] instanceof Date ? p[10] : new Date(String(p[10])),
|
|
196
|
+
});
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (text.startsWith("update oke_vault_status")) {
|
|
201
|
+
applyStatusUpdate(status, text, p);
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
text.startsWith("update oke_vault_audit set row_hash") &&
|
|
207
|
+
text.includes("where seq = (select max(seq) from oke_vault_audit)")
|
|
208
|
+
) {
|
|
209
|
+
const last = [...audit].sort((a, b) => Number(b.seq) - Number(a.seq))[0];
|
|
210
|
+
if (last) last.row_hash = p[0];
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (
|
|
215
|
+
text.startsWith("select encode(encrypted_value") ||
|
|
216
|
+
text.startsWith("select encrypted_value")
|
|
217
|
+
) {
|
|
218
|
+
return secrets.map((r) => {
|
|
219
|
+
const bytes = asBytes(r.encrypted_value);
|
|
220
|
+
if (text.includes("encode(")) {
|
|
221
|
+
return { blob: Buffer.from(bytes).toString("latin1") };
|
|
222
|
+
}
|
|
223
|
+
return { encrypted_value: bytes };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error(`memory vault sql: unsupported query: ${sql.trim().slice(0, 120)}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
driverId: "memory",
|
|
232
|
+
role: "primary",
|
|
233
|
+
query,
|
|
234
|
+
async exec(sql, params = []) {
|
|
235
|
+
await query(sql, params);
|
|
236
|
+
return { changes: 0 };
|
|
237
|
+
},
|
|
238
|
+
async close() {
|
|
239
|
+
/* in-memory — nothing to release */
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Collapse whitespace / case so template literals match. */
|
|
245
|
+
function normalize(sql: string): string {
|
|
246
|
+
return sql.replace(/\s+/g, " ").trim().toLowerCase();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** DDL / index / alter — tables are implicit in the fake. */
|
|
250
|
+
function isDdl(text: string): boolean {
|
|
251
|
+
return (
|
|
252
|
+
text.startsWith("create table") ||
|
|
253
|
+
text.startsWith("alter table") ||
|
|
254
|
+
text.startsWith("create index")
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Seed the singleton status row (`id = 1`). */
|
|
259
|
+
function defaultStatus(): Row {
|
|
260
|
+
return {
|
|
261
|
+
id: 1,
|
|
262
|
+
sealed: true,
|
|
263
|
+
initialized: false,
|
|
264
|
+
master_key_present: false,
|
|
265
|
+
last_sealed_at: null,
|
|
266
|
+
last_unsealed_at: null,
|
|
267
|
+
seal_count: 0,
|
|
268
|
+
rewrap_checkpoint: null,
|
|
269
|
+
rewrap_target_kek_version: null,
|
|
270
|
+
rewrap_key_hash: null,
|
|
271
|
+
rotate_locked_by: null,
|
|
272
|
+
rotate_lease_expires_at: null,
|
|
273
|
+
updated_at: new Date(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Apply a status UPDATE by reading assigned columns from the SQL text.
|
|
279
|
+
*
|
|
280
|
+
* Vault only emits a handful of fixed UPDATE shapes; we mirror those
|
|
281
|
+
* assignments rather than parsing a full SET grammar.
|
|
282
|
+
*/
|
|
283
|
+
function applyStatusUpdate(status: Row, text: string, params: unknown[]): void {
|
|
284
|
+
const now = new Date();
|
|
285
|
+
status.updated_at = now;
|
|
286
|
+
|
|
287
|
+
if (text.includes("initialized = true")) {
|
|
288
|
+
status.initialized = true;
|
|
289
|
+
status.sealed = true;
|
|
290
|
+
status.master_key_present = true;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (text.includes("sealed = false") && text.includes("last_unsealed_at")) {
|
|
295
|
+
status.sealed = false;
|
|
296
|
+
status.last_unsealed_at = now;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (text.includes("sealed = true") && text.includes("seal_count = seal_count + 1")) {
|
|
301
|
+
status.sealed = true;
|
|
302
|
+
status.last_sealed_at = now;
|
|
303
|
+
status.seal_count = Number(status.seal_count ?? 0) + 1;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (text.includes("rotate_locked_by = $1") && text.includes("rotate_lease_expires_at = $2")) {
|
|
308
|
+
status.rotate_locked_by = params[0] ?? null;
|
|
309
|
+
status.rotate_lease_expires_at = params[1] ?? null;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (text.includes("rotate_locked_by = null") && text.includes("rotate_lease_expires_at = null")) {
|
|
314
|
+
if (params[0] === undefined || status.rotate_locked_by === params[0]) {
|
|
315
|
+
status.rotate_locked_by = null;
|
|
316
|
+
status.rotate_lease_expires_at = null;
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (text.includes("rewrap_target_kek_version = $1") && text.includes("rewrap_key_hash = $2")) {
|
|
322
|
+
status.rewrap_target_kek_version = params[0] ?? null;
|
|
323
|
+
status.rewrap_checkpoint = null;
|
|
324
|
+
status.rewrap_key_hash = params[1] ?? null;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (
|
|
329
|
+
text.includes("rewrap_checkpoint = $1") &&
|
|
330
|
+
text.includes("rewrap_target_kek_version = $2") &&
|
|
331
|
+
text.includes("rewrap_key_hash = $3")
|
|
332
|
+
) {
|
|
333
|
+
status.rewrap_checkpoint = params[0] ?? null;
|
|
334
|
+
status.rewrap_target_kek_version = params[1] ?? null;
|
|
335
|
+
status.rewrap_key_hash = params[2] ?? null;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (
|
|
340
|
+
text.includes("rewrap_checkpoint = null") &&
|
|
341
|
+
text.includes("rewrap_target_kek_version = null") &&
|
|
342
|
+
text.includes("rewrap_key_hash = null")
|
|
343
|
+
) {
|
|
344
|
+
status.rewrap_checkpoint = null;
|
|
345
|
+
status.rewrap_target_kek_version = null;
|
|
346
|
+
status.rewrap_key_hash = null;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Fallback: ignore unknown status updates.
|
|
351
|
+
void params;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Coerce driver-ish binary params to `Uint8Array`. */
|
|
355
|
+
function asBytes(value: unknown): Uint8Array {
|
|
356
|
+
if (value instanceof Uint8Array) return value;
|
|
357
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
358
|
+
if (typeof value === "string") return new TextEncoder().encode(value);
|
|
359
|
+
if (value == null) return new Uint8Array();
|
|
360
|
+
throw new Error(`memory vault sql: expected bytes, got ${typeof value}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Epoch-ms for ordering `created_at` columns. */
|
|
364
|
+
function toTime(value: unknown): number {
|
|
365
|
+
if (value instanceof Date) return value.getTime();
|
|
366
|
+
if (typeof value === "string" || typeof value === "number") return new Date(value).getTime();
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
package/src/elements/vault.ts
CHANGED
|
@@ -67,10 +67,12 @@ export type {
|
|
|
67
67
|
} from "./vault/types.ts";
|
|
68
68
|
|
|
69
69
|
export {
|
|
70
|
+
BACKUP_END_MARKER,
|
|
70
71
|
BACKUP_MAGIC,
|
|
71
72
|
createBuiltinVaultAdapter,
|
|
72
73
|
DEFAULT_KEK_REWRAP_BATCH_SIZE,
|
|
73
74
|
sqlConnectionAsExec,
|
|
75
|
+
writeBackupFileAtomic,
|
|
74
76
|
} from "./vault/builtin-adapter.ts";
|
|
75
77
|
export type {
|
|
76
78
|
BuiltinVaultAdapter,
|
|
@@ -87,11 +89,15 @@ export {
|
|
|
87
89
|
export type { AuditAction, AuditEntry, AuditSink, AuditWriter } from "./vault/audit.ts";
|
|
88
90
|
|
|
89
91
|
export {
|
|
92
|
+
acquireRotateLease,
|
|
93
|
+
CLAIM_ROTATE_LEASE_SQL,
|
|
90
94
|
createSqlAuditWriter,
|
|
95
|
+
DEFAULT_ROTATE_LEASE_MS,
|
|
91
96
|
ensureVaultTables,
|
|
92
97
|
purgeAuditBefore,
|
|
93
98
|
purgeExpiredSecrets,
|
|
94
99
|
readAuditPage,
|
|
100
|
+
releaseRotateLease,
|
|
95
101
|
verifyAuditChain,
|
|
96
102
|
VAULT_DDL_STATEMENTS,
|
|
97
103
|
} from "./vault/storage.ts";
|