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)),
]);
+8 -6
View File
@@ -21,6 +21,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",
@@ -50,6 +51,7 @@
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.67",
"zod-to-json-schema": "^3.25.2",
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
@@ -1010,7 +1012,7 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@@ -1384,7 +1386,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
@@ -1428,6 +1430,8 @@
"@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
@@ -1464,8 +1468,6 @@
"conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1530,6 +1532,8 @@
"@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -1586,8 +1590,6 @@
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+10 -2
View File
@@ -94,7 +94,7 @@ services:
migrate: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health/ready',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 20s
@@ -102,7 +102,15 @@ services:
worker:
<<: *runtime
build: {context: ., target: worker}
environment: *environment
environment:
<<: *environment
WORKER_HEALTH_PORT: "3001"
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3001/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
depends_on:
migrate: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
+11 -1
View File
@@ -39,13 +39,23 @@ services:
depends_on:
migrate: {condition: service_completed_successfully}
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health/ready',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
worker:
<<: *runtime
environment:
NODE_ENV: production
DATABASE_URL: postgres://manyangles:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/manyangles
WORKER_HEALTH_PORT: "3001"
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3001/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
build: {context: ., target: worker}
depends_on:
migrate: {condition: service_completed_successfully}
+83
View File
@@ -0,0 +1,83 @@
# Assistant access (MCP)
Open **Albums → Assistants** (`/dashboard/assistants`) and create a connection.
The endpoint is `https://ma.hadlock.tech/api/mcp`, using Streamable HTTP. Configure
your MCP client to send `Authorization: Bearer <token>` on every request. Tokens
are displayed once, stored only as SHA-256 hashes, expire after 1365 days, and
can be revoked in that screen. Keep them in the client's secret storage, never
in prompts or source control. This version uses bearer tokens, not OAuth discovery;
clients must support custom authorization headers. No public/anonymous access.
## Permissions
Tokens default to read-only statistics/discovery permissions. Enable only the
existing event, group, and platform permissions needed by that assistant.
Selections are a ceiling: the same current memberships/roles used by the web UI
are re-evaluated for every action. Selecting platform permissions never makes an
ordinary account a platform administrator. Removing a role restricts existing
tokens; revoking a token blocks new requests, not work already executing.
Private photo access is separately restricted by `photos.private.read`, including
when the token belongs to an owner/admin. Read-only tokens cannot call write tools
even if they include management permissions for reading associated data.
The explicit tool allowlist covers platform stats/settings/users/roles/allowances,
groups and memberships, events and schedules, photos/submission/note moderation,
guest lists and notification emails, invitations, exports, and banner/sign workflows.
It does not expose sessions, token management, guest authentication, SQL, arbitrary
HTTP requests, or shell commands. New application procedures require explicit review
before being added to the MCP catalog.
## Calling tools
Tool names use underscores, for example `manager_stats` and `group_rename`.
Existing application input goes under `input`. Dates are ISO-8601 strings.
```json
{"name":"manager_stats","arguments":{"input":{"eventId":"EVENT_UUID"}}}
```
Every write requires `confirm: true` in addition to a write-enabled token and the
required permissions. This is an explicit API acknowledgement, not proof that a
human approved the action; configure your assistant to ask before destructive
actions or sending messages.
```json
{"name":"group_rename","arguments":{"input":{"groupId":"GROUP_UUID","name":"Wedding team"},"confirm":true}}
```
Do not automatically retry writes after an ambiguous connection failure. Re-read
state first: mutations may have succeeded and are not universally idempotent.
List outputs are paginated with top-level `offset` and `limit` (default 50, max
100), returning `items`, `total`, and `nextOffset`. Application-specific filters
and page fields remain inside `input`. Some application queries impose their own
limits; `total` then reflects that result set rather than all database records.
Uploads still go directly to storage using signed URLs. MCP never proxies original
files. Tool results may contain personal data or signed URLs according to the
selected permissions—treat the connected assistant as a data recipient. Guest
notes, names, and other content returned by tools are untrusted data, not instructions.
Calls have per-client and per-token rate limits, a 64 KiB request-body cap, origin
validation, and no-cache responses. Tool audit entries record token/user ids,
tool name and success, not raw arguments, credentials, or guest data. Existing
mutation audit entries remain unchanged. No production token is created automatically.
## Verification
```sh
MCP_INTEGRATION=1 bun --env-file=.env test apps/web/src/server/mcp/mcp.integration.test.ts
```
The integration test refuses non-local databases and does not send email.
## Coolify health checks
Both Compose definitions use `/api/health/ready` for web readiness (bounded DB
probe) and a loopback-only worker `/health` endpoint on port 3001. The worker
checks DB connectivity and loop heartbeats; a loop stalled for 15 minutes is
unhealthy. Long exports can exceed this threshold and should be investigated.
`/api/health` remains process liveness. Responses omit internal dependency details.
Checks run every 30 seconds, with a 5-second timeout and three retries. No worker
health port is published. Docker health status by itself does not automatically
restart an unhealthy container; it supplies readiness information to Coolify.
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const createAssistantTokenInputSchema = z.object({
name: z.string().trim().min(1).max(80),
permissions: z.array(z.string().min(1).max(80)).min(1).max(64),
readOnly: z.boolean().default(true),
days: z.number().int().min(1).max(365).default(30),
});
+2
View File
@@ -201,6 +201,7 @@ export const moderatePhotoInputSchema = z.object({
});
export const moderateSubmissionInputSchema = z.object({
eventId: z.string().uuid(),
submissionId: z.string().uuid(),
visibility: photoVisibilitySchema,
});
@@ -273,3 +274,4 @@ export type EventCreatePolicy = z.infer<typeof eventCreatePolicySchema>;
export type PhotoProcessingStatus = z.infer<typeof photoProcessingStatusSchema>;
export type PhotoVisibility = z.infer<typeof photoVisibilitySchema>;
export type AllowedImageType = z.infer<typeof allowedImageTypeSchema>;
export { createAssistantTokenInputSchema } from "./assistant";
+3 -3
View File
@@ -2,9 +2,9 @@ import { expect, test } from "bun:test";
import { savedSignSchema } from "./sign";
test("dragged headline positions persist and reject invalid coordinates", () => {
const base = { title: "Wedding", headline: "Share", message: "Welcome", paper: "card6x4", ink: "black" };
const base = { title: "Wedding", headline: "Share", message: "Welcome", paper: "card6x4", ink: "black" } as const;
expect(savedSignSchema.parse(base).headlinePosition).toBeUndefined();
const moved = { ...base, headlinePosition: { x: 12.5, y: 14, width: 42, height: 28 }, headlineAlign: "center", headlineVerticalAlign: "middle" };
const moved = { ...base, headlinePosition: { x: 12.5, y: 14, width: 42, height: 28 }, headlineAlign: "center", headlineVerticalAlign: "middle" } as const;
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(moved)))).toEqual(moved);
for (const headlinePosition of [{ x: -1, y: 0 }, { x: 0, y: 101 }, { x: NaN, y: 2 }, { x: 1, y: Infinity }, { x: "5", y: 0 }, { x: 0, y: 0, z: 2 }]) {
expect(savedSignSchema.safeParse({ ...base, headlinePosition }).success).toBe(false);
@@ -15,7 +15,7 @@ test("dragged headline positions persist and reject invalid coordinates", () =>
});
test("text sizing survives saved design round trips and rejects invalid scales", () => {
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" };
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" } as const;
expect(savedSignSchema.parse(base).headlineScale).toBeUndefined();
const sized = { ...base, headlineScale: 150, messageScale: 125, titleScale: 75, showUrl: false };
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(sized)))).toEqual(sized);
@@ -0,0 +1,14 @@
CREATE TABLE "assistant_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"token_hash" text NOT NULL UNIQUE,
"permissions" jsonb NOT NULL,
"read_only" boolean DEFAULT true NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX "assistant_tokens_user_idx" ON "assistant_tokens" ("user_id");
+2 -1
View File
@@ -80,6 +80,7 @@
"breakpoints": true
},
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true },
{ "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true }
{ "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true },
{ "idx": 13, "version": "7", "when": 1789164000000, "tag": "0013_assistant_tokens", "breakpoints": true }
]
}
+16
View File
@@ -0,0 +1,16 @@
import { sql } from "drizzle-orm";
import { getDb } from "./db";
// Bound probes and share an in-flight query so a DB outage cannot fill the pool.
let pending: Promise<void> | undefined;
export async function databaseReady() {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
pending ??= getDb().execute(sql`select 1`).then(() => {}).finally(() => { pending = undefined; });
await Promise.race([pending, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Database probe timed out")), 2500);
})]);
return true;
} catch { return false; }
finally { clearTimeout(timer); }
}
+1
View File
@@ -1,3 +1,4 @@
export { closeDb, getDb, type Database } from "./db";
export { databaseReady } from "./health";
export * from "./schema";
export * from "./auth-schema";
+13
View File
@@ -25,6 +25,19 @@ const timestamps = {
.notNull(),
};
export const assistantTokens = pgTable("assistant_tokens", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull().unique(),
permissions: jsonb("permissions").$type<string[]>().notNull(),
readOnly: boolean("read_only").notNull().default(true),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
...timestamps,
}, table => [index("assistant_tokens_user_idx").on(table.userId)]);
export const eventStatus = pgEnum("event_status", [
"draft",
"published",