Add permission-scoped MCP, readiness checks, and management UI improvements

This commit is contained in:
2026-09-11 18:12:48 -04:00
parent 32e56b1c34
commit 815640d919
40 changed files with 845 additions and 57 deletions
+3 -1
View File
@@ -18,6 +18,7 @@
"@fontsource-variable/funnel-display": "^5.3.0",
"@fontsource-variable/geologica": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"@radix-ui/react-popover": "^1.1.23",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.4.3",
@@ -46,7 +47,8 @@
"superjson": "^2.2.2",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.67"
"zod": "^3.25.67",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
@@ -0,0 +1,10 @@
import { databaseReady } from "@album/database";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
const ready = await databaseReady();
return Response.json({ status: ready ? "ready" : "unavailable" }, {
status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" },
});
}
+6
View File
@@ -0,0 +1,6 @@
import { handleMcp } from "@/server/mcp/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const POST = handleMcp;
export const GET = handleMcp;
export const DELETE = handleMcp;
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { CopyIcon, KeyRoundIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel, FieldDescription } from "@/components/ui/field";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
export function AssistantConnections({ endpoint }: { endpoint: string }) {
const utils = api.useUtils();
const options = api.assistants.options.useQuery();
const tokens = api.assistants.list.useQuery();
const [name, setName] = useState("");
const [days, setDays] = useState(30);
const [readOnly, setReadOnly] = useState(true);
const [selected, setSelected] = useState<string[] | null>(null);
const [secret, setSecret] = useState<string | null>(null);
const permissions = selected ?? options.data?.defaults ?? [];
const create = api.assistants.create.useMutation({ onSuccess: async result => {
setSecret(result.token); create.reset(); setName(""); await utils.assistants.list.invalidate(); toast.success("Assistant token created");
}, onError: error => toast.error(error.message) });
const revoke = api.assistants.revoke.useMutation({ onSuccess: async () => { await utils.assistants.list.invalidate(); toast.success("Token revoked"); }, onError: error => toast.error(error.message) });
async function copy(value: string) { try { await navigator.clipboard.writeText(value); toast.success("Copied"); } catch { toast.error("Could not copy; select and copy the text manually."); } }
return <>
<Card><CardHeader><CardTitle>MCP endpoint</CardTitle><CardDescription>Streamable HTTP with an Authorization: Bearer token header. No browser cookies or shared administrator key.</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row"><Input aria-label="MCP endpoint" readOnly value={endpoint} /><Button variant="outline" onClick={() => void copy(endpoint)}><CopyIcon data-icon="inline-start" />Copy endpoint</Button></CardContent></Card>
{secret ? <Card><CardHeader><CardTitle>Save your token now</CardTitle><CardDescription>This is the only time it can be shown. Store it in your assistant's secret manager; never paste it into prompts.</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-3"><Input type="password" aria-label="New assistant token" readOnly value={secret} /><div className="flex flex-wrap gap-2"><Button onClick={() => void copy(secret)}><CopyIcon data-icon="inline-start" />Copy token</Button><Button variant="outline" onClick={() => setSecret(null)}>I've saved it</Button></div></CardContent></Card> : null}
<Card><CardHeader><CardTitle>Create a connection</CardTitle><CardDescription>Selected permissions are a ceiling, not a new role. Your current account permissions are checked for every action.</CardDescription></CardHeader>
<CardContent><form className="flex flex-col gap-6" onSubmit={event => { event.preventDefault(); if (!create.isPending) create.mutate({ name, days, readOnly, permissions }); }}>
<FieldGroup><Field><FieldLabel htmlFor="assistant-name">Connection name</FieldLabel><Input id="assistant-name" value={name} onChange={event => setName(event.target.value)} placeholder="My assistant" required maxLength={80} /></Field>
<Field><FieldLabel htmlFor="assistant-days">Expires after (days)</FieldLabel><Input id="assistant-days" type="number" min={1} max={365} required value={days} onChange={event => setDays(Number(event.target.value))} /></Field>
<Field orientation="horizontal"><FieldLabel htmlFor="assistant-read-only">Read-only access</FieldLabel><Switch id="assistant-read-only" checked={readOnly} onCheckedChange={setReadOnly} /></Field>
<FieldDescription>Turn off read-only only for assistants allowed to make changes, send emails, or delete data. Write tools also require an explicit confirmation argument.</FieldDescription>
</FieldGroup>
{!readOnly ? <Alert><AlertDescription>This token can perform changes permitted by the selections below and your current roles. Only give it to an assistant you trust.</AlertDescription></Alert> : null}
<details className="rounded-lg border p-4"><summary className="cursor-pointer font-medium">Permissions · {permissions.length} selected</summary>
<FieldGroup className="mt-4">{options.data?.permissions.map(permission => <Field key={permission} orientation="horizontal"><FieldLabel htmlFor={`permission-${permission}`}>{permission}</FieldLabel><Switch id={`permission-${permission}`} checked={permissions.includes(permission)} onCheckedChange={checked => setSelected(checked ? [...permissions, permission] : permissions.filter(value => value !== permission))} /></Field>)}</FieldGroup>
</details>
{options.isError ? <p role="alert">Permissions could not load. Refresh to retry.</p> : null}
<Button type="submit" disabled={create.isPending || !!secret || !name.trim() || !permissions.length || !options.data}><KeyRoundIcon data-icon="inline-start" />{create.isPending ? "Creating…" : "Create token"}</Button>
</form></CardContent></Card>
<Card><CardHeader><CardTitle>Your connections</CardTitle><CardDescription>Revocation blocks subsequent requests immediately. Requests already executing may finish.</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">{tokens.isLoading ? <p>Loading connections</p> : tokens.isError ? <p role="alert">Connections could not load. <Button variant="outline" onClick={() => tokens.refetch()}>Retry</Button></p> : !tokens.data?.length ? <p className="text-sm text-muted-foreground">No assistant connections yet.</p> : tokens.data.map(token => <div key={token.id} className="flex flex-col gap-3 border-b pb-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="font-medium">{token.name}</p><p className="text-sm text-muted-foreground">{token.revokedAt ? "Revoked" : token.expiresAt < new Date() ? "Expired" : token.readOnly ? "Read-only" : "Read and write"} · expires {token.expiresAt.toLocaleDateString()} · {token.permissions.length} permissions</p><p className="text-xs text-muted-foreground">{token.lastUsedAt ? `Last used ${token.lastUsedAt.toLocaleString()}` : "Not used yet"}</p></div>
{!token.revokedAt ? <Button variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate({ id: token.id })}>Revoke</Button> : null}
</div>)}</CardContent></Card>
</>;
}
@@ -0,0 +1,10 @@
import { AssistantConnections } from "./connections";
import { publicAppOrigin } from "@/server/public-app-url";
export default function AssistantsPage() {
return <div className="reveal flex flex-col gap-6">
<div><h1 className="text-3xl font-semibold tracking-tight">Assistant connections</h1>
<p className="text-sm text-muted-foreground">Connect assistants through MCP with your existing event, group, and platform permissions.</p></div>
<AssistantConnections endpoint={`${publicAppOrigin()}/api/mcp`} />
</div>;
}
@@ -70,8 +70,8 @@ export function ModerationGrid({
onError: (error) => toast.error(error.message),
});
const moderateSubmission = api.manager.moderateSubmission.useMutation({
onSuccess: async () => {
toast.success("Submission updated");
onSuccess: async (result) => {
toast.success(`${result.updatedCount} photos updated`);
await utils.manager.photos.invalidate({ eventId });
},
onError: (error) => toast.error(error.message),
@@ -211,9 +211,15 @@ export function ModerationGrid({
</a>
</DropdownMenuItem>
) : null}
{canModerate && photo.processingStatus === "ready" ? (
{canModerate ? (
<DropdownMenuItem className="min-h-11 px-3"
onSelect={() => moderateSubmission.mutate({ submissionId: photo.submissionId, visibility: "hidden" })}>
onSelect={() => moderateSubmission.mutate({ eventId, submissionId: photo.submissionId, visibility: "public" })}>
<CheckIcon aria-hidden="true" />Approve Entire Submission
</DropdownMenuItem>
) : null}
{canModerate ? (
<DropdownMenuItem className="min-h-11 px-3"
onSelect={() => moderateSubmission.mutate({ eventId, submissionId: photo.submissionId, visibility: "hidden" })}>
<EyeOffIcon aria-hidden="true" />Hide Entire Submission
</DropdownMenuItem>
) : null}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function DashboardPeoplePage() {
<Empty className="border">
<EmptyHeader>
<EmptyTitle>No group yet</EmptyTitle>
<EmptyDescription>Create an event to start a group.</EmptyDescription>
<EmptyDescription>Use Create group in the header to start a workspace for your team.</EmptyDescription>
</EmptyHeader>
</Empty>
);
+20 -1
View File
@@ -17,6 +17,8 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { uploadProgress } from "@/lib/upload-progress";
type QueueItem = {
file?: File;
@@ -59,6 +61,7 @@ export function GuestUpload({
const retryUpload = api.photos.retryUpload.useMutation();
const uploading = useRef(false);
const utils = api.useUtils();
const progress = uploadProgress(queue);
useEffect(() => {
const storedName = localStorage.getItem(guestKey(slug, "name")) ?? "";
@@ -257,7 +260,20 @@ export function GuestUpload({
</Button>
</label> : null}
{queue.length > 0 ? (
<ul className="flex flex-col gap-3" aria-live="polite">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<CardTitle>{busy ? "Uploading your photos" : progress.failed ? "Some photos need attention" : "Photos uploaded"}</CardTitle>
<span className="text-2xl font-semibold tabular-nums">{progress.percent}%</span>
</div>
<CardDescription role="status">{progress.completed} of {progress.total} photos uploaded{progress.failed ? ` · ${progress.failed} need retry` : ""}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Progress value={progress.percent} aria-label="Overall upload progress" />
<p className="text-sm text-muted-foreground">{busy ? "Keep this page open until uploading finishes." : progress.failed ? "Open file details below to retry unsuccessful uploads." : "Your originals are saved. Gallery previews may take a moment to appear."}</p>
<details className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 text-sm font-medium [&::-webkit-details-marker]:hidden">File details<ChevronDownIcon className="size-4 transition-transform group-open:rotate-180" aria-hidden="true" /></summary>
<ul className="flex max-h-80 flex-col gap-4 overflow-y-auto pt-3">
{queue.map((item) => (
<li key={item.id} className="flex flex-col gap-1">
<div className="flex justify-between gap-3 text-sm">
@@ -272,6 +288,9 @@ export function GuestUpload({
</li>
))}
</ul>
</details>
</CardContent>
</Card>
) : null}
<details
className="rounded-2xl border bg-card/60 px-4 py-1"
@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
export function CreateGroupDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [name, setName] = useState("");
const router = useRouter();
const utils = api.useUtils();
const create = api.group.create.useMutation({
onSuccess: async () => {
await utils.invalidate();
setName("");
onOpenChange(false);
toast.success("Group created");
router.push("/dashboard/people");
router.refresh();
},
onError: error => toast.error(error.message),
});
return <Dialog open={open} onOpenChange={next => { if (!create.isPending) onOpenChange(next); }}>
<DialogContent>
<DialogHeader><DialogTitle>Create a group</DialogTitle><DialogDescription>A separate workspace for your team. Existing events, members, and event allowances stay with their current group.</DialogDescription></DialogHeader>
<form className="flex flex-col gap-6" onSubmit={event => { event.preventDefault(); if (!create.isPending) create.mutate({ name }); }}>
<FieldGroup><Field><FieldLabel htmlFor="new-group-name">Group name</FieldLabel><Input id="new-group-name" value={name} onChange={event => setName(event.target.value)} maxLength={100} required disabled={create.isPending} autoFocus /></Field></FieldGroup>
<DialogFooter><Button type="button" variant="outline" disabled={create.isPending} onClick={() => onOpenChange(false)}>Cancel</Button><Button type="submit" disabled={create.isPending || !name.trim()}>{create.isPending ? <Spinner data-icon="inline-start" /> : null}Create group</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>;
}
+14 -4
View File
@@ -1,6 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { CreateGroupDialog } from "@/components/create-group-dialog";
import { Button } from "@/components/ui/button";
import { ImagesIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
@@ -21,6 +24,7 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
compact?: boolean;
}) {
const router = useRouter();
const [creating, setCreating] = useState(false);
const utils = api.useUtils();
const select = api.group.select.useMutation({
onSuccess: async () => {
@@ -30,11 +34,15 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
},
onError: (error) => toast.error(error.message),
});
if (groups.length === 0) return null;
if (groups.length === 0) return <><Button variant="outline" onClick={() => setCreating(true)}>Create group</Button><CreateGroupDialog open={creating} onOpenChange={setCreating} /></>;
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0]!;
return (
<Select value={activeGroup.id} disabled={select.isPending}
onValueChange={(groupId) => { if (groupId !== activeGroup.id) select.mutate({ groupId }); }}>
<><Select value={activeGroup.id} disabled={select.isPending}
onValueChange={(groupId) => {
if (groupId === "create") { setCreating(true); return; }
if (groupId === "manage") { router.push("/dashboard/people"); return; }
if (groupId !== activeGroup.id) select.mutate({ groupId });
}}>
<SelectTrigger aria-label="Active workspace"
className={cn(topBarControlClass, "w-11 shrink-0 px-3 sm:w-52 [&>svg:last-child]:hidden sm:[&>svg:last-child]:block", !compact && "sm:w-full")}>
<ImagesIcon aria-hidden="true" className="shrink-0 text-primary" />
@@ -47,8 +55,10 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
{group.name}
</SelectItem>
))}
<SelectItem value="manage" className="min-h-11">Manage current group</SelectItem>
<SelectItem value="create" className="min-h-11">Create a new group</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Select><CreateGroupDialog open={creating} onOpenChange={setCreating} /></>
);
}
+1 -1
View File
@@ -63,7 +63,7 @@ export function SiteHeaderBar({ links, primary, signedIn, user, groups = [], act
<BrandLockup markClassName="size-7" wordmarkClassName="hidden sm:inline" />
</Link>
{signedIn && backend && groups.length > 0 && !pathname.startsWith("/admin") ? (
{signedIn && backend && !pathname.startsWith("/admin") ? (
<>
<span className="mx-1 h-7 w-px bg-border" />
<div className="min-w-0 max-w-56 flex-1">
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test";
import { uploadProgress } from "./upload-progress";
test("upload summary counts completion and averages file progress", () => {
expect(uploadProgress([{ status: "done", progress: 100 }, { status: "uploading", progress: 50 }]))
.toEqual({ completed: 1, failed: 0, percent: 75, total: 2 });
});
test("finalization and failures cannot report complete", () => {
expect(uploadProgress([{ status: "error", progress: 100 }]))
.toEqual({ completed: 0, failed: 1, percent: 99, total: 1 });
expect(uploadProgress([{ status: "uploading", progress: 100 }]).percent).toBe(99);
expect(uploadProgress([{ status: "done", progress: 100 }]).percent).toBe(100);
expect(uploadProgress([]).percent).toBe(0);
});
+7
View File
@@ -0,0 +1,7 @@
export function uploadProgress(items: readonly { progress: number; status: string }[]) {
const completed = items.filter(item => item.status === "done").length;
const failed = items.filter(item => item.status === "error").length;
const percent = items.length === 0 ? 0 : Math.floor(items.reduce((sum, item) =>
sum + (item.status === "done" ? 100 : Math.min(99, Math.max(0, item.progress))), 0) / items.length);
return { completed, failed, percent, total: items.length };
}
+3 -1
View File
@@ -1,4 +1,4 @@
import { CalendarDays, Images, Users, ShieldCheck, Settings } from "lucide-react";
import { CalendarDays, Images, Users, ShieldCheck, Settings, Bot } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export type NavigationItem = {
@@ -29,6 +29,8 @@ export const albumWorkspaces: NavigationWorkspace[] = [
icon: Users, href: "/dashboard/people",
items: [{ label: "Workspace Access", href: "/dashboard/people", icon: Users }],
},
{ label: "Assistants", description: "Permission-scoped MCP connections.", icon: Bot, href: "/dashboard/assistants",
items: [{ label: "Assistant Connections", href: "/dashboard/assistants", icon: Bot }] },
];
export const adminWorkspaces: NavigationWorkspace[] = [
+2
View File
@@ -9,8 +9,10 @@ import { photosRouter } from "./routers/photos";
import { platformRouter } from "./routers/platform";
import { viewerRouter } from "./routers/viewer";
import { signsRouter } from "./routers/signs";
import { assistantsRouter } from "./routers/assistants";
export const appRouter = createTRPCRouter({
assistants: assistantsRouter,
health: publicProcedure.query(() => ({
status: "ok" as const,
service: "album-trpc",
@@ -0,0 +1,45 @@
import { randomBytes, createHash } from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import { assistantTokens, auditEvents, getDb } from "@album/database";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { defaultAssistantPermissions, permissionOptions } from "@/server/mcp/catalog";
import { consumeRateLimit } from "@/server/rate-limit";
import { createAssistantTokenInputSchema } from "@album/contracts";
export const assistantsRouter = createTRPCRouter({
options: protectedProcedure.query(() => ({ permissions: permissionOptions, defaults: defaultAssistantPermissions })),
list: protectedProcedure.query(({ ctx }) => getDb().select({
id: assistantTokens.id, name: assistantTokens.name, permissions: assistantTokens.permissions,
readOnly: assistantTokens.readOnly, expiresAt: assistantTokens.expiresAt, revokedAt: assistantTokens.revokedAt,
lastUsedAt: assistantTokens.lastUsedAt, createdAt: assistantTokens.createdAt,
}).from(assistantTokens).where(eq(assistantTokens.userId, ctx.session.user.id)).orderBy(desc(assistantTokens.createdAt))),
create: protectedProcedure.input(createAssistantTokenInputSchema).mutation(async ({ ctx, input }) => {
if (input.permissions.some(value => !permissionOptions.includes(value as typeof permissionOptions[number]))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Unknown permission" });
}
if (!ctx.session.user.emailVerified) throw new TRPCError({ code: "FORBIDDEN", message: "Verify your email before creating an assistant token." });
const limit = await consumeRateLimit({ namespace: "assistant-token-create", identifier: ctx.session.user.id, limit: 10, windowMs: 3600000 });
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
const token = `ma_mcp_${randomBytes(32).toString("base64url")}`;
const id = await getDb().transaction(async tx => {
const [row] = await tx.insert(assistantTokens).values({ userId: ctx.session.user.id, name: input.name,
permissions: [...new Set(input.permissions)], readOnly: input.readOnly,
tokenHash: createHash("sha256").update(token).digest("hex"), expiresAt: new Date(Date.now() + input.days * 86400000),
}).returning({ id: assistantTokens.id });
await tx.insert(auditEvents).values({ actorUserId: ctx.session.user.id, action: "assistant.token.create", subjectType: "assistant_token", subjectId: row!.id });
return row!.id;
});
return { id, token }; // One-time display only; no recoverable secret is stored.
}),
revoke: protectedProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
await getDb().transaction(async tx => {
const changed = await tx.update(assistantTokens).set({ revokedAt: new Date(), updatedAt: new Date() })
.where(and(eq(assistantTokens.id, input.id), eq(assistantTokens.userId, ctx.session.user.id))).returning({ id: assistantTokens.id });
if (!changed.length) throw new TRPCError({ code: "NOT_FOUND" });
await tx.insert(auditEvents).values({ actorUserId: ctx.session.user.id, action: "assistant.token.revoke", subjectType: "assistant_token", subjectId: input.id });
});
return { ok: true };
}),
});
+12
View File
@@ -69,6 +69,18 @@ async function requireInviteManagement(userId: string, invite: typeof invites.$i
}
export const groupRouter = createTRPCRouter({
create: protectedProcedure.input(z.object({ name: z.string().trim().min(1).max(100) })).mutation(async ({ ctx, input }) => {
const limit = await consumeRateLimit({ namespace: "group-create", identifier: ctx.session.user.id, limit: 5, windowMs: 3600000 });
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Please wait before creating another group." });
const group = await getDb().transaction(async tx => {
const [created] = await tx.insert(groups).values({ name: input.name, slug: `group-${crypto.randomUUID()}`, createdByUserId: ctx.session.user.id }).returning();
await tx.insert(groupMemberships).values({ groupId: created!.id, userId: ctx.session.user.id, role: "owner" });
await tx.insert(auditEvents).values({ groupId: created!.id, actorUserId: ctx.session.user.id, action: "group.create", subjectType: "group", subjectId: created!.id });
return created!;
});
ctx.appendSetCookie(serializeCookie(GROUP_COOKIE, group.id, { maxAge: 60 * 60 * 24 * 365 }));
return { id: group.id, name: group.name };
}),
copyInviteLink: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const [invite] = await getDb().select().from(invites).where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId))).limit(1);
if (!invite || invite.kind !== "email") throw new TRPCError({ code: "NOT_FOUND" });
+39 -21
View File
@@ -81,6 +81,18 @@ async function signedPhotoUrls(photo: {
}
export const managerRouter = createTRPCRouter({
stats: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.OVERVIEW_READ);
const rows = await getDb().execute(sql`select
(select count(*)::int from photos where event_id = ${input.eventId}) as photos,
(select count(*)::int from photos where event_id = ${input.eventId} and visibility = 'public' and processing_status = 'ready') as approved,
(select count(*)::int from photos where event_id = ${input.eventId} and visibility = 'pending') as awaiting_review,
(select count(*)::int from photos where event_id = ${input.eventId} and processing_status = 'failed') as failed,
(select count(*)::int from guests where event_id = ${input.eventId}) as guests,
(select count(*)::int from guests where event_id = ${input.eventId} and note is not null and note <> '') as notes`);
return rows[0];
}),
requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
@@ -511,7 +523,7 @@ export const managerRouter = createTRPCRouter({
const [submission] = await getDb()
.select()
.from(submissions)
.where(eq(submissions.id, input.submissionId))
.where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, input.eventId)))
.limit(1);
if (!submission) throw new TRPCError({ code: "NOT_FOUND" });
const platformRole = await getPlatformRole(ctx.session.user.id);
@@ -521,28 +533,34 @@ export const managerRouter = createTRPCRouter({
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE);
const rows = await getDb()
.select()
.from(photos)
.where(eq(photos.submissionId, submission.id));
for (const photo of rows) {
if (photo.processingStatus !== "ready") continue;
if (!canTransitionVisibility(photo.visibility, input.visibility)) continue;
await getDb()
.update(photos)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(photos.id, photo.id));
if (input.visibility === "private" && !access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "submission.visibility",
subjectType: "submission",
subjectId: submission.id,
metadata: { visibility: input.visibility, count: rows.length },
return getDb().transaction(async (tx) => {
const rows = await tx
.select()
.from(photos)
.where(and(eq(photos.submissionId, submission.id), eq(photos.eventId, event.id)))
.for("update");
const eligible = rows.filter(photo =>
(photo.visibility !== "private" || access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) &&
canTransitionVisibility(photo.visibility, input.visibility));
if (eligible.length) {
await tx.update(photos)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(and(eq(photos.eventId, event.id), eq(photos.submissionId, submission.id), inArray(photos.id, eligible.map(photo => photo.id))));
}
await tx.insert(auditEvents).values({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "submission.visibility",
subjectType: "submission",
subjectId: submission.id,
metadata: { visibility: input.visibility, count: eligible.length },
});
return { ok: true as const, updatedCount: eligible.length };
});
return { ok: true as const };
}),
deletePhoto: protectedProcedure
+14 -1
View File
@@ -1,5 +1,5 @@
import { TRPCError } from "@trpc/server";
import { desc, eq, ilike, or } from "drizzle-orm";
import { desc, eq, ilike, or, sql } from "drizzle-orm";
import {
auditEvents,
deploymentSettings,
@@ -29,6 +29,19 @@ import { writeAudit } from "@/server/audit";
import { createInviteCode } from "@/server/invites";
export const platformRouter = createTRPCRouter({
stats: platformPermissionProcedure(PLATFORM_PERMISSIONS.EVENTS_READ).query(async () => {
const rows = await getDb().execute(sql`select
(select count(*)::int from events) as events,
(select count(*)::int from groups) as groups,
(select count(*)::int from photos) as photos,
(select count(*)::int from guests) as guests,
(select count(*)::int from guests where note is not null and note <> '') as notes,
(select count(*)::int from photo_jobs where status = 'pending') as queued,
(select count(*)::int from photo_jobs where status = 'processing') as processing,
(select count(*)::int from photo_jobs where status = 'failed') as failed,
(select coalesce(sum(byte_size),0)::text from photos) as original_bytes`);
return rows[0];
}),
settings: platformPermissionProcedure(PLATFORM_PERMISSIONS.SETTINGS_MANAGE).query(
async () => getDeploymentSettings(),
),
+24
View File
@@ -0,0 +1,24 @@
import { createHash } from "node:crypto";
import { and, eq, gt, isNull } from "drizzle-orm";
import { assistantTokens, getDb, user } from "@album/database";
import type { TrpcContext } from "../api/trpc";
export async function authenticateAssistant(header: string | null) {
const match = /^Bearer (ma_mcp_[A-Za-z0-9_-]{43})$/i.exec(header ?? "");
if (!match) return null;
const [row] = await getDb().select({ token: assistantTokens, user }).from(assistantTokens)
.innerJoin(user, eq(user.id, assistantTokens.userId))
.where(and(eq(assistantTokens.tokenHash, createHash("sha256").update(match[1]!).digest("hex")),
isNull(assistantTokens.revokedAt), gt(assistantTokens.expiresAt, new Date()), eq(user.emailVerified, true))).limit(1);
return row ?? null;
}
export function assistantContext(principal: NonNullable<Awaited<ReturnType<typeof authenticateAssistant>>>, origin: string): TrpcContext {
const now = new Date();
return {
session: { user: principal.user, session: { id: principal.token.id, userId: principal.user.id,
token: "", expiresAt: principal.token.expiresAt, createdAt: now, updatedAt: now, ipAddress: null, userAgent: "MCP" } },
requestOrigin: origin, clientIdentifier: principal.token.id, cookies: new Map(), activeGroupId: null,
guestTokenForEvent: () => null, setCookies: [], appendSetCookie: () => {},
};
}
+74
View File
@@ -0,0 +1,74 @@
import { EVENT_PERMISSIONS as E, GROUP_PERMISSIONS as G, PLATFORM_PERMISSIONS as P } from "../permissions";
export const permissionOptions = [...new Set([...Object.values(E), ...Object.values(G), ...Object.values(P)])];
export const defaultAssistantPermissions = [E.OVERVIEW_READ, G.GROUP_READ, P.EVENTS_READ];
type ToolDefinition = { path: string; description: string; permissions: string[]; write?: boolean };
const tool = (path: string, description: string, permissions: string[], write = false): ToolDefinition => ({ path, description, permissions, write });
// Explicit allowlist: new tRPC actions are NOT automatically exposed to assistants.
export const assistantTools: ToolDefinition[] = [
tool("platform.stats", "Platform-wide counts and processing queue statistics.", [P.EVENTS_READ]),
tool("platform.events", "List platform events.", [P.EVENTS_READ]),
tool("platform.groups", "List platform groups and event allowances.", [P.EVENTS_READ]),
tool("platform.users", "Find accounts and platform roles.", [P.USERS_MANAGE]),
tool("platform.settings", "Read deployment settings.", [P.SETTINGS_MANAGE]),
tool("platform.audit", "Read platform audit history.", [P.AUDIT_READ]),
tool("platform.updateSettings", "Update deployment settings.", [P.SETTINGS_MANAGE], true),
tool("platform.setPlatformRole", "Change an account's platform role. Grant permissions are checked separately.", [P.USERS_MANAGE], true),
tool("platform.grantEntitlement", "Grant a group event allowance.", [P.ENTITLEMENTS_MANAGE], true),
tool("platform.createCode", "Create an invitation code with entitlements.", [P.ENTITLEMENTS_MANAGE], true),
tool("group.list", "List your groups.", [G.GROUP_READ]),
tool("group.get", "Read a group and its allowance.", [G.GROUP_READ]),
tool("group.members", "List group accounts.", [G.GROUP_READ]),
tool("group.pendingInvites", "List pending group or event invitations.", [G.GROUP_READ]),
tool("group.audit", "Read group audit history.", [G.GROUP_READ]),
tool("group.rename", "Rename a group.", [G.GROUP_MANAGE], true),
tool("group.create", "Create a separate group without copying existing event allowances.", [G.GROUP_MANAGE], true),
tool("group.setMember", "Add an existing account or update a group role.", [G.PEOPLE_MANAGE], true),
tool("group.removeMember", "Remove a group account's membership.", [G.PEOPLE_MANAGE], true),
tool("group.inviteEmail", "Send an account invitation email.", [G.PEOPLE_MANAGE], true),
tool("group.createCode", "Create a group invitation code.", [G.PEOPLE_MANAGE], true),
tool("group.resendInvite", "Resend an invitation, optionally regenerating its link.", [G.PEOPLE_MANAGE], true),
tool("group.revokeInvite", "Revoke a pending invitation.", [G.PEOPLE_MANAGE], true),
tool("group.copyInviteLink", "Get a pending invitation's existing link.", [G.PEOPLE_MANAGE]),
tool("manager.events", "List events accessible to your account.", [E.OVERVIEW_READ]),
tool("manager.event", "Read event settings, schedule, and access.", [E.OVERVIEW_READ]),
tool("manager.stats", "Read event upload, guest, note, and processing counts.", [E.OVERVIEW_READ]),
tool("manager.photos", "List event photos. Private photos require photos.private.read.", [E.PHOTOS_READ]),
tool("manager.notes", "List guest notes.", [E.NOTES_READ]),
tool("manager.guests", "List event guests and contact details.", [E.PEOPLE_READ]),
tool("manager.members", "List accounts with event access.", [E.PEOPLE_READ]),
tool("manager.audit", "Read event audit history.", [E.AUDIT_READ]),
tool("manager.emailHistory", "Read guest email delivery history.", [E.PEOPLE_MANAGE]),
tool("manager.emailPreview", "Preview the event's guest notification email without sending it.", [E.PEOPLE_MANAGE]),
tool("manager.checkSlug", "Check whether an event URL slug is available.", [E.SETTINGS_MANAGE]),
tool("manager.generateSlug", "Suggest an available event URL slug.", [E.SETTINGS_MANAGE]),
tool("manager.searchLocations", "Search event locations.", [E.SETTINGS_MANAGE]),
tool("manager.createEvent", "Create an event in an explicit groupId; existing quota and invitation rules apply.", [G.EVENTS_CREATE], true),
tool("manager.updateEvent", "Update an event, schedule, banner selection, or publishing policy.", [E.SETTINGS_MANAGE], true),
tool("manager.completeEvent", "Complete an event and close submissions.", [E.SETTINGS_MANAGE], true),
tool("manager.releaseGallery", "Release the event gallery.", [E.GALLERY_RELEASE], true),
tool("manager.moderatePhoto", "Approve, hide, reject, or privatize one photo.", [E.PHOTOS_MODERATE], true),
tool("manager.moderateSubmission", "Approve or change visibility of an entire submission.", [E.PHOTOS_MODERATE], true),
tool("manager.moderateNote", "Approve or unpublish a guest note.", [E.PHOTOS_MODERATE], true),
tool("manager.deletePhoto", "Permanently delete a photo and its stored files.", [E.PHOTOS_DELETE], true),
tool("manager.deleteSubmission", "Permanently delete a submission and its stored photos.", [E.PHOTOS_DELETE], true),
tool("manager.setMember", "Add or update an account's event access.", [E.PEOPLE_MANAGE], true),
tool("manager.removeMember", "Remove an account's event access.", [E.PEOPLE_MANAGE], true),
tool("manager.notifyGuests", "Send photo-ready emails to eligible opted-in guests.", [E.PEOPLE_MANAGE], true),
tool("manager.retryEmail", "Retry a failed guest email.", [E.PEOPLE_MANAGE], true),
tool("manager.exports", "List your event exports.", [E.SETTINGS_MANAGE, E.PHOTOS_PRIVATE_READ]),
tool("manager.downloadExport", "Get a short-lived download link for your completed export.", [E.SETTINGS_MANAGE, E.PHOTOS_PRIVATE_READ]),
tool("manager.requestExport", "Queue an original-quality ZIP export.", [E.SETTINGS_MANAGE, E.PHOTOS_PRIVATE_READ], true),
tool("manager.deleteEvent", "Permanently delete an event.", [E.EVENT_DELETE], true),
tool("signs.get", "Read the saved event sign configuration.", [E.OVERVIEW_READ]),
tool("signs.prepare", "Prepare a direct storage upload for a sign configuration.", [E.SETTINGS_MANAGE], true),
tool("signs.save", "Save an uploaded sign configuration with revision checking.", [E.SETTINGS_MANAGE], true),
tool("banners.create", "Prepare a direct storage upload for an event banner.", [E.SETTINGS_MANAGE], true),
tool("banners.complete", "Finish a banner upload and queue optimization.", [E.SETTINGS_MANAGE], true),
tool("banners.retry", "Retry a failed banner optimization.", [E.SETTINGS_MANAGE], true),
tool("banners.status", "Read banner processing status.", [E.SETTINGS_MANAGE]),
];
export function visibleAssistantTools(token: { permissions: string[]; readOnly: boolean }) {
return assistantTools.filter(tool => (!token.readOnly || !tool.write) && tool.permissions.every(permission => token.permissions.includes(permission)));
}
@@ -0,0 +1,83 @@
import { expect, test } from "bun:test";
import { and, eq, inArray } from "drizzle-orm";
import { auditEvents, assistantTokens, getDb, user, groups, groupMemberships, events, eventMemberships } from "@album/database";
import { assistantsRouter } from "../api/routers/assistants";
import { appRouter } from "../api/root";
import { handleMcp } from "./server";
import { assistantTools, permissionOptions } from "./catalog";
import type { TrpcContext } from "../api/trpc";
test("MCP allowlist references real procedures and has unique names", () => {
const procedures = appRouter._def.procedures as unknown as Record<string, unknown>;
expect(new Set(assistantTools.map(tool => tool.path)).size).toBe(assistantTools.length);
for (const tool of assistantTools) expect(procedures[tool.path]).toBeDefined();
expect(assistantTools.some(tool => tool.path === "viewer.me")).toBe(false);
expect(assistantTools.some(tool => tool.path.startsWith("assistants."))).toBe(false);
});
test.skipIf(process.env.MCP_INTEGRATION !== "1")("MCP protocol, permission ceilings, role changes, and revocation", async () => {
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const db = getDb();
const id = crypto.randomUUID();
const [person] = await db.insert(user).values({ id, name: "MCP test", email: `${id}@manyangles.test`, emailVerified: true }).returning();
const ctx: TrpcContext = { session: { user: person!, session: {} } as TrpcContext["session"], cookies: new Map(), activeGroupId: null,
requestOrigin: "http://localhost:3000", clientIdentifier: id, guestTokenForEvent: () => null, setCookies: [], appendSetCookie: () => {} };
const owner = assistantsRouter.createCaller(ctx);
let groupId: string | undefined;
let otherGroupId: string | undefined;
const request = (token: string, method: string, params: unknown = {}, origin?: string) => handleMcp(new Request("http://localhost:3000/api/mcp", {
method: "POST", headers: { Authorization: `Bearer ${token}`, Accept: "application/json, text/event-stream", "Content-Type": "application/json", ...(origin ? { Origin: origin } : {}) },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
}));
try {
const [group] = await db.insert(groups).values({ name: "MCP test", slug: id, createdByUserId: id }).returning();
groupId = group!.id;
await db.insert(groupMemberships).values({ groupId, userId: id, role: "owner" });
const [event] = await db.insert(events).values({ groupId, title: "MCP test", slug: id }).returning();
await db.insert(eventMemberships).values({ eventId: event!.id, userId: id, role: "owner" });
const [otherGroup] = await db.insert(groups).values({ name: "Isolated MCP test", slug: `${id}-other`, createdByUserId: id }).returning();
otherGroupId = otherGroup!.id;
const [otherEvent] = await db.insert(events).values({ groupId: otherGroupId, title: "Not accessible", slug: `${id}-other` }).returning();
const read = await owner.create({ name: "Read only", permissions: [...permissionOptions], readOnly: true, days: 1 });
const write = await owner.create({ name: "Writer", permissions: ["group.manage", "group.read", "overview.read"], readOnly: false, days: 1 });
const limited = await owner.create({ name: "Limited", permissions: ["group.read"], readOnly: false, days: 1 });
const init = await request(read.token, "initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1" } });
expect(init.status).toBe(200);
expect((await init.json()).result.serverInfo.name).toBe("manyangles");
const listed = await (await request(read.token, "tools/list")).json();
expect(listed.result.tools.some((tool: { name: string }) => tool.name === "manager_stats")).toBe(true);
expect(listed.result.tools.some((tool: { name: string }) => tool.name === "group_rename")).toBe(false);
expect((await (await request(read.token, "tools/call", { name: "group_rename", arguments: { input: { groupId, name: "Denied" }, confirm: true } })).json()).result.isError).toBe(true);
expect((await (await request(limited.token, "tools/call", { name: "group_rename", arguments: { input: { groupId, name: "Denied" }, confirm: true } })).json()).result.isError).toBe(true);
expect((await (await request(write.token, "tools/call", { name: "group_rename", arguments: { input: { groupId, name: "Denied" } } })).json()).result.isError).toBe(true);
const changed = await (await request(write.token, "tools/call", { name: "group_rename", arguments: { input: { groupId, name: "Changed through MCP" }, confirm: true } })).json();
expect(changed.result.isError).toBeUndefined();
expect((await db.select().from(groups).where(eq(groups.id, groupId)))[0]!.name).toBe("Changed through MCP");
const stats = await (await request(read.token, "tools/call", { name: "manager_stats", arguments: { input: { eventId: event!.id } } })).json();
expect(JSON.parse(stats.result.content[0].text).photos).toBe(0);
const denied = await (await request(read.token, "tools/call", { name: "platform_stats" })).json();
expect(denied.result.isError).toBe(true); // Token selection never grants platform authority.
const isolated = await (await request(read.token, "tools/call", { name: "manager_stats", arguments: { input: { eventId: crypto.randomUUID() } } })).json();
expect(isolated.result.isError).toBe(true);
const otherTenant = await (await request(read.token, "tools/call", { name: "manager_stats", arguments: { input: { eventId: otherEvent!.id } } })).json();
expect(otherTenant.result.isError).toBe(true);
await db.update(groupMemberships).set({ role: "member" }).where(and(eq(groupMemberships.groupId, groupId), eq(groupMemberships.userId, id)));
expect((await (await request(write.token, "tools/call", { name: "group_rename", arguments: { input: { groupId, name: "Denied" }, confirm: true } })).json()).result.isError).toBe(true);
expect((await request(read.token, "tools/list", {}, "https://attacker.invalid")).status).toBe(403);
const saved = (await db.select().from(assistantTokens).where(eq(assistantTokens.id, read.id)))[0]!;
expect(saved.tokenHash).not.toBe(read.token);
expect(JSON.stringify(await owner.list())).not.toContain(read.token);
await owner.revoke({ id: read.id });
expect((await request(read.token, "tools/list")).status).toBe(401);
await db.update(assistantTokens).set({ expiresAt: new Date(0) }).where(eq(assistantTokens.id, limited.id));
expect((await request(limited.token, "tools/list")).status).toBe(401);
const logs = await db.select().from(auditEvents).where(eq(auditEvents.actorUserId, id));
expect(logs.some(log => log.action === "assistant.tool")).toBe(true);
expect(JSON.stringify(logs)).not.toContain(write.token);
} finally {
await db.delete(auditEvents).where(eq(auditEvents.actorUserId, id));
if (groupId) await db.delete(groups).where(eq(groups.id, groupId));
if (otherGroupId) await db.delete(groups).where(eq(groups.id, otherGroupId));
await db.delete(user).where(inArray(user.id, [id]));
}
});
+96
View File
@@ -0,0 +1,96 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { zodToJsonSchema } from "zod-to-json-schema";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { assistantTokens, auditEvents, getDb } from "@album/database";
import { appRouter } from "../api/root";
import { publicAppOrigin } from "../public-app-url";
import { consumeRateLimit, requestClientIdentifier } from "../rate-limit";
import { withPermissionCeiling } from "../permission-ceiling";
import { authenticateAssistant, assistantContext } from "./auth";
import { visibleAssistantTools } from "./catalog";
const json = (body: unknown, status: number) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
export async function handleMcp(request: Request) {
const origin = publicAppOrigin();
if (request.headers.get("origin") && request.headers.get("origin") !== origin) return json({ error: "Origin not allowed" }, 403);
if (request.method !== "POST") return new Response(null, { status: 405, headers: { Allow: "POST", "Cache-Control": "no-store" } });
const ipLimit = await consumeRateLimit({ namespace: "mcp-http", identifier: requestClientIdentifier(request), limit: 120, windowMs: 60_000 });
if (!ipLimit.allowed) return json({ error: "Rate limit exceeded" }, 429);
const principal = await authenticateAssistant(request.headers.get("authorization"));
if (!principal) return new Response(JSON.stringify({ error: "A valid assistant bearer token is required" }), {
status: 401, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "WWW-Authenticate": 'Bearer realm="Manyangles MCP"' },
});
const limit = await consumeRateLimit({ namespace: "mcp-token", identifier: principal.token.id, limit: 120, windowMs: 60_000 });
if (!limit.allowed) return json({ error: "Rate limit exceeded" }, 429);
const reader = request.body?.getReader();
if (!reader) return json({ error: "Request body required" }, 400);
const chunks: Uint8Array[] = [];
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.length;
if (bytes > 65536) { await reader.cancel(); return json({ error: "Request too large" }, 413); }
chunks.push(chunk.value);
}
let parsedBody: unknown;
try { parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
catch { return json({ error: "Invalid JSON" }, 400); }
if (Array.isArray(parsedBody)) return json({ error: "Batch requests are not supported" }, 400);
const server = new Server({ name: "manyangles", version: "1.0.0" }, { capabilities: { tools: {} } });
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: visibleAssistantTools(principal.token).map(tool => {
const procedure = (appRouter._def.procedures as unknown as Record<string, { _def: { inputs: z.ZodTypeAny[] } }>)[tool.path];
if (!procedure) throw new Error("MCP catalog references a missing procedure");
const input = procedure._def.inputs[0] as z.ZodTypeAny | undefined;
const schema = input ? zodToJsonSchema(input, { $refStrategy: "none", dateStrategy: "string" }) : { type: "object", additionalProperties: false };
return { name: tool.path.replaceAll(".", "_"), description: `${tool.description}${tool.write ? " Requires confirm=true. Non-idempotent: do not retry blindly after a connection failure." : ""}`,
inputSchema: { type: "object" as const, properties: { input: schema, ...(tool.write ? { confirm: { type: "boolean", const: true } } : {}),
...(!tool.write ? { offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 100 } } : {}) },
required: [...(input && !input.isOptional() ? ["input"] : []), ...(tool.write ? ["confirm"] : [])], additionalProperties: false },
annotations: { readOnlyHint: !tool.write, destructiveHint: !!tool.write, idempotentHint: !tool.write, openWorldHint: true },
};
}) }));
server.setRequestHandler(CallToolRequestSchema, async message => {
// Recheck revocation/expiry at execution, not only at connection establishment.
const current = await authenticateAssistant(request.headers.get("authorization"));
if (!current) return { isError: true, content: [{ type: "text", text: "UNAUTHORIZED: Token expired or revoked." }] };
const tool = visibleAssistantTools(current.token).find(tool => tool.path.replaceAll(".", "_") === message.params.name);
if (!tool) return { isError: true, content: [{ type: "text", text: "FORBIDDEN: Tool is not allowed for this token." }] };
if (tool.write && message.params.arguments?.confirm !== true) return { isError: true, content: [{ type: "text", text: "Explicit confirm=true is required for this action." }] };
let success = false;
try {
const paging = z.object({ offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(100).default(50) }).parse(message.params.arguments ?? {});
const result = await withPermissionCeiling(current.token.permissions, async () => {
const caller = appRouter.createCaller(assistantContext(current, origin));
const [routerName, procedureName] = tool.path.split(".");
// Paths come only from our allowlist, never from arbitrary client input.
const router = caller[routerName as keyof typeof caller] as unknown as Record<string, (input: unknown) => Promise<unknown>>;
return router[procedureName!]!(message.params.arguments?.input);
});
success = true;
const output = !tool.write && Array.isArray(result) ? {
items: result.slice(paging.offset, paging.offset + paging.limit), total: result.length,
nextOffset: paging.offset + paging.limit < result.length ? paging.offset + paging.limit : null,
} : result;
return { content: [{ type: "text" as const, text: JSON.stringify(output ?? null) }] };
} catch (error) {
return { isError: true, content: [{ type: "text" as const, text: error instanceof TRPCError && error.code !== "INTERNAL_SERVER_ERROR" ? `${error.code}: ${error.message}` : "Operation failed. Check the current state before retrying a write." }] };
} finally {
await getDb().insert(auditEvents).values({ actorUserId: current.user.id, action: "assistant.tool", subjectType: "assistant_token", subjectId: current.token.id,
metadata: { tool: tool.path, success } });
}
});
await getDb().update(assistantTokens).set({ lastUsedAt: new Date() }).where(eq(assistantTokens.id, principal.token.id));
try {
await server.connect(transport);
const response = await transport.handleRequest(request, { parsedBody });
response.headers.set("Cache-Control", "no-store");
return response;
} finally { await server.close(); }
}
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { withPermissionCeiling } from "./permission-ceiling";
import { hasPlatformPermission, resolveEventAccess } from "./roles";
test("assistant ceilings hide private photos even from owner tokens", () => {
withPermissionCeiling(["photos.read"], () => {
expect(resolveEventAccess({ membershipRole: "owner", platformRole: null })!.permissions).toEqual(["photos.read"]);
expect(hasPlatformPermission("super_admin", "platform.users.manage")).toBe(false);
});
expect(resolveEventAccess({ membershipRole: "owner", platformRole: null })!.permissions).toContain("photos.private.read");
});
test("concurrent requests cannot share permission ceilings", async () => {
await Promise.all([
withPermissionCeiling(["photos.read"], async () => { await Promise.resolve(); expect(resolveEventAccess({ membershipRole: "owner", platformRole: null })!.permissions).toEqual(["photos.read"]); }),
withPermissionCeiling(["notes.read"], async () => { await Promise.resolve(); expect(resolveEventAccess({ membershipRole: "owner", platformRole: null })!.permissions).toEqual(["notes.read"]); }),
]);
});
@@ -0,0 +1,8 @@
import { AsyncLocalStorage } from "node:async_hooks";
// Delegated credentials can narrow existing permissions, never grant new ones.
const ceiling = new AsyncLocalStorage<ReadonlySet<string>>();
export const withPermissionCeiling = <T>(permissions: readonly string[], run: () => T): T =>
ceiling.run(new Set(permissions), run);
export const withinPermissionCeiling = (permission: string) => ceiling.getStore()?.has(permission) ?? true;
export const restrictPermissions = <T extends string>(permissions: T[]): T[] => permissions.filter(withinPermissionCeiling);
+8 -7
View File
@@ -1,4 +1,5 @@
import { and, eq } from "drizzle-orm";
import { restrictPermissions, withinPermissionCeiling } from "./permission-ceiling";
import {
eventMemberships,
getDb,
@@ -74,13 +75,13 @@ export function resolveGroupAccess(input: {
if (input.platformRole) {
return {
role: "platform",
permissions: platformGroupPermissions(input.platformRole),
permissions: restrictPermissions(platformGroupPermissions(input.platformRole)),
};
}
if (!input.membershipRole) return null;
return {
role: input.membershipRole,
permissions: groupPermissionsForRole(input.membershipRole),
permissions: restrictPermissions(groupPermissionsForRole(input.membershipRole)),
};
}
@@ -91,13 +92,13 @@ export function resolveEventAccess(input: {
if (input.platformRole) {
return {
role: "platform",
permissions: platformEventPermissions(input.platformRole),
permissions: restrictPermissions(platformEventPermissions(input.platformRole)),
};
}
if (!input.membershipRole) return null;
return {
role: input.membershipRole,
permissions: eventPermissionsForRole(input.membershipRole),
permissions: restrictPermissions(eventPermissionsForRole(input.membershipRole)),
};
}
@@ -105,14 +106,14 @@ export function hasEventPermission(
permissions: EventPermission[],
permission: EventPermission,
) {
return permissions.includes(permission);
return withinPermissionCeiling(permission) && permissions.includes(permission);
}
export function hasGroupPermission(
permissions: GroupPermission[],
permission: GroupPermission,
) {
return permissions.includes(permission);
return withinPermissionCeiling(permission) && permissions.includes(permission);
}
export function hasPlatformPermission(
@@ -120,7 +121,7 @@ export function hasPlatformPermission(
permission: PlatformPermission,
) {
if (!role) return false;
return platformPermissionsForRole(role).includes(permission);
return withinPermissionCeiling(permission) && platformPermissionsForRole(role).includes(permission);
}
export { EVENT_PERMISSIONS };
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
import { and, eq, inArray } from "drizzle-orm";
import { getDb, user, groups, guests, submissions, photos, events, eventMemberships } from "@album/database";
import { groupRouter } from "./api/routers/group";
import { managerRouter } from "./api/routers/manager";
import type { TrpcContext } from "./api/trpc";
test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-scoped submission approval", async () => {
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const db = getDb();
const id = crypto.randomUUID();
const people = await db.insert(user).values([0, 1].map(n => ({ id: `${id}-${n}`, name: "Workflow test", email: `${id}-${n}@manyangles.test`, emailVerified: true }))).returning();
const ctx = (n: number): TrpcContext => ({ session: { user: people[n]!, session: {} } as TrpcContext["session"], cookies: new Map(), activeGroupId: null, requestOrigin: "http://localhost:3000", clientIdentifier: id, guestTokenForEvent: () => null, setCookies: [], appendSetCookie: () => {} });
let groupId: string | undefined;
try {
const owner = groupRouter.createCaller(ctx(0));
const group = await owner.create({ name: "Workflow test" });
groupId = group.id;
expect((await owner.get({ groupId })).role).toBe("owner");
await owner.rename({ groupId, name: "Renamed group" });
expect((await owner.get({ groupId })).name).toBe("Renamed group");
await expect(groupRouter.createCaller(ctx(1)).rename({ groupId, name: "Denied" })).rejects.toThrow();
const [event] = await db.insert(events).values({ groupId, title: "Workflow test", slug: id }).returning();
await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" });
const [guest] = await db.insert(guests).values({ eventId: event!.id, tokenHash: id }).returning();
const [submission] = await db.insert(submissions).values({ eventId: event!.id, guestId: guest!.id }).returning();
await db.insert(photos).values((["ready", "processing"] as const).map(processingStatus => ({ eventId: event!.id, submissionId: submission!.id, originalKey: `test/${id}/${processingStatus}`, contentType: "image/jpeg", processingStatus })));
const manager = managerRouter.createCaller(ctx(0));
const input = { eventId: event!.id, submissionId: submission!.id, visibility: "public" as const };
await expect(managerRouter.createCaller(ctx(1)).moderateSubmission(input)).rejects.toThrow();
await expect(manager.moderateSubmission({ ...input, eventId: crypto.randomUUID() })).rejects.toThrow();
expect((await manager.moderateSubmission(input)).updatedCount).toBe(2);
expect((await manager.moderateSubmission(input)).updatedCount).toBe(0);
const result = await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.submissionId, submission!.id)));
expect(result.every(photo => photo.visibility === "public")).toBe(true);
expect(result.find(photo => photo.processingStatus === "processing")).toBeDefined();
const [privatePhoto] = await db.insert(photos).values({ eventId: event!.id, submissionId: submission!.id, originalKey: `test/${id}/private`, contentType: "image/jpeg", processingStatus: "ready", visibility: "private" }).returning();
await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[1]!.id, role: "moderator" });
const moderator = managerRouter.createCaller(ctx(1));
expect((await moderator.moderateSubmission({ ...input, visibility: "hidden" })).updatedCount).toBe(2);
const [preserved] = await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.id, privatePhoto!.id)));
expect(preserved!.visibility).toBe("private");
await expect(moderator.moderateSubmission({ ...input, visibility: "private" })).rejects.toThrow();
} finally {
if (groupId) await db.delete(groups).where(eq(groups.id, groupId));
await db.delete(user).where(inArray(user.id, people.map(person => person.id)));
}
});
+14 -2
View File
@@ -6,7 +6,7 @@ import type { BannerCrop } from "@album/contracts";
import { processPhotoExport } from "./exports";
import { processEmailDelivery } from "@album/email/queue";
import convert from "heic-convert";
import { eventBanners, getDb, photoJobs, photos } from "@album/database";
import { databaseReady, eventBanners, getDb, photoJobs, photos } from "@album/database";
import {
displayObjectKey,
getObjectBuffer,
@@ -16,6 +16,16 @@ import {
const POLL_MS = 1000;
const CONCURRENCY = Math.max(1, Number(process.env.WORKER_CONCURRENCY ?? 1));
const heartbeats = new Map<string, number>();
const beat = (name: string) => heartbeats.set(name, Date.now());
if (process.env.WORKER_HEALTH_PORT) {
Bun.serve({ hostname: "127.0.0.1", port: Number(process.env.WORKER_HEALTH_PORT), async fetch(request) {
if (new URL(request.url).pathname !== "/health") return new Response(null, { status: 404 });
const fresh = heartbeats.size === CONCURRENCY + 2 && [...heartbeats.values()].every(time => Date.now() - time < 15 * 60_000);
const ready = fresh && await databaseReady();
return Response.json({ status: ready ? "ready" : "unavailable" }, { status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" } });
} });
}
const MIN_INTERVAL_MS = Math.max(
0,
Number(process.env.WORKER_MIN_INTERVAL_MS ?? 250),
@@ -129,6 +139,7 @@ async function failJob(job: ClaimedJob, error: unknown) {
async function workerLoop(workerId: number) {
while (true) {
beat(`photo-${workerId}`);
const started = Date.now();
const job = await claimJob();
if (!job) {
@@ -183,13 +194,14 @@ console.info(
async function emailLoop() {
while (true) {
beat("email");
try { await processEmailDelivery(); } catch { console.error("Email queue check failed"); }
await Bun.sleep(1000);
}
}
await Promise.all([
(async () => { while (true) { try { await processPhotoExport(); } catch { console.error("Export queue check failed"); } await Bun.sleep(1000); } })(),
(async () => { while (true) { beat("exports"); try { await processPhotoExport(); } catch { console.error("Export queue check failed"); } await Bun.sleep(1000); } })(),
emailLoop(),
...Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index + 1)),
]);