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,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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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<Session | null> => {
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user