Persist event sign designs and harden invitation permissions

This commit is contained in:
2026-09-10 09:52:02 -04:00
parent 3a115f1161
commit 76ef573b3a
15 changed files with 289 additions and 32 deletions
+2
View File
@@ -8,6 +8,7 @@ import { managerRouter } from "./routers/manager";
import { photosRouter } from "./routers/photos";
import { platformRouter } from "./routers/platform";
import { viewerRouter } from "./routers/viewer";
import { signsRouter } from "./routers/signs";
export const appRouter = createTRPCRouter({
health: publicProcedure.query(() => ({
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
service: "album-trpc",
})),
viewer: viewerRouter,
signs: signsRouter,
event: eventRouter,
banners: bannersRouter,
guest: guestRouter,
+25 -2
View File
@@ -18,11 +18,13 @@ import { z } from "zod";
import {
createTRPCRouter,
loadGroupAccess,
loadEventAccess,
requireEventPermission,
protectedProcedure,
requireGroupPermission,
} from "../trpc";
import { GROUP_PERMISSIONS } from "@/server/permissions";
import { getPlatformRole } from "@/server/roles";
import { EVENT_PERMISSIONS, GROUP_PERMISSIONS, PLATFORM_PERMISSIONS } from "@/server/permissions";
import { getPlatformRole, hasPlatformPermission } from "@/server/roles";
import { resolveGroupQuota } from "@/server/entitlements";
import { countGroupOwners } from "@/server/membership";
import { writeAudit } from "@/server/audit";
@@ -33,6 +35,25 @@ import { publicAppOrigin } from "@/server/public-app-url";
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
import { invites } from "@album/database";
async function validateInviteGrants(userId: string, input: {
groupId?: string; eventId?: string; eventRole?: string;
grantUnlimitedEvents?: boolean; grantEventLimit?: number | null; grantComplimentary?: boolean;
}) {
const platformRole = await getPlatformRole(userId);
if ((input.grantUnlimitedEvents || input.grantEventLimit != null || input.grantComplimentary) &&
!hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only platform administrators can grant event entitlements." });
}
if (input.eventId) {
const { event, access } = await loadEventAccess(userId, input.eventId, platformRole);
if (event.groupId !== input.groupId) throw new TRPCError({ code: "BAD_REQUEST", message: "Event must belong to this workspace." });
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE);
if (input.eventRole === "owner") requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
} else if (input.eventRole) {
throw new TRPCError({ code: "BAD_REQUEST", message: "An event is required for an event role." });
}
}
export const groupRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return getDb()
@@ -222,6 +243,7 @@ export const groupRouter = createTRPCRouter({
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const token = newToken();
await validateInviteGrants(ctx.session.user.id, input);
await getDb().insert(invites).values({
kind: "email",
email: input.email,
@@ -266,6 +288,7 @@ export const groupRouter = createTRPCRouter({
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
await validateInviteGrants(ctx.session.user.id, input);
const code = await createInviteCode({
reusable: input.reusable ?? false,
maxUses: input.maxUses ?? 1,
+59
View File
@@ -0,0 +1,59 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { events, eventSigns, getDb } from "@album/database";
import { savedSignSchema } from "@album/contracts";
import { createPresignedGetUrl, createPresignedPutUrl, getObjectBuffer, headObject, putObject, deleteObject } from "@album/storage";
import { createTRPCRouter, protectedProcedure, loadEventAccess, requireEventPermission, EVENT_PERMISSIONS } from "../trpc";
import { getPlatformRole } from "@/server/roles";
import { signPaper, type SignDesign } from "@/lib/guest-sign";
import { writeAudit } from "@/server/audit";
const eventInput = z.object({ eventId: z.string().uuid() });
const pendingKey = (eventId: string, userId: string, uploadId: string) => `events/${eventId}/signs/pending/${userId}/${uploadId}.json`;
async function access(userId: string, eventId: string, write: boolean) {
const result = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
requireEventPermission(result.access.permissions, write ? EVENT_PERMISSIONS.SETTINGS_MANAGE : EVENT_PERMISSIONS.OVERVIEW_READ);
return result;
}
export const signsRouter = createTRPCRouter({
get: protectedProcedure.input(eventInput).query(async ({ ctx, input }) => {
await access(ctx.session.user.id, input.eventId, false);
const [saved] = await getDb().select().from(eventSigns).where(eq(eventSigns.eventId, input.eventId));
return saved ? { revision: saved.revision, url: await createPresignedGetUrl(saved.objectKey), updatedAt: saved.updatedAt } : null;
}),
prepare: protectedProcedure.input(eventInput).mutation(async ({ ctx, input }) => {
await access(ctx.session.user.id, input.eventId, true);
const uploadId = crypto.randomUUID();
return { uploadId, url: await createPresignedPutUrl({ key: pendingKey(input.eventId, ctx.session.user.id, uploadId), contentType: "application/json" }) };
}),
save: protectedProcedure.input(eventInput.extend({ uploadId: z.string().uuid(), revision: z.string().uuid().nullable() })).mutation(async ({ ctx, input }) => {
const { event } = await access(ctx.session.user.id, input.eventId, true);
const key = pendingKey(input.eventId, ctx.session.user.id, input.uploadId);
const metadata = await headObject(key);
if (!metadata?.ContentLength || metadata.ContentLength > 20 * 1024 * 1024) throw new TRPCError({ code: "BAD_REQUEST", message: "Design must be under 20 MB. Try a smaller photo." });
const bytes = await getObjectBuffer(key);
if (bytes.length > 20 * 1024 * 1024) throw new TRPCError({ code: "BAD_REQUEST", message: "Design is too large." });
let design;
try {
design = savedSignSchema.parse(JSON.parse(bytes.toString("utf8")));
signPaper(design as SignDesign);
} catch { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid design. Check the wording, images, and paper size." }); }
const revision = crypto.randomUUID();
const objectKey = `events/${input.eventId}/signs/saved/${revision}.json`;
// Publish a validated immutable copy, never the still-writable upload URL.
await putObject({ key: objectKey, body: Buffer.from(JSON.stringify(design)), contentType: "application/json" });
try {
await getDb().transaction(async tx => {
await tx.select({ id: events.id }).from(events).where(eq(events.id, input.eventId)).for("update");
const [current] = await tx.select().from(eventSigns).where(eq(eventSigns.eventId, input.eventId));
if ((current?.revision ?? null) !== input.revision) throw new TRPCError({ code: "CONFLICT", message: "Someone saved a newer design. Reload it before saving your changes." });
await tx.insert(eventSigns).values({ eventId: input.eventId, revision, objectKey }).onConflictDoUpdate({ target: eventSigns.eventId, set: { revision, objectKey, updatedAt: new Date() } });
await writeAudit({ eventId: input.eventId, groupId: event.groupId, actorUserId: ctx.session.user.id, action: "sign.save", subjectType: "event", subjectId: input.eventId }, tx as unknown as ReturnType<typeof getDb>);
});
} catch (error) { await deleteObject(objectKey).catch(() => {}); throw error; }
await deleteObject(key).catch(() => {});
return { revision };
}),
});
+11 -2
View File
@@ -58,7 +58,7 @@ export function assertInviteRedeemable(invite: typeof invites.$inferSelect) {
if (invite.status === "expired") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
}
if (invite.expiresAt && invite.expiresAt < new Date()) {
if (invite.expiresAt && invite.expiresAt <= new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
}
if (!invite.reusable && invite.status === "accepted") {
@@ -79,7 +79,13 @@ export async function redeemInviteForUser(
input: { token: string; userId: string },
db: Database = getDb(),
) {
const invite = await findInviteByToken(input.token, db);
return db.transaction(tx => redeemLockedInvite(input, tx as unknown as Database));
}
async function redeemLockedInvite(input: { token: string; userId: string }, db: Database) {
// Serialize redemptions and keep membership grants and usage accounting atomic.
const [invite] = await db.select().from(invites)
.where(eq(invites.tokenHash, hashToken(input.token))).limit(1).for("update");
if (!invite) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
}
@@ -93,6 +99,9 @@ export async function redeemInviteForUser(
if (!actor) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (invite.kind === "email" && (!invite.email || invite.email.trim().toLowerCase() !== actor.email.trim().toLowerCase())) {
throw new TRPCError({ code: "FORBIDDEN", message: "Sign in with the email address this invitation was sent to." });
}
let groupId = invite.groupId;
if (
@@ -0,0 +1,63 @@
import { expect, test } from "bun:test";
import sharp from "sharp";
import { and, eq, inArray } from "drizzle-orm";
import { getDb, user, groups, events, eventMemberships, groupMemberships, invites, eventSigns, auditEvents } from "@album/database";
import { deletePrefix } from "@album/storage";
import { hashToken } from "./tokens";
import { redeemInviteForUser } from "./invites";
import { signsRouter } from "./api/routers/signs";
import { groupRouter } from "./api/routers/group";
import type { TrpcContext } from "./api/trpc";
test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomic redemption, and saved sign roundtrip/conflicts", async () => {
for (const key of ["DATABASE_URL", "S3_ENDPOINT"]) if (!["localhost", "127.0.0.1"].includes(new URL(process.env[key]!).hostname)) throw new Error("Local services required");
const db = getDb();
const id = crypto.randomUUID();
const people = await db.insert(user).values([0, 1].map(n => ({ id: `${id}-${n}`, name: "Polish test", email: `${id}-${n}@manyangles.test`, emailVerified: true }))).returning();
const [group] = await db.insert(groups).values({ name: "Polish test", slug: id, createdByUserId: people[0]!.id }).returning();
const [event] = await db.insert(events).values({ groupId: group!.id, title: "Polish test", slug: id }).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: () => {} });
try {
await db.insert(groupMemberships).values({ groupId: group!.id, userId: people[0]!.id, role: "owner" });
await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" });
const token = crypto.randomUUID();
await db.insert(invites).values({ kind: "email", email: people[0]!.email.toUpperCase(), tokenHash: hashToken(token), groupId: group!.id, maxUses: 1 });
await expect(redeemInviteForUser({ token, userId: people[1]!.id })).rejects.toThrow("email address");
const attempts = await Promise.allSettled([redeemInviteForUser({ token, userId: people[0]!.id }), redeemInviteForUser({ token, userId: people[0]!.id })]);
expect(attempts.filter(r => r.status === "fulfilled")).toHaveLength(1);
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
expect(used!.usedCount).toBe(1);
const workspace = groupRouter.createCaller(ctx(0));
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
const signs = signsRouter.createCaller(ctx(0));
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
expect(await signs.get({ eventId: event!.id })).toBeNull();
const image = `data:image/png;base64,${(await sharp({ create: { width: 8, height: 8, channels: 3, background: "#4055b5" } }).png().toBuffer()).toString("base64")}`;
const design = { title: "Saved test", headline: "Share your moments", message: "Photos welcome", paper: "card6x4", ink: "indigo", layout: "compact", background: "#f1eadf", accent: "#473a30", backgroundImage: image, logoImage: image };
const upload = async () => {
const prepared = await signs.prepare({ eventId: event!.id });
const response = await fetch(prepared.url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(design) });
expect(response.ok).toBe(true);
return prepared.uploadId;
};
const first = await signs.save({ eventId: event!.id, uploadId: await upload(), revision: null });
const saved = await signs.get({ eventId: event!.id });
expect(saved!.revision).toBe(first.revision);
expect(await (await fetch(saved!.url)).json()).toEqual(design);
await expect(signs.save({ eventId: event!.id, uploadId: await upload(), revision: null })).rejects.toThrow("newer design");
expect((await signs.get({ eventId: event!.id }))!.revision).toBe(first.revision);
const second = await signs.save({ eventId: event!.id, uploadId: await upload(), revision: first.revision });
expect(second.revision).not.toBe(first.revision);
await db.update(eventMemberships).set({ role: "viewer" }).where(and(eq(eventMemberships.eventId, event!.id), eq(eventMemberships.userId, people[0]!.id)));
await db.delete(groupMemberships).where(eq(groupMemberships.groupId, group!.id));
await expect(signs.prepare({ eventId: event!.id })).rejects.toThrow();
} finally {
await deletePrefix(`events/${event!.id}/signs/`);
await db.delete(eventSigns).where(eq(eventSigns.eventId, event!.id));
await db.delete(invites).where(eq(invites.groupId, group!.id));
await db.delete(auditEvents).where(eq(auditEvents.groupId, group!.id));
await db.delete(events).where(eq(events.id, event!.id));
await db.delete(groups).where(eq(groups.id, group!.id));
await db.delete(user).where(inArray(user.id, people.map(p => p.id)));
}
}, 30000);