diff --git a/packages/server/src/routes/grants.test.ts b/packages/server/src/routes/grants.test.ts index b7836e8c..6bc3cbb9 100644 --- a/packages/server/src/routes/grants.test.ts +++ b/packages/server/src/routes/grants.test.ts @@ -447,3 +447,78 @@ describe("POST /", () => { expect(grantPayload.nonce).toBe(42); }); }); + +describe("DELETE /:grantId", () => { + const VALID_GRANT_ID = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + async function deleteWithOwnerAuth( + app: ReturnType, + grantId: string, + ) { + const auth = await buildWeb3SignedHeader({ + wallet: owner, + aud: SERVER_ORIGIN, + method: "DELETE", + uri: `/${grantId}`, + bodyHash: "", + }); + return app.request(`/${grantId}`, { + method: "DELETE", + headers: { authorization: auth }, + }); + } + + it("revokes grant via gateway and returns { status: 'revoked', grantId }", async () => { + const mockGateway = createMockGateway(); + const mockSigner = createMockServerSigner(); + + const app = createApp({ gateway: mockGateway, serverSigner: mockSigner }); + const res = await deleteWithOwnerAuth(app, VALID_GRANT_ID); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.status).toBe("revoked"); + expect(json.grantId).toBe(VALID_GRANT_ID); + + expect(mockSigner.signGrantRevocation).toHaveBeenCalledWith({ + grantorAddress: owner.address, + grantId: VALID_GRANT_ID, + }); + expect(mockGateway.revokeGrant).toHaveBeenCalledWith({ + grantId: VALID_GRANT_ID, + grantorAddress: owner.address, + signature: "0xrevokesig", + }); + }); + + it("returns 502 on gateway error", async () => { + const mockGateway = createMockGateway(); + vi.mocked(mockGateway.revokeGrant).mockRejectedValue( + new Error("gateway down"), + ); + + const app = createApp({ gateway: mockGateway }); + const res = await deleteWithOwnerAuth(app, VALID_GRANT_ID); + + expect(res.status).toBe(502); + const json = await res.json(); + expect(json.error.errorCode).toBe("GATEWAY_ERROR"); + }); + + it("returns 500 when serverSigner is not configured", async () => { + const app = grantsRoutes({ + logger, + gateway: createMockGateway(), + serverOwner: owner.address, + serverOrigin: SERVER_ORIGIN, + // serverSigner intentionally omitted + }); + + const res = await deleteWithOwnerAuth(app, VALID_GRANT_ID); + + expect(res.status).toBe(500); + const json = await res.json(); + expect(json.error.errorCode).toBe("SERVER_SIGNER_NOT_CONFIGURED"); + }); +}); diff --git a/packages/server/src/routes/grants.ts b/packages/server/src/routes/grants.ts index e924bc70..6495f1a4 100644 --- a/packages/server/src/routes/grants.ts +++ b/packages/server/src/routes/grants.ts @@ -1,5 +1,5 @@ /** - * Grants routes — GET / (owner), POST / (create grant), POST /verify (public). + * Grants routes — GET / (owner), POST / (create grant), DELETE /:grantId (revoke), POST /verify (public). */ import { Hono } from "hono"; @@ -218,6 +218,91 @@ export function grantsRoutes(deps: GrantsRouteDeps): Hono { return c.json({ grantId: result.grantId }, 201); }); + // DELETE /:grantId — revoke a grant (owner-only). + // Signs GrantRevocation EIP-712 with serverSigner (delegated) and submits to gateway. + app.delete("/:grantId", web3Auth, ownerCheck, async (c) => { + if (!deps.serverOwner) { + return c.json( + { + error: { + code: 500, + errorCode: "SERVER_NOT_CONFIGURED", + message: + "Server owner address not configured. Set VANA_MASTER_KEY_SIGNATURE environment variable.", + }, + }, + 500, + ); + } + if (!deps.serverSigner) { + return c.json( + { + error: { + code: 500, + errorCode: "SERVER_SIGNER_NOT_CONFIGURED", + message: + "Server signer not configured. Set VANA_MASTER_KEY_SIGNATURE environment variable.", + }, + }, + 500, + ); + } + + const grantId = c.req.param("grantId"); + if (!grantId || typeof grantId !== "string") { + return c.json( + { error: { code: 400, message: "Missing grantId parameter" } }, + 400, + ); + } + + // Sign EIP-712 GrantRevocation + let signature: `0x${string}`; + try { + signature = await deps.serverSigner.signGrantRevocation({ + grantorAddress: deps.serverOwner, + grantId: grantId as `0x${string}`, + }); + } catch (err) { + deps.logger.error({ err, grantId }, "Grant revocation signing failed"); + return c.json( + { + error: { + code: 500, + errorCode: "GRANT_REVOCATION_SIGN_FAILED", + message: "Failed to sign grant revocation", + }, + }, + 500, + ); + } + + // Submit to Gateway + try { + await deps.gateway.revokeGrant({ + grantId, + grantorAddress: deps.serverOwner, + signature, + }); + } catch (err) { + deps.logger.error({ err, grantId }, "Gateway grant revocation failed"); + const message = err instanceof Error ? err.message : String(err); + return c.json( + { + error: { + code: 502, + errorCode: "GATEWAY_ERROR", + message: `Gateway grant revocation failed: ${message}`, + }, + }, + 502, + ); + } + + deps.logger.info({ grantId }, "Grant revoked"); + return c.json({ status: "revoked", grantId }); + }); + // POST /verify — public endpoint, no auth required app.post("/verify", async (c) => { let body: unknown;