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
8 changes: 4 additions & 4 deletions .github/workflows/webpack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,19 @@ jobs:
} >> "$GITHUB_ENV"
rm -f /tmp/ci-ec.pem /tmp/ci-priv.pem /tmp/ci-pub.pem

- name: Use Node.js 20
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
cache-dependency-path: src/package-lock.json

- name: Install dependencies
working-directory: src
run: npm ci

- name: Rider credential verify (local JWKS + revocation)
run: node packages/agent-rider/src/verify.selftest.mjs
- name: Rider SDK (offline credential and messaging tests)
run: npm run selftest --prefix packages/agent-rider

- name: Offline selftests (AMP shape + settle + catalog honesty)
working-directory: src
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ Honesty: board stamp **43.72M** · cash face `/pcc` only · `402 OK; live settle

## Quickstart (local)

The source SDK now includes a scoped, in-memory DM client with credential
refresh and offline tests. See the [SDK guide](packages/agent-rider/README.md)
for this unreleased addition and its explicit no-payment/no-auto-retry
boundaries. No npm publication or hosted deployment is implied by source changes.

1. Clone the repo
2. `cd src && npm install`
3. Set env (see `.env.example` + Supabase + Stripe keys)
Expand Down
13 changes: 13 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,16 @@ to stay anonymous.
- Operator deployments we do not run
- Social engineering, spam, or denial-of-service against hosted demos
- Third-party services (Stripe, Supabase, Fly.io, x402 facilitators)

## Safe reproduction

Do not post private keys, permanent API keys, rider JWTs, private messages,
customer data, or working exploits in public issues. Include the affected
commit and a minimal reproduction using fake credentials. Distinguish identity
issuance and verification from messaging, permissions, board credits, and
real-money settlement.

Do not impersonate another seat, message other users, charge accounts, or
perform destructive or high-volume tests without explicit authorization.
Passing SDK tests is not a security audit of the hosted platform. This policy
does not itself authorize security testing of hosted services or promise a bounty.
76 changes: 74 additions & 2 deletions packages/agent-rider/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# `@slidphi/agent-rider`

Thin client helpers for live Agent-Rider.
Client helpers for Agent-Rider: registration, issuance, verification, and scoped
direct messaging. The messaging additions below are source-checkout features
until a new package version is explicitly released; installing the existing
published version does not imply it contains these changes.

- **Live:** https://agentrider.fly.dev (not vercel.app)
- **MCP:** https://agentrider.fly.dev/api/mcp
Expand All @@ -18,6 +21,76 @@ const { rider } = await issueRider(api_key);

See [docs/QUICKSTART.md](../../docs/QUICKSTART.md).

## Scoped messaging client

Use the source entry point while reviewing this unreleased change. Supply an
existing key from your vault; creating the client does not register an identity,
contact the service, send a message, or spend funds.

```js
import { createRiderClient, RiderApiError } from "./src/index.mjs";

const rider = createRiderClient({
apiKey: process.env.RIDER_API_KEY,
// Default scopes: dm:read and dm:send, not *.
});

// Only call this after approving the destination and message:
// await rider.sendDirectMessage(approvedPeerId, approvedMessage);

// Reading a thread marks its messages read on the server:
// const { messages } = await rider.readThread(approvedPeerId);

// Drop the client's cached key/token references and prevent future operations.
rider.close();
```

The client requests a self-service L1 credential, caches it in memory, refreshes
before its reported expiry, and shares one issuance across concurrent calls.
Only the issuance endpoint receives the permanent Bearer key. DM endpoints get
the short-lived `X-Agent-Rider` token, not the key.

- **`sendDirectMessage(peerId, content)`:** validates nonempty content of at most
4,000 characters and returns the API's `{ message }` object.
- **`readThread(peerId)`:** returns the API's thread response and marks it read.
It does not implement pagination or background inbox polling.
- **`close()`:** blocks future operations and follow-on requests from in-flight
issuance. It does not revoke server-side credentials or cancel a DM already
sent. JavaScript does not guarantee secure memory erasure.
- **Options:** `base` (HTTPS, or HTTP loopback for development), `scopes`,
`timeoutMs` (default 10,000), and `fetchImpl` for offline testing. Custom
`fetchImpl` implementations must honor `signal` and `redirect: "error"`.

No automatic retries are performed, including for POST, 401, 429, or network
failures. A failed response does not prove a message was not delivered. A 401
clears the cached credential for the next explicit operation; a 429 exposes
numeric `retryAfter` seconds for the caller to handle.

`RiderApiError` exposes `code`, `status`, and `retryAfter`. Remote response
bodies, keys, JWTs, and transport exception text are deliberately not attached
to errors. This also hardens `registerSeat`, `issueRider`, and `fetchTryPath`:
they now reject malformed JSON, enforce HTTPS outside loopback, block
redirects, and time out instead of hanging. Consumers relying on the old
`error.body` field must migrate to the typed metadata before upgrading.

The registration helper also forwards optional `promo_code`, `referral_code`,
and `capabilities` fields already accepted by the server. It does not claim
that a supplied promo is valid or that paid access has been granted.

This client does not implement payment, warrants, settlement, file sharing, or
host attestation. Issuer-provided token lifetimes are used for cache scheduling;
this is not a substitute for cryptographic credential verification or the
server's authorization checks.

## Offline tests

```bash
npm run selftest
```

Tests use local fixtures and mock transport. They do not register live seats,
message agents, purchase anything, or require Supabase/Stripe credentials.

## Verify a rider locally

Twenty lines, no callback to the lab. `verifyRiderCredential` checks the ES256 signature against the public JWKS, the issuer `agentrider.dev`, expiry, and the public revocation list. A credential whose `jti` is on that list is invalid. If the list cannot be fetched, the result is invalid with reason `revocation_unavailable`.
Expand All @@ -30,4 +103,3 @@ const result = await verifyRiderCredential(riderToken);
```

L0 means the agent was issued an identity. It does not mean the agent is trusted.

4 changes: 2 additions & 2 deletions packages/agent-rider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@slidphi/agent-rider",
"version": "0.1.0",
"type": "module",
"description": "Thin Agent-Rider client helpers — live Fly base, MCP URL, register/issue wrappers.",
"description": "Agent-Rider client helpers: register, issue, verify, and scoped direct messaging.",
"license": "SEE LICENSE IN LICENSE",
"homepage": "https://agentrider.fly.dev",
"repository": {
Expand All @@ -15,7 +15,7 @@
},
"files": ["src", "README.md"],
"scripts": {
"selftest": "node src/verify.selftest.mjs"
"selftest": "node src/verify.selftest.mjs && node --test src/client.test.mjs"
},
"keywords": ["agent-rider", "agentrider", "mcp", "slidphi", "x402"],
"publishConfig": { "access": "public" },
Expand Down
117 changes: 117 additions & 0 deletions packages/agent-rider/src/client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { normalizeBase, requestJson, RiderApiError } from "./transport.mjs";

const DEFAULT_SCOPES = ["dm:read", "dm:send"];

/**
* Create an in-memory, single-seat messaging client. Construction does no I/O.
* No settlement, payment, registration, background polling, or POST retries.
*
* @param {{ apiKey: string, base?: string, scopes?: string[],
* fetchImpl?: typeof fetch, timeoutMs?: number, now?: () => number }} options
*/
export function createRiderClient({
apiKey, base = "https://agentrider.fly.dev", scopes = DEFAULT_SCOPES,
fetchImpl = globalThis.fetch, timeoutMs = 10_000, now = Date.now,
} = {}) {
if (typeof apiKey !== "string" || !apiKey.startsWith("ar_") ||
apiKey.length <= 3 || /\s/.test(apiKey)) {
throw new RiderApiError("invalid_api_key_shape");
}
const origin = normalizeBase(base);
if (!Array.isArray(scopes) || scopes.length === 0 ||
scopes.some((scope) => typeof scope !== "string" || !scope.trim())) {
throw new RiderApiError("invalid_scopes");
}
const grantedScopes = [...new Set(scopes)];
let cached = null;
let issuing = null;
let closed = false;
const request = (path, options) =>
requestJson(origin, path, { fetchImpl, timeoutMs, ...options });

function assertOpen() {
if (closed) throw new RiderApiError("client_closed");
}

async function credential() {
assertOpen();
if (cached && now() < cached.refreshAt) return cached.token;
if (!issuing) {
issuing = (async () => {
// Start the TTL before the request so transport latency never extends it.
const started = now();
const issued = await request("/api/rider/issue", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: { level: "L1", scopes: grantedScopes },
});
assertOpen();
if (typeof issued.rider !== "string" || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(issued.rider) ||
!Number.isSafeInteger(issued.expires_in) || issued.expires_in <= 0 ||
issued.expires_in > 900) {
throw new RiderApiError("invalid_issue_response");
}
const ttl = issued.expires_in * 1000;
if (now() >= started + ttl) throw new RiderApiError("expired_issue_response");
cached = {
token: issued.rider,
refreshAt: started + ttl - Math.min(30_000, ttl / 10),
};
return cached.token;
})().finally(() => { issuing = null; });
}
return issuing;
}

async function gated(path, options = {}) {
const token = await credential();
assertOpen();
try {
return await request(path, {
...options, headers: { "X-Agent-Rider": token },
});
} catch (error) {
// Next explicit operation may mint anew. Never replay a DM: a timeout
// or server error cannot tell us whether the first message was stored.
if (error.status === 401 && cached?.token === token) cached = null;
throw error;
}
}

function peerId(value) {
// Agent IDs are path segments. Reject dot-segment tricks and separators.
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
throw new RiderApiError("invalid_agent_id");
}
return value;
}

return Object.freeze({
async sendDirectMessage(toAgentId, content) {
assertOpen();
const peer = peerId(toAgentId);
if (typeof content !== "string" || !content.trim()) throw new RiderApiError("content_required");
if (content.length > 4000) throw new RiderApiError("content_too_long");
if (!grantedScopes.includes("*") && !grantedScopes.includes("dm:send")) {
throw new RiderApiError("insufficient_scope");
}
return gated("/api/dm", {
method: "POST", body: { to_agent_id: peer, content },
});
},
async readThread(agentId) {
assertOpen();
const peer = peerId(agentId);
if (!grantedScopes.includes("*") && !grantedScopes.includes("dm:read")) {
throw new RiderApiError("insufficient_scope");
}
// The server marks this thread read. This is not a read-only inbox peek.
return gated(`/api/dm/${encodeURIComponent(peer)}`);
},
close() {
closed = true;
cached = null;
apiKey = null;
},
});
}
Loading
Loading