@secondlayer/shared 7.7.0 → 7.8.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/dist/src/archive/key-registry.d.ts +52 -0
- package/dist/src/archive/key-registry.js +168 -0
- package/dist/src/archive/key-registry.js.map +11 -0
- package/dist/src/archive/progress.d.ts +36 -0
- package/dist/src/archive/progress.js +75 -0
- package/dist/src/archive/progress.js.map +10 -0
- package/dist/src/archive/status.d.ts +92 -0
- package/dist/src/archive/status.js +106 -0
- package/dist/src/archive/status.js.map +10 -0
- package/package.json +14 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**\\n* Trust root for the canonical archive.\\n*\\n* Day-to-day signing uses an ONLINE key that lives on the publishing host, so\\n* it is exposed to every risk that host is. The archive's actual trust anchor\\n* is an OFFLINE ROOT key that never touches a server: it signs a registry of\\n* online keys, and nothing else. A consumer pins the root public key, verifies\\n* the registry against it, and only then decides whether a manifest's signature\\n* means anything.\\n*\\n* The distinction that makes rotation survivable:\\n*\\n* retired / rotated → the key stops signing NEW objects, but everything it\\n* signed while valid stays valid. Without this, every\\n* rotation would invalidate the entire published history.\\n* compromised → every signature by that key is suspect, including\\n* historical ones, because an attacker holding the key\\n* could have backdated anything.\\n*\\n* Those two cases look identical in a naive "is this key current?" check, and\\n* conflating them either destroys your history on every rotation or accepts\\n* forgeries after a leak.\\n*/
|
|
2
|
+
declare const KEY_REGISTRY_SCHEMA_VERSION = 1;
|
|
3
|
+
type KeyStatus = "active" | "retired" | "compromised";
|
|
4
|
+
type RegisteredKey = {
|
|
5
|
+
key_id: string
|
|
6
|
+
public_key_pem: string
|
|
7
|
+
status: KeyStatus
|
|
8
|
+
/** ISO timestamp this key became valid for signing. */
|
|
9
|
+
valid_from: string
|
|
10
|
+
/** ISO timestamp it stopped signing. Null while active. */
|
|
11
|
+
valid_until: string | null
|
|
12
|
+
/** For `compromised`: the moment trust is withdrawn. Everything this key
|
|
13
|
+
* signed is suspect regardless of when, so this is provenance, not a
|
|
14
|
+
* validity bound. */
|
|
15
|
+
compromised_at?: string
|
|
16
|
+
};
|
|
17
|
+
type KeyRegistry = {
|
|
18
|
+
schema_version: typeof KEY_REGISTRY_SCHEMA_VERSION
|
|
19
|
+
network: string
|
|
20
|
+
keys: RegisteredKey[]
|
|
21
|
+
updated_at: string
|
|
22
|
+
/** Root signature over the registry minus this envelope. */
|
|
23
|
+
signature?: string
|
|
24
|
+
root_key_id?: string
|
|
25
|
+
};
|
|
26
|
+
type KeyTrustResult = {
|
|
27
|
+
trusted: boolean
|
|
28
|
+
/** Machine-readable so callers can map to exit codes and messages. */
|
|
29
|
+
reason: "trusted" | "registry-unsigned" | "registry-signature-invalid" | "unknown-key" | "key-compromised" | "signed-outside-validity"
|
|
30
|
+
detail: string
|
|
31
|
+
};
|
|
32
|
+
/** The exact bytes the root signature covers: the registry minus its envelope. */
|
|
33
|
+
declare function canonicalRegistryPayload(registry: KeyRegistry): string;
|
|
34
|
+
/**
|
|
35
|
+
* Verify the registry itself against the pinned root public key. Everything
|
|
36
|
+
* downstream is meaningless if this fails — an attacker who can serve a
|
|
37
|
+
* registry can otherwise nominate their own signing key.
|
|
38
|
+
*/
|
|
39
|
+
declare function verifyRegistry(registry: KeyRegistry, rootPublicKeyPem: string): KeyTrustResult;
|
|
40
|
+
/**
|
|
41
|
+
* Decide whether an object signed by `keyId` at `signedAt` should be trusted.
|
|
42
|
+
*
|
|
43
|
+
* `signedAt` is the object's own generation time, NOT now — that is what lets a
|
|
44
|
+
* rotated key keep vouching for what it signed while it was valid.
|
|
45
|
+
*/
|
|
46
|
+
declare function checkKeyTrust(registry: KeyRegistry, keyId: string, signedAt: string): KeyTrustResult;
|
|
47
|
+
/** Look up a key's public PEM for signature verification. */
|
|
48
|
+
declare function publicKeyFor(registry: KeyRegistry, keyId: string): string | undefined;
|
|
49
|
+
/** Derive the registry entry id for a public key, so operators cannot mislabel
|
|
50
|
+
* a key by hand-editing the registry. */
|
|
51
|
+
declare function keyIdFor(publicKeyPem: string): string;
|
|
52
|
+
export { verifyRegistry, publicKeyFor, keyIdFor, checkKeyTrust, canonicalRegistryPayload, RegisteredKey, KeyTrustResult, KeyStatus, KeyRegistry, KEY_REGISTRY_SCHEMA_VERSION };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/crypto/ed25519.ts
|
|
18
|
+
var exports_ed25519 = {};
|
|
19
|
+
__export(exports_ed25519, {
|
|
20
|
+
verifyEd25519: () => verifyEd25519,
|
|
21
|
+
signEd25519: () => signEd25519,
|
|
22
|
+
publicKeyPemFromPrivate: () => publicKeyPemFromPrivate,
|
|
23
|
+
loadEd25519PublicKey: () => loadEd25519PublicKey,
|
|
24
|
+
loadEd25519PrivateKey: () => loadEd25519PrivateKey,
|
|
25
|
+
generateEd25519KeyPair: () => generateEd25519KeyPair,
|
|
26
|
+
ed25519KeyId: () => ed25519KeyId
|
|
27
|
+
});
|
|
28
|
+
import {
|
|
29
|
+
createHash,
|
|
30
|
+
createPrivateKey,
|
|
31
|
+
createPublicKey,
|
|
32
|
+
generateKeyPairSync,
|
|
33
|
+
sign as nodeSign,
|
|
34
|
+
verify as nodeVerify
|
|
35
|
+
} from "node:crypto";
|
|
36
|
+
function generateEd25519KeyPair() {
|
|
37
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
38
|
+
return {
|
|
39
|
+
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
|
40
|
+
publicKeyPem: publicKey.export({ format: "pem", type: "spki" }).toString()
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function loadEd25519PrivateKey(pem) {
|
|
44
|
+
return createPrivateKey(pem);
|
|
45
|
+
}
|
|
46
|
+
function loadEd25519PublicKey(pem) {
|
|
47
|
+
return createPublicKey(pem);
|
|
48
|
+
}
|
|
49
|
+
function publicKeyPemFromPrivate(privateKeyPem) {
|
|
50
|
+
return createPublicKey(createPrivateKey(privateKeyPem)).export({ format: "pem", type: "spki" }).toString();
|
|
51
|
+
}
|
|
52
|
+
function ed25519KeyId(publicKeyPem) {
|
|
53
|
+
const der = createPublicKey(publicKeyPem).export({
|
|
54
|
+
format: "der",
|
|
55
|
+
type: "spki"
|
|
56
|
+
});
|
|
57
|
+
return createHash("sha256").update(der).digest("base64url").slice(0, 16);
|
|
58
|
+
}
|
|
59
|
+
function signEd25519(payload, privateKey) {
|
|
60
|
+
return nodeSign(null, Buffer.from(payload, "utf8"), privateKey).toString("base64");
|
|
61
|
+
}
|
|
62
|
+
function verifyEd25519(payload, signatureBase64, publicKey) {
|
|
63
|
+
try {
|
|
64
|
+
return nodeVerify(null, Buffer.from(payload, "utf8"), publicKey, Buffer.from(signatureBase64, "base64"));
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/archive/key-registry.ts
|
|
71
|
+
var KEY_REGISTRY_SCHEMA_VERSION = 1;
|
|
72
|
+
function canonicalRegistryPayload(registry) {
|
|
73
|
+
const { signature: _s, root_key_id: _r, ...rest } = registry;
|
|
74
|
+
return JSON.stringify(rest);
|
|
75
|
+
}
|
|
76
|
+
function verifyRegistry(registry, rootPublicKeyPem) {
|
|
77
|
+
if (!registry.signature) {
|
|
78
|
+
return {
|
|
79
|
+
trusted: false,
|
|
80
|
+
reason: "registry-unsigned",
|
|
81
|
+
detail: "key registry carries no root signature"
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
let valid = false;
|
|
85
|
+
try {
|
|
86
|
+
valid = verifyEd25519(canonicalRegistryPayload(registry), registry.signature, loadEd25519PublicKey(rootPublicKeyPem));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
trusted: false,
|
|
90
|
+
reason: "registry-signature-invalid",
|
|
91
|
+
detail: err instanceof Error ? err.message : "root verification failed"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return valid ? {
|
|
95
|
+
trusted: true,
|
|
96
|
+
reason: "trusted",
|
|
97
|
+
detail: "registry verified against root"
|
|
98
|
+
} : {
|
|
99
|
+
trusted: false,
|
|
100
|
+
reason: "registry-signature-invalid",
|
|
101
|
+
detail: "registry signature does not match the pinned root key"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function checkKeyTrust(registry, keyId, signedAt) {
|
|
105
|
+
const key = registry.keys.find((k) => k.key_id === keyId);
|
|
106
|
+
if (!key) {
|
|
107
|
+
return {
|
|
108
|
+
trusted: false,
|
|
109
|
+
reason: "unknown-key",
|
|
110
|
+
detail: `key ${keyId} is not in the registry`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (key.status === "compromised") {
|
|
114
|
+
return {
|
|
115
|
+
trusted: false,
|
|
116
|
+
reason: "key-compromised",
|
|
117
|
+
detail: `key ${keyId} was compromised${key.compromised_at ? ` (${key.compromised_at})` : ""}; every signature it made is suspect`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const signedTime = Date.parse(signedAt);
|
|
121
|
+
const from = Date.parse(key.valid_from);
|
|
122
|
+
if (Number.isNaN(signedTime) || Number.isNaN(from)) {
|
|
123
|
+
return {
|
|
124
|
+
trusted: false,
|
|
125
|
+
reason: "signed-outside-validity",
|
|
126
|
+
detail: "unparseable timestamp on the object or the key"
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (signedTime < from) {
|
|
130
|
+
return {
|
|
131
|
+
trusted: false,
|
|
132
|
+
reason: "signed-outside-validity",
|
|
133
|
+
detail: `object predates key validity (${signedAt} < ${key.valid_from})`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (key.valid_until !== null) {
|
|
137
|
+
const until = Date.parse(key.valid_until);
|
|
138
|
+
if (!Number.isNaN(until) && signedTime > until) {
|
|
139
|
+
return {
|
|
140
|
+
trusted: false,
|
|
141
|
+
reason: "signed-outside-validity",
|
|
142
|
+
detail: `object postdates key retirement (${signedAt} > ${key.valid_until})`
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
trusted: true,
|
|
148
|
+
reason: "trusted",
|
|
149
|
+
detail: key.status === "active" ? `signed by active key ${keyId}` : `signed by ${keyId} while it was valid (now ${key.status})`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function publicKeyFor(registry, keyId) {
|
|
153
|
+
return registry.keys.find((k) => k.key_id === keyId)?.public_key_pem;
|
|
154
|
+
}
|
|
155
|
+
function keyIdFor(publicKeyPem) {
|
|
156
|
+
return ed25519KeyId(publicKeyPem);
|
|
157
|
+
}
|
|
158
|
+
export {
|
|
159
|
+
verifyRegistry,
|
|
160
|
+
publicKeyFor,
|
|
161
|
+
keyIdFor,
|
|
162
|
+
checkKeyTrust,
|
|
163
|
+
canonicalRegistryPayload,
|
|
164
|
+
KEY_REGISTRY_SCHEMA_VERSION
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
//# debugId=06FAC7E08C33616364756E2164756E21
|
|
168
|
+
//# sourceMappingURL=key-registry.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/crypto/ed25519.ts", "../src/archive/key-registry.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import {\n\ttype KeyObject,\n\tcreateHash,\n\tcreatePrivateKey,\n\tcreatePublicKey,\n\tgenerateKeyPairSync,\n\tsign as nodeSign,\n\tverify as nodeVerify,\n} from \"node:crypto\";\n\n/**\n * Asymmetric ed25519 signing for Streams response proofs.\n *\n * Asymmetric (not HMAC) so the proof is real: only the server holds the private\n * key, and any consumer verifies with the published public key — no shared\n * secret to leak. ed25519 uses node's `sign`/`verify` with a `null` algorithm.\n * Keys are PEM (PKCS8 private / SPKI public) for env transport; load once and\n * reuse the KeyObject on hot paths.\n */\n\nexport function generateEd25519KeyPair(): {\n\tprivateKeyPem: string;\n\tpublicKeyPem: string;\n} {\n\tconst { privateKey, publicKey } = generateKeyPairSync(\"ed25519\");\n\treturn {\n\t\tprivateKeyPem: privateKey\n\t\t\t.export({ format: \"pem\", type: \"pkcs8\" })\n\t\t\t.toString(),\n\t\tpublicKeyPem: publicKey.export({ format: \"pem\", type: \"spki\" }).toString(),\n\t};\n}\n\nexport function loadEd25519PrivateKey(pem: string): KeyObject {\n\treturn createPrivateKey(pem);\n}\n\nexport function loadEd25519PublicKey(pem: string): KeyObject {\n\treturn createPublicKey(pem);\n}\n\nexport function publicKeyPemFromPrivate(privateKeyPem: string): string {\n\treturn createPublicKey(createPrivateKey(privateKeyPem))\n\t\t.export({ format: \"pem\", type: \"spki\" })\n\t\t.toString();\n}\n\n/** Stable short id for a public key (rotation hint via X-Signature-KeyId). */\nexport function ed25519KeyId(publicKeyPem: string): string {\n\tconst der = createPublicKey(publicKeyPem).export({\n\t\tformat: \"der\",\n\t\ttype: \"spki\",\n\t});\n\treturn createHash(\"sha256\").update(der).digest(\"base64url\").slice(0, 16);\n}\n\nexport function signEd25519(payload: string, privateKey: KeyObject): string {\n\treturn nodeSign(null, Buffer.from(payload, \"utf8\"), privateKey).toString(\n\t\t\"base64\",\n\t);\n}\n\nexport function verifyEd25519(\n\tpayload: string,\n\tsignatureBase64: string,\n\tpublicKey: KeyObject,\n): boolean {\n\ttry {\n\t\treturn nodeVerify(\n\t\t\tnull,\n\t\t\tBuffer.from(payload, \"utf8\"),\n\t\t\tpublicKey,\n\t\t\tBuffer.from(signatureBase64, \"base64\"),\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n}\n",
|
|
6
|
+
"import {\n\ted25519KeyId,\n\tloadEd25519PublicKey,\n\tverifyEd25519,\n} from \"../crypto/ed25519.ts\";\n\n/**\n * Trust root for the canonical archive.\n *\n * Day-to-day signing uses an ONLINE key that lives on the publishing host, so\n * it is exposed to every risk that host is. The archive's actual trust anchor\n * is an OFFLINE ROOT key that never touches a server: it signs a registry of\n * online keys, and nothing else. A consumer pins the root public key, verifies\n * the registry against it, and only then decides whether a manifest's signature\n * means anything.\n *\n * The distinction that makes rotation survivable:\n *\n * retired / rotated → the key stops signing NEW objects, but everything it\n * signed while valid stays valid. Without this, every\n * rotation would invalidate the entire published history.\n * compromised → every signature by that key is suspect, including\n * historical ones, because an attacker holding the key\n * could have backdated anything.\n *\n * Those two cases look identical in a naive \"is this key current?\" check, and\n * conflating them either destroys your history on every rotation or accepts\n * forgeries after a leak.\n */\n\nexport const KEY_REGISTRY_SCHEMA_VERSION = 1;\n\nexport type KeyStatus = \"active\" | \"retired\" | \"compromised\";\n\nexport type RegisteredKey = {\n\tkey_id: string;\n\tpublic_key_pem: string;\n\tstatus: KeyStatus;\n\t/** ISO timestamp this key became valid for signing. */\n\tvalid_from: string;\n\t/** ISO timestamp it stopped signing. Null while active. */\n\tvalid_until: string | null;\n\t/** For `compromised`: the moment trust is withdrawn. Everything this key\n\t * signed is suspect regardless of when, so this is provenance, not a\n\t * validity bound. */\n\tcompromised_at?: string;\n};\n\nexport type KeyRegistry = {\n\tschema_version: typeof KEY_REGISTRY_SCHEMA_VERSION;\n\tnetwork: string;\n\tkeys: RegisteredKey[];\n\tupdated_at: string;\n\t/** Root signature over the registry minus this envelope. */\n\tsignature?: string;\n\troot_key_id?: string;\n};\n\nexport type KeyTrustResult = {\n\ttrusted: boolean;\n\t/** Machine-readable so callers can map to exit codes and messages. */\n\treason:\n\t\t| \"trusted\"\n\t\t| \"registry-unsigned\"\n\t\t| \"registry-signature-invalid\"\n\t\t| \"unknown-key\"\n\t\t| \"key-compromised\"\n\t\t| \"signed-outside-validity\";\n\tdetail: string;\n};\n\n/** The exact bytes the root signature covers: the registry minus its envelope. */\nexport function canonicalRegistryPayload(registry: KeyRegistry): string {\n\tconst { signature: _s, root_key_id: _r, ...rest } = registry;\n\treturn JSON.stringify(rest);\n}\n\n/**\n * Verify the registry itself against the pinned root public key. Everything\n * downstream is meaningless if this fails — an attacker who can serve a\n * registry can otherwise nominate their own signing key.\n */\nexport function verifyRegistry(\n\tregistry: KeyRegistry,\n\trootPublicKeyPem: string,\n): KeyTrustResult {\n\tif (!registry.signature) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"registry-unsigned\",\n\t\t\tdetail: \"key registry carries no root signature\",\n\t\t};\n\t}\n\tlet valid = false;\n\ttry {\n\t\tvalid = verifyEd25519(\n\t\t\tcanonicalRegistryPayload(registry),\n\t\t\tregistry.signature,\n\t\t\tloadEd25519PublicKey(rootPublicKeyPem),\n\t\t);\n\t} catch (err) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"registry-signature-invalid\",\n\t\t\tdetail: err instanceof Error ? err.message : \"root verification failed\",\n\t\t};\n\t}\n\treturn valid\n\t\t? {\n\t\t\t\ttrusted: true,\n\t\t\t\treason: \"trusted\",\n\t\t\t\tdetail: \"registry verified against root\",\n\t\t\t}\n\t\t: {\n\t\t\t\ttrusted: false,\n\t\t\t\treason: \"registry-signature-invalid\",\n\t\t\t\tdetail: \"registry signature does not match the pinned root key\",\n\t\t\t};\n}\n\n/**\n * Decide whether an object signed by `keyId` at `signedAt` should be trusted.\n *\n * `signedAt` is the object's own generation time, NOT now — that is what lets a\n * rotated key keep vouching for what it signed while it was valid.\n */\nexport function checkKeyTrust(\n\tregistry: KeyRegistry,\n\tkeyId: string,\n\tsignedAt: string,\n): KeyTrustResult {\n\tconst key = registry.keys.find((k) => k.key_id === keyId);\n\tif (!key) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"unknown-key\",\n\t\t\tdetail: `key ${keyId} is not in the registry`,\n\t\t};\n\t}\n\tif (key.status === \"compromised\") {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"key-compromised\",\n\t\t\tdetail: `key ${keyId} was compromised${key.compromised_at ? ` (${key.compromised_at})` : \"\"}; every signature it made is suspect`,\n\t\t};\n\t}\n\n\tconst signedTime = Date.parse(signedAt);\n\tconst from = Date.parse(key.valid_from);\n\tif (Number.isNaN(signedTime) || Number.isNaN(from)) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"signed-outside-validity\",\n\t\t\tdetail: \"unparseable timestamp on the object or the key\",\n\t\t};\n\t}\n\tif (signedTime < from) {\n\t\treturn {\n\t\t\ttrusted: false,\n\t\t\treason: \"signed-outside-validity\",\n\t\t\tdetail: `object predates key validity (${signedAt} < ${key.valid_from})`,\n\t\t};\n\t}\n\tif (key.valid_until !== null) {\n\t\tconst until = Date.parse(key.valid_until);\n\t\tif (!Number.isNaN(until) && signedTime > until) {\n\t\t\treturn {\n\t\t\t\ttrusted: false,\n\t\t\t\treason: \"signed-outside-validity\",\n\t\t\t\tdetail: `object postdates key retirement (${signedAt} > ${key.valid_until})`,\n\t\t\t};\n\t\t}\n\t}\n\n\t// A retired key that was valid at signing time is fine — that is the whole\n\t// point of recording a validity window rather than a boolean.\n\treturn {\n\t\ttrusted: true,\n\t\treason: \"trusted\",\n\t\tdetail:\n\t\t\tkey.status === \"active\"\n\t\t\t\t? `signed by active key ${keyId}`\n\t\t\t\t: `signed by ${keyId} while it was valid (now ${key.status})`,\n\t};\n}\n\n/** Look up a key's public PEM for signature verification. */\nexport function publicKeyFor(\n\tregistry: KeyRegistry,\n\tkeyId: string,\n): string | undefined {\n\treturn registry.keys.find((k) => k.key_id === keyId)?.public_key_pem;\n}\n\n/** Derive the registry entry id for a public key, so operators cannot mislabel\n * a key by hand-editing the registry. */\nexport function keyIdFor(publicKeyPem: string): string {\n\treturn ed25519KeyId(publicKeyPem);\n}\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "06FAC7E08C33616364756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress reporting for multi-hour archive jobs.
|
|
3
|
+
*
|
|
4
|
+
* A job that prints nothing until it finishes is indistinguishable from a hung
|
|
5
|
+
* one. That is not a cosmetic problem: on 2026-08-12 an ran silently for
|
|
6
|
+
* 2.5 hours and progress had to be inferred by listing partition filenames on
|
|
7
|
+
* disk, and a killed upload left a log whose single line could not distinguish
|
|
8
|
+
* "died immediately" from "died at 90%".
|
|
9
|
+
*
|
|
10
|
+
* Two rules make the output trustworthy:
|
|
11
|
+
*
|
|
12
|
+
* - **Time-based, not count-based.** Reporting every N items goes quiet for
|
|
13
|
+
* an hour when items are large and floods when they are small. Reporting
|
|
14
|
+
* every N seconds behaves the same for a sparse range and a dense one, so
|
|
15
|
+
* silence always means the same thing: something is wrong.
|
|
16
|
+
* - **stderr only.** Progress is chrome. Putting it on stdout would corrupt
|
|
17
|
+
* `--json` output for every caller that pipes it.
|
|
18
|
+
*/
|
|
19
|
+
type ProgressReporter = {
|
|
20
|
+
/** Record cumulative progress. Emits at most once per interval. */
|
|
21
|
+
tick: (completed: number, detail?: string) => void
|
|
22
|
+
/** Emit a final line regardless of interval. */
|
|
23
|
+
finish: (detail?: string) => void
|
|
24
|
+
};
|
|
25
|
+
type ProgressOptions = {
|
|
26
|
+
label: string
|
|
27
|
+
/** Total units, when known. Enables percentage and ETA. */
|
|
28
|
+
total?: number
|
|
29
|
+
intervalMs?: number
|
|
30
|
+
/** Defaults to stderr. Injectable so tests capture without spying. */
|
|
31
|
+
write?: (line: string) => void
|
|
32
|
+
now?: () => number
|
|
33
|
+
};
|
|
34
|
+
declare function formatDuration(seconds: number): string;
|
|
35
|
+
declare function createProgressReporter(options: ProgressOptions): ProgressReporter;
|
|
36
|
+
export { formatDuration, createProgressReporter, ProgressReporter, ProgressOptions };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/archive/progress.ts
|
|
18
|
+
var DEFAULT_INTERVAL_MS = 30000;
|
|
19
|
+
function formatDuration(seconds) {
|
|
20
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
21
|
+
return "unknown";
|
|
22
|
+
if (seconds < 90)
|
|
23
|
+
return `${Math.round(seconds)}s`;
|
|
24
|
+
if (seconds < 5400)
|
|
25
|
+
return `${Math.round(seconds / 60)}m`;
|
|
26
|
+
if (seconds < 172800)
|
|
27
|
+
return `${(seconds / 3600).toFixed(1)}h`;
|
|
28
|
+
return `${(seconds / 86400).toFixed(1)}d`;
|
|
29
|
+
}
|
|
30
|
+
function createProgressReporter(options) {
|
|
31
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
32
|
+
const now = options.now ?? (() => Date.now());
|
|
33
|
+
const write = options.write ?? ((line2) => process.stderr.write(`${line2}
|
|
34
|
+
`));
|
|
35
|
+
const startedAt = now();
|
|
36
|
+
let lastEmit = startedAt;
|
|
37
|
+
function line(completed, detail) {
|
|
38
|
+
const elapsed = (now() - startedAt) / 1000;
|
|
39
|
+
const parts = [`${options.label}:`];
|
|
40
|
+
if (options.total !== undefined && options.total > 0) {
|
|
41
|
+
const pct = Math.floor(completed / options.total * 100);
|
|
42
|
+
parts.push(`${completed}/${options.total} (${pct}%)`);
|
|
43
|
+
if (completed > 0 && elapsed > 0) {
|
|
44
|
+
const remaining = (options.total - completed) / completed * elapsed;
|
|
45
|
+
parts.push(`eta ${formatDuration(remaining)}`);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
parts.push(String(completed));
|
|
49
|
+
}
|
|
50
|
+
parts.push(`elapsed ${formatDuration(elapsed)}`);
|
|
51
|
+
if (detail)
|
|
52
|
+
parts.push(`· ${detail}`);
|
|
53
|
+
return ` ${parts.join(" ")}`;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
tick(completed, detail) {
|
|
57
|
+
const current = now();
|
|
58
|
+
if (current - lastEmit < intervalMs)
|
|
59
|
+
return;
|
|
60
|
+
lastEmit = current;
|
|
61
|
+
write(line(completed, detail));
|
|
62
|
+
},
|
|
63
|
+
finish(detail) {
|
|
64
|
+
lastEmit = now();
|
|
65
|
+
write(line(options.total ?? 0, detail));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
formatDuration,
|
|
71
|
+
createProgressReporter
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
//# debugId=C1E6E800BC29361764756E2164756E21
|
|
75
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/archive/progress.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * Progress reporting for multi-hour archive jobs.\n *\n * A job that prints nothing until it finishes is indistinguishable from a hung\n * one. That is not a cosmetic problem: on 2026-08-12 an export ran silently for\n * 2.5 hours and progress had to be inferred by listing partition filenames on\n * disk, and a killed upload left a log whose single line could not distinguish\n * \"died immediately\" from \"died at 90%\".\n *\n * Two rules make the output trustworthy:\n *\n * - **Time-based, not count-based.** Reporting every N items goes quiet for\n * an hour when items are large and floods when they are small. Reporting\n * every N seconds behaves the same for a sparse range and a dense one, so\n * silence always means the same thing: something is wrong.\n * - **stderr only.** Progress is chrome. Putting it on stdout would corrupt\n * `--json` output for every caller that pipes it.\n */\n\nexport type ProgressReporter = {\n\t/** Record cumulative progress. Emits at most once per interval. */\n\ttick: (completed: number, detail?: string) => void;\n\t/** Emit a final line regardless of interval. */\n\tfinish: (detail?: string) => void;\n};\n\nexport type ProgressOptions = {\n\tlabel: string;\n\t/** Total units, when known. Enables percentage and ETA. */\n\ttotal?: number;\n\tintervalMs?: number;\n\t/** Defaults to stderr. Injectable so tests capture without spying. */\n\twrite?: (line: string) => void;\n\tnow?: () => number;\n};\n\nconst DEFAULT_INTERVAL_MS = 30_000;\n\nexport function formatDuration(seconds: number): string {\n\tif (!Number.isFinite(seconds) || seconds < 0) return \"unknown\";\n\tif (seconds < 90) return `${Math.round(seconds)}s`;\n\tif (seconds < 5_400) return `${Math.round(seconds / 60)}m`;\n\tif (seconds < 172_800) return `${(seconds / 3_600).toFixed(1)}h`;\n\treturn `${(seconds / 86_400).toFixed(1)}d`;\n}\n\nexport function createProgressReporter(\n\toptions: ProgressOptions,\n): ProgressReporter {\n\tconst intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;\n\tconst now = options.now ?? (() => Date.now());\n\tconst write =\n\t\toptions.write ?? ((line: string) => process.stderr.write(`${line}\\n`));\n\n\tconst startedAt = now();\n\tlet lastEmit = startedAt;\n\n\tfunction line(completed: number, detail?: string): string {\n\t\tconst elapsed = (now() - startedAt) / 1000;\n\t\tconst parts = [`${options.label}:`];\n\n\t\tif (options.total !== undefined && options.total > 0) {\n\t\t\tconst pct = Math.floor((completed / options.total) * 100);\n\t\t\tparts.push(`${completed}/${options.total} (${pct}%)`);\n\t\t\t// ETA only once there is a rate to extrapolate from; a prediction made\n\t\t\t// from zero progress is noise dressed as information.\n\t\t\tif (completed > 0 && elapsed > 0) {\n\t\t\t\tconst remaining = ((options.total - completed) / completed) * elapsed;\n\t\t\t\tparts.push(`eta ${formatDuration(remaining)}`);\n\t\t\t}\n\t\t} else {\n\t\t\tparts.push(String(completed));\n\t\t}\n\n\t\tparts.push(`elapsed ${formatDuration(elapsed)}`);\n\t\tif (detail) parts.push(`· ${detail}`);\n\t\treturn ` ${parts.join(\" \")}`;\n\t}\n\n\treturn {\n\t\ttick(completed, detail) {\n\t\t\tconst current = now();\n\t\t\tif (current - lastEmit < intervalMs) return;\n\t\t\tlastEmit = current;\n\t\t\twrite(line(completed, detail));\n\t\t},\n\t\tfinish(detail) {\n\t\t\tlastEmit = now();\n\t\t\twrite(line(options.total ?? 0, detail));\n\t\t},\n\t};\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";;;;;;;;;;;;;;;;;AAoCA,IAAM,sBAAsB;AAErB,SAAS,cAAc,CAAC,SAAyB;AAAA,EACvD,IAAI,CAAC,OAAO,SAAS,OAAO,KAAK,UAAU;AAAA,IAAG,OAAO;AAAA,EACrD,IAAI,UAAU;AAAA,IAAI,OAAO,GAAG,KAAK,MAAM,OAAO;AAAA,EAC9C,IAAI,UAAU;AAAA,IAAO,OAAO,GAAG,KAAK,MAAM,UAAU,EAAE;AAAA,EACtD,IAAI,UAAU;AAAA,IAAS,OAAO,IAAI,UAAU,MAAO,QAAQ,CAAC;AAAA,EAC5D,OAAO,IAAI,UAAU,OAAQ,QAAQ,CAAC;AAAA;AAGhC,SAAS,sBAAsB,CACrC,SACmB;AAAA,EACnB,MAAM,aAAa,QAAQ,cAAc;AAAA,EACzC,MAAM,MAAM,QAAQ,QAAQ,MAAM,KAAK,IAAI;AAAA,EAC3C,MAAM,QACL,QAAQ,UAAU,CAAC,UAAiB,QAAQ,OAAO,MAAM,GAAG;AAAA,CAAQ;AAAA,EAErE,MAAM,YAAY,IAAI;AAAA,EACtB,IAAI,WAAW;AAAA,EAEf,SAAS,IAAI,CAAC,WAAmB,QAAyB;AAAA,IACzD,MAAM,WAAW,IAAI,IAAI,aAAa;AAAA,IACtC,MAAM,QAAQ,CAAC,GAAG,QAAQ,QAAQ;AAAA,IAElC,IAAI,QAAQ,UAAU,aAAa,QAAQ,QAAQ,GAAG;AAAA,MACrD,MAAM,MAAM,KAAK,MAAO,YAAY,QAAQ,QAAS,GAAG;AAAA,MACxD,MAAM,KAAK,GAAG,aAAa,QAAQ,UAAU,OAAO;AAAA,MAGpD,IAAI,YAAY,KAAK,UAAU,GAAG;AAAA,QACjC,MAAM,aAAc,QAAQ,QAAQ,aAAa,YAAa;AAAA,QAC9D,MAAM,KAAK,OAAO,eAAe,SAAS,GAAG;AAAA,MAC9C;AAAA,IACD,EAAO;AAAA,MACN,MAAM,KAAK,OAAO,SAAS,CAAC;AAAA;AAAA,IAG7B,MAAM,KAAK,WAAW,eAAe,OAAO,GAAG;AAAA,IAC/C,IAAI;AAAA,MAAQ,MAAM,KAAK,KAAI,QAAQ;AAAA,IACnC,OAAO,KAAK,MAAM,KAAK,GAAG;AAAA;AAAA,EAG3B,OAAO;AAAA,IACN,IAAI,CAAC,WAAW,QAAQ;AAAA,MACvB,MAAM,UAAU,IAAI;AAAA,MACpB,IAAI,UAAU,WAAW;AAAA,QAAY;AAAA,MACrC,WAAW;AAAA,MACX,MAAM,KAAK,WAAW,MAAM,CAAC;AAAA;AAAA,IAE9B,MAAM,CAAC,QAAQ;AAAA,MACd,WAAW,IAAI;AAAA,MACf,MAAM,KAAK,QAAQ,SAAS,GAAG,MAAM,CAAC;AAAA;AAAA,EAExC;AAAA;",
|
|
8
|
+
"debugId": "C1E6E800BC29361764756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `status.json` — the archive's operational truth, published short-cache from
|
|
3
|
+
* verified state only.
|
|
4
|
+
*
|
|
5
|
+
* Everything else in the tree is immutable and signed, which makes it durable
|
|
6
|
+
* but silent: a consumer holding a valid manifest cannot tell whether it is
|
|
7
|
+
* current, three days behind, or the last thing published before an incident.
|
|
8
|
+
* This object answers that, and it is the only object allowed to say something
|
|
9
|
+
* unflattering.
|
|
10
|
+
*
|
|
11
|
+
* The distinction this file exists to protect:
|
|
12
|
+
*
|
|
13
|
+
* lagging → the archive trails the chain tip because it only publishes below
|
|
14
|
+
* the finality boundary. This is CORRECT and permanent. An archive
|
|
15
|
+
* that reported `stale` for its own finality lag would be crying
|
|
16
|
+
* wolf forever, and consumers would learn to ignore it.
|
|
17
|
+
* stale → publishing stopped. The lag exceeds what the finality rule can
|
|
18
|
+
* explain, so something is broken.
|
|
19
|
+
*
|
|
20
|
+
* `state` is derived from measurements, never asserted by the publisher, and
|
|
21
|
+
* every unhealthy state is reachable — an archive that can only report health
|
|
22
|
+
* is decoration.
|
|
23
|
+
*/
|
|
24
|
+
declare const ARCHIVE_STATUS_SCHEMA_VERSION = 1;
|
|
25
|
+
type ArchiveState = "fresh" | "lagging" | "stale" | "gap" | "failed-audit" | "source-unavailable";
|
|
26
|
+
type ArchiveStatus = {
|
|
27
|
+
schema_version: typeof ARCHIVE_STATUS_SCHEMA_VERSION
|
|
28
|
+
network: string
|
|
29
|
+
state: ArchiveState
|
|
30
|
+
/** Human-facing one-liner explaining the state, including WHY. */
|
|
31
|
+
detail: string
|
|
32
|
+
generated_at: string
|
|
33
|
+
archive: {
|
|
34
|
+
snapshot_digest: string | null
|
|
35
|
+
coverage_to_block: number | null
|
|
36
|
+
promoted_at: string | null
|
|
37
|
+
signing_key_id: string | null
|
|
38
|
+
}
|
|
39
|
+
source: {
|
|
40
|
+
/** Chain tip as the publisher last observed it. Null when unreachable. */
|
|
41
|
+
tip_height: number | null
|
|
42
|
+
/** Highest height eligible to publish under the finality rule. */
|
|
43
|
+
finalized_height: number | null
|
|
44
|
+
}
|
|
45
|
+
lag: {
|
|
46
|
+
/** Behind the FINALIZED height — the number that indicates a problem. */
|
|
47
|
+
blocks_behind_finalized: number | null
|
|
48
|
+
/** Behind the raw chain tip — expected to be non-zero forever. */
|
|
49
|
+
blocks_behind_tip: number | null
|
|
50
|
+
seconds_since_promotion: number | null
|
|
51
|
+
}
|
|
52
|
+
audit: {
|
|
53
|
+
complete: boolean
|
|
54
|
+
checked_at: string
|
|
55
|
+
} | null
|
|
56
|
+
signature?: string
|
|
57
|
+
key_id?: string
|
|
58
|
+
};
|
|
59
|
+
type StatusInputs = {
|
|
60
|
+
network: string
|
|
61
|
+
snapshotDigest: string | null
|
|
62
|
+
coverageToBlock: number | null
|
|
63
|
+
promotedAt: string | null
|
|
64
|
+
signingKeyId: string | null
|
|
65
|
+
/** Null when the source could not be reached. */
|
|
66
|
+
sourceTipHeight: number | null
|
|
67
|
+
finalizedHeight: number | null
|
|
68
|
+
audit: {
|
|
69
|
+
complete: boolean
|
|
70
|
+
checkedAt: string
|
|
71
|
+
} | null
|
|
72
|
+
now: Date
|
|
73
|
+
/**
|
|
74
|
+
* How far behind the FINALIZED height the archive may fall before it is
|
|
75
|
+
* stale rather than merely lagging. Publishing one partition covers 50k
|
|
76
|
+
* blocks, so a healthy archive sits well inside this.
|
|
77
|
+
*/
|
|
78
|
+
maxBlocksBehindFinalized?: number
|
|
79
|
+
/** How long since the last promotion before staleness, regardless of height. */
|
|
80
|
+
maxSecondsSincePromotion?: number
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Derive status from measurements. Pure, so every state below is reachable in
|
|
84
|
+
* a test rather than only in production at 3am.
|
|
85
|
+
*
|
|
86
|
+
* Order matters: a failing audit outranks freshness, because an archive that
|
|
87
|
+
* is current and wrong is worse than one that is behind and correct.
|
|
88
|
+
*/
|
|
89
|
+
declare function deriveArchiveStatus(inputs: StatusInputs): ArchiveStatus;
|
|
90
|
+
/** States a consumer should refuse to restore from. */
|
|
91
|
+
declare function isRestorable(state: ArchiveState): boolean;
|
|
92
|
+
export { isRestorable, deriveArchiveStatus, StatusInputs, ArchiveStatus, ArchiveState, ARCHIVE_STATUS_SCHEMA_VERSION };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/archive/status.ts
|
|
18
|
+
var ARCHIVE_STATUS_SCHEMA_VERSION = 1;
|
|
19
|
+
var DEFAULT_MAX_BLOCKS_BEHIND_FINALIZED = 60000;
|
|
20
|
+
var DEFAULT_MAX_SECONDS_SINCE_PROMOTION = 48 * 3600;
|
|
21
|
+
function deriveArchiveStatus(inputs) {
|
|
22
|
+
const maxBehind = inputs.maxBlocksBehindFinalized ?? DEFAULT_MAX_BLOCKS_BEHIND_FINALIZED;
|
|
23
|
+
const maxAge = inputs.maxSecondsSincePromotion ?? DEFAULT_MAX_SECONDS_SINCE_PROMOTION;
|
|
24
|
+
const secondsSincePromotion = inputs.promotedAt ? Math.max(0, Math.round((inputs.now.getTime() - Date.parse(inputs.promotedAt)) / 1000)) : null;
|
|
25
|
+
const behindFinalized = inputs.finalizedHeight !== null && inputs.coverageToBlock !== null ? Math.max(0, inputs.finalizedHeight - inputs.coverageToBlock) : null;
|
|
26
|
+
const behindTip = inputs.sourceTipHeight !== null && inputs.coverageToBlock !== null ? Math.max(0, inputs.sourceTipHeight - inputs.coverageToBlock) : null;
|
|
27
|
+
const base = {
|
|
28
|
+
schema_version: ARCHIVE_STATUS_SCHEMA_VERSION,
|
|
29
|
+
network: inputs.network,
|
|
30
|
+
generated_at: inputs.now.toISOString(),
|
|
31
|
+
archive: {
|
|
32
|
+
snapshot_digest: inputs.snapshotDigest,
|
|
33
|
+
coverage_to_block: inputs.coverageToBlock,
|
|
34
|
+
promoted_at: inputs.promotedAt,
|
|
35
|
+
signing_key_id: inputs.signingKeyId
|
|
36
|
+
},
|
|
37
|
+
source: {
|
|
38
|
+
tip_height: inputs.sourceTipHeight,
|
|
39
|
+
finalized_height: inputs.finalizedHeight
|
|
40
|
+
},
|
|
41
|
+
lag: {
|
|
42
|
+
blocks_behind_finalized: behindFinalized,
|
|
43
|
+
blocks_behind_tip: behindTip,
|
|
44
|
+
seconds_since_promotion: secondsSincePromotion
|
|
45
|
+
},
|
|
46
|
+
audit: inputs.audit ? { complete: inputs.audit.complete, checked_at: inputs.audit.checkedAt } : null
|
|
47
|
+
};
|
|
48
|
+
if (inputs.audit && !inputs.audit.complete) {
|
|
49
|
+
return {
|
|
50
|
+
...base,
|
|
51
|
+
state: "failed-audit",
|
|
52
|
+
detail: `the canonical audit did not pass at ${inputs.audit.checkedAt}; this archive is not safe to restore from`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!inputs.snapshotDigest || inputs.coverageToBlock === null) {
|
|
56
|
+
return {
|
|
57
|
+
...base,
|
|
58
|
+
state: "gap",
|
|
59
|
+
detail: "no snapshot is currently promoted"
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (inputs.sourceTipHeight === null || inputs.finalizedHeight === null) {
|
|
63
|
+
return {
|
|
64
|
+
...base,
|
|
65
|
+
state: "source-unavailable",
|
|
66
|
+
detail: "the chain source could not be reached, so freshness cannot be determined"
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (secondsSincePromotion !== null && secondsSincePromotion > maxAge) {
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
state: "stale",
|
|
73
|
+
detail: `last promotion was ${Math.round(secondsSincePromotion / 3600)}h ago, beyond the ${Math.round(maxAge / 3600)}h freshness objective`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (behindFinalized !== null && behindFinalized > maxBehind) {
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
state: "stale",
|
|
80
|
+
detail: `${behindFinalized} blocks behind the finalized height, beyond the ${maxBehind} threshold`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (behindTip !== null && behindTip > 0) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
state: "lagging",
|
|
87
|
+
detail: `${behindTip} blocks behind the chain tip, which is expected: only heights below the finality boundary are published`
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
state: "fresh",
|
|
93
|
+
detail: "published through the finalized height"
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function isRestorable(state) {
|
|
97
|
+
return state === "fresh" || state === "lagging";
|
|
98
|
+
}
|
|
99
|
+
export {
|
|
100
|
+
isRestorable,
|
|
101
|
+
deriveArchiveStatus,
|
|
102
|
+
ARCHIVE_STATUS_SCHEMA_VERSION
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
//# debugId=D9102A12541A66EA64756E2164756E21
|
|
106
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/archive/status.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * `status.json` — the archive's operational truth, published short-cache from\n * verified state only.\n *\n * Everything else in the tree is immutable and signed, which makes it durable\n * but silent: a consumer holding a valid manifest cannot tell whether it is\n * current, three days behind, or the last thing published before an incident.\n * This object answers that, and it is the only object allowed to say something\n * unflattering.\n *\n * The distinction this file exists to protect:\n *\n * lagging → the archive trails the chain tip because it only publishes below\n * the finality boundary. This is CORRECT and permanent. An archive\n * that reported `stale` for its own finality lag would be crying\n * wolf forever, and consumers would learn to ignore it.\n * stale → publishing stopped. The lag exceeds what the finality rule can\n * explain, so something is broken.\n *\n * `state` is derived from measurements, never asserted by the publisher, and\n * every unhealthy state is reachable — an archive that can only report health\n * is decoration.\n */\n\nexport const ARCHIVE_STATUS_SCHEMA_VERSION = 1;\n\nexport type ArchiveState =\n\t| \"fresh\"\n\t| \"lagging\"\n\t| \"stale\"\n\t| \"gap\"\n\t| \"failed-audit\"\n\t| \"source-unavailable\";\n\nexport type ArchiveStatus = {\n\tschema_version: typeof ARCHIVE_STATUS_SCHEMA_VERSION;\n\tnetwork: string;\n\tstate: ArchiveState;\n\t/** Human-facing one-liner explaining the state, including WHY. */\n\tdetail: string;\n\tgenerated_at: string;\n\tarchive: {\n\t\tsnapshot_digest: string | null;\n\t\tcoverage_to_block: number | null;\n\t\tpromoted_at: string | null;\n\t\tsigning_key_id: string | null;\n\t};\n\tsource: {\n\t\t/** Chain tip as the publisher last observed it. Null when unreachable. */\n\t\ttip_height: number | null;\n\t\t/** Highest height eligible to publish under the finality rule. */\n\t\tfinalized_height: number | null;\n\t};\n\tlag: {\n\t\t/** Behind the FINALIZED height — the number that indicates a problem. */\n\t\tblocks_behind_finalized: number | null;\n\t\t/** Behind the raw chain tip — expected to be non-zero forever. */\n\t\tblocks_behind_tip: number | null;\n\t\tseconds_since_promotion: number | null;\n\t};\n\taudit: {\n\t\tcomplete: boolean;\n\t\tchecked_at: string;\n\t} | null;\n\tsignature?: string;\n\tkey_id?: string;\n};\n\nexport type StatusInputs = {\n\tnetwork: string;\n\tsnapshotDigest: string | null;\n\tcoverageToBlock: number | null;\n\tpromotedAt: string | null;\n\tsigningKeyId: string | null;\n\t/** Null when the source could not be reached. */\n\tsourceTipHeight: number | null;\n\tfinalizedHeight: number | null;\n\taudit: { complete: boolean; checkedAt: string } | null;\n\tnow: Date;\n\t/**\n\t * How far behind the FINALIZED height the archive may fall before it is\n\t * stale rather than merely lagging. Publishing one partition covers 50k\n\t * blocks, so a healthy archive sits well inside this.\n\t */\n\tmaxBlocksBehindFinalized?: number;\n\t/** How long since the last promotion before staleness, regardless of height. */\n\tmaxSecondsSincePromotion?: number;\n};\n\nconst DEFAULT_MAX_BLOCKS_BEHIND_FINALIZED = 60_000;\nconst DEFAULT_MAX_SECONDS_SINCE_PROMOTION = 48 * 3_600;\n\n/**\n * Derive status from measurements. Pure, so every state below is reachable in\n * a test rather than only in production at 3am.\n *\n * Order matters: a failing audit outranks freshness, because an archive that\n * is current and wrong is worse than one that is behind and correct.\n */\nexport function deriveArchiveStatus(inputs: StatusInputs): ArchiveStatus {\n\tconst maxBehind =\n\t\tinputs.maxBlocksBehindFinalized ?? DEFAULT_MAX_BLOCKS_BEHIND_FINALIZED;\n\tconst maxAge =\n\t\tinputs.maxSecondsSincePromotion ?? DEFAULT_MAX_SECONDS_SINCE_PROMOTION;\n\n\tconst secondsSincePromotion = inputs.promotedAt\n\t\t? Math.max(\n\t\t\t\t0,\n\t\t\t\tMath.round(\n\t\t\t\t\t(inputs.now.getTime() - Date.parse(inputs.promotedAt)) / 1000,\n\t\t\t\t),\n\t\t\t)\n\t\t: null;\n\n\tconst behindFinalized =\n\t\tinputs.finalizedHeight !== null && inputs.coverageToBlock !== null\n\t\t\t? Math.max(0, inputs.finalizedHeight - inputs.coverageToBlock)\n\t\t\t: null;\n\tconst behindTip =\n\t\tinputs.sourceTipHeight !== null && inputs.coverageToBlock !== null\n\t\t\t? Math.max(0, inputs.sourceTipHeight - inputs.coverageToBlock)\n\t\t\t: null;\n\n\tconst base = {\n\t\tschema_version:\n\t\t\tARCHIVE_STATUS_SCHEMA_VERSION as typeof ARCHIVE_STATUS_SCHEMA_VERSION,\n\t\tnetwork: inputs.network,\n\t\tgenerated_at: inputs.now.toISOString(),\n\t\tarchive: {\n\t\t\tsnapshot_digest: inputs.snapshotDigest,\n\t\t\tcoverage_to_block: inputs.coverageToBlock,\n\t\t\tpromoted_at: inputs.promotedAt,\n\t\t\tsigning_key_id: inputs.signingKeyId,\n\t\t},\n\t\tsource: {\n\t\t\ttip_height: inputs.sourceTipHeight,\n\t\t\tfinalized_height: inputs.finalizedHeight,\n\t\t},\n\t\tlag: {\n\t\t\tblocks_behind_finalized: behindFinalized,\n\t\t\tblocks_behind_tip: behindTip,\n\t\t\tseconds_since_promotion: secondsSincePromotion,\n\t\t},\n\t\taudit: inputs.audit\n\t\t\t? { complete: inputs.audit.complete, checked_at: inputs.audit.checkedAt }\n\t\t\t: null,\n\t};\n\n\t// A failed audit is the loudest signal: current-and-wrong beats behind-and-right.\n\tif (inputs.audit && !inputs.audit.complete) {\n\t\treturn {\n\t\t\t...base,\n\t\t\tstate: \"failed-audit\",\n\t\t\tdetail: `the canonical audit did not pass at ${inputs.audit.checkedAt}; this archive is not safe to restore from`,\n\t\t};\n\t}\n\n\tif (!inputs.snapshotDigest || inputs.coverageToBlock === null) {\n\t\treturn {\n\t\t\t...base,\n\t\t\tstate: \"gap\",\n\t\t\tdetail: \"no snapshot is currently promoted\",\n\t\t};\n\t}\n\n\t// Source unreachable: report the gap in KNOWLEDGE rather than guessing. The\n\t// archive may be perfectly healthy; we cannot currently say so.\n\tif (inputs.sourceTipHeight === null || inputs.finalizedHeight === null) {\n\t\treturn {\n\t\t\t...base,\n\t\t\tstate: \"source-unavailable\",\n\t\t\tdetail:\n\t\t\t\t\"the chain source could not be reached, so freshness cannot be determined\",\n\t\t};\n\t}\n\n\tif (secondsSincePromotion !== null && secondsSincePromotion > maxAge) {\n\t\treturn {\n\t\t\t...base,\n\t\t\tstate: \"stale\",\n\t\t\tdetail: `last promotion was ${Math.round(secondsSincePromotion / 3_600)}h ago, beyond the ${Math.round(maxAge / 3_600)}h freshness objective`,\n\t\t};\n\t}\n\n\tif (behindFinalized !== null && behindFinalized > maxBehind) {\n\t\treturn {\n\t\t\t...base,\n\t\t\tstate: \"stale\",\n\t\t\tdetail: `${behindFinalized} blocks behind the finalized height, beyond the ${maxBehind} threshold`,\n\t\t};\n\t}\n\n\t// Behind the tip but within the finality boundary — correct, permanent, and\n\t// explicitly NOT staleness.\n\tif (behindTip !== null && behindTip > 0) {\n\t\treturn {\n\t\t\t...base,\n\t\t\tstate: \"lagging\",\n\t\t\tdetail: `${behindTip} blocks behind the chain tip, which is expected: only heights below the finality boundary are published`,\n\t\t};\n\t}\n\n\treturn {\n\t\t...base,\n\t\tstate: \"fresh\",\n\t\tdetail: \"published through the finalized height\",\n\t};\n}\n\n/** States a consumer should refuse to restore from. */\nexport function isRestorable(state: ArchiveState): boolean {\n\treturn state === \"fresh\" || state === \"lagging\";\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";;;;;;;;;;;;;;;;;AAwBO,IAAM,gCAAgC;AAiE7C,IAAM,sCAAsC;AAC5C,IAAM,sCAAsC,KAAK;AAS1C,SAAS,mBAAmB,CAAC,QAAqC;AAAA,EACxE,MAAM,YACL,OAAO,4BAA4B;AAAA,EACpC,MAAM,SACL,OAAO,4BAA4B;AAAA,EAEpC,MAAM,wBAAwB,OAAO,aAClC,KAAK,IACL,GACA,KAAK,OACH,OAAO,IAAI,QAAQ,IAAI,KAAK,MAAM,OAAO,UAAU,KAAK,IAC1D,CACD,IACC;AAAA,EAEH,MAAM,kBACL,OAAO,oBAAoB,QAAQ,OAAO,oBAAoB,OAC3D,KAAK,IAAI,GAAG,OAAO,kBAAkB,OAAO,eAAe,IAC3D;AAAA,EACJ,MAAM,YACL,OAAO,oBAAoB,QAAQ,OAAO,oBAAoB,OAC3D,KAAK,IAAI,GAAG,OAAO,kBAAkB,OAAO,eAAe,IAC3D;AAAA,EAEJ,MAAM,OAAO;AAAA,IACZ,gBACC;AAAA,IACD,SAAS,OAAO;AAAA,IAChB,cAAc,OAAO,IAAI,YAAY;AAAA,IACrC,SAAS;AAAA,MACR,iBAAiB,OAAO;AAAA,MACxB,mBAAmB,OAAO;AAAA,MAC1B,aAAa,OAAO;AAAA,MACpB,gBAAgB,OAAO;AAAA,IACxB;AAAA,IACA,QAAQ;AAAA,MACP,YAAY,OAAO;AAAA,MACnB,kBAAkB,OAAO;AAAA,IAC1B;AAAA,IACA,KAAK;AAAA,MACJ,yBAAyB;AAAA,MACzB,mBAAmB;AAAA,MACnB,yBAAyB;AAAA,IAC1B;AAAA,IACA,OAAO,OAAO,QACX,EAAE,UAAU,OAAO,MAAM,UAAU,YAAY,OAAO,MAAM,UAAU,IACtE;AAAA,EACJ;AAAA,EAGA,IAAI,OAAO,SAAS,CAAC,OAAO,MAAM,UAAU;AAAA,IAC3C,OAAO;AAAA,SACH;AAAA,MACH,OAAO;AAAA,MACP,QAAQ,uCAAuC,OAAO,MAAM;AAAA,IAC7D;AAAA,EACD;AAAA,EAEA,IAAI,CAAC,OAAO,kBAAkB,OAAO,oBAAoB,MAAM;AAAA,IAC9D,OAAO;AAAA,SACH;AAAA,MACH,OAAO;AAAA,MACP,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EAIA,IAAI,OAAO,oBAAoB,QAAQ,OAAO,oBAAoB,MAAM;AAAA,IACvE,OAAO;AAAA,SACH;AAAA,MACH,OAAO;AAAA,MACP,QACC;AAAA,IACF;AAAA,EACD;AAAA,EAEA,IAAI,0BAA0B,QAAQ,wBAAwB,QAAQ;AAAA,IACrE,OAAO;AAAA,SACH;AAAA,MACH,OAAO;AAAA,MACP,QAAQ,sBAAsB,KAAK,MAAM,wBAAwB,IAAK,sBAAsB,KAAK,MAAM,SAAS,IAAK;AAAA,IACtH;AAAA,EACD;AAAA,EAEA,IAAI,oBAAoB,QAAQ,kBAAkB,WAAW;AAAA,IAC5D,OAAO;AAAA,SACH;AAAA,MACH,OAAO;AAAA,MACP,QAAQ,GAAG,kEAAkE;AAAA,IAC9E;AAAA,EACD;AAAA,EAIA,IAAI,cAAc,QAAQ,YAAY,GAAG;AAAA,IACxC,OAAO;AAAA,SACH;AAAA,MACH,OAAO;AAAA,MACP,QAAQ,GAAG;AAAA,IACZ;AAAA,EACD;AAAA,EAEA,OAAO;AAAA,OACH;AAAA,IACH,OAAO;AAAA,IACP,QAAQ;AAAA,EACT;AAAA;AAIM,SAAS,YAAY,CAAC,OAA8B;AAAA,EAC1D,OAAO,UAAU,WAAW,UAAU;AAAA;",
|
|
8
|
+
"debugId": "D9102A12541A66EA64756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secondlayer/shared",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.8.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -108,6 +108,18 @@
|
|
|
108
108
|
"types": "./dist/src/streams-bulk-manifest.d.ts",
|
|
109
109
|
"import": "./dist/src/streams-bulk-manifest.js"
|
|
110
110
|
},
|
|
111
|
+
"./archive/progress": {
|
|
112
|
+
"types": "./dist/src/archive/progress.d.ts",
|
|
113
|
+
"import": "./dist/src/archive/progress.js"
|
|
114
|
+
},
|
|
115
|
+
"./archive/status": {
|
|
116
|
+
"types": "./dist/src/archive/status.d.ts",
|
|
117
|
+
"import": "./dist/src/archive/status.js"
|
|
118
|
+
},
|
|
119
|
+
"./archive/key-registry": {
|
|
120
|
+
"types": "./dist/src/archive/key-registry.d.ts",
|
|
121
|
+
"import": "./dist/src/archive/key-registry.js"
|
|
122
|
+
},
|
|
111
123
|
"./archive/copy-loader": {
|
|
112
124
|
"types": "./dist/src/archive/copy-loader.d.ts",
|
|
113
125
|
"import": "./dist/src/archive/copy-loader.js"
|
|
@@ -172,7 +184,7 @@
|
|
|
172
184
|
"prepublishOnly": "bun run build"
|
|
173
185
|
},
|
|
174
186
|
"dependencies": {
|
|
175
|
-
"@secondlayer/stacks": "^3.
|
|
187
|
+
"@secondlayer/stacks": "^3.3.1",
|
|
176
188
|
"kysely": "0.28.17",
|
|
177
189
|
"kysely-postgres-js": "3.0.0",
|
|
178
190
|
"postgres": "^3.4.6",
|