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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: 'node', include: ['test/**/*.test.ts'] },
|
||||
});
|
||||
Reference in New Issue
Block a user