diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a37b12..b5fc0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c ## [Unreleased] +## [0.1.12] - 2026-11-04 + +### Added + +- **Your app's users can talk to your agent.** `ProofableSignIn` signs them in with Proofable, the same sign-in every MCP host uses: `start()` sends the person to Proofable, `finish()` exchanges the code for their tokens, `refresh()` renews them, and `register()` records your callback once. Then `client.agentChat({ agentId, agentWallet })` as the person runs your public agent inside their own profile: their records, and nothing of yours in the conversation. +- **Your app pays for those chats.** `client.sponsorApp()` and `proofable sponsor` turn sponsorship on once: your app may spend up to a daily cap of your credits on its users' checks and chats. A client with `appId` and `billingWallet` then carries it, and every spend record names the app. `fetchSponsorGrant` takes `scope: 'agent-chat'`. +- **`client.me()`** says who the credential belongs to. + ## [0.1.11] - 2026-11-01 ### Added @@ -192,7 +200,8 @@ The `@proofable/sdk` package continues the line published as `@neus/sdk`. The no | [1.1.0](https://www.npmjs.com/package/@neus/sdk/v/1.1.0) | 2026-05-26 | First public SDK release on npm: OAuth-first setup, the CLI, public MCP tools including encrypted Vault secrets, and the Claude Code plugin with the trust workflow skill. | | [1.0.12](https://www.npmjs.com/package/@neus/sdk/v/1.0.12) | 2026-05-26 | First `@neus/sdk` release with the hosted MCP CLI (`neus setup`, `neus auth`, import/export) and the OAuth browser flow. | -[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.11...HEAD +[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.12...HEAD +[0.1.12]: https://github.com/proofable/sdk/compare/v0.1.11...v0.1.12 [0.1.11]: https://github.com/proofable/sdk/compare/v0.1.10...v0.1.11 [0.1.10]: https://github.com/proofable/sdk/compare/v0.1.9...v0.1.10 [0.1.9]: https://github.com/proofable/sdk/compare/v0.1.8...v0.1.9 diff --git a/README.md b/README.md index 437f925..6fbdb57 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,50 @@ turn.credits; // what the turn spent An action the agent's permission does not allow is refused before it runs, in every runtime that calls it. Revoke the permission once and the next call stops. Read your spend any time with `client.getUsage({ from: '2026-11-01' })`. +## Let your app's users talk to your agent + +Sign your users in with Proofable, then make the same call as them. Each person talks to your public agent inside their own Proofable profile: their records, and nothing of yours in the conversation. Your agent's instructions and the records you made public are what it knows. + +```js +import { ProofableClient, ProofableSignIn } from '@proofable/sdk'; + +// clientId: what register() returned the first time, kept with your app's settings. +const signIn = new ProofableSignIn({ redirectUri: 'https://your.app/proofable/callback', clientId: settings.proofableClientId }); + +// 1. Send the person to Proofable. Keep `state` and `verifier` for when they come back (a signed cookie works). +const started = await signIn.start(); +redirect(started.url); + +// 2. They are back at your callback with `code` and `state` in the URL. +const tokens = await signIn.finish({ code: query.code, verifier: kept.verifier, state: query.state, expectedState: kept.state }); + +// 3. Act as the person. Your app pays: appId and billingWallet make every turn your organization's spend. +const person = new ProofableClient({ + apiKey: tokens.accessToken, + appId: 'your-app', + billingWallet: '0xYourAccount', + appOrigin: 'https://your.app', +}); +const who = await person.me(); // { handle, address, ... } +const turn = await person.agentChat({ + agentId: 'my-agent', + agentWallet: '0xYourAccount', // your account: the agent runs in the person's own profile + turnId: `u-${yourUserKey}-${Date.now()}`, // your key for the person; every spend record carries it + messages: [{ role: 'user', content: 'Where does the case start?' }], + onEvent: (type, data) => type === 'answer.delta' && respond(data.text), +}); +``` + +**Sign-in.** `start()` with no `clientId` registers your callback with Proofable and returns the id; store it with your app's settings and pass it on every later instance. Refresh with `signIn.refresh(tokens.refreshToken)` before the token expires. + +**Who pays.** Your app does. Turn it on once, signed in as yourself: + +```bash +npx -y @proofable/sdk sponsor --app your-app --origin https://your.app --credits-per-day 2000 +``` + +From then on a client built with `appId` and `billingWallet` carries your sponsorship: your organization pays for your users' checks and their chats with your agents, within the daily cap you set, and every spend record names your app and your `turnId`, so you can set your own limits per person. Without `appId` and `billingWallet`, each person spends their own credits. A record the agent keeps for a person is the person's own and costs one credit from their own allowance, which every account has. In code, the same step is `client.sponsorApp({ appId, origin, creditsPerDay })`. + ## Add a check to your app Verify someone once, then reuse that proof on every later check. diff --git a/cli/proofable.mjs b/cli/proofable.mjs index f807361..728a635 100644 --- a/cli/proofable.mjs +++ b/cli/proofable.mjs @@ -1004,11 +1004,21 @@ function parseArgs(argv) { expiresAt: '', gateCommand: '', subject: '', - gateTarget: '' + gateTarget: '', + app: '', + origin: '', + creditsPerDay: '' }; for (let index = 1; index < argv.length; index += 1) { const token = argv[index]; + if (token === '--app' || token === '--origin' || token === '--credits-per-day') { + const value = argv[index + 1]; + if (!value) throw new Error(`${token} requires a value`); + options[token === '--app' ? 'app' : token === '--origin' ? 'origin' : 'creditsPerDay'] = value.trim(); + index += 1; + continue; + } if (token === '--json') { options.json = true; continue; @@ -1122,6 +1132,7 @@ function printUsage(exitCode = 0) { ' import Create an agent from a repo (URL or owner/name), folder, MCP URL, or agent card, then mount it', ' gate check --subject Check a Gate Policy file against a subject', ' gate get Read a published gate snapshot', + ' sponsor --app --origin --credits-per-day Let your app pay for its users\' checks and chats with your agents', ' help Show this message', '', 'Options:', @@ -3158,6 +3169,35 @@ async function runDisconnect(options) { } } +/** + * `proofable sponsor --app --origin --credits-per-day `: one signed record on your + * account that lets your app pay for its users' checks and their chats with your agents, up to the + * daily cap. Signs in the way `doctor --live` does: --access-key, the installed key, or the CLI store. + */ +async function runSponsor(options) { + const usage = 'Usage: proofable sponsor --app --origin --credits-per-day [--json]'; + if (!options.app || !options.origin || !options.creditsPerDay) throw new Error(usage); + const scope = resolveScope(options); + const credential = resolveLiveCredential(options, scope, process.cwd()); + if (!credential?.key) { + throw new Error('Sign in first: `proofable auth --oauth`, or pass --access-key with your own profile access key.'); + } + const { ProofableClient } = await import('../client.js'); + const client = new ProofableClient({ apiKey: credential.key }); + const result = await client.sponsorApp({ + appId: options.app, + origin: options.origin, + creditsPerDay: Number(options.creditsPerDay) + }); + if (options.json) { + printJson({ command: 'sponsor', ...result }); + return; + } + logStep('ok', 'sponsor', `${result.appId} at ${result.origin} may spend up to ${result.creditsPerDay} credits a day on its users' checks and chats`); + logStep('next', 'build', `new ProofableClient({ apiKey, appId: '${result.appId}', billingWallet: '', appOrigin: '${result.origin}' })`); + logStep('info', 'record', `https://proofable.me/proof/${result.qHash}`); +} + async function runGate(options) { const sub = String(options.gateCommand || '').trim().toLowerCase(); if (sub !== 'check' && sub !== 'get') { @@ -3276,6 +3316,10 @@ async function main() { await runGate(options); return; } + if (command === 'sponsor') { + await runSponsor(options); + return; + } process.stderr.write(`Unknown subcommand: ${command}\n`); printUsage(1); diff --git a/client.js b/client.js index 4167397..d636111 100644 --- a/client.js +++ b/client.js @@ -494,7 +494,7 @@ export class ProofableClient { return null; } - async _resolveSponsorGrantHeaders(verifierIds = []) { + async _resolveSponsorGrantHeaders(verifierIds = [], { scope = 'sponsored-verification', fresh = false } = {}) { const appId = typeof this.config.appId === 'string' ? this.config.appId.trim() : ''; const orgWallet = this._getBillingWallet(); if (!appId || !orgWallet) { @@ -504,9 +504,11 @@ export class ProofableClient { const normalizedVerifierIds = Array.isArray(verifierIds) ? verifierIds.map((v) => String(v || '').trim()).filter(Boolean).slice(0, 25) : []; - const cacheKey = `${appId}:${orgWallet}:${normalizedVerifierIds.join(',')}`; + const cacheKey = `${appId}:${orgWallet}:${scope}:${normalizedVerifierIds.join(',')}`; const now = Date.now(); + // A grant is accepted once. A call that must carry its own takes a fresh one every time. if ( + !fresh && this._sponsorGrantCache && this._sponsorGrantCache.key === cacheKey && this._sponsorGrantCache.expMs > now + 30_000 @@ -520,6 +522,7 @@ export class ProofableClient { appId, orgWallet, verifierIds: normalizedVerifierIds, + scope, origin }); @@ -2111,6 +2114,11 @@ export class ProofableClient { * Authenticate the client with a server key (`apiKey`) on a server, or a signed-in * session token in a browser. Never ship a server key in browser code. * + * Who pays: the signed-in account, unless the client carries `appId` and `billingWallet`. + * Then it sends your app's sponsor grant with the turn and your organization pays, within + * the daily cap you set with `sponsorApp()`, with the spend recorded under the app. That is + * how an app pays for its users' chats with your agent. + * * @param {object} params * @param {Array<{ role: 'user'|'assistant', content: string }>} params.messages the last one from the user * @param {string} [params.agentId] run as this agent; omit to run as yourself @@ -2129,11 +2137,12 @@ export class ProofableClient { if (!Array.isArray(body.messages) || body.messages.length === 0) { throw new ValidationError('messages must be a non-empty array, the last one from the user', 'messages', body.messages); } + const sponsorHeaders = await this._resolveSponsorGrantHeaders([], { scope: 'agent-chat', fresh: true }); let response; try { response = await fetch(`${this.baseUrl}/api/v1/agent/chat/turn`, { method: 'POST', - headers: { ...this.defaultHeaders, Accept: 'text/event-stream' }, + headers: { ...this.defaultHeaders, ...sponsorHeaders, Accept: 'text/event-stream' }, body: JSON.stringify(body), signal }); @@ -2176,6 +2185,85 @@ export class ProofableClient { return final; } + /** + * Let your app sponsor usage: the checks your users run and the chats they have with your + * agents are billed to your organization, within a daily credit cap, and recorded under the + * app. One signed record on your account, made with your own access key or sign-in; after + * it, a client built with `appId` and `billingWallet` carries your sponsorship on every call. + * + * @param {object} params + * @param {string} params.appId your app's id, letters, digits, `.`, `_`, `:` or `-` + * @param {string} params.origin the https origin your app calls from + * @param {number} params.creditsPerDay the most credits the app may spend in a UTC day + * @param {Array<'sponsored-verification'|'agent-chat'>} [params.scopes] what it sponsors; both by default + * @returns {Promise<{ qHash: string, appId: string, origin: string, creditsPerDay: number, scopes: string[] }>} + */ + async sponsorApp(params = {}) { + const appId = String(params.appId || '').trim(); + if (!/^[a-zA-Z0-9._:-]{1,64}$/.test(appId)) { + throw new ValidationError('appId must be 1-64 characters of letters, digits, ".", "_", ":" or "-"', 'appId', params.appId); + } + let origin; + try { + const url = new URL(String(params.origin || '').trim()); + if (url.protocol !== 'https:') throw new Error('https'); + origin = url.origin; + } catch { + throw new ValidationError('origin must be the https origin your app calls from', 'origin', params.origin); + } + const creditsPerDay = Number(params.creditsPerDay); + if (!Number.isInteger(creditsPerDay) || creditsPerDay < 1 || creditsPerDay > 999_999_999) { + throw new ValidationError('creditsPerDay must be a whole number of credits', 'creditsPerDay', params.creditsPerDay); + } + const allowed = ['sponsored-verification', 'agent-chat']; + const scopes = Array.isArray(params.scopes) && params.scopes.length > 0 + ? [...new Set(params.scopes.map((s) => String(s || '').trim().toLowerCase()))] + : allowed; + const unknown = scopes.find((s) => !allowed.includes(s)); + if (unknown) throw new ValidationError(`scopes may hold only ${allowed.join(' and ')}`, 'scopes', params.scopes); + + const issuer = await this._makeRequest('GET', '/api/v1/sponsor/issuer-address'); + const issuerAddress = String(issuer?.issuerAddress || issuer?.data?.issuerAddress || '').trim().toLowerCase(); + if (!EVM_ADDRESS_RE.test(issuerAddress)) { + throw new ApiError('Proofable did not return its sponsorship issuer', 502, 'SPONSOR_ISSUER_UNAVAILABLE', issuer); + } + const me = await this.me(); + const owner = String(me?.primaryAccount || me?.address || '').trim().toLowerCase(); + if (!EVM_ADDRESS_RE.test(owner)) { + throw new ValidationError('Sign the client in with your own access key or sign-in before sponsoring an app', 'apiKey', null); + } + const response = await this._makeRequest('POST', '/api/v1/verification', { + walletAddress: owner, + verifierIds: ['agent-delegation'], + data: { + controllerWallet: owner, + agentWallet: issuerAddress, + agentId: `sponsor-${appId}`, + scope: 'sponsor-grants', + allowedActions: ['read_context'], + permissions: [`app:${appId}`, `origin:${origin}`, `cap:creditsPerDay=${creditsPerDay}`, `scopes:${scopes.join(',')}`] + }, + signedTimestamp: Date.now(), + options: { privacyLevel: 'private' } + }); + const qHash = String(response?.data?.qHash || response?.qHash || ''); + if (!response?.success || !qHash) { + throw new ApiError(`Sponsorship could not be recorded: ${response?.error?.message || 'unknown error'}`, 502, 'SPONSOR_SETUP_FAILED', response); + } + return { qHash, appId, origin, creditsPerDay, scopes }; + } + + /** + * Who this client is signed in as: the profile behind the access key or the person's token + * from `ProofableSignIn`. `authenticated` is false for a client with no credential. + * + * @returns {Promise<{ authenticated: boolean, address?: string, primaryAccount?: string, handle?: string, profileSummary?: Record, [key: string]: unknown }>} + */ + async me() { + const response = await this._makeRequest('GET', '/api/v1/auth/me'); + return response?.data ?? { authenticated: false }; + } + /** * Your credits and spend, readable without opening Proofable: the balance, recent spend * events, and a summary by line (`byMode`) and by UTC day (`debitedByDay`). diff --git a/index.js b/index.js index 740be0b..52c5dad 100644 --- a/index.js +++ b/index.js @@ -65,6 +65,13 @@ export { export { fetchSponsorGrant } from './sponsor.js'; +export { + ProofableSignIn, + PROOFABLE_SIGN_IN_ISSUER, + PROOFABLE_SIGN_IN_RESOURCE, + PROOFABLE_SIGN_IN_SCOPE +} from './sign-in.js'; + export { RUNTIME_MOUNT_SCHEMA, normalizeWallet, diff --git a/package-lock.json b/package-lock.json index 062e3ad..309f39d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@proofable/sdk", - "version": "0.1.11", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@proofable/sdk", - "version": "0.1.11", + "version": "0.1.12", "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^2.3.0", diff --git a/package.json b/package.json index b90d71b..f14575e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@proofable/sdk", - "version": "0.1.11", + "version": "0.1.12", "description": "SDK and CLI for verification gates, reusable proof, and agent permissions.", "bin": { "proofable": "cli/proofable.mjs" @@ -47,6 +47,11 @@ "import": "./runtime-adapters.js", "require": "./cjs/runtime-adapters.cjs" }, + "./sign-in": { + "types": "./types.d.ts", + "import": "./sign-in.js", + "require": "./cjs/sign-in.cjs" + }, "./sponsor": { "types": "./types.d.ts", "import": "./sponsor.js", @@ -74,7 +79,7 @@ "format": "prettier --write \"**/*.js\"", "build": "npm run build:widgets && npm run build:cjs", "build:widgets": "npx esbuild widgets/verify-gate/VerifyGate.jsx widgets/verify-gate/ProofBadge.jsx --bundle --platform=browser --format=esm --outdir=widgets/verify-gate/dist --jsx=automatic --legal-comments=none --external:react --external:react-dom --external:react/jsx-runtime --external:@proofable/sdk/client --external:@proofable/sdk/utils", - "build:cjs": "npx esbuild index.js client.js utils.js errors.js gates.js runtime-mount.js runtime-adapters.js sponsor.js brand-mark.js --bundle --platform=node --format=cjs --outdir=cjs --out-extension:.js=.cjs --legal-comments=none --external:ethers --external:@zkpassport/sdk --external:react --external:react-dom --external:react/jsx-runtime", + "build:cjs": "npx esbuild index.js client.js utils.js errors.js gates.js runtime-mount.js runtime-adapters.js sponsor.js sign-in.js brand-mark.js --bundle --platform=node --format=cjs --outdir=cjs --out-extension:.js=.cjs --legal-comments=none --external:ethers --external:@zkpassport/sdk --external:react --external:react-dom --external:react/jsx-runtime", "prepack": "npm run build", "prepublishOnly": "npm run lint && npm test", "proofable": "node cli/proofable.mjs" @@ -163,6 +168,7 @@ "errors.js", "gates.js", "sponsor.js", + "sign-in.js", "brand-mark.js", "runtime-mount.js", "runtime-adapters.js", diff --git a/sign-in.js b/sign-in.js new file mode 100644 index 0000000..7c48a48 --- /dev/null +++ b/sign-in.js @@ -0,0 +1,206 @@ +/** + * Sign your app's users in with Proofable. + * + * The same sign-in every MCP host uses (OAuth 2.0 authorization code with PKCE, RFC 7591 + * client registration, RFC 8707 resource), for your own web app. Register your callback once, + * send the person to Proofable, exchange the code for their access token, then act as them + * with `new ProofableClient({ apiKey: tokens.accessToken })`. Runs wherever `fetch` and Web + * Crypto exist: Node 20+, Deno, edge runtimes and browsers. + */ +import { ApiError, NetworkError, ValidationError } from './errors.js'; + +export const PROOFABLE_SIGN_IN_ISSUER = 'https://proofable.me'; +export const PROOFABLE_SIGN_IN_RESOURCE = 'https://mcp.proofable.me/mcp'; +// What an app's sign-in asks for: the person's profile and records, and a refresh token. Never +// their vault; an app that needs it asks for `proofable:secrets` on purpose. +export const PROOFABLE_SIGN_IN_SCOPE = 'proofable:core proofable:profile offline_access'; + +const CLIENT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; + +function webCrypto() { + const c = globalThis.crypto; + if (!c || typeof c.getRandomValues !== 'function' || !c.subtle) { + throw new NetworkError('Web Crypto is not available in this runtime.'); + } + return c; +} + +function base64Url(bytes) { + let text = ''; + for (const byte of new Uint8Array(bytes)) text += String.fromCharCode(byte); + return btoa(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function randomToken(size = 32) { + return base64Url(webCrypto().getRandomValues(new Uint8Array(size))); +} + +async function pkceChallenge(verifier) { + const digest = await webCrypto().subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return base64Url(digest); +} + +function normalizeTokens(json) { + const expiresIn = Number(json?.expires_in); + return { + accessToken: String(json?.access_token || ''), + refreshToken: json?.refresh_token ? String(json.refresh_token) : null, + tokenType: String(json?.token_type || 'Bearer'), + expiresIn: Number.isFinite(expiresIn) ? expiresIn : null, + expiresAt: Number.isFinite(expiresIn) ? Date.now() + expiresIn * 1000 : null, + scope: String(json?.scope || '') + }; +} + +export class ProofableSignIn { + /** + * @param {object} options + * @param {string} options.redirectUri where Proofable sends the person back, an https URL on your site + * @param {string} [options.clientId] the client id `register()` gave you; omit it and `start()` registers once per instance + * @param {string} [options.scope] defaults to the person's profile and records with a refresh token; add `proofable:secrets` only if your app needs their vault + * @param {string} [options.issuer] defaults to https://proofable.me + * @param {string} [options.resource] defaults to the Proofable resource every token names + * @param {typeof fetch} [options.fetchImpl] + */ + constructor(options = {}) { + const redirectUri = String(options.redirectUri || '').trim(); + let parsed; + try { + parsed = new URL(redirectUri); + } catch { + parsed = null; + } + if (!parsed || parsed.protocol !== 'https:') { + throw new ValidationError('redirectUri must be an https URL on your site', 'redirectUri', options.redirectUri); + } + this.redirectUri = redirectUri; + this.clientId = options.clientId ? String(options.clientId).trim() : null; + if (this.clientId && !CLIENT_ID_PATTERN.test(this.clientId)) { + throw new ValidationError('clientId is not a client id Proofable issued', 'clientId', options.clientId); + } + this.issuer = String(options.issuer || PROOFABLE_SIGN_IN_ISSUER).replace(/\/$/, ''); + this.resource = String(options.resource || PROOFABLE_SIGN_IN_RESOURCE); + this.scope = String(options.scope || PROOFABLE_SIGN_IN_SCOPE); + this._fetch = typeof options.fetchImpl === 'function' ? options.fetchImpl : (...args) => fetch(...args); + } + + async _post(path, body, { form = false } = {}) { + let response; + try { + response = await this._fetch(`${this.issuer}${path}`, { + method: 'POST', + headers: { + 'Content-Type': form ? 'application/x-www-form-urlencoded' : 'application/json', + Accept: 'application/json' + }, + body: form ? new URLSearchParams(body).toString() : JSON.stringify(body) + }); + } catch (error) { + throw new NetworkError(`Network error: ${error.message}`); + } + const json = await response.json().catch(() => ({})); + if (!response.ok) { + throw new ApiError( + json?.error_description || json?.error || `Proofable sign-in failed with status ${response.status}`, + response.status, + String(json?.error || 'SIGN_IN_ERROR').toUpperCase(), + json + ); + } + return json; + } + + /** + * Register your callback with Proofable. Do this once and keep the client id it returns; + * `start()` calls it for you when the instance has none. + * @returns {Promise} the client id + */ + async register() { + const json = await this._post('/oauth/register', { redirect_uris: [this.redirectUri] }); + const clientId = String(json?.client_id || '').trim(); + if (!CLIENT_ID_PATTERN.test(clientId)) { + throw new ApiError('Proofable did not return a client id', 502, 'SIGN_IN_ERROR', json); + } + this.clientId = clientId; + return clientId; + } + + /** + * Where to send the person. Keep `verifier` and `state` until they come back (a signed, + * short-lived cookie is the usual place), then pass both to `finish()`. + * @param {object} [options] + * @param {string} [options.state] your own value for this attempt; generated when omitted + * @returns {Promise<{ url: string, state: string, verifier: string, clientId: string }>} + */ + async start(options = {}) { + if (!this.clientId) await this.register(); + const state = String(options.state || '').trim() || randomToken(16); + const verifier = randomToken(32); + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.clientId, + redirect_uri: this.redirectUri, + code_challenge: await pkceChallenge(verifier), + code_challenge_method: 'S256', + state, + scope: this.scope, + resource: this.resource + }); + return { url: `${this.issuer}/oauth/authorize?${params.toString()}`, state, verifier, clientId: this.clientId }; + } + + /** + * The person is back: exchange the code for their tokens. Refuses a callback whose `state` + * is not the one you started, or whose issuer is not Proofable. + * @param {object} input + * @param {string} input.code `code` from the callback URL + * @param {string} input.verifier from `start()` + * @param {string} [input.state] `state` from the callback URL + * @param {string} [input.expectedState] the `state` from `start()` + * @param {string} [input.iss] `iss` from the callback URL, when present + * @returns {Promise<{ accessToken: string, refreshToken: string|null, tokenType: string, expiresIn: number|null, expiresAt: number|null, scope: string }>} + */ + async finish(input = {}) { + const code = String(input.code || '').trim(); + const verifier = String(input.verifier || '').trim(); + if (!code) throw new ValidationError('code is required', 'code', input.code); + if (!verifier) throw new ValidationError('verifier is required', 'verifier', input.verifier); + if (input.expectedState !== undefined && input.expectedState !== null && String(input.state || '') !== String(input.expectedState)) { + throw new ValidationError('state does not match the sign-in you started', 'state', input.state); + } + if (input.iss !== undefined && input.iss !== null && String(input.iss).replace(/\/$/, '') !== this.issuer) { + throw new ValidationError('the callback did not come from Proofable', 'iss', input.iss); + } + if (!this.clientId) throw new ValidationError('clientId is required to finish a sign-in', 'clientId', null); + const json = await this._post('/api/v1/auth/mcp/token', { + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + code_verifier: verifier, + resource: this.resource + }, { form: true }); + const tokens = normalizeTokens(json); + if (!tokens.accessToken) throw new ApiError('Proofable did not return an access token', 502, 'SIGN_IN_ERROR', json); + return tokens; + } + + /** + * New tokens from a refresh token. The old refresh token is spent; keep the new one. + * @param {string} refreshToken + */ + async refresh(refreshToken) { + const token = String(refreshToken || '').trim(); + if (!token) throw new ValidationError('refreshToken is required', 'refreshToken', refreshToken); + if (!this.clientId) throw new ValidationError('clientId is required to refresh a sign-in', 'clientId', null); + const json = await this._post('/api/v1/auth/mcp/token', { + grant_type: 'refresh_token', + refresh_token: token, + client_id: this.clientId, + resource: this.resource + }, { form: true }); + const tokens = normalizeTokens(json); + if (!tokens.accessToken) throw new ApiError('Proofable did not return an access token', 502, 'SIGN_IN_ERROR', json); + return tokens; + } +} diff --git a/sponsor.js b/sponsor.js index 360af2f..6d47227 100644 --- a/sponsor.js +++ b/sponsor.js @@ -1,8 +1,10 @@ import { ValidationError, ApiError, NetworkError } from './errors.js'; /** - * Request short-lived billing authorization so verification usage bills the app owner. - * Requires a completed app registration (`sponsor-grants` delegation) on orgWallet. + * A short-lived grant that bills your app's usage to the app's owner: checks its users run + * (`sponsored-verification`, the default) or chats its users have with the owner's agents + * (`agent-chat`). The owner turns sponsorship on once with `client.sponsorApp()` or + * `proofable sponsor`. A grant is accepted once; fetch one per call that carries it. */ export async function fetchSponsorGrant(params = {}) { const { @@ -12,9 +14,11 @@ export async function fetchSponsorGrant(params = {}) { verifierIds = [], targetChains = [], origin, + scope = 'sponsored-verification', expiresInSeconds = 900, fetchImpl = fetch } = params; + const normalizedScope = scope === 'agent-chat' ? 'agent-chat' : 'sponsored-verification'; const normalizedAppId = typeof appId === 'string' ? appId.trim() : ''; const normalizedOrg = typeof orgWallet === 'string' ? orgWallet.trim().toLowerCase() : ''; @@ -43,12 +47,14 @@ export async function fetchSponsorGrant(params = {}) { 'X-Neus-Sdk': 'js' }; if (typeof origin === 'string' && origin.trim()) { + // Both: a runtime that will not send Origin still names the app's origin. headers.Origin = origin.trim(); + headers['X-Client-Origin'] = origin.trim(); } const body = { orgWallet: normalizedOrg, - scope: 'sponsored-verification', + scope: normalizedScope, expiresInSeconds, ...(Array.isArray(verifierIds) && verifierIds.length > 0 ? { verifierIds: verifierIds.map((v) => String(v).trim()).filter(Boolean).slice(0, 25) } diff --git a/test/agent-chat.test.js b/test/agent-chat.test.js index 1519ee6..9a079e9 100644 --- a/test/agent-chat.test.js +++ b/test/agent-chat.test.js @@ -51,6 +51,26 @@ describe('ProofableClient.agentChat', () => { expect(JSON.parse(init.body)).toEqual({ agentId: 'human-alignment', messages: [{ role: 'user', content: 'How does the book open?' }] }); }); + it('sends the app\'s sponsor grant for a chat when the client bills the app', async () => { + const fetch = vi.fn(async (url) => { + if (String(url).endsWith('/api/v1/sponsor/grant')) { + return new Response(JSON.stringify({ success: true, data: { sponsorGrant: 'grant-1', exp: Math.floor(Date.now() / 1000) + 900, orgWallet: `0x${'3'.repeat(40)}`, appId: 'everyone' } }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return sseResponse([event(1, 'final', { turnId: 't3', outcome: 'succeeded', answer: 'Hi.', citations: [] })]); + }); + vi.stubGlobal('fetch', fetch); + const client = new ProofableClient({ apiKey: 'at-ada', appId: 'everyone', billingWallet: `0x${'3'.repeat(40)}`, appOrigin: 'https://everyone.team' }); + await client.agentChat({ agentId: 'human-alignment', agentWallet: `0x${'3'.repeat(40)}`, messages: [{ role: 'user', content: 'hi' }] }); + await client.agentChat({ agentId: 'human-alignment', agentWallet: `0x${'3'.repeat(40)}`, messages: [{ role: 'user', content: 'again' }] }); + const grantCalls = fetch.mock.calls.filter(([url]) => String(url).endsWith('/api/v1/sponsor/grant')); + // A grant is accepted once, so every turn takes its own. + expect(grantCalls).toHaveLength(2); + expect(JSON.parse(grantCalls[0][1].body)).toMatchObject({ scope: 'agent-chat', orgWallet: `0x${'3'.repeat(40)}` }); + expect(grantCalls[0][1].headers).toMatchObject({ Origin: 'https://everyone.team', 'X-Client-Origin': 'https://everyone.team' }); + const turnCall = fetch.mock.calls.find(([url]) => String(url).endsWith('/api/v1/agent/chat/turn')); + expect(turnCall[1].headers).toMatchObject({ Authorization: 'Bearer at-ada', 'X-Sponsor-Grant': 'grant-1', Origin: 'https://everyone.team' }); + }); + it('raises a refusal before the stream as an ApiError with its code', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: false, @@ -74,6 +94,23 @@ describe('ProofableClient.agentChat', () => { }); }); +describe('ProofableClient.me', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('says who the credential belongs to', async () => { + const fetch = vi.fn(async () => new Response(JSON.stringify({ + success: true, + data: { authenticated: true, address: `0x${'3'.repeat(40)}`, handle: 'ada', profileSummary: { displayName: 'Ada Lovelace' } } + }), { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', fetch); + const client = new ProofableClient({ apiKey: 'at-1' }); + expect(await client.me()).toMatchObject({ authenticated: true, handle: 'ada', profileSummary: { displayName: 'Ada Lovelace' } }); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe('https://api.proofable.me/api/v1/auth/me'); + expect(init.headers).toMatchObject({ Authorization: 'Bearer at-1' }); + }); +}); + describe('ProofableClient.getUsage', () => { afterEach(() => vi.unstubAllGlobals()); diff --git a/test/exports.test.js b/test/exports.test.js index 8932edd..6c69a61 100644 --- a/test/exports.test.js +++ b/test/exports.test.js @@ -12,5 +12,6 @@ describe('package exports', () => { expect(Index.getHostedAgentCreateUrl).toBeTypeOf('function'); expect(Index.computePortableProofQHash).toBeTypeOf('function'); expect(Index.verifyPortableProofEnvelope).toBeTypeOf('function'); + expect(Index.ProofableSignIn).toBeTypeOf('function'); }); }); diff --git a/test/sign-in.test.js b/test/sign-in.test.js new file mode 100644 index 0000000..fc1b847 --- /dev/null +++ b/test/sign-in.test.js @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ProofableSignIn } from '../sign-in.js'; +import { ApiError, ValidationError } from '../errors.js'; + +const REDIRECT = 'https://everyone.team/rooms/agent/callback'; +const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('ProofableSignIn', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('needs an https callback on your site', () => { + expect(() => new ProofableSignIn({ redirectUri: 'http://everyone.team/cb' })).toThrow(ValidationError); + expect(() => new ProofableSignIn({})).toThrow(ValidationError); + }); + + it('registers the callback once, then sends the person to Proofable with PKCE', async () => { + const fetch = vi.fn(async (url) => { + if (String(url).endsWith('/oauth/register')) return json({ client_id: 'mcp_0123abcd', redirect_uris: [REDIRECT] }, 201); + throw new Error(`unexpected ${url}`); + }); + vi.stubGlobal('fetch', fetch); + + const signIn = new ProofableSignIn({ redirectUri: REDIRECT }); + const first = await signIn.start({ state: 'attempt-1' }); + const second = await signIn.start(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ redirect_uris: [REDIRECT] }); + expect(first.clientId).toBe('mcp_0123abcd'); + const url = new URL(first.url); + expect(`${url.origin}${url.pathname}`).toBe('https://proofable.me/oauth/authorize'); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + response_type: 'code', + client_id: 'mcp_0123abcd', + redirect_uri: REDIRECT, + code_challenge_method: 'S256', + state: 'attempt-1', + scope: 'proofable:core proofable:profile offline_access', + resource: 'https://mcp.proofable.me/mcp' + }); + expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(first.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + // Each attempt gets its own verifier and state. + expect(second.verifier).not.toBe(first.verifier); + expect(second.state).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it('exchanges the code for the person\'s tokens, and refuses a callback that is not the one it started', async () => { + const fetch = vi.fn(async () => json({ access_token: 'at-1', refresh_token: 'rt-1', token_type: 'Bearer', expires_in: 86400, scope: 'proofable:core' })); + vi.stubGlobal('fetch', fetch); + const signIn = new ProofableSignIn({ redirectUri: REDIRECT, clientId: 'mcp_0123abcd' }); + + await expect(signIn.finish({ code: 'c', verifier: 'v', state: 'other', expectedState: 'mine' })).rejects.toThrow(ValidationError); + await expect(signIn.finish({ code: 'c', verifier: 'v', iss: 'https://evil.example' })).rejects.toThrow(ValidationError); + expect(fetch).not.toHaveBeenCalled(); + + const tokens = await signIn.finish({ code: 'c', verifier: 'v', state: 'mine', expectedState: 'mine', iss: 'https://proofable.me' }); + expect(tokens).toMatchObject({ accessToken: 'at-1', refreshToken: 'rt-1', tokenType: 'Bearer', expiresIn: 86400, scope: 'proofable:core' }); + expect(tokens.expiresAt).toBeGreaterThan(Date.now()); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe('https://proofable.me/api/v1/auth/mcp/token'); + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({ + grant_type: 'authorization_code', + code: 'c', + redirect_uri: REDIRECT, + client_id: 'mcp_0123abcd', + code_verifier: 'v', + resource: 'https://mcp.proofable.me/mcp' + }); + }); + + it('refreshes with the client id and surfaces a refusal as an ApiError', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(json({ access_token: 'at-2', refresh_token: 'rt-2', expires_in: 86400 })) + .mockResolvedValueOnce(json({ error: 'invalid_grant', error_description: 'refresh token expired' }, 400)); + vi.stubGlobal('fetch', fetch); + const signIn = new ProofableSignIn({ redirectUri: REDIRECT, clientId: 'mcp_0123abcd' }); + + const tokens = await signIn.refresh('rt-1'); + expect(tokens).toMatchObject({ accessToken: 'at-2', refreshToken: 'rt-2' }); + expect(Object.fromEntries(new URLSearchParams(fetch.mock.calls[0][1].body))).toEqual({ + grant_type: 'refresh_token', refresh_token: 'rt-1', client_id: 'mcp_0123abcd', resource: 'https://mcp.proofable.me/mcp' + }); + + const error = await signIn.refresh('rt-2').catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ statusCode: 400, code: 'INVALID_GRANT', message: 'refresh token expired' }); + }); +}); diff --git a/test/sponsor.test.js b/test/sponsor.test.js index 2b01c88..e48cb2f 100644 --- a/test/sponsor.test.js +++ b/test/sponsor.test.js @@ -1,7 +1,51 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { fetchSponsorGrant } from '../sponsor.js'; +import { ProofableClient } from '../client.js'; import { ValidationError, ApiError, NetworkError } from '../errors.js'; +describe('ProofableClient.sponsorApp', () => { + afterEach(() => vi.restoreAllMocks()); + + it('records, as the signed-in account, that the app may pay for its users\' checks and chats', async () => { + const OWNER = `0x${'3'.repeat(40)}`; + const ISSUER = `0x${'9'.repeat(40)}`; + const json = (body) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + const fetch = vi.fn(async (url) => { + const u = String(url); + if (u.endsWith('/api/v1/sponsor/issuer-address')) return json({ success: true, issuerAddress: ISSUER }); + if (u.endsWith('/api/v1/auth/me')) return json({ success: true, data: { authenticated: true, primaryAccount: OWNER } }); + if (u.endsWith('/api/v1/verification')) return json({ success: true, data: { qHash: `0x${'a'.repeat(64)}`, status: 'verified' } }); + throw new Error(`unexpected ${u}`); + }); + vi.stubGlobal('fetch', fetch); + const client = new ProofableClient({ apiKey: 'npk_owner' }); + const result = await client.sponsorApp({ appId: 'everyone', origin: 'https://everyone.team/', creditsPerDay: 2000 }); + expect(result).toEqual({ qHash: `0x${'a'.repeat(64)}`, appId: 'everyone', origin: 'https://everyone.team', creditsPerDay: 2000, scopes: ['sponsored-verification', 'agent-chat'] }); + const record = fetch.mock.calls.find(([u]) => String(u).endsWith('/api/v1/verification')); + expect(JSON.parse(record[1].body)).toMatchObject({ + walletAddress: OWNER, + verifierIds: ['agent-delegation'], + data: { + controllerWallet: OWNER, + agentWallet: ISSUER, + scope: 'sponsor-grants', + allowedActions: ['read_context'], + permissions: ['app:everyone', 'origin:https://everyone.team', 'cap:creditsPerDay=2000', 'scopes:sponsored-verification,agent-chat'] + }, + options: { privacyLevel: 'private' } + }); + vi.unstubAllGlobals(); + }); + + it('refuses an app id, origin or cap it cannot record', async () => { + const client = new ProofableClient({ apiKey: 'npk_owner' }); + await expect(client.sponsorApp({ appId: 'has space', origin: 'https://x.app', creditsPerDay: 1 })).rejects.toThrow(ValidationError); + await expect(client.sponsorApp({ appId: 'ok', origin: 'http://x.app', creditsPerDay: 1 })).rejects.toThrow(ValidationError); + await expect(client.sponsorApp({ appId: 'ok', origin: 'https://x.app', creditsPerDay: 0 })).rejects.toThrow(ValidationError); + await expect(client.sponsorApp({ appId: 'ok', origin: 'https://x.app', creditsPerDay: 5, scopes: ['everything'] })).rejects.toThrow(ValidationError); + }); +}); + global.fetch = vi.fn(); describe('fetchSponsorGrant', () => { diff --git a/types.d.ts b/types.d.ts index 7dc2164..5063b92 100644 --- a/types.d.ts +++ b/types.d.ts @@ -42,9 +42,21 @@ gateCheck(params: GateCheckApiParams): Promise; - /** Talk to a hosted agent: one chat turn run as the agent, under the delegation you signed for it. */ + /** + * Talk to a hosted agent: one chat turn run as the agent, under the delegation you signed for it. + * With `agentWallet`, someone else's public agent runs in your own profile, with nothing of its owner's in the conversation. + */ agentChat(params: AgentChatParams): Promise; + /** Who this client is signed in as; `authenticated` is false with no credential. */ + me(): Promise; + + /** + * Let your app sponsor usage: your users' checks and their chats with your agents are billed to + * your organization within a daily credit cap. One signed record on your account. + */ + sponsorApp(params: SponsorAppParams): Promise; + /** Your credits and spend: the balance, recent events, and spend by line and by UTC day. */ getUsage(options?: UsageOptions): Promise; @@ -69,7 +81,10 @@ export interface ProofableClientConfig { apiUrl?: string; - /** Optional server profile key; MCP/CI only. Not required for VerifyGate or gateCheck. */ + /** + * The credential sent as a Bearer token: a profile access key on a server, or a person's + * access token from `ProofableSignIn`. Not required for VerifyGate or gateCheck. + */ apiKey?: string; /** Public app attribution id (non-secret). */ appId?: string; @@ -99,6 +114,8 @@ verifierIds?: string[]; targetChains?: number[]; origin?: string; + /** What the app sponsors: its users' checks (the default) or their chats with the owner's agents. */ + scope?: 'sponsored-verification' | 'agent-chat'; expiresInSeconds?: number; } @@ -112,6 +129,86 @@ export function fetchSponsorGrant(params: FetchSponsorGrantParams): Promise; + export interface SponsorAppParams { + /** Your app's id: letters, digits, ".", "_", ":" or "-". */ + appId: string; + /** The https origin your app calls from. */ + origin: string; + /** The most credits the app may spend in a UTC day. */ + creditsPerDay: number; + /** What the app sponsors; both by default. */ + scopes?: Array<'sponsored-verification' | 'agent-chat'>; + } + + export interface SponsorAppResult { + qHash: string; + appId: string; + origin: string; + creditsPerDay: number; + scopes: string[]; + } + + /** The signed-in profile, as `client.me()` returns it. */ + export interface ProofableMe { + authenticated: boolean; + address?: string; + primaryAccount?: string; + handle?: string; + profileSummary?: Record; + [key: string]: unknown; + } + + export interface ProofableSignInOptions { + /** Where Proofable sends the person back: an https URL on your site. */ + redirectUri: string; + /** The client id `register()` gave you. Omit it and `start()` registers once per instance. */ + clientId?: string; + scope?: string; + issuer?: string; + resource?: string; + fetchImpl?: typeof fetch; + } + + export interface ProofableSignInStart { + /** Send the person here. */ + url: string; + state: string; + /** Keep it until the person is back, then pass it to `finish()`. */ + verifier: string; + clientId: string; + } + + export interface ProofableTokens { + accessToken: string; + refreshToken: string | null; + tokenType: string; + expiresIn: number | null; + expiresAt: number | null; + scope: string; + } + + /** + * Sign your app's users in with Proofable: the same sign-in every MCP host uses, for your + * own web app. Then act as the person with `new ProofableClient({ apiKey: tokens.accessToken })`. + */ + export class ProofableSignIn { + constructor(options: ProofableSignInOptions); + redirectUri: string; + clientId: string | null; + /** Register your callback once and keep the client id. */ + register(): Promise; + /** Where to send the person, with the values to keep until they are back. */ + start(options?: { state?: string }): Promise; + /** Exchange the callback's code for the person's tokens. */ + finish(input: { code: string; verifier: string; state?: string; expectedState?: string; iss?: string }): Promise; + /** New tokens from a refresh token; the old one is spent. */ + refresh(refreshToken: string): Promise; + } + + export const PROOFABLE_SIGN_IN_ISSUER: string; + export const PROOFABLE_SIGN_IN_RESOURCE: string; + export const PROOFABLE_SIGN_IN_SCOPE: string; + export interface VerificationOptions { privacyLevel?: PrivacyLevel; enableIpfs?: boolean; @@ -728,7 +825,10 @@ messages: AgentChatMessage[]; /** Run as this agent; omit to run as yourself. */ agentId?: string; - /** The agent's account, when two of your agents share an agentId. */ + /** + * The agent's account: when two of your agents share an agentId, or to talk to someone + * else's public agent, which then runs in your own profile, with nothing of its owner's in the conversation. + */ agentWallet?: string; /** Context from your app, passed to the agent as data. */ systemPrompt?: string; @@ -1667,6 +1767,7 @@ declare module '@proofable/sdk/sponsor' { verifierIds?: string[]; targetChains?: number[]; origin?: string; + scope?: 'sponsored-verification' | 'agent-chat'; expiresInSeconds?: number; fetchImpl?: typeof fetch; }): Promise<{ @@ -1678,6 +1779,10 @@ declare module '@proofable/sdk/sponsor' { }>; } +declare module '@proofable/sdk/sign-in' { + export { ProofableSignIn, ProofableSignInOptions, ProofableSignInStart, ProofableTokens, PROOFABLE_SIGN_IN_ISSUER, PROOFABLE_SIGN_IN_RESOURCE, PROOFABLE_SIGN_IN_SCOPE } from '@proofable/sdk'; +} + declare module '@proofable/sdk/brand-mark' { export const PROOFABLE_MARK_CDN_ORIGIN: string; export function brandPackUrl(file: string): string;