Add permission-scoped MCP, readiness checks, and management UI improvements
This commit is contained in:
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} /></>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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: () => {},
|
||||
};
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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)));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user