From 8e0c363e0d77502b3bfacde5b5984719cc2b6b32 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 1 Apr 2026 05:38:48 +0000 Subject: [PATCH] feat(wasteland): add tRPC init, schemas, and resolveWastelandOwnership helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create trpc/init.ts with TRPCContext, analyticsProcedure, procedure, and adminProcedure (no gastownAccess gate — wasteland is simpler) - Create trpc/schemas.ts with WastelandOutput, WastelandMemberOutput, WastelandCredentialStatusOutput, WastelandConfigOutput and rpcSafe wrappers - Create trpc/ownership.ts with resolveWastelandOwnership checking user/org/admin - Add @hono/trpc-server dependency to package.json - Add 'trpc' to WastelandDelivery type in analytics.util.ts - Extend WastelandDO stub with getConfig() signature for type compatibility --- cloudflare-wasteland/package.json | 1 + .../src/dos/WastelandDO.stub.ts | 21 +++++- cloudflare-wasteland/src/trpc/init.ts | 67 +++++++++++++++++ cloudflare-wasteland/src/trpc/ownership.ts | 41 +++++++++++ cloudflare-wasteland/src/trpc/schemas.ts | 71 +++++++++++++++++++ .../src/util/analytics.util.ts | 2 +- pnpm-lock.yaml | 3 + 7 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 cloudflare-wasteland/src/trpc/init.ts create mode 100644 cloudflare-wasteland/src/trpc/ownership.ts create mode 100644 cloudflare-wasteland/src/trpc/schemas.ts diff --git a/cloudflare-wasteland/package.json b/cloudflare-wasteland/package.json index 7c2a20772..db80b72e2 100644 --- a/cloudflare-wasteland/package.json +++ b/cloudflare-wasteland/package.json @@ -22,6 +22,7 @@ "@cloudflare/containers": "^0.1.0", "@kilocode/worker-utils": "workspace:*", "@sentry/cloudflare": "^9", + "@hono/trpc-server": "^0.4.2", "@trpc/server": "catalog:", "hono": "catalog:", "itty-time": "^1.0.6", diff --git a/cloudflare-wasteland/src/dos/WastelandDO.stub.ts b/cloudflare-wasteland/src/dos/WastelandDO.stub.ts index a1e45a926..b0b0389c0 100644 --- a/cloudflare-wasteland/src/dos/WastelandDO.stub.ts +++ b/cloudflare-wasteland/src/dos/WastelandDO.stub.ts @@ -1,9 +1,24 @@ import { DurableObject } from 'cloudflare:workers'; +/** Shape returned by WastelandDO.getConfig() — matches the wasteland_config table. */ +export type WastelandConfigResult = { + wasteland_id: string; + name: string; + owner_type: 'user' | 'org'; + owner_user_id: string | null; + organization_id: string | null; + dolthub_upstream: string | null; + visibility: 'public' | 'private'; + status: 'active' | 'deleted'; + created_at: string; + updated_at: string; +}; + /** * Stub WastelandDO — placeholder until the full implementation lands. * Provides the class export that wrangler.jsonc requires for the - * WASTELAND durable_objects binding. + * WASTELAND durable_objects binding, plus RPC method signatures that + * downstream tRPC code depends on. */ export class WastelandDO extends DurableObject { async fetch(): Promise { @@ -12,6 +27,10 @@ export class WastelandDO extends DurableObject { headers: { 'Content-Type': 'application/json' }, }); } + + async getConfig(): Promise { + throw new Error('WastelandDO not yet implemented'); + } } export function getWastelandDOStub(env: Env, wastelandId: string) { diff --git a/cloudflare-wasteland/src/trpc/init.ts b/cloudflare-wasteland/src/trpc/init.ts new file mode 100644 index 000000000..06161ea5e --- /dev/null +++ b/cloudflare-wasteland/src/trpc/init.ts @@ -0,0 +1,67 @@ +import { initTRPC, TRPCError } from '@trpc/server'; +import { writeEvent } from '../util/analytics.util'; + +import type { JwtOrgMembership } from '../middleware/auth.middleware'; + +export type TRPCContext = { + env: Env; + userId: string; + isAdmin: boolean; + apiTokenPepper: string | null; + orgMemberships: JwtOrgMembership[]; +}; + +const t = initTRPC.context().create(); + +export const router = t.router; + +/** + * Analytics middleware — wraps every tRPC procedure to emit an analytics + * event with timing and error capture. Runs before auth so even rejected + * requests are tracked. + */ +const analyticsProcedure = t.procedure.use(async ({ ctx, path, type, next }) => { + const start = performance.now(); + let error: string | undefined; + try { + const result = await next({ ctx }); + return result; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + throw err; + } finally { + const durationMs = performance.now() - start; + writeEvent(ctx.env, { + event: path, + delivery: 'trpc', + route: `${type} ${path}`, + error, + userId: ctx.userId || undefined, + durationMs, + }); + } +}); + +/** + * Base procedure — requires a valid Kilo JWT (enforced by kiloAuthMiddleware + * running before tRPC). The userId is extracted from the JWT and set on the + * Hono context by kiloAuthMiddleware, then forwarded into the tRPC context + * by the createContext callback in wasteland.worker.ts. + */ +export const procedure = analyticsProcedure.use(async ({ ctx, next }) => { + if (!ctx.userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Authentication required' }); + } + return next({ ctx }); +}); + +/** + * Admin-only procedure — requires `isAdmin` on the JWT. Used for admin + * endpoints that bypass per-user ownership checks. + */ +export const adminProcedure = procedure.use(async ({ ctx, next }) => { + if (!ctx.isAdmin) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); + } + return next({ ctx }); +}); diff --git a/cloudflare-wasteland/src/trpc/ownership.ts b/cloudflare-wasteland/src/trpc/ownership.ts new file mode 100644 index 000000000..36b3a2adb --- /dev/null +++ b/cloudflare-wasteland/src/trpc/ownership.ts @@ -0,0 +1,41 @@ +import { TRPCError } from '@trpc/server'; +import { getWastelandDOStub } from '../dos/WastelandDO.stub'; +import type { TRPCContext } from './init'; + +type WastelandOwnershipResult = + | { type: 'user'; userId: string } + | { type: 'org'; orgId: string } + | { type: 'admin' }; + +export async function resolveWastelandOwnership( + env: Env, + ctx: TRPCContext, + wastelandId: string +): Promise { + const stub = getWastelandDOStub(env, wastelandId); + const config = await stub.getConfig(); + + if (!config || config.status === 'deleted') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Wasteland not found' }); + } + + if (config.owner_type === 'user') { + if (config.owner_user_id !== ctx.userId) { + if (ctx.isAdmin) return { type: 'admin' }; + throw new TRPCError({ code: 'FORBIDDEN' }); + } + return { type: 'user', userId: ctx.userId }; + } + + if (config.owner_type === 'org' && config.organization_id) { + const membership = ctx.orgMemberships.find(m => m.orgId === config.organization_id); + if (!membership || membership.role === 'billing_manager') { + if (ctx.isAdmin) return { type: 'admin' }; + throw new TRPCError({ code: 'FORBIDDEN' }); + } + return { type: 'org', orgId: config.organization_id }; + } + + if (ctx.isAdmin) return { type: 'admin' }; + throw new TRPCError({ code: 'NOT_FOUND' }); +} diff --git a/cloudflare-wasteland/src/trpc/schemas.ts b/cloudflare-wasteland/src/trpc/schemas.ts new file mode 100644 index 000000000..909c5683d --- /dev/null +++ b/cloudflare-wasteland/src/trpc/schemas.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; + +/** + * Wraps a Zod schema in z.any().pipe(schema) so the TS input type is `any` + * (avoiding "excessively deep" instantiation with Rpc.Promisified DO stubs) + * while still performing full runtime validation via the piped schema. + */ +function rpcSafe(schema: T): z.ZodPipe { + return z.any().pipe(schema); +} + +// ── Wasteland (config output for API consumers) ───────────────────────── + +export const WastelandOutput = z.object({ + wasteland_id: z.string(), + name: z.string(), + owner_type: z.enum(['user', 'org']), + owner_user_id: z.string().nullable(), + organization_id: z.string().nullable(), + dolthub_upstream: z.string().nullable(), + visibility: z.enum(['public', 'private']), + status: z.enum(['active', 'deleted']), + created_at: z.string(), + updated_at: z.string(), +}); + +// ── Wasteland Member ──────────────────────────────────────────────────── + +export const WastelandMemberOutput = z.object({ + member_id: z.string(), + user_id: z.string(), + trust_level: z.number(), + role: z.enum(['contributor', 'maintainer', 'owner']), + joined_at: z.string(), +}); + +// ── Credential Status (never expose encrypted_token) ──────────────────── + +export const WastelandCredentialStatusOutput = z.object({ + user_id: z.string(), + dolthub_org: z.string(), + rig_handle: z.string().nullable(), + connected_at: z.string(), +}); + +// ── Full Config (same shape as WastelandOutput for now) ───────────────── + +export const WastelandConfigOutput = z.object({ + wasteland_id: z.string(), + name: z.string(), + owner_type: z.enum(['user', 'org']), + owner_user_id: z.string().nullable(), + organization_id: z.string().nullable(), + dolthub_upstream: z.string().nullable(), + visibility: z.enum(['public', 'private']), + status: z.enum(['active', 'deleted']), + created_at: z.string(), + updated_at: z.string(), +}); + +// ── rpcSafe wrappers ──────────────────────────────────────────────────── +// tRPC's .output() forces TypeScript to check that the handler return type +// is assignable to the schema's input type. When handlers return values from +// Cloudflare Rpc.Promisified DO stubs, the deeply recursive proxy types +// exceed TS's instantiation depth limit. Wrapping with rpcSafe() (z.any().pipe) +// short-circuits the type check while preserving identical runtime validation. + +export const RpcWastelandOutput = rpcSafe(WastelandOutput); +export const RpcWastelandMemberOutput = rpcSafe(WastelandMemberOutput); +export const RpcWastelandCredentialStatusOutput = rpcSafe(WastelandCredentialStatusOutput); +export const RpcWastelandConfigOutput = rpcSafe(WastelandConfigOutput); diff --git a/cloudflare-wasteland/src/util/analytics.util.ts b/cloudflare-wasteland/src/util/analytics.util.ts index a54d319cd..06d3669dd 100644 --- a/cloudflare-wasteland/src/util/analytics.util.ts +++ b/cloudflare-wasteland/src/util/analytics.util.ts @@ -18,7 +18,7 @@ export type WastelandEventName = // a massive union — event names are derived from route patterns. | (string & {}); -export type WastelandDelivery = 'http' | 'internal'; +export type WastelandDelivery = 'http' | 'trpc' | 'internal'; export type WastelandEventData = { event: WastelandEventName; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd4fa5db8..f5f8472bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1335,6 +1335,9 @@ importers: '@cloudflare/containers': specifier: ^0.1.0 version: 0.1.1 + '@hono/trpc-server': + specifier: ^0.4.2 + version: 0.4.2(@trpc/server@11.13.0(typescript@5.9.3))(hono@4.12.8) '@kilocode/worker-utils': specifier: workspace:* version: link:../packages/worker-utils