@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.
- package/dist/corpus-read/acquire.d.ts +6 -0
- package/dist/corpus-read/acquire.js +71 -81
- package/dist/corpus-read/artifact-retrieval.d.ts +181 -0
- package/dist/corpus-read/artifact-retrieval.js +268 -0
- package/dist/corpus-read/cache.d.ts +7 -8
- package/dist/corpus-read/cache.js +1 -17
- package/dist/corpus-read/env.d.ts +9 -0
- package/dist/corpus-read/env.js +15 -0
- package/dist/corpus-read/fetch-artifact.js +1 -14
- package/dist/corpus-read/index.d.ts +1 -1
- package/dist/corpus-read/index.js +1 -1
- package/dist/corpus-read/ipfs.d.ts +112 -0
- package/dist/corpus-read/ipfs.js +372 -60
- package/package.json +2 -2
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
const bytes =
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
99
|
-
if (
|
|
100
|
-
throw new HashMismatchError(sha256,
|
|
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
|
|
129
|
-
if (
|
|
130
|
-
throw new HashMismatchError(sha256,
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
+
}
|