Password-protect HTML publishing by default
Summary
pages.json public so titles, dates, links, and cross-machine search continue to work. A new page uses the supplied password=<value>; an omitted or empty password causes the script to generate a unique strong random password; access=public explicitly opts that page out of protection. Browsers get a normal password prompt. Any agent given only the link and password can read the page with standard curl and no local setup. Missing security configuration fails closed.
This is materially safer than unguessable URLs, but it is still shared-secret protection rather than individual identity. Cloudflare recommends Access for production-grade identity controls; the proposed first version deliberately favors the requested password experience and easy agent access.
Key decisions
| Area | Decision | Reason |
|---|---|---|
| Enforcement | default Root _worker.js in the published archive | It runs at Cloudflare before static assets are returned and works with the existing Wrangler direct-upload workflow. Cloudflare documents that advanced-mode workers take control of all requests and delegate allowed assets through env.ASSETS.fetch(). |
| Skill input | password=<value>, empty/omitted password, or access=public | Codex skills are instruction bundles invoked with free-form prompt text, not tools with a typed argument schema. These are therefore explicit invocation conventions which the skill translates into safe script flags. Official OpenAI documentation shows skill invocation with trailing prompt text and recommends explicit inputs and outputs. |
| Public surface | Index resources are always public; page bodies are public only when their registry entry says access=public | The local and cross-machine index remains usable. Public pages are a deliberate per-page opt-out, never the result of a missing password. |
| Human auth | Per-page HTTP Basic Auth over HTTPS, fixed username page | Each plan, page, or handoff can use a desired or generated password without rotating credentials for older URLs. |
| Agent access | Standard HTTP Basic Auth with fixed username page | A URL plus its password is sufficient on any computer: curl --user "page:PASSWORD" URL. No skill installation, shared token, synchronized registry, or Cloudflare account is required. |
| Password registry | Restricted local registry plus a generated worker route-policy map containing salted password verifiers | The worker can enforce different policies per URL without publishing plaintext passwords as static assets. This avoids a separate pepper secret and multi-machine provisioning protocol. |
| Caching | Cache-Control: private, no-store on protected responses | Reduces leakage from browser and intermediary caches after authentication. |
| Free-tier routing | Exclude only the public index resources from Function invocation; route every page body through the worker | Public index traffic stays on unlimited static serving, while protected bodies consume the Workers Free quota. Configure Pages Functions to fail closed if that quota is exhausted. |
| Failure mode | required A page without a valid registry entry returns 503; never infer public access | A missing password, corrupt registry, or failed map generation must make content unavailable rather than public. |
Primary references: Pages advanced mode, direct upload and _worker.js, Cloudflare’s Basic Auth example and cautions, and OpenAI’s Codex skill invocation and authoring guidance.
Scope control after adversarial Astra audit
| Classification | Work |
|---|---|
| MVP | Per-page registry; unique desired/random password contract; explicit public opt-out; public index; edge gate; link+password interoperability with standard curl; fail-closed tests; single-project rollout. |
| security | Audit old deployment aliases once, replace undeletable branch-head deployments with a gated version, and test old URLs before claiming legacy content is protected. |
| defer | Curl wrapper, site-wide bearer token, HMAC pepper lifecycle, capacity alerts, automated retention/cleanup, custom logout endpoint, authenticated ngrok fallback, and automatic registry synchronization. Cross-computer sharing uses the unique page password directly. |
| accepted | The simplest receiving-agent command may contain the page-specific password in its process arguments. This is acceptable because the password is intentionally shared with that agent and grants only one page; interactive curl remains available when process visibility matters. |
Phases
Phase 1 — Build and test the edge gate medium
- Add an auth-worker source asset under
html-page/assets/; havedeploy.shcopy it to~/.claude/html-plans/_worker.jsbefore every deployment. - Generate a route-policy map for every archived HTML page. Each canonical path is either
publicor carries a salted password verifier; the index resources have a hard-coded public policy. - Parse Basic authorization defensively using the fixed username
page. Verify per-page passwords against a salted verifier, compare fixed-length digests, and never log credentials or authorization headers. Generated passwords carry high entropy; user-supplied weak passwords remain weak. - Return
401withWWW-Authenticate: Basicand a short plain-text body showingcurl --user "page:PASSWORD" URL, plus the interactive alternativecurl --user page URL. Use a distinct realm derived from the page path so browsers do not confuse different pages’ cached credentials. - Normalize paths using
new URL(request.url).pathnameand cover both the source.htmlpath and Pages’ extensionless canonical URL. Do not use substring or suffix allowlists. - Forward authorized requests with
env.ASSETS.fetch(request), then clone protected responses withprivate, no-storecaching headers. - Add local tests for public index/manifest, an explicitly public page, generated and supplied page passwords, wrong credentials, missing registry entries,
GET/HEAD, encoded-path attempts, and unknown paths.
Phase 2 — Define the skill arguments and credential lifecycle medium
- Document the same input contract in
html-page,html-plan, andhtml-handoff:password=<value>sets the human password;password=requests a new random password;access=publicdisables password protection for that page. Reject a non-empty password combined withaccess=public. - For a new page, omitted
passwordhas the same secure default aspassword=: the script generates a cryptographically random URL-safe password. For an existing page, omitted access arguments preserve its current policy;password=explicitly rotates it to a newly generated password. - Use explicit deployment flags:
--password-file <path>,--random-password, or--public. Never place a password directly in the process command line or environment. The skill passes a supplied value through a mode-0600 temporary file and deletes that file after registration. - Maintain
~/.config/html-page/auth.jsonunder a mode-0700 directory with mode-0600 permissions. It records each canonical URL’s access mode and human password so generated passwords can be recovered and policies can be rebuilt. - Any agent with only URL + password can run
curl --user "page:PASSWORD" URL. The unauthenticated401response advertises that contract so an unfamiliar agent can discover it. When process visibility matters,curl --user page URLprompts for the password instead. - Return a compact share capsule after deployment containing only the canonical URL,
ACCESS=protected|public, and password when protected; the fixed username is discoverable from the401response. For a generated password, show it once and state that it is also stored in the restricted origin registry. A public capsule contains no password. - Treat the page password as the intentional capability being shared with the reader. Require a separately generated password per page by default, warn against reusing personal passwords or one password across pages, and reject accidental reuse of a generated password.
- The publishing workflow must not store plaintext credentials in skill files, the HTML archive,
pages.json, Git, its own process arguments, or deployment logs. A receiving agent may deliberately use the directcurl --user "page:PASSWORD"interoperability form.
# Works on any computer with only the link and password
curl --fail-with-body --user "page:PASSWORD" \
https://miro-plans-xps13.pages.dev/pages/some-page
# Alternative: curl prompts for the password instead of putting it in argv
curl --fail-with-body --user page \
https://miro-plans-xps13.pages.dev/pages/some-page
Phase 3 — Make secure publishing the default medium
- Update
deploy.shto register or preserve the target page’s access policy, generate the worker/map beforebuild-index.sh, refuse deployment when the registry is absent or invalid, and keep public index generation unchanged. - Generate a
_routes.jsonthat excludes only the intentionally public index resources from Function invocation and includes every other path. Set each Pages project’s quota behavior to fail closed; Cloudflare warns that fail-open mode can serve static assets when the Functions quota is exhausted. - Replace the current unconditional
200check with policy-aware checks: index is200; protected page is401without credentials and200with its password; public page is200without credentials. - Disable the ngrok fallback once protected publishing becomes the default. Restoring it with equivalent authentication is deferred until Miro actually needs that fallback.
- Update the skills so random protection is the default,
access=publicis the explicit per-page opt-out, titles remain public, and agent handoff includes the standard curl command. Changing an existing protected page to public must be reported prominently with the final URL.
Phase 4 — Roll out one project and close legacy aliases careful
- Build an access-policy entry with a distinct generated password for every existing page before deploying the worker. Store the passwords in the restricted registry.
- Deploy to one project first. Test its canonical URL and deployment-specific alias: index and manifest public; protected bodies
401; deliberately public bodies200; browser login and direct Basic-Auth curl both work. - Inventory older deployment and branch aliases once. Replace any undeletable branch-head deployment with a gated version, then—after explicit approval—delete superseded unauthenticated deployments. Cloudflare keeps preview aliases reachable and does not allow deleting the latest deployment for a branch.
- Document that password rotation and public→protected revocation apply immediately to the current deployment only. When historical deployment URLs matter, remove or independently gate every older deployment carrying the superseded policy before declaring revocation complete.
- Allow rollback only to a deployment that contains the authentication gate and an acceptable access-policy map. A pre-auth or superseded-public deployment is not a valid rollback target.
- After the desktop project is proven, treat XPS13 and battlebox as a separate fleet-rollout decision. The public combined index may continue listing those sites during the pilot; do not claim their page bodies are protected until separately verified.
Migration references: persistent preview aliases, branch-head deletion restriction, delete a Pages deployment, and Pages rollback behavior.
Phase 5 — Deferred improvements, outside MVP scope
- defer Capacity report, 80% alerts, and automated age/count cleanup. Manual inspection and Cloudflare’s supported deployment-delete command are enough initially.
- defer Curl wrapper, site-wide bearer credentials, and automatic registry synchronization. Add only if direct URL + per-page password sharing proves insufficient.
- defer Authenticated ngrok fallback and a custom logout endpoint. Keep the fallback disabled and document Basic Auth’s browser logout limitation.
- defer Cloudflare Access migration. Revisit when individual identity, MFA, revocation, or audit logs become requirements.
Capacity model: Cloudflare’s current Free-plan limits are 20,000 files per Pages site, 25 MiB per individual asset, and 500 builds/deploys per month. It documents unlimited active preview deployments and no cumulative byte-storage quota for Pages. This machine’s current archive is 379 files / 67 MB; its largest file is about 9.5 MB. Deleting pages helps the per-site file count; it does not fix the monthly deploy quota or an individual file over 25 MiB. References: Pages limits, automated old-deployment deletion, Functions pricing and Free quota, and Cloudflare Access service tokens.
Risks
| Risk | Mitigation / acceptance test |
|---|---|
| high Old deployment aliases preserve old access policies | Inventory and probe them during migration. Replace gated branch heads where deletion is prohibited, and remove old policy versions when actual password/public-access revocation is required. |
| high Worker or registry entry omitted on a future deploy | deploy.sh always installs the worker; worker fails closed; smoke test requires unauthenticated 401 before reporting success. |
| high Workers Free daily request quota is exhausted | Set Pages Runtime to fail closed, test that setting during rollout, and exclude public index resources with _routes.json. Protected pages become unavailable rather than public. |
| medium Password reused across pages | Generate a unique password independently for every page by default and warn against supplying a reused personal/shared password. Per-page credentials keep an intentional share scoped to one body. |
| medium Registry/map drift changes access unexpectedly | Generate the map transactionally, fail closed on missing entries, preserve existing policy when arguments are omitted, and smoke-test the target URL’s expected status after every deployment. |
| medium Weak user-selected password | Accept it because the requested argument is authoritative, warn against reused/valuable passwords, and recommend the high-entropy generated default. |
| medium Link/password pair is forwarded further | This is the intended capability model. Unique per-page passwords limit the additional reader to one page; rotate that page’s password when access should be revoked. |
| medium Basic Auth browser caching and awkward logout | Use HTTPS only, no-store, document the browser limitation, and recommend a private browser window on shared devices. A custom logout endpoint is outside MVP. |
| medium Public index leaks titles and dates | Accepted requirement. Keep page titles non-sensitive; do not put secrets or confidential client names in titles. |
| medium Cross-machine index breaks | Leave pages.json and its CORS header public; add an end-to-end merged-index test after each machine rollout. |
Open questions
- After secure deployments pass, may implementation delete older unauthenticated Cloudflare deployments?
default Yes, after listing the exact deployment IDs/URLs and obtaining explicit destructive-action approval.
alt Keep an old deployment only if it is independently gated and its policy is still acceptable; otherwise protection remains incomplete.
- How should the initial migration assign passwords to the already-published archive?
default Generate a distinct strong password for every existing page and store them in the restricted registry. This preserves the one-password/one-page capability boundary.
- What is the cross-computer sharing contract?
default Return URL + access mode + unique per-page password. Any receiving agent can use standard Basic Auth with fixed username
page; the401response explains the curl command.