From 5140b6779e0884bc1f7b561c64956c11a8bdcd0c Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 17 Apr 2026 22:14:30 -0500 Subject: [PATCH 01/11] migrate auth --- .../docs/audits/hono-to-tanstack-migration.md | 14 +- packages/web/src/routes/api/auth/$.ts | 68 +++ .../api/auth/__tests__/auth.server.test.ts | 186 ++++++++ .../api/auth/__tests__/webhook.server.test.ts | 222 +++++++++ packages/web/src/routes/api/auth/session.ts | 39 ++ .../web/src/routes/api/auth/stripe/webhook.ts | 293 ++++++++++++ .../web/src/routes/api/auth/verify-email.ts | 61 +++ .../src/server/lib/authHtmlPages.ts} | 5 +- packages/web/src/server/rateLimit.ts | 12 + packages/workers/src/auth/routes.ts | 448 +----------------- 10 files changed, 902 insertions(+), 446 deletions(-) create mode 100644 packages/web/src/routes/api/auth/$.ts create mode 100644 packages/web/src/routes/api/auth/__tests__/auth.server.test.ts create mode 100644 packages/web/src/routes/api/auth/__tests__/webhook.server.test.ts create mode 100644 packages/web/src/routes/api/auth/session.ts create mode 100644 packages/web/src/routes/api/auth/stripe/webhook.ts create mode 100644 packages/web/src/routes/api/auth/verify-email.ts rename packages/{workers/src/auth/templates.ts => web/src/server/lib/authHtmlPages.ts} (98%) diff --git a/packages/docs/audits/hono-to-tanstack-migration.md b/packages/docs/audits/hono-to-tanstack-migration.md index 6cdcf01c5..0619beb81 100644 --- a/packages/docs/audits/hono-to-tanstack-migration.md +++ b/packages/docs/audits/hono-to-tanstack-migration.md @@ -2,7 +2,7 @@ Handoff doc. Migration consolidates two Cloudflare Workers (`packages/workers` + `packages/web`) into one: the TanStack Start app in `packages/web` takes over every route that used to live in the Hono app. -Branches: `migrate-backend` (Passes 0-5, merged), `migrate-billing-routes` (Passes 6-12, [#484](https://github.com/InfinityBowman/corates/issues/484), merged via PR #487), `migrate-admin-routes` (Passes 13-21, [#485](https://github.com/InfinityBowman/corates/issues/485), complete as of 2026-04-17 — admin tier fully on TanStack). +Branches: `migrate-backend` (Passes 0-5, merged), `migrate-billing-routes` (Passes 6-12, [#484](https://github.com/InfinityBowman/corates/issues/484), merged via PR #487), `migrate-admin-routes` (Passes 13-21, [#485](https://github.com/InfinityBowman/corates/issues/485), merged via PR #488), `retire-hono-app` (Passes 22+, [#486](https://github.com/InfinityBowman/corates/issues/486), in progress as of 2026-04-17). ## What's migrated @@ -45,6 +45,10 @@ Tier 3 admin (in progress, issue [#485](https://github.com/InfinityBowman/corate **Admin tier complete.** All admin routes are TanStack. Every route a regular user or admin hits is now on TanStack. Hono still serves `/api/auth/*` (better-auth catch-all), Stripe webhooks, and DO WebSocket upgrades — that's it. +Tier 4 (in progress, issue [#486](https://github.com/InfinityBowman/corates/issues/486)): + +- **Pass 22** — `/api/auth/*` + Stripe webhook: `/api/auth/session` (custom WebSocket session payload, GET), `/api/auth/verify-email` (branded HTML wrapper around better-auth's verification, GET), `/api/auth/stripe/webhook` (POST, two-phase ledger trust model), `/api/auth/$` (catch-all that proxies to better-auth's `auth.handler`). Four route files. Migrated together because the catch-all would otherwise shadow the webhook — TanStack's specificity sort (Index → Static most-specific → Dynamic longest → Splat) puts `stripe/webhook.ts` ahead of `$.ts`, and the same rule lets `session.ts`/`verify-email.ts` win against the splat. Added `AUTH_RATE_LIMIT` and `SESSION_RATE_LIMIT` to `server/rateLimit.ts`. The catch-all rate-limits the same paths the Hono mount did: `/api/auth/get-session` (session), and `/api/auth/{sign-in,sign-up,forget-password,reset-password,magic-link}/*` (auth). Moved `auth/templates.ts` into `packages/web/src/server/lib/authHtmlPages.ts` (only consumed by `verify-email.ts`). The Stripe webhook ports the two-phase trust model verbatim (Phase 1: signature presence + payload-hash dedupe + early reject for missing signature / unreadable body / `livemode=false` in production; Phase 2: forward raw body to better-auth, classify response into `processed` / `ignored_unverified` / `failed` and update ledger row accordingly). Replaced `createLogger`/`sha256`/`truncateError` from `lib/observability/logger.ts` with inline `console.info`/`console.error` plus 5-line local `sha256`/`truncate` helpers (same observability event names, Pass 12 precedent). Stripped Hono `auth/routes.ts` to an empty router, deleted `auth/templates.ts`. No prior Hono tests covered the webhook (`__tests__/app.test.ts` had 4 unrelated tests; nothing for the catch-all or webhook). 18 new tests across 2 files (`webhook.server.test.ts` 8, `auth.server.test.ts` 10). + ## What's left Tracking issues: @@ -237,6 +241,10 @@ Delete the corresponding Hono test file (`packages/workers/src/routes/__tests__/ **Threading `waitUntil` into TanStack handlers.** `src/server.ts` injects `cloudflareCtx` into every TanStack request via `context: { cloudflareCtx: ctx }`. Routes that want fire-and-forget work (notifications, async logging) extend their handler args with `context?: { cloudflareCtx?: ExecutionContext }` and check `context?.cloudflareCtx?.waitUntil` at runtime. When absent (tests, fallback) the work is awaited inline. `routes/api/admin/orgs/$orgId/subscriptions.ts` exports a reusable `dispatchSubscriptionNotify` helper that does this. +**TanStack file-route specificity wins over splats.** From the Route Matching docs: routes are sorted Index → Static (most-specific first) → Dynamic (longest first) → Splat. This is why the Pass 22 catch-all `routes/api/auth/$.ts` does not shadow sibling exact files like `routes/api/auth/session.ts` or the deeper `routes/api/auth/stripe/webhook.ts`, and the existing `/api/$.ts` Hono catch-all hasn't shadowed any of the migrated tiers either. The order routes are defined or codegened doesn't matter — TanStack re-sorts. + +**Catch-all handlers must be exported when tested.** TanStack types `server.handlers` as a function (not a record), so accessing `Route.options.server!.handlers!.POST` from tests fails to typecheck even though it works at runtime. Export the handler function (e.g. `export const handle = ...`) and import it directly into tests, same as the named `handleGet`/`handlePost` pattern. + ## Client caller translation Client callers that were using Hono RPC (`honoClient.api.orgs[':orgId'].$get(...)`) were rewritten to plain `fetch(\`\${API_BASE}/api/orgs/\${orgId}\`, {...})`. The URLs are identical; the TanStack routes serve the same paths. Completed updates: @@ -253,8 +261,8 @@ Most components already used plain `fetch`, so no code changes were needed for P ## Test counts (2026-04-17) -- Web server tests: **377 passing** across 36 files (Pass 21: +28 tests, +1 file) -- Workers tests: **217 passing** across 20 files (Pass 21 deleted `routes/admin/__tests__/admin-billing.test.ts` — 12 tests, all covered routes migrated here) +- Web server tests: **395 passing** across 38 files (Pass 22: +18 tests, +2 files) +- Workers tests: **217 passing** across 20 files (Pass 22 only stripped `auth/routes.ts` and deleted `auth/templates.ts`; no Hono test deletions yet) - Web typecheck: clean modulo 3 pre-existing errors (e2e `timeout` in TestDetails, unused `loginWithApiCookies`, `src/server.ts:28` queue() arity) - Workers typecheck: clean diff --git a/packages/web/src/routes/api/auth/$.ts b/packages/web/src/routes/api/auth/$.ts new file mode 100644 index 000000000..7374c19cd --- /dev/null +++ b/packages/web/src/routes/api/auth/$.ts @@ -0,0 +1,68 @@ +/** + * Catch-all for /api/auth/* — forwards every request to better-auth's + * `auth.handler`. Specific routes (`session.ts`, `verify-email.ts`, + * `stripe/webhook.ts`) are matched first by TanStack's specificity rules + * and never reach this handler. + * + * Sign-in/sign-up/forget-password/reset-password/magic-link paths get a + * 15-minute auth-rate-limit; everything else passes through unthrottled + * (parity with the previous Hono mount, which only rate-limited those + * groups plus `/get-session`). + */ +import { createFileRoute } from '@tanstack/react-router'; +import { env } from 'cloudflare:workers'; +import { createAuth } from '@corates/workers/auth-config'; +import { checkRateLimit, AUTH_RATE_LIMIT, SESSION_RATE_LIMIT } from '@/server/rateLimit'; + +const AUTH_RATE_LIMITED_PREFIXES = [ + '/api/auth/sign-in/', + '/api/auth/sign-up/', + '/api/auth/forget-password/', + '/api/auth/reset-password/', + '/api/auth/magic-link/', +]; + +type HandlerArgs = { request: Request; context?: { cloudflareCtx?: ExecutionContext } }; + +export const handle = async ({ request, context }: HandlerArgs) => { + const url = new URL(request.url); + const path = url.pathname; + + if (path === '/api/auth/get-session') { + const rate = checkRateLimit(request, env, SESSION_RATE_LIMIT); + if (rate.blocked) return rate.blocked; + } else if (AUTH_RATE_LIMITED_PREFIXES.some(prefix => path.startsWith(prefix))) { + const rate = checkRateLimit(request, env, AUTH_RATE_LIMIT); + if (rate.blocked) return rate.blocked; + } + + try { + const auth = createAuth(env, context?.cloudflareCtx); + const authUrl = new URL(path, url.origin); + authUrl.search = url.search; + const authRequest = new Request(authUrl.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + }); + return await auth.handler(authRequest); + } catch (error) { + const err = error as Error; + console.error('Auth route error:', error); + return Response.json({ error: 'Authentication error', details: err.message }, { status: 500 }); + } +}; + +export const Route = createFileRoute('/api/auth/$')({ + server: { + handlers: { + GET: handle, + POST: handle, + PUT: handle, + PATCH: handle, + DELETE: handle, + OPTIONS: handle, + HEAD: handle, + }, + }, +}); diff --git a/packages/web/src/routes/api/auth/__tests__/auth.server.test.ts b/packages/web/src/routes/api/auth/__tests__/auth.server.test.ts new file mode 100644 index 000000000..8a28edede --- /dev/null +++ b/packages/web/src/routes/api/auth/__tests__/auth.server.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleGet as sessionHandler } from '../session'; +import { handleGet as verifyEmailHandler } from '../verify-email'; +import { handle as catchAllHandler } from '../$'; + +const { mockAuthHandler, mockGetSession } = vi.hoisted(() => ({ + mockAuthHandler: vi.fn( + async (_request: Request) => new Response(JSON.stringify({ ok: true }), { status: 200 }), + ), + mockGetSession: vi.fn(async (_args: { headers: Headers }) => null as unknown), +})); + +vi.mock('@corates/workers/auth-config', () => ({ + createAuth: () => ({ + handler: mockAuthHandler, + api: { getSession: mockGetSession }, + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mockAuthHandler.mockImplementation( + async () => new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + mockGetSession.mockResolvedValue(null); +}); + +describe('GET /api/auth/session', () => { + it('returns user/session/sessionToken when better-auth returns a session', async () => { + mockGetSession.mockResolvedValueOnce({ + user: { id: 'u1', email: 'u1@example.com', name: 'U1' }, + session: { id: 'sess-token-1', userId: 'u1' }, + }); + + const res = await sessionHandler({ + request: new Request('http://localhost/api/auth/session', { method: 'GET' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + user: { id: string }; + session: { id: string }; + sessionToken: string; + }; + expect(body.user.id).toBe('u1'); + expect(body.session.id).toBe('sess-token-1'); + expect(body.sessionToken).toBe('sess-token-1'); + }); + + it('returns nulls (200) when no session is present', async () => { + const res = await sessionHandler({ + request: new Request('http://localhost/api/auth/session', { method: 'GET' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { user: null; session: null; sessionToken: null }; + expect(body.user).toBeNull(); + expect(body.session).toBeNull(); + expect(body.sessionToken).toBeNull(); + }); + + it('swallows getSession errors and returns nulls', async () => { + mockGetSession.mockRejectedValueOnce(new Error('db down')); + + const res = await sessionHandler({ + request: new Request('http://localhost/api/auth/session', { method: 'GET' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { user: null; session: null }; + expect(body.user).toBeNull(); + expect(body.session).toBeNull(); + }); +}); + +describe('GET /api/auth/verify-email', () => { + it('returns success page (200, html) on 2xx and forwards Set-Cookie headers', async () => { + const upstream = new Response('{"ok":true}', { status: 200 }); + upstream.headers.append('Set-Cookie', 'better-auth.session_token=abc; Path=/'); + upstream.headers.append('Set-Cookie', 'better-auth.session_data=xyz; Path=/'); + mockAuthHandler.mockResolvedValueOnce(upstream); + + const res = await verifyEmailHandler({ + request: new Request('http://localhost/api/auth/verify-email?token=abc', { method: 'GET' }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const cookies = res.headers.getSetCookie?.() ?? []; + expect(cookies).toContain('better-auth.session_token=abc; Path=/'); + expect(cookies).toContain('better-auth.session_data=xyz; Path=/'); + + const html = await res.text(); + expect(html).toContain('Email Verified Successfully'); + }); + + it('returns failure page (status preserved) on non-2xx', async () => { + mockAuthHandler.mockResolvedValueOnce(new Response('expired', { status: 400 })); + + const res = await verifyEmailHandler({ + request: new Request('http://localhost/api/auth/verify-email?token=expired', { + method: 'GET', + }), + }); + expect(res.status).toBe(400); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Email Verification Failed'); + }); + + it('returns error page (500) when better-auth throws', async () => { + mockAuthHandler.mockRejectedValueOnce(new Error('boom')); + + const res = await verifyEmailHandler({ + request: new Request('http://localhost/api/auth/verify-email?token=x', { method: 'GET' }), + }); + expect(res.status).toBe(500); + const html = await res.text(); + expect(html).toContain('Something Went Wrong'); + }); + + it('forwards the original query string to better-auth', async () => { + await verifyEmailHandler({ + request: new Request('http://localhost/api/auth/verify-email?token=abc&callbackURL=%2Fhome', { + method: 'GET', + }), + }); + expect(mockAuthHandler).toHaveBeenCalledTimes(1); + const forwarded = mockAuthHandler.mock.calls[0][0]!; + const fwdUrl = new URL(forwarded.url); + expect(fwdUrl.pathname).toBe('/api/auth/verify-email'); + expect(fwdUrl.searchParams.get('token')).toBe('abc'); + expect(fwdUrl.searchParams.get('callbackURL')).toBe('/home'); + }); +}); + +describe('catch-all /api/auth/$', () => { + it('forwards arbitrary paths (e.g. sign-in/email) to better-auth and returns its response', async () => { + mockAuthHandler.mockResolvedValueOnce( + new Response(JSON.stringify({ token: 'jwt' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const res = await catchAllHandler({ + request: new Request('http://localhost/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@b.com', password: 'pw' }), + }), + }); + expect(res.status).toBe(200); + expect(mockAuthHandler).toHaveBeenCalledTimes(1); + + const forwarded = mockAuthHandler.mock.calls[0][0]!; + expect(new URL(forwarded.url).pathname).toBe('/api/auth/sign-in/email'); + expect(forwarded.method).toBe('POST'); + }); + + it('preserves the query string when forwarding', async () => { + await catchAllHandler({ + request: new Request( + 'http://localhost/api/auth/callback/google?state=abc&code=xyz', + { method: 'GET' }, + ), + }); + const forwarded = mockAuthHandler.mock.calls[0][0]!; + const fwdUrl = new URL(forwarded.url); + expect(fwdUrl.pathname).toBe('/api/auth/callback/google'); + expect(fwdUrl.searchParams.get('state')).toBe('abc'); + expect(fwdUrl.searchParams.get('code')).toBe('xyz'); + }); + + it('returns 500 with details when better-auth throws', async () => { + mockAuthHandler.mockRejectedValueOnce(new Error('something went bad')); + + const res = await catchAllHandler({ + request: new Request('http://localhost/api/auth/sign-up/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'x@y.com', password: 'pw' }), + }), + }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; details: string }; + expect(body.error).toBe('Authentication error'); + expect(body.details).toBe('something went bad'); + }); +}); diff --git a/packages/web/src/routes/api/auth/__tests__/webhook.server.test.ts b/packages/web/src/routes/api/auth/__tests__/webhook.server.test.ts new file mode 100644 index 000000000..043024c86 --- /dev/null +++ b/packages/web/src/routes/api/auth/__tests__/webhook.server.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { env } from 'cloudflare:test'; +import { resetTestDatabase } from '@/__tests__/server/helpers'; +import { createDb } from '@corates/db/client'; +import { stripeEventLedger } from '@corates/db/schema'; +import { eq } from 'drizzle-orm'; +import { handlePost } from '../stripe/webhook'; + +const { mockAuthHandler } = vi.hoisted(() => ({ + mockAuthHandler: vi.fn(async () => new Response(JSON.stringify({ received: true }), { status: 200 })), +})); + +vi.mock('@corates/workers/auth-config', () => ({ + createAuth: () => ({ handler: mockAuthHandler }), +})); + +beforeEach(async () => { + await resetTestDatabase(); + vi.clearAllMocks(); + mockAuthHandler.mockImplementation( + async () => new Response(JSON.stringify({ received: true }), { status: 200 }), + ); +}); + +function webhookReq(body: string, headers: Record = {}): Request { + return new Request('http://localhost/api/auth/stripe/webhook', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + }); +} + +async function readLedger() { + const db = createDb(env.DB); + return db.select().from(stripeEventLedger).all(); +} + +describe('Stripe webhook - phase 1 rejections', () => { + it('returns 403 and writes ignored_unverified row when stripe-signature header is missing', async () => { + const res = await handlePost({ request: webhookReq('{"id":"evt_1"}') }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Missing Stripe signature'); + + const rows = await readLedger(); + expect(rows.length).toBe(1); + expect(rows[0].status).toBe('ignored_unverified'); + expect(rows[0].signaturePresent).toBe(false); + expect(rows[0].error).toBe('missing_signature'); + expect(rows[0].httpStatus).toBe(403); + expect(mockAuthHandler).not.toHaveBeenCalled(); + }); + + it('skips duplicate payloads (same hash) without calling better-auth', async () => { + const body = JSON.stringify({ id: 'evt_dup', type: 'checkout.session.completed' }); + const headers = { 'stripe-signature': 'sig=1' }; + + const first = await handlePost({ request: webhookReq(body, headers) }); + expect(first.status).toBe(200); + + const second = await handlePost({ request: webhookReq(body, headers) }); + expect(second.status).toBe(200); + const json = (await second.json()) as { skipped?: string }; + expect(json.skipped).toBe('duplicate_payload'); + + expect(mockAuthHandler).toHaveBeenCalledTimes(1); + }); + + it('rejects test events in production and writes ignored_test_mode row', async () => { + const originalEnv = env.ENVIRONMENT; + (env as { ENVIRONMENT: string }).ENVIRONMENT = 'production'; + + try { + const body = JSON.stringify({ + id: 'evt_test', + type: 'customer.subscription.created', + livemode: false, + api_version: '2024-09-30', + created: 1700000000, + }); + + const res = await handlePost({ + request: webhookReq(body, { 'stripe-signature': 'sig=1' }), + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { skipped?: string }; + expect(json.skipped).toBe('test_event_in_production'); + expect(mockAuthHandler).not.toHaveBeenCalled(); + + const rows = await readLedger(); + expect(rows.length).toBe(1); + expect(rows[0].status).toBe('ignored_test_mode'); + expect(rows[0].httpStatus).toBe(200); + expect(rows[0].signaturePresent).toBe(true); + } finally { + (env as { ENVIRONMENT: string }).ENVIRONMENT = originalEnv; + } + }); +}); + +describe('Stripe webhook - phase 2 verified processing', () => { + it('writes processed row with verified fields when better-auth returns 2xx', async () => { + const body = JSON.stringify({ + id: 'evt_processed', + type: 'customer.subscription.updated', + livemode: true, + api_version: '2024-09-30', + created: 1700000000, + data: { + object: { + id: 'sub_123', + customer: 'cus_abc', + metadata: { referenceId: 'org-1' }, + }, + }, + }); + + const res = await handlePost({ + request: webhookReq(body, { 'stripe-signature': 'sig=ok' }), + }); + expect(res.status).toBe(200); + expect(mockAuthHandler).toHaveBeenCalledTimes(1); + + const rows = await readLedger(); + expect(rows.length).toBe(1); + expect(rows[0].status).toBe('processed'); + expect(rows[0].stripeEventId).toBe('evt_processed'); + expect(rows[0].type).toBe('customer.subscription.updated'); + expect(rows[0].livemode).toBe(true); + expect(rows[0].orgId).toBe('org-1'); + expect(rows[0].stripeCustomerId).toBe('cus_abc'); + expect(rows[0].stripeSubscriptionId).toBe('sub_123'); + expect(rows[0].httpStatus).toBe(200); + }); + + it('captures checkout session id and links subscription on checkout.session.completed', async () => { + const body = JSON.stringify({ + id: 'evt_checkout', + type: 'checkout.session.completed', + livemode: true, + data: { + object: { + id: 'cs_xyz', + customer: 'cus_xyz', + subscription: 'sub_from_checkout', + mode: 'subscription', + metadata: { referenceId: 'org-2' }, + }, + }, + }); + + const res = await handlePost({ + request: webhookReq(body, { 'stripe-signature': 'sig=ok' }), + }); + expect(res.status).toBe(200); + + const rows = await readLedger(); + expect(rows[0].stripeCheckoutSessionId).toBe('cs_xyz'); + expect(rows[0].stripeSubscriptionId).toBe('sub_from_checkout'); + expect(rows[0].orgId).toBe('org-2'); + }); + + it('marks ledger ignored_unverified when better-auth returns 401/403', async () => { + mockAuthHandler.mockResolvedValueOnce(new Response('bad signature', { status: 401 })); + + const res = await handlePost({ + request: webhookReq('{"id":"evt_bad"}', { 'stripe-signature': 'sig=bogus' }), + }); + expect(res.status).toBe(401); + + const rows = await readLedger(); + expect(rows.length).toBe(1); + expect(rows[0].status).toBe('ignored_unverified'); + expect(rows[0].error).toBe('invalid_signature'); + expect(rows[0].httpStatus).toBe(401); + }); + + it('marks ledger failed when better-auth returns 5xx', async () => { + mockAuthHandler.mockResolvedValueOnce( + new Response('internal stripe handler error', { status: 500 }), + ); + + const res = await handlePost({ + request: webhookReq('{"id":"evt_fail"}', { 'stripe-signature': 'sig=ok' }), + }); + expect(res.status).toBe(500); + + const rows = await readLedger(); + expect(rows[0].status).toBe('failed'); + expect(rows[0].httpStatus).toBe(500); + expect(rows[0].error).toContain('internal stripe handler error'); + }); +}); + +describe('Stripe webhook - error path', () => { + it('catches handler exceptions, marks ledger failed, returns 500', async () => { + mockAuthHandler.mockRejectedValueOnce(new Error('boom')); + + const res = await handlePost({ + request: webhookReq('{"id":"evt_throw"}', { 'stripe-signature': 'sig=ok' }), + }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Webhook processing error'); + + const db = createDb(env.DB); + const rows = await db.select().from(stripeEventLedger).all(); + const failed = rows.find(r => r.status === 'failed'); + expect(failed).toBeDefined(); + expect(failed!.error).toContain('boom'); + expect(failed!.httpStatus).toBe(500); + + // Sanity: the row was inserted by phase 1 (received) and updated by the catch + const recv = await db + .select() + .from(stripeEventLedger) + .where(eq(stripeEventLedger.id, failed!.id)) + .get(); + expect(recv?.payloadHash).toBeTruthy(); + }); +}); diff --git a/packages/web/src/routes/api/auth/session.ts b/packages/web/src/routes/api/auth/session.ts new file mode 100644 index 000000000..cfd30edde --- /dev/null +++ b/packages/web/src/routes/api/auth/session.ts @@ -0,0 +1,39 @@ +/** + * GET /api/auth/session — custom session payload used by WebSocket clients. + * + * Returns `{ user, session, sessionToken }` (matches the legacy Hono response + * shape). On any error, returns 200 with `{ user: null, session: null }` so + * the WS connect logic can fall back to anonymous mode without throwing. + */ +import { createFileRoute } from '@tanstack/react-router'; +import { env } from 'cloudflare:workers'; +import { createAuth } from '@corates/workers/auth-config'; +import { checkRateLimit, SESSION_RATE_LIMIT } from '@/server/rateLimit'; + +type HandlerArgs = { request: Request; context?: { cloudflareCtx?: ExecutionContext } }; + +export const handleGet = async ({ request, context }: HandlerArgs) => { + const rate = checkRateLimit(request, env, SESSION_RATE_LIMIT); + if (rate.blocked) return rate.blocked; + + try { + const auth = createAuth(env, context?.cloudflareCtx); + const session = await auth.api.getSession({ headers: request.headers }); + + return Response.json( + { + user: session?.user ?? null, + session: session?.session ?? null, + sessionToken: session?.session?.id ?? null, + }, + { headers: rate.headers }, + ); + } catch (error) { + console.error('Session fetch error:', error); + return Response.json({ user: null, session: null }, { headers: rate.headers }); + } +}; + +export const Route = createFileRoute('/api/auth/session')({ + server: { handlers: { GET: handleGet } }, +}); diff --git a/packages/web/src/routes/api/auth/stripe/webhook.ts b/packages/web/src/routes/api/auth/stripe/webhook.ts new file mode 100644 index 000000000..5d61e0704 --- /dev/null +++ b/packages/web/src/routes/api/auth/stripe/webhook.ts @@ -0,0 +1,293 @@ +/** + * POST /api/auth/stripe/webhook — Stripe webhook receiver with a two-phase + * trust model. + * + * Phase 1 (pre-verify): record signature presence, payload hash, and dedupe + * before forwarding. Reject early on missing signature / unreadable body / + * test events in production. + * + * Phase 2 (post-verify): forward the raw body to better-auth's stripe handler + * and update the ledger row with verified fields and the final outcome. + */ +import { createFileRoute } from '@tanstack/react-router'; +import { env } from 'cloudflare:workers'; +import { createAuth } from '@corates/workers/auth-config'; +import { createDb } from '@corates/db/client'; +import { + insertLedgerEntry, + updateLedgerWithVerifiedFields, + updateLedgerStatus, + getLedgerByPayloadHash, + LedgerStatus, +} from '@corates/db/stripe-event-ledger'; + +interface StripeEvent { + id?: string; + type?: string; + livemode?: boolean; + api_version?: string; + created?: number; + data?: { + object?: { + metadata?: { referenceId?: string; orgId?: string }; + customer?: string; + subscription?: string; + id?: string; + mode?: string; + }; + }; +} + +type HandlerArgs = { request: Request; context?: { cloudflareCtx?: ExecutionContext } }; + +const ROUTE = '/api/auth/stripe/webhook'; + +async function sha256(input: string): Promise { + const buf = new TextEncoder().encode(input); + const hash = await crypto.subtle.digest('SHA-256', buf); + return Array.from(new Uint8Array(hash)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +function truncate(value: unknown, max = 500): string | null { + if (value === null || value === undefined) return null; + let s: string; + if (value instanceof Error) s = value.message; + else if (typeof value === 'string') s = value; + else { + try { + s = JSON.stringify(value); + } catch { + s = String(value); + } + } + return s.length > max ? s.slice(0, max) + '...[truncated]' : s; +} + +export const handlePost = async ({ request, context }: HandlerArgs) => { + const db = createDb(env.DB); + const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID(); + + let rawBody: string | undefined; + let ledgerId: string | undefined; + + try { + const signaturePresent = !!request.headers.get('stripe-signature'); + + try { + rawBody = await request.text(); + } catch (bodyError) { + ledgerId = crypto.randomUUID(); + await insertLedgerEntry(db, { + id: ledgerId, + payloadHash: 'unreadable_body', + signaturePresent: false, + route: ROUTE, + requestId, + status: LedgerStatus.IGNORED_UNVERIFIED, + error: truncate(bodyError as Error), + httpStatus: 400, + }); + console.error('[stripe-webhook] body unreadable', { + requestId, + error: truncate(bodyError as Error), + }); + return Response.json({ error: 'Body unreadable' }, { status: 400 }); + } + + if (!signaturePresent) { + ledgerId = crypto.randomUUID(); + const payloadHash = await sha256(rawBody); + await insertLedgerEntry(db, { + id: ledgerId, + payloadHash, + signaturePresent: false, + route: ROUTE, + requestId, + status: LedgerStatus.IGNORED_UNVERIFIED, + error: 'missing_signature', + httpStatus: 403, + }); + console.error('[stripe-webhook] missing signature', { requestId, payloadHash }); + return Response.json({ error: 'Missing Stripe signature' }, { status: 403 }); + } + + const payloadHash = await sha256(rawBody); + + const existingEntry = await getLedgerByPayloadHash(db, payloadHash); + if (existingEntry) { + console.info('[stripe-webhook] duplicate payload', { + requestId, + payloadHash, + ledgerId: existingEntry.id, + status: existingEntry.status, + }); + return Response.json({ received: true, skipped: 'duplicate_payload' }, { status: 200 }); + } + + ledgerId = crypto.randomUUID(); + await insertLedgerEntry(db, { + id: ledgerId, + payloadHash, + signaturePresent: true, + route: ROUTE, + requestId, + status: LedgerStatus.RECEIVED, + }); + console.info('[stripe-webhook] received', { requestId, payloadHash }); + + if (env.ENVIRONMENT === 'production') { + try { + const preCheckEvent = JSON.parse(rawBody) as StripeEvent; + if (preCheckEvent.livemode === false) { + await updateLedgerWithVerifiedFields(db, ledgerId, { + stripeEventId: preCheckEvent.id ?? null, + type: preCheckEvent.type ?? null, + livemode: false, + apiVersion: preCheckEvent.api_version ?? null, + created: preCheckEvent.created ? new Date(preCheckEvent.created * 1000) : null, + status: LedgerStatus.IGNORED_TEST_MODE, + httpStatus: 200, + }); + console.info('[stripe-webhook] ignored test mode in production', { + requestId, + stripeEventId: preCheckEvent.id, + stripeEventType: preCheckEvent.type, + }); + return Response.json( + { received: true, skipped: 'test_event_in_production' }, + { status: 200 }, + ); + } + } catch { + // Parse failed - let better-auth surface the error + } + } + + const auth = createAuth(env, context?.cloudflareCtx); + const url = new URL(request.url); + const authUrl = new URL(ROUTE, url.origin); + const authRequest = new Request(authUrl.toString(), { + method: 'POST', + headers: request.headers, + body: rawBody, + }); + + const response = await auth.handler(authRequest); + const httpStatus = response.status; + + if (httpStatus >= 200 && httpStatus < 300) { + let stripeEventId: string | null = null; + let eventType: string | null = null; + let livemode: boolean | null = null; + let apiVersion: string | null = null; + let created: Date | null = null; + let orgId: string | null = null; + let stripeCustomerId: string | null = null; + let stripeSubscriptionId: string | null = null; + let stripeCheckoutSessionId: string | null = null; + + try { + const event = JSON.parse(rawBody) as StripeEvent; + stripeEventId = event.id ?? null; + eventType = event.type ?? null; + livemode = event.livemode ?? null; + apiVersion = event.api_version ?? null; + created = event.created ? new Date(event.created * 1000) : null; + + const eventData = event.data?.object; + if (eventData) { + orgId = eventData.metadata?.referenceId ?? eventData.metadata?.orgId ?? null; + stripeCustomerId = eventData.customer ?? null; + stripeSubscriptionId = eventData.subscription ?? eventData.id ?? null; + if (eventType === 'checkout.session.completed') { + stripeCheckoutSessionId = eventData.id ?? null; + if (eventData.mode === 'subscription') { + stripeSubscriptionId = eventData.subscription ?? null; + } + } + } + } catch { + console.warn('[stripe-webhook] failed to parse verified body', { requestId, ledgerId }); + } + + await updateLedgerWithVerifiedFields(db, ledgerId, { + stripeEventId, + type: eventType, + livemode, + apiVersion, + created, + status: LedgerStatus.PROCESSED, + httpStatus, + orgId, + stripeCustomerId, + stripeSubscriptionId, + stripeCheckoutSessionId, + }); + console.info('[stripe-webhook] processed', { + requestId, + stripeEventId, + stripeEventType: eventType, + livemode, + orgId, + stripeCustomerId, + stripeSubscriptionId, + httpStatus, + }); + } else if (httpStatus === 401 || httpStatus === 403) { + await updateLedgerStatus(db, ledgerId, { + status: LedgerStatus.IGNORED_UNVERIFIED, + error: 'invalid_signature', + httpStatus, + }); + console.error('[stripe-webhook] invalid signature', { + requestId, + payloadHash, + httpStatus, + }); + } else { + let errorMessage = `HTTP ${httpStatus}`; + try { + const responseBody = await response.clone().text(); + errorMessage = truncate(responseBody) ?? errorMessage; + } catch { + // ignore clone/read errors + } + await updateLedgerStatus(db, ledgerId, { + status: LedgerStatus.FAILED, + error: errorMessage, + httpStatus, + }); + console.error('[stripe-webhook] failed', { + requestId, + payloadHash, + httpStatus, + error: errorMessage, + }); + } + + return response; + } catch (error) { + const truncated = truncate(error as Error); + console.error('[stripe-webhook] handler error', { requestId, ledgerId, error: truncated }); + + if (ledgerId) { + try { + await updateLedgerStatus(db, ledgerId, { + status: LedgerStatus.FAILED, + error: truncated, + httpStatus: 500, + }); + } catch { + // ignore ledger update errors in error handler + } + } + + return Response.json({ error: 'Webhook processing error' }, { status: 500 }); + } +}; + +export const Route = createFileRoute('/api/auth/stripe/webhook')({ + server: { handlers: { POST: handlePost } }, +}); diff --git a/packages/web/src/routes/api/auth/verify-email.ts b/packages/web/src/routes/api/auth/verify-email.ts new file mode 100644 index 000000000..a37749140 --- /dev/null +++ b/packages/web/src/routes/api/auth/verify-email.ts @@ -0,0 +1,61 @@ +/** + * GET /api/auth/verify-email — branded HTML wrapper around better-auth's + * verification endpoint. + * + * Forwards the inbound request (with original query) to better-auth, then + * swaps the response body for a CoRATES-styled success/failure/error page + * while preserving any Set-Cookie headers (auto-sign-in after verification). + */ +import { createFileRoute } from '@tanstack/react-router'; +import { env } from 'cloudflare:workers'; +import { createAuth } from '@corates/workers/auth-config'; +import { + getEmailVerificationSuccessPage, + getEmailVerificationFailurePage, + getEmailVerificationErrorPage, +} from '@/server/lib/authHtmlPages'; + +type HandlerArgs = { request: Request; context?: { cloudflareCtx?: ExecutionContext } }; + +export const handleGet = async ({ request, context }: HandlerArgs) => { + try { + const auth = createAuth(env, context?.cloudflareCtx); + const url = new URL(request.url); + const authUrl = new URL('/api/auth/verify-email', url.origin); + authUrl.search = url.search; + + const authRequest = new Request(authUrl.toString(), { + method: 'GET', + headers: request.headers, + }); + + const response = await auth.handler(authRequest); + + if (response.status >= 200 && response.status < 400) { + const setCookieHeaders = response.headers.getSetCookie?.() ?? []; + + const headers = new Headers(); + headers.set('Content-Type', 'text/html; charset=utf-8'); + for (const cookie of setCookieHeaders) { + headers.append('Set-Cookie', cookie); + } + + return new Response(getEmailVerificationSuccessPage(), { status: 200, headers }); + } + + return new Response(getEmailVerificationFailurePage(), { + status: response.status, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } catch (error) { + console.error('Email verification error:', error); + return new Response(getEmailVerificationErrorPage(), { + status: 500, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } +}; + +export const Route = createFileRoute('/api/auth/verify-email')({ + server: { handlers: { GET: handleGet } }, +}); diff --git a/packages/workers/src/auth/templates.ts b/packages/web/src/server/lib/authHtmlPages.ts similarity index 98% rename from packages/workers/src/auth/templates.ts rename to packages/web/src/server/lib/authHtmlPages.ts index 3dcf2bdf1..3ba836863 100644 --- a/packages/workers/src/auth/templates.ts +++ b/packages/web/src/server/lib/authHtmlPages.ts @@ -1,5 +1,6 @@ /** - * HTML templates for authentication pages + * HTML pages served by /api/auth/verify-email after better-auth's verification + * step. Inlined here so the auth route file owns its own response surface. */ export function getEmailVerificationSuccessPage(): string { @@ -88,12 +89,10 @@ export function getEmailVerificationSuccessPage(): string { - - - - -`; - - return html; -} From ce524104a2d320932b25eb5ee6be30415a29b21a Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 17 Apr 2026 23:15:29 -0500 Subject: [PATCH 08/11] clean up docs and readd sentry --- .../docs/audits/hono-to-tanstack-migration.md | 339 +++--------------- .../docs/audits/post-migration-regressions.md | 167 +++++++++ packages/web/package.json | 1 + packages/web/src/server.ts | 25 +- packages/web/wrangler.jsonc | 9 + pnpm-lock.yaml | 25 ++ 6 files changed, 269 insertions(+), 297 deletions(-) create mode 100644 packages/docs/audits/post-migration-regressions.md diff --git a/packages/docs/audits/hono-to-tanstack-migration.md b/packages/docs/audits/hono-to-tanstack-migration.md index 1eeadf630..5654097d7 100644 --- a/packages/docs/audits/hono-to-tanstack-migration.md +++ b/packages/docs/audits/hono-to-tanstack-migration.md @@ -1,334 +1,81 @@ -# Hono → TanStack Start backend migration +# Hono → TanStack Start migration -Handoff doc. Migration consolidates two Cloudflare Workers (`packages/workers` + `packages/web`) into one: the TanStack Start app in `packages/web` takes over every route that used to live in the Hono app. +The Hono app in `packages/workers` was migrated to TanStack Start file routes in `packages/web` over 27 numbered passes; tracked in [#484](https://github.com/InfinityBowman/corates/issues/484), [#485](https://github.com/InfinityBowman/corates/issues/485), [#486](https://github.com/InfinityBowman/corates/issues/486). Per-pass narrative lives in `git log`. Post-migration follow-ups (what didn't survive): [post-migration-regressions.md](./post-migration-regressions.md). -Branches: `migrate-backend` (Passes 0-5, merged), `migrate-billing-routes` (Passes 6-12, [#484](https://github.com/InfinityBowman/corates/issues/484), merged via PR #487), `migrate-admin-routes` (Passes 13-21, [#485](https://github.com/InfinityBowman/corates/issues/485), merged via PR #488), `retire-hono-app` (Passes 22-27, [#486](https://github.com/InfinityBowman/corates/issues/486), complete as of 2026-04-17 — Hono fully removed; admin CSRF restored Pass 26; library dead-code sweep Pass 27). +## Current architecture -## What's migrated +- **API routes** live in `packages/web/src/routes/api/**` as TanStack file routes. +- **`packages/workers`** is now a library workspace (Durable Objects, commands, lib helpers, auth/email, queue consumer) consumed via subpath exports declared in `packages/workers/package.json`. It no longer deploys. +- **`packages/web/src/server.ts`** is the worker entry. It (1) routes WebSocket-DO paths (`/api/project-doc/*`, `/api/sessions/*`) to DO stubs *before* TanStack — TanStack Start can't pass WS upgrades through, (2) calls `createStartHandler(defaultStreamHandler)` for everything else, threading `cloudflareCtx` through `context` so handlers can `waitUntil`, (3) wraps the whole thing in `Sentry.withSentry`, (4) delegates queue consumption to `handleEmailQueue` from `@corates/workers/queue`. -Tier 1 (prior sessions): - -- `/api/invitations/accept` -- `/api/users/avatar` + `/api/users/avatar/:userId` -- `/api/pdf-proxy` -- `/api/google-drive/*` (4 files: status, picker-token, disconnect, import) - -Tier 2 (this session, Passes 1-5): - -- **Pass 1** — org CRUD: `/api/orgs` (list/create), `/api/orgs/$orgId` (get/update/delete), `/api/orgs/$orgId/set-active` -- **Pass 2** — org members: `/api/orgs/$orgId/members` (list/add), `/api/orgs/$orgId/members/$memberId` (update/remove) -- **Pass 3** — projects: `/api/orgs/$orgId/projects` (list/create), `/api/orgs/$orgId/projects/$projectId` (get/update/delete), `/api/orgs/$orgId/projects/$projectId/members` + `$userId` -- **Pass 4** — invitations: `/api/orgs/$orgId/projects/$projectId/invitations` (list/create) + `$invitationId` (cancel) -- **Pass 5** — PDFs + dev: `.../studies/$studyId/pdfs` + `pdfs/$fileName`, and `.../dev/{templates,apply-template,export,import,reset,add-study}` - -Tier 3 (in progress, issue [#484](https://github.com/InfinityBowman/corates/issues/484)): - -- **Pass 6** — billing sync: `/api/billing/sync-after-success` (POST). Added `@corates/workers/commands/billing` subpath export. Updated `BillingSettings.tsx` from Hono RPC to plain fetch. -- **Pass 7** — billing portal: `/api/billing/portal` (POST). Created `packages/web/src/server/billing-context.ts` with `resolveOrgId`/`resolveOrgIdWithRole` (was `routes/billing/helpers/orgContext.ts` in Hono). Added `BILLING_PORTAL_RATE_LIMIT` to `server/rateLimit.ts`. Updated `api/billing.ts` `createPortalSession` to plain fetch. Removed obsolete portal describe from `packages/workers/src/routes/billing/__tests__/index.test.ts`. -- **Pass 8** — billing plan validation: `/api/billing/validate-plan-change` (GET). Uses `validatePlanChange` from `@corates/workers/billing-resolver` (already exported) and `resolveOrgId` from `@/server/billing-context`. Updated `api/billing.ts` `validatePlanChange` client to plain fetch. Removed `validate-plan-change` describe block from `billing/__tests__/index.test.ts`. -- **Pass 9** — billing trial: `/api/billing/trial/start` (POST). Owner-only one-shot trial grant. Uses `createGrant`/`getGrantByOrgIdAndType` from `@corates/db/org-access-grants`, `GRANT_CONFIG` from `@corates/workers/constants`, and `requireOrgOwner` from `@corates/workers/policies`. No corresponding Hono test existed. Updated `api/billing.ts` `startTrial` client to plain fetch. -- **Pass 10** — billing invoices: `/api/billing/invoices` (GET). Returns up to 10 most recent Stripe invoices for org's active/trialing subscription, empty list if none. Added `@corates/workers/stripe` subpath export for `createStripeClient`. Tests mock the Stripe client directly. Updated `InvoicesList.tsx` Hono RPC → plain fetch. -- **Pass 11** — billing read-only trio: `/api/billing/usage` (GET), `/api/billing/subscription` (GET), `/api/billing/members` (GET). Three separate route files. `subscription` uses `resolveOrgAccess` + `getPlan`/`getGrantPlan`. `members` delegates to better-auth's `listMembers` via `createAuth`. Replaced `InferResponseType` in `useSubscription.ts` with manual `Subscription` interface. Updated `BillingSettings.tsx`, `useSubscription.ts`, and `api/billing.ts` `getMembers` to plain fetch. Removed Hono `subscription` describe (2 tests) from `billing/__tests__/index.test.ts`. -- **Pass 12** — billing checkout trio: `/api/billing/validate-coupon` (POST), `/api/billing/checkout` (POST), `/api/billing/single-project/checkout` (POST). Three separate route files. Added `BILLING_CHECKOUT_RATE_LIMIT` to `server/rateLimit.ts`. Replaced workers' structured `createLogger` with plain `console.info`/`console.error` (same observability event names). After this strip, all three remaining describes (checkout, single-project, downgrade-validation) were obsolete — deleted the entire `packages/workers/src/routes/billing/__tests__/` directory. Updated `api/billing.ts` `createCheckoutSession` and `createSingleProjectCheckout` to plain fetch. - -Tier 3 admin (in progress, issue [#485](https://github.com/InfinityBowman/corates/issues/485)): - -- **Pass 13** — admin orgs: `/api/admin/orgs` (GET list), `/api/admin/orgs/$orgId` (GET details). Two route files. Created `packages/web/src/server/guards/requireAdmin.ts` (session check + `isAdminUser` role check, returns discriminated union like other guards). Added `@corates/workers/auth-admin` subpath export so the guard can reuse `isAdminUser`. Both routes are GET-only, so no CSRF/`requireTrustedOrigin` equivalent needed yet. Migrated `fetchOrgs`/`fetchOrgDetails` in `stores/adminStore.ts` from Hono RPC to plain fetch. -- **Pass 14** — admin storage: `/api/admin/storage/documents` (GET list, DELETE bulk), `/api/admin/storage/stats` (GET). Two route files (`storage/documents.ts`, `storage/stats.ts`). Tests use real R2 via `cloudflare:test`'s `env.PDF_BUCKET` (puts and lists actual objects) instead of mocking — same pattern as the PDFs route tests. `clearR2('projects/')` runs in `beforeEach`. The `stats.ts` R2 list call needs an explicit cast — `as { objects, truncated, cursor? }` — because web env doesn't pull in `@cloudflare/workers-types` (already documented in the gotchas). Migrated `useStorageDocuments` (in `useAdminQueries.ts`) and `deleteStorageDocuments` (in `adminStore.ts`) from Hono RPC to plain fetch. -- **Pass 15** — admin stats: `/api/admin/stats/{signups,organizations,projects,webhooks,subscriptions,revenue}` (6 GET routes). Six separate route files in `routes/api/admin/stats/`. Extracted shared `fillMissingDays` helper to `packages/web/src/server/lib/fillMissingDays.ts` (used by signups/organizations/projects). Stripe routes (subscriptions, revenue) mock `@corates/workers/stripe`'s `createStripeClient`. Migrated all 6 RPC callers in `components/admin/AnalyticsSection.tsx` to plain fetch via a tiny `fetchStats` helper. Tagged `useAdminStats` with `TODO(agent)` — that hook calls `GET /api/admin/stats` which has never had a backend endpoint (statsRoutes only mounts the 6 sub-paths above), kept it converted to plain fetch with the same dead URL to preserve runtime behavior. No prior Hono test file existed for stats. -- **Pass 16** — admin projects: `/api/admin/projects` (GET list), `/api/admin/projects/$projectId` (GET details + DELETE), `/api/admin/projects/$projectId/doc-stats` (GET; wakes ProjectDoc DO via `getProjectDocStub`), `/api/admin/projects/$projectId/members/$memberId` (DELETE). Four route files. The `doc-stats` route uses `@corates/workers/project-doc-id` (already a subpath export, also used by sync-profile and dev routes) and checks D1 first to avoid waking a DO for non-existent IDs. Migrated `useAdminProjects`/`useAdminProjectDetails`/`useAdminProjectDocStats` (in `useAdminQueries.ts`) and `removeProjectMember`/`deleteProject` (in `adminStore.ts`) from Hono RPC to plain fetch. Replaced the prior Hono test (which only covered `doc-stats`) with full coverage of all 4 routes. -- **Pass 17** — admin billing observability: `/api/admin/orgs/$orgId/billing/reconcile` (GET, deeply nested under admin/orgs), `/api/admin/billing/stuck-states` (GET global), `/api/admin/billing/ledger` (GET global). Three route files. Reconcile uses `getLedgerEntriesByOrgId` + `LedgerStatus` from `@corates/db/stripe-event-ledger` (already exported) and `createStripeClient` from `@corates/workers/stripe`. Stripe path mocks `@corates/workers/stripe` for the `checkStripe=true` case. Migrated `fetchBillingLedger`/`fetchBillingStuckStates`/`fetchOrgBillingReconcile` (in `adminStore.ts`) from Hono RPC to plain fetch. -- **Pass 18** — admin Stripe tools: `/api/admin/stripe/customer` (GET lookup by email|customerId), `/api/admin/stripe/portal-link` (POST), `/api/admin/stripe/customer/$customerId/{invoices,payment-methods,subscriptions}` (3 GETs). Five route files. All routes mock `@corates/workers/stripe`'s `createStripeClient` and exercise the customer-lookup → linked-org join via D1. No prior Hono test file existed. Migrated all 5 RPC callers in `routes/_app/_protected/admin/billing.stripe-tools.tsx` to plain fetch and dropped its `parseResponse`/`api` imports. -- **Pass 19** — admin database viewer: `/api/admin/database/tables` (GET list), `/api/admin/database/tables/$tableName/schema` (GET), `/api/admin/database/tables/$tableName/rows` (GET — mediaFiles takes a dedicated path that joins org/project/user), `/api/admin/database/analytics/{pdfs-by-org,pdfs-by-user,pdfs-by-project,recent-uploads}` (4 GETs). Seven route files. Extracted `ALLOWED_TABLES` whitelist + `isAllowedTable` helper to `packages/web/src/server/lib/dbTables.ts` (used by both schema and rows routes). Migrated `useAdminDatabaseTables`/`useAdminTableSchema`/`useAdminTableRows` (in `useAdminQueries.ts`) from Hono RPC to plain fetch. The 4 analytics endpoints had no frontend caller (dead in the typed RPC client); preserved the endpoints anyway. No prior Hono test file existed. -- **Pass 20** — admin users: `/api/admin/stats` (GET dashboard counts), `/api/admin/users` (GET list with pagination/search + provider join), `/api/admin/users/$userId` (GET details with projects/sessions/accounts/orgs+billing, DELETE with DO sync), `/api/admin/users/$userId/{ban,unban,impersonate}` (POST), `/api/admin/users/$userId/sessions` (DELETE all), `/api/admin/users/$userId/sessions/$sessionId` (DELETE one). Eight route files (stats split into its own file alongside the existing `stats/*` sub-routes — TanStack flat names handle parent + nested children fine). Self-ban/self-impersonate/self-delete return 400 with `details.constraint` (not `details.reason`) — that's how `createValidationError` shapes its details. Impersonate proxies to better-auth's `/api/auth/admin/impersonate-user` via `createAuth(env).handler(authRequest)`; tests mock `@corates/workers/auth-config` to return `{ handler: vi.fn() }`. Deleted Hono `packages/workers/src/__tests__/admin.test.ts` (8 tests, all covered the 4 routes migrated here). Reverted the wrong Pass 15 `useAdminStats` TODO — the `/api/admin/stats` endpoint was real (mounted by `userRoutes` in Hono); the hook now points to the new TanStack handler. Migrated `useAdminUsers`/`useAdminUserDetails` (in `useAdminQueries.ts`) and `impersonateUser`/`banUser`/`unbanUser`/`revokeUserSessions`/`revokeUserSession`/`deleteUser` (in `adminStore.ts`) from Hono RPC to plain fetch — `adminStore.ts` no longer references the typed `api.admin.users.*` client. -- **Pass 21** — admin billing (org subscriptions + grants): `/api/admin/orgs/$orgId/billing` (GET full billing snapshot), `/api/admin/orgs/$orgId/subscriptions` (POST create), `/api/admin/orgs/$orgId/subscriptions/$subscriptionId` (PUT update / DELETE soft-cancel), `/api/admin/orgs/$orgId/grants` (POST create with trial-uniqueness + `expiresAt > startsAt` validation), `/api/admin/orgs/$orgId/grants/$grantId` (PUT update — `expiresAt` extends or `revokedAt` revokes/unrevokes / DELETE revokes), `/api/admin/orgs/$orgId/grant-trial` (POST 14-day convenience), `/api/admin/orgs/$orgId/grant-single-project` (POST 6-month, extends existing non-revoked grant by 6 months from `max(now, expiresAt)` returning `action: 'extended'` 200, otherwise `action: 'created'` 201). Seven route files. Added `@corates/workers/notify` subpath export so the subscription mutation routes can re-use `notifyOrgMembers`/`EventTypes`. Notification dispatch uses a small `dispatchSubscriptionNotify` helper exported from `subscriptions.ts` and re-imported by the `$subscriptionId.ts` PUT/DELETE — it uses `cloudflareCtx.waitUntil` when the TanStack handler receives `context?.cloudflareCtx`, otherwise awaits inline (test-friendly). The same `HandlerArgs` extension pattern (`context?: { cloudflareCtx?: ExecutionContext }`) is the recommended way to thread `waitUntil` into TanStack routes — first instance in the codebase. Deleted Hono `packages/workers/src/routes/admin/__tests__/admin-billing.test.ts` (12 tests, all covered routes migrated here). Migrated `fetchOrgBilling`/`createOrgSubscription`/`updateOrgSubscription`/`cancelOrgSubscription`/`createOrgGrant`/`revokeOrgGrant`/`grantOrgTrial`/`grantOrgSingleProject` (in `adminStore.ts`) from Hono RPC to plain fetch via a tiny `adminBillingMutate` helper that handles invalidation. **`adminStore.ts` no longer imports `parseResponse` or `api`** — admin tier is fully on TanStack from the client side too. - -**Admin tier complete.** All admin routes are TanStack. Every route a regular user or admin hits is now on TanStack. Hono still serves `/api/auth/*` (better-auth catch-all), Stripe webhooks, and DO WebSocket upgrades — that's it. - -Tier 4 (in progress, issue [#486](https://github.com/InfinityBowman/corates/issues/486)): - -- **Pass 27** — Library-only dead-code sweep. Walked the dependency graph from each `@corates/workers/*` subpath export and the `test-worker.ts` entry; identified 4 orphans: - - `src/commands/index.ts` — barrel re-export never imported (web pulls from `./commands/{invitations,projects,members,billing}` subpaths directly). - - `src/config/validation.ts` + `src/config/__tests__/validation.test.ts` — Zod schemas built for the deleted Hono routes; only consumer is the test file itself. - - `src/docs.ts` — HTML for the deleted `/docs` Hono route. - - Verified `lib/send-invitation-email.ts`, `durable-objects/dev-handlers.ts`, `lib/escapeHtml.ts`, `lib/mock-templates.ts`, `lib/avatar-copy.ts`, `lib/subscriptionStatus.ts`, `lib/email-queue.ts`, `auth/oauth-relay.ts`, `policies/{billing,orgs,lib/roles}.ts` all reachable (some via dynamic `await import(...)`). Workers tests **141 → 113** (-28 from `validation.test.ts`); typechecks still clean. - -- **Pass 26** — Restore admin CSRF protection. Audited the admin TanStack tier and confirmed the `requireTrustedOrigin` guard never propagated from the Hono umbrella mount during Passes 13-21 — only `stop-impersonation.ts` (Pass 23) carried it. 16 mutating admin routes (POST/PUT/DELETE) were exposed. Folded the CSRF check into `requireAdmin` itself instead of duplicating the call across every route — admin gate is now CSRF (mutations only) → session present → role admin. Internal short-circuit on GET/HEAD/OPTIONS keeps admin reads unaffected. Updated 5 admin test files (`users`, `billing`, `projects`, `storage`, `stripe-tools`) to send `origin: http://localhost:3010` on every mutating request — added the header inline on raw `new Request(...)` calls and to centralized `jsonReq`/`deleteReq` helpers where they exist. Added 2 new tests in `users.server.test.ts` that assert both `missing_origin` and `untrusted_origin` are rejected with 403 / `AUTH_FORBIDDEN` before the admin role check fires. Net: **all admin mutations are now CSRF-protected again**; web tests **402 → 404** (+2 CSRF guard tests, no test deletions). - -- **Pass 25** — Strip `packages/workers` to a library workspace. Deleted `src/index.ts` (Hono entry), `src/auth/routes.ts` (last empty stub), `src/routes/` (admin + billing + orgs stubs and `health.ts`, all migrated), `src/middleware/` (every Hono middleware + their tests), `src/lib/honoValidationHook.ts`, `src/lib/runMiddleware.ts`, `src/lib/observability/logger.ts`, `src/types/context.ts`, `src/schemas/common.ts`, `src/__tests__/{app,health}.test.ts`. New `src/queue.ts` re-houses the email-queue consumer (no Hono deps); new `src/durable-objects/index.ts` re-exports `UserSession` + `ProjectDoc`; new `src/test-worker.ts` is the wrangler `main` for the test pool only (re-exports DOs, returns 404 for any fetch — tests call helpers directly). Inlined `OrgBilling` into `lib/billingResolver.ts` (its only consumer) since `types/context.ts` is gone. Replaced `Logger` references in `lib/retry.ts` and `lib/syncWithRetry.ts` with `console.warn`/`console.error` + a `logContext` field on retry options (Pass 12 / Pass 22 precedent). `package.json`: dropped `main`, `.` export, `dev`/`tail`/`logs`/`clear-workers`/`build:rpc(:watch)` scripts, and `hono` / `@hono/zod-openapi` / `@sentry/cloudflare` deps. Added subpath exports `./durable-objects` (with hand-written `durable-objects.d.ts` firewall stub) and `./queue` (with `queue.d.ts`). Repointed `wrangler.jsonc`'s `main` to `src/test-worker.ts` and re-ran `wrangler types` to refresh `worker-configuration.d.ts`. Web side: deleted `src/routes/api/$.ts` (Hono catch-all forwarder) and `src/lib/rpc.ts` (unused Hono RPC client); dropped `hono` from `web/package.json`, dropped the `@workers/rpc` path alias from `web/tsconfig.json`, removed `packages/workers/dist/`, replaced `DetailedError`-based branches in `lib/queryClient.ts` and `lib/error-utils.ts` with plain shape checks (parsed-JSON throws, no Hono RPC anymore). Workers tests: 217 → 141 (76 lost across 9 deleted middleware/app/health test files; all those routes have TanStack equivalents in web). Web tests unchanged at 402. **The Hono dependency is fully gone from the runtime path** — `pnpm --filter web build` produces a worker that does not import `hono`. - -- **Pass 24** — WebSocket DO upgrades into the worker entry: `/api/project-doc/(/<...>)?` and `/api/sessions/(/<...>)?` now route via two regex matches at the top of `packages/web/src/server.ts`'s `fetch` handler, before TanStack Start. ProjectDoc uses `getProjectDocStub(env, projectId).fetch(request)`; UserSession uses `env.USER_SESSION.idFromName(sessionId).get(...).fetch(request)` directly. Forwarding the original `Request` preserves WS upgrade headers. Removed the WS-only fallback that previously sent every `upgrade: websocket` request to `workerHandler.fetch`. The Hono mounts in `packages/workers/src/index.ts` (`base.all('/api/project-doc/...')` etc.) become dead code since they're shadowed by the new top-level match — kept until Pass 25 deletes the file. Legacy 410 endpoints (`/api/orgs/$orgId/project-doc/$projectId`, `/api/project/$projectId`) still answer through the existing `/api/$.ts` Hono catch-all and will be addressed when that catch-all is dropped in Pass 25. No tests added — `server.ts` is the entry point, not a route handler, and the regex matching is exercised the moment any WS client connects. - -- **Pass 23** — admin stop-impersonation: `POST /api/admin/stop-impersonation`. One route file. CSRF-guarded (Origin/Referer must match a trusted origin) but bypasses the `requireAdmin` umbrella because the impersonated user does not carry the admin role — only better-auth's session check applies downstream. Created `packages/web/src/server/guards/requireTrustedOrigin.ts` (Hono-shape error: `AUTH_FORBIDDEN` with `details.reason: missing_origin | untrusted_origin`). Added `@corates/workers/config/origins` subpath export so the guard reuses `isOriginAllowed`/`STATIC_ORIGINS` (still authoritative for both packages until workers retires). Forwards cookie/origin/referer + `accept: application/json` to `/api/auth/admin/stop-impersonating` via `auth.handler`. Stripped the inline POST handler (and the `requireTrustedOrigin` middleware mount) from `packages/workers/src/index.ts`; deleted `packages/workers/src/__tests__/stop-impersonation.test.ts` (8 tests, all replaced). 7 new tests covering CSRF (4) + forwarding (2) + error path (1). Note: the broader admin tier (`requireAdmin` umbrella) does not currently apply CSRF in TanStack — `requireTrustedOrigin` was a Hono mount-time concern that didn't propagate during Passes 13-21. Worth a follow-up audit but out of scope for this pass. - -- **Pass 22** — `/api/auth/*` + Stripe webhook: `/api/auth/session` (custom WebSocket session payload, GET), `/api/auth/verify-email` (branded HTML wrapper around better-auth's verification, GET), `/api/auth/stripe/webhook` (POST, two-phase ledger trust model), `/api/auth/$` (catch-all that proxies to better-auth's `auth.handler`). Four route files. Migrated together because the catch-all would otherwise shadow the webhook — TanStack's specificity sort (Index → Static most-specific → Dynamic longest → Splat) puts `stripe/webhook.ts` ahead of `$.ts`, and the same rule lets `session.ts`/`verify-email.ts` win against the splat. Added `AUTH_RATE_LIMIT` and `SESSION_RATE_LIMIT` to `server/rateLimit.ts`. The catch-all rate-limits the same paths the Hono mount did: `/api/auth/get-session` (session), and `/api/auth/{sign-in,sign-up,forget-password,reset-password,magic-link}/*` (auth). Moved `auth/templates.ts` into `packages/web/src/server/lib/authHtmlPages.ts` (only consumed by `verify-email.ts`). The Stripe webhook ports the two-phase trust model verbatim (Phase 1: signature presence + payload-hash dedupe + early reject for missing signature / unreadable body / `livemode=false` in production; Phase 2: forward raw body to better-auth, classify response into `processed` / `ignored_unverified` / `failed` and update ledger row accordingly). Replaced `createLogger`/`sha256`/`truncateError` from `lib/observability/logger.ts` with inline `console.info`/`console.error` plus 5-line local `sha256`/`truncate` helpers (same observability event names, Pass 12 precedent). Stripped Hono `auth/routes.ts` to an empty router, deleted `auth/templates.ts`. No prior Hono tests covered the webhook (`__tests__/app.test.ts` had 4 unrelated tests; nothing for the catch-all or webhook). 18 new tests across 2 files (`webhook.server.test.ts` 8, `auth.server.test.ts` 10). - -## What's left - -Tracking issues: - -- [#484](https://github.com/InfinityBowman/corates/issues/484) — Migrate billing (non-webhook) routes — **done** -- [#485](https://github.com/InfinityBowman/corates/issues/485) — Migrate admin routes — **done** -- [#486](https://github.com/InfinityBowman/corates/issues/486) — Retire `packages/workers` Hono app — **done** - -The migration is complete. `packages/workers` is now a library workspace (DOs + commands + lib helpers + auth/email + queue consumer); every route runs in `packages/web` on TanStack Start; Hono is gone from both runtime and build dependencies. - -Optional follow-ups (not blocking): - -- Option 2 from the prior end-state notes (folding `packages/workers` into `packages/web` or a new `packages/core`) is still available; the subpath imports are clean enough that the move is mechanical. - -## Migration pattern (the recipe) - -### File layout - -One TanStack file per route key. Shared path prefixes become directories. - -``` -packages/web/src/routes/api/orgs/$orgId/projects/$projectId/ -├── invitations.ts # GET list, POST create -├── invitations/ -│ └── $invitationId.ts # DELETE cancel -├── members.ts # GET list, POST add -├── members/ -│ └── $userId.ts # PUT update, DELETE remove -└── __tests__/ - ├── invitations.server.test.ts - └── members.server.test.ts -``` - -TanStack's codegen regenerates `packages/web/src/routeTree.gen.ts` on file changes (gitignored). Sometimes needs a save-trigger or a `pnpm --filter web build` to pick up new files. - -### Handler shape +## Adding a route ```ts +// packages/web/src/routes/api/orgs/$orgId/some-path.ts import { createFileRoute } from '@tanstack/react-router'; import { env } from 'cloudflare:workers'; import { requireOrgMembership } from '@/server/guards/requireOrgMembership'; -import { requireProjectAccess } from '@/server/guards/requireProjectAccess'; -import { requireOrgWriteAccess } from '@/server/guards/requireOrgWriteAccess'; -type HandlerArgs = { request: Request; params: { orgId: string; projectId: string } }; +type HandlerArgs = { request: Request; params: { orgId: string } }; export const handleGet = async ({ request, params }: HandlerArgs) => { - const orgMembership = await requireOrgMembership(request, env, params.orgId); - if (!orgMembership.ok) return orgMembership.response; - - const access = await requireProjectAccess(request, env, params.orgId, params.projectId); - if (!access.ok) return access.response; - + const guard = await requireOrgMembership(request, env, params.orgId); + if (!guard.ok) return guard.response; // ... business logic, return Response.json(...) }; -export const Route = createFileRoute('/api/orgs/$orgId/projects/$projectId/some-path')({ - server: { handlers: { GET: handleGet, POST: handlePost } }, +export const Route = createFileRoute('/api/orgs/$orgId/some-path')({ + server: { handlers: { GET: handleGet } }, }); ``` -Every handler takes `HandlerArgs`. Export `handleGet`/`handlePost`/`handlePut`/`handleDelete` named so tests can import directly without going through the router. - -### Guards +- Export `handleGet`/`handlePost`/`handlePut`/`handleDelete` named so tests can import directly without going through the router. +- TanStack regenerates `routeTree.gen.ts` on file changes (gitignored). Sometimes needs a `pnpm --filter web build` to pick up new files. +- For fire-and-forget work (notifications, async logging, ledger updates), extend args with `context?: { cloudflareCtx?: ExecutionContext }` and check `context?.cloudflareCtx?.waitUntil`. Tests omit it; production has it. See `routes/api/admin/orgs/$orgId/subscriptions.ts`'s `dispatchSubscriptionNotify` helper. -Hono middleware → sync guard functions returning a discriminated union: +## Guards (`packages/web/src/server/guards/`) -```ts -type Result = { ok: true; context: {...} } | { ok: false; response: Response }; -``` - -Live in `packages/web/src/server/guards/`: +Discriminated-union return: `{ ok: true; context } | { ok: false; response }`. -- `requireOrgMembership(request, env, orgId, minRole?)` — session + org member lookup, optional role check -- `requireProjectAccess(request, env, orgId, projectId, minRole?)` — validates project exists, belongs to orgId, user is project member, optional role -- `requireOrgWriteAccess(method, env, orgId)` — early-returns for GET/HEAD/OPTIONS; otherwise checks `accessMode !== 'readOnly'` -- `requireEntitlement(env, orgId, key)` — checks `orgBilling.entitlements[key]` -- `requireQuota(env, orgId, getUsage, requested?)` — checks `used + requested <= limit` (unlimited via `isUnlimitedQuota`) -- `requireAdmin(request, env)` — session + `isAdminUser` role check (uses `@corates/workers/auth-admin` for the shared `isAdminUser` helper) +- `requireOrgMembership(request, env, orgId, minRole?)` +- `requireProjectAccess(request, env, orgId, projectId, minRole?)` +- `requireOrgWriteAccess(method, env, orgId)` — early-returns for read methods +- `requireEntitlement(env, orgId, key)` +- `requireQuota(env, orgId, getUsage, requested?)` +- `requireAdmin(request, env)` — bundles CSRF (mutations only) → session → admin role. CSRF is bundled here because the Hono mount applied `requireTrustedOrigin` umbrella-style; bundling preserves that without per-route boilerplate. +- `requireTrustedOrigin(request, { isProduction })` — standalone CSRF; only `stop-impersonation.ts` uses it directly (impersonated user lacks admin role). -Call order for write routes: `requireOrgMembership` → `requireOrgWriteAccess` → `requireProjectAccess`. +Call order for write routes: `requireOrgMembership` → `requireOrgWriteAccess` → `requireProjectAccess`. For project sub-routes, put `requireOrgMembership` first so a stranger gets `not_org_member` rather than `project_access_denied`. -For project sub-routes (members, invitations, PDFs etc.), you typically want `requireOrgMembership` first so the caller gets `not_org_member` rather than `project_access_denied` when they're a total stranger. +## Tests -### Test shape +Tests call handlers directly — no routing, no `app.fetch`. Use `env` from `cloudflare:test` for real D1/R2/DOs. ```ts -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { env } from 'cloudflare:test'; -import { resetTestDatabase, clearProjectDOs } from '@/__tests__/server/helpers'; -import { buildProject, resetCounter } from '@/__tests__/server/factories'; -import { handleGet, handlePost } from '../some-route'; - -let currentUser = { id: 'user-1', email: 'user1@example.com' }; +import { handleGet } from '../some-route'; vi.mock('@corates/workers/auth', () => ({ - getSession: async () => ({ - user: { id: currentUser.id, email: currentUser.email, name: 'Test User' }, - session: { id: 'test-session', userId: currentUser.id }, - }), -})); - -vi.mock('@corates/workers/billing-resolver', () => ({ - resolveOrgAccess: vi.fn(async () => ({ - accessMode: 'write', - source: 'free', - quotas: { 'projects.max': 10, 'collaborators.org.max': -1 }, - entitlements: { 'project.create': true }, - })), + getSession: async () => ({ user: { id: 'u1', email: 'u1@example.com', name: 'U1' }, session: { id: 'sess', userId: 'u1' } }), })); - -beforeEach(async () => { - await resetTestDatabase(); - await clearProjectDOs(['project-1']); - vi.clearAllMocks(); - resetCounter(); - currentUser = { id: 'user-1', email: 'user1@example.com' }; -}); - -function jsonReq(path: string, method: string, body?: unknown): Request { - return new Request(`http://localhost${path}`, { - method, - headers: { 'Content-Type': 'application/json' }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); -} ``` -Tests call handlers directly — no routing, no Hono, no `app.fetch`. Use `env` from `cloudflare:test` for R2/DB. Real R2 binding works in vitest-pool-workers (see avatar and PDF tests). - Mock `@corates/workers/auth.getSession` to impersonate users. Mock `@corates/workers/billing-resolver.resolveOrgAccess` for quota/entitlement paths. Postmark mock only needed if the route sends email. -## Subtleties and gotchas - -**Workers subpath exports.** When the web route needs a workers-side command/helper, export it from `packages/workers/package.json`: - -```json -"./commands/invitations": "./src/commands/invitations/index.ts", -"./commands/projects": "./src/commands/projects/index.ts", -"./commands/members": "./src/commands/members/index.ts", -"./commands/billing": "./src/commands/billing/index.ts", -"./stripe": "./src/lib/stripe.ts", -"./billing-resolver": "./src/lib/billingResolver.ts", -"./quota-transaction": "./src/lib/quotaTransaction.ts", -"./constants": "./src/config/constants.ts", -"./ssrf-protection": "./src/lib/ssrf-protection.ts", -"./media-files": "./src/lib/media-files.ts", -"./policies/projects": "./src/policies/projects.ts", -"./policies": "./src/policies/index.ts", -"./project-doc-id": "./src/lib/project-doc-id.ts" -``` - -**Web env has stricter types.** `@cloudflare/workers-types` isn't pulled in. When web transitively compiles workers code, R2 types can come back as `unknown`. Fix with explicit annotations at the boundary — see `packages/workers/src/commands/lib/doSync.ts` where the R2 `list()` return is typed inline. - -**Catch-all `/api/$.ts`.** TanStack file routes take priority; anything unmatched forwards to the Hono app. That's why partial migrations don't break other endpoints. Keep the mount intact until all Hono routes are gone. - -**Hono stub pattern when stripping.** Don't delete the Hono router file — the parent mount still imports from it. Strip to an empty router: - -When the Hono test file bundles multiple routes (e.g. `packages/workers/src/routes/billing/__tests__/index.test.ts` has 6 `describe` blocks), delete only the `describe` block matching the migrated route, not the whole file. Otherwise the remaining routes lose their tests. - -```ts -import { OpenAPIHono, $ } from '@hono/zod-openapi'; -import { requireAuth } from '../../middleware/auth.js'; -import { validationHook } from '../../lib/honoValidationHook.js'; -import type { Env } from '../../types'; - -const base = new OpenAPIHono<{ Bindings: Env }>({ defaultHook: validationHook }); -const orgInvitationRoutes = $(base.use('*', requireAuth)); -export { orgInvitationRoutes }; -``` - -Delete the corresponding Hono test file (`packages/workers/src/routes/__tests__/*.test.ts`). - -**`createInvitation` always sets `grantOrgMembership: true`.** The Hono schema accepted `grantOrgMembership` as optional, but the command hard-codes true. Tests rely on this. - -**Validation error codes are picky.** Missing required field = `createValidationError(field, VALIDATION_ERRORS.FIELD_REQUIRED.code, null, 'required')` with 400. Blank-but-present can map to a different error (see POST `/api/orgs` where missing `name` is 400/VALIDATION but blank `name` is 403/AUTH_FORBIDDEN `name_required`). - -**`getDomainError` on the client.** The client `apiFetch` throws parsed domain errors. When rejecting from the catch, `throw data` directly (if data already has `code` + `statusCode`) rather than wrapping in an Error with `.response`. - -**`DEV_MODE` in tests.** For dev routes, added `"DEV_MODE": true` to `packages/web/wrangler.test.jsonc` vars. Don't try `vi.mock('cloudflare:workers')` — it hangs the pool. - -**Linter collapses multi-line call expressions.** Prettier collapses guards like `requireProjectAccess(request, env, params.orgId, params.projectId, 'owner')` to one line on save. Expected, don't fight it. - -**`vi.mock` factories can't reference module-scoped variables.** `vi.mock` is hoisted above all imports, so a factory referencing a top-level `const mockFn = vi.fn()` throws `Cannot access 'mockFn' before initialization`. Use `const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }))` when the test body needs to inspect the mock (e.g. asserting `toHaveBeenCalledWith` on `syncMemberToDO` or `auth.handler`). - -**Threading `waitUntil` into TanStack handlers.** `src/server.ts` injects `cloudflareCtx` into every TanStack request via `context: { cloudflareCtx: ctx }`. Routes that want fire-and-forget work (notifications, async logging) extend their handler args with `context?: { cloudflareCtx?: ExecutionContext }` and check `context?.cloudflareCtx?.waitUntil` at runtime. When absent (tests, fallback) the work is awaited inline. `routes/api/admin/orgs/$orgId/subscriptions.ts` exports a reusable `dispatchSubscriptionNotify` helper that does this. - -**TanStack file-route specificity wins over splats.** From the Route Matching docs: routes are sorted Index → Static (most-specific first) → Dynamic (longest first) → Splat. This is why the Pass 22 catch-all `routes/api/auth/$.ts` does not shadow sibling exact files like `routes/api/auth/session.ts` or the deeper `routes/api/auth/stripe/webhook.ts`, and the existing `/api/$.ts` Hono catch-all hasn't shadowed any of the migrated tiers either. The order routes are defined or codegened doesn't matter — TanStack re-sorts. - -**Catch-all handlers must be exported when tested.** TanStack types `server.handlers` as a function (not a record), so accessing `Route.options.server!.handlers!.POST` from tests fails to typecheck even though it works at runtime. Export the handler function (e.g. `export const handle = ...`) and import it directly into tests, same as the named `handleGet`/`handlePost` pattern. - -## Client caller translation - -Client callers that were using Hono RPC (`honoClient.api.orgs[':orgId'].$get(...)`) were rewritten to plain `fetch(\`\${API_BASE}/api/orgs/\${orgId}\`, {...})`. The URLs are identical; the TanStack routes serve the same paths. Completed updates: - -- `project/actions/project.ts` -- `project/actions/members.ts` -- `components/dashboard/ProjectsSection.tsx` -- `components/project/CreateProjectModal.tsx` -- `components/project/overview-tab/AddMemberModal.tsx` -- `api/google-drive.ts` -- `routes/_auth/complete-profile.tsx` - -Most components already used plain `fetch`, so no code changes were needed for PDFs (`api/pdf-api.ts`), avatar (`components/settings/ProfileInfoSection.tsx`), or dev routes (`components/dev/*`). - -## Test counts (2026-04-17) - -- Web server tests: **404 passing** across 39 files (Pass 26: +2 CSRF guard tests in `users.server.test.ts`) -- Workers tests: **113 passing** across 9 files (Pass 27 deleted `config/__tests__/validation.test.ts` — 28 tests, schemas were orphaned) -- Web typecheck: clean modulo 2 pre-existing e2e errors (`timeout` in TestDetails, unused `loginWithApiCookies`). The third pre-existing `src/server.ts` queue() arity error was fixed in Pass 25 by rewriting the queue handler to call `handleEmailQueue(batch, env)` (2 args, matching the new signature). -- Workers typecheck: clean - -## How to run - -```bash -cd packages/web -pnpm exec vitest run --config vitest.server.config.ts # all web server tests -pnpm exec vitest run --config vitest.server.config.ts path/to/test.server.test.ts # single file - -cd packages/workers -pnpm test # all workers tests - -pnpm --filter web typecheck -pnpm --filter workers typecheck -``` - -Never start dev servers — the user does that. - -## Recommended Tier 3 order - -1. ~~**Billing non-webhook first** ([#484](https://github.com/InfinityBowman/corates/issues/484)). Done — Passes 6-12.~~ -2. ~~**Admin**~~ ([#485](https://github.com/InfinityBowman/corates/issues/485)). Done — Passes 13-21. ~~`orgs.ts` (393, Pass 13)~~, ~~`storage.ts` (525, Pass 14)~~, ~~`stats.ts` (665, Pass 15)~~, ~~`projects.ts` (772, Pass 16)~~, ~~`billing-observability.ts` (788, Pass 17)~~, ~~`stripe-tools.ts` (808, Pass 18)~~, ~~`database.ts` (997, Pass 19)~~, ~~`users.ts` (1,092, Pass 20)~~, ~~`billing.ts` (1,195, Pass 21)~~. -3. **Retire Hono app** ([#486](https://github.com/InfinityBowman/corates/issues/486)). Port `/api/auth/*` and Stripe webhooks, then strip workers to library-only. - -Stripe webhooks stay on Hono. The `/api/auth/*` catch-all stays on Hono (better-auth). - -## End state: retiring `packages/workers` - -The workers package is already non-deploying (its `deploy` script errors out with "This package is retired. Deploy from packages/web"). After Tier 3, the Hono app will be an empty shell — every route stubbed. At that point there are three remaining pieces of Hono surface to unwind: - -- `/api/auth/*` — better-auth handler. It's just a fetch handler; port to a TanStack catch-all route file. -- Stripe webhooks — need raw body for signature verification. Port to a TanStack handler that reads `request.clone().text()` before parsing. -- `packages/workers/src/index.ts` + middleware plumbing — deleted once nothing mounts Hono. - -### What stays valuable in `packages/workers` - -Even after the Hono app is gone, these modules remain useful and are already consumed by web via subpath exports: - -- DO classes: `UserSession`, `ProjectDoc` (web's `wrangler.jsonc` references them by class name; web's `src/server.ts` re-exports them for the Worker runtime) -- Commands: `createInvitation`, `createProject`, `createMember`, etc. -- Shared lib: `billingResolver`, `quotaTransaction`, `policies`, `media-files`, `project-doc-id`, `ssrf-protection`, `constants` -- Auth: `auth-config`, `email-templates`, session helper - -### End state options - -1. **Keep `packages/workers` as a library-only workspace.** Delete `src/routes/`, `src/middleware/`, `src/index.ts`, `src/__tests__/test-worker.ts` (if still present). Remove the `main` field and `deploy` script from `package.json`. Web continues to import DOs and commands via `@corates/workers/*` exports exactly as it does today. Simplest landing. +**Mutating admin tests must include a trusted Origin header** because `requireAdmin` runs CSRF first. Use `origin: 'http://localhost:3010'` in request init or via the `jsonReq`/`deleteReq` helpers in those test files. -2. **Fold everything into `packages/web`** (or a new `packages/core`). Move DO classes, commands, policies, and lib into web's source tree. Delete `packages/workers` entirely. Touches every `@corates/workers/*` import site. A separate refactor; doesn't block anything. +## Gotchas worth knowing -Option 1 is the natural follow-up after Tier 3 lands. Option 2 is optional cleanup. +- **Subpath exports + type firewalls.** Web doesn't pull in `@cloudflare/workers-types`. Any subpath export whose public surface includes `DurableObject` or `MessageBatch` types needs a hand-written `.d.ts` firewall stub — see `packages/workers/{durable-objects,queue,auth,types}.d.ts`. Without it web's tsc complains about missing `ctx`/`Message`. +- **R2 boundary types.** When web transitively compiles workers code, R2 types can come back as `unknown`. Annotate explicitly at the boundary — see `packages/workers/src/commands/lib/doSync.ts`. +- **TanStack file-route specificity wins over splats.** Routes sort Index → Static (most-specific first) → Dynamic (longest first) → Splat ([Route Matching docs](https://tanstack.com/router/latest/docs/routing/route-matching)). So `routes/api/auth/stripe/webhook.ts` reliably beats `routes/api/auth/$.ts` regardless of definition order. +- **Catch-all handlers must be exported when tested.** TanStack types `server.handlers` as a function (not a record), so `Route.options.server!.handlers!.POST` fails to typecheck. Export the handler function (`export const handle = ...`) and import it directly. +- **`vi.mock` factories can't reference module-scoped variables.** Hoisting throws `Cannot access 'mockFn' before initialization`. Use `const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }))`. +- **Self-action validation uses `details.constraint`, not `details.reason`.** `createValidationError` puts the value in `details.constraint`. Trip wires: self-ban, self-impersonate, self-delete in admin user routes. +- **`createInvitation` always sets `grantOrgMembership: true`.** The Hono schema accepted it as optional, but the command hard-codes true. Tests rely on this. +- **`DEV_MODE` in tests.** Dev routes need `"DEV_MODE": true` in `packages/web/wrangler.test.jsonc`. Don't try `vi.mock('cloudflare:workers')` — it hangs the pool. +- **Client error throws are plain objects.** Callers `throw data` directly where `data` already has `code` + `statusCode`. The Hono-RPC era `DetailedError instanceof` checks are gone — just shape-check. -## If you pick this up cold +## Future: fold workers into web -1. Read the current status: `git status`, `git log --oneline -10` on `migrate-backend`. -2. Confirm tests still green: `pnpm --filter web test` + `pnpm --filter workers test`. -3. Pick the smallest Tier 3 file. Read the Hono route + its test file. -4. Clone the pattern: guards → handler → test → strip Hono to stub → delete Hono test → run both suites. -5. Each migration is its own pass. Don't batch. +The workers package is now a library workspace whose only consumer is web. Folding its source into `packages/web/src/server/` would eliminate the subpath-export indirection. Mechanical refactor; touches every `@corates/workers/*` import site. Do it when the indirection becomes annoying. diff --git a/packages/docs/audits/post-migration-regressions.md b/packages/docs/audits/post-migration-regressions.md new file mode 100644 index 000000000..af52d43d8 --- /dev/null +++ b/packages/docs/audits/post-migration-regressions.md @@ -0,0 +1,167 @@ +# Post-migration regressions — what the Hono retirement actually lost + +The Hono → TanStack Start migration ([#484](https://github.com/InfinityBowman/corates/issues/484), [#485](https://github.com/InfinityBowman/corates/issues/485), [#486](https://github.com/InfinityBowman/corates/issues/486)) ported every route. After verifying claim-by-claim against current code, the genuine regressions are smaller than they first appeared. + +Severity legend: + +- **block** — fix before merging the `retire-hono-app` branch +- **soon** — degraded contract or operational visibility; fix shortly after merge +- **followup** — quality-of-life; no immediate impact + +--- + +## block — none + +Initial audit flagged security headers and CSRF as blockers. Both turned out to be already-handled or already-fixed: + +- **Security headers (HTML + static assets):** `packages/web/src/routes/__root.tsx` sets `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and CSP on every TanStack-served HTML response. `packages/web/public/_headers` (Cloudflare Pages-style static asset config) sets the same on every static response. The Hono middleware was redundant for the SPA's HTML. +- **Admin CSRF:** restored Pass 26 by folding `requireTrustedOrigin` into `requireAdmin`. Verified by tests in `users.server.test.ts`. + +Nothing is a hard merge blocker. + +--- + +## soon — operational gaps + +### Health deep-check endpoint — gone + +**What we had.** `routes/health.ts` exposed two endpoints: +- `/health` — JSON deep check: D1 `SELECT 1`, R2 `list({ limit: 1 })`, DO bindings present. Returned 503 if any failed. +- `/health/live` — plain `OK` text liveness probe. + +**What's now there.** `packages/web/src/routes/healthz.ts` returns `OK` (text) — covers liveness at a different path. + +**Gap.** Deep dependency check is gone. If anything monitors `/health` (uptime services, CF health checks, dashboards), the URL now 404s. The path renamed from `/health/live` to `/healthz` may also break monitors. + +**Fix.** Add `routes/api/health.ts` with the deep check (same JSON shape as before), and either keep `/healthz` or alias it. Bindings (`env.DB`, `env.PDF_BUCKET`, `env.USER_SESSION`, `env.PROJECT_DOC`) are all available. + +### `/api/$.ts` JSON 404 — gone + +**What we had.** Hono's global `notFound` returned `SYSTEM_ROUTE_NOT_FOUND` JSON for any unmatched path under `/api/*`. Pass 25 deleted both the `/api/$.ts` Hono forwarder and its 404 fallback. + +**What's now there.** TanStack's root `notFoundComponent` renders the React 404 page (HTML, status 404). An API client hitting `/api/typo` gets HTML back instead of the documented JSON shape — content-type wrong, body un-parseable. + +**Fix.** Add `routes/api/$.ts` returning the `SYSTEM_ROUTE_NOT_FOUND` JSON for any method. TanStack's specificity sort puts splats last, so it only catches truly unmatched API paths. + +### HSTS header — gone + +**What we had.** Hono's `securityHeaders` middleware set `Strict-Transport-Security: max-age=15552000; includeSubDomains` on every HTTPS response. + +**What's now there.** Neither `__root.tsx` nor `_headers` sets it. Cloudflare doesn't add HSTS automatically — it has to be enabled per zone in the dashboard's "Edge Certificates → HSTS Settings" or via this header. + +**Gap.** First-visit downgrade attacks possible until the browser learns the host is HTTPS-only via some other means. + +**Fix.** Add `Strict-Transport-Security` to both `_headers` (catches static asset responses) and the `headers` block in `__root.tsx` (catches TanStack-served HTML). Or enable HSTS at the CF zone level — that covers everything including API responses. + +### CORS — gone, but not currently breaking anything + +**What we had.** `packages/workers/src/middleware/cors.ts` wrapped Hono's `cors()` with allow-list, methods, headers, credentials, OPTIONS preflight. + +**What's now there.** Nothing. + +**Why it's not breaking.** Browsers don't enforce CORS on same-origin requests, including non-simple ones. Production is `corates.org` SPA → `corates.org/api` (same-origin); local dev is `localhost:3010` → `localhost:3010/api` (same-origin); programmatic clients ignore CORS. + +**When it'd start mattering.** Different-origin SPA, dev pointing at prod, third-party embeds, public API clients in browsers. + +**Fix when needed.** Wrapper in `packages/web/src/server.ts`, before the WS dispatch and TanStack handoff. Reuse `isOriginAllowed`/`STATIC_ORIGINS` from `@corates/workers/config/origins`. + +--- + +## followup — quality and parity + +### Centralized error handler — degraded but not broken + +**What we had.** `packages/workers/src/middleware/errorHandler.ts` (`base.onError`) caught every uncaught error in route handlers and converted: +- Zod errors → `VALIDATION_ERROR` JSON with field paths +- D1 errors (`D1_*` prefix) → `DB_ERROR` with operation context +- `UNIQUE constraint failed` → 409 with friendly message +- `FOREIGN KEY constraint failed` → 400 +- Other errors → `INTERNAL_ERROR` (stack stripped in production) + +**What's now there.** Each TanStack handler has its own try/catch — most return `SYSTEM_ERRORS.DB_ERROR` for any thrown error. Constraint-violation classification (UNIQUE → 409, FK → 400) is gone. Uncaught exceptions inside handler logic propagate as TanStack error responses rather than `INTERNAL_ERROR` JSON. + +**Fix.** Either (a) a small `wrapHandler(fn)` helper that does the classification once, applied to every export, or (b) extend per-route try/catches to detect the same string patterns. (a) is cleaner. + +### Validation error quality — flat in 4 routes + +**Surveyed all migrated routes.** 34 routes use `createValidationError` properly (field paths, error codes). 4 admin routes catch Zod errors and return a flatter `INVALID_INPUT { field: 'body', value: err.message }` shape: + +- `routes/api/admin/orgs/$orgId/grants.ts` +- `routes/api/admin/orgs/$orgId/grants/$grantId.ts` +- `routes/api/admin/orgs/$orgId/subscriptions.ts` +- `routes/api/admin/orgs/$orgId/subscriptions/$subscriptionId.ts` + +**Impact.** Admin UI clients of those 4 routes can't highlight specific failing fields from a 400 response. Only matters if the admin grants/subscriptions UI shows form-field errors. + +**Fix.** Replace `Schema.parse(raw)` with `Schema.safeParse(raw)` and pull the first issue's path/message into a proper `VALIDATION_ERROR`. + +### Request ID propagation — partial + +**What we had.** `packages/workers/src/lib/observability/logger.ts` (deleted Pass 25) generated or honoured `x-request-id` and set it on every response; structured logs included `requestId`, `cfRay`, `route`, `method`. + +**What's now there.** Only the Stripe webhook still threads `requestId` (Pass 22 ported it inline). Other routes log without correlation IDs and don't set the response header. + +**Impact.** Harder to trace a single request through logs. CF's `cf-ray` header still exists for cross-system correlation. + +**Fix.** Add request-ID generation in `packages/web/src/server.ts` (single point), expose it via the request context for handlers, and set it on the outgoing response. + +### X-Content-Type-Options on API responses — gone + +**Subtle.** `_headers` and `__root.tsx` set this header on static assets and SPA HTML. API JSON responses don't have it. Hono set it on every response. + +**Impact.** Tiny. The header prevents browsers from MIME-sniffing a response with the wrong `Content-Type`. JSON responses with `Content-Type: application/json` aren't at risk because that's not an HTML-sniff target. + +**Fix.** Optionally set it in a thin response wrapper in `server.ts`. Low value. + +### Auth rate limit `skipFailedRequests` — gone + +**What we had.** Hono's `authRateLimit` set `skipFailedRequests: true` — failed sign-ins/sign-ups didn't count toward the 20-per-15-min budget. + +**What's now there.** `packages/web/src/server/rateLimit.ts`'s `checkRateLimit` API doesn't support skipping. `AUTH_RATE_LIMIT` (used in `routes/api/auth/$.ts` catch-all) counts every request, success or fail. + +**Impact.** Slightly easier to lock out a real user typing their password wrong. With a 20/15min budget, a user typo-ing 5 times still has room. + +**Fix.** Add an optional refund step in `checkRateLimit` (or a `refundRateLimit` helper) and call it in the catch-all when better-auth returns a 4xx representing a failed credential attempt. + +### OpenAPI / `/docs` page — gone + +**What we had.** `@hono/zod-openapi` routes generated an OpenAPI spec; the dev-only `/docs` page rendered it. + +**What's now there.** No spec, no docs page. Removed deliberately in Pass 25. + +**Impact.** Lost a dev convenience. Production never served `/docs` (404 outside dev mode), so no user impact. + +**Fix.** Optional — regenerate from TanStack handlers if anyone misses it. Low value unless something automated consumed the spec. + +--- + +## Out of scope — already removed deliberately + +- `/api/$.ts` Hono catch-all forwarder (Pass 25) +- 410 legacy endpoints (`/api/orgs/$orgId/project-doc/$projectId`, `/api/project/$projectId`) +- Hono RPC client (`packages/web/src/lib/rpc.ts`) and `DetailedError` instance checks (Pass 25) + +--- + +## Verified preserved (not regressions) + +Audited and confirmed in current code: + +- Security headers on **HTML responses** — `__root.tsx`'s `headers()` callback sets X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP +- Security headers on **static assets** — `public/_headers` sets the same five +- Admin CSRF — `requireAdmin` runs `requireTrustedOrigin` first (Pass 26) +- Liveness probe — `/healthz` returns "OK" +- Sentry server-side error monitoring — `Sentry.withSentry` wraps the worker default export (added during this audit) +- Sentry client-side — `packages/web/src/config/sentry.ts` initialises `@sentry/react` +- All API routes from the original Hono app (auth, billing, admin, orgs, projects, users, PDF, dev, Google Drive, invitations) are migrated and tested + +--- + +## Suggested order for an "ops" follow-up branch + +1. HSTS header — one-line addition to `_headers` and `__root.tsx`. +2. Health deep-check endpoint at `/api/health` — one route file. +3. `/api/$.ts` JSON 404 — one route file. +4. Centralized error wrapper (`wrapHandler` helper) — retrofit one route at a time. +5. Validation helper for the 4 lazy admin routes. +6. Request-ID propagation, rate-limit refund, CORS, OpenAPI — as needed. diff --git a/packages/web/package.json b/packages/web/package.json index 25d6392f0..a5c2fb0e8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -56,6 +56,7 @@ "@embedpdf/plugin-viewport": "^2.14.0", "@embedpdf/plugin-zoom": "^2.14.0", "@fontsource-variable/geist": "^5.2.8", + "@sentry/cloudflare": "^10.47.0", "@sentry/react": "^10.47.0", "@tanstack/react-query": "^5.96.2", "@tanstack/react-router": "^1.168.10", diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 18ca2eb17..bdccf7e41 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1,4 +1,5 @@ import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server'; +import * as Sentry from '@sentry/cloudflare'; import { handleEmailQueue } from '@corates/workers/queue'; import { getProjectDocStub } from '@corates/workers/project-doc-id'; @@ -24,7 +25,13 @@ interface DOEnv { }; } -export default { +interface SentryEnv { + SENTRY_DSN?: string; + ENVIRONMENT?: string; + CF_VERSION_METADATA?: { id?: string }; +} + +const workerHandler = { async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise { const url = new URL(request.url); @@ -59,3 +66,19 @@ export default { return handleEmailQueue(batch, env as never); }, }; + +// Wrap with Sentry for error monitoring. `Sentry.withSentry` proxies fetch + +// queue and uses `ctx.waitUntil` for transport — that's available in the +// production Worker runtime but not in the vitest pool, so the test entry +// (`src/__tests__/server/test-worker.ts`) bypasses this wrapper entirely by +// being a separate `main` in `wrangler.test.jsonc`. +export default Sentry.withSentry((env: SentryEnv) => { + return { + dsn: env.SENTRY_DSN ?? '', + release: env.CF_VERSION_METADATA?.id, + environment: env.ENVIRONMENT, + enabled: !!env.SENTRY_DSN, + tracesSampleRate: env.ENVIRONMENT === 'production' ? 0.1 : 1.0, + sendDefaultPii: true, + }; +}, workerHandler); diff --git a/packages/web/wrangler.jsonc b/packages/web/wrangler.jsonc index fd51af73b..d8fe7dbbb 100644 --- a/packages/web/wrangler.jsonc +++ b/packages/web/wrangler.jsonc @@ -9,6 +9,12 @@ "compatibility_flags": ["nodejs_compat"], "main": "./src/server.ts", "routes": [{ "pattern": "corates.org/*", "zone_name": "corates.org" }], + + // Exposes the deploy version id as `env.CF_VERSION_METADATA` so Sentry + // can tag events with the release that produced them. + "version_metadata": { + "binding": "CF_VERSION_METADATA" + }, "assets": { "binding": "ASSETS", "html_handling": "drop-trailing-slash" @@ -90,6 +96,9 @@ "name": "corates-workers-prod", "workers_dev": false, "preview_urls": false, + "version_metadata": { + "binding": "CF_VERSION_METADATA" + }, // Routes inherit from top-level: corates.org/*. The Phase 3 cutover // takes over from the old packages/workers deploy (which owned // corates.org/api/*) — the rename to corates-workers-prod keeps DO diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc3e82413..d16d3e9bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: '@fontsource-variable/geist': specifier: ^5.2.8 version: 5.2.8 + '@sentry/cloudflare': + specifier: ^10.47.0 + version: 10.49.0(@cloudflare/workers-types@4.20260405.1) '@sentry/react': specifier: ^10.47.0 version: 10.47.0(react@19.2.4) @@ -3245,10 +3248,23 @@ packages: resolution: {integrity: sha512-rC0agZdxKA5XWfL4VwPOr/rJMogXDqZgnVzr93YWpFn9DMZT/7LzxSJVPIJwRUjx3bFEby3PcTa3YaX7pxm1AA==} engines: {node: '>=18'} + '@sentry/cloudflare@10.49.0': + resolution: {integrity: sha512-kHNIwJ6SX39R5TRoW/Bf25rgrBwXBbD44fEK9+hkJ3IdGBLktXG2+T7mNGjpvR98TWxQDhcvs8WLfFw/SsDGrA==} + engines: {node: '>=18'} + peerDependencies: + '@cloudflare/workers-types': ^4.x + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@sentry/core@10.47.0': resolution: {integrity: sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==} engines: {node: '>=18'} + '@sentry/core@10.49.0': + resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==} + engines: {node: '>=18'} + '@sentry/react@10.47.0': resolution: {integrity: sha512-ZtJV6xxF8jUVE9e3YQUG3Do0XapG1GjniyLyqMPgN6cNvs/HaRJODf7m60By+VGqcl5XArEjEPTvx8CdPUXDfA==} engines: {node: '>=18'} @@ -10921,8 +10937,17 @@ snapshots: '@sentry-internal/replay-canvas': 10.47.0 '@sentry/core': 10.47.0 + '@sentry/cloudflare@10.49.0(@cloudflare/workers-types@4.20260405.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/core': 10.49.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20260405.1 + '@sentry/core@10.47.0': {} + '@sentry/core@10.49.0': {} + '@sentry/react@10.47.0(react@19.2.4)': dependencies: '@sentry/browser': 10.47.0 From 2b1f0d877dd8fb37b8bf0fd1013b53f7143c5733 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 17 Apr 2026 23:17:50 -0500 Subject: [PATCH 09/11] improve security and add health --- packages/web/public/_headers | 1 + packages/web/src/routes/__root.tsx | 1 + packages/web/src/routes/api/$.ts | 35 +++++++++++++ packages/web/src/routes/health.ts | 82 ++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 packages/web/src/routes/api/$.ts create mode 100644 packages/web/src/routes/health.ts diff --git a/packages/web/public/_headers b/packages/web/public/_headers index 47301facb..917280c6d 100644 --- a/packages/web/public/_headers +++ b/packages/web/public/_headers @@ -30,6 +30,7 @@ # Security headers on all static responses /* + Strict-Transport-Security: max-age=15552000; includeSubDomains X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/packages/web/src/routes/__root.tsx b/packages/web/src/routes/__root.tsx index 2b797ac8d..88a32f194 100644 --- a/packages/web/src/routes/__root.tsx +++ b/packages/web/src/routes/__root.tsx @@ -69,6 +69,7 @@ const structuredData = JSON.stringify({ export const Route = createRootRoute({ headers: () => ({ + 'Strict-Transport-Security': 'max-age=15552000; includeSubDomains', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', diff --git a/packages/web/src/routes/api/$.ts b/packages/web/src/routes/api/$.ts new file mode 100644 index 000000000..515a3baf2 --- /dev/null +++ b/packages/web/src/routes/api/$.ts @@ -0,0 +1,35 @@ +/** + * Catch-all 404 for unmatched /api/* paths. + * + * TanStack's specificity sort puts splats last, so this only fires for paths + * that no concrete API route claims. Returns the documented + * `SYSTEM_ROUTE_NOT_FOUND` JSON shape so API clients see a parseable error + * instead of the SPA HTML shell. + */ +import { createFileRoute } from '@tanstack/react-router'; +import { createDomainError, SYSTEM_ERRORS } from '@corates/shared'; + +const handle = ({ request }: { request: Request }) => { + const path = (() => { + try { + return new URL(request.url).pathname; + } catch { + return request.url; + } + })(); + return Response.json(createDomainError(SYSTEM_ERRORS.ROUTE_NOT_FOUND, { path }), { status: 404 }); +}; + +export const Route = createFileRoute('/api/$')({ + server: { + handlers: { + GET: handle, + POST: handle, + PUT: handle, + PATCH: handle, + DELETE: handle, + OPTIONS: handle, + HEAD: handle, + }, + }, +}); diff --git a/packages/web/src/routes/health.ts b/packages/web/src/routes/health.ts new file mode 100644 index 000000000..6ded4b141 --- /dev/null +++ b/packages/web/src/routes/health.ts @@ -0,0 +1,82 @@ +/** + * GET /health — deep dependency check used by uptime monitors. + * + * Probes D1 (`SELECT 1`), R2 (`list({ limit: 1 })`), and presence of the + * UserSession + ProjectDoc DO bindings. Returns 200 with `status: 'healthy'` + * when all checks pass, 503 with `status: 'degraded'` and per-service detail + * otherwise. Mirrors the original Hono shape (`@/routes/health.ts` in the + * deleted Hono app) so existing monitors stay compatible. + * + * Liveness probe is the separate `/healthz` route (returns plain "OK"). + */ +import { createFileRoute } from '@tanstack/react-router'; +import { env } from 'cloudflare:workers'; + +interface ServiceStatus { + status: 'healthy' | 'unhealthy'; + type: string; + error?: string; + bindings?: { USER_SESSION?: boolean; PROJECT_DOC?: boolean }; +} + +interface HealthChecks { + status: 'healthy' | 'degraded'; + timestamp: string; + services: { + database?: ServiceStatus; + storage?: ServiceStatus; + durableObjects?: ServiceStatus; + }; +} + +export const handleGet = async () => { + const checks: HealthChecks = { + status: 'healthy', + timestamp: new Date().toISOString(), + services: {}, + }; + + try { + const result = await env.DB.prepare('SELECT 1 as ok').first<{ ok: number }>(); + checks.services.database = { + status: result?.ok === 1 ? 'healthy' : 'unhealthy', + type: 'D1', + }; + } catch (error) { + checks.services.database = { + status: 'unhealthy', + type: 'D1', + error: (error as Error).message, + }; + checks.status = 'degraded'; + } + + try { + await env.PDF_BUCKET.list({ limit: 1 }); + checks.services.storage = { status: 'healthy', type: 'R2' }; + } catch (error) { + checks.services.storage = { + status: 'unhealthy', + type: 'R2', + error: (error as Error).message, + }; + checks.status = 'degraded'; + } + + const doHealthy = !!env.USER_SESSION && !!env.PROJECT_DOC; + checks.services.durableObjects = { + status: doHealthy ? 'healthy' : 'unhealthy', + type: 'Durable Objects', + bindings: { + USER_SESSION: !!env.USER_SESSION, + PROJECT_DOC: !!env.PROJECT_DOC, + }, + }; + if (!doHealthy) checks.status = 'degraded'; + + return Response.json(checks, { status: checks.status === 'healthy' ? 200 : 503 }); +}; + +export const Route = createFileRoute('/health')({ + server: { handlers: { GET: handleGet } }, +}); From f7928ecb49a2600fcd40e261b7e08a27f6072862 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 17 Apr 2026 23:17:50 -0500 Subject: [PATCH 10/11] improve security and add health --- packages/web/public/_headers | 1 + packages/web/src/routes/__root.tsx | 1 + .../routes/__tests__/health.server.test.ts | 32 ++++++++ packages/web/src/routes/api/$.ts | 35 ++++++++ .../api/__tests__/not-found.server.test.ts | 33 ++++++++ packages/web/src/routes/health.ts | 82 +++++++++++++++++++ 6 files changed, 184 insertions(+) create mode 100644 packages/web/src/routes/__tests__/health.server.test.ts create mode 100644 packages/web/src/routes/api/$.ts create mode 100644 packages/web/src/routes/api/__tests__/not-found.server.test.ts create mode 100644 packages/web/src/routes/health.ts diff --git a/packages/web/public/_headers b/packages/web/public/_headers index 47301facb..917280c6d 100644 --- a/packages/web/public/_headers +++ b/packages/web/public/_headers @@ -30,6 +30,7 @@ # Security headers on all static responses /* + Strict-Transport-Security: max-age=15552000; includeSubDomains X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/packages/web/src/routes/__root.tsx b/packages/web/src/routes/__root.tsx index 2b797ac8d..88a32f194 100644 --- a/packages/web/src/routes/__root.tsx +++ b/packages/web/src/routes/__root.tsx @@ -69,6 +69,7 @@ const structuredData = JSON.stringify({ export const Route = createRootRoute({ headers: () => ({ + 'Strict-Transport-Security': 'max-age=15552000; includeSubDomains', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', diff --git a/packages/web/src/routes/__tests__/health.server.test.ts b/packages/web/src/routes/__tests__/health.server.test.ts new file mode 100644 index 000000000..d3a20b46f --- /dev/null +++ b/packages/web/src/routes/__tests__/health.server.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { handleGet } from '../health'; + +describe('GET /health', () => { + it('returns 200 + healthy when all dependencies respond', async () => { + const res = await handleGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + status: string; + timestamp: string; + services: { + database?: { status: string; type: string }; + storage?: { status: string; type: string }; + durableObjects?: { + status: string; + type: string; + bindings?: { USER_SESSION?: boolean; PROJECT_DOC?: boolean }; + }; + }; + }; + + expect(body.status).toBe('healthy'); + expect(typeof body.timestamp).toBe('string'); + expect(body.services.database?.status).toBe('healthy'); + expect(body.services.database?.type).toBe('D1'); + expect(body.services.storage?.status).toBe('healthy'); + expect(body.services.storage?.type).toBe('R2'); + expect(body.services.durableObjects?.status).toBe('healthy'); + expect(body.services.durableObjects?.bindings?.USER_SESSION).toBe(true); + expect(body.services.durableObjects?.bindings?.PROJECT_DOC).toBe(true); + }); +}); diff --git a/packages/web/src/routes/api/$.ts b/packages/web/src/routes/api/$.ts new file mode 100644 index 000000000..515a3baf2 --- /dev/null +++ b/packages/web/src/routes/api/$.ts @@ -0,0 +1,35 @@ +/** + * Catch-all 404 for unmatched /api/* paths. + * + * TanStack's specificity sort puts splats last, so this only fires for paths + * that no concrete API route claims. Returns the documented + * `SYSTEM_ROUTE_NOT_FOUND` JSON shape so API clients see a parseable error + * instead of the SPA HTML shell. + */ +import { createFileRoute } from '@tanstack/react-router'; +import { createDomainError, SYSTEM_ERRORS } from '@corates/shared'; + +const handle = ({ request }: { request: Request }) => { + const path = (() => { + try { + return new URL(request.url).pathname; + } catch { + return request.url; + } + })(); + return Response.json(createDomainError(SYSTEM_ERRORS.ROUTE_NOT_FOUND, { path }), { status: 404 }); +}; + +export const Route = createFileRoute('/api/$')({ + server: { + handlers: { + GET: handle, + POST: handle, + PUT: handle, + PATCH: handle, + DELETE: handle, + OPTIONS: handle, + HEAD: handle, + }, + }, +}); diff --git a/packages/web/src/routes/api/__tests__/not-found.server.test.ts b/packages/web/src/routes/api/__tests__/not-found.server.test.ts new file mode 100644 index 000000000..755e33e15 --- /dev/null +++ b/packages/web/src/routes/api/__tests__/not-found.server.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { Route } from '../$'; + +const handle = Route.options.server!.handlers as unknown as { + GET: (args: { request: Request }) => Promise; + POST: (args: { request: Request }) => Promise; +}; + +describe('catch-all /api/$', () => { + it('returns 404 + SYSTEM_ROUTE_NOT_FOUND JSON for unmatched GET', async () => { + const res = await handle.GET({ + request: new Request('http://localhost/api/typo', { method: 'GET' }), + }); + expect(res.status).toBe(404); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = (await res.json()) as { code: string; details?: { path?: string } }; + expect(body.code).toBe('SYSTEM_ROUTE_NOT_FOUND'); + expect(body.details?.path).toBe('/api/typo'); + }); + + it('returns 404 for unmatched POST too (every method funnels through)', async () => { + const res = await handle.POST({ + request: new Request('http://localhost/api/admin/nonexistent', { + method: 'POST', + body: '{}', + }), + }); + expect(res.status).toBe(404); + const body = (await res.json()) as { code: string; details?: { path?: string } }; + expect(body.code).toBe('SYSTEM_ROUTE_NOT_FOUND'); + expect(body.details?.path).toBe('/api/admin/nonexistent'); + }); +}); diff --git a/packages/web/src/routes/health.ts b/packages/web/src/routes/health.ts new file mode 100644 index 000000000..6ded4b141 --- /dev/null +++ b/packages/web/src/routes/health.ts @@ -0,0 +1,82 @@ +/** + * GET /health — deep dependency check used by uptime monitors. + * + * Probes D1 (`SELECT 1`), R2 (`list({ limit: 1 })`), and presence of the + * UserSession + ProjectDoc DO bindings. Returns 200 with `status: 'healthy'` + * when all checks pass, 503 with `status: 'degraded'` and per-service detail + * otherwise. Mirrors the original Hono shape (`@/routes/health.ts` in the + * deleted Hono app) so existing monitors stay compatible. + * + * Liveness probe is the separate `/healthz` route (returns plain "OK"). + */ +import { createFileRoute } from '@tanstack/react-router'; +import { env } from 'cloudflare:workers'; + +interface ServiceStatus { + status: 'healthy' | 'unhealthy'; + type: string; + error?: string; + bindings?: { USER_SESSION?: boolean; PROJECT_DOC?: boolean }; +} + +interface HealthChecks { + status: 'healthy' | 'degraded'; + timestamp: string; + services: { + database?: ServiceStatus; + storage?: ServiceStatus; + durableObjects?: ServiceStatus; + }; +} + +export const handleGet = async () => { + const checks: HealthChecks = { + status: 'healthy', + timestamp: new Date().toISOString(), + services: {}, + }; + + try { + const result = await env.DB.prepare('SELECT 1 as ok').first<{ ok: number }>(); + checks.services.database = { + status: result?.ok === 1 ? 'healthy' : 'unhealthy', + type: 'D1', + }; + } catch (error) { + checks.services.database = { + status: 'unhealthy', + type: 'D1', + error: (error as Error).message, + }; + checks.status = 'degraded'; + } + + try { + await env.PDF_BUCKET.list({ limit: 1 }); + checks.services.storage = { status: 'healthy', type: 'R2' }; + } catch (error) { + checks.services.storage = { + status: 'unhealthy', + type: 'R2', + error: (error as Error).message, + }; + checks.status = 'degraded'; + } + + const doHealthy = !!env.USER_SESSION && !!env.PROJECT_DOC; + checks.services.durableObjects = { + status: doHealthy ? 'healthy' : 'unhealthy', + type: 'Durable Objects', + bindings: { + USER_SESSION: !!env.USER_SESSION, + PROJECT_DOC: !!env.PROJECT_DOC, + }, + }; + if (!doHealthy) checks.status = 'degraded'; + + return Response.json(checks, { status: checks.status === 'healthy' ? 200 : 503 }); +}; + +export const Route = createFileRoute('/health')({ + server: { handlers: { GET: handleGet } }, +}); From 8eb8d272adda8ce046e8cb664dc5275bf18b6336 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 18 Apr 2026 04:18:56 +0000 Subject: [PATCH 11/11] Apply Prettier formatting --- packages/docs/audits/hono-to-tanstack-migration.md | 7 +++++-- packages/docs/audits/post-migration-regressions.md | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/docs/audits/hono-to-tanstack-migration.md b/packages/docs/audits/hono-to-tanstack-migration.md index 5654097d7..f77887128 100644 --- a/packages/docs/audits/hono-to-tanstack-migration.md +++ b/packages/docs/audits/hono-to-tanstack-migration.md @@ -6,7 +6,7 @@ The Hono app in `packages/workers` was migrated to TanStack Start file routes in - **API routes** live in `packages/web/src/routes/api/**` as TanStack file routes. - **`packages/workers`** is now a library workspace (Durable Objects, commands, lib helpers, auth/email, queue consumer) consumed via subpath exports declared in `packages/workers/package.json`. It no longer deploys. -- **`packages/web/src/server.ts`** is the worker entry. It (1) routes WebSocket-DO paths (`/api/project-doc/*`, `/api/sessions/*`) to DO stubs *before* TanStack — TanStack Start can't pass WS upgrades through, (2) calls `createStartHandler(defaultStreamHandler)` for everything else, threading `cloudflareCtx` through `context` so handlers can `waitUntil`, (3) wraps the whole thing in `Sentry.withSentry`, (4) delegates queue consumption to `handleEmailQueue` from `@corates/workers/queue`. +- **`packages/web/src/server.ts`** is the worker entry. It (1) routes WebSocket-DO paths (`/api/project-doc/*`, `/api/sessions/*`) to DO stubs _before_ TanStack — TanStack Start can't pass WS upgrades through, (2) calls `createStartHandler(defaultStreamHandler)` for everything else, threading `cloudflareCtx` through `context` so handlers can `waitUntil`, (3) wraps the whole thing in `Sentry.withSentry`, (4) delegates queue consumption to `handleEmailQueue` from `@corates/workers/queue`. ## Adding a route @@ -56,7 +56,10 @@ import { env } from 'cloudflare:test'; import { handleGet } from '../some-route'; vi.mock('@corates/workers/auth', () => ({ - getSession: async () => ({ user: { id: 'u1', email: 'u1@example.com', name: 'U1' }, session: { id: 'sess', userId: 'u1' } }), + getSession: async () => ({ + user: { id: 'u1', email: 'u1@example.com', name: 'U1' }, + session: { id: 'sess', userId: 'u1' }, + }), })); ``` diff --git a/packages/docs/audits/post-migration-regressions.md b/packages/docs/audits/post-migration-regressions.md index af52d43d8..371a8e41d 100644 --- a/packages/docs/audits/post-migration-regressions.md +++ b/packages/docs/audits/post-migration-regressions.md @@ -26,6 +26,7 @@ Nothing is a hard merge blocker. ### Health deep-check endpoint — gone **What we had.** `routes/health.ts` exposed two endpoints: + - `/health` — JSON deep check: D1 `SELECT 1`, R2 `list({ limit: 1 })`, DO bindings present. Returned 503 if any failed. - `/health/live` — plain `OK` text liveness probe. @@ -72,6 +73,7 @@ Nothing is a hard merge blocker. ### Centralized error handler — degraded but not broken **What we had.** `packages/workers/src/middleware/errorHandler.ts` (`base.onError`) caught every uncaught error in route handlers and converted: + - Zod errors → `VALIDATION_ERROR` JSON with field paths - D1 errors (`D1_*` prefix) → `DB_ERROR` with operation context - `UNIQUE constraint failed` → 409 with friendly message