Public: anyone can read this; the crew can change it.
The payment links from #11149 can be set from Settings → Billing and by API;… #11185
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11184-the-payment-links-from-11149-can-be-set → main 5 commits fork 2 threads · 2 unresolved
7 files
changed +127
−3
docs/features/billing-links.md +2 −1
| @@ -6,7 +6,8 @@ Where a buyer pays for a hosted forge (#11149). One https link per size (small, | ||
| 6 | 6 | |
| 7 | 7 | - Web: `/admin/limits`, the **Buy links** row; one address per size, empty clears it. |
| 8 | 8 | - Terminal: the palette entry **Buy links**; enter on a size asks for its address. |
| 9 | - API: `PATCH /api/v1/admin/settings` with `{"billingLinks": {"small": "https://…"}}` (admin token); `GET` on the same path reads them. | |
| 9 | - Web: `/settings/billing`, the **Payment links** section; one field per size, saved in one submit. | |
| 10 | - API: `PUT /api/v1/admin/billing/links` with `{"links": {...}}` (admin token), or `PATCH /api/v1/admin/settings` with `{"billingLinks": {"small": "https://…"}}` (admin token); `GET` on the same path reads them. | |
| 10 | 11 | - MCP: the `signup` tool with `billingLinks`; it plans first, send `dryRun: false` with an admin token to apply. Without `mode` or `billingLinks` it reads them. |
| 11 | 12 | |
| 12 | 13 | An address is https, at most 300 bytes, with no credentials, spaces or quotes. Only a forge admin who is a person may read or change them; each change is kept with who and when. |
src/contract/openapi/routes/index.ts +1
| @@ -376,6 +376,7 @@ export { route as postApiV1UserOriginsOauth } from "./postApiV1UserOriginsOauth. | ||
| 376 | 376 | export { route as postApiV1UserPassword } from "./postApiV1UserPassword.ts"; |
| 377 | 377 | export { route as postApiV1Users } from "./postApiV1Users.ts"; |
| 378 | 378 | export { route as postMcp } from "./postMcp.ts"; |
| 379 | export { route as putApiV1AdminBillingLinks } from "./putApiV1AdminBillingLinks.ts"; | |
| 379 | 380 | export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts"; |
| 380 | 381 | export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts"; |
| 381 | 382 | export { route as putApiV1AdminMachinesMachineDisksDevice } from "./putApiV1AdminMachinesMachineDisksDevice.ts"; |
src/contract/openapi/routes/putApiV1AdminBillingLinks.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "put", | |
| 5 | path: "/api/v1/admin/billing/links", | |
| 6 | summary: "Replace the payment links a buyer pays at, one per size (a person admin's token, admin scope); with any set the home page offers paid forges; answers { links }", | |
| 7 | auth: true, | |
| 8 | role: "admin", | |
| 9 | body: { links: "object of small, medium and large, each an https URL or empty; a size left out has none" }, | |
| 10 | refusals: { | |
| 11 | not_found: "the token is not a person who administers the forge (404)", | |
| 12 | invalid_billing_links: "a link is not an https URL, or a size is unknown (422)", | |
| 13 | }, | |
| 14 | }; |
src/http/api/admin.ts +11
| @@ -268,6 +268,17 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 268 | 268 | return apiError("invalid_plans", 422, changed.error.invalid); |
| 269 | 269 | }); |
| 270 | 270 | |
| 271 | // The payment links (`settings/billing.ts`): the whole object is replaced; the REST twin of MCP `signup` with billingLinks. | |
| 272 | router.add("PUT", "/api/v1/admin/billing/links", async (ctx) => { | |
| 273 | const denied = accountAdmin(ctx, "admin"); | |
| 274 | if (denied) return denied; | |
| 275 | const input = await body(ctx.request); | |
| 276 | const set = changeBillingLinks(ctx.app, ctx.auth.user, input["links"]); | |
| 277 | if (set.ok) return json({ links: set.value }); | |
| 278 | if (set.error === "not_found") return notFound(); | |
| 279 | return apiError(set.error, 422, BILLING_WORDS); | |
| 280 | }); | |
| 281 | ||
| 271 | 282 | // Per-account quotas (`settings/quotas.ts`), key by key: the REST twin of MCP `quotas`. |
| 272 | 283 | router.add("GET", "/api/v1/admin/quotas", (ctx) => { |
| 273 | 284 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
src/http/settings_routes.ts +2 −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, stripeConnect, stripeDisconnect, stripeSecret } from "../web/pages/billing.ts"; | |
| 21 | import { billingPage, billingGrant, billingSetLinks, 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,7 @@ 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/links", billingSetLinks); | |
| 213 | 214 | router.add("POST", "/settings/billing/stripe/connect", stripeConnect); |
| 214 | 215 | router.add("POST", "/settings/billing/stripe/secret", stripeSecret); |
| 215 | 216 | router.add("POST", "/settings/billing/stripe/disconnect", stripeDisconnect); |
src/web/pages/billing.ts +45 −1
| @@ -11,6 +11,9 @@ import { publicOrigin } from "../../core/public_origin.ts"; | ||
| 11 | 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 13 | import { SIZES } from "../../spawn/sizes.ts"; |
| 14 | import { BILLING_SIZES, parseBillingLinks } from "../../settings/billing.ts"; | |
| 15 | import { getPlans } from "../../settings/plans.ts"; | |
| 16 | import { changeBillingLinks, readBillingLinks } from "../../actions/signup.ts"; | |
| 14 | 17 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 15 | 18 | import { field, readForm } from "../form.ts"; |
| 16 | 19 | import { html, type Html } from "../html.ts"; |
| @@ -76,6 +79,46 @@ function codesSection(ctx: Ctx, shown: Shown | undefined, values: Record<string, | ||
| 76 | 79 | |
| 77 | 80 | const codeWords = (e: CodeError): string => (e === "not_found" ? "Not found" : CODE_ERRORS[e]); |
| 78 | 81 | |
| 82 | /** The Payment links section: one https field per size a paid plan sits on, saved in one submit. */ | |
| 83 | function linksSection(ctx: Ctx, retry?: { values: Record<string, string>; error: string }): Html | null { | |
| 84 | const links = readBillingLinks(ctx.app, ctx.auth.user); | |
| 85 | if (!links.ok) return null; | |
| 86 | const paid = getPlans(ctx.app.db).filter((p) => p.priceUsd > 0); | |
| 87 | const fields = BILLING_SIZES.map((z) => { | |
| 88 | const named = paid.filter((p) => p.size === z).map((p) => p.name).join(", "); | |
| 89 | return { size: z, named }; | |
| 90 | }).filter((f) => f.named !== "" || links.value[f.size] !== undefined); | |
| 91 | return html`<section class="settings-block" id="links"> | |
| 92 | <h2>Payment links</h2> | |
| 93 | <p class="muted small">Where a buyer pays for each paid plan. With any set, the home page offers paid forges; empty clears one.</p> | |
| 94 | ${retry === undefined ? null : html`<p class="error" role="alert">${retry.error}</p>`} | |
| 95 | <form class="stack" method="post" action="${PATH}/links"> | |
| 96 | ${fields.map((f) => | |
| 97 | formField({ label: `${f.named === "" ? f.size : f.named} (${f.size})`, name: f.size, type: "url", value: retry?.values[f.size] ?? links.value[f.size] ?? "", hint: "https://" }), | |
| 98 | )} | |
| 99 | ${btn("Save payment links", { variant: "primary" })} | |
| 100 | </form> | |
| 101 | </section>`; | |
| 102 | } | |
| 103 | ||
| 104 | // POST /settings/billing/links | |
| 105 | export async function billingSetLinks(ctx: Ctx): Promise<Response> { | |
| 106 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); | |
| 107 | const form = await readForm(ctx, "admin"); | |
| 108 | if (form instanceof Response) return form; | |
| 109 | const current = readBillingLinks(ctx.app, ctx.auth.user); | |
| 110 | const next: Record<string, string> = current.ok ? { ...current.value } : {}; | |
| 111 | // Only the fields the form carries change; a size without a field keeps its link. | |
| 112 | for (const z of BILLING_SIZES) if (form.has(z)) next[z] = field(form, z); | |
| 113 | const changed = changeBillingLinks(ctx.app, ctx.auth.user, next); | |
| 114 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); | |
| 115 | if (changed.ok) return flashTo(ctx, `${PATH}#links`, { message: "Payment links saved." }); | |
| 116 | // Name the sizes refused, and show the form again with what was typed. | |
| 117 | const refused = BILLING_SIZES.filter((z) => !parseBillingLinks({ [z]: next[z] }).ok); | |
| 118 | const error = `${refused.join(" and ")}: each payment link is an https address with no spaces, or empty. Nothing was saved.`; | |
| 119 | return billingView(ctx, undefined, {}, undefined, 422, { values: next, error }); | |
| 120 | } | |
| 121 | ||
| 79 | 122 | // GET /settings/billing |
| 80 | 123 | export function billingPage(ctx: Ctx): Response { |
| 81 | 124 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -83,7 +126,7 @@ export function billingPage(ctx: Ctx): Response { | ||
| 83 | 126 | return billingView(ctx); |
| 84 | 127 | } |
| 85 | 128 | |
| 86 | function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200): Response { | |
| 129 | function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {}, error?: string, status = 200, links?: { values: Record<string, string>; error: string }): Response { | |
| 87 | 130 | const read = readBilling(ctx.app, ctx.auth.user); |
| 88 | 131 | if (!read.ok) return notFoundPage(ctx); |
| 89 | 132 | const b = read.value; |
| @@ -129,6 +172,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 129 | 172 | ? html`<p class="muted">No forge has been sent from here.</p>` |
| 130 | 173 | : html`<ul class="list setting-list">${rows}</ul>`} |
| 131 | 174 | </section> |
| 175 | ${linksSection(ctx, links)} | |
| 132 | 176 | ${codesSection(ctx, shown, values, error)}`; |
| 133 | 177 | const response = page({ |
| 134 | 178 | ctx, |
test/billing-links.test.ts +52
| @@ -187,3 +187,55 @@ test("MCP signup: billingLinks plans first, needs an admin token to apply, and a | ||
| 187 | 187 | expect((await tool(adminToken, {})).value.billingLinks).toEqual(LINKS); |
| 188 | 188 | await patch(adminToken, { billingLinks: {} }); |
| 189 | 189 | }); |
| 190 | ||
| 191 | const links = (cookie: string, fields: Record<string, string>) => | |
| 192 | fetch(`${servers.url}/settings/billing/links`, { | |
| 193 | method: "POST", | |
| 194 | headers: { cookie, origin: servers.url, accept: "text/html" }, | |
| 195 | body: new URLSearchParams(fields), | |
| 196 | redirect: "manual", | |
| 197 | }); | |
| 198 | ||
| 199 | test("Settings → Billing: set three links, the home shows them; clear, the home is as before", async () => { | |
| 200 | const page = await (await fetch(`${servers.url}/settings/billing`, { headers: { cookie: adminCookie } })).text(); | |
| 201 | expect(page).toContain("Payment links"); | |
| 202 | for (const z of ["small", "medium", "large"]) expect(page).toContain(`name="${z}"`); | |
| 203 | expect((await links(memberCookie, LINKS)).status).toBe(404); | |
| 204 | const bad = await links(adminCookie, { ...LINKS, large: "http://nope.test" }); | |
| 205 | expect(bad.status).toBe(422); | |
| 206 | const badPage = await bad.text(); | |
| 207 | expect(badPage).toContain("large: each payment link is an https address"); | |
| 208 | expect(badPage).not.toContain("small and"); | |
| 209 | // What was typed stays in the form. | |
| 210 | expect(badPage).toContain('value="http://nope.test"'); | |
| 211 | expect(badPage).toContain(`value="${LINKS.small}"`); | |
| 212 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 213 | expect((await links(adminCookie, LINKS)).status).toBe(303); | |
| 214 | const text = await home(); | |
| 215 | expect(text.match(/Get a forge/g)).toHaveLength(3); | |
| 216 | for (const u of Object.values(LINKS)) expect(text).toContain(`href="${u}"`); | |
| 217 | expect(text).not.toContain("Hosted forges are"); | |
| 218 | await links(adminCookie, { small: "", medium: "", large: "" }); | |
| 219 | const none = await home(); | |
| 220 | expect(none).toContain("Hosted forges are"); | |
| 221 | expect(none).not.toContain("Get a forge"); | |
| 222 | }); | |
| 223 | ||
| 224 | test("PUT /api/v1/admin/billing/links replaces the links; a member and a bad link are refused; the spec names it", async () => { | |
| 225 | const put = (token: string, links: unknown) => | |
| 226 | fetch(`${servers.url}/api/v1/admin/billing/links`, { | |
| 227 | method: "PUT", | |
| 228 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 229 | body: JSON.stringify({ links }), | |
| 230 | }); | |
| 231 | expect((await put(memberToken, LINKS)).status).toBe(403); | |
| 232 | expect((await put(adminToken, { small: "http://x.test" })).status).toBe(422); | |
| 233 | const ok = await put(adminToken, LINKS); | |
| 234 | expect(ok.status).toBe(200); | |
| 235 | expect(((await ok.json()) as { links: unknown }).links).toEqual(LINKS); | |
| 236 | expect(getBillingLinks(app.db)).toEqual(LINKS); | |
| 237 | await put(adminToken, {}); | |
| 238 | expect(getBillingLinks(app.db)).toEqual({}); | |
| 239 | const spec = await (await fetch(`${servers.url}/api/v1/openapi.json`)).text(); | |
| 240 | expect(spec).toContain("/api/v1/admin/billing/links"); | |
| 241 | }); |
new above
offline
The error flash is generic and the typed values are lost on redirect; name the size that was refused so the admin can fix it (principle 8, plain words that say what to do).