Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"

/**
* BFF contract for the ownership-transfer route.
*
* What matters here is the targeting form CGS requires (#27): the `status`
* QUERY carries `repo` on the querystring, while `propose` / `accept` /
* `cancel` are JSON-body procedures that carry it in the body. Getting that
* backwards is rejected upstream with `jwt audience does not match service
* did`, which is a confusing failure to debug from the UI — so it's pinned.
*
* `isValidDid` is the real guard under test; only the auth / CSRF / rate-limit
* / CGS seams are mocked.
*/

vi.mock("@/lib/groups/proxy-agent", () => ({
getAuthenticatedAgent: vi.fn(),
callGroupServiceJson: vi.fn(),
}))
vi.mock("@/lib/auth/csrf", () => ({ checkCsrf: vi.fn(() => null) }))
vi.mock("@/lib/auth/rate-limit", () => ({
enforceRateLimit: vi.fn(async () => null),
makeLimiter: () => ({}),
}))

import {
getAuthenticatedAgent,
callGroupServiceJson,
} from "@/lib/groups/proxy-agent"
import { checkCsrf } from "@/lib/auth/csrf"
import { enforceRateLimit } from "@/lib/auth/rate-limit"

const GROUP_DID = "did:plc:abcdefghijklmnopqrstuvwx"
const MEMBER_DID = "did:plc:bcdefghijklmnopqrstuvwxy"

function makeRequest(method: string, body?: unknown): Request {
return new Request(
`https://example.test/api/groups/${GROUP_DID}/ownership-transfer`,
{
method,
headers: {
"Content-Type": "application/json",
origin: "https://example.test",
},
// A GET Request may not carry a body, so drop it there.
...(body !== undefined && method !== "GET"
? { body: JSON.stringify(body) }
: {}),
},
)
}

function makeContext(groupDid = GROUP_DID) {
return { params: Promise.resolve({ groupDid }) }
}

async function call(
method: "GET" | "POST" | "PUT" | "DELETE",
body?: unknown,
groupDid = GROUP_DID,
): Promise<Response> {
const mod = await import("../route")
const handler = mod[method]
return handler(
makeRequest(method, body) as unknown as Parameters<typeof handler>[0],
makeContext(groupDid) as unknown as Parameters<typeof handler>[1],
)
}

beforeEach(() => {
vi.mocked(checkCsrf).mockReset().mockReturnValue(null)
vi.mocked(enforceRateLimit).mockReset().mockResolvedValue(null)
vi.mocked(getAuthenticatedAgent)
.mockReset()
.mockResolvedValue({ agent: {} as never, did: "did:plc:alice" })
vi.mocked(callGroupServiceJson).mockReset().mockResolvedValue({ ok: true })
})

afterEach(() => {
vi.restoreAllMocks()
})

describe("ownership-transfer — CGS targeting", () => {
it("puts repo on the querystring for the status query", async () => {
const res = await call("GET")
expect(res.status).toBe(200)
expect(callGroupServiceJson).toHaveBeenCalledWith(
expect.anything(),
"app.certified.group.ownershipTransfer.status",
{ query: { repo: GROUP_DID } },
)
})

it("puts repo in the body for propose, with the proposed DID", async () => {
const res = await call("POST", { newOwner: MEMBER_DID })
expect(res.status).toBe(200)
expect(callGroupServiceJson).toHaveBeenCalledWith(
expect.anything(),
"app.certified.group.ownershipTransfer.propose",
{ body: { repo: GROUP_DID, newOwner: MEMBER_DID } },
)
})

it("puts repo in the body for accept, and names no member", async () => {
// CGS derives the accepting member from the JWT — a client-supplied
// identity here would be meaningless at best.
const res = await call("PUT")
expect(res.status).toBe(200)
expect(callGroupServiceJson).toHaveBeenCalledWith(
expect.anything(),
"app.certified.group.ownershipTransfer.accept",
{ body: { repo: GROUP_DID } },
)
})

it("puts repo in the body for cancel", async () => {
const res = await call("DELETE")
expect(res.status).toBe(200)
expect(callGroupServiceJson).toHaveBeenCalledWith(
expect.anything(),
"app.certified.group.ownershipTransfer.cancel",
{ body: { repo: GROUP_DID } },
)
})
})

describe("ownership-transfer — validation", () => {
it("rejects a missing or malformed newOwner before calling CGS", async () => {
for (const newOwner of [undefined, "", "alice.test", "did:", 42]) {
vi.mocked(callGroupServiceJson).mockClear()
const res = await call("POST", { newOwner })
expect(res.status).toBe(400)
expect(callGroupServiceJson).not.toHaveBeenCalled()
}
})

it("rejects an invalid group DID on every method", async () => {
for (const method of ["GET", "POST", "PUT", "DELETE"] as const) {
vi.mocked(callGroupServiceJson).mockClear()
const res = await call(method, { newOwner: MEMBER_DID }, "not-a-did")
expect(res.status).toBe(400)
expect(callGroupServiceJson).not.toHaveBeenCalled()
}
})
})

describe("ownership-transfer — gates", () => {
it("returns the CSRF response before any upstream call on mutations", async () => {
for (const method of ["POST", "PUT", "DELETE"] as const) {
vi.mocked(callGroupServiceJson).mockClear()
vi.mocked(checkCsrf).mockReturnValueOnce(
new Response(JSON.stringify({ error: "csrf" }), {
status: 403,
}) as never,
)
const res = await call(method, { newOwner: MEMBER_DID })
expect(res.status).toBe(403)
expect(callGroupServiceJson).not.toHaveBeenCalled()
}
})

it("401s when not authenticated", async () => {
vi.mocked(getAuthenticatedAgent).mockResolvedValue(null)
const res = await call("GET")
expect(res.status).toBe(401)
expect(callGroupServiceJson).not.toHaveBeenCalled()
})

it("rate-limits propose before calling CGS", async () => {
vi.mocked(enforceRateLimit).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "slow down" }), {
status: 429,
}) as never,
)
const res = await call("POST", { newOwner: MEMBER_DID })
expect(res.status).toBe(429)
expect(callGroupServiceJson).not.toHaveBeenCalled()
})
})

describe("ownership-transfer — upstream errors", () => {
it("forwards the atproto error code so the UI can tell cases apart", async () => {
vi.mocked(callGroupServiceJson).mockRejectedValueOnce(
Object.assign(new Error("Not a member"), {
status: 400,
error: "NotAMember",
}),
)
const res = await call("POST", { newOwner: MEMBER_DID })
expect(res.status).toBe(400)
expect((await res.json()).code).toBe("NotAMember")
})

it("passes a non-party's NoPendingTransfer straight through", async () => {
// A member who isn't a party gets the same 404 as when nothing is
// pending. The BFF must not try to distinguish them — the service
// makes the two indistinguishable on purpose.
vi.mocked(callGroupServiceJson).mockRejectedValueOnce(
Object.assign(new Error("No pending transfer"), {
status: 404,
error: "NoPendingTransfer",
}),
)
const res = await call("PUT")
expect(res.status).toBe(404)
expect((await res.json()).code).toBe("NoPendingTransfer")
})
})
183 changes: 183 additions & 0 deletions src/app/api/groups/[groupDid]/ownership-transfer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { NextRequest, NextResponse } from "next/server"
import {
getAuthenticatedAgent,
callGroupServiceJson,
} from "@/lib/groups/proxy-agent"
import { checkCsrf } from "@/lib/auth/csrf"
import { isValidDid } from "@/lib/utils/did"
import { extractRouteError, parseJsonBody } from "@/lib/utils/api"
import { enforceRateLimit, makeLimiter } from "@/lib/auth/rate-limit"

/**
* Ownership transfer (CGS >= 0.6.0) — the two-phase handshake behind
* `app.certified.group.ownershipTransfer.*`. The owner proposes an existing
* member; ownership moves only once that member accepts, which proves they
* still control their DID. Either party can cancel, and an un-accepted
* proposal lapses after 7 days.
*
* Disclosure rule (CGS contract): a member who is NOT a party to a pending
* transfer gets exactly what they'd get if none existed — `pending: false`
* from the status query, `NoPendingTransfer` from accept/cancel. We forward
* those responses untouched; never try to distinguish "nothing pending" from
* "not yours" here or in the UI, because the service deliberately makes them
* indistinguishable.
*
* Requires the group service to be on 0.6.0+; a 0.5.0 deployment answers these
* NSIDs with `501 MethodNotImplemented`, which surfaces as a plain upstream
* error. That's gated by release (staging only until production's CGS is
* upgraded), not by runtime detection.
*/

// 5 proposals per 10 min per DID. Proposing is owner-only and rare;
// the limit just bounds a loop that renews the same proposal (a
// re-propose restarts the 7-day window server-side).
const PROPOSE_LIMITER = makeLimiter("groups-transfer", 5, 600)

/**
* GET /api/groups/[groupDid]/ownership-transfer
* Report the pending transfer, if any. Callable by any member; details are
* disclosed by CGS only to the two parties.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ groupDid: string }> }
) {
try {
const { groupDid } = await params
if (!isValidDid(groupDid)) {
return NextResponse.json({ error: "Invalid group DID" }, { status: 400 })
}
const auth = await getAuthenticatedAgent()
if (!auth)
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })

// A query: `repo` goes on the querystring (CGS #27 targeting).
const data = await callGroupServiceJson(
auth.agent,
"app.certified.group.ownershipTransfer.status",
{ query: { repo: groupDid } }
)

return NextResponse.json(data)
} catch (err: unknown) {
const { status, message } = extractRouteError(err)
return NextResponse.json({ error: message }, { status })
}
}

/**
* POST /api/groups/[groupDid]/ownership-transfer
* Propose a new owner (requires owner). The target must already be a member.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ groupDid: string }> }
) {
const csrfError = checkCsrf(request)
if (csrfError) return csrfError

try {
const { groupDid } = await params
if (!isValidDid(groupDid)) {
return NextResponse.json({ error: "Invalid group DID" }, { status: 400 })
}
const auth = await getAuthenticatedAgent()
if (!auth)
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })

const rateDenied = await enforceRateLimit(PROPOSE_LIMITER, auth.did)
if (rateDenied) return rateDenied

const parsed = await parseJsonBody(request, "[groups/ownership-transfer POST]")
if (!parsed.ok) return parsed.response
const { newOwner } = (parsed.body ?? {}) as { newOwner?: string }

// CGS accepts a handle or a DID; the settings UI always sends the DID of
// a member it already listed, so require that stricter form here.
if (!newOwner || !isValidDid(newOwner)) {
return NextResponse.json(
{ error: "newOwner must be a DID" },
{ status: 400 }
)
}

const data = await callGroupServiceJson(
auth.agent,
"app.certified.group.ownershipTransfer.propose",
{ body: { repo: groupDid, newOwner } }
)

return NextResponse.json(data)
} catch (err: unknown) {
const { status, message, code } = extractRouteError(err)
return NextResponse.json({ error: message, code }, { status })
}
}

/**
* PUT /api/groups/[groupDid]/ownership-transfer
* Accept a pending transfer. Callable only by the proposed member; CGS demotes
* the previous owner to admin and promotes the caller atomically.
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ groupDid: string }> }
) {
const csrfError = checkCsrf(request)
if (csrfError) return csrfError

try {
const { groupDid } = await params
if (!isValidDid(groupDid)) {
return NextResponse.json({ error: "Invalid group DID" }, { status: 400 })
}
const auth = await getAuthenticatedAgent()
if (!auth)
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })

const data = await callGroupServiceJson(
auth.agent,
"app.certified.group.ownershipTransfer.accept",
{ body: { repo: groupDid } }
)

return NextResponse.json(data)
} catch (err: unknown) {
const { status, message, code } = extractRouteError(err)
return NextResponse.json({ error: message, code }, { status })
}
}

/**
* DELETE /api/groups/[groupDid]/ownership-transfer
* Cancel a pending transfer — the owner revoking, or the proposed member
* declining. Either party may call it.
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ groupDid: string }> }
) {
const csrfError = checkCsrf(request)
if (csrfError) return csrfError

try {
const { groupDid } = await params
if (!isValidDid(groupDid)) {
return NextResponse.json({ error: "Invalid group DID" }, { status: 400 })
}
const auth = await getAuthenticatedAgent()
if (!auth)
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })

const data = await callGroupServiceJson(
auth.agent,
"app.certified.group.ownershipTransfer.cancel",
{ body: { repo: groupDid } }
)

return NextResponse.json(data)
} catch (err: unknown) {
const { status, message, code } = extractRouteError(err)
return NextResponse.json({ error: message, code }, { status })
}
}
Loading
Loading