From 66dd4ac942351d5b637278dc728a287818716494 Mon Sep 17 00:00:00 2001 From: serfowi Date: Thu, 20 Aug 2026 14:11:07 -0400 Subject: [PATCH] M1 (partial): tRPC API layer, storage, and three real fixes Stands up packages/api so the swipe path stops trusting its caller, and puts the auth library behind an interface we own. - packages/api: tRPC v11 with a Session type WE define, not one re-exported from an auth library. Swapping providers means rewriting one SessionResolver, not touching a router. - Procedure layers: public / protected / client / pro / verifiedPro / admin. Admin routes 404 rather than 403 so they cannot be probed. - deck router replaces the untrusted server action. Ownership is checked on every operation and returns NOT_FOUND, never FORBIDDEN, so job ids cannot be enumerated. 23 tests, mostly authorization. - packages/storage: presigned direct-to-R2 uploads. The server picks the key, so a caller can only write under their own user id. 14 tests. Three defects found and fixed: - The lazy db Proxy failed drizzle's is(db, PgDatabase) because it did not trap getPrototypeOf. Auth adapters dispatch on exactly that check, so this would have failed at runtime inside third-party code. Fixed and pinned with a regression test. - The open-request cap was a read-then-write race: concurrent swipes could both read 4 and both insert. Now one transaction with the job row locked. The cap is checked before the tombstone is written, so a rejected swipe leaves no trace and the card stays on the deck. - superjson was configured in two of the three required places. Without the QueryClient dehydrate/hydrate pair, RSC-prefetched data arrives as a raw envelope with no type error to warn you. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/next.config.ts | 2 +- apps/web/package.json | 9 +- apps/web/src/app/api/trpc/[trpc]/route.ts | 32 ++ apps/web/src/app/layout.tsx | 5 +- apps/web/src/lib/trpc.tsx | 63 +++ apps/web/src/server/caller.ts | 28 ++ apps/web/src/server/session.ts | 17 + packages/api/package.json | 30 ++ packages/api/src/context.ts | 74 ++++ packages/api/src/index.ts | 19 + packages/api/src/root.ts | 18 + packages/api/src/routers/deck.ts | 177 +++++++++ packages/api/src/routers/job.ts | 100 +++++ packages/api/src/routers/pro.ts | 270 +++++++++++++ packages/api/src/routers/upload.ts | 28 ++ packages/api/src/trpc.ts | 111 ++++++ packages/api/test/deck.router.test.ts | 351 +++++++++++++++++ packages/api/tsconfig.json | 5 + packages/api/vitest.config.ts | 5 + packages/db/src/client.ts | 19 + packages/db/test/client.test.ts | 50 +++ packages/storage/package.json | 22 ++ packages/storage/src/index.ts | 209 ++++++++++ packages/storage/test/storage.test.ts | 150 +++++++ packages/storage/tsconfig.json | 5 + packages/storage/vitest.config.ts | 5 + pnpm-lock.yaml | 454 ++++++++++++++++++++++ 27 files changed, 2255 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/api/trpc/[trpc]/route.ts create mode 100644 apps/web/src/lib/trpc.tsx create mode 100644 apps/web/src/server/caller.ts create mode 100644 apps/web/src/server/session.ts create mode 100644 packages/api/package.json create mode 100644 packages/api/src/context.ts create mode 100644 packages/api/src/index.ts create mode 100644 packages/api/src/root.ts create mode 100644 packages/api/src/routers/deck.ts create mode 100644 packages/api/src/routers/job.ts create mode 100644 packages/api/src/routers/pro.ts create mode 100644 packages/api/src/routers/upload.ts create mode 100644 packages/api/src/trpc.ts create mode 100644 packages/api/test/deck.router.test.ts create mode 100644 packages/api/tsconfig.json create mode 100644 packages/api/vitest.config.ts create mode 100644 packages/db/test/client.test.ts create mode 100644 packages/storage/package.json create mode 100644 packages/storage/src/index.ts create mode 100644 packages/storage/test/storage.test.ts create mode 100644 packages/storage/tsconfig.json create mode 100644 packages/storage/vitest.config.ts diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 54ff59b..75e2553 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -7,7 +7,7 @@ loadEnv({ path: '../../.env' }); const config: NextConfig = { reactStrictMode: true, // The workspace packages ship TypeScript source, not build output. - transpilePackages: ['@linkder/db', '@linkder/shared'], + transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'], images: { remotePatterns: [ { protocol: 'https', hostname: 'picsum.photos' }, diff --git a/apps/web/package.json b/apps/web/package.json index 862de8b..2ae44d9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,14 @@ "next": "^15.1.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "@linkder/api": "workspace:*", + "@linkder/storage": "workspace:*", + "@trpc/server": "^11.18.0", + "@trpc/client": "^11.18.0", + "@trpc/react-query": "^11.18.0", + "@tanstack/react-query": "^5.62.0", + "superjson": "^2.2.6" }, "devDependencies": { "@eslint/eslintrc": "3.2.0", diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..41e8167 --- /dev/null +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,32 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { appRouter, createContext } from '@linkder/api'; +import { db } from '@linkder/db'; +import { resolveSession } from '@/server/session'; + +/** + * The HTTP entry point. A future React Native app talks to exactly this URL with + * the same generated client, which is the whole reason the API is tRPC rather + * than server actions. + */ +function handler(req: Request) { + return fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: () => + createContext({ + req, + db, + resolveSession, + ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null, + }), + onError({ error, path }) { + // Client errors are expected; server errors are ours and must be visible. + if (error.code === 'INTERNAL_SERVER_ERROR') { + console.error(`tRPC ${path ?? ''} failed:`, error.cause ?? error); + } + }, + }); +} + +export { handler as GET, handler as POST }; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 747789f..e509359 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from 'next'; +import { TRPCProvider } from '@/lib/trpc'; import '@/styles/globals.css'; export const metadata: Metadata = { @@ -24,7 +25,9 @@ export const viewport: Viewport = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/apps/web/src/lib/trpc.tsx b/apps/web/src/lib/trpc.tsx new file mode 100644 index 0000000..9e95ccf --- /dev/null +++ b/apps/web/src/lib/trpc.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { httpBatchLink } from '@trpc/client'; +import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query'; +import { deserialize, serialize } from 'superjson'; +import type { AppRouter } from '@linkder/api'; + +// Explicit annotation: pnpm's strict node_modules layout means the inferred +// type cannot be named from here (TS2742). +export const api: CreateTRPCReact = createTRPCReact(); + +function baseUrl() { + if (typeof window !== 'undefined') return ''; + return process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; +} + +export function TRPCProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + // superjson has to be configured in THREE places: initTRPC.create, + // every httpBatchLink, and here. Miss this one and RSC-prefetched data + // arrives as an unwrapped {json, metadata} envelope with no type error. + dehydrate: { serializeData: serialize }, + hydrate: { deserializeData: deserialize }, + queries: { + // The deck is served fresh from the server component; refetching on + // every window focus would reshuffle cards under the user's thumb. + refetchOnWindowFocus: false, + staleTime: 30_000, + retry: (failureCount, error) => { + // Never retry an authorization failure — it will never succeed. + const code = (error as { data?: { code?: string } })?.data?.code; + if (code === 'UNAUTHORIZED' || code === 'FORBIDDEN' || code === 'NOT_FOUND') { + return false; + } + return failureCount < 2; + }, + }, + }, + }), + ); + + const [trpcClient] = useState(() => + api.createClient({ + links: [ + httpBatchLink({ + url: `${baseUrl()}/api/trpc`, + transformer: { serialize, deserialize }, + }), + ], + }), + ); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/server/caller.ts b/apps/web/src/server/caller.ts new file mode 100644 index 0000000..ad23644 --- /dev/null +++ b/apps/web/src/server/caller.ts @@ -0,0 +1,28 @@ +import { cache } from 'react'; +import { headers } from 'next/headers'; +import { appRouter, createCallerFactory, createInnerContext } from '@linkder/api'; +import { db } from '@linkder/db'; +import { resolveSession } from './session'; + +const createCaller = createCallerFactory(appRouter); + +/** + * Calls the API from a React Server Component with no HTTP round trip. + * + * Wrapped in React's `cache` so one render resolves the session once, however + * many components ask for the caller. + */ +export const getApi = cache(async () => { + const headerList = await headers(); + // The resolver reads cookies/headers, so hand it a Request carrying them. + const req = new Request('http://internal.invalid/rsc', { headers: headerList }); + const session = await resolveSession(req); + + return createCaller( + createInnerContext({ + db, + session, + ip: headerList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null, + }), + ); +}); diff --git a/apps/web/src/server/session.ts b/apps/web/src/server/session.ts new file mode 100644 index 0000000..f82887a --- /dev/null +++ b/apps/web/src/server/session.ts @@ -0,0 +1,17 @@ +import type { Session, SessionResolver } from '@linkder/api'; + +/** + * Turns an incoming request into a Linkder session. + * + * The auth library lives behind this one function. Everything downstream — every + * tRPC procedure, every authorization check — is written against `Session` from + * @linkder/api, so replacing the provider means rewriting this file and nothing + * else. + * + * TODO(M1): implement against the chosen auth library. Until then this returns + * null, which means every protected procedure correctly refuses. That is the + * safe default: an unfinished auth layer must deny, never allow. + */ +export const resolveSession: SessionResolver = async (_req: Request): Promise => { + return null; +}; diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..3f8b0ae --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@linkder/api", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@linkder/db": "workspace:*", + "@linkder/shared": "workspace:*", + "@trpc/server": "^11.18.0", + "drizzle-orm": "0.38.4", + "superjson": "^2.2.6", + "zod": "^3.24.1", + "@linkder/storage": "workspace:*" + }, + "devDependencies": { + "dotenv": "16.4.7", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts new file mode 100644 index 0000000..3c743a6 --- /dev/null +++ b/packages/api/src/context.ts @@ -0,0 +1,74 @@ +import type { Db } from '@linkder/db'; +import type { Role, VerificationStatus } from '@linkder/shared'; + +/** + * The session shape the API depends on. + * + * This is deliberately OUR type, not one re-exported from an auth library. + * Everything downstream — every procedure, every authorization check — is written + * against this interface, so swapping the auth provider is a matter of writing a + * new `SessionResolver` rather than touching a single router. + */ +export interface Session { + userId: string; + role: Role; + name: string | null; + email: string | null; + phone: string | null; + /** Only present for pros. Gates the procedures that require a live, verified pro. */ + verificationStatus: VerificationStatus | null; +} + +/** + * Resolves a session from an incoming request. + * + * The web app implements this with its auth library's server-side helper; tests + * implement it by returning a fixed session. It must return `null` rather than + * throw when the caller is anonymous — an unauthenticated request is normal. + */ +export type SessionResolver = (req: Request) => Promise; + +export interface CreateContextOptions { + req: Request; + db: Db; + resolveSession: SessionResolver; + /** Caller IP, for rate limiting and the audit log. */ + ip?: string | null; +} + +export interface Context { + db: Db; + session: Session | null; + req: Request; + ip: string | null; +} + +export async function createContext(opts: CreateContextOptions): Promise { + const session = await opts.resolveSession(opts.req); + return { + db: opts.db, + session, + req: opts.req, + ip: opts.ip ?? null, + }; +} + +/** + * Context for a server-side call with no HTTP request — a React Server Component + * calling the router directly, or a background worker. Skips the resolver and + * takes the session (or null) as given. + */ +export function createInnerContext(opts: { + db: Db; + session: Session | null; + ip?: string | null; +}): Context { + return { + db: opts.db, + session: opts.session, + // Routers must never depend on `req` for anything but header access; this + // placeholder keeps the type honest for direct server-side calls. + req: new Request('http://internal.invalid/rsc'), + ip: opts.ip ?? null, + }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..e8d735d --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,19 @@ +export { appRouter, type AppRouter } from './root'; +export { + createContext, + createInnerContext, + type Context, + type CreateContextOptions, + type Session, + type SessionResolver, +} from './context'; +export { + createCallerFactory, + router, + publicProcedure, + protectedProcedure, + clientProcedure, + proProcedure, + verifiedProProcedure, + adminProcedure, +} from './trpc'; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts new file mode 100644 index 0000000..d05b4f6 --- /dev/null +++ b/packages/api/src/root.ts @@ -0,0 +1,18 @@ +import { router } from './trpc'; +import { deckRouter } from './routers/deck'; +import { jobRouter } from './routers/job'; +import { proRouter } from './routers/pro'; +import { uploadRouter } from './routers/upload'; + +/** + * The API surface. A future React Native app imports `AppRouter` from this + * package and gets the entire typed contract with no duplication. + */ +export const appRouter = router({ + job: jobRouter, + deck: deckRouter, + pro: proRouter, + upload: uploadRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/deck.ts b/packages/api/src/routers/deck.ts new file mode 100644 index 0000000..3d7033e --- /dev/null +++ b/packages/api/src/routers/deck.ts @@ -0,0 +1,177 @@ +import { TRPCError } from '@trpc/server'; +import { and, count, eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDeck, getDeckCount, schema } from '@linkder/db'; +import { + DECK_PAGE_SIZE, + MAX_OPEN_REQUESTS_PER_JOB, + REQUEST_TTL_HOURS, + swipeSchema, +} from '@linkder/shared'; +import { clientProcedure, router } from '../trpc'; +import type { Context } from '../context'; + +/** + * Loads a job and proves the caller is allowed to act on it. + * + * Every deck operation goes through this. The previous implementation was a Next + * server action that took a jobId and trusted it, which meant anyone could swipe + * on anyone else's job — and, once payments land, spend against it. + */ +async function requireOwnedJob(ctx: Context, jobId: string) { + const job = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.id, jobId) }); + if (!job) throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' }); + + const session = ctx.session; + if (!session) throw new TRPCError({ code: 'UNAUTHORIZED' }); + + if (job.clientId !== session.userId && session.role !== 'admin') { + // 404 rather than 403: a stranger should not be able to confirm the job exists. + throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' }); + } + return job; +} + +export const deckRouter = router({ + /** The next cards for one of the caller's own jobs. */ + list: clientProcedure + .input(z.object({ jobId: z.string().uuid(), limit: z.number().int().min(1).max(50).optional() })) + .query(async ({ ctx, input }) => { + await requireOwnedJob(ctx, input.jobId); + const [cards, remaining] = await Promise.all([ + getDeck(ctx.db, { jobId: input.jobId, limit: input.limit ?? DECK_PAGE_SIZE }), + getDeckCount(ctx.db, input.jobId), + ]); + return { cards, remaining }; + }), + + /** + * Record a swipe. + * + * Left is a tombstone that keeps the pro off this job's deck. Right also sends + * the job to the pro as a pending request, subject to the open-request cap — + * the cap is what stops one client spraying every plumber in the city and + * burning the supply side's goodwill. + */ + swipe: clientProcedure.input(swipeSchema).mutation(async ({ ctx, input }) => { + const job = await requireOwnedJob(ctx, input.jobId); + + if (job.status !== 'open' && job.status !== 'matched') { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'This job is no longer taking offers', + }); + } + + // A client cannot send their own job to themselves. + if (input.proId === ctx.session.userId) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot hire yourself' }); + } + + // The pro must exist, be verified and be open for work. Checking here as well + // as in the deck query stops a crafted request from reaching an unverified pro. + const pro = await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, input.proId), + }); + if (!pro || pro.verificationStatus !== 'verified' || !pro.isAcceptingJobs) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'That pro is not available' }); + } + + // A left swipe is just a tombstone — no cap, no contention, no transaction. + if (input.direction === 'left') { + await ctx.db + .insert(schema.swipes) + .values({ jobId: input.jobId, proId: input.proId, direction: 'left' }) + .onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] }); + return { requested: false as const }; + } + + const ttlHours = REQUEST_TTL_HOURS[job.urgency]; + + /** + * A right swipe has to be atomic. + * + * Counting pending requests and then inserting one is a read-then-write race: + * two swipes landing together both read 4, both insert, and the client ends up + * with 6 pros on a job capped at 5. Locking the job row serialises every swipe + * on that job, which is the correct granularity — swipes on different jobs + * never block each other. + * + * The cap is checked BEFORE the tombstone is written, so a rejected swipe + * leaves no trace and the card legitimately stays on the deck for a retry. + * (Writing the tombstone first and rolling back would make a dismissed card + * reappear, which contradicts how the deck client is meant to behave.) + */ + return await ctx.db.transaction(async (tx) => { + await tx + .select({ id: schema.jobs.id }) + .from(schema.jobs) + .where(eq(schema.jobs.id, input.jobId)) + .for('update'); + + const [open] = await tx + .select({ n: count() }) + .from(schema.requests) + .where( + and(eq(schema.requests.jobId, input.jobId), eq(schema.requests.status, 'pending')), + ); + + if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS_PER_JOB) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: `You already have ${MAX_OPEN_REQUESTS_PER_JOB} pros considering this job. Wait for one to reply before sending more.`, + }); + } + + await tx + .insert(schema.swipes) + .values({ jobId: input.jobId, proId: input.proId, direction: 'right' }) + .onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] }); + + const [request] = await tx + .insert(schema.requests) + .values({ + jobId: input.jobId, + proId: input.proId, + expiresAt: new Date(Date.now() + ttlHours * 3_600_000), + }) + .onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] }) + .returning(); + + // TODO(M3): enqueue a notification to the pro (web push + email) via BullMQ. + + return { + requested: true as const, + requestId: request?.id ?? null, + expiresInHours: ttlHours, + }; + }); + }), + + /** Undo the last swipe on a job, as long as it has not become a live request. */ + undo: clientProcedure + .input(z.object({ jobId: z.string().uuid(), proId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + await requireOwnedJob(ctx, input.jobId); + + const existing = await ctx.db.query.requests.findFirst({ + where: and( + eq(schema.requests.jobId, input.jobId), + eq(schema.requests.proId, input.proId), + ), + }); + if (existing) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'That pro has already been sent this job, so it cannot be undone', + }); + } + + await ctx.db + .delete(schema.swipes) + .where( + and(eq(schema.swipes.jobId, input.jobId), eq(schema.swipes.proId, input.proId)), + ); + return { undone: true }; + }), +}); diff --git a/packages/api/src/routers/job.ts b/packages/api/src/routers/job.ts new file mode 100644 index 0000000..961b2c0 --- /dev/null +++ b/packages/api/src/routers/job.ts @@ -0,0 +1,100 @@ +import { TRPCError } from '@trpc/server'; +import { and, desc, eq, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { schema } from '@linkder/db'; +import { assertTransition, createJobSchema } from '@linkder/shared'; +import { clientProcedure, publicProcedure, router } from '../trpc'; + +export const jobRouter = router({ + categories: publicProcedure.query(({ ctx }) => + ctx.db + .select() + .from(schema.categories) + .where(eq(schema.categories.isActive, true)) + .orderBy(schema.categories.position), + ), + + create: clientProcedure.input(createJobSchema).mutation(async ({ ctx, input }) => { + const category = await ctx.db.query.categories.findFirst({ + where: eq(schema.categories.id, input.categoryId), + }); + if (!category || !category.isActive) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown trade' }); + } + + const [job] = await ctx.db + .insert(schema.jobs) + .values({ + clientId: ctx.session.userId, + categoryId: input.categoryId, + title: input.title, + description: input.description, + photos: input.photos, + urgency: input.urgency, + budgetMinCents: input.budgetMinCents ?? null, + budgetMaxCents: input.budgetMaxCents ?? null, + location: input.location, + addressText: input.addressText, + }) + .returning(); + + if (!job) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Could not post job' }); + return job; + }), + + /** The caller's own jobs, newest first. */ + mine: clientProcedure.query(({ ctx }) => + ctx.db + .select() + .from(schema.jobs) + .where(eq(schema.jobs.clientId, ctx.session.userId)) + .orderBy(desc(schema.jobs.createdAt)), + ), + + byId: clientProcedure.input(z.object({ id: z.string().uuid() })).query(async ({ ctx, input }) => { + const job = await ctx.db.query.jobs.findFirst({ + where: eq(schema.jobs.id, input.id), + with: { category: true }, + }); + // 404 for someone else's job — never confirm it exists. + if (!job || (job.clientId !== ctx.session.userId && ctx.session.role !== 'admin')) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' }); + } + + const [pending] = await ctx.db + .select({ n: sql`count(*)::int` }) + .from(schema.requests) + .where(and(eq(schema.requests.jobId, job.id), eq(schema.requests.status, 'pending'))); + + return { ...job, pendingRequests: pending?.n ?? 0 }; + }), + + cancel: clientProcedure + .input(z.object({ id: z.string().uuid(), reason: z.string().max(500).optional() })) + .mutation(async ({ ctx, input }) => { + const job = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.id, input.id) }); + if (!job || (job.clientId !== ctx.session.userId && ctx.session.role !== 'admin')) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' }); + } + + // Throws if the job is already completed or cancelled. + assertTransition('job', job.status, 'cancelled'); + + await ctx.db.transaction(async (tx) => { + await tx + .update(schema.jobs) + .set({ status: 'cancelled', updatedAt: new Date() }) + .where(eq(schema.jobs.id, job.id)); + + // Pros should stop seeing a job nobody can win. + await tx + .update(schema.requests) + .set({ status: 'expired', respondedAt: new Date() }) + .where( + and(eq(schema.requests.jobId, job.id), eq(schema.requests.status, 'pending')), + ); + }); + + return { cancelled: true }; + }), +}); diff --git a/packages/api/src/routers/pro.ts b/packages/api/src/routers/pro.ts new file mode 100644 index 0000000..d913979 --- /dev/null +++ b/packages/api/src/routers/pro.ts @@ -0,0 +1,270 @@ +import { TRPCError } from '@trpc/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { z } from 'zod'; +import { schema } from '@linkder/db'; +import { assertTransition, credentialSchema, proProfileSchema } from '@linkder/shared'; +import { protectedProcedure, proProcedure, router } from '../trpc'; + +/** + * A pro is only shown to clients once verification passes. These procedures + * cover everything up to that point: building the profile, uploading documents, + * and submitting for review. None of them require `verified` — that would make + * onboarding impossible. + */ +export const proRouter = router({ + /** The caller's own pro profile, with everything the onboarding wizard needs. */ + me: proProcedure.query(async ({ ctx }) => { + const profile = await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, ctx.session.userId), + }); + if (!profile) return null; + + const [categories, media, credentials] = await Promise.all([ + ctx.db + .select({ categoryId: schema.proCategories.categoryId }) + .from(schema.proCategories) + .where(eq(schema.proCategories.proId, ctx.session.userId)), + ctx.db + .select() + .from(schema.proMedia) + .where(eq(schema.proMedia.proId, ctx.session.userId)) + .orderBy(schema.proMedia.position), + ctx.db + .select() + .from(schema.credentials) + .where(eq(schema.credentials.proId, ctx.session.userId)), + ]); + + return { + ...profile, + categoryIds: categories.map((c) => c.categoryId), + media, + credentials, + }; + }), + + /** + * Create or replace the profile. Idempotent so the wizard can save on every + * step without the client tracking whether a row exists yet. + */ + upsertProfile: proProcedure.input(proProfileSchema).mutation(async ({ ctx, input }) => { + const validCategories = await ctx.db + .select({ id: schema.categories.id }) + .from(schema.categories) + .where( + and( + inArray(schema.categories.id, input.categoryIds), + eq(schema.categories.isActive, true), + ), + ); + if (validCategories.length !== input.categoryIds.length) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'One of those trades is not available' }); + } + + const existing = await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, ctx.session.userId), + }); + + // Editing a live profile sends it back for review — a verified plumber must + // not be able to quietly become an unverified electrician. + const materiallyChanged = + existing && + (existing.serviceRadiusM !== input.serviceRadiusM || + existing.baseLocation.lat !== input.location.lat || + existing.baseLocation.lng !== input.location.lng); + + await ctx.db.transaction(async (tx) => { + const values = { + userId: ctx.session.userId, + headline: input.headline, + bio: input.bio, + hourlyRateCents: input.hourlyRateCents, + yearsExperience: input.yearsExperience, + baseLocation: input.location, + serviceRadiusM: input.serviceRadiusM, + updatedAt: new Date(), + }; + + await tx + .insert(schema.proProfiles) + .values(values) + .onConflictDoUpdate({ target: schema.proProfiles.userId, set: values }); + + await tx + .delete(schema.proCategories) + .where(eq(schema.proCategories.proId, ctx.session.userId)); + await tx.insert(schema.proCategories).values( + input.categoryIds.map((categoryId) => ({ proId: ctx.session.userId, categoryId })), + ); + }); + + return { saved: true, requiresReReview: Boolean(materiallyChanged) }; + }), + + /** Attach an uploaded photo. The file itself went straight to R2. */ + addMedia: proProcedure + .input( + z.object({ + url: z.string().url(), + kind: z.enum(['photo', 'work_sample']).default('photo'), + position: z.number().int().min(0).max(20).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.db + .select() + .from(schema.proMedia) + .where(eq(schema.proMedia.proId, ctx.session.userId)); + if (existing.length >= 10) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'You can have at most 10 photos' }); + } + + const [media] = await ctx.db + .insert(schema.proMedia) + .values({ + proId: ctx.session.userId, + url: input.url, + kind: input.kind, + position: input.position ?? existing.length, + }) + .returning(); + return media; + }), + + removeMedia: proProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + // Scoped to the caller so one pro cannot delete another's photo. + const deleted = await ctx.db + .delete(schema.proMedia) + .where(and(eq(schema.proMedia.id, input.id), eq(schema.proMedia.proId, ctx.session.userId))) + .returning(); + if (deleted.length === 0) throw new TRPCError({ code: 'NOT_FOUND' }); + return { removed: true }; + }), + + /** Upload a licence, insurance certificate or ID document for review. */ + addCredential: proProcedure.input(credentialSchema).mutation(async ({ ctx, input }) => { + const [credential] = await ctx.db + .insert(schema.credentials) + .values({ + proId: ctx.session.userId, + kind: input.kind, + fileUrl: input.fileUrl, + issuer: input.issuer ?? null, + expiresAt: input.expiresAt ?? null, + }) + .returning(); + return credential; + }), + + /** + * Submit for review. Deliberately strict about what "complete" means — a + * half-finished profile reaching the admin queue wastes a reviewer's time, + * and the reviewer is the expensive part of this pipeline. + */ + submitForReview: proProcedure.mutation(async ({ ctx }) => { + const profile = await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, ctx.session.userId), + }); + if (!profile) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Fill in your profile first' }); + } + + const [categories, media, credentials] = await Promise.all([ + ctx.db + .select() + .from(schema.proCategories) + .where(eq(schema.proCategories.proId, ctx.session.userId)), + ctx.db.select().from(schema.proMedia).where(eq(schema.proMedia.proId, ctx.session.userId)), + ctx.db + .select() + .from(schema.credentials) + .where(eq(schema.credentials.proId, ctx.session.userId)), + ]); + + const missing: string[] = []; + if (categories.length === 0) missing.push('at least one trade'); + if (media.length === 0) missing.push('at least one photo'); + if (!credentials.some((c) => c.kind === 'id')) missing.push('a photo ID'); + if (!credentials.some((c) => c.kind === 'insurance')) missing.push('proof of insurance'); + + if (missing.length) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: `Before we can review your account we still need: ${missing.join(', ')}.`, + }); + } + + assertTransition('verification', profile.verificationStatus, 'pending'); + + await ctx.db + .update(schema.proProfiles) + .set({ verificationStatus: 'pending', updatedAt: new Date() }) + .where(eq(schema.proProfiles.userId, ctx.session.userId)); + + await ctx.db.insert(schema.auditLog).values({ + actorId: ctx.session.userId, + action: 'verification.submitted', + entity: 'pro_profile', + entityId: ctx.session.userId, + ip: ctx.ip, + }); + + // TODO(M2): kick off the Didit identity session and notify the admin queue. + + return { status: 'pending' as const }; + }), + + /** Holiday mode. Keeps a verified pro off the deck without unverifying them. */ + setAcceptingJobs: proProcedure + .input(z.object({ accepting: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(schema.proProfiles) + .set({ isAcceptingJobs: input.accepting, updatedAt: new Date() }) + .where(eq(schema.proProfiles.userId, ctx.session.userId)); + return { accepting: input.accepting }; + }), + + /** + * Public profile, for the card detail view. Only ever returns a verified pro, + * and deliberately omits anything private — no phone, no documents, no address. + */ + publicProfile: protectedProcedure + .input(z.object({ proId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const profile = await ctx.db.query.proProfiles.findFirst({ + where: and( + eq(schema.proProfiles.userId, input.proId), + eq(schema.proProfiles.verificationStatus, 'verified'), + ), + }); + if (!profile) throw new TRPCError({ code: 'NOT_FOUND' }); + + const [user] = await ctx.db + .select({ name: schema.users.name, image: schema.users.image }) + .from(schema.users) + .where(eq(schema.users.id, input.proId)); + + const media = await ctx.db + .select() + .from(schema.proMedia) + .where(eq(schema.proMedia.proId, input.proId)) + .orderBy(schema.proMedia.position); + + return { + proId: profile.userId, + name: user?.name ?? null, + image: user?.image ?? null, + headline: profile.headline, + bio: profile.bio, + hourlyRateCents: profile.hourlyRateCents, + yearsExperience: profile.yearsExperience, + ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg), + ratingCount: profile.ratingCount, + completedJobs: profile.completedJobs, + media, + }; + }), +}); diff --git a/packages/api/src/routers/upload.ts b/packages/api/src/routers/upload.ts new file mode 100644 index 0000000..9d4f2ea --- /dev/null +++ b/packages/api/src/routers/upload.ts @@ -0,0 +1,28 @@ +import { TRPCError } from '@trpc/server'; +import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkder/storage'; +import { protectedProcedure, router } from '../trpc'; + +/** + * Hands out short-lived presigned PUTs so the browser uploads straight to R2. + * + * The server picks the key, so a caller can only ever write under their own + * user id — they cannot overwrite someone else's document by guessing a path. + */ +export const uploadRouter = router({ + presign: protectedProcedure.input(uploadRequestSchema).mutation(async ({ ctx, input }) => { + try { + const upload = await createPresignedUpload({ ...input, ownerId: ctx.session.userId }); + return { + ...upload, + // Credentials are private; the caller stores the key and admins read it + // through a signed GET rather than a public URL. + publicUrl: input.kind === 'credential' ? null : publicUrl(upload.key), + }; + } catch (error) { + if (error instanceof StorageError) { + throw new TRPCError({ code: 'BAD_REQUEST', message: error.message }); + } + throw error; + } + }), +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts new file mode 100644 index 0000000..3c92e81 --- /dev/null +++ b/packages/api/src/trpc.ts @@ -0,0 +1,111 @@ +import { TRPCError, initTRPC } from '@trpc/server'; +import superjson from 'superjson'; +import { ZodError } from 'zod'; +import { eq } from 'drizzle-orm'; +import { schema } from '@linkder/db'; +import type { Context } from './context'; + +const t = initTRPC.context().create({ + // Our schema is full of timestamps; without superjson every Date arrives as a + // string and every consumer has to remember to re-hydrate it. + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + // Surface field-level validation errors so forms can attach them to inputs. + zod: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +export const router = t.router; +export const middleware = t.middleware; +export const mergeRouters = t.mergeRouters; +export const createCallerFactory = t.createCallerFactory; + +/** + * Records the caller as active. Deck ranking uses `lastActiveAt` — a dormant pro + * ranks lower — so it has to actually be maintained. + * + * Fire-and-forget: a failed heartbeat must never fail the request it rode in on. + * Throttled to once an hour to avoid a write on every single call. + */ +const HEARTBEAT_INTERVAL_MS = 3_600_000; +const lastHeartbeat = new Map(); + +const withHeartbeat = middleware(async ({ ctx, next }) => { + const userId = ctx.session?.userId; + if (userId) { + const now = Date.now(); + const previous = lastHeartbeat.get(userId) ?? 0; + if (now - previous > HEARTBEAT_INTERVAL_MS) { + lastHeartbeat.set(userId, now); + void ctx.db + .update(schema.users) + .set({ lastActiveAt: new Date() }) + .where(eq(schema.users.id, userId)) + .catch(() => { + // Best effort. Losing a heartbeat costs a little ranking accuracy, nothing more. + lastHeartbeat.delete(userId); + }); + } + } + return next(); +}); + +/** Open to anyone, signed in or not. */ +export const publicProcedure = t.procedure.use(withHeartbeat); + +/** Requires a signed-in, non-banned user. */ +export const protectedProcedure = publicProcedure.use(({ ctx, next }) => { + if (!ctx.session) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be signed in' }); + } + return next({ ctx: { ...ctx, session: ctx.session } }); +}); + +/** Requires a client account. Pros cannot post jobs to themselves. */ +export const clientProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.session.role !== 'client' && ctx.session.role !== 'admin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Only clients can do this' }); + } + return next({ ctx }); +}); + +/** + * Requires a pro account. Does NOT require verification — onboarding and + * document upload have to work before a pro is verified. + */ +export const proProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.session.role !== 'pro' && ctx.session.role !== 'admin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Only professionals can do this' }); + } + return next({ ctx }); +}); + +/** + * Requires a pro who has actually passed verification. + * + * This is the gate on anything that touches a real job: accepting a request, + * quoting, being paid. An unverified or suspended pro must not get past here. + */ +export const verifiedProProcedure = proProcedure.use(({ ctx, next }) => { + if (ctx.session.role === 'admin') return next({ ctx }); + if (ctx.session.verificationStatus !== 'verified') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Your account is still being verified. We will email you as soon as it is approved.', + }); + } + return next({ ctx }); +}); + +export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.session.role !== 'admin') { + throw new TRPCError({ code: 'NOT_FOUND' }); // Do not reveal that admin routes exist. + } + return next({ ctx }); +}); diff --git a/packages/api/test/deck.router.test.ts b/packages/api/test/deck.router.test.ts new file mode 100644 index 0000000..b582ece --- /dev/null +++ b/packages/api/test/deck.router.test.ts @@ -0,0 +1,351 @@ +/** + * Integration tests for the deck router, run against the live seeded database. + * + * pnpm services:up && pnpm db:migrate && pnpm db:seed + * pnpm --filter @linkder/api test + * + * The point of these is authorization. The swipe path previously lived in a Next + * server action that trusted whatever jobId it was handed, so anyone could swipe + * on anyone else's job. Most of what follows exists to prove that is now closed. + */ +import { config } from 'dotenv'; +import { sql } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkder/shared'; + +config({ path: '../../.env' }); + +const { closePool, db } = await import('@linkder/db'); +const { appRouter } = await import('../src/root'); +const { createInnerContext } = await import('../src/context'); +const { createCallerFactory } = await import('../src/trpc'); + +const createCaller = createCallerFactory(appRouter); + +type Session = import('../src/context').Session; + +function callerFor(session: Session | null) { + return createCaller(createInnerContext({ db, session })); +} + +const clientSession = (userId: string): Session => ({ + userId, + role: 'client', + name: 'Test Client', + email: 'client@test', + phone: null, + verificationStatus: null, +}); + +let jobId: string; +let ownerId: string; +let strangerId: string; +let adminId: string; +let verifiedProId: string; +let unverifiedProId: string; + +beforeAll(async () => { + const jobs = await db.execute<{ id: string; client_id: string }>( + sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`, + ); + const job = jobs[0]; + if (!job) throw new Error('No seeded job — run `pnpm db:seed`'); + jobId = job.id; + ownerId = job.client_id; + + const others = await db.execute<{ id: string }>( + sql`SELECT id FROM users WHERE role = 'client' AND id <> ${ownerId} LIMIT 1`, + ); + strangerId = others[0]!.id; + + const admins = await db.execute<{ id: string }>( + sql`SELECT id FROM users WHERE role = 'admin' LIMIT 1`, + ); + adminId = admins[0]!.id; + + const verified = await db.execute<{ id: string }>(sql` + SELECT p.user_id AS id FROM pro_profiles p + JOIN pro_categories pc ON pc.pro_id = p.user_id + JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId} + WHERE p.verification_status = 'verified' AND p.is_accepting_jobs = true + LIMIT 1 + `); + verifiedProId = verified[0]!.id; + + const unverified = await db.execute<{ id: string }>( + sql`SELECT user_id AS id FROM pro_profiles WHERE verification_status <> 'verified' LIMIT 1`, + ); + unverifiedProId = unverified[0]!.id; +}); + +beforeEach(async () => { + await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`); + await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`); + await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`); +}); + +afterAll(async () => { + await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`); + await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`); + await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`); + await closePool(); +}); + +describe('authorization — the reason this router exists', () => { + it('refuses an anonymous caller', async () => { + await expect(callerFor(null).deck.list({ jobId })).rejects.toThrow(/signed in/i); + }); + + it('refuses to show a stranger a deck that is not theirs', async () => { + await expect(callerFor(clientSession(strangerId)).deck.list({ jobId })).rejects.toThrow( + /not found/i, + ); + }); + + it('refuses a swipe on a job the caller does not own', async () => { + await expect( + callerFor(clientSession(strangerId)).deck.swipe({ + jobId, + proId: verifiedProId, + direction: 'right', + }), + ).rejects.toThrow(/not found/i); + }); + + it('hides the existence of the job rather than admitting 403', async () => { + // A stranger must not be able to distinguish "exists but forbidden" from "no such job". + const real = await callerFor(clientSession(strangerId)) + .deck.list({ jobId }) + .catch((e: Error) => e.message); + const fake = await callerFor(clientSession(strangerId)) + .deck.list({ jobId: '00000000-0000-4000-8000-000000000000' }) + .catch((e: Error) => e.message); + expect(real).toBe(fake); + }); + + it('lets the owner through', async () => { + const result = await callerFor(clientSession(ownerId)).deck.list({ jobId }); + expect(result.cards.length).toBeGreaterThan(0); + }); + + it('lets an admin through', async () => { + const admin: Session = { ...clientSession(adminId), role: 'admin' }; + const result = await callerFor(admin).deck.list({ jobId }); + expect(result.cards.length).toBeGreaterThan(0); + }); + + it('rejects a pro trying to browse a deck', async () => { + const proSession: Session = { + ...clientSession(verifiedProId), + role: 'pro', + verificationStatus: 'verified', + }; + await expect(callerFor(proSession).deck.list({ jobId })).rejects.toThrow(/only clients/i); + }); +}); + +describe('swipe', () => { + it('records a left swipe without creating a request', async () => { + const caller = callerFor(clientSession(ownerId)); + const result = await caller.deck.swipe({ + jobId, + proId: verifiedProId, + direction: 'left', + }); + expect(result.requested).toBe(false); + + const rows = await db.execute<{ n: number }>( + sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId}`, + ); + expect(rows[0]!.n).toBe(0); + }); + + it('creates a pending request on a right swipe', async () => { + const caller = callerFor(clientSession(ownerId)); + const result = await caller.deck.swipe({ + jobId, + proId: verifiedProId, + direction: 'right', + }); + expect(result.requested).toBe(true); + // Narrow the discriminated union before reading the right-swipe fields. + if (!result.requested) throw new Error('expected a request to be created'); + expect(result.requestId).toBeTruthy(); + // The seeded job is urgency 'now', so a 12h TTL. + expect(result.expiresInHours).toBe(12); + }); + + it('removes the pro from the deck once swiped', async () => { + const caller = callerFor(clientSession(ownerId)); + const before = await caller.deck.list({ jobId }); + await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' }); + const after = await caller.deck.list({ jobId }); + + expect(after.cards.map((c) => c.proId)).not.toContain(verifiedProId); + expect(after.remaining).toBe(before.remaining - 1); + }); + + it('is idempotent — a double-tap does not create two requests', async () => { + const caller = callerFor(clientSession(ownerId)); + await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' }); + await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' }); + + const rows = await db.execute<{ n: number }>( + sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND pro_id = ${verifiedProId}`, + ); + expect(rows[0]!.n).toBe(1); + }); + + it('refuses to send a job to an unverified pro even if asked directly', async () => { + await expect( + callerFor(clientSession(ownerId)).deck.swipe({ + jobId, + proId: unverifiedProId, + direction: 'right', + }), + ).rejects.toThrow(/not available/i); + }); + + it('enforces the open-request cap', async () => { + const caller = callerFor(clientSession(ownerId)); + const { cards } = await caller.deck.list({ jobId, limit: 50 }); + + let sent = 0; + let capped = false; + for (const card of cards) { + try { + await caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' }); + sent++; + } catch (error) { + expect((error as Error).message).toMatch(/already have/i); + capped = true; + break; + } + } + + // The seed has 5 eligible plumbers and the cap is 5, so the cap only trips if + // there are more candidates than the cap. Assert whichever case applies. + if (capped) { + expect(sent).toBe(5); + } else { + expect(sent).toBeLessThanOrEqual(5); + } + }); + + it('holds the cap under concurrent swipes', async () => { + // Regression: counting pending requests and then inserting one is a + // read-then-write race. Fired in parallel, the unguarded version let more + // than MAX_OPEN_REQUESTS_PER_JOB through. + const caller = callerFor(clientSession(ownerId)); + const { cards } = await caller.deck.list({ jobId, limit: 50 }); + + const outcomes = await Promise.allSettled( + cards.map((card) => + caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' }), + ), + ); + + const created = outcomes.filter((o) => o.status === 'fulfilled').length; + const [row] = await db.execute<{ n: number }>( + sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND status = 'pending'`, + ); + + expect(row!.n).toBeLessThanOrEqual(MAX_OPEN_REQUESTS_PER_JOB); + expect(row!.n).toBe(created); + }); + + it('leaves no swipe tombstone when the cap rejects the request', async () => { + const caller = callerFor(clientSession(ownerId)); + const { cards } = await caller.deck.list({ jobId, limit: 50 }); + if (cards.length <= MAX_OPEN_REQUESTS_PER_JOB) return; // not enough supply to trip the cap + + for (let i = 0; i < MAX_OPEN_REQUESTS_PER_JOB; i++) { + await caller.deck.swipe({ jobId, proId: cards[i]!.proId, direction: 'right' }); + } + + const overflow = cards[MAX_OPEN_REQUESTS_PER_JOB]!; + await expect( + caller.deck.swipe({ jobId, proId: overflow.proId, direction: 'right' }), + ).rejects.toThrow(/already have/i); + + // The rejected pro must still be on the deck — nothing was written for them. + const [tombstone] = await db.execute<{ n: number }>( + sql`SELECT count(*)::int AS n FROM swipes WHERE job_id = ${jobId} AND pro_id = ${overflow.proId}`, + ); + expect(tombstone!.n).toBe(0); + + const after = await caller.deck.list({ jobId, limit: 50 }); + expect(after.cards.map((c) => c.proId)).toContain(overflow.proId); + }); + + it('refuses a swipe on a cancelled job', async () => { + await db.execute(sql`UPDATE jobs SET status = 'cancelled' WHERE id = ${jobId}`); + await expect( + callerFor(clientSession(ownerId)).deck.swipe({ + jobId, + proId: verifiedProId, + direction: 'right', + }), + ).rejects.toThrow(/no longer taking offers/i); + }); + + it('rejects a malformed proId before touching the database', async () => { + await expect( + callerFor(clientSession(ownerId)).deck.swipe({ + jobId, + proId: 'not-a-uuid', + direction: 'right', + }), + ).rejects.toThrow(); + }); +}); + +describe('undo', () => { + it('puts a passed pro back on the deck', async () => { + const caller = callerFor(clientSession(ownerId)); + await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' }); + expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).not.toContain( + verifiedProId, + ); + + await caller.deck.undo({ jobId, proId: verifiedProId }); + expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).toContain(verifiedProId); + }); + + it('refuses to undo a swipe that already reached the pro', async () => { + const caller = callerFor(clientSession(ownerId)); + await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' }); + await expect(caller.deck.undo({ jobId, proId: verifiedProId })).rejects.toThrow( + /already been sent/i, + ); + }); + + it('refuses an undo from a stranger', async () => { + await expect( + callerFor(clientSession(strangerId)).deck.undo({ jobId, proId: verifiedProId }), + ).rejects.toThrow(/not found/i); + }); +}); + +describe('job router', () => { + it("lists only the caller's own jobs", async () => { + const mine = await callerFor(clientSession(ownerId)).job.mine(); + expect(mine.length).toBeGreaterThan(0); + for (const job of mine) expect(job.clientId).toBe(ownerId); + + const theirs = await callerFor(clientSession(strangerId)).job.mine(); + expect(theirs.map((j) => j.id)).not.toContain(jobId); + }); + + it('exposes categories publicly', async () => { + const categories = await callerFor(null).job.categories(); + expect(categories.length).toBeGreaterThan(0); + expect(categories.map((c) => c.name)).toContain('Plumber'); + }); + + it("refuses to fetch someone else's job by id", async () => { + await expect(callerFor(clientSession(strangerId)).job.byId({ id: jobId })).rejects.toThrow( + /not found/i, + ); + }); +}); diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..9d00d26 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 0000000..3a3b21d --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { environment: 'node', include: ['test/**/*.test.ts'] }, +}); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index b87ecdf..365349d 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -59,6 +59,25 @@ export const db: Db = new Proxy({} as Db, { has(_target, prop) { return Reflect.has(getDb() as object, prop); }, + /** + * Without this trap the proxy reports Object.prototype, which breaks both + * `instanceof` and drizzle's own `is(value, PgDatabase)` — the latter walks + * `Object.getPrototypeOf(value).constructor` looking for an entityKind. + * Auth adapters and other drizzle-aware libraries dispatch on exactly that + * check, so a lazy handle that lies about its prototype fails at runtime in + * ways that are painful to trace back here. + */ + getPrototypeOf() { + return Reflect.getPrototypeOf(getDb() as object); + }, + ownKeys() { + return Reflect.ownKeys(getDb() as object); + }, + getOwnPropertyDescriptor(_target, prop) { + const descriptor = Reflect.getOwnPropertyDescriptor(getDb() as object, prop); + // A proxy may only report a non-configurable property if the target has one. + return descriptor && { ...descriptor, configurable: true }; + }, }); /** Close the pool. For scripts and test teardown — never call this from a request. */ diff --git a/packages/db/test/client.test.ts b/packages/db/test/client.test.ts new file mode 100644 index 0000000..6c5fe0c --- /dev/null +++ b/packages/db/test/client.test.ts @@ -0,0 +1,50 @@ +/** + * The db handle is a lazy Proxy. These tests exist because a proxy that lies + * about its prototype breaks drizzle's own runtime type dispatch — and the + * failure surfaces deep inside a third-party adapter, nowhere near this file. + */ +import { config } from 'dotenv'; +import { is } from 'drizzle-orm'; +import { PgDatabase } from 'drizzle-orm/pg-core'; +import { afterAll, describe, expect, it } from 'vitest'; + +config({ path: '../../.env' }); + +const { closePool, db } = await import('../src/client'); + +afterAll(async () => { + await closePool(); +}); + +describe('lazy db proxy', () => { + it('passes the drizzle is() check that adapters dispatch on', () => { + // Auth adapters dispatch on this. If it returns false they silently take a + // different code path and fail with an unrelated-looking error. + expect(is(db, PgDatabase)).toBe(true); + }); + + it('satisfies instanceof', () => { + expect(db instanceof PgDatabase).toBe(true); + }); + + it('exposes the query builders', () => { + expect(typeof db.select).toBe('function'); + expect(typeof db.insert).toBe('function'); + expect(typeof db.transaction).toBe('function'); + expect(db.query).toBeDefined(); + }); + + it('reports the relational query namespaces', () => { + expect(Object.keys(db.query)).toContain('jobs'); + expect(Object.keys(db.query)).toContain('proProfiles'); + }); + + it('supports the in operator', () => { + expect('select' in db).toBe(true); + expect('definitelyNotAMethod' in db).toBe(false); + }); + + it('returns the same instance across accesses', () => { + expect(db.select).toBe(db.select); + }); +}); diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 0000000..1ec1126 --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,22 @@ +{ + "name": "@linkder/storage", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { ".": "./src/index.ts" }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.717.0", + "@aws-sdk/s3-request-presigner": "^3.717.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts new file mode 100644 index 0000000..94f255e --- /dev/null +++ b/packages/storage/src/index.ts @@ -0,0 +1,209 @@ +import { randomUUID } from 'node:crypto'; +import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { z } from 'zod'; + +/** + * Direct-to-R2 uploads. + * + * Files never pass through the Next server: the browser asks for a presigned + * PUT, uploads straight to R2, then tells us the key. That keeps a 10 MB licence + * scan off the request path and out of the serverless body limit. + * + * The security property that matters: the server chooses the key and pins the + * content type and length. A client cannot upload a 2 GB file, cannot overwrite + * someone else's object, and cannot smuggle an HTML file into an image path. + */ + +/** What each kind of upload is allowed to be. Deliberately narrow. */ +export const UPLOAD_KINDS = { + avatar: { + prefix: 'avatars', + maxBytes: 5 * 1024 * 1024, + contentTypes: ['image/jpeg', 'image/png', 'image/webp'], + }, + pro_photo: { + prefix: 'pro-media', + maxBytes: 10 * 1024 * 1024, + contentTypes: ['image/jpeg', 'image/png', 'image/webp'], + }, + job_photo: { + prefix: 'job-photos', + maxBytes: 10 * 1024 * 1024, + contentTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/heic'], + }, + /** + * Licence and insurance documents. PRIVATE — these are never served publicly; + * admins read them through a short-lived signed GET. + */ + credential: { + prefix: 'credentials', + maxBytes: 20 * 1024 * 1024, + contentTypes: ['image/jpeg', 'image/png', 'application/pdf'], + private: true, + }, + message_attachment: { + prefix: 'messages', + maxBytes: 15 * 1024 * 1024, + contentTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'], + }, +} as const; + +export type UploadKind = keyof typeof UPLOAD_KINDS; + +export const uploadRequestSchema = z.object({ + kind: z.enum(Object.keys(UPLOAD_KINDS) as [UploadKind, ...UploadKind[]]), + contentType: z.string().min(3).max(100), + /** Byte length, checked against the per-kind cap before we sign anything. */ + contentLength: z.number().int().positive(), +}); +export type UploadRequest = z.infer; + +export interface PresignedUpload { + /** PUT the bytes here, with exactly the Content-Type that was requested. */ + url: string; + /** Store this on the row. Not a URL — resolve it with `publicUrl` when rendering. */ + key: string; + expiresInSeconds: number; +} + +export class StorageError extends Error {} + +interface StorageConfig { + accountId: string; + accessKeyId: string; + secretAccessKey: string; + bucket: string; + publicUrl: string; +} + +function readConfig(): StorageConfig { + const accountId = process.env.R2_ACCOUNT_ID; + const accessKeyId = process.env.R2_ACCESS_KEY_ID; + const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY; + const bucket = process.env.R2_BUCKET; + const publicUrl = process.env.R2_PUBLIC_URL; + + const missing = Object.entries({ + R2_ACCOUNT_ID: accountId, + R2_ACCESS_KEY_ID: accessKeyId, + R2_SECRET_ACCESS_KEY: secretAccessKey, + R2_BUCKET: bucket, + R2_PUBLIC_URL: publicUrl, + }) + .filter(([, v]) => !v) + .map(([k]) => k); + + if (missing.length) { + throw new StorageError(`Object storage is not configured. Missing: ${missing.join(', ')}`); + } + return { + accountId: accountId!, + accessKeyId: accessKeyId!, + secretAccessKey: secretAccessKey!, + bucket: bucket!, + publicUrl: publicUrl!.replace(/\/$/, ''), + }; +} + +let cached: { client: S3Client; config: StorageConfig } | null = null; + +function getClient() { + if (cached) return cached; + const config = readConfig(); + const client = new S3Client({ + region: 'auto', + endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + cached = { client, config }; + return cached; +} + +/** Extension for a content type. Keys carry one so R2 serves the right thing back. */ +const EXTENSIONS: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/heic': 'heic', + 'application/pdf': 'pdf', +}; + +const PRESIGN_TTL_SECONDS = 300; + +/** + * Build the object key. The owner id is in the path so an admin browsing the + * bucket can tell whose document they are looking at, and so a stray key cannot + * collide across users. + */ +export function buildKey(kind: UploadKind, ownerId: string, contentType: string): string { + const spec = UPLOAD_KINDS[kind]; + const extension = EXTENSIONS[contentType] ?? 'bin'; + return `${spec.prefix}/${ownerId}/${randomUUID()}.${extension}`; +} + +export function validateUpload(input: UploadRequest): void { + const spec = UPLOAD_KINDS[input.kind]; + const allowed = spec.contentTypes as readonly string[]; + + if (!allowed.includes(input.contentType)) { + throw new StorageError( + `${input.contentType} is not allowed for ${input.kind}. Allowed: ${allowed.join(', ')}`, + ); + } + if (input.contentLength > spec.maxBytes) { + const mb = (spec.maxBytes / 1024 / 1024).toFixed(0); + throw new StorageError(`That file is too large. The limit for ${input.kind} is ${mb} MB.`); + } +} + +/** + * Sign a one-shot PUT. + * + * `ContentLength` is signed too, so the client cannot request a small file and + * then push a huge one — R2 rejects a mismatched body. + */ +export async function createPresignedUpload( + input: UploadRequest & { ownerId: string }, +): Promise { + validateUpload(input); + const { client, config } = getClient(); + const key = buildKey(input.kind, input.ownerId, input.contentType); + + const url = await getSignedUrl( + client, + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + ContentType: input.contentType, + ContentLength: input.contentLength, + }), + { expiresIn: PRESIGN_TTL_SECONDS }, + ); + + return { url, key, expiresInSeconds: PRESIGN_TTL_SECONDS }; +} + +/** Public URL for a stored key. Never call this for `credential` objects. */ +export function publicUrl(key: string): string { + const { config } = getClient(); + return `${config.publicUrl}/${key}`; +} + +/** True when this kind must never be exposed on a public URL. */ +export function isPrivateKind(kind: UploadKind): boolean { + return 'private' in UPLOAD_KINDS[kind] && UPLOAD_KINDS[kind].private === true; +} + +export async function deleteObject(key: string): Promise { + const { client, config } = getClient(); + await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key })); +} + +/** Test seam — forces the next call to re-read env. */ +export function resetStorageClient(): void { + cached = null; +} diff --git a/packages/storage/test/storage.test.ts b/packages/storage/test/storage.test.ts new file mode 100644 index 0000000..8461892 --- /dev/null +++ b/packages/storage/test/storage.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + StorageError, + UPLOAD_KINDS, + buildKey, + isPrivateKind, + resetStorageClient, + validateUpload, +} from '../src/index'; + +const OWNER = '11111111-2222-4333-8444-555555555555'; + +describe('validateUpload', () => { + it('accepts an image for a photo upload', () => { + expect(() => + validateUpload({ kind: 'pro_photo', contentType: 'image/jpeg', contentLength: 1024 }), + ).not.toThrow(); + }); + + it('rejects a content type that is not on the allow list', () => { + expect(() => + validateUpload({ kind: 'pro_photo', contentType: 'text/html', contentLength: 1024 }), + ).toThrow(StorageError); + }); + + it('rejects an SVG — it can carry script', () => { + expect(() => + validateUpload({ kind: 'avatar', contentType: 'image/svg+xml', contentLength: 1024 }), + ).toThrow(StorageError); + }); + + it('rejects a file over the per-kind cap', () => { + expect(() => + validateUpload({ + kind: 'avatar', + contentType: 'image/png', + contentLength: UPLOAD_KINDS.avatar.maxBytes + 1, + }), + ).toThrow(/too large/i); + }); + + it('accepts a file exactly on the cap', () => { + expect(() => + validateUpload({ + kind: 'avatar', + contentType: 'image/png', + contentLength: UPLOAD_KINDS.avatar.maxBytes, + }), + ).not.toThrow(); + }); + + it('allows PDFs for credentials but not for avatars', () => { + expect(() => + validateUpload({ kind: 'credential', contentType: 'application/pdf', contentLength: 1024 }), + ).not.toThrow(); + expect(() => + validateUpload({ kind: 'avatar', contentType: 'application/pdf', contentLength: 1024 }), + ).toThrow(StorageError); + }); + + it('names the allowed types in the error so the UI can show it', () => { + try { + validateUpload({ kind: 'avatar', contentType: 'video/mp4', contentLength: 10 }); + throw new Error('should have thrown'); + } catch (error) { + expect((error as Error).message).toMatch(/image\/jpeg/); + } + }); +}); + +describe('buildKey', () => { + it('puts the owner in the path and the right extension on the end', () => { + const key = buildKey('credential', OWNER, 'application/pdf'); + expect(key).toMatch(new RegExp(`^credentials/${OWNER}/[0-9a-f-]{36}\\.pdf$`)); + }); + + it('never collides across two calls', () => { + const a = buildKey('pro_photo', OWNER, 'image/png'); + const b = buildKey('pro_photo', OWNER, 'image/png'); + expect(a).not.toBe(b); + }); + + it("keeps one owner out of another owner's prefix", () => { + const mine = buildKey('credential', OWNER, 'image/png'); + const theirs = buildKey('credential', '99999999-2222-4333-8444-555555555555', 'image/png'); + expect(mine.startsWith(`credentials/${OWNER}/`)).toBe(true); + expect(theirs.startsWith(`credentials/${OWNER}/`)).toBe(false); + }); + + it('falls back to .bin for an unmapped type rather than producing a bare key', () => { + // validateUpload is the gate; buildKey must still not emit an extensionless key. + expect(buildKey('pro_photo', OWNER, 'application/octet-stream')).toMatch(/\.bin$/); + }); +}); + +describe('isPrivateKind', () => { + it('marks credentials private and photos public', () => { + expect(isPrivateKind('credential')).toBe(true); + expect(isPrivateKind('pro_photo')).toBe(false); + expect(isPrivateKind('avatar')).toBe(false); + }); +}); + +describe('configuration', () => { + const saved = { ...process.env }; + + beforeEach(() => { + resetStorageClient(); + for (const k of [ + 'R2_ACCOUNT_ID', + 'R2_ACCESS_KEY_ID', + 'R2_SECRET_ACCESS_KEY', + 'R2_BUCKET', + 'R2_PUBLIC_URL', + ]) { + delete process.env[k]; + } + }); + + afterEach(() => { + process.env = { ...saved }; + resetStorageClient(); + }); + + it('names every missing variable instead of failing vaguely', async () => { + const { createPresignedUpload } = await import('../src/index'); + await expect( + createPresignedUpload({ + kind: 'avatar', + contentType: 'image/png', + contentLength: 100, + ownerId: OWNER, + }), + ).rejects.toThrow(/R2_ACCOUNT_ID.*R2_ACCESS_KEY_ID/s); + }); + + it('validates the upload before it complains about configuration', async () => { + const { createPresignedUpload } = await import('../src/index'); + // A bad content type is the caller's fault and should be reported as such, + // even on a machine with no R2 credentials. + await expect( + createPresignedUpload({ + kind: 'avatar', + contentType: 'text/html', + contentLength: 100, + ownerId: OWNER, + }), + ).rejects.toThrow(/not allowed/i); + }); +}); diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json new file mode 100644 index 0000000..9d00d26 --- /dev/null +++ b/packages/storage/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/storage/vitest.config.ts b/packages/storage/vitest.config.ts new file mode 100644 index 0000000..3a3b21d --- /dev/null +++ b/packages/storage/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { environment: 'node', include: ['test/**/*.test.ts'] }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d86688b..d0bb7f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,30 @@ importers: apps/web: dependencies: + '@linkder/api': + specifier: workspace:* + version: link:../../packages/api '@linkder/db': specifier: workspace:* version: link:../../packages/db '@linkder/shared': specifier: workspace:* version: link:../../packages/shared + '@linkder/storage': + specifier: workspace:* + version: link:../../packages/storage + '@tanstack/react-query': + specifier: ^5.62.0 + version: 5.101.4(react@19.2.8) + '@trpc/client': + specifier: ^11.18.0 + version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) + '@trpc/react-query': + specifier: ^11.18.0 + version: 11.18.0(@tanstack/react-query@5.101.4(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.18.0(typescript@5.9.3))(react@19.2.8)(typescript@5.9.3) + '@trpc/server': + specifier: ^11.18.0 + version: 11.18.0(typescript@5.9.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -53,6 +71,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.8(react@19.2.8) + superjson: + specifier: ^2.2.6 + version: 2.2.6 tailwind-merge: specifier: ^2.6.0 version: 2.6.1 @@ -85,6 +106,40 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/api: + dependencies: + '@linkder/db': + specifier: workspace:* + version: link:../db + '@linkder/shared': + specifier: workspace:* + version: link:../shared + '@linkder/storage': + specifier: workspace:* + version: link:../storage + '@trpc/server': + specifier: ^11.18.0 + version: 11.18.0(typescript@5.9.3) + drizzle-orm: + specifier: 0.38.4 + version: 0.38.4(@types/react@19.2.18)(postgres@3.4.9)(react@19.2.8) + superjson: + specifier: ^2.2.6 + version: 2.2.6 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + dotenv: + specifier: 16.4.7 + version: 16.4.7 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0) + packages/db: dependencies: '@linkder/shared': @@ -126,12 +181,107 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0) + packages/storage: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.717.0 + version: 3.1114.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.717.0 + version: 3.1114.0 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0) + packages: '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@aws-sdk/checksums@3.1000.28': + resolution: {integrity: sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1114.0': + resolution: {integrity: sha512-ZeAgOtB+CXFaWXph98U7a/XrBlVx1lQ2rCJRWLxLmAaQ9k5lht6DWfwnEcVjdzduK/ySao1tCjq0fBAScGqjAg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.8': + resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.69': + resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.71': + resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.14': + resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.76': + resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.80': + resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.69': + resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.13': + resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.75': + resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.74': + resolution: {integrity: sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.43': + resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1114.0': + resolution: {integrity: sha512-z1HQYonZxSJQj6JxWGTyNzOore/46bQ8ZFNAlZJlVs1Ot5oi4bB9AR409mJJrMH7YNjKaEP7bOx+wDoN/RheNQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.45': + resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1111.0': + resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.4': + resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.39': + resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1151,6 +1301,30 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.11.3': + resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1242,6 +1416,36 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + + '@trpc/client@11.18.0': + resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==} + hasBin: true + peerDependencies: + '@trpc/server': 11.18.0 + typescript: '>=5.7.2' + + '@trpc/react-query@11.18.0': + resolution: {integrity: sha512-C1+Wwm2pCeUJucI+bnFpxGYjNuvV+ko1BC1T9tUxBVdrhHRCdn9ubxdevdLSAa49XRJRJiZnSuzl3Ys/yvs1vg==} + peerDependencies: + '@tanstack/react-query': ^5.80.3 + '@trpc/client': 11.18.0 + '@trpc/server': 11.18.0 + react: '>=18.2.0' + typescript: '>=5.7.2' + + '@trpc/server@11.18.0': + resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} + hasBin: true + peerDependencies: + typescript: '>=5.7.2' + '@turbo/darwin-64@2.10.11': resolution: {integrity: sha512-v3R+1R/Ysozyo+p7Ri8MCIbndOvYt3DgPFrGLhrhQHfvyvbxyH3WyJj+A/2JTNmNleuAlh3JUyCV0iSVHIONTA==} cpu: [x64] @@ -1579,6 +1783,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -1648,6 +1855,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + copy-anything@4.1.0: + resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2832,6 +3043,10 @@ packages: babel-plugin-macros: optional: true + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3042,6 +3257,180 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@aws-sdk/checksums@3.1000.28': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1114.0': + dependencies: + '@aws-sdk/checksums': 3.1000.28 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-node': 3.972.80 + '@aws-sdk/middleware-sdk-s3': 3.972.74 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@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.8.1 + + '@aws-sdk/core@3.977.8': + dependencies: + '@aws-sdk/types': 3.974.4 + '@aws-sdk/xml-builder': 3.972.39 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.3 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@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.8.1 + + '@aws-sdk/credential-provider-ini@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-login': 3.972.76 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.80': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-ini': 3.973.14 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.13': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/token-providers': 3.1111.0 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.43': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@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.8.1 + + '@aws-sdk/s3-request-presigner@3.1114.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.45': + dependencies: + '@aws-sdk/types': 3.974.4 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1111.0': + dependencies: + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.4': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.39': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.10.0': @@ -3684,6 +4073,39 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@smithy/core@3.33.3': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.7.2': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.11.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3757,6 +4179,30 @@ snapshots: postcss: 8.5.26 tailwindcss: 4.3.3 + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + + '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@trpc/server': 11.18.0(typescript@5.9.3) + typescript: 5.9.3 + + '@trpc/react-query@11.18.0(@tanstack/react-query@5.101.4(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.18.0(typescript@5.9.3))(react@19.2.8)(typescript@5.9.3)': + dependencies: + '@tanstack/react-query': 5.101.4(react@19.2.8) + '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) + '@trpc/server': 11.18.0(typescript@5.9.3) + react: 19.2.8 + typescript: 5.9.3 + + '@trpc/server@11.18.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@turbo/darwin-64@2.10.11': optional: true @@ -4105,6 +4551,8 @@ snapshots: balanced-match@4.0.4: {} + bowser@2.14.1: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -4174,6 +4622,8 @@ snapshots: concat-map@0.0.1: {} + copy-anything@4.1.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5576,6 +6026,10 @@ snapshots: client-only: 0.0.1 react: 19.2.8 + superjson@2.2.6: + dependencies: + copy-anything: 4.1.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0