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,241 @@
1
+ /**
2
+ * First-class **site management** — the `site` namespace (spec user stories 4,
3
+ * 15; CONTEXT.md `gateway warming`). Sites are auto-discovered from MFS
4
+ * `/sites/*` (that is how `gateway warming`, IPNS republish, and `status` find
5
+ * them), so managing the sites a node serves = managing those MFS entries + the
6
+ * pins that back them. This module gives the operator explicit verbs for that
7
+ * lifecycle instead of leaving it an implicit side-effect of `deploy`:
8
+ *
9
+ * - **list** — enumerate `/sites/*` with each site's current CID and, when a
10
+ * same-`id` keystore key exists, its IPNS id.
11
+ * - **remove** — `files/rm /sites/<id>` (the entry drops out of
12
+ * warm/republish/status auto-discovery) AND `pin/rm <cid>` so
13
+ * the content stops being served/announced and its storage is
14
+ * reclaimed.
15
+ * - **add** — place an already-imported `/ipfs/<cid>` into MFS
16
+ * `/sites/<id>` (mkdir parents / rm old / cp). See the
17
+ * DESIGN NOTE below on its relationship to `deploy`.
18
+ *
19
+ * Every verb speaks ONLY the Kubo RPC seam (MFS + pin endpoints), so the same
20
+ * core is usable both as a TypeScript API and behind the thin `pinnace site
21
+ * <verb>` CLI (CONTEXT.md `core vs cli`). Tests drive it through the recording
22
+ * `MockKuboApi`, never a live daemon (spec Testing Decisions).
23
+ *
24
+ * DESIGN NOTE (add vs deploy) — recorded per the task's "decide during build".
25
+ * `add` is a DISTINCT verb, not an alias over `deploy`. `deploy` (see
26
+ * `deploy-multi-target`) builds a fresh CAR, imports+pins it on every node, and
27
+ * THEN performs this MFS-placement step. `add` is exactly that final placement
28
+ * step in isolation: it takes an EXISTING CID (already imported/pinned, e.g. a
29
+ * known-good historical CID, or one imported out of band) and makes the node
30
+ * serve it under a site name, WITHOUT building or importing a CAR. Adding a site
31
+ * from an existing CID is a meaningful operation deploy does not cover (no fresh
32
+ * artifact), so it earns its own verb. Deploy is expected to REUSE
33
+ * {@link placeInMfs} for its placement step when it lands, keeping a single
34
+ * implementation of the mkdir/rm/cp sequence rather than forking it. This is
35
+ * NOT a new domain concept: `add` sits at the same layer as the other explicit
36
+ * client verbs and reuses the existing `/sites/<name>` MFS convention.
37
+ */
38
+ import type {KuboRpcClient} from '../rpc/kubo-rpc-client.js';
39
+
40
+ /** The three site-management verbs, under the `site` namespace. */
41
+ export type SiteVerb = 'list' | 'remove' | 'add';
42
+
43
+ /** The verbs, in a stable order (for help text / iteration). */
44
+ export const SITE_VERBS: readonly SiteVerb[] = ['list', 'remove', 'add'];
45
+
46
+ /** The MFS directory sites live under. */
47
+ const DEFAULT_SITES_DIR = '/sites';
48
+
49
+ /** One site as listed: its MFS `id`, current CID, and IPNS id if a key exists. */
50
+ export interface SiteListing {
51
+ /** The site's single `id` (its MFS entry under `/sites/`). */
52
+ id: string;
53
+ /** The current content root CID (`files/stat --hash`). */
54
+ cid: string;
55
+ /** The IPNS id, if a same-`id` keystore key exists (ipfs-mode sites lack one). */
56
+ ipns?: string;
57
+ }
58
+
59
+ /** Inputs shared by every site-management verb. */
60
+ interface SiteBaseInput {
61
+ /** The Kubo RPC client for the node whose sites are being managed. */
62
+ client: KuboRpcClient;
63
+ /** The MFS directory sites live under (default `/sites`). */
64
+ sitesDir?: string;
65
+ }
66
+
67
+ /** Inputs to {@link listSites}. */
68
+ export type ListSitesInput = SiteBaseInput;
69
+
70
+ /** Inputs to {@link removeSite}. */
71
+ export interface RemoveSiteInput extends SiteBaseInput {
72
+ /** The site `id` (its MFS entry `/sites/<id>`) to remove. */
73
+ id: string;
74
+ }
75
+
76
+ /** The outcome of {@link removeSite}. */
77
+ export interface RemoveSiteResult {
78
+ /** The site `id` that was removed. */
79
+ id: string;
80
+ /** The CID that backed it (undefined if it had no resolvable content). */
81
+ cid?: string;
82
+ /** True when the content was unpinned (storage reclaimed); false if unpin failed. */
83
+ unpinned: boolean;
84
+ }
85
+
86
+ /** Inputs to {@link addSite}. */
87
+ export interface AddSiteInput extends SiteBaseInput {
88
+ /** The site `id` to expose it under (its MFS entry `/sites/<id>`). */
89
+ id: string;
90
+ /** The already-imported content root CID to place into MFS. */
91
+ cid: string;
92
+ }
93
+
94
+ /** The outcome of {@link addSite}. */
95
+ export interface AddSiteResult {
96
+ /** The site `id` that was placed. */
97
+ id: string;
98
+ /** The CID that now backs it in MFS. */
99
+ cid: string;
100
+ }
101
+
102
+ /**
103
+ * **list** — enumerate the sites the node currently serves (MFS `/sites/*`),
104
+ * annotating each with its current CID (from `files/stat`) and, when a keystore
105
+ * key of the same name exists, its IPNS id (from `key/list -l`). A fresh box
106
+ * with no `/sites` dir yields an empty list, not an error.
107
+ */
108
+ export async function listSites(input: ListSitesInput): Promise<SiteListing[]> {
109
+ const sitesDir = input.sitesDir ?? DEFAULT_SITES_DIR;
110
+ const entries = await lsSites(input.client, sitesDir);
111
+ if (entries.length === 0) return [];
112
+
113
+ const keys = await listKeys(input.client);
114
+ const sites: SiteListing[] = [];
115
+ for (const id of entries) {
116
+ const cid = await statCid(input.client, `${sitesDir}/${id}`);
117
+ if (!cid) continue; // an entry with no resolvable CID is skipped, not fatal.
118
+ sites.push({id, cid, ipns: keys.get(id)});
119
+ }
120
+ return sites;
121
+ }
122
+
123
+ /**
124
+ * **remove** — delete a site: `files/rm /sites/<name>` FIRST (so it immediately
125
+ * drops out of MFS auto-discovery and stops being served/announced/warmed),
126
+ * THEN `pin/rm <cid>` to unpin the content so its storage is reclaimed. The CID
127
+ * is resolved up-front with `files/stat` so we know what to unpin after the
128
+ * entry is gone.
129
+ *
130
+ * The MFS removal is the load-bearing step and is always attempted. The unpin
131
+ * is best-effort: if the content was never pinned (or is pinned indirectly),
132
+ * `pin/rm` errors, which is REPORTED (`unpinned: false`) rather than thrown, so
133
+ * a partially-pinned site still removes cleanly.
134
+ */
135
+ export async function removeSite(
136
+ input: RemoveSiteInput,
137
+ ): Promise<RemoveSiteResult> {
138
+ const sitesDir = input.sitesDir ?? DEFAULT_SITES_DIR;
139
+ const path = `${sitesDir}/${input.id}`;
140
+
141
+ // Resolve the CID before we remove the entry (afterwards it is gone).
142
+ const cid = await statCid(input.client, path);
143
+
144
+ // Remove the MFS entry first: it stops being discovered/served/announced.
145
+ await input.client.filesRm(path, {recursive: true, force: true});
146
+
147
+ // Then reclaim storage by unpinning the content. Best-effort: a site that
148
+ // was never pinned (or pinned indirectly) must not fail the removal.
149
+ let unpinned = false;
150
+ if (cid) {
151
+ try {
152
+ await input.client.pinRm(cid);
153
+ unpinned = true;
154
+ } catch {
155
+ unpinned = false;
156
+ }
157
+ }
158
+
159
+ return {id: input.id, cid, unpinned};
160
+ }
161
+
162
+ /**
163
+ * **add** — expose an existing CID as a served site by placing it into MFS at
164
+ * `/sites/<name>` (the discoverable location warm/republish/status read). This
165
+ * is deploy's MFS-placement step in isolation (see the module DESIGN NOTE): it
166
+ * does NOT build or import a CAR and does NOT pin (the CID is assumed already
167
+ * imported/pinned on the node).
168
+ */
169
+ export async function addSite(input: AddSiteInput): Promise<AddSiteResult> {
170
+ const sitesDir = input.sitesDir ?? DEFAULT_SITES_DIR;
171
+ await placeInMfs(input.client, sitesDir, input.id, input.cid);
172
+ return {id: input.id, cid: input.cid};
173
+ }
174
+
175
+ /**
176
+ * The MFS-placement step: `files/mkdir /sites --parents`, `files/rm
177
+ * /sites/<id> --recursive --force` (clear any prior content), then `files/cp
178
+ * /ipfs/<cid> /sites/<id>`. Ported from the reference prototype's deploy MFS
179
+ * placement (`~/searches/ipfs-hetzner/deploy-car.mjs`). Exported so `deploy`
180
+ * can REUSE this exact sequence rather than forking it.
181
+ */
182
+ export async function placeInMfs(
183
+ client: KuboRpcClient,
184
+ sitesDir: string,
185
+ id: string,
186
+ cid: string,
187
+ ): Promise<void> {
188
+ await client.filesMkdir(sitesDir, {parents: true});
189
+ await client.filesRm(`${sitesDir}/${id}`, {recursive: true, force: true});
190
+ await client.filesCp(`/ipfs/${cid}`, `${sitesDir}/${id}`);
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Small shared Kubo reads (fail-soft where a missing dir is not an error).
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /** List the entry NAMES under the sites dir; empty on a fresh box (no dir). */
198
+ async function lsSites(
199
+ client: KuboRpcClient,
200
+ sitesDir: string,
201
+ ): Promise<string[]> {
202
+ let listing: {Entries?: Array<{Name?: string}> | null};
203
+ try {
204
+ listing = await client.filesLs<{Entries?: Array<{Name?: string}> | null}>(
205
+ sitesDir,
206
+ );
207
+ } catch {
208
+ // No /sites dir yet (fresh box) — nothing to list, not an error.
209
+ return [];
210
+ }
211
+ const names: string[] = [];
212
+ for (const entry of listing.Entries ?? []) {
213
+ if (entry?.Name) names.push(entry.Name);
214
+ }
215
+ return names;
216
+ }
217
+
218
+ /** Stat an MFS path for its current CID, or undefined if it cannot be resolved. */
219
+ async function statCid(
220
+ client: KuboRpcClient,
221
+ path: string,
222
+ ): Promise<string | undefined> {
223
+ try {
224
+ const stat = await client.filesStat<{Hash?: string}>(path);
225
+ return stat?.Hash;
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ /** Map site/key name -> IPNS id from `key/list -l` (same shape as node-commands). */
232
+ async function listKeys(client: KuboRpcClient): Promise<Map<string, string>> {
233
+ const res = await client.keyList<{
234
+ Keys?: Array<{Name?: string; Id?: string}> | null;
235
+ }>();
236
+ const map = new Map<string, string>();
237
+ for (const k of res.Keys ?? []) {
238
+ if (k?.Name && k?.Id) map.set(k.Name, k.Id);
239
+ }
240
+ return map;
241
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The `status` core operation — pinnace's discoverability VERIFICATION surface
3
+ * (CONTEXT.md `gateway warming`; spec user story 18).
4
+ *
5
+ * For every site auto-discovered from MFS `/sites/*` (over the Kubo RPC seam) it
6
+ * reports FOUR fields so an operator can confirm a deploy actually landed
7
+ * everywhere:
8
+ *
9
+ * 1. current CID — `files/stat --hash` (via {@link discoverSites}).
10
+ * 2. IPNS id — from `key/list -l` IF a key of the SAME name exists
11
+ * (ipfs-mode sites have none).
12
+ * 3. network-announce — does the NETWORK announce THIS node for the CID? The
13
+ * external delegated-routing providers list for the
14
+ * CID contains this node's PeerID (`id`).
15
+ * 4. gateway-serves — does a COLD public gateway serve it? An HTTP
16
+ * range/HEAD result from a public gateway.
17
+ *
18
+ * The two checks in (3) and (4) reach OUTSIDE the node. They are INJECTABLE
19
+ * ({@link ProvidersLookup}, {@link GatewayProbe}) so tests run against a fake
20
+ * HTTP layer and never the live network; production supplies the default
21
+ * `fetch`-backed implementations below.
22
+ *
23
+ * Behaviour ported (NOT copied) from the reference prototype
24
+ * `~/searches/ipfs-hetzner/status.sh`: the delegated-routing providers lookup at
25
+ * `https://delegated-ipfs.dev/routing/v1/providers/<cid>` (does
26
+ * `.Providers[].ID` include our PeerID?) and the
27
+ * `https://<cid>.ipfs.dweb.link/` range-request cold-gateway probe. The shell's
28
+ * `jq .Providers[].ID == $PEERID` and `curl -r 0-0 -w %{http_code}` shapes
29
+ * become the two TS predicates here.
30
+ *
31
+ * SEAM NOTE: this module OWNS the per-site status checks; the on-box `status`
32
+ * verb (`../node/node-commands.ts`) consumes them via its injectable
33
+ * `NodeCommandOps.status` seam — {@link makeStatusOp} adapts this core into that
34
+ * seam. The dashboard persistence (`status.json`) is the command layer's job,
35
+ * not this module's: this module PRODUCES the report and stops.
36
+ *
37
+ * A failed external check is a REPORTED outcome, never a throw: a
38
+ * delegated-routing lookup that errors reports `announced=false` (not findable
39
+ * right now) and a gateway probe that errors reports `gatewayServes=false`. One
40
+ * cold gateway or one flaky routing endpoint must not fail the whole report.
41
+ */
42
+ import {discoverSites, type DiscoveredSite} from '../node/node-commands.js';
43
+ import type {KuboRpcClient} from '../rpc/kubo-rpc-client.js';
44
+ import type {NodeCommandContext, NodeOpResult} from '../node/node-commands.js';
45
+
46
+ /** The delegated-routing providers endpoint (path takes the CID). */
47
+ const DELEGATED_ROUTING_BASE =
48
+ 'https://delegated-ipfs.dev/routing/v1/providers';
49
+
50
+ /** The public gateway used for the cold-serve probe (`<cid>.ipfs.<host>`). */
51
+ const DWEB_LINK_HOST = 'ipfs.dweb.link';
52
+
53
+ /**
54
+ * The subset of the delegated-routing `/routing/v1/providers/<cid>` JSON we
55
+ * read: the `Providers` array, each with an `ID` (a PeerID). Other fields are
56
+ * ignored.
57
+ */
58
+ export interface ProvidersResponse {
59
+ Providers?: Array<{ID?: string}> | null;
60
+ }
61
+
62
+ /**
63
+ * An injectable delegated-routing providers lookup: given a CID, return the
64
+ * providers response (whose `.Providers[].ID` we scan for our PeerID). Tests
65
+ * inject a fake; production uses {@link defaultProvidersLookup}. Throwing is
66
+ * treated as "not findable" (announced=false), never propagated.
67
+ */
68
+ export type ProvidersLookup = (cid: string) => Promise<ProvidersResponse>;
69
+
70
+ /**
71
+ * An injectable cold-gateway probe: given a CID, return the HTTP status a
72
+ * public gateway responds with (a 2xx/206 means it served). Tests inject a
73
+ * fake; production uses {@link defaultGatewayProbe}. Throwing is treated as
74
+ * "did not serve" (gatewayServes=false), never propagated.
75
+ */
76
+ export type GatewayProbe = (cid: string) => Promise<number>;
77
+
78
+ /** The status of one site: the four report fields plus its `id`. */
79
+ export interface SiteStatus {
80
+ /** The site's single `id` (its MFS entry under `/sites/`). */
81
+ id: string;
82
+ /** The current content root CID (`files/stat --hash`). */
83
+ cid: string;
84
+ /** The IPNS id, if a same-named keystore key exists (else undefined). */
85
+ ipns?: string;
86
+ /** True when the delegated-routing providers list for the CID has our PeerID. */
87
+ announced: boolean;
88
+ /** The HTTP status the cold public gateway probe returned (undefined on error). */
89
+ gatewayHttp?: number;
90
+ /** True when the gateway probe status indicates the CID was served (2xx/206). */
91
+ gatewayServes: boolean;
92
+ }
93
+
94
+ /** The full status report: this node's PeerID plus a per-site status line. */
95
+ export interface StatusReport {
96
+ /** This node's PeerID (from Kubo `id`); the announce check looks for it. */
97
+ peerId: string;
98
+ /** One {@link SiteStatus} per site discovered under MFS `/sites/*`. */
99
+ sites: SiteStatus[];
100
+ }
101
+
102
+ /** Inputs to {@link statusReport}. */
103
+ export interface StatusReportInput {
104
+ /** The Kubo RPC client for THIS node (files/ls, files/stat, key/list, id). */
105
+ client: KuboRpcClient;
106
+ /** The MFS directory sites live under (default `/sites`). */
107
+ sitesDir?: string;
108
+ /**
109
+ * The delegated-routing providers lookup (injectable; defaults to the live
110
+ * `delegated-ipfs.dev` fetch via {@link defaultProvidersLookup}).
111
+ */
112
+ providersLookup?: ProvidersLookup;
113
+ /**
114
+ * The cold-gateway probe (injectable; defaults to the live `dweb.link`
115
+ * range-request via {@link defaultGatewayProbe}).
116
+ */
117
+ gatewayProbe?: GatewayProbe;
118
+ /**
119
+ * Pre-discovered sites (optional). When set, discovery is SKIPPED and these
120
+ * are used as-is — the node-command layer already walked MFS, so the adapter
121
+ * ({@link makeStatusOp}) passes them straight through rather than re-listing.
122
+ */
123
+ sites?: DiscoveredSite[];
124
+ }
125
+
126
+ /**
127
+ * Build the per-site status report. Discovers sites (unless supplied), reads
128
+ * this node's PeerID and its keystore once, then for each site runs the two
129
+ * external checks (through the injected/default fakes) to fill the four fields.
130
+ * External-check failures degrade to `false`, never throw.
131
+ */
132
+ export async function statusReport(
133
+ input: StatusReportInput,
134
+ ): Promise<StatusReport> {
135
+ const sitesDir = input.sitesDir ?? '/sites';
136
+ const providersLookup = input.providersLookup ?? defaultProvidersLookup;
137
+ const gatewayProbe = input.gatewayProbe ?? defaultGatewayProbe;
138
+
139
+ const sites = input.sites ?? (await discoverSites(input.client, sitesDir));
140
+ const peerId = await readPeerId(input.client);
141
+ const keys = await listKeys(input.client);
142
+
143
+ const statuses: SiteStatus[] = [];
144
+ for (const site of sites) {
145
+ const announced = await checkAnnounced(providersLookup, site.cid, peerId);
146
+ const gatewayHttp = await probeGateway(gatewayProbe, site.cid);
147
+ statuses.push({
148
+ id: site.id,
149
+ cid: site.cid,
150
+ ipns: keys.get(site.id),
151
+ announced,
152
+ gatewayHttp,
153
+ gatewayServes: gatewayHttp !== undefined && servesStatus(gatewayHttp),
154
+ });
155
+ }
156
+
157
+ return {peerId, sites: statuses};
158
+ }
159
+
160
+ /**
161
+ * Adapt {@link statusReport} into a {@link NodeCommandContext} `status` op so
162
+ * the on-box `status` verb can inject it (its `ops.status` seam). The command
163
+ * layer discovers sites and passes them in; this adapter runs the four-field
164
+ * checks over them and flattens the result into a {@link NodeOpResult} (its
165
+ * per-site outcomes carry the announce + gateway outcomes too).
166
+ */
167
+ export function makeStatusOp(
168
+ checks: {providersLookup?: ProvidersLookup; gatewayProbe?: GatewayProbe} = {},
169
+ ): (ctx: NodeCommandContext, sites: DiscoveredSite[]) => Promise<NodeOpResult> {
170
+ return async (ctx, sites) => {
171
+ const report = await statusReport({
172
+ client: ctx.client,
173
+ sitesDir: ctx.sitesDir,
174
+ sites,
175
+ providersLookup: checks.providersLookup,
176
+ gatewayProbe: checks.gatewayProbe,
177
+ });
178
+ return {
179
+ sites: report.sites.map((s) => ({
180
+ id: s.id,
181
+ cid: s.cid,
182
+ ipns: s.ipns ?? '',
183
+ announced: s.announced,
184
+ gatewayServes: s.gatewayServes,
185
+ gatewayHttp: s.gatewayHttp,
186
+ status: s.gatewayServes && s.announced ? 'ok' : 'unverified',
187
+ })),
188
+ };
189
+ };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Per-site checks (fail-soft).
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Ask delegated routing who provides the CID and answer: is OUR PeerID among
198
+ * them? A lookup error is "not findable right now" -> false, never thrown (one
199
+ * flaky routing endpoint must not fail the report). Ports the shell's
200
+ * `jq '.Providers[]|select(.ID==$p)'`.
201
+ */
202
+ async function checkAnnounced(
203
+ lookup: ProvidersLookup,
204
+ cid: string,
205
+ peerId: string,
206
+ ): Promise<boolean> {
207
+ if (!peerId) return false;
208
+ try {
209
+ const res = await lookup(cid);
210
+ return (res.Providers ?? []).some((p) => p?.ID === peerId);
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Probe a cold public gateway for the CID and return its HTTP status, or
218
+ * undefined if the probe itself errored (network down, DNS, ...). Ports the
219
+ * shell's `curl -r 0-0 -w %{http_code}`.
220
+ */
221
+ async function probeGateway(
222
+ probe: GatewayProbe,
223
+ cid: string,
224
+ ): Promise<number | undefined> {
225
+ try {
226
+ return await probe(cid);
227
+ } catch {
228
+ return undefined;
229
+ }
230
+ }
231
+
232
+ /** A gateway HTTP status counts as "served" iff it is 2xx (including 206). */
233
+ function servesStatus(status: number): boolean {
234
+ return status >= 200 && status < 300;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Kubo reads.
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /** Read this node's PeerID from Kubo `id` (empty string if unavailable). */
242
+ async function readPeerId(client: KuboRpcClient): Promise<string> {
243
+ try {
244
+ const res = await client.id<{ID?: string}>();
245
+ return res?.ID ?? '';
246
+ } catch {
247
+ return '';
248
+ }
249
+ }
250
+
251
+ /** Map site/key name -> IPNS id from `key/list -l` (same shape as node-commands). */
252
+ async function listKeys(client: KuboRpcClient): Promise<Map<string, string>> {
253
+ const res = await client.keyList<{
254
+ Keys?: Array<{Name?: string; Id?: string}> | null;
255
+ }>();
256
+ const map = new Map<string, string>();
257
+ for (const k of res.Keys ?? []) {
258
+ if (k?.Name && k?.Id) map.set(k.Name, k.Id);
259
+ }
260
+ return map;
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Default (LIVE) external checks. Production wiring; tests inject fakes instead.
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * The default delegated-routing lookup: GET
269
+ * `https://delegated-ipfs.dev/routing/v1/providers/<cid>` and parse its JSON.
270
+ * (Injected fakes replace this in tests; it is the only place the live routing
271
+ * endpoint is named.)
272
+ */
273
+ export const defaultProvidersLookup: ProvidersLookup = async (cid) => {
274
+ const res = await fetch(`${DELEGATED_ROUTING_BASE}/${cid}`, {
275
+ headers: {accept: 'application/json'},
276
+ });
277
+ if (!res.ok) return {Providers: []};
278
+ return (await res.json()) as ProvidersResponse;
279
+ };
280
+
281
+ /**
282
+ * The default cold-gateway probe: a single-byte range request to
283
+ * `https://<cid>.ipfs.dweb.link/` returning the HTTP status. (Injected fakes
284
+ * replace this in tests; it is the only place the live gateway is named.)
285
+ */
286
+ export const defaultGatewayProbe: GatewayProbe = async (cid) => {
287
+ const res = await fetch(`https://${cid}.${DWEB_LINK_HOST}/`, {
288
+ headers: {range: 'bytes=0-0'},
289
+ });
290
+ return res.status;
291
+ };