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
20 changes: 19 additions & 1 deletion lib/commands/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ export function rankPickArgv({ intent, liveProbe = false, endpoint, method, capa
];
}

// Unlisted pins have no catalog terms and no vetting — the merchant's own
// published docs (llms.txt / openapi.json) are the only request contract
// there is, and merchants in this class can capture payment BEFORE validating
// the body (observed: api.auto.exchange, 3 charges settled then 400'd on a
// wrong param name, 2026-09-04). --docs-check makes selat-pay resolve those
// docs pre-sign and refuse a body missing a documented-required field.
// Requires selat-pay >= 0.10 (older versions reject the flag as unknown —
// the dep pin guarantees the pair).
export function withDocsCheck(selatPayArgs, { allowUnlisted = false } = {}) {
if (!allowUnlisted || selatPayArgs.includes("--docs-check")) return selatPayArgs;
return [...selatPayArgs, "--docs-check"];
}

/**
* Map rank.mjs's pin-specific exit codes to an honest error.
*
Expand Down Expand Up @@ -261,7 +274,10 @@ export async function run(args) {
console.log(" the only source of price and rail, and the default cap");
console.log(" is the flat no-price $0.10 (raise with --max-amount; the");
console.log(" $1 hard ceiling, validate-before-sign, and Circle policy");
console.log(" caps still apply). Explicit per call, never implied.");
console.log(" caps still apply). Also resolves the merchant's published");
console.log(" docs (llms.txt / openapi.json) as a request contract: a");
console.log(" body missing a documented-required field is refused");
console.log(" before signing. Explicit per call, never implied.");
console.log(" --capability <name> Rank only endpoints labeled with this capability");
console.log(" (Layer 0). Unknown names and empty labeled pools");
console.log(" are refused, never silently widened.");
Expand Down Expand Up @@ -447,6 +463,8 @@ export async function run(args) {
return emitRunError({ jsonMode, error: capped.reason });
}
selatPayArgs = capped.args;

selatPayArgs = withDocsCheck(selatPayArgs, { allowUnlisted });
display = ["selat-pay", ...selatPayArgs].map(shellQuoteForDisplay).join(" ");

// --dry-run stops HERE: the pick is ranked and the selat-pay command is
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"dependencies": {
"@selat-ai/selat-discovery": "^0.24.9",
"@selat-ai/selat-pay": "^0.9.14",
"@selat-ai/selat-pay": "^0.10.0",
"qrcode-generator": "2.0.4"
},
"overrides": {
Expand Down
14 changes: 13 additions & 1 deletion test/run-allow-unlisted.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import test from "node:test";
import assert from "node:assert/strict";

import { parseRunArgs, rankPickArgv, KNOWN_RUN_FLAGS, pinRefusal } from "../lib/commands/run.mjs";
import { parseRunArgs, rankPickArgv, KNOWN_RUN_FLAGS, pinRefusal, withDocsCheck } from "../lib/commands/run.mjs";

test("--allow-unlisted is a known flag and parses with --endpoint", () => {
assert.ok(KNOWN_RUN_FLAGS.includes("--allow-unlisted"));
Expand Down Expand Up @@ -35,6 +35,18 @@ test("rankPickArgv forwards --allow-unlisted only alongside a pin", () => {
);
});

// The merchant's published docs are the only request contract an unlisted
// pin has — the paid command must carry --docs-check so selat-pay refuses a
// body missing a documented-required field before signing.
test("withDocsCheck appends --docs-check for unlisted pins only, idempotently", () => {
const base = ["POST", "https://api.x.dev/v1", "--chain", "base", "--max-amount", "0.1"];
assert.deepEqual(withDocsCheck(base, { allowUnlisted: true }), [...base, "--docs-check"]);
assert.deepEqual(withDocsCheck(base, { allowUnlisted: false }), base);
assert.deepEqual(withDocsCheck(base, {}), base);
const already = [...base, "--docs-check"];
assert.deepEqual(withDocsCheck(already, { allowUnlisted: true }), already);
});

test("the not-in-catalog refusal names the flag", () => {
const refusal = pinRefusal(4, "https://api.x.dev/v1");
assert.equal(refusal.reason, "endpoint-not-in-catalog");
Expand Down