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.2",
"version": "0.1.0-alpha.1",
"description": "Lightweight TypeScript SDK for ERC-8004 (Trustless Agents) — Identity and Reputation registries",
"type": "module",
"license": "Apache-2.0",
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export {
export type {
AgentRegistrationFile,
CreateRegistrationFileParameters,
FetchRegistrationFileOptions,
RegistrationBinding,
ResolvedServiceEndpoint,
ResolveServiceEndpointParameters,
Expand Down
85 changes: 6 additions & 79 deletions src/registration/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,21 @@
import { parseRegistrationFile } from './parse.js'
import type { AgentRegistrationFile } from './types.js'

const DEFAULT_IPFS_GATEWAY = 'https://ipfs.io'

export interface FetchRegistrationFileOptions {
/** IPFS gateway base URL. Default: `https://ipfs.io`. */
ipfsGateway?: string
}

/**
* Fetch and validate an Agent Registration File from a URI.
* Fetch and validate an Agent Registration File from an HTTPS URL.
*
* 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)
* Uses `globalThis.fetch` — no external HTTP dependencies required.
*
* @throws on unsupported scheme, non-200 response, non-JSON body, or invalid schema
* @throws on non-HTTPS URL, non-200 response, non-JSON body, or invalid schema
*/
export async function fetchRegistrationFile(
uri: string,
options?: FetchRegistrationFileOptions,
): Promise<AgentRegistrationFile> {
if (uri.startsWith('data:')) {
return parseDataUri(uri)
if (!uri.startsWith('https://')) {
throw new Error(`Only HTTPS URIs are supported, got: ${uri.slice(0, 40)}`)
}

let fetchUrl: string
if (uri.startsWith('ipfs://')) {
const gateway = options?.ipfsGateway ?? DEFAULT_IPFS_GATEWAY
const base = gateway.endsWith('/') ? gateway.slice(0, -1) : gateway
const path = uri.slice(7) // strip "ipfs://"
fetchUrl = `${base}/ipfs/${path}`
} 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(fetchUrl, {
const response = await globalThis.fetch(uri, {
signal: AbortSignal.timeout(10_000),
})

Expand All @@ -65,50 +39,3 @@ 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 ? atob(payload) : decodeURIComponent(payload)
} catch {
throw new Error(
`Failed to decode data URI (${isBase64 ? 'base64' : 'percent-encoded'})`,
)
}

if (decoded.length > 1_048_576) {
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)
}
5 changes: 1 addition & 4 deletions src/registration/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
export { createRegistrationFile } from './build.js'
export {
type FetchRegistrationFileOptions,
fetchRegistrationFile,
} from './fetch.js'
export { fetchRegistrationFile } from './fetch.js'
export { parseRegistrationFile } from './parse.js'
export { resolveServiceEndpoint } from './resolve.js'
export { findService, findServices } from './services.js'
Expand Down
4 changes: 1 addition & 3 deletions src/registration/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ export async function resolveServiceEndpoint(

let file: AgentRegistrationFile
try {
file = await fetchRegistrationFile(agent.agentURI, {
ipfsGateway: parameters.ipfsGateway,
})
file = await fetchRegistrationFile(agent.agentURI)
} catch (error) {
throw new Error(
`Agent ${parameters.agentId}: ${error instanceof Error ? error.message : String(error)}`,
Expand Down
2 changes: 0 additions & 2 deletions src/registration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ export interface ResolveServiceEndpointParameters {
agentId: bigint
serviceName: string
registryAddress?: Address
/** IPFS gateway base URL for `ipfs://` agent URIs. Default: `https://ipfs.io`. */
ipfsGateway?: string
}

export interface ResolvedServiceEndpoint {
Expand Down
126 changes: 2 additions & 124 deletions tests/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,132 +319,10 @@ describe('fetchRegistrationFile', () => {
expect(result.name).toBe('My Agent')
})

it('throws on unsupported URI scheme', async () => {
it('throws on non-HTTPS URL', async () => {
await expect(
fetchRegistrationFile('http://example.com/agent.json'),
).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('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(),
)
).rejects.toThrow('Only HTTPS URIs are supported')
})

it('throws on non-200 response', async () => {
Expand Down
Loading