Public: anyone can read this; the crew can change it.
Stripe is connected by pasting one key: checkout and the customer portal run over fetch, with nothing to set up in Stripe's dashboard #11163
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11136-stripe-is-connected-by-pasting-one-key-c → main 6 commits fork 1 thread · 1 unresolved
16 files
changed +674
−17
docs/CLOUD.md +2 −2
| @@ -18,8 +18,8 @@ Beside `forge.json`, at the root of the repository: | ||
| 18 | 18 | { "uses": [{ "vendor": "s3", "use": "backup", "why": "off-machine copy of the encrypted snapshot" }] } |
| 19 | 19 | |
| 20 | 20 | |
| 21 | - `vendor`: `cloudflare`, `fly`, `hetzner`, `s3`, `github`, `vercel`, `digitalocean` or `azure`. | |
| 22 | - `use`: `compute`, `storage`, `backup`, `dns` or `cdn`. | |
| 21 | - `vendor`: `cloudflare`, `fly`, `hetzner`, `s3`, `github`, `vercel`, `digitalocean`, `azure` or `stripe`. | |
| 22 | - `use`: `compute`, `storage`, `backup`, `dns`, `cdn` or `payments`. The forge declares `stripe`/`payments` for itself (`FORGE_STRIPE`): `api.stripe.com`, for connecting (its webhook endpoint), and nothing else. | |
| 23 | 23 | - `why`: one line, up to 300 characters. |
| 24 | 24 | |
| 25 | 25 | No file means no uses. A malformed file is refused with one sentence |
docs/PROFIT.md +8
| @@ -125,3 +125,11 @@ plans by hand (`PUT /api/v1/forges/{name}/plan`, the Billing page, the | ||
| 125 | 125 | terminal, the MCP `forges` tool). A forge with no row is active on the plan |
| 126 | 126 | its size names. Nothing reads a plan to limit a forge yet. How to use it: |
| 127 | 127 | `docs/features/billing.md`. |
| 128 | ||
| 129 | ### Connect Stripe | |
| 130 | ||
| 131 | 1. In Stripe, make a secret or restricted key (a `sk_test_`/`rk_test_` key is a sandbox; `sk_live_`/`rk_live_` is live). A restricted key needs write on Webhook Endpoints. | |
| 132 | 2. Settings → Billing → Provider: paste it and press Connect. The forge seals it with its secrets and never shows it again; the page says **sandbox** or **live**. In the terminal: Billing, `c`, paste the key (shown as dots). | |
| 133 | 3. The forge registers its own webhook at its configured public address (`FORGE_URL`) `/hooks/stripe` for five events and seals the signing secret. A key that may not do that is told so; paste a `whsec_` signing secret instead. | |
| 134 | 4. Plain `fetch` calls, nothing to create in Stripe's dashboard (`src/billing/stripe.ts`, API version pinned in `STRIPE_VERSION`). Reconnecting forgets the old key's secret and removes its endpoint. | |
| 135 | 5. Disconnect (page, or `x` in the terminal) removes the endpoint and the sealed key, and says so when Stripe could not remove the endpoint. `api.stripe.com` is the one declared vendor (`stripe`/`payments`, docs/CLOUD.md). |
docs/features/billing.md +2 −1
| @@ -5,7 +5,8 @@ Billing records the plan each forge is on (`src/billing/`): a store of entitleme | ||
| 5 | 5 | - **Plans:** small, medium or large, the sizes. A forge never granted a plan reads as `active` on its size, source `granted`. |
| 6 | 6 | - **Grant by hand (admin, a person):** the Billing page, the terminal screen, `PUT /api/v1/forges/{name}/plan` with `{ "plan": "large" }` (`GET` reads it), or the MCP `forges` tool with `{ forge, plan }` (planned first; `dryRun: false` grants). |
| 7 | 7 | - **Audit:** every grant is a row in `entitlement_change` and a `billing.plan` event. |
| 8 | - **Terminal:** the palette's *Billing* (admins), `src/tui/screens/billing.ts`: `j`/`k` pick a forge, `s`, `m` or `l` grants that plan after a yes/no. | |
| 8 | - **Terminal:** the palette's *Billing* (admins), `src/tui/screens/billing.ts`: `j`/`k` pick a forge, `s`, `m` or `l` grants that plan after a yes/no; `c` connects Stripe (key shown as dots), `w` saves a signing secret, `x` disconnects after a yes/no. | |
| 9 | - **Stripe (#11136):** Settings → Billing → Provider takes one key (`src/billing/stripe.ts`, `connection.ts`); how to connect it is in `docs/PROFIT.md`. Not yet: the `/hooks/stripe` receiver, and checkout, portal and reconcile, which come with the pull that calls them. | |
| 9 | 10 | |
| 10 | 11 | ## Gift codes |
| 11 | 12 | A code gives a plan for free, with or without a payment provider (`src/billing/codes.ts`, `src/actions/codes.ts`). No provider is asked: a redeemed code writes the entitlement itself, source `code:<id>`. |
scripts/unreached-allow.json −1
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | [ |
| 2 | "src/apps/cloud.ts", | |
| 3 | 2 | "src/crew/governor_log.ts", |
| 4 | 3 | "src/flow/alarm_unproposed.ts", |
| 5 | 4 | "src/flow/approved_wait.ts", |
src/actions/billing.ts +95
| @@ -11,11 +11,27 @@ import { changesOf, entitlementOf, putEntitlement, type EntitlementChange } from | ||
| 11 | 11 | import type { Entitlement } from "../billing/types.ts"; |
| 12 | 12 | import type { User } from "../core/users.ts"; |
| 13 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 14 | import { connectionOf, forget, openKey, sealKey, sealWebhook, type Connection } from "../billing/connection.ts"; | |
| 15 | import { isStripeKey, stripeClient, type StripeError } from "../billing/stripe.ts"; | |
| 16 | import { publicOrigin } from "../core/public_origin.ts"; | |
| 17 | import { holdValues } from "../kernel/log.ts"; | |
| 14 | 18 | import { listChildrenNow } from "./spawn.ts"; |
| 15 | 19 | import { parseSize } from "../spawn/sizes.ts"; |
| 16 | 20 | |
| 17 | 21 | export type BillingError = "not_found" | "no_such_forge" | "bad_plan"; |
| 18 | 22 | |
| 23 | export type StripeWords = "no_origin" | "left_at_stripe" | "bad_key" | "bad_secret" | "not_connected" | "no_permission" | "refused" | "unreachable"; | |
| 24 | export const STRIPE_WORDS: Record<StripeWords, string> = { | |
| 25 | no_origin: "This forge does not know its public address; set FORGE_URL so it can register its webhook, or paste a signing secret with the key", | |
| 26 | left_at_stripe: "Disconnected here, but Stripe's webhook endpoint could not be removed; delete it on Stripe's webhook page", | |
| 27 | bad_key: "That is not a Stripe secret or restricted key (it starts sk_ or rk_)", | |
| 28 | bad_secret: "A signing secret starts whsec_", | |
| 29 | not_connected: "Stripe is not connected", | |
| 30 | no_permission: "Connected, but this key may not create webhook endpoints. Paste a signing secret from Stripe's webhook page below", | |
| 31 | refused: "Stripe refused the key", | |
| 32 | unreachable: "Stripe could not be reached", | |
| 33 | }; | |
| 34 | ||
| 19 | 35 | export const BILLING_WORDS: Record<Exclude<BillingError, "not_found">, string> = { |
| 20 | 36 | no_such_forge: "No forge by that name was sent from here", |
| 21 | 37 | bad_plan: "A plan is small, medium or large", |
| @@ -23,6 +39,8 @@ export const BILLING_WORDS: Record<Exclude<BillingError, "not_found">, string> = | ||
| 23 | 39 | |
| 24 | 40 | export interface BillingView { |
| 25 | 41 | readonly forges: readonly (Entitlement & { size: string })[]; |
| 42 | /** The Stripe connection (mode, whether events are signed), never the key. */ | |
| 43 | readonly stripe: Connection | undefined; | |
| 26 | 44 | } |
| 27 | 45 | |
| 28 | 46 | const admin = (a: User | undefined): Result<User, "not_found"> => (a?.isAdmin === true && a.kind !== "agent" ? ok(a) : err("not_found")); |
| @@ -32,6 +50,7 @@ export function readBilling(app: App, actor: User | undefined): Result<BillingVi | ||
| 32 | 50 | if (!who.ok) return who; |
| 33 | 51 | return ok({ |
| 34 | 52 | forges: listChildrenNow(app).map((c) => ({ ...entitlementOf(app.db, c.name, c.size), size: c.size })), |
| 53 | stripe: connectionOf(app.db), | |
| 35 | 54 | }); |
| 36 | 55 | } |
| 37 | 56 | |
| @@ -67,3 +86,79 @@ export function grantPlan(app: App, actor: User | undefined, forge: unknown, pla | ||
| 67 | 86 | app.bus.append({ kind: "billing.plan", actorId: who.id, payload: { forge: to.forge, from: from.plan, to: to.plan, source: "granted" } }); |
| 68 | 87 | return ok({ entitlement, changes: changesOf(app.db, to.forge) }); |
| 69 | 88 | } |
| 89 | ||
| 90 | const SIGNING = /^whsec_[A-Za-z0-9]{8,}$/; | |
| 91 | const failure = (e: StripeError): StripeWords => (e.kind === "permission" ? "no_permission" : e.kind === "unreachable" ? "unreachable" : "refused"); | |
| 92 | ||
| 93 | /** | |
| 94 | * Connect Stripe with one pasted key: the forge registers its own webhook | |
| 95 | * endpoint (`<public origin>/hooks/stripe`, from the forge's configuration, | |
| 96 | * never from a request's Host) and seals the signing secret it gets. A key | |
| 97 | * Stripe rejects is not kept, and the connection it would have replaced | |
| 98 | * stays. A key that may not create endpoints is kept, and the answer | |
| 99 | * `no_permission` asks for a signing secret (`saveSigningSecret`). | |
| 100 | * `signingSecret` given with the key skips the call. Whatever the previous | |
| 101 | * connection sealed is forgotten first, and its endpoint removed at Stripe, | |
| 102 | * so no secret or endpoint id outlives the key it belonged to. | |
| 103 | */ | |
| 104 | export async function connectStripe( | |
| 105 | app: App, | |
| 106 | actor: User | undefined, | |
| 107 | key: unknown, | |
| 108 | signingSecret: unknown, | |
| 109 | fetchFn: typeof fetch = fetch, | |
| 110 | ): Promise<Result<Connection, BillingError | StripeWords>> { | |
| 111 | const who = admin(actor); | |
| 112 | if (!who.ok) return who; | |
| 113 | const k = typeof key === "string" ? key.trim() : ""; | |
| 114 | const sec = typeof signingSecret === "string" ? signingSecret.trim() : ""; | |
| 115 | if (!isStripeKey(k)) return err("bad_key"); | |
| 116 | if (sec !== "" && !SIGNING.test(sec)) return err("bad_secret"); | |
| 117 | if (sec === "" && app.config.url === undefined) return err("no_origin"); | |
| 118 | holdValues("stripe", [k, ...(sec === "" ? [] : [sec])]); | |
| 119 | let denied: StripeWords | undefined; | |
| 120 | let hook: { id: string; secret: string } | undefined; | |
| 121 | if (sec === "") { | |
| 122 | const made = await stripeClient(k, fetchFn).addWebhook(`${publicOrigin(app.config.url, "")}/hooks/stripe`); | |
| 123 | if (made.ok) hook = made.value; | |
| 124 | else if (made.error.kind === "permission") denied = "no_permission"; | |
| 125 | else return err(failure(made.error)); | |
| 126 | } | |
| 127 | const old = openKey(app.db, app.config.dataDir, app.bus, who.value.id); | |
| 128 | if (old && old.endpointId !== "" && old.endpointId !== hook?.id) await stripeClient(old.key, fetchFn).removeWebhook(old.endpointId); | |
| 129 | forget(app.db); | |
| 130 | if (!sealKey(app.db, app.config.dataDir, k)) return err("bad_key"); | |
| 131 | if (hook) sealWebhook(app.db, app.config.dataDir, hook.secret, hook.id); | |
| 132 | else if (sec !== "") sealWebhook(app.db, app.config.dataDir, sec); | |
| 133 | const c = connectionOf(app.db) as Connection; | |
| 134 | app.bus.append({ kind: "billing.provider", actorId: who.value.id, payload: { provider: "stripe", mode: c.mode, signed: c.signed } }); | |
| 135 | return denied ? err(denied) : ok(c); | |
| 136 | } | |
| 137 | ||
| 138 | /** A signing secret pasted by hand, for a key that may not register one. */ | |
| 139 | export function saveSigningSecret(app: App, actor: User | undefined, secret: unknown): Result<Connection, BillingError | StripeWords> { | |
| 140 | const who = admin(actor); | |
| 141 | if (!who.ok) return who; | |
| 142 | if (!connectionOf(app.db)) return err("not_connected"); | |
| 143 | const sec = typeof secret === "string" ? secret.trim() : ""; | |
| 144 | if (!SIGNING.test(sec)) return err("bad_secret"); | |
| 145 | holdValues("stripe", [sec]); | |
| 146 | sealWebhook(app.db, app.config.dataDir, sec); | |
| 147 | app.bus.append({ kind: "billing.provider", actorId: who.value.id, payload: { provider: "stripe", signed: true } }); | |
| 148 | return ok(connectionOf(app.db) as Connection); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Remove the webhook endpoint and the sealed key and secret; the key and secret go even when Stripe cannot be reached, | |
| 153 | * and `left_at_stripe` says the endpoint is still there. | |
| 154 | */ | |
| 155 | export async function disconnectStripe(app: App, actor: User | undefined, fetchFn: typeof fetch = fetch): Promise<Result<true, BillingError | StripeWords>> { | |
| 156 | const who = admin(actor); | |
| 157 | if (!who.ok) return who; | |
| 158 | const open = openKey(app.db, app.config.dataDir, app.bus, who.value.id); | |
| 159 | if (!open) return err("not_connected"); | |
| 160 | const removed = open.endpointId === "" ? undefined : await stripeClient(open.key, fetchFn).removeWebhook(open.endpointId); | |
| 161 | forget(app.db); | |
| 162 | app.bus.append({ kind: "billing.provider", actorId: who.value.id, payload: { provider: "none", ...(removed && !removed.ok ? { endpointLeft: true } : {}) } }); | |
| 163 | return removed && !removed.ok ? err("left_at_stripe") : ok(true); | |
| 164 | } |
src/apps/manifest.ts +2 −2
| @@ -496,9 +496,9 @@ export function parseCheck( | ||
| 496 | 496 | } |
| 497 | 497 | |
| 498 | 498 | /** The vendors a repository may name in `cloud.json`; a closed set. */ |
| 499 | export const CLOUD_VENDORS = ["cloudflare", "fly", "hetzner", "s3", "github", "vercel", "digitalocean", "azure"] as const; | |
| 499 | export const CLOUD_VENDORS = ["cloudflare", "fly", "hetzner", "s3", "github", "vercel", "digitalocean", "azure", "stripe"] as const; | |
| 500 | 500 | /** What a vendor may be used for; a closed set. */ |
| 501 | export const CLOUD_USES = ["compute", "storage", "backup", "dns", "cdn"] as const; | |
| 501 | export const CLOUD_USES = ["compute", "storage", "backup", "dns", "cdn", "payments"] as const; | |
| 502 | 502 | export type CloudVendor = (typeof CLOUD_VENDORS)[number]; |
| 503 | 503 | export type CloudUse = (typeof CLOUD_USES)[number]; |
| 504 | 504 |
src/billing/connection.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * The Stripe connection as stored (#11136): the pasted key and the webhook's | |
| 3 | * signing secret, sealed with the forge's secrets (`core/secret_store.ts`). | |
| 4 | * The mode is in the secret's NAME (`STRIPE_KEY_TEST` or `STRIPE_KEY_LIVE`), | |
| 5 | * so every admin view can say sandbox or live without opening the key. | |
| 6 | * Opening a value is only `openKey`, and it makes the value redacted from | |
| 7 | * logs for the life of the process. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import type { EventBus } from "../core/events.ts"; | |
| 12 | import { deleteSecret, listSecretNames, resolveSecrets, setSecret } from "../core/secret_store.ts"; | |
| 13 | import { holdValues } from "../kernel/log.ts"; | |
| 14 | import { modeOf, type StripeMode } from "./stripe.ts"; | |
| 15 | ||
| 16 | const KEY_NAME: Record<StripeMode, string> = { sandbox: "STRIPE_KEY_TEST", live: "STRIPE_KEY_LIVE" }; | |
| 17 | const SECRET = "STRIPE_WEBHOOK_SECRET"; | |
| 18 | const ENDPOINT = "STRIPE_WEBHOOK_ID"; | |
| 19 | ||
| 20 | export interface Connection { | |
| 21 | readonly mode: StripeMode; | |
| 22 | /** True when a signing secret is sealed, so events can be verified. */ | |
| 23 | readonly signed: boolean; | |
| 24 | } | |
| 25 | ||
| 26 | /** What is connected, from names alone: nothing is opened. */ | |
| 27 | export function connectionOf(db: Database): Connection | undefined { | |
| 28 | const names = new Set(listSecretNames(db, "forge").map((s) => s.name)); | |
| 29 | const mode = names.has(KEY_NAME.live) ? "live" : names.has(KEY_NAME.sandbox) ? "sandbox" : undefined; | |
| 30 | return mode === undefined ? undefined : { mode, signed: names.has(SECRET) }; | |
| 31 | } | |
| 32 | ||
| 33 | /** Seal the key under its mode's name, forgetting the other mode's. False when it is not a Stripe key. */ | |
| 34 | export function sealKey(db: Database, dataDir: string, key: string): boolean { | |
| 35 | const mode = modeOf(key); | |
| 36 | if (mode === undefined) return false; | |
| 37 | deleteSecret(db, "forge", "", KEY_NAME[mode === "live" ? "sandbox" : "live"]); | |
| 38 | return setSecret(db, dataDir, "forge", "", KEY_NAME[mode], key).ok; | |
| 39 | } | |
| 40 | ||
| 41 | export function sealWebhook(db: Database, dataDir: string, secret: string, endpointId = ""): boolean { | |
| 42 | if (endpointId !== "" && !setSecret(db, dataDir, "forge", "", ENDPOINT, endpointId).ok) return false; | |
| 43 | return setSecret(db, dataDir, "forge", "", SECRET, secret).ok; | |
| 44 | } | |
| 45 | ||
| 46 | /** The sealed key and the endpoint id, opened (audited as `secret.read`). */ | |
| 47 | export function openKey(db: Database, dataDir: string, bus: EventBus, actor: string): { key: string; endpointId: string } | undefined { | |
| 48 | const c = connectionOf(db); | |
| 49 | if (!c) return undefined; | |
| 50 | const got = resolveSecrets(db, dataDir, bus, [KEY_NAME[c.mode], ENDPOINT], { actor }); | |
| 51 | const key = got[KEY_NAME[c.mode]]; | |
| 52 | if (key === undefined) return undefined; | |
| 53 | holdValues("stripe", [key]); | |
| 54 | return { key, endpointId: got[ENDPOINT] ?? "" }; | |
| 55 | } | |
| 56 | ||
| 57 | export function forget(db: Database): void { | |
| 58 | for (const n of [...Object.values(KEY_NAME), SECRET, ENDPOINT]) deleteSecret(db, "forge", "", n); | |
| 59 | } |
src/billing/stripe.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * The `stripe` provider of the Billing seam (#11136): Stripe over `fetch` | |
| 3 | * and nothing else, no SDK. The owner pastes one secret or restricted key; | |
| 4 | * checkout uses inline `price_data`, so nothing is set up in Stripe's | |
| 5 | * dashboard. Plain data in, a Result out. A key is held only in the closure | |
| 6 | * of `stripeClient`: it goes in one `Authorization` header and into no error, | |
| 7 | * no log line and no argument list. A failed call says what Stripe said | |
| 8 | * about the request (its status and error code), never what was sent. | |
| 9 | * | |
| 10 | * The vendor is declared (`FORGE_STRIPE`, docs/CLOUD.md) and every call asks | |
| 11 | * `mayUse` first. The way out: disconnect removes the webhook endpoint and | |
| 12 | * the sealed key; the entitlements stay in the store. | |
| 13 | */ | |
| 14 | ||
| 15 | import { mayUse } from "../apps/cloud.ts"; | |
| 16 | import type { CloudDeclaration } from "../apps/manifest.ts"; | |
| 17 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 18 | ||
| 19 | export const STRIPE_API = "https://api.stripe.com"; | |
| 20 | /** The one API version every call is pinned to. */ | |
| 21 | export const STRIPE_VERSION = "2025-09-30.clover"; | |
| 22 | export const WEBHOOK_EVENTS = [ | |
| 23 | "checkout.session.completed", | |
| 24 | "customer.subscription.updated", | |
| 25 | "customer.subscription.deleted", | |
| 26 | "invoice.paid", | |
| 27 | "invoice.payment_failed", | |
| 28 | ] as const; | |
| 29 | ||
| 30 | /** What the forge declares for itself: the only vendor its own process calls. */ | |
| 31 | export const FORGE_STRIPE: readonly CloudDeclaration[] = [ | |
| 32 | { vendor: "stripe", use: "payments", why: "the plans payment webhook; leave by disconnecting" }, | |
| 33 | ]; | |
| 34 | ||
| 35 | export type StripeMode = "sandbox" | "live"; | |
| 36 | const KEY = /^(sk|rk)_(test|live)_[A-Za-z0-9]{8,}$/; | |
| 37 | ||
| 38 | export const isStripeKey = (key: string): boolean => KEY.test(key); | |
| 39 | ||
| 40 | /** `sandbox` for a test key, `live` for a live one; undefined when it is no Stripe key. */ | |
| 41 | export function modeOf(key: string): StripeMode | undefined { | |
| 42 | const m = KEY.exec(key); | |
| 43 | return m ? (m[2] === "test" ? "sandbox" : "live") : undefined; | |
| 44 | } | |
| 45 | ||
| 46 | export type StripeError = | |
| 47 | | { kind: "permission"; message: string } | |
| 48 | | { kind: "rejected"; message: string; status: number } | |
| 49 | | { kind: "unreachable"; message: string }; | |
| 50 | ||
| 51 | type Value = string | number | boolean | undefined | Value[] | { [k: string]: Value }; | |
| 52 | ||
| 53 | /** Stripe's form encoding: nested keys in brackets, arrays by index. */ | |
| 54 | export function formBody(data: { [k: string]: Value }): string { | |
| 55 | const parts: string[] = []; | |
| 56 | const add = (name: string, v: Value): void => { | |
| 57 | if (v === undefined) return; | |
| 58 | if (Array.isArray(v)) v.forEach((x, i) => add(`${name}[${i}]`, x)); | |
| 59 | else if (typeof v === "object") for (const [k, x] of Object.entries(v)) add(`${name}[${k}]`, x); | |
| 60 | else parts.push(`${encodeURIComponent(name)}=${encodeURIComponent(String(v))}`); | |
| 61 | }; | |
| 62 | for (const [k, v] of Object.entries(data)) add(k, v); | |
| 63 | return parts.join("&"); | |
| 64 | } | |
| 65 | ||
| 66 | export interface StripeClient { | |
| 67 | addWebhook(url: string): Promise<Result<{ id: string; secret: string }, StripeError>>; | |
| 68 | removeWebhook(id: string): Promise<Result<true, StripeError>>; | |
| 69 | } | |
| 70 | ||
| 71 | const PERMISSION = "This key may not create webhook endpoints. Paste a signing secret from Stripe's webhook page instead."; | |
| 72 | ||
| 73 | export function stripeClient(key: string, fetchFn: typeof fetch = fetch): StripeClient { | |
| 74 | const call = async (method: "GET" | "POST" | "DELETE", path: string, data?: { [k: string]: Value }, idem?: string): Promise<Result<Record<string, unknown>, StripeError>> => { | |
| 75 | if (!mayUse(FORGE_STRIPE, "stripe", "payments")) return err({ kind: "unreachable", message: "Stripe is not declared for this forge." }); | |
| 76 | const headers: Record<string, string> = { authorization: `Bearer ${key}`, "stripe-version": STRIPE_VERSION }; | |
| 77 | if (method === "POST") { | |
| 78 | headers["content-type"] = "application/x-www-form-urlencoded"; | |
| 79 | if (idem) headers["idempotency-key"] = idem; | |
| 80 | } | |
| 81 | let res: Response; | |
| 82 | try { | |
| 83 | res = await fetchFn(`${STRIPE_API}${path}`, { method, headers, ...(method === "POST" ? { body: formBody(data ?? {}) } : {}) }); | |
| 84 | } catch { | |
| 85 | return err({ kind: "unreachable", message: "Stripe could not be reached." }); | |
| 86 | } | |
| 87 | let json: Record<string, unknown> = {}; | |
| 88 | try { | |
| 89 | json = (await res.json()) as Record<string, unknown>; | |
| 90 | } catch { | |
| 91 | // an empty or non-JSON body: the status says enough | |
| 92 | } | |
| 93 | if (res.ok) return ok(json); | |
| 94 | const e = (json["error"] ?? {}) as Record<string, unknown>; | |
| 95 | const code = typeof e["code"] === "string" ? e["code"] : typeof e["type"] === "string" ? e["type"] : "error"; | |
| 96 | if (res.status === 403 || e["type"] === "invalid_request_error" && res.status === 401) return err({ kind: "permission", message: PERMISSION }); | |
| 97 | return err({ kind: "rejected", status: res.status, message: `Stripe refused the request (${res.status}, ${String(code).replace(/[^\w.-]/g, "").slice(0, 60)}).` }); | |
| 98 | }; | |
| 99 | const str = (o: Record<string, unknown>, k: string): string => (typeof o[k] === "string" ? (o[k] as string) : ""); | |
| 100 | const missing = (what: string): StripeError => ({ kind: "rejected", status: 502, message: `Stripe's answer had no ${what}.` }); | |
| 101 | ||
| 102 | return { | |
| 103 | async addWebhook(url) { | |
| 104 | const r = await call("POST", "/v1/webhook_endpoints", { url, enabled_events: [...WEBHOOK_EVENTS] }, | |
| 105 | // fresh per attempt: Stripe replays an answer for a repeated key for 24h, and a reconnect must not get a deleted endpoint back | |
| 106 | crypto.randomUUID()); | |
| 107 | if (!r.ok) return r; | |
| 108 | const id = str(r.value, "id"), secret = str(r.value, "secret"); | |
| 109 | return id && secret ? ok({ id, secret }) : err(missing("signing secret")); | |
| 110 | }, | |
| 111 | async removeWebhook(id) { | |
| 112 | const r = await call("DELETE", `/v1/webhook_endpoints/${encodeURIComponent(id)}`); | |
| 113 | return r.ok ? ok(true as const) : r; | |
| 114 | }, | |
| 115 | }; | |
| 116 | } |
src/http/settings_routes.ts +4 −1
| @@ -18,7 +18,7 @@ import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts | ||
| 18 | 18 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 19 | 19 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 20 | 20 | import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts"; |
| 21 | import { billingPage, billingGrant, billingMakeCode, billingRevokeCode } from "../web/pages/billing.ts"; | |
| 21 | import { billingPage, billingGrant, billingMakeCode, billingRevokeCode, stripeConnect, stripeDisconnect, stripeSecret } from "../web/pages/billing.ts"; | |
| 22 | 22 | import { economicsPage, economicsSet } from "../web/pages/economics.ts"; |
| 23 | 23 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; |
| 24 | 24 | import { usagePage } from "../web/pages/usage.ts"; |
| @@ -210,6 +210,9 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 210 | 210 | router.add("POST", "/settings/economics", economicsSet); |
| 211 | 211 | router.add("GET", "/settings/billing", billingPage); |
| 212 | 212 | router.add("POST", "/settings/billing/grant", billingGrant); |
| 213 | router.add("POST", "/settings/billing/stripe/connect", stripeConnect); | |
| 214 | router.add("POST", "/settings/billing/stripe/secret", stripeSecret); | |
| 215 | router.add("POST", "/settings/billing/stripe/disconnect", stripeDisconnect); | |
| 213 | 216 | router.add("POST", "/settings/billing/codes", billingMakeCode); |
| 214 | 217 | router.add("POST", "/settings/billing/codes/revoke", billingRevokeCode); |
| 215 | 218 | router.add("GET", "/settings/flags", flagsPage); |
src/tui/screens/billing.ts +38 −5
| @@ -3,9 +3,11 @@ | ||
| 3 | 3 | * and the hand grant, through the same `readBilling` and `grantPlan` the page |
| 4 | 4 | * and API use. `s`, `m` or `l` grants the forge under the cursor that plan |
| 5 | 5 | * after a yes/no; the change is kept in the audit trail like any other. |
| 6 | * `c` connects Stripe with a pasted key (shown as dots), `w` saves a webhook | |
| 7 | * signing secret, `x` disconnects after a yes/no: the same actions as the page. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | import { grantPlan, readBilling, BILLING_WORDS } from "../../actions/billing.ts"; | |
| 10 | import { grantPlan, readBilling, connectStripe, disconnectStripe, saveSigningSecret, BILLING_WORDS, STRIPE_WORDS } from "../../actions/billing.ts"; | |
| 9 | 11 | import type { Ctx, Screen } from "../app/app.ts"; |
| 10 | 12 | import { binding } from "../app/keys.ts"; |
| 11 | 13 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -13,10 +15,12 @@ import { style } from "../term/style.ts"; | ||
| 13 | 15 | import { truncate } from "../term/width.ts"; |
| 14 | 16 | import type { Session } from "../session.ts"; |
| 15 | 17 | import { emptyState } from "../ui/widgets.ts"; |
| 16 | import { ListState, confirmScreen, drawList, yesNo, type ListRow } from "./common.ts"; | |
| 18 | import { ListState, confirmScreen, drawList, promptScreen, yesNo, type ListRow } from "./common.ts"; | |
| 17 | 19 | import type { PaletteEntry } from "./entries.ts"; |
| 18 | 20 | |
| 19 | 21 | const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; |
| 22 | const stripeWords = (e: keyof typeof STRIPE_WORDS | "not_found" | "no_such_forge" | "bad_plan"): string => | |
| 23 | e === "not_found" ? "Not found." : e in STRIPE_WORDS ? `${STRIPE_WORDS[e as keyof typeof STRIPE_WORDS]}.` : `${BILLING_WORDS[e as keyof typeof BILLING_WORDS]}.`; | |
| 20 | 24 | const PLANS: Record<string, string> = { s: "small", m: "medium", l: "large" }; |
| 21 | 25 | |
| 22 | 26 | export function billingRows(f: Session): ListRow[] { |
| @@ -30,21 +34,50 @@ export function billingScreen(f: Session, ctx: Ctx): Screen { | ||
| 30 | 34 | let note = ""; |
| 31 | 35 | const screen: Screen = { |
| 32 | 36 | title: "Billing", |
| 33 | keys: [binding(["j", "k"], "move cursor"), binding(["s", "m", "l"], "grant small, medium, large"), binding(["esc"], "back")], | |
| 37 | keys: [binding(["j", "k"], "move cursor"), binding(["s", "m", "l"], "grant small, medium, large"), binding(["c"], "connect Stripe"), binding(["w"], "signing secret"), binding(["x"], "disconnect Stripe"), binding(["esc"], "back")], | |
| 34 | 38 | render(area, s) { |
| 35 | 39 | const c = ctx.theme.c; |
| 36 | 40 | const rows = billingRows(f); |
| 37 | 41 | state.clamp(rows.length); |
| 42 | const sc = readBilling(f.app, f.user); | |
| 43 | const stripe = sc.ok && sc.value.stripe ? `Stripe ${sc.value.stripe.mode}, ${sc.value.stripe.signed ? "signed" : "no signing secret"}` : "Stripe not connected"; | |
| 38 | 44 | if (rows.length === 0) { |
| 39 | emptyState(s, area, c, { title: "No forge has been sent from here.", body: "Billing has nothing to grant yet." }); | |
| 45 | emptyState(s, area, c, { title: "No forge has been sent from here.", body: note === "" ? `Billing has nothing to grant yet. ${stripe}.` : note }); | |
| 40 | 46 | return; |
| 41 | 47 | } |
| 42 | 48 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| 43 | const line = note === "" ? "Every forge is free within its plan; a grant is recorded by hand" : note; | |
| 49 | const line = note === "" ? `${stripe} (c connect, w secret, x disconnect)` : note; | |
| 44 | 50 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 45 | 51 | drawList(s, body!, c, state, rows); |
| 46 | 52 | }, |
| 47 | 53 | key(e) { |
| 54 | if (isAdmin(f) && (e.key === "c" || e.key === "w")) { | |
| 55 | const connect = e.key === "c"; | |
| 56 | ctx.push( | |
| 57 | promptScreen(ctx, screen, { | |
| 58 | title: connect ? "Connect Stripe" : "Webhook signing secret", | |
| 59 | label: connect ? "Stripe secret or restricted key" : "Signing secret (whsec_…)", | |
| 60 | mask: true, | |
| 61 | onSubmit: async (value) => { | |
| 62 | const done = connect ? await connectStripe(f.app, f.user, value, "") : saveSigningSecret(f.app, f.user, value); | |
| 63 | note = done.ok ? (connect ? "Stripe is connected." : "Signing secret saved.") : stripeWords(done.error); | |
| 64 | ctx.invalidate(); | |
| 65 | }, | |
| 66 | }), | |
| 67 | ); | |
| 68 | return true; | |
| 69 | } | |
| 70 | if (isAdmin(f) && e.key === "x") { | |
| 71 | const go = (i: number): void => { | |
| 72 | if (i !== 0) return; | |
| 73 | void disconnectStripe(f.app, f.user).then((r) => { | |
| 74 | note = r.ok ? "Stripe is disconnected." : stripeWords(r.error); | |
| 75 | ctx.invalidate(); | |
| 76 | }); | |
| 77 | }; | |
| 78 | ctx.push(yesNo(ctx, confirmScreen(ctx, screen, { title: "Disconnect Stripe", body: ["Remove the webhook and forget the key?"], actions: [{ label: "Disconnect" }, { label: "Cancel" }], onChoose: go }), go)); | |
| 79 | return true; | |
| 80 | } | |
| 48 | 81 | const plan = PLANS[e.key]; |
| 49 | 82 | const rows = billingRows(f); |
| 50 | 83 | const target = rows[state.index]; |
src/web/pages/billing.ts +56 −3
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * Anyone else gets the not-found page. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { BILLING_WORDS, grantPlan, readBilling, type BillingError } from "../../actions/billing.ts"; | |
| 8 | import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts"; | |
| 9 | 9 | import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts"; |
| 10 | 10 | import { publicOrigin } from "../../core/public_origin.ts"; |
| 11 | 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| @@ -19,7 +19,7 @@ import { btn, copyField, field as formField, selectField } from "../ui.ts"; | ||
| 19 | 19 | import { accountSections } from "./origins.ts"; |
| 20 | 20 | |
| 21 | 21 | const PATH = "/settings/billing"; |
| 22 | const words = (e: BillingError) => (e === "not_found" ? "Not found" : BILLING_WORDS[e]); | |
| 22 | const words = (e: BillingError | StripeWords) => (e === "not_found" ? "Not found" : e in BILLING_WORDS ? BILLING_WORDS[e as keyof typeof BILLING_WORDS] : STRIPE_WORDS[e as StripeWords]); | |
| 23 | 23 | |
| 24 | 24 | /** A code just made: the plain code and its link, shown once and never kept. */ |
| 25 | 25 | interface Shown { |
| @@ -101,7 +101,28 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 101 | 101 | </li>`, |
| 102 | 102 | ); |
| 103 | 103 | const rendering = performance.now(); |
| 104 | const body = html`<section class="settings-block"> | |
| 104 | const s = b.stripe; | |
| 105 | const stripe = html`<section class="settings-block"> | |
| 106 | <h2>Provider</h2> | |
| 107 | ${s | |
| 108 | ? html`<dl class="status-rows"> | |
| 109 | <div class="status-row"><dt>Stripe</dt><dd data-mode="${s.mode}">${s.mode}</dd></div> | |
| 110 | <div class="status-row"><dt>Events</dt><dd>${s.signed ? "signed" : "no signing secret yet"}</dd></div> | |
| 111 | </dl> | |
| 112 | ${s.signed | |
| 113 | ? null | |
| 114 | : html`<form class="row-fields" method="post" action="${PATH}/stripe/secret"> | |
| 115 | <input class="input mono" type="password" name="secret" autocomplete="off" placeholder="whsec_…" aria-label="Webhook signing secret" required /> | |
| 116 | ${btn("Save signing secret")} | |
| 117 | </form>`} | |
| 118 | <form method="post" action="${PATH}/stripe/disconnect">${btn("Disconnect")}</form>` | |
| 119 | : html`<p class="muted small">Paste a Stripe secret or restricted key; the forge registers its own webhook.</p> | |
| 120 | <form class="row-fields" method="post" action="${PATH}/stripe/connect"> | |
| 121 | <input class="input mono" type="password" name="key" autocomplete="off" placeholder="sk_test_… or rk_live_…" aria-label="Stripe key" required /> | |
| 122 | ${btn("Connect")} | |
| 123 | </form>`} | |
| 124 | </section>`; | |
| 125 | const body = html`${stripe}<section class="settings-block"> | |
| 105 | 126 | <h2>Plans</h2> |
| 106 | 127 | <p class="muted small">Choose the plan each forge is on.</p> |
| 107 | 128 | ${rows.length === 0 |
| @@ -151,3 +172,35 @@ export async function billingGrant(ctx: Ctx): Promise<Response> { | ||
| 151 | 172 | if (!set.ok && set.error === "not_found") return notFoundPage(ctx); |
| 152 | 173 | return flashTo(ctx, PATH, set.ok ? { message: `${set.value.entitlement.forge} is on ${set.value.entitlement.plan}.` } : { tone: "danger", message: `${words(set.error)}.` }); |
| 153 | 174 | } |
| 175 | ||
| 176 | const back = (ctx: Ctx, r: { ok: true; message: string } | { ok: false; error: BillingError | StripeWords }): Response | Promise<Response> => | |
| 177 | r.ok ? flashTo(ctx, PATH, { message: r.message }) : r.error === "not_found" ? notFoundPage(ctx) : flashTo(ctx, PATH, { tone: "danger", message: `${words(r.error)}.` }); | |
| 178 | ||
| 179 | const isPerson = (ctx: Ctx): boolean => ctx.auth.user?.isAdmin === true && ctx.auth.user.kind !== "agent"; | |
| 180 | ||
| 181 | // POST /settings/billing/stripe/connect | |
| 182 | export async function stripeConnect(ctx: Ctx): Promise<Response> { | |
| 183 | if (!isPerson(ctx)) return notFoundPage(ctx); | |
| 184 | const form = await readForm(ctx, "admin"); | |
| 185 | if (form instanceof Response) return form; | |
| 186 | const r = await connectStripe(ctx.app, ctx.auth.user, field(form, "key"), field(form, "secret")); | |
| 187 | return back(ctx, r.ok ? { ok: true, message: `Stripe is connected (${r.value.mode}).` } : r); | |
| 188 | } | |
| 189 | ||
| 190 | // POST /settings/billing/stripe/secret | |
| 191 | export async function stripeSecret(ctx: Ctx): Promise<Response> { | |
| 192 | if (!isPerson(ctx)) return notFoundPage(ctx); | |
| 193 | const form = await readForm(ctx, "admin"); | |
| 194 | if (form instanceof Response) return form; | |
| 195 | const r = saveSigningSecret(ctx.app, ctx.auth.user, field(form, "secret")); | |
| 196 | return back(ctx, r.ok ? { ok: true, message: "Signing secret saved." } : r); | |
| 197 | } | |
| 198 | ||
| 199 | // POST /settings/billing/stripe/disconnect | |
| 200 | export async function stripeDisconnect(ctx: Ctx): Promise<Response> { | |
| 201 | if (!isPerson(ctx)) return notFoundPage(ctx); | |
| 202 | const form = await readForm(ctx, "admin"); | |
| 203 | if (form instanceof Response) return form; | |
| 204 | const r = await disconnectStripe(ctx.app, ctx.auth.user); | |
| 205 | return back(ctx, r.ok ? { ok: true, message: "Stripe is disconnected." } : r); | |
| 206 | } |
test/billing-tui.test.ts +51
| @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 8 | import { connectStripe } from "../src/actions/billing.ts"; | |
| 9 | import { connectionOf } from "../src/billing/connection.ts"; | |
| 8 | 10 | import { entitlementOf } from "../src/billing/store.ts"; |
| 9 | 11 | import { createUser, type User } from "../src/core/users.ts"; |
| 10 | 12 | import { ok } from "../src/kernel/result.ts"; |
| @@ -107,3 +109,52 @@ test("a non-admin who reaches the screen sees no forge and cannot grant", async | ||
| 107 | 109 | expect(entitlementOf(app.db, "kid", "small").plan).toBe("large"); |
| 108 | 110 | tui.stop?.(); |
| 109 | 111 | }); |
| 112 | ||
| 113 | const type = (tui: ReturnType<typeof open>["tui"], text: string) => { | |
| 114 | for (const ch of text) tui.dispatch(key(ch)); | |
| 115 | }; | |
| 116 | ||
| 117 | test("the admin connects Stripe's signing secret, sees it masked, and disconnects after a yes", async () => { | |
| 118 | const k = "sk_test_AbCdEf1234567890"; | |
| 119 | expect((await connectStripe(app, alice, k, "whsec_first1234567")).ok).toBe(true); | |
| 120 | expect(connectionOf(app.db)).toEqual({ mode: "sandbox", signed: true }); | |
| 121 | const { tui, session } = open(alice); | |
| 122 | tui.ctx.push(billingScreen(session, tui.ctx)); | |
| 123 | await settleApp(tui); | |
| 124 | expect(tui.screen.toText()).toContain("Stripe sandbox, signed"); | |
| 125 | tui.dispatch(key("w")); | |
| 126 | await settleApp(tui); | |
| 127 | type(tui, "whsec_second123456"); | |
| 128 | await settleApp(tui); | |
| 129 | expect(tui.screen.toText()).not.toContain("whsec_second123456"); | |
| 130 | tui.dispatch(key("enter")); | |
| 131 | await settleApp(tui); | |
| 132 | expect(tui.screen.toText()).toContain("Signing secret saved."); | |
| 133 | tui.dispatch(key("x")); | |
| 134 | await settleApp(tui); | |
| 135 | expect(tui.screen.toText()).toContain("Remove the webhook and forget the key?"); | |
| 136 | tui.dispatch(key("n")); | |
| 137 | await settleApp(tui); | |
| 138 | expect(connectionOf(app.db)).toBeDefined(); | |
| 139 | tui.stop?.(); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a bad key typed into the connect prompt is refused in words; a non-admin cannot open the prompt", async () => { | |
| 143 | const { tui, session } = open(alice); | |
| 144 | tui.ctx.push(billingScreen(session, tui.ctx)); | |
| 145 | await settleApp(tui); | |
| 146 | tui.dispatch(key("c")); | |
| 147 | await settleApp(tui); | |
| 148 | type(tui, "pk_live_abcdefgh12"); | |
| 149 | tui.dispatch(key("enter")); | |
| 150 | await settleApp(tui); | |
| 151 | expect(tui.screen.toText()).toContain("That is not a Stripe secret or restricted key"); | |
| 152 | tui.stop?.(); | |
| 153 | const bobs = open(bob); | |
| 154 | bobs.tui.ctx.push(billingScreen(bobs.session, bobs.tui.ctx)); | |
| 155 | await settleApp(bobs.tui); | |
| 156 | bobs.tui.dispatch(key("x")); | |
| 157 | await settleApp(bobs.tui); | |
| 158 | expect(bobs.tui.screen.toText()).not.toContain("Remove the webhook"); | |
| 159 | bobs.tui.stop?.(); | |
| 160 | }); |
test/billing.test.ts +1 −1
| @@ -137,7 +137,7 @@ describe("billing seam", () => { | ||
| 137 | 137 | expect(page.status).toBe(200); |
| 138 | 138 | const text = await page.text(); |
| 139 | 139 | expect(text).toContain("Choose the plan each forge is on."); |
| 140 | expect(text).not.toContain("Stripe"); | |
| 140 | expect(text).toContain("Paste a Stripe secret or restricted key"); | |
| 141 | 141 | expect(text).toContain('data-forge="kid"'); |
| 142 | 142 | expect((await send("GET", "/settings/billing", bob)).status).toBe(404); |
| 143 | 143 | }); |
test/data-browser.test.ts +1
| @@ -28,6 +28,7 @@ const AUDITED_CLEAR: string[] = [ | ||
| 28 | 28 | "degraded_span.sentence", |
| 29 | 29 | "feed_chip.value", |
| 30 | 30 | "fleet_operation.value", |
| 31 | "gift_redeem.code_id", | |
| 31 | 32 | "issue.body", |
| 32 | 33 | "oauth_flow.detail", |
| 33 | 34 | "oidc_key.public_jwk", |
test/stripe.test.ts added +238
| @@ -0,0 +1,238 @@ | ||
| 1 | /** | |
| 2 | * The stripe provider (#11136): exact form bodies and headers against a fake | |
| 3 | * fetch, the connection sealed and never shown, the sandbox/live word. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { connectStripe, disconnectStripe, readBilling, saveSigningSecret } from "../src/actions/billing.ts"; | |
| 11 | import { connectionOf } from "../src/billing/connection.ts"; | |
| 12 | import { FORGE_STRIPE, STRIPE_VERSION, formBody, modeOf, stripeClient } from "../src/billing/stripe.ts"; | |
| 13 | import { mayUse } from "../src/apps/cloud.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const KEY = "sk_test_AbCdEf1234567890"; | |
| 22 | type Call = { url: string; method: string; headers: Record<string, string>; body: string }; | |
| 23 | ||
| 24 | function fake(answer: (c: Call) => { status?: number; body: unknown } | Error) { | |
| 25 | const calls: Call[] = []; | |
| 26 | const f = (async (url: string, init: RequestInit) => { | |
| 27 | const c = { url, method: init.method ?? "GET", headers: init.headers as Record<string, string>, body: String(init.body ?? "") }; | |
| 28 | calls.push(c); | |
| 29 | const a = answer(c); | |
| 30 | if (a instanceof Error) throw a; | |
| 31 | return new Response(JSON.stringify(a.body), { status: a.status ?? 200 }); | |
| 32 | }) as unknown as typeof fetch; | |
| 33 | return { f, calls }; | |
| 34 | } | |
| 35 | ||
| 36 | describe("stripe client", () => { | |
| 37 | test("the webhook endpoint: exact form, a fresh idempotency key each attempt, delete", async () => { | |
| 38 | const { f, calls } = fake((c) => (c.method === "DELETE" ? { body: { deleted: true } } : { body: { id: "we_1", secret: "whsec_abcdefgh12" } })); | |
| 39 | const s = stripeClient(KEY, f); | |
| 40 | expect(await s.addWebhook("https://f.test/hooks/stripe")).toEqual({ ok: true, value: { id: "we_1", secret: "whsec_abcdefgh12" } }); | |
| 41 | await s.addWebhook("https://f.test/hooks/stripe"); | |
| 42 | const c = calls[0]!; | |
| 43 | expect(c.url).toBe("https://api.stripe.com/v1/webhook_endpoints"); | |
| 44 | expect(c.headers).toMatchObject({ authorization: `Bearer ${KEY}`, "stripe-version": STRIPE_VERSION, "content-type": "application/x-www-form-urlencoded" }); | |
| 45 | expect(decodeURIComponent(c.body)).toBe( | |
| 46 | "url=https://f.test/hooks/stripe&enabled_events[0]=checkout.session.completed&enabled_events[1]=customer.subscription.updated&enabled_events[2]=customer.subscription.deleted&enabled_events[3]=invoice.paid&enabled_events[4]=invoice.payment_failed", | |
| 47 | ); | |
| 48 | expect(calls[0]!.headers["idempotency-key"]).toBeTruthy(); | |
| 49 | expect(calls[1]!.headers["idempotency-key"]).not.toBe(calls[0]!.headers["idempotency-key"]); | |
| 50 | await s.removeWebhook("we_1"); | |
| 51 | expect(calls[2]).toMatchObject({ url: "https://api.stripe.com/v1/webhook_endpoints/we_1", method: "DELETE" }); | |
| 52 | expect(calls[2]!.headers["idempotency-key"]).toBeUndefined(); | |
| 53 | }); | |
| 54 | ||
| 55 | test("modes read, form nesting encodes", () => { | |
| 56 | expect([modeOf("sk_test_abcdefgh"), modeOf("rk_live_abcdefgh"), modeOf("pk_live_abcdefgh")]).toEqual(["sandbox", "live", undefined]); | |
| 57 | expect(formBody({ a: { b: [1, 2] }, c: "x y&z", d: undefined })).toBe("a%5Bb%5D%5B0%5D=1&a%5Bb%5D%5B1%5D=2&c=x%20y%26z"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("an error never carries the key or what was sent", async () => { | |
| 61 | const { f } = fake(() => ({ status: 400, body: { error: { code: "bad", message: `bad key ${KEY}` } } })); | |
| 62 | const r = await stripeClient(KEY, f).addWebhook("https://f.test/hooks/stripe"); | |
| 63 | expect(r.ok).toBe(false); | |
| 64 | expect(JSON.stringify(r)).not.toContain(KEY); | |
| 65 | const down = await stripeClient(KEY, fake(() => new Error(`connect ${KEY}`)).f).removeWebhook("we_1"); | |
| 66 | expect(JSON.stringify(down)).not.toContain(KEY); | |
| 67 | expect(down).toMatchObject({ ok: false, error: { kind: "unreachable" } }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("the vendor is declared and mayUse says so", () => { | |
| 71 | expect(mayUse(FORGE_STRIPE, "stripe", "payments")).toBe(true); | |
| 72 | expect(mayUse(FORGE_STRIPE, "stripe", "dns")).toBe(false); | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | describe("connecting stripe", () => { | |
| 77 | let dataDir: string; | |
| 78 | let app: App; | |
| 79 | let servers: Servers; | |
| 80 | let alice: User; | |
| 81 | let bob: User; | |
| 82 | let token = ""; | |
| 83 | ||
| 84 | beforeAll(async () => { | |
| 85 | dataDir = await mkdtemp(join(tmpdir(), "forge-stripe-")); | |
| 86 | app = createApp({ dataDir, port: 0, url: "https://f.test" }); | |
| 87 | servers = await serve(app); | |
| 88 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 89 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 90 | if (!a.ok || !b.ok) throw new Error("user"); | |
| 91 | alice = a.value; | |
| 92 | bob = b.value; | |
| 93 | token = createToken(app.db, alice.id, "admin", "admin").token; | |
| 94 | }, 30_000); | |
| 95 | afterAll(async () => { | |
| 96 | servers.stop(); | |
| 97 | app.close(); | |
| 98 | await rm(dataDir, { recursive: true, force: true }); | |
| 99 | }); | |
| 100 | ||
| 101 | const ok = () => fake(() => ({ body: { id: "we_9", secret: "whsec_abcdefgh12" } })); | |
| 102 | ||
| 103 | test("a non-admin and a bad key change nothing", async () => { | |
| 104 | const { f, calls } = ok(); | |
| 105 | expect(await connectStripe(app, bob, KEY, "", f)).toEqual({ ok: false, error: "not_found" }); | |
| 106 | expect(await connectStripe(app, alice, "pk_live_abcdefgh12", "", f)).toEqual({ ok: false, error: "bad_key" }); | |
| 107 | expect(await connectStripe(app, alice, KEY, "nope", f)).toEqual({ ok: false, error: "bad_secret" }); | |
| 108 | expect(calls).toEqual([]); | |
| 109 | expect(connectionOf(app.db)).toBeUndefined(); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a key Stripe rejects is not kept", async () => { | |
| 113 | const { f } = fake(() => ({ status: 401, body: { error: { type: "authentication_error" } } })); | |
| 114 | expect(await connectStripe(app, alice, KEY, "", f)).toEqual({ ok: false, error: "refused" }); | |
| 115 | expect(connectionOf(app.db)).toBeUndefined(); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a key without permission is kept, told so, and takes a signing secret", async () => { | |
| 119 | const { f } = fake(() => ({ status: 403, body: { error: { type: "invalid_request_error" } } })); | |
| 120 | expect(await connectStripe(app, alice, KEY, "", f)).toEqual({ ok: false, error: "no_permission" }); | |
| 121 | expect(connectionOf(app.db)).toEqual({ mode: "sandbox", signed: false }); | |
| 122 | expect(saveSigningSecret(app, alice, "nope")).toEqual({ ok: false, error: "bad_secret" }); | |
| 123 | expect(saveSigningSecret(app, alice, "whsec_pasted12345")).toEqual({ ok: true, value: { mode: "sandbox", signed: true } }); | |
| 124 | }); | |
| 125 | ||
| 126 | test("connect registers the endpoint, seals key and secret, and shows only the mode", async () => { | |
| 127 | await disconnectStripe(app, alice, ok().f); | |
| 128 | const { f, calls } = ok(); | |
| 129 | const r = await connectStripe(app, alice, ` ${KEY} `, "", f); | |
| 130 | expect(r).toEqual({ ok: true, value: { mode: "sandbox", signed: true } }); | |
| 131 | expect(calls[0]!.body).toContain("url=https%3A%2F%2Ff.test%2Fhooks%2Fstripe"); | |
| 132 | const view = readBilling(app, alice); | |
| 133 | expect(view.ok && view.value.stripe).toEqual({ mode: "sandbox", signed: true }); | |
| 134 | const rows = JSON.stringify([ | |
| 135 | app.db.query("SELECT * FROM secret").all(), | |
| 136 | app.db.query("SELECT payload FROM event").all(), | |
| 137 | ]); | |
| 138 | expect(rows).not.toContain(KEY); | |
| 139 | expect(rows).not.toContain("whsec_abcdefgh12"); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a live key reads live; the page says the word and never the key", async () => { | |
| 143 | const live = "rk_live_ZyXwVu0987654321"; | |
| 144 | await connectStripe(app, alice, live, "", ok().f); | |
| 145 | expect(connectionOf(app.db)?.mode).toBe("live"); | |
| 146 | expect(app.db.query<{ name: string }, []>("SELECT name FROM secret WHERE name LIKE 'STRIPE_KEY%'").all()).toEqual([{ name: "STRIPE_KEY_LIVE" }]); | |
| 147 | const res = await fetch(`${servers.url}/settings/billing`, { headers: { authorization: `Bearer ${token}` } }); | |
| 148 | const text = await res.text(); | |
| 149 | expect(text).toContain('data-mode="live"'); | |
| 150 | expect(text).not.toContain(live); | |
| 151 | }); | |
| 152 | ||
| 153 | test("the form route is same-origin only, and disconnect removes the endpoint and the secrets", async () => { | |
| 154 | const post = (origin: boolean) => | |
| 155 | fetch(`${servers.url}/settings/billing/stripe/disconnect`, { method: "POST", redirect: "manual", headers: { authorization: `Bearer ${token}`, ...(origin ? {} : { origin: "https://evil.example" }) } }); | |
| 156 | expect((await post(false)).status).toBe(403); | |
| 157 | expect(connectionOf(app.db)).toBeDefined(); | |
| 158 | const { f, calls } = fake(() => ({ body: { deleted: true } })); | |
| 159 | expect(await disconnectStripe(app, bob, f)).toEqual({ ok: false, error: "not_found" }); | |
| 160 | expect(await disconnectStripe(app, alice, f)).toEqual({ ok: true, value: true }); | |
| 161 | expect(calls[0]).toMatchObject({ method: "DELETE", url: "https://api.stripe.com/v1/webhook_endpoints/we_9" }); | |
| 162 | expect(connectionOf(app.db)).toBeUndefined(); | |
| 163 | expect(app.db.query("SELECT COUNT(*) AS n FROM secret WHERE name LIKE 'STRIPE_%'").get()).toEqual({ n: 0 }); | |
| 164 | expect((await post(true)).status).toBeLessThan(400); | |
| 165 | }); | |
| 166 | ||
| 167 | const form = (path: string, body: Record<string, string>) => | |
| 168 | fetch(`${servers.url}/settings/billing/stripe/${path}`, { method: "POST", redirect: "manual", headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body) }); | |
| 169 | const flashOf = async (res: Response): Promise<string> => { | |
| 170 | const to = res.headers.get("location") ?? "/settings/billing"; | |
| 171 | const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 172 | const page = await fetch(new URL(to, servers.url), { headers: { authorization: `Bearer ${token}`, cookie } }); | |
| 173 | return await page.text(); | |
| 174 | }; | |
| 175 | ||
| 176 | test("reconnecting forgets what the last key sealed, and removes its endpoint", async () => { | |
| 177 | await disconnectStripe(app, alice, ok().f); | |
| 178 | await connectStripe(app, alice, KEY, "", ok().f); | |
| 179 | const { f, calls } = fake(() => ({ status: 403, body: { error: { type: "invalid_request_error" } } })); | |
| 180 | const second = "sk_test_ZzZzZz9999999999"; | |
| 181 | expect(await connectStripe(app, alice, second, "", f)).toEqual({ ok: false, error: "no_permission" }); | |
| 182 | expect(calls.map((c) => c.method)).toEqual(["POST", "DELETE"]); | |
| 183 | // the old key's endpoint was removed, and its secret and id are gone | |
| 184 | expect(connectionOf(app.db)).toEqual({ mode: "sandbox", signed: false }); | |
| 185 | expect(app.db.query("SELECT COUNT(*) AS n FROM secret WHERE name IN ('STRIPE_WEBHOOK_SECRET','STRIPE_WEBHOOK_ID')").get()).toEqual({ n: 0 }); | |
| 186 | await connectStripe(app, alice, KEY, "whsec_pasted12345", ok().f); | |
| 187 | expect(app.db.query("SELECT COUNT(*) AS n FROM secret WHERE name = 'STRIPE_WEBHOOK_ID'").get()).toEqual({ n: 0 }); | |
| 188 | }); | |
| 189 | ||
| 190 | test("a reconnect with a new endpoint deletes the old one at Stripe", async () => { | |
| 191 | await disconnectStripe(app, alice, ok().f); | |
| 192 | await connectStripe(app, alice, KEY, "", fake(() => ({ body: { id: "we_old", secret: "whsec_oldoldold1" } })).f); | |
| 193 | const { f, calls } = fake((c) => (c.method === "DELETE" ? { body: { deleted: true } } : { body: { id: "we_new", secret: "whsec_newnewnew1" } })); | |
| 194 | expect(await connectStripe(app, alice, "sk_test_Another1234567890", "", f)).toEqual({ ok: true, value: { mode: "sandbox", signed: true } }); | |
| 195 | expect(calls.map((c) => `${c.method} ${c.url.split("/").pop()}`)).toEqual(["POST webhook_endpoints", "DELETE we_old"]); | |
| 196 | expect(calls[1]!.headers["authorization"]).toBe(`Bearer ${KEY}`); | |
| 197 | expect(await disconnectStripe(app, alice, f)).toEqual({ ok: true, value: true }); | |
| 198 | expect(calls[2]).toMatchObject({ method: "DELETE", url: "https://api.stripe.com/v1/webhook_endpoints/we_new" }); | |
| 199 | }); | |
| 200 | ||
| 201 | test("disconnect says so when the endpoint could not be removed, and still forgets the key", async () => { | |
| 202 | await connectStripe(app, alice, KEY, "", ok().f); | |
| 203 | const down = fake(() => ({ status: 500, body: {} })); | |
| 204 | expect(await disconnectStripe(app, alice, down.f)).toEqual({ ok: false, error: "left_at_stripe" }); | |
| 205 | expect(connectionOf(app.db)).toBeUndefined(); | |
| 206 | }); | |
| 207 | ||
| 208 | test("the webhook address is the configured origin, not the request's Host", async () => { | |
| 209 | const { f, calls } = ok(); | |
| 210 | await connectStripe(app, alice, KEY, "", f); | |
| 211 | expect(decodeURIComponent(calls[0]!.body)).toContain("url=https://f.test/hooks/stripe&"); | |
| 212 | const bare = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-stripe-bare-")), port: 0 }); | |
| 213 | const a = await createUser(bare.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 214 | if (!a.ok) throw new Error("user"); | |
| 215 | const none = ok(); | |
| 216 | expect(await connectStripe(bare, a.value, KEY, "", none.f)).toEqual({ ok: false, error: "no_origin" }); | |
| 217 | expect(none.calls).toEqual([]); | |
| 218 | bare.close(); | |
| 219 | }); | |
| 220 | ||
| 221 | test("the connect and secret routes, through HTTP, with their flash words", async () => { | |
| 222 | await disconnectStripe(app, alice, ok().f); | |
| 223 | const bad = await form("connect", { key: "pk_live_abcdefgh12" }); | |
| 224 | expect(bad.status).toBeLessThan(400); | |
| 225 | expect(await flashOf(bad)).toContain("That is not a Stripe secret or restricted key"); | |
| 226 | expect(connectionOf(app.db)).toBeUndefined(); | |
| 227 | // no network in tests: a key and a pasted signing secret seal without a Stripe call | |
| 228 | const good = await form("connect", { key: KEY, secret: "whsec_pasted12345" }); | |
| 229 | expect(await flashOf(good)).toContain("Stripe is connected (sandbox)."); | |
| 230 | expect(connectionOf(app.db)).toEqual({ mode: "sandbox", signed: true }); | |
| 231 | const worse = await form("secret", { secret: "nope" }); | |
| 232 | expect(await flashOf(worse)).toContain("A signing secret starts whsec_"); | |
| 233 | const saved = await form("secret", { secret: "whsec_another12345" }); | |
| 234 | expect(await flashOf(saved)).toContain("Signing secret saved."); | |
| 235 | const asBob = await fetch(`${servers.url}/settings/billing/stripe/connect`, { method: "POST", redirect: "manual", headers: { authorization: `Bearer ${createToken(app.db, bob.id, "bob", "admin").token}`, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ key: KEY }) }); | |
| 236 | expect(asBob.status).toBe(404); | |
| 237 | }); | |
| 238 | }); |
test/tui-asks-first.test.ts +1 −1
| @@ -51,7 +51,7 @@ const allowed: readonly { file: string; action: string; reason: string }[] = [ | ||
| 51 | 51 | { file: "src/tui/screens/deploys.ts", action: "stopEnv", reason: "in `act`, called from `go` after the Stop confirm" }, |
| 52 | 52 | { file: "src/tui/screens/boards.ts", action: "deleteBoard", reason: "in `remove`, called only on the y of the delete y/n" }, |
| 53 | 53 | { file: "src/tui/screens/about.ts", action: "detachFromParent", reason: "the first call is the dry run; the real one is in onChoose" }, |
| 54 | { file: "src/tui/screens/devices.ts", action: "revoke", reason: "passed to `askApp`, run only from the slug prompt's onSubmit" }, | |
| 54 | { file: "src/tui/screens/codes.ts", action: "revoke", reason: "in `go`, passed to confirmScreen and yesNo as onChoose" }, | |
| 55 | 55 | ]; |
| 56 | 56 | |
| 57 | 57 | /** How many lines above a call a `y` key check still guards it. */ |