@@ -13,11 +13,15 @@ import { upsertRepositorySettings } from "../db/repositories";
1313import { createInstallationToken } from "../github/app" ;
1414import { githubHeaders , timeoutFetch } from "../github/client" ;
1515import { loadAprIdeaCompletion , type AprIdeaCompletionLookup } from "./apr-idea-completion" ;
16+ import { loadAprRepoBinding as loadAprRepoBindingDefault , type AprRepoBinding , type AprRepoBindingLookup } from "./apr-repo-binding" ;
1617// `Env` is the ambient Cloudflare Worker binding interface (worker-configuration.d.ts) — a global, not imported.
1718
1819export type { AprIdeaCompletionLookup , AprIdeaCompletionLookupInput } from "./apr-idea-completion" ;
1920export { loadAprIdeaCompletion } from "./apr-idea-completion" ;
2021
22+ export type { AprRepoBinding , AprRepoBindingLookup } from "./apr-repo-binding" ;
23+ export { loadAprRepoBinding } from "./apr-repo-binding" ;
24+
2125/**
2226 * Result of initiating an APR repo transfer.
2327 *
@@ -54,10 +58,36 @@ export type RequestAprRepoTransferInput = {
5458 * GitHub accepted a *pending* transfer (see {@link AprRepoTransferResult}), never "transfer done".
5559 */
5660export type RequestAprRepoTransferResult =
57- | { status : "rejected" ; reason : "idea_not_complete" }
61+ | { status : "rejected" ; reason : "idea_not_complete" | AprRepoTransferBindingRejection }
5862 | { status : "initiated" ; transfer : Extract < AprRepoTransferResult , { initiated : true } > }
5963 | { status : "failed" ; transfer : Extract < AprRepoTransferResult , { initiated : false } > } ;
6064
65+ /** #9490: the three ways the tenant binding can refuse a transfer. Distinct reasons on purpose — an operator
66+ * debugging a refused transfer needs to know WHICH leg failed, and none of them leak anything the caller did
67+ * not already supply. */
68+ export type AprRepoTransferBindingRejection =
69+ /** No server-side binding record exists for this repo at all — either not an APR repo, or #7664 has not
70+ * persisted its record. Fail closed: an unbound repo is never transferable. */
71+ | "repo_not_apr_bound"
72+ /** The caller-supplied `newOwner` is not the customer this APR repo is bound to. The whole point: a transfer
73+ * may only ever move a repo to its OWN customer, never to whoever asked. */
74+ | "new_owner_not_bound_customer"
75+ /** The caller-supplied `installationId` is not the installation the binding records for this repo. */
76+ | "installation_not_bound" ;
77+
78+ /** #9490: pure tenant-binding check, separated from the completion gate so each is independently testable. */
79+ export function evaluateAprRepoTransferBinding (
80+ input : Pick < RequestAprRepoTransferInput , "installationId" | "repoFullName" | "newOwner" > ,
81+ binding : AprRepoBinding | null ,
82+ ) : { allowed : true } | { allowed : false ; reason : AprRepoTransferBindingRejection } {
83+ if ( binding === null ) return { allowed : false , reason : "repo_not_apr_bound" } ;
84+ if ( binding . customerLogin . toLowerCase ( ) !== input . newOwner . trim ( ) . toLowerCase ( ) ) {
85+ return { allowed : false , reason : "new_owner_not_bound_customer" } ;
86+ }
87+ if ( binding . installationId !== input . installationId ) return { allowed : false , reason : "installation_not_bound" } ;
88+ return { allowed : true } ;
89+ }
90+
6191/** Decide whether a customer may request an APR repo transfer right now (#7742). Pure and deterministic. */
6292export function evaluateAprRepoTransferRequestEligibility ( input : {
6393 ideaComplete : boolean ;
@@ -116,6 +146,8 @@ export async function requestAprRepoTransfer(
116146 newOwner : string ,
117147 ) => Promise < AprRepoTransferResult > ;
118148 loadCompletion ?: AprIdeaCompletionLookup ;
149+ /** #9490 seam: the tenant-binding lookup; injectable for tests, fail-closed default. */
150+ loadBinding ?: AprRepoBindingLookup ;
119151 /** #7741 deliverable 2 seam: how to freeze AMS dispatch once a transfer is pending. Injectable for tests. */
120152 pauseDispatch ?: ( env : Env , repoFullName : string ) => Promise < void > ;
121153 } = { } ,
@@ -125,6 +157,15 @@ export async function requestAprRepoTransfer(
125157 const eligibility = evaluateAprRepoTransferRequestEligibility ( { ideaComplete } ) ;
126158 if ( ! eligibility . allowed ) return { status : "rejected" , reason : eligibility . reason } ;
127159
160+ // #9490: the tenant binding gates AFTER completion (cheapest rejection first is irrelevant here -- both are
161+ // local lookups -- but completion-first keeps today's observable behaviour byte-identical: an incomplete
162+ // idea still rejects with the same reason it always did) and BEFORE any GitHub call: a transfer request
163+ // that fails authorization must never reach GitHub at all, not even to fail there.
164+ const loadBinding = options . loadBinding ?? loadAprRepoBindingDefault ;
165+ const binding = await loadBinding ( env , { repoFullName : input . repoFullName } ) ;
166+ const bindingCheck = evaluateAprRepoTransferBinding ( input , binding ) ;
167+ if ( ! bindingCheck . allowed ) return { status : "rejected" , reason : bindingCheck . reason } ;
168+
128169 const initiate = options . initiate ?? initiateAprRepoTransfer ;
129170 const transfer = await initiate ( env , input . installationId , input . repoFullName , input . newOwner ) ;
130171 if ( transfer . initiated ) {
@@ -217,7 +258,21 @@ export async function probeAprRepoTransfer(
217258 const response = await timeoutFetch ( `https://api.github.com/repos/${ transfer . repoFullName } ` , {
218259 headers : githubHeaders ( { token } ) ,
219260 } ) ;
220- if ( response . status === 404 ) return { state : "access_departed" } ;
261+ // #9490: a plain 404 at the original path is AMBIGUOUS -- ownership moved away, OR the repo was simply
262+ // deleted / the App uninstalled. Recording "accepted_departed" (a terminal SUCCESS) for a deleted repo is
263+ // a false outcome in the transfer ledger, so departure is only declared once the repo demonstrably
264+ // resolves under the TARGET owner. When that corroboration cannot be obtained (a private repo the App can
265+ // no longer see), the transfer stays "pending" and the existing expiry clock resolves it -- a bounded,
266+ // late, truthful answer over a fast wrong one.
267+ if ( response . status === 404 ) {
268+ const repoName = transfer . repoFullName . split ( "/" ) [ 1 ] ?? "" ;
269+ const relocated = repoName ? await timeoutFetch ( `https://api.github.com/repos/${ transfer . newOwner } /${ repoName } ` , { headers : githubHeaders ( { token } ) } ) . catch ( ( ) => null ) : null ;
270+ if ( relocated ?. ok ) {
271+ const relocatedBody = ( await relocated . json ( ) . catch ( ( ) => null ) ) as { owner ?: { login ?: string } } | null ;
272+ if ( relocatedBody ?. owner ?. login ?. toLowerCase ( ) === transfer . newOwner . toLowerCase ( ) ) return { state : "access_departed" } ;
273+ }
274+ return { state : "pending" } ;
275+ }
221276 if ( ! response . ok ) return { state : "pending" } ;
222277 const body = ( await response . json ( ) . catch ( ( ) => null ) ) as { owner ?: { login ?: string } } | null ;
223278 const owner = body ?. owner ?. login ;
0 commit comments