@dcl-regenesislabs/artifacts
v1.1.0
Published
Publish static artifacts to artifacts.dclregenesislabs.xyz — sign in with a work email, push a folder, get a link back
Readme
ia-generated-artifacts
Self-hosted artifact host — a Claude-Artifacts-like place to drop a folder of static files and get a link back. Runs as a Cloudflare Worker in front of an R2 bucket on the dclregenesislabs account.
https://artifacts.dclregenesislabs.xyz/{id} latest version
https://artifacts.dclregenesislabs.xyz/{id}@2 one version, pinned
https://artifacts.dclregenesislabs.xyz/p/{alias} public link, no loginid is a UUID v4 minted on the first push and kept for life. Every later push to
the same id is a new version; the link never changes. Every gated page gets a
bar at the top, injected by the Worker: the artifact's name, a version picker,
the public link switch, copy link. Checkboxes marked data-persist remember
their state on the host, for everyone.
Every /{id} URL is behind a Cloudflare Access login that only accepts
@dclregenesislabs.xyz and @decentraland.org addresses. Making something
public is done from the bar, by a signed-in person — never by the upload token.
See Visibility.
Publish something
The CLI is on npm as @dcl-regenesislabs/artifacts,
so nothing needs installing:
npx @dcl-regenesislabs/artifacts login # once per machine, work email in a browser
npx @dcl-regenesislabs/artifacts push ./my-demo --name "my demo"From a checkout of this repo, the same commands are node cli/artifacts.mjs …:
node cli/artifacts.mjs login
node cli/artifacts.mjs push ./my-demo --name "my demo"
# → https://artifacts.dclregenesislabs.xyz/3f2b9c1e-4d5a-4b6c-8e7f-9a0b1c2d3e4f
node cli/artifacts.mjs push ./my-demo --id https://artifacts.dclregenesislabs.xyz/3f2b9c1e-… # new version, same linkindex.html is the entry point. A single file is served at the bare /{id}. A
folder is served under /{id}/ (the bare URL redirects there), so reference its
assets relatively (./assets/app.js) — absolute paths break.
node cli/artifacts.mjs push ./report.html # single-file artifact
node cli/artifacts.mjs push ./demos/*/ # several new artifacts at once
node cli/artifacts.mjs info <id or url> # name, versions, visibility
node cli/artifacts.mjs ls # everything on the host
node cli/artifacts.mjs rm <id or url> # every version, state and public link
node cli/artifacts.mjs --helpPushing bytes identical to the latest version records nothing. Blobs are stored
by digest, so a new version uploads only the files that changed. commit carries
the version number the client last saw and is refused if someone pushed in between.
House template
templates/artifact/ is the look every page on the host shares — IBM Plex,
indigo accent, three-state dark mode, and a set of blocks (callouts, cards,
tables, phases, checklist, diff columns). It was lifted from a Claude-designed
artifact so pages here match the ones Claude makes elsewhere.
node cli/artifacts.mjs new ./my-page --name "Q3 numbers" # scaffold
$EDITOR my-page/index.html # write
node cli/artifacts.mjs push ./my-page # publishnew writes one self-contained index.html: the CSS and JS in
templates/artifact/assets/ stay editable as files and are inlined at
scaffold time, so the result is the same single-file shape as a Claude artifact
and works anywhere. The scaffolded page is a live reference of every block. The
one knob per page is --accent in its small <style>. The skill tells Claude
to start here. (docs/guide/index.html is generated by scripts/build-guide.mjs
from the same assets — re-run it when dcl.css changes. The guide carries the
install line with the token filled in, so it stays team-only: never flip it
public.)
The bar
The Worker injects <dcl-bar> plus /__ui/bar.js at the top of <body> of
every HTML response on a gated URL (src/serve.ts, HTMLRewriter). The script
renders into a shadow root, so page CSS can't touch it and vice versa. It talks
to a small in-page API under the artifact:
| Call | Who | Does |
| --- | --- | --- |
| GET /{id}/__ui/meta | anyone through the gate | name, versions, public link, viewer, checkbox state |
| PUT /{id}/__ui/public | a signed-in person | mints the public alias; records who and when |
| DELETE /{id}/__ui/public | a signed-in person | deletes the alias — the link dies at once |
| PUT /{id}/__ui/state | a signed-in person | { "key": true } merges into the checkbox state |
| GET /p/{alias}/__ui/state | anyone with the link | the state, read-only |
"Signed-in person" means the Worker verified the Cloudflare Access JWT
(src/access.ts: signature against the team's public keys, audience, issuer,
expiry) and took the email from it. That is the answer to who can make a
document public: anyone who can open it. The public view (/p/…) gets no bar
and only a tiny script that paints the saved checkbox state.
Checkboxes. <input type="checkbox" data-persist="deploy-staging">. State is
keyed by artifact, not version, so ticks survive a re-push as long as the keys do.
The bar shows who ticked each box. Public viewers see them read-only.
Visibility
| | URL | Who can open it |
| --- | --- | --- |
| team (always) | /{id}, /{id}@n | @dclregenesislabs.xyz and @decentraland.org only, via a work-email login |
| public (switch in the bar) | /p/{alias} | anyone holding the link, no login; latest version; no bar |
The public link is additive — it mints a second URL and the gated one keeps
working. Everything under /p/ is exempt from the Access policy, so the alias
is the only credential: 128 bits of randomness, unrelated to the artifact id.
Switching it off deletes the alias immediately — aliases are resolved against R2 on every request rather than cached — and it is permanent: switching on again mints a different alias, so a link already sent out stays dead. Neither the CLI nor the upload API can do any of this; there is no endpoint for it.
Claude picks all of this up through the project skill in
.claude/skills/dcl-artifacts/.
Install the skill (one line)
npx @dcl-regenesislabs/artifacts setupSame line on macOS, Linux and Windows. It writes the skill — SKILL.md and its
style reference, both shipped inside the npm package — to
~/.claude/skills/dcl-artifacts/, with every command rewritten to
npx -y @dcl-regenesislabs/artifacts …, and then runs login unless you are
already signed in. Run it again to update; it only rewrites what changed.
login on its own offers to install the skill afterwards when run in a
terminal, and stays quiet otherwise. --skill-dir <dir> puts the skill
somewhere else — any agent that reads the SKILL.md format, e.g.
~/.codex/skills/dcl-artifacts, or .claude/skills/dcl-artifacts inside a
project to share it with a team.
Needs Node 20+, nothing else — no git, no repo access, no npm install. The
old curl … | bash and irm … | iex lines still work: they are stubs that
run the line above.
Inside this repository, the project-level skill at .claude/skills/dcl-artifacts/
is the source of truth and runs node cli/artifacts.mjs directly — npx cannot
resolve a package from inside its own checkout.
Signing in
dcl-artifacts login binds a browser login to the terminal that asked for it:
- The CLI holds a loopback port, then opens
/cli/loginwith the port, a randomstate, andsha256(verifier)— the verifier never leaves the process. - Cloudflare Access gates that page, so reaching it proves a work-email login. The page shows a code that must match the terminal, and minting only happens on a POST from that page.
- The Worker mints a one-time code (60s, single use, R2) and redirects to
http://127.0.0.1:<port>/cb— a target it builds itself from a validated integer, never from the query string. - The CLI checks
state, then exchanges the code plus the verifier at/_api/cli/exchangefor a token, and writes it to~/.config/dcl-artifacts/credentials.json(mode 600).
Tokens are dcla_v1_…, live 90 days, and are stored only as sha256 in KV.
Revoke one from /cli/tokens or with dcl-artifacts logout [--all]. Nothing is
ever pasted anywhere, and the token is never printed.
Layout
| Path | What it is |
| --- | --- |
| src/index.ts | router: /_api/* → upload API, everything else → site |
| src/api.ts | status / blobs / commit / info / list / delete, all bearer-authed |
| src/serve.ts | reads: version resolution, @n, redirects, index.html + SPA fallback, ETag/Range/cache, bar injection |
| src/ui.ts | the in-page API the bar calls |
| src/access.ts | Cloudflare Access JWT verification |
| src/bar-script.ts | the injected client code (bar, and the public-view state painter) |
| src/store.ts | meta / state / manifest records, compare-and-swap on the R2 ETag |
| src/auth.ts, src/mime.ts, src/types.ts, src/urls.ts | token comparison, content types, bindings and record shapes, URL helpers |
| shared/artifact-id.js | id / alias / hash / manifest rules — imported by both the Worker and the CLI |
| cli/artifacts.mjs | zero-dependency Node uploader |
| scripts/smoke.sh | end-to-end check against a local or deployed host |
| scripts/setup-access.mjs | reconciles the Cloudflare Access apps and policies |
| scripts/build-guide.mjs | regenerates docs/guide/index.html from the template assets |
| scripts/install.sh, scripts/install.ps1 | legacy installer stubs served at /install.sh and /install.ps1; they run npx … setup |
R2 layout, per artifact:
a/{id}/__meta.json name, versions[], current, public {alias, at, by} (mutable, CAS)
a/{id}/__state.json checkbox state {key: {checked, by, at}} (mutable, CAS)
a/{id}/m/{n}.json file list of version n (immutable)
a/{id}/b/{sha256} blob, one per distinct content (immutable)
p/{alias} → {id} (exists only while public)Nothing under a/ is ever served by path — the Worker resolves a URL through the
meta record and the version's manifest to a blob. Blobs and manifests are cached
at the edge under keys the router 404s.
How an upload works
The client hashes locally and uploads straight to blob keys, so the Worker never streams objects through itself:
POST /_api/artifacts/{id}/status— sends the file list, gets back{exists, current, missing: [sha256…]}.PUT /_api/artifacts/{id}/blobs/{sha256}— raw bytes. The Worker checks the digest and hands the same digest to R2, which checks it again.POST /_api/artifacts/{id}/commit—{files, base, name?, author?}. The Worker confirms every blob is present, writes the version manifest, then updates the meta record — refusing ifcurrent !== base. Only then is the version live.
All /_api/* calls require Authorization: Bearer <token> — either a per-user
token from login or, until it is retired, the shared UPLOAD_TOKEN. The two
exceptions are POST /_api/cli/exchange, which is how the CLI gets a token in
the first place (PKCE, not a bearer), and GET /_api/whoami, which reports what
the caller is signed in as.
commit records by from the token's own email; the client-supplied author
is honoured only for the shared token, which carries no identity.
Who can read what
Cloudflare Access sits in front of the hostname. It matches the most specific path first, which gives these rules:
| Path | Policy | Why |
| --- | --- | --- |
| /_api/* | bypass | already guarded by the bearer token |
| /_health | bypass | exposes nothing, keeps uptime monitors working |
| /p/* | bypass | opt-in public aliases carry their own secret |
| /install.sh, /install.ps1 | bypass | legacy installer stubs; no secrets in them |
| everything else | allow @dclregenesislabs.xyz, @decentraland.org | the gate — pages, /__ui/bar.js, the in-page API |
Login is Cloudflare's built-in one-time PIN: the visitor types their email,
gets a 6-digit code, and the policy checks the domain. No identity provider to
configure. Sessions last a week. A service-token policy also lets CI reach the
gated path — scripts/setup-access.mjs mints that token; the Worker attributes
its in-page writes to the token's name.
The Worker also verifies the Access JWT itself for in-page writes. ACCESS_AUD
in wrangler.jsonc is the AUD tag of the catch-all "Artifacts" application —
the kid in its login redirect. If that application is ever recreated, update it.
workers_dev and preview_urls are pinned to false in wrangler.jsonc. That
is load-bearing: a *.workers.dev URL would answer on a hostname Access does not
cover and would hand anyone a way straight past the gate.
Develop
npm install
printf 'UPLOAD_TOKEN=%s\nPUBLIC_BASE_URL=http://127.0.0.1:8787\nLOCAL_DEV=1\n' "$(openssl rand -hex 32)" > .dev.vars
npm run dev # wrangler dev, local R2
./scripts/smoke.sh http://127.0.0.1:8787 "$(sed -n 's/^UPLOAD_TOKEN=//p' .dev.vars)"
npm run typecheckLOCAL_DEV=1 stands in for Access locally: in-page writes are attributed to
dev@localhost. It lives only in .dev.vars, which is gitignored and never
deployed.
Deploy
Wrangler must be logged into the dclregenesislabs account (wrangler whoami
should show a2b29bacd555c6fc78becaad8b183e9c; wrangler login to switch).
npx wrangler r2 bucket create dcl-artifacts
npx wrangler r2 bucket create dcl-artifacts-preview # used by `wrangler dev --remote`
npx wrangler kv namespace create TOKENS # put the id in wrangler.jsonc
npx wrangler kv namespace create TOKENS --preview # and the preview_id
npx wrangler secret put UPLOAD_TOKEN # the shared token, until it is retired
npm run deploy # provisions the custom domain
# Cloudflare Access — needs an API token with Access: Apps and Policies = Edit
CLOUDFLARE_API_TOKEN=… node scripts/setup-access.mjs --dry-run
CLOUDFLARE_API_TOKEN=… node scripts/setup-access.mjs
CF_ACCESS_CLIENT_ID=… CF_ACCESS_CLIENT_SECRET=… \
./scripts/smoke.sh https://artifacts.dclregenesislabs.xyz "$DCL_ARTIFACTS_TOKEN"setup-access.mjs is idempotent — it rewrites the applications and their
policies to match the table above, so re-run it after any policy change.
Releasing the CLI to npm
The package is cli/, shared/, templates/ and the skill under
.claude/skills/dcl-artifacts/ — the files list in package.json; nothing
of the Worker ships. Releases go through
.github/workflows/publish.yml using npm trusted publishing: npm trusts
that workflow's GitHub OIDC identity, so there is no npm token in the repo, in
Actions secrets, or anywhere else, and every release carries provenance.
# bump "version" in package.json, commit, then:
git tag v1.2.3 && git push origin v1.2.3The trust is configured on npmjs.com under the package's Settings → Trusted
Publisher (organisation dcl-regenesislabs, repository
ia-generated-artifacts, workflow publish.yml). It keys on the workflow's
filename, so renaming the file breaks publishing until the setting is updated.
npm pack --dry-run shows exactly what a release would contain.
Retiring the shared token
UPLOAD_TOKEN still works alongside per-user tokens so nobody's publishing
breaks mid-migration. Every API request logs its credential kind, so:
- Watch for
"kind":"shared"innpm run tail. When two weeks pass with none — long enough to cover people who publish weekly — everybody has moved. wrangler secret put UPLOAD_TOKENwith a value nobody holds. That is a reversible kill switch; leave it a week.- Delete the shared branch in
src/auth.tsand theauthorfallback insrc/api.ts.
Rotate it in any case. The old value is in this repo's git history, so removing
it from HEAD did not remove it.
Caveats
- Public URLs are genuinely public. Anyone with a
/p/{alias}link can open it, with no login, until someone switches it off in the bar. Treat the switch as publishing — the bar asks for confirmation and records who did it. - All artifacts share one origin. A published page can read another artifact's
localStorage, and — since it runs as the signed-in viewer — call another artifact's in-page API. Only people who can sign in with a work email can publish pages, so this is the same trust as the gate itself; still, do not publish pages that run untrusted third-party script. - Access does not encrypt anything at rest — it gates the front door. R2 objects are still readable by anyone with account access.
- A revoked token can still work for up to a minute: KV reads are cached at the edge with a 60-second floor.
- Windows has no file modes, so
%APPDATA%\dcl-artifacts\credentials.jsonis protected by the default per-user ACL rather than by600. - Limits: 25 MB per file, 2000 files per artifact, 1000 persisted checkboxes per artifact.
