From 509817c1847d810f5661fa2fad8fb6e65200c2b3 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 28 Jul 2026 18:59:56 +0100 Subject: [PATCH 1/2] Let claimants withdraw their own pending developer-profile claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /developers/claims/{id}/cancel, scoped to the caller's own pending claim, so a claim can be retracted without waiting on a moderator to reject it — needed alongside blocking "Create" on the extensions account pages while a claim is outstanding, since a user can only ever own one developer profile. Claude-Session: https://claude.ai/code/session_01QaaK546yMRs9xgyGrnu7Vx --- .../extensions/v2/developers-database.ts | 39 ++++++++++++ src/services/extensions/v2/index.ts | 60 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/services/extensions/v2/developers-database.ts b/src/services/extensions/v2/developers-database.ts index 0b82560..ef20188 100644 --- a/src/services/extensions/v2/developers-database.ts +++ b/src/services/extensions/v2/developers-database.ts @@ -1227,6 +1227,45 @@ export class DevelopersDatabase { } } + // Lets a claimant withdraw their own pending claim — scoped to + // claimant_id so this can't be used to cancel someone else's, and to + // status = 'pending' so a moderator's decision can't be undone by it. + async cancelClaim( + claimId: string, + claimantId: string + ): Promise> { + let result; + try { + result = await this.db + .prepare( + `DELETE FROM developer_claims WHERE id = ? AND claimant_id = ? AND status = 'pending'` + ) + .bind(claimId, claimantId) + .run(); + } catch (error) { + return databaseError("cancelClaim", error); + } + + if (!result.success) { + return databaseError( + "cancelClaim", + new Error(result.error || "Database query failed") + ); + } + + if (!result.meta?.changes) { + return { + data: null, + error: { + message: `Cannot find pending claim by id: ${claimId}`, + code: "NOT_FOUND" + } + }; + } + + return { data: { id: claimId }, error: null }; + } + async listMyClaims( claimantId: string ): Promise> { diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 029c315..9bec641 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -792,6 +792,66 @@ extensionsV2.openapi(claimDeveloperRoute, async (c) => { return c.json({ result: data }, 201); }); +const cancelClaimRoute = createRoute({ + method: "post", + path: "/developers/claims/{id}/cancel", + tags: ["Developers"], + summary: "Withdraw the caller's own pending profile claim", + security: [{ Bearer: [] }], + middleware: [requireAuth()] as const, + request: { params: IdParamSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + result: z.object({ id: z.string(), cancelled: z.literal(true) }) + }) + } + }, + description: "Claim withdrawn" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 404: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "No pending claim with that id owned by the caller" + }, + 422: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "id param failed validation" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } +}); + +extensionsV2.openapi(cancelClaimRoute, async (c) => { + const auth = getAuth(c); + const { id } = c.req.valid("param"); + const platform = getPlatform(c); + const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + + const { data, error } = await db.cancelClaim(id, auth.userId); + if (error || !data) { + return c.json( + { + error: { + message: error?.message ?? "Unable to cancel claim", + code: error?.code ?? "DATABASE_ERROR" + } + }, + statusFromErrorCode(error?.code) + ); + } + + return c.json({ result: { id: data.id, cancelled: true as const } }, 200); +}); + const myClaimsRoute = createRoute({ method: "get", path: "/developers/claims/mine", From cae13e8f3c3ced1cdaee79a0c49618db806edd87 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 28 Jul 2026 19:51:41 +0100 Subject: [PATCH 2/2] Add test coverage for the claim-cancel endpoint The D1 mock had no branch for the new DELETE, so any test exercising it failed with a simulated DATABASE_ERROR; add the mock branch plus route tests for own/foreign/non-pending claims. Also narrows the cancel handler's error-status mapping to 404/500 (the shared statusFromErrorCode helper's 409 case doesn't apply here and doesn't match the route's declared responses). Claude-Session: https://claude.ai/code/session_01QaaK546yMRs9xgyGrnu7Vx --- src/services/extensions/v2/index.ts | 3 +- test/services/extensions/v2/index.test.ts | 65 +++++++++++++++++++++++ test/services/extensions/v2/mock-db.ts | 10 ++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 9bec641..26f7484 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -838,6 +838,7 @@ extensionsV2.openapi(cancelClaimRoute, async (c) => { const { data, error } = await db.cancelClaim(id, auth.userId); if (error || !data) { + const status = error?.code === "NOT_FOUND" ? 404 : 500; return c.json( { error: { @@ -845,7 +846,7 @@ extensionsV2.openapi(cancelClaimRoute, async (c) => { code: error?.code ?? "DATABASE_ERROR" } }, - statusFromErrorCode(error?.code) + status ); } diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index e5fe183..b7653ff 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -2163,6 +2163,70 @@ describe("Extensions API v2", () => { ); expect(reject.status).toBe(403); }); + + it("lets a claimant cancel their own pending claim", async () => { + seedUnownedDeveloper("legacy-developer"); + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + + const cancel = await post( + `/extensions/v2/developers/claims/${claimId}/cancel`, + await authHeaders("user-1") + ); + expect(cancel.status).toBe(200); + const cancelled = (await cancel.json()) as { + result: { id: string; cancelled: boolean }; + }; + expect(cancelled.result).toEqual({ id: claimId, cancelled: true }); + expect(tables.developer_claims.has(claimId)).toBe(false); + }); + + it("rejects cancelling a claim that belongs to someone else", async () => { + seedUnownedDeveloper("legacy-developer"); + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + + const cancel = await post( + `/extensions/v2/developers/claims/${claimId}/cancel`, + await authHeaders("user-2") + ); + expect(cancel.status).toBe(404); + expect(tables.developer_claims.has(claimId)).toBe(true); + }); + + it("rejects cancelling a claim that is no longer pending", async () => { + seedUnownedDeveloper("legacy-developer"); + tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + await post( + `/extensions/v2/developers/claims/${claimId}/reject`, + await authHeaders("mod-1"), + { review_note: "no" } + ); + + const cancel = await post( + `/extensions/v2/developers/claims/${claimId}/cancel`, + await authHeaders("user-1") + ); + expect(cancel.status).toBe(404); + expect(tables.developer_claims.get(claimId)?.status).toBe("rejected"); + }); }); describe("GET /developers/{id}", () => { @@ -2331,6 +2395,7 @@ describe("Extensions API v2", () => { "/developers/{id}/transfer/revoke", "/developers/transfers/accept", "/developers/{id}/claim", + "/developers/claims/{id}/cancel", "/developers/claims/mine", "/developers/claims", "/developers/claims/{id}/approve", diff --git a/test/services/extensions/v2/mock-db.ts b/test/services/extensions/v2/mock-db.ts index 790d999..4b7060c 100644 --- a/test/services/extensions/v2/mock-db.ts +++ b/test/services/extensions/v2/mock-db.ts @@ -1061,6 +1061,16 @@ class MockStatement implements D1PreparedStatement { return []; } + if (q.startsWith("DELETE FROM developer_claims WHERE id = ?")) { + const [id, claimant_id] = p; + const row = this.tables.developer_claims.get(String(id)); + const eligible = + !!row && row.claimant_id === claimant_id && row.status === "pending"; + if (eligible) this.tables.developer_claims.delete(String(id)); + this.changes = eligible ? 1 : 0; + return []; + } + if (q.startsWith("DELETE FROM developer_claims WHERE developer_id = ?")) { const [developer_id, owner_user_id] = p; let changes = 0;