Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
export type {
AgentRegistrationFile,
CreateRegistrationFileParameters,
FetchRegistrationFileOptions,
RegistrationBinding,
ResolvedServiceEndpoint,
ResolveServiceEndpointParameters,
Expand Down
95 changes: 87 additions & 8 deletions src/registration/fetch.ts
Original file line number Diff line number Diff line change
@@ -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://<cid>` — 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<AgentRegistrationFile> {
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),
})

Expand All @@ -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')
}

Expand All @@ -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)
}
1 change: 1 addition & 0 deletions src/registration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { findService, findServices } from './services.js'
export {
type AgentRegistrationFile,
type CreateRegistrationFileParameters,
type FetchRegistrationFileOptions,
REGISTRATION_TYPE,
type RegistrationBinding,
type ResolvedServiceEndpoint,
Expand Down
2 changes: 1 addition & 1 deletion src/registration/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down
8 changes: 7 additions & 1 deletion src/registration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
191 changes: 189 additions & 2 deletions tests/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 })

Expand Down
Loading