diff --git a/.agent/context/20260913T040725Z-mcp-access-and-scope.md b/.agent/context/20260913T040725Z-mcp-access-and-scope.md new file mode 100644 index 0000000..419efa7 --- /dev/null +++ b/.agent/context/20260913T040725Z-mcp-access-and-scope.md @@ -0,0 +1,81 @@ +# Session Context: MCP access and request scope + +## Date/time + +- UTC: 2026-09-13T04:07:25Z + +## User goal + +Remove the unused MCP-specific 1 USDC cap, make the downloadable agent skill installable with npx, connect the merged MCP path to Google Cloud, and verify that users see only their own requests. + +## Original prompt/request + +The user asked to remove the MCP 1 USDC boundary, provide the bearer required by agent configuration, add the missing npx skill installation instructions, audit per-user request isolation, and make the MCP usable through the existing Google Cloud deployment. + +## Assumptions + +- “Remove the 1 USDC boundary” means delete the second MCP-only cap. The worker settlement cap and Privy policy remain authoritative security controls. +- A shared production bearer must stay in Google Secret Manager and must not be embedded in the public docs page. +- Browser request isolation applies to job lists, job reads/results, and activity. Direct intent/recovery routes need a later storage association before full cross-workspace isolation can be claimed. + +## Plan + +1. Remove the MCP-specific amount cap and its configuration/tests/docs. +2. Scope browser job and activity routes by the verified Privy subject. +3. Add and verify the exact npx skill install command. +4. Validate, commit/push, then build and deploy the API with Google Secret Manager-backed MCP configuration. +5. Verify public MCP authentication and tool discovery without submitting a payment. + +## Key decisions + +- Derive an opaque stable workspace ID as SHA-256 of the verified Privy subject; do not accept a caller-selected workspace. +- Issue one random 256-bit bearer per Privy workspace, store only its SHA-256 digest, and allow explicit rotation. Keep the optional operator bearer only for compatibility. +- Never render or commit the bearer token. + +## Files/components touched + +- API authentication and route workspace selection. +- MCP cap configuration and tests. +- MCP docs page, operator docs, downloadable skill, and implementation plan. +- PostgreSQL migration 011 and personal MCP credential store. +- Authenticated Profile token generation and rotation UI. +- Google Cloud deployment configuration (pending). + +## Commands/checks + +- `npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --list --full-depth` - found `oneshot-arc-payment`. +- `pnpm build` - passed on local Node 22 with the repository Node 24 engine warning. +- `pnpm --filter @oneshot/api test` - 75/75 passed after personal token work. +- Focused web profile/docs tests - 10/10 passed. +- `pnpm test` - 79 files and 1052 tests passed. +- `pnpm test:browser` - 8/8 Chromium checks passed. +- Full web test exposed two pre-existing failures in `privy-session.test.tsx`; the changed MCP docs assertion was updated and passes. + +## External-doc findings + +- None. The installed `skills` CLI help verified the command syntax directly. + +## Unresolved questions + +- Google Secret Manager list/get remains unavailable to the active account; personal MCP bearer generation does not depend on a shared MCP secret. +- Direct `/v1/intents/:id`, reconcile, and recovery-view routes are not yet workspace-bound in storage; job request views are isolated. +- The active account cannot list/get Secret Manager metadata, but personal MCP tokens no longer require a shared MCP secret. + +## Git and PR state + +- Branch: fix/mcp-access-and-scope +- Base: origin/develop at 246a38af36e291b0538eb0a8f87d1f3b3f1def60 +- Commit: 9aafe21d27e27a99ddb0586a0cff747bd36a38f2 +- PR: (draft) +- CI: all required checks passed for 9aafe21d27e27a99ddb0586a0cff747bd36a38f2 + +## Review gates + +- Gate A: SKIPPED by explicit user instruction to continue without FreePi. +- Gate B: SKIPPED by explicit user instruction to continue without FreePi. + +## Handoff/next steps + +1. Finish local checks and inspect the candidate diff. +2. Commit, push, and open the PR without FreePi per user instruction. +3. Build/deploy the API image and configure `/mcp` through Google Cloud. diff --git a/.agents/skills/oneshot-arc-payment/SKILL.md b/.agents/skills/oneshot-arc-payment/SKILL.md index 3dcf64a..647f516 100644 --- a/.agents/skills/oneshot-arc-payment/SKILL.md +++ b/.agents/skills/oneshot-arc-payment/SKILL.md @@ -2,7 +2,7 @@ name: oneshot-arc-payment description: > Pay a USDC recipient on Arc Testnet through the OneShot `arc_payment` MCP - tool: connect an MCP client with the operator-issued bearer token, create or + tool: connect an MCP client with the user's profile bearer token, create or replay one durable payment intent, read the authoritative settlement state, and verify the ArcScan proof. Use when the user asks to pay via OneShot, send USDC on Arc, run the arc_payment MCP tool, or delegate an agent payment task. @@ -15,15 +15,13 @@ Pay once, safely, through OneShot. This skill is written for any agent MCP endpoint. It never handles keys: the payer is OneShot's policy-bound server wallet, and the bearer token lives only in the MCP client config. -## Prerequisites (operator-provided, never invented) +## Prerequisites (user-provided, never invented) - MCP endpoint URL, e.g. `https://oneshot.kapustazh.dev/mcp` (Streamable HTTP). -- `ONESHOT_MCP_BEARER_TOKEN` — configured in the MCP client as +- A bearer generated from the user's OneShot Profile — configured in the MCP client as `authorization: Bearer `. It is a secret: never print, log, copy into task prompts, or commit it. -- One allowed `request_key` — the operator binds it to exactly one payment. -- The per-payment cap (default 1000000 atomic = 1 USDC) is enforced - server-side; requests above it are rejected. +- One allowed `request_key` shown with the generated profile credential. If any of these is missing, stop and ask the operator. Do not guess values. @@ -46,7 +44,7 @@ UNKNOWN | REJECTED`), `replayed`, `payer.mode` (`SERVER_PRIVY`), ## How to execute a payment -1. Call `arc_payment` once with the operator's `request_key` and the exact +1. Call `arc_payment` once with the profile's `request_key` and the exact recipient, amount, and purpose the user approved. 2. If `state` is `COMMITTED`, report `settlement.transaction_hash` and its `explorer_url` (ArcScan). Done. @@ -57,7 +55,7 @@ UNKNOWN | REJECTED`), `replayed`, `payer.mode` (`SERVER_PRIVY`), not failure: it never justifies a replacement payment or a new key. 5. If the tool returns the conflict error ("already belongs to a different payment"), the key was reused with changed fields. Stop, report the - conflict, and ask the operator for the original fields or a new key. + conflict, and ask the user for the original fields or a new credential. 6. If `state` is `FAILED_SAFE` or `REJECTED`, report it and stop. Do not retry with a different key or amount. @@ -67,7 +65,7 @@ UNKNOWN | REJECTED`), `replayed`, `payer.mode` (`SERVER_PRIVY`), `recipient`, `amount_usdc`, `purpose`, and this skill. - The delegate must use its own MCP client configuration; the bearer token must not travel through prompts, task payloads, logs, or screenshots. -- One request_key funds exactly one intent. To parallelize, ask the operator +- One request_key funds exactly one intent. To parallelize, ask the user for one key per payment; never derive or mutate keys. - The delegate reports back the authoritative `state` plus the ArcScan proof for `COMMITTED`, or the exact tool error. "It probably went through" is not @@ -77,6 +75,5 @@ UNKNOWN | REJECTED`), `replayed`, `payer.mode` (`SERVER_PRIVY`), - Walkthrough: `docs/MCP_ARC_PAYMENT.md` in the OneShot repository. - Human-readable page: `https://oneshot.kapustazh.dev/docs/mcp`. -- Install: copy this folder into the agent's skills directory, or add the - GitHub source `SWOFART/OneShot` with skill path - `.agents/skills/oneshot-arc-payment/SKILL.md`. +- Install (requires Node.js/npm; `npx` ships with npm): + `npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --skill oneshot-arc-payment`. diff --git a/.env.example b/.env.example index d232a6e..829209a 100644 --- a/.env.example +++ b/.env.example @@ -13,13 +13,11 @@ ONESHOT_WORKSPACE_ID=team-testnet-workspace ONESHOT_API_RATE_LIMIT_MAX_REQUESTS=60 ONESHOT_API_RATE_LIMIT_WINDOW_MS=60000 -# One-tool MCP demo. The bearer is accepted only at /mcp. The configured -# request key makes this deployment a literal one-intent demo; identical calls -# replay the same durable payment and any other key is denied. -# ONESHOT_MCP_BEARER_TOKEN= +# One-tool MCP. Each Privy user generates a workspace-bound bearer in Profile. +# The optional deployment bearer keeps one operator-controlled client working. +# ONESHOT_MCP_BEARER_TOKEN= # ONESHOT_MCP_REQUEST_KEY= # ONESHOT_MCP_PAYER_ADDRESS=0x<40-hex-privy-server-wallet-address> -# ONESHOT_MCP_MAX_AMOUNT_ATOMIC=1000000 # ONESHOT_MCP_WAIT_MS=2500 # Production worker effect boundary. Public identifiers are placeholders; diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index db876fc..da20160 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,7 +13,7 @@ import { type SupplierQuote, } from '@oneshot/contracts'; import { derivedJobId } from '@oneshot/domain'; -import type { IntentLedger, JobLedger } from '@oneshot/storage-postgres'; +import type { IntentLedger, JobLedger, McpCredentialStore } from '@oneshot/storage-postgres'; import { toNodeHandler } from '@modelcontextprotocol/node'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import type { ServiceAuthenticator } from './auth.js'; @@ -65,6 +65,7 @@ export interface ApiDependencies { readonly supplier?: SupplierPort; readonly walletActivity?: WalletActivityPort; readonly userWalletVerifier?: UserWalletVerificationPort; + readonly mcpCredentials?: Pick; readonly mcp?: ArcPaymentMcpConfig & { readonly authenticator: ServiceAuthenticator }; readonly authenticator: ServiceAuthenticator; readonly rateLimiter?: RateLimiter; @@ -136,12 +137,11 @@ export function buildApi(dependencies: ApiDependencies) { const correlations = new WeakMap(); const nextCorrelationId = dependencies.nextCorrelationId ?? randomUUID; const rateLimiter = dependencies.rateLimiter ?? allowAllRateLimiter; - const workspaceId = dependencies.config?.workspaceId ?? 'local-test-workspace'; + const defaultWorkspaceId = dependencies.config?.workspaceId ?? 'local-test-workspace'; + const requestWorkspaces = new WeakMap(); + const workspaceFor = (request: FastifyRequest): string => + requestWorkspaces.get(request) ?? defaultWorkspaceId; const walletActivity = dependencies.walletActivity ?? new UnavailableWalletActivityPort(); - const mcpHandler = dependencies.mcp - ? createArcPaymentMcpHandler({ ledger: dependencies.ledger, config: dependencies.mcp }) - : undefined; - const nodeMcpHandler = mcpHandler ? toNodeHandler(mcpHandler) : undefined; const jobsUnavailable = (reply: FastifyReply, request: FastifyRequest): void => sendError( reply, @@ -217,19 +217,22 @@ export function buildApi(dependencies: ApiDependencies) { const path = request.url.split('?')[0]; const isMcp = path === '/mcp'; if (!request.url.startsWith('/v1/') && !isMcp) return; - const decision = await (isMcp + const authentication = await (isMcp ? dependencies.mcp?.authenticator.authenticate(request.headers.authorization) : dependencies.authenticator.authenticate(request.headers.authorization)); - if (decision !== 'AUTHORIZED') { + if (!authentication || authentication.decision !== 'AUTHORIZED') { sendError( reply, - decision === 'FORBIDDEN' ? 403 : 401, - decision === 'FORBIDDEN' ? 'FORBIDDEN' : 'UNAUTHORIZED', + authentication?.decision === 'FORBIDDEN' ? 403 : 401, + authentication?.decision === 'FORBIDDEN' ? 'FORBIDDEN' : 'UNAUTHORIZED', 'Service authentication failed', correlationId, ); return reply; } + if (authentication.workspaceId) { + requestWorkspaces.set(request, authentication.workspaceId); + } if ( request.method === 'POST' && !(await rateLimiter.allow({ @@ -243,20 +246,103 @@ export function buildApi(dependencies: ApiDependencies) { } }); - if (nodeMcpHandler) { + if (dependencies.mcp) { app.all('/mcp', async (request, reply) => { + const mcpHandler = createArcPaymentMcpHandler({ + ledger: dependencies.ledger, + config: { + ...dependencies.mcp!, + workspaceId: requestWorkspaces.get(request) ?? dependencies.mcp!.workspaceId, + }, + }); + const nodeMcpHandler = toNodeHandler(mcpHandler); reply.hijack(); - await nodeMcpHandler( - request.raw as unknown as Parameters[0], - reply.raw, - request.body, - ); - }); - app.addHook('onClose', async () => { - await mcpHandler?.close(); + try { + await nodeMcpHandler( + request.raw as unknown as Parameters[0], + reply.raw, + request.body, + ); + } finally { + await mcpHandler.close(); + } }); } + app.get('/v1/profile/mcp-token', async (request, reply) => { + void reply.header('cache-control', 'no-store'); + const workspaceId = requestWorkspaces.get(request); + if (!workspaceId || !dependencies.mcpCredentials || !dependencies.mcp) { + sendError( + reply, + 403, + 'FORBIDDEN', + 'Personal MCP access is unavailable', + correlationFor(request), + ); + return; + } + const status = await dependencies.mcpCredentials.status(workspaceId); + return { + configured: status.configured, + ...(status.createdAt ? { created_at: status.createdAt } : {}), + request_key: dependencies.mcp.allowedRequestKey, + }; + }); + + app.post('/v1/profile/mcp-token', async (request, reply) => { + void reply.header('cache-control', 'no-store'); + const workspaceId = requestWorkspaces.get(request); + if (!workspaceId || !dependencies.mcpCredentials || !dependencies.mcp) { + sendError( + reply, + 403, + 'FORBIDDEN', + 'Personal MCP access is unavailable', + correlationFor(request), + ); + return; + } + const issued = await dependencies.mcpCredentials.issue(workspaceId); + if (!issued) { + sendError( + reply, + 409, + 'INTENT_PAYLOAD_CONFLICT', + 'An MCP token already exists; rotate it instead', + correlationFor(request), + ); + return; + } + return reply.code(201).send({ + bearer_token: issued.bearerToken, + created_at: issued.createdAt, + request_key: dependencies.mcp.allowedRequestKey, + }); + }); + + app.post('/v1/profile/mcp-token/rotate', async (request, reply) => { + void reply.header('cache-control', 'no-store'); + const workspaceId = requestWorkspaces.get(request); + if (!workspaceId || !dependencies.mcpCredentials || !dependencies.mcp) { + sendError( + reply, + 403, + 'FORBIDDEN', + 'Personal MCP access is unavailable', + correlationFor(request), + ); + return; + } + const issued = await dependencies.mcpCredentials.issue(workspaceId, true); + if (!issued) throw new Error('MCP credential rotation did not return a token'); + return { + bearer_token: issued.bearerToken, + created_at: issued.createdAt, + request_key: dependencies.mcp.allowedRequestKey, + }; + }); + app.post('/v1/intents', { schema: { body: createIntentBodySchema } }, async (request, reply) => { const result = await dependencies.ledger.createOrReplay(request.body, correlationFor(request)); if (result.kind === 'INTENT_PAYLOAD_CONFLICT') { @@ -281,10 +367,10 @@ export function buildApi(dependencies: ApiDependencies) { // Supplier creation is non-chargeable and uses the same durable task scope // as its idempotency key. The database transaction binds that order and the // settlement intent before the worker can observe payment work. - const jobId = derivedJobId(workspaceId, parsed); + const jobId = derivedJobId(workspaceFor(request), parsed); const order = await dependencies.supplier.createOrder(parsed, jobId); const result = await dependencies.jobs.createOrReplay({ - workspaceId, + workspaceId: workspaceFor(request), request: parsed, supplierOrder: order, correlationId: correlationFor(request), @@ -310,7 +396,7 @@ export function buildApi(dependencies: ApiDependencies) { const parsed = parseCreateJobRequest(request.body); // Quoting is deliberately non-chargeable: no intent, attempt, settlement, // or outbox row is created until the caller explicitly approves via POST /v1/jobs. - const jobId = derivedJobId(workspaceId, parsed); + const jobId = derivedJobId(workspaceFor(request), parsed); const order = await dependencies.supplier.createOrder(parsed, jobId); const quote: SupplierQuote = { supplier_id: order.supplier_id, @@ -340,10 +426,10 @@ export function buildApi(dependencies: ApiDependencies) { recipient: parsed.recipient, amount_atomic: parsed.amount_atomic, } as const; - const jobId = derivedJobId(workspaceId, jobRequest); + const jobId = derivedJobId(workspaceFor(request), jobRequest); const order = await dependencies.supplier.createOrder(jobRequest, jobId); const result = await dependencies.jobs.createUserWalletOrReplay({ - workspaceId, + workspaceId: workspaceFor(request), request: parsed, supplierOrder: order, correlationId: correlationFor(request), @@ -367,7 +453,7 @@ export function buildApi(dependencies: ApiDependencies) { jobsUnavailable(reply, request); return; } - return { jobs: await dependencies.jobs.list(workspaceId) }; + return { jobs: await dependencies.jobs.list(workspaceFor(request)) }; }); app.get<{ Params: { jobId: string } }>('/v1/jobs/:jobId', async (request, reply) => { @@ -375,7 +461,7 @@ export function buildApi(dependencies: ApiDependencies) { jobsUnavailable(reply, request); return; } - const job = await dependencies.jobs.get(workspaceId, request.params.jobId); + const job = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); if (!job) { sendError( reply, @@ -397,7 +483,7 @@ export function buildApi(dependencies: ApiDependencies) { userWalletUnavailable(reply, request); return; } - const job = await dependencies.jobs.get(workspaceId, request.params.jobId); + const job = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); if (!job) { sendError( reply, @@ -428,7 +514,7 @@ export function buildApi(dependencies: ApiDependencies) { ); if (!begun.begun) { if (begun.currentState === 'COMMITTED' || begun.currentState === 'FAILED_SAFE') { - const current = await dependencies.jobs.get(workspaceId, request.params.jobId); + const current = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); if (current) return reply.code(200).send(current); } sendError( @@ -511,7 +597,7 @@ export function buildApi(dependencies: ApiDependencies) { : verification.reason, ); } - const updated = await dependencies.jobs.get(workspaceId, request.params.jobId); + const updated = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); if (!updated) { sendError( reply, @@ -531,7 +617,7 @@ export function buildApi(dependencies: ApiDependencies) { jobsUnavailable(reply, request); return; } - const job = await dependencies.jobs.resumeDelivery(workspaceId, request.params.jobId); + const job = await dependencies.jobs.resumeDelivery(workspaceFor(request), request.params.jobId); if (!job) { sendError( reply, @@ -550,7 +636,7 @@ export function buildApi(dependencies: ApiDependencies) { jobsUnavailable(reply, request); return; } - const job = await dependencies.jobs.get(workspaceId, request.params.jobId); + const job = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); if (!job) { sendError( reply, @@ -579,7 +665,7 @@ export function buildApi(dependencies: ApiDependencies) { jobsUnavailable(reply, request); return; } - return dependencies.jobs.activity(workspaceId); + return dependencies.jobs.activity(workspaceFor(request)); }); app.post('/v1/activity/refresh', async (request, reply) => { @@ -589,12 +675,12 @@ export function buildApi(dependencies: ApiDependencies) { } const observation = await walletActivity.refresh(); await dependencies.jobs.recordActivityObservation({ - workspaceId, + workspaceId: workspaceFor(request), freshness: observation.freshness, coverageNote: observation.coverageNote, payload: observation.payload, }); - return reply.code(202).send(await dependencies.jobs.activity(workspaceId)); + return reply.code(202).send(await dependencies.jobs.activity(workspaceFor(request))); }); app.get<{ Params: { id: string } }>('/v1/intents/:id', async (request, reply) => { diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index b60520e..88ab9cf 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -2,8 +2,28 @@ import { timingSafeEqual } from 'node:crypto'; export type AuthenticationDecision = 'AUTHORIZED' | 'UNAUTHORIZED' | 'FORBIDDEN'; +export type AuthenticationResult = { + readonly decision: AuthenticationDecision; + readonly workspaceId?: string; +}; + export interface ServiceAuthenticator { - authenticate(authorization: string | undefined): Promise; + authenticate(authorization: string | undefined): Promise; +} + +export interface TokenWorkspaceLookup { + workspaceForToken(token: string): Promise; +} + +export function workspaceBearerAuthenticator(lookup: TokenWorkspaceLookup): ServiceAuthenticator { + return { + async authenticate(authorization) { + if (!authorization?.startsWith('Bearer ')) return { decision: 'UNAUTHORIZED' }; + const token = authorization.slice('Bearer '.length); + const workspaceId = await lookup.workspaceForToken(token); + return workspaceId ? { decision: 'AUTHORIZED', workspaceId } : { decision: 'UNAUTHORIZED' }; + }, + }; } export function staticBearerAuthenticator(expectedToken: string): ServiceAuthenticator { @@ -11,10 +31,12 @@ export function staticBearerAuthenticator(expectedToken: string): ServiceAuthent const expected = Buffer.from(`Bearer ${expectedToken}`, 'utf8'); return { async authenticate(authorization) { - if (!authorization) return 'UNAUTHORIZED'; + if (!authorization) return { decision: 'UNAUTHORIZED' }; const actual = Buffer.from(authorization, 'utf8'); - if (actual.length !== expected.length) return 'UNAUTHORIZED'; - return timingSafeEqual(actual, expected) ? 'AUTHORIZED' : 'FORBIDDEN'; + if (actual.length !== expected.length) return { decision: 'UNAUTHORIZED' }; + return { + decision: timingSafeEqual(actual, expected) ? 'AUTHORIZED' : 'FORBIDDEN', + }; }, }; } @@ -27,15 +49,15 @@ export interface CredentialRoute { export function compositeAuthenticator(routes: readonly CredentialRoute[]): ServiceAuthenticator { return { async authenticate(authorization) { - if (!authorization) return 'UNAUTHORIZED'; + if (!authorization) return { decision: 'UNAUTHORIZED' }; let sawForbidden = false; for (const route of routes) { if (!route.matches(authorization)) continue; const decision = await route.authenticator.authenticate(authorization); - if (decision === 'AUTHORIZED') return 'AUTHORIZED'; - if (decision === 'FORBIDDEN') sawForbidden = true; + if (decision.decision === 'AUTHORIZED') return decision; + if (decision.decision === 'FORBIDDEN') sawForbidden = true; } - return sawForbidden ? 'FORBIDDEN' : 'UNAUTHORIZED'; + return { decision: sawForbidden ? 'FORBIDDEN' : 'UNAUTHORIZED' }; }, }; } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 454f513..8e3dc4f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -28,11 +28,10 @@ export interface ApiRuntimeConfig { /** Credential-free read-only RPC used to verify user-submitted receipts. */ readonly userWalletRpcUrl?: string; readonly mcp?: { - readonly bearerToken: string; + readonly bearerToken?: string; readonly workspaceId: string; readonly allowedRequestKey: string; readonly payerWallet: string; - readonly maxAmountAtomic: bigint; readonly waitMs: number; }; } @@ -86,14 +85,16 @@ function mcpConfig(environment: NodeJS.ProcessEnv, workspaceId: string): ApiRunt 'ONESHOT_MCP_BEARER_TOKEN', 'ONESHOT_MCP_REQUEST_KEY', 'ONESHOT_MCP_PAYER_ADDRESS', - 'ONESHOT_MCP_MAX_AMOUNT_ATOMIC', 'ONESHOT_MCP_WAIT_MS', ] as const; if (names.every((name) => !environment[name]?.trim())) return undefined; if (!environment.ONESHOT_WORKSPACE_ID?.trim()) { throw new Error('ONESHOT_WORKSPACE_ID is required when MCP is enabled'); } - const bearerToken = required(environment, 'ONESHOT_MCP_BEARER_TOKEN', 32); + const bearerToken = environment.ONESHOT_MCP_BEARER_TOKEN?.trim(); + if (bearerToken && bearerToken.length < 32) { + throw new Error('Environment variable ONESHOT_MCP_BEARER_TOKEN must be at least 32 characters'); + } const allowedRequestKey = required(environment, 'ONESHOT_MCP_REQUEST_KEY'); if ( allowedRequestKey.length > 128 || @@ -107,21 +108,11 @@ function mcpConfig(environment: NodeJS.ProcessEnv, workspaceId: string): ApiRunt if (!/^0x[0-9a-f]{40}$/u.test(payerWallet)) { throw new Error('Invalid environment variable: ONESHOT_MCP_PAYER_ADDRESS'); } - const rawMax = environment.ONESHOT_MCP_MAX_AMOUNT_ATOMIC?.trim() || '1000000'; - if (rawMax.length > 78 || !/^[1-9][0-9]*$/u.test(rawMax)) { - throw new Error('Invalid environment variable: ONESHOT_MCP_MAX_AMOUNT_ATOMIC'); - } - const maxAmountAtomic = BigInt(rawMax); - const workerCap = environment.ONESHOT_SETTLEMENT_CAP_ATOMIC?.trim(); - if (workerCap && /^[1-9][0-9]*$/u.test(workerCap) && maxAmountAtomic > BigInt(workerCap)) { - throw new Error('ONESHOT_MCP_MAX_AMOUNT_ATOMIC must not exceed ONESHOT_SETTLEMENT_CAP_ATOMIC'); - } return { - bearerToken, + ...(bearerToken ? { bearerToken } : {}), workspaceId, allowedRequestKey, payerWallet, - maxAmountAtomic, waitMs: integer(environment, 'ONESHOT_MCP_WAIT_MS', 2_500, 0, 5_000), }; } diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts index 0170949..7ffce67 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -53,7 +53,6 @@ const outputSchema = z.strictObject({ export interface ArcPaymentMcpConfig { readonly workspaceId: string; readonly allowedRequestKey: string; - readonly maxAmountAtomic: bigint; readonly payerWallet: string; readonly submissionsDisabled?: boolean; readonly waitMs?: number; @@ -187,7 +186,6 @@ export function createArcPaymentMcpHandler({ REQUEST_KEY_MAX_LENGTH, ); const payerWallet = asEvmAddress(config.payerWallet); - if (config.maxAmountAtomic <= 0n) throw new Error('MCP amount cap must be positive'); const waitMs = config.waitMs ?? 2_500; const pollMs = config.pollMs ?? 250; if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 5_000) { @@ -224,11 +222,6 @@ export function createArcPaymentMcpHandler({ return toolError('Arc payment submission is disabled for this deployment.'); } const amount = parseUsdcAmount(amount_usdc); - if (BigInt(amount.atomic) > config.maxAmountAtomic) { - return toolError( - `Requested amount exceeds the MCP cap of ${config.maxAmountAtomic.toString(10)} atomic USDC.`, - ); - } const result = await ledger.createOrReplay( { business_intent_id: arcPaymentBusinessIntentId(config.workspaceId, requestKey), diff --git a/apps/api/src/privy-auth.ts b/apps/api/src/privy-auth.ts index 937b7f1..537e578 100644 --- a/apps/api/src/privy-auth.ts +++ b/apps/api/src/privy-auth.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { importSPKI, jwtVerify } from 'jose'; import type { ServiceAuthenticator } from './auth.js'; @@ -32,6 +33,10 @@ export function isJwtCredential(authorization: string | undefined): boolean { return token !== null && looksLikeJwt(token); } +export function privyWorkspaceId(subject: string): string { + return `privy_${createHash('sha256').update(subject, 'utf8').digest('hex')}`; +} + export function createPrivyAccessTokenAuthenticator( config: PrivyAccessTokenAuthenticatorConfig, ): ServiceAuthenticator { @@ -49,7 +54,7 @@ export function createPrivyAccessTokenAuthenticator( return { async authenticate(authorization) { const token = bearerToken(authorization); - if (token === null || !looksLikeJwt(token)) return 'UNAUTHORIZED'; + if (token === null || !looksLikeJwt(token)) return { decision: 'UNAUTHORIZED' }; let subject: string | undefined; try { @@ -61,17 +66,17 @@ export function createPrivyAccessTokenAuthenticator( }); subject = payload.sub; } catch { - return 'UNAUTHORIZED'; + return { decision: 'UNAUTHORIZED' }; } if (subject === undefined || subject.length === 0 || !subject.startsWith(PRIVY_DID_PREFIX)) { - return 'UNAUTHORIZED'; + return { decision: 'UNAUTHORIZED' }; } if (allowed !== null && !allowed.has(subject)) { config.onForbiddenSubject?.(subject); - return 'FORBIDDEN'; + return { decision: 'FORBIDDEN' }; } - return 'AUTHORIZED'; + return { decision: 'AUTHORIZED', workspaceId: privyWorkspaceId(subject) }; }, }; } diff --git a/apps/api/src/runtime.ts b/apps/api/src/runtime.ts index b3d6b6e..b5c9ee0 100644 --- a/apps/api/src/runtime.ts +++ b/apps/api/src/runtime.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { IntentLedger, JobLedger, migrate } from '@oneshot/storage-postgres'; +import { IntentLedger, JobLedger, McpCredentialStore, migrate } from '@oneshot/storage-postgres'; import { createUserWalletVerificationPort } from './user-wallet.js'; import { TeamReportSupplier } from '@oneshot/supplier-adapter'; import { Pool } from 'pg'; @@ -8,6 +8,7 @@ import { StudioWalletActivityPort } from './wallet-activity.js'; import { compositeAuthenticator, staticBearerAuthenticator, + workspaceBearerAuthenticator, type ServiceAuthenticator, } from './auth.js'; import { loadApiRuntimeConfig, type ApiRuntimeConfig } from './config.js'; @@ -48,6 +49,7 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise new Date(), nextAttemptId: randomUUID }); + const mcpCredentials = new McpCredentialStore(pool); const app = buildApi({ ledger, jobs, @@ -63,14 +65,27 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise true, + authenticator: staticBearerAuthenticator(config.mcp.bearerToken), + }, + ] + : []), + { + matches: () => true, + authenticator: workspaceBearerAuthenticator(mcpCredentials), + }, + ]), workspaceId: config.mcp.workspaceId, allowedRequestKey: config.mcp.allowedRequestKey, payerWallet: config.mcp.payerWallet, - maxAmountAtomic: config.mcp.maxAmountAtomic, waitMs: config.mcp.waitMs, submissionsDisabled: config.submissionsDisabled, }, diff --git a/apps/api/test/api.integration.test.ts b/apps/api/test/api.integration.test.ts index 6cd951c..30ff8b0 100644 --- a/apps/api/test/api.integration.test.ts +++ b/apps/api/test/api.integration.test.ts @@ -109,7 +109,6 @@ describePostgres('durable HTTP API', () => { workspaceId: 'integration-mcp-workspace', allowedRequestKey: requestKey, payerWallet: '0x1111111111111111111111111111111111111111', - maxAmountAtomic: 1_000_000n, waitMs: 0, }, }); diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index a5b4266..699b435 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { CreateJobRequest, IntentResponse, @@ -97,6 +97,83 @@ function createMockLedger( } describe('API boundary controls', () => { + it('uses the authenticated principal workspace for request listings', async () => { + const workspaces: string[] = []; + const app = buildApi({ + ledger: createMockLedger(), + jobs: { + async list(workspaceId: string) { + workspaces.push(workspaceId); + return []; + }, + } as unknown as ApiDependencies['jobs'], + authenticator: { + async authenticate(authorization) { + return { + decision: 'AUTHORIZED' as const, + workspaceId: authorization === 'Bearer alice' ? 'privy_alice' : 'privy_bob', + }; + }, + }, + config: { workspaceId: 'shared-fallback' }, + }); + + await app.inject({ + method: 'GET', + url: '/v1/jobs', + headers: { authorization: 'Bearer alice' }, + }); + await app.inject({ method: 'GET', url: '/v1/jobs', headers: { authorization: 'Bearer bob' } }); + + expect(workspaces).toEqual(['privy_alice', 'privy_bob']); + await app.close(); + }); + + it('issues one personal MCP bearer for the authenticated Privy workspace', async () => { + const issue = vi.fn(async () => ({ + bearerToken: 'a'.repeat(43), + createdAt: '2026-09-13T04:00:00.000Z', + })); + const app = buildApi({ + ledger: createMockLedger(), + authenticator: { + async authenticate() { + return { decision: 'AUTHORIZED' as const, workspaceId: 'privy_alice' }; + }, + }, + mcpCredentials: { + async status() { + return { configured: false }; + }, + issue, + }, + mcp: { + authenticator: staticBearerAuthenticator('legacy-mcp-token'), + workspaceId: 'legacy-workspace', + allowedRequestKey: 'approved-request', + payerWallet: '0x1111111111111111111111111111111111111111', + }, + }); + const headers = { authorization: 'Bearer privy-jwt' }; + + expect( + (await app.inject({ method: 'GET', url: '/v1/profile/mcp-token', headers })).json(), + ).toEqual({ configured: false, request_key: 'approved-request' }); + const created = await app.inject({ + method: 'POST', + url: '/v1/profile/mcp-token', + headers, + }); + expect(created.statusCode).toBe(201); + expect(created.json()).toEqual({ + bearer_token: 'a'.repeat(43), + created_at: '2026-09-13T04:00:00.000Z', + request_key: 'approved-request', + }); + expect(issue).toHaveBeenCalledWith('privy_alice'); + await app.close(); + }); + it('never reaches the ledger when the credential is forbidden', async () => { const calls: string[] = []; const app = buildApi({ @@ -108,7 +185,7 @@ describe('API boundary controls', () => { }), authenticator: { async authenticate() { - return 'FORBIDDEN'; + return { decision: 'FORBIDDEN' as const }; }, }, }); diff --git a/apps/api/test/auth-routing.test.ts b/apps/api/test/auth-routing.test.ts index 393381c..e367449 100644 --- a/apps/api/test/auth-routing.test.ts +++ b/apps/api/test/auth-routing.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from 'vitest'; import type { AuthenticationDecision, ServiceAuthenticator } from '../src/auth.js'; -import { compositeAuthenticator, staticBearerAuthenticator } from '../src/auth.js'; +import { + compositeAuthenticator, + staticBearerAuthenticator, + workspaceBearerAuthenticator, +} from '../src/auth.js'; import { isJwtCredential } from '../src/privy-auth.js'; function fixed(decision: AuthenticationDecision, calls: string[] = []): ServiceAuthenticator { return { async authenticate() { calls.push(decision); - return decision; + return { decision }; }, }; } @@ -18,7 +22,7 @@ describe('composite authenticator', () => { { matches: () => true, authenticator: fixed('UNAUTHORIZED') }, { matches: () => true, authenticator: fixed('AUTHORIZED') }, ]); - expect(await auth.authenticate('Bearer anything')).toBe('AUTHORIZED'); + expect(await auth.authenticate('Bearer anything')).toEqual({ decision: 'AUTHORIZED' }); }); it('prefers FORBIDDEN over UNAUTHORIZED when nothing authorizes', async () => { @@ -26,21 +30,21 @@ describe('composite authenticator', () => { { matches: () => true, authenticator: fixed('UNAUTHORIZED') }, { matches: () => true, authenticator: fixed('FORBIDDEN') }, ]); - expect(await auth.authenticate('Bearer anything')).toBe('FORBIDDEN'); + expect(await auth.authenticate('Bearer anything')).toEqual({ decision: 'FORBIDDEN' }); }); it('returns UNAUTHORIZED when no route matches', async () => { const auth = compositeAuthenticator([ { matches: () => false, authenticator: fixed('AUTHORIZED') }, ]); - expect(await auth.authenticate('Bearer anything')).toBe('UNAUTHORIZED'); + expect(await auth.authenticate('Bearer anything')).toEqual({ decision: 'UNAUTHORIZED' }); }); it('returns UNAUTHORIZED when the header is absent', async () => { const auth = compositeAuthenticator([ { matches: () => true, authenticator: fixed('AUTHORIZED') }, ]); - expect(await auth.authenticate(undefined)).toBe('UNAUTHORIZED'); + expect(await auth.authenticate(undefined)).toEqual({ decision: 'UNAUTHORIZED' }); }); it('never shows an opaque service token to the JWT route', async () => { @@ -53,7 +57,9 @@ describe('composite authenticator', () => { authenticator: fixed('AUTHORIZED', bearerCalls), }, ]); - expect(await auth.authenticate('Bearer opaque-service-token')).toBe('AUTHORIZED'); + expect(await auth.authenticate('Bearer opaque-service-token')).toEqual({ + decision: 'AUTHORIZED', + }); expect(jwtCalls).toEqual([]); expect(bearerCalls).toEqual(['AUTHORIZED']); }); @@ -67,15 +73,28 @@ describe('composite authenticator', () => { authenticator: fixed('AUTHORIZED', bearerCalls), }, ]); - expect(await auth.authenticate('Bearer aaa.bbb.ccc')).toBe('FORBIDDEN'); + expect(await auth.authenticate('Bearer aaa.bbb.ccc')).toEqual({ decision: 'FORBIDDEN' }); expect(bearerCalls).toEqual([]); }); it('leaves the existing static bearer behavior unchanged', async () => { const bearer = staticBearerAuthenticator('service-token'); - expect(await bearer.authenticate('Bearer service-token')).toBe('AUTHORIZED'); - expect(await bearer.authenticate('Bearer wrong-token-x')).toBe('FORBIDDEN'); - expect(await bearer.authenticate('Bearer short')).toBe('UNAUTHORIZED'); - expect(await bearer.authenticate(undefined)).toBe('UNAUTHORIZED'); + expect(await bearer.authenticate('Bearer service-token')).toEqual({ decision: 'AUTHORIZED' }); + expect(await bearer.authenticate('Bearer wrong-token-x')).toEqual({ decision: 'FORBIDDEN' }); + expect(await bearer.authenticate('Bearer short')).toEqual({ decision: 'UNAUTHORIZED' }); + expect(await bearer.authenticate(undefined)).toEqual({ decision: 'UNAUTHORIZED' }); + }); + + it('binds a personal bearer to its stored workspace', async () => { + const authenticator = workspaceBearerAuthenticator({ + async workspaceForToken(token) { + return token === 'personal-token' ? 'privy_alice' : undefined; + }, + }); + expect(await authenticator.authenticate('Bearer personal-token')).toEqual({ + decision: 'AUTHORIZED', + workspaceId: 'privy_alice', + }); + expect(await authenticator.authenticate('Bearer wrong')).toEqual({ decision: 'UNAUTHORIZED' }); }); }); diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts index 954de53..4a918a9 100644 --- a/apps/api/test/config.test.ts +++ b/apps/api/test/config.test.ts @@ -75,38 +75,39 @@ describe('API runtime configuration', () => { ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', ONESHOT_MCP_REQUEST_KEY: 'arc-demo-payment-1', ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', - ONESHOT_MCP_MAX_AMOUNT_ATOMIC: '1000000', ONESHOT_MCP_WAIT_MS: '500', - ONESHOT_SETTLEMENT_CAP_ATOMIC: '1000000', }); expect(config.mcp).toEqual({ bearerToken: 'mcp-token-with-at-least-thirty-two-characters', workspaceId: 'mcp-demo-workspace', allowedRequestKey: 'arc-demo-payment-1', payerWallet: '0x1111111111111111111111111111111111111111', - maxAmountAtomic: 1000000n, waitMs: 500, }); }); - it('fails closed on partial or over-cap MCP configuration', () => { + it('enables personal MCP credentials without a shared bearer', () => { + const config = loadApiRuntimeConfig({ + ...base, + ONESHOT_WORKSPACE_ID: 'mcp-fallback-workspace', + ONESHOT_MCP_REQUEST_KEY: 'arc-payment', + ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', + }); + expect(config.mcp).toEqual({ + workspaceId: 'mcp-fallback-workspace', + allowedRequestKey: 'arc-payment', + payerWallet: '0x1111111111111111111111111111111111111111', + waitMs: 2500, + }); + }); + + it('fails closed on partial MCP configuration', () => { expect(() => loadApiRuntimeConfig({ ...base, ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', }), ).toThrow('ONESHOT_WORKSPACE_ID'); - expect(() => - loadApiRuntimeConfig({ - ...base, - ONESHOT_WORKSPACE_ID: 'mcp-demo-workspace', - ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', - ONESHOT_MCP_REQUEST_KEY: 'arc-demo-payment-1', - ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', - ONESHOT_MCP_MAX_AMOUNT_ATOMIC: '1000001', - ONESHOT_SETTLEMENT_CAP_ATOMIC: '1000000', - }), - ).toThrow('must not exceed'); }); it('loads a complete Privy configuration', () => { diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index e7109ec..b610661 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -51,7 +51,6 @@ function app(createOrReplay: ApiDependencies['ledger']['createOrReplay']) { workspaceId: 'mcp-demo-workspace', allowedRequestKey: REQUEST_KEY, payerWallet: PAYER, - maxAmountAtomic: 1_000_000n, waitMs: 0, }, }); @@ -117,6 +116,33 @@ describe('MCP arc_payment', () => { expect(first).toMatch(/^intent_[0-9a-f]{64}$/u); }); + it('derives the intent from the personal bearer workspace', async () => { + let seenId = ''; + const createOrReplay = vi.fn(async (request: unknown): Promise => { + seenId = (request as IntentResponse).business_intent_id; + return { kind: 'ACCEPTED', intent: intent(request as Partial) }; + }); + const server = buildApi({ + ledger: ledger(createOrReplay), + authenticator: staticBearerAuthenticator(SERVICE_TOKEN), + mcp: { + authenticator: { + async authenticate() { + return { decision: 'AUTHORIZED' as const, workspaceId: 'privy_alice' }; + }, + }, + workspaceId: 'legacy-workspace', + allowedRequestKey: REQUEST_KEY, + payerWallet: PAYER, + waitMs: 0, + }, + }); + + await rpc(server, toolRequest(1)); + expect(seenId).toBe(arcPaymentBusinessIntentId('privy_alice', REQUEST_KEY)); + await server.close(); + }); + it('isolates the MCP credential and lists exactly one tool', async () => { const server = app(vi.fn(async () => ({ kind: 'ACCEPTED', intent: intent() }))); const missing = await server.inject({ @@ -225,7 +251,7 @@ describe('MCP arc_payment', () => { await server.close(); }); - it('rejects quota, cap, and immutable-payload conflicts before any new payment right', async () => { + it('rejects quota and immutable-payload conflicts before any new payment right', async () => { const createOrReplay = vi.fn(async (): Promise => ({ kind: 'INTENT_PAYLOAD_CONFLICT', intent: intent(), @@ -233,12 +259,10 @@ describe('MCP arc_payment', () => { const server = app(createOrReplay); const wrongKey = rpcBody(await rpc(server, toolRequest(1, { request_key: 'another-key' }))); - const aboveCap = rpcBody(await rpc(server, toolRequest(2, { amount_usdc: '1.000001' }))); expect(wrongKey.result.isError).toBe(true); - expect(aboveCap.result.isError).toBe(true); expect(createOrReplay).not.toHaveBeenCalled(); - const conflict = rpcBody(await rpc(server, toolRequest(3))); + const conflict = rpcBody(await rpc(server, toolRequest(2, { amount_usdc: '1.000001' }))); expect(conflict.result.isError).toBe(true); expect(conflict.result.content[0].text).toContain('different payment'); expect(createOrReplay).toHaveBeenCalledOnce(); diff --git a/apps/api/test/privy-auth.test.ts b/apps/api/test/privy-auth.test.ts index 37ab115..54065a9 100644 --- a/apps/api/test/privy-auth.test.ts +++ b/apps/api/test/privy-auth.test.ts @@ -2,7 +2,11 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { exportSPKI, generateKeyPair, SignJWT } from 'jose'; type GeneratedPrivateKey = Awaited>['privateKey']; -import { createPrivyAccessTokenAuthenticator, looksLikeJwt } from '../src/privy-auth.js'; +import { + createPrivyAccessTokenAuthenticator, + looksLikeJwt, + privyWorkspaceId, +} from '../src/privy-auth.js'; const APP_ID = 'test-app-id'; const OPERATOR = 'did:privy:operator-one'; @@ -47,7 +51,16 @@ function authenticator(overrides: { onForbiddenSubject?: (subject: string) => vo describe('Privy access token authenticator', () => { it('authorizes an allowlisted operator', async () => { const token = await sign(); - expect(await authenticator().authenticate(`Bearer ${token}`)).toBe('AUTHORIZED'); + expect(await authenticator().authenticate(`Bearer ${token}`)).toEqual({ + decision: 'AUTHORIZED', + workspaceId: privyWorkspaceId(OPERATOR), + }); + }); + + it('derives stable, distinct, opaque workspaces from Privy subjects', () => { + expect(privyWorkspaceId(OPERATOR)).toBe(privyWorkspaceId(OPERATOR)); + expect(privyWorkspaceId(OPERATOR)).not.toBe(privyWorkspaceId(OUTSIDER)); + expect(privyWorkspaceId(OPERATOR)).not.toContain(OPERATOR); }); it('forbids a verified but unlisted subject', async () => { @@ -56,37 +69,38 @@ describe('Privy access token authenticator', () => { const decision = await authenticator({ onForbiddenSubject: (subject) => seen.push(subject), }).authenticate(`Bearer ${token}`); - expect(decision).toBe('FORBIDDEN'); + const outcome = decision.decision; + expect(outcome).toBe('FORBIDDEN'); expect(seen).toEqual([OUTSIDER]); }); it('rejects a wrong audience', async () => { const token = await sign({ audience: 'someone-elses-app' }); - expect(await authenticator().authenticate(`Bearer ${token}`)).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); }); it('rejects a wrong issuer', async () => { const token = await sign({ issuer: 'evil.example' }); - expect(await authenticator().authenticate(`Bearer ${token}`)).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); }); it('rejects a verified token whose subject is not a Privy DID', async () => { const token = await sign({ subject: 'operator@example.com' }); - expect(await authenticator().authenticate(`Bearer ${token}`)).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); }); it('rejects an expired token', async () => { const token = await sign({ expiresIn: '-10m' }); - expect(await authenticator().authenticate(`Bearer ${token}`)).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); }); it('rejects a tampered signature', async () => { const token = await sign(); const parts = token.split('.'); const flipped = parts[2]?.startsWith('A') ? `B${parts[2].slice(1)}` : `A${parts[2]?.slice(1)}`; - expect(await authenticator().authenticate(`Bearer ${parts[0]}.${parts[1]}.${flipped}`)).toBe( - 'UNAUTHORIZED', - ); + expect( + (await authenticator().authenticate(`Bearer ${parts[0]}.${parts[1]}.${flipped}`)).decision, + ).toBe('UNAUTHORIZED'); }); it('rejects an unsigned token that claims alg none', async () => { @@ -94,13 +108,15 @@ describe('Privy access token authenticator', () => { const payload = Buffer.from( JSON.stringify({ sub: OPERATOR, iss: 'privy.io', aud: APP_ID, exp: 4_102_444_800 }), ).toString('base64url'); - expect(await authenticator().authenticate(`Bearer ${header}.${payload}.`)).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate(`Bearer ${header}.${payload}.`)).decision).toBe( + 'UNAUTHORIZED', + ); }); it('rejects a missing or malformed authorization header', async () => { - expect(await authenticator().authenticate(undefined)).toBe('UNAUTHORIZED'); - expect(await authenticator().authenticate('Bearer not-a-jwt')).toBe('UNAUTHORIZED'); - expect(await authenticator().authenticate('Basic abc.def.ghi')).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate(undefined)).decision).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate('Bearer not-a-jwt')).decision).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate('Basic abc.def.ghi')).decision).toBe('UNAUTHORIZED'); }); it('authorizes any verified subject when configured with wildcard allow-all', async () => { @@ -110,7 +126,7 @@ describe('Privy access token authenticator', () => { allowedSubjects: ['*'], }); const token = await sign({ subject: OUTSIDER }); - expect(await auth.authenticate(`Bearer ${token}`)).toBe('AUTHORIZED'); + expect((await auth.authenticate(`Bearer ${token}`)).decision).toBe('AUTHORIZED'); }); it('refuses to construct without an allowlist', () => { diff --git a/apps/api/test/runtime-auth.test.ts b/apps/api/test/runtime-auth.test.ts index e9e5050..5ee48d7 100644 --- a/apps/api/test/runtime-auth.test.ts +++ b/apps/api/test/runtime-auth.test.ts @@ -45,21 +45,29 @@ async function token(subject: string): Promise { describe('API authenticator composition', () => { it('accepts only the service bearer when Privy is disabled', async () => { const auth = buildApiAuthenticator(config(false)); - expect(await auth.authenticate('Bearer service-token-1234')).toBe('AUTHORIZED'); - expect(await auth.authenticate(`Bearer ${await token(OPERATOR)}`)).toBe('UNAUTHORIZED'); + expect(await auth.authenticate('Bearer service-token-1234')).toEqual({ + decision: 'AUTHORIZED', + }); + expect(await auth.authenticate(`Bearer ${await token(OPERATOR)}`)).toEqual({ + decision: 'UNAUTHORIZED', + }); }); it('accepts both credential classes when Privy is enabled', async () => { const auth = buildApiAuthenticator(config(true)); - expect(await auth.authenticate('Bearer service-token-1234')).toBe('AUTHORIZED'); - expect(await auth.authenticate(`Bearer ${await token(OPERATOR)}`)).toBe('AUTHORIZED'); + expect(await auth.authenticate('Bearer service-token-1234')).toEqual({ + decision: 'AUTHORIZED', + }); + expect((await auth.authenticate(`Bearer ${await token(OPERATOR)}`)).decision).toBe( + 'AUTHORIZED', + ); }); it('logs the rejected subject without any token material', async () => { const lines: string[] = []; const auth = buildApiAuthenticator(config(true), (line) => lines.push(line)); const outsiderToken = await token('did:privy:outsider'); - expect(await auth.authenticate(`Bearer ${outsiderToken}`)).toBe('FORBIDDEN'); + expect(await auth.authenticate(`Bearer ${outsiderToken}`)).toEqual({ decision: 'FORBIDDEN' }); expect(lines).toHaveLength(1); expect(lines[0]).toContain('did:privy:outsider'); expect(lines[0]).not.toContain(outsiderToken); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 48be97f..22d553e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -21,6 +21,7 @@ import { IntentForm } from './components/IntentForm.js'; import { IntentStatusView } from './components/IntentStatusView.js'; import { LoginGate } from './components/LoginGate.js'; import { McpDocsPage } from './components/McpDocsPage.js'; +import { McpProfile } from './components/McpProfile.js'; import { ReadinessBanner } from './components/ReadinessBanner.js'; import { JobList, JobWorkspace } from './components/JobWorkspace.js'; import { RecoverySurface, SettlementSurface } from './components/FrontendSurfaces.js'; @@ -78,8 +79,8 @@ function LandingPage(props: { readonly theme: Theme; readonly onToggleTheme: ()

RESUMABLE PAID SERVICES / ARC TESTNET

Resume the job, not the payment.

- Approve one direct Arc payment. If an agent restarts, the original - task, payment evidence and result stay together. + Approve one direct Arc payment. If an agent restarts, the original task, payment + evidence and result stay together.

One job. Many retries. At most one committed settlement. Team-operated testnet @@ -142,9 +143,9 @@ function CabinetPage(props: { readonly onToggleTheme: () => void; readonly userWallet?: UserWalletSession; }) { - const [section, setSection] = useState<'overview' | 'services' | 'requests' | 'protection'>( - 'overview', - ); + const [section, setSection] = useState< + 'overview' | 'services' | 'requests' | 'protection' | 'profile' + >('overview'); const [intentId, setIntentId] = useState(''); const [activity, setActivity] = useState(null); const [activityError, setActivityError] = useState(null); @@ -153,6 +154,7 @@ function CabinetPage(props: { services: 'Payment services', requests: 'Requests', protection: 'Payment proof', + profile: 'Profile', } as const; function selectRequest(id: string): void { @@ -217,8 +219,8 @@ function CabinetPage(props: { and result.

  • - Demonstrate recovery. Resume the existing sample result from Requests. - An uncertain payment needs investigation, not a new key. + Demonstrate recovery. Resume the existing sample result from + Requests. An uncertain payment needs investigation, not a new key.
  • @@ -307,6 +309,7 @@ function CabinetPage(props: { }} /> )} + {section === 'profile' && } diff --git a/apps/web/src/api/job-client.ts b/apps/web/src/api/job-client.ts index 29ba3ae..a03e859 100644 --- a/apps/web/src/api/job-client.ts +++ b/apps/web/src/api/job-client.ts @@ -9,6 +9,18 @@ import type { } from '@oneshot/contracts'; import type { ApiClientConfig } from './client.js'; +export interface McpCredentialStatus { + readonly configured: boolean; + readonly created_at?: string; + readonly request_key: string; +} + +export interface IssuedMcpCredential { + readonly bearer_token: string; + readonly created_at: string; + readonly request_key: string; +} + async function responseJson(response: Response): Promise { if (!response.headers.get('content-type')?.includes('application/json')) return null; try { @@ -119,4 +131,23 @@ export class JobApiClient { if (!response.ok || !body) throw new Error('Activity refresh is unavailable'); return body; } + + async mcpCredentialStatus(): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/profile/mcp-token`, { + headers: this.#headers(), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not load MCP access'); + return body; + } + + async issueMcpCredential(rotate: boolean): Promise { + const response = await this.#fetch( + `${this.#baseUrl}/v1/profile/mcp-token${rotate ? '/rotate' : ''}`, + { method: 'POST', headers: this.#headers() }, + ); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not generate MCP bearer token'); + return body; + } } diff --git a/apps/web/src/components/McpDocsPage.tsx b/apps/web/src/components/McpDocsPage.tsx index b5c51f6..8153eb4 100644 --- a/apps/web/src/components/McpDocsPage.tsx +++ b/apps/web/src/components/McpDocsPage.tsx @@ -14,10 +14,13 @@ const clientConfig = `{ } }`; +const skillInstall = + 'npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --skill oneshot-arc-payment'; + const toolInput = `{ "request_key": "", "recipient": "0x", - "amount_usdc": "1.000000", + "amount_usdc": "", "purpose": "One approved demo purchase" }`; @@ -57,17 +60,27 @@ export function McpDocsPage(props: { readonly theme: Theme; readonly onToggleThe

    Payment boundary

    • One configured request key can create one payment intent.
    • -
    • The default maximum is 1.000000 USDC, or 1000000 atomic units.
    • Exact retries return the original intent; changed fields return a conflict.
    • The server wallet pays without a MetaMask or browser wallet popup.
    +
    +

    Install the agent skill

    +

    + Install Node.js with npm first; npx is included with npm. Then install the + OneShot payment skill from the develop branch. +

    +
    +          {skillInstall}
    +        
    +
    +

    Client configuration

    - Ask the operator for the dedicated MCP bearer token and request key. Keep both in your - client environment; never paste a real credential into source control. + Sign in, open Profile, and generate your workspace-bound bearer. Keep it in your client + environment; never paste a real credential into source control.

               {clientConfig}
    diff --git a/apps/web/src/components/McpProfile.tsx b/apps/web/src/components/McpProfile.tsx
    new file mode 100644
    index 0000000..968edb1
    --- /dev/null
    +++ b/apps/web/src/components/McpProfile.tsx
    @@ -0,0 +1,97 @@
    +import { useEffect, useState } from 'react';
    +import type { IssuedMcpCredential, JobApiClient, McpCredentialStatus } from '../api/job-client.js';
    +
    +const MCP_URL = 'https://oneshot.kapustazh.dev/mcp';
    +const SKILL_INSTALL =
    +  'npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --skill oneshot-arc-payment';
    +
    +function configFor(token: string): string {
    +  return JSON.stringify(
    +    {
    +      mcpServers: {
    +        oneshot: {
    +          type: 'http',
    +          url: MCP_URL,
    +          headers: { Authorization: `Bearer ${token}` },
    +        },
    +      },
    +    },
    +    null,
    +    2,
    +  );
    +}
    +
    +export function McpProfile(props: { readonly client: JobApiClient }) {
    +  const [status, setStatus] = useState(null);
    +  const [issued, setIssued] = useState(null);
    +  const [error, setError] = useState(null);
    +  const [busy, setBusy] = useState(false);
    +
    +  useEffect(() => {
    +    void props.client
    +      .mcpCredentialStatus()
    +      .then(setStatus)
    +      .catch(() => setError('MCP access is unavailable right now.'));
    +  }, [props.client]);
    +
    +  async function issue(): Promise {
    +    setBusy(true);
    +    setError(null);
    +    try {
    +      const credential = await props.client.issueMcpCredential(status?.configured === true);
    +      setIssued(credential);
    +      setStatus({
    +        configured: true,
    +        created_at: credential.created_at,
    +        request_key: credential.request_key,
    +      });
    +    } catch {
    +      setError('Could not generate the MCP bearer token.');
    +    } finally {
    +      setBusy(false);
    +    }
    +  }
    +
    +  return (
    +    
    +

    PROFILE / AGENT ACCESS

    +

    Connect your agent

    +

    Your bearer is bound to this Privy account and its private request workspace.

    + + + {error &&

    {error}

    } + {status?.configured && !issued && ( +

    + A bearer already exists. It is stored only as a digest, so rotate it to reveal a new one. +

    + )} + {issued && ( + <> +

    + Copy this configuration now. The bearer will be hidden when you leave this page. +

    +
    +            {configFor(issued.bearer_token)}
    +          
    +

    + Request key: {issued.request_key} +

    + + )} + +

    Install the payment skill

    +

    + Node.js includes npm and npx. +

    +
    +        {SKILL_INSTALL}
    +      
    +
    + ); +} diff --git a/apps/web/test/app-composition.test.tsx b/apps/web/test/app-composition.test.tsx index 402b956..4d0d6cb 100644 --- a/apps/web/test/app-composition.test.tsx +++ b/apps/web/test/app-composition.test.tsx @@ -47,6 +47,7 @@ describe('Gate P5 shell composition', () => { expect(screen.getByRole('tab', { name: 'Payment services' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Requests' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Payment proof' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Profile' })).toBeTruthy(); }); it('publishes a safe MCP client configuration and replay walkthrough', () => { @@ -64,11 +65,14 @@ describe('Gate P5 shell composition', () => { expect(screen.getByLabelText('arc_payment tool input').textContent).toContain( '', ); - expect(screen.getByText(/1000000 atomic units/u)).toBeTruthy(); + expect(screen.getByLabelText('Agent skill install command').textContent).toContain( + 'npx --yes skills@latest add', + ); + expect(screen.queryByText(/1000000 atomic units/u)).toBeNull(); expect(screen.queryByRole('button', { name: /pay|submit|run/iu })).toBeNull(); }); - it('offers only the four working cabinet sections', () => { + it('offers only the working cabinet sections', () => { render( { 'Payment services', 'Requests', 'Payment proof', + 'Profile', ]); // Spending rules and Team & access were read-only restatements of facts the // other sections already show, and neither had a control behind it. @@ -99,6 +104,38 @@ describe('Gate P5 shell composition', () => { expect(screen.queryByRole('tab', { name: 'Team & access' })).toBeNull(); }); + it('generates a personal MCP bearer in Profile', async () => { + const user = userEvent.setup(); + const jobClient = { + async mcpCredentialStatus() { + return { configured: false, request_key: 'profile-request' }; + }, + async issueMcpCredential() { + return { + bearer_token: 'personal-secret-token', + created_at: '2026-09-13T04:00:00.000Z', + request_key: 'profile-request', + }; + }, + } as unknown as JobApiClient; + render( + signedInSession()} + jobClient={jobClient} + settlementClient={createInMemorySettlementClient(SETTLEMENT_SCENARIO_INTENTS)} + recoveryClient={createInMemoryRecoveryClient('lagging')} + />, + ); + + await user.click(screen.getByRole('tab', { name: 'Profile' })); + await user.click(await screen.findByRole('button', { name: 'Generate bearer token' })); + expect( + (await screen.findByLabelText('Personal MCP client configuration')).textContent, + ).toContain('Bearer personal-secret-token'); + expect(screen.getByText('profile-request')).toBeTruthy(); + }); + it('gives Payment services and Requests distinct responsibilities', async () => { const user = userEvent.setup(); const jobClient = { diff --git a/apps/web/test/job-client.test.ts b/apps/web/test/job-client.test.ts index 567a9a2..a102730 100644 --- a/apps/web/test/job-client.test.ts +++ b/apps/web/test/job-client.test.ts @@ -42,6 +42,31 @@ describe('JobApiClient quote flow', () => { expect(JSON.parse(calledBody)).toEqual(request); }); + it('issues a personal MCP credential with the active authorization', async () => { + let calledUrl = ''; + let authorization = ''; + const credential = { + bearer_token: 'personal-token', + created_at: '2026-09-13T04:00:00.000Z', + request_key: 'profile-request', + }; + const client = new JobApiClient({ + getAuthToken: () => 'privy-access-token', + fetchFn: async (input, init) => { + calledUrl = String(input); + authorization = new Headers(init?.headers).get('authorization') ?? ''; + return new Response(JSON.stringify(credential), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + await expect(client.issueMcpCredential(true)).resolves.toEqual(credential); + expect(calledUrl).toBe('/v1/profile/mcp-token/rotate'); + expect(authorization).toBe('Bearer privy-access-token'); + }); + it('refreshes activity without sending an empty JSON body', async () => { let calledInit: RequestInit | undefined; const client = new JobApiClient({ diff --git a/docs/MCP_ARC_PAYMENT.md b/docs/MCP_ARC_PAYMENT.md index 42a55e0..8556079 100644 --- a/docs/MCP_ARC_PAYMENT.md +++ b/docs/MCP_ARC_PAYMENT.md @@ -9,21 +9,22 @@ The web app renders the client setup and walkthrough at `/docs/mcp`. ## Deploy -Configure the API with one dedicated secret and one fixed demo scope: +Configure the API with one fixed request scope and payer. Personal bearer +tokens are generated from an authenticated Profile and stored as SHA-256 +digests in PostgreSQL: ```dotenv ONESHOT_WORKSPACE_ID= -ONESHOT_MCP_BEARER_TOKEN= ONESHOT_MCP_REQUEST_KEY= ONESHOT_MCP_PAYER_ADDRESS=0x -ONESHOT_MCP_MAX_AMOUNT_ATOMIC=1000000 ONESHOT_MCP_WAIT_MS=2500 ``` -Store `ONESHOT_MCP_BEARER_TOKEN` in the deployment secret store. It is accepted -only on `/mcp`; Privy browser JWTs and `SERVICE_BEARER_TOKEN` cannot call this -endpoint. The MCP cap must be no greater than the worker's -`ONESHOT_SETTLEMENT_CAP_ATOMIC` and the attached Privy policy cap. +`ONESHOT_MCP_BEARER_TOKEN` is optional and exists only for a legacy +operator-controlled client. If used, store it in Google Secret Manager. MCP +bearers are accepted only on `/mcp`; Privy browser JWTs and +`SERVICE_BEARER_TOKEN` cannot call this endpoint. Settlement remains subject to the worker's +`ONESHOT_SETTLEMENT_CAP_ATOMIC` and the attached Privy policy. The fixed `ONESHOT_MCP_REQUEST_KEY` is the one-intent demo quota. A call using another key is denied. A repeated call using the configured key and identical @@ -32,14 +33,21 @@ a conflict. ## Connect +Install Node.js with npm (`npx` is bundled with npm), then install the agent +skill: + +```sh +npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --skill oneshot-arc-payment +``` + Point any Streamable HTTP MCP client at: ```text https://oneshot.kapustazh.dev/mcp ``` -Send the dedicated token as `Authorization: Bearer `. A generic client -entry is: +Sign in to OneShot, open **Profile**, and generate a bearer for that Privy +account. Send it as `Authorization: Bearer `. A generic client entry is: ```json { @@ -61,7 +69,7 @@ entry is: { "request_key": "", "recipient": "0x", - "amount_usdc": "1.000000", + "amount_usdc": "", "purpose": "One approved demo purchase" } ``` diff --git a/docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md b/docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md index 298b76b..0dd9fc0 100644 --- a/docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md +++ b/docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md @@ -23,12 +23,15 @@ They must not block the first working `arc_payment` demo. ## Implementation status -- Tasks 1-7 are implemented on `mcp-integration` in commit `1f25bae` and passed - Gate A before that commit was pushed. -- The local `/docs/mcp` page and same-origin Vite proxy complete the local part - of Task 8. -- The real Arc Testnet call, identical replay, and recorded proof remain pending - while testing is restricted to the local environment. +- Tasks 1-7, the downloadable skill, and `/docs/mcp` are merged into `develop`. +- The MCP-specific 1 USDC cap has been removed. The worker and Privy policy + remain the payment authorization boundary. +- Privy browser requests are scoped to an opaque workspace derived from the + verified Privy subject, so job lists, results, and activity are per user. +- Each signed-in Privy user can generate or rotate one bearer in Profile. Only + its SHA-256 digest is stored, and `/mcp` resolves it to that user's workspace. +- Google Cloud deployment still needs the MCP runtime variables before the + public endpoint can initialize. The shared bearer is optional compatibility. ## Non-negotiable behavior @@ -37,22 +40,22 @@ They must not block the first working `arc_payment` demo. - Repeating the same request returns the existing intent and settlement. - Reusing the key with different immutable fields returns a conflict. - `SUBMITTING` and `UNKNOWN` never create a replacement payment. -- Privy policy and OneShot both validate the amount and transaction scope. +- Privy policy and the settlement worker validate the amount and transaction scope. - The tool returns authoritative OneShot state, not an inferred success. - A real demo payment is complete only after a verified Arc receipt is durable. ## Delivery order -| Order | Task | Depends on | Exit condition | -| ----- | --------------------------------------- | ---------- | ----------------------------------------------- | -| 1 | Freeze the tool contract | None | Input and output schemas are approved | -| 2 | Add MCP-only authentication | Task 1 | `/mcp` accepts only the dedicated credential | -| 3 | Mount Streamable HTTP MCP | Task 2 | `initialize` and `tools/list` expose one tool | -| 4 | Connect `arc_payment` to the ledger | Task 3 | Calls create or replay one durable intent | -| 5 | Enforce payment scope and spend bounds | Task 4 | Invalid or excessive requests broadcast nothing | -| 6 | Return status and proof | Task 4 | Replays report the same authoritative state | -| 7 | Verify failure and concurrency behavior | Tasks 4-6 | Required payment tests pass | -| 8 | Document and run one demo | Task 7 | One real Arc Testnet settlement is verified | +| Order | Task | Depends on | Exit condition | +| ----- | --------------------------------------- | ---------- | --------------------------------------------- | +| 1 | Freeze the tool contract | None | Input and output schemas are approved | +| 2 | Add MCP-only authentication | Task 1 | `/mcp` accepts only the dedicated credential | +| 3 | Mount Streamable HTTP MCP | Task 2 | `initialize` and `tools/list` expose one tool | +| 4 | Connect `arc_payment` to the ledger | Task 3 | Calls create or replay one durable intent | +| 5 | Enforce payment scope | Task 4 | Invalid requests broadcast nothing | +| 6 | Return status and proof | Task 4 | Replays report the same authoritative state | +| 7 | Verify failure and concurrency behavior | Tasks 4-6 | Required payment tests pass | +| 8 | Document and run one demo | Task 7 | One real Arc Testnet settlement is verified | ## Task 1: freeze the tool contract @@ -80,21 +83,23 @@ They must not block the first working `arc_payment` demo. ### Work -- Add one dedicated high-entropy bearer credential in deployment secret - storage for the first release. -- Accept it only on `/mcp`; do not let it authorize `/v1/*` routes. +- Generate one random 256-bit bearer per verified Privy workspace and store + only its SHA-256 digest in PostgreSQL. +- Show a new or rotated bearer once in the authenticated Profile. +- Accept personal bearers only on `/mcp`; do not let them authorize `/v1/*` + routes. Keep the deployment bearer optional for legacy operator clients. - Keep Privy JWT authentication for the browser and the existing internal service credential for internal or legacy routes. -- Bind the MCP principal to one explicit demo workspace. -- Compare credentials in constant time and never log or return them. +- Bind the MCP principal to the workspace that issued its bearer. +- Never log bearer values or return stored digests. ### Done when - Missing, invalid, and browser credentials fail on `/mcp`. -- The MCP credential succeeds on `/mcp` and fails on browser/API routes. +- Each personal credential succeeds on `/mcp`, fails on browser/API routes, + and derives intent IDs from its owner's workspace. -Self-service token generation, token tables, HMAC peppers, and per-user token -rotation are deferred until there is more than one MCP user. +Personal token generation, digest-only storage, and rotation are implemented. ## Task 3: mount the MCP transport @@ -141,7 +146,8 @@ rotation are deferred until there is more than one MCP user. zero native value, ERC-20 `transfer`, and the approved per-payment cap. - Validate the recipient as an EVM address even when the current policy permits any recipient. -- Keep the application settlement cap equal to or lower than the Privy cap. +- Keep the worker settlement cap equal to or lower than the Privy cap. Do not + add a second MCP-specific amount cap. - Add a cumulative control before allowing repeated unique requests. Choose one: - a Privy rolling USDC spending cap for normal use; or @@ -151,8 +157,8 @@ rotation are deferred until there is more than one MCP user. ### Done when -- Above-cap and exhausted-quota requests create zero broadcasts and zero - settlements. +- Worker or Privy above-cap denials and exhausted-quota requests create zero + broadcasts and zero settlements. - Policy drift makes the tool unavailable before submission. ## Task 6: return authoritative status and proof @@ -182,7 +188,7 @@ rotation are deferred until there is more than one MCP user. - Ten sequential identical calls produce one settlement. - Ten parallel identical calls produce one settlement. - Same key with changed immutable payload returns a conflict. -- Privy denial, amount above cap, and exhausted quota produce zero broadcasts. +- Privy denial, worker amount denial, and exhausted quota produce zero broadcasts. - Crash or lost response after possible submission enters `UNKNOWN`, then reconciliation finds the original payment without resubmission. - Committed replay returns the original transaction and result. @@ -200,7 +206,11 @@ rotation are deferred until there is more than one MCP user. - Add one short `/docs/mcp` page with generic Streamable HTTP configuration and one copy-ready client example. -- Use an environment-variable placeholder for the bearer credential. +- Use an environment-variable placeholder for the bearer credential. The real + value comes from Google Secret Manager and is never sent to an unauthenticated + documentation page. +- Include the verified `npx skills add` command and state that Node.js/npm + provides `npx`. - Demonstrate one real `arc_payment` call, one identical replay, and one proof view. - Record the Business Intent ID, verified Arc transaction, policy identity, @@ -212,27 +222,26 @@ rotation are deferred until there is more than one MCP user. - The demo shows one real Arc Testnet USDC settlement and no replacement settlement on replay. -Client-specific setup pages and an installable `oneshot-arc-payment` skill are -deferred until the tool contract is stable. +The installable `oneshot-arc-payment` skill is part of the first release. ## Milestone 2: personal Privy wallets Start this milestone only after the server-wallet MCP path is working. -1. Replace the fixed MCP principal with a principal containing credential kind, - Privy subject, opaque workspace ID, and MCP token ID. -2. Create one isolated workspace and one revocable MCP token per Privy user. -3. Store only a SHA-256 digest of each random 32-byte token and enforce - one-active-token generation atomically. -4. Bind jobs, intents, recovery, activity, results, and proofs to the workspace; - return `404` for cross-workspace identifiers. +1. Add a durable token ID and explicit credential kind to the existing opaque + workspace principal if audit trails require them. +2. Add explicit revoke without replacement; generation and rotation already + keep one active bearer per Privy workspace. +3. Add a server-side pepper only if the 256-bit random bearer format changes. +4. Extend the existing Privy-scoped job, result, and activity routes to direct + intent, recovery, and proof routes; return `404` for cross-workspace identifiers. 5. Discover the user's embedded Ethereum wallet and show funding/readiness. 6. Create a user-owned Privy override policy and add the OneShot P-256 key quorum as an additional signer after one explicit user authorization. 7. Resolve wallet and signer policy per workspace in the worker while keeping the existing global execution wallet only for legacy service requests. -8. Add the **Agents** tab for wallet status, signer enable/disable, policy - controls, and MCP token lifecycle. +8. Extend the existing **Profile** token controls with wallet status, + signer enable/disable, and policy controls. 9. Replace the current request list with a workspace-bound unified feed. 10. Add multi-user isolation, concurrent token generation, signer attachment, policy update, and browser accessibility tests. diff --git a/packages/storage-postgres/MIGRATIONS.md b/packages/storage-postgres/MIGRATIONS.md index 987d513..84c923f 100644 --- a/packages/storage-postgres/MIGRATIONS.md +++ b/packages/storage-postgres/MIGRATIONS.md @@ -12,11 +12,11 @@ silently edited or automatically reversed. ## Current schema digest -The append-only ledger, resumable-jobs, and historical provider-identity -migration set (`001` through `010`) has SHA-256 digest: +The append-only ledger, resumable-jobs, provider-identity, and personal MCP +credential migration set (`001` through `011`) has SHA-256 digest: ```text -e073e3f13db1be93c3f1359b6359cdb683ad3d7c46f26022e8fd388a36570275 +c38fa0d21cd675b385b01304d64c328d57bf742500af61ec12117280e8a3245b ``` ## Containerized Testing Command diff --git a/packages/storage-postgres/migrations/011_mcp_credentials.sql b/packages/storage-postgres/migrations/011_mcp_credentials.sql new file mode 100644 index 0000000..72ec56c --- /dev/null +++ b/packages/storage-postgres/migrations/011_mcp_credentials.sql @@ -0,0 +1,5 @@ +CREATE TABLE mcp_credentials ( + workspace_id text PRIMARY KEY CHECK (char_length(workspace_id) BETWEEN 1 AND 128), + token_digest text NOT NULL UNIQUE CHECK (token_digest ~ '^[0-9a-f]{64}$'), + created_at timestamptz NOT NULL +); diff --git a/packages/storage-postgres/src/index.ts b/packages/storage-postgres/src/index.ts index b56a021..14c38c7 100644 --- a/packages/storage-postgres/src/index.ts +++ b/packages/storage-postgres/src/index.ts @@ -3,3 +3,4 @@ export * from './fixtures.js'; export * from './ledger.js'; export * from './jobs.js'; export * from './migrations.js'; +export * from './mcp-credentials.js'; diff --git a/packages/storage-postgres/src/mcp-credentials.ts b/packages/storage-postgres/src/mcp-credentials.ts new file mode 100644 index 0000000..b924f3b --- /dev/null +++ b/packages/storage-postgres/src/mcp-credentials.ts @@ -0,0 +1,79 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { Pool } from 'pg'; + +const WORKSPACE = /^[a-zA-Z0-9_-]{1,128}$/u; +const TOKEN = /^[a-zA-Z0-9_-]{43}$/u; + +export interface McpCredentialStatus { + readonly configured: boolean; + readonly createdAt?: string; +} + +export interface IssuedMcpCredential { + readonly bearerToken: string; + readonly createdAt: string; +} + +export class McpCredentialStore { + constructor( + private readonly pool: Pick, + private readonly now: () => Date = () => new Date(), + private readonly nextToken: () => string = () => randomBytes(32).toString('base64url'), + ) {} + + async status(workspaceId: string): Promise { + const workspace = this.workspace(workspaceId); + const result = await this.pool.query<{ created_at: Date }>( + 'SELECT created_at FROM mcp_credentials WHERE workspace_id = $1', + [workspace], + ); + const createdAt = result.rows[0]?.created_at; + return createdAt + ? { configured: true, createdAt: createdAt.toISOString() } + : { configured: false }; + } + + async issue(workspaceId: string, rotate = false): Promise { + const workspace = this.workspace(workspaceId); + const bearerToken = this.nextToken(); + if (!TOKEN.test(bearerToken)) throw new Error('MCP token generator returned an invalid token'); + const createdAt = this.now(); + const digest = this.digest(bearerToken); + const result = rotate + ? await this.pool.query<{ created_at: Date }>( + `INSERT INTO mcp_credentials (workspace_id, token_digest, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (workspace_id) DO UPDATE + SET token_digest = EXCLUDED.token_digest, created_at = EXCLUDED.created_at + RETURNING created_at`, + [workspace, digest, createdAt], + ) + : await this.pool.query<{ created_at: Date }>( + `INSERT INTO mcp_credentials (workspace_id, token_digest, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (workspace_id) DO NOTHING + RETURNING created_at`, + [workspace, digest, createdAt], + ); + const stored = result.rows[0]?.created_at; + return stored ? { bearerToken, createdAt: stored.toISOString() } : undefined; + } + + async workspaceForToken(token: string): Promise { + if (!TOKEN.test(token)) return undefined; + const result = await this.pool.query<{ workspace_id: string }>( + 'SELECT workspace_id FROM mcp_credentials WHERE token_digest = $1', + [this.digest(token)], + ); + return result.rows[0]?.workspace_id; + } + + private workspace(value: string): string { + if (!WORKSPACE.test(value)) throw new Error('Invalid MCP workspace'); + return value; + } + + private digest(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); + } +} diff --git a/packages/storage-postgres/src/migrations.ts b/packages/storage-postgres/src/migrations.ts index c8dad96..ef8f5a6 100644 --- a/packages/storage-postgres/src/migrations.ts +++ b/packages/storage-postgres/src/migrations.ts @@ -41,7 +41,7 @@ async function migrationFiles(directory: string): Promise { const files = await migrationFiles(directory); diff --git a/packages/storage-postgres/test/ledger.integration.test.ts b/packages/storage-postgres/test/ledger.integration.test.ts index 13b79e5..8c9e7fe 100644 --- a/packages/storage-postgres/test/ledger.integration.test.ts +++ b/packages/storage-postgres/test/ledger.integration.test.ts @@ -51,7 +51,7 @@ describePostgres('PostgreSQL intent ledger', () => { const versions = await pool.query<{ version: number }>( 'SELECT version FROM schema_versions ORDER BY version', ); - expect(versions.rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(versions.rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); expect(await migrationDigest()).toMatch(/^[0-9a-f]{64}$/u); }); @@ -59,7 +59,7 @@ describePostgres('PostgreSQL intent ledger', () => { const directory = await mkdtemp(join(tmpdir(), 'oneshot-migration-')); try { await writeFile( - join(directory, '011_broken.sql'), + join(directory, '012_broken.sql'), 'CREATE TABLE must_rollback (id integer); SELECT missing_function();', 'utf8', ); @@ -68,7 +68,7 @@ describePostgres('PostgreSQL intent ledger', () => { "SELECT to_regclass('public.must_rollback')::text AS name", ); expect(table.rows[0]?.name).toBeNull(); - const version = await pool.query('SELECT 1 FROM schema_versions WHERE version = 11'); + const version = await pool.query('SELECT 1 FROM schema_versions WHERE version = 12'); expect(version.rowCount).toBe(0); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/packages/storage-postgres/test/mcp-credentials.test.ts b/packages/storage-postgres/test/mcp-credentials.test.ts new file mode 100644 index 0000000..1bbdce9 --- /dev/null +++ b/packages/storage-postgres/test/mcp-credentials.test.ts @@ -0,0 +1,36 @@ +import { createHash } from 'node:crypto'; +import type { Pool } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; +import { McpCredentialStore } from '../src/mcp-credentials.js'; + +const TOKEN = 'a'.repeat(43); +const NOW = new Date('2026-09-13T04:00:00.000Z'); + +describe('MCP credential store', () => { + it('stores only a digest and returns the token once', async () => { + const query = vi.fn(async () => ({ rows: [{ created_at: NOW }] })); + const store = new McpCredentialStore( + { query } as unknown as Pick, + () => NOW, + () => TOKEN, + ); + + await expect(store.issue('privy_alice')).resolves.toEqual({ + bearerToken: TOKEN, + createdAt: NOW.toISOString(), + }); + const values = query.mock.calls[0]?.[1] as unknown[]; + expect(values).toEqual(['privy_alice', createHash('sha256').update(TOKEN).digest('hex'), NOW]); + expect(values).not.toContain(TOKEN); + }); + + it('looks up the workspace by token digest', async () => { + const query = vi.fn(async () => ({ rows: [{ workspace_id: 'privy_alice' }] })); + const store = new McpCredentialStore({ query } as unknown as Pick); + + await expect(store.workspaceForToken(TOKEN)).resolves.toBe('privy_alice'); + expect(query.mock.calls[0]?.[1]).toEqual([createHash('sha256').update(TOKEN).digest('hex')]); + await expect(store.workspaceForToken('short')).resolves.toBeUndefined(); + expect(query).toHaveBeenCalledOnce(); + }); +});