@zincapp/znvault-cli 4.23.0 → 4.24.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/dist/commands/dynamic-secrets/connection.js +1 -1
- package/dist/commands/dynamic-secrets/connection.js.map +1 -1
- package/dist/commands/dynamic-secrets/index.d.ts.map +1 -1
- package/dist/commands/dynamic-secrets/index.js +16 -17
- package/dist/commands/dynamic-secrets/index.js.map +1 -1
- package/dist/commands/dynamic-secrets/lease.d.ts.map +1 -1
- package/dist/commands/dynamic-secrets/lease.js +3 -1
- package/dist/commands/dynamic-secrets/lease.js.map +1 -1
- package/dist/commands/dynamic-secrets/role.js +1 -1
- package/dist/commands/dynamic-secrets/role.js.map +1 -1
- package/dist/commands/dynamic-secrets/types.d.ts +2 -1
- package/dist/commands/dynamic-secrets/types.d.ts.map +1 -1
- package/dist/commands/lmk-ceremony.d.ts +3 -0
- package/dist/commands/lmk-ceremony.d.ts.map +1 -0
- package/dist/commands/lmk-ceremony.js +597 -0
- package/dist/commands/lmk-ceremony.js.map +1 -0
- package/dist/commands/lmk-escrow.d.ts.map +1 -1
- package/dist/commands/lmk-escrow.js +2 -0
- package/dist/commands/lmk-escrow.js.map +1 -1
- package/dist/lib/ceremony/gates.d.ts +126 -0
- package/dist/lib/ceremony/gates.d.ts.map +1 -0
- package/dist/lib/ceremony/gates.js +270 -0
- package/dist/lib/ceremony/gates.js.map +1 -0
- package/dist/lib/ceremony/system.d.ts +36 -0
- package/dist/lib/ceremony/system.d.ts.map +1 -0
- package/dist/lib/ceremony/system.js +176 -0
- package/dist/lib/ceremony/system.js.map +1 -0
- package/dist/lib/ceremony/workspace.d.ts +54 -0
- package/dist/lib/ceremony/workspace.d.ts.map +1 -0
- package/dist/lib/ceremony/workspace.js +98 -0
- package/dist/lib/ceremony/workspace.js.map +1 -0
- package/dist/lib/db/key-lifecycle.d.ts +86 -0
- package/dist/lib/db/key-lifecycle.d.ts.map +1 -0
- package/dist/lib/db/key-lifecycle.js +182 -0
- package/dist/lib/db/key-lifecycle.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Path: src/lib/ceremony/system.ts
|
|
2
|
+
//
|
|
3
|
+
// Facts about this machine, gathered for the ceremony's gates to judge.
|
|
4
|
+
//
|
|
5
|
+
// This module DECIDES NOTHING. It shells out to `hdiutil`, `mount`, `ioreg` and
|
|
6
|
+
// `diskutil` and reports what they said; every refusal lives in `gates.ts` and
|
|
7
|
+
// `workspace.ts`, which are pure and therefore testable. The split is
|
|
8
|
+
// deliberate: this file cannot be unit-tested, so it must not be where the
|
|
9
|
+
// thinking happens.
|
|
10
|
+
//
|
|
11
|
+
// macOS-only by construction. The ceremony runs on the operator's Mac (see
|
|
12
|
+
// `docs/emergency-dr/PROTOCOLO-ceremonia-en-mac.md` for why that is the
|
|
13
|
+
// decision and what it costs); a Linux ceremony host would need its own
|
|
14
|
+
// implementation of exactly these five functions and nothing else.
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { CEREMONY_MOUNT_POINT } from './workspace.js';
|
|
18
|
+
/**
|
|
19
|
+
* Run a command and return stdout, or `null` if it could not run. Never throws.
|
|
20
|
+
*
|
|
21
|
+
* `null` AND NOT `''`. An audit found the earlier version collapsed the two:
|
|
22
|
+
* a `mount` that failed to execute returned the empty string, which reads as
|
|
23
|
+
* "nothing is mounted" — so teardown verification passed on zero evidence and a
|
|
24
|
+
* ceremony closed as COMPLETED over a live RAM disk. Callers now have to decide
|
|
25
|
+
* what silence means, and for teardown it means refuse.
|
|
26
|
+
*/
|
|
27
|
+
function run(cmd, args) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync(cmd, args, { encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** `run`, for the callers where "could not run" and "said nothing" are the same. */
|
|
36
|
+
function runOrEmpty(cmd, args) {
|
|
37
|
+
return run(cmd, args) ?? '';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Does this `hdiutil info` / `mount` line refer to exactly this device?
|
|
41
|
+
*
|
|
42
|
+
* NOT `startsWith`: `/dev/disk3` is a prefix of `/dev/disk32`, and a Mac with
|
|
43
|
+
* simulators attached routinely has a dozen images listed, so a straddling pair
|
|
44
|
+
* of unit numbers is ordinary rather than exotic. Prefix matching attributed one
|
|
45
|
+
* device's backing to another.
|
|
46
|
+
*/
|
|
47
|
+
function refersTo(line, device) {
|
|
48
|
+
const trimmed = line.trimStart();
|
|
49
|
+
if (!trimmed.startsWith(device))
|
|
50
|
+
return false;
|
|
51
|
+
const next = trimmed.charAt(device.length);
|
|
52
|
+
return next === '' || !/[0-9a-zA-Z]/.test(next);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a RAM-backed volume and report what the system says about it.
|
|
56
|
+
*
|
|
57
|
+
* Returns facts rather than throwing on failure: `assertRamBacked` is what
|
|
58
|
+
* refuses, and it must see the same facts an operator would — including the
|
|
59
|
+
* case where the mount silently did not happen, which is the whole reason the
|
|
60
|
+
* gate exists.
|
|
61
|
+
*
|
|
62
|
+
* `awk '{print $1}'` and NOT `tr -d ' '`: hdiutil pads the device path with
|
|
63
|
+
* TABS, and stripping only spaces yields a malformed path whose mount fails
|
|
64
|
+
* quietly while writes land on the SSD. That was a real hour of this build.
|
|
65
|
+
*/
|
|
66
|
+
export function createRamWorkspace(megabytes) {
|
|
67
|
+
const sectors = megabytes * 2048;
|
|
68
|
+
const attached = runOrEmpty('hdiutil', ['attach', '-nomount', `ram://${String(sectors)}`]);
|
|
69
|
+
const device = attached.trim().split(/\s+/)[0] ?? '';
|
|
70
|
+
if (device === '') {
|
|
71
|
+
return { device: '(hdiutil produced no device)', imagePath: null, mountPoint: null };
|
|
72
|
+
}
|
|
73
|
+
runOrEmpty('newfs_hfs', ['-v', 'CEREMONY', device]);
|
|
74
|
+
if (!existsSync(CEREMONY_MOUNT_POINT))
|
|
75
|
+
mkdirSync(CEREMONY_MOUNT_POINT, { recursive: true });
|
|
76
|
+
runOrEmpty('diskutil', ['mount', '-mountPoint', CEREMONY_MOUNT_POINT, device]);
|
|
77
|
+
return describeDevice(device);
|
|
78
|
+
}
|
|
79
|
+
/** What `hdiutil info` and `mount` currently say about a device. */
|
|
80
|
+
export function describeDevice(device) {
|
|
81
|
+
let imagePath = null;
|
|
82
|
+
let current = null;
|
|
83
|
+
for (const line of runOrEmpty('hdiutil', ['info']).split('\n')) {
|
|
84
|
+
if (line.startsWith('image-path')) {
|
|
85
|
+
// `hdiutil info` prints `image-path : ram://131072`, colon and all. An
|
|
86
|
+
// earlier version kept the colon and the gate refused a perfectly good
|
|
87
|
+
// RAM volume — failing closed on a parsing bug, which is the right
|
|
88
|
+
// direction, but still a bug.
|
|
89
|
+
current = line.replace(/^image-path\s*:?\s*/, '').trim();
|
|
90
|
+
}
|
|
91
|
+
else if (refersTo(line, device)) {
|
|
92
|
+
imagePath = current;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const mountLine = runOrEmpty('mount', []).split('\n').find((l) => l.startsWith(`${device} on `));
|
|
96
|
+
const mountPoint = mountLine === undefined
|
|
97
|
+
? null
|
|
98
|
+
: (/^\S+ on (.+?) \(/.exec(mountLine)?.[1] ?? null);
|
|
99
|
+
return { device, imagePath, mountPoint };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Unmount and detach, then report whether it worked.
|
|
103
|
+
*
|
|
104
|
+
* Reports rather than asserts, for the same reason as above: unmounting does
|
|
105
|
+
* not destroy a RAM device, and `assertTornDown` is what insists on both.
|
|
106
|
+
*/
|
|
107
|
+
export function destroyRamWorkspace(device) {
|
|
108
|
+
runOrEmpty('diskutil', ['unmount', CEREMONY_MOUNT_POINT]);
|
|
109
|
+
runOrEmpty('hdiutil', ['detach', device]);
|
|
110
|
+
// `null` where the command could not run at all: `assertTornDown` refuses
|
|
111
|
+
// rather than reading silence as proof the workspace is gone.
|
|
112
|
+
const mountOut = run('mount', []);
|
|
113
|
+
const infoOut = run('hdiutil', ['info']);
|
|
114
|
+
return {
|
|
115
|
+
device,
|
|
116
|
+
stillMounted: mountOut === null ? null : mountOut.includes(`${device} on `),
|
|
117
|
+
stillAttached: infoOut === null
|
|
118
|
+
? null
|
|
119
|
+
: infoOut.split('\n').some((l) => refersTo(l, device)),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Every mounted datAshur, with the USB serial that actually identifies it.
|
|
124
|
+
*
|
|
125
|
+
* The mapping matters more than it looks: both devices shipped with the same
|
|
126
|
+
* volume label, so the mount path is not an identity. This walks the IO
|
|
127
|
+
* registry to pair each BSD disk with the serial of the USB device it hangs
|
|
128
|
+
* from — the same walk done by hand on 2026-08-25 to tell copy A from copy B.
|
|
129
|
+
*/
|
|
130
|
+
export function listEscrowDevices() {
|
|
131
|
+
const tree = runOrEmpty('ioreg', ['-p', 'IOService', '-n', 'datAshur PRO2', '-r', '-l', '-w', '0']);
|
|
132
|
+
// Serial appears above the BSD names of the disks beneath it.
|
|
133
|
+
const serialByDisk = new Map();
|
|
134
|
+
let serial = null;
|
|
135
|
+
for (const line of tree.split('\n')) {
|
|
136
|
+
const s = /"kUSBSerialNumberString"\s*=\s*"([^"]+)"/.exec(line);
|
|
137
|
+
if (s?.[1] !== undefined) {
|
|
138
|
+
serial = s[1];
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const b = /"BSD Name"\s*=\s*"(disk\d+)"/.exec(line);
|
|
142
|
+
if (b?.[1] !== undefined && serial !== null)
|
|
143
|
+
serialByDisk.set(b[1], serial);
|
|
144
|
+
}
|
|
145
|
+
const devices = [];
|
|
146
|
+
for (const line of runOrEmpty('mount', []).split('\n')) {
|
|
147
|
+
const m = /^\/dev\/(disk\d+)s\d+ on (.+?) \(/.exec(line);
|
|
148
|
+
if (m === null)
|
|
149
|
+
continue;
|
|
150
|
+
// Neither group is optional, so a successful match guarantees both.
|
|
151
|
+
const [, whole, mountPoint] = m;
|
|
152
|
+
if (!mountPoint.startsWith('/Volumes/'))
|
|
153
|
+
continue;
|
|
154
|
+
const serialForDisk = serialByDisk.get(whole);
|
|
155
|
+
if (serialForDisk === undefined)
|
|
156
|
+
continue; // not a datAshur
|
|
157
|
+
devices.push({
|
|
158
|
+
mountPoint,
|
|
159
|
+
usbSerial: serialForDisk,
|
|
160
|
+
volumeLabel: mountPoint.slice('/Volumes/'.length),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return devices;
|
|
164
|
+
}
|
|
165
|
+
/** Is Spotlight indexing this path? Ceremony hygiene, reported not enforced. */
|
|
166
|
+
export function indexingEnabled(path) {
|
|
167
|
+
const out = run('mdutil', ['-s', path]);
|
|
168
|
+
if (out === null || out === '')
|
|
169
|
+
return null;
|
|
170
|
+
if (/Indexing enabled/i.test(out))
|
|
171
|
+
return true;
|
|
172
|
+
if (/Indexing disabled|not (?:supported|eligible)/i.test(out))
|
|
173
|
+
return false;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=system.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"system.js","sourceRoot":"","sources":["../../../src/lib/ceremony/system.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Facts about the workspace device, as reported by the system. */
|
|
2
|
+
export interface DeviceFacts {
|
|
3
|
+
/** e.g. `/dev/disk32` */
|
|
4
|
+
device: string;
|
|
5
|
+
/**
|
|
6
|
+
* `image-path` from `hdiutil info`. `ram://<sectors>` for a RAM device, a
|
|
7
|
+
* filesystem path for a disk image, `null` when hdiutil said nothing.
|
|
8
|
+
*/
|
|
9
|
+
imagePath: string | null;
|
|
10
|
+
/** Where `mount` says it is, or `null` if it is not mounted. */
|
|
11
|
+
mountPoint: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface TeardownFacts {
|
|
14
|
+
device: string;
|
|
15
|
+
/**
|
|
16
|
+
* `null` when it could not be observed at all — `mount` or `hdiutil` failed
|
|
17
|
+
* rather than reported "no". The distinction is load-bearing: see
|
|
18
|
+
* `assertTornDown`.
|
|
19
|
+
*/
|
|
20
|
+
stillMounted: boolean | null;
|
|
21
|
+
stillAttached: boolean | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Where a ceremony workspace is allowed to live.
|
|
25
|
+
*
|
|
26
|
+
* Pinned so teardown can find it and so a workspace cannot be created on top of
|
|
27
|
+
* an escrow device by a mistyped argument.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CEREMONY_MOUNT_POINT = "/private/tmp/ceremony-ram";
|
|
30
|
+
/**
|
|
31
|
+
* Refuse to proceed unless the workspace is demonstrably RAM-backed and mounted
|
|
32
|
+
* where it is supposed to be.
|
|
33
|
+
*
|
|
34
|
+
* @throws with the observed facts named, because this aborts a ceremony that
|
|
35
|
+
* someone scheduled and possibly travelled for.
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertRamBacked(facts: DeviceFacts): void;
|
|
38
|
+
/**
|
|
39
|
+
* Refuse to close a ceremony while the workspace still exists.
|
|
40
|
+
*
|
|
41
|
+
* Unmounted is not destroyed: the RAM still holds the bytes and anyone can
|
|
42
|
+
* remount the device. Both facts have to be observed, and both have to be false.
|
|
43
|
+
*
|
|
44
|
+
* THE `null` CASE IS WHY THIS WAS REWRITTEN. The first version took plain
|
|
45
|
+
* booleans, and the fact-gatherer returned `false` both for "I looked and it is
|
|
46
|
+
* gone" and for "the command failed and I saw nothing". So a `mount` that could
|
|
47
|
+
* not run — ENOENT, a truncated buffer, a signal — read as proof of teardown,
|
|
48
|
+
* and the ceremony closed as COMPLETED over a live RAM disk still holding the
|
|
49
|
+
* bootstrap key. `assertRamBacked` had the opposite, correct policy three
|
|
50
|
+
* functions up ("absence of evidence is not evidence of RAM"); this one applied
|
|
51
|
+
* it backwards at the single moment that ends the ceremony.
|
|
52
|
+
*/
|
|
53
|
+
export declare function assertTornDown(facts: TeardownFacts): void;
|
|
54
|
+
//# sourceMappingURL=workspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../../../src/lib/ceremony/workspace.ts"],"names":[],"mappings":"AA4BA,mEAAmE;AACnE,MAAM,WAAW,WAAW;IAC1B,yBAAyB;IACzB,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gEAAgE;IAChE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,YAAY,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7B,aAAa,EAAE,OAAO,GAAG,IAAI,CAAC;CAC/B;AAED;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,8BAA8B,CAAC;AAEhE;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAkCxD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAsBzD"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Path: src/lib/ceremony/workspace.ts
|
|
2
|
+
//
|
|
3
|
+
// The scratch volume an escrow ceremony works in, and the proof that it is in
|
|
4
|
+
// RAM rather than on the disk.
|
|
5
|
+
//
|
|
6
|
+
// WHY A GATE AND NOT A CHECK OF THE RETURN CODE. Observed while building this,
|
|
7
|
+
// 2026-08-25:
|
|
8
|
+
//
|
|
9
|
+
// $ diskutil mount ... /dev/disk34<TAB> # malformed path
|
|
10
|
+
// Failed to find disk /dev/disk34
|
|
11
|
+
// $ head -c 32 /dev/urandom > /private/tmp/ceremony-ram/probe
|
|
12
|
+
// 32 bytes written: OK # on the SSD
|
|
13
|
+
//
|
|
14
|
+
// The mount point existed as an ordinary directory, so the write succeeded and
|
|
15
|
+
// nothing complained. Substitute the bootstrap key for random bytes and that is
|
|
16
|
+
// the root of the entire key hierarchy sitting on a general-purpose disk while
|
|
17
|
+
// the operator believes it is in volatile memory — undetectably, because
|
|
18
|
+
// everything downstream keeps working.
|
|
19
|
+
//
|
|
20
|
+
// The proximate cause was mundane (`tr -d ' '` does not strip the TABS that
|
|
21
|
+
// `hdiutil` emits). The lesson is not "use awk". It is that a workspace for key
|
|
22
|
+
// material must PROVE where it lives, from facts the system reports, rather
|
|
23
|
+
// than trust the exit status of the command that created it.
|
|
24
|
+
//
|
|
25
|
+
// HONEST LIMIT: macOS `ram://` is not Linux `tmpfs,noswap`. It is memory, but
|
|
26
|
+
// the system may page it. With FileVault, anything paged is encrypted at rest.
|
|
27
|
+
// This is stated in the ceremony protocol as a residual, not papered over.
|
|
28
|
+
/**
|
|
29
|
+
* Where a ceremony workspace is allowed to live.
|
|
30
|
+
*
|
|
31
|
+
* Pinned so teardown can find it and so a workspace cannot be created on top of
|
|
32
|
+
* an escrow device by a mistyped argument.
|
|
33
|
+
*/
|
|
34
|
+
export const CEREMONY_MOUNT_POINT = '/private/tmp/ceremony-ram';
|
|
35
|
+
/**
|
|
36
|
+
* Refuse to proceed unless the workspace is demonstrably RAM-backed and mounted
|
|
37
|
+
* where it is supposed to be.
|
|
38
|
+
*
|
|
39
|
+
* @throws with the observed facts named, because this aborts a ceremony that
|
|
40
|
+
* someone scheduled and possibly travelled for.
|
|
41
|
+
*/
|
|
42
|
+
export function assertRamBacked(facts) {
|
|
43
|
+
if (facts.imagePath === null) {
|
|
44
|
+
throw new Error(`The backing of ${facts.device} could not be determined — 'hdiutil info' ` +
|
|
45
|
+
'reported nothing for it. Absence of evidence is not evidence of RAM. ' +
|
|
46
|
+
'Refusing to place key material here.');
|
|
47
|
+
}
|
|
48
|
+
if (!facts.imagePath.startsWith('ram://')) {
|
|
49
|
+
throw new Error(`${facts.device} is NOT backed by RAM: its image-path is ${facts.imagePath}, ` +
|
|
50
|
+
'expected something beginning with ram://. A disk image on the SSD mounts ' +
|
|
51
|
+
'and behaves identically, so image-path is the only thing that tells them ' +
|
|
52
|
+
'apart. Refusing to place key material here.');
|
|
53
|
+
}
|
|
54
|
+
if (facts.mountPoint === null) {
|
|
55
|
+
throw new Error(`${facts.device} is ram-backed but NOT mounted. This is the dangerous case: ` +
|
|
56
|
+
`if ${CEREMONY_MOUNT_POINT} exists as an ordinary directory, every write ` +
|
|
57
|
+
'below succeeds and lands on the SSD, with nothing to indicate it. ' +
|
|
58
|
+
'Refusing to proceed.');
|
|
59
|
+
}
|
|
60
|
+
if (facts.mountPoint !== CEREMONY_MOUNT_POINT) {
|
|
61
|
+
throw new Error(`Unexpected mount point: the workspace is at ${facts.mountPoint}, not ` +
|
|
62
|
+
`${CEREMONY_MOUNT_POINT}. The location is pinned so teardown can find it, ` +
|
|
63
|
+
'and so a mistyped argument cannot turn an escrow device into the workspace.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Refuse to close a ceremony while the workspace still exists.
|
|
68
|
+
*
|
|
69
|
+
* Unmounted is not destroyed: the RAM still holds the bytes and anyone can
|
|
70
|
+
* remount the device. Both facts have to be observed, and both have to be false.
|
|
71
|
+
*
|
|
72
|
+
* THE `null` CASE IS WHY THIS WAS REWRITTEN. The first version took plain
|
|
73
|
+
* booleans, and the fact-gatherer returned `false` both for "I looked and it is
|
|
74
|
+
* gone" and for "the command failed and I saw nothing". So a `mount` that could
|
|
75
|
+
* not run — ENOENT, a truncated buffer, a signal — read as proof of teardown,
|
|
76
|
+
* and the ceremony closed as COMPLETED over a live RAM disk still holding the
|
|
77
|
+
* bootstrap key. `assertRamBacked` had the opposite, correct policy three
|
|
78
|
+
* functions up ("absence of evidence is not evidence of RAM"); this one applied
|
|
79
|
+
* it backwards at the single moment that ends the ceremony.
|
|
80
|
+
*/
|
|
81
|
+
export function assertTornDown(facts) {
|
|
82
|
+
if (facts.stillMounted === null || facts.stillAttached === null) {
|
|
83
|
+
throw new Error(`Could not determine whether ${facts.device} is really gone: the system ` +
|
|
84
|
+
'commands that would have said so did not run. Refusing to record a ' +
|
|
85
|
+
'teardown that has not been observed — check by hand with ' +
|
|
86
|
+
`'mount' and 'hdiutil info' before closing the ceremony.`);
|
|
87
|
+
}
|
|
88
|
+
if (facts.stillMounted) {
|
|
89
|
+
throw new Error(`${facts.device} is still mounted. The workspace held key material; the ` +
|
|
90
|
+
'ceremony is not closed until it is gone.');
|
|
91
|
+
}
|
|
92
|
+
if (facts.stillAttached) {
|
|
93
|
+
throw new Error(`${facts.device} is unmounted but still attached. Unmounting does not ` +
|
|
94
|
+
'destroy it — the RAM still holds the bytes and the device can be ' +
|
|
95
|
+
'remounted. Detach it before closing the ceremony.');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=workspace.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace.js","sourceRoot":"","sources":["../../../src/lib/ceremony/workspace.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,EAAE;AACF,8EAA8E;AAC9E,+BAA+B;AAC/B,EAAE;AACF,+EAA+E;AAC/E,cAAc;AACd,EAAE;AACF,mEAAmE;AACnE,sCAAsC;AACtC,kEAAkE;AAClE,+DAA+D;AAC/D,EAAE;AACF,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,yEAAyE;AACzE,uCAAuC;AACvC,EAAE;AACF,4EAA4E;AAC5E,gFAAgF;AAChF,4EAA4E;AAC5E,6DAA6D;AAC7D,EAAE;AACF,8EAA8E;AAC9E,+EAA+E;AAC/E,2EAA2E;AA0B3E;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,2BAA2B,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,KAAkB;IAChD,IAAI,KAAK,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CACb,kBAAkB,KAAK,CAAC,MAAM,4CAA4C;YAC1E,uEAAuE;YACvE,sCAAsC,CACvC,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CACb,GAAG,KAAK,CAAC,MAAM,4CAA4C,KAAK,CAAC,SAAS,IAAI;YAC9E,2EAA2E;YAC3E,2EAA2E;YAC3E,6CAA6C,CAC9C,CAAC;IACJ,CAAC;IAED,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CACb,GAAG,KAAK,CAAC,MAAM,8DAA8D;YAC7E,MAAM,oBAAoB,gDAAgD;YAC1E,oEAAoE;YACpE,sBAAsB,CACvB,CAAC;IACJ,CAAC;IAED,IAAI,KAAK,CAAC,UAAU,KAAK,oBAAoB,EAAE,CAAC;QAC9C,MAAM,IAAI,KAAK,CACb,+CAA+C,KAAK,CAAC,UAAU,QAAQ;YACvE,GAAG,oBAAoB,oDAAoD;YAC3E,6EAA6E,CAC9E,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAAC,KAAoB;IACjD,IAAI,KAAK,CAAC,YAAY,KAAK,IAAI,IAAI,KAAK,CAAC,aAAa,KAAK,IAAI,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CACb,+BAA+B,KAAK,CAAC,MAAM,8BAA8B;YACzE,qEAAqE;YACrE,2DAA2D;YAC3D,yDAAyD,CAC1D,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CACb,GAAG,KAAK,CAAC,MAAM,0DAA0D;YACzE,0CAA0C,CAC3C,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,CAAC,aAAa,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CACb,GAAG,KAAK,CAAC,MAAM,wDAAwD;YACvE,mEAAmE;YACnE,mDAAmD,CACpD,CAAC;IACJ,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { BaseDBClient } from './client.js';
|
|
2
|
+
export type CeremonyPhase = 'preflight' | 'workspace' | 'material' | 'write-a' | 'write-b' | 'verify' | 'teardown';
|
|
3
|
+
export interface PhaseEvent {
|
|
4
|
+
seq: number;
|
|
5
|
+
phase: string;
|
|
6
|
+
state: string;
|
|
7
|
+
at: string;
|
|
8
|
+
detail: Record<string, unknown> | null;
|
|
9
|
+
}
|
|
10
|
+
export interface LifecycleOperation {
|
|
11
|
+
operationId: string;
|
|
12
|
+
kind: string;
|
|
13
|
+
state: string;
|
|
14
|
+
phase: string;
|
|
15
|
+
epoch: number;
|
|
16
|
+
ownerNodeId: string;
|
|
17
|
+
ownerPrincipal: string;
|
|
18
|
+
minRelease: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
finishedAt: string | null;
|
|
21
|
+
lastError: string | null;
|
|
22
|
+
}
|
|
23
|
+
export declare class KeyLifecycleOperations extends BaseDBClient {
|
|
24
|
+
/**
|
|
25
|
+
* Is the schema present?
|
|
26
|
+
*
|
|
27
|
+
* The CLI ships independently of the server, so it can meet a deployment that
|
|
28
|
+
* predates migration 093. Saying so plainly beats a `relation does not exist`
|
|
29
|
+
* in the middle of a ceremony.
|
|
30
|
+
*/
|
|
31
|
+
schemaPresent(): Promise<boolean>;
|
|
32
|
+
/** The operation currently holding the exclusion slot, if any. */
|
|
33
|
+
active(): Promise<LifecycleOperation | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Start a ceremony, or find out who already holds the slot.
|
|
36
|
+
*
|
|
37
|
+
* Does NOT look before it leaps: a "is anything running?" SELECT followed by
|
|
38
|
+
* an INSERT is exactly the race the index exists to close. The INSERT is the
|
|
39
|
+
* check, and 23505 is the answer.
|
|
40
|
+
*/
|
|
41
|
+
claim(input: {
|
|
42
|
+
phase: CeremonyPhase;
|
|
43
|
+
ownerNodeId: string;
|
|
44
|
+
ownerPrincipal: string;
|
|
45
|
+
minRelease: string;
|
|
46
|
+
detail?: Record<string, unknown>;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
ok: true;
|
|
49
|
+
operation: LifecycleOperation;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
active: LifecycleOperation | null;
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Move to the next phase, recording it in the same transaction.
|
|
56
|
+
*
|
|
57
|
+
* Compare-and-swap on `epoch`: a caller holding a stale read is refused
|
|
58
|
+
* rather than silently overwriting whoever moved first.
|
|
59
|
+
*/
|
|
60
|
+
advance(input: {
|
|
61
|
+
operationId: string;
|
|
62
|
+
expectedEpoch: number;
|
|
63
|
+
phase: CeremonyPhase;
|
|
64
|
+
nodeId: string;
|
|
65
|
+
detail?: Record<string, unknown>;
|
|
66
|
+
}): Promise<LifecycleOperation>;
|
|
67
|
+
/**
|
|
68
|
+
* Close the operation, releasing the slot.
|
|
69
|
+
*
|
|
70
|
+
* `ABANDONED` is a real transition with a reason rather than a DELETE: why a
|
|
71
|
+
* ceremony was abandoned is exactly what the next person needs to read, and a
|
|
72
|
+
* global exclusion with no escape hatch is a deadlock waiting for its first
|
|
73
|
+
* interrupted ceremony.
|
|
74
|
+
*/
|
|
75
|
+
finish(input: {
|
|
76
|
+
operationId: string;
|
|
77
|
+
expectedEpoch: number;
|
|
78
|
+
outcome: 'COMPLETED' | 'FAILED' | 'ABANDONED';
|
|
79
|
+
nodeId: string;
|
|
80
|
+
error?: string;
|
|
81
|
+
detail?: Record<string, unknown>;
|
|
82
|
+
}): Promise<LifecycleOperation>;
|
|
83
|
+
/** The full route travelled, oldest first. */
|
|
84
|
+
history(operationId: string): Promise<PhaseEvent[]>;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=key-lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"key-lifecycle.d.ts","sourceRoot":"","sources":["../../../src/lib/db/key-lifecycle.ts"],"names":[],"mappings":"AA0BA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,WAAW,GACX,UAAU,GACV,SAAS,GACT,SAAS,GACT,QAAQ,GACR,UAAU,CAAC;AAEf,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAsCD,qBAAa,sBAAuB,SAAQ,YAAY;IACtD;;;;;;OAMG;IACG,aAAa,IAAI,OAAO,CAAC,OAAO,CAAC;IAQvC,kEAAkE;IAC5D,MAAM,IAAI,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IASlD;;;;;;OAMG;IACG,KAAK,CAAC,KAAK,EAAE;QACjB,KAAK,EAAE,aAAa,CAAC;QACrB,WAAW,EAAE,MAAM,CAAC;QACpB,cAAc,EAAE,MAAM,CAAC;QACvB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,SAAS,EAAE,kBAAkB,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI,CAAA;KAAE,CAAC;IAgC3G;;;;;OAKG;IACG,OAAO,CAAC,KAAK,EAAE;QACnB,WAAW,EAAE,MAAM,CAAC;QACpB,aAAa,EAAE,MAAM,CAAC;QACtB,KAAK,EAAE,aAAa,CAAC;QACrB,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAqC/B;;;;;;;OAOG;IACG,MAAM,CAAC,KAAK,EAAE;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,aAAa,EAAE,MAAM,CAAC;QACtB,OAAO,EAAE,WAAW,GAAG,QAAQ,GAAG,WAAW,CAAC;QAC9C,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAqC/B,8CAA8C;IACxC,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;CAU1D"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Path: src/lib/db/key-lifecycle.ts
|
|
2
|
+
//
|
|
3
|
+
// CLI-side access to the durable key-lifecycle state (zn-vault migration 093).
|
|
4
|
+
//
|
|
5
|
+
// WHY THE CEREMONY WRITES HERE AND NOT TO A LOG FILE. The owner's requirement
|
|
6
|
+
// was that a ceremony leave a record of who ran it, when, and how far it got —
|
|
7
|
+
// the thing a shell script cannot give you, because a script that dies leaves
|
|
8
|
+
// nothing and a script that is edited leaves no trace of the edit.
|
|
9
|
+
//
|
|
10
|
+
// `key_lifecycle_operations` gives all three, and two more that matter:
|
|
11
|
+
//
|
|
12
|
+
// EXCLUSION IS IN THE DATABASE. A partial unique index means a second
|
|
13
|
+
// ceremony — or a ceremony during an LMK rotation — is refused by PostgreSQL,
|
|
14
|
+
// not by an `if` that both processes evaluate to false at the same instant.
|
|
15
|
+
//
|
|
16
|
+
// THE HISTORY CANNOT BE REWRITTEN. `key_lifecycle_phase_events` is
|
|
17
|
+
// append-only by trigger and is written in the SAME transaction as the state
|
|
18
|
+
// change, so a ceremony that dies at phase 5 still shows how it reached
|
|
19
|
+
// phase 5.
|
|
20
|
+
//
|
|
21
|
+
// PORT NOTICE. The server has a richer repository for these tables
|
|
22
|
+
// (`zn-vault/src/db/repo.key-lifecycle.ts`) with compare-and-swap on `epoch`.
|
|
23
|
+
// This package ships to npm on its own and cannot import it. The contract is
|
|
24
|
+
// the SCHEMA, pinned by the migration — not this file and not that one. What is
|
|
25
|
+
// duplicated here is deliberately the minimum: claim, advance, finish, read.
|
|
26
|
+
import { BaseDBClient } from './client.js';
|
|
27
|
+
const COLUMNS = `operation_id, kind, state, phase, epoch, owner_node_id,
|
|
28
|
+
owner_principal, min_release, started_at, finished_at, last_error`;
|
|
29
|
+
const PG_UNIQUE_VIOLATION = '23505';
|
|
30
|
+
function toOperation(r) {
|
|
31
|
+
return {
|
|
32
|
+
operationId: r.operation_id,
|
|
33
|
+
kind: r.kind,
|
|
34
|
+
state: r.state,
|
|
35
|
+
phase: r.phase,
|
|
36
|
+
// BIGINT arrives as a string from some drivers, a number from others.
|
|
37
|
+
epoch: Number(r.epoch),
|
|
38
|
+
ownerNodeId: r.owner_node_id,
|
|
39
|
+
ownerPrincipal: r.owner_principal,
|
|
40
|
+
minRelease: r.min_release,
|
|
41
|
+
startedAt: r.started_at,
|
|
42
|
+
finishedAt: r.finished_at,
|
|
43
|
+
lastError: r.last_error,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export class KeyLifecycleOperations extends BaseDBClient {
|
|
47
|
+
/**
|
|
48
|
+
* Is the schema present?
|
|
49
|
+
*
|
|
50
|
+
* The CLI ships independently of the server, so it can meet a deployment that
|
|
51
|
+
* predates migration 093. Saying so plainly beats a `relation does not exist`
|
|
52
|
+
* in the middle of a ceremony.
|
|
53
|
+
*/
|
|
54
|
+
async schemaPresent() {
|
|
55
|
+
await this.connect();
|
|
56
|
+
const r = await this.getRawClient().query(`SELECT to_regclass('public.key_lifecycle_operations')::text AS present`);
|
|
57
|
+
return (r.rows[0]?.present ?? null) !== null;
|
|
58
|
+
}
|
|
59
|
+
/** The operation currently holding the exclusion slot, if any. */
|
|
60
|
+
async active() {
|
|
61
|
+
await this.connect();
|
|
62
|
+
const r = await this.getRawClient().query(`SELECT ${COLUMNS} FROM key_lifecycle_operations WHERE state = 'IN_PROGRESS' LIMIT 1`);
|
|
63
|
+
const row = r.rows.at(0);
|
|
64
|
+
return row === undefined ? null : toOperation(row);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Start a ceremony, or find out who already holds the slot.
|
|
68
|
+
*
|
|
69
|
+
* Does NOT look before it leaps: a "is anything running?" SELECT followed by
|
|
70
|
+
* an INSERT is exactly the race the index exists to close. The INSERT is the
|
|
71
|
+
* check, and 23505 is the answer.
|
|
72
|
+
*/
|
|
73
|
+
async claim(input) {
|
|
74
|
+
await this.connect();
|
|
75
|
+
const c = this.getRawClient();
|
|
76
|
+
try {
|
|
77
|
+
await c.query('BEGIN');
|
|
78
|
+
const r = await c.query(`INSERT INTO key_lifecycle_operations
|
|
79
|
+
(kind, state, phase, epoch, owner_node_id, owner_principal, min_release)
|
|
80
|
+
VALUES ('ESCROW_SNAPSHOT', 'IN_PROGRESS', $1, 0, $2, $3, $4)
|
|
81
|
+
RETURNING ${COLUMNS}`, [input.phase, input.ownerNodeId, input.ownerPrincipal, input.minRelease]);
|
|
82
|
+
const row = r.rows.at(0);
|
|
83
|
+
if (row === undefined)
|
|
84
|
+
throw new Error('claim: INSERT ... RETURNING produced no row');
|
|
85
|
+
// Same transaction: an operation whose history starts one commit late is
|
|
86
|
+
// an operation whose first phase can vanish.
|
|
87
|
+
await c.query(`INSERT INTO key_lifecycle_phase_events
|
|
88
|
+
(operation_id, seq, phase, state, node_id, detail)
|
|
89
|
+
VALUES ($1, 0, $2, 'IN_PROGRESS', $3, $4)`, [row.operation_id, input.phase, input.ownerNodeId,
|
|
90
|
+
input.detail ? JSON.stringify(input.detail) : null]);
|
|
91
|
+
await c.query('COMMIT');
|
|
92
|
+
return { ok: true, operation: toOperation(row) };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
await c.query('ROLLBACK').catch(() => undefined);
|
|
96
|
+
if (error.code !== PG_UNIQUE_VIOLATION)
|
|
97
|
+
throw error;
|
|
98
|
+
return { ok: false, active: await this.active() };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Move to the next phase, recording it in the same transaction.
|
|
103
|
+
*
|
|
104
|
+
* Compare-and-swap on `epoch`: a caller holding a stale read is refused
|
|
105
|
+
* rather than silently overwriting whoever moved first.
|
|
106
|
+
*/
|
|
107
|
+
async advance(input) {
|
|
108
|
+
await this.connect();
|
|
109
|
+
const c = this.getRawClient();
|
|
110
|
+
const next = input.expectedEpoch + 1;
|
|
111
|
+
try {
|
|
112
|
+
await c.query('BEGIN');
|
|
113
|
+
const r = await c.query(`UPDATE key_lifecycle_operations
|
|
114
|
+
SET phase = $1, epoch = $2, updated_at = now()
|
|
115
|
+
WHERE operation_id = $3 AND epoch = $4 AND state = 'IN_PROGRESS'
|
|
116
|
+
RETURNING ${COLUMNS}`, [input.phase, next, input.operationId, input.expectedEpoch]);
|
|
117
|
+
const row = r.rows.at(0);
|
|
118
|
+
if (row === undefined) {
|
|
119
|
+
await c.query('ROLLBACK');
|
|
120
|
+
throw new Error(`Could not advance operation ${input.operationId} to '${input.phase}': it is ` +
|
|
121
|
+
'no longer IN_PROGRESS at the expected epoch. Someone or something else ' +
|
|
122
|
+
"moved it. Run 'ceremony status' before deciding what to do.");
|
|
123
|
+
}
|
|
124
|
+
await c.query(`INSERT INTO key_lifecycle_phase_events
|
|
125
|
+
(operation_id, seq, phase, state, node_id, detail)
|
|
126
|
+
VALUES ($1, $2, $3, 'IN_PROGRESS', $4, $5)`, [input.operationId, next, input.phase, input.nodeId,
|
|
127
|
+
input.detail ? JSON.stringify(input.detail) : null]);
|
|
128
|
+
await c.query('COMMIT');
|
|
129
|
+
return toOperation(row);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
await c.query('ROLLBACK').catch(() => undefined);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Close the operation, releasing the slot.
|
|
138
|
+
*
|
|
139
|
+
* `ABANDONED` is a real transition with a reason rather than a DELETE: why a
|
|
140
|
+
* ceremony was abandoned is exactly what the next person needs to read, and a
|
|
141
|
+
* global exclusion with no escape hatch is a deadlock waiting for its first
|
|
142
|
+
* interrupted ceremony.
|
|
143
|
+
*/
|
|
144
|
+
async finish(input) {
|
|
145
|
+
await this.connect();
|
|
146
|
+
const c = this.getRawClient();
|
|
147
|
+
const next = input.expectedEpoch + 1;
|
|
148
|
+
try {
|
|
149
|
+
await c.query('BEGIN');
|
|
150
|
+
const r = await c.query(`UPDATE key_lifecycle_operations
|
|
151
|
+
SET state = $1, epoch = $2, last_error = COALESCE($3, last_error),
|
|
152
|
+
updated_at = now(), finished_at = now()
|
|
153
|
+
WHERE operation_id = $4 AND epoch = $5 AND state = 'IN_PROGRESS'
|
|
154
|
+
RETURNING ${COLUMNS}`, [input.outcome, next, input.error ?? null, input.operationId, input.expectedEpoch]);
|
|
155
|
+
const row = r.rows.at(0);
|
|
156
|
+
if (row === undefined) {
|
|
157
|
+
await c.query('ROLLBACK');
|
|
158
|
+
throw new Error(`Could not close operation ${input.operationId}: it is no longer IN_PROGRESS ` +
|
|
159
|
+
'at the expected epoch.');
|
|
160
|
+
}
|
|
161
|
+
await c.query(`INSERT INTO key_lifecycle_phase_events
|
|
162
|
+
(operation_id, seq, phase, state, node_id, detail)
|
|
163
|
+
VALUES ($1, $2, $3, $4, $5, $6)`, [input.operationId, next, row.phase, input.outcome, input.nodeId,
|
|
164
|
+
input.detail ? JSON.stringify(input.detail) : null]);
|
|
165
|
+
await c.query('COMMIT');
|
|
166
|
+
return toOperation(row);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
await c.query('ROLLBACK').catch(() => undefined);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** The full route travelled, oldest first. */
|
|
174
|
+
async history(operationId) {
|
|
175
|
+
await this.connect();
|
|
176
|
+
const r = await this.getRawClient().query(`SELECT seq, phase, state, at::text AS at, detail
|
|
177
|
+
FROM key_lifecycle_phase_events
|
|
178
|
+
WHERE operation_id = $1 ORDER BY seq ASC`, [operationId]);
|
|
179
|
+
return r.rows;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=key-lifecycle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"key-lifecycle.js","sourceRoot":"","sources":["../../../src/lib/db/key-lifecycle.ts"],"names":[],"mappings":"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"}
|