@dcl-regenesislabs/artifacts 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,357 @@
1
+ # ia-generated-artifacts
2
+
3
+ Self-hosted artifact host — a Claude-Artifacts-like place to drop a folder of static
4
+ files and get a link back. Runs as a Cloudflare Worker in front of an R2 bucket on
5
+ the **dclregenesislabs** account.
6
+
7
+ ```
8
+ https://artifacts.dclregenesislabs.xyz/{id} latest version
9
+ https://artifacts.dclregenesislabs.xyz/{id}@2 one version, pinned
10
+ https://artifacts.dclregenesislabs.xyz/p/{alias} public link, no login
11
+ ```
12
+
13
+ `id` is a UUID v4 minted on the first push and kept for life. Every later push to
14
+ the same id is a new **version**; the link never changes. Every gated page gets a
15
+ **bar** at the top, injected by the Worker: the artifact's name, a version picker,
16
+ the **public link switch**, copy link. Checkboxes marked `data-persist` remember
17
+ their state on the host, for everyone.
18
+
19
+ Every `/{id}` URL is behind a **Cloudflare Access** login that only accepts
20
+ `@dclregenesislabs.xyz` and `@decentraland.org` addresses. Making something
21
+ public is done from the bar, by a signed-in person — never by the upload token.
22
+ See [Visibility](#visibility).
23
+
24
+ ## Publish something
25
+
26
+ The CLI is on npm as [`@dcl-regenesislabs/artifacts`](https://www.npmjs.com/package/@dcl-regenesislabs/artifacts),
27
+ so nothing needs installing:
28
+
29
+ ```bash
30
+ npx @dcl-regenesislabs/artifacts login # once per machine, work email in a browser
31
+ npx @dcl-regenesislabs/artifacts push ./my-demo --name "my demo"
32
+ ```
33
+
34
+ From a checkout of this repo, the same commands are `node cli/artifacts.mjs …`:
35
+
36
+ ```bash
37
+ node cli/artifacts.mjs login
38
+ node cli/artifacts.mjs push ./my-demo --name "my demo"
39
+ # → https://artifacts.dclregenesislabs.xyz/3f2b9c1e-4d5a-4b6c-8e7f-9a0b1c2d3e4f
40
+
41
+ node cli/artifacts.mjs push ./my-demo --id https://artifacts.dclregenesislabs.xyz/3f2b9c1e-… # new version, same link
42
+ ```
43
+
44
+ `index.html` is the entry point. A single file is served at the bare `/{id}`. A
45
+ folder is served under `/{id}/` (the bare URL redirects there), so reference its
46
+ assets **relatively** (`./assets/app.js`) — absolute paths break.
47
+
48
+ ```bash
49
+ node cli/artifacts.mjs push ./report.html # single-file artifact
50
+ node cli/artifacts.mjs push ./demos/*/ # several new artifacts at once
51
+ node cli/artifacts.mjs info <id or url> # name, versions, visibility
52
+ node cli/artifacts.mjs ls # everything on the host
53
+ node cli/artifacts.mjs rm <id or url> # every version, state and public link
54
+ node cli/artifacts.mjs --help
55
+ ```
56
+
57
+ Pushing bytes identical to the latest version records nothing. Blobs are stored
58
+ by digest, so a new version uploads only the files that changed. `commit` carries
59
+ the version number the client last saw and is refused if someone pushed in between.
60
+
61
+ ## House template
62
+
63
+ `templates/artifact/` is the look every page on the host shares — IBM Plex,
64
+ indigo accent, three-state dark mode, and a set of blocks (callouts, cards,
65
+ tables, phases, checklist, diff columns). It was lifted from a Claude-designed
66
+ artifact so pages here match the ones Claude makes elsewhere.
67
+
68
+ ```bash
69
+ node cli/artifacts.mjs new ./my-page --name "Q3 numbers" # scaffold
70
+ $EDITOR my-page/index.html # write
71
+ node cli/artifacts.mjs push ./my-page # publish
72
+ ```
73
+
74
+ `new` writes **one self-contained `index.html`**: the CSS and JS in
75
+ `templates/artifact/assets/` stay editable as files and are inlined at
76
+ scaffold time, so the result is the same single-file shape as a Claude artifact
77
+ and works anywhere. The scaffolded page is a live reference of every block. The
78
+ one knob per page is `--accent` in its small `<style>`. The skill tells Claude
79
+ to start here. (`docs/guide/index.html` is generated by `scripts/build-guide.mjs`
80
+ from the same assets — re-run it when `dcl.css` changes. The guide carries the
81
+ install line with the token filled in, so it stays team-only: never flip it
82
+ public.)
83
+
84
+ ## The bar
85
+
86
+ The Worker injects `<dcl-bar>` plus `/__ui/bar.js` at the top of `<body>` of
87
+ every HTML response on a gated URL (`src/serve.ts`, HTMLRewriter). The script
88
+ renders into a shadow root, so page CSS can't touch it and vice versa. It talks
89
+ to a small in-page API under the artifact:
90
+
91
+ | Call | Who | Does |
92
+ | --- | --- | --- |
93
+ | `GET /{id}/__ui/meta` | anyone through the gate | name, versions, public link, viewer, checkbox state |
94
+ | `PUT /{id}/__ui/public` | a signed-in person | mints the public alias; records who and when |
95
+ | `DELETE /{id}/__ui/public` | a signed-in person | deletes the alias — the link dies at once |
96
+ | `PUT /{id}/__ui/state` | a signed-in person | `{ "key": true }` merges into the checkbox state |
97
+ | `GET /p/{alias}/__ui/state` | anyone with the link | the state, read-only |
98
+
99
+ "Signed-in person" means the Worker verified the Cloudflare Access JWT
100
+ (`src/access.ts`: signature against the team's public keys, audience, issuer,
101
+ expiry) and took the email from it. That is the answer to *who can make a
102
+ document public*: anyone who can open it. The public view (`/p/…`) gets no bar
103
+ and only a tiny script that paints the saved checkbox state.
104
+
105
+ **Checkboxes.** `<input type="checkbox" data-persist="deploy-staging">`. State is
106
+ keyed by artifact, not version, so ticks survive a re-push as long as the keys do.
107
+ The bar shows who ticked each box. Public viewers see them read-only.
108
+
109
+ ## Visibility
110
+
111
+ | | URL | Who can open it |
112
+ | --- | --- | --- |
113
+ | **team** (always) | `/{id}`, `/{id}@n` | `@dclregenesislabs.xyz` and `@decentraland.org` only, via a work-email login |
114
+ | **public** (switch in the bar) | `/p/{alias}` | anyone holding the link, no login; latest version; no bar |
115
+
116
+ The public link is additive — it mints a *second* URL and the gated one keeps
117
+ working. Everything under `/p/` is exempt from the Access policy, so **the alias
118
+ is the only credential**: 128 bits of randomness, unrelated to the artifact id.
119
+
120
+ Switching it off deletes the alias immediately — aliases are resolved against R2
121
+ on every request rather than cached — and it is permanent: switching on again
122
+ mints a *different* alias, so a link already sent out stays dead. Neither the CLI
123
+ nor the upload API can do any of this; there is no endpoint for it.
124
+
125
+ Claude picks all of this up through the project skill in
126
+ `.claude/skills/dcl-artifacts/`.
127
+
128
+ ## Install the skill (one line)
129
+
130
+ macOS / Linux:
131
+
132
+ ```bash
133
+ curl -fsSL https://artifacts.dclregenesislabs.xyz/install.sh | bash
134
+ ```
135
+
136
+ Windows (PowerShell):
137
+
138
+ ```powershell
139
+ irm https://artifacts.dclregenesislabs.xyz/install.ps1 | iex
140
+ ```
141
+
142
+ No secret in either line. `scripts/install.sh` / `scripts/install.ps1` are
143
+ served by the Worker at a public path together with `dist/dcl-artifacts.tgz` —
144
+ the CLI, the shared id module, the house template and the skill with its style
145
+ reference, built by `scripts/build-dist.mjs` before every `npm run dev` /
146
+ `npm run deploy` and checked to contain nothing token-shaped. The installer
147
+ unpacks the tarball into `~/.dcl-artifacts`, writes the skill to
148
+ `~/.claude/skills/dcl-artifacts/` with that path filled in, and then runs
149
+ `dcl-artifacts login`, which opens a browser for the work-email one-time PIN.
150
+ Needs Node 20+, nothing else — no git, no repo access, no `npm install`.
151
+ Re-run the same line to update.
152
+
153
+ By hand, with access to this repo (no `npm install` needed; run
154
+ `node cli/artifacts.mjs login` afterwards):
155
+
156
+ ```bash
157
+ git clone git@github.com:dcl-regenesislabs/ia-generated-artifacts.git ~/.dcl-artifacts
158
+ ~/.dcl-artifacts/scripts/install-skill.sh # or: npm run skill:install, inside the checkout
159
+ ```
160
+
161
+ The one-line installers leave an existing checkout at `~/.dcl-artifacts` alone,
162
+ so the two ways don't fight.
163
+
164
+ ## Signing in
165
+
166
+ `dcl-artifacts login` binds a browser login to the terminal that asked for it:
167
+
168
+ 1. The CLI holds a loopback port, then opens `/cli/login` with the port, a
169
+ random `state`, and `sha256(verifier)` — the verifier never leaves the process.
170
+ 2. Cloudflare Access gates that page, so reaching it proves a work-email login.
171
+ The page shows a code that must match the terminal, and minting only happens
172
+ on a POST from that page.
173
+ 3. The Worker mints a one-time code (60s, single use, R2) and redirects to
174
+ `http://127.0.0.1:<port>/cb` — a target it builds itself from a validated
175
+ integer, never from the query string.
176
+ 4. The CLI checks `state`, then exchanges the code plus the verifier at
177
+ `/_api/cli/exchange` for a token, and writes it to
178
+ `~/.config/dcl-artifacts/credentials.json` (mode 600).
179
+
180
+ Tokens are `dcla_v1_…`, live 90 days, and are stored only as `sha256` in KV.
181
+ Revoke one from `/cli/tokens` or with `dcl-artifacts logout [--all]`. Nothing is
182
+ ever pasted anywhere, and the token is never printed.
183
+
184
+ ## Layout
185
+
186
+ | Path | What it is |
187
+ | --- | --- |
188
+ | `src/index.ts` | router: `/_api/*` → upload API, everything else → site |
189
+ | `src/api.ts` | `status` / `blobs` / `commit` / info / list / delete, all bearer-authed |
190
+ | `src/serve.ts` | reads: version resolution, `@n`, redirects, `index.html` + SPA fallback, ETag/Range/cache, bar injection |
191
+ | `src/ui.ts` | the in-page API the bar calls |
192
+ | `src/access.ts` | Cloudflare Access JWT verification |
193
+ | `src/bar-script.ts` | the injected client code (bar, and the public-view state painter) |
194
+ | `src/store.ts` | meta / state / manifest records, compare-and-swap on the R2 ETag |
195
+ | `src/auth.ts`, `src/mime.ts`, `src/types.ts`, `src/urls.ts` | token comparison, content types, bindings and record shapes, URL helpers |
196
+ | `shared/artifact-id.js` | id / alias / hash / manifest rules — imported by *both* the Worker and the CLI |
197
+ | `cli/artifacts.mjs` | zero-dependency Node uploader |
198
+ | `scripts/smoke.sh` | end-to-end check against a local or deployed host |
199
+ | `scripts/setup-access.mjs` | reconciles the Cloudflare Access apps and policies |
200
+ | `scripts/build-guide.mjs` | regenerates `docs/guide/index.html` from the template assets |
201
+ | `scripts/install.sh`, `scripts/install.ps1` | the one-line installers, served at `/install.sh` and `/install.ps1` |
202
+ | `scripts/build-dist.mjs` | builds `dist/dcl-artifacts.tgz`, served at `/install/dcl-artifacts.tgz` |
203
+
204
+ R2 layout, per artifact:
205
+
206
+ ```
207
+ a/{id}/__meta.json name, versions[], current, public {alias, at, by} (mutable, CAS)
208
+ a/{id}/__state.json checkbox state {key: {checked, by, at}} (mutable, CAS)
209
+ a/{id}/m/{n}.json file list of version n (immutable)
210
+ a/{id}/b/{sha256} blob, one per distinct content (immutable)
211
+ p/{alias} → {id} (exists only while public)
212
+ ```
213
+
214
+ Nothing under `a/` is ever served by path — the Worker resolves a URL through the
215
+ meta record and the version's manifest to a blob. Blobs and manifests are cached
216
+ at the edge under keys the router 404s.
217
+
218
+ ## How an upload works
219
+
220
+ The client hashes locally and uploads straight to blob keys, so the Worker never
221
+ streams objects through itself:
222
+
223
+ 1. `POST /_api/artifacts/{id}/status` — sends the file list, gets back
224
+ `{exists, current, missing: [sha256…]}`.
225
+ 2. `PUT /_api/artifacts/{id}/blobs/{sha256}` — raw bytes. The Worker checks the
226
+ digest and hands the same digest to R2, which checks it again.
227
+ 3. `POST /_api/artifacts/{id}/commit` — `{files, base, name?, author?}`. The
228
+ Worker confirms every blob is present, writes the version manifest, then
229
+ updates the meta record — refusing if `current !== base`. Only then is the
230
+ version live.
231
+
232
+ All `/_api/*` calls require `Authorization: Bearer <token>` — either a per-user
233
+ token from `login` or, until it is retired, the shared `UPLOAD_TOKEN`. The two
234
+ exceptions are `POST /_api/cli/exchange`, which is how the CLI gets a token in
235
+ the first place (PKCE, not a bearer), and `GET /_api/whoami`, which reports what
236
+ the caller is signed in as.
237
+
238
+ `commit` records `by` from the token's own email; the client-supplied `author`
239
+ is honoured only for the shared token, which carries no identity.
240
+
241
+ ## Who can read what
242
+
243
+ Cloudflare Access sits in front of the hostname. It matches the most specific
244
+ path first, which gives these rules:
245
+
246
+ | Path | Policy | Why |
247
+ | --- | --- | --- |
248
+ | `/_api/*` | bypass | already guarded by the bearer token |
249
+ | `/_health` | bypass | exposes nothing, keeps uptime monitors working |
250
+ | `/p/*` | bypass | opt-in public aliases carry their own secret |
251
+ | `/install.sh`, `/install.ps1`, `/install/*` | bypass | the installers and their tarball; no secrets in any of them |
252
+ | everything else | allow `@dclregenesislabs.xyz`, `@decentraland.org` | the gate — pages, `/__ui/bar.js`, the in-page API |
253
+
254
+ Login is Cloudflare's built-in **one-time PIN**: the visitor types their email,
255
+ gets a 6-digit code, and the policy checks the domain. No identity provider to
256
+ configure. Sessions last a week. A service-token policy also lets CI reach the
257
+ gated path — `scripts/setup-access.mjs` mints that token; the Worker attributes
258
+ its in-page writes to the token's name.
259
+
260
+ The Worker also verifies the Access JWT itself for in-page writes. `ACCESS_AUD`
261
+ in `wrangler.jsonc` is the AUD tag of the catch-all "Artifacts" application —
262
+ the `kid` in its login redirect. If that application is ever recreated, update it.
263
+
264
+ `workers_dev` and `preview_urls` are pinned to `false` in `wrangler.jsonc`. That
265
+ is load-bearing: a `*.workers.dev` URL would answer on a hostname Access does not
266
+ cover and would hand anyone a way straight past the gate.
267
+
268
+ ## Develop
269
+
270
+ ```bash
271
+ npm install
272
+ printf 'UPLOAD_TOKEN=%s\nPUBLIC_BASE_URL=http://127.0.0.1:8787\nLOCAL_DEV=1\n' "$(openssl rand -hex 32)" > .dev.vars
273
+ npm run dev # wrangler dev, local R2
274
+ ./scripts/smoke.sh http://127.0.0.1:8787 "$(sed -n 's/^UPLOAD_TOKEN=//p' .dev.vars)"
275
+ npm run typecheck
276
+ ```
277
+
278
+ `LOCAL_DEV=1` stands in for Access locally: in-page writes are attributed to
279
+ `dev@localhost`. It lives only in `.dev.vars`, which is gitignored and never
280
+ deployed.
281
+
282
+ ## Deploy
283
+
284
+ Wrangler must be logged into the **dclregenesislabs** account (`wrangler whoami`
285
+ should show `a2b29bacd555c6fc78becaad8b183e9c`; `wrangler login` to switch).
286
+
287
+ ```bash
288
+ npx wrangler r2 bucket create dcl-artifacts
289
+ npx wrangler r2 bucket create dcl-artifacts-preview # used by `wrangler dev --remote`
290
+ npx wrangler kv namespace create TOKENS # put the id in wrangler.jsonc
291
+ npx wrangler kv namespace create TOKENS --preview # and the preview_id
292
+ npx wrangler secret put UPLOAD_TOKEN # the shared token, until it is retired
293
+ npm run deploy # builds dist/, provisions the custom domain
294
+
295
+ # Cloudflare Access — needs an API token with Access: Apps and Policies = Edit
296
+ CLOUDFLARE_API_TOKEN=… node scripts/setup-access.mjs --dry-run
297
+ CLOUDFLARE_API_TOKEN=… node scripts/setup-access.mjs
298
+
299
+ CF_ACCESS_CLIENT_ID=… CF_ACCESS_CLIENT_SECRET=… \
300
+ ./scripts/smoke.sh https://artifacts.dclregenesislabs.xyz "$DCL_ARTIFACTS_TOKEN"
301
+ ```
302
+
303
+ `setup-access.mjs` is idempotent — it rewrites the applications and their
304
+ policies to match the table above, so re-run it after any policy change.
305
+
306
+ ### Releasing the CLI to npm
307
+
308
+ The package is `cli/`, `shared/` and `templates/` — the `files` list in
309
+ `package.json`; nothing of the Worker ships. Releases go through
310
+ `.github/workflows/publish.yml` using npm **trusted publishing**: npm trusts
311
+ that workflow's GitHub OIDC identity, so there is no npm token in the repo, in
312
+ Actions secrets, or anywhere else, and every release carries provenance.
313
+
314
+ ```bash
315
+ # bump "version" in package.json, commit, then:
316
+ git tag v1.2.3 && git push origin v1.2.3
317
+ ```
318
+
319
+ The trust is configured on npmjs.com under the package's *Settings → Trusted
320
+ Publisher* (organisation `dcl-regenesislabs`, repository
321
+ `ia-generated-artifacts`, workflow `publish.yml`). It keys on the workflow's
322
+ filename, so renaming the file breaks publishing until the setting is updated.
323
+ `npm pack --dry-run` shows exactly what a release would contain.
324
+
325
+ ### Retiring the shared token
326
+
327
+ `UPLOAD_TOKEN` still works alongside per-user tokens so nobody's publishing
328
+ breaks mid-migration. Every API request logs its credential `kind`, so:
329
+
330
+ 1. Watch for `"kind":"shared"` in `npm run tail`. When two weeks pass with none
331
+ — long enough to cover people who publish weekly — everybody has moved.
332
+ 2. `wrangler secret put UPLOAD_TOKEN` with a value nobody holds. That is a
333
+ reversible kill switch; leave it a week.
334
+ 3. Delete the shared branch in `src/auth.ts` and the `author` fallback in
335
+ `src/api.ts`.
336
+
337
+ Rotate it in any case. The old value is in this repo's git history, so removing
338
+ it from `HEAD` did not remove it.
339
+
340
+ ## Caveats
341
+
342
+ - **Public URLs are genuinely public.** Anyone with a `/p/{alias}` link can open
343
+ it, with no login, until someone switches it off in the bar. Treat the switch
344
+ as publishing — the bar asks for confirmation and records who did it.
345
+ - **All artifacts share one origin.** A published page can read another artifact's
346
+ `localStorage`, and — since it runs as the signed-in viewer — call another
347
+ artifact's in-page API. Only people who can sign in with a work email can
348
+ publish pages, so this is the same trust as the gate itself; still, do not
349
+ publish pages that run untrusted third-party script.
350
+ - Access does not encrypt anything at rest — it gates the front door. R2 objects
351
+ are still readable by anyone with account access.
352
+ - A revoked token can still work for up to a minute: KV reads are cached at the
353
+ edge with a 60-second floor.
354
+ - Windows has no file modes, so `%APPDATA%\dcl-artifacts\credentials.json` is
355
+ protected by the default per-user ACL rather than by `600`.
356
+ - Limits: 25 MB per file, 2000 files per artifact, 1000 persisted checkboxes per
357
+ artifact.