Skip to content

Commit 3175833

Browse files
server: structured JSON errors + pre-payment validation
Every error is now JSON { code, message, nextAction, docs } with a stable `code` field agents can branch on. Three failure modes are caught BEFORE paymentMiddleware so agents are never charged USDC for guaranteed-failure requests: - UNKNOWN_TIER (404) — path tier not 1day|7days|30days - MISSING_SENTINEL_ADDR (400) — body lacks sentinelAddr - INVALID_SENTINEL_ADDR (400) — sentinelAddr doesn't match sent1-bech32 regex Replaces Express's default HTML 404 with a JSON catch-all (NOT_FOUND), and adds a final error handler so any uncaught exception still renders as structured JSON (INTERNAL_ERROR). /manifest.response.errors now publishes the full code table; docs/llms.txt adds an Errors section mirroring it. Verified locally with curl probes — all four error modes return structured JSON; valid requests still flow through paymentMiddleware to HTTP 402.
1 parent 7b211bc commit 3175833

2 files changed

Lines changed: 116 additions & 10 deletions

File tree

docs/llms.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ const vpn = await connect({
125125
}
126126
```
127127

128+
## Errors
129+
130+
All errors are JSON `{ code, message, nextAction, docs }`. Branch on `code` — it is the stable contract. The first three are returned **before** payment is taken, so agents are never charged USDC for a guaranteed-failure request.
131+
132+
| `code` | Status | Pre-payment | Meaning |
133+
|---|---|---|---|
134+
| `MISSING_SENTINEL_ADDR` | 400 | yes | Body did not include `sentinelAddr`. |
135+
| `INVALID_SENTINEL_ADDR` | 400 | yes | `sentinelAddr` does not match `^sent1[0-9a-z]{38}$`. Use `createWallet()` from `blue-js-sdk/ai-path`. |
136+
| `UNKNOWN_TIER` | 404 | yes | Path tier is not `1day` \| `7days` \| `30days`. |
137+
| `PAYMENT_REQUIRED` | 402 | — | Sign EIP-3009 `transferWithAuthorization` and resend with the `PAYMENT-SIGNATURE` header. `@x402/fetch` handles this automatically. |
138+
| `PROVISIONING_FAILED` | 500 | — | Payment settled but Sentinel TX failed. Safe to retry once; EIP-3009 nonce prevents double-charge. |
139+
| `NOT_FOUND` | 404 | — | No route matches. See `/manifest`. |
140+
| `INTERNAL_ERROR` | 500 | — | Unexpected server error. |
141+
128142
## Security properties
129143

130144
- Agent's Sentinel private key never leaves the agent. Handshake is agent ↔ node.

server/src/index.ts

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,55 @@ const resourceServer = new x402ResourceServer(facilitator)
7070
const app = express();
7171
app.use(express.json());
7272

73+
// ─── Pre-payment Validation ───
74+
// Runs BEFORE paymentMiddleware so agents never pay for requests that are
75+
// guaranteed to fail (bad tier, missing/malformed sentinelAddr). Every error
76+
// is structured JSON with a stable `code` field agents can branch on.
77+
78+
const VALID_TIERS = new Set(['1day', '7days', '30days']);
79+
const SENTINEL_ADDR_RE = /^sent1[0-9a-z]{38}$/;
80+
81+
app.use((req, res, next) => {
82+
if (req.method !== 'POST') return next();
83+
const m = req.path.match(/^\/vpn\/connect\/([^/]+)$/);
84+
if (!m) return next();
85+
const tier = m[1];
86+
87+
if (!VALID_TIERS.has(tier)) {
88+
return res.status(404).json({
89+
code: 'UNKNOWN_TIER',
90+
message: `Tier '${tier}' does not exist. Valid tiers: 1day, 7days, 30days.`,
91+
validTiers: ['1day', '7days', '30days'],
92+
nextAction: 'POST /vpn/connect/{1day|7days|30days} — see GET /manifest for prices.',
93+
docs: '/manifest',
94+
});
95+
}
96+
97+
const body = (req.body ?? {}) as Record<string, unknown>;
98+
const sentinelAddr = body.sentinelAddr;
99+
100+
if (typeof sentinelAddr !== 'string' || sentinelAddr.length === 0) {
101+
return res.status(400).json({
102+
code: 'MISSING_SENTINEL_ADDR',
103+
message: 'Request body must include `sentinelAddr` (string, sent1-bech32 address).',
104+
nextAction: "Call createWallet() from 'blue-js-sdk/ai-path' to mint one, then resend with { sentinelAddr: wallet.address }.",
105+
docs: '/manifest',
106+
});
107+
}
108+
109+
if (!SENTINEL_ADDR_RE.test(sentinelAddr)) {
110+
return res.status(400).json({
111+
code: 'INVALID_SENTINEL_ADDR',
112+
message: `sentinelAddr '${sentinelAddr}' is not a valid Sentinel bech32 address.`,
113+
expectedPattern: '^sent1[0-9a-z]{38}$',
114+
nextAction: "Use the `address` field returned by createWallet() from 'blue-js-sdk/ai-path' — do not hand-construct this.",
115+
docs: '/manifest',
116+
});
117+
}
118+
119+
next();
120+
});
121+
73122
// ─── x402-Protected Routes ───
74123
// When an agent hits these without payment, they get HTTP 402 with payment details.
75124
// When they pay (via @x402/fetch), the facilitator settles USDC to our wallet,
@@ -115,13 +164,23 @@ app.use(
115164

116165
// ─── Route Handlers (only reached after payment is settled) ───
117166

167+
function sendProvisionError(res: express.Response, err: unknown) {
168+
const message = (err as Error).message;
169+
console.error('[x402] Provision failed:', message);
170+
res.status(500).json({
171+
code: 'PROVISIONING_FAILED',
172+
message,
173+
nextAction: 'Safe to retry once. EIP-3009 nonce prevents double-charge. If it persists, GET /agent/{sentinelAddr} to inspect chain state.',
174+
docs: '/manifest',
175+
});
176+
}
177+
118178
app.post('/vpn/connect/1day', async (req, res) => {
119179
try {
120180
const result = await provisionVpn(1, req.body);
121181
res.json(result);
122182
} catch (err) {
123-
console.error('[x402] Provision failed:', (err as Error).message);
124-
res.status(500).json({ error: 'Provisioning failed', detail: (err as Error).message });
183+
sendProvisionError(res, err);
125184
}
126185
});
127186

@@ -130,8 +189,7 @@ app.post('/vpn/connect/7days', async (req, res) => {
130189
const result = await provisionVpn(7, req.body);
131190
res.json(result);
132191
} catch (err) {
133-
console.error('[x402] Provision failed:', (err as Error).message);
134-
res.status(500).json({ error: 'Provisioning failed', detail: (err as Error).message });
192+
sendProvisionError(res, err);
135193
}
136194
});
137195

@@ -140,8 +198,7 @@ app.post('/vpn/connect/30days', async (req, res) => {
140198
const result = await provisionVpn(30, req.body);
141199
res.json(result);
142200
} catch (err) {
143-
console.error('[x402] Provision failed:', (err as Error).message);
144-
res.status(500).json({ error: 'Provisioning failed', detail: (err as Error).message });
201+
sendProvisionError(res, err);
145202
}
146203
});
147204

@@ -297,9 +354,17 @@ app.get('/manifest', (_req, res) => {
297354
nextAction: 'Sign EIP-3009 transferWithAuthorization, resend with PAYMENT-SIGNATURE header',
298355
},
299356
errors: {
300-
400: 'Bad request — missing or malformed sentinelAddr',
301-
402: 'Payment required — sign EIP-3009 and resend',
302-
500: 'Provisioning failed — { error, detail }. Safe to retry once.',
357+
note: 'All errors are JSON: { code, message, nextAction, docs }. Branch on `code` — it is the stable contract.',
358+
codes: {
359+
MISSING_SENTINEL_ADDR: { status: 400, meaning: 'Request body did not include sentinelAddr.' },
360+
INVALID_SENTINEL_ADDR: { status: 400, meaning: "sentinelAddr is not a valid sent1-bech32 address — use createWallet() from 'blue-js-sdk/ai-path'." },
361+
UNKNOWN_TIER: { status: 404, meaning: 'Path tier is not one of 1day | 7days | 30days.' },
362+
PAYMENT_REQUIRED: { status: 402, meaning: 'Sign EIP-3009 transferWithAuthorization and resend with PAYMENT-SIGNATURE header. @x402/fetch handles this automatically.' },
363+
PROVISIONING_FAILED: { status: 500, meaning: 'Payment settled but Sentinel TX failed. Safe to retry once; EIP-3009 nonce prevents double-charge.' },
364+
INTERNAL_ERROR: { status: 500, meaning: 'Unexpected server error. Open an issue if reproducible.' },
365+
NOT_FOUND: { status: 404, meaning: 'No route matches. See /manifest for endpoints.' },
366+
},
367+
prePaymentGuarantee: 'MISSING_SENTINEL_ADDR / INVALID_SENTINEL_ADDR / UNKNOWN_TIER are returned BEFORE paymentMiddleware. Agents are never charged USDC for guaranteed-failure requests.',
303368
},
304369
},
305370
flow: [
@@ -433,12 +498,39 @@ app.get('/agent/:sentinelAddr', async (req, res) => {
433498
}
434499
});
435500

501+
// ─── JSON 404 / 405 catch-all ───
502+
// Replaces Express's default HTML 404 page so agents always get structured JSON.
503+
504+
app.use((req, res) => {
505+
res.status(404).json({
506+
code: 'NOT_FOUND',
507+
message: `No route matches ${req.method} ${req.path}.`,
508+
nextAction: 'See GET /manifest for the full list of endpoints.',
509+
docs: '/manifest',
510+
});
511+
});
512+
513+
// ─── JSON error handler ───
514+
// Final safety net — any uncaught error becomes structured JSON, not an HTML stack trace.
515+
516+
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
517+
console.error('[x402] Uncaught error:', err.message);
518+
if (res.headersSent) return;
519+
res.status(500).json({
520+
code: 'INTERNAL_ERROR',
521+
message: err.message,
522+
nextAction: 'Report at https://github.com/Sentinel-Bluebuilder/x402/issues if it persists.',
523+
docs: '/manifest',
524+
});
525+
});
526+
436527
// ─── VPN Provisioning ───
437528

438529
async function provisionVpn(days: number, body: Record<string, unknown>) {
439530
const sentinelAddr = body.sentinelAddr as string;
440531

441-
if (!sentinelAddr || !sentinelAddr.startsWith('sent1')) {
532+
// Pre-payment middleware already validated the address — this is a defensive recheck.
533+
if (!sentinelAddr || !SENTINEL_ADDR_RE.test(sentinelAddr)) {
442534
throw new Error('Include sentinelAddr (sent1...) in request body');
443535
}
444536

0 commit comments

Comments
 (0)