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
+209
View File
@@ -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<typeof uploadRequestSchema>;
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<string, string> = {
'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<PresignedUpload> {
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<void> {
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;
}