feat: add Cloudflare Workers edition as the recommended deployment#27
Conversation
- worker/: self-contained Workers port (KV storage, PBKDF2 session auth, sandboxed preview, API token + remote MCP upload, runtime EN/JA UI) - DEMO_MODE: read-only public demo mode (all writes return 403, login-free browsing, demo banner) + scripts/seed-demo.mjs - README/README.ja: Workers quick start promoted to the top with a Deploy to Cloudflare button and live demo link; Docker/self-host moved under an advanced section; security table now covers both editions accurately
There was a problem hiding this comment.
Pull request overview
This PR introduces a fully self-contained Cloudflare Workers edition of HTML Vault and updates the root documentation to make Workers the recommended deployment path (including a public read-only demo and DEMO_MODE behavior).
Changes:
- Added a complete Workers + KV implementation (
worker/) with session auth, CSRF protection, sandboxed preview, optional remote MCP endpoint, and demo-mode write blocking. - Added Workers tooling/scripts (
wrangler.toml,setpass.mjs, demo seeding script) and a self-contained Workers README + runtime EN/JA UI toggle. - Restructured root READMEs to promote Workers quick start and document secrets/vars, MCP setup, and demo-mode behavior.
Reviewed changes
Copilot reviewed 10 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| worker/wrangler.toml | Wrangler config for deploying the Workers edition with KV binding and demo-mode var guidance. |
| worker/src/worker.js | Main Workers application: auth, KV storage, preview sandboxing, MCP endpoint, and DEMO_MODE behavior. |
| worker/setpass.mjs | PBKDF2 password hash generator/installer for Workers secrets and local dev vars. |
| worker/scripts/seed-demo.mjs | Generates a KV bulk upload JSON to seed a dedicated demo namespace. |
| worker/README.md | Workers-specific deployment, local dev, and configuration documentation. |
| worker/public/index.html | Self-contained UI for Workers edition with runtime EN/JA toggle and demo-mode UI behavior. |
| worker/package.json | Worker subproject package definition and wrangler scripts. |
| worker/package-lock.json | Lockfile for the worker subproject dependencies. |
| worker/.dev.vars.example | Example local dev variables for wrangler dev. |
| README.md | Root README restructured to recommend Workers and document demo/MCP/Workers config. |
| README.ja.md | Japanese root README updated in parallel with the Workers-first structure. |
| .gitignore | Ignores Workers dev artifacts (worker/node_modules, .dev.vars, .wrangler, etc.). |
Files not reviewed (1)
- worker/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const inTitle = (meta.title || '').toLowerCase().includes(needle); | ||
| const inTags = (meta.tags || '').toLowerCase().includes(needle); | ||
| let bodyText = null; | ||
| if (validId(meta.id)) { | ||
| const raw = await env.VAULT.get('snip:' + meta.id); | ||
| if (raw != null) bodyText = htmlToText(raw); | ||
| } | ||
| const inBody = !!(bodyText && bodyText.toLowerCase().includes(needle)); | ||
| if (!inTitle && !inTags && !inBody) continue; |
There was a problem hiding this comment.
Fixed in 4cccc58 — the body is now fetched and converted only when the query did not already match the title or tags. Verified locally: a title hit returns field: "title" with no KV body read; a body-only hit still returns field: "body" with an excerpt.
| return new Response(html, { | ||
| headers: { | ||
| 'Content-Type': 'text/html; charset=utf-8', | ||
| 'X-Frame-Options': 'SAMEORIGIN', | ||
| 'Content-Security-Policy': | ||
| 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation', | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Fixed in 4cccc58 — added X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer, and switched to allow-top-navigation-by-user-activation (user-clicked links between /p/ pages keep working; script-initiated top redirects from stored HTML are now blocked). I deliberately did not spread the full SEC_HEADERS here: it carries the app-shell CSP (default-src 'self'), which would break external resources in stored HTML — isolation stays on the sandbox CSP, which is the intended model.
| return new Response(injectScrollScript(html), { | ||
| headers: { | ||
| 'Content-Type': 'text/html; charset=utf-8', | ||
| 'X-Frame-Options': 'SAMEORIGIN', | ||
| 'Content-Security-Policy': | ||
| 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-pointer-lock', | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Fixed in 4cccc58 — preview responses now also send X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer. Same note as on /p/: the app-shell CSP from SEC_HEADERS is intentionally omitted so stored HTML can still load external resources inside the sandbox.
| const list = await loadIndex(env); | ||
| list.push(meta); | ||
| await saveIndex(env, list); | ||
| return { meta }; |
There was a problem hiding this comment.
Acknowledged — this is a real race, and 4cccc58 documents it instead of fixing it: KV has no transactions, so the single-key index is read-modify-write by design. This app is a single-user personal vault, so concurrent writers are out of scope; making that consistent would mean Durable Objects or D1, which I'd rather not pull in here. Added the trade-off to a code comment at loadIndex/saveIndex and a new Limitations section in worker/README.md so operators aren't surprised.
…index caveat - /api/search: skip the KV body fetch and HTML-to-text pass when the query already matched the title or tags - /p/ and preview responses: add X-Content-Type-Options and Referrer-Policy (kept off the app CSP on purpose — stored HTML may load external resources; isolation stays on the sandbox CSP) - /p/: allow-top-navigation -> allow-top-navigation-by-user-activation so stored HTML can no longer script-redirect the top-level page, while user-clicked links keep working - document the single-key index read-modify-write trade-off (no KV transactions; single-user design) in code and worker/README
| if (path === '/api/logout' && method === 'POST') { | ||
| const sess = await requireAuth(req, env); | ||
| if (!sess) return json({ error: 'Unauthorized.' }, 401); | ||
| return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', 0, secure) }); | ||
| } |
There was a problem hiding this comment.
Fixed in c6294c2 — /api/logout now requires the CSRF token like the other mutating endpoints (the UI already sends x-csrf-token on every non-GET call, so no client change was needed). Verified: logout without the header returns 403, with it 200.
| async function handleMcp(req, env, origin) { | ||
| if (req.method === 'GET') return new Response('Method Not Allowed', { status: 405 }); | ||
| if (req.method !== 'POST') return new Response(null, { status: 405 }); | ||
|
|
||
| let body; | ||
| try { body = await req.json(); } catch { return json(rpcError(null, -32700, 'Parse error')); } | ||
| const batch = Array.isArray(body); | ||
| const msgs = batch ? body : [body]; | ||
|
|
||
| // request(id付き)が1つも無い (= notification/response のみ) → 202 Accepted | ||
| const hasRequest = msgs.some((m) => m && m.id !== undefined && m.id !== null && typeof m.method === 'string'); | ||
| if (!hasRequest) return new Response(null, { status: 202 }); | ||
|
|
||
| const out = []; | ||
| for (const m of msgs) { | ||
| if (!m || m.id === undefined || m.id === null) continue; // notification は応答不要 | ||
| out.push(await mcpDispatch(m, env, origin)); | ||
| } | ||
| return json(batch ? out : out[0]); | ||
| } |
There was a problem hiding this comment.
Fixed in c6294c2 — the MCP 405/202 responses now carry the standard headers, and I also swept the remaining bare error responses (download/preview 400/401/404) into the same pattern. Verified against wrangler dev: 405, 202, and the error paths all return nosniff / X-Frame-Options / Referrer-Policy, and tools/list still works.
| $('search').placeholder = t('searchPlaceholder'); | ||
| $('newBtn').title = t('newBtn'); | ||
| $('logoutBtn').title = t('logout'); | ||
| $('logoutLabel').textContent = t('logout'); |
There was a problem hiding this comment.
Fixed in c6294c2 — aria-label is now set next to title in the same i18n apply step, so it stays in sync when the language toggles. Verified in the browser: the button exposes the localized name.
| // Read the password via readline so no characters are lost or garbled. | ||
| // (Input is echoed to the screen — local terminal only. What you type is exactly what gets stored.) | ||
| function askHidden(query) { | ||
| return new Promise((resolve) => { | ||
| const rl = createInterface({ input: process.stdin, output: process.stdout }); | ||
| rl.question(query, (answer) => { | ||
| rl.close(); | ||
| resolve(answer); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function b64u(buf) { | ||
| return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | ||
| } | ||
|
|
||
| function argValue(flag) { | ||
| const i = process.argv.indexOf(flag); | ||
| return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : ''; | ||
| } | ||
|
|
||
| (async () => { | ||
| const p1 = await askHidden('New password: '); | ||
| if (!p1 || p1.length < 8) { console.log('Use at least 8 characters.'); process.exit(1); } | ||
| const p2 = await askHidden('Type it again: '); |
There was a problem hiding this comment.
Fixed in c6294c2 — renamed to askLine and reworded the comment to state plainly that input is echoed (it is a plain line prompt, not a hidden one).
…1y, naming - /api/logout now requires the CSRF token like every other mutating API (the UI already sends x-csrf-token on all non-GET calls) - MCP 405/202 responses and download/preview error responses now carry the standard security headers (nosniff, X-Frame-Options, Referrer-Policy) like every other response - new-snippet button gets an aria-label alongside its title (visible label is just a plus sign) - setpass.mjs: rename askHidden -> askLine; the input is echoed, and the old name implied otherwise
| const htmlErr = (msg, status) => | ||
| new Response( | ||
| '<!doctype html><html lang="en"><meta charset="utf-8"><body style="font-family:sans-serif;padding:40px;color:#333">' + | ||
| '<p>' + msg + '</p><p><a href="/">Open HTML Vault</a></p></body></html>', | ||
| { status, headers: { 'Content-Type': 'text/html; charset=utf-8', ...SEC_HEADERS } } | ||
| ); |
There was a problem hiding this comment.
Fixed in 94d34fe — the slug is now escaped with a new escapeHtml() helper before being inserted into the error page. Verified with an integration test: /p/<img src=x onerror=alert(1)> now renders as <img …> (inert), and the test asserts no raw <img appears.
While here I ran an integration test + adversarial review pass over the whole Worker and fixed a batch of issues in the same commit so they don't come back one at a time:
- a11y:
for=on all form labels,aria-labelon the search input,titleon the preview iframe (axe label / frame-title) - wired up a previously-unreachable Edit button (the
PUT /api/snippets/:idpath had no UI); hidden in demo mode - removed a dead source-view element + its CSS
- renamed callback params that shadowed the i18n
t()helper - documented the best-effort/non-atomic login rate-limit (KV read-modify-write, TOCTOU)
Added worker/test/http-smoke.mjs (npm test): a self-orchestrating wrangler-dev integration test — 44 cases covering auth/CSRF, security headers on every route, sandbox CSP, reflected-XSS escaping, demo-mode 403s, MCP gating, and id validation. All 44 pass across normal / demo / mcp-unset configs.
Addresses the outstanding Copilot review comment (reflected XSS) plus issues found by an integration test + adversarial review pass: - /p/ error page: escape user-controlled slug before inserting into HTML (reflected XSS); add escapeHtml() helper - a11y: associate all form <label>s via for=, add aria-label to the search input and title to the preview iframe (axe label/frame-title) - wire up the edit flow: add an Edit button to the open-snippet action bar (was dead code — PUT /api/snippets/:id had no UI path); hidden in demo mode - remove the unreachable source-view element and its dead CSS - rename callback params that shadowed the i18n t() helper - document the best-effort/non-atomic login rate-limit (KV RMW, TOCTOU) - add test/http-smoke.mjs: self-orchestrating wrangler-dev integration test (44 cases: auth/CSRF, security headers on every route, sandbox CSP, reflected-XSS escaping, demo 403s, MCP gating, id validation) and an 'npm test' script All 44 cases pass across normal / demo / mcp-unset configs. Edit flow, demo-mode button hiding, and PUT persistence verified live.
| * Self-orchestrating: this single file owns the whole lifecycle. It backs up the | ||
| * developer's worker/.dev.vars, then for each phase it writes a phase-specific | ||
| * .dev.vars, spawns `wrangler dev` (isolated --persist-to so it never touches your | ||
| * real local KV / rate-limit state), waits for /api/me, runs the phase's assertions, | ||
| * and kills the server. On exit the original .dev.vars is always restored. |
There was a problem hiding this comment.
Fixed in bea1c24 — added SIGINT/SIGTERM/SIGHUP handlers that restore .dev.vars and kill the dev server before exiting, and softened the header comment to reflect that a hard SIGKILL / power loss can still leave the phase-specific .dev.vars in place (re-running overwrites it cleanly).
| // Belt-and-suspenders: kill any lingering local runtime holding the port. | ||
| if (IS_WIN) spawnSync('taskkill', ['/F', '/IM', 'workerd.exe'], { stdio: 'ignore' }); | ||
| } |
There was a problem hiding this comment.
Fixed in bea1c24 — removed the taskkill /IM workerd.exe fallback entirely. The PID-tree kill (taskkill /F /T /PID) already reaps the spawned workerd child, so there's no need for the image-name kill that would have taken down every workerd on the machine. Added a comment explaining why it's intentionally omitted.
| "version": "1.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "description": "HTML Vault — self-hosted HTML snippet vault on Cloudflare Workers + KV", | ||
| "scripts": { |
There was a problem hiding this comment.
Fixed in bea1c24 — added "engines": { "node": ">=22.0.0" } to worker/package.json. Verified against package-lock (wrangler@4.111.0 declares node >=22.0.0), so the constraint matches exactly and older Node now fails fast with a clear message.
… node engine - restore .dev.vars on SIGINT/SIGTERM/SIGHUP, not just normal exit, so Ctrl+C during a run no longer leaves a phase-specific .dev.vars behind (comment softened to match: a hard SIGKILL can still leave it) - drop the 'taskkill /IM workerd.exe' fallback: it killed every workerd on the machine (other projects' dev servers too). The PID-tree kill (taskkill /F /T) already reaps the spawned workerd child - declare engines.node >=22.0.0 in package.json to match wrangler's own requirement (verified against package-lock), so older Node fails early with a clear message Re-ran npm test: 44/44 pass, .dev.vars restored.
What
worker/— a self-contained Cloudflare Workers edition (KV storage, PBKDF2 session auth, sandboxed preview, optional API token + remote MCP upload). Runtime EN/JA UI switch (auto-detect + toggle), server messages in English.DEMO_MODE: setting theDEMO_MODE=1var turns the instance into a public read-only demo (browsing needs no login; every write —/api/login, POST/PUT/DELETE/api/snippets, MCPupload_html— returns 403). Ships withscripts/seed-demo.mjsto seed a dedicated demo namespace.Why
The Docker edition needs a box, HTTPS, and a tunnel/reverse proxy before the remote MCP upload works. On Workers the free tier gives an always-on public HTTPS URL with zero ops, which makes it the friendlier default — and enables the public demo.
Keep-intact checklist (per CONTRIBUTING)
sandboxpreview withoutallow-same-origin— unchanged; verified live: JS inside a stored snippet getsSecurityErrorondocument.cookie, both in the iframe and on direct URL access (CSPsandboxheader)..env/data//.dev.varscommitted;worker/additions are covered by.gitignore.Testing
wrangler dev(wrangler 4.111): login → save → sandboxed preview → language toggle verified in a browser.DEMO_MODE=1: list/search/preview public (200), all writes 403 with a deploy-your-own message, demo banner shown, edit UI hidden.node --checkpasses on all worker sources; behavior harness (mocked KV) 33/33.Notes
worker/is dependency-complete on purpose: the Deploy to Cloudflare button requires the subdirectory to be fully isolated.tree/main/worker, so it only becomes functional after this PR merges — needs one manual click-through afterwards.worker/wrangler.demo.toml) is intentionally gitignored.