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 + + + + + + + + +
+
+ + + QuestSync + + +
+
+ +
+
+
+

A companion for your Habitica tasks

+
+ +
+

Tick your Habitica tasks from anywhere.

+

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.

+ +
+
+
+
+ + + +
+
+
+

Getting set up

+

Three ticks to sync

+
+
+
+ +
+

Add a CalDAV account

+

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.

+ Read the docs +
+
+ +
+ +
+
+

Hosted · €1 / month

+

Let us host it

+
+

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.

+ +
+ +
+
+
+
+
+ +
+ +

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.

+
+
+
+
+ + + + +