diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index ba125f89..2ae6295f 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -104,9 +104,9 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { // Pro's, so the account's own checkout, its trial first expect(f).toContain("P R O"); expect(f).toContain("$15 / developer / month"); - expect(f).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(f).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=pro&start=1`); expect(f).toContain("Apps and their plans Shop no plan chosen"); - expect(JSON.parse((await said(() => plan({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, limits: { apps: 1 }, offer: { plan: { id: "team" } }, cli: { paid: false, checkout_url: `${HOST}/account/billing?plan=team&start=1`, trial_days: 3 } }); + expect(JSON.parse((await said(() => plan({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, limits: { apps: 1 }, offer: { plan: { id: "team" } }, cli: { paid: false, checkout_url: `${HOST}/account/billing?plan=pro&start=1`, trial_days: 3 } }); }); it("Free says so after a command, with the way up; profile and upgrade name the app on Free and its plan page", async () => { @@ -127,13 +127,13 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(f).toContain("Apps and their plans Shop no plan chosen"); expect(f).toContain("Start the 3-day free trial $15 / developer / month npx genclass upgrade"); expect(f).toContain("Sign out npx genclass logout"); - expect(JSON.parse((await said(() => profile({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, apps: [{ name: "Shop", plan: { id: "free" } }], upgrade_url: `${HOST}/account/billing?plan=team&start=1`, cli: { paid: false } }); + expect(JSON.parse((await said(() => profile({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, apps: [{ name: "Shop", plan: { id: "free" } }], upgrade_url: `${HOST}/account/billing?plan=pro&start=1`, cli: { paid: false } }); // upgrade: Pro's card and its checkout, for the account (the genclass command is Pro's) const u = await said(() => upgrade(me.o, me.deps)); expect(u.code).toBe(0); expect(tooWide(u.text)).toEqual([]); expect(flat(u.text)).toContain("Start your 3-day free trial"); - expect(flat(u.text)).toContain(`start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(flat(u.text)).toContain(`start your 3-day free trial at ${HOST}/account/billing?plan=pro&start=1`); // on Team: no line, nothing to upgrade sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(me.app.id); expect(await freeLine(me.o, me.deps)).toBe(false); @@ -175,7 +175,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { const no = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); expect(no.code).toBe(1); // the genclass command is Pro's: the invite, like every command, says where Pro starts - expect(flat(no.text)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(flat(no.text)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=pro&start=1`); expect(flat(no.text)).toContain("npx genclass upgrade"); team3(sql, me.account.id); const made = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); @@ -221,10 +221,10 @@ describe("genclass is Pro's: a view the server refuses with 402 says where Pro s const v = await said(() => analytics(me.o, me.deps)); expect(v.code).toBe(1); expect(tooWide(v.text)).toEqual([]); - expect(flat(v.text)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(flat(v.text)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=pro&start=1`); expect(flat(v.text)).toContain("npx genclass upgrade"); expect(v.text).not.toContain("Could not read the dashboard"); - expect(JSON.parse((await said(() => analytics({ ...me.o, json: true }, me.deps))).text)).toEqual({ ok: false, error: "the genclass command comes with Pro", needs_pro: true, checkout_url: `${HOST}/account/billing?plan=team&start=1`, trial_days: 3, run: "npx genclass upgrade" }); + expect(JSON.parse((await said(() => analytics({ ...me.o, json: true }, me.deps))).text)).toEqual({ ok: false, error: "the genclass command comes with Pro", needs_pro: true, checkout_url: `${HOST}/account/billing?plan=pro&start=1`, trial_days: 3, run: "npx genclass upgrade" }); }); }); @@ -253,7 +253,7 @@ describe("genclass connections (src/manage.mjs connections)", () => { expect(code).toBe(1); expect(tooWide(text)).toEqual([]); const f = flat(text); - expect(f).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(f).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=pro&start=1`); expect(f).toContain("npx genclass upgrade"); expect(f).not.toContain("not set up"); // setting one up on Free: the same, said before anything is asked for @@ -262,7 +262,7 @@ describe("genclass connections (src/manage.mjs connections)", () => { expect(flat(no.text)).toContain("The genclass command comes with Pro"); expect(flat(no.text)).toContain("npx genclass upgrade"); expect(no.text).not.toContain("Slack incoming webhook URL"); - expect(JSON.parse((await said(() => connections({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: false, needs_pro: true, checkout_url: `${HOST}/account/billing?plan=team&start=1`, run: "npx genclass upgrade" }); + expect(JSON.parse((await said(() => connections({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: false, needs_pro: true, checkout_url: `${HOST}/account/billing?plan=pro&start=1`, run: "npx genclass upgrade" }); }); it("Team: add one (what is typed is never shown), test it, turn it off, remove it", async () => { diff --git a/packages/cli/test/server.test.ts b/packages/cli/test/server.test.ts index 32fd1e3c..9f1dcc32 100644 --- a/packages/cli/test/server.test.ts +++ b/packages/cli/test/server.test.ts @@ -149,7 +149,7 @@ describe("genclass against the collector (src/api.mjs, telemetry-worker/src/cli. designs: [], ideasLocked: 2, designsLocked: 1, - upgradeUrl: `${HOST}/account/billing?plan=team&from=cli`, + upgradeUrl: `${HOST}/account/billing?plan=pro&from=cli`, upgradePlan: { plan: "Pro", price: "$15 / developer / month", trialDays: 3 }, limited: false, }; diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index 8e1cee2c..b0377c4b 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -334,11 +334,11 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s price: "$15 / developer / month", summary: "Everything for up to 3 apps, per developer.", features: ["Every suggestion, with a prompt for your agent", "GitHub App on private repositories", "Visitor analytics", "Alerts to Sentry, PostHog, Datadog, Slack or a webhook"], - checkout_url: `${HOST}/account/billing?plan=team&start=1`, + checkout_url: `${HOST}/account/billing?plan=pro&start=1`, trial_days: 3, free: { name: "Free", apps: 1, history_days: 7, suggestions_shown: 1 }, }; - const CHECKOUT = `${HOST}/account/billing?plan=team&start=1`; + const CHECKOUT = `${HOST}/account/billing?plan=pro&start=1`; const LOCKED = { paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: CHECKOUT }; const PRO = { plan: { id: "team", name: "Pro" }, cli: { paid: true } }; const plain = (t: string) => t.replace(/\u001b\[[0-9;]*m/g, ""); diff --git a/packages/cli/test/suggest.test.ts b/packages/cli/test/suggest.test.ts index 2b3a8dc0..7dc0ae08 100644 --- a/packages/cli/test/suggest.test.ts +++ b/packages/cli/test/suggest.test.ts @@ -143,7 +143,7 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => { const text = await printed(async () => (code = await suggest({ cwd: dir, url: "https://shop.example", yes: true, json: true }, deps(w)))); expect(code).toBe(0); const j = JSON.parse(text); - expect(j).toMatchObject({ site: "https://shop.example", source: "checks", plan: { id: "free", name: "Free" }, upgrade_url: `${HOST}/account/billing?plan=team&from=cli`, ideas: [], designs: [] }); + expect(j).toMatchObject({ site: "https://shop.example", source: "checks", plan: { id: "free", name: "Free" }, upgrade_url: `${HOST}/account/billing?plan=pro&from=cli`, ideas: [], designs: [] }); // Free: the first one in full, the rest counted and not listed expect(j.failed).toHaveLength(1); expect(j.failed[0]).toMatchObject({ n: 1, why: expect.any(String), do: expect.any(String), prompt: expect.any(String) }); diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index cda90934..b5e48032 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -663,7 +663,8 @@ their link opens the dashboard (24 hours, 7 and 30 days). written yet); with one seat the owner is told to add seats in Manage billing; a plan GenClass grants (a paid plan under a grant of the same plan too), and every account on a server with `PLAN_GATES = "open"`, no limit. A member works under its team's plan in the genclass command (`src/cli.ts` workingAccess: suggestions, `ask`, the plan it - names; `cli.via` is `"team"`). The owner is the first seat, then members in + names; `cli.via` is `"team"`). An app such a member adds goes into the team's account (`src/team.ts` appHome), so + the team's plan covers it and the owner sees it. The owner is the first seat, then members in the order they joined. A member beyond the seats (fewer seats, or the plan ended) keeps the row and loses the access until there is a seat again: no billing change removes anyone. - **What a member can do.** Open the dashboards and read the stats of the owner's apps, shown with the owner's plan @@ -855,7 +856,7 @@ does about it. | `GET /api/billing` | browser session: `{ plan, subscription_status, current_period_end, cancel_at_period_end, on_sale, access, seats }` (`on_sale`: a plan is sold and this account can buy it); `access` is `paid`, `comp` (GenClass's grant), `open` (this server's `PLAN_GATES`) or null (Free); `seats`: the people the account holds, null for no limit | | `POST /api/billing/trial` | 410: GenClass has no trial any more ([Grants and no trial](#grants-enterprise-and-no-trial)) | | `GET /account/trial` | `/account`, through `/signup` when signed out (genclass.dev's pricing page linked here until 2026-10-19) | -| `GET /account/billing?project=` | one app's plan page (`billing/plans.json` version 5): "Choose a plan for ", Pro's card (its name, price, first 5 lines and "N more"), Start your 3-day free trial (the account's Checkout, coming back here) or Continue with Pro for a paying account, and a very small "Skip" under it (Free, after the reason popup; it read "Try for free (limited)" until 2026-11-13), none for an app past what the account's plan covers (Free: since 13 November 2026 one app, the oldest; Pro: Enterprise by email); the app's live fixes this month, once there are some. `&from=cli` (the genclass command's plan step, which waits for the choice; the choice goes on to the app's dashboard), `&new=1` (an app just added on the website, which goes on to its setup), `&start=1` (opens the Checkout at once). `?plan=team` without a project opens the plan page of the account's first app on Free. | +| `GET /account/billing?project=` | one app's plan page (`billing/plans.json` version 5): "Choose a plan for ", Pro's card (its name, price, first 5 lines and "N more"), Start your 3-day free trial (the account's Checkout, coming back here) or Continue with Pro for a paying account, and a very small "Skip" under it (Free, after the reason popup; it read "Try for free (limited)" until 2026-11-13), none for an app past what the account's plan covers (Free: since 13 November 2026 one app, the oldest; Pro: Enterprise by email); the app's live fixes this month, once there are some. `&from=cli` (the genclass command's plan step, which waits for the choice; the choice goes on to the app's dashboard), `&new=1` (an app just added on the website, which goes on to its setup), `&start=1` (opens the Checkout at once). `?plan=pro` (links name a plan in lower case, `plans.ts` planParam; its id, `team`, still works) without a project opens the plan page of the account's first app on Free. | | `POST /api/billing/projects//free` | `{ reason, note? }`, browser session and our `Origin`: Free chosen for one app on its plan page, after the popup "What's stopping you from paying?" (`reason` one of `FREE_REASONS` in `src/billing.ts`: price, trying, approval, feature, unsure, other; `note` at most 500 characters). Keeps the answer (`plan_feedback`, migration 0017), writes `projects.plan_chosen` (which `GET /api/cli/overview` reports as `plan_chosen`) and tells the owner (`NOTIFY_WEBHOOK`, kind `free_chosen`). 400 without a reason, 409 for an app past the ones Free covers, 404 for another account's app. Until an own app on Free has a choice (where Team is sold), `/account/apps/` redirects to its plan page. | | `GET /account/billing` | the plan, its renewal or end date, the plans side by side (Free, Team and Enterprise), the seats box and Upgrade (and Switch, with two paid plans) buttons, the Team section (members, invite links, the teams the account is in) and Manage billing (only to an account that can buy), Talk to us (a mailto link) for Enterprise, and which plan is sold through Link (Managed Payments; none today). With no plan sold it shows no Upgrade button, no "billed monthly" and no Stripe footer. `?plan=` (a pricing page button) survives the sign-in redirect and highlights and focuses that plan's button. Back from Checkout (`?done=1`) while the plan still reads Free, or back from a switch (`?done=1&to=`) while the plan is still the old one, it shows no buttons and waits for the webhook. | | `GET /api/account/projects//integrations` | browser session: the app's forwarding destinations, masked | diff --git a/telemetry-worker/src/account-pages.ts b/telemetry-worker/src/account-pages.ts index 6db11f7f..cb31387d 100644 --- a/telemetry-worker/src/account-pages.ts +++ b/telemetry-worker/src/account-pages.ts @@ -96,6 +96,9 @@ dl.req{display:grid;grid-template-columns:minmax(110px,auto) minmax(0,1fr);borde dl.req dt,dl.req dd{padding:11px 0;border-bottom:1px solid var(--rule);font-size:14.5px;min-width:0;overflow-wrap:anywhere} dl.req dt{font:500 12.5px var(--mono);color:var(--ink3);padding-top:13px;padding-right:16px} ul.scope{margin:10px 0 0;padding-left:20px;font-size:14.5px;line-height:1.5;color:var(--ink2)} +details.scope-d{margin-top:16px;font-size:14.5px;color:var(--ink2)} +details.scope-d summary{cursor:pointer;color:var(--ink)} +details.scope-d .lead{margin-top:10px} ul.scope li{margin-top:5px} .acts{display:flex;gap:10px;margin-top:22px} .acts .btn{flex:1} @@ -573,11 +576,9 @@ ${signInWays({ ...v.providers, next: v.next })}
${esc(r.userCode)}
Requested by
${r.client ? `${esc(r.client)} (as the command line reported itself)` : 'Unknown client (it did not name itself as the GenClass command line)'}
-
Requested
${time(r.created, "time")} ยท ${time(r.created, "ago")}
-
Code expires
${time(r.expires, "time")}
Account
${who}
-

If you approve, the terminal that shows this code can do this, for 90 days or until you revoke it on your account page:

+
What this terminal can do for 90 days
  • list and create apps, add an app from its private dashboard link, and write a project key into your project
  • show your apps' dashboards, visitor analytics and stored events
  • @@ -585,7 +586,8 @@ ${signInWays({ ...v.providers, next: v.next })}
  • show and change where an app's alerts are sent, its funnels, and its private dashboard link
  • show your plan and your team, and make a team invite link
-

It cannot change how you sign in, pay for anything, remove people, or delete an app or your account.

+

It cannot change how you sign in, pay for anything, remove people, or delete an app or your account. You can revoke it any time on your account page.

+

Approve only a sign-in you started. If you did not run ${runOf(r.client)} yourself just now, or someone sent you this link or code, choose Deny.

diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index b2f10562..2a30eece 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -82,9 +82,9 @@ import { forgetAccount as forgetAccountGithub, unlinkProject } from "./github/st import { json, mediaType, readCapped } from "./http.js"; import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js"; import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js"; -import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, pitchLines, PLANS_FILE, priceText, problemsLock, projectAccess, viewRanges } from "./plans.js"; +import { accountAccess, accountBilling, canBuy, firstPlanWith, planParam, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, pitchLines, PLANS_FILE, priceText, problemsLock, projectAccess, viewRanges } from "./plans.js"; import { trialFor } from "./billing.js"; -import { activeOwners, forgetTeam, membershipsOf } from "./team.js"; +import { activeOwners, appHome, forgetTeam, membershipsOf } from "./team.js"; import { cancelForDeletion } from "./stripe.js"; import { b64url, @@ -1585,12 +1585,22 @@ async function overAppLimit(c: C, accountId: string): Promise { return null; } +/** + * An app a team's member added to the team's account counts as chosen: its plan is the owner's, so the owner is not + * sent to "Choose a plan" for it (an app the plan does not cover still is: the lock shows its plan page). + */ +async function teamAppChosen(c: C, id: string | null): Promise { + if (id) await c.db.prepare("UPDATE projects SET plan_chosen = ? WHERE id = ? AND plan_chosen IS NULL").bind(c.now.toISOString(), id).run(); +} + async function createAccountProject(c: C): Promise { const a = await writer(c); if (a instanceof Response) return a; const body = await readJson(c, 4096); if (body instanceof Response) return body; - const over = await overAppLimit(c, a.account.id); + // a team's member without a plan of its own adds the app to the team's account, under the team's plan (appHome) + const home = await appHome(c.db, c.env, a.account.id, c.now); + const over = await overAppLimit(c, home); if (over) return over; if (c.env.CREATE_LIMITER) { const { success } = await c.env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:account:${a.account.id}`)).slice(0, 32) }); @@ -1600,8 +1610,9 @@ async function createAccountProject(c: C): Promise { if ((await projectsCreatedSince(c.db, new Date(c.now.getTime() - MINUTE), "account")) >= MAX_ACCOUNT_PROJECTS_PER_MINUTE) { return fail(429, "too many apps created; try again in a minute", { "retry-after": "60" }); } - const p = await createProject(c.db, sanitizeName(body.name), c.now, a.account.id); - const project = await ownedProject(c, a.account.id, p.id); + const p = await createProject(c.db, sanitizeName(body.name), c.now, home); + if (home !== a.account.id) await teamAppChosen(c, p.id); + const project = await readableProject(c, a.account.id, p.id); return json(201, { project: projectJson(c, project!) }); } @@ -1907,7 +1918,8 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E if (!a) return null; const json201 = (request.headers.get("accept") ?? "").includes("application/json"); const elsewhere = (): Response => (json201 ? json(200, { url: "/account/new", copiedUrl: "/account/new", token: null, command: null }) : redirect("/account/new", [], 303)); - if (!originOk(c) || (await overAppLimit(c, a.account.id))) return elsewhere(); + const home = await appHome(c.db, env, a.account.id, c.now); + if (!originOk(c) || (await overAppLimit(c, home))) return elsewhere(); if (c.env.CREATE_LIMITER) { const { success } = await c.env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:account:${a.account.id}`)).slice(0, 32) }); if (!success) return elsewhere(); @@ -1918,8 +1930,13 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E const body = await readJson(c); if (!(body instanceof Response)) name = sanitizeName(body.name); } - const p = await createProject(c.db, name, c.now, a.account.id); + const p = await createProject(c.db, name, c.now, home); const to = `/account/apps/${p.id}#connect`; + // a team's member adds it to the team's account (appHome): the owner's plan covers it, so it goes to its setup + if (home !== a.account.id) { + await teamAppChosen(c, p.id); + return json201 ? json(201, { url: to, copiedUrl: to, token: p.token, command: setupCommand(p.token) }) : redirect(to, [], 303); + } // the form's way on is the new app's plan page first (it then goes on to the app's setup) return json201 ? json(201, { url: to, copiedUrl: to, token: p.token, command: setupCommand(p.token) }) : redirect(`/account/billing?project=${encodeURIComponent(p.id ?? "")}&new=1`, [], 303); } @@ -1966,7 +1983,7 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E price: priceText(sold), days: (await trialFor(c.db, a.account.id, sold)) || null, features: pitchLines(sold), - start: `/account/billing?plan=${encodeURIComponent(sold.id)}&start=1`, + start: `/account/billing?plan=${encodeURIComponent(planParam(sold))}&start=1`, }; } return page(200, (n) => accountPage(n, view)); diff --git a/telemetry-worker/src/billing.ts b/telemetry-worker/src/billing.ts index c2371a2e..fcfcf0cd 100644 --- a/telemetry-worker/src/billing.ts +++ b/telemetry-worker/src/billing.ts @@ -46,6 +46,8 @@ import { PLANS, PLANS_FILE, planById, + planFromParam, + planParam, projectAccess, accountProjectsAccess, stripeMode, @@ -105,7 +107,7 @@ export async function overBudget(lim: RateLimiterLike | undefined, bucket: strin export const tooMany = (): Response => fail(429, "too many requests from this account; try again in a minute", { "retry-after": "60" }); /** The plan a link names (?plan=, from the pricing page), if it is a paid plan. */ -const pickedPlan = (v: string | null): Plan | null => PAID_PLANS.find((p) => p.id === v) ?? null; +const pickedPlan = (v: string | null): Plan | null => planFromParam(v); /** A browser-only write: a cookie session with our Origin. */ export async function browserWrite(c: C, what: string): Promise { @@ -220,7 +222,7 @@ export async function routeBilling(request: Request, url: URL, path: string, env const go = !!picked && url.searchParams.get("start") === "1"; const a = await webSession(c); // the plan picked on the pricing page survives the sign-in - if (!a) return redirect(`/login?next=${encodeURIComponent(`/account/billing${picked ? `?plan=${picked.id}${go ? "&start=1" : ""}` : ""}`)}`); + if (!a) return redirect(`/login?next=${encodeURIComponent(`/account/billing${picked ? `?plan=${planParam(picked)}${go ? "&start=1" : ""}` : ""}`)}`); // a plan sold per project, picked on the pricing page or started from an older genclass command: the plan page // of the account's first app still on Free, where it is bought for that app if (picked?.per_project === true) { diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts index 13bc4e54..bff6c9d9 100644 --- a/telemetry-worker/src/cli.ts +++ b/telemetry-worker/src/cli.ts @@ -55,7 +55,7 @@ import { isKind, KINDS } from "./forward.js"; import { githubApp } from "./github/config.js"; import { repoStatusForAccount } from "./github/http.js"; import { json } from "./http.js"; -import { accountAccess, accountBilling, accountProjectsAccess, better, canBuy, entitled, firstPaidWith, firstPlanWith, FREE, hasAnalytics, hasAsk, hasDashboard, hasProblems, PAID_PLANS, pickRange, priceText, problemsLock, projectAccess, viewRanges, type Access } from "./plans.js"; +import { accountAccess, accountBilling, accountProjectsAccess, better, planParam, canBuy, entitled, firstPaidWith, firstPlanWith, FREE, hasAnalytics, hasAsk, hasDashboard, hasProblems, PAID_PLANS, pickRange, priceText, problemsLock, projectAccess, viewRanges, type Access } from "./plans.js"; import { liveFixUsage } from "./usage.js"; import { appKey, askModel, byWeight, claimRun, hasModel, keepAnswer, lastAnswer, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem, type SuggestAnswer } from "./suggest.js"; import { activeOwners, createInvite, isTeamError, membershipsOf, teamOf } from "./team.js"; @@ -107,7 +107,7 @@ async function cliJson(c: C, a: Auth): Promise<{ paid: true; via: "plan" | "team const via = await cliVia(c, a); if (via !== null) return { paid: true, via }; const plan = PAID_PLANS[0]!; - return { paid: false, plan: plan.name, price: priceText(plan), trial_days: (await trialFor(c.db, a.account.id, plan)) || null, checkout_url: `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1` }; + return { paid: false, plan: plan.name, price: priceText(plan), trial_days: (await trialFor(c.db, a.account.id, plan)) || null, checkout_url: `${c.cfg.origin}/account/billing?plan=${planParam(plan)}&start=1` }; } /** The routes that are Pro's for a CLI session: all but the overview and the account. */ @@ -170,7 +170,7 @@ async function offerFor(c: C, account: { id: string; email: string | null }, acc summary: plan.summary, // the plan's own "N days free" line only while the account still has the trial features: plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text) && (trialDays || !/days free/i.test(f.text))).map((f) => f.text).slice(0, 12), - checkout_url: one ? `${c.cfg.origin}/account/billing?project=${encodeURIComponent(one)}&from=cli` : `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1`, + checkout_url: one ? `${c.cfg.origin}/account/billing?project=${encodeURIComponent(one)}&from=cli` : `${c.cfg.origin}/account/billing?plan=${planParam(plan)}&start=1`, trial_days: trialDays, // a plan sold by units of apps (a design never sold): always null, kept because the genclass command reads it apps_per_unit: null, @@ -272,7 +272,7 @@ async function suggest(c: C): Promise { designs_locked: partial ? designs.length : 0, // the answer was kept from an earlier run (Free sees it again; a plan out of runs for the day sees its last) cached_at: created, - upgrade_url: locked.length || hidden || limited ? `${c.cfg.origin}/account/billing?plan=team&from=cli` : null, + upgrade_url: locked.length || hidden || limited ? `${c.cfg.origin}/account/billing?plan=${planParam(pro ?? PAID_PLANS[0]!)}&from=cli` : null, upgrade_plan: pro ? { name: pro.name, price: priceText(pro), trial_days: pro.trial_days ?? null } : null, remaining_today: used === null ? Math.max(0, lim.per_day - (await usedToday(c.db, a.account.id, c.now))) : Math.max(0, lim.per_day - used), limited, diff --git a/telemetry-worker/src/plans.ts b/telemetry-worker/src/plans.ts index 921ca2c9..d6256d31 100644 --- a/telemetry-worker/src/plans.ts +++ b/telemetry-worker/src/plans.ts @@ -212,6 +212,14 @@ export function pitchLines(plan: Plan): string[] { return plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text) && !/days free/i.test(f.text)).map((f) => f.text); } +/** + * A plan as its links name it (`?plan=pro`): its name in lower case, so a link says what every screen says. A link + * with the plan's id (`?plan=team`, the links sent before 2026-11-13) still works (planFromParam). + */ +export const planParam = (plan: Plan): string => plan.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +/** The plan sold under a link's `plan` (its planParam or its id), or null. */ +export const planFromParam = (v: string | null): Plan | null => (v ? (PAID_PLANS.find((p) => planParam(p) === v.toLowerCase() || p.id === v) ?? null) : null); + /** The most seats one Checkout sells and one account holds on a per_seat plan; a larger team is agreed by email. */ export const MAX_SEATS = 200; diff --git a/telemetry-worker/src/team.ts b/telemetry-worker/src/team.ts index 8ec3c81e..c1cb56d1 100644 --- a/telemetry-worker/src/team.ts +++ b/telemetry-worker/src/team.ts @@ -15,6 +15,8 @@ // and unexpired invites together never exceed the seats when an invite is made; a join checks the seats again. // Expired invites go in the daily sweep (src/auth.ts authRetention). // - Joining never merges or links accounts, and a member's own apps, plan and billing are untouched by it. +// - An app a member with no plan of its own adds goes into the paying team's account (appHome): the team's plan covers +// it (Pro covers up to 3 apps an account, whatever the seats: the owner's call, 2026-11-13) and the owner sees it. import type { Env } from "./collector.js"; import { randomBase62, sha256Hex, type D1Like } from "./dashboard.js"; @@ -115,6 +117,19 @@ export async function activeOwners(db: D1Like, env: Env, memberId: string, now: return out; } +/** + * The account an app `accountId` adds goes into: its own, unless it has no plan of its own and an active place in a + * team whose owner has one; then that owner's (the first such team it joined), so the team's plan covers the app + * instead of Free, and the owner sees it with the rest of the team's apps. A member keeps reading it as a team app. + */ +export async function appHome(db: D1Like, env: Env, accountId: string, now: Date): Promise { + if ((await accountAccess(db, accountId, env, now)).source !== null) return accountId; + for (const owner of await activeOwners(db, env, accountId, now)) { + if ((await accountAccess(db, owner, env, now)).source !== null) return owner; + } + return accountId; +} + /** `memberId` may read the apps of `ownerId` now (it is the owner, or an active member of its team). */ export async function canRead(db: D1Like, env: Env, memberId: string, ownerId: string | null | undefined, now: Date): Promise { if (!ownerId) return false; diff --git a/telemetry-worker/test/auth.test.ts b/telemetry-worker/test/auth.test.ts index 542bb8e5..661335e9 100644 --- a/telemetry-worker/test/auth.test.ts +++ b/telemetry-worker/test/auth.test.ts @@ -545,6 +545,12 @@ describe("accounts: device flow for the CLI (RFC 8628)", () => { expect(page).toContain("show and change where an app's alerts are sent, its funnels, and its private dashboard link"); expect(page).toContain("make a team invite link"); expect(page).toContain("It cannot change how you sign in, pay for anything, remove people, or delete an app or your account."); + // short first (the 0.3.7 walk): the list is on the page, folded under one line; the warning stays open + expect(page).toContain('
What this terminal can do for 90 days'); + expect(page.indexOf('id="scopelist"')).toBeGreaterThan(page.indexOf('
")); + expect(page.indexOf("Approve only a sign-in you started")).toBeGreaterThan(page.indexOf("
")); + expect(page).not.toContain("
Code expires
"); // the page names the command that asked: the runtime CLI's login here, `npx genclass` for the genclass command expect(page).toContain("If you did not run npx @genclass/runtime@beta login yourself just now"); const cli = (await (await call(env, "POST", "/api/device/code", { body: { client_name: "genclass 0.2.2" }, origin: null })).json()) as any; diff --git a/telemetry-worker/test/cli-account.test.ts b/telemetry-worker/test/cli-account.test.ts index b8e2d34a..2bad9c23 100644 --- a/telemetry-worker/test/cli-account.test.ts +++ b/telemetry-worker/test/cli-account.test.ts @@ -38,7 +38,7 @@ const LIVE = { STRIPE_SECRET_KEY: "sk_live_fake", INTEGRATIONS_KEY: SEAL }; /** A server that sells nothing (no Stripe key): the genclass command is not locked there, so Free meets its plan's own rules. */ const NOT_SOLD = { INTEGRATIONS_KEY: SEAL }; /** The 402 a Free CLI session gets where Pro is on sale (src/cli.ts cliPaid, the owner's call of 2026-11-13). */ -const NEEDS_PRO = { ok: false, needs_pro: true, paid: false, plan: "Pro", checkout_url: "https://genclass.dev/account/billing?plan=team&start=1" }; +const NEEDS_PRO = { ok: false, needs_pro: true, paid: false, plan: "Pro", checkout_url: "https://genclass.dev/account/billing?plan=pro&start=1" }; const get = (env: E, path: string, bearer: string) => call(env, "GET", path, { bearer, origin: null }); const send = (env: E, method: string, path: string, bearer: string, body?: unknown, fetch?: Fetch) => call(env, method, path, { bearer, origin: null, body, fetch }); const json = async (r: Promise | Response) => (await (await r).json()) as any; @@ -118,7 +118,7 @@ describe("genclass command: GET /api/cli/account and POST /api/cli/team/invite", // nothing of how the account signs in or pays expect(JSON.stringify(j)).not.toMatch(/stripe|customer|password|passkey/i); // the genclass command is Pro's where Pro is sold: the account says so, and every other route answers 402 - expect(j.cli).toEqual({ paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: "https://genclass.dev/account/billing?plan=team&start=1" }); + expect(j.cli).toEqual({ paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: "https://genclass.dev/account/billing?plan=pro&start=1" }); const no = await send(env, "POST", "/api/cli/team/invite", me.bearer, {}); expect(no.status).toBe(402); expect(await no.json()).toMatchObject(NEEDS_PRO); @@ -450,7 +450,7 @@ describe("genclass command: POST /api/cli/ask (src/ask.ts)", () => { }); describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of 2026-11-13, 'make the entire CLI paid')", () => { - const CHECKOUT = "https://genclass.dev/account/billing?plan=team&start=1"; + const CHECKOUT = "https://genclass.dev/account/billing?plan=pro&start=1"; /** What every gated route answers a Free CLI session where Pro is on sale. */ const LOCKED = { ok: false, diff --git a/telemetry-worker/test/cli.test.ts b/telemetry-worker/test/cli.test.ts index e8c98a94..4eeae8f4 100644 --- a/telemetry-worker/test/cli.test.ts +++ b/telemetry-worker/test/cli.test.ts @@ -246,7 +246,7 @@ describe("genclass command: POST /api/cli/suggest (src/suggest.ts header)", () = designs_locked: 0, cached_at: null, // the plan page that starts Pro from the command, and what Pro is - upgrade_url: "https://genclass.dev/account/billing?plan=team&from=cli", + upgrade_url: "https://genclass.dev/account/billing?plan=pro&from=cli", upgrade_plan: { name: "Pro", price: "$15 / developer / month", trial_days: 3 }, remaining_today: 3, limited: false, @@ -340,7 +340,7 @@ describe("genclass command: POST /api/cli/suggest (src/suggest.ts header)", () = expect([j.source, j.remaining_today, j.limited, j.items.length, j.locked, j.cached_at]).toEqual(["model", left, false, 1, 4, null]); } const over = (await (await post(s.env, me.bearer, at(4), model.fetch)).json()) as any; - expect([over.source, over.remaining_today, over.limited, over.upgrade_url, over.cached_at]).toEqual(["checks", 0, true, "https://genclass.dev/account/billing?plan=team&from=cli", null]); + expect([over.source, over.remaining_today, over.limited, over.upgrade_url, over.cached_at]).toEqual(["checks", 0, true, "https://genclass.dev/account/billing?plan=pro&from=cli", null]); expect(over.items.map((i: any) => i.id)).toEqual(["text-without-js"]); expect(model.calls).toHaveLength(3); expect(s.sql.rows("SELECT runs FROM suggest_usage")).toEqual([{ runs: 3 }]); @@ -398,7 +398,7 @@ describe("genclass command: POST /api/cli/suggest (src/suggest.ts header)", () = ideas_locked: 3, designs_locked: 2, cached_at: null, - upgrade_url: "https://genclass.dev/account/billing?plan=team&from=cli", + upgrade_url: "https://genclass.dev/account/billing?plan=pro&from=cli", upgrade_plan: { name: "Pro", price: "$15 / developer / month", trial_days: 3 }, remaining_today: 2, limited: false, @@ -444,7 +444,7 @@ describe("genclass command: POST /api/cli/suggest (src/suggest.ts header)", () = const about = "Polar Parts sells spare parts for snow machines. " + "x".repeat(400); for (const app of [{ name: "with-about", about }, { name: "with-site", site: "https://withsite.example" }]) { const j = (await (await post(s.env, me.bearer, { app, checks: [], passed: 20 }, model.fetch)).json()) as any; - expect([j.source, j.total, j.items, j.locked, j.ideas, j.ideas_locked, j.upgrade_url], app.name).toEqual(["model", 0, [], 0, [], 1, "https://genclass.dev/account/billing?plan=team&from=cli"]); + expect([j.source, j.total, j.items, j.locked, j.ideas, j.ideas_locked, j.upgrade_url], app.name).toEqual(["model", 0, [], 0, [], 1, "https://genclass.dev/account/billing?plan=pro&from=cli"]); } expect(model.calls).toHaveLength(2); // what the site says it is about goes to the model cut to 300 characters diff --git a/telemetry-worker/test/project-plans.test.ts b/telemetry-worker/test/project-plans.test.ts index a5c17174..aeb6c2b3 100644 --- a/telemetry-worker/test/project-plans.test.ts +++ b/telemetry-worker/test/project-plans.test.ts @@ -703,7 +703,7 @@ describe("the website: a new account's first screen is Pro's trial, with a tiny const page = await home.text(); expect(page).toContain('

Start your 3-day free trial

'); expect(page).toContain('

Pro, $15 / developer / month

'); - expect(page).toContain('Start your 3-day free trial'); + expect(page).toContain('Start your 3-day free trial'); expect(page).toContain("

3 days free, then $15 / developer / month. Cancel before the trial ends and you pay nothing.

"); // 5 lines shown, the rest folded, and no summary repeating the price (the owner, 2026-11-13: less written) expect(page).toContain(`
${pitchLines(PLANS[1]!).length - 5} more`); @@ -713,8 +713,10 @@ describe("the website: a new account's first screen is Pro's trial, with a tiny expect(page).not.toContain("
  • Everything in Free
  • "); expect(page).not.toContain("Create your first project"); // the trial's own button opens the account's checkout at once (the billing page, go) - const billing = await (await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: t.cookie })).text(); + const billing = await (await call(t.env, "GET", "/account/billing?plan=pro&start=1", { cookie: t.cookie })).text(); expect(billing).toContain('"go":true'); + // links name the plan as every screen does (plans.ts planParam); one sent before, with the plan's id, still works + expect(await (await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: t.cookie })).text()).toContain('"go":true'); }); it("an account with an app, on Pro, or where nothing is sold sees its projects as before", async () => { @@ -783,22 +785,22 @@ describe("the website: an own app opens its plan page until a plan is chosen the expect(await at(3)).toBe(200); }); - it("?plan=team&start=1 (the pricing page, the genclass command's checkout_url) opens the account's billing page, which starts Pro's Checkout", async () => { + it("?plan=pro&start=1 (the pricing page, the genclass command's checkout_url) opens the account's billing page, which starts Pro's Checkout", async () => { const t = setup(LIVE); const a = await signedIn(t.env); // no app needed: Pro is the account's - const none = await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie }); + const none = await call(t.env, "GET", "/account/billing?plan=pro&start=1", { cookie: a.cookie }); expect(none.status).toBe(200); const html = await none.text(); expect(html).toContain('"go":true'); expect(html).toContain('data-plan="team" data-pick>Upgrade to Pro'); await createApp(t.env, a.cookie, "Shop"); - expect((await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie })).status).toBe(200); + expect((await call(t.env, "GET", "/account/billing?plan=pro&start=1", { cookie: a.cookie })).status).toBe(200); // a plain link shows the page, and starts nothing - const plain = await text(call(t.env, "GET", "/account/billing?plan=team", { cookie: a.cookie })); + const plain = await text(call(t.env, "GET", "/account/billing?plan=pro", { cookie: a.cookie })); expect(plain).not.toContain('"go"'); // signed out: the plan survives the sign-in - expect((await call(t.env, "GET", "/account/billing?plan=team&start=1", {})).headers.get("location")).toBe("/login?next=%2Faccount%2Fbilling%3Fplan%3Dteam%26start%3D1"); + expect((await call(t.env, "GET", "/account/billing?plan=pro&start=1", {})).headers.get("location")).toBe("/login?next=%2Faccount%2Fbilling%3Fplan%3Dpro%26start%3D1"); }); }); diff --git a/telemetry-worker/test/team.test.ts b/telemetry-worker/test/team.test.ts index 6b1cb0e2..0f6c33c8 100644 --- a/telemetry-worker/test/team.test.ts +++ b/telemetry-worker/test/team.test.ts @@ -170,7 +170,7 @@ describe("a member reads the owner's dashboards with the owner's plan, and chang }; it("the app is in the member's list (marked shared), opens, and its stats have Team's analytics and 90 days", async () => { - const { env, owner, bob, app } = await joined(); + const { env, sql, owner, bob, app } = await joined(); const me = await json(call(env, "GET", "/api/me", { cookie: bob.cookie })); expect(me.projects).toMatchObject([{ id: app.id, name: "Shop", shared: true }]); // the owner's own list has no such mark @@ -202,11 +202,19 @@ describe("a member reads the owner's dashboards with the owner's plan, and chang expect(st.locked).toBeUndefined(); expect(st.totals.ix_views).toBe(1); expect(st.totals.detections).toBe(1); - // the member's own account is still Free: its own app shows 7 days and no analytics - const own = await createApp(env, bob.cookie, "Mine"); - const mine = await json(call(env, "GET", `/api/account/projects/${own.id}/stats?range=90d`, { cookie: bob.cookie })); - expect(mine.range).toBe("7d"); - expect(mine.locked.analytics.plan).toBe("Team"); + // the member's own account is still Free, so an app it adds goes into the team's account (src/team.ts appHome): + // the team's plan covers it (90 days, analytics), the owner sees it, and nobody is sent to "Choose a plan" for it + const added = await createApp(env, bob.cookie, "Mine"); + const mine = await json(call(env, "GET", `/api/account/projects/${added.id}/stats?range=90d`, { cookie: bob.cookie })); + expect(mine.range).toBe("90d"); + expect(mine.locked?.analytics).toBeUndefined(); + expect(sql.rows("SELECT account_id FROM projects WHERE id = ?", added.id)).toEqual([{ account_id: owner.account.id }]); + expect((await json(call(env, "GET", "/api/me", { cookie: owner.cookie }))).projects.map((p: { name: string }) => p.name).sort()).toEqual(["Mine", "Shop"]); + expect((await call(env, "GET", `/account/apps/${added.id}`, { cookie: owner.cookie })).status).toBe(200); + // the website's form does the same, and goes on to the app's setup, not a plan page + const form = await call(env, "POST", "/start", { cookie: bob.cookie, body: { name: "Form app" }, headers: { accept: "application/json" } }); + expect(form.status).toBe(201); + expect(sql.rows("SELECT account_id FROM projects WHERE name = 'Form app'")).toEqual([{ account_id: owner.account.id }]); }); it("every write and every paid setting stays the owner's: delete, private link, funnels, forwarding, export, invites", async () => {