@jinn-network/core 0.1.2-canary.b9f63334 → 0.1.2-canary.c8da24a8

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.
@@ -3,6 +3,12 @@
3
3
  *
4
4
  * Always hash-verifies before persisting to cache.
5
5
  *
6
+ * The two *network* legs — the donated IPFS mirror and the operator origin —
7
+ * are not implemented here: they are `fetchVerifiedArtifact`, the keyless
8
+ * retrieval primitive (#4179). What remains in this file is the store-aware
9
+ * composition around it: the cache read, the self-store mirror, the route
10
+ * resolver, and the cache writes whose ordering the daemon owns.
11
+ *
6
12
  * Spec §2.3 step 4-6.
7
13
  */
8
14
  import { type AcquireResult } from './fetch-artifact.js';
@@ -3,59 +3,68 @@
3
3
  *
4
4
  * Always hash-verifies before persisting to cache.
5
5
  *
6
+ * The two *network* legs — the donated IPFS mirror and the operator origin —
7
+ * are not implemented here: they are `fetchVerifiedArtifact`, the keyless
8
+ * retrieval primitive (#4179). What remains in this file is the store-aware
9
+ * composition around it: the cache read, the self-store mirror, the route
10
+ * resolver, and the cache writes whose ordering the daemon owns.
11
+ *
6
12
  * Spec §2.3 step 4-6.
7
13
  */
8
- import { createHash } from 'node:crypto';
9
14
  import { fetchArtifactContent } from './fetch-artifact.js';
15
+ import { fetchVerifiedArtifact, verifyArtifactDigest } from './artifact-retrieval.js';
10
16
  import { AcquireError, HashMismatchError } from './types.js';
11
- import { fetchFromIpfs as defaultFetchFromIpfs } from './ipfs.js';
12
- const DONATION_ARTIFACT_ENCODING = 'jinn.artifact.donation.v1';
13
- function sha256Hex(buf) {
14
- return createHash('sha256').update(buf).digest('hex');
15
- }
16
- function decodeDonationArtifact(raw, expectedSha256) {
17
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
18
- throw new Error('donation artifact payload is not an object');
19
- }
20
- const record = raw;
21
- if (record['schemaVersion'] !== DONATION_ARTIFACT_ENCODING || record['encoding'] !== DONATION_ARTIFACT_ENCODING) {
22
- throw new Error('donation artifact payload has unexpected encoding');
23
- }
24
- if (record['sha256'] !== expectedSha256) {
25
- throw new Error('donation artifact sha256 does not match requested artifact');
26
- }
27
- if (typeof record['data'] !== 'string') {
28
- throw new Error('donation artifact payload is missing base64 data');
29
- }
30
- return Buffer.from(record['data'], 'base64');
17
+ /**
18
+ * Adapt a legacy `AcquireFn` to the primitive's clean seam. The legacy shape —
19
+ * a third `privateKey` parameter and a `Buffer | null` return — exists for
20
+ * older test fakes and stays confined to this file.
21
+ */
22
+ function toAcquireResultFn(acquireFn, privateKey) {
23
+ return async (endpoint, sha256) => {
24
+ const raw = await acquireFn(endpoint, sha256, privateKey);
25
+ if (raw === null)
26
+ return { ok: false, reason: 'not_found', message: 'origin returned null (404 / not found)' };
27
+ if (Buffer.isBuffer(raw))
28
+ return { ok: true, content: raw };
29
+ return raw;
30
+ };
31
31
  }
32
32
  export async function acquireArtifactContent(args) {
33
- const { sha256, artifactType, access, store, selfSafeAddress, privateKey, routeResolver, envelopeCid, sources = [], ipfsGatewayUrl, ownerSafe, acquireFn = (endpoint, sha256) => fetchArtifactContent(endpoint, sha256), fetchFromIpfs = defaultFetchFromIpfs, } = args;
33
+ const { sha256, artifactType, access, store, selfSafeAddress, privateKey, routeResolver, envelopeCid, sources = [], ipfsGatewayUrl, ownerSafe, acquireFn = (endpoint, sha256) => fetchArtifactContent(endpoint, sha256), fetchFromIpfs, } = args;
34
34
  const now = () => new Date().toISOString();
35
- // 1. Cache hit
35
+ // 1. Cache hit. network_artifacts is a cache, not authoritative local state:
36
+ // a row that does not hash to its own key is a MISS, and every leg below
37
+ // re-saves through INSERT OR REPLACE, so falling through repairs it. (The
38
+ // self-store path at step 3 throws instead, and rightly — there is no
39
+ // upstream to re-fetch a corrupt served_artifacts row from.)
36
40
  const cached = store.getNetworkArtifact(sha256);
37
41
  if (cached) {
38
- store.touchNetworkArtifactUsage(sha256, now());
39
- return {
40
- sha256,
41
- bytes: cached.content,
42
- artifactType: cached.artifactType,
43
- source: 'cache',
44
- paidAmountUsdc: '0',
45
- fetchedAt: cached.fetchedAt,
46
- sourceOperator: cached.sourceOperator ?? undefined,
47
- };
42
+ // Verified before the usage bump: a row we are about to discard must not
43
+ // have its last_used_at refreshed.
44
+ const verifiedCache = verifyArtifactDigest(sha256, cached.content);
45
+ if (verifiedCache.ok) {
46
+ store.touchNetworkArtifactUsage(sha256, now());
47
+ return {
48
+ sha256,
49
+ bytes: cached.content,
50
+ artifactType: cached.artifactType,
51
+ source: 'cache',
52
+ paidAmountUsdc: '0',
53
+ fetchedAt: cached.fetchedAt,
54
+ sourceOperator: cached.sourceOperator ?? undefined,
55
+ };
56
+ }
57
+ console.warn(`[corpus-read] cached artifact ${sha256} hashed to ${verifiedCache.actualSha256}; `
58
+ + 'treating the row as a miss and re-fetching');
48
59
  }
49
- // 2. Public IPFS donation source.
60
+ // 2. Public IPFS donation source. The primitive owns the fetch, the decode,
61
+ // the verification, and the #3441 fall-through warning; the cache write and
62
+ // the mismatch-is-fatal decision stay here, where the store lives.
50
63
  const ipfsSource = sources.find((source) => source.kind === 'ipfs');
51
64
  if (ipfsSource && ipfsGatewayUrl) {
52
- try {
53
- const raw = await fetchFromIpfs(ipfsGatewayUrl, ipfsSource.cid);
54
- const bytes = decodeDonationArtifact(raw, sha256);
55
- const actualSha = sha256Hex(bytes);
56
- if (actualSha !== sha256) {
57
- throw new HashMismatchError(sha256, actualSha, 'ipfs', ownerSafe);
58
- }
65
+ const retrieved = await fetchVerifiedArtifact({ sha256, artifactType }, { sources, ipfsGatewayUrl, ownerSafe, envelopeCid }, { deps: { fetchFromIpfs } });
66
+ if (retrieved.ok) {
67
+ const bytes = retrieved.artifact.bytes;
59
68
  const ts = now();
60
69
  store.saveNetworkArtifact({
61
70
  sha256,
@@ -78,13 +87,13 @@ export async function acquireArtifactContent(args) {
78
87
  sourceOperator: ownerSafe,
79
88
  };
80
89
  }
81
- catch (err) {
82
- if (err instanceof HashMismatchError)
83
- throw err;
84
- // Donated IPFS is an opportunistic fast path. Gateway failures and
85
- // malformed donation payloads should not block the acquisition chain;
86
- // only verified byte hash mismatches remain fatal.
90
+ if (retrieved.reason === 'digest_mismatch') {
91
+ throw new HashMismatchError(sha256, retrieved.mismatch.actualSha256, 'ipfs', ownerSafe);
87
92
  }
93
+ // Donated IPFS is an opportunistic fast path: gateway failures and
94
+ // malformed donation payloads fall through to the next source. Anything
95
+ // that was not proven absence has already reached the operator as a
96
+ // warning from inside the primitive (#3441).
88
97
  }
89
98
  // 3. Self-store fast path
90
99
  if (ownerSafe && ownerSafe.toLowerCase() === selfSafeAddress.toLowerCase()) {
@@ -95,9 +104,9 @@ export async function acquireArtifactContent(args) {
95
104
  // propagate the bad bytes into network_artifacts where peers can
96
105
  // fetch them. Throwing closes the cache-poisoning gap; the self-store
97
106
  // path is now hash-equivalent to origin / route-resolver.
98
- const actualSha = sha256Hex(own.content);
99
- if (actualSha !== sha256) {
100
- throw new HashMismatchError(sha256, actualSha, 'self-store', selfSafeAddress);
107
+ const verified = verifyArtifactDigest(sha256, own.content);
108
+ if (!verified.ok) {
109
+ throw new HashMismatchError(sha256, verified.actualSha256, 'self-store', selfSafeAddress);
101
110
  }
102
111
  // Mirror into cache so peer asks for the same content can hit cache (provenance: self-store-mirror).
103
112
  const ts = now();
@@ -125,9 +134,9 @@ export async function acquireArtifactContent(args) {
125
134
  try {
126
135
  const out = await routeResolver.resolve({ sha256, access, requesterSafe: selfSafeAddress });
127
136
  if (out) {
128
- const actualSha = sha256Hex(out.bytes);
129
- if (actualSha !== sha256) {
130
- throw new HashMismatchError(sha256, actualSha, 'route-resolver', out.sourceOperator);
137
+ const verified = verifyArtifactDigest(sha256, out.bytes);
138
+ if (!verified.ok) {
139
+ throw new HashMismatchError(sha256, verified.actualSha256, 'route-resolver', out.sourceOperator);
131
140
  }
132
141
  const ts = now();
133
142
  store.saveNetworkArtifact({
@@ -158,36 +167,17 @@ export async function acquireArtifactContent(args) {
158
167
  }
159
168
  }
160
169
  // 5. Origin fetch
161
- let bytes;
162
- try {
163
- const raw = await acquireFn(access.endpoint, sha256, privateKey);
164
- // Accept both the legacy `Buffer | null` shape and the discriminated
165
- // union from fetchArtifactContent. Buffer.isBuffer guards before we
166
- // duck-type into the union.
167
- if (raw === null || Buffer.isBuffer(raw)) {
168
- bytes = raw;
169
- }
170
- else if (raw.ok === true) {
171
- bytes = raw.content;
172
- }
173
- else {
174
- // ok: false — preserve the reason so the caller / daemon route can
175
- // surface it via AcquireError.cause.
176
- throw new AcquireError(sha256, `origin fetch failed: ${raw.reason}${raw.message ? ` (${raw.message})` : ''}`, raw);
170
+ const retrieved = await fetchVerifiedArtifact({ sha256, artifactType }, { endpoint: access.endpoint, ownerSafe, envelopeCid }, { deps: { fetchArtifact: toAcquireResultFn(acquireFn, privateKey) } });
171
+ if (!retrieved.ok) {
172
+ if (retrieved.reason === 'digest_mismatch') {
173
+ throw new HashMismatchError(sha256, retrieved.mismatch.actualSha256, 'origin', ownerSafe);
177
174
  }
175
+ // Preserve the reason token in the message so callers and the daemon route
176
+ // can surface which failure this was.
177
+ const attempt = retrieved.attempts.find((entry) => entry.leg === 'origin');
178
+ throw new AcquireError(sha256, `origin fetch failed: ${retrieved.reason}${attempt?.message ? ` (${attempt.message})` : ''}`, retrieved);
178
179
  }
179
- catch (err) {
180
- if (err instanceof AcquireError)
181
- throw err;
182
- throw new AcquireError(sha256, 'origin fetch failed', err);
183
- }
184
- if (!bytes) {
185
- throw new AcquireError(sha256, 'origin returned null (404 / not found)');
186
- }
187
- const actualSha = sha256Hex(bytes);
188
- if (actualSha !== sha256) {
189
- throw new HashMismatchError(sha256, actualSha, 'origin', ownerSafe);
190
- }
180
+ const bytes = retrieved.artifact.bytes;
191
181
  const ts = now();
192
182
  store.saveNetworkArtifact({
193
183
  sha256,
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Artifact retrieval primitive — fetch by content address, verify, return bytes.
3
+ *
4
+ * One function fetches an artifact from the network legs an envelope names and
5
+ * hands back its bytes only after they hash to the recorded sha256, together
6
+ * with the provenance of the leg that produced them. A digest mismatch fails
7
+ * closed: the failure arm of the result union has no bytes on it at all.
8
+ *
9
+ * Keyless and filesystem-neutral by construction. The primitive itself neither
10
+ * accepts nor touches key material or the filesystem: it takes no store, no
11
+ * signer, no private key, and no path, and it performs no write of any kind.
12
+ * The one channel through which such material can reach a call is the caller's
13
+ * own `deps` seam closure — `acquire.ts` legitimately supplies one that holds a
14
+ * private key — and that closure is opaque here: it is invoked, never read,
15
+ * never logged, and never surfaced in provenance.
16
+ * `test/architecture/keyless-primitive.test.ts` holds the module graph to the
17
+ * same promise.
18
+ *
19
+ * Where the returned bytes land is the caller's decision. The daemon chain
20
+ * (`acquire.ts`) composes this with its byte cache; the CLI hands them to the
21
+ * operator; the MCP handler proxies to a daemon that runs it. None of that is
22
+ * this function's business.
23
+ *
24
+ * Issue #4179 (umbrella #2448 / journey spec B0c).
25
+ */
26
+ import { type AcquireResult } from './fetch-artifact.js';
27
+ import type { ArtifactSource } from './types.js';
28
+ /**
29
+ * Content address of a corpus artifact: the sha256 of the bytes themselves, as
30
+ * recorded in the envelope's `artifact.sha256` — never an IPFS CID (artifacts
31
+ * stopped having CIDs post jinn-mono-vy37.1.2).
32
+ */
33
+ export interface ArtifactAddress {
34
+ /** 64-char lowercase hex. The one thing that is trusted. */
35
+ sha256: string;
36
+ /**
37
+ * Declared type from the envelope. Carried into the result for the caller's
38
+ * convenience and never consulted during verification — an attacker-supplied
39
+ * label cannot influence whether bytes are admitted.
40
+ */
41
+ artifactType?: string;
42
+ }
43
+ /**
44
+ * Where the bytes might be. Every field is manifest-supplied and therefore
45
+ * attacker-controlled; the destination policy and the byte/redirect/deadline
46
+ * caps in `fetch-artifact.ts` and `ipfs.ts` are what make that safe.
47
+ */
48
+ export interface ArtifactLocators {
49
+ /** Donated IPFS sources — the envelope's `artifact.sources` entries. */
50
+ sources?: readonly ArtifactSource[];
51
+ /** Gateway base for the IPFS leg. The leg is skipped when this is absent. */
52
+ ipfsGatewayUrl?: string;
53
+ /** HTTP origin — the envelope's `artifact.access.endpoint`. */
54
+ endpoint?: string;
55
+ /** Envelope CID this artifact was named by. Provenance only. */
56
+ envelopeCid?: string;
57
+ /** Publishing operator's Safe, when known. Provenance and attribution only. */
58
+ ownerSafe?: string;
59
+ }
60
+ export interface FetchVerifiedArtifactOptions {
61
+ /**
62
+ * Injection seams, mirroring the `acquire.ts` precedent so tests need no
63
+ * module mock. A caller that needs to bound or redirect a leg replaces the
64
+ * leg — there is no separate options passthrough, because no call site wants
65
+ * the default transport with different bounds.
66
+ */
67
+ deps?: {
68
+ fetchArtifact?: (endpoint: string, sha256: string) => Promise<AcquireResult>;
69
+ fetchFromIpfs?: (gatewayUrl: string, cid: string) => Promise<unknown>;
70
+ /** Clock seam for `fetchedAt`. */
71
+ now?: () => string;
72
+ };
73
+ }
74
+ /** One leg's outcome, recorded whether it succeeded, was skipped, or failed. */
75
+ export interface ArtifactFetchAttempt {
76
+ readonly leg: 'ipfs' | 'origin';
77
+ /** `ipfs://<cid>` or the exact URL `buildArtifactUrl` produced; empty when skipped. */
78
+ readonly sourceUri: string;
79
+ readonly outcome: 'verified' | 'skipped' | 'failed' | 'digest_mismatch';
80
+ /**
81
+ * Present on `failed`; the classified reason, never a raw string match.
82
+ * `digest_mismatch` is excluded because `refuse()` records that outcome with
83
+ * no `reason` field — no attempt ever carries the token, and saying so here
84
+ * is what lets the failure union below narrow without a cast.
85
+ */
86
+ readonly reason?: Exclude<ArtifactFetchFailureReason, 'digest_mismatch'>;
87
+ readonly message?: string;
88
+ }
89
+ export interface ArtifactProvenance {
90
+ /** Which leg produced the admitted bytes. */
91
+ readonly source: 'ipfs' | 'origin';
92
+ readonly sourceUri: string;
93
+ /** Always 'sha256'. Explicit so the record does not silently mean "some digest". */
94
+ readonly digestAlgorithm: 'sha256';
95
+ /** ISO-8601 instant the bytes were verified, not when they were requested. */
96
+ readonly fetchedAt: string;
97
+ readonly sourceOperator?: string;
98
+ readonly envelopeCid?: string;
99
+ /** Every leg tried, in order. Legible even on the happy path. */
100
+ readonly attempts: readonly ArtifactFetchAttempt[];
101
+ }
102
+ export interface VerifiedArtifact {
103
+ readonly sha256: string;
104
+ /** Verified bytes, in memory. Never a path, never a handle. */
105
+ readonly bytes: Buffer;
106
+ readonly artifactType: string;
107
+ readonly provenance: ArtifactProvenance;
108
+ }
109
+ export type ArtifactFetchFailureReason =
110
+ /** Bytes arrived and did not hash to the address. */
111
+ 'digest_mismatch'
112
+ /** Every attempted leg answered, and every answer was absence. */
113
+ | 'not_found'
114
+ /** Nothing was supplied to try. */
115
+ | 'no_locator'
116
+ /** Destination policy refused an origin or a redirect hop. */
117
+ | 'blocked' | 'too_large' | 'timeout'
118
+ /** Transport failure — nothing was learned about whether the content exists. */
119
+ | 'unavailable'
120
+ /**
121
+ * A locator resolved and what it served is permanently not this artifact —
122
+ * a broken donation payload. Conclusive absence AT THAT LOCATOR, so it is
123
+ * not retryable; never conclusive globally, so it never outranks a leg that
124
+ * learned nothing (#3441).
125
+ */
126
+ | 'malformed_payload';
127
+ /** Evidence, not content. Present on `digest_mismatch` and only there. */
128
+ export interface ArtifactDigestMismatch {
129
+ readonly expectedSha256: string;
130
+ readonly actualSha256: string;
131
+ readonly sourceUri: string;
132
+ readonly sourceOperator?: string;
133
+ }
134
+ interface FetchVerifiedArtifactFailureBase {
135
+ readonly ok: false;
136
+ readonly sha256: string;
137
+ /** Derived from `reason`, never caller-supplied. */
138
+ readonly retryable: boolean;
139
+ readonly message: string;
140
+ readonly attempts: readonly ArtifactFetchAttempt[];
141
+ }
142
+ /**
143
+ * Discriminated on `reason` so the compiler, not a constructor comment, carries
144
+ * the invariant that `digest_mismatch` always ships its evidence. `refuse()` is
145
+ * the sole producer of that reason and has always set `mismatch`; before this
146
+ * split, callers reading the evidence had to assert it with `!`.
147
+ */
148
+ export type FetchVerifiedArtifactFailure = (FetchVerifiedArtifactFailureBase & {
149
+ readonly reason: 'digest_mismatch';
150
+ readonly mismatch: ArtifactDigestMismatch;
151
+ }) | (FetchVerifiedArtifactFailureBase & {
152
+ readonly reason: Exclude<ArtifactFetchFailureReason, 'digest_mismatch'>;
153
+ readonly mismatch?: undefined;
154
+ });
155
+ export type FetchVerifiedArtifactResult = {
156
+ readonly ok: true;
157
+ readonly artifact: VerifiedArtifact;
158
+ } | FetchVerifiedArtifactFailure;
159
+ /**
160
+ * The one digest check in the system. Legs that already hold bytes — the byte
161
+ * cache, the self-store, a route resolver, the MCP daemon proxy — call this
162
+ * rather than writing `createHash('sha256')` out again.
163
+ */
164
+ export declare function verifyArtifactDigest(expectedSha256: string, bytes: Buffer): {
165
+ ok: true;
166
+ } | {
167
+ ok: false;
168
+ actualSha256: string;
169
+ };
170
+ /**
171
+ * Fetch an artifact by content address and return its bytes only after they
172
+ * hash to that address.
173
+ *
174
+ * Legs run in the order the daemon chain has always used: the free donated IPFS
175
+ * mirror first, the operator's HTTP origin second. A leg with no locator is
176
+ * *skipped*, not failed. A digest mismatch on any leg fails the whole call and
177
+ * does not fall through — the bytes are wrong or hostile, and the next locator
178
+ * cannot make them right.
179
+ */
180
+ export declare function fetchVerifiedArtifact(address: ArtifactAddress, locators: ArtifactLocators, options?: FetchVerifiedArtifactOptions): Promise<FetchVerifiedArtifactResult>;
181
+ export {};
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Artifact retrieval primitive — fetch by content address, verify, return bytes.
3
+ *
4
+ * One function fetches an artifact from the network legs an envelope names and
5
+ * hands back its bytes only after they hash to the recorded sha256, together
6
+ * with the provenance of the leg that produced them. A digest mismatch fails
7
+ * closed: the failure arm of the result union has no bytes on it at all.
8
+ *
9
+ * Keyless and filesystem-neutral by construction. The primitive itself neither
10
+ * accepts nor touches key material or the filesystem: it takes no store, no
11
+ * signer, no private key, and no path, and it performs no write of any kind.
12
+ * The one channel through which such material can reach a call is the caller's
13
+ * own `deps` seam closure — `acquire.ts` legitimately supplies one that holds a
14
+ * private key — and that closure is opaque here: it is invoked, never read,
15
+ * never logged, and never surfaced in provenance.
16
+ * `test/architecture/keyless-primitive.test.ts` holds the module graph to the
17
+ * same promise.
18
+ *
19
+ * Where the returned bytes land is the caller's decision. The daemon chain
20
+ * (`acquire.ts`) composes this with its byte cache; the CLI hands them to the
21
+ * operator; the MCP handler proxies to a daemon that runs it. None of that is
22
+ * this function's business.
23
+ *
24
+ * Issue #4179 (umbrella #2448 / journey spec B0c).
25
+ */
26
+ import { createHash } from 'node:crypto';
27
+ import { buildArtifactUrl, fetchArtifactContent, } from './fetch-artifact.js';
28
+ import { classifyIpfsFetchFailure, fetchFromIpfs as defaultFetchFromIpfs } from './ipfs.js';
29
+ const DONATION_ARTIFACT_ENCODING = 'jinn.artifact.donation.v1';
30
+ /** Nothing that was learned tells us to stop; everything else does. */
31
+ const RETRYABLE_REASONS = new Set([
32
+ 'timeout',
33
+ 'unavailable',
34
+ ]);
35
+ /** Reasons that are positive proof of absence at the leg that reported them. */
36
+ const CONCLUSIVE_ABSENCE = new Set([
37
+ 'not_found',
38
+ 'malformed_payload',
39
+ ]);
40
+ /**
41
+ * The one digest check in the system. Legs that already hold bytes — the byte
42
+ * cache, the self-store, a route resolver, the MCP daemon proxy — call this
43
+ * rather than writing `createHash('sha256')` out again.
44
+ */
45
+ export function verifyArtifactDigest(expectedSha256, bytes) {
46
+ const actualSha256 = createHash('sha256').update(bytes).digest('hex');
47
+ return actualSha256 === expectedSha256 ? { ok: true } : { ok: false, actualSha256 };
48
+ }
49
+ /**
50
+ * Log an IPFS read the retrieval is about to fall through, unless the gateway
51
+ * actually answered "not there" (#3441). A cap refusal is positive evidence the
52
+ * content exists, so it must not read like an ordinary miss.
53
+ */
54
+ function warnIpfsFallThrough(subject, error) {
55
+ // A broken donation is not the ordinary absence #3441 carved out of the
56
+ // warning, so it still warns — but it must not read as a transport failure.
57
+ const classification = error instanceof DonationDecodeError
58
+ ? 'malformed-payload'
59
+ : classifyIpfsFetchFailure(error);
60
+ if (classification === 'not-found')
61
+ return;
62
+ const detail = error instanceof Error ? error.message : String(error);
63
+ console.warn(`[corpus-read] IPFS source for ${subject} could not be used (${classification}), `
64
+ + `falling through to the next source: ${detail}`);
65
+ }
66
+ /**
67
+ * A donation payload that will never decode into this artifact. Distinct from a
68
+ * gateway transport failure: the CID resolved, and what it resolved to is not
69
+ * the requested bytes — including a valid donation for a *different* artifact.
70
+ */
71
+ class DonationDecodeError extends Error {
72
+ name = 'DonationDecodeError';
73
+ }
74
+ /**
75
+ * Decode a `jinn.artifact.donation.v1` payload into its raw bytes.
76
+ *
77
+ * Moved verbatim from `acquire.ts`. The `schemaVersion` check compares against
78
+ * the encoding constant — the published fixtures set both fields to that same
79
+ * string, so the comparison is load-bearing as written and is not "corrected"
80
+ * here.
81
+ */
82
+ function decodeDonationArtifact(raw, expectedSha256) {
83
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
84
+ throw new DonationDecodeError('donation artifact payload is not an object');
85
+ }
86
+ const record = raw;
87
+ if (record['schemaVersion'] !== DONATION_ARTIFACT_ENCODING || record['encoding'] !== DONATION_ARTIFACT_ENCODING) {
88
+ throw new DonationDecodeError('donation artifact payload has unexpected encoding');
89
+ }
90
+ if (record['sha256'] !== expectedSha256) {
91
+ throw new DonationDecodeError('donation artifact sha256 does not match requested artifact');
92
+ }
93
+ if (typeof record['data'] !== 'string') {
94
+ throw new DonationDecodeError('donation artifact payload is missing base64 data');
95
+ }
96
+ return Buffer.from(record['data'], 'base64');
97
+ }
98
+ /** `fetchArtifactContent`'s vocabulary → the primitive's. */
99
+ function originReason(reason) {
100
+ return reason === 'network_error' ? 'unavailable' : reason;
101
+ }
102
+ /** `classifyIpfsFetchFailure`'s hyphenated vocabulary → the primitive's. */
103
+ function ipfsReason(classification) {
104
+ if (classification === 'too-large')
105
+ return 'too_large';
106
+ if (classification === 'not-found')
107
+ return 'not_found';
108
+ return 'unavailable';
109
+ }
110
+ function errorMessage(error) {
111
+ return error instanceof Error ? error.message : String(error);
112
+ }
113
+ /**
114
+ * Fetch an artifact by content address and return its bytes only after they
115
+ * hash to that address.
116
+ *
117
+ * Legs run in the order the daemon chain has always used: the free donated IPFS
118
+ * mirror first, the operator's HTTP origin second. A leg with no locator is
119
+ * *skipped*, not failed. A digest mismatch on any leg fails the whole call and
120
+ * does not fall through — the bytes are wrong or hostile, and the next locator
121
+ * cannot make them right.
122
+ */
123
+ export async function fetchVerifiedArtifact(address, locators, options) {
124
+ const { sha256, artifactType } = address;
125
+ const { sources = [], ipfsGatewayUrl, endpoint, envelopeCid, ownerSafe } = locators;
126
+ const fetchIpfs = options?.deps?.fetchFromIpfs ?? defaultFetchFromIpfs;
127
+ const fetchOrigin = options?.deps?.fetchArtifact ?? fetchArtifactContent;
128
+ const now = options?.deps?.now ?? (() => new Date().toISOString());
129
+ const attempts = [];
130
+ const admit = (source, sourceUri, bytes) => {
131
+ attempts.push({ leg: source, sourceUri, outcome: 'verified' });
132
+ return {
133
+ ok: true,
134
+ artifact: {
135
+ sha256,
136
+ bytes,
137
+ artifactType: artifactType ?? 'unknown',
138
+ provenance: {
139
+ source,
140
+ sourceUri,
141
+ digestAlgorithm: 'sha256',
142
+ fetchedAt: now(),
143
+ ...(ownerSafe ? { sourceOperator: ownerSafe } : {}),
144
+ ...(envelopeCid ? { envelopeCid } : {}),
145
+ attempts,
146
+ },
147
+ },
148
+ };
149
+ };
150
+ const refuse = (leg, sourceUri, actualSha256) => {
151
+ attempts.push({ leg, sourceUri, outcome: 'digest_mismatch' });
152
+ return {
153
+ ok: false,
154
+ sha256,
155
+ reason: 'digest_mismatch',
156
+ retryable: false,
157
+ message: `artifact ${sha256} from ${sourceUri} hashed to ${actualSha256}; `
158
+ + 'the bytes were discarded',
159
+ attempts,
160
+ mismatch: {
161
+ expectedSha256: sha256,
162
+ actualSha256,
163
+ sourceUri,
164
+ ...(ownerSafe ? { sourceOperator: ownerSafe } : {}),
165
+ },
166
+ };
167
+ };
168
+ // 1. Donated IPFS mirror — free and public, so it goes first.
169
+ const ipfsSource = sources.find((source) => source.kind === 'ipfs');
170
+ if (ipfsSource && ipfsGatewayUrl) {
171
+ const sourceUri = `ipfs://${ipfsSource.cid}`;
172
+ try {
173
+ const raw = await fetchIpfs(ipfsGatewayUrl, ipfsSource.cid);
174
+ const bytes = decodeDonationArtifact(raw, sha256);
175
+ const verified = verifyArtifactDigest(sha256, bytes);
176
+ if (!verified.ok)
177
+ return refuse('ipfs', sourceUri, verified.actualSha256);
178
+ return admit('ipfs', sourceUri, bytes);
179
+ }
180
+ catch (err) {
181
+ // Neither a gateway failure nor a malformed donation payload is a verdict
182
+ // on the artifact *globally*, so both fall through and let the next
183
+ // locator answer. They differ in what they proved here: a transport
184
+ // failure learned nothing, while a payload that will never decode into
185
+ // this artifact is absence at this locator — hence the split reason. Both
186
+ // still warn; only a gateway's own "not there" is silent (#3441).
187
+ warnIpfsFallThrough(`donation artifact ${sha256}`, err);
188
+ attempts.push({
189
+ leg: 'ipfs',
190
+ sourceUri,
191
+ outcome: 'failed',
192
+ reason: err instanceof DonationDecodeError
193
+ ? 'malformed_payload'
194
+ : ipfsReason(classifyIpfsFetchFailure(err)),
195
+ message: errorMessage(err),
196
+ });
197
+ }
198
+ }
199
+ else {
200
+ attempts.push({ leg: 'ipfs', sourceUri: '', outcome: 'skipped' });
201
+ }
202
+ // 2. Operator HTTP origin.
203
+ if (endpoint) {
204
+ const sourceUri = buildArtifactUrl(endpoint, sha256);
205
+ let outcome;
206
+ try {
207
+ outcome = await fetchOrigin(endpoint, sha256);
208
+ }
209
+ catch (err) {
210
+ outcome = { ok: false, reason: 'network_error', message: errorMessage(err) };
211
+ }
212
+ if (outcome.ok) {
213
+ const verified = verifyArtifactDigest(sha256, outcome.content);
214
+ if (!verified.ok)
215
+ return refuse('origin', sourceUri, verified.actualSha256);
216
+ return admit('origin', sourceUri, outcome.content);
217
+ }
218
+ attempts.push({
219
+ leg: 'origin',
220
+ sourceUri,
221
+ outcome: 'failed',
222
+ reason: originReason(outcome.reason),
223
+ ...(outcome.message ? { message: outcome.message } : {}),
224
+ });
225
+ }
226
+ else {
227
+ attempts.push({ leg: 'origin', sourceUri: '', outcome: 'skipped' });
228
+ }
229
+ // Nothing verified. Absence is only claimed when every attempted leg said so:
230
+ // one inconclusive leg means we learned nothing about existence (#3441).
231
+ const failures = attempts.filter((attempt) => attempt.outcome === 'failed');
232
+ if (failures.length === 0) {
233
+ // Name why each leg was skipped rather than assuming both locators were
234
+ // absent: donated sources with no gateway to read them through is its own
235
+ // case, and reporting it as "no donated IPFS source" is simply wrong.
236
+ const missing = [
237
+ ipfsSource
238
+ ? 'a donated IPFS source but no gateway URL to read it through'
239
+ : 'no donated IPFS source',
240
+ 'no origin endpoint',
241
+ ];
242
+ return {
243
+ ok: false,
244
+ sha256,
245
+ reason: 'no_locator',
246
+ retryable: false,
247
+ message: `artifact ${sha256} has no locator to try: ${missing.join(', and ')}`,
248
+ attempts,
249
+ };
250
+ }
251
+ // When more than one leg fails inconclusively, the last one wins. Legs run
252
+ // cheapest-first — the opportunistic donated mirror, then the operator's own
253
+ // origin — so the last leg to answer is the artifact's actual home, and its
254
+ // reason is the one the operator can act on.
255
+ // Absence is a set, not one literal: a leg that resolved and served bytes that
256
+ // are permanently not this artifact answered as conclusively as a 404 did.
257
+ const inconclusive = failures.filter((attempt) => !CONCLUSIVE_ABSENCE.has(attempt.reason));
258
+ const reason = (inconclusive.length > 0 ? inconclusive : failures).at(-1).reason;
259
+ return {
260
+ ok: false,
261
+ sha256,
262
+ reason,
263
+ retryable: RETRYABLE_REASONS.has(reason),
264
+ message: `artifact ${sha256} could not be retrieved (${reason}) from `
265
+ + failures.map((attempt) => `${attempt.sourceUri}: ${attempt.reason}`).join('; '),
266
+ attempts,
267
+ };
268
+ }