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": {
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue