Autonnel v0.1.0
Back to blog

Shipping a marketplace on Cloudflare: entitlements, signed URLs, device flow

How the Autonnel marketplace authorizes a CLI with no browser redirect, turns a purchase into a durable entitlement, and never leaks a storage key.

· 8 min read

The marketplace sells plugins and template packs as one-time purchases. That one sentence hides three separate problems, and they only look easy until you write them down:

  1. A CLI running on a developer’s laptop has to prove who it belongs to, with no browser redirect available to it.
  2. A payment has to become something durable that survives the payment session and can be checked years later.
  3. A paid .zip sitting in object storage has to reach exactly one person, without the storage key ever becoming public.

All three run on Cloudflare Workers, all three are in the open-source repo, and the third one is where I want to start, because it is the rule I care most about and the one most likely to be broken by accident.

The allowlist rule

The marketplace catalog is public. Anyone can GET /api/marketplace/items.json without authenticating. That endpoint reads directly from the MarketplaceItem table, which also holds the columns that make the paid artifacts work: s3Key, sha256, checksum, bytes, public, sortOrder.

The rule, written into the repo’s agent guide so it stops being folklore: the public DTO is an explicit field allowlist, and a raw database row is never spread into a response.

In practice that is a boring function in src/lib/marketplace/read-model.ts:

// Allowlist mapper: server-only columns (s3Key, sha256, checksum, bytes, public,
// sortOrder) must NEVER reach a public DTO. Never spread a raw DB row.
function toPublicItem(row: { /* ... */ }): MarketplaceItemPublic {
  return {
    slug: row.slug,
    kind: row.kind,
    title: row.title,
    tagline: row.tagline,
    // ... twelve fields, each one written out
  };
}

Twelve lines that a code reviewer would call redundant, because the Prisma select above it already lists the same fields. It is redundant. That is the point.

Here is what goes wrong without it. Someone adds a column. Say internalNotes, or costBasis, or a stripeProductId. If the response is built with ...row, that column ships to the public JSON on the next deploy, and nothing fails. No type error, no test failure, no 500. The API just quietly grows a field. You find out when someone tells you, or when you read your own JSON six months later.

With an explicit mapper, a new column is invisible until someone writes a line of code adding it. The cost of exposure goes from zero to one deliberate act. That is the entire security property, and it is worth twelve boring lines.

The version of this I like best is sortOrder. It appears in the query:

orderBy: [{ featured: 'desc' }, { sortOrder: 'asc' }, { title: 'asc' }],

but never in the select and never in the DTO. It shapes the response without being in it. The catalog’s merchandising decisions stay a server-side concern, which is where they belong, and the ordering still works.

A second-order benefit I did not plan for: because the DTO type is the API contract, changing the public shape now requires editing an interface that three call sites depend on. The SSR pages at /marketplace and /marketplace/[slug] read the same read model server-side rather than fetching their own API, so the HTML and the JSON cannot drift apart.

Authorizing a CLI with no browser

Now the first problem. The autonnel CLI needs a token. It has no redirect URI, no way to receive a callback, and no business asking anyone to paste a password into a terminal.

The answer is the OAuth 2.0 device authorization grant, RFC 8628. The CLI asks for a code, prints it, and polls while the human authorizes it in a browser somewhere else. Our constants live in src/lib/cli/codes.ts:

const DEVICE_TTL_MS = 10 * 60 * 1000;
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
const POLL_INTERVAL_SECONDS = 5;

const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ0123456789';
const USER_CODE_LEN = 8;

Ten minutes for the device request, ninety days for the resulting token, a five-second poll interval. The user code is eight characters rendered as ABCD-1234, drawn from a thirty-character alphabet with the vowels and the visually ambiguous characters removed. No A, E, I, O, U, so the generator cannot spell anything unfortunate, and no 1/I or 0/O confusion for someone reading a code off a terminal and typing it into a phone.

POST /api/cli/device-code writes a DeviceAuth row and returns device_code, user_code, a verification_uri pointing at /activate, verification_uri_complete with the code prefilled, expires_in, and interval. The device code and the eventual access token are both 32 random bytes from crypto.getRandomValues, base64url encoded.

The CLI then polls POST /api/cli/device-token, which is a small state machine returning RFC 8628 error codes:

  • invalid_request for a missing or unparseable body
  • invalid_grant for an unknown device code
  • expired_token past the ten minutes
  • slow_down if the poll arrived faster than the negotiated interval
  • access_denied if the human clicked Deny
  • authorization_pending while nothing has happened yet

slow_down is the one people skip when they implement this by hand, and it is the one that matters at scale: every impatient CLI in the world tightening its loop is a self-inflicted denial of service.

The approval side is a session-cookied POST /api/cli/device-approve from the /activate page, which shows the requesting scopes (marketplace:read, marketplace:download) and the signed-in email before you confirm. If you are not signed in, the page redirects to /login?next=/activate?code=... so you land back where you started.

Two details in the approve path that I would defend in review. The device request must be in pending or the endpoint returns 409 already_resolved, so a stale browser tab cannot re-decide a request that was already answered. And on successful minting, device-token flips the row to consumed in the same handler that creates the token:

await db.cliToken.create({
  data: { tokenHash, userId: row.userId, scope: deviceFlow.tokenScope, expiresAt },
});
await db.deviceAuth.update({ where: { id: row.id }, data: { status: 'consumed' } });

One approval, one token. A later poll finds the row in consumed, which is not approved, so it falls through to authorization_pending instead of minting a duplicate.

The token itself is never stored. cli_tokens.tokenHash holds a SHA-256 of the value, and resolveCliUser in src/lib/cli/bearer.ts hashes the incoming bearer and looks up by hash, treating an expired row as a miss. A database dump does not yield usable credentials.

A purchase becomes a row

The entitlement is deliberately unexciting. An Order row with @@unique([userId, itemId]), meaning one order per user per item, and a status that defaults to paid. There is no expiry column, because there is no expiry. This is what “one-time purchase” means in schema form, and it is documented on buying a plugin or template.

Everything downstream asks the same question through resolveCliDownload, which matches an order by npm package name or marketplace slug, then resolves the requested version through the [itemId, version] composite unique on ItemVersion. Three outcomes, three different HTTP results: no order is 403, an order for a version that has no built artifact is 404, and a hit returns the artifact.

Keeping “do they own it” and “does this version exist” as separate answers matters more than it looks. Collapsing both into a 404 would be marginally more private and would make the CLI’s error message useless. Collapsing both into a 403 would tell someone their purchase had evaporated when the truth is a missing build.

Delivery without exposing the key

Finally the artifact. The rule is that a browser never sees the storage key path, and object storage is never public.

Both download paths presign with aws4fetch, which is one of the few S3 signers that runs on workerd. The TTL is five minutes:

const DOWNLOAD_URL_TTL = 300;

GET /api/download/[orderId] authorizes by session cookie or by the order’s orderToken (compared with a constant-time equality helper, because a token compared with === leaks its prefix through timing), presigns, and returns a 302 to the signed URL. Every failure mode returns the same bare 404: missing order, wrong token, S3 unconfigured, presign failure. A distinguishable error would turn the endpoint into an oracle for whether an order id exists.

What I would do differently

Two things bother me.

The ETag on the catalog endpoints is computed from the row count and the maximum updatedAt across published items, hashed with FNV-1a. That is a correct fingerprint. But neither endpoint reads If-None-Match, so a conditional request still gets a full body and a 200. The header is there for the CDN and for future me, and it is currently doing about half a job. Adding the 304 branch is maybe six lines and it has not happened.

The other is sortOrder doing merchandising work with no admin UI behind it. The catalog is seeded from a TypeScript file. That is fine for eight items and will not be fine at eighty.

The device flow, the entitlement check, and the presign are each about a hundred lines. The value is not in the code, it is in the fact that all three refuse to answer questions they were not asked. Details are in the CLI install docs.