17 — agent advertiser API and authorization server (Design)
17 — agent advertiser API and authorization server (Design)
Section titled “17 — agent advertiser API and authorization server (Design)”Status: Binding
Date: 2026-09-20
Owner: agent readiness (subsystem 17)
Depends on: 02 identity-onboarding (first-bid gate, VAT/KYC states), 03 ad-moderation (isAdApproved), 04 auction-engine (AuctionDO, idempotency), 05 payment-saga (default payment method), 08 advertiser-dashboard (/app/*), 12 legal-compliance, 13 quality-and-release-policy.
Binding refs: master decision log D59 (added by this spec); docs/agent-readiness/ora-85-followup.md (anonymous public API policy, superseded in part by this spec).
1. Goals and non-goals
Section titled “1. Goals and non-goals”Goal. An AI agent can advertise on nowyourlink for a verified advertiser: place and raise a sealed bid, upload and submit a creative, read bids, creatives, invoices and the account. The advertiser delegates access through OAuth 2.1 with a per-grant spending mandate. The service exposes the same surface as REST and as an authenticated MCP server.
In scope (subsystem 17 owns):
- The OAuth 2.1 authorization server on
apps/api, issuerhttps://api.nowyourlink.com. - The consent page on the apex (
/app/authorize) and the “Connected agents” dashboard page. - The mandate model (scopes, caps, validity) and its enforcement.
- The authorized REST surface
/v1/agent/*and the authenticated MCP serverhttps://api.nowyourlink.com/mcp. - Discovery documents: RFC 8414 metadata, RFC 9728 PRM on apex and api,
auth.mdagent_authblock, advertiser MCP server card. - Kit additions: SDK and CLI auth, skill
advertise-on-nowyourlink.
Non-goals (stay where they are):
- Registration, e-mail verification, VAT validation, KYC, payment-method setup, 3DS. These stay browser-only (spec 02). An agent cannot complete them.
- Admin, moderation decisions, saga control, house ads, marketing campaigns.
- Video creative upload through the agent path (presign flow deferred).
- Machine-to-machine client-credentials tokens without a user. Every token belongs to one advertiser.
- Agent payment protocols (x402, MPP, ACP, UCP, AP2). A later spec may map the mandate to AP2.
Success criteria.
- An MCP client (Claude, ChatGPT, Cursor) connects to
https://api.nowyourlink.com/mcpwith no manual configuration, completes consent, and places a bid that lands inAuctionDOunder the advertiser’suser_id. - The API refuses a bid above
max_bid_centsor beyonddaily_cap_centswith403 agent.mandate-exceededand changes no state. - Ora
oauth-support,oauth-protected-resource,mcp-auth-mechanism,scoped-permissions,agent-auth-*and isitagentreadydiscovery.oauthDiscovery,oauthProtectedResource,authMdpass without fabricated documents.
2. Existing contracts this spec builds on
Section titled “2. Existing contracts this spec builds on”Read these files before you implement. The table names real files, not summaries.
| Contract | Where | What the agent path reuses |
|---|---|---|
| Bid placement | apps/api/src/auction-bid-routes.ts (PostBidBody, forwardBidEvent, assertOwnedApprovedAd) | Body shape amount_cents, currency, ad_id, idempotency_key; creative gate; user_id from trusted context only |
| Bid increase | same file, POST /api/auctions/:day/bids/:bidId/increase | hold_ref injected from users.default_payment_method_id |
| Idempotency | apps/api/src/auction-do.ts (idem:<user_id>:<key>, 24 h, POST /idem-replay) | Agent Idempotency-Key header becomes idempotency_key |
| Engine rules | packages/auction-engine/src/state-machine.ts (MIN_BID_CENTS = 100, supersede-once, anti-snipe) | Unchanged |
| First-bid gate | apps/api/src/first-bid-routes.ts (VAT statuses, escalation #1, D30, SetupIntent, pending_bid_token) | Agent path never runs it; it checks the gate’s outcome instead (§5) |
| Creative upload | apps/api/src/ads-routes.ts (postAdsUpload, 10 MB, MIME allowlist, SHA-256 dedupe) | Called as-is with the agent’s user_id |
| Creative lifecycle | apps/api/src/ads-library-routes.ts (POST /ads, PATCH, POST /ads/:id/submit, GET /ads, GET /ads/:id) | Called as-is |
| Account | packages/users/migrations/0001_initial.sql (users.vat_validation_status, default_payment_method_id, kyc_level, banned_at), kyc_escalations | Eligibility matrix (§5) |
| Session | packages/auth/src/session.ts (nyl_sess, readSessionCookie, touchSession) | Consent page only |
| Errors | @nowyourlink/errors toProblemJson, app.onError in apps/api/src/app.ts | All agent errors are problem+json |
| Rate limit | packages/auth/src/rate-limit.ts (KV RATE) | Bucket keyed by grant id |
| Public agent surface | apps/web/src/lib/agent-public.ts, agent-docs.ts | Apex PRM, auth.md, server cards, llms.txt updates |
3. Architecture
Section titled “3. Architecture”3.1 Placement
Section titled “3.1 Placement”agent (MCP client / SDK) │ 1. GET https://api.nowyourlink.com/mcp → 401 + WWW-Authenticate: Bearer resource_metadata="https://api.nowyourlink.com/.well-known/oauth-protected-resource" │ 2. PRM → authorization_servers ["https://api.nowyourlink.com"] → RFC 8414 metadata │ 3. CIMD (client_id = https URL of the client's metadata) or DCR at /oauth/register │ 4. GET /oauth/authorize?…&code_challenge=… (PKCE S256) ▼apps/api (workers-oauth-provider wraps the Hono app) │ 5. signed hand-off → 302 https://nowyourlink.com/app/authorize?h=<blob> ▼apps/web /app/authorize (Astro, nyl_sess) ── advertiser picks scopes + mandate │ 6. POST env.API /oauth/approve {handoff, scopes, mandate} (service binding, internal secret) ▼apps/api completeAuthorization → {redirectTo} → web 302 to the client's redirect_uri with code │ 7. POST /oauth/token (code + code_verifier) → access (1 h) + refresh (30 d) ▼apps/api /v1/agent/* and /mcp with Bearer → ctx.props {userId, grantId, scopes, mandate}The public anonymous surfaces on the apex (/mcp, /mcp/docs, /api/v1/*) stay as they are.
3.2 Authorization server
Section titled “3.2 Authorization server”Library: @cloudflare/workers-oauth-provider (Cloudflare, OAuth 2.1 with PKCE, tokens in KV). Docs: https://github.com/cloudflare/workers-oauth-provider. Pin the version the kit already runs elsewhere (0.10.x) or newer; the options below exist in the _autodocs/configuration.md of that repo.
apps/api/src/index.ts exports new OAuthProvider({...}) and the current createApp() Hono app becomes the defaultHandler. Configuration:
| Option | Value | Why |
|---|---|---|
authorizeEndpoint | /oauth/authorize | A path, not an absolute URL |
tokenEndpoint | /oauth/token | The library derives the issuer from the request origin |
clientRegistrationEndpoint | /oauth/register | DCR (RFC 7591) as compatibility path |
clientIdMetadataDocumentEnabled | true | MCP 2026 default; requires global_fetch_strictly_public in wrangler.jsonc per the library README |
allowPlainPKCE | false | S256 only |
allowImplicitFlow | false | |
accessTokenTTL | 3600 | |
refreshTokenTTL | 2592000 | 30 days, bounded further by the mandate valid_until (§4) |
scopesSupported | the scopes in §4 | |
apiHandlers | { "/v1/agent": AgentApiHandler, "/mcp": AgentMcpHandler } | Bearer-gated prefixes |
resourceMetadata | { scopes_supported, resource_name } | The library serves /.well-known/oauth-protected-resource on api. resource and authorization_servers stay unset, so both derive from the request origin and preview advertises itself |
clientRegistrationCallback | reject registrations whose redirect_uris are not https or loopback | |
onError | log agent.oauth.error with code, never the token |
Storage: new KV namespace binding OAUTH_KV on nowyourlink-api-production and -preview (Terraform cloudflare_workers_kv_namespace, both envs, see memory rule “TF changes include preview env”). The library owns the key layout; nothing else reads it.
The library answers /.well-known/oauth-authorization-server itself and offers no hook for extra members. The api therefore serves a second, equivalent document at /.well-known/oauth-authorization-server-agent, which carries the agent_auth block (auth.md spec, https://github.com/workos/auth.md). Its issuer and endpoints derive from the request origin, like the canonical document. /auth.md links both.
"agent_auth": { "skill": "https://nowyourlink.com/auth.md", "identity_types_supported": ["anonymous"], "anonymous": { "credential_types_supported": ["oauth2_authorization_code"], "claim_uri": "https://nowyourlink.com/app/authorize" }, "revocation_uri": "https://nowyourlink.com/app/agents"}anonymous is the auth.md identity type for “the agent has no identity of its own; the user grants access in a ceremony”. The ceremony here is the OAuth consent. The server does not offer identity_assertion (ID-JAG).
3.3 Consent hand-off
Section titled “3.3 Consent hand-off”The consent page needs the advertiser’s browser session, which lives on nowyourlink.com, not on api.nowyourlink.com. The hand-off follows the pattern already in production in the founder’s bq-chain project (workers/mcp/src/oauth-handoff.ts):
GET /oauth/authorizeon api:env.OAUTH_PROVIDER.parseAuthRequest(request),lookupClient. OnAuthorizationErrorwith a redirect URI, redirect witherroranderror_description; without one, render a plain 400.- Build
handoff = { authRequest, clientId, clientName, requestedScopes, exp: now + 10 min }, HMAC-SHA256 with a new Worker secretOAUTH_HANDOFF_SECRET(both api and web hold it; add to the secret checklist files per memory rule “new worker secret file checklist”). Redirect302tohttps://nowyourlink.com/app/authorize?h=<base64url(json)>.<hex(hmac)>. /app/authorize(Astro page, session required). An unauthenticated visitor goes through the normal magic-link login and comes back:/loginsets a 15-minutenyl_nextcookie with the consent URL, and/apphonours it once. The cookie is per browser, so the login must finish in the browser that started the consent. Verify signature and expiry before parsing the body. Render client name, requested scopes, the mandate form (§4) and a warning that an agent will spend real money within the caps.- Submit posts to
POST /app/authorize/decide; the revoke form on/app/agentsposts toPOST /app/agents/revoke. Both are Astro.tsendpoints under/app/, which Astro’s CSRF check already covers, so neither needs aworker.tsfast-path. Each forwards to api (POST /oauth/approve,POST /oauth/grants/{grantId}/revoke) over theAPIservice binding with the internal headerX-NYL-Internal(existing shared-secret pattern) and the sessionuser_id. /oauth/approveverifies the hand-off again and burns itsjti: oneINSERTintooauth_handoff_jti(D1, PRIMARY KEY, migration 0048). One blob buys one decision, approve or deny. TheINSERTis the decision — a read-then-write lets two concurrent posts of the same blob both pass and mint two grants. The same statement deletes the rows that expired, so no cron owns the table. Then it callscompleteAuthorization({ request, userId, scope, metadata: { clientName, mandate, grantId }, props: { userId, grantId, scopes, mandate } })and returns{ redirectTo }. Web responds302 redirectTo. Consent validates the explicit mandate and active account; physical first-bid prerequisites are read during preparation and enforced on every bid.- Deny: web redirects to the client’s
redirect_uriwitherror=access_deniedandstate.
The library stores the props with the grant and returns them as ctx.props on every bearer request. To change the mandate, the advertiser revokes the grant and consents again; v1 has no in-place edit.
3.4 Authorized REST surface /v1/agent/*
Section titled “3.4 Authorized REST surface /v1/agent/*”All routes: bearer via the provider, Content-Type: application/json unless noted, problem+json errors, X-Request-Id echoed, RateLimit headers from the existing bucket keyed agent:<grantId> (120 tokens, 2/s, same policy as the public API).
| Method and path | Scope | Behaviour |
|---|---|---|
GET /v1/agent/me | account:read | Account summary plus first_bid prerequisites and next action. Optional day, ad_id, amount_cents select a concrete proposal. Never returns e-mail, VAT id or card details. |
GET /v1/agent/bids?day=YYYY-MM-DD | bids:read | The advertiser’s bids for the day from user_bids (D1 projection), plus auction status. |
POST /v1/agent/bids | bids:write | Body { day, ad_id, amount_cents, currency }. Header Idempotency-Key (visible ASCII, 1–255) required → idempotency_key. Runs §5 eligibility, §4 mandate, then the existing bid handler with hold_ref injected from default_payment_method_id. 202 with the DO result body. |
POST /v1/agent/bids/{bidId}/increase | bids:write | Same gates; existing increase handler. |
GET /v1/agent/creatives | creatives:read | Existing GET /ads list (cursor, state filter). |
GET /v1/agent/creatives/{id} | creatives:read | Existing GET /ads/:id. |
POST /v1/agent/creatives/upload | creatives:write | multipart/form-data, existing postAdsUpload (10 MB, jpeg/png/webp/gif). Returns the draft asset. |
POST /v1/agent/creatives | creatives:write | Existing POST /ads (headline, description, target URL, asset id). |
POST /v1/agent/creatives/{id}/submit | creatives:write | Existing submit (draft→pending, blacklist, q-moderation), 202. |
GET /v1/agent/invoices | invoices:read | New thin reader over the invoices D1 table (the dashboard reads it through locals today; move the reader into packages/users so both share it). Fields: id, day, amount_cents, currency, status, pdf URL (signed, 15 min). |
Each route is an adapter: it builds the same input the session route builds, with user_id = ctx.props.userId, and calls the same pure route function. It duplicates no business logic.
Error types added to @nowyourlink/errors (all application/problem+json): agent.scope-missing (403), agent.mandate-exceeded (403, with limit, used, requested in the body), agent.mandate-expired (403), agent.human-step-required (403, with action_url), agent.idempotency-key-required (400).
3.5 Authenticated MCP server https://api.nowyourlink.com/mcp
Section titled “3.5 Authenticated MCP server https://api.nowyourlink.com/mcp”Streamable HTTP, JSON-RPC 2.0, protocol 2025-06-18, stateless like the public server. Unauthenticated requests get 401 with WWW-Authenticate: Bearer resource_metadata="https://api.nowyourlink.com/.well-known/oauth-protected-resource" (the library does this for apiHandlers). initialize returns serverInfo { name: "nowyourlink-advertiser", version } and instructions that state the mandate limits of this grant.
12 tools (names snake_case). The public tool list — name, description, inputSchema, annotations, no call mapping — lives in packages/shared/src/agent-tools.ts so apps/api and the server card publish the same data; outputSchema per tool and an image_base64 alternative to image_url on upload_creative are deferred, not in this cut.
| Tool | Scope | Annotations | Maps to |
|---|---|---|---|
get_account | account:read | readOnly | GET /v1/agent/me |
list_my_bids | bids:read | readOnly | GET /v1/agent/bids |
place_bid | bids:write | readOnly false, destructive false, idempotent true, openWorld false | POST /v1/agent/bids; idempotency_key argument required |
increase_bid | bids:write | same as place_bid | increase |
list_creatives, get_creative | creatives:read | readOnly | list / get |
upload_creative | creatives:write | readOnly false, destructive false, idempotent false, openWorld true (dials the advertiser’s image host) | image_url (https, fetched server-side, 10 MB cap, same MIME allowlist); then POST creatives with copy fields |
update_creative | creatives:write | readOnly false, destructive false | Edit headline, description or target URL of an owned draft, without re-uploading the image |
submit_creative | creatives:write | readOnly false | submit |
list_invoices | invoices:read | readOnly | invoices |
sandbox_place_bid | bids:write | readOnly true, destructive false | Read concrete prerequisites and the mandate; validate any explicitly selected card through the same ownership gate as place_bid. Return sandbox, accepted and first_bid; write and spend nothing. |
sandbox_upload_creative | creatives:write | readOnly true, destructive false | Validate creative copy and the image URL shape, run the moderation preflight, write nothing |
The server lists tools the grant’s scopes do not cover, so clients can show them, and answers their calls with isError and agent.scope-missing. The public server card stays; a second card https://nowyourlink.com/.well-known/mcp/advertiser-server-card.json describes this server with authentication: { type: "oauth2", authorization_servers: [...] } and is linked from llms.txt, agents.md, developers.md and the ARD catalog.
sandbox_upload_creative and submit_creative both run copy through the same Jev-backed moderation preflight ahead of the real moderation queue. The sandbox call returns the verdict (pass, review, block or unavailable) and its signals without writing anything, so an agent can fix headline, description or target URL before it spends a real submission; submit_creative runs the identical check and refuses with 422 on a block verdict instead of queuing the creative.
3.6 Discovery documents
Section titled “3.6 Discovery documents”2026-10-06 account-plugin extension. https://nowyourlink.com/mcp/account
combines the two anonymous documentation tools with the delegated tool catalog.
The web Worker handles it before Astro’s browser CSRF guard, retains request
budgets and forwards private calls through its existing API service binding.
The forwarded request retains the exact apex resource URL. The gateway removes
cookies and internal authentication headers. The account resource accepts only
tokens whose audience is exactly ${APP_BASE_URL}/mcp/account, applies token downscoping,
and rejects missing or banned users before invoking a tool. Its OAuth helper is
request-local so it cannot change the legacy authorization server’s policy.
/.well-known/oauth-protected-resource/mcp/account advertises that resource and
the corresponding API authorization server. Unsupported deployment hosts return
503 rather than advertising a local authorization server.
The legacy /mcp/docs and API /mcp remain available. Mixed-auth tool descriptors
declare their scope in both securitySchemes and _meta.securitySchemes.
Private tool calls without sufficient authorization return the tool-level
_meta["mcp/www_authenticate"] challenge; private Events methods use a transport
401. Modern documentation and private results share the nowyourlink server
identity. get_profile has no arguments, uses account:read, carries
_meta["openai/profile"]: true and returns the credential-owned user ID plus an
available company display name.
The catalog also exposes affiliate profile reads and explicit terms acceptance,
Connect readiness refresh and hosted onboarding. These adapters use the existing
affiliate APIs and their ownership and eligibility checks; affiliate-only access
needs no spending mandate. Real bids, bid increases, payout requests, creative
overwrites and affiliate terms acceptance carry a destructive hint appropriate
to their effect. The connection reuses the existing creative.status_changed
Events contract and advertises no additional auction or affiliate Events.
Native ChatGPT acceptance and delivery remain separate release checks. The
following report records the submission package and current check evidence:
docs/agent-readiness/chatgpt-account-implementation-2026-10-06.md.
The October 6 extension adds private campaign snapshots and owned Spotlight
statistics (reports:read), settings reads (account:read) and eligible company
corrections/contact preferences (account:write). Browser billing, passkeys,
marketing opt-in confirmation and device push enrollment retain their existing
flows. Report adapters preserve frozen spend and null tracking metrics; live
queries withhold mixed-advertiser aggregates.
Six video tools reuse advertiser multipart upload, metadata validation and the
existing transcode queue. Their trusted actor comes from the grant, never a
cookie or body field. Video writes use creatives:write; status uses
creatives:read. New delegated routes reject missing/banned accounts on both
legacy and account endpoints after the scope check. Upload URLs expire after
15 minutes; the client keeps actual PUT ETags. The MCP body cap rises to 64 KiB
for up to 299 completion parts. Only ready permits moderation submission.
Replacement detaches the current draft media immediately. The optional
Brag/HyperFrames skill prepares video compositions using a separate rendering
runtime; the server does not render HTML. Native host rendering and byte upload
capabilities require separate verification.
| Document | Host | Change |
|---|---|---|
/.well-known/oauth-authorization-server | api | The library serves it unchanged; issuer and endpoints derive from the request origin |
/.well-known/oauth-authorization-server-agent | api | The api serves it: the same members plus the agent_auth block (§3.2) |
/.well-known/oauth-protected-resource | api | The library serves it (resourceMetadata); resource derives from the request |
/.well-known/oauth-protected-resource | apex | authorization_servers: ["https://api.nowyourlink.com"], scopes_supported = the scopes in §4, bearer_methods_supported: ["header"], resource stays https://nowyourlink.com/api/v1; note in the body that /api/v1 itself needs no token |
/auth.md | apex | Rewrite the “Pick a method / Register / Claim / Exchange / Use / Errors / Revocation” sections for the OAuth flow; keep the H1 # nowyourlink auth.md |
/openapi.json | apex | Add the /v1/agent/* paths with securitySchemes.oauth2 (authorizationCode, PKCE, scopes) and the problem types |
/llms.txt, /developers.md, /agents.md, /pricing.md | apex | Link the advertiser MCP server and the connect flow; pricing states that agent access is free and bids cost what human bids cost |
4. Scopes and mandate
Section titled “4. Scopes and mandate”Scopes (scopesSupported): account:read, account:write, reports:read, bids:read, bids:write, creatives:read, creatives:write, invoices:read, affiliate:read, affiliate:write, billing:read, billing:write, security:read, security:write, privacy:write, connections:read, connections:write. A client may request any subset; the consent page selects the requested scopes and the advertiser can remove scopes.
Mandate (required whenever bids:write is granted, stored in the grant props and in the grant metadata):
| Field | Type | Rule |
|---|---|---|
max_bid_cents | integer | 100 ≤ x ≤ 100 000 000; upper bound on amount_cents of any single bid or increase target |
daily_cap_cents | integer | ≥ max_bid_cents; sum of accepted amount_cents deltas per UTC day per grant |
valid_until | ISO date-time | ≤ 90 days from consent; refresh is refused after it, access tokens are refused after it |
apps/api/src/agent-mandate.ts enforces the mandate. The REST adapters and the MCP tools call it before the existing handler. assertAgentMandate(props, deltaCents, amountCents, now) does the read-only checks; reserveAgentSpend and releaseAgentSpend hold and give back the money:
-
valid_untilin the past →agent.mandate-expired. -
amount_cents > max_bid_cents→agent.mandate-exceeded. -
Spend reservation, BEFORE the engine call:
reserveAgentSpend(db, request)inagent-mandate.ts. The delta isamount_centsfor a new bid,amount_cents − previous_amountfor an increase. The function makes two writes, in this order:INSERT OR IGNORE INTO agent_spend_keysclaims(grant_id, auction day, idempotency key).changes: 0means the key came again. The function reserves nothing and reportsreplay.- The
agent_grant_spendupsert carriesdaily_cap_centsin its ownWHERE, so SQLite decides whether the row may grow.changes: 0means the cap breaks. The function releases the key claim and reportscappedwith the used cents. The adapter answersagent.mandate-exceeded.
The tally day is the UTC calendar day the cap resets on. The key is scoped to the auction day the bid targets, because the AuctionDO caches idempotency per day.
-
Compensation, AFTER the engine answers. Three outcomes, not two:
- Accepted (
200, or202for a queued accept): the reservation stands. - Refused (a non-2xx the engine really returned):
releaseAgentSpendsubtracts the delta and deletes the key claim. The agent can retry with the same key. - Unknown (something threw before an answer): keep the reservation and log
agent.spend.reservation_kept. The bid may already be in the DO. A release would let a real bid drop out of the tally on the retry, because the DO replay peek then reserves nothing. An over-counted tally only makes the mandate stricter until the UTC day rolls over.
A
replayreservation reserved nothing, so it gives nothing back. - Accepted (
The tally counts bids the agent placed, not bids the human placed in the browser. It guards spending for the token; it is not an accounting record. Settlement stays in the saga.
Revocation. Dashboard page /app/agents lists the grants from the library’s listUserGrants (client name, scopes, mandate, created). There is no “last used” column: the library records no such timestamp. “Revoke” calls api POST /oauth/grants/{grantId}/revoke (internal, service binding), which first stores a durable D1 revocation marker, calls the library’s revokeGrant, and then deletes tally and key rows. Provider failure retains spending state; retries use the durable grant mapping. Atomic spend reservations, private tool calls and event delivery deny marked grants. The next bearer request with a revoked grant fails with 401 invalid_token.
5. Eligibility
Section titled “5. Eligibility”Consent permits an active advertiser to connect an agent before physical onboarding is complete. It grants only the explicitly selected scopes and mandate. The API checks eligibility on every bids:write call, from the users row and kyc_escalations:
| Condition | Result |
|---|---|
banned_at IS NOT NULL | agent.human-step-required, reason banned |
vat_validation_status is pending or rejected | agent.human-step-required, reason vat, action_url /app/billing. vies_degraded_pending does not block here; it blocks in the escalation row below, above the D30 threshold |
default_payment_method_id IS NULL | agent.human-step-required, reason payment_method, action_url /app/billing. The advertiser completes the existing no-bid card setup at /app/billing (SetupIntent, optional 3DS), then the agent reads readiness again. |
open kyc_escalations row for the user | agent.human-step-required, reason kyc |
would trigger first-bid escalation #1 (bid > 500 € and account < 14 days) or D30 (vies_degraded_pending and bid > 100 €) | agent.human-step-required, reason escalation. The agent path never creates an escalation; the human does that bid in the browser. |
GET /v1/agent/me and get_account accept optional day, ad_id and amount_cents. Their first_bid read model reports satisfied, blocked and unknown prerequisites, existing continuation actions and concrete browser steps. sandbox_place_bid reads that same model without writing; the dashboard reads it via session-authenticated GET /first-bid/readiness. can_bid is true only for a ready concrete proposal. Successful identity verification suppresses the amount-triggered escalation; open or failed verification remains blocking. An unknown write outcome requires reading the original day and retaining the original request and idempotency key.
Every engine rule (minimum, supersede-once, anti-snipe, close) still applies. Those errors pass through unchanged.
6. Data
Section titled “6. Data”New D1 migration packages/users/migrations/0047_agent_grants.sql (0047 is free on develop as of 2026-09-20; re-check before you commit, per the decision-log collision rule):
There is no agent_grants_meta table. The library already stores one row per grant in OAUTH_KV, so a D1 mirror would be a second source of truth. Grant listing uses listUserGrants; consent stores { clientName, mandate, grantId } in the grant metadata.
CREATE TABLE agent_grant_spend ( grant_id TEXT NOT NULL, day TEXT NOT NULL CHECK (length(day) = 10), cents INTEGER NOT NULL DEFAULT 0 CHECK (cents >= 0), PRIMARY KEY (grant_id, day));
-- The reservation dedupe key (§4 step 3). `day` is the AUCTION day, because-- the AuctionDO caches idempotency per day. Rows older than 23 h are swept at-- insert time; the index serves that sweep.CREATE TABLE agent_spend_keys ( grant_id TEXT NOT NULL, day TEXT NOT NULL CHECK (length(day) = 10), idem_key TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (grant_id, day, idem_key));CREATE INDEX agent_spend_keys_created_at_idx ON agent_spend_keys (created_at);grant_id is our own props.grantId, not the library’s grant id, and it has no foreign key: the grant lives in KV. Update scripts/seed and the schema-test drop lists (memory rules). Erase sweep (data rights, spec 12): the sweep deletes the tally and key rows with the user and revokes the grants in OAUTH_KV through the library helper.
New KV: OAUTH_KV (api, both envs). New table: oauth_handoff_jti (jti text PRIMARY KEY, exp integer) with an index on exp (migration 0048) — the hand-off replay guard, swept by the burn itself. New secret: OAUTH_HANDOFF_SECRET (api, web and queue, both envs). No change to users, bids, AuctionDO.
7. Security notes
Section titled “7. Security notes”user_idnever comes from a request body; it comes fromctx.propsset at consent.- The AS requires PKCE S256 and refuses the implicit flow, plain PKCE, and public-client DCR with non-https redirects. It fetches CIMD documents with
global_fetch_strictly_public, so an attacker cannot point it at internal hosts. - The AS signs hand-off blobs with HMAC, expires them after 10 minutes, and verifies them before parsing. It burns the
jtiin D1 under a PRIMARY KEY, so a leaked blob cannot buy a second decision — not even from two requests that arrive together. - Tokens are opaque; the library stores them hashed in KV and nothing logs them.
onErrorlogs codes only. - Mandate checks run before the DO call; a failed check writes nothing.
- Creative fetch by URL in
upload_creativeuses the same fetch policy as CIMD (public hosts only, 10 MB cap, MIME sniffing). - Rate limit per grant on top of the per-IP public bucket.
- The apex CSP and the MCP-Apps card do not change; the advertiser server has no
ui://resources in v1.
8. Testing
Section titled “8. Testing”apps/api, 100 % coverage, vitest with the Workers pool:
- Provider: DCR and CIMD registration, PKCE S256 happy path, plain PKCE refused, refresh, refresh after
valid_untilrefused, revoke →401 invalid_token, metadata documents byte-compared against fixtures. - Hand-off: signature tamper, expiry, replay after approve.
- Mandate: table-driven cases for each rule, UTC day boundary, idempotent replay does not double count, increase delta math.
- Eligibility matrix: one case per row of §5 at consent and at bid time.
- REST adapters: each route with a valid token, missing scope, missing
Idempotency-Key, engine error pass-through (bid.ad-not-approved,bid-superseded). - MCP:
tools/listshape, every tool throughtools/call, scope-missingisError, sandbox twins write nothing (assert on the DO stub and D1). apps/web: consent page render (scopes, mandate form, ineligible reasons),.tsendpoint CSRF path,/app/agentslist and revoke.- Contract:
openapi.jsonvalidates; the kit’s SDK tests run against the OpenAPI paths. - Acceptance: Ora force rescan and isitagentready scan after production deploy; the checks named in §1 pass.
9. Delivery
Section titled “9. Delivery”Four PRs, each merge-ready on its own, develop base, cherry-pick to main if develop and main diverge again:
- Authorization server. Library,
OAUTH_KV(Terraform, both envs),OAUTH_HANDOFF_SECRET,/oauth/*, consent page,/app/agents, the-agentmetadata document and PRM on api, apex PRM update,auth.mdrewrite. No protected routes yet except aGET /v1/agent/mesmoke route. - Mandate and REST.
agent_grant_spend,assertAgentMandate, eligibility, all/v1/agent/*routes, invoices reader, error types, OpenAPI. - MCP advertiser server. Tools, sandbox twins, server card, discovery links.
- Kit. SDK and CLI
login(PKCE in a local loopback flow) and authorized methods, Python parity, skilladvertise-on-nowyourlink, README and llms.txt.
Reviewers: two on payment and auction paths (PR 2), per spec 13.
10. Decision-log addition (applied to master design)
Section titled “10. Decision-log addition (applied to master design)”| # | Decision |
|---|---|
| D59 | Delegated agent access (spec 17). Advertisers can delegate bidding and creative management to AI agents through an OAuth 2.1 authorization server on api.nowyourlink.com (@cloudflare/workers-oauth-provider, PKCE S256, CIMD plus DCR, opaque tokens in OAUTH_KV). Every token belongs to one advertiser and carries a per-grant mandate (max_bid_cents, daily_cap_cents, valid_until) enforced before the auction engine. Registration, VAT, KYC and payment-method setup stay browser-only; an agent may place the first bid once the physical prerequisites and explicitly granted spending mandate permit the concrete proposal. The authorized surface is /v1/agent/* and an authenticated MCP server at api.nowyourlink.com/mcp; the anonymous public API and /mcp on the apex stay as they are. Supersedes the “no authorization server” line of docs/agent-readiness/ora-85-followup.md. |
October 6 website parity extension
Section titled “October 6 website parity extension”The account gateway now exposes 58 tools: two anonymous documentation tools,
three anonymous public Spotlight reads and 53 private customer tools. Shared
application handlers retain ownership, eligibility and payment guards. New tools
cover calendar/detail, all-day bid history, draft creation/cloning/deletion,
private asset downloads, saved cards, billing-address portal, passkey removal,
marketing consent request/withdrawal, erasure and agent connection management.
place_bid accepts an owned saved payment_method_id; validation occurs before
spend reservation. Bid increases retain the existing payment method.
Device registration, card entry, first-bid verification, push enrollment and
OAuth consent use prepare_account_action and the existing browser flows.
Data export preserves the website’s 501 response. No tool grants administrator
rights or raises its own spending mandate. Migration
0061_agent_grant_revocations.sql must precede runtime deployment.
See docs/agent-readiness/chatgpt-website-parity-2026-10-06.md for the customer
workflow matrix, checks and browser boundaries.