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..26f7484 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -792,6 +792,67 @@ 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) { + const status = error?.code === "NOT_FOUND" ? 404 : 500; + return c.json( + { + error: { + message: error?.message ?? "Unable to cancel claim", + code: error?.code ?? "DATABASE_ERROR" + } + }, + status + ); + } + + return c.json({ result: { id: data.id, cancelled: true as const } }, 200); +}); + const myClaimsRoute = createRoute({ method: "get", path: "/developers/claims/mine", 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;