(Request) => Response handler. Locally, Bun.serve runs it.
In the cloud, a Cloudflare Worker runs the same handler inside a Durable Object, with DO
SQLite for storage, hibernating WebSockets for realtime, and R2 for attachments.
Deploy it when pins need to outlive one laptop: a shared staging URL, teammates or
testers who file pins, or a browser that cannot reach your machine.
The
pinbox CLI always talks to the local hub on 127.0.0.1. A cloud hub serves the
toolbar in the browser. The two are the same code and the same routes, but the CLI has no
flag to point at a remote hub.Get the template
The deployable template lives in the repo atexamples/worker/. It is a byte-identical
copy of packages/cli/templates/worker/, and CI fails if the two ever drift.
@autono/pinbox-core in package.json to the version you want (the template
ships ^0.0.0) and install:
What is in the template
src/index.ts re-exports the Durable Object class from the published package:
Deploy
1
Create the R2 bucket
wrangler.jsonc under r2_buckets[0].bucket_name. The shipped
placeholder is pinbox-media-placeholder-provision-me, which fails at deploy on purpose.2
Set the hub secret
token strategy. Skip it if you are using jwt — see
Auth strategies below.3
Deploy
4
Check it
GET /_pinbox/health is the one route that needs no credential. Every other route
requires one..dev.vars.example to .dev.vars (it is gitignored) and run
wrangler dev.
Bindings and vars
Set inwrangler.jsonc:
Two more lines in
wrangler.jsonc matter:
compatibility_dateis pinned to a fixed date, not “today”. Bump it deliberately.- The
rulesentry makes**/*.iife.jsa text module, so the toolbar bundle can be served verbatim.
Secrets
Set withwrangler secret put <NAME>. Never put these in wrangler.jsonc vars.
Auth strategies
The Durable Object builds its verifier from the environment at construction. A misconfigured strategy fails closed: every route returns 500 with the reason, andGET /_pinbox/health returns 503 with the same reason so a monitor can tell a broken hub
from a dead one. There is no configuration in which writes are accepted unverified by
accident.
token — the default
token — the default
Set
AUTH_STRATEGY=token and the PINBOX_TOKEN secret. Clients send
Authorization: Bearer <token>.The compare is constant-time: both sides are SHA-256 hashed and the digests compared
byte-wise, so neither length nor content leaks through timing.A successful request gets the identity { userId: "token" }. This is a shared secret —
it tells you a caller had the token, not who they are.If AUTH_STRATEGY=token and no PINBOX_TOKEN is set, the hub refuses every request.jwt — verified against a JWKS
jwt — verified against a JWKS
Set
AUTH_STRATEGY=jwt and all three of:Any identity provider that publishes a JWKS works. Clients send the token as
Authorization: Bearer <jwt>.Rules the verifier enforces:- Signature algorithms are limited to
EdDSAandRS256. Nothing else is accepted. issandaudmust match exactly.- Standard expiry and not-before checks apply.
submust be a string. Without it, the token is rejected.
Key fetching, caching, and refresh are handled by
jose. Verification failures of every
kind — bad signature, wrong issuer, wrong audience, expired, disallowed algorithm,
malformed — produce the same 401 E_AUTH.none — loopback and dev only
none — loopback and dev only
AUTH_STRATEGY=none accepts every request as { userId: "anonymous" }.It is refused unless you also set ALLOW_UNAUTHENTICATED=1. Two explicit settings, not
one. If you set the strategy and forget the opt-in, the hub refuses everything rather
than falling open.Do not deploy this on a reachable URL. Anyone who can load the page can create, reply to,
and resolve pins.custom — translate the credential in the Worker
custom — translate the credential in the Worker
The Durable Object picks its verifier from Then wrap the request where Keep
AUTH_STRATEGY. To accept a credential that
does not arrive as an Authorization header, translate it in src/index.ts before
forwarding to the DO, and let the configured strategy verify the result.A browser session cookie holding a JWT, for example:src/index.ts forwards to the stub:AUTH_STRATEGY=jwt and point JWT_ISSUER / JWT_JWKS_URL / JWT_AUDIENCE at
whoever signed the cookie. The translation decides what counts as the credential; the
verifier still decides whether it is valid.If you want to embed the hub in something other than this template, build the handler
yourself. @autono/pinbox-core/auth exports verifyToken, verifyJwt, verifyNone, and
verifyCustom, and @autono/pinbox-core/hub takes any verify function of type
(req: Request) => Promise<Identity | null>. Returning null produces a 401 E_AUTH.
A verify function must never throw.Serving the toolbar
SetORIGIN_URL to your staging origin. Every request that is not under /_pinbox is
then proxied to that origin, and HTML responses get the toolbar <script> injected by
HTMLRewriter as they stream. No build change on the app side.
The injected tag looks like this:
- The snippet never carries a token. The page is untrusted; authentication is the hub’s job.
- A page that already mounts a pinbox script is left alone — no double mount.
- Only
text/htmlresponses are rewritten. Everything else passes through untouched. - The proxy requests
accept-encoding: identityupstream so it never rewrites compressed bytes.
ORIGIN_URL empty if you only want the hub. Requests outside /_pinbox then get a
404 with a hint instead of being proxied.
Attachments
Reads stream from theMEDIA binding. Uploads are presigned R2 PUT URLs, which the
binding cannot mint — that needs R2’s S3 API credentials. Create an R2 API token in the
Cloudflare dashboard and set the four R2_* secrets.
Without them, POST /_pinbox/attachments returns 500 with a hint saying exactly that.
Attachments are capped at 5 MB. The cap is enforced twice: the request body is counted as
it streams and the read aborted the moment it goes over, and the measured length is signed
into the presigned PUT so R2 rejects an upload of any other size.
Projects
PINBOX_PROJECT names the Durable Object, and one DO exists per name. Two Workers with
different PINBOX_PROJECT values keep entirely separate pin sets. Changing the value
points at a different — initially empty — hub.
Routes
Everything mounts under/_pinbox, same-origin with the injected page, so there is no
CORS to configure and cookies ride along.