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/funnel-display": "^5.3.0",
|
||||||
"@fontsource-variable/geologica": "^5.3.0",
|
"@fontsource-variable/geologica": "^5.3.0",
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@radix-ui/react-popover": "^1.1.23",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@trpc/client": "^11.4.3",
|
"@trpc/client": "^11.4.3",
|
||||||
@@ -46,7 +47,8 @@
|
|||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67",
|
||||||
|
"zod-to-json-schema": "^3.25.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
"@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),
|
onError: (error) => toast.error(error.message),
|
||||||
});
|
});
|
||||||
const moderateSubmission = api.manager.moderateSubmission.useMutation({
|
const moderateSubmission = api.manager.moderateSubmission.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async (result) => {
|
||||||
toast.success("Submission updated");
|
toast.success(`${result.updatedCount} photos updated`);
|
||||||
await utils.manager.photos.invalidate({ eventId });
|
await utils.manager.photos.invalidate({ eventId });
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(error.message),
|
onError: (error) => toast.error(error.message),
|
||||||
@@ -211,9 +211,15 @@ export function ModerationGrid({
|
|||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{canModerate && photo.processingStatus === "ready" ? (
|
{canModerate ? (
|
||||||
<DropdownMenuItem className="min-h-11 px-3"
|
<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
|
<EyeOffIcon aria-hidden="true" />Hide Entire Submission
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default async function DashboardPeoplePage() {
|
|||||||
<Empty className="border">
|
<Empty className="border">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyTitle>No group yet</EmptyTitle>
|
<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>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||||
|
import { uploadProgress } from "@/lib/upload-progress";
|
||||||
|
|
||||||
type QueueItem = {
|
type QueueItem = {
|
||||||
file?: File;
|
file?: File;
|
||||||
@@ -59,6 +61,7 @@ export function GuestUpload({
|
|||||||
const retryUpload = api.photos.retryUpload.useMutation();
|
const retryUpload = api.photos.retryUpload.useMutation();
|
||||||
const uploading = useRef(false);
|
const uploading = useRef(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const progress = uploadProgress(queue);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedName = localStorage.getItem(guestKey(slug, "name")) ?? "";
|
const storedName = localStorage.getItem(guestKey(slug, "name")) ?? "";
|
||||||
@@ -257,7 +260,20 @@ export function GuestUpload({
|
|||||||
</Button>
|
</Button>
|
||||||
</label> : null}
|
</label> : null}
|
||||||
{queue.length > 0 ? (
|
{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) => (
|
{queue.map((item) => (
|
||||||
<li key={item.id} className="flex flex-col gap-1">
|
<li key={item.id} className="flex flex-col gap-1">
|
||||||
<div className="flex justify-between gap-3 text-sm">
|
<div className="flex justify-between gap-3 text-sm">
|
||||||
@@ -272,6 +288,9 @@ export function GuestUpload({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
<details
|
<details
|
||||||
className="rounded-2xl border bg-card/60 px-4 py-1"
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
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 { ImagesIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api } from "@/trpc/react";
|
import { api } from "@/trpc/react";
|
||||||
@@ -21,6 +24,7 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const select = api.group.select.useMutation({
|
const select = api.group.select.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -30,11 +34,15 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
|
|||||||
},
|
},
|
||||||
onError: (error) => toast.error(error.message),
|
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]!;
|
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0]!;
|
||||||
return (
|
return (
|
||||||
<Select value={activeGroup.id} disabled={select.isPending}
|
<><Select value={activeGroup.id} disabled={select.isPending}
|
||||||
onValueChange={(groupId) => { if (groupId !== activeGroup.id) select.mutate({ groupId }); }}>
|
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"
|
<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")}>
|
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" />
|
<ImagesIcon aria-hidden="true" className="shrink-0 text-primary" />
|
||||||
@@ -47,8 +55,10 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
|
|||||||
{group.name}
|
{group.name}
|
||||||
</SelectItem>
|
</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>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</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" />
|
<BrandLockup markClassName="size-7" wordmarkClassName="hidden sm:inline" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{signedIn && backend && groups.length > 0 && !pathname.startsWith("/admin") ? (
|
{signedIn && backend && !pathname.startsWith("/admin") ? (
|
||||||
<>
|
<>
|
||||||
<span className="mx-1 h-7 w-px bg-border" />
|
<span className="mx-1 h-7 w-px bg-border" />
|
||||||
<div className="min-w-0 max-w-56 flex-1">
|
<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";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export type NavigationItem = {
|
export type NavigationItem = {
|
||||||
@@ -29,6 +29,8 @@ export const albumWorkspaces: NavigationWorkspace[] = [
|
|||||||
icon: Users, href: "/dashboard/people",
|
icon: Users, href: "/dashboard/people",
|
||||||
items: [{ label: "Workspace Access", href: "/dashboard/people", icon: Users }],
|
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[] = [
|
export const adminWorkspaces: NavigationWorkspace[] = [
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { photosRouter } from "./routers/photos";
|
|||||||
import { platformRouter } from "./routers/platform";
|
import { platformRouter } from "./routers/platform";
|
||||||
import { viewerRouter } from "./routers/viewer";
|
import { viewerRouter } from "./routers/viewer";
|
||||||
import { signsRouter } from "./routers/signs";
|
import { signsRouter } from "./routers/signs";
|
||||||
|
import { assistantsRouter } from "./routers/assistants";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
|
assistants: assistantsRouter,
|
||||||
health: publicProcedure.query(() => ({
|
health: publicProcedure.query(() => ({
|
||||||
status: "ok" as const,
|
status: "ok" as const,
|
||||||
service: "album-trpc",
|
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({
|
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 }) => {
|
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);
|
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" });
|
if (!invite || invite.kind !== "email") throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ async function signedPhotoUrls(photo: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const managerRouter = createTRPCRouter({
|
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}) => {
|
requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => {
|
||||||
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
||||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||||
@@ -511,7 +523,7 @@ export const managerRouter = createTRPCRouter({
|
|||||||
const [submission] = await getDb()
|
const [submission] = await getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(submissions)
|
.from(submissions)
|
||||||
.where(eq(submissions.id, input.submissionId))
|
.where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, input.eventId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!submission) throw new TRPCError({ code: "NOT_FOUND" });
|
if (!submission) throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
const platformRole = await getPlatformRole(ctx.session.user.id);
|
const platformRole = await getPlatformRole(ctx.session.user.id);
|
||||||
@@ -521,28 +533,34 @@ export const managerRouter = createTRPCRouter({
|
|||||||
platformRole,
|
platformRole,
|
||||||
);
|
);
|
||||||
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
||||||
const rows = await getDb()
|
if (input.visibility === "private" && !access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) {
|
||||||
.select()
|
throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
await writeAudit({
|
return getDb().transaction(async (tx) => {
|
||||||
groupId: event.groupId,
|
const rows = await tx
|
||||||
eventId: event.id,
|
.select()
|
||||||
actorUserId: ctx.session.user.id,
|
.from(photos)
|
||||||
action: "submission.visibility",
|
.where(and(eq(photos.submissionId, submission.id), eq(photos.eventId, event.id)))
|
||||||
subjectType: "submission",
|
.for("update");
|
||||||
subjectId: submission.id,
|
const eligible = rows.filter(photo =>
|
||||||
metadata: { visibility: input.visibility, count: rows.length },
|
(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
|
deletePhoto: protectedProcedure
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { desc, eq, ilike, or } from "drizzle-orm";
|
import { desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
auditEvents,
|
auditEvents,
|
||||||
deploymentSettings,
|
deploymentSettings,
|
||||||
@@ -29,6 +29,19 @@ import { writeAudit } from "@/server/audit";
|
|||||||
import { createInviteCode } from "@/server/invites";
|
import { createInviteCode } from "@/server/invites";
|
||||||
|
|
||||||
export const platformRouter = createTRPCRouter({
|
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(
|
settings: platformPermissionProcedure(PLATFORM_PERMISSIONS.SETTINGS_MANAGE).query(
|
||||||
async () => getDeploymentSettings(),
|
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 { and, eq } from "drizzle-orm";
|
||||||
|
import { restrictPermissions, withinPermissionCeiling } from "./permission-ceiling";
|
||||||
import {
|
import {
|
||||||
eventMemberships,
|
eventMemberships,
|
||||||
getDb,
|
getDb,
|
||||||
@@ -74,13 +75,13 @@ export function resolveGroupAccess(input: {
|
|||||||
if (input.platformRole) {
|
if (input.platformRole) {
|
||||||
return {
|
return {
|
||||||
role: "platform",
|
role: "platform",
|
||||||
permissions: platformGroupPermissions(input.platformRole),
|
permissions: restrictPermissions(platformGroupPermissions(input.platformRole)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!input.membershipRole) return null;
|
if (!input.membershipRole) return null;
|
||||||
return {
|
return {
|
||||||
role: input.membershipRole,
|
role: input.membershipRole,
|
||||||
permissions: groupPermissionsForRole(input.membershipRole),
|
permissions: restrictPermissions(groupPermissionsForRole(input.membershipRole)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,13 +92,13 @@ export function resolveEventAccess(input: {
|
|||||||
if (input.platformRole) {
|
if (input.platformRole) {
|
||||||
return {
|
return {
|
||||||
role: "platform",
|
role: "platform",
|
||||||
permissions: platformEventPermissions(input.platformRole),
|
permissions: restrictPermissions(platformEventPermissions(input.platformRole)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!input.membershipRole) return null;
|
if (!input.membershipRole) return null;
|
||||||
return {
|
return {
|
||||||
role: input.membershipRole,
|
role: input.membershipRole,
|
||||||
permissions: eventPermissionsForRole(input.membershipRole),
|
permissions: restrictPermissions(eventPermissionsForRole(input.membershipRole)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,14 +106,14 @@ export function hasEventPermission(
|
|||||||
permissions: EventPermission[],
|
permissions: EventPermission[],
|
||||||
permission: EventPermission,
|
permission: EventPermission,
|
||||||
) {
|
) {
|
||||||
return permissions.includes(permission);
|
return withinPermissionCeiling(permission) && permissions.includes(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasGroupPermission(
|
export function hasGroupPermission(
|
||||||
permissions: GroupPermission[],
|
permissions: GroupPermission[],
|
||||||
permission: GroupPermission,
|
permission: GroupPermission,
|
||||||
) {
|
) {
|
||||||
return permissions.includes(permission);
|
return withinPermissionCeiling(permission) && permissions.includes(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPlatformPermission(
|
export function hasPlatformPermission(
|
||||||
@@ -120,7 +121,7 @@ export function hasPlatformPermission(
|
|||||||
permission: PlatformPermission,
|
permission: PlatformPermission,
|
||||||
) {
|
) {
|
||||||
if (!role) return false;
|
if (!role) return false;
|
||||||
return platformPermissionsForRole(role).includes(permission);
|
return withinPermissionCeiling(permission) && platformPermissionsForRole(role).includes(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { EVENT_PERMISSIONS };
|
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)));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import type { BannerCrop } from "@album/contracts";
|
|||||||
import { processPhotoExport } from "./exports";
|
import { processPhotoExport } from "./exports";
|
||||||
import { processEmailDelivery } from "@album/email/queue";
|
import { processEmailDelivery } from "@album/email/queue";
|
||||||
import convert from "heic-convert";
|
import convert from "heic-convert";
|
||||||
import { eventBanners, getDb, photoJobs, photos } from "@album/database";
|
import { databaseReady, eventBanners, getDb, photoJobs, photos } from "@album/database";
|
||||||
import {
|
import {
|
||||||
displayObjectKey,
|
displayObjectKey,
|
||||||
getObjectBuffer,
|
getObjectBuffer,
|
||||||
@@ -16,6 +16,16 @@ import {
|
|||||||
|
|
||||||
const POLL_MS = 1000;
|
const POLL_MS = 1000;
|
||||||
const CONCURRENCY = Math.max(1, Number(process.env.WORKER_CONCURRENCY ?? 1));
|
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(
|
const MIN_INTERVAL_MS = Math.max(
|
||||||
0,
|
0,
|
||||||
Number(process.env.WORKER_MIN_INTERVAL_MS ?? 250),
|
Number(process.env.WORKER_MIN_INTERVAL_MS ?? 250),
|
||||||
@@ -129,6 +139,7 @@ async function failJob(job: ClaimedJob, error: unknown) {
|
|||||||
|
|
||||||
async function workerLoop(workerId: number) {
|
async function workerLoop(workerId: number) {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
beat(`photo-${workerId}`);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const job = await claimJob();
|
const job = await claimJob();
|
||||||
if (!job) {
|
if (!job) {
|
||||||
@@ -183,13 +194,14 @@ console.info(
|
|||||||
|
|
||||||
async function emailLoop() {
|
async function emailLoop() {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
beat("email");
|
||||||
try { await processEmailDelivery(); } catch { console.error("Email queue check failed"); }
|
try { await processEmailDelivery(); } catch { console.error("Email queue check failed"); }
|
||||||
await Bun.sleep(1000);
|
await Bun.sleep(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
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(),
|
emailLoop(),
|
||||||
...Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index + 1)),
|
...Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index + 1)),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@fontsource-variable/funnel-display": "^5.3.0",
|
"@fontsource-variable/funnel-display": "^5.3.0",
|
||||||
"@fontsource-variable/geologica": "^5.3.0",
|
"@fontsource-variable/geologica": "^5.3.0",
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@radix-ui/react-popover": "^1.1.23",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@trpc/client": "^11.4.3",
|
"@trpc/client": "^11.4.3",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^3.25.67",
|
"zod": "^3.25.67",
|
||||||
|
"zod-to-json-schema": "^3.25.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
"@tailwindcss/postcss": "^4.1.10",
|
||||||
@@ -1010,7 +1012,7 @@
|
|||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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/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-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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|||||||
+10
-2
@@ -94,7 +94,7 @@ services:
|
|||||||
migrate: {condition: service_completed_successfully}
|
migrate: {condition: service_completed_successfully}
|
||||||
storage-init: {condition: service_completed_successfully}
|
storage-init: {condition: service_completed_successfully}
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
@@ -102,7 +102,15 @@ services:
|
|||||||
worker:
|
worker:
|
||||||
<<: *runtime
|
<<: *runtime
|
||||||
build: {context: ., target: worker}
|
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:
|
depends_on:
|
||||||
migrate: {condition: service_completed_successfully}
|
migrate: {condition: service_completed_successfully}
|
||||||
storage-init: {condition: service_completed_successfully}
|
storage-init: {condition: service_completed_successfully}
|
||||||
|
|||||||
+11
-1
@@ -39,13 +39,23 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
migrate: {condition: service_completed_successfully}
|
migrate: {condition: service_completed_successfully}
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
retries: 3
|
retries: 3
|
||||||
worker:
|
worker:
|
||||||
<<: *runtime
|
<<: *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}
|
build: {context: ., target: worker}
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate: {condition: service_completed_successfully}
|
migrate: {condition: service_completed_successfully}
|
||||||
|
|||||||
+83
@@ -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 1–365 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.
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -201,6 +201,7 @@ export const moderatePhotoInputSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const moderateSubmissionInputSchema = z.object({
|
export const moderateSubmissionInputSchema = z.object({
|
||||||
|
eventId: z.string().uuid(),
|
||||||
submissionId: z.string().uuid(),
|
submissionId: z.string().uuid(),
|
||||||
visibility: photoVisibilitySchema,
|
visibility: photoVisibilitySchema,
|
||||||
});
|
});
|
||||||
@@ -273,3 +274,4 @@ export type EventCreatePolicy = z.infer<typeof eventCreatePolicySchema>;
|
|||||||
export type PhotoProcessingStatus = z.infer<typeof photoProcessingStatusSchema>;
|
export type PhotoProcessingStatus = z.infer<typeof photoProcessingStatusSchema>;
|
||||||
export type PhotoVisibility = z.infer<typeof photoVisibilitySchema>;
|
export type PhotoVisibility = z.infer<typeof photoVisibilitySchema>;
|
||||||
export type AllowedImageType = z.infer<typeof allowedImageTypeSchema>;
|
export type AllowedImageType = z.infer<typeof allowedImageTypeSchema>;
|
||||||
|
export { createAssistantTokenInputSchema } from "./assistant";
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { expect, test } from "bun:test";
|
|||||||
import { savedSignSchema } from "./sign";
|
import { savedSignSchema } from "./sign";
|
||||||
|
|
||||||
test("dragged headline positions persist and reject invalid coordinates", () => {
|
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();
|
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);
|
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 }]) {
|
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);
|
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", () => {
|
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();
|
expect(savedSignSchema.parse(base).headlineScale).toBeUndefined();
|
||||||
const sized = { ...base, headlineScale: 150, messageScale: 125, titleScale: 75, showUrl: false };
|
const sized = { ...base, headlineScale: 150, messageScale: 125, titleScale: 75, showUrl: false };
|
||||||
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(sized)))).toEqual(sized);
|
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");
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
export { closeDb, getDb, type Database } from "./db";
|
export { closeDb, getDb, type Database } from "./db";
|
||||||
|
export { databaseReady } from "./health";
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
export * from "./auth-schema";
|
export * from "./auth-schema";
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ const timestamps = {
|
|||||||
.notNull(),
|
.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", [
|
export const eventStatus = pgEnum("event_status", [
|
||||||
"draft",
|
"draft",
|
||||||
"published",
|
"published",
|
||||||
|
|||||||
Reference in New Issue
Block a user