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
21 changes: 21 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: verify

on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
18 changes: 15 additions & 3 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ function text(
return value;
}

function multilineText(
value: unknown,
label: string,
maximum = 32_000,
): string {
if (
typeof value !== 'string' || value.length === 0 || value.length > maximum ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
) throw new Error(`gateway ${label} is invalid`);
return value;
}

function decimal(value: unknown, label: string): string {
const found = text(value, label, 80);
if (!/^\d+$/.test(found)) throw new Error(`gateway ${label} is invalid`);
Expand Down Expand Up @@ -290,7 +302,7 @@ function parsePublicSkill(value: unknown): PublicSkill {
id: text(listing.listingId, 'listing ID', 64),
skillId: text(skill.skillId, 'skill ID', 96),
name: text(presentation.name, 'skill name', 160),
description: text(presentation.description, 'skill description'),
description: multilineText(presentation.description, 'skill description'),
tags: strings(presentation.tags, 'skill tags', 64),
documentationUrl: httpsUrl(
presentation.documentationUrl,
Expand All @@ -301,7 +313,7 @@ function parsePublicSkill(value: unknown): PublicSkill {
variable: paymentRequired && fixedAmountAtomic === null,
paymentRequired,
acceptingNewOrders: booleanValue(
contract.acceptingNewOrders,
skill.acceptingNewOrders,
'skill acceptingNewOrders',
),
fulfillmentMode: fulfillmentMode as PublicSkill['fulfillmentMode'],
Expand Down Expand Up @@ -360,7 +372,7 @@ function parsePublicService(value: unknown): PublicService {
categoryFamily: text(service.categoryFamily, 'category family', 128),
serviceType: text(service.serviceType, 'service type', 128),
jurisdictions: strings(service.jurisdictions, 'service jurisdictions', 64),
serviceDescription: text(item.description, 'service description'),
serviceDescription: multilineText(item.description, 'service description'),
serviceLifecycle: text(service.lifecycle, 'service lifecycle', 128),
turnaroundEstimate: text(
service.turnaroundEstimate,
Expand Down
101 changes: 27 additions & 74 deletions test/service-catalog.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
parseServiceIndex,
Expand All @@ -7,88 +8,40 @@ import {
servicePath,
} from '../src/lib/api.ts';

const service = {
gatewayRegistrationId: '4ce2f9c8-2980-49ad-a919-3e0acf1ea4e7',
providerAgentId: '41',
serviceId: `0x${'ab'.repeat(32)}`,
agentCardUrl: 'https://provider.example/agent-cards/lab-analysis.json',
providerPayee: `0x${'12'.repeat(20)}`,
legal: {
marketplaceTermsUrl: 'https://daski.example/terms',
marketplacePrivacyUrl: 'https://daski.example/privacy',
providerLegalName: 'Example Research LLC',
providerTermsUrl: 'https://provider.example/terms',
providerPrivacyUrl: 'https://provider.example/privacy',
},
name: 'Laboratory Analysis',
description: 'Independent laboratory analysis for submitted samples.',
service: {
serviceId: `0x${'ab'.repeat(32)}`,
slug: 'lab-analysis',
version: '1',
categoryFamily: 'scientific-services',
serviceType: 'laboratory-analysis',
jurisdictions: ['US'],
lifecycle: 'active',
turnaroundEstimate: '3-5 business days',
acceptingNewOrders: true,
},
standardRail: {
origin: 'https://provider.example/',
providerAudience: 'https://provider.example',
quoteUrl: 'https://provider.example/standard-rail/quote',
dispatchUrl: 'https://provider.example/standard-rail/dispatch',
dispatchStatusUrl: 'https://provider.example/standard-rail/status',
lifecycleUrl: 'https://provider.example/standard-rail/lifecycle',
assetQueryUrl: 'https://provider.example/standard-rail/assets',
assetActionUrl: 'https://provider.example/standard-rail/actions',
},
skills: [{
skillId: 'analyze-sample',
skillContractHash: `0x${'cd'.repeat(32)}`,
presentation: {
name: 'Analyze sample',
description: 'Analyze one submitted sample.',
examples: [],
tags: ['laboratory'],
documentationUrl: 'https://provider.example/docs/analyze-sample',
},
contract: {
inputSchema: { type: 'object' },
resultSchema: { type: 'object' },
pricing: { USDC: { fixed_amount: '2500000' } },
paymentRequired: true,
requiresAssetOwnership: false,
assetType: null,
fulfillmentMode: 'human',
acceptingNewOrders: true,
capacity: { maxOpenOrders: 4 },
deadlines: {},
assetAction: null,
},
listing: {
listingId: 'listing-lab-analysis',
listingKey: `0x${'ef'.repeat(32)}`,
paymentRequired: true,
splitterAddress: `0x${'34'.repeat(20)}`,
},
}],
freshness: {
lastValidatedAt: '2026-08-26T12:00:00.000Z',
presentationStaleAfterSeconds: 86400,
commerceFreshnessSeconds: 300,
},
};
const serviceIndex = JSON.parse(readFileSync(
new URL('./vectors/public-v3-services.json', import.meta.url),
'utf8',
));
const service = serviceIndex.services[0];
const copyServiceIndex = () => structuredClone(serviceIndex);

test('parses the service-first gateway catalog without category allowlists', () => {
const parsed = parseServiceIndex({ services: [service] });
test('parses the gateway-owned public services contract', () => {
const parsed = parseServiceIndex(copyServiceIndex());

assert.equal(parsed.services.length, 1);
assert.equal(parsed.services[0].categoryFamily, 'scientific-services');
assert.equal(parsed.services[0].providerName, 'Example Research LLC');
assert.equal(parsed.services[0].skills[0].skillId, 'analyze-sample');
assert.equal(parsed.services[0].skills[0].acceptingNewOrders, true);
assert.match(parsed.services[0].skills[0].description, /\nReturns a signed result\./);
assert.equal(priceRange(parsed.services[0]), '2.5 USDC');
assert.equal(servicePath(parsed.services[0]), `/service/${service.serviceId}`);
assert.equal(service.skills[0].contract.acceptingNewOrders, undefined);
});

test('reads mutable skill availability outside the hashed contract', () => {
const paused = copyServiceIndex();
paused.services[0].skills[0].acceptingNewOrders = false;
paused.services[0].skills[0].contract.acceptingNewOrders = true;
assert.equal(parseServiceIndex(paused).services[0].skills[0].acceptingNewOrders, false);

const nestedOnly = copyServiceIndex();
delete nestedOnly.services[0].skills[0].acceptingNewOrders;
nestedOnly.services[0].skills[0].contract.acceptingNewOrders = true;
assert.throws(
() => parseServiceIndex(nestedOnly),
/skill acceptingNewOrders is invalid/,
);
});

test('rejects duplicate canonical service ids', () => {
Expand Down
94 changes: 94 additions & 0 deletions test/vectors/public-v3-services.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"services": [
{
"gatewayRegistrationId": "4ce2f9c8-2980-49ad-a919-3e0acf1ea4e7",
"providerAgentId": "41",
"serviceId": "0xabababababababababababababababababababababababababababababababab",
"agentCardUrl": "https://provider.example/agent-cards/lab-analysis.json",
"providerPayee": "0x1212121212121212121212121212121212121212",
"legal": {
"marketplaceTermsUrl": "https://daski.example/terms",
"marketplacePrivacyUrl": "https://daski.example/privacy",
"providerLegalName": "Example Research LLC",
"providerTermsUrl": "https://provider.example/terms",
"providerPrivacyUrl": "https://provider.example/privacy"
},
"name": "Laboratory Analysis",
"description": "Independent laboratory analysis for submitted samples.\nResults are returned as structured data.",
"service": {
"serviceId": "0xabababababababababababababababababababababababababababababababab",
"slug": "lab-analysis",
"version": "1",
"categoryFamily": "scientific-services",
"serviceType": "laboratory-analysis",
"jurisdictions": [
"US"
],
"lifecycle": "active",
"turnaroundEstimate": "3-5 business days",
"acceptingNewOrders": true
},
"standardRail": {
"origin": "https://provider.example/",
"providerAudience": "https://provider.example",
"quoteUrl": "https://provider.example/standard-rail/quote",
"dispatchUrl": "https://provider.example/standard-rail/dispatch",
"dispatchStatusUrl": "https://provider.example/standard-rail/status",
"lifecycleUrl": "https://provider.example/standard-rail/lifecycle",
"assetQueryUrl": "https://provider.example/standard-rail/assets",
"assetActionUrl": "https://provider.example/standard-rail/actions"
},
"skills": [
{
"skillId": "analyze-sample",
"skillContractHash": "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"acceptingNewOrders": true,
"presentation": {
"name": "Analyze sample",
"description": "Analyze one submitted sample.\nReturns a signed result.",
"examples": [],
"tags": [
"laboratory"
],
"documentationUrl": "https://provider.example/docs/analyze-sample"
},
"contract": {
"inputSchema": {
"type": "object"
},
"resultSchema": {
"type": "object"
},
"pricing": {
"USDC": {
"fixed_amount": "2500000"
}
},
"paymentRequired": true,
"requiresAssetOwnership": false,
"assetType": null,
"fulfillmentMode": "human",
"capacity": {
"maxOpenOrders": 4
},
"deadlines": {},
"assetAction": null
},
"listing": {
"listingId": "listing-lab-analysis",
"listingKey": "0xefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef",
"paymentRequired": true,
"splitterAddress": "0x3434343434343434343434343434343434343434",
"runtimeCommitmentHash": null,
"runtimeCommitment": null
}
}
],
"freshness": {
"lastValidatedAt": "2026-08-26T12:00:00.000Z",
"presentationStaleAfterSeconds": 86400,
"commerceFreshnessSeconds": 300
}
}
]
}
Loading