# x402 resell worker (sell kit)

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/walletstarter/x402-sell-kit)

A small Cloudflare Worker **you** deploy on **your** account. It sells JSON bundles you assembled
over x402 (`GET /bundle/<topic>`, or `GET /bundle?topic=...`), paid straight to **your** wallet.

It is receive-only: it holds no wallet and signs nothing. Your agent buys the inputs where it
already does (for example an Open Commerce Pathways session, each payment signed in its own
wallet), turns them into something worth reading, and uploads the result. Every buyer read after
that is income at no extra input cost. makememoneynomistakes.ai never sees your secrets, never
holds your money and takes no cut. Nothing here promises a sale: whether anyone pays is up to the
market.

Checked with x402 packages 2.28.0 (`@x402/hono`, `@x402/core`, `@x402/evm`, `@x402/extensions`)
and `@coinbase/x402` 2.1.0: it typechecks and bundles with `wrangler deploy --dry-run`. Run it on
Base Sepolia before real money.

## One click

The button above copies this repository into your own GitHub account, creates the KV namespace
for your bundles, asks for your receiving wallet (`PAY_TO`) and an upload token (`UPLOAD_TOKEN`),
and deploys the Worker on your Cloudflare account on Base Sepolia. From then on every push to your
copy redeploys it. Then upload a bundle (step 3) and, when you are ready for real money, set
`NETWORK` to Base and add your own CDP key (step 4). Nothing of ours is in the loop: the Worker,
the wallet and the secrets are yours. By hand instead: steps 2 to 4.

## What you need

- A Cloudflare account (Workers Free is enough to start) and Node 20+.
- **Receiving wallet** (`PAY_TO`): its public address only. This is your income. The deploy page
  asks for it; by hand it is `npx wrangler secret put PAY_TO` (a public address kept like a secret
  only so the one-click flow can ask for it).
- **Upload token** (`UPLOAD_TOKEN`): 32+ random characters, a Worker secret only your agent knows.
- For Base mainnet only: your own CDP API key (`CDP_API_KEY_ID`, `CDP_API_KEY_SECRET`) from the
  Coinbase Developer Platform, for the CDP facilitator. Base Sepolia uses the public
  `https://x402.org/facilitator` with no key.

## 1. Pick what to bundle

Ask the Pathways API which trusted routes your budget can afford and where buyers already spend:

```
curl "https://makememoneynomistakes.ai/api/v1/pathways/routes?budget=4"
curl "https://makememoneynomistakes.ai/api/v1/pathways?budget=4&route=<route url>"
```

Good bundles combine several sources into one answer a buyer would otherwise assemble call by
call: filtered, ranked, summarized, refreshed on a schedule. Your margin is the reads you sell
minus what the inputs cost you, so a bundle read many times beats one read once.

## 2. Deploy on Base Sepolia first

```
npm install
npx wrangler login
npx wrangler kv namespace create BUNDLES
npx wrangler secret put PAY_TO
npx wrangler secret put UPLOAD_TOKEN
npx wrangler deploy
```

Paste the KV id into `wrangler.toml` first. For local runs, copy `.dev.vars.example` to
`.dev.vars`, fill in `PAY_TO` and `UPLOAD_TOKEN` (never commit it) and run `npm run dev`.

## 3. Upload a bundle

```
curl -X PUT "https://<your-worker>.workers.dev/upload/base-usdc-daily" -H "Authorization: Bearer <UPLOAD_TOKEN>" -H "Content-Type: application/json" --data-binary "@bundle.json"
```

Topics are 1 to 64 of `a-z`, `0-9` and `-`; a bundle is JSON up to 100 KB. Upload again to refresh
it. `GET /` lists topics and when each was updated, free.

Test: `curl -i "https://<your-worker>.workers.dev/bundle/base-usdc-daily"` answers `402`
with a `PAYMENT-REQUIRED` header. Pay it with any x402 v2 client using test USDC. Unknown topics
answer 404 before any payment, and a payment settles only when the bundle is served.

Browser buyers (optional): a web page paying from the visitor's own wallet, such as
`https://app.makememoneynomistakes.ai`, needs CORS. Set `CORS_ORIGINS` to the page origins you
allow, comma-separated; `/bundle` then lets them send `PAYMENT-SIGNATURE` and read
`PAYMENT-REQUIRED` / `PAYMENT-RESPONSE`, on every answer including a failed settlement (`src/cors.ts`
puts the headers back after the paywall, which otherwise drops them on that path). The same origins
may read `GET /` and upload with `PUT /upload/<topic>`: that is how the hosted earn page at
`https://app.makememoneynomistakes.ai` uploads a run's bundle to your kit with the token you type
there (it travels from your browser to this Worker, never through them) and pays your listing once.
Unset, there is no CORS and agents work as before.

## 4. Go live on Base

1. `NETWORK = "eip155:8453"` in `wrangler.toml`.
2. `npx wrangler secret put CDP_API_KEY_ID` and `npx wrangler secret put CDP_API_KEY_SECRET` (your key).
3. `npx wrangler deploy`.

## 5. List it in the x402 Bazaar

The route declares Bazaar discovery metadata (`declareDiscoveryExtension`). The CDP Bazaar indexes
a route after its first payment settled through the CDP facilitator, usually within 15 minutes.
Keep `DESCRIPTION` one plain line that describes the endpoint; the Bazaar screens out text that
instructs agents.

Two things must hold, or the payment settles and nothing is listed:

- **Pay each bundle at its own path, `/bundle/<topic>`.** Coinbase lists a route by its bare path
  with the query dropped and checks that bare URL answers 402 unpaid, so `/bundle?topic=x` can
  never be its own listing. Every bundle is sold at `/bundle/<topic>` too (same cost, its own
  description), and one settled payment to that path lists that bundle. Optional: set
  `DEFAULT_TOPIC` in `wrangler.toml` and a bare `/bundle` sells that topic and can be listed as well.
- **The first payment must echo the 402's `extensions`** (the bazaar block): this Worker forwards
  the buyer's payment to the facilitator as is. Standard x402 v2 clients do this.

Check: `curl "https://api.cdp.coinbase.com/platform/v2/x402/discovery/merchant?payTo=<PAY_TO>"`.
A log line `has an invalid bazaar extension: ... Code generation from strings disallowed` is a
warning only: Workers can't run the package's schema check, and the block still goes out. Once listed, our directory picks it up on its next refresh. A grade there needs a
verified ERC-8004 identity link (see https://makememoneynomistakes.ai/skill.md).

## 6. Count your income

The ledger follows wallets that paid for a Pathways run or session (that payment enrolls them).
Pay one small session from your receiving wallet, then read:

```
curl "https://makememoneynomistakes.ai/api/v1/pathways/ledger?wallet=<PAY_TO>"
```

The ledger scans Base USDC transfers to and from the wallet onchain (about every 10 minutes).
**Income is inbound x402 (EIP-3009) payments only**: plain transfers in, such as deposits from your
own wallets, show as `deposits_usdc`, never income. "Profit" appears only when the ledger's net is
positive and its coverage is current. Paying your own `PAY_TO` from your own wallets is money in a
circle, not income: don't.

## Files

- `src/index.ts`: the Worker (free topic list, token-guarded upload, the x402 paywall, the bundle).
- `src/cors.ts`: the CORS headers for browser buyers, kept on every `/bundle` answer.
- `wrangler.toml`: public config only. Secrets: `PAY_TO`, `UPLOAD_TOKEN`, and on mainnet
  `CDP_API_KEY_ID` and `CDP_API_KEY_SECRET`.
- `.dev.vars.example`: the values the deploy page asks for; copy it to `.dev.vars` for local runs.

## The files

Copy these into a new folder `x402-resell-worker/`, keeping the paths.

### `wrangler.toml`

```toml
# Your Worker, on YOUR Cloudflare account. Nothing here is a secret; secrets go in
# `npx wrangler secret put NAME` (or .dev.vars locally, which you must not commit).
name = "x402-resell-worker"
main = "src/index.ts"
compatibility_date = "2026-10-01"
compatibility_flags = ["nodejs_compat"]

# Your bundles. The Deploy to Cloudflare button creates this namespace for you and fills in the id;
# by hand: `npx wrangler kv namespace create BUNDLES` and paste its id here.
[[kv_namespaces]]
binding = "BUNDLES"
id = "replace-with-your-kv-namespace-id"

# Your receiving wallet, PAY_TO, is not in [vars]: the Deploy to Cloudflare page asks for it (from
# .dev.vars.example), or set it by hand with `npx wrangler secret put PAY_TO` (locally: .dev.vars).
# It is a public address, kept like a secret only so the one-click flow can ask for it; a var of the
# same name here would override what you entered.
[vars]
# What one bundle read costs. Keep it above what the bundle cost you, divided by the reads you expect.
PRICE_USDC = "0.02"
# Test on Base Sepolia first; switch to "eip155:8453" for real money.
NETWORK = "eip155:84532"
DESCRIPTION = "Curated JSON bundles from several x402 sources, one call each."
# Optional but needed for the x402 Bazaar: the topic a bare GET /bundle serves. Coinbase lists
# /bundle without its query, and only if that answers 402 unpaid (unset = 404, never listed).
# DEFAULT_TOPIC = "base-usdc-daily"
# Optional: web origins whose pages may pay from a browser wallet, comma-separated (unset = no CORS).
# CORS_ORIGINS = "https://app.makememoneynomistakes.ai"
```

### `.dev.vars.example`

```ini
# The two values the Deploy to Cloudflare page asks for. By hand: `npx wrangler secret put NAME`.
# For local runs copy this file to .dev.vars and fill it in; never commit .dev.vars.

# Your receiving wallet's PUBLIC address (0x...). Buyers pay here; this is your income.
PAY_TO=

# 32+ random characters only your agent knows; it authorizes PUT /upload/<topic>.
UPLOAD_TOKEN=

# Base mainnet only (NETWORK = "eip155:8453" in wrangler.toml): your own CDP API key for the CDP
# facilitator. Not needed on Base Sepolia. Add them later with `npx wrangler secret put`.
# CDP_API_KEY_ID=
# CDP_API_KEY_SECRET=
```

### `package.json`

```json
{
  "name": "x402-resell-worker",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "wrangler dev",
    "check": "tsc --noEmit",
    "deploy": "wrangler deploy"
  },
  "dependencies": {
    "@coinbase/x402": "2.1.0",
    "@x402/core": "2.28.0",
    "@x402/evm": "2.28.0",
    "@x402/extensions": "2.28.0",
    "@x402/hono": "2.28.0",
    "hono": "^4.13.12"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^5.20261019.1",
    "typescript": "^5.6.0",
    "wrangler": "^4.147.0"
  }
}
```

### `tsconfig.json`

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "types": ["@cloudflare/workers-types"],
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src"]
}
```

### `src/index.ts`

```ts
// x402 resell worker: sells bundles you assembled, over x402, paid to YOUR wallet (PAY_TO).
// Receive-only: it holds no wallet and signs nothing. Your agent buys the inputs elsewhere (for
// example in an Open Commerce Pathways session, signing in its own wallet), turns them into a
// bundle and uploads it here; buyers pay you per read. Nothing here belongs to
// makememoneynomistakes.ai: you deploy it, you keep the income.
import { Hono, type Context, type Next } from 'hono';
import { cors } from 'hono/cors';
import { paymentMiddleware, x402ResourceServer } from '@x402/hono';
import { HTTPFacilitatorClient } from '@x402/core/server';
import { ExactEvmScheme } from '@x402/evm/exact/server';
import { bazaarResourceServerExtension, declareDiscoveryExtension } from '@x402/extensions/bazaar';
import { createFacilitatorConfig } from '@coinbase/x402';
import { ALLOW_HEADERS, ALLOW_METHODS, allowedOrigins, allowOrigin, EXPOSE_HEADERS, keepCors, UPLOAD_ALLOW_HEADERS, UPLOAD_ALLOW_METHODS } from './cors';

interface Env {
  BUNDLES: KVNamespace; // your bundles, one key per topic
  PAY_TO: string; // your receiving wallet (public address)
  PRICE_USDC: string; // what one bundle read costs, e.g. "0.02"
  NETWORK: string; // "eip155:84532" (Base Sepolia) to test, "eip155:8453" (Base) for real
  DESCRIPTION: string; // one plain line for the Bazaar listing
  FACILITATOR_URL?: string; // testnet default: https://x402.org/facilitator
  CDP_API_KEY_ID?: string; // secret, Base mainnet only: your own CDP key
  CDP_API_KEY_SECRET?: string; // secret, Base mainnet only
  UPLOAD_TOKEN: string; // secret: a long random string only your agent knows, for uploads
  CORS_ORIGINS?: string; // optional: comma-separated web origins whose pages may buy (browser wallets)
  DEFAULT_TOPIC?: string; // optional: the topic a bare GET /bundle serves (each bundle is also sold at /bundle/<topic>; README, step 5)
}

const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
const TOPIC = /^[a-z0-9-]{1,64}$/;
const MAX_BUNDLE_BYTES = 100_000;

/** Decimal USDC string to 6-decimal atomic units, or null when malformed. */
function atomic(usdc: string): bigint | null {
  const m = /^(\d{1,6})(?:\.(\d{1,6}))?$/.exec(usdc.trim());
  if (!m) return null;
  const units = BigInt(m[1]!) * 1_000_000n + BigInt((m[2] ?? '').padEnd(6, '0'));
  return units > 0n ? units : null;
}

/** Checks the config; throws with a readable reason so a bad deploy fails loud. */
function readConfig(env: Env): bigint {
  if (!ADDRESS.test(env.PAY_TO) || /^0x0{40}$/.test(env.PAY_TO)) throw new Error('PAY_TO must be your 0x wallet address');
  if (env.NETWORK !== 'eip155:8453' && env.NETWORK !== 'eip155:84532') {
    throw new Error('NETWORK must be eip155:8453 (Base) or eip155:84532 (Base Sepolia)');
  }
  const charge = atomic(env.PRICE_USDC);
  if (!charge) throw new Error('PRICE_USDC must be a positive decimal like "0.02"');
  if (!env.UPLOAD_TOKEN || env.UPLOAD_TOKEN.length < 32) throw new Error('Set UPLOAD_TOKEN (32+ random characters) as a Worker secret');
  return charge;
}

function facilitator(env: Env): HTTPFacilitatorClient {
  if (env.NETWORK === 'eip155:8453') {
    if (!env.CDP_API_KEY_ID || !env.CDP_API_KEY_SECRET) {
      throw new Error('Base mainnet needs your CDP_API_KEY_ID and CDP_API_KEY_SECRET as Worker secrets');
    }
    return new HTTPFacilitatorClient(createFacilitatorConfig(env.CDP_API_KEY_ID, env.CDP_API_KEY_SECRET));
  }
  return new HTTPFacilitatorClient({ url: env.FACILITATOR_URL || 'https://x402.org/facilitator' });
}

/** Constant-time comparison for the upload token. */
function sameToken(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

type App = { Bindings: Env };
const app = new Hono<App>();

/**
 * The topic a /bundle request reads: ?topic=, else DEFAULT_TOPIC. The x402 Bazaar lists /bundle by
 * its bare path (query dropped) and only if that path answers 402 unpaid, so without a default
 * topic it answers 404 and the Worker can't be listed.
 */
const topicOf = (c: { req: { query(name: string): string | undefined }; env: Env }) => c.req.query('topic') ?? c.env.DEFAULT_TOPIC ?? '';

// One paywall per paid route, built on its first request from the Worker's env (env is only known
// per request) and kept for the isolate's life: `GET /bundle` (the ?topic= form, or DEFAULT_TOPIC)
// and one `GET /bundle/<topic>` per bundle. The Bazaar lists a route by its bare path, query dropped,
// so `/bundle?topic=x` is never its own listing; `/bundle/x` lists each bundle on its own, with its
// own description, and that is what README step 5 pays for.
const paywalls = new Map<string, ReturnType<typeof paymentMiddleware>>();
let server: x402ResourceServer | null = null;

function paywallFor(env: Env, route: 'GET /bundle' | `GET /bundle/${string}`, topic: string): ReturnType<typeof paymentMiddleware> {
  const have = paywalls.get(route);
  if (have) return have;
  const charge = readConfig(env);
  server ??= new x402ResourceServer(facilitator(env))
    .register(env.NETWORK as `${string}:${string}`, new ExactEvmScheme())
    .registerExtension(bazaarResourceServerExtension);
  const byQuery = route === 'GET /bundle';
  const made = paymentMiddleware(
    {
      [route]: {
        accepts: {
          scheme: 'exact',
          price: `$${Number(charge) / 1e6}`,
          network: env.NETWORK as `${string}:${string}`,
          payTo: env.PAY_TO,
        },
        description: byQuery ? env.DESCRIPTION : `${env.DESCRIPTION} This route serves the ${topic} bundle.`,
        mimeType: 'application/json',
        extensions: {
          ...declareDiscoveryExtension({
            // The ?topic= form names the topic as a query parameter (optional: without it, /bundle
            // serves DEFAULT_TOPIC); a per-bundle path takes no input.
            input: byQuery ? { topic } : {},
            inputSchema: byQuery ? { properties: { topic: { type: 'string', pattern: '^[a-z0-9-]{1,64}$' } } } : { properties: {} },
            output: { example: { topic, updated_at: '2026-10-19T21:00:00.000Z', bundle: {} } },
          }),
        },
      },
    },
    server,
  );
  paywalls.set(route, made);
  return made;
}

// Optional: lets the web pages you list buy from a browser wallet. A page must read PAYMENT-REQUIRED
// and PAYMENT-RESPONSE and send PAYMENT-SIGNATURE. Agents and servers don't need it; unset = no CORS.
app.use('/bundle/*', async (c, next) => {
  const allowed = allowedOrigins(c.env.CORS_ORIGINS);
  if (!allowed.length) return next();
  return cors({
    origin: (origin) => allowOrigin(origin, allowed),
    allowMethods: ALLOW_METHODS.split(','),
    allowHeaders: ALLOW_HEADERS.split(','),
    exposeHeaders: EXPOSE_HEADERS.split(','),
    maxAge: 600,
  })(c, next);
});
app.use('/bundle', async (c, next) => {
  const allowed = allowedOrigins(c.env.CORS_ORIGINS);
  if (!allowed.length) return next();
  return cors({
    origin: (origin) => allowOrigin(origin, allowed),
    allowMethods: ALLOW_METHODS.split(','),
    allowHeaders: ALLOW_HEADERS.split(','),
    exposeHeaders: EXPOSE_HEADERS.split(','),
    maxAge: 600,
  })(c, next);
});

// The same origins may also read what you sell (GET /) and upload bundles from the browser (the hosted
// earn page at app.makememoneynomistakes.ai: the visitor types their own UPLOAD_TOKEN there, and it
// travels only from their browser to this Worker). Unset = no CORS, as before.
app.use('/', async (c, next) => {
  const allowed = allowedOrigins(c.env.CORS_ORIGINS);
  if (!allowed.length) return next();
  return cors({ origin: (origin) => allowOrigin(origin, allowed), allowMethods: ALLOW_METHODS.split(','), maxAge: 600 })(c, next);
});
app.use('/upload/*', async (c, next) => {
  const allowed = allowedOrigins(c.env.CORS_ORIGINS);
  if (!allowed.length) return next();
  return cors({ origin: (origin) => allowOrigin(origin, allowed), allowMethods: UPLOAD_ALLOW_METHODS.split(','), allowHeaders: UPLOAD_ALLOW_HEADERS.split(','), maxAge: 600 })(c, next);
});

// Free: what is on sale, so buyers (and the Bazaar) can see topics and freshness before paying.
app.get('/', async (c) => {
  const list = await c.env.BUNDLES.list<{ updated_at: string }>();
  return c.json({
    service: 'x402 resell worker',
    endpoint: '/bundle/<topic>',
    endpoints: ['/bundle/<topic>', '/bundle?topic=<topic>'],
    default_topic: c.env.DEFAULT_TOPIC ?? null,
    price_usdc: c.env.PRICE_USDC,
    network: c.env.NETWORK,
    pay_to: c.env.PAY_TO,
    topics: list.keys.map((k) => ({ topic: k.name, url: `/bundle/${k.name}`, updated_at: k.metadata?.updated_at ?? null })),
  });
});

// Yours only: your agent uploads a bundle it assembled. Bearer UPLOAD_TOKEN.
app.put('/upload/:topic', async (c) => {
  readConfig(c.env);
  const auth = c.req.header('Authorization') ?? '';
  if (!sameToken(auth, `Bearer ${c.env.UPLOAD_TOKEN}`)) return c.json({ error: 'unauthorized' }, 401);
  const topic = c.req.param('topic');
  if (!TOPIC.test(topic)) return c.json({ error: 'topic: 1-64 of a-z, 0-9 and -' }, 400);
  const text = await c.req.text();
  if (new TextEncoder().encode(text).length > MAX_BUNDLE_BYTES) return c.json({ error: `at most ${MAX_BUNDLE_BYTES} bytes` }, 413);
  try {
    JSON.parse(text);
  } catch {
    return c.json({ error: 'the bundle must be JSON' }, 400);
  }
  const updated_at = new Date().toISOString();
  await c.env.BUNDLES.put(topic, text, { metadata: { updated_at } });
  return c.json({ topic, updated_at, bytes: text.length });
});

/** The paywall for one request: 404 before payment for a missing or unknown topic, so nobody pays for nothing. */
async function paid(c: Context<App>, next: Next, topic: string, route: 'GET /bundle' | `GET /bundle/${string}`): Promise<Response | void> {
  if (!TOPIC.test(topic) || (await c.env.BUNDLES.get(topic)) === null) {
    return c.json({ error: 'unknown topic; GET / lists them' }, 404);
  }
  const paywall = paywallFor(c.env, route, topic);
  const res = await paywall(c, next);
  if (res && !c.finalized) c.res = res; // what Hono does with a returned Response
  // A failed settlement is answered with a fresh Response that carries none of the headers set
  // above (src/cors.ts): put the allowed origin back, or a browser buyer sees "Failed to fetch".
  keepCors(c.res.headers, c.req.header('Origin'), allowedOrigins(c.env.CORS_ORIGINS));
}

/** Runs only after the buyer's payment verified; the payment settles only on this 200. */
async function serve(c: Context<App>, topic: string): Promise<Response> {
  const hit = await c.env.BUNDLES.getWithMetadata<{ updated_at: string }>(topic);
  if (hit.value === null) return c.json({ error: 'unknown topic; you were not charged' }, 404);
  return c.json({ topic, updated_at: hit.metadata?.updated_at ?? null, bundle: JSON.parse(hit.value) });
}

// One path per bundle: what the Bazaar lists (README, step 5).
app.use('/bundle/:topic', (c, next) => paid(c, next, c.req.param('topic'), `GET /bundle/${c.req.param('topic')}`));
app.get('/bundle/:topic', (c) => serve(c, c.req.param('topic')));

// The ?topic= form (no topic means DEFAULT_TOPIC, when you set one).
app.use('/bundle', (c, next) => paid(c, next, topicOf(c), 'GET /bundle'));
app.get('/bundle', (c) => serve(c, topicOf(c)));

export default app;
```

### `src/cors.ts`

```ts
// CORS for browser buyers (CORS_ORIGINS), kept on every /bundle answer.
//
// Why this exists: `paymentMiddleware` (@x402/hono) answers a failed settlement with a fresh
// Response built from x402's own headers (`c.res = undefined`, then a new Response). Hono carries
// no header over to it, so the Access-Control-* headers that `cors()` set before the paywall are
// gone, and a browser buyer sees "Failed to fetch" instead of the 402 and its reason. A refused
// signature keeps them (that answer is built through Hono); only the settle-failure path loses
// them. So after the paywall, `keepCors` puts the allowed origin and the exposed headers back.
// Pure (no Hono, no x402), so it can be tested alone.

export const ALLOW_METHODS = 'GET,OPTIONS';
export const ALLOW_HEADERS = 'PAYMENT-SIGNATURE,Content-Type';
export const EXPOSE_HEADERS = 'PAYMENT-REQUIRED,PAYMENT-RESPONSE';
/** The upload, for the hosted earn page: the visitor's own token travels from their browser to this Worker. */
export const UPLOAD_ALLOW_METHODS = 'PUT,OPTIONS';
export const UPLOAD_ALLOW_HEADERS = 'Authorization,Content-Type';

/** CORS_ORIGINS as a list of origins; empty = no CORS. */
export function allowedOrigins(value: string | undefined): string[] {
  return (value ?? '').split(',').map((o) => o.trim()).filter(Boolean);
}

/** The origin to allow for this request, or null (not allowed, or no Origin header). */
export function allowOrigin(origin: string | undefined | null, allowed: readonly string[]): string | null {
  return origin && allowed.includes(origin) ? origin : null;
}

/**
 * Sets the CORS headers for `origin` on `headers` when it is allowed (what `cors()` set before the
 * paywall), and returns whether it did. Idempotent, so it is safe on an answer that already has
 * them. `Vary: Origin` needs no help: `cors()` appends it after the paywall on every path.
 */
export function keepCors(headers: Headers, origin: string | undefined | null, allowed: readonly string[]): boolean {
  const allow = allowOrigin(origin, allowed);
  if (!allow) return false;
  headers.set('Access-Control-Allow-Origin', allow);
  headers.set('Access-Control-Expose-Headers', EXPOSE_HEADERS);
  return true;
}
```
