sealkeep 0.5.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/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import type { ControlPlaneStore, Device } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Device authentication for the control plane.
|
|
6
|
+
*
|
|
7
|
+
* A device signs a canonical string covering method, path, timestamp, nonce, and a
|
|
8
|
+
* hash of the body. That binds a signature to one request: replaying it elsewhere
|
|
9
|
+
* changes the string, and replaying it verbatim is caught by the nonce cache.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SIGNATURE_WINDOW_MS: number;
|
|
12
|
+
export declare const HEADERS: {
|
|
13
|
+
readonly device: "x-vaultline-device";
|
|
14
|
+
readonly timestamp: "x-vaultline-timestamp";
|
|
15
|
+
readonly nonce: "x-vaultline-nonce";
|
|
16
|
+
readonly signature: "x-vaultline-signature";
|
|
17
|
+
};
|
|
18
|
+
export declare function signingString(method: string, path: string, timestamp: string, nonce: string, body: Buffer | string): string;
|
|
19
|
+
/** Client helper: produces the four headers a signed request needs. */
|
|
20
|
+
export declare function signRequest(input: {
|
|
21
|
+
method: string;
|
|
22
|
+
path: string;
|
|
23
|
+
deviceId: string;
|
|
24
|
+
privateKey: KeyObject | string;
|
|
25
|
+
body?: Buffer | string;
|
|
26
|
+
timestamp?: string;
|
|
27
|
+
nonce: string;
|
|
28
|
+
}): Record<string, string>;
|
|
29
|
+
export type AuthFailure = {
|
|
30
|
+
ok: false;
|
|
31
|
+
status: number;
|
|
32
|
+
code: string;
|
|
33
|
+
message: string;
|
|
34
|
+
deviceId?: string;
|
|
35
|
+
};
|
|
36
|
+
export type AuthSuccess = {
|
|
37
|
+
ok: true;
|
|
38
|
+
device: Device;
|
|
39
|
+
};
|
|
40
|
+
export type AuthResult = AuthFailure | AuthSuccess;
|
|
41
|
+
export declare class NonceCache {
|
|
42
|
+
private readonly windowMs;
|
|
43
|
+
private readonly limit;
|
|
44
|
+
private readonly seen;
|
|
45
|
+
constructor(windowMs?: number, limit?: number);
|
|
46
|
+
/** True when the nonce is fresh; false when it has already been used inside the window. */
|
|
47
|
+
accept(nonce: string, now: number): boolean;
|
|
48
|
+
}
|
|
49
|
+
export declare function authenticate(store: ControlPlaneStore, request: IncomingMessage, path: string, body: Buffer, nonces: NonceCache, now?: number): AuthResult;
|
|
50
|
+
/** Ed25519 SPKI wrapper around a raw 32-byte public key. */
|
|
51
|
+
export declare function publicKeyFromRaw(base64: string): KeyObject;
|
|
52
|
+
export declare function rawPublicKey(key: KeyObject): Buffer;
|
|
53
|
+
export declare function hashEnrollmentToken(token: string): string;
|
|
54
|
+
export declare function enrollmentTokenMatches(token: string, hash: string): boolean;
|
|
55
|
+
/** A token bucket per key. Abuse control that does not need shared state. */
|
|
56
|
+
export declare class RateLimiter {
|
|
57
|
+
private readonly capacity;
|
|
58
|
+
private readonly refillPerSecond;
|
|
59
|
+
private readonly buckets;
|
|
60
|
+
constructor(capacity?: number, refillPerSecond?: number);
|
|
61
|
+
take(key: string, now?: number): boolean;
|
|
62
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash, createPublicKey, sign as edSign, verify as edVerify, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Device authentication for the control plane.
|
|
4
|
+
*
|
|
5
|
+
* A device signs a canonical string covering method, path, timestamp, nonce, and a
|
|
6
|
+
* hash of the body. That binds a signature to one request: replaying it elsewhere
|
|
7
|
+
* changes the string, and replaying it verbatim is caught by the nonce cache.
|
|
8
|
+
*/
|
|
9
|
+
export const SIGNATURE_WINDOW_MS = 5 * 60_000;
|
|
10
|
+
export const HEADERS = {
|
|
11
|
+
device: "x-vaultline-device",
|
|
12
|
+
timestamp: "x-vaultline-timestamp",
|
|
13
|
+
nonce: "x-vaultline-nonce",
|
|
14
|
+
signature: "x-vaultline-signature"
|
|
15
|
+
};
|
|
16
|
+
const sha256hex = (value) => createHash("sha256").update(value).digest("hex");
|
|
17
|
+
export function signingString(method, path, timestamp, nonce, body) {
|
|
18
|
+
return `vaultline-request:v1\n${method.toUpperCase()}\n${path}\n${timestamp}\n${nonce}\n${sha256hex(body)}`;
|
|
19
|
+
}
|
|
20
|
+
/** Client helper: produces the four headers a signed request needs. */
|
|
21
|
+
export function signRequest(input) {
|
|
22
|
+
const timestamp = input.timestamp ?? new Date().toISOString();
|
|
23
|
+
const message = signingString(input.method, input.path, timestamp, input.nonce, input.body ?? "");
|
|
24
|
+
const key = typeof input.privateKey === "string" ? createPublicKey(input.privateKey) : input.privateKey;
|
|
25
|
+
return {
|
|
26
|
+
[HEADERS.device]: input.deviceId,
|
|
27
|
+
[HEADERS.timestamp]: timestamp,
|
|
28
|
+
[HEADERS.nonce]: input.nonce,
|
|
29
|
+
[HEADERS.signature]: edSign(null, Buffer.from(message), key).toString("base64")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export class NonceCache {
|
|
33
|
+
windowMs;
|
|
34
|
+
limit;
|
|
35
|
+
seen = new Map();
|
|
36
|
+
constructor(windowMs = SIGNATURE_WINDOW_MS, limit = 10_000) {
|
|
37
|
+
this.windowMs = windowMs;
|
|
38
|
+
this.limit = limit;
|
|
39
|
+
}
|
|
40
|
+
/** True when the nonce is fresh; false when it has already been used inside the window. */
|
|
41
|
+
accept(nonce, now) {
|
|
42
|
+
for (const [key, at] of this.seen)
|
|
43
|
+
if (now - at > this.windowMs)
|
|
44
|
+
this.seen.delete(key);
|
|
45
|
+
if (this.seen.has(nonce))
|
|
46
|
+
return false;
|
|
47
|
+
if (this.seen.size >= this.limit)
|
|
48
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
49
|
+
this.seen.set(nonce, now);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function authenticate(store, request, path, body, nonces, now = Date.now()) {
|
|
54
|
+
const header = (name) => { const value = request.headers[name]; return typeof value === "string" ? value : undefined; };
|
|
55
|
+
const deviceId = header(HEADERS.device);
|
|
56
|
+
const timestamp = header(HEADERS.timestamp);
|
|
57
|
+
const nonce = header(HEADERS.nonce);
|
|
58
|
+
const signature = header(HEADERS.signature);
|
|
59
|
+
if (!deviceId || !timestamp || !nonce || !signature)
|
|
60
|
+
return { ok: false, status: 401, code: "unauthorized", message: "A signed device request is required" };
|
|
61
|
+
const at = Date.parse(timestamp);
|
|
62
|
+
if (!Number.isFinite(at))
|
|
63
|
+
return { ok: false, status: 401, code: "unauthorized", message: "Malformed request timestamp", deviceId };
|
|
64
|
+
if (Math.abs(now - at) > SIGNATURE_WINDOW_MS)
|
|
65
|
+
return { ok: false, status: 401, code: "unauthorized", message: "Request timestamp is outside the accepted window", deviceId };
|
|
66
|
+
if (!nonces.accept(`${deviceId}:${nonce}`, now))
|
|
67
|
+
return { ok: false, status: 401, code: "unauthorized", message: "This request was already presented", deviceId };
|
|
68
|
+
const device = store.device(deviceId);
|
|
69
|
+
if (!device)
|
|
70
|
+
return { ok: false, status: 401, code: "unauthorized", message: "Unknown device", deviceId };
|
|
71
|
+
if (device.revokedAt)
|
|
72
|
+
return { ok: false, status: 403, code: "forbidden", message: `Device revoked at ${device.revokedAt}`, deviceId };
|
|
73
|
+
const message = Buffer.from(signingString(request.method ?? "GET", path, timestamp, nonce, body));
|
|
74
|
+
let valid = false;
|
|
75
|
+
try {
|
|
76
|
+
valid = edVerify(null, message, publicKeyFromRaw(device.publicKey), Buffer.from(signature, "base64"));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
valid = false;
|
|
80
|
+
}
|
|
81
|
+
if (!valid)
|
|
82
|
+
return { ok: false, status: 401, code: "unauthorized", message: "Request signature does not verify", deviceId };
|
|
83
|
+
return { ok: true, device };
|
|
84
|
+
}
|
|
85
|
+
/** Ed25519 SPKI wrapper around a raw 32-byte public key. */
|
|
86
|
+
export function publicKeyFromRaw(base64) {
|
|
87
|
+
const raw = Buffer.from(base64, "base64");
|
|
88
|
+
if (raw.length !== 32)
|
|
89
|
+
throw new Error("An Ed25519 public key must be 32 bytes");
|
|
90
|
+
return createPublicKey({ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]), format: "der", type: "spki" });
|
|
91
|
+
}
|
|
92
|
+
export function rawPublicKey(key) {
|
|
93
|
+
return key.export({ type: "spki", format: "der" }).subarray(-32);
|
|
94
|
+
}
|
|
95
|
+
export function hashEnrollmentToken(token) {
|
|
96
|
+
return sha256hex(`vaultline-enrollment:v1:${token}`);
|
|
97
|
+
}
|
|
98
|
+
export function enrollmentTokenMatches(token, hash) {
|
|
99
|
+
const presented = Buffer.from(hashEnrollmentToken(token));
|
|
100
|
+
const expected = Buffer.from(hash);
|
|
101
|
+
return presented.length === expected.length && timingSafeEqual(presented, expected);
|
|
102
|
+
}
|
|
103
|
+
/** A token bucket per key. Abuse control that does not need shared state. */
|
|
104
|
+
export class RateLimiter {
|
|
105
|
+
capacity;
|
|
106
|
+
refillPerSecond;
|
|
107
|
+
buckets = new Map();
|
|
108
|
+
constructor(capacity = 60, refillPerSecond = 1) {
|
|
109
|
+
this.capacity = capacity;
|
|
110
|
+
this.refillPerSecond = refillPerSecond;
|
|
111
|
+
}
|
|
112
|
+
take(key, now = Date.now()) {
|
|
113
|
+
const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
|
|
114
|
+
const refill = ((now - bucket.updatedAt) / 1000) * this.refillPerSecond;
|
|
115
|
+
const tokens = Math.min(this.capacity, bucket.tokens + refill);
|
|
116
|
+
if (tokens < 1) {
|
|
117
|
+
this.buckets.set(key, { tokens, updatedAt: now });
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
this.buckets.set(key, { tokens: tokens - 1, updatedAt: now });
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import { ControlPlaneStore } from "./store.js";
|
|
3
|
+
export type ControlPlaneOptions = {
|
|
4
|
+
/** Path for durable state. Omit for an in-memory instance. */
|
|
5
|
+
storePath?: string;
|
|
6
|
+
store?: ControlPlaneStore;
|
|
7
|
+
/** Development mode: skips device signatures. Never enable for a deployment. */
|
|
8
|
+
requireAuth?: boolean;
|
|
9
|
+
tls?: {
|
|
10
|
+
key: string | Buffer;
|
|
11
|
+
cert: string | Buffer;
|
|
12
|
+
};
|
|
13
|
+
now?: () => number;
|
|
14
|
+
leaseTtlMs?: number;
|
|
15
|
+
rateLimit?: {
|
|
16
|
+
capacity: number;
|
|
17
|
+
refillPerSecond: number;
|
|
18
|
+
};
|
|
19
|
+
/** Content-free request log. Receives counts and outcomes only. */
|
|
20
|
+
onLog?: (line: Record<string, string | number>) => void;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The Sealkeep control plane.
|
|
24
|
+
*
|
|
25
|
+
* It holds account, device, provider, and manifest state, and issues short-lived
|
|
26
|
+
* single-object upload leases. It never receives transcript plaintext, an archive
|
|
27
|
+
* key, or a recovery phrase, and every endpoint that mutates state records an
|
|
28
|
+
* audit event carrying identifiers and counts only.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createControlPlaneServer(options?: ControlPlaneOptions): Server;
|
|
31
|
+
export { ControlPlaneStore };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
3
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { authenticate, enrollmentTokenMatches, hashEnrollmentToken, NonceCache, RateLimiter } from "./auth.js";
|
|
6
|
+
import { ControlPlaneStore } from "./store.js";
|
|
7
|
+
const MAX_BODY_BYTES = 512 * 1024;
|
|
8
|
+
const DEFAULT_LEASE_TTL_MS = 15 * 60_000;
|
|
9
|
+
const storageSchema = z.object({
|
|
10
|
+
provider: z.enum(["vaultline", "s3", "r2", "gcs"]),
|
|
11
|
+
bucket: z.string().min(1).max(255),
|
|
12
|
+
prefix: z.string().min(1).max(512),
|
|
13
|
+
region: z.string().min(1).max(64).optional(),
|
|
14
|
+
credentialMode: z.enum(["server-managed", "byo-role", "byo-service-account"]).default("server-managed")
|
|
15
|
+
}).strict();
|
|
16
|
+
const leaseSchema = z.object({
|
|
17
|
+
storageConfigId: z.string().uuid(),
|
|
18
|
+
archiveId: z.string().uuid(),
|
|
19
|
+
ciphertextSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
20
|
+
bytes: z.number().int().positive(),
|
|
21
|
+
partCount: z.number().int().positive().max(10_000).default(1)
|
|
22
|
+
}).strict();
|
|
23
|
+
const completeSchema = z.object({
|
|
24
|
+
leaseId: z.string().uuid(),
|
|
25
|
+
providerChecksum: z.string().min(1).max(128),
|
|
26
|
+
bytes: z.number().int().positive(),
|
|
27
|
+
manifestSha256: z.string().regex(/^[a-f0-9]{64}$/).optional()
|
|
28
|
+
}).strict();
|
|
29
|
+
// The manifest is opaque ciphertext. A strict schema is what stops a future caller
|
|
30
|
+
// from "temporarily" posting plaintext transcript fields to this endpoint.
|
|
31
|
+
const manifestSchema = z.object({ ciphertext: z.string().min(1).max(256 * 1024) }).strict();
|
|
32
|
+
const enrollSchema = z.object({
|
|
33
|
+
accountId: z.string().uuid(),
|
|
34
|
+
enrollmentToken: z.string().min(8).max(200),
|
|
35
|
+
label: z.string().min(1).max(120),
|
|
36
|
+
publicKey: z.string().min(40).max(100)
|
|
37
|
+
}).strict();
|
|
38
|
+
function json(response, status, value) {
|
|
39
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
40
|
+
response.end(JSON.stringify(value));
|
|
41
|
+
}
|
|
42
|
+
async function readBody(request) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
for await (const chunk of request) {
|
|
46
|
+
size += chunk.length;
|
|
47
|
+
if (size > MAX_BODY_BYTES)
|
|
48
|
+
throw new Error("Request body is too large");
|
|
49
|
+
chunks.push(chunk);
|
|
50
|
+
}
|
|
51
|
+
return Buffer.concat(chunks);
|
|
52
|
+
}
|
|
53
|
+
function parse(schema, body) {
|
|
54
|
+
let value;
|
|
55
|
+
try {
|
|
56
|
+
value = JSON.parse(body.toString("utf8") || "{}");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error("Request body must be valid JSON");
|
|
60
|
+
}
|
|
61
|
+
const result = schema.safeParse(value);
|
|
62
|
+
if (!result.success)
|
|
63
|
+
throw new Error(result.error.issues.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`).join("; "));
|
|
64
|
+
return result.data;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The Sealkeep control plane.
|
|
68
|
+
*
|
|
69
|
+
* It holds account, device, provider, and manifest state, and issues short-lived
|
|
70
|
+
* single-object upload leases. It never receives transcript plaintext, an archive
|
|
71
|
+
* key, or a recovery phrase, and every endpoint that mutates state records an
|
|
72
|
+
* audit event carrying identifiers and counts only.
|
|
73
|
+
*/
|
|
74
|
+
export function createControlPlaneServer(options = {}) {
|
|
75
|
+
const store = options.store ?? new ControlPlaneStore(options.storePath);
|
|
76
|
+
const requireAuth = options.requireAuth ?? true;
|
|
77
|
+
const now = options.now ?? Date.now;
|
|
78
|
+
const nonces = new NonceCache();
|
|
79
|
+
const limiter = new RateLimiter(options.rateLimit?.capacity ?? 120, options.rateLimit?.refillPerSecond ?? 2);
|
|
80
|
+
const handler = async (request, response) => {
|
|
81
|
+
const started = now();
|
|
82
|
+
const url = new URL(request.url ?? "/", "http://control-plane");
|
|
83
|
+
const method = request.method ?? "GET";
|
|
84
|
+
let status = 500;
|
|
85
|
+
let deviceId;
|
|
86
|
+
try {
|
|
87
|
+
const limitKey = request.headers["x-vaultline-device"] ?? request.socket.remoteAddress ?? "anonymous";
|
|
88
|
+
if (!limiter.take(limitKey, now())) {
|
|
89
|
+
status = 429;
|
|
90
|
+
store.audit({ action: `${method} ${url.pathname}`, outcome: "denied", detail: { reason: "rate_limited" } });
|
|
91
|
+
return json(response, 429, { error: { code: "rate_limited", message: "Too many requests" } });
|
|
92
|
+
}
|
|
93
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
94
|
+
status = 200;
|
|
95
|
+
return json(response, 200, { ok: true, mode: requireAuth ? "control-plane" : "development-interface", credentials: "disabled" });
|
|
96
|
+
}
|
|
97
|
+
const body = await readBody(request);
|
|
98
|
+
// Account creation and device enrollment cannot be device-signed: they are
|
|
99
|
+
// what establish a device. Enrollment is gated on the account's token instead.
|
|
100
|
+
if (method === "POST" && url.pathname === "/v1/accounts") {
|
|
101
|
+
const label = JSON.parse(body.toString("utf8") || "{}").label ?? "account";
|
|
102
|
+
const enrollmentToken = randomBytes(24).toString("base64url");
|
|
103
|
+
const account = store.createAccount({ id: randomUUID(), label: String(label).slice(0, 120), createdAt: new Date(now()).toISOString(), enrollmentTokenHash: hashEnrollmentToken(enrollmentToken), quotaBytes: 50 * 1024 ** 3 });
|
|
104
|
+
store.audit({ accountId: account.id, action: "account.create", outcome: "allowed" });
|
|
105
|
+
status = 201;
|
|
106
|
+
// The token is shown once; only its hash is stored.
|
|
107
|
+
return json(response, 201, { id: account.id, label: account.label, enrollmentToken });
|
|
108
|
+
}
|
|
109
|
+
if (method === "POST" && url.pathname === "/v1/devices") {
|
|
110
|
+
const input = parse(enrollSchema, body);
|
|
111
|
+
const account = store.account(input.accountId);
|
|
112
|
+
if (!account || !enrollmentTokenMatches(input.enrollmentToken, account.enrollmentTokenHash)) {
|
|
113
|
+
store.audit({ accountId: input.accountId, action: "device.enroll", outcome: "denied", detail: { reason: "bad_enrollment_token" } });
|
|
114
|
+
status = 401;
|
|
115
|
+
return json(response, 401, { error: { code: "unauthorized", message: "Enrollment token is not valid for this account" } });
|
|
116
|
+
}
|
|
117
|
+
const device = store.addDevice({ id: randomUUID(), accountId: account.id, label: input.label, publicKey: input.publicKey, createdAt: new Date(now()).toISOString() });
|
|
118
|
+
store.audit({ accountId: account.id, deviceId: device.id, action: "device.enroll", outcome: "allowed" });
|
|
119
|
+
status = 201;
|
|
120
|
+
return json(response, 201, { id: device.id, accountId: device.accountId, label: device.label, createdAt: device.createdAt });
|
|
121
|
+
}
|
|
122
|
+
let auth = { ok: true, device: { id: "development", accountId: "development", label: "development", publicKey: "", createdAt: new Date(now()).toISOString() } };
|
|
123
|
+
if (requireAuth) {
|
|
124
|
+
auth = authenticate(store, request, url.pathname, body, nonces, now());
|
|
125
|
+
deviceId = auth.ok ? auth.device.id : auth.deviceId;
|
|
126
|
+
if (!auth.ok) {
|
|
127
|
+
store.audit({ deviceId, action: `${method} ${url.pathname}`, outcome: "denied", detail: { reason: auth.code } });
|
|
128
|
+
status = auth.status;
|
|
129
|
+
return json(response, auth.status, { error: { code: auth.code, message: auth.message } });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const device = auth.ok ? auth.device : undefined;
|
|
133
|
+
deviceId = device.id;
|
|
134
|
+
if (method === "GET" && url.pathname === "/v1/devices") {
|
|
135
|
+
status = 200;
|
|
136
|
+
return json(response, 200, store.devicesFor(device.accountId).map(({ id, label, createdAt, revokedAt }) => ({ id, label, createdAt, revokedAt: revokedAt ?? null })));
|
|
137
|
+
}
|
|
138
|
+
const revokeMatch = /^\/v1\/devices\/([0-9a-f-]{36})\/revoke$/.exec(url.pathname);
|
|
139
|
+
if (method === "POST" && revokeMatch) {
|
|
140
|
+
const target = store.device(revokeMatch[1]);
|
|
141
|
+
if (!target || target.accountId !== device.accountId) {
|
|
142
|
+
status = 404;
|
|
143
|
+
return json(response, 404, { error: { code: "not_found", message: "device not found" } });
|
|
144
|
+
}
|
|
145
|
+
const revoked = store.revokeDevice(target.id, new Date(now()).toISOString());
|
|
146
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "device.revoke", outcome: "allowed", detail: { target: target.id } });
|
|
147
|
+
status = 200;
|
|
148
|
+
// Revocation blocks new leases. It never deletes an archive.
|
|
149
|
+
return json(response, 200, { id: revoked.id, revokedAt: revoked.revokedAt, archivesRetained: store.archivesFor(device.accountId).length });
|
|
150
|
+
}
|
|
151
|
+
if (method === "POST" && url.pathname === "/v1/storage-configs") {
|
|
152
|
+
const input = parse(storageSchema, body);
|
|
153
|
+
const config = store.addStorageConfig({ id: randomUUID(), accountId: device.accountId, createdAt: new Date(now()).toISOString(), ...input });
|
|
154
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "storage-config.create", outcome: "allowed", detail: { provider: config.provider } });
|
|
155
|
+
status = 201;
|
|
156
|
+
return json(response, 201, config);
|
|
157
|
+
}
|
|
158
|
+
const configMatch = /^\/v1\/storage-configs\/([0-9a-f-]{36})$/.exec(url.pathname);
|
|
159
|
+
if (method === "GET" && configMatch) {
|
|
160
|
+
const config = store.storageConfig(configMatch[1]);
|
|
161
|
+
if (!config || config.accountId !== device.accountId) {
|
|
162
|
+
status = 404;
|
|
163
|
+
return json(response, 404, { error: { code: "not_found", message: "storage config not found" } });
|
|
164
|
+
}
|
|
165
|
+
status = 200;
|
|
166
|
+
return json(response, 200, config);
|
|
167
|
+
}
|
|
168
|
+
if (method === "POST" && url.pathname === "/v1/archives/leases") {
|
|
169
|
+
const input = parse(leaseSchema, body);
|
|
170
|
+
const config = store.storageConfig(input.storageConfigId);
|
|
171
|
+
if (!config || config.accountId !== device.accountId) {
|
|
172
|
+
status = 404;
|
|
173
|
+
return json(response, 404, { error: { code: "not_found", message: "storage config not found" } });
|
|
174
|
+
}
|
|
175
|
+
const account = store.account(device.accountId);
|
|
176
|
+
if (account && store.usedBytes(account.id) + input.bytes > account.quotaBytes) {
|
|
177
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "lease.create", outcome: "denied", detail: { reason: "quota_exceeded", bytes: input.bytes } });
|
|
178
|
+
status = 402;
|
|
179
|
+
return json(response, 402, { error: { code: "quota_exceeded", message: "This account has no remaining quota" } });
|
|
180
|
+
}
|
|
181
|
+
// The object key is allocated here. A client cannot choose where its bytes land.
|
|
182
|
+
const objectKey = `${config.prefix.replace(/^\/+|\/+$/g, "")}/${input.archiveId}.vlarchive`;
|
|
183
|
+
const issuedAt = now();
|
|
184
|
+
const lease = store.addLease({
|
|
185
|
+
id: randomUUID(), accountId: device.accountId, deviceId: device.id, archiveId: input.archiveId, storageConfigId: config.id,
|
|
186
|
+
objectKey, bytes: input.bytes, ciphertextSha256: input.ciphertextSha256,
|
|
187
|
+
issuedAt: new Date(issuedAt).toISOString(), expiresAt: new Date(issuedAt + (options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS)).toISOString()
|
|
188
|
+
});
|
|
189
|
+
store.upsertArchive({ accountId: device.accountId, archiveId: input.archiveId, bytes: input.bytes, ciphertextSha256: input.ciphertextSha256, objectKey, createdAt: new Date(issuedAt).toISOString() });
|
|
190
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "lease.create", outcome: "allowed", detail: { archiveId: input.archiveId, bytes: input.bytes } });
|
|
191
|
+
status = 201;
|
|
192
|
+
return json(response, 201, {
|
|
193
|
+
leaseId: lease.id, archiveId: lease.archiveId, objectKey, expiresAt: lease.expiresAt,
|
|
194
|
+
method: config.provider === "gcs" ? "RESUMABLE" : "MULTIPART", partCount: input.partCount,
|
|
195
|
+
requiredHeaders: { "content-type": "application/vnd.vaultline.ciphertext", "x-vaultline-ciphertext-sha256": input.ciphertextSha256 },
|
|
196
|
+
status: "pending-signer", uploadable: false,
|
|
197
|
+
note: "This deployment issues lease records only. A signer service must mint provider URLs."
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const completeMatch = /^\/v1\/archives\/([0-9a-f-]{36})\/complete$/.exec(url.pathname);
|
|
201
|
+
if (method === "POST" && completeMatch) {
|
|
202
|
+
const input = parse(completeSchema, body);
|
|
203
|
+
const lease = store.lease(input.leaseId);
|
|
204
|
+
if (!lease || lease.accountId !== device.accountId || lease.archiveId !== completeMatch[1]) {
|
|
205
|
+
status = 404;
|
|
206
|
+
return json(response, 404, { error: { code: "not_found", message: "lease not found for this archive" } });
|
|
207
|
+
}
|
|
208
|
+
if (Date.parse(lease.expiresAt) <= now()) {
|
|
209
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "archive.complete", outcome: "denied", detail: { reason: "lease_expired", archiveId: lease.archiveId } });
|
|
210
|
+
status = 409;
|
|
211
|
+
return json(response, 409, { error: { code: "lease_expired", message: `Lease expired at ${lease.expiresAt}` } });
|
|
212
|
+
}
|
|
213
|
+
if (!store.consumeLease(lease.id, new Date(now()).toISOString())) {
|
|
214
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "archive.complete", outcome: "denied", detail: { reason: "lease_replayed", archiveId: lease.archiveId } });
|
|
215
|
+
status = 409;
|
|
216
|
+
return json(response, 409, { error: { code: "lease_replayed", message: "This lease was already completed" } });
|
|
217
|
+
}
|
|
218
|
+
if (input.bytes !== lease.bytes) {
|
|
219
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "archive.complete", outcome: "denied", detail: { reason: "size_mismatch", archiveId: lease.archiveId } });
|
|
220
|
+
status = 409;
|
|
221
|
+
return json(response, 409, { error: { code: "checksum_mismatch", message: `Lease promised ${lease.bytes} bytes; ${input.bytes} were reported` } });
|
|
222
|
+
}
|
|
223
|
+
const durable = store.upsertArchive({ accountId: device.accountId, archiveId: lease.archiveId, bytes: input.bytes, ciphertextSha256: lease.ciphertextSha256, objectKey: lease.objectKey, durableAt: new Date(now()).toISOString(), createdAt: new Date(now()).toISOString() });
|
|
224
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "archive.complete", outcome: "allowed", detail: { archiveId: lease.archiveId, bytes: input.bytes } });
|
|
225
|
+
status = 200;
|
|
226
|
+
return json(response, 200, { archiveId: durable.archiveId, durableAt: durable.durableAt, objectKey: durable.objectKey, providerChecksum: input.providerChecksum });
|
|
227
|
+
}
|
|
228
|
+
const manifestMatch = /^\/v1\/archives\/([0-9a-f-]{36})\/manifest$/.exec(url.pathname);
|
|
229
|
+
if (method === "PUT" && manifestMatch) {
|
|
230
|
+
const input = parse(manifestSchema, body);
|
|
231
|
+
const existing = store.archive(device.accountId, manifestMatch[1]);
|
|
232
|
+
if (!existing) {
|
|
233
|
+
status = 404;
|
|
234
|
+
return json(response, 404, { error: { code: "not_found", message: "archive not found" } });
|
|
235
|
+
}
|
|
236
|
+
store.upsertArchive({ ...existing, manifest: { ciphertext: input.ciphertext, updatedAt: new Date(now()).toISOString() } });
|
|
237
|
+
store.audit({ accountId: device.accountId, deviceId: device.id, action: "manifest.put", outcome: "allowed", detail: { archiveId: manifestMatch[1], bytes: input.ciphertext.length } });
|
|
238
|
+
status = 200;
|
|
239
|
+
return json(response, 200, { archiveId: manifestMatch[1], storedBytes: input.ciphertext.length });
|
|
240
|
+
}
|
|
241
|
+
if (method === "GET" && url.pathname === "/v1/archives") {
|
|
242
|
+
status = 200;
|
|
243
|
+
return json(response, 200, store.archivesFor(device.accountId).map(({ archiveId, bytes, objectKey, durableAt }) => ({ archiveId, bytes, objectKey, durableAt: durableAt ?? null })));
|
|
244
|
+
}
|
|
245
|
+
if (method === "GET" && url.pathname === "/v1/audit") {
|
|
246
|
+
status = 200;
|
|
247
|
+
return json(response, 200, store.auditEvents().filter((event) => !event.accountId || event.accountId === device.accountId).slice(-200));
|
|
248
|
+
}
|
|
249
|
+
status = 404;
|
|
250
|
+
return json(response, 404, { error: { code: "not_found", message: `No such endpoint: ${method} ${url.pathname}` } });
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
status = 400;
|
|
254
|
+
return json(response, 400, { error: { code: "invalid_argument", message: error instanceof Error ? error.message : "bad request" } });
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
// Observability without content: method, path, status, timing, device id.
|
|
258
|
+
options.onLog?.({ method, path: url.pathname, status, ms: now() - started, device: deviceId ?? "-" });
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
return options.tls ? createHttpsServer({ key: options.tls.key, cert: options.tls.cert }, handler) : createHttpServer(handler);
|
|
262
|
+
}
|
|
263
|
+
export { ControlPlaneStore };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable state for the control plane.
|
|
3
|
+
*
|
|
4
|
+
* One JSON document is written atomically (temp file, then rename) on every
|
|
5
|
+
* mutation, which gives crash-safety without a database dependency. It is sized
|
|
6
|
+
* for a self-hosted deployment; the migration path to Postgres or SQLite is to
|
|
7
|
+
* replace this class, since nothing above it touches the file layout.
|
|
8
|
+
*/
|
|
9
|
+
export type Account = {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
enrollmentTokenHash: string;
|
|
14
|
+
quotaBytes: number;
|
|
15
|
+
};
|
|
16
|
+
export type Device = {
|
|
17
|
+
id: string;
|
|
18
|
+
accountId: string;
|
|
19
|
+
label: string;
|
|
20
|
+
publicKey: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
revokedAt?: string;
|
|
23
|
+
};
|
|
24
|
+
export type StorageConfig = {
|
|
25
|
+
id: string;
|
|
26
|
+
accountId: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
bucket: string;
|
|
29
|
+
region?: string;
|
|
30
|
+
prefix: string;
|
|
31
|
+
credentialMode: string;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
};
|
|
34
|
+
export type LeaseRecord = {
|
|
35
|
+
id: string;
|
|
36
|
+
accountId: string;
|
|
37
|
+
deviceId: string;
|
|
38
|
+
archiveId: string;
|
|
39
|
+
storageConfigId: string;
|
|
40
|
+
objectKey: string;
|
|
41
|
+
bytes: number;
|
|
42
|
+
ciphertextSha256: string;
|
|
43
|
+
issuedAt: string;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
usedAt?: string;
|
|
46
|
+
};
|
|
47
|
+
export type ArchiveRecord = {
|
|
48
|
+
accountId: string;
|
|
49
|
+
archiveId: string;
|
|
50
|
+
bytes: number;
|
|
51
|
+
ciphertextSha256: string;
|
|
52
|
+
objectKey?: string;
|
|
53
|
+
durableAt?: string;
|
|
54
|
+
manifest?: {
|
|
55
|
+
ciphertext: string;
|
|
56
|
+
updatedAt: string;
|
|
57
|
+
};
|
|
58
|
+
createdAt: string;
|
|
59
|
+
};
|
|
60
|
+
export type AuditEvent = {
|
|
61
|
+
id: string;
|
|
62
|
+
at: string;
|
|
63
|
+
accountId?: string;
|
|
64
|
+
deviceId?: string;
|
|
65
|
+
action: string;
|
|
66
|
+
outcome: "allowed" | "denied";
|
|
67
|
+
detail?: Record<string, string | number>;
|
|
68
|
+
};
|
|
69
|
+
export declare class ControlPlaneStore {
|
|
70
|
+
private readonly path?;
|
|
71
|
+
private document;
|
|
72
|
+
constructor(path?: string | undefined);
|
|
73
|
+
private load;
|
|
74
|
+
private persist;
|
|
75
|
+
accounts(): Account[];
|
|
76
|
+
devices(): Device[];
|
|
77
|
+
storageConfigs(): StorageConfig[];
|
|
78
|
+
leases(): LeaseRecord[];
|
|
79
|
+
archives(): ArchiveRecord[];
|
|
80
|
+
auditEvents(): AuditEvent[];
|
|
81
|
+
createAccount(account: Account): Account;
|
|
82
|
+
account(id: string): Account | undefined;
|
|
83
|
+
addDevice(device: Device): Device;
|
|
84
|
+
device(id: string): Device | undefined;
|
|
85
|
+
devicesFor(accountId: string): Device[];
|
|
86
|
+
revokeDevice(id: string, at: string): Device | undefined;
|
|
87
|
+
addStorageConfig(config: StorageConfig): StorageConfig;
|
|
88
|
+
storageConfig(id: string): StorageConfig | undefined;
|
|
89
|
+
addLease(lease: LeaseRecord): LeaseRecord;
|
|
90
|
+
lease(id: string): LeaseRecord | undefined;
|
|
91
|
+
/** Single use: a lease that has already completed an archive can never be replayed. */
|
|
92
|
+
consumeLease(id: string, at: string): LeaseRecord | undefined;
|
|
93
|
+
upsertArchive(record: ArchiveRecord): ArchiveRecord;
|
|
94
|
+
archive(accountId: string, archiveId: string): ArchiveRecord | undefined;
|
|
95
|
+
archivesFor(accountId: string): ArchiveRecord[];
|
|
96
|
+
/** Audit entries record counts, ids, and outcomes. They never carry archive content. */
|
|
97
|
+
audit(event: Omit<AuditEvent, "id" | "at"> & {
|
|
98
|
+
at?: string;
|
|
99
|
+
}): AuditEvent;
|
|
100
|
+
usedBytes(accountId: string): number;
|
|
101
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const EMPTY = { version: 1, accounts: [], devices: [], storageConfigs: [], leases: [], archives: [], audit: [] };
|
|
5
|
+
const AUDIT_LIMIT = 5000;
|
|
6
|
+
export class ControlPlaneStore {
|
|
7
|
+
path;
|
|
8
|
+
document;
|
|
9
|
+
constructor(path) {
|
|
10
|
+
this.path = path;
|
|
11
|
+
this.document = path ? this.load(path) : structuredClone(EMPTY);
|
|
12
|
+
}
|
|
13
|
+
load(path) {
|
|
14
|
+
try {
|
|
15
|
+
return { ...structuredClone(EMPTY), ...JSON.parse(readFileSync(path, "utf8")) };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return structuredClone(EMPTY);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
persist() {
|
|
22
|
+
if (!this.path)
|
|
23
|
+
return;
|
|
24
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
25
|
+
const temp = join(dirname(this.path), `.${randomUUID()}.tmp`);
|
|
26
|
+
writeFileSync(temp, JSON.stringify(this.document, null, 2) + "\n", { mode: 0o600 });
|
|
27
|
+
renameSync(temp, this.path);
|
|
28
|
+
}
|
|
29
|
+
accounts() { return this.document.accounts; }
|
|
30
|
+
devices() { return this.document.devices; }
|
|
31
|
+
storageConfigs() { return this.document.storageConfigs; }
|
|
32
|
+
leases() { return this.document.leases; }
|
|
33
|
+
archives() { return this.document.archives; }
|
|
34
|
+
auditEvents() { return this.document.audit; }
|
|
35
|
+
createAccount(account) { this.document.accounts.push(account); this.persist(); return account; }
|
|
36
|
+
account(id) { return this.document.accounts.find((item) => item.id === id); }
|
|
37
|
+
addDevice(device) { this.document.devices.push(device); this.persist(); return device; }
|
|
38
|
+
device(id) { return this.document.devices.find((item) => item.id === id); }
|
|
39
|
+
devicesFor(accountId) { return this.document.devices.filter((item) => item.accountId === accountId); }
|
|
40
|
+
revokeDevice(id, at) {
|
|
41
|
+
const device = this.device(id);
|
|
42
|
+
if (!device)
|
|
43
|
+
return undefined;
|
|
44
|
+
device.revokedAt = at;
|
|
45
|
+
this.persist();
|
|
46
|
+
return device;
|
|
47
|
+
}
|
|
48
|
+
addStorageConfig(config) { this.document.storageConfigs.push(config); this.persist(); return config; }
|
|
49
|
+
storageConfig(id) { return this.document.storageConfigs.find((item) => item.id === id); }
|
|
50
|
+
addLease(lease) { this.document.leases.push(lease); this.persist(); return lease; }
|
|
51
|
+
lease(id) { return this.document.leases.find((item) => item.id === id); }
|
|
52
|
+
/** Single use: a lease that has already completed an archive can never be replayed. */
|
|
53
|
+
consumeLease(id, at) {
|
|
54
|
+
const lease = this.lease(id);
|
|
55
|
+
if (!lease || lease.usedAt)
|
|
56
|
+
return undefined;
|
|
57
|
+
lease.usedAt = at;
|
|
58
|
+
this.persist();
|
|
59
|
+
return lease;
|
|
60
|
+
}
|
|
61
|
+
upsertArchive(record) {
|
|
62
|
+
const index = this.document.archives.findIndex((item) => item.archiveId === record.archiveId && item.accountId === record.accountId);
|
|
63
|
+
if (index >= 0)
|
|
64
|
+
this.document.archives[index] = { ...this.document.archives[index], ...record };
|
|
65
|
+
else
|
|
66
|
+
this.document.archives.push(record);
|
|
67
|
+
this.persist();
|
|
68
|
+
return this.archive(record.accountId, record.archiveId);
|
|
69
|
+
}
|
|
70
|
+
archive(accountId, archiveId) { return this.document.archives.find((item) => item.accountId === accountId && item.archiveId === archiveId); }
|
|
71
|
+
archivesFor(accountId) { return this.document.archives.filter((item) => item.accountId === accountId); }
|
|
72
|
+
/** Audit entries record counts, ids, and outcomes. They never carry archive content. */
|
|
73
|
+
audit(event) {
|
|
74
|
+
const entry = { id: randomUUID(), at: event.at ?? new Date().toISOString(), ...event };
|
|
75
|
+
this.document.audit.push(entry);
|
|
76
|
+
if (this.document.audit.length > AUDIT_LIMIT)
|
|
77
|
+
this.document.audit.splice(0, this.document.audit.length - AUDIT_LIMIT);
|
|
78
|
+
this.persist();
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
usedBytes(accountId) { return this.archivesFor(accountId).filter((item) => item.durableAt).reduce((total, item) => total + item.bytes, 0); }
|
|
82
|
+
}
|