Skip to content

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).


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, issuer https://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 server https://api.nowyourlink.com/mcp.
  • Discovery documents: RFC 8414 metadata, RFC 9728 PRM on apex and api, auth.md agent_auth block, 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/mcp with no manual configuration, completes consent, and places a bid that lands in AuctionDO under the advertiser’s user_id.
  • The API refuses a bid above max_bid_cents or beyond daily_cap_cents with 403 agent.mandate-exceeded and changes no state.
  • Ora oauth-support, oauth-protected-resource, mcp-auth-mechanism, scoped-permissions, agent-auth-* and isitagentready discovery.oauthDiscovery, oauthProtectedResource, authMd pass without fabricated documents.

Read these files before you implement. The table names real files, not summaries.

ContractWhereWhat the agent path reuses
Bid placementapps/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 increasesame file, POST /api/auctions/:day/bids/:bidId/increasehold_ref injected from users.default_payment_method_id
Idempotencyapps/api/src/auction-do.ts (idem:<user_id>:<key>, 24 h, POST /idem-replay)Agent Idempotency-Key header becomes idempotency_key
Engine rulespackages/auction-engine/src/state-machine.ts (MIN_BID_CENTS = 100, supersede-once, anti-snipe)Unchanged
First-bid gateapps/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 uploadapps/api/src/ads-routes.ts (postAdsUpload, 10 MB, MIME allowlist, SHA-256 dedupe)Called as-is with the agent’s user_id
Creative lifecycleapps/api/src/ads-library-routes.ts (POST /ads, PATCH, POST /ads/:id/submit, GET /ads, GET /ads/:id)Called as-is
Accountpackages/users/migrations/0001_initial.sql (users.vat_validation_status, default_payment_method_id, kyc_level, banned_at), kyc_escalationsEligibility matrix (§5)
Sessionpackages/auth/src/session.ts (nyl_sess, readSessionCookie, touchSession)Consent page only
Errors@nowyourlink/errors toProblemJson, app.onError in apps/api/src/app.tsAll agent errors are problem+json
Rate limitpackages/auth/src/rate-limit.ts (KV RATE)Bucket keyed by grant id
Public agent surfaceapps/web/src/lib/agent-public.ts, agent-docs.tsApex PRM, auth.md, server cards, llms.txt updates
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.

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:

OptionValueWhy
authorizeEndpoint/oauth/authorizeA path, not an absolute URL
tokenEndpoint/oauth/tokenThe library derives the issuer from the request origin
clientRegistrationEndpoint/oauth/registerDCR (RFC 7591) as compatibility path
clientIdMetadataDocumentEnabledtrueMCP 2026 default; requires global_fetch_strictly_public in wrangler.jsonc per the library README
allowPlainPKCEfalseS256 only
allowImplicitFlowfalse
accessTokenTTL3600
refreshTokenTTL259200030 days, bounded further by the mandate valid_until (§4)
scopesSupportedthe 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
clientRegistrationCallbackreject registrations whose redirect_uris are not https or loopback
onErrorlog 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).

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):

  1. GET /oauth/authorize on api: env.OAUTH_PROVIDER.parseAuthRequest(request), lookupClient. On AuthorizationError with a redirect URI, redirect with error and error_description; without one, render a plain 400.
  2. Build handoff = { authRequest, clientId, clientName, requestedScopes, exp: now + 10 min }, HMAC-SHA256 with a new Worker secret OAUTH_HANDOFF_SECRET (both api and web hold it; add to the secret checklist files per memory rule “new worker secret file checklist”). Redirect 302 to https://nowyourlink.com/app/authorize?h=<base64url(json)>.<hex(hmac)>.
  3. /app/authorize (Astro page, session required). An unauthenticated visitor goes through the normal magic-link login and comes back: /login sets a 15-minute nyl_next cookie with the consent URL, and /app honours 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.
  4. Submit posts to POST /app/authorize/decide; the revoke form on /app/agents posts to POST /app/agents/revoke. Both are Astro .ts endpoints under /app/, which Astro’s CSRF check already covers, so neither needs a worker.ts fast-path. Each forwards to api (POST /oauth/approve, POST /oauth/grants/{grantId}/revoke) over the API service binding with the internal header X-NYL-Internal (existing shared-secret pattern) and the session user_id.
  5. /oauth/approve verifies the hand-off again and burns its jti: one INSERT into oauth_handoff_jti (D1, PRIMARY KEY, migration 0048). One blob buys one decision, approve or deny. The INSERT is 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 calls completeAuthorization({ request, userId, scope, metadata: { clientName, mandate, grantId }, props: { userId, grantId, scopes, mandate } }) and returns { redirectTo }. Web responds 302 redirectTo. Consent validates the explicit mandate and active account; physical first-bid prerequisites are read during preparation and enforced on every bid.
  6. Deny: web redirects to the client’s redirect_uri with error=access_denied and state.

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.

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 pathScopeBehaviour
GET /v1/agent/meaccount:readAccount 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-DDbids:readThe advertiser’s bids for the day from user_bids (D1 projection), plus auction status.
POST /v1/agent/bidsbids:writeBody { 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}/increasebids:writeSame gates; existing increase handler.
GET /v1/agent/creativescreatives:readExisting GET /ads list (cursor, state filter).
GET /v1/agent/creatives/{id}creatives:readExisting GET /ads/:id.
POST /v1/agent/creatives/uploadcreatives:writemultipart/form-data, existing postAdsUpload (10 MB, jpeg/png/webp/gif). Returns the draft asset.
POST /v1/agent/creativescreatives:writeExisting POST /ads (headline, description, target URL, asset id).
POST /v1/agent/creatives/{id}/submitcreatives:writeExisting submit (draft→pending, blacklist, q-moderation), 202.
GET /v1/agent/invoicesinvoices:readNew 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.

ToolScopeAnnotationsMaps to
get_accountaccount:readreadOnlyGET /v1/agent/me
list_my_bidsbids:readreadOnlyGET /v1/agent/bids
place_bidbids:writereadOnly false, destructive false, idempotent true, openWorld falsePOST /v1/agent/bids; idempotency_key argument required
increase_bidbids:writesame as place_bidincrease
list_creatives, get_creativecreatives:readreadOnlylist / get
upload_creativecreatives:writereadOnly 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_creativecreatives:writereadOnly false, destructive falseEdit headline, description or target URL of an owned draft, without re-uploading the image
submit_creativecreatives:writereadOnly falsesubmit
list_invoicesinvoices:readreadOnlyinvoices
sandbox_place_bidbids:writereadOnly true, destructive falseRead 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_creativecreatives:writereadOnly true, destructive falseValidate 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.

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.

DocumentHostChange
/.well-known/oauth-authorization-serverapiThe library serves it unchanged; issuer and endpoints derive from the request origin
/.well-known/oauth-authorization-server-agentapiThe api serves it: the same members plus the agent_auth block (§3.2)
/.well-known/oauth-protected-resourceapiThe library serves it (resourceMetadata); resource derives from the request
/.well-known/oauth-protected-resourceapexauthorization_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.mdapexRewrite the “Pick a method / Register / Claim / Exchange / Use / Errors / Revocation” sections for the OAuth flow; keep the H1 # nowyourlink auth.md
/openapi.jsonapexAdd the /v1/agent/* paths with securitySchemes.oauth2 (authorizationCode, PKCE, scopes) and the problem types
/llms.txt, /developers.md, /agents.md, /pricing.mdapexLink the advertiser MCP server and the connect flow; pricing states that agent access is free and bids cost what human bids cost

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):

FieldTypeRule
max_bid_centsinteger100 ≤ x ≤ 100 000 000; upper bound on amount_cents of any single bid or increase target
daily_cap_centsinteger≥ max_bid_cents; sum of accepted amount_cents deltas per UTC day per grant
valid_untilISO 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:

  1. valid_until in the past → agent.mandate-expired.

  2. amount_cents > max_bid_cents → agent.mandate-exceeded.

  3. Spend reservation, BEFORE the engine call: reserveAgentSpend(db, request) in agent-mandate.ts. The delta is amount_cents for a new bid, amount_cents − previous_amount for an increase. The function makes two writes, in this order:

    1. INSERT OR IGNORE INTO agent_spend_keys claims (grant_id, auction day, idempotency key). changes: 0 means the key came again. The function reserves nothing and reports replay.
    2. The agent_grant_spend upsert carries daily_cap_cents in its own WHERE, so SQLite decides whether the row may grow. changes: 0 means the cap breaks. The function releases the key claim and reports capped with the used cents. The adapter answers agent.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.

  4. Compensation, AFTER the engine answers. Three outcomes, not two:

    • Accepted (200, or 202 for a queued accept): the reservation stands.
    • Refused (a non-2xx the engine really returned): releaseAgentSpend subtracts 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 replay reservation reserved nothing, so it gives nothing back.

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.

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:

ConditionResult
banned_at IS NOT NULLagent.human-step-required, reason banned
vat_validation_status is pending or rejectedagent.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 NULLagent.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 useragent.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.

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.

  • user_id never comes from a request body; it comes from ctx.props set 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 jti in 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. onError logs codes only.
  • Mandate checks run before the DO call; a failed check writes nothing.
  • Creative fetch by URL in upload_creative uses 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.

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_until refused, 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/list shape, every tool through tools/call, scope-missing isError, sandbox twins write nothing (assert on the DO stub and D1).
  • apps/web: consent page render (scopes, mandate form, ineligible reasons), .ts endpoint CSRF path, /app/agents list and revoke.
  • Contract: openapi.json validates; 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.

Four PRs, each merge-ready on its own, develop base, cherry-pick to main if develop and main diverge again:

  1. Authorization server. Library, OAUTH_KV (Terraform, both envs), OAUTH_HANDOFF_SECRET, /oauth/*, consent page, /app/agents, the -agent metadata document and PRM on api, apex PRM update, auth.md rewrite. No protected routes yet except a GET /v1/agent/me smoke route.
  2. Mandate and REST. agent_grant_spend, assertAgentMandate, eligibility, all /v1/agent/* routes, invoices reader, error types, OpenAPI.
  3. MCP advertiser server. Tools, sandbox twins, server card, discovery links.
  4. Kit. SDK and CLI login (PKCE in a local loopback flow) and authorized methods, Python parity, skill advertise-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
D59Delegated 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.

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.