diff --git a/package.json b/package.json index 3b8eddb..24c6561 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@x402r/erc8004", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "description": "Lightweight TypeScript SDK for ERC-8004 (Trustless Agents) — Identity and Reputation registries", "type": "module", "license": "Apache-2.0", diff --git a/src/index.ts b/src/index.ts index 4d28c51..e74f887 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ export { export type { AgentRegistrationFile, CreateRegistrationFileParameters, + FetchRegistrationFileOptions, RegistrationBinding, ResolvedServiceEndpoint, ResolveServiceEndpointParameters, diff --git a/src/registration/fetch.ts b/src/registration/fetch.ts index 0ef888b..b2b3d85 100644 --- a/src/registration/fetch.ts +++ b/src/registration/fetch.ts @@ -1,21 +1,49 @@ import { parseRegistrationFile } from './parse.js' -import type { AgentRegistrationFile } from './types.js' +import type { + AgentRegistrationFile, + FetchRegistrationFileOptions, +} from './types.js' + +const DEFAULT_IPFS_GATEWAY = 'https://ipfs.io' +const MAX_REGISTRATION_FILE_SIZE = 1_048_576 /** - * Fetch and validate an Agent Registration File from an HTTPS URL. + * Fetch and validate an Agent Registration File from a URI. * - * Uses `globalThis.fetch` — no external HTTP dependencies required. + * Supported schemes: + * - `https://` — fetched via `globalThis.fetch` + * - `data:application/json;base64,...` — decoded inline (fully on-chain storage) + * - `data:application/json,...` — URL-decoded inline + * - `ipfs://` — resolved via public IPFS gateway (configurable) * - * @throws on non-HTTPS URL, non-200 response, non-JSON body, or invalid schema + * @throws on unsupported scheme, non-200 response, non-JSON body, or invalid schema */ export async function fetchRegistrationFile( uri: string, + options?: FetchRegistrationFileOptions, ): Promise { - if (!uri.startsWith('https://')) { - throw new Error(`Only HTTPS URIs are supported, got: ${uri.slice(0, 40)}`) + if (uri.startsWith('data:')) { + return parseDataUri(uri) + } + + let fetchUrl: string + if (uri.startsWith('ipfs://')) { + const cidPath = uri.slice(7) // strip "ipfs://" + if (!cidPath || cidPath.split('/').some((s) => s === '..' || s === '.')) { + throw new Error('Invalid IPFS URI: empty CID or path traversal') + } + const gateway = options?.ipfsGateway ?? DEFAULT_IPFS_GATEWAY + const base = gateway.endsWith('/') ? gateway.slice(0, -1) : gateway + fetchUrl = `${base}/ipfs/${cidPath}` + } else if (uri.startsWith('https://')) { + fetchUrl = uri + } else { + throw new Error( + `Unsupported URI scheme (expected https, data, or ipfs): ${uri.slice(0, 40)}`, + ) } - const response = await globalThis.fetch(uri, { + const response = await globalThis.fetch(fetchUrl, { signal: AbortSignal.timeout(10_000), }) @@ -26,7 +54,7 @@ export async function fetchRegistrationFile( } const text = await response.text() - if (text.length > 1_048_576) { + if (text.length > MAX_REGISTRATION_FILE_SIZE) { throw new Error('Registration file exceeds 1 MB size limit') } @@ -39,3 +67,54 @@ export async function fetchRegistrationFile( return parseRegistrationFile(json) } + +// --------------------------------------------------------------------------- +// data: URI parsing +// --------------------------------------------------------------------------- + +function parseDataUri(uri: string): AgentRegistrationFile { + const commaIndex = uri.indexOf(',') + if (commaIndex === -1) { + throw new Error('Malformed data URI: missing comma separator') + } + + const header = uri.slice(5, commaIndex) // strip "data:" prefix + const payload = uri.slice(commaIndex + 1) + + // Validate MIME type — accept application/json with optional charset + const mimeEnd = header.indexOf(';') + const mime = mimeEnd === -1 ? header : header.slice(0, mimeEnd) + if (mime !== 'application/json') { + throw new Error( + `data URI must have application/json MIME type, got: ${mime}`, + ) + } + + const isBase64 = header.endsWith(';base64') + + let decoded: string + try { + decoded = isBase64 + ? new TextDecoder().decode( + Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)), + ) + : decodeURIComponent(payload) + } catch { + throw new Error( + `Failed to decode data URI (${isBase64 ? 'base64' : 'percent-encoded'})`, + ) + } + + if (decoded.length > MAX_REGISTRATION_FILE_SIZE) { + throw new Error('Registration file exceeds 1 MB size limit') + } + + let json: unknown + try { + json = JSON.parse(decoded) + } catch { + throw new Error('data URI content is not valid JSON') + } + + return parseRegistrationFile(json) +} diff --git a/src/registration/index.ts b/src/registration/index.ts index 997c5a7..5b51642 100644 --- a/src/registration/index.ts +++ b/src/registration/index.ts @@ -6,6 +6,7 @@ export { findService, findServices } from './services.js' export { type AgentRegistrationFile, type CreateRegistrationFileParameters, + type FetchRegistrationFileOptions, REGISTRATION_TYPE, type RegistrationBinding, type ResolvedServiceEndpoint, diff --git a/src/registration/resolve.ts b/src/registration/resolve.ts index 7c55c47..212aeb9 100644 --- a/src/registration/resolve.ts +++ b/src/registration/resolve.ts @@ -31,7 +31,7 @@ export async function resolveServiceEndpoint( let file: AgentRegistrationFile try { - file = await fetchRegistrationFile(agent.agentURI) + file = await fetchRegistrationFile(agent.agentURI, parameters) } catch (error) { throw new Error( `Agent ${parameters.agentId}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/registration/types.ts b/src/registration/types.ts index ac4d8c1..a835c49 100644 --- a/src/registration/types.ts +++ b/src/registration/types.ts @@ -31,7 +31,13 @@ export type CreateRegistrationFileParameters = Omit< 'type' > -export interface ResolveServiceEndpointParameters { +export interface FetchRegistrationFileOptions { + /** IPFS gateway base URL. Default: `https://ipfs.io`. */ + ipfsGateway?: string +} + +export interface ResolveServiceEndpointParameters + extends FetchRegistrationFileOptions { agentId: bigint serviceName: string registryAddress?: Address diff --git a/tests/registration.test.ts b/tests/registration.test.ts index 5c8a908..8c76fe7 100644 --- a/tests/registration.test.ts +++ b/tests/registration.test.ts @@ -319,10 +319,144 @@ describe('fetchRegistrationFile', () => { expect(result.name).toBe('My Agent') }) - it('throws on non-HTTPS URL', async () => { + it('throws on unsupported URI scheme', async () => { await expect( fetchRegistrationFile('http://example.com/agent.json'), - ).rejects.toThrow('Only HTTPS URIs are supported') + ).rejects.toThrow('Unsupported URI scheme') + await expect( + fetchRegistrationFile('ftp://example.com/file'), + ).rejects.toThrow('Unsupported URI scheme') + }) + + // --- data: URIs --- + + it('parses base64-encoded data URI', async () => { + const json = JSON.stringify(validPayload()) + const encoded = btoa(json) + const result = await fetchRegistrationFile( + `data:application/json;base64,${encoded}`, + ) + expect(result.name).toBe('My Agent') + }) + + it('parses URL-encoded data URI', async () => { + const json = JSON.stringify(validPayload()) + const encoded = encodeURIComponent(json) + const result = await fetchRegistrationFile( + `data:application/json,${encoded}`, + ) + expect(result.name).toBe('My Agent') + }) + + it('parses data URI with charset parameter', async () => { + const json = JSON.stringify(validPayload()) + const encoded = btoa(json) + const result = await fetchRegistrationFile( + `data:application/json;charset=utf-8;base64,${encoded}`, + ) + expect(result.name).toBe('My Agent') + }) + + it('throws on data URI with wrong MIME type', async () => { + await expect( + fetchRegistrationFile('data:text/plain;base64,aGVsbG8='), + ).rejects.toThrow('application/json MIME type') + }) + + it('throws on data URI with malformed base64', async () => { + await expect( + fetchRegistrationFile('data:application/json;base64,!!!invalid'), + ).rejects.toThrow('Failed to decode') + }) + + it('throws on data URI with invalid JSON', async () => { + const encoded = btoa('not json') + await expect( + fetchRegistrationFile(`data:application/json;base64,${encoded}`), + ).rejects.toThrow('not valid JSON') + }) + + it('throws on data URI exceeding size limit', async () => { + const big = JSON.stringify({ + ...validPayload(), + pad: 'x'.repeat(1_048_577), + }) + const encoded = btoa(big) + await expect( + fetchRegistrationFile(`data:application/json;base64,${encoded}`), + ).rejects.toThrow('exceeds 1 MB') + }) + + it('throws on data URI missing comma', async () => { + await expect( + fetchRegistrationFile('data:application/json;base64'), + ).rejects.toThrow('missing comma') + }) + + // --- ipfs:// URIs --- + + it('rewrites ipfs:// to default gateway', async () => { + const fetchSpy = mockFetch(JSON.stringify(validPayload())) + vi.stubGlobal('fetch', fetchSpy) + + await fetchRegistrationFile('ipfs://QmTest123') + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://ipfs.io/ipfs/QmTest123', + expect.anything(), + ) + }) + + it('accepts custom ipfsGateway', async () => { + const fetchSpy = mockFetch(JSON.stringify(validPayload())) + vi.stubGlobal('fetch', fetchSpy) + + await fetchRegistrationFile('ipfs://QmTest123', { + ipfsGateway: 'https://gateway.pinata.cloud', + }) + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://gateway.pinata.cloud/ipfs/QmTest123', + expect.anything(), + ) + }) + + it('handles ipfs:// with path after CID', async () => { + const fetchSpy = mockFetch(JSON.stringify(validPayload())) + vi.stubGlobal('fetch', fetchSpy) + + await fetchRegistrationFile('ipfs://QmTest123/metadata.json') + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://ipfs.io/ipfs/QmTest123/metadata.json', + expect.anything(), + ) + }) + + it('throws on ipfs:// with empty CID', async () => { + await expect(fetchRegistrationFile('ipfs://')).rejects.toThrow( + 'empty CID or path traversal', + ) + }) + + it('throws on ipfs:// with path traversal', async () => { + await expect( + fetchRegistrationFile('ipfs://QmFoo/../../../other'), + ).rejects.toThrow('empty CID or path traversal') + }) + + it('handles ipfsGateway with trailing slash', async () => { + const fetchSpy = mockFetch(JSON.stringify(validPayload())) + vi.stubGlobal('fetch', fetchSpy) + + await fetchRegistrationFile('ipfs://QmTest123', { + ipfsGateway: 'https://ipfs.io/', + }) + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://ipfs.io/ipfs/QmTest123', + expect.anything(), + ) }) it('throws on non-200 response', async () => { @@ -436,6 +570,59 @@ describe('resolveServiceEndpoint', () => { ).rejects.toThrow('Agent 42 has no URI set') }) + it('passes ipfsGateway through to fetch for ipfs:// URIs', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(registrationPayload)), + }) + vi.stubGlobal('fetch', fetchSpy) + + const client = mockPublic({ + ownerOf: ADDR_A, + getAgentWallet: ADDR_A, + tokenURI: 'ipfs://QmTest123', + }) + + await resolveServiceEndpoint(client, { + agentId: 42n, + serviceName: 'web', + registryAddress: REGISTRY, + ipfsGateway: 'https://gateway.pinata.cloud', + }) + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://gateway.pinata.cloud/ipfs/QmTest123', + expect.anything(), + ) + }) + + it('uses default IPFS gateway when ipfsGateway is omitted', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(registrationPayload)), + }) + vi.stubGlobal('fetch', fetchSpy) + + const client = mockPublic({ + ownerOf: ADDR_A, + getAgentWallet: ADDR_A, + tokenURI: 'ipfs://QmTest123', + }) + + await resolveServiceEndpoint(client, { + agentId: 42n, + serviceName: 'web', + registryAddress: REGISTRY, + }) + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://ipfs.io/ipfs/QmTest123', + expect.anything(), + ) + }) + it('includes agent ID in fetch error messages', async () => { stubFetch('', { ok: false, status: 500 })