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

  1. Look up the page; 404 if missing.
  2. Determine access (see below). If the visitor needs to authenticate or supply a passcode, return the access gate template.
  3. Look for file_path = path exactly.
  4. If path doesn't end in .html, look for {path}/index.html.
  5. Fall back to the page's default_file — this is what makes single-page-app routes work (/p/Ab12Cd34/dashboard/users serves the SPA shell).
  6. 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:

ConditionResult
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

FieldNotes
passcodePlain 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
Cookie format. 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:

  1. jwt_secret is hashed with SHA-256 to derive a 32-byte key.
  2. The key is base64url-encoded for Fernet.
  3. 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.

Endpoint pick. Use 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.

Email matching is case-insensitive. Outpost lowercases both the stored allow-list and the caller's email before comparison, so 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.
Why no cache. Because the page is non-public, the response carries Cache-Control: no-store — Alice's view won't end up in a CDN where a non-allowed visitor could pick it up.