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": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@encryptid/sdk": "file:../encryptid-sdk",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -72,7 +72,8 @@ model Space {
name String name String
slug String @unique slug String @unique
description String? @db.Text 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 // Configurable per-space voting parameters
promotionThreshold Int @default(100) promotionThreshold Int @default(100)

View File

@ -1,9 +1,10 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSpaceAdmin, requireSpaceMembership } from "@/lib/spaces"; import { requireSpaceAdmin, requireSpaceMembership } from "@/lib/spaces";
import { checkSpaceAccess } from "@encryptid/sdk/server/nextjs";
import { NextRequest, NextResponse } from "next/server"; 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( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ slug: string }> } { 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({ 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) // PATCH /api/spaces/[slug] — Update space (admin only)
@ -44,7 +62,7 @@ export async function PATCH(
const body = await req.json(); const body = await req.json();
const allowedFields = [ const allowedFields = [
"name", "description", "isPublic", "name", "description", "visibility",
"promotionThreshold", "votingPeriodDays", "promotionThreshold", "votingPeriodDays",
"creditsPerDay", "maxCredits", "startingCredits", "creditsPerDay", "maxCredits", "startingCredits",
]; ];

View File

@ -40,7 +40,16 @@ export async function POST(req: NextRequest) {
} }
const body = await req.json(); 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) { if (!name || typeof name !== "string" || name.trim().length === 0) {
return NextResponse.json({ error: "Name is required" }, { status: 400 }); return NextResponse.json({ error: "Name is required" }, { status: 400 });
@ -67,6 +76,8 @@ export async function POST(req: NextRequest) {
name: name.trim(), name: name.trim(),
slug, slug,
description: description?.trim() || null, description: description?.trim() || null,
visibility,
ownerDid: session.user.did || null,
}, },
}); });

View File

@ -41,7 +41,7 @@ export default async function SpaceLayout({
name: space.name, name: space.name,
slug: space.slug, slug: space.slug,
description: space.description, description: space.description,
isPublic: space.isPublic, visibility: space.visibility,
promotionThreshold: space.promotionThreshold, promotionThreshold: space.promotionThreshold,
votingPeriodDays: space.votingPeriodDays, votingPeriodDays: space.votingPeriodDays,
creditsPerDay: space.creditsPerDay, creditsPerDay: space.creditsPerDay,

View File

@ -8,7 +8,7 @@ interface SpaceCardProps {
name: string; name: string;
slug: string; slug: string;
description: string | null; description: string | null;
isPublic: boolean; visibility: string;
_count: { _count: {
members: number; members: number;
proposals: number; proposals: number;
@ -32,9 +32,15 @@ export function SpaceCard({ space }: SpaceCardProps) {
{space.role === "ADMIN" && ( {space.role === "ADMIN" && (
<Badge variant="secondary" className="text-xs">Admin</Badge> <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> <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>
</div> </div>
{space.description && ( {space.description && (

View File

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

View File

@ -1,12 +1,16 @@
/** /**
* EncryptID configuration for rvote-online * 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 = export const ENCRYPTID_SERVER_URL =
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com'; 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. * Returns claims if valid, null if invalid.
*/ */
export async function verifyEncryptIDToken(token: string): Promise<{ export async function verifyEncryptIDToken(token: string): Promise<{
@ -16,22 +20,15 @@ export async function verifyEncryptIDToken(token: string): Promise<{
exp?: number; exp?: number;
} | null> { } | null> {
try { try {
const res = await fetch(`${ENCRYPTID_SERVER_URL}/api/session/verify`, { const claims = await sdkVerify(token, {
method: 'POST', serverUrl: ENCRYPTID_SERVER_URL,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
}); });
return {
const data = await res.json(); sub: claims.sub,
if (data.valid) { username: claims.username,
return { did: claims.did,
sub: data.userId, exp: claims.exp,
username: data.username, };
did: data.did,
exp: data.exp,
};
}
return null;
} catch { } catch {
return null; return null;
} }