# Embedding Undercurrent payments — `uc-pay.js`

Payment components for any website: **donate**, **subscribe**, **credits top-up**,
**one-off purchase** and **pay links**. Each one is a plain HTML form that POSTs to the
Undercurrent pay API, which answers with a `303` redirect to a hosted Stripe Checkout
page. There is no Stripe.js, no publishable key, no iframe, no cookie and no client-side
card handling — the drop-in is a *link*, not a script. A static site can take money.

| | |
|---|---|
| Version | **0.1.0** (`VERSION`) |
| Script (pinned, immutable) | `https://embed-qa.undercurrent.ai/0.1.0/uc-pay.js` |
| Script (rolling v1) | `https://embed-qa.undercurrent.ai/v1/uc-pay.js` |
| SRI for 0.1.0 | `sha384-nKoc6hPi47/CnCBuIZhr0jygCKMqdhAvKZ7Czv3aI5zGNEqDz6NkAx1p1bHmrBeu` |
| Size | 12,156 bytes on disk (≈ 5.2 KB gzipped), zero dependencies, no build step |
| Pay API (QA) | `https://pay-qa.undercurrent.ai` — Stripe **test mode** |
| Pay API (prod) | `https://pay.undercurrent.ai` — **not deployed yet** |
| Playground | `https://embed-qa.undercurrent.ai/playground.html` |
| API contract | [PAY_V1.md](PAY_V1.md) |

> **QA today.** Stripe is not connected yet (`stripe login` has not happened), so every
> checkout on QA answers with a clear **503 "Payments aren't switched on yet — no charge
> was made"** page instead of Checkout. That is the designed degraded state. The catalog
> (`GET /v1/catalog`) works now; the buttons start working the moment Stripe test mode is
> connected, with no change to any page.

---

## 60-second quickstart

Paste this anywhere in a page:

```html
<script src="https://embed-qa.undercurrent.ai/0.1.0/uc-pay.js"
        integrity="sha384-nKoc6hPi47/CnCBuIZhr0jygCKMqdhAvKZ7Czv3aI5zGNEqDz6NkAx1p1bHmrBeu"
        crossorigin="anonymous" defer></script>

<uc-donate data-brand="uc" data-env="qa"></uc-donate>
```

That renders an amount picker and a **Donate** button. Pressing it POSTs to
`/v1/checkout` and the browser follows the server's redirect to Checkout.

For pages that must work **with JavaScript off** (or under a CSP that blocks the script),
paste the snippet the [playground](https://embed-qa.undercurrent.ai/playground.html)
generates instead: it is the same element *wrapping its own plain `<form>`*. With the
script the element enhances that form; without it the form still posts.

```html
<uc-credits data-brand="uc" data-env="qa" data-org="demo-org">
  <form class="ucp" method="post" action="https://pay-qa.undercurrent.ai/v1/checkout" accept-charset="utf-8">
    <input type="hidden" name="kind" value="credits">
    <input type="hidden" name="brand" value="uc">
    <input type="hidden" name="src" value="embed">
    <input type="hidden" name="org" value="demo-org">
    <input type="hidden" name="sku" value="uc_credits_1usd">
    <fieldset class="ucp-chips"><legend>Amount</legend>
      <label class="ucp-chip"><input type="radio" name="quantity" value="10"><span>$10</span></label>
      <label class="ucp-chip"><input type="radio" name="quantity" value="25" checked><span>$25</span></label>
      <label class="ucp-chip"><input type="radio" name="quantity" value="50"><span>$50</span></label></fieldset>
    <button type="submit" class="ucp-btn">Add credits</button>
    <div hidden><label>Company<input name="hp_company" tabindex="-1" autocomplete="off"></label></div>
    <p class="ucp-status" aria-live="polite"></p>
    <p class="ucp-note">Secure checkout on Stripe. Card details never touch this page.</p></form>
</uc-credits>
```

Which script URL:

- **`/0.1.0/uc-pay.js` + `integrity`** — for production pages. The file at a version path
  never changes (the publisher refuses to overwrite it), so the SRI hash stays valid.
- **`/v1/uc-pay.js`** — always the latest 1.x, 5-minute cache. Do **not** put an
  `integrity` attribute on it: the hash changes with every release.
- `https://embed-qa.undercurrent.ai/v1/manifest.json` gives `{ version, url, integrity }`
  for automation.

Always set `data-env`. In 0.x an element without it points at QA; that default will
flip to `prod` once `pay.undercurrent.ai` exists.

---

## The elements

Every attribute is a `data-*` attribute; its name maps 1:1 onto the form field the
server reads (`data-success-url` → `success_url`). All values are HTML-escaped into the
generated markup.

### Shared attributes

| Attribute | Meaning |
|---|---|
| `data-env` | `qa` (default in 0.x) or `prod` — picks the pay host |
| `data-api` | Override the pay host, e.g. an execute-api URL or `http://localhost:8787`. Only `https://host[:port]` or `http://localhost` / `127.0.0.1` are accepted; anything else is ignored |
| `data-brand` | Brand id from `/v1/catalog` (default `uc`) |
| `data-org` | Org slug. Set it and the org is a hidden field; omit it and credits/plans ask the payer |
| `data-email` | Prefill the Checkout email |
| `data-label` | Button text |
| `data-success-url`, `data-cancel-url` | Absolute URLs to return to. **The server only accepts origins on the brand's allow-list** (no open redirect); omit them and the payer lands on the pay host's own thank-you / canceled page |
| `data-src` | Surface tag recorded on the payment: `embed` (default), `site`, `signal_bot`, `slack`, `invoice`, `storefront` |
| `data-theme` | `dark` for the dark palette; light is the default |

### `<uc-donate>` — one-off or monthly giving

| Attribute | Default | |
|---|---|---|
| `data-amounts` | `5,25,100` | Preset chips, in dollars |
| `data-default` | the 2nd preset | Pre-selected amount |
| `data-recurring` | *(checkbox)* | `none` hides monthly; `only` makes it monthly-only. Monthly is whole dollars |
| `data-fields` | *(none)* | Any of `name,message,public` — donor name, a message, "show my name on the supporters list" |

The amount text field is the real input (`amount`, dollars as text, e.g. `25.50`); the
preset chips only fill it in and are hidden with JS off, so a chip can never disagree
with what is charged. With JS on, `$1,250.5` is accepted and normalised to `1250.50`;
below $1 is refused before posting. **Donations are not tax-deductible** — never add copy
that implies they are.

### `<uc-subscribe>` — seat plans

| Attribute | |
|---|---|
| `data-skus` | Comma list of subscription SKUs from `/v1/catalog`, e.g. `uc_community_monthly,uc_pro_monthly` |
| `data-quantity` | Initial seat count (1–500) |

With JS the plan `<select>` is replaced by a priced tier table built from
`GET /v1/catalog?brand=…`; each tier's **Choose** button submits its own `sku`. If the
catalog cannot be fetched the plain select stays and still works. Plans need an org.

### `<uc-credits>` — prepaid top-up

| Attribute | Default | |
|---|---|---|
| `data-sku` | `uc_credits_1usd` | A `credits` SKU ($1 per unit) |
| `data-amounts`, `data-default` | `10,25,50` / 2nd | Chips **are** the `quantity` (whole dollars) and work with JS off |

Credits land in the org's balance only after the payment webhook confirms the money.

### `<uc-pay-button>` — buy one SKU

| Attribute | |
|---|---|
| `data-sku` | Required |
| `data-kind` | `one_time` (default) or `credits` / `subscription` / `donation` when the SKU is one of those |
| `data-quantity` | Default `1` |
| `data-org-required` | Ask the payer for an org |

With JS it looks the SKU up in the catalog: shows the price on the button, or disables it
and says why (`amount undecided`, `brand not in account`, `fulfilment pending`).

### `<uc-pay-link>` — invoice / pay code

| Attribute | |
|---|---|
| `data-code` | A pay code minted with `POST /v1/pay` (e.g. `K7MXQ-3PAHD`) |

Renders `<a href="{pay host}/v1/pay/{CODE}">`. Nothing is posted from the page; the pay
host 303s to a Checkout session it mints (and reuses while it is fresh). Codes outside the
alphabet `23456789ABCDEFGHJKMNPQRSTVWXYZ` never become a link.

---

## Theming

Everything is driven by CSS custom properties. Set them on the element **or any
ancestor** (`:root` works) — the defaults are `var()` fallbacks, not declarations, so
inherited values win.

| Property | Light default | Dark (`data-theme="dark"`) |
|---|---|---|
| `--uc-pay-accent` | `#C8005A` (brand magenta) | `#ff0062` |
| `--uc-pay-accent-fg` | `#fff` | `#fff` |
| `--uc-pay-alt` (focus ring) | `#0EA87E` (brand aqua) | `#20f0b9` |
| `--uc-pay-bg` | `#fff` | `#141418` |
| `--uc-pay-fg` | `#16161a` | `#e0e0e6` |
| `--uc-pay-muted` | `#5b5b66` | `#9a9aa6` |
| `--uc-pay-border` | `#d6d6de` | `#2a2a34` |
| `--uc-pay-radius` | `10px` | |
| `--uc-pay-font` | `Inter, system-ui, sans-serif` | |

```css
uc-donate, uc-credits { --uc-pay-accent: #2f5d46; --uc-pay-radius: 4px; }
```

uc-pay's own rules live in `@layer uc-pay`, so **any** rule in your stylesheet beats
them without `!important`. The flip side: a global rule such as `button { … }` on your
site also applies to the pay button. Class hooks: `.ucp` (form), `.ucp-btn`,
`.ucp-chips`, `.ucp-chip`, `.ucp-tiers`, `.ucp-tier`, `.ucp-price`, `.ucp-status`,
`.ucp-note`. The elements render in the light DOM (no shadow root) so the form is real.

## Accessibility

Real `<label>`s on every control; preset amounts are radio inputs in a `<fieldset>` with a
`<legend>` (arrow keys move between them); visible `:focus-visible` rings in
`--uc-pay-alt`; the status line is `aria-live="polite"` ("Redirecting to secure
checkout…", validation messages). Returning with the Back button re-enables the form
(bfcache `pageshow`).

---

## Content Security Policy

What a page needs depends on whether it loads the script:

| Setup | Directives |
|---|---|
| JS-off form only (pasted snippet, no `<script>`) | `form-action https://pay-qa.undercurrent.ai https://checkout.stripe.com` — only if your page sets `form-action` at all |
| With `uc-pay.js` | the above **+** `script-src https://embed-qa.undercurrent.ai` **+** `connect-src https://pay-qa.undercurrent.ai` (the catalog fetch) |

- **Why `checkout.stripe.com` in `form-action`:** Chrome also checks the *redirect target*
  of a form submission against `form-action`, so the 303 to Checkout is blocked unless it
  is listed. Firefox and Safari currently do not, but list it anyway.
- **No `style-src` change is needed.** Styles are installed through a constructable
  stylesheet (`document.adoptedStyleSheets`), which is not a CSP `style-src` sink — tested
  under `style-src 'self'` with no `'unsafe-inline'`. Only browsers without constructable
  stylesheets fall back to a `<style>` element.
- **Trusted Types:** if your page enforces `require-trusted-types-for 'script'`, author the
  full snippet (element + form) so no markup is injected; the subscribe tier table is the
  one enhancement that writes HTML and will be skipped.
- No `frame-src`, no `img-src`, no Stripe domains in `script-src`: nothing from Stripe ever
  loads in your page.

For `prod`, replace `pay-qa` with `pay` and `embed-qa` with `embed`.

## Security model

- **No payment data touches your page.** The page posts kind, brand, SKU, quantity or
  amount, and optional org / email / donor fields. Card entry happens only on Stripe's
  hosted Checkout.
- **Nothing is trusted from the client.** The server validates brand, kind and SKU against
  its catalog, recomputes the price from the catalog (never from the page), and caps
  quantities. Editing the form in devtools can only ask for something the catalog allows.
- **No open redirect.** `success_url` / `cancel_url` are honoured only when their origin is
  on the brand's allow-list; otherwise the request is refused (`return_url_not_allowed`)
  and nothing is created.
- **No grant from the browser.** Arriving on the success URL grants nothing. Credits, seats
  and entitlements change only when the signed Stripe webhook confirms the money.
- **No idempotency key in a snippet.** A static `idempotency_key` would be shared by every
  visitor to the page, so the kit never emits one; a double submit is prevented by
  disabling the button until the redirect (and re-enabling it on Back).
- **No cookies, no storage, no tracking.** The catalog fetch is `credentials: 'omit'`; the
  script makes no other request. The honeypot field (`hp_company`) is hidden and empty for
  humans; a filled one gets a harmless fake success.
- **Supply chain:** pin `/0.1.0/uc-pay.js` with the SRI hash above. The publisher refuses
  to overwrite a published version, and serves `Access-Control-Allow-Origin: *` so
  `crossorigin="anonymous"` + `integrity` works. (Hosting note: CloudFront's *managed* CORS
  response-header policies omit that header when the request carries a non-safelisted
  header — and Chrome sends `priority: u=1` on every HTTP/2 fetch — so a pinned script
  loaded fine in curl and failed in Chrome. The distribution uses a custom policy with
  `AllowHeaders: *`; verified in headless Chromium under a strict CSP.)

## Files

| Path | |
|---|---|
| `uc-pay.js` | The embed. The only file a site loads |
| `snippet.js` | Authoring helper for the playground and tests (not loaded by sites) |
| `playground.html` | Flow/brand/SKU/theme pickers, live preview, copy-paste snippet, SRI computed in-browser |
| `examples/` | Generated by `node scripts/gen-examples.mjs`; a test fails if stale |
| `scripts/publish.sh` | S3 + CloudFront + ACM; dry-run by default, `--apply` to act |
| `test/` | `node --test test/` |
