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.
- package/LICENSE +661 -0
- package/README.md +164 -0
- package/dist/car/car-build.d.ts +26 -0
- package/dist/car/car-build.d.ts.map +1 -0
- package/dist/car/car-build.js +113 -0
- package/dist/car/car-build.js.map +1 -0
- package/dist/ci/ci-emit.d.ts +116 -0
- package/dist/ci/ci-emit.d.ts.map +1 -0
- package/dist/ci/ci-emit.js +190 -0
- package/dist/ci/ci-emit.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +17 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/run.d.ts +94 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +731 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/config/config-resolution.d.ts +186 -0
- package/dist/config/config-resolution.d.ts.map +1 -0
- package/dist/config/config-resolution.js +137 -0
- package/dist/config/config-resolution.js.map +1 -0
- package/dist/deploy/deploy.d.ts +121 -0
- package/dist/deploy/deploy.d.ts.map +1 -0
- package/dist/deploy/deploy.js +150 -0
- package/dist/deploy/deploy.js.map +1 -0
- package/dist/derive/ipns-key-derivation.d.ts +37 -0
- package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
- package/dist/derive/ipns-key-derivation.js +130 -0
- package/dist/derive/ipns-key-derivation.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/node/node-commands.d.ts +115 -0
- package/dist/node/node-commands.d.ts.map +1 -0
- package/dist/node/node-commands.js +231 -0
- package/dist/node/node-commands.js.map +1 -0
- package/dist/provision/cloud-init.d.ts +162 -0
- package/dist/provision/cloud-init.d.ts.map +1 -0
- package/dist/provision/cloud-init.js +460 -0
- package/dist/provision/cloud-init.js.map +1 -0
- package/dist/publisher/key-import.d.ts +105 -0
- package/dist/publisher/key-import.d.ts.map +1 -0
- package/dist/publisher/key-import.js +76 -0
- package/dist/publisher/key-import.js.map +1 -0
- package/dist/publisher/record-sequence.d.ts +90 -0
- package/dist/publisher/record-sequence.d.ts.map +1 -0
- package/dist/publisher/record-sequence.js +250 -0
- package/dist/publisher/record-sequence.js.map +1 -0
- package/dist/rpc/kubo-rpc-client.d.ts +154 -0
- package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
- package/dist/rpc/kubo-rpc-client.js +207 -0
- package/dist/rpc/kubo-rpc-client.js.map +1 -0
- package/dist/rpc/mock-kubo.d.ts +87 -0
- package/dist/rpc/mock-kubo.d.ts.map +1 -0
- package/dist/rpc/mock-kubo.js +129 -0
- package/dist/rpc/mock-kubo.js.map +1 -0
- package/dist/site/site-management.d.ts +126 -0
- package/dist/site/site-management.d.ts.map +1 -0
- package/dist/site/site-management.js +123 -0
- package/dist/site/site-management.js.map +1 -0
- package/dist/status/status-report.d.ts +145 -0
- package/dist/status/status-report.d.ts.map +1 -0
- package/dist/status/status-report.js +192 -0
- package/dist/status/status-report.js.map +1 -0
- package/package.json +45 -2
- package/src/car/car-build.ts +135 -0
- package/src/ci/ci-emit.ts +284 -0
- package/src/cli/bin.ts +20 -0
- package/src/cli/run.ts +970 -0
- package/src/config/config-resolution.ts +264 -0
- package/src/deploy/deploy.ts +250 -0
- package/src/derive/ipns-key-derivation.ts +173 -0
- package/src/index.ts +146 -0
- package/src/node/node-commands.ts +395 -0
- package/src/provision/cloud-init.ts +646 -0
- package/src/publisher/key-import.ts +141 -0
- package/src/publisher/record-sequence.ts +336 -0
- package/src/rpc/kubo-rpc-client.ts +281 -0
- package/src/rpc/mock-kubo.ts +194 -0
- package/src/site/site-management.ts +241 -0
- 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
|
+
}
|