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) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-20 14:11:07 -04:00
co-authored by Claude Opus 5
parent 19623bcccb
commit 66dd4ac942
27 changed files with 2255 additions and 3 deletions
+30
View File
@@ -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"
}
}
+74
View File
@@ -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<Session | null>;
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<Context> {
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,
};
}
+19
View File
@@ -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';
+18
View File
@@ -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;
+177
View File
@@ -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 };
}),
});
+100
View File
@@ -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<number>`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 };
}),
});
+270
View File
@@ -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,
};
}),
});
+28
View File
@@ -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;
}
}),
});
+111
View File
@@ -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<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<string, number>();
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 });
});
+351
View File
@@ -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,
);
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'node', include: ['test/**/*.test.ts'] },
});