pinnace 0.0.0 → 0.2.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.
Files changed (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +164 -0
  3. package/dist/car/car-build.d.ts +26 -0
  4. package/dist/car/car-build.d.ts.map +1 -0
  5. package/dist/car/car-build.js +113 -0
  6. package/dist/car/car-build.js.map +1 -0
  7. package/dist/ci/ci-emit.d.ts +116 -0
  8. package/dist/ci/ci-emit.d.ts.map +1 -0
  9. package/dist/ci/ci-emit.js +190 -0
  10. package/dist/ci/ci-emit.js.map +1 -0
  11. package/dist/cli/bin.d.ts +3 -0
  12. package/dist/cli/bin.d.ts.map +1 -0
  13. package/dist/cli/bin.js +17 -0
  14. package/dist/cli/bin.js.map +1 -0
  15. package/dist/cli/run.d.ts +94 -0
  16. package/dist/cli/run.d.ts.map +1 -0
  17. package/dist/cli/run.js +731 -0
  18. package/dist/cli/run.js.map +1 -0
  19. package/dist/config/config-resolution.d.ts +186 -0
  20. package/dist/config/config-resolution.d.ts.map +1 -0
  21. package/dist/config/config-resolution.js +137 -0
  22. package/dist/config/config-resolution.js.map +1 -0
  23. package/dist/deploy/deploy.d.ts +121 -0
  24. package/dist/deploy/deploy.d.ts.map +1 -0
  25. package/dist/deploy/deploy.js +150 -0
  26. package/dist/deploy/deploy.js.map +1 -0
  27. package/dist/derive/ipns-key-derivation.d.ts +37 -0
  28. package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
  29. package/dist/derive/ipns-key-derivation.js +130 -0
  30. package/dist/derive/ipns-key-derivation.js.map +1 -0
  31. package/dist/index.d.ts +27 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +29 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/node/node-commands.d.ts +115 -0
  36. package/dist/node/node-commands.d.ts.map +1 -0
  37. package/dist/node/node-commands.js +231 -0
  38. package/dist/node/node-commands.js.map +1 -0
  39. package/dist/provision/cloud-init.d.ts +162 -0
  40. package/dist/provision/cloud-init.d.ts.map +1 -0
  41. package/dist/provision/cloud-init.js +460 -0
  42. package/dist/provision/cloud-init.js.map +1 -0
  43. package/dist/publisher/key-import.d.ts +105 -0
  44. package/dist/publisher/key-import.d.ts.map +1 -0
  45. package/dist/publisher/key-import.js +76 -0
  46. package/dist/publisher/key-import.js.map +1 -0
  47. package/dist/publisher/record-sequence.d.ts +90 -0
  48. package/dist/publisher/record-sequence.d.ts.map +1 -0
  49. package/dist/publisher/record-sequence.js +250 -0
  50. package/dist/publisher/record-sequence.js.map +1 -0
  51. package/dist/rpc/kubo-rpc-client.d.ts +154 -0
  52. package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
  53. package/dist/rpc/kubo-rpc-client.js +207 -0
  54. package/dist/rpc/kubo-rpc-client.js.map +1 -0
  55. package/dist/rpc/mock-kubo.d.ts +87 -0
  56. package/dist/rpc/mock-kubo.d.ts.map +1 -0
  57. package/dist/rpc/mock-kubo.js +129 -0
  58. package/dist/rpc/mock-kubo.js.map +1 -0
  59. package/dist/site/site-management.d.ts +126 -0
  60. package/dist/site/site-management.d.ts.map +1 -0
  61. package/dist/site/site-management.js +123 -0
  62. package/dist/site/site-management.js.map +1 -0
  63. package/dist/status/status-report.d.ts +145 -0
  64. package/dist/status/status-report.d.ts.map +1 -0
  65. package/dist/status/status-report.js +192 -0
  66. package/dist/status/status-report.js.map +1 -0
  67. package/package.json +45 -2
  68. package/src/car/car-build.ts +135 -0
  69. package/src/ci/ci-emit.ts +284 -0
  70. package/src/cli/bin.ts +20 -0
  71. package/src/cli/run.ts +970 -0
  72. package/src/config/config-resolution.ts +264 -0
  73. package/src/deploy/deploy.ts +250 -0
  74. package/src/derive/ipns-key-derivation.ts +173 -0
  75. package/src/index.ts +146 -0
  76. package/src/node/node-commands.ts +395 -0
  77. package/src/provision/cloud-init.ts +646 -0
  78. package/src/publisher/key-import.ts +141 -0
  79. package/src/publisher/record-sequence.ts +336 -0
  80. package/src/rpc/kubo-rpc-client.ts +281 -0
  81. package/src/rpc/mock-kubo.ts +194 -0
  82. package/src/site/site-management.ts +241 -0
  83. package/src/status/status-report.ts +291 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Import a derived per-site IPNS key into the **publisher** node's keystore.
3
+ *
4
+ * This module wires the frozen master-key -> per-site key derivation (see
5
+ * `../derive/ipns-key-derivation.ts`, {@link DerivedIpnsKey}) into a running
6
+ * publisher node: it serializes the ed25519 keypair into the libp2p-protobuf
7
+ * form Kubo's `ipfs key import` (`key/import`) expects, then POSTs those bytes
8
+ * to the publisher's keystore via the {@link KuboRpcClient} seam. The client
9
+ * only ever supplies key MATERIAL.
10
+ *
11
+ * THE LOAD-BEARING INVARIANT (docs/adr/0003-*): deriving a key client-side is
12
+ * NOT client-side record SIGNING. A future reader will look at client-side key
13
+ * derivation + import and reasonably wonder "isn't this the fully-keyless C-1
14
+ * client-signing model the spec excludes?" — it is NOT. This module hands the
15
+ * node key MATERIAL; the NODE then signs the IPNS record and owns sequence
16
+ * numbers / validity via `name/publish` (owned by the on-box `republish` verb,
17
+ * `../node/node-commands.ts`). No signing primitive runs here; the only RPC
18
+ * this module issues is `key/import`. Spec Out of Scope excludes the C-1
19
+ * fully-keyless client-signing model; ADR-0003 records that invariant durably.
20
+ *
21
+ * PUBLISHER-ONLY (CONTEXT.md `publisher`, `replica`): exactly one node per
22
+ * shared IPNS name holds the key; replicas are KEYLESS and only re-announce the
23
+ * publisher's signed record. So this module REFUSES to import onto a `replica`
24
+ * ({@link KeyImportRoleError}) rather than silently proceeding — a wrong-role
25
+ * import is a caller error, not a no-op, because it would put a signing key on a
26
+ * box that must never hold one. (Design note: this refusal is a new, loud ERROR
27
+ * gated on the existing `HostRole` concept; it does not invent a role. See the
28
+ * done record's Decisions block.)
29
+ *
30
+ * SCOPE: ONLY key import into the publisher. Deriving the key is the
31
+ * `ipns-key-derivation` task; the publish/refresh timers and the record
32
+ * export/mirror are the `publisher-replica-model` task. This module does
33
+ * neither — it lands the key and stops.
34
+ */
35
+ import type {DerivedIpnsKey} from '../derive/ipns-key-derivation.js';
36
+ import type {HostRole} from '../config/config-resolution.js';
37
+ import type {KuboRpcClient} from '../rpc/kubo-rpc-client.js';
38
+
39
+ /**
40
+ * The frozen libp2p-protobuf `PrivateKey` prefix for an ed25519 key with a
41
+ * 64-byte `Data`:
42
+ *
43
+ * 0x08 0x01 — field 1 (Type), varint, KeyType Ed25519 = 1
44
+ * 0x12 0x40 — field 2 (Data), length-delimited, length = 64
45
+ *
46
+ * followed by the 64-byte ed25519 raw key (32-byte seed || 32-byte public key,
47
+ * the Go `ed25519.PrivateKey` layout libp2p `Raw()` emits). This is the
48
+ * `libp2p-protobuf-cleartext` format `ipfs key import` defaults to.
49
+ *
50
+ * @see https://github.com/libp2p/go-libp2p/blob/master/core/crypto/pb/crypto.proto
51
+ * @see https://github.com/libp2p/go-libp2p/blob/master/core/crypto/ed25519.go
52
+ */
53
+ export const LIBP2P_ED25519_PRIVATE_KEY_PREFIX = Uint8Array.from([
54
+ 0x08, 0x01, 0x12, 0x40,
55
+ ]);
56
+
57
+ /**
58
+ * Serialize a {@link DerivedIpnsKey} into the libp2p-protobuf-cleartext
59
+ * `PrivateKey` bytes `ipfs key import` expects.
60
+ *
61
+ * The libp2p ed25519 private key `Data` is the 64-byte concatenation of the
62
+ * 32-byte private seed and the 32-byte public key (so the public key need not
63
+ * be recomputed on load); we wrap it in the `PrivateKey { Type=Ed25519, Data }`
64
+ * protobuf via {@link LIBP2P_ED25519_PRIVATE_KEY_PREFIX}. Pure and offline: no
65
+ * node, network, or signing — this only reshapes bytes we already derived.
66
+ */
67
+ export function serializeIpnsKeyForImport(derived: DerivedIpnsKey): Uint8Array {
68
+ if (derived.seed.length !== 32) {
69
+ throw new Error(
70
+ `expected a 32-byte ed25519 seed, got ${derived.seed.length}`,
71
+ );
72
+ }
73
+ if (derived.publicKey.length !== 32) {
74
+ throw new Error(
75
+ `expected a 32-byte ed25519 public key, got ${derived.publicKey.length}`,
76
+ );
77
+ }
78
+ const prefix = LIBP2P_ED25519_PRIVATE_KEY_PREFIX;
79
+ const out = new Uint8Array(prefix.length + 64);
80
+ out.set(prefix, 0);
81
+ out.set(derived.seed, prefix.length); // Data[0..32): the private seed.
82
+ out.set(derived.publicKey, prefix.length + 32); // Data[32..64): the pubkey.
83
+ return out;
84
+ }
85
+
86
+ /** A loud refusal to import a key onto a non-publisher node (a replica). */
87
+ export class KeyImportRoleError extends Error {
88
+ constructor(
89
+ /** The role the caller passed (must be `publisher` to import). */
90
+ readonly role: HostRole,
91
+ /** The key name the import was attempted under. */
92
+ readonly keyName: string,
93
+ ) {
94
+ super(
95
+ `refusing key/import '${keyName}' onto a ${role}: only the publisher ` +
96
+ `holds the IPNS key; replicas are keyless and re-announce the ` +
97
+ `publisher's signed record`,
98
+ );
99
+ this.name = 'KeyImportRoleError';
100
+ }
101
+ }
102
+
103
+ /** Inputs to {@link importIpnsKeyIntoPublisher}. */
104
+ export interface ImportIpnsKeyInput {
105
+ /** The Kubo RPC client for the PUBLISHER node (per-node, bearer-guarded). */
106
+ client: KuboRpcClient;
107
+ /** The target node's role; MUST be `publisher` or the import is refused. */
108
+ role: HostRole;
109
+ /** The keystore key name to import under (the site name / `key/list` Name). */
110
+ keyName: string;
111
+ /** The derived per-site key (seed + public key) from `ipns-key-derivation`. */
112
+ derived: DerivedIpnsKey;
113
+ }
114
+
115
+ /**
116
+ * The Kubo `key/import` JSON response (`Name` + IPNS `Id`), as far as we read.
117
+ */
118
+ export interface KeyImportResult {
119
+ Name?: string;
120
+ Id?: string;
121
+ }
122
+
123
+ /**
124
+ * Serialize the derived key and import it into the PUBLISHER node's keystore
125
+ * via Kubo `key/import`. REFUSES ({@link KeyImportRoleError}) on any non-
126
+ * publisher role, touching the node not at all, so a replica stays keyless.
127
+ *
128
+ * The client supplies key MATERIAL only. The node signs IPNS records itself via
129
+ * `name/publish` (a separate concern the on-box `republish` verb owns); nothing
130
+ * here signs, and the only RPC issued is `key/import`. See the module doc +
131
+ * ADR-0003 for the "no client-side record signing" invariant.
132
+ */
133
+ export async function importIpnsKeyIntoPublisher(
134
+ input: ImportIpnsKeyInput,
135
+ ): Promise<KeyImportResult> {
136
+ if (input.role !== 'publisher') {
137
+ throw new KeyImportRoleError(input.role, input.keyName);
138
+ }
139
+ const keyBytes = serializeIpnsKeyForImport(input.derived);
140
+ return await input.client.keyImport<KeyImportResult>(input.keyName, keyBytes);
141
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The **publisher / keyless-replica IPNS record SEQUENCE** — pinnace's C-2
3
+ * grace-window machinery (CONTEXT.md `publisher`, `replica`, `IPNS record`;
4
+ * spec "Publisher / keyless-replica model" + user stories 12, 13, 14).
5
+ *
6
+ * A shared IPNS name stays reachable with a grace window even if the publisher
7
+ * dies. Exactly ONE publisher per name holds the derived key and:
8
+ * - refreshes the record's validity by re-SIGNING it (`name/publish`,
9
+ * ~72h lifetime, ~1h ttl — refreshed well within validity), then
10
+ * - EXPORTS the raw signed record (`routing/get`) plus its ipns id to a
11
+ * records dir where keyless replicas fetch it.
12
+ * Keyless REPLICAS hold no key. For each site they:
13
+ * - FETCH the publisher's exported record from the publisher endpoint,
14
+ * - re-announce it (`routing/put`) — they NEVER sign,
15
+ * - FALL BACK to their last cached record if the publisher is unreachable,
16
+ * so the name keeps announcing through a publisher outage.
17
+ *
18
+ * THE LOAD-BEARING INVARIANT (spec Out of Scope C-1; ADR-0003): a replica NEVER
19
+ * signs. The only signing primitive in the whole sequence is the publisher's
20
+ * `name/publish` (the NODE signs, owning sequence numbers + validity). A replica
21
+ * issues ONLY `routing/put` (re-announce) — never `name/publish`. Re-announcing
22
+ * a signed record is not signing it. The tests assert no `name/publish` ever
23
+ * reaches a replica's Kubo, including on the cache-fallback path.
24
+ *
25
+ * ONE IMPLEMENTATION (ADR-0002): this module is the SINGLE home of the record
26
+ * sequence. The on-box `pinnace node republish|mirror` verbs
27
+ * (`../node/node-commands.ts`) do NOT re-implement it — they inject the ops
28
+ * this module exposes ({@link makeRepublishOp}, {@link makeMirrorOp}) through
29
+ * their `NodeCommandOps` seam, exactly as `status-report` supplies the `status`
30
+ * op via `makeStatusOp`. So the client and the box run the same code; there is
31
+ * no bash/TS behaviour drift like the reference prototype had.
32
+ *
33
+ * Behaviour ported (NOT copied as bash) from the reference cloud-init scripts
34
+ * `ipfs-ipns-publish.sh` (publisher: `name publish` + `routing get` export to
35
+ * the dashboard vhost) and `ipfs-ipns-mirror.sh` (replica: curl the publisher's
36
+ * `/records/<name>.ipns-record`, `routing put`, fall back to cache)
37
+ * (`~/searches/ipfs-hetzner/cloud-init.yaml`).
38
+ *
39
+ * Promotion (story 14) — {@link promoteReplicaToPublisher} — reuses the
40
+ * `key-import-publisher` seam ({@link importIpnsKeyIntoPublisher}) to import the
41
+ * derived key and flip the role, recovering the name within the record's
42
+ * validity window without content downtime.
43
+ */
44
+ import {mkdir, readFile, writeFile} from 'node:fs/promises';
45
+ import {join} from 'node:path';
46
+ import type {KuboRpcClient} from '../rpc/kubo-rpc-client.js';
47
+ import type {HostRole} from '../config/config-resolution.js';
48
+ import type {DerivedIpnsKey} from '../derive/ipns-key-derivation.js';
49
+ import {importIpnsKeyIntoPublisher} from './key-import.js';
50
+ import type {
51
+ DiscoveredSite,
52
+ NodeCommandContext,
53
+ NodeOpResult,
54
+ PublisherFetch,
55
+ SiteOutcome,
56
+ } from '../node/node-commands.js';
57
+
58
+ /**
59
+ * Records are ~72h valid, refreshed with a ~1h ttl (the reference values). The
60
+ * republish timer fires well within 72h so the record never lapses. Exported so
61
+ * tests pin the exact validity contract and callers do not re-magic-number it.
62
+ */
63
+ export const RECORD_LIFETIME = '72h';
64
+ export const RECORD_TTL = '1h';
65
+
66
+ /** The exported record file suffix (raw signed record bytes). */
67
+ const RECORD_SUFFIX = '.ipns-record';
68
+ /** The exported ipns-id file suffix (the `k51...` name `routing/put` targets). */
69
+ const NAME_SUFFIX = '.ipns-name';
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Publisher: refresh (sign) + export.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * PUBLISHER op. For each discovered site the node holds a key for:
77
+ * 1. `name/publish` to refresh the signed record (~72h lifetime, ~1h ttl),
78
+ * 2. `routing/get` to EXPORT the raw signed record, then
79
+ * 3. write the record + its ipns id under {@link NodeCommandContext.recordsDir}
80
+ * where replicas fetch them.
81
+ * Sites the node holds NO key for are ipfs-mode only and are left alone
82
+ * (reported `no-key`, never published). This is the ONLY place a signing
83
+ * primitive (`name/publish`) runs in the whole sequence.
84
+ */
85
+ export async function republishAndExport(
86
+ ctx: NodeCommandContext,
87
+ sites: DiscoveredSite[],
88
+ ): Promise<NodeOpResult> {
89
+ const keys = await listKeys(ctx.client);
90
+ const outcomes: SiteOutcome[] = [];
91
+
92
+ for (const site of sites) {
93
+ const ipns = keys.get(site.id);
94
+ if (!ipns) {
95
+ // ipfs-mode site (no key): nothing to sign or export.
96
+ outcomes.push({id: site.id, cid: site.cid, status: 'no-key'});
97
+ continue;
98
+ }
99
+
100
+ // The NODE signs here (refreshing validity). Not the client, not a replica.
101
+ await ctx.client.namePublish({
102
+ cidPath: `/ipfs/${site.cid}`,
103
+ key: site.id,
104
+ lifetime: RECORD_LIFETIME,
105
+ ttl: RECORD_TTL,
106
+ allowOffline: true,
107
+ });
108
+
109
+ // Export the raw signed record for replicas to mirror.
110
+ const record = await ctx.client.routingGet(`/ipns/${ipns}`);
111
+ if (ctx.recordsDir) {
112
+ await mkdir(ctx.recordsDir, {recursive: true});
113
+ await writeFile(join(ctx.recordsDir, site.id + NAME_SUFFIX), ipns);
114
+ await writeFile(
115
+ join(ctx.recordsDir, site.id + RECORD_SUFFIX),
116
+ Buffer.from(record),
117
+ );
118
+ }
119
+
120
+ outcomes.push({id: site.id, cid: site.cid, ipns, status: 'exported'});
121
+ }
122
+
123
+ return {sites: outcomes};
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Replica: fetch + re-announce, with cache fallback. NEVER signs.
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * REPLICA op. For each discovered site:
132
+ * 1. FETCH the publisher's exported record (+ its ipns id) from the publisher
133
+ * endpoint;
134
+ * 2. on ANY fetch failure, FALL BACK to the last cached record for that site;
135
+ * 3. re-announce whichever record it got via `routing/put`.
136
+ * A freshly fetched record is cached so a later publisher outage can fall back
137
+ * to it. A site with neither a live publisher NOR a cache is REPORTED
138
+ * (`no-record`), never thrown — one unreachable site must not fail the others.
139
+ *
140
+ * A replica NEVER signs: this op issues NO `name/publish`. It only re-announces
141
+ * a record the publisher already signed.
142
+ */
143
+ export async function mirrorAndReannounce(
144
+ ctx: NodeCommandContext,
145
+ sites: DiscoveredSite[],
146
+ ): Promise<NodeOpResult> {
147
+ const base = (ctx.publisherEndpoint ?? '').replace(/\/+$/, '');
148
+ const fetchRecord: PublisherFetch = ctx.publisherFetch ?? httpFetchText;
149
+ const outcomes: SiteOutcome[] = [];
150
+
151
+ for (const site of sites) {
152
+ let record: string | undefined;
153
+ let ipnsId: string | undefined;
154
+ let fromCache = false;
155
+
156
+ // Try the publisher first; on ANY failure fall back to the cache.
157
+ if (base) {
158
+ try {
159
+ record = await fetchRecord(
160
+ `${base}/records/${site.id}${RECORD_SUFFIX}`,
161
+ );
162
+ ipnsId = (
163
+ await fetchRecord(`${base}/records/${site.id}${NAME_SUFFIX}`)
164
+ ).trim();
165
+ } catch {
166
+ record = undefined;
167
+ ipnsId = undefined;
168
+ }
169
+ }
170
+ if (record === undefined && ctx.cacheDir) {
171
+ const cached = await readCached(ctx.cacheDir, site.id);
172
+ if (cached) {
173
+ record = cached.record;
174
+ ipnsId = cached.ipnsId;
175
+ fromCache = true;
176
+ }
177
+ }
178
+
179
+ if (record === undefined || !ipnsId) {
180
+ // Neither a live publisher nor a cache: report, don't throw.
181
+ outcomes.push({id: site.id, status: 'no-record'});
182
+ continue;
183
+ }
184
+
185
+ // Persist a freshly-fetched record so a later outage can fall back to it.
186
+ // (Cache-sourced records are already on disk.)
187
+ if (!fromCache && ctx.cacheDir) {
188
+ await mkdir(ctx.cacheDir, {recursive: true});
189
+ await writeFile(join(ctx.cacheDir, site.id + RECORD_SUFFIX), record);
190
+ await writeFile(join(ctx.cacheDir, site.id + NAME_SUFFIX), ipnsId);
191
+ }
192
+
193
+ // Re-announce ONLY. No signing — this is a replica.
194
+ await ctx.client.routingPut(
195
+ `/ipns/${ipnsId}`,
196
+ new Uint8Array(Buffer.from(record)),
197
+ );
198
+ outcomes.push({
199
+ id: site.id,
200
+ ipns: ipnsId,
201
+ status: fromCache ? 're-announced-cached' : 're-announced',
202
+ });
203
+ }
204
+
205
+ return {sites: outcomes};
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Node-command adapters: supply the owned ops behind the SAME NodeCommandOps
210
+ // seam the on-box `republish`/`mirror` verbs inject (mirrors makeStatusOp).
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Adapt {@link republishAndExport} into a {@link NodeCommandContext} `republish`
215
+ * op so the on-box `republish` verb injects it (its `ops.republish` seam). The
216
+ * command layer discovers sites and passes them in; this op signs+exports over
217
+ * them. So the box runs THIS code, not a re-implementation.
218
+ */
219
+ export function makeRepublishOp(): (
220
+ ctx: NodeCommandContext,
221
+ sites: DiscoveredSite[],
222
+ ) => Promise<NodeOpResult> {
223
+ return (ctx, sites) => republishAndExport(ctx, sites);
224
+ }
225
+
226
+ /**
227
+ * Adapt {@link mirrorAndReannounce} into a {@link NodeCommandContext} `mirror`
228
+ * op so the on-box `mirror` verb injects it (its `ops.mirror` seam). Same core,
229
+ * one implementation.
230
+ */
231
+ export function makeMirrorOp(): (
232
+ ctx: NodeCommandContext,
233
+ sites: DiscoveredSite[],
234
+ ) => Promise<NodeOpResult> {
235
+ return (ctx, sites) => mirrorAndReannounce(ctx, sites);
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Promote a replica to publisher (story 14).
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /** Inputs to {@link promoteReplicaToPublisher}. */
243
+ export interface PromoteReplicaInput {
244
+ /** The Kubo RPC client for the node being promoted (per-node, bearer-guarded). */
245
+ client: KuboRpcClient;
246
+ /** The node's CURRENT role (typically `replica`; `publisher` is a safe re-run). */
247
+ currentRole: HostRole;
248
+ /** The keystore key name to import under (the site name / `key/list` Name). */
249
+ keyName: string;
250
+ /** The derived per-site key (seed + public key) from `ipns-key-derivation`. */
251
+ derived: DerivedIpnsKey;
252
+ }
253
+
254
+ /** The outcome of a promotion: the flipped role + the key that was imported. */
255
+ export interface PromoteReplicaResult {
256
+ /** Always `publisher` after a successful promotion (the flipped role). */
257
+ role: HostRole;
258
+ /** The key name imported into the (now) publisher's keystore. */
259
+ keyName: string;
260
+ /** The IPNS id the imported key resolves to (from the `key/import` response). */
261
+ ipns?: string;
262
+ }
263
+
264
+ /**
265
+ * PROMOTE a keyless replica to publisher (spec user story 14): import the
266
+ * derived key into the node's keystore and flip its role to `publisher`, so the
267
+ * former replica can now sign/refresh the record itself — recovering the name
268
+ * without downtime of the CONTENT (the CID stays pinned throughout).
269
+ *
270
+ * This is a client-driven operation (run by the operator against the target
271
+ * node's RPC), NOT an on-box recurring verb: it reuses the `key-import-publisher`
272
+ * seam ({@link importIpnsKeyIntoPublisher}) to land the key material. Because
273
+ * that seam REFUSES a non-publisher role, we call it with role `publisher` (the
274
+ * target role) — promotion is precisely the act of making this node the
275
+ * publisher, so importing under the publisher role is correct.
276
+ *
277
+ * MUST happen WITHIN the current record's validity window: while the old
278
+ * publisher's ~72h record is still valid (kept alive by replica re-announcement
279
+ * during the grace window), the promoted node's first `name/publish` re-signs
280
+ * before the record lapses, so the name never goes dark. Promotion imports the
281
+ * key ONLY; it does not itself sign — the node signs on its next `republish`.
282
+ */
283
+ export async function promoteReplicaToPublisher(
284
+ input: PromoteReplicaInput,
285
+ ): Promise<PromoteReplicaResult> {
286
+ // Reuse the key-import seam. It refuses any non-publisher role, so we pass
287
+ // the TARGET role (`publisher`): promotion IS making this node the publisher.
288
+ const imported = await importIpnsKeyIntoPublisher({
289
+ client: input.client,
290
+ role: 'publisher',
291
+ keyName: input.keyName,
292
+ derived: input.derived,
293
+ });
294
+ // Flip the role. From here the node is the single signer for this name.
295
+ return {role: 'publisher', keyName: input.keyName, ipns: imported.Id};
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Small shared helpers.
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /** Map site/key name -> IPNS id from `key/list -l`. */
303
+ async function listKeys(client: KuboRpcClient): Promise<Map<string, string>> {
304
+ const res = await client.keyList<{
305
+ Keys?: Array<{Name?: string; Id?: string}> | null;
306
+ }>();
307
+ const map = new Map<string, string>();
308
+ for (const k of res.Keys ?? []) {
309
+ if (k?.Name && k?.Id) map.set(k.Name, k.Id);
310
+ }
311
+ return map;
312
+ }
313
+
314
+ /** Read a cached record + its ipns id for a site, or undefined if absent. */
315
+ async function readCached(
316
+ cacheDir: string,
317
+ name: string,
318
+ ): Promise<{record: string; ipnsId: string} | undefined> {
319
+ try {
320
+ const record = await readFile(join(cacheDir, name + RECORD_SUFFIX), 'utf8');
321
+ const ipnsId = (
322
+ await readFile(join(cacheDir, name + NAME_SUFFIX), 'utf8')
323
+ ).trim();
324
+ if (!ipnsId) return undefined;
325
+ return {record, ipnsId};
326
+ } catch {
327
+ return undefined;
328
+ }
329
+ }
330
+
331
+ /** Production publisher-record fetch: GET the URL and return its body text. */
332
+ async function httpFetchText(url: string): Promise<string> {
333
+ const res = await fetch(url);
334
+ if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`);
335
+ return await res.text();
336
+ }