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 = {
|
const config: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// The workspace packages ship TypeScript source, not build output.
|
// The workspace packages ship TypeScript source, not build output.
|
||||||
transpilePackages: ['@linkder/db', '@linkder/shared'],
|
transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'],
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ protocol: 'https', hostname: 'picsum.photos' },
|
{ protocol: 'https', hostname: 'picsum.photos' },
|
||||||
|
|||||||
@@ -21,7 +21,14 @@
|
|||||||
"next": "^15.1.4",
|
"next": "^15.1.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "3.2.0",
|
"@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 type { Metadata, Viewport } from 'next';
|
||||||
|
import { TRPCProvider } from '@/lib/trpc';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -24,7 +25,9 @@ export const viewport: Viewport = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="min-h-dvh antialiased">{children}</body>
|
<body className="min-h-dvh antialiased">
|
||||||
|
<TRPCProvider>{children}</TRPCProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@linkder/api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./client": "./src/client.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@linkder/db": "workspace:*",
|
||||||
|
"@linkder/shared": "workspace:*",
|
||||||
|
"@trpc/server": "^11.18.0",
|
||||||
|
"drizzle-orm": "0.38.4",
|
||||||
|
"superjson": "^2.2.6",
|
||||||
|
"zod": "^3.24.1",
|
||||||
|
"@linkder/storage": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv": "16.4.7",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Db } from '@linkder/db';
|
||||||
|
import type { Role, VerificationStatus } from '@linkder/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session shape the API depends on.
|
||||||
|
*
|
||||||
|
* This is deliberately OUR type, not one re-exported from an auth library.
|
||||||
|
* Everything downstream — every procedure, every authorization check — is written
|
||||||
|
* against this interface, so swapping the auth provider is a matter of writing a
|
||||||
|
* new `SessionResolver` rather than touching a single router.
|
||||||
|
*/
|
||||||
|
export interface Session {
|
||||||
|
userId: string;
|
||||||
|
role: Role;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
/** Only present for pros. Gates the procedures that require a live, verified pro. */
|
||||||
|
verificationStatus: VerificationStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a session from an incoming request.
|
||||||
|
*
|
||||||
|
* The web app implements this with its auth library's server-side helper; tests
|
||||||
|
* implement it by returning a fixed session. It must return `null` rather than
|
||||||
|
* throw when the caller is anonymous — an unauthenticated request is normal.
|
||||||
|
*/
|
||||||
|
export type SessionResolver = (req: Request) => Promise<Session | null>;
|
||||||
|
|
||||||
|
export interface CreateContextOptions {
|
||||||
|
req: Request;
|
||||||
|
db: Db;
|
||||||
|
resolveSession: SessionResolver;
|
||||||
|
/** Caller IP, for rate limiting and the audit log. */
|
||||||
|
ip?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Context {
|
||||||
|
db: Db;
|
||||||
|
session: Session | null;
|
||||||
|
req: Request;
|
||||||
|
ip: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContext(opts: CreateContextOptions): Promise<Context> {
|
||||||
|
const session = await opts.resolveSession(opts.req);
|
||||||
|
return {
|
||||||
|
db: opts.db,
|
||||||
|
session,
|
||||||
|
req: opts.req,
|
||||||
|
ip: opts.ip ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for a server-side call with no HTTP request — a React Server Component
|
||||||
|
* calling the router directly, or a background worker. Skips the resolver and
|
||||||
|
* takes the session (or null) as given.
|
||||||
|
*/
|
||||||
|
export function createInnerContext(opts: {
|
||||||
|
db: Db;
|
||||||
|
session: Session | null;
|
||||||
|
ip?: string | null;
|
||||||
|
}): Context {
|
||||||
|
return {
|
||||||
|
db: opts.db,
|
||||||
|
session: opts.session,
|
||||||
|
// Routers must never depend on `req` for anything but header access; this
|
||||||
|
// placeholder keeps the type honest for direct server-side calls.
|
||||||
|
req: new Request('http://internal.invalid/rsc'),
|
||||||
|
ip: opts.ip ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export { appRouter, type AppRouter } from './root';
|
||||||
|
export {
|
||||||
|
createContext,
|
||||||
|
createInnerContext,
|
||||||
|
type Context,
|
||||||
|
type CreateContextOptions,
|
||||||
|
type Session,
|
||||||
|
type SessionResolver,
|
||||||
|
} from './context';
|
||||||
|
export {
|
||||||
|
createCallerFactory,
|
||||||
|
router,
|
||||||
|
publicProcedure,
|
||||||
|
protectedProcedure,
|
||||||
|
clientProcedure,
|
||||||
|
proProcedure,
|
||||||
|
verifiedProProcedure,
|
||||||
|
adminProcedure,
|
||||||
|
} from './trpc';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { router } from './trpc';
|
||||||
|
import { deckRouter } from './routers/deck';
|
||||||
|
import { jobRouter } from './routers/job';
|
||||||
|
import { proRouter } from './routers/pro';
|
||||||
|
import { uploadRouter } from './routers/upload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API surface. A future React Native app imports `AppRouter` from this
|
||||||
|
* package and gets the entire typed contract with no duplication.
|
||||||
|
*/
|
||||||
|
export const appRouter = router({
|
||||||
|
job: jobRouter,
|
||||||
|
deck: deckRouter,
|
||||||
|
pro: proRouter,
|
||||||
|
upload: uploadRouter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { and, count, eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getDeck, getDeckCount, schema } from '@linkder/db';
|
||||||
|
import {
|
||||||
|
DECK_PAGE_SIZE,
|
||||||
|
MAX_OPEN_REQUESTS_PER_JOB,
|
||||||
|
REQUEST_TTL_HOURS,
|
||||||
|
swipeSchema,
|
||||||
|
} from '@linkder/shared';
|
||||||
|
import { clientProcedure, router } from '../trpc';
|
||||||
|
import type { Context } from '../context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a job and proves the caller is allowed to act on it.
|
||||||
|
*
|
||||||
|
* Every deck operation goes through this. The previous implementation was a Next
|
||||||
|
* server action that took a jobId and trusted it, which meant anyone could swipe
|
||||||
|
* on anyone else's job — and, once payments land, spend against it.
|
||||||
|
*/
|
||||||
|
async function requireOwnedJob(ctx: Context, jobId: string) {
|
||||||
|
const job = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.id, jobId) });
|
||||||
|
if (!job) throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||||
|
|
||||||
|
const session = ctx.session;
|
||||||
|
if (!session) throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
|
|
||||||
|
if (job.clientId !== session.userId && session.role !== 'admin') {
|
||||||
|
// 404 rather than 403: a stranger should not be able to confirm the job exists.
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deckRouter = router({
|
||||||
|
/** The next cards for one of the caller's own jobs. */
|
||||||
|
list: clientProcedure
|
||||||
|
.input(z.object({ jobId: z.string().uuid(), limit: z.number().int().min(1).max(50).optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
await requireOwnedJob(ctx, input.jobId);
|
||||||
|
const [cards, remaining] = await Promise.all([
|
||||||
|
getDeck(ctx.db, { jobId: input.jobId, limit: input.limit ?? DECK_PAGE_SIZE }),
|
||||||
|
getDeckCount(ctx.db, input.jobId),
|
||||||
|
]);
|
||||||
|
return { cards, remaining };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a swipe.
|
||||||
|
*
|
||||||
|
* Left is a tombstone that keeps the pro off this job's deck. Right also sends
|
||||||
|
* the job to the pro as a pending request, subject to the open-request cap —
|
||||||
|
* the cap is what stops one client spraying every plumber in the city and
|
||||||
|
* burning the supply side's goodwill.
|
||||||
|
*/
|
||||||
|
swipe: clientProcedure.input(swipeSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
const job = await requireOwnedJob(ctx, input.jobId);
|
||||||
|
|
||||||
|
if (job.status !== 'open' && job.status !== 'matched') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'This job is no longer taking offers',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client cannot send their own job to themselves.
|
||||||
|
if (input.proId === ctx.session.userId) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot hire yourself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pro must exist, be verified and be open for work. Checking here as well
|
||||||
|
// as in the deck query stops a crafted request from reaching an unverified pro.
|
||||||
|
const pro = await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: eq(schema.proProfiles.userId, input.proId),
|
||||||
|
});
|
||||||
|
if (!pro || pro.verificationStatus !== 'verified' || !pro.isAcceptingJobs) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'That pro is not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A left swipe is just a tombstone — no cap, no contention, no transaction.
|
||||||
|
if (input.direction === 'left') {
|
||||||
|
await ctx.db
|
||||||
|
.insert(schema.swipes)
|
||||||
|
.values({ jobId: input.jobId, proId: input.proId, direction: 'left' })
|
||||||
|
.onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] });
|
||||||
|
return { requested: false as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlHours = REQUEST_TTL_HOURS[job.urgency];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A right swipe has to be atomic.
|
||||||
|
*
|
||||||
|
* Counting pending requests and then inserting one is a read-then-write race:
|
||||||
|
* two swipes landing together both read 4, both insert, and the client ends up
|
||||||
|
* with 6 pros on a job capped at 5. Locking the job row serialises every swipe
|
||||||
|
* on that job, which is the correct granularity — swipes on different jobs
|
||||||
|
* never block each other.
|
||||||
|
*
|
||||||
|
* The cap is checked BEFORE the tombstone is written, so a rejected swipe
|
||||||
|
* leaves no trace and the card legitimately stays on the deck for a retry.
|
||||||
|
* (Writing the tombstone first and rolling back would make a dismissed card
|
||||||
|
* reappear, which contradicts how the deck client is meant to behave.)
|
||||||
|
*/
|
||||||
|
return await ctx.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.select({ id: schema.jobs.id })
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, input.jobId))
|
||||||
|
.for('update');
|
||||||
|
|
||||||
|
const [open] = await tx
|
||||||
|
.select({ n: count() })
|
||||||
|
.from(schema.requests)
|
||||||
|
.where(
|
||||||
|
and(eq(schema.requests.jobId, input.jobId), eq(schema.requests.status, 'pending')),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS_PER_JOB) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: `You already have ${MAX_OPEN_REQUESTS_PER_JOB} pros considering this job. Wait for one to reply before sending more.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(schema.swipes)
|
||||||
|
.values({ jobId: input.jobId, proId: input.proId, direction: 'right' })
|
||||||
|
.onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] });
|
||||||
|
|
||||||
|
const [request] = await tx
|
||||||
|
.insert(schema.requests)
|
||||||
|
.values({
|
||||||
|
jobId: input.jobId,
|
||||||
|
proId: input.proId,
|
||||||
|
expiresAt: new Date(Date.now() + ttlHours * 3_600_000),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// TODO(M3): enqueue a notification to the pro (web push + email) via BullMQ.
|
||||||
|
|
||||||
|
return {
|
||||||
|
requested: true as const,
|
||||||
|
requestId: request?.id ?? null,
|
||||||
|
expiresInHours: ttlHours,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Undo the last swipe on a job, as long as it has not become a live request. */
|
||||||
|
undo: clientProcedure
|
||||||
|
.input(z.object({ jobId: z.string().uuid(), proId: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await requireOwnedJob(ctx, input.jobId);
|
||||||
|
|
||||||
|
const existing = await ctx.db.query.requests.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.requests.jobId, input.jobId),
|
||||||
|
eq(schema.requests.proId, input.proId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'That pro has already been sent this job, so it cannot be undone',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.delete(schema.swipes)
|
||||||
|
.where(
|
||||||
|
and(eq(schema.swipes.jobId, input.jobId), eq(schema.swipes.proId, input.proId)),
|
||||||
|
);
|
||||||
|
return { undone: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { schema } from '@linkder/db';
|
||||||
|
import { assertTransition, createJobSchema } from '@linkder/shared';
|
||||||
|
import { clientProcedure, publicProcedure, router } from '../trpc';
|
||||||
|
|
||||||
|
export const jobRouter = router({
|
||||||
|
categories: publicProcedure.query(({ ctx }) =>
|
||||||
|
ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.categories)
|
||||||
|
.where(eq(schema.categories.isActive, true))
|
||||||
|
.orderBy(schema.categories.position),
|
||||||
|
),
|
||||||
|
|
||||||
|
create: clientProcedure.input(createJobSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
const category = await ctx.db.query.categories.findFirst({
|
||||||
|
where: eq(schema.categories.id, input.categoryId),
|
||||||
|
});
|
||||||
|
if (!category || !category.isActive) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown trade' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [job] = await ctx.db
|
||||||
|
.insert(schema.jobs)
|
||||||
|
.values({
|
||||||
|
clientId: ctx.session.userId,
|
||||||
|
categoryId: input.categoryId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
photos: input.photos,
|
||||||
|
urgency: input.urgency,
|
||||||
|
budgetMinCents: input.budgetMinCents ?? null,
|
||||||
|
budgetMaxCents: input.budgetMaxCents ?? null,
|
||||||
|
location: input.location,
|
||||||
|
addressText: input.addressText,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!job) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Could not post job' });
|
||||||
|
return job;
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** The caller's own jobs, newest first. */
|
||||||
|
mine: clientProcedure.query(({ ctx }) =>
|
||||||
|
ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.clientId, ctx.session.userId))
|
||||||
|
.orderBy(desc(schema.jobs.createdAt)),
|
||||||
|
),
|
||||||
|
|
||||||
|
byId: clientProcedure.input(z.object({ id: z.string().uuid() })).query(async ({ ctx, input }) => {
|
||||||
|
const job = await ctx.db.query.jobs.findFirst({
|
||||||
|
where: eq(schema.jobs.id, input.id),
|
||||||
|
with: { category: true },
|
||||||
|
});
|
||||||
|
// 404 for someone else's job — never confirm it exists.
|
||||||
|
if (!job || (job.clientId !== ctx.session.userId && ctx.session.role !== 'admin')) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pending] = await ctx.db
|
||||||
|
.select({ n: sql<number>`count(*)::int` })
|
||||||
|
.from(schema.requests)
|
||||||
|
.where(and(eq(schema.requests.jobId, job.id), eq(schema.requests.status, 'pending')));
|
||||||
|
|
||||||
|
return { ...job, pendingRequests: pending?.n ?? 0 };
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancel: clientProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid(), reason: z.string().max(500).optional() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const job = await ctx.db.query.jobs.findFirst({ where: eq(schema.jobs.id, input.id) });
|
||||||
|
if (!job || (job.clientId !== ctx.session.userId && ctx.session.role !== 'admin')) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throws if the job is already completed or cancelled.
|
||||||
|
assertTransition('job', job.status, 'cancelled');
|
||||||
|
|
||||||
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(schema.jobs)
|
||||||
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||||
|
.where(eq(schema.jobs.id, job.id));
|
||||||
|
|
||||||
|
// Pros should stop seeing a job nobody can win.
|
||||||
|
await tx
|
||||||
|
.update(schema.requests)
|
||||||
|
.set({ status: 'expired', respondedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(eq(schema.requests.jobId, job.id), eq(schema.requests.status, 'pending')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cancelled: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { schema } from '@linkder/db';
|
||||||
|
import { assertTransition, credentialSchema, proProfileSchema } from '@linkder/shared';
|
||||||
|
import { protectedProcedure, proProcedure, router } from '../trpc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pro is only shown to clients once verification passes. These procedures
|
||||||
|
* cover everything up to that point: building the profile, uploading documents,
|
||||||
|
* and submitting for review. None of them require `verified` — that would make
|
||||||
|
* onboarding impossible.
|
||||||
|
*/
|
||||||
|
export const proRouter = router({
|
||||||
|
/** The caller's own pro profile, with everything the onboarding wizard needs. */
|
||||||
|
me: proProcedure.query(async ({ ctx }) => {
|
||||||
|
const profile = await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||||
|
});
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
const [categories, media, credentials] = await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.select({ categoryId: schema.proCategories.categoryId })
|
||||||
|
.from(schema.proCategories)
|
||||||
|
.where(eq(schema.proCategories.proId, ctx.session.userId)),
|
||||||
|
ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.proMedia)
|
||||||
|
.where(eq(schema.proMedia.proId, ctx.session.userId))
|
||||||
|
.orderBy(schema.proMedia.position),
|
||||||
|
ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.credentials)
|
||||||
|
.where(eq(schema.credentials.proId, ctx.session.userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
categoryIds: categories.map((c) => c.categoryId),
|
||||||
|
media,
|
||||||
|
credentials,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or replace the profile. Idempotent so the wizard can save on every
|
||||||
|
* step without the client tracking whether a row exists yet.
|
||||||
|
*/
|
||||||
|
upsertProfile: proProcedure.input(proProfileSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
const validCategories = await ctx.db
|
||||||
|
.select({ id: schema.categories.id })
|
||||||
|
.from(schema.categories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.categories.id, input.categoryIds),
|
||||||
|
eq(schema.categories.isActive, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (validCategories.length !== input.categoryIds.length) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'One of those trades is not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Editing a live profile sends it back for review — a verified plumber must
|
||||||
|
// not be able to quietly become an unverified electrician.
|
||||||
|
const materiallyChanged =
|
||||||
|
existing &&
|
||||||
|
(existing.serviceRadiusM !== input.serviceRadiusM ||
|
||||||
|
existing.baseLocation.lat !== input.location.lat ||
|
||||||
|
existing.baseLocation.lng !== input.location.lng);
|
||||||
|
|
||||||
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
const values = {
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
headline: input.headline,
|
||||||
|
bio: input.bio,
|
||||||
|
hourlyRateCents: input.hourlyRateCents,
|
||||||
|
yearsExperience: input.yearsExperience,
|
||||||
|
baseLocation: input.location,
|
||||||
|
serviceRadiusM: input.serviceRadiusM,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(schema.proProfiles)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({ target: schema.proProfiles.userId, set: values });
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.delete(schema.proCategories)
|
||||||
|
.where(eq(schema.proCategories.proId, ctx.session.userId));
|
||||||
|
await tx.insert(schema.proCategories).values(
|
||||||
|
input.categoryIds.map((categoryId) => ({ proId: ctx.session.userId, categoryId })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { saved: true, requiresReReview: Boolean(materiallyChanged) };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Attach an uploaded photo. The file itself went straight to R2. */
|
||||||
|
addMedia: proProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
kind: z.enum(['photo', 'work_sample']).default('photo'),
|
||||||
|
position: z.number().int().min(0).max(20).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.proMedia)
|
||||||
|
.where(eq(schema.proMedia.proId, ctx.session.userId));
|
||||||
|
if (existing.length >= 10) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You can have at most 10 photos' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [media] = await ctx.db
|
||||||
|
.insert(schema.proMedia)
|
||||||
|
.values({
|
||||||
|
proId: ctx.session.userId,
|
||||||
|
url: input.url,
|
||||||
|
kind: input.kind,
|
||||||
|
position: input.position ?? existing.length,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return media;
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeMedia: proProcedure
|
||||||
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Scoped to the caller so one pro cannot delete another's photo.
|
||||||
|
const deleted = await ctx.db
|
||||||
|
.delete(schema.proMedia)
|
||||||
|
.where(and(eq(schema.proMedia.id, input.id), eq(schema.proMedia.proId, ctx.session.userId)))
|
||||||
|
.returning();
|
||||||
|
if (deleted.length === 0) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||||
|
return { removed: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Upload a licence, insurance certificate or ID document for review. */
|
||||||
|
addCredential: proProcedure.input(credentialSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
const [credential] = await ctx.db
|
||||||
|
.insert(schema.credentials)
|
||||||
|
.values({
|
||||||
|
proId: ctx.session.userId,
|
||||||
|
kind: input.kind,
|
||||||
|
fileUrl: input.fileUrl,
|
||||||
|
issuer: input.issuer ?? null,
|
||||||
|
expiresAt: input.expiresAt ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return credential;
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit for review. Deliberately strict about what "complete" means — a
|
||||||
|
* half-finished profile reaching the admin queue wastes a reviewer's time,
|
||||||
|
* and the reviewer is the expensive part of this pipeline.
|
||||||
|
*/
|
||||||
|
submitForReview: proProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const profile = await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||||
|
});
|
||||||
|
if (!profile) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Fill in your profile first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [categories, media, credentials] = await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.proCategories)
|
||||||
|
.where(eq(schema.proCategories.proId, ctx.session.userId)),
|
||||||
|
ctx.db.select().from(schema.proMedia).where(eq(schema.proMedia.proId, ctx.session.userId)),
|
||||||
|
ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.credentials)
|
||||||
|
.where(eq(schema.credentials.proId, ctx.session.userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (categories.length === 0) missing.push('at least one trade');
|
||||||
|
if (media.length === 0) missing.push('at least one photo');
|
||||||
|
if (!credentials.some((c) => c.kind === 'id')) missing.push('a photo ID');
|
||||||
|
if (!credentials.some((c) => c.kind === 'insurance')) missing.push('proof of insurance');
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: `Before we can review your account we still need: ${missing.join(', ')}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTransition('verification', profile.verificationStatus, 'pending');
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(schema.proProfiles)
|
||||||
|
.set({ verificationStatus: 'pending', updatedAt: new Date() })
|
||||||
|
.where(eq(schema.proProfiles.userId, ctx.session.userId));
|
||||||
|
|
||||||
|
await ctx.db.insert(schema.auditLog).values({
|
||||||
|
actorId: ctx.session.userId,
|
||||||
|
action: 'verification.submitted',
|
||||||
|
entity: 'pro_profile',
|
||||||
|
entityId: ctx.session.userId,
|
||||||
|
ip: ctx.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO(M2): kick off the Didit identity session and notify the admin queue.
|
||||||
|
|
||||||
|
return { status: 'pending' as const };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Holiday mode. Keeps a verified pro off the deck without unverifying them. */
|
||||||
|
setAcceptingJobs: proProcedure
|
||||||
|
.input(z.object({ accepting: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db
|
||||||
|
.update(schema.proProfiles)
|
||||||
|
.set({ isAcceptingJobs: input.accepting, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.proProfiles.userId, ctx.session.userId));
|
||||||
|
return { accepting: input.accepting };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public profile, for the card detail view. Only ever returns a verified pro,
|
||||||
|
* and deliberately omits anything private — no phone, no documents, no address.
|
||||||
|
*/
|
||||||
|
publicProfile: protectedProcedure
|
||||||
|
.input(z.object({ proId: z.string().uuid() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const profile = await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.proProfiles.userId, input.proId),
|
||||||
|
eq(schema.proProfiles.verificationStatus, 'verified'),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!profile) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||||
|
|
||||||
|
const [user] = await ctx.db
|
||||||
|
.select({ name: schema.users.name, image: schema.users.image })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, input.proId));
|
||||||
|
|
||||||
|
const media = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(schema.proMedia)
|
||||||
|
.where(eq(schema.proMedia.proId, input.proId))
|
||||||
|
.orderBy(schema.proMedia.position);
|
||||||
|
|
||||||
|
return {
|
||||||
|
proId: profile.userId,
|
||||||
|
name: user?.name ?? null,
|
||||||
|
image: user?.image ?? null,
|
||||||
|
headline: profile.headline,
|
||||||
|
bio: profile.bio,
|
||||||
|
hourlyRateCents: profile.hourlyRateCents,
|
||||||
|
yearsExperience: profile.yearsExperience,
|
||||||
|
ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg),
|
||||||
|
ratingCount: profile.ratingCount,
|
||||||
|
completedJobs: profile.completedJobs,
|
||||||
|
media,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkder/storage';
|
||||||
|
import { protectedProcedure, router } from '../trpc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands out short-lived presigned PUTs so the browser uploads straight to R2.
|
||||||
|
*
|
||||||
|
* The server picks the key, so a caller can only ever write under their own
|
||||||
|
* user id — they cannot overwrite someone else's document by guessing a path.
|
||||||
|
*/
|
||||||
|
export const uploadRouter = router({
|
||||||
|
presign: protectedProcedure.input(uploadRequestSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
try {
|
||||||
|
const upload = await createPresignedUpload({ ...input, ownerId: ctx.session.userId });
|
||||||
|
return {
|
||||||
|
...upload,
|
||||||
|
// Credentials are private; the caller stores the key and admins read it
|
||||||
|
// through a signed GET rather than a public URL.
|
||||||
|
publicUrl: input.kind === 'credential' ? null : publicUrl(upload.key),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StorageError) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { TRPCError, initTRPC } from '@trpc/server';
|
||||||
|
import superjson from 'superjson';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { schema } from '@linkder/db';
|
||||||
|
import type { Context } from './context';
|
||||||
|
|
||||||
|
const t = initTRPC.context<Context>().create({
|
||||||
|
// Our schema is full of timestamps; without superjson every Date arrives as a
|
||||||
|
// string and every consumer has to remember to re-hydrate it.
|
||||||
|
transformer: superjson,
|
||||||
|
errorFormatter({ shape, error }) {
|
||||||
|
return {
|
||||||
|
...shape,
|
||||||
|
data: {
|
||||||
|
...shape.data,
|
||||||
|
// Surface field-level validation errors so forms can attach them to inputs.
|
||||||
|
zod: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = t.router;
|
||||||
|
export const middleware = t.middleware;
|
||||||
|
export const mergeRouters = t.mergeRouters;
|
||||||
|
export const createCallerFactory = t.createCallerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the caller as active. Deck ranking uses `lastActiveAt` — a dormant pro
|
||||||
|
* ranks lower — so it has to actually be maintained.
|
||||||
|
*
|
||||||
|
* Fire-and-forget: a failed heartbeat must never fail the request it rode in on.
|
||||||
|
* Throttled to once an hour to avoid a write on every single call.
|
||||||
|
*/
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 3_600_000;
|
||||||
|
const lastHeartbeat = new Map<string, number>();
|
||||||
|
|
||||||
|
const withHeartbeat = middleware(async ({ ctx, next }) => {
|
||||||
|
const userId = ctx.session?.userId;
|
||||||
|
if (userId) {
|
||||||
|
const now = Date.now();
|
||||||
|
const previous = lastHeartbeat.get(userId) ?? 0;
|
||||||
|
if (now - previous > HEARTBEAT_INTERVAL_MS) {
|
||||||
|
lastHeartbeat.set(userId, now);
|
||||||
|
void ctx.db
|
||||||
|
.update(schema.users)
|
||||||
|
.set({ lastActiveAt: new Date() })
|
||||||
|
.where(eq(schema.users.id, userId))
|
||||||
|
.catch(() => {
|
||||||
|
// Best effort. Losing a heartbeat costs a little ranking accuracy, nothing more.
|
||||||
|
lastHeartbeat.delete(userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Open to anyone, signed in or not. */
|
||||||
|
export const publicProcedure = t.procedure.use(withHeartbeat);
|
||||||
|
|
||||||
|
/** Requires a signed-in, non-banned user. */
|
||||||
|
export const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
|
||||||
|
if (!ctx.session) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be signed in' });
|
||||||
|
}
|
||||||
|
return next({ ctx: { ...ctx, session: ctx.session } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Requires a client account. Pros cannot post jobs to themselves. */
|
||||||
|
export const clientProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||||
|
if (ctx.session.role !== 'client' && ctx.session.role !== 'admin') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only clients can do this' });
|
||||||
|
}
|
||||||
|
return next({ ctx });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires a pro account. Does NOT require verification — onboarding and
|
||||||
|
* document upload have to work before a pro is verified.
|
||||||
|
*/
|
||||||
|
export const proProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||||
|
if (ctx.session.role !== 'pro' && ctx.session.role !== 'admin') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only professionals can do this' });
|
||||||
|
}
|
||||||
|
return next({ ctx });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires a pro who has actually passed verification.
|
||||||
|
*
|
||||||
|
* This is the gate on anything that touches a real job: accepting a request,
|
||||||
|
* quoting, being paid. An unverified or suspended pro must not get past here.
|
||||||
|
*/
|
||||||
|
export const verifiedProProcedure = proProcedure.use(({ ctx, next }) => {
|
||||||
|
if (ctx.session.role === 'admin') return next({ ctx });
|
||||||
|
if (ctx.session.verificationStatus !== 'verified') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Your account is still being verified. We will email you as soon as it is approved.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ ctx });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||||
|
if (ctx.session.role !== 'admin') {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND' }); // Do not reveal that admin routes exist.
|
||||||
|
}
|
||||||
|
return next({ ctx });
|
||||||
|
});
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for the deck router, run against the live seeded database.
|
||||||
|
*
|
||||||
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||||
|
* pnpm --filter @linkder/api test
|
||||||
|
*
|
||||||
|
* The point of these is authorization. The swipe path previously lived in a Next
|
||||||
|
* server action that trusted whatever jobId it was handed, so anyone could swipe
|
||||||
|
* on anyone else's job. Most of what follows exists to prove that is now closed.
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkder/shared';
|
||||||
|
|
||||||
|
config({ path: '../../.env' });
|
||||||
|
|
||||||
|
const { closePool, db } = await import('@linkder/db');
|
||||||
|
const { appRouter } = await import('../src/root');
|
||||||
|
const { createInnerContext } = await import('../src/context');
|
||||||
|
const { createCallerFactory } = await import('../src/trpc');
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(appRouter);
|
||||||
|
|
||||||
|
type Session = import('../src/context').Session;
|
||||||
|
|
||||||
|
function callerFor(session: Session | null) {
|
||||||
|
return createCaller(createInnerContext({ db, session }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSession = (userId: string): Session => ({
|
||||||
|
userId,
|
||||||
|
role: 'client',
|
||||||
|
name: 'Test Client',
|
||||||
|
email: 'client@test',
|
||||||
|
phone: null,
|
||||||
|
verificationStatus: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let jobId: string;
|
||||||
|
let ownerId: string;
|
||||||
|
let strangerId: string;
|
||||||
|
let adminId: string;
|
||||||
|
let verifiedProId: string;
|
||||||
|
let unverifiedProId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const jobs = await db.execute<{ id: string; client_id: string }>(
|
||||||
|
sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`,
|
||||||
|
);
|
||||||
|
const job = jobs[0];
|
||||||
|
if (!job) throw new Error('No seeded job — run `pnpm db:seed`');
|
||||||
|
jobId = job.id;
|
||||||
|
ownerId = job.client_id;
|
||||||
|
|
||||||
|
const others = await db.execute<{ id: string }>(
|
||||||
|
sql`SELECT id FROM users WHERE role = 'client' AND id <> ${ownerId} LIMIT 1`,
|
||||||
|
);
|
||||||
|
strangerId = others[0]!.id;
|
||||||
|
|
||||||
|
const admins = await db.execute<{ id: string }>(
|
||||||
|
sql`SELECT id FROM users WHERE role = 'admin' LIMIT 1`,
|
||||||
|
);
|
||||||
|
adminId = admins[0]!.id;
|
||||||
|
|
||||||
|
const verified = await db.execute<{ id: string }>(sql`
|
||||||
|
SELECT p.user_id AS id FROM pro_profiles p
|
||||||
|
JOIN pro_categories pc ON pc.pro_id = p.user_id
|
||||||
|
JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId}
|
||||||
|
WHERE p.verification_status = 'verified' AND p.is_accepting_jobs = true
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
verifiedProId = verified[0]!.id;
|
||||||
|
|
||||||
|
const unverified = await db.execute<{ id: string }>(
|
||||||
|
sql`SELECT user_id AS id FROM pro_profiles WHERE verification_status <> 'verified' LIMIT 1`,
|
||||||
|
);
|
||||||
|
unverifiedProId = unverified[0]!.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`);
|
||||||
|
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`);
|
||||||
|
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`);
|
||||||
|
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`);
|
||||||
|
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
||||||
|
await closePool();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authorization — the reason this router exists', () => {
|
||||||
|
it('refuses an anonymous caller', async () => {
|
||||||
|
await expect(callerFor(null).deck.list({ jobId })).rejects.toThrow(/signed in/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to show a stranger a deck that is not theirs', async () => {
|
||||||
|
await expect(callerFor(clientSession(strangerId)).deck.list({ jobId })).rejects.toThrow(
|
||||||
|
/not found/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a swipe on a job the caller does not own', async () => {
|
||||||
|
await expect(
|
||||||
|
callerFor(clientSession(strangerId)).deck.swipe({
|
||||||
|
jobId,
|
||||||
|
proId: verifiedProId,
|
||||||
|
direction: 'right',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the existence of the job rather than admitting 403', async () => {
|
||||||
|
// A stranger must not be able to distinguish "exists but forbidden" from "no such job".
|
||||||
|
const real = await callerFor(clientSession(strangerId))
|
||||||
|
.deck.list({ jobId })
|
||||||
|
.catch((e: Error) => e.message);
|
||||||
|
const fake = await callerFor(clientSession(strangerId))
|
||||||
|
.deck.list({ jobId: '00000000-0000-4000-8000-000000000000' })
|
||||||
|
.catch((e: Error) => e.message);
|
||||||
|
expect(real).toBe(fake);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the owner through', async () => {
|
||||||
|
const result = await callerFor(clientSession(ownerId)).deck.list({ jobId });
|
||||||
|
expect(result.cards.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin through', async () => {
|
||||||
|
const admin: Session = { ...clientSession(adminId), role: 'admin' };
|
||||||
|
const result = await callerFor(admin).deck.list({ jobId });
|
||||||
|
expect(result.cards.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a pro trying to browse a deck', async () => {
|
||||||
|
const proSession: Session = {
|
||||||
|
...clientSession(verifiedProId),
|
||||||
|
role: 'pro',
|
||||||
|
verificationStatus: 'verified',
|
||||||
|
};
|
||||||
|
await expect(callerFor(proSession).deck.list({ jobId })).rejects.toThrow(/only clients/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swipe', () => {
|
||||||
|
it('records a left swipe without creating a request', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
const result = await caller.deck.swipe({
|
||||||
|
jobId,
|
||||||
|
proId: verifiedProId,
|
||||||
|
direction: 'left',
|
||||||
|
});
|
||||||
|
expect(result.requested).toBe(false);
|
||||||
|
|
||||||
|
const rows = await db.execute<{ n: number }>(
|
||||||
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId}`,
|
||||||
|
);
|
||||||
|
expect(rows[0]!.n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a pending request on a right swipe', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
const result = await caller.deck.swipe({
|
||||||
|
jobId,
|
||||||
|
proId: verifiedProId,
|
||||||
|
direction: 'right',
|
||||||
|
});
|
||||||
|
expect(result.requested).toBe(true);
|
||||||
|
// Narrow the discriminated union before reading the right-swipe fields.
|
||||||
|
if (!result.requested) throw new Error('expected a request to be created');
|
||||||
|
expect(result.requestId).toBeTruthy();
|
||||||
|
// The seeded job is urgency 'now', so a 12h TTL.
|
||||||
|
expect(result.expiresInHours).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the pro from the deck once swiped', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
const before = await caller.deck.list({ jobId });
|
||||||
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' });
|
||||||
|
const after = await caller.deck.list({ jobId });
|
||||||
|
|
||||||
|
expect(after.cards.map((c) => c.proId)).not.toContain(verifiedProId);
|
||||||
|
expect(after.remaining).toBe(before.remaining - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — a double-tap does not create two requests', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
||||||
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
||||||
|
|
||||||
|
const rows = await db.execute<{ n: number }>(
|
||||||
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND pro_id = ${verifiedProId}`,
|
||||||
|
);
|
||||||
|
expect(rows[0]!.n).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to send a job to an unverified pro even if asked directly', async () => {
|
||||||
|
await expect(
|
||||||
|
callerFor(clientSession(ownerId)).deck.swipe({
|
||||||
|
jobId,
|
||||||
|
proId: unverifiedProId,
|
||||||
|
direction: 'right',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/not available/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the open-request cap', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let capped = false;
|
||||||
|
for (const card of cards) {
|
||||||
|
try {
|
||||||
|
await caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' });
|
||||||
|
sent++;
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as Error).message).toMatch(/already have/i);
|
||||||
|
capped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seed has 5 eligible plumbers and the cap is 5, so the cap only trips if
|
||||||
|
// there are more candidates than the cap. Assert whichever case applies.
|
||||||
|
if (capped) {
|
||||||
|
expect(sent).toBe(5);
|
||||||
|
} else {
|
||||||
|
expect(sent).toBeLessThanOrEqual(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the cap under concurrent swipes', async () => {
|
||||||
|
// Regression: counting pending requests and then inserting one is a
|
||||||
|
// read-then-write race. Fired in parallel, the unguarded version let more
|
||||||
|
// than MAX_OPEN_REQUESTS_PER_JOB through.
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
||||||
|
|
||||||
|
const outcomes = await Promise.allSettled(
|
||||||
|
cards.map((card) =>
|
||||||
|
caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = outcomes.filter((o) => o.status === 'fulfilled').length;
|
||||||
|
const [row] = await db.execute<{ n: number }>(
|
||||||
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND status = 'pending'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(row!.n).toBeLessThanOrEqual(MAX_OPEN_REQUESTS_PER_JOB);
|
||||||
|
expect(row!.n).toBe(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no swipe tombstone when the cap rejects the request', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
||||||
|
if (cards.length <= MAX_OPEN_REQUESTS_PER_JOB) return; // not enough supply to trip the cap
|
||||||
|
|
||||||
|
for (let i = 0; i < MAX_OPEN_REQUESTS_PER_JOB; i++) {
|
||||||
|
await caller.deck.swipe({ jobId, proId: cards[i]!.proId, direction: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const overflow = cards[MAX_OPEN_REQUESTS_PER_JOB]!;
|
||||||
|
await expect(
|
||||||
|
caller.deck.swipe({ jobId, proId: overflow.proId, direction: 'right' }),
|
||||||
|
).rejects.toThrow(/already have/i);
|
||||||
|
|
||||||
|
// The rejected pro must still be on the deck — nothing was written for them.
|
||||||
|
const [tombstone] = await db.execute<{ n: number }>(
|
||||||
|
sql`SELECT count(*)::int AS n FROM swipes WHERE job_id = ${jobId} AND pro_id = ${overflow.proId}`,
|
||||||
|
);
|
||||||
|
expect(tombstone!.n).toBe(0);
|
||||||
|
|
||||||
|
const after = await caller.deck.list({ jobId, limit: 50 });
|
||||||
|
expect(after.cards.map((c) => c.proId)).toContain(overflow.proId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a swipe on a cancelled job', async () => {
|
||||||
|
await db.execute(sql`UPDATE jobs SET status = 'cancelled' WHERE id = ${jobId}`);
|
||||||
|
await expect(
|
||||||
|
callerFor(clientSession(ownerId)).deck.swipe({
|
||||||
|
jobId,
|
||||||
|
proId: verifiedProId,
|
||||||
|
direction: 'right',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/no longer taking offers/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed proId before touching the database', async () => {
|
||||||
|
await expect(
|
||||||
|
callerFor(clientSession(ownerId)).deck.swipe({
|
||||||
|
jobId,
|
||||||
|
proId: 'not-a-uuid',
|
||||||
|
direction: 'right',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undo', () => {
|
||||||
|
it('puts a passed pro back on the deck', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' });
|
||||||
|
expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).not.toContain(
|
||||||
|
verifiedProId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await caller.deck.undo({ jobId, proId: verifiedProId });
|
||||||
|
expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).toContain(verifiedProId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to undo a swipe that already reached the pro', async () => {
|
||||||
|
const caller = callerFor(clientSession(ownerId));
|
||||||
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
||||||
|
await expect(caller.deck.undo({ jobId, proId: verifiedProId })).rejects.toThrow(
|
||||||
|
/already been sent/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an undo from a stranger', async () => {
|
||||||
|
await expect(
|
||||||
|
callerFor(clientSession(strangerId)).deck.undo({ jobId, proId: verifiedProId }),
|
||||||
|
).rejects.toThrow(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('job router', () => {
|
||||||
|
it("lists only the caller's own jobs", async () => {
|
||||||
|
const mine = await callerFor(clientSession(ownerId)).job.mine();
|
||||||
|
expect(mine.length).toBeGreaterThan(0);
|
||||||
|
for (const job of mine) expect(job.clientId).toBe(ownerId);
|
||||||
|
|
||||||
|
const theirs = await callerFor(clientSession(strangerId)).job.mine();
|
||||||
|
expect(theirs.map((j) => j.id)).not.toContain(jobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes categories publicly', async () => {
|
||||||
|
const categories = await callerFor(null).job.categories();
|
||||||
|
expect(categories.length).toBeGreaterThan(0);
|
||||||
|
expect(categories.map((c) => c.name)).toContain('Plumber');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to fetch someone else's job by id", async () => {
|
||||||
|
await expect(callerFor(clientSession(strangerId)).job.byId({ id: jobId })).rejects.toThrow(
|
||||||
|
/not found/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'] },
|
||||||
|
});
|
||||||
@@ -59,6 +59,25 @@ export const db: Db = new Proxy({} as Db, {
|
|||||||
has(_target, prop) {
|
has(_target, prop) {
|
||||||
return Reflect.has(getDb() as object, prop);
|
return Reflect.has(getDb() as object, prop);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Without this trap the proxy reports Object.prototype, which breaks both
|
||||||
|
* `instanceof` and drizzle's own `is(value, PgDatabase)` — the latter walks
|
||||||
|
* `Object.getPrototypeOf(value).constructor` looking for an entityKind.
|
||||||
|
* Auth adapters and other drizzle-aware libraries dispatch on exactly that
|
||||||
|
* check, so a lazy handle that lies about its prototype fails at runtime in
|
||||||
|
* ways that are painful to trace back here.
|
||||||
|
*/
|
||||||
|
getPrototypeOf() {
|
||||||
|
return Reflect.getPrototypeOf(getDb() as object);
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return Reflect.ownKeys(getDb() as object);
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(_target, prop) {
|
||||||
|
const descriptor = Reflect.getOwnPropertyDescriptor(getDb() as object, prop);
|
||||||
|
// A proxy may only report a non-configurable property if the target has one.
|
||||||
|
return descriptor && { ...descriptor, configurable: true };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Close the pool. For scripts and test teardown — never call this from a request. */
|
/** Close the pool. For scripts and test teardown — never call this from a request. */
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* The db handle is a lazy Proxy. These tests exist because a proxy that lies
|
||||||
|
* about its prototype breaks drizzle's own runtime type dispatch — and the
|
||||||
|
* failure surfaces deep inside a third-party adapter, nowhere near this file.
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { is } from 'drizzle-orm';
|
||||||
|
import { PgDatabase } from 'drizzle-orm/pg-core';
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
config({ path: '../../.env' });
|
||||||
|
|
||||||
|
const { closePool, db } = await import('../src/client');
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await closePool();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lazy db proxy', () => {
|
||||||
|
it('passes the drizzle is() check that adapters dispatch on', () => {
|
||||||
|
// Auth adapters dispatch on this. If it returns false they silently take a
|
||||||
|
// different code path and fail with an unrelated-looking error.
|
||||||
|
expect(is(db, PgDatabase)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('satisfies instanceof', () => {
|
||||||
|
expect(db instanceof PgDatabase).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the query builders', () => {
|
||||||
|
expect(typeof db.select).toBe('function');
|
||||||
|
expect(typeof db.insert).toBe('function');
|
||||||
|
expect(typeof db.transaction).toBe('function');
|
||||||
|
expect(db.query).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the relational query namespaces', () => {
|
||||||
|
expect(Object.keys(db.query)).toContain('jobs');
|
||||||
|
expect(Object.keys(db.query)).toContain('proProfiles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports the in operator', () => {
|
||||||
|
expect('select' in db).toBe(true);
|
||||||
|
expect('definitelyNotAMethod' in db).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same instance across accesses', () => {
|
||||||
|
expect(db.select).toBe(db.select);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'] },
|
||||||
|
});
|
||||||
Generated
+454
@@ -23,12 +23,30 @@ importers:
|
|||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@linkder/api':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/api
|
||||||
'@linkder/db':
|
'@linkder/db':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/db
|
version: link:../../packages/db
|
||||||
'@linkder/shared':
|
'@linkder/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@linkder/storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/storage
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.62.0
|
||||||
|
version: 5.101.4(react@19.2.8)
|
||||||
|
'@trpc/client':
|
||||||
|
specifier: ^11.18.0
|
||||||
|
version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
|
'@trpc/react-query':
|
||||||
|
specifier: ^11.18.0
|
||||||
|
version: 11.18.0(@tanstack/react-query@5.101.4(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.18.0(typescript@5.9.3))(react@19.2.8)(typescript@5.9.3)
|
||||||
|
'@trpc/server':
|
||||||
|
specifier: ^11.18.0
|
||||||
|
version: 11.18.0(typescript@5.9.3)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -53,6 +71,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.8(react@19.2.8)
|
version: 19.2.8(react@19.2.8)
|
||||||
|
superjson:
|
||||||
|
specifier: ^2.2.6
|
||||||
|
version: 2.2.6
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.1
|
version: 2.6.1
|
||||||
@@ -85,6 +106,40 @@ importers:
|
|||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/api:
|
||||||
|
dependencies:
|
||||||
|
'@linkder/db':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../db
|
||||||
|
'@linkder/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
|
'@linkder/storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../storage
|
||||||
|
'@trpc/server':
|
||||||
|
specifier: ^11.18.0
|
||||||
|
version: 11.18.0(typescript@5.9.3)
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: 0.38.4
|
||||||
|
version: 0.38.4(@types/react@19.2.18)(postgres@3.4.9)(react@19.2.8)
|
||||||
|
superjson:
|
||||||
|
specifier: ^2.2.6
|
||||||
|
version: 2.2.6
|
||||||
|
zod:
|
||||||
|
specifier: ^3.24.1
|
||||||
|
version: 3.25.76
|
||||||
|
devDependencies:
|
||||||
|
dotenv:
|
||||||
|
specifier: 16.4.7
|
||||||
|
version: 16.4.7
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.8
|
||||||
|
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages/db:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@linkder/shared':
|
'@linkder/shared':
|
||||||
@@ -126,12 +181,107 @@ importers:
|
|||||||
specifier: ^2.1.8
|
specifier: ^2.1.8
|
||||||
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)
|
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)
|
||||||
|
|
||||||
|
packages/storage:
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/client-s3':
|
||||||
|
specifier: ^3.717.0
|
||||||
|
version: 3.1114.0
|
||||||
|
'@aws-sdk/s3-request-presigner':
|
||||||
|
specifier: ^3.717.0
|
||||||
|
version: 3.1114.0
|
||||||
|
zod:
|
||||||
|
specifier: ^3.24.1
|
||||||
|
version: 3.25.76
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.8
|
||||||
|
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@aws-sdk/checksums@3.1000.28':
|
||||||
|
resolution: {integrity: sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/client-s3@3.1114.0':
|
||||||
|
resolution: {integrity: sha512-ZeAgOtB+CXFaWXph98U7a/XrBlVx1lQ2rCJRWLxLmAaQ9k5lht6DWfwnEcVjdzduK/ySao1tCjq0fBAScGqjAg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/core@3.977.8':
|
||||||
|
resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-env@3.972.69':
|
||||||
|
resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-http@3.972.71':
|
||||||
|
resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-ini@3.973.14':
|
||||||
|
resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-login@3.972.76':
|
||||||
|
resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-node@3.972.80':
|
||||||
|
resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-process@3.972.69':
|
||||||
|
resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-sso@3.973.13':
|
||||||
|
resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-web-identity@3.972.75':
|
||||||
|
resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/middleware-sdk-s3@3.972.74':
|
||||||
|
resolution: {integrity: sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/nested-clients@3.997.43':
|
||||||
|
resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/s3-request-presigner@3.1114.0':
|
||||||
|
resolution: {integrity: sha512-z1HQYonZxSJQj6JxWGTyNzOore/46bQ8ZFNAlZJlVs1Ot5oi4bB9AR409mJJrMH7YNjKaEP7bOx+wDoN/RheNQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/signature-v4-multi-region@3.996.45':
|
||||||
|
resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/token-providers@3.1111.0':
|
||||||
|
resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/types@3.974.4':
|
||||||
|
resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/xml-builder@3.972.39':
|
||||||
|
resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws/lambda-invoke-store@0.3.0':
|
||||||
|
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@drizzle-team/brocli@0.10.2':
|
'@drizzle-team/brocli@0.10.2':
|
||||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||||
|
|
||||||
@@ -1151,6 +1301,30 @@ packages:
|
|||||||
'@rushstack/eslint-patch@1.16.1':
|
'@rushstack/eslint-patch@1.16.1':
|
||||||
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
|
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
|
||||||
|
|
||||||
|
'@smithy/core@3.33.3':
|
||||||
|
resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@smithy/credential-provider-imds@4.5.2':
|
||||||
|
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@smithy/fetch-http-handler@5.7.2':
|
||||||
|
resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@smithy/node-http-handler@4.11.3':
|
||||||
|
resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@smithy/signature-v4@5.7.3':
|
||||||
|
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@smithy/types@4.17.2':
|
||||||
|
resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
@@ -1242,6 +1416,36 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.3.3':
|
'@tailwindcss/postcss@4.3.3':
|
||||||
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
|
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.101.4':
|
||||||
|
resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.101.4':
|
||||||
|
resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
|
'@trpc/client@11.18.0':
|
||||||
|
resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@trpc/server': 11.18.0
|
||||||
|
typescript: '>=5.7.2'
|
||||||
|
|
||||||
|
'@trpc/react-query@11.18.0':
|
||||||
|
resolution: {integrity: sha512-C1+Wwm2pCeUJucI+bnFpxGYjNuvV+ko1BC1T9tUxBVdrhHRCdn9ubxdevdLSAa49XRJRJiZnSuzl3Ys/yvs1vg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tanstack/react-query': ^5.80.3
|
||||||
|
'@trpc/client': 11.18.0
|
||||||
|
'@trpc/server': 11.18.0
|
||||||
|
react: '>=18.2.0'
|
||||||
|
typescript: '>=5.7.2'
|
||||||
|
|
||||||
|
'@trpc/server@11.18.0':
|
||||||
|
resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.7.2'
|
||||||
|
|
||||||
'@turbo/darwin-64@2.10.11':
|
'@turbo/darwin-64@2.10.11':
|
||||||
resolution: {integrity: sha512-v3R+1R/Ysozyo+p7Ri8MCIbndOvYt3DgPFrGLhrhQHfvyvbxyH3WyJj+A/2JTNmNleuAlh3JUyCV0iSVHIONTA==}
|
resolution: {integrity: sha512-v3R+1R/Ysozyo+p7Ri8MCIbndOvYt3DgPFrGLhrhQHfvyvbxyH3WyJj+A/2JTNmNleuAlh3JUyCV0iSVHIONTA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
@@ -1579,6 +1783,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
|
bowser@2.14.1:
|
||||||
|
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||||
|
|
||||||
brace-expansion@1.1.18:
|
brace-expansion@1.1.18:
|
||||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||||
|
|
||||||
@@ -1648,6 +1855,10 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
|
copy-anything@4.1.0:
|
||||||
|
resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2832,6 +3043,10 @@ packages:
|
|||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
superjson@2.2.6:
|
||||||
|
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3042,6 +3257,180 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
|
'@aws-sdk/checksums@3.1000.28':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-s3@3.1114.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/checksums': 3.1000.28
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/credential-provider-node': 3.972.80
|
||||||
|
'@aws-sdk/middleware-sdk-s3': 3.972.74
|
||||||
|
'@aws-sdk/signature-v4-multi-region': 3.996.45
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/fetch-http-handler': 5.7.2
|
||||||
|
'@smithy/node-http-handler': 4.11.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/core@3.977.8':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@aws-sdk/xml-builder': 3.972.39
|
||||||
|
'@aws/lambda-invoke-store': 0.3.0
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/signature-v4': 5.7.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
bowser: 2.14.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-env@3.972.69':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-http@3.972.71':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/fetch-http-handler': 5.7.2
|
||||||
|
'@smithy/node-http-handler': 4.11.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-ini@3.973.14':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/credential-provider-env': 3.972.69
|
||||||
|
'@aws-sdk/credential-provider-http': 3.972.71
|
||||||
|
'@aws-sdk/credential-provider-login': 3.972.76
|
||||||
|
'@aws-sdk/credential-provider-process': 3.972.69
|
||||||
|
'@aws-sdk/credential-provider-sso': 3.973.13
|
||||||
|
'@aws-sdk/credential-provider-web-identity': 3.972.75
|
||||||
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/credential-provider-imds': 4.5.2
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-login@3.972.76':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-node@3.972.80':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/credential-provider-env': 3.972.69
|
||||||
|
'@aws-sdk/credential-provider-http': 3.972.71
|
||||||
|
'@aws-sdk/credential-provider-ini': 3.973.14
|
||||||
|
'@aws-sdk/credential-provider-process': 3.972.69
|
||||||
|
'@aws-sdk/credential-provider-sso': 3.973.13
|
||||||
|
'@aws-sdk/credential-provider-web-identity': 3.972.75
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/credential-provider-imds': 4.5.2
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-process@3.972.69':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-sso@3.973.13':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
|
'@aws-sdk/token-providers': 3.1111.0
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-web-identity@3.972.75':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/middleware-sdk-s3@3.972.74':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/signature-v4-multi-region': 3.996.45
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/nested-clients@3.997.43':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/signature-v4-multi-region': 3.996.45
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/fetch-http-handler': 5.7.2
|
||||||
|
'@smithy/node-http-handler': 4.11.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/s3-request-presigner@3.1114.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/signature-v4-multi-region': 3.996.45
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/signature-v4-multi-region@3.996.45':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/signature-v4': 5.7.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/token-providers@3.1111.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.977.8
|
||||||
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
|
'@aws-sdk/types': 3.974.4
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/types@3.974.4':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/xml-builder@3.972.39':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws/lambda-invoke-store@0.3.0': {}
|
||||||
|
|
||||||
'@drizzle-team/brocli@0.10.2': {}
|
'@drizzle-team/brocli@0.10.2': {}
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
@@ -3684,6 +4073,39 @@ snapshots:
|
|||||||
|
|
||||||
'@rushstack/eslint-patch@1.16.1': {}
|
'@rushstack/eslint-patch@1.16.1': {}
|
||||||
|
|
||||||
|
'@smithy/core@3.33.3':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@smithy/credential-provider-imds@4.5.2':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@smithy/fetch-http-handler@5.7.2':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@smithy/node-http-handler@4.11.3':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@smithy/signature-v4@5.7.3':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/core': 3.33.3
|
||||||
|
'@smithy/types': 4.17.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@smithy/types@4.17.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -3757,6 +4179,30 @@ snapshots:
|
|||||||
postcss: 8.5.26
|
postcss: 8.5.26
|
||||||
tailwindcss: 4.3.3
|
tailwindcss: 4.3.3
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.101.4': {}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.101.4(react@19.2.8)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.101.4
|
||||||
|
react: 19.2.8
|
||||||
|
|
||||||
|
'@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
'@trpc/server': 11.18.0(typescript@5.9.3)
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
'@trpc/react-query@11.18.0(@tanstack/react-query@5.101.4(react@19.2.8))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.18.0(typescript@5.9.3))(react@19.2.8)(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/react-query': 5.101.4(react@19.2.8)
|
||||||
|
'@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
|
'@trpc/server': 11.18.0(typescript@5.9.3)
|
||||||
|
react: 19.2.8
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
'@trpc/server@11.18.0(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@turbo/darwin-64@2.10.11':
|
'@turbo/darwin-64@2.10.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4105,6 +4551,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
|
bowser@2.14.1: {}
|
||||||
|
|
||||||
brace-expansion@1.1.18:
|
brace-expansion@1.1.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
@@ -4174,6 +4622,8 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
|
copy-anything@4.1.0: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5576,6 +6026,10 @@ snapshots:
|
|||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|
||||||
|
superjson@2.2.6:
|
||||||
|
dependencies:
|
||||||
|
copy-anything: 4.1.0
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user