diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c276df3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/node_modules +**/.next +**/.turbo +.git +**/.env* +**/*.log +**/*.tsbuildinfo +.secrets +coverage diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..8116821 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,21 @@ +# Copy to .env.production; never commit the filled-in file. +# Use a long random URL-safe password (e.g. hexadecimal). +POSTGRES_PASSWORD= +NEXT_PUBLIC_APP_URL=https://photos.example.com +BETTER_AUTH_URL=https://photos.example.com +BETTER_AUTH_SECRET= +EMAIL_PROVIDER=resend +EMAIL_FROM=Manyangles +RESEND_API_KEY= +RESEND_WEBHOOK_SECRET= +S3_ENDPOINT=https://s3.example.com +S3_PUBLIC_ENDPOINT=https://s3.example.com +S3_REGION=us-east-1 +S3_BUCKET=manyangles +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_FORCE_PATH_STYLE=true +AUTHENTIK_ISSUER= +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +WORKER_CONCURRENCY=1 diff --git a/.gitignore b/.gitignore index 2cc9fe8..c849262 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules dist .env .env.local +.env.production *.log *.tsbuildinfo coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a7ccd6f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM oven/bun:1.4.0 AS source +WORKDIR /app +COPY package.json bun.lock ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/worker/package.json apps/worker/package.json +COPY packages/contracts/package.json packages/contracts/package.json +COPY packages/database/package.json packages/database/package.json +COPY packages/email/package.json packages/email/package.json +COPY packages/storage/package.json packages/storage/package.json +RUN bun install --frozen-lockfile --network-concurrency 8 +COPY . . + +FROM source AS builder +ARG NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL NEXT_TELEMETRY_DISABLED=1 +# Build-only placeholders; never copy production secrets into image layers. +RUN DATABASE_URL=postgres://build:build@127.0.0.1:5432/build BETTER_AUTH_SECRET=build-only-placeholder-secret-not-for-runtime BETTER_AUTH_URL=$NEXT_PUBLIC_APP_URL bun run --filter @album/web build + +FROM oven/bun:1.4.0 AS web +WORKDIR /app +ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 NEXT_TELEMETRY_DISABLED=1 +COPY --from=builder --chown=bun:bun /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=bun:bun /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder --chown=bun:bun /app/apps/web/public ./apps/web/public +COPY --from=builder --chown=bun:bun /app/scripts/check-production-env.ts ./scripts/check-production-env.ts +USER bun +EXPOSE 3000 +CMD ["sh", "-c", "bun scripts/check-production-env.ts && exec bun apps/web/server.js"] + +FROM source AS worker +ENV NODE_ENV=production +USER bun +CMD ["sh", "-c", "bun scripts/check-production-env.ts && exec bun apps/worker/src/index.ts"] + +FROM worker AS migrate +WORKDIR /app/packages/database +CMD ["bun", "src/migrate.ts"] diff --git a/README.md b/README.md index 2cd8944..18536ca 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ packages/ ## Quick start -Requirements: Bun 1.3+ and Docker. +Requirements: Bun 1.4+ and Docker. ```bash cp .env.example .env @@ -49,6 +49,9 @@ Example accounts (password `host`, admin password `admin`): ## Authentik sign-in +Production Docker/Compose setup and original-quality exports are documented in +[the deployment guide](docs/deployment.md). + Manyangles can use an Authentik OAuth2/OpenID Connect provider alongside email and password authentication. In Authentik, create a confidential OAuth2/OpenID provider and application with the `openid`, `profile`, and `email` scopes. diff --git a/apps/web/package.json b/apps/web/package.json index 5e20d57..e2b52e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@album/database": "workspace:*", "@album/email": "workspace:*", "@album/storage": "workspace:*", + "@radix-ui/react-popover": "^1.1.23", "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.4.3", "@trpc/react-query": "^11.4.3", @@ -23,6 +24,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cn": "^0.2.6", + "date-fns": "^4.4.0", "dotenv": "^16.5.0", "drizzle-orm": "^0.45.2", "lucide-react": "^0.468.0", @@ -30,6 +32,7 @@ "next-themes": "^0.4.6", "radix-ui": "^1.6.7", "react": "19.2.8", + "react-day-picker": "^9", "react-dom": "19.2.8", "server-only": "^0.0.1", "shadcn": "^4.21.0", diff --git a/apps/web/public/.gitkeep b/apps/web/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/web/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts new file mode 100644 index 0000000..41063cf --- /dev/null +++ b/apps/web/src/app/api/health/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ status: "ok" }); +} diff --git a/apps/web/src/app/dashboard/events/[id]/event-exports.tsx b/apps/web/src/app/dashboard/events/[id]/event-exports.tsx new file mode 100644 index 0000000..1950725 --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/event-exports.tsx @@ -0,0 +1,30 @@ +"use client"; +import { useState } from "react"; +import { Download } from "lucide-react"; +import { api } from "@/trpc/react"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "sonner"; + +export function EventExports({eventId}:{eventId:string}) { + const [filter,setFilter] = useState<"approved"|"all">("approved"); + const history = api.manager.exports.useQuery({eventId},{refetchInterval:3000}); + const request = api.manager.requestExport.useMutation({onSuccess:()=>{toast.success("Originals export queued");void history.refetch();},onError:e=>toast.error(e.message)}); + const download = api.manager.downloadExport.useMutation({onSuccess:result=>{window.location.assign(result.url);},onError:e=>toast.error(e.message)}); + return
+

Export originals

+

Full-quality files, unchanged—including original metadata. ZIPs expire after 24 hours. Includes processed gallery photos, not standalone banners. Maximum 10,000 photos / 20 GB per export.

+
+ + +
+ {history.isError ?

Could not load exports.

: null} + +
; +} diff --git a/apps/web/src/app/dashboard/events/[id]/event-schedule.tsx b/apps/web/src/app/dashboard/events/[id]/event-schedule.tsx index 9e89215..e14e525 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-schedule.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-schedule.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { api } from "@/trpc/react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { DateTimePicker } from "@/components/ui/date-time-picker"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; @@ -36,7 +36,7 @@ export function EventSchedule({ eventId }: { eventId: string }) { }}> {dateFields.map(([key, label]) => {label} - setDates({ ...dates, [key]: e.target.value })} /> + setDates(previous => ({ ...previous, [key]: value }))} /> )}

Setting a future public date hides the guest page until then. Gallery and note schedules never approve existing private content. Submission closing ends new contributions; it does not hide the gallery. Dates alone do not send email.

diff --git a/apps/web/src/app/dashboard/events/[id]/page.tsx b/apps/web/src/app/dashboard/events/[id]/page.tsx index 5ad4f04..c69f51e 100644 --- a/apps/web/src/app/dashboard/events/[id]/page.tsx +++ b/apps/web/src/app/dashboard/events/[id]/page.tsx @@ -11,6 +11,7 @@ import { EventAudit } from "./event-audit"; import { EventSchedule } from "./event-schedule"; import { eventStatusLabel } from "@/lib/event-status"; import { effectiveEvent } from "@/lib/event-lifecycle"; +import { EventExports } from "./event-exports"; export default async function EventDashboardPage({ params, @@ -37,12 +38,15 @@ export default async function EventDashboardPage({ id: "photos", label: "Photos", content: ( +
+ {canSettings && event.permissions.includes("photos.private.read") ? : null} +
), }, ...(canSettings diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx new file mode 100644 index 0000000..c4285f2 --- /dev/null +++ b/apps/web/src/components/ui/calendar.tsx @@ -0,0 +1,176 @@ +"use client"; + +import * as React from "react"; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react"; +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"]; +}) { + const defaults = getDefaultClassNames(); + + return ( + + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaults.root), + months: cn("relative flex flex-col gap-4 md:flex-row", defaults.months), + month: cn("flex w-[15.75rem] flex-col gap-4", defaults.month), + nav: cn( + "absolute inset-x-0 top-0 flex items-center justify-between", + defaults.nav, + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-9 p-0", + defaults.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-9 p-0", + defaults.button_next, + ), + month_caption: cn( + "flex h-9 items-center justify-center px-9", + defaults.month_caption, + ), + caption_label: cn( + "select-none text-sm font-bold", + captionLayout !== "label" && + "flex h-8 items-center gap-1 rounded-md pl-2 pr-1", + defaults.caption_label, + ), + dropdowns: cn( + "flex h-9 items-center justify-center gap-1.5 text-sm font-medium", + defaults.dropdowns, + ), + dropdown_root: cn( + "relative rounded-md border border-input focus-within:ring-2 focus-within:ring-ring", + defaults.dropdown_root, + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaults.dropdown, + ), + month_grid: cn("w-full border-collapse", defaults.month_grid), + weekdays: cn("grid grid-cols-7", defaults.weekdays), + weekday: cn( + "flex size-9 select-none items-center justify-center rounded-md text-center text-xs font-semibold text-muted-foreground", + defaults.weekday, + ), + week: cn("mt-2 grid grid-cols-7", defaults.week), + day: cn( + "relative size-9 p-0 text-center", + defaults.day, + ), + today: cn( + "rounded-md bg-accent text-accent-foreground", + defaults.today, + ), + outside: cn( + "text-muted-foreground opacity-50", + defaults.outside, + ), + disabled: cn( + "pointer-events-none text-muted-foreground opacity-40", + defaults.disabled, + ), + hidden: cn("invisible", defaults.hidden), + range_start: cn("rounded-l-md bg-accent", defaults.range_start), + range_middle: cn("rounded-none bg-accent", defaults.range_middle), + range_end: cn("rounded-r-md bg-accent", defaults.range_end), + ...classNames, + }} + components={{ + Chevron: ({ className: iconClassName, orientation, ...iconProps }) => { + if (orientation === "left") { + return ( + + ); + } + if (orientation === "right") { + return ( + + ); + } + return ( + + ); + }, + DayButton: CalendarDayButton, + ...components, + }} + {...props} + /> + ); +} + +export function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const ref = React.useRef(null); + + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + + + + + + + {clearable && value ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/ui/date-time-picker.tsx b/apps/web/src/components/ui/date-time-picker.tsx new file mode 100644 index 0000000..7572ca9 --- /dev/null +++ b/apps/web/src/components/ui/date-time-picker.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { format, isValid, parse } from "date-fns"; +import { CalendarIcon, ChevronDown } from "lucide-react"; +import { Calendar } from "@/components/ui/calendar"; +import { TimeFields, formatTimeValue } from "@/components/ui/time-picker"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; + +function parseDate(value: string) { + const date = parse(value, "yyyy-MM-dd", new Date(2000, 0, 1)); + return isValid(date) ? date : undefined; +} + +// One popup with a draft: dismissing never modifies the controlled field. +export function DateTimePicker({id,value,onValueChange,disabled,ariaLabel="Date and time",className}:{ + id:string; + value:string; + onValueChange:(value:string)=>void; + disabled?:boolean; + ariaLabel?:string; + className?:string; +}) { + const [open, setOpen] = useState(false); + const [draftDate, setDraftDate] = useState(""); + const [draftTime, setDraftTime] = useState("19:00"); + const selected = parseDate(value.slice(0,10)); + const draft = parseDate(draftDate); + return { + if (next) { setDraftDate(value.slice(0,10)); setDraftTime(value.slice(11,16)||"19:00"); } + setOpen(next); + }}> +
+ + + + +
+ + setDraftDate(date?format(date,"yyyy-MM-dd"):"")} className="self-center p-0" classNames={{ month: "flex w-[15.75rem] flex-col gap-1", week: "grid grid-cols-7" }} /> + + +
+

Device timezone

+ +
+
+
; +} diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx new file mode 100644 index 0000000..9378626 --- /dev/null +++ b/apps/web/src/components/ui/popover.tsx @@ -0,0 +1,34 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } + diff --git a/apps/web/src/components/ui/time-picker.test.ts b/apps/web/src/components/ui/time-picker.test.ts new file mode 100644 index 0000000..8304428 --- /dev/null +++ b/apps/web/src/components/ui/time-picker.test.ts @@ -0,0 +1,13 @@ +import {expect,test} from "bun:test"; +import {formatTimeValue,parseTimeValue,toTimeValue} from "./time-picker"; + +test("RaceTix time picker preserves midnight, noon and non-five-minute values",()=>{ + expect(formatTimeValue("00:00")).toBe("12:00 AM"); + expect(formatTimeValue("12:00")).toBe("12:00 PM"); + expect(formatTimeValue("23:57")).toBe("11:57 PM"); + for(let h=0;h<24;h++) for(const m of ["00","07","30","59"]) { + const value=`${String(h).padStart(2,"0")}:${m}`; + const parsed=parseTimeValue(value); + expect(toTimeValue(parsed.hour,parsed.minute,parsed.period)).toBe(value); + } +}); diff --git a/apps/web/src/components/ui/time-picker.tsx b/apps/web/src/components/ui/time-picker.tsx new file mode 100644 index 0000000..9155cfe --- /dev/null +++ b/apps/web/src/components/ui/time-picker.tsx @@ -0,0 +1,130 @@ +"use client"; + +import * as React from "react"; +import { Clock3, ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +type SelectFieldOption = {value:string;label:string}; +function SelectField({id,ariaLabel,value,options,onValueChange}:{id:string;ariaLabel:string;value:string;options:SelectFieldOption[];onValueChange:(value:string)=>void}) { + return ; +} +import { cn } from "@/lib/utils"; + +type Period = "AM" | "PM"; + +export function parseTimeValue(value: string) { + const match = /^(\d{2}):(\d{2})$/.exec(value); + if (!match) return { hour: "7", minute: "00", period: "PM" as Period }; + + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (hours > 23 || minutes > 59) { + return { hour: "7", minute: "00", period: "PM" as Period }; + } + + return { + hour: String(hours % 12 || 12), + minute: String(minutes).padStart(2, "0"), + period: (hours >= 12 ? "PM" : "AM") as Period, + }; +} + +export function toTimeValue(hour: string, minute: string, period: Period) { + const hourNumber = Number(hour) % 12; + const hours = period === "PM" ? hourNumber + 12 : hourNumber; + return `${String(hours).padStart(2, "0")}:${minute.padStart(2, "0")}`; +} + +export function formatTimeValue(value: string) { + const { hour, minute, period } = parseTimeValue(value); + return `${hour}:${minute} ${period}`; +} + +const hourOptions = Array.from({ length: 12 }, (_, index) => ({ value: String(index + 1), label: String(index + 1) })); +const standardMinutes = Array.from({ length: 12 }, (_, index) => String(index * 5).padStart(2, "0")); + +export function TimeFields({ id, value, onValueChange }: { + id: string; + value: string; + onValueChange: (value: string) => void; +}) { + const { hour, minute, period } = parseTimeValue(value); + const minutes = standardMinutes.includes(minute) ? standardMinutes : [...standardMinutes, minute].sort(); + return ( + + + Hour + onValueChange(toTimeValue(next, minute, period))} /> + + + Minute + ({ value, label: value }))} onValueChange={next => onValueChange(toTimeValue(hour, next, period))} /> + + + Period + onValueChange(toTimeValue(hour, minute, next as Period))} /> + + + ); +} + +export function TimePicker({ id, name, value, defaultValue = "19:00", onValueChange, disabled, required, ariaLabel = "Time", className }: { + id?: string; + name?: string; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + disabled?: boolean; + required?: boolean; + ariaLabel?: string; + className?: string; +}) { + const generatedId = React.useId(); + const pickerId = id ?? generatedId; + const [open, setOpen] = React.useState(false); + const [internalValue, setInternalValue] = React.useState(defaultValue); + const isControlled = value !== undefined; + const currentValue = value ?? internalValue; + + return ( + + {name ? : null} + + + + +
+

Choose a time

+

Times use your device’s timezone.

+
+ { if (!isControlled) setInternalValue(next); onValueChange?.(next); }} /> + +
+
+ ); +} diff --git a/apps/web/src/server/api/routers/manager.ts b/apps/web/src/server/api/routers/manager.ts index b36bb5e..fbb9d77 100644 --- a/apps/web/src/server/api/routers/manager.ts +++ b/apps/web/src/server/api/routers/manager.ts @@ -16,11 +16,13 @@ import { groupMemberships, guests, photos, + photoExports, submissions, user, } from "@album/database"; import { createEventInputSchema, + exportPhotosInputSchema, checkEventSlugInputSchema, generateEventSlugInputSchema, locationSearchInputSchema, @@ -79,6 +81,30 @@ async function signedPhotoUrls(photo: { } export const managerRouter = createTRPCRouter({ + requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => { + const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id)); + requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE); + requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); + const [job] = await getDb().insert(photoExports).values({eventId:input.eventId,requestedBy:ctx.session.user.id,filter:input.filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning({id:photoExports.id}); + if (!job) throw new TRPCError({code:"CONFLICT",message:"An export is already queued or processing."}); + await writeAudit({eventId:input.eventId,actorUserId:ctx.session.user.id,action:"photo.export.requested",subjectType:"export",subjectId:job.id,metadata:{filter:input.filter}}); + return job; + }), + exports: 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.SETTINGS_MANAGE); + requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); + return getDb().select({id:photoExports.id,status:photoExports.status,filter:photoExports.filter,total:photoExports.total,processed:photoExports.processed,expiresAt:photoExports.expiresAt}).from(photoExports) + .where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.requestedBy,ctx.session.user.id))).orderBy(desc(photoExports.createdAt)).limit(10); + }), + downloadExport: protectedProcedure.input(z.object({eventId:z.string().uuid(),exportId:z.string().uuid()})).mutation(async ({ctx,input}) => { + const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id)); + requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE); + requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); + const [job] = await getDb().select().from(photoExports).where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.id,input.exportId),eq(photoExports.requestedBy,ctx.session.user.id))); + if (!job || job.status !== "ready" || job.expiresAt <= new Date()) throw new TRPCError({code:"NOT_FOUND",message:"Export is unavailable or expired."}); + return {url:await createPresignedGetUrl(`exports/${input.eventId}/${job.id}.zip`,Math.max(1,Math.min(300,Math.floor((job.expiresAt.getTime()-Date.now())/1000))))}; + }), searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole); diff --git a/apps/web/src/server/exports.integration.test.ts b/apps/web/src/server/exports.integration.test.ts new file mode 100644 index 0000000..14b9842 --- /dev/null +++ b/apps/web/src/server/exports.integration.test.ts @@ -0,0 +1,39 @@ +import {test,expect} from "bun:test"; +import {and,eq,ne} from "drizzle-orm"; +import {events,eventMemberships,getDb,groups,photoExports,user} from "@album/database"; +import {managerRouter} from "./api/routers/manager"; +import type {TrpcContext} from "./api/trpc"; + +test.skipIf(process.env.EXPORT_PERMISSIONS_INTEGRATION!=="1")("export authorization, requester scoping, revoked access and expiry",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:"Export test",email:`${id}@manyangles.test`,emailVerified:true}).returning(); + const [group]=await db.select({id:groups.id}).from(groups).limit(1); + const [event]=await db.insert(events).values({groupId:group!.id,title:"Permission test",slug:`export-access-${id}`}).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 caller=managerRouter.createCaller(ctx); + try { + await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow(); + await db.insert(eventMemberships).values({eventId:event!.id,userId:id,role:"viewer"}); + await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow(); + await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id))); + const job=await caller.requestExport({eventId:event!.id,filter:"all"}); + const scope=and(eq(photoExports.eventId,event!.id),eq(photoExports.id,job.id)); + await db.update(photoExports).set({status:"ready"}).where(scope); + expect((await caller.downloadExport({eventId:event!.id,exportId:job.id})).url).toContain("X-Amz-Expires=300"); + const [other]=await db.select({id:user.id}).from(user).where(ne(user.id,id)).limit(1); + await db.update(photoExports).set({requestedBy:other!.id}).where(scope); + await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow(); + await db.update(photoExports).set({requestedBy:id}).where(scope); + await expect(caller.downloadExport({eventId:crypto.randomUUID(),exportId:job.id})).rejects.toThrow(); + await db.update(eventMemberships).set({role:"viewer"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id))); + await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow(); + await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id))); + await db.update(photoExports).set({expiresAt:new Date(0)}).where(scope); + await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow("expired"); + } finally { + await db.delete(events).where(eq(events.id,event!.id)); + await db.delete(user).where(eq(user.id,id)); + } +}); diff --git a/apps/worker/package.json b/apps/worker/package.json index 0a19748..73f283c 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -11,14 +11,18 @@ "lint": "tsc --noEmit" }, "dependencies": { - "@album/email": "workspace:*", "@album/database": "workspace:*", + "@album/email": "workspace:*", "@album/storage": "workspace:*", + "@aws-sdk/lib-storage": "3.1127.0", + "archiver": "^8.0.0", "drizzle-orm": "^0.45.2", "heic-convert": "^2.1.0", "sharp": "0.35.3" }, "devDependencies": { + "@types/archiver": "^8.0.0", + "fflate": "^0.8.3", "typescript": "^5.8.3" } } diff --git a/apps/worker/src/exports.integration.test.ts b/apps/worker/src/exports.integration.test.ts new file mode 100644 index 0000000..a994904 --- /dev/null +++ b/apps/worker/src/exports.integration.test.ts @@ -0,0 +1,42 @@ +import {test,expect} from "bun:test"; +import {unzipSync} from "fflate"; +import {and,eq} from "drizzle-orm"; +import {events,getDb,photos,photoExports,user,guests,submissions} from "@album/database"; +import {getObjectBuffer,deleteObject,putObject,headObject} from "@album/storage"; +import {processPhotoExport} from "./exports"; + +test.skipIf(process.env.EXPORT_INTEGRATION!=="1")("original ZIP bytes, approved filtering, duplicate requests and expiry",async()=>{ + if (!["localhost","127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname) || !["localhost","127.0.0.1"].includes(new URL(process.env.S3_ENDPOINT!).hostname)) throw new Error("Local storage/database required"); + const db=getDb(); + const [demo]=await db.select().from(events).where(eq(events.slug,"demo")); + const [owner]=await db.select({id:user.id}).from(user).limit(1); + const [event]=await db.insert(events).values({groupId:demo!.groupId,title:"Export test",slug:`export-${crypto.randomUUID()}`}).returning(); + const [guest]=await db.insert(guests).values({eventId:event!.id,tokenHash:crypto.randomUUID()}).returning(); + const [submission]=await db.insert(submissions).values({eventId:event!.id,guestId:guest!.id}).returning(); + const bytes=Buffer.from("original bytes preserved exactly"); + const sourceKey=`events/${event!.id}/export-test/original`; + const jobIds:string[]=[]; + try { + await putObject({key:sourceKey,body:bytes,contentType:"image/jpeg"}); + const [photo]=await db.insert(photos).values({eventId:event!.id,submissionId:submission!.id,originalKey:sourceKey,contentType:"image/jpeg",byteSize:bytes.length,processingStatus:"ready",visibility:"public"}).returning(); + await db.insert(photos).values({eventId:event!.id,submissionId:submission!.id,originalKey:sourceKey,contentType:"image/jpeg",byteSize:bytes.length,processingStatus:"ready",visibility:"private"}); + for(const filter of ["approved","all"]) { + const [job]=await db.insert(photoExports).values({eventId:event!.id,requestedBy:owner!.id,filter,expiresAt:new Date(Date.now()+86400000)}).returning(); + jobIds.push(job!.id); + expect(await db.insert(photoExports).values({eventId:event!.id,requestedBy:owner!.id,filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning()).toHaveLength(0); + await processPhotoExport(); + const [finished]=await db.select().from(photoExports).where(and(eq(photoExports.id,job!.id),eq(photoExports.eventId,event!.id))); + expect(finished?.status).toBe("ready"); + const zip=unzipSync(await getObjectBuffer(`exports/${event!.id}/${job!.id}.zip`)); + expect(Object.keys(zip)).toHaveLength(filter==="approved"?1:2); + expect(Buffer.from(zip[`${photo!.id}.jpg`]!)).toEqual(bytes); + await db.update(photoExports).set({expiresAt:new Date(0)}).where(and(eq(photoExports.id,job!.id),eq(photoExports.eventId,event!.id))); + await processPhotoExport(); + expect(await headObject(`exports/${event!.id}/${job!.id}.zip`)).toBeNull(); + } + } finally { + for(const id of jobIds) await deleteObject(`exports/${event!.id}/${id}.zip`); + await deleteObject(sourceKey); + await db.delete(events).where(eq(events.id,event!.id)); + } +},30000); diff --git a/apps/worker/src/exports.ts b/apps/worker/src/exports.ts new file mode 100644 index 0000000..bb61019 --- /dev/null +++ b/apps/worker/src/exports.ts @@ -0,0 +1,66 @@ +import { ZipArchive } from "archiver"; +import { Upload } from "@aws-sdk/lib-storage"; +import { once } from "node:events"; +import { PassThrough } from "node:stream"; +import { and, eq, sql } from "drizzle-orm"; +import { getDb, photoExports, photos } from "@album/database"; +import { deleteObject, getObjectBuffer, getSigningClient, storageConfig } from "@album/storage"; + +export async function processPhotoExport() { + const db = getDb(); + // Interrupted jobs are failed, never reclaimed while an old worker may write. + await db.execute(sql`UPDATE photo_exports SET status = 'failed', updated_at = now() + WHERE status = 'processing' AND updated_at < now() - interval '1 hour'`); + const expired = await db.execute<{id:string;event_id:string}>(sql`SELECT id,event_id FROM photo_exports WHERE expires_at < now() AND status <> 'expired' LIMIT 1`); + if (expired[0]) { + const item = expired[0]; + await deleteObject(`exports/${item.event_id}/${item.id}.zip`); + await db.update(photoExports).set({status:"expired",updatedAt:new Date()}).where(and(eq(photoExports.id,item.id),eq(photoExports.eventId,item.event_id))); + } + const rows = await db.execute<{id:string;event_id:string;filter:string}>(sql`UPDATE photo_exports SET status = 'processing', updated_at = now() + WHERE id = (SELECT id FROM photo_exports WHERE status = 'pending' AND expires_at > now() ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) + RETURNING id,event_id,filter`); + const job = rows[0]; + if (!job) return false; + const scope = and(eq(photoExports.id,job.id),eq(photoExports.eventId,job.event_id),eq(photoExports.status,"processing")); + const key = `exports/${job.event_id}/${job.id}.zip`; + const archive = new ZipArchive({ store:true, forceZip64:true }); + const body = new PassThrough(); + archive.pipe(body); + const upload = new Upload({ client:getSigningClient(), params:{Bucket:storageConfig().bucket,Key:key,Body:body,ContentType:"application/zip",ContentDisposition:'attachment; filename="manyangles-originals.zip"'}, queueSize:2, partSize:8*1024*1024 }); + const uploaded = upload.done(); + // Attach immediately: a storage failure must not become an unhandled rejection. + void uploaded.catch(() => archive.destroy(new Error("Export storage failed"))); + archive.on("error", error => body.destroy(error)); + body.on("error", () => {}); + try { + const items = await db.select({id:photos.id,key:photos.originalKey,type:photos.contentType,bytes:photos.byteSize}).from(photos) + .where(and(eq(photos.eventId,job.event_id),eq(photos.processingStatus,"ready"),job.filter === "approved" ? eq(photos.visibility,"public") : undefined)).orderBy(photos.id).limit(10001); + if (!items.length || items.length > 10000 || items.reduce((sum,p)=>sum+p.bytes,0)>20*1024**3) throw new Error("Export size limit"); + await db.update(photoExports).set({total:items.length,updatedAt:new Date()}).where(scope); + let processed = 0; + for (const item of items) { + const buffer = await getObjectBuffer(item.key); + if (archive.destroyed) { await uploaded; throw new Error("Export stream closed"); } + const extension = ({"image/jpeg":"jpg","image/png":"png","image/webp":"webp","image/heic":"heic","image/heif":"heif"} as Record)[item.type] ?? "bin"; + const consumed = once(archive,"entry"); + archive.append(buffer,{name:`${item.id}.${extension}`}); + await consumed; + const changed = await db.update(photoExports).set({processed:++processed,updatedAt:new Date()}).where(scope).returning({id:photoExports.id}); + if (!changed.length) throw new Error("Export no longer active"); + } + await archive.finalize(); + await uploaded; + const changed = await db.update(photoExports).set({status:"ready",updatedAt:new Date()}).where(scope).returning({id:photoExports.id}); + if (!changed.length) await deleteObject(key); + } catch { + archive.abort(); + body.destroy(); + await upload.abort().catch(()=>{}); + await uploaded.catch(()=>{}); + await deleteObject(key).catch(()=>{}); + await db.update(photoExports).set({status:"failed",updatedAt:new Date()}).where(scope); + console.error(`Export failed for event ${job.event_id}`); + } + return true; +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 42eb864..b5e6260 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,6 +1,7 @@ import { sql } from "drizzle-orm"; import { and, eq } from "drizzle-orm"; import sharp from "sharp"; +import { processPhotoExport } from "./exports"; import { processEmailDelivery } from "@album/email/queue"; import convert from "heic-convert"; import { eventBanners, getDb, photoJobs, photos } from "@album/database"; @@ -208,6 +209,7 @@ async function emailLoop() { } await Promise.all([ + (async () => { while (true) { try { await processPhotoExport(); } catch { console.error("Export queue check failed"); } await Bun.sleep(1000); } })(), emailLoop(), ...Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index + 1)), ]); diff --git a/bun.lock b/bun.lock index bcc3ed0..4b38c1e 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@album/database": "workspace:*", "@album/email": "workspace:*", "@album/storage": "workspace:*", + "@radix-ui/react-popover": "^1.1.23", "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.4.3", "@trpc/react-query": "^11.4.3", @@ -26,6 +27,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cn": "^0.2.6", + "date-fns": "^4.4.0", "dotenv": "^16.5.0", "drizzle-orm": "^0.45.2", "lucide-react": "^0.468.0", @@ -33,6 +35,7 @@ "next-themes": "^0.4.6", "radix-ui": "^1.6.7", "react": "19.2.8", + "react-day-picker": "^9", "react-dom": "19.2.8", "server-only": "^0.0.1", "shadcn": "^4.21.0", @@ -60,11 +63,15 @@ "@album/database": "workspace:*", "@album/email": "workspace:*", "@album/storage": "workspace:*", + "@aws-sdk/lib-storage": "3.1127.0", + "archiver": "^8.0.0", "drizzle-orm": "^0.45.2", "heic-convert": "^2.1.0", "sharp": "0.35.3", }, "devDependencies": { + "@types/archiver": "^8.0.0", + "fflate": "^0.8.3", "typescript": "^5.8.3", }, }, @@ -157,6 +164,8 @@ "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + "@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.1127.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.1127.0" } }, "sha512-em87TJQOrdE3sAqlMnhg3HUAVj1hcoGdDjpekzDdKQcyQSKKbFfulnV57j41h/dHJxVqXRGzTqS8QOdOPtNFtw=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng=="], "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], @@ -247,6 +256,8 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], @@ -559,6 +570,8 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], @@ -613,6 +626,8 @@ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA=="], + "@types/archiver": ["@types/archiver@8.0.0", "", { "dependencies": { "@types/node": "*", "@types/readdir-glob": "*" } }, "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A=="], + "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], @@ -623,8 +638,12 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/readdir-glob": ["@types/readdir-glob@1.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg=="], + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -635,16 +654,34 @@ "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "ast-types": ["ast-types@0.16.3", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + "b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "bare-events": ["bare-events@2.9.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA=="], + + "bare-fs": ["bare-fs@4.8.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg=="], + + "bare-path": ["bare-path@3.1.2", "", {}, "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw=="], + + "bare-stream": ["bare-stream@2.13.4", "", { "dependencies": { "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA=="], + + "bare-url": ["bare-url@2.5.4", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], "better-auth": ["better-auth@1.6.24", "", { "dependencies": { "@better-auth/core": "1.6.24", "@better-auth/drizzle-adapter": "1.6.24", "@better-auth/kysely-adapter": "1.6.24", "@better-auth/memory-adapter": "1.6.24", "@better-auth/mongo-adapter": "1.6.24", "@better-auth/prisma-adapter": "1.6.24", "@better-auth/telemetry": "1.6.24", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-MtBxUKI2y5hXBBLU1MAXkUA/xTEPJ2lkzWLKCTQyG/IKNQQ8ve3tZK0uvq4AIv9iOLZHKUpBOBsDoqTEHaRb9Q=="], @@ -661,6 +698,10 @@ "browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="], + "buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], + + "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], @@ -695,6 +736,8 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], + "conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -709,16 +752,26 @@ "copy-anything": ["copy-anything@4.1.0", "", {}, "sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "crc32-stream": ["crc32-stream@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -785,6 +838,12 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], @@ -797,6 +856,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], @@ -807,6 +868,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -865,6 +928,8 @@ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -905,6 +970,8 @@ "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -933,6 +1000,8 @@ "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "libheif-js": ["libheif-js@1.23.2", "", {}, "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1011,6 +1080,8 @@ "nodemailer": ["nodemailer@9.1.1", "", {}, "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1073,6 +1144,10 @@ "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -1089,6 +1164,8 @@ "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1097,6 +1174,10 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="], + "recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1119,6 +1200,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1173,8 +1256,14 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], + + "streamx": ["streamx@2.28.1", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1195,6 +1284,12 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tar-stream": ["tar-stream@3.2.1", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ=="], + + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1251,6 +1346,8 @@ "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -1291,8 +1388,12 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/archiver/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "@types/nodemailer/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "@types/readdir-glob/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "better-auth/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -1311,6 +1412,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1327,8 +1430,14 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -1395,14 +1504,20 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@types/archiver/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "@types/nodemailer/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "@types/readdir-glob/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], diff --git a/compose.coolify.yml b/compose.coolify.yml new file mode 100644 index 0000000..6558c0c --- /dev/null +++ b/compose.coolify.yml @@ -0,0 +1,105 @@ +# Use the Docker Compose build pack, with this file as its compose location. +# Coolify owns generated URLs and secrets; never commit a rendered .env. +x-environment: &environment + NODE_ENV: production + DATABASE_URL: postgres://manyangles:${SERVICE_PASSWORD_64_POSTGRES}@postgres:5432/manyangles + NEXT_PUBLIC_APP_URL: ${SERVICE_URL_WEB} + BETTER_AUTH_URL: ${SERVICE_URL_WEB} + BETTER_AUTH_SECRET: ${SERVICE_PASSWORD_64_AUTH} + S3_ENDPOINT: http://garage:3900 + S3_PUBLIC_ENDPOINT: ${SERVICE_URL_GARAGE} + S3_REGION: garage + S3_BUCKET: manyangles + S3_ACCESS_KEY: GK${SERVICE_HEX_24_S3KEY} + S3_SECRET_KEY: ${SERVICE_HEX_64_S3SECRET} + S3_FORCE_PATH_STYLE: "true" + EMAIL_PROVIDER: resend + EMAIL_FROM: ${EMAIL_FROM} + RESEND_API_KEY: ${RESEND_API_KEY} + RESEND_WEBHOOK_SECRET: ${RESEND_WEBHOOK_SECRET} + WORKER_CONCURRENCY: "1" +x-runtime: &runtime + restart: unless-stopped + init: true + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: manyangles + POSTGRES_USER: manyangles + POSTGRES_PASSWORD: ${SERVICE_PASSWORD_64_POSTGRES} + volumes: ["postgres-data:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U manyangles -d manyangles"] + interval: 5s + timeout: 3s + retries: 20 + garage: + image: dxflrs/garage:v2.3.0 + restart: unless-stopped + command: ["/garage", "-c", "/etc/garage.toml", "server", "--single-node", "--default-bucket"] + environment: + SERVICE_URL_GARAGE_3900: ${SERVICE_URL_GARAGE_3900} + GARAGE_RPC_SECRET: ${SERVICE_HEX_64_RPC} + GARAGE_ADMIN_TOKEN: ${SERVICE_PASSWORD_64_GARAGEADMIN} + GARAGE_DEFAULT_ACCESS_KEY: GK${SERVICE_HEX_24_S3KEY} + GARAGE_DEFAULT_SECRET_KEY: ${SERVICE_HEX_64_S3SECRET} + GARAGE_DEFAULT_BUCKET: manyangles + expose: ["3900"] + volumes: + - ./docker/garage.production.toml:/etc/garage.toml:ro + - garage-meta:/var/lib/garage/meta + - garage-data:/var/lib/garage/data + healthcheck: + test: ["CMD", "/garage", "-c", "/etc/garage.toml", "status"] + interval: 10s + timeout: 5s + retries: 10 + migrate: + build: {context: ., target: migrate} + environment: + DATABASE_URL: postgres://manyangles:${SERVICE_PASSWORD_64_POSTGRES}@postgres:5432/manyangles + restart: "no" + depends_on: + postgres: {condition: service_healthy} + storage-init: + build: {context: ., target: worker} + command: ["bun", "packages/storage/src/configure.ts"] + environment: *environment + restart: "no" + depends_on: + garage: {condition: service_healthy} + web: + <<: *runtime + build: + context: . + target: web + args: + NEXT_PUBLIC_APP_URL: ${SERVICE_URL_WEB} + environment: + <<: *environment + SERVICE_URL_WEB_3000: ${SERVICE_URL_WEB_3000} + expose: ["3000"] + depends_on: + migrate: {condition: service_completed_successfully} + storage-init: {condition: service_completed_successfully} + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + worker: + <<: *runtime + build: {context: ., target: worker} + environment: *environment + depends_on: + migrate: {condition: service_completed_successfully} + storage-init: {condition: service_completed_successfully} +volumes: + postgres-data: + garage-meta: + garage-data: diff --git a/compose.production.yml b/compose.production.yml new file mode 100644 index 0000000..4a9eb5f --- /dev/null +++ b/compose.production.yml @@ -0,0 +1,53 @@ +name: manyangles +x-runtime: &runtime + env_file: ${RUNTIME_ENV_FILE:-.env.production} + environment: + NODE_ENV: production + DATABASE_URL: postgres://manyangles:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/manyangles + restart: unless-stopped + init: true + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: manyangles + POSTGRES_USER: manyangles + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: ["postgres-data:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U manyangles -d manyangles"] + interval: 5s + timeout: 3s + retries: 20 + migrate: + <<: *runtime + restart: "no" + build: {context: ., target: migrate} + depends_on: + postgres: {condition: service_healthy} + web: + <<: *runtime + build: + context: . + target: web + args: + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:?Set public HTTPS origin} + ports: ["127.0.0.1:3000:3000"] + depends_on: + migrate: {condition: service_completed_successfully} + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + worker: + <<: *runtime + build: {context: ., target: worker} + depends_on: + migrate: {condition: service_completed_successfully} +volumes: + postgres-data: diff --git a/docker/garage.production.toml b/docker/garage.production.toml new file mode 100644 index 0000000..c5bfd43 --- /dev/null +++ b/docker/garage.production.toml @@ -0,0 +1,16 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" +replication_factor = 1 +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" +rpc_secret_file = "env:GARAGE_RPC_SECRET" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.localhost" + +[admin] +api_bind_addr = "[::]:3903" +admin_token_file = "env:GARAGE_ADMIN_TOKEN" diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..30e8949 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,105 @@ +# Production deployment + +## Coolify on one VM + +Use the repository Docker Compose build pack and `/compose.coolify.yml`. +This adds isolated Postgres and Garage volumes, migrations, and a one-shot +storage initializer (exact-origin CORS, one-day export expiry and abandoned +multipart cleanup). It does not import local demo data or create accounts. +Coolify generates `SERVICE_URL_WEB` and `SERVICE_URL_GARAGE`; these are passed +to the app/auth and browser storage endpoint respectively. Route web to port +3000 and Garage to 3900. Both generated domains must use HTTPS. Only these +services are public; do not expose Postgres or Garage's admin/RPC ports. +Set `EMAIL_FROM`, `RESEND_API_KEY`, and `RESEND_WEBHOOK_SECRET` in Coolify before +starting. Generated credentials belong to this stack, not other applications. +Keep worker concurrency at one initially on a shared VM. Back up all three +data volumes off-host and verify recovery before collecting real wedding photos. + +## Standalone Compose + +Requires Docker Compose, an HTTPS reverse proxy, and a private S3-compatible +bucket. Compose includes persistent Postgres, a one-shot migration service, +the standalone web app, and a persistent Bun image/email/export worker. +Mailpit and demo credentials are not included. Production never auto-creates +the bucket or changes its CORS configuration. + +1. Copy `.env.production.example` to `.env.production` and fill every required + secret. Use random values, not development credentials. Keep auth and public + URLs identical to the final HTTPS origin. The public URL is also a build arg; + rebuild the web image when changing it. +2. Provision the private bucket and configure CORS: allow your exact public app + origin, GET/PUT/HEAD, Content-Type and necessary S3 checksum headers; expose + ETag and Content-Length. Guests must reach `S3_PUBLIC_ENDPOINT` over HTTPS. + The worker needs list/get/put/delete and multipart-upload permissions. + Set a one-day lifecycle expiry for the `exports/` prefix and abort incomplete + multipart uploads after one day. Do not expire originals. The worker also + removes expired ZIPs, but lifecycle handles archives belonging to deleted events. +3. Verify the Resend sender domain. Configure `/api/webhooks/resend` as described + in `email-delivery.md`. Set Authentik's callback if using it (see README). +4. Run: + +```sh +docker compose --env-file .env.production -f compose.production.yml build +docker compose --env-file .env.production -f compose.production.yml up -d +docker compose --env-file .env.production -f compose.production.yml ps +``` + +The web service binds only `127.0.0.1:3000`. Configure your host reverse proxy to +forward HTTPS to that address, preserve Host and X-Forwarded-Proto, and support +normal streaming responses. Postgres is not published to the host. If your proxy +runs in Docker, attach it to the Compose network and use `web:3000` instead. + +## First administrator + +Register your real account in the app and verify its email (or sign in through +Authentik). Obtain its ID from your database administrator, then run: + +```sh +docker compose --env-file .env.production -f compose.production.yml exec worker bun packages/database/src/bootstrap-admin.ts USER_ID +``` + +This promotes only an existing verified account and refuses once any platform +administrator exists. No passwords are created or logged. Never run demo seeds. + +## Updates and operations + +Back up Postgres and the original-image bucket before updates. Build updated +images, then run `docker compose ... run --rm migrate` before `up -d` (replace +`...` with the same env-file/file arguments above). Do not use `down -v` on a +live installation. Schema rollback requires a tested backup/restore strategy. +Monitor web health at `/api/health` (process liveness only), worker logs, failed +jobs, disk usage, database backups and storage lifecycle rules. Test restores. +Pin image digests in your deployment pipeline after verifying them. + +## Originals exports + +Event → Photos → Export originals. Owners/managers with settings and private-photo +access can choose approved or all processed photos. Exports are requester-only; +permissions are rechecked before issuing each five-minute signed download URL. +Already-issued URLs remain usable until expiry. ZIP files contain unchanged +original bytes (including EXIF/GPS), named by photo ID to prevent unsafe or +duplicate filenames. Standalone banners and unprocessed uploads are excluded. +The selection is taken when processing starts. No originals pass through Next.js. +ZIP64, streaming multipart uploads and one-at-a-time input reads bound memory. +One active export per requester/event is allowed; limit 10,000 photos / 20 GB. +Archives expire after 24 hours; interrupted jobs fail and may be requested again. + +## Verification + +Validated locally: web/worker/migration Docker image builds (Linux ARM64), web +container startup and health response, worker native Sharp and ZIP dependencies, +Compose configuration, clean database migrations, typecheck and application build. +The demo's eight exported originals were compared byte-for-byte successfully. +No production services were deployed and no production emails were sent. + +```sh +EXPORT_INTEGRATION=1 bun --env-file=.env test apps/worker/src/exports.integration.test.ts +cd apps/web +EXPORT_PERMISSIONS_INTEGRATION=1 bun --env-file=../../.env test src/server/exports.integration.test.ts +``` + +The local-only integration tests cover archive contents, original bytes, +approved/private filtering, expiry deletion, duplicate jobs, event/requester +scoping, denied roles, revoked permissions, and expired download rejection. +They remove their own temporary fixtures. Validate your actual S3 provider and +target architecture in staging before launch. diff --git a/package.json b/package.json index 79725b9..4c8453e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "album", "private": true, - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.0", "workspaces": [ "apps/*", "packages/*" ], "engines": { - "bun": ">=1.3.0" + "bun": ">=1.4.0" }, "scripts": { "dev": "bun --env-file=.env x turbo dev", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7c9934e..3567ba8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -51,6 +51,7 @@ export const allowedImageTypes = [ export const allowedImageTypeSchema = z.enum(allowedImageTypes); export const MAX_PHOTO_BYTES = 25 * 1024 * 1024; +export const exportPhotosInputSchema = z.object({ eventId: z.string().uuid(), filter: z.enum(["approved", "all"]).default("approved") }); export const eventSlugSchema = z .string() diff --git a/packages/database/drizzle/0009_photo_exports.sql b/packages/database/drizzle/0009_photo_exports.sql new file mode 100644 index 0000000..d98f49c --- /dev/null +++ b/packages/database/drizzle/0009_photo_exports.sql @@ -0,0 +1,15 @@ +CREATE TABLE photo_exports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE, + requested_by text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + filter text NOT NULL CHECK (filter IN ('approved', 'all')), + status text NOT NULL DEFAULT 'pending', + total integer NOT NULL DEFAULT 0, + processed integer NOT NULL DEFAULT 0, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX photo_exports_queue_idx ON photo_exports(status, created_at); +CREATE UNIQUE INDEX photo_exports_active_idx ON photo_exports(event_id, requested_by) + WHERE status IN ('pending', 'processing'); diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 1ac0c7e..2023502 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1788906000000, "tag": "0008_email_webhooks", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1788906100000, + "tag": "0009_photo_exports", + "breakpoints": true } ] } diff --git a/packages/database/src/bootstrap-admin.ts b/packages/database/src/bootstrap-admin.ts new file mode 100644 index 0000000..4ad9ee6 --- /dev/null +++ b/packages/database/src/bootstrap-admin.ts @@ -0,0 +1,16 @@ +import { eq, sql } from "drizzle-orm"; +import { closeDb, getDb, platformAdministrators, user } from "@album/database"; + +// Promote an existing verified account, never create credentials or bypass email verification. +const userId = process.argv[2]; +if (!userId) throw new Error("Usage: bun packages/database/src/bootstrap-admin.ts "); +try { + await getDb().transaction(async tx => { + await tx.execute(sql`LOCK TABLE platform_administrators IN EXCLUSIVE MODE`); + if ((await tx.select({id:platformAdministrators.id}).from(platformAdministrators).limit(1)).length) throw new Error("An administrator already exists; use the admin UI instead"); + const [existing] = await tx.select({id:user.id,verified:user.emailVerified}).from(user).where(eq(user.id,userId)); + if (!existing?.verified) throw new Error("Account must exist and have verified email"); + await tx.insert(platformAdministrators).values({userId,role:"super_admin"}); + }); + console.info("Initial administrator promoted"); +} finally { await closeDb(); } diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index d7da868..3ea5da7 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -199,6 +199,18 @@ export const emailWebhookEvents = pgTable("email_webhook_events", { receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [index("email_webhook_events_provider_idx").on(table.providerId)]); +export const photoExports = pgTable("photo_exports", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }), + requestedBy: text("requested_by").notNull().references(() => user.id, { onDelete: "cascade" }), + filter: text("filter").notNull(), + status: text("status").notNull().default("pending"), + total: integer("total").notNull().default(0), + processed: integer("processed").notNull().default(0), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + ...timestamps, +}, (table) => [index("photo_exports_queue_idx").on(table.status, table.createdAt), uniqueIndex("photo_exports_active_idx").on(table.eventId,table.requestedBy).where(sql`${table.status} IN ('pending', 'processing')`), check("photo_exports_filter_check",sql`${table.filter} IN ('approved', 'all')`)]); + export const eventBanners = pgTable("event_banners", { id: uuid("id").defaultRandom().primaryKey(), eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }), diff --git a/packages/storage/src/configure.ts b/packages/storage/src/configure.ts new file mode 100644 index 0000000..d4ee057 --- /dev/null +++ b/packages/storage/src/configure.ts @@ -0,0 +1,24 @@ +import { HeadBucketCommand, PutBucketCorsCommand, PutBucketLifecycleConfigurationCommand, S3Client } from "@aws-sdk/client-s3"; + +// Explicit deployment-only initialization, never run implicitly by web requests. +const origin = new URL(process.env.NEXT_PUBLIC_APP_URL!).origin; +if (!origin.startsWith("https://")) throw new Error("Storage CORS requires an HTTPS app origin"); +const bucket = process.env.S3_BUCKET!; +if (!bucket || !process.env.S3_ACCESS_KEY || !process.env.S3_SECRET_KEY) throw new Error("Missing storage configuration"); +const client = new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION, + forcePathStyle: true, + credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY }, + requestChecksumCalculation: "WHEN_REQUIRED", +}); +await client.send(new HeadBucketCommand({ Bucket: bucket })); +await client.send(new PutBucketCorsCommand({ Bucket: bucket, CORSConfiguration: { CORSRules: [{ + AllowedOrigins: [origin], AllowedMethods: ["GET", "PUT", "HEAD"], AllowedHeaders: ["*"], + ExposeHeaders: ["ETag", "Content-Length"], MaxAgeSeconds: 3600, +}] } })); +await client.send(new PutBucketLifecycleConfigurationCommand({ Bucket: bucket, LifecycleConfiguration: { Rules: [ + { ID: "expire-exports", Status: "Enabled", Filter: { Prefix: "exports/" }, Expiration: { Days: 1 } }, + { ID: "abort-incomplete", Status: "Enabled", Filter: { Prefix: "" }, AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 } }, +] } })); +console.log("Storage CORS and lifecycle configured."); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index cf503b6..000ef5a 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -2,6 +2,9 @@ export { createPresignedGetUrl, createPresignedPutUrl, deletePrefix, + deleteObject, + getSigningClient, + storageConfig, ensureBucket, getObjectBuffer, headObject, diff --git a/packages/storage/src/s3.ts b/packages/storage/src/s3.ts index 0d83845..f2982de 100644 --- a/packages/storage/src/s3.ts +++ b/packages/storage/src/s3.ts @@ -1,6 +1,7 @@ import { CreateBucketCommand, DeleteObjectsCommand, + DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, @@ -67,6 +68,11 @@ export async function ensureBucket() { if (bucketReady) return; const client = getSigningClient(); const { bucket } = storageConfig(); + if (process.env.NODE_ENV === "production") { + await client.send(new HeadBucketCommand({ Bucket: bucket })); + bucketReady = true; + return; + } try { await client.send(new HeadBucketCommand({ Bucket: bucket })); } catch { @@ -112,7 +118,7 @@ export async function createPresignedPutUrl(input: { ); } -export async function createPresignedGetUrl(key: string) { +export async function createPresignedGetUrl(key: string, expiresIn = PRESIGN_GET_SECONDS) { await ensureBucket(); const { bucket } = storageConfig(); return getSignedUrl( @@ -121,10 +127,14 @@ export async function createPresignedGetUrl(key: string) { Bucket: bucket, Key: key, }), - { expiresIn: PRESIGN_GET_SECONDS }, + { expiresIn }, ); } +export async function deleteObject(key: string) { + await getSigningClient().send(new DeleteObjectCommand({ Bucket: storageConfig().bucket, Key: key })); +} + export async function headObject( key: string, ): Promise { diff --git a/scripts/check-production-env.ts b/scripts/check-production-env.ts new file mode 100644 index 0000000..91bcb6d --- /dev/null +++ b/scripts/check-production-env.ts @@ -0,0 +1,6 @@ +const required = ["DATABASE_URL", "NEXT_PUBLIC_APP_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "S3_ENDPOINT", "S3_PUBLIC_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "EMAIL_FROM", "RESEND_API_KEY", "RESEND_WEBHOOK_SECRET"]; +for (const name of required) if (!process.env[name]?.trim()) throw new Error(`Missing production setting: ${name}`); +for (const name of ["NEXT_PUBLIC_APP_URL","BETTER_AUTH_URL","S3_PUBLIC_ENDPOINT"]) if (new URL(process.env[name]!).protocol !== "https:") throw new Error(`${name} must use HTTPS`); +if (process.env.NEXT_PUBLIC_APP_URL !== process.env.BETTER_AUTH_URL) throw new Error("Public and auth URLs must match"); +if (process.env.BETTER_AUTH_SECRET!.length < 32 || process.env.BETTER_AUTH_SECRET!.includes("development")) throw new Error("Use a strong random production auth secret"); +if (process.env.EMAIL_PROVIDER !== "resend") throw new Error("Production Compose requires Resend");