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
138 changes: 138 additions & 0 deletions app/api/snippets/[id]/permissions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { NextRequest, NextResponse } from "next/server";
import { OwnershipMiddleware } from "../../ownership.middleware";
import {
grant,
revoke,
getPermissionsForSnippet,
getActivityLog,
} from "@/lib/permissions.service";
import { z } from "zod";

const grantSchema = z.object({
granteeWalletAddress: z
.string()
.min(56, "Invalid Stellar wallet address")
.max(56, "Invalid Stellar wallet address"),
permissionType: z.enum(["view", "edit"]),
});

const revokeSchema = z.object({
granteeWalletAddress: z.string().min(56).max(56),
permissionType: z.enum(["view", "edit"]),
});

/**
* GET /api/snippets/[id]/permissions
* Returns active permissions and activity log for a snippet.
* Only the snippet owner can see the full list.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const walletAddress = OwnershipMiddleware.extractWalletAddress(req);

if (!walletAddress) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const url = new URL(req.url);
const includeLog = url.searchParams.get("includeLog") === "true";

const [permissions, activityLog] = await Promise.all([
getPermissionsForSnippet(id),
includeLog ? getActivityLog(id) : Promise.resolve([]),
]);

return NextResponse.json({ permissions, activityLog });
} catch (error) {
console.error("[Permissions] GET error:", error);
return NextResponse.json({ error: "Failed to fetch permissions" }, { status: 500 });
}
}

/**
* POST /api/snippets/[id]/permissions
* Grant a permission to a wallet address.
* Body: { granteeWalletAddress, permissionType }
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const walletAddress = OwnershipMiddleware.extractWalletAddress(req);

if (!walletAddress) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await req.json();
const parsed = grantSchema.safeParse(body);

if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.errors },
{ status: 400 },
);
}

const { granteeWalletAddress, permissionType } = parsed.data;

const result = await grant(id, granteeWalletAddress, permissionType, walletAddress);

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 403 });
}

return NextResponse.json(result.permission, { status: 201 });
} catch (error) {
console.error("[Permissions] POST error:", error);
return NextResponse.json({ error: "Failed to grant permission" }, { status: 500 });
}
}

/**
* DELETE /api/snippets/[id]/permissions
* Revoke a permission from a wallet address.
* Body: { granteeWalletAddress, permissionType }
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const walletAddress = OwnershipMiddleware.extractWalletAddress(req);

if (!walletAddress) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await req.json();
const parsed = revokeSchema.safeParse(body);

if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.errors },
{ status: 400 },
);
}

const { granteeWalletAddress, permissionType } = parsed.data;

const result = await revoke(id, granteeWalletAddress, permissionType, walletAddress);

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 403 });
}

return NextResponse.json({ message: "Permission revoked" });
} catch (error) {
console.error("[Permissions] DELETE error:", error);
return NextResponse.json({ error: "Failed to revoke permission" }, { status: 500 });
}
}
35 changes: 26 additions & 9 deletions app/api/snippets/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getVersionById,
restoreVersion,
} from "@/lib/db";
import { canView, canEdit } from "@/lib/permissions.service";
import { ZodError } from "zod";

// Dependency Injection instantiation
Expand Down Expand Up @@ -54,6 +55,23 @@ export async function GET(

// Default: get snippet by ID via service
const snippet = await service.getSnippetById(id);

// Enforce view permission if snippet has an owner
const ownerWallet = (snippet as any).owner_wallet_address;
if (ownerWallet) {
const walletAddress = OwnershipMiddleware.extractWalletAddress(req);
if (!walletAddress) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const allowed = await canView(id, walletAddress);
if (!allowed) {
return NextResponse.json(
{ error: "Forbidden", message: "You do not have view access to this snippet." },
{ status: 403 },
);
}
}

return NextResponse.json(snippet);
} catch (error) {
if (error instanceof Error && error.message === "Snippet not found") {
Expand Down Expand Up @@ -93,7 +111,7 @@ export async function PUT(
}

// Default: update snippet via service
// Extract wallet address and verify ownership
// Extract wallet address and verify ownership or edit permission
const walletAddress = OwnershipMiddleware.extractWalletAddress(req);

if (!walletAddress) {
Expand All @@ -103,14 +121,13 @@ export async function PUT(
);
}

// Verify ownership before update
const ownershipResult = await ownershipMiddleware.verifyOwnership(
id,
walletAddress,
);

if (!ownershipResult.isOwner) {
return ownershipResult.error!;
// Check edit permission (owner OR granted edit access)
const editAllowed = await canEdit(id, walletAddress);
if (!editAllowed) {
return NextResponse.json(
{ error: "Forbidden", message: "You do not have edit access to this snippet." },
{ status: 403 },
);
}

const body = await req.json();
Expand Down
11 changes: 11 additions & 0 deletions app/snippets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { Trash2, Copy, Plus } from "lucide-react";
import { Navbar } from "@/components/navbar";
import Loader from "@/components/ui/loader";
import { VersionHistoryPanel } from "@/components/VersionHistory";
import { PermissionsManager } from "@/components/PermissionsManager";
import { useWallet } from "@/components/WalletConnect";

const LANGUAGES = [
"javascript",
Expand All @@ -43,6 +45,7 @@ interface Snippet {
code: string;
language: string;
tags: string[];
owner_wallet_address: string | null;
created_at: string;
updated_at: string;
}
Expand All @@ -59,6 +62,7 @@ interface PaginatedResponse {
const DEFAULT_LIMIT = 20;

export default function SnippetsPage() {
const wallet = useWallet();
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
Expand Down Expand Up @@ -477,6 +481,13 @@ transition-all duration-200"
snippetId={snippet.id}
onRestore={() => fetchSnippets()}
/>
{snippet.owner_wallet_address && (
<PermissionsManager
snippetId={snippet.id}
snippetTitle={snippet.title}
ownerWalletAddress={snippet.owner_wallet_address}
/>
)}
<Button
onClick={() => handleEdit(snippet)}
size="sm"
Expand Down
Loading