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:
Jeff Emmett 2026-02-13 11:54:12 -07:00
parent 4c7cc616fe
commit 3cd6ccee6d
8 changed files with 59 additions and 25 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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",
];

View File

@ -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,
},
});

View File

@ -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,

View File

@ -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 && (

View File

@ -8,7 +8,7 @@ interface SpaceContextValue {
name: string;
slug: string;
description: string | null;
isPublic: boolean;
visibility: string;
promotionThreshold: number;
votingPeriodDays: number;
creditsPerDay: number;

View File

@ -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;
}