Private, access-gated hosting for landing pages and HTML/Claude artifacts,
running entirely on Cloudflare Workers. A public product site at / (plus /docs and
/login) explains the product and collects access requests; invited people sign in (Cloudflare
Access, email one-time-PIN) to reach their gallery at /gallery. Artifacts — single HTML files
or multi-file static bundles — are published from a web dashboard, a CLI, or an agent session
(Claude Code, Hermes). With per-artifact permissions and versioning.
- 🌐 Public product site —
/,/docs,/loginand/waitlistare reachable by anyone, with SEO metadata,sitemap.xml,robots.txtandllms.txt; everything else needs an identity. - 🔒 Access-gated dashboard — Cloudflare Access handles login for
/gallery,/admin, and the API; no passwords stored by the app. - 👥 Per-artifact permissions — each artifact is private, shared with specific people, or open to all signed-in users.
- 🕓 Versioning — every re-publish is a new immutable version; roll back anytime.
- 📈 Views log — see who viewed each artifact, when, which version, and from where.
- 🖼️ Gallery + dashboard — a filtered index for viewers, an admin UI to publish and manage.
- 🧑💻 CLI — publish and manage from your terminal.
- 🔑 API tokens — hashed bearer tokens for server-to-server publishing (Hermes Cloud, CI), scoped and revocable.
- ☁️ All Cloudflare — Worker + R2 (files) + D1 (metadata). No servers, no database to run.
Stack: TypeScript · Hono · Cloudflare Workers / R2 / D1 · Cloudflare Access
/ and /waitlist (always public)
│
Browser / CLI ─────────────────────────┼──────────────────────────▶ Worker (Hono)
┌──────────── Cloudflare Access ────────────┐ │
│ login gate (email OTP) + allow-list │────▶ │
└───────────────────────────────────────────┘ │
/gallery · files · /admin · /api │
┌───────────────────────┬────────────────────┐ │
▼ ▼ ▼ │
R2 (files at D1 (metadata: Cloudflare │
<slug>/v<N>/…) artifacts, grants, Access API ◀┘
versions, waitlist) (manage users)
/and/waitlistare never behind Cloudflare Access — the public landing page and waitlist signup must be reachable by anyone.- Cloudflare Access authenticates every other request (
/gallery,/admin,/api, artifact files) and holds the login allow-list. - The Worker authorizes per-artifact (who sees what), serves the current version of each artifact, renders the landing page/gallery/dashboard, and exposes a JSON API.
- R2 stores files under
<slug>/v<N>/…; D1 stores metadata.
See docs/ARCHITECTURE.md for details.
- Node.js 18+
- A Cloudflare account with:
- a domain (zone) on the account (you'll serve from a subdomain, e.g.
artifacts.example.com) - Zero Trust enabled (pick a team name once in the dashboard — the free plan is fine)
- a domain (zone) on the account (you'll serve from a subdomain, e.g.
npx wrangler login(authorizes Workers/R2/D1/deploy)
git clone https://github.com/yogevgab/artifacts-server.git
cd artifacts-server
npm install
npx wrangler login
# Optional but recommended: a Cloudflare API token with
# "Access: Apps and Policies — Edit" so setup can wire Access for you.
npm run setupnpm run setup will prompt for your domain, admin email, Zero Trust team domain, and (optionally)
the API token, then:
- create the R2 bucket + D1 database and apply the schema,
- create the two Cloudflare Access applications, their policies, and a CLI service token,
- fill in
wrangler.jsonc, deploy, and print your CLI credentials.
npm run setup does not yet create the public-bypass Access app described in step 3 of
Manual deployment below — add it once, by hand, so / and /waitlist
stay reachable without logging in.
Prefer to skip Access automation? Set SKIP_ACCESS=1 — it deploys everything else, and you
finish Access from the dashboard (see Manual deployment).
When it finishes, open https://<your-domain>/admin, add users under Users, and publish.
Step-by-step without the setup script
Deploying with a separate content-only hostname (multiple
routesentries, e.g. app + content-host isolation)?npm run setuponly supports a single hostname — seedocs/DEPLOY_RTFX.mdfor a worked example runbook.
npm install
npx wrangler login
# 1. Storage
npx wrangler r2 bucket create artifacts-files
npx wrangler d1 create artifacts-meta # copy the database_id into wrangler.jsonc
npx wrangler d1 execute artifacts-meta --remote --file schema.sql
# 2. Edit wrangler.jsonc: set routes[0].pattern to your domain, ADMIN_EMAILS,
# ACCESS_TEAM_DOMAIN, CF_ACCOUNT_ID, and the database_id.
# 3. Deploy (creates the custom domain)
npx wrangler deployCloudflare Access (Zero Trust dashboard):
- Create a self-hosted app
Artifacts (viewers)onyour-domainwith two policies:— humans: action Allow, include the admin email (add more viewers later, or via the app).— cli: action Service Auth, include a new service tokenartifacts-cli.
- Create a self-hosted app
Artifacts (admin)on pathsyour-domain/adminandyour-domain/api, with the same two policies. - So the public landing page and waitlist are reachable without logging in, add a third
self-hosted app
Artifacts (public)on pathsyour-domain/(exact root) andyour-domain/waitlist, with a single policy action Bypass (no rule criteria needed). Access evaluates the most specific matching app, so this exempts just those two paths —/gallery, artifact links,/admin, and/apistay behind the viewer/admin apps above. - Put the admin app's AUD and the viewer app's AUD into
ACCESS_AUDas"<viewerAud>,<adminAud>". Put the viewer app id and its— humanspolicy id intoACCESS_VIEWER_APP_ID/ACCESS_VIEWER_POLICY_ID, and the service token's client id intoADMIN_SERVICE_TOKENS. Redeploy. - For in-app user management, store an API token:
npx wrangler secret put CF_API_TOKEN.
All config lives in wrangler.jsonc (vars) plus one secret. See the comments there.
| Setting | What it is |
|---|---|
routes[0].pattern |
Hostname to serve from (a zone on your account). |
ADMIN_EMAILS |
Comma-separated emails with admin rights. |
SUPER_ADMIN_EMAILS (optional) |
The operator/owner account(s). A super admin may manage other admins, and can never be paused or removed — the anti-lockout invariant. Defaults to the first ADMIN_EMAILS entry, so every deployment has one. |
ADMIN_SERVICE_TOKENS |
Access service-token client ids (…access) with admin rights (for the CLI). |
ACCESS_TEAM_DOMAIN |
Your Zero Trust team domain, …cloudflareaccess.com. |
ACCESS_AUD |
Comma-separated viewerAud,adminAud; the Worker verifies JWTs against either. |
CF_ACCOUNT_ID, ACCESS_VIEWER_APP_ID, ACCESS_VIEWER_POLICY_ID |
Used to manage the login allow-list via the Cloudflare API. |
CF_API_TOKEN (secret) |
Token with "Access: Apps and Policies — Edit". Only for in-app user management. |
PUBLIC_BASE_URL (optional) |
Canonical public origin, e.g. https://rtfx.pro. Canonical links, OpenGraph URLs, sitemap.xml and llms.txt are absolute against it, and any other hostname serves a disallow-everything robots.txt. Defaults to https://rtfx.pro (SITE.origin in src/seo.ts). |
CONTENT_HOSTNAMES (optional) |
Hostnames that serve artifact files only — no dashboard, API or product pages. |
https://<your-domain>/ — public, no login required. Positions the product, covers use cases and
differentiators, and collects /waitlist access requests. Its two CTAs are deliberately distinct:
Request access (for people without an account) and Sign in (/login, for people with one).
/docs is the public documentation page (publishing, Claude Code/Hermes, the access model, FAQ).
See docs/PUBLIC_SITE.md for the SEO/crawler surface and the copy rules.
https://<your-domain>/login — public, and must stay outside the Cloudflare Access
application. It authenticates nobody: it explains that access is by invitation and that invited
users get a one-time code by email, then hands off to /admin, which Access gates —
and that hand-off is what triggers the login. It renders three states: signed out, already
signed in, and paused (a valid login whose account an admin disabled). There is no password
auth anywhere in this product by design.
https://<your-domain>/admin — publish artifacts, manage per-artifact access, upload new
versions / roll back, and (admins only) manage people. Admins see every artifact, labelled
with its owner; a member sees only the ones they published. The gallery at /gallery is
filtered to what each viewer may see; visiting it signed out redirects to /login.
Admin-only, in the dashboard and at /api/users. Cloudflare Access remains the authentication
provider; the local users table (migrations/0007_users.sql) is product metadata and state
layered above the Access allow-list:
| Layer | Holds | Source of truth for |
|---|---|---|
| Cloudflare Access | The login allow-list | Who can authenticate at all |
users table |
status, display name, notes, invited_at / last_seen_at / disabled_at |
Whether this app serves them |
ADMIN_EMAILS / SUPER_ADMIN_EMAILS |
Privilege | Who is an admin or the operator |
The role column only records configuration — writing it can never escalate anyone, and the
Worker always re-derives privilege from env. Lifecycle actions: invite (adds to the Access
allow-list and creates the row), pause (disable — refused on every surface immediately, and
their API tokens are revoked), re-enable, and remove (drops the login, every artifact
grant and every API token — but never their published artifacts). Safeguards: the super admin
can't be paused or removed by anyone including themselves, only a super admin may act on another
admin, nobody may disable their own account, and API tokens are refused from these routes
entirely.
If Cloudflare Access gates
/adminand/apito admins only (the default in docs/DEPLOY_RTFX.md step 5), invited members are stopped at the edge before the Worker's ownership rules apply. Step 5b there narrows the admin Access app to/api/usersso invited users can reach their own dashboard.
export ARTIFACTS_URL=https://<your-domain>
# Authenticate with an API token, a Cloudflare Access service token, or both
# (Access gets you through the edge gate; the API token identifies you to the app).
export RTFX_API_TOKEN=<rtfx_… token>
export CF_ACCESS_CLIENT_ID=<service-token-client-id>
export CF_ACCESS_CLIENT_SECRET=<service-token-client-secret>
node cli/artifacts.mjs publish ./page.html --slug my-page --title "My Page"
node cli/artifacts.mjs publish ./site/ --slug demo --title "Demo" # a folder is zipped
node cli/artifacts.mjs list
node cli/artifacts.mjs publish ./page-v2.html --slug my-page --note "new hero" # new version
node cli/artifacts.mjs versions my-page
node cli/artifacts.mjs rollback my-page 1
node cli/artifacts.mjs grant my-page alice@example.com
node cli/artifacts.mjs views my-page # total/unique + recent views log
node cli/artifacts.mjs users # directory with role + status
node cli/artifacts.mjs user-add bob@example.com # invite
node cli/artifacts.mjs user-disable bob@example.com # pause + revoke their tokens
node cli/artifacts.mjs user-enable bob@example.com
node cli/artifacts.mjs token-create "hermes-cloud" --owner alice@example.com --scopes read,publish
node cli/artifacts.mjs tokens
node cli/artifacts.mjs token-revoke <token-id>token-* and user-* require an Access login (or the Access service token) — an API token
can't mint or revoke tokens, or change who may sign in.
Automated publishers — Hermes Cloud, CI, scripts — authenticate with a hashed API token sent
as Authorization: Bearer <token>, instead of a browser login:
curl -X POST "$ARTIFACTS_URL/api/artifacts" \
-H "Authorization: Bearer $RTFX_API_TOKEN" \
-F "slug=my-page" -F "title=My Page" -F "file=@./page.html;type=text/html"- Only a SHA-256 hash of each token is stored; the plaintext is shown once, at creation.
- Every token has an owner and so inherits that person's ownership rules — an admin token manages everything, a user's token only their own artifacts.
- Scopes (
read,publish,manage) narrow a token below its owner's rights; they never widen anyone's. Default isread,publish, so a publishing integration can't delete. - Tokens are revocable (
token-revoke), can carry an expiry, and are revoked automatically when their owner is removed from rtfx.pro. - Bearer auth is an additional app-layer check — it does not bypass Cloudflare Access, which still gates the edge.
Full request/response contract, error codes and rollback flow:
docs/HERMES_CLOUD.md.
Cloudflare Access decides who can log in (managed in the app's People panel, which writes
the Access allow-list). The Worker decides who sees each artifact: restricted (only granted
emails + admins) or everyone (any signed-in user). New artifacts are private by default. A
direct URL a viewer lacks access to returns 404.
Ownership (invite-only access). Every artifact belongs to the person who published it
(artifacts.owner_email). Admins (ADMIN_EMAILS, plus ADMIN_SERVICE_TOKENS) manage
everything; a signed-in member manages only their own — their dashboard, /api/artifacts
list, version previews and analytics are scoped to artifacts they own, and any attempt to
read or change someone else's returns 404, so a slug they don't own is indistinguishable
from one that doesn't exist. Being granted view access to an artifact never confers
management rights, and publishing to a slug someone else owns is refused (409 slug_taken)
rather than adding a version to their artifact. Managing the sign-in allow-list (/api/users)
is admin-only, and a non-admin owner's grants deliberately do not add anyone to that
allow-list — only an admin invites new people.
Artifacts with no owner (published before this model, or by a service token, which has no
email) are manageable by admins only. Run migrations/0005_owner_email.sql on an existing
database; it backfills owners from created_by where that was a real email. API tokens live
in migrations/0006_api_tokens.sql, and the local user directory in
migrations/0007_users.sql (additive and backfilled from artifact owners — an Access-allowed
person with no row is still a valid user, so applying it can't lock anyone out).
Each publish to an existing slug creates a new immutable version and makes it live; previous
versions are kept. Admins preview any version at /v/<slug>/<n>/; roll back from the dashboard
or rollback <slug> <n>.
Each artifact records a view when a signed-in person loads an HTML page (assets, machine/
service-token fetches, and admin version previews aren't counted). The admin dashboard and
views <slug> show total/unique counts and a recent log (time · viewer · version · country).
Views are retained indefinitely; prune the artifact_views table if it grows large.
npm install
npm run dev # wrangler dev on http://localhost:8787 (no Access gate; you are admin)
npm test # vitest (unit + integration via @cloudflare/vitest-pool-workers)
npm run typecheck
npm run check # typecheck + testsLocally there's no Access gate. To simulate a specific viewer, send an X-Dev-Email header; to
simulate a signed-out visitor (e.g. to see /gallery redirect to the landing page), send
X-Dev-Anonymous: true. Both are honored only when DEV_LOGIN=true, which npm run dev sets —
never in production.
# once, seed the local DB:
npx wrangler d1 execute artifacts-meta --local --file schema.sqlIssues and PRs welcome — see CONTRIBUTING.md. Please run npm run check
before opening a PR. Security reports: see SECURITY.md.