diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/docs/checkout.md b/docs/checkout.md
new file mode 100644
index 0000000..33504cf
--- /dev/null
+++ b/docs/checkout.md
@@ -0,0 +1,58 @@
+# Hosted-plan checkout page
+
+A **static** landing + PayPal-subscribe page (`index.html`) for the hosted QuestSync
+plan. It has no backend: PayPal's JS SDK creates the subscription entirely in the
+browser, stamping the subscriber's identity into the subscription's `custom_id`.
+[WardStone](../../wardstone) (private) binds the entitlement when PayPal delivers the
+`BILLING.SUBSCRIPTION.ACTIVATED` webhook. **This page never talks to WardStone**, so
+the open engine reveals nothing about the private billing backend.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `index.html` | the page — self-host pitch + Habitica-ID field + PayPal subscribe button |
+| `custom-id.js` | the **one** `custom_id` encoder, imported by both the page and the test |
+| `custom-id.golden.json` | golden vectors **generated from WardStone's own codec** |
+| `custom-id.test.mjs` | asserts the JS encoder reproduces every golden vector |
+| `.nojekyll` | serve files verbatim (no Jekyll processing of `.js` / `.mjs` / `.json`) |
+
+## Configure (both values are public — safe to commit)
+
+Edit the `CONFIG` block at the bottom of `index.html`:
+
+- `PAYPAL_CLIENT_ID` — your PayPal app's client-id (sandbox first).
+- `PLAN_ID` — the subscription plan id (`P-…`).
+- `PRODUCT` — leave `"questsync"`. **It must equal the product this `PLAN_ID` maps to in
+ WardStone's `WARDSTONE_PAYPAL_PLAN_MAP`** (e.g. `WARDSTONE_PAYPAL_PLAN_MAP="P-xxxx:questsync"`).
+ If they disagree, WardStone's `_resolve_link` cross-check fails and the payment is
+ *parked*, not bound — the subscriber pays and gets no access.
+
+## The codec contract (why the golden file exists)
+
+The subscriber's Habitica User ID is stamped into `custom_id` as
+`1|b64url(product)|b64url(subject)` (URL-safe base64, padding stripped). The browser's
+encoder and WardStone's Python decoder must agree exactly, or people pay and silently get
+no access. `custom-id.golden.json` is generated from WardStone's real
+`encode_custom_id`, so the JS test passing is proof the two languages agree.
+
+```bash
+node docs/custom-id.test.mjs # JS encoder matches WardStone's vectors
+```
+
+If the codec ever changes in WardStone, regenerate the golden file and both sides' tests
+flag the drift.
+
+## Publish (GitHub Pages, no Actions workflow needed)
+
+Repo **Settings → Pages → Build and deployment**: Source = *Deploy from a branch*,
+Branch = `main`, Folder = `/docs`. The repo is public, so free-plan Pages serves it. The
+site is public (as intended — it's marketing); it exposes only the two public PayPal ids.
+
+## Going live (gated — see session notes)
+
+Sandbox is for clicking through the flow. Before real money: (1) create a **live** PayPal
+plan and swap `ENV`/ids; (2) resolve the Habitica **ToS GO/NO-GO** — the API is silent on
+paid token-brokering proxies, and their API guidelines' "reviewable in a public repo" rule
+is satisfied by QuestSync (public) but WardStone is deliberately private; (3) use this page
+itself as a **willingness-to-pay** smoke test first.
diff --git a/docs/custom-id.golden.json b/docs/custom-id.golden.json
new file mode 100644
index 0000000..e595164
--- /dev/null
+++ b/docs/custom-id.golden.json
@@ -0,0 +1,37 @@
+[
+ {
+ "product": "questsync",
+ "subject": "29bfa638-cb88-40e8-a0d0-48f455e7b2bd",
+ "custom_id": "1|cXVlc3RzeW5j|MjliZmE2MzgtY2I4OC00MGU4LWEwZDAtNDhmNDU1ZTdiMmJk"
+ },
+ {
+ "product": "questsync",
+ "subject": "a",
+ "custom_id": "1|cXVlc3RzeW5j|YQ"
+ },
+ {
+ "product": "questsync",
+ "subject": "ab",
+ "custom_id": "1|cXVlc3RzeW5j|YWI"
+ },
+ {
+ "product": "questsync",
+ "subject": "abc",
+ "custom_id": "1|cXVlc3RzeW5j|YWJj"
+ },
+ {
+ "product": "questsync",
+ "subject": "a|b|c",
+ "custom_id": "1|cXVlc3RzeW5j|YXxifGM"
+ },
+ {
+ "product": "questsync",
+ "subject": "café-Ω-🦊",
+ "custom_id": "1|cXVlc3RzeW5j|Y2Fmw6ktzqkt8J-mig"
+ },
+ {
+ "product": "acme",
+ "subject": "user-42",
+ "custom_id": "1|YWNtZQ|dXNlci00Mg"
+ }
+]
\ No newline at end of file
diff --git a/docs/custom-id.js b/docs/custom-id.js
new file mode 100644
index 0000000..eadbf5d
--- /dev/null
+++ b/docs/custom-id.js
@@ -0,0 +1,33 @@
+// custom-id.js — the ONE encoder, shared by index.html and custom-id.test.mjs.
+//
+// It must reproduce WardStone's encode_custom_id (private repo:
+// src/wardstone/processors/paypal.py) byte-for-byte:
+//
+// custom_id = "1|" + b64url(utf8(product)) + "|" + b64url(utf8(subject))
+//
+// where b64url is URL-safe base64 (- and _, never + or /) with "=" padding stripped.
+//
+// This codec is NOT a secret: access is gated by the Habitica API token at CalDAV
+// login, never by hiding this format. So it lives in the open engine repo, and
+// custom-id.golden.json (generated FROM WardStone's own function) pins the two
+// implementations together — see custom-id.test.mjs.
+
+export const CUSTOM_ID_VERSION = "1";
+
+// base64url of a string's UTF-8 bytes, padding stripped.
+// Matches Python: base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
+export function b64url(raw) {
+ const bytes = new TextEncoder().encode(raw); // UTF-8 — same bytes as Python str.encode()
+ let bin = "";
+ for (const b of bytes) bin += String.fromCharCode(b); // Uint8Array -> binary string for btoa
+ return btoa(bin) // standard base64 (uses + / and = padding)
+ .replace(/\+/g, "-") // -> URL-safe alphabet
+ .replace(/\//g, "_")
+ .replace(/=+$/, ""); // strip padding, like Python's rstrip("=")
+}
+
+// Encode (product, subject) into a PayPal subscription custom_id.
+// subject is opaque and may contain the "|" delimiter, so both halves are base64url'd.
+export function encodeCustomId(product, subject) {
+ return `${CUSTOM_ID_VERSION}|${b64url(product)}|${b64url(subject)}`;
+}
diff --git a/docs/custom-id.test.mjs b/docs/custom-id.test.mjs
new file mode 100644
index 0000000..6eb118a
--- /dev/null
+++ b/docs/custom-id.test.mjs
@@ -0,0 +1,27 @@
+// custom-id.test.mjs — proves the browser encoder reproduces WardStone's codec.
+//
+// node docs/custom-id.test.mjs (no dependencies, no network)
+//
+// custom-id.golden.json is generated FROM WardStone's own encode_custom_id, so if
+// every vector here matches, the JavaScript (this repo) and the Python (WardStone)
+// implementations agree — a user who checks out will bind the exact subject the gate
+// later looks up. If the codec ever changes, regenerate the golden file from WardStone
+// and both sides' tests will flag the drift.
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { encodeCustomId } from "./custom-id.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const vectors = JSON.parse(readFileSync(join(here, "custom-id.golden.json"), "utf8"));
+
+let failed = 0;
+for (const { product, subject, custom_id } of vectors) {
+ const got = encodeCustomId(product, subject);
+ const ok = got === custom_id;
+ if (!ok) failed++;
+ const label = `(${JSON.stringify(product)}, ${JSON.stringify(subject)})`;
+ console.log(`${ok ? "ok " : "FAIL"} ${label} -> ${got}${ok ? "" : ` expected ${custom_id}`}`);
+}
+console.log(`\n${vectors.length - failed}/${vectors.length} vectors matched WardStone's codec`);
+process.exit(failed ? 1 : 0);
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..57848bc
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,492 @@
+
+
+
+
+
+
+QuestSync — Habitica in any calendar app
+
+
+
+
+
+
+Skip to content
+
+
+
QuestSync surfaces your Habitica to-dos and dailies in any CalDAV app — Tasks.org, Apple Reminders, Thunderbird, Nextcloud Tasks. Read them, check them off, done. Habitica stays the single source of truth; QuestSync is stateless and stores nothing.
In your calendar or tasks app, point a new CalDAV account at your QuestSync instance. Use your Habitica User ID as the username and your API token as the password.
+
+
+
+
+
+
See your to-dos & dailies
+
Your Habitica tasks appear as native items in Tasks.org, Apple Reminders, Thunderbird, Nextcloud Tasks — anywhere that speaks CalDAV.
+
+
+
+
+
+
Tick anywhere — Habitica stays canon
+
Check a task off on your phone and it lands back in Habitica. QuestSync keeps no copy; Habitica remains the single source of truth for your to-dos and dailies.
+
+
+
+
+
+
+
+
+
+
+
+
Two paths, one feed
+
Choose your path
+
Both deliver the exact same CalDAV feed. The only difference is who runs the server.
+
+
+
+
+
+
+
+
Open source · Free
+
Self-host it
+
+
Own the whole thing. Clone the repo and bring it up with one Docker command — it is open source and free forever.
+
git clone https://github.com/SDR3078/questsync
+cd questsync
+docker compose up -d --build
+
Then add a CalDAV account using your Habitica User ID as the username and your API token as the password.
Skip the setup. We run an always-on instance for you — just log in with your own Habitica credentials and start ticking. Enter your Habitica User ID to subscribe:
+
+
+
+
Find it at habitica.com → Settings → API (the "User ID"). Double-check it — a typo means you'd pay but bind the wrong account, and you'd have to contact us to fix it.
+
+
+
This page isn't configured yet: set PAYPAL_CLIENT_ID and PLAN_ID in index.html.
+
+
+
+
+
+
+
+
+
Your API token never touches us. On both paths it travels straight from your app to Habitica — QuestSync is stateless and stores no data, tokens or tasks. Nothing to breach, nothing to leak.