Skip to content

feat: add Cloudflare Workers edition as the recommended deployment#27

Merged
uzuraDev merged 5 commits into
mainfrom
feat/workers-main
Jul 16, 2026
Merged

feat: add Cloudflare Workers edition as the recommended deployment#27
uzuraDev merged 5 commits into
mainfrom
feat/workers-main

Conversation

@uzuraDev

Copy link
Copy Markdown
Owner

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.
  • README / README.ja restructured: Workers quick start promoted to the top with a Deploy to Cloudflare button and a link to the live read-only demo (https://html-vault-demo.uzuradev.workers.dev). Docker / self-host moved under an "advanced" section — content preserved, headings demoted.
  • DEMO_MODE: setting the DEMO_MODE=1 var turns the instance into a public read-only demo (browsing needs no login; every write — /api/login, POST/PUT/DELETE /api/snippets, MCP upload_html — returns 403). Ships with scripts/seed-demo.mjs to 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)

  • sandbox preview without allow-same-origin — unchanged; verified live: JS inside a stored snippet gets SecurityError on document.cookie, both in the iframe and on direct URL access (CSP sandbox header).
  • CSRF double-submit token on mutating APIs — unchanged.
  • Login rate limit (10 / 15 min, KV counter) — unchanged.
  • Server-generated 32-hex IDs — unchanged.
  • Security headers: the Workers edition sets CSP / X-Frame-Options / nosniff / Referrer-Policy explicitly (no helmet on workerd).
  • No .env / data/ / .dev.vars committed; 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.
  • Production: demo Worker deployed from this branch and verified (reads 200, writes 403, MCP 404 with no secret set).
  • node --check passes 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.
  • The button URL points at tree/main/worker, so it only becomes functional after this PR merges — needs one manual click-through afterwards.
  • The demo's own config (worker/wrangler.demo.toml) is intentionally gitignored.

- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread worker/src/worker.js
Comment on lines +547 to +555
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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread worker/src/worker.js
Comment on lines +672 to +679
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',
},
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread worker/src/worker.js
Comment on lines +692 to +699
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',
},
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread worker/src/worker.js
Comment on lines +262 to +265
const list = await loadIndex(env);
list.push(meta);
await saveIndex(env, list);
return { meta };

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 12 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • worker/package-lock.json: Generated file

Comment thread worker/src/worker.js
Comment on lines +514 to +518
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) });
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread worker/src/worker.js
Comment on lines +400 to +419
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]);
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread worker/public/index.html
Comment on lines +341 to +344
$('search').placeholder = t('searchPlaceholder');
$('newBtn').title = t('newBtn');
$('logoutBtn').title = t('logout');
$('logoutLabel').textContent = t('logout');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c6294c2aria-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.

Comment thread worker/setpass.mjs Outdated
Comment on lines +19 to +43
// 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: ');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 12 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • worker/package-lock.json: Generated file

Comment thread worker/src/worker.js
Comment on lines +663 to +668
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 } }
);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &lt;img …&gt; (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-label on the search input, title on the preview iframe (axe label / frame-title)
  • wired up a previously-unreachable Edit button (the PUT /api/snippets/:id path 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 13 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • worker/package-lock.json: Generated file

Comment thread worker/test/http-smoke.mjs Outdated
Comment on lines +4 to +8
* 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread worker/test/http-smoke.mjs Outdated
Comment on lines +128 to +130
// Belt-and-suspenders: kill any lingering local runtime holding the port.
if (IS_WIN) spawnSync('taskkill', ['/F', '/IM', 'workerd.exe'], { stdio: 'ignore' });
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread worker/package.json
Comment on lines +3 to +7
"version": "1.0.0",
"private": true,
"type": "module",
"description": "HTML Vault — self-hosted HTML snippet vault on Cloudflare Workers + KV",
"scripts": {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@uzuraDev
uzuraDev requested a review from Copilot July 16, 2026 08:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@uzuraDev
uzuraDev merged commit 7dd9da8 into main Jul 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants