okengine 0.11.0 → 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/gate/config.ts +13 -3
- package/src/elements/gate/declare.ts +1 -1
- package/src/elements/gate/strategies.ts +2 -12
- 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-auth.ts +98 -0
- package/src/kernel/app.ts +120 -78
- package/src/kernel/auto-registry.test.ts +52 -1
- package/src/kernel/boot.test.ts +4 -18
- 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/release/build-lib.ts +3 -0
- package/src/shared/lazy-src.ts +79 -0
- package/src/test/create-test-app.ts +16 -11
- package/src/test/reset-element-registries.ts +17 -9
|
@@ -44,11 +44,15 @@ import { VaultError } from "./errors.ts";
|
|
|
44
44
|
import { canonicalizePath, canonicalizePrefix } from "./path.ts";
|
|
45
45
|
import { createResilientSqlExec } from "./resilience.ts";
|
|
46
46
|
import {
|
|
47
|
+
acquireRotateLease,
|
|
47
48
|
createSqlAuditWriter,
|
|
49
|
+
DEFAULT_ROTATE_LEASE_MS,
|
|
48
50
|
ensureVaultTables,
|
|
49
51
|
purgeAuditBefore as purgeAuditRows,
|
|
50
52
|
purgeExpiredSecrets,
|
|
51
53
|
readAuditPage,
|
|
54
|
+
releaseRotateLease,
|
|
55
|
+
renewRotateLease,
|
|
52
56
|
verifyAuditChain,
|
|
53
57
|
type AuditChainResult,
|
|
54
58
|
type AuditPageOptions,
|
|
@@ -76,6 +80,9 @@ export const DEFAULT_KEK_REWRAP_BATCH_SIZE = 100;
|
|
|
76
80
|
/** Leading magic line of an {@link BuiltinVaultAdapter.exportBackup} bundle. */
|
|
77
81
|
export const BACKUP_MAGIC = "oke-vault-backup-v1\n";
|
|
78
82
|
|
|
83
|
+
/** Trailing completeness marker — absent means the file was truncated mid-write. */
|
|
84
|
+
export const BACKUP_END_MARKER = "oke-vault-backup-end\n";
|
|
85
|
+
|
|
79
86
|
/** Synthetic path bound into the backup bundle's AAD. */
|
|
80
87
|
const BACKUP_AAD_PATH = "oke-vault-backup";
|
|
81
88
|
|
|
@@ -92,6 +99,11 @@ export interface CreateBuiltinVaultOptions {
|
|
|
92
99
|
readonly auditSink?: AuditSink;
|
|
93
100
|
/** DEKs re-wrapped per rotation batch. Defaults to {@link DEFAULT_KEK_REWRAP_BATCH_SIZE}. */
|
|
94
101
|
readonly kekRewrapBatchSize?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Rotate-master lease TTL in ms (Clock/Signal lazy-reclaim physics).
|
|
104
|
+
* Defaults to {@link DEFAULT_ROTATE_LEASE_MS}.
|
|
105
|
+
*/
|
|
106
|
+
readonly rotateLeaseMs?: number;
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
/** Progress of a master-key rotation. */
|
|
@@ -247,6 +259,8 @@ interface BackupMetadata {
|
|
|
247
259
|
readonly count: number;
|
|
248
260
|
readonly iv: string;
|
|
249
261
|
readonly tag: string;
|
|
262
|
+
/** Hex SHA-256 of the ciphertext bytes (completeness check before decrypt). */
|
|
263
|
+
readonly payloadSha256: string;
|
|
250
264
|
}
|
|
251
265
|
|
|
252
266
|
/** Master key held for the target generation of an in-flight rewrap. */
|
|
@@ -254,6 +268,8 @@ interface PendingRotation {
|
|
|
254
268
|
readonly unsealer: Unsealer;
|
|
255
269
|
readonly kekVersion: number;
|
|
256
270
|
readonly verifyHash: string;
|
|
271
|
+
/** Lease holder id for SKIP LOCKED rotate exclusivity. */
|
|
272
|
+
readonly holderId: string;
|
|
257
273
|
}
|
|
258
274
|
|
|
259
275
|
/**
|
|
@@ -270,6 +286,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
270
286
|
const rawDb = opts.db;
|
|
271
287
|
const db = createResilientSqlExec(rawDb);
|
|
272
288
|
const batchSize = Math.max(1, opts.kekRewrapBatchSize ?? DEFAULT_KEK_REWRAP_BATCH_SIZE);
|
|
289
|
+
const rotateLeaseMs = Math.max(50, opts.rotateLeaseMs ?? DEFAULT_ROTATE_LEASE_MS);
|
|
273
290
|
const audit = opts.auditSink ?? createAuditSink("db", { writer: createSqlAuditWriter(rawDb) });
|
|
274
291
|
|
|
275
292
|
let unsealer: Unsealer | null = opts.unsealer ?? null;
|
|
@@ -612,6 +629,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
612
629
|
unsealer?.seal();
|
|
613
630
|
unsealer = rotation.unsealer;
|
|
614
631
|
pending = null;
|
|
632
|
+
await releaseRotateLease(rawDb, rotation.holderId);
|
|
615
633
|
} else {
|
|
616
634
|
await db.execute(
|
|
617
635
|
`UPDATE oke_vault_status
|
|
@@ -620,6 +638,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
620
638
|
WHERE id = 1`,
|
|
621
639
|
[checkpoint, target, rotation.verifyHash],
|
|
622
640
|
);
|
|
641
|
+
await renewRotateLease(rawDb, rotation.holderId, Date.now(), rotateLeaseMs);
|
|
623
642
|
}
|
|
624
643
|
|
|
625
644
|
await record("rewrap", undefined, { success: true });
|
|
@@ -836,34 +855,65 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
836
855
|
if (pending) {
|
|
837
856
|
throw new VaultError("UNSUPPORTED", "vault: a master rotation is already in progress");
|
|
838
857
|
}
|
|
839
|
-
const master = await readMaster();
|
|
840
|
-
const target = toInt(master.kek_version) + 1;
|
|
841
858
|
|
|
842
|
-
const
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
verifyHash: await deriveVerifyHash(raw),
|
|
851
|
-
};
|
|
852
|
-
if (generated) encoded = masterKeyToBase64(raw);
|
|
853
|
-
} finally {
|
|
854
|
-
if (generated) zeroBytes(raw);
|
|
859
|
+
const holderId = crypto.randomUUID();
|
|
860
|
+
const now = Date.now();
|
|
861
|
+
const won = await acquireRotateLease(rawDb, holderId, now, rotateLeaseMs);
|
|
862
|
+
if (!won) {
|
|
863
|
+
throw new VaultError(
|
|
864
|
+
"UNSUPPORTED",
|
|
865
|
+
"vault: master rotation lease held by another instance",
|
|
866
|
+
);
|
|
855
867
|
}
|
|
856
|
-
pending = rotation;
|
|
857
868
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
869
|
+
try {
|
|
870
|
+
const status = await readStatus();
|
|
871
|
+
if (status.rewrap_target_kek_version !== null) {
|
|
872
|
+
throw new VaultError("UNSUPPORTED", "vault: a master rotation is already in progress");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const master = await readMaster();
|
|
876
|
+
const target = toInt(master.kek_version) + 1;
|
|
877
|
+
|
|
878
|
+
const generated = newMasterKey === undefined;
|
|
879
|
+
const raw = newMasterKey ?? generateMasterKey();
|
|
880
|
+
let rotation: PendingRotation;
|
|
881
|
+
let encoded: string | undefined;
|
|
882
|
+
try {
|
|
883
|
+
rotation = {
|
|
884
|
+
unsealer: createMemoryUnsealer(raw),
|
|
885
|
+
kekVersion: target,
|
|
886
|
+
verifyHash: await deriveVerifyHash(raw),
|
|
887
|
+
holderId,
|
|
888
|
+
};
|
|
889
|
+
if (generated) encoded = masterKeyToBase64(raw);
|
|
890
|
+
} finally {
|
|
891
|
+
if (generated) zeroBytes(raw);
|
|
892
|
+
}
|
|
893
|
+
pending = rotation;
|
|
894
|
+
|
|
895
|
+
await db.execute(
|
|
896
|
+
`UPDATE oke_vault_status
|
|
897
|
+
SET rewrap_target_kek_version = $1, rewrap_checkpoint = NULL,
|
|
898
|
+
rewrap_key_hash = $2, updated_at = now()
|
|
899
|
+
WHERE id = 1`,
|
|
900
|
+
[target, rotation.verifyHash],
|
|
901
|
+
);
|
|
902
|
+
const result = await rewrapBatch(rotation);
|
|
903
|
+
return encoded === undefined ? result : { ...result, masterKey: encoded };
|
|
904
|
+
} catch (error) {
|
|
905
|
+
// No persisted rewrap yet → release so a peer can retry immediately.
|
|
906
|
+
if (pending === null) {
|
|
907
|
+
await releaseRotateLease(rawDb, holderId).catch(() => undefined);
|
|
908
|
+
} else {
|
|
909
|
+
const status = await readStatus().catch(() => null);
|
|
910
|
+
if (status?.rewrap_target_kek_version === null) {
|
|
911
|
+
pending = null;
|
|
912
|
+
await releaseRotateLease(rawDb, holderId).catch(() => undefined);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
867
917
|
},
|
|
868
918
|
|
|
869
919
|
async continueRotateMaster(
|
|
@@ -887,6 +937,14 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
887
937
|
"vault: resume rotate-master with the new master key (--new-key / OKE_VAULT_NEW_MASTER_KEY)",
|
|
888
938
|
);
|
|
889
939
|
}
|
|
940
|
+
const holderId = crypto.randomUUID();
|
|
941
|
+
const won = await acquireRotateLease(rawDb, holderId, Date.now(), rotateLeaseMs);
|
|
942
|
+
if (!won) {
|
|
943
|
+
throw new VaultError(
|
|
944
|
+
"UNSUPPORTED",
|
|
945
|
+
"vault: master rotation lease held by another instance",
|
|
946
|
+
);
|
|
947
|
+
}
|
|
890
948
|
const raw =
|
|
891
949
|
typeof newMasterKey === "string"
|
|
892
950
|
? masterKeyFromBase64(newMasterKey)
|
|
@@ -894,6 +952,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
894
952
|
try {
|
|
895
953
|
const hash = await deriveVerifyHash(raw);
|
|
896
954
|
if (!constantTimeEqualStrings(hash, expectedHash)) {
|
|
955
|
+
await releaseRotateLease(rawDb, holderId).catch(() => undefined);
|
|
897
956
|
throw new VaultError(
|
|
898
957
|
"INVALID_KEY",
|
|
899
958
|
"vault: new master key does not match in-flight rotation",
|
|
@@ -903,11 +962,20 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
903
962
|
unsealer: createMemoryUnsealer(raw),
|
|
904
963
|
kekVersion: toInt(target),
|
|
905
964
|
verifyHash: hash,
|
|
965
|
+
holderId,
|
|
906
966
|
};
|
|
907
967
|
pending = rotation;
|
|
908
968
|
} finally {
|
|
909
969
|
zeroBytes(raw);
|
|
910
970
|
}
|
|
971
|
+
} else {
|
|
972
|
+
const won = await renewRotateLease(rawDb, rotation.holderId, Date.now(), rotateLeaseMs);
|
|
973
|
+
if (!won) {
|
|
974
|
+
throw new VaultError(
|
|
975
|
+
"UNSUPPORTED",
|
|
976
|
+
"vault: master rotation lease held by another instance",
|
|
977
|
+
);
|
|
978
|
+
}
|
|
911
979
|
}
|
|
912
980
|
return rewrapBatch(rotation);
|
|
913
981
|
},
|
|
@@ -982,6 +1050,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
982
1050
|
zeroBytes(payload);
|
|
983
1051
|
}
|
|
984
1052
|
|
|
1053
|
+
const payloadSha256 = await sha256Hex(sealed.ciphertext);
|
|
985
1054
|
const metadata: BackupMetadata = {
|
|
986
1055
|
format: 1,
|
|
987
1056
|
createdAt: new Date().toISOString(),
|
|
@@ -989,39 +1058,66 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
989
1058
|
count: entries.length,
|
|
990
1059
|
iv: toBase64(sealed.iv),
|
|
991
1060
|
tag: toBase64(sealed.tag),
|
|
1061
|
+
payloadSha256,
|
|
992
1062
|
};
|
|
993
1063
|
const header = new TextEncoder().encode(`${BACKUP_MAGIC}${JSON.stringify(metadata)}\n`);
|
|
994
|
-
const
|
|
1064
|
+
const end = new TextEncoder().encode(BACKUP_END_MARKER);
|
|
1065
|
+
const blob = new Uint8Array(
|
|
1066
|
+
header.byteLength + sealed.ciphertext.byteLength + end.byteLength,
|
|
1067
|
+
);
|
|
995
1068
|
blob.set(header, 0);
|
|
996
1069
|
blob.set(sealed.ciphertext, header.byteLength);
|
|
1070
|
+
blob.set(end, header.byteLength + sealed.ciphertext.byteLength);
|
|
997
1071
|
return blob;
|
|
998
1072
|
},
|
|
999
1073
|
|
|
1000
1074
|
async importBackup(blob: Uint8Array): Promise<void> {
|
|
1001
1075
|
await ensureReady();
|
|
1002
1076
|
const active = requireUnsealer();
|
|
1003
|
-
const
|
|
1004
|
-
if (!
|
|
1077
|
+
const textHead = new TextDecoder().decode(blob.subarray(0, Math.min(blob.byteLength, 8192)));
|
|
1078
|
+
if (!textHead.startsWith(BACKUP_MAGIC)) {
|
|
1005
1079
|
throw new VaultError("UNSUPPORTED", "vault: not an oke vault backup bundle");
|
|
1006
1080
|
}
|
|
1007
|
-
|
|
1081
|
+
|
|
1082
|
+
const endBytes = new TextEncoder().encode(BACKUP_END_MARKER);
|
|
1083
|
+
if (blob.byteLength < endBytes.byteLength || !tailEquals(blob, endBytes)) {
|
|
1084
|
+
throw new VaultError(
|
|
1085
|
+
"UNSUPPORTED",
|
|
1086
|
+
"vault: backup bundle is incomplete (missing end marker)",
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
const body = blob.subarray(0, blob.byteLength - endBytes.byteLength);
|
|
1090
|
+
|
|
1091
|
+
const headerEnd = textHead.indexOf("\n", BACKUP_MAGIC.length);
|
|
1008
1092
|
if (headerEnd < 0) {
|
|
1009
1093
|
throw new VaultError("UNSUPPORTED", "vault: backup bundle header is truncated");
|
|
1010
1094
|
}
|
|
1011
1095
|
|
|
1012
1096
|
let metadata: BackupMetadata;
|
|
1013
1097
|
try {
|
|
1014
|
-
metadata = JSON.parse(
|
|
1098
|
+
metadata = JSON.parse(textHead.slice(BACKUP_MAGIC.length, headerEnd)) as BackupMetadata;
|
|
1015
1099
|
} catch {
|
|
1016
1100
|
throw new VaultError("UNSUPPORTED", "vault: backup bundle header is not valid JSON");
|
|
1017
1101
|
}
|
|
1018
1102
|
|
|
1019
|
-
const offset = new TextEncoder().encode(
|
|
1103
|
+
const offset = new TextEncoder().encode(textHead.slice(0, headerEnd + 1)).byteLength;
|
|
1104
|
+
const ciphertext = body.subarray(offset);
|
|
1105
|
+
if (
|
|
1106
|
+
typeof metadata.payloadSha256 !== "string" ||
|
|
1107
|
+
metadata.payloadSha256.length === 0 ||
|
|
1108
|
+
!constantTimeEqualStrings(metadata.payloadSha256, await sha256Hex(ciphertext))
|
|
1109
|
+
) {
|
|
1110
|
+
throw new VaultError(
|
|
1111
|
+
"UNSUPPORTED",
|
|
1112
|
+
"vault: backup bundle checksum mismatch (truncated or corrupt)",
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1020
1116
|
const plain = await decryptBytes(
|
|
1021
1117
|
await active.unwrapBackupKek(),
|
|
1022
1118
|
{
|
|
1023
1119
|
iv: fromBase64(metadata.iv),
|
|
1024
|
-
ciphertext
|
|
1120
|
+
ciphertext,
|
|
1025
1121
|
tag: fromBase64(metadata.tag),
|
|
1026
1122
|
},
|
|
1027
1123
|
buildAad(BACKUP_AAD_PATH, 1, ALGORITHM, metadata.kekVersion),
|
|
@@ -1062,7 +1158,10 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
1062
1158
|
* object to the {@link SqlExec} surface the Vault needs.
|
|
1063
1159
|
*
|
|
1064
1160
|
* Accepts either `exec` (the driver contract) or `execute`, so a thin custom
|
|
1065
|
-
* wrapper works without a shim.
|
|
1161
|
+
* wrapper works without a shim. Always exposes {@link SqlExec.begin} so
|
|
1162
|
+
* rotate-master leases (`FOR UPDATE SKIP LOCKED`) and audit appends
|
|
1163
|
+
* (`FOR UPDATE`) serialize — in-process via a mutex, and on real SQL via
|
|
1164
|
+
* `BEGIN`/`COMMIT` when the driver is not the memory fake.
|
|
1066
1165
|
*
|
|
1067
1166
|
* @param conn - Connection exposing `query` plus `exec` or `execute`
|
|
1068
1167
|
*/
|
|
@@ -1070,23 +1169,118 @@ export function sqlConnectionAsExec(conn: {
|
|
|
1070
1169
|
query(sql: string, params?: readonly unknown[]): Promise<unknown[]>;
|
|
1071
1170
|
exec?(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
1072
1171
|
execute?(sql: string, params?: readonly unknown[]): Promise<void>;
|
|
1172
|
+
driverId?: string;
|
|
1073
1173
|
}): SqlExec {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1174
|
+
let gate: Promise<unknown> = Promise.resolve();
|
|
1175
|
+
|
|
1176
|
+
async function runQuery<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
1177
|
+
return (await conn.query(sql, params ?? [])) as T[];
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function runExecute(sql: string, params?: unknown[]): Promise<void> {
|
|
1181
|
+
if (conn.exec) {
|
|
1182
|
+
await conn.exec(sql, params ?? []);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (conn.execute) {
|
|
1186
|
+
await conn.execute(sql, params ?? []);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
await conn.query(sql, params ?? []);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const surface: SqlExec = {
|
|
1193
|
+
query: runQuery,
|
|
1194
|
+
execute: runExecute,
|
|
1195
|
+
begin<T>(fn: (tx: SqlExec) => Promise<T>): Promise<T> {
|
|
1196
|
+
const run = async (): Promise<T> => {
|
|
1197
|
+
const useSqlTx = conn.driverId !== "memory";
|
|
1198
|
+
if (useSqlTx) await runExecute("BEGIN");
|
|
1199
|
+
try {
|
|
1200
|
+
// Nested work shares this connection; the gate serializes begin() callers.
|
|
1201
|
+
const result = await fn({
|
|
1202
|
+
query: runQuery,
|
|
1203
|
+
execute: runExecute,
|
|
1204
|
+
});
|
|
1205
|
+
if (useSqlTx) await runExecute("COMMIT");
|
|
1206
|
+
return result;
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
if (useSqlTx) await runExecute("ROLLBACK").catch(() => undefined);
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
const next = gate.then(run, run);
|
|
1213
|
+
gate = next.then(
|
|
1214
|
+
() => undefined,
|
|
1215
|
+
() => undefined,
|
|
1216
|
+
);
|
|
1217
|
+
return next;
|
|
1088
1218
|
},
|
|
1089
1219
|
};
|
|
1220
|
+
return surface;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Write a vault backup bundle atomically: temp file → fsync → rename.
|
|
1225
|
+
*
|
|
1226
|
+
* Prevents a crash mid-write from leaving a magic-prefixed partial file at
|
|
1227
|
+
* the final path that an operator could mistake for a complete backup.
|
|
1228
|
+
*
|
|
1229
|
+
* @param path - Final destination path
|
|
1230
|
+
* @param blob - Complete bundle bytes (including end marker)
|
|
1231
|
+
*/
|
|
1232
|
+
export async function writeBackupFileAtomic(path: string, blob: Uint8Array): Promise<void> {
|
|
1233
|
+
const { open, rename, unlink } = await import("node:fs/promises");
|
|
1234
|
+
const tmp = `${path}.oke-tmp-${process.pid}-${Date.now()}`;
|
|
1235
|
+
try {
|
|
1236
|
+
await Bun.write(tmp, blob);
|
|
1237
|
+
const fh = await open(tmp, "r+");
|
|
1238
|
+
try {
|
|
1239
|
+
await fh.sync();
|
|
1240
|
+
} finally {
|
|
1241
|
+
await fh.close();
|
|
1242
|
+
}
|
|
1243
|
+
await rename(tmp, path);
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
await unlink(tmp).catch(() => undefined);
|
|
1246
|
+
throw error;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Hex-encode a SHA-256 digest of `bytes`.
|
|
1252
|
+
*
|
|
1253
|
+
* @param bytes - Payload to hash
|
|
1254
|
+
*/
|
|
1255
|
+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
1256
|
+
const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes));
|
|
1257
|
+
return Buffer.from(digest).toString("hex");
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Copy into a real `ArrayBuffer` for Web Crypto `BufferSource` typing.
|
|
1262
|
+
*
|
|
1263
|
+
* @param view - Source bytes (may be backed by `ArrayBufferLike`)
|
|
1264
|
+
*/
|
|
1265
|
+
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
|
|
1266
|
+
const copy = new Uint8Array(view.byteLength);
|
|
1267
|
+
copy.set(view);
|
|
1268
|
+
return copy.buffer;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Whether `blob` ends exactly with `tail`.
|
|
1273
|
+
*
|
|
1274
|
+
* @param blob - Full buffer
|
|
1275
|
+
* @param tail - Expected suffix
|
|
1276
|
+
*/
|
|
1277
|
+
function tailEquals(blob: Uint8Array, tail: Uint8Array): boolean {
|
|
1278
|
+
if (blob.byteLength < tail.byteLength) return false;
|
|
1279
|
+
const start = blob.byteLength - tail.byteLength;
|
|
1280
|
+
for (let i = 0; i < tail.byteLength; i += 1) {
|
|
1281
|
+
if (blob[start + i] !== tail[i]) return false;
|
|
1282
|
+
}
|
|
1283
|
+
return true;
|
|
1090
1284
|
}
|
|
1091
1285
|
|
|
1092
1286
|
/**
|