Page serving
The public-facing routes that visitors hit when they open a page URL
(https://outpost.click/p/<id>/<path>). They
enforce visibility rules, render the access gate, and resolve
SPA-style fallback to the page's default file.
GET /p/{page_id}
Redirects to /p/{page_id}/{default_file} so the URL bar
shows the actual entry point. Useful when the entry point isn't
index.html (e.g. an uploaded ZIP with only
main.html at the root).
GET /p/Ab12Cd34
302 Found
Location: /p/Ab12Cd34/index.html
Errors
404 Page not found
GET /p/{page_id}/{path}
Serve a stored file, render the access gate, or fall back to the default file (SPA support).
Resolution order
- Look up the page; 404 if missing.
- Determine access (see below). If the visitor needs to authenticate or supply a passcode, return the access gate template.
- Look for
file_path = pathexactly. - If
pathdoesn't end in.html, look for{path}/index.html. - Fall back to the page's
default_file— this is what makes single-page-app routes work (/p/Ab12Cd34/dashboard/usersserves the SPA shell). - If still nothing, return
404 File not found.
Examples
# Direct file
GET /p/Ab12Cd34/about.html
# Directory-style fallback to about/index.html
GET /p/Ab12Cd34/about
# Deep SPA route — falls back to the default file (often index.html)
GET /p/Ab12Cd34/dashboard/projects/42
Response headers
Returned files are sent with the content_type stored in
page_files. Outpost adds aggressive no-cache headers when the
page is non-public or has any passcode set:
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
Pragma: no-cache
Expires: 0
For public pages without passcodes, no cache headers are added — the upstream / CDN policy applies.
Visibility & access gate
The decision tree implemented in serve_page:
| Condition | Result |
|---|---|
| Caller is the page owner. | Always granted. |
The page_access_<id> cookie matches the expected HMAC. |
Granted (passcode previously verified). |
Page is public with no passcodes. | Granted. |
Page is public with passcodes. |
Show access gate until the visitor supplies a valid passcode. |
Page is shared, caller is logged in and email is in allowed_emails. |
Granted. |
Page is shared, caller is anonymous or email isn't allowed. |
Show access gate (passcode unlocks if any are set). |
Page is private and caller is not the owner. |
Show access gate (passcode unlocks if any are set). |
Access gate
The gate is an HTML page rendered from
templates/access_gate.html with these template variables:
{
page_id: "Ab12Cd34",
page_visibility: "private",
has_passcodes: true,
logged_in: false,
error: null # set on a failed verify
}
POST /p/{page_id}/verify
Submit a passcode against the configured list. On success, set the
page_access_<id> cookie (HttpOnly, SameSite=Lax, 24 h)
and 303-redirect back to the page. On failure, re-render the gate with
an error.
Form fields
| Field | Notes |
|---|---|
passcode | Plain text. Compared in constant time against every stored passcode. |
Example: HTML form (what the gate posts)
<form method="post" action="/p/Ab12Cd34/verify">
<input name="passcode" type="password">
<button type="submit">Unlock</button>
</form>
Example: cURL
curl -i -X POST https://outpost.click/p/Ab12Cd34/verify \
-F "passcode=letmein"
# 303 See Other
# Set-Cookie: page_access_Ab12Cd34=<hex hmac>; HttpOnly; SameSite=Lax; Max-Age=86400
# Location: /p/Ab12Cd34
Errors
- Invalid passcode → 200 OK with the access gate template re-rendered (
error: "Invalid passcode"). 404 Page not found
page_access_<id> = HMAC_SHA256(jwt_secret, "page_access:<id>")
rendered as hex. Because the value depends only on the page id and the
server secret, all visitors who unlock a given page share the same
cookie value — but it can't be forged without the secret.
Passcode storage
Passcodes are encrypted before being stored and decrypted on read:
jwt_secretis hashed with SHA-256 to derive a 32-byte key.- The key is base64url-encoded for Fernet.
- Each passcode is encrypted independently; the resulting list is
stored as a JSON array of Fernet ciphertexts in
pages.passcode_hashes.
This is reversible by design: the dashboard's GET /pages
returns the plain-text passcodes so the owner can show them. Don't share
that response with viewers.
End-to-end example
1. Create a private page with a passcode
curl -X POST https://outpost.click/api/pages \
-H "Authorization: Bearer op_YOUR_TOKEN" \
-F "name=Secret docs" \
-F "visibility=private" \
-F "file=@./docs.zip"
# {"id":"Wq8K3pLm",...}
# Add a passcode (use the cookie/JWT API for this)
curl -X PUT https://outpost.click/pages/Wq8K3pLm \
-H "Authorization: Bearer op_YOUR_TOKEN" \
-F "passcodes=letmein"
2. Visitor gets the gate
curl -i https://outpost.click/p/Wq8K3pLm
# 200 OK, returns the access gate HTML
3. Visitor unlocks
curl -ic /tmp/cj -X POST https://outpost.click/p/Wq8K3pLm/verify \
-F "passcode=letmein"
# 303 See Other -> /p/Wq8K3pLm
# Set-Cookie: page_access_Wq8K3pLm=...
curl -b /tmp/cj https://outpost.click/p/Wq8K3pLm
# Now serves the actual page content
Example: creating a page with shared access
Shared pages combine three access mechanisms — the owner is always
allowed, anyone whose logged-in email is in allowed_emails
is allowed, and anyone who supplies a valid passcode is allowed for 24
hours. You can configure any combination at create time.
POST /pages when you
want to set allowed_emails in one request. The
/api/pages endpoint creates pages but does not
accept allowed_emails or passcodes — see the
two-step API-token recipe at the bottom of this section.
Shared with an email allow-list (no passcode)
curl -X POST https://outpost.click/pages \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "name=Team Wiki" \
-F "visibility=shared" \
-F "allowed_emails=alice@acme.com,bob@acme.com" \
-F "file=@./wiki.zip"
{
"id": "Mn7Pq2Rs",
"name": "Team Wiki",
"visibility": "shared",
"passcodes": [],
"allowed_emails": ["alice@acme.com", "bob@acme.com"],
"default_file": "index.html",
"created_at": "2026-05-03T22:41:11",
"updated_at": "2026-05-03T22:41:11"
}
Shared with passcode only (no allow-list)
curl -X POST https://outpost.click/pages \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "name=Beta Preview" \
-F "visibility=shared" \
-F "passcodes=preview-2026" \
-F "file=@./preview.zip"
Anyone (logged in or not) who has the passcode can unlock the page; without it they hit the access gate.
Shared with both (allow-list or passcode)
curl -X POST https://outpost.click/pages \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "name=Stakeholder demo" \
-F "visibility=shared" \
-F "allowed_emails=alice@acme.com,bob@acme.com" \
-F "passcodes=demo-day,backup-pass" \
-F "file=@./demo.zip"
Either condition grants access — Alice and Bob view directly without needing the passcode; outside guests can still unlock with one of the two passcodes.
From a URL instead of a file
curl -X POST https://outpost.click/pages \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "name=Vendor docs" \
-F "visibility=shared" \
-F "allowed_emails=alice@acme.com" \
-F "source_url=https://vendor.example.com/onboarding"
Two-step: create via API token, then add the allow-list
The /api/pages endpoint (used by CLI/MCP integrations)
doesn't accept allowed_emails. Create the page first, then
PUT the metadata.
# 1. Create the page (token-only API; visibility=shared but no allow-list yet).
PAGE=$(curl -sS -X POST https://outpost.click/api/pages \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "name=Team Wiki" \
-F "visibility=shared" \
-F "file=@./wiki.zip" | jq -r .id)
# 2. Add allowed_emails (and optionally passcodes) via the cookie/JWT API.
curl -X PUT "https://outpost.click/pages/$PAGE" \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "allowed_emails=alice@acme.com,bob@acme.com"
Adding a teammate later
# Replaces the entire list — include everyone who should keep access.
curl -X PUT https://outpost.click/pages/Mn7Pq2Rs \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "allowed_emails=alice@acme.com,bob@acme.com,carol@acme.com"
Removing the allow-list (keep passcode-only)
curl -X PUT https://outpost.click/pages/Mn7Pq2Rs \
-H "Authorization: Bearer op_OWNER_TOKEN" \
-F "allowed_emails="
Example: shared page with an allow-list (access flow)
Continuing from the page we just created above: Alice (allowed) views it directly; Carol (not allowed) and anonymous visitors hit the gate.
Alice@Acme.com matches alice@acme.com.
1a. Alice signs in (browser flow)
# Alice's browser hits /login, posts her credentials, and gets the
# `token` cookie set automatically.
curl -ic /tmp/alice.cj -X POST https://outpost.click/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@acme.com","password":"hunter2hunter2"}'
# Set-Cookie: token=eyJhbGciOiJIUzI1NiI...; HttpOnly; SameSite=Lax
2a. Alice opens the page — served directly, no gate
curl -b /tmp/alice.cj -i https://outpost.click/p/Mn7Pq2Rs
# 302 Found
# Location: /p/Mn7Pq2Rs/index.html
curl -b /tmp/alice.cj https://outpost.click/p/Mn7Pq2Rs/index.html
# <!doctype html>... (actual page content)
1b. Or Alice authenticates with a bearer token (API/script flow)
# JWT acquired from /auth/login, or an API token Alice created at /tokens.
ALICE_TOKEN="eyJhbGciOiJIUzI1NiI..." # or "op_..." API token
curl -H "Authorization: Bearer $ALICE_TOKEN" \
https://outpost.click/p/Mn7Pq2Rs/index.html
# <!doctype html>... (actual page content)
3. A non-allowed user (Carol) hits the gate
# Carol logs in with her own credentials, then tries to view the page.
curl -ic /tmp/carol.cj -X POST https://outpost.click/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"carol@example.org","password":"…"}'
curl -b /tmp/carol.cj -i https://outpost.click/p/Mn7Pq2Rs
# 200 OK, returns the access gate HTML
# (Carol can still get in if the owner sets a passcode and shares it.)
4. An anonymous visitor also hits the gate
curl -i https://outpost.click/p/Mn7Pq2Rs
# 200 OK, returns the access gate HTML
# Visibility is "shared", so anonymous viewers are never granted.
Cache-Control: no-store — Alice's view
won't end up in a CDN where a non-allowed visitor could pick it up.