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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11136-stripe-is-connected-by-pasting-one-key-c → main 6 commits fork 1 thread · 1 unresolved

  • Merged. scout/11136-stripe-is-connected-by-pasting-one-key-c was merged into main as 7ab3f87.

Review

Sign in to review this change.
16 files changed +674 −17
docs/CLOUD.md +2 −2
@@ -18,8 +18,8 @@ Beside `forge.json`, at the root of the repository:
1818 { "uses": [{ "vendor": "s3", "use": "backup", "why": "off-machine copy of the encrypted snapshot" }] }
1919 ```
2020
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.
2323 - `why`: one line, up to 300 characters.
2424
2525 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
125125 terminal, the MCP `forges` tool). A forge with no row is active on the plan
126126 its size names. Nothing reads a plan to limit a forge yet. How to use it:
127127 `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
55 - **Plans:** small, medium or large, the sizes. A forge never granted a plan reads as `active` on its size, source `granted`.
66 - **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).
77 - **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.
910
1011 ## Gift codes
1112 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 @@
11 [
2 "src/apps/cloud.ts",
32 "src/crew/governor_log.ts",
43 "src/flow/alarm_unproposed.ts",
54 "src/flow/approved_wait.ts",
src/actions/billing.ts +95
@@ -11,11 +11,27 @@ import { changesOf, entitlementOf, putEntitlement, type EntitlementChange } from
1111 import type { Entitlement } from "../billing/types.ts";
1212 import type { User } from "../core/users.ts";
1313 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";
1418 import { listChildrenNow } from "./spawn.ts";
1519 import { parseSize } from "../spawn/sizes.ts";
1620
1721 export type BillingError = "not_found" | "no_such_forge" | "bad_plan";
1822
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
1935 export const BILLING_WORDS: Record<Exclude<BillingError, "not_found">, string> = {
2036 no_such_forge: "No forge by that name was sent from here",
2137 bad_plan: "A plan is small, medium or large",
@@ -23,6 +39,8 @@ export const BILLING_WORDS: Record<Exclude<BillingError, "not_found">, string> =
2339
2440 export interface BillingView {
2541 readonly forges: readonly (Entitlement & { size: string })[];
42 /** The Stripe connection (mode, whether events are signed), never the key. */
43 readonly stripe: Connection | undefined;
2644 }
2745
2846 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
3250 if (!who.ok) return who;
3351 return ok({
3452 forges: listChildrenNow(app).map((c) => ({ ...entitlementOf(app.db, c.name, c.size), size: c.size })),
53 stripe: connectionOf(app.db),
3554 });
3655 }
3756
@@ -67,3 +86,79 @@ export function grantPlan(app: App, actor: User | undefined, forge: unknown, pla
6786 app.bus.append({ kind: "billing.plan", actorId: who.id, payload: { forge: to.forge, from: from.plan, to: to.plan, source: "granted" } });
6887 return ok({ entitlement, changes: changesOf(app.db, to.forge) });
6988 }
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(
496496 }
497497
498498 /** 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;
500500 /** 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;
502502 export type CloudVendor = (typeof CLOUD_VENDORS)[number];
503503 export type CloudUse = (typeof CLOUD_USES)[number];
504504
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
1818 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1919 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2020 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";
2222 import { economicsPage, economicsSet } from "../web/pages/economics.ts";
2323 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
2424 import { usagePage } from "../web/pages/usage.ts";
@@ -210,6 +210,9 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
210210 router.add("POST", "/settings/economics", economicsSet);
211211 router.add("GET", "/settings/billing", billingPage);
212212 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);
213216 router.add("POST", "/settings/billing/codes", billingMakeCode);
214217 router.add("POST", "/settings/billing/codes/revoke", billingRevokeCode);
215218 router.add("GET", "/settings/flags", flagsPage);
src/tui/screens/billing.ts +38 −5
@@ -3,9 +3,11 @@
33 * and the hand grant, through the same `readBilling` and `grantPlan` the page
44 * and API use. `s`, `m` or `l` grants the forge under the cursor that plan
55 * 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.
68 */
79
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";
911 import type { Ctx, Screen } from "../app/app.ts";
1012 import { binding } from "../app/keys.ts";
1113 import { fill, len, split } from "../ui/layout.ts";
@@ -13,10 +15,12 @@ import { style } from "../term/style.ts";
1315 import { truncate } from "../term/width.ts";
1416 import type { Session } from "../session.ts";
1517 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";
1719 import type { PaletteEntry } from "./entries.ts";
1820
1921 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]}.`;
2024 const PLANS: Record<string, string> = { s: "small", m: "medium", l: "large" };
2125
2226 export function billingRows(f: Session): ListRow[] {
@@ -30,21 +34,50 @@ export function billingScreen(f: Session, ctx: Ctx): Screen {
3034 let note = "";
3135 const screen: Screen = {
3236 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")],
3438 render(area, s) {
3539 const c = ctx.theme.c;
3640 const rows = billingRows(f);
3741 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";
3844 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 });
4046 return;
4147 }
4248 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;
4450 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
4551 drawList(s, body!, c, state, rows);
4652 },
4753 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 }
4881 const plan = PLANS[e.key];
4982 const rows = billingRows(f);
5083 const target = rows[state.index];
src/web/pages/billing.ts +56 −3
@@ -5,7 +5,7 @@
55 * Anyone else gets the not-found page.
66 */
77
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";
99 import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts";
1010 import { publicOrigin } from "../../core/public_origin.ts";
1111 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
@@ -19,7 +19,7 @@ import { btn, copyField, field as formField, selectField } from "../ui.ts";
1919 import { accountSections } from "./origins.ts";
2020
2121 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]);
2323
2424 /** A code just made: the plain code and its link, shown once and never kept. */
2525 interface Shown {
@@ -101,7 +101,28 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
101101 </li>`,
102102 );
103103 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">
105126 <h2>Plans</h2>
106127 <p class="muted small">Choose the plan each forge is on.</p>
107128 ${rows.length === 0
@@ -151,3 +172,35 @@ export async function billingGrant(ctx: Ctx): Promise<Response> {
151172 if (!set.ok && set.error === "not_found") return notFoundPage(ctx);
152173 return flashTo(ctx, PATH, set.ok ? { message: `${set.value.entitlement.forge} is on ${set.value.entitlement.plan}.` } : { tone: "danger", message: `${words(set.error)}.` });
153174 }
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";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { connectStripe } from "../src/actions/billing.ts";
9 import { connectionOf } from "../src/billing/connection.ts";
810 import { entitlementOf } from "../src/billing/store.ts";
911 import { createUser, type User } from "../src/core/users.ts";
1012 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
107109 expect(entitlementOf(app.db, "kid", "small").plan).toBe("large");
108110 tui.stop?.();
109111 });
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", () => {
137137 expect(page.status).toBe(200);
138138 const text = await page.text();
139139 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");
141141 expect(text).toContain('data-forge="kid"');
142142 expect((await send("GET", "/settings/billing", bob)).status).toBe(404);
143143 });
test/data-browser.test.ts +1
@@ -28,6 +28,7 @@ const AUDITED_CLEAR: string[] = [
2828 "degraded_span.sentence",
2929 "feed_chip.value",
3030 "fleet_operation.value",
31 "gift_redeem.code_id",
3132 "issue.body",
3233 "oauth_flow.detail",
3334 "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 }[] = [
5151 { file: "src/tui/screens/deploys.ts", action: "stopEnv", reason: "in `act`, called from `go` after the Stop confirm" },
5252 { file: "src/tui/screens/boards.ts", action: "deleteBoard", reason: "in `remove`, called only on the y of the delete y/n" },
5353 { 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" },
5555 ];
5656
5757 /** How many lines above a call a `y` key check still guards it. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs