diff --git a/src/api/routes.ts b/src/api/routes.ts index 2943336199..4061e37265 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -150,6 +150,7 @@ import { type LoopOverMentionCommandName, } from "../github/commands"; import { handleGitHubWebhook, handleOrbRelay } from "../github/webhook"; +import { requestAprRepoTransfer } from "../orb/apr-repo-transfer"; import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest"; import { handleAmsIngest } from "../ams/ingest"; import { handleOrbWebhook } from "../orb/webhook"; @@ -532,6 +533,18 @@ const evaluateEscalationSchema = z.object({ killRequested: z.boolean().optional(), }); +// #7742: customer-facing APR transfer request. Completion is resolved SERVER-SIDE via loadAprIdeaCompletion — +// never accepted from the body (that was the #8000 Superagent P1). `.strict()` rejects any attempt to smuggle +// `ideaComplete` (or other unknown keys). Plan/payment fields are deliberately absent. +const requestAprTransferSchema = z + .object({ + installationId: z.number().int().positive(), + repoFullName: z.string().min(1).max(200), + newOwner: z.string().min(1).max(100), + ideaId: z.string().min(1).max(200).optional(), + }) + .strict(); + // #6744: mirrors proposeActionShape in src/mcp/server.ts VERBATIM, minus owner/repo (they are path params), so // POST /v1/repos/:owner/:repo/agent/pending-actions can never stage an action the loopover_propose_action MCP // tool would reject, or vice versa. actionClass stays the 7-value propose set (a subset of AgentActionClass). @@ -3727,6 +3740,21 @@ export function createApp() { return c.json(evaluateEscalation(parsed.data)); }); + // #7742: customer-facing "request transfer" for an APR repo. Request-only (nothing auto-offers). Completion is + // resolved SERVER-SIDE by requestAprRepoTransfer → loadAprIdeaCompletion (fail-closed until #7591/#7664 persist + // a record) — the body must NOT carry ideaComplete (`.strict()` schema rejects smuggling attempts; that was + // the #8000 Superagent P1). Rejected gate → 409 without touching GitHub; initiation is still pending-acceptance + // (202), never "transfer done". + app.post("/v1/loop/request-apr-transfer", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = requestAprTransferSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_request_apr_transfer_request", issues: parsed.error.issues }, 400); + const result = await requestAprRepoTransfer(c.env, parsed.data); + if (result.status === "rejected") return c.json(result, 409); + if (result.status === "failed") return c.json(result, 502); + return c.json(result, 202); + }); + // #6752: REST mirror of the loopover_build_results_payload MCP tool, bringing it to the same REST/CLI parity // its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. Both are pure, source-free // composers over caller-supplied, already-computed iteration metadata, so this route delegates to the same diff --git a/src/orb/apr-idea-completion.ts b/src/orb/apr-idea-completion.ts new file mode 100644 index 0000000000..0a37d6a5f2 --- /dev/null +++ b/src/orb/apr-idea-completion.ts @@ -0,0 +1,28 @@ +// Trusted server-side idea-completion lookup for APR transfer gating (#7742). +// +// Until #7591/#7664 persist a completion record, this ALWAYS returns incomplete (fail closed). A client +// boolean must never substitute for this — that was the #8000 Superagent P1. When a persisted lookup lands, +// replace the body of this function (keep the signature) so every caller picks it up. + +export type AprIdeaCompletionLookupInput = { + repoFullName: string; + /** Optional idea/submission id for the eventual #7664 record lookup. */ + ideaId?: string | undefined; +}; + +export type AprIdeaCompletionLookup = ( + env: Env, + input: AprIdeaCompletionLookupInput, +) => Promise<{ ideaComplete: boolean }>; + +/** + * Resolve whether an APR idea's task-graph is complete (#7591). Fail-closed until a persisted record exists. + * Declared return is `{ ideaComplete: boolean }` so a future persisted lookup (and test doubles) can return true; + * today's body always returns false. + */ +export async function loadAprIdeaCompletion( + _env: Env, + _input: AprIdeaCompletionLookupInput, +): Promise<{ ideaComplete: boolean }> { + return { ideaComplete: false }; +} diff --git a/src/orb/apr-repo-transfer.ts b/src/orb/apr-repo-transfer.ts index ef5f97ceb3..e56e07a79f 100644 --- a/src/orb/apr-repo-transfer.ts +++ b/src/orb/apr-repo-transfer.ts @@ -2,14 +2,21 @@ // under a loopover-controlled GitHub org (#7637) and can later be transferred, on explicit customer request, to // the customer's own account via GitHub's standard repository-transfer flow. // -// This module owns ONLY the initiation call. Detecting when a pending transfer is accepted or expires, any -// customer-facing UI, and the policy of *when* a transfer should be offered are deliberately out of scope -// (separate follow-ons per #7638). No provisioning or repo-creation logic lives here. +// Initiation (#7638) lives here; the customer-facing request gate (#7742) does too. Detecting when a pending +// transfer is accepted or expires (#7741) remains out of scope. No provisioning or repo-creation logic lives +// here. Transfer is NEVER offered or nudged proactively in v1 — request-only, and only once a TRUSTED +// server-side idea-completion signal (#7591) says the task-graph is done. Callers must NEVER supply that +// boolean over the wire; {@link loadAprIdeaCompletion} is the sole source, and it fail-closes until #7664 +// persists a completion record. import { createInstallationToken } from "../github/app"; import { githubHeaders, timeoutFetch } from "../github/client"; +import { loadAprIdeaCompletion, type AprIdeaCompletionLookup } from "./apr-idea-completion"; // `Env` is the ambient Cloudflare Worker binding interface (worker-configuration.d.ts) — a global, not imported. +export type { AprIdeaCompletionLookup, AprIdeaCompletionLookupInput } from "./apr-idea-completion"; +export { loadAprIdeaCompletion } from "./apr-idea-completion"; + /** * Result of initiating an APR repo transfer. * @@ -22,6 +29,42 @@ export type AprRepoTransferResult = | { initiated: true; status: number; newFullName: string | null } | { initiated: false; status: number; error: string }; +/** + * #7742 policy gate: a transfer may be requested only after the idea's completion signal (#7591) is true. + * Plan/payment tiers are deliberately NOT consulted — this stays clear of the billing track. Pure: no IO. + * The boolean MUST come from {@link loadAprIdeaCompletion} (or a test double of it), never from a client body. + */ +export type AprRepoTransferRequestEligibility = + | { allowed: true } + | { allowed: false; reason: "idea_not_complete" }; + +export type RequestAprRepoTransferInput = { + installationId: number; + repoFullName: string; + newOwner: string; + ideaId?: string | undefined; +}; + +/** + * Outcome of a customer-initiated transfer request (#7742). + * + * - `rejected` — the completion gate blocked the call; GitHub was never contacted. + * - `initiated` / `failed` — the gate passed and {@link initiateAprRepoTransfer} ran; `initiated` still means + * GitHub accepted a *pending* transfer (see {@link AprRepoTransferResult}), never "transfer done". + */ +export type RequestAprRepoTransferResult = + | { status: "rejected"; reason: "idea_not_complete" } + | { status: "initiated"; transfer: Extract } + | { status: "failed"; transfer: Extract }; + +/** Decide whether a customer may request an APR repo transfer right now (#7742). Pure and deterministic. */ +export function evaluateAprRepoTransferRequestEligibility(input: { + ideaComplete: boolean; +}): AprRepoTransferRequestEligibility { + if (input.ideaComplete !== true) return { allowed: false, reason: "idea_not_complete" }; + return { allowed: true }; +} + /** * Initiate a transfer of `repoFullName` (a loopover-org APR repo, `owner/name`) to the GitHub account `newOwner`, * using the App installation token — the same token source as APR repo creation (#7637). @@ -30,6 +73,9 @@ export type AprRepoTransferResult = * throwing on an API error (a non-existent target account, or missing admin access to the repo, come back as a * structured `{ initiated: false }` result), so callers get a total function they can branch on. A successful * result models the transfer as INITIATED, not complete — see {@link AprRepoTransferResult}. + * + * Prefer {@link requestAprRepoTransfer} for the customer-facing path — it applies the #7742 completion gate + * before calling this. Direct callers are for tests / internal seams that already enforced the gate. */ export async function initiateAprRepoTransfer( env: Env, @@ -51,3 +97,33 @@ export async function initiateAprRepoTransfer( const payload = (await response.json().catch(() => null)) as { full_name?: string } | null; return { initiated: true, status: response.status, newFullName: payload?.full_name ?? null }; } + +/** + * Customer-facing "request transfer" action (#7742): resolve idea completion via a trusted server lookup + * ({@link loadAprIdeaCompletion}), gate on that result, then call {@link initiateAprRepoTransfer}. Never + * initiates when incomplete — and nothing in this module (or its REST mirror) auto-offers or nudges a transfer, + * or accepts a client-supplied completion boolean. + */ +export async function requestAprRepoTransfer( + env: Env, + input: RequestAprRepoTransferInput, + options: { + initiate?: ( + env: Env, + installationId: number, + repoFullName: string, + newOwner: string, + ) => Promise; + loadCompletion?: AprIdeaCompletionLookup; + } = {}, +): Promise { + const loadCompletion = options.loadCompletion ?? loadAprIdeaCompletion; + const { ideaComplete } = await loadCompletion(env, { repoFullName: input.repoFullName, ideaId: input.ideaId }); + const eligibility = evaluateAprRepoTransferRequestEligibility({ ideaComplete }); + if (!eligibility.allowed) return { status: "rejected", reason: eligibility.reason }; + + const initiate = options.initiate ?? initiateAprRepoTransfer; + const transfer = await initiate(env, input.installationId, input.repoFullName, input.newOwner); + if (transfer.initiated) return { status: "initiated", transfer }; + return { status: "failed", transfer }; +} diff --git a/test/unit/orb-apr-repo-transfer.test.ts b/test/unit/orb-apr-repo-transfer.test.ts index 8ec85d6059..37492e6c1b 100644 --- a/test/unit/orb-apr-repo-transfer.test.ts +++ b/test/unit/orb-apr-repo-transfer.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createInstallationToken } from "../../src/github/app"; -import { initiateAprRepoTransfer } from "../../src/orb/apr-repo-transfer"; +import { + evaluateAprRepoTransferRequestEligibility, + initiateAprRepoTransfer, + loadAprIdeaCompletion, + requestAprRepoTransfer, +} from "../../src/orb/apr-repo-transfer"; import { createTestEnv } from "../helpers/d1"; // The transfer initiation mints an App installation token. Mock that mint to return a plain opaque token string @@ -50,7 +55,6 @@ describe("initiateAprRepoTransfer (#7638)", () => { it("models a successful response with no repo body as initiated with an unknown destination", async () => { stubFetch(() => new Response("", { status: 202 })); const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct"); - // A 202 with an unparseable/empty body still means "initiated" — the destination path is simply not known yet. expect(result).toEqual({ initiated: true, status: 202, newFullName: null }); }); @@ -73,4 +77,117 @@ describe("initiateAprRepoTransfer (#7638)", () => { const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct"); expect(result).toEqual({ initiated: false, status: 403, error: "transfer request failed (403)" }); }); + + it("falls back to a status message when response.text() rejects on a non-OK reply", async () => { + stubFetch( + () => + ({ + ok: false, + status: 500, + text: async () => { + throw new Error("body unread"); + }, + }) as unknown as Response, + ); + const result = await initiateAprRepoTransfer(createTestEnv(), 1, "loopover-repos/widgets", "customer-acct"); + expect(result).toEqual({ initiated: false, status: 500, error: "transfer request failed (500)" }); + }); +}); + +describe("evaluateAprRepoTransferRequestEligibility (#7742)", () => { + it("allows a request only when the idea-completion signal is true", () => { + expect(evaluateAprRepoTransferRequestEligibility({ ideaComplete: true })).toEqual({ allowed: true }); + }); + + it("rejects when the idea is not complete — including an explicit false", () => { + expect(evaluateAprRepoTransferRequestEligibility({ ideaComplete: false })).toEqual({ + allowed: false, + reason: "idea_not_complete", + }); + }); +}); + +describe("loadAprIdeaCompletion (#7742)", () => { + it("fail-closes to incomplete until a persisted #7591/#7664 record exists", async () => { + await expect(loadAprIdeaCompletion(createTestEnv(), { repoFullName: "loopover-repos/widgets" })).resolves.toEqual({ + ideaComplete: false, + }); + await expect( + loadAprIdeaCompletion(createTestEnv(), { repoFullName: "loopover-repos/widgets", ideaId: "idea-1" }), + ).resolves.toEqual({ ideaComplete: false }); + }); +}); + +describe("requestAprRepoTransfer (#7742)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("rejects without calling initiate when the trusted lookup reports incomplete (default fail-closed)", async () => { + const initiate = vi.fn(); + const result = await requestAprRepoTransfer( + createTestEnv(), + { installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" }, + { initiate }, + ); + expect(result).toEqual({ status: "rejected", reason: "idea_not_complete" }); + expect(initiate).not.toHaveBeenCalled(); + }); + + it("rejects when an injectable lookup reports incomplete, without calling initiate", async () => { + const initiate = vi.fn(); + const loadCompletion = vi.fn().mockResolvedValue({ ideaComplete: false }); + const env = createTestEnv(); + const result = await requestAprRepoTransfer( + env, + { installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct", ideaId: "idea-9" }, + { initiate, loadCompletion }, + ); + expect(loadCompletion).toHaveBeenCalledWith(env, { repoFullName: "loopover-repos/widgets", ideaId: "idea-9" }); + expect(result).toEqual({ status: "rejected", reason: "idea_not_complete" }); + expect(initiate).not.toHaveBeenCalled(); + }); + + it("initiates when a trusted lookup reports complete and the GitHub call succeeds", async () => { + const initiate = vi.fn().mockResolvedValue({ initiated: true, status: 202, newFullName: "customer-acct/widgets" }); + const loadCompletion = vi.fn().mockResolvedValue({ ideaComplete: true }); + const env = createTestEnv(); + const result = await requestAprRepoTransfer( + env, + { installationId: 7, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" }, + { initiate, loadCompletion }, + ); + expect(initiate).toHaveBeenCalledWith(env, 7, "loopover-repos/widgets", "customer-acct"); + expect(result).toEqual({ + status: "initiated", + transfer: { initiated: true, status: 202, newFullName: "customer-acct/widgets" }, + }); + }); + + it("surfaces a structured failure when initiate returns initiated:false after a trusted complete lookup", async () => { + const initiate = vi.fn().mockResolvedValue({ initiated: false, status: 403, error: "no admin" }); + const result = await requestAprRepoTransfer( + createTestEnv(), + { installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" }, + { initiate, loadCompletion: async () => ({ ideaComplete: true }) }, + ); + expect(result).toEqual({ + status: "failed", + transfer: { initiated: false, status: 403, error: "no admin" }, + }); + }); + + it("defaults to initiateAprRepoTransfer when no initiate hook is supplied and completion is trusted-complete", async () => { + mockedToken.mockResolvedValue("ghs_installation_token"); + stubFetch(() => new Response(JSON.stringify({ full_name: "customer-acct/widgets" }), { status: 202 })); + const result = await requestAprRepoTransfer( + createTestEnv(), + { installationId: 1, repoFullName: "loopover-repos/widgets", newOwner: "customer-acct" }, + { loadCompletion: async () => ({ ideaComplete: true }) }, + ); + expect(result).toEqual({ + status: "initiated", + transfer: { initiated: true, status: 202, newFullName: "customer-acct/widgets" }, + }); + }); }); diff --git a/test/unit/routes-request-apr-transfer.test.ts b/test/unit/routes-request-apr-transfer.test.ts new file mode 100644 index 0000000000..bd26d010a6 --- /dev/null +++ b/test/unit/routes-request-apr-transfer.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createInstallationToken } from "../../src/github/app"; +import { loadAprIdeaCompletion } from "../../src/orb/apr-idea-completion"; +import { createTestEnv } from "../helpers/d1"; + +// #7742: POST /v1/loop/request-apr-transfer — customer-facing request-only APR transfer. Completion is +// server-resolved (never from the body). Pins the ROUTE contract against real wiring; GitHub is mocked. +vi.mock("../../src/github/app", async (importOriginal) => ({ + ...(await importOriginal()), + createInstallationToken: vi.fn(), +})); +vi.mock("../../src/orb/apr-idea-completion", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadAprIdeaCompletion: vi.fn(actual.loadAprIdeaCompletion), + }; +}); +const mockedToken = vi.mocked(createInstallationToken); +const mockedLoadCompletion = vi.mocked(loadAprIdeaCompletion); + +function stubFetch(handler: (url: string, init: RequestInit) => Response): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init ?? {})); +} + +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }); +const PATH = "/v1/loop/request-apr-transfer"; + +const validBody = { + installationId: 42, + repoFullName: "loopover-repos/widgets", + newOwner: "customer-acct", +}; + +const post = (env: Env, body: unknown) => + createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env); + +describe("POST /v1/loop/request-apr-transfer (#7742)", () => { + beforeEach(() => { + mockedToken.mockReset(); + mockedToken.mockResolvedValue("ghs_installation_token"); + mockedLoadCompletion.mockReset(); + mockedLoadCompletion.mockResolvedValue({ ideaComplete: false }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns 409 without contacting GitHub under the default fail-closed completion lookup", async () => { + let fetchCalls = 0; + stubFetch(() => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }); + const response = await post(createTestEnv(), validBody); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ status: "rejected", reason: "idea_not_complete" }); + expect(fetchCalls).toBe(0); + expect(mockedToken).not.toHaveBeenCalled(); + expect(mockedLoadCompletion).toHaveBeenCalled(); + }); + + it("rejects a body that smuggles ideaComplete (strict schema) before any lookup or GitHub call", async () => { + let fetchCalls = 0; + stubFetch(() => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }); + const response = await post(createTestEnv(), { ...validBody, ideaComplete: true }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_request_apr_transfer_request" }); + expect(mockedLoadCompletion).not.toHaveBeenCalled(); + expect(fetchCalls).toBe(0); + expect(mockedToken).not.toHaveBeenCalled(); + }); + + it("returns 202 when a trusted server lookup reports complete and GitHub accepts", async () => { + mockedLoadCompletion.mockResolvedValue({ ideaComplete: true }); + stubFetch(() => new Response(JSON.stringify({ full_name: "customer-acct/widgets" }), { status: 202 })); + const response = await post(createTestEnv(), validBody); + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ + status: "initiated", + transfer: { initiated: true, status: 202, newFullName: "customer-acct/widgets" }, + }); + }); + + it("returns 502 when a trusted lookup reports complete but GitHub rejects the transfer", async () => { + mockedLoadCompletion.mockResolvedValue({ ideaComplete: true }); + stubFetch(() => new Response("", { status: 403 })); + const response = await post(createTestEnv(), validBody); + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + status: "failed", + transfer: { initiated: false, status: 403, error: "transfer request failed (403)" }, + }); + }); + + it("rejects an invalid or unparseable body with 400 before any GitHub call", async () => { + let fetchCalls = 0; + stubFetch(() => { + fetchCalls += 1; + return new Response("{}", { status: 202 }); + }); + const env = createTestEnv(); + for (const body of [ + {}, + { ...validBody, installationId: 0 }, + { ...validBody, installationId: -1 }, + { ...validBody, repoFullName: "" }, + { ...validBody, newOwner: "" }, + { ...validBody, ideaId: "" }, + { installationId: 1, repoFullName: "a/b" }, // missing newOwner + ]) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_request_apr_transfer_request" }); + } + const malformed = await createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: "{not json" }, env); + expect(malformed.status).toBe(400); + expect(fetchCalls).toBe(0); + expect(mockedToken).not.toHaveBeenCalled(); + }); + + it("forwards an optional ideaId to the trusted lookup without treating it as a completion claim", async () => { + const response = await post(createTestEnv(), { ...validBody, ideaId: "idea-42" }); + expect(response.status).toBe(409); + expect(mockedLoadCompletion).toHaveBeenCalledWith(expect.anything(), { + repoFullName: "loopover-repos/widgets", + ideaId: "idea-42", + }); + }); + + it("leaks no wallet/hotkey/trust-score terms", async () => { + const text = JSON.stringify(await (await post(createTestEnv(), validBody)).json()); + expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward/i); + }); +});