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
62 changes: 47 additions & 15 deletions app/api/groups/[id]/transfer-ownership/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
* Allows the current group owner to transfer ownership to another member.
*
* Request body:
* {
* walletAddress: string // Caller's Stellar public key
* signature: string // Hex-encoded Ed25519 signature of the nonce
* newOwnerWalletAddress: string // Stellar public key of the new owner
* }
* {
* walletAddress: string // Caller's Stellar public key
* signature: string // Hex-encoded Ed25519 signature of the nonce
* newOwnerWalletAddress: string // Stellar public key of the new owner
* }
*
* Flow:
* 1. Authenticate caller via Supabase session
* 2. Validate inputs (wallet address format, required fields)
* 3. Consume the one-time nonce for the caller's wallet (prevents replay)
* 4. Verify the Ed25519 signature over the nonce
* 5. Resolve the new owner's user ID from their wallet address
* 6. Confirm the new owner is an active member of the group
* 7. Call transfer_room_ownership RPC (atomic DB update + audit log)
* 8. Write a room_activity_logs entry for transparency
* 9. Optionally submit a Stellar transaction recording the transfer on-chain
* 1. Authenticate caller via Supabase session
* 2. Validate inputs (wallet address format, required fields)
* 3. Consume the one-time nonce for the caller's wallet (prevents replay)
* 4. Verify the Ed25519 signature over the nonce
* 5. Resolve the new owner's user ID from their wallet address
* 6. Confirm the new owner is an active member of the group
* 7. Call transfer_room_ownership RPC (atomic DB update + audit log)
* 8. Write a room_activity_logs entry for transparency
* 9. Optionally submit a Stellar transaction recording the transfer on-chain
*/

import { createClient } from "@/lib/supabase/server"
Expand All @@ -30,6 +30,7 @@
resolveWalletFromUser,
verifyWalletAuthorization,
} from "@/lib/auth/wallet-authorization"
import { verifyWalletSignature } from "@/lib/auth/stellar-verify";
import { auditLog } from "@/lib/auth/signed-message-middleware"
import { insertRoomActivity } from "@/lib/activity/room-activity"
import {
Expand All @@ -42,6 +43,7 @@
generateCorrelationId,
} from "@/lib/blockchain/logger"
import type { SupabaseClient } from "@supabase/supabase-js"
import { requireGroupOwner } from "@/lib/middleware/group-ownership"
import { notifyOwnershipTransferred } from "@/lib/notifications/service"

// ── Types ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -139,8 +141,38 @@
return walletMismatch
}

// ── Guard: callerWallet must be non-null before signature verification ────
if (!callerWallet) {
return NextResponse.json(
{ error: "No wallet address associated with this account." },
{ status: 400 }
)
}

const nonce = auth.nonce

// ── 5. Verify the Ed25519 signature ───────────────────────────────────────
if (!signature) {
return NextResponse.json(
{ error: "Signature is required for verification." },
{ status: 400 }
)
}

const isValid = verifyWalletSignature(callerWallet, nonce, signature)

Check warning on line 162 in app/api/groups/[id]/transfer-ownership/route.ts

View workflow job for this annotation

GitHub Actions / Check & Build

'isValid' is assigned a value but never used. Allowed unused vars must match /^_/u

// ── 6. Verify the group exists and the caller is the current owner ────────
const ownerCheck = await requireGroupOwner({
supabase,
groupId,
callerWallet,
userId: user.id,
})

if (ownerCheck instanceof NextResponse) {
return ownerCheck
}

// ── 4. Verify the group exists and the caller is the current owner ────────
const { data: group, error: groupError } = await supabase
.from("rooms")
Expand Down Expand Up @@ -254,7 +286,7 @@
new_owner: newOwnerWalletAddress,
timestamp: new Date().toISOString(),
}
const metadataHash = computeHash(transferMetadata as any)

Check warning on line 289 in app/api/groups/[id]/transfer-ownership/route.ts

View workflow job for this annotation

GitHub Actions / Check & Build

Unexpected any. Specify a different type
const result = await submitMetadataHash(groupId, metadataHash)

if (result.success && result.transactionHash) {
Expand All @@ -275,7 +307,7 @@
correlationId
)
}
} catch (blockchainErr: any) {

Check warning on line 310 in app/api/groups/[id]/transfer-ownership/route.ts

View workflow job for this annotation

GitHub Actions / Check & Build

Unexpected any. Specify a different type
// Non-fatal: the DB transfer is the source of truth
logBlockchainOperation(
"error",
Expand Down Expand Up @@ -427,4 +459,4 @@
{ status: 500 }
)
}
}
}
21 changes: 12 additions & 9 deletions app/api/rooms/[roomId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { consumeNonce, verifyWalletSignature } from "@/lib/auth/stellar-verify";
import { validateRequiredFields, validateStellarAddress } from "@/lib/auth/validation";
import { resolveRoomOwnerWallet } from "@/lib/auth/wallet-owner";
import { requireGroupOwner } from "@/lib/middleware/group-ownership";
import { computeHash } from "@/lib/blockchain/metadata-hash";
import { submitMetadataHash, getTransactionExplorerUrl } from "@/lib/blockchain/stellar-service";
import { GroupMetadata } from "@/types/blockchain";
Expand Down Expand Up @@ -72,13 +72,15 @@ export async function PATCH(
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}

const ownerWallet = await resolveRoomOwnerWallet(supabase, room);
if (!ownerWallet) {
return NextResponse.json({ error: "Cannot resolve room owner wallet" }, { status: 400 });
}
const ownerCheck = await requireGroupOwner({
supabase,
groupId: roomId,
callerWallet: walletAddress,
userId: user.id,
})

if (ownerWallet !== walletAddress) {
return NextResponse.json({ error: "Unauthorized: wallet does not own this room" }, { status: 403 });
if (ownerCheck instanceof NextResponse) {
return ownerCheck
}

const nonce = await consumeNonce(walletAddress);
Expand Down Expand Up @@ -120,14 +122,15 @@ export async function PATCH(
throw updateError || new Error("Failed to update room");
}

// ── Fixed: owner_wallet variable mapping resolved below ──────────────────
const metadata: GroupMetadata = {
id: updatedRoom.id,
name: updatedRoom.name,
description: updatedRoom.description,
created_by: updatedRoom.created_by,
created_at: updatedRoom.created_at,
is_private: updatedRoom.is_private,
owner_wallet: ownerWallet,
owner_wallet: updatedRoom.owner_wallet || walletAddress,
};

const currentMetadataHash = computeHash(metadata);
Expand Down Expand Up @@ -185,4 +188,4 @@ export async function PATCH(
console.error("[rooms/[roomId]] PATCH error:", error);
return NextResponse.json({ error: "Failed to update room" }, { status: 500 });
}
}
}
79 changes: 79 additions & 0 deletions lib/middleware/group-ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server"
import { resolveRoomOwnerWallet } from "../auth/wallet-owner"

type RequireGroupOwnerParams = {
supabase: any
groupId: string
callerWallet?: string | null
userId?: string | null
}

/**
* Verifies that the caller (by wallet or user id) is the owner of the group.
* Returns an object with `authorized: true` when check passes, otherwise
* returns a `NextResponse` with a properly shaped 403 Unauthorized JSON body.
*/
export async function requireGroupOwner({
supabase,
groupId,
callerWallet,
userId,
}: RequireGroupOwnerParams): Promise<any> {
try {
const { data: room, error } = await supabase
.from("rooms")
.select("id, owner_wallet, created_by")
.eq("id", groupId)
.maybeSingle()

if (error) {
console.error("[requireGroupOwner] group lookup error:", error)
return NextResponse.json({ error: "Failed to retrieve group" }, { status: 500 })
}

if (!room) {
return NextResponse.json({ error: "Group not found" }, { status: 404 })
}

const ownerWallet = await resolveRoomOwnerWallet(supabase, room)

if (callerWallet) {
if (!ownerWallet || ownerWallet !== callerWallet) {
console.warn(
`[requireGroupOwner] wallet ${
callerWallet?.substring(0, 8) ?? callerWallet
}... is not owner of group ${groupId}`
)
return NextResponse.json(
{ error: "Unauthorized", message: "You are not the owner of this group." },
{ status: 403 }
)
}

return { authorized: true, ownerWallet, ownerUserId: room.created_by }
}

if (userId) {
if (room.created_by !== userId) {
console.warn(`[requireGroupOwner] user ${userId} is not owner of group ${groupId}`)
return NextResponse.json(
{ error: "Unauthorized", message: "You are not the owner of this group." },
{ status: 403 }
)
}

return { authorized: true, ownerWallet, ownerUserId: room.created_by }
}

console.warn(`[requireGroupOwner] missing callerWallet and userId for group ${groupId}`)
return NextResponse.json(
{ error: "Unauthorized", message: "You are not the owner of this group." },
{ status: 403 }
)
} catch (err) {
console.error("[requireGroupOwner] unexpected error:", err)
return NextResponse.json({ error: "Failed to verify ownership" }, { status: 500 })
}
}

export default requireGroupOwner
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test:sensitive-actions": "node scripts/test-sensitive-actions-api.mjs",
"test:vote-remove": "node scripts/test-vote-remove-api.mjs",
"test:ephemeral": "node scripts/test-ephemeral-cleanup.mjs",
"test:unit": "vitest",
"start": "next start",
"cleanup:worker": "node scripts/ephemeral-cleanup-worker.js",
"cleanup:trigger": "node scripts/trigger-cleanup.mjs"
Expand Down Expand Up @@ -103,8 +104,8 @@
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5.9.3",
"vitest": "^3.2.4",
"ws": "^8.19.0"
"ws": "^8.19.0",
"vitest": "^3.2.4"
},
"optionalDependencies": {
"lightningcss-darwin-x64": "^1.30.2"
Expand Down
Loading
Loading