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
+150
View File
@@ -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);
});
});