diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index a43456e1ed..2f93064573 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -158,6 +158,7 @@ const stubDirectory = Layer.succeed(MemberDirectory)({ const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("revoke does not touch billing"), ensureCustomer: () => Effect.die("revoke does not touch billing"), + findCustomer: () => Effect.die("revoke does not touch billing"), checkExecutionBalance: () => Effect.die("revoke does not touch billing"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index d166e49b83..e5c3173e5a 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -175,6 +175,7 @@ const seedOrganization = (id: string) => const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("feeders do not read billing"), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("feeders do not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -673,6 +674,7 @@ describe("session handlers read membership from the mirror", () => { const deletingAutumn = Layer.succeed(AutumnService)({ use: () => Effect.succeed({} as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("deletion does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -866,6 +868,7 @@ describe("session handlers read membership from the mirror", () => { return Effect.fail(failure); }), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("deletion does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1234,6 +1237,7 @@ describe("account service writes through to the mirror", () => { subscriptions: [{ planId: "team", status: "active" }], } as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("invite does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1314,6 +1318,7 @@ describe("account service writes through to the mirror", () => { const freeAutumn = Layer.succeed(AutumnService)({ use: () => Effect.succeed({ subscriptions: [] } as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("invite does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1541,6 +1546,7 @@ describe("seat reporter", () => { const recording = Layer.succeed(AutumnService)({ use: () => Effect.die("the seat reporter sets seats, it does not read"), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("the seat reporter does not check balances"), trackExecution: () => Effect.void, setMemberSeats: (organizationId, seats) => diff --git a/apps/cloud/src/auth/v2-import.test.ts b/apps/cloud/src/auth/v2-import.test.ts new file mode 100644 index 0000000000..584f7cb568 --- /dev/null +++ b/apps/cloud/src/auth/v2-import.test.ts @@ -0,0 +1,284 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { AutumnService } from "../extensions/billing/service"; +import { + isPaidForV2Import, + liveV2ImportSource, + v2ImportEligible, + v2ImportUrl, + type V2ImportSource, + type V2ImportSubscription, +} from "./v2-import"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; + +const USER = "user_signed_in"; +const OTHER = "user_other"; +const ORG = "org_example"; + +interface Facts { + readonly emailVerified?: boolean; + readonly members?: ReadonlyArray; + readonly subscriptions?: ReadonlyArray; + readonly failing?: "user" | "members" | "billing"; +} + +/** A source answering from fixed facts, recording which reads were made. */ +const sourceOf = (facts: Facts) => { + const reads: string[] = []; + const answer = (read: string, value: A) => + Effect.suspend(() => { + reads.push(read); + return facts.failing === read ? Effect.fail("unavailable") : Effect.succeed(value); + }); + const source: V2ImportSource = { + emailVerified: (userId) => { + expect(userId).toBe(USER); + return answer("user", facts.emailVerified ?? true); + }, + activeMemberIds: (organizationId) => { + expect(organizationId).toBe(ORG); + return answer("members", facts.members ?? [USER]); + }, + subscriptions: (organizationId) => { + expect(organizationId).toBe(ORG); + return answer("billing", facts.subscriptions ?? [{ planId: "free", status: "active" }]); + }, + }; + return { source, reads }; +}; + +describe("v2 import eligibility", () => { + it.effect("admits a verified user who is the free org's only active member", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({}); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(true); + expect(reads).toEqual(["user", "members", "billing"]); + }), + ); + + it.effect("admits an org on the free pay-as-you-go plan or with no subscription", () => + Effect.gen(function* () { + for (const subscriptions of [[{ planId: "free-pay-as-you-go", status: "active" }], []]) { + const { source } = sourceOf({ subscriptions }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(true); + } + }), + ); + + it.effect("refuses an unverified email without reading the org", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({ emailVerified: false }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + expect(reads).toEqual(["user"]); + }), + ); + + it.effect("refuses an org with more than one active member, without reading billing", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({ members: [USER, OTHER] }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + expect(reads).toEqual(["user", "members"]); + }), + ); + + it.effect("refuses an org whose only active member is someone else", () => + Effect.gen(function* () { + for (const members of [[OTHER], []]) { + const { source } = sourceOf({ members }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + } + }), + ); + + it.effect("refuses an org on a paid plan", () => + Effect.gen(function* () { + const { source } = sourceOf({ subscriptions: [{ planId: "team", status: "active" }] }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + }), + ); + + it.effect("refuses when any read is unavailable", () => + Effect.gen(function* () { + for (const failing of ["user", "members", "billing"] as const) { + const { source } = sourceOf({ failing }); + expect(yield* v2ImportEligible(source, USER, ORG), failing).toBe(false); + } + }), + ); +}); + +describe("v2 import billing read", () => { + const AUTUMN_URL = "https://autumn.example.test"; + interface AutumnRequest { + readonly url: string; + readonly method: string; + readonly body: string; + } + + let requests: AutumnRequest[] = []; + const originalFetch = globalThis.fetch; + const { AUTUMN_SECRET_KEY, AUTUMN_API_URL } = env; + + /** Autumn answering every request with `answer`, recording each request. */ + const autumnAnswers = (answer: () => Response | Promise) => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: String(init?.body), + }); + return answer(); + }) as typeof fetch; + }; + const customer = (subscriptions: ReadonlyArray<{ plan_id: string; status: string }>) => + Response.json({ id: ORG, name: null, balances: {}, subscriptions, purchases: [] }); + + beforeEach(() => { + requests = []; + env.AUTUMN_SECRET_KEY = "am_sk_test"; + env.AUTUMN_API_URL = AUTUMN_URL; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + env.AUTUMN_SECRET_KEY = AUTUMN_SECRET_KEY; + env.AUTUMN_API_URL = AUTUMN_API_URL; + }); + + // WorkOS is not read here: the user and member answers come from `sourceOf`. + const unreadWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), + ); + + /** The eligibility answer for a verified sole member, billing read from Autumn. */ + const eligibleWithLiveBilling = Effect.gen(function* () { + const live = yield* liveV2ImportSource; + const source = { ...sourceOf({}).source, subscriptions: live.subscriptions }; + return yield* v2ImportEligible(source, USER, ORG); + }).pipe(Effect.provide(Layer.merge(AutumnService.Default, unreadWorkOS))); + + it.effect("looks the customer up read-only, so an org Autumn has never seen stays absent", () => + Effect.gen(function* () { + autumnAnswers(() => + Response.json( + { message: `Customer ${ORG} not found`, code: "customer_not_found" }, + { status: 404 }, + ), + ); + expect(yield* eligibleWithLiveBilling, "no customer is free").toBe(true); + expect(requests).toEqual([ + { url: `${AUTUMN_URL}/v1/customers.get`, method: "POST", body: `{"customer_id":"${ORG}"}` }, + ]); + }), + ); + + it.effect("admits a customer on a free plan or with no subscription", () => + Effect.gen(function* () { + for (const subscriptions of [ + [{ plan_id: "free", status: "active" }], + [{ plan_id: "free-pay-as-you-go", status: "active" }], + [], + ]) { + autumnAnswers(() => customer(subscriptions)); + expect(yield* eligibleWithLiveBilling, JSON.stringify(subscriptions)).toBe(true); + } + }), + ); + + it.effect("refuses a customer on a paid plan", () => + Effect.gen(function* () { + autumnAnswers(() => customer([{ plan_id: "team", status: "active" }])); + expect(yield* eligibleWithLiveBilling).toBe(false); + }), + ); + + it.effect("admits a customer whose paid plan has expired", () => + Effect.gen(function* () { + autumnAnswers(() => + customer([ + { plan_id: "free", status: "active" }, + { plan_id: "team", status: "expired" }, + ]), + ); + expect(yield* eligibleWithLiveBilling).toBe(true); + }), + ); + + it.effect("refuses when the lookup fails or answers something unexpected", () => + Effect.gen(function* () { + const failures: ReadonlyArray Response | Promise]> = [ + [ + "network error", + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fetch rejects with a TypeError on a network failure; that rejection is the fixture + () => Promise.reject(new TypeError("fetch failed")), + ], + ["server error", () => Response.json({ message: "boom" }, { status: 500 })], + ["unauthorized", () => Response.json({ code: "invalid_secret_key" }, { status: 401 })], + ["404 without customer_not_found", () => new Response("Not Found", { status: 404 })], + ["body without subscriptions", () => Response.json({ id: ORG })], + ["body that is not JSON", () => new Response("", { status: 200 })], + ]; + for (const [name, answer] of failures) { + autumnAnswers(answer); + expect(yield* eligibleWithLiveBilling, name).toBe(false); + } + }), + ); + + it.effect("refuses without an Autumn secret, without calling Autumn", () => + Effect.gen(function* () { + delete env.AUTUMN_SECRET_KEY; + autumnAnswers(() => customer([])); + expect(yield* eligibleWithLiveBilling).toBe(false); + expect(requests).toEqual([]); + }), + ); +}); + +describe("v2 import paid plans", () => { + it("counts every plan outside v2's free list that has not expired", () => { + expect(isPaidForV2Import([{ planId: "team", status: "active" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "enterprise", status: "trialing" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "team", status: "past_due" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "team", status: "scheduled" }])).toBe(true); + // A grandfathered or later plan v1 never listed is paid too. + expect(isPaidForV2Import([{ planId: "professional", status: "active" }])).toBe(true); + expect( + isPaidForV2Import([ + { planId: "free", status: "active" }, + { planId: "team", status: "active" }, + ]), + ).toBe(true); + }); + + it("does not count free plans or expired subscriptions", () => { + expect(isPaidForV2Import([])).toBe(false); + expect(isPaidForV2Import([{ planId: "free", status: "active" }])).toBe(false); + expect(isPaidForV2Import([{ planId: "free-pay-as-you-go", status: "active" }])).toBe(false); + expect(isPaidForV2Import([{ planId: "team", status: "expired" }])).toBe(false); + }); +}); + +describe("v2 import url", () => { + const configured = { + V2: { fetch: () => Promise.resolve(new Response(null)) }, + V2_SIGN_UP_URL: "https://app.example.test/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }; + + it("is the import page on v2's app host, the sign-up page's origin", () => { + expect(v2ImportUrl(configured)?.href).toBe("https://app.example.test/v1-import"); + }); + + it("is absent without the v2 settings or with broken ones", () => { + expect(v2ImportUrl({})).toBeNull(); + expect(v2ImportUrl({ ...configured, V2_SIGN_UP_URL: "not a url" })).toBeNull(); + expect(v2ImportUrl({ ...configured, V2: undefined })).toBeNull(); + }); +}); diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index b5c99af906..07c5e38f01 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -227,6 +227,7 @@ const stubDb = Layer.succeed(DbService)({ db: {} as never }); const stubAutumn = Layer.succeed(AutumnService)({ use: unread("the delete does not consult billing"), ensureCustomer: unread("the delete does not provision billing"), + findCustomer: unread("the delete does not read billing"), checkExecutionBalance: unread("the delete does not check balances"), trackExecution: unread("the delete does not track usage"), setMemberSeats: unread("the delete does not count seats"), diff --git a/bun.lock b/bun.lock index c9436aa422..b2431ad8d7 100644 --- a/bun.lock +++ b/bun.lock @@ -1272,6 +1272,7 @@ "patchedDependencies": { "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", "agents@0.17.3": "patches/agents@0.17.3.patch", + "@executor-js/emulate@0.14.3-mfa.0": "patches/@executor-js%2Femulate@0.14.3-mfa.0.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", diff --git a/e2e/cloud/v2-import-link.test.ts b/e2e/cloud/v2-import-link.test.ts new file mode 100644 index 0000000000..d31b28bd5c --- /dev/null +++ b/e2e/cloud/v2-import-link.test.ts @@ -0,0 +1,162 @@ +// Cloud-only: the sidebar's "Move to Executor v2" link to v2's workspace +// import. v2 imports an organization only for a verified user who is its one +// active member and only when it is not on a paid plan, so v1 shows the link to +// exactly those users. Each scenario reads the real `/api/auth/v2-import` +// answer the dashboard renders from, and the browser checks the sidebar itself. +// +// An unverified email is covered by `apps/cloud/src/auth/v2-import.test.ts`: +// the WorkOS emulator signs every user in with a verified email. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Autumn, Billing, Browser, Target } from "../src/services"; +import { settle, visit } from "../src/surfaces/browser"; +import type { Identity, Target as TargetShape } from "../src/target"; +import { activeOrg, forBrowser, joinOrg } from "./support/session"; + +const LINK_NAME = "Move to Executor v2"; +const V2_IMPORT_API = "/api/auth/v2-import"; + +/** The link the dashboard would render for this identity's org, or null. */ +const v2ImportUrlOf = (target: TargetShape, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const response = await fetch(new URL(V2_IMPORT_API, target.baseUrl), { + headers: identity.headers, + }); + if (!response.ok) throw new Error(`${V2_IMPORT_API} failed (${response.status})`); + return ((await response.json()) as { url: string | null }).url; + }); + +scenario( + "v2 import · the only member of a free org sees the link to v2's import", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + const url = yield* v2ImportUrlOf(target, identity); + expect(url, "a sole member of a free org is offered the import").not.toBeNull(); + const parsed = new URL(url!); + expect(parsed.pathname, "the link opens v2's import page").toBe("/v1-import"); + expect(parsed.origin, "the import page is on v2's host, not v1's").not.toBe( + new URL(target.baseUrl).origin, + ); + + const { slug } = yield* activeOrg(target, identity); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the dashboard", async () => { + await visit(page, `/${slug}`); + }); + await step(`See "${LINK_NAME}" in the sidebar`, async () => { + const link = page.getByRole("link", { name: LINK_NAME }); + await link.waitFor(); + expect(await link.getAttribute("href"), "the sidebar links to v2's import").toBe(url); + expect(await link.getAttribute("target"), "it opens in a new tab").toBe("_blank"); + }); + }); + }), +); + +scenario( + "v2 import · billing is read without creating a customer, and no customer is free", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const identity = yield* target.newIdentity(); + const { id } = yield* activeOrg(target, identity); + const lookups = autumn + .ledgerFor("customers.get") + .pipe(Effect.map((entries) => entries.filter((entry) => entry.customerId === id))); + + // The org's customer exists, so the read-only lookup answers it: free. + expect( + yield* v2ImportUrlOf(target, identity), + "a free customer is offered the import", + ).not.toBeNull(); + const answered = yield* lookups; + expect(answered.length, "the billing read is Autumn's read-only customers.get").toBeGreaterThan( + 0, + ); + expect(answered.every((entry) => !entry.faulted)).toBe(true); + + // Autumn answering that it has no such customer is an answer too: free. + yield* Effect.gen(function* () { + yield* autumn.armFault({ + match: { operationId: "customers.get" }, + response: { + status: 404, + body: { message: "Customer not found", code: "customer_not_found" }, + }, + times: 1, + }); + expect( + yield* v2ImportUrlOf(target, identity), + "an org Autumn has no customer for is offered the import", + ).not.toBeNull(); + expect( + (yield* lookups).some((entry) => entry.faulted), + "the lookup reached Autumn and was answered customer_not_found", + ).toBe(true); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); + +scenario( + "v2 import · members of a shared org do not see the link", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + + expect( + yield* v2ImportUrlOf(target, admin), + "the admin of a two-member org is not offered the import", + ).toBeNull(); + expect( + yield* v2ImportUrlOf(target, member), + "a member of a two-member org is not offered the import", + ).toBeNull(); + + const { slug } = yield* activeOrg(target, admin); + yield* browser.session(forBrowser(admin), async ({ page, step }) => { + await step("Open the shared org's dashboard", async () => { + const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API)); + await visit(page, `/${slug}`); + await answered; + await settle(page); + }); + await step(`See no "${LINK_NAME}" in the sidebar`, async () => { + await page.getByRole("button", { name: "Get support" }).waitFor(); + expect(await page.getByRole("link", { name: LINK_NAME }).count()).toBe(0); + }); + }); + }), +); + +scenario( + "v2 import · the only member of a paid org does not see the link", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const identity = yield* target.newIdentity(); + const { id } = yield* activeOrg(target, identity); + + // Enterprise activates inline in the emulator (no checkout), like the + // rate-limit scenario's paid org. + yield* autumn.attachPlan(id, "enterprise"); + + expect( + yield* v2ImportUrlOf(target, identity), + "a paid org is not offered the import", + ).toBeNull(); + }), +); diff --git a/package.json b/package.json index 1b1e9cfded..4ef32460ed 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "agents@0.17.3": "patches/agents@0.17.3.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", - "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch" + "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", + "@executor-js/emulate@0.14.3-mfa.0": "patches/@executor-js%2Femulate@0.14.3-mfa.0.patch" } } diff --git a/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch b/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch new file mode 100644 index 0000000000..dd32e79318 --- /dev/null +++ b/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch @@ -0,0 +1,30 @@ +diff --git a/dist/dist-AJUH5YLR.js b/dist/dist-AJUH5YLR.js +index 0590e15752d2536b7d8e63f378c92ac7910a91be..de28354560fa4ef00122ef4407b667d9f84a16c6 100644 +--- a/dist/dist-AJUH5YLR.js ++++ b/dist/dist-AJUH5YLR.js +@@ -234,6 +234,17 @@ function autumnApiRoutes(ctx) { + const customer = ensureCustomer(as(), id, data); + return c.json(serializeCustomer(as(), customer, { expand: parseExpand(body.expand) })); + }); ++ app.post("/v1/customers.get", async (c) => { ++ const body = await c.req.json().catch(() => ({})); ++ const id = String(body.customer_id ?? body.customerId ?? ""); ++ if (!id) return c.json({ message: "customer_id is required", code: "invalid_request" }, 400); ++ const store2 = as(); ++ const customer = store2.customers.findOneBy("customer_id", id); ++ if (!customer) { ++ return c.json({ message: `Customer ${id} not found`, code: "customer_not_found" }, 404); ++ } ++ return c.json(serializeCustomer(store2, customer, { expand: parseExpand(body.expand) })); ++ }); + app.post("/v1/customers.list", async (c) => { + const store2 = as(); + const customers = store2.customers.all().map((customer) => serializeCustomer(store2, customer)); +@@ -1318,6 +1329,7 @@ var manifest = { + path: "/v1/customers.get_or_create", + status: "hand-authored" + }, ++ { operationId: "customers.get", method: "POST", path: "/v1/customers.get", status: "hand-authored" }, + { operationId: "customers.list", method: "POST", path: "/v1/customers.list", status: "hand-authored" }, + { operationId: "customers.update", method: "POST", path: "/v1/customers.update", status: "hand-authored" }, + { operationId: "balances.track", method: "POST", path: "/v1/balances.track", status: "hand-authored" },