@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.
@@ -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.7.0",
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.0.0",
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",