feat: migrate to configurable space visibility with SDK auth
Replace isPublic boolean with visibility string field (public/public_read/ authenticated/members_only). Use encryptid-sdk for token verification. Enforce space access in API routes via checkSpaceAccess. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4c7cc616fe
commit
3cd6ccee6d
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ model Space {
|
|||
name String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
isPublic Boolean @default(false)
|
||||
visibility String @default("public_read") // public, public_read, authenticated, members_only
|
||||
ownerDid String?
|
||||
|
||||
// Configurable per-space voting parameters
|
||||
promotionThreshold Int @default(100)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSpaceAdmin, requireSpaceMembership } from "@/lib/spaces";
|
||||
import { checkSpaceAccess } from "@encryptid/sdk/server/nextjs";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// GET /api/spaces/[slug] — Get space details
|
||||
// GET /api/spaces/[slug] — Get space details (respects visibility)
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
|
|
@ -21,7 +22,24 @@ export async function GET(
|
|||
return NextResponse.json({ error: "Space not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(space);
|
||||
// Check space visibility
|
||||
const access = await checkSpaceAccess(req, slug, {
|
||||
getSpaceConfig: async () => ({
|
||||
spaceSlug: slug,
|
||||
visibility: (space.visibility as any) || "public_read",
|
||||
ownerDID: space.ownerDid || undefined,
|
||||
app: "rvote",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: access.reason },
|
||||
{ status: access.claims ? 403 : 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...space, readOnly: access.readOnly });
|
||||
}
|
||||
|
||||
// PATCH /api/spaces/[slug] — Update space (admin only)
|
||||
|
|
@ -44,7 +62,7 @@ export async function PATCH(
|
|||
|
||||
const body = await req.json();
|
||||
const allowedFields = [
|
||||
"name", "description", "isPublic",
|
||||
"name", "description", "visibility",
|
||||
"promotionThreshold", "votingPeriodDays",
|
||||
"creditsPerDay", "maxCredits", "startingCredits",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -40,7 +40,16 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, description, slug: requestedSlug } = body;
|
||||
const { name, description, slug: requestedSlug, visibility = "public_read" } = body;
|
||||
|
||||
// Validate visibility
|
||||
const validVisibilities = ["public", "public_read", "authenticated", "members_only"];
|
||||
if (!validVisibilities.includes(visibility)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
|
|
@ -67,6 +76,8 @@ export async function POST(req: NextRequest) {
|
|||
name: name.trim(),
|
||||
slug,
|
||||
description: description?.trim() || null,
|
||||
visibility,
|
||||
ownerDid: session.user.did || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export default async function SpaceLayout({
|
|||
name: space.name,
|
||||
slug: space.slug,
|
||||
description: space.description,
|
||||
isPublic: space.isPublic,
|
||||
visibility: space.visibility,
|
||||
promotionThreshold: space.promotionThreshold,
|
||||
votingPeriodDays: space.votingPeriodDays,
|
||||
creditsPerDay: space.creditsPerDay,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface SpaceCardProps {
|
|||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
visibility: string;
|
||||
_count: {
|
||||
members: number;
|
||||
proposals: number;
|
||||
|
|
@ -32,9 +32,15 @@ export function SpaceCard({ space }: SpaceCardProps) {
|
|||
{space.role === "ADMIN" && (
|
||||
<Badge variant="secondary" className="text-xs">Admin</Badge>
|
||||
)}
|
||||
{space.isPublic && (
|
||||
{(space.visibility === "public" || space.visibility === "public_read") && (
|
||||
<Badge variant="outline" className="text-xs">Public</Badge>
|
||||
)}
|
||||
{space.visibility === "authenticated" && (
|
||||
<Badge variant="outline" className="text-xs">Login Required</Badge>
|
||||
)}
|
||||
{space.visibility === "members_only" && (
|
||||
<Badge variant="outline" className="text-xs">Members Only</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{space.description && (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface SpaceContextValue {
|
|||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
visibility: string;
|
||||
promotionThreshold: number;
|
||||
votingPeriodDays: number;
|
||||
creditsPerDay: number;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
/**
|
||||
* EncryptID configuration for rvote-online
|
||||
*
|
||||
* Uses @encryptid/sdk for token verification instead of manual HTTP calls.
|
||||
*/
|
||||
|
||||
import { verifyEncryptIDToken as sdkVerify } from '@encryptid/sdk/server';
|
||||
|
||||
export const ENCRYPTID_SERVER_URL =
|
||||
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
|
||||
|
||||
/**
|
||||
* Verify an EncryptID JWT token by calling the EncryptID server.
|
||||
* Verify an EncryptID JWT token.
|
||||
* Returns claims if valid, null if invalid.
|
||||
*/
|
||||
export async function verifyEncryptIDToken(token: string): Promise<{
|
||||
|
|
@ -16,22 +20,15 @@ export async function verifyEncryptIDToken(token: string): Promise<{
|
|||
exp?: number;
|
||||
} | null> {
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_SERVER_URL}/api/session/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
const claims = await sdkVerify(token, {
|
||||
serverUrl: ENCRYPTID_SERVER_URL,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.valid) {
|
||||
return {
|
||||
sub: data.userId,
|
||||
username: data.username,
|
||||
did: data.did,
|
||||
exp: data.exp,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
return {
|
||||
sub: claims.sub,
|
||||
username: claims.username,
|
||||
did: claims.did,
|
||||
exp: claims.exp,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue