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:
@@ -7,7 +7,7 @@ loadEnv({ path: '../../.env' });
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// The workspace packages ship TypeScript source, not build output.
|
||||
transpilePackages: ['@linkder/db', '@linkder/shared'],
|
||||
transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: 'picsum.photos' },
|
||||
|
||||
@@ -21,7 +21,14 @@
|
||||
"next": "^15.1.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"@linkder/api": "workspace:*",
|
||||
"@linkder/storage": "workspace:*",
|
||||
"@trpc/server": "^11.18.0",
|
||||
"@trpc/client": "^11.18.0",
|
||||
"@trpc/react-query": "^11.18.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"superjson": "^2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.2.0",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { appRouter, createContext } from '@linkder/api';
|
||||
import { db } from '@linkder/db';
|
||||
import { resolveSession } from '@/server/session';
|
||||
|
||||
/**
|
||||
* The HTTP entry point. A future React Native app talks to exactly this URL with
|
||||
* the same generated client, which is the whole reason the API is tRPC rather
|
||||
* than server actions.
|
||||
*/
|
||||
function handler(req: Request) {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () =>
|
||||
createContext({
|
||||
req,
|
||||
db,
|
||||
resolveSession,
|
||||
ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
|
||||
}),
|
||||
onError({ error, path }) {
|
||||
// Client errors are expected; server errors are ours and must be visible.
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
console.error(`tRPC ${path ?? '<no path>'} failed:`, error.cause ?? error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { TRPCProvider } from '@/lib/trpc';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -24,7 +25,9 @@ export const viewport: Viewport = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-dvh antialiased">{children}</body>
|
||||
<body className="min-h-dvh antialiased">
|
||||
<TRPCProvider>{children}</TRPCProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
|
||||
import { deserialize, serialize } from 'superjson';
|
||||
import type { AppRouter } from '@linkder/api';
|
||||
|
||||
// Explicit annotation: pnpm's strict node_modules layout means the inferred
|
||||
// type cannot be named from here (TS2742).
|
||||
export const api: CreateTRPCReact<AppRouter, unknown> = createTRPCReact<AppRouter>();
|
||||
|
||||
function baseUrl() {
|
||||
if (typeof window !== 'undefined') return '';
|
||||
return process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
|
||||
}
|
||||
|
||||
export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
// superjson has to be configured in THREE places: initTRPC.create,
|
||||
// every httpBatchLink, and here. Miss this one and RSC-prefetched data
|
||||
// arrives as an unwrapped {json, metadata} envelope with no type error.
|
||||
dehydrate: { serializeData: serialize },
|
||||
hydrate: { deserializeData: deserialize },
|
||||
queries: {
|
||||
// The deck is served fresh from the server component; refetching on
|
||||
// every window focus would reshuffle cards under the user's thumb.
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
retry: (failureCount, error) => {
|
||||
// Never retry an authorization failure — it will never succeed.
|
||||
const code = (error as { data?: { code?: string } })?.data?.code;
|
||||
if (code === 'UNAUTHORIZED' || code === 'FORBIDDEN' || code === 'NOT_FOUND') {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
api.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${baseUrl()}/api/trpc`,
|
||||
transformer: { serialize, deserialize },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</api.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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