Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added docs/.nojekyll
Empty file.
58 changes: 58 additions & 0 deletions docs/checkout.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions docs/custom-id.golden.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
33 changes: 33 additions & 0 deletions docs/custom-id.js
Original file line number Diff line number Diff line change
@@ -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)}`;
}
27 changes: 27 additions & 0 deletions docs/custom-id.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading