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

This commit is contained in:
2026-09-11 18:12:48 -04:00
parent 32e56b1c34
commit 815640d919
40 changed files with 845 additions and 57 deletions
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const createAssistantTokenInputSchema = z.object({
name: z.string().trim().min(1).max(80),
permissions: z.array(z.string().min(1).max(80)).min(1).max(64),
readOnly: z.boolean().default(true),
days: z.number().int().min(1).max(365).default(30),
});
+2
View File
@@ -201,6 +201,7 @@ export const moderatePhotoInputSchema = z.object({
});
export const moderateSubmissionInputSchema = z.object({
eventId: z.string().uuid(),
submissionId: z.string().uuid(),
visibility: photoVisibilitySchema,
});
@@ -273,3 +274,4 @@ export type EventCreatePolicy = z.infer<typeof eventCreatePolicySchema>;
export type PhotoProcessingStatus = z.infer<typeof photoProcessingStatusSchema>;
export type PhotoVisibility = z.infer<typeof photoVisibilitySchema>;
export type AllowedImageType = z.infer<typeof allowedImageTypeSchema>;
export { createAssistantTokenInputSchema } from "./assistant";
+3 -3
View File
@@ -2,9 +2,9 @@ import { expect, test } from "bun:test";
import { savedSignSchema } from "./sign";
test("dragged headline positions persist and reject invalid coordinates", () => {
const base = { title: "Wedding", headline: "Share", message: "Welcome", paper: "card6x4", ink: "black" };
const base = { title: "Wedding", headline: "Share", message: "Welcome", paper: "card6x4", ink: "black" } as const;
expect(savedSignSchema.parse(base).headlinePosition).toBeUndefined();
const moved = { ...base, headlinePosition: { x: 12.5, y: 14, width: 42, height: 28 }, headlineAlign: "center", headlineVerticalAlign: "middle" };
const moved = { ...base, headlinePosition: { x: 12.5, y: 14, width: 42, height: 28 }, headlineAlign: "center", headlineVerticalAlign: "middle" } as const;
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(moved)))).toEqual(moved);
for (const headlinePosition of [{ x: -1, y: 0 }, { x: 0, y: 101 }, { x: NaN, y: 2 }, { x: 1, y: Infinity }, { x: "5", y: 0 }, { x: 0, y: 0, z: 2 }]) {
expect(savedSignSchema.safeParse({ ...base, headlinePosition }).success).toBe(false);
@@ -15,7 +15,7 @@ test("dragged headline positions persist and reject invalid coordinates", () =>
});
test("text sizing survives saved design round trips and rejects invalid scales", () => {
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" };
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" } as const;
expect(savedSignSchema.parse(base).headlineScale).toBeUndefined();
const sized = { ...base, headlineScale: 150, messageScale: 125, titleScale: 75, showUrl: false };
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(sized)))).toEqual(sized);
@@ -0,0 +1,14 @@
CREATE TABLE "assistant_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"token_hash" text NOT NULL UNIQUE,
"permissions" jsonb NOT NULL,
"read_only" boolean DEFAULT true NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX "assistant_tokens_user_idx" ON "assistant_tokens" ("user_id");
+2 -1
View File
@@ -80,6 +80,7 @@
"breakpoints": true
},
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true },
{ "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true }
{ "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true },
{ "idx": 13, "version": "7", "when": 1789164000000, "tag": "0013_assistant_tokens", "breakpoints": true }
]
}
+16
View File
@@ -0,0 +1,16 @@
import { sql } from "drizzle-orm";
import { getDb } from "./db";
// Bound probes and share an in-flight query so a DB outage cannot fill the pool.
let pending: Promise<void> | undefined;
export async function databaseReady() {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
pending ??= getDb().execute(sql`select 1`).then(() => {}).finally(() => { pending = undefined; });
await Promise.race([pending, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Database probe timed out")), 2500);
})]);
return true;
} catch { return false; }
finally { clearTimeout(timer); }
}
+1
View File
@@ -1,3 +1,4 @@
export { closeDb, getDb, type Database } from "./db";
export { databaseReady } from "./health";
export * from "./schema";
export * from "./auth-schema";
+13
View File
@@ -25,6 +25,19 @@ const timestamps = {
.notNull(),
};
export const assistantTokens = pgTable("assistant_tokens", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull().unique(),
permissions: jsonb("permissions").$type<string[]>().notNull(),
readOnly: boolean("read_only").notNull().default(true),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
...timestamps,
}, table => [index("assistant_tokens_user_idx").on(table.userId)]);
export const eventStatus = pgEnum("event_status", [
"draft",
"published",