From 3cd6ccee6de55407de9cbee6ae5e215d5b942c2a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 11:54:12 -0700 Subject: [PATCH] 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 --- package.json | 1 + prisma/schema.prisma | 3 ++- src/app/api/spaces/[slug]/route.ts | 24 +++++++++++++++++++++--- src/app/api/spaces/route.ts | 13 ++++++++++++- src/app/s/[slug]/layout.tsx | 2 +- src/components/SpaceCard.tsx | 10 ++++++++-- src/components/SpaceProvider.tsx | 2 +- src/lib/encryptid.ts | 29 +++++++++++++---------------- 8 files changed, 59 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index f251a24..64f7eb8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 947d322..eaede54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/api/spaces/[slug]/route.ts b/src/app/api/spaces/[slug]/route.ts index c2a7a37..744c17d 100644 --- a/src/app/api/spaces/[slug]/route.ts +++ b/src/app/api/spaces/[slug]/route.ts @@ -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", ]; diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts index f15b8f4..96b1e78 100644 --- a/src/app/api/spaces/route.ts +++ b/src/app/api/spaces/route.ts @@ -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, }, }); diff --git a/src/app/s/[slug]/layout.tsx b/src/app/s/[slug]/layout.tsx index d579fbc..c4f37e3 100644 --- a/src/app/s/[slug]/layout.tsx +++ b/src/app/s/[slug]/layout.tsx @@ -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, diff --git a/src/components/SpaceCard.tsx b/src/components/SpaceCard.tsx index 739595a..4deb48c 100644 --- a/src/components/SpaceCard.tsx +++ b/src/components/SpaceCard.tsx @@ -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" && ( Admin )} - {space.isPublic && ( + {(space.visibility === "public" || space.visibility === "public_read") && ( Public )} + {space.visibility === "authenticated" && ( + Login Required + )} + {space.visibility === "members_only" && ( + Members Only + )} {space.description && ( diff --git a/src/components/SpaceProvider.tsx b/src/components/SpaceProvider.tsx index a05d005..665e4ea 100644 --- a/src/components/SpaceProvider.tsx +++ b/src/components/SpaceProvider.tsx @@ -8,7 +8,7 @@ interface SpaceContextValue { name: string; slug: string; description: string | null; - isPublic: boolean; + visibility: string; promotionThreshold: number; votingPeriodDays: number; creditsPerDay: number; diff --git a/src/lib/encryptid.ts b/src/lib/encryptid.ts index 5b494b6..f6977fc 100644 --- a/src/lib/encryptid.ts +++ b/src/lib/encryptid.ts @@ -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; }