Skip to content

feat: restructure into Turborepo monorepo with shared packages#395

Open
williamluke4 wants to merge 7 commits intomainfrom
williamluke4/monorepo-plan
Open

feat: restructure into Turborepo monorepo with shared packages#395
williamluke4 wants to merge 7 commits intomainfrom
williamluke4/monorepo-plan

Conversation

@williamluke4
Copy link
Copy Markdown
Member

Summary

  • Restructures the codebase into a Turborepo monorepo: Next.js app moves to apps/web/, shared server code extracted into @sarafu/api, @sarafu/contracts, @sarafu/core, and @sarafu/schemas packages
  • Adds a skeletal Expo mobile app at apps/mobile/ with tRPC integration and auth scaffolding
  • Adds Bearer token auth support so mobile clients can authenticate via sealed iron-session tokens alongside the existing cookie-based web auth
  • Configures Turborepo for build orchestration, adds tsconfig.base.json, and deduplicates @types/react via pnpm overrides
  • Updates CI to Node.js 22 and pnpm/action-setup@v4

Test plan

  • Verify pnpm build completes successfully across all packages
  • Verify pnpm test passes for all packages
  • Verify pnpm check-types passes with no errors
  • Verify web app functions identically at runtime (auth, tRPC, pages)
  • Verify the verify endpoint returns a token field in its response
  • Verify Bearer token auth works in tRPC context (e.g. via curl with sealed token)

🤖 Generated with Claude Code

williamluke4 and others added 2 commits April 10, 2026 11:48
Move the Next.js app into apps/web/, extract shared server code into
@sarafu/api, @sarafu/contracts, @sarafu/core, and @sarafu/schemas
packages, add a skeletal Expo mobile app at apps/mobile/, and configure
Turborepo for build orchestration. Adds Bearer token auth support so
the mobile client can authenticate via sealed iron-session tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds pnpm overrides for @types/react and @types/react-dom to prevent
duplicate type definitions across the monorepo, which caused TypeScript
errors in icons.tsx and other files with React ref types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sarafu-network Ready Ready Preview, Comment Apr 10, 2026 9:44am

Request Review

@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 10, 2026

Claude finished @williamluke4's task in 4m 10s —— View job


Code Review: Turborepo Monorepo Restructure

This is a substantial and well-executed restructure. The package boundaries are sensible, the Turborepo pipeline is configured correctly, and the Bearer token auth is a clean addition. Below are the findings grouped by severity.


Security

[Medium] Bearer token always returned in SIWE verify response

apps/web/src/app/api/auth/verify/route.ts:89-91

const token = await createAuthToken(address, chainId);
return NextResponse.json({ ok: true, token });

The sealed token is returned unconditionally — web clients don't need it and it needlessly exposes the credential. Consider returning it only when the request signals it's a non-cookie client (e.g. via an Accept: application/token header or X-Client-Type: mobile). The token itself is sealed with iron-session so it's encrypted, but it's still unnecessary exposure.

Fix this →

[Low] No token revocation

Bearer tokens are sealed with a 30-day TTL but there's no revocation mechanism. If a token is stolen, the only recourse is rotating NEXT_IRON_PASSWORD, which invalidates all sessions and tokens. This is a known limitation of the stateless approach — worth documenting in a comment on createAuthToken.


Bugs / Type Safety

[Medium] IP extraction may be string | undefined with noUncheckedIndexedAccess

packages/api/src/context.ts:11-12

const ip = (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0];

With noUncheckedIndexedAccess: true (set in tsconfig.base.json), .split(",")[0] is string | undefined. ctx.ip would therefore be typed as string | undefined. If type-checking is passing it may be because the package's tsconfig.json doesn't extend base properly — worth verifying with pnpm check-types. Suggested fix:

const ip = (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0] ?? "127.0.0.1";

Fix this →

[Low] Unstable tRPC internal import

apps/web/src/server/api/routers/_app.ts:5

import { createCallerFactory } from "@trpc/server/unstable-core-do-not-import";

createCallerFactory is already exported from packages/api/src/trpc.ts and accessible as @sarafu/api/trpc. Using the internal path risks breakage on tRPC upgrades.

Fix this →


Architecture

[Medium] @sarafu/api is coupled to Next.js App Router

packages/api/src/auth/index.ts imports cookies from next/headers and cache from react. This makes @sarafu/api only usable within Next.js App Router RSC context. The package has next >= 15 as a peer dep which documents this, but the coupling leaks into what should be a framework-agnostic server package. Long-term this limits portability (e.g. if an Express/Hono adapter is ever needed). Worth a comment in the package's README or a note in auth/index.ts explaining the constraint.

[Low] Mobile app depends on a Next.js-coupled package

apps/mobile/lib/trpc.tsx:6 imports type AppRouter from "@sarafu/api/root". This is a type-only import so it doesn't bundle Next.js code at runtime, and Metro strips types. However, this creates a confusing dependency relationship visible in apps/mobile/package.json. A future refactor could extract just the router type to a separate @sarafu/api-types package to make the boundary explicit.


Testing

[Medium] Missing test: verify endpoint doesn't assert the token field

apps/web/__tests__/server/api/auth/verify.test.ts:100-107

The success test only checks body.ok and response.status. The new token field — which is the primary new feature of this PR — is not tested.

it("returns ok on valid SIWE verification", async () => {
  // ...
  expect(body.ok).toBe(true);
  // Missing: expect(typeof body.token).toBe("string");
});

Fix this →

[Low] No tests for createAuthToken / getSessionFromToken

packages/api/src/auth/index.ts has no unit tests. A round-trip test (seal then unseal) would validate the token flow and guard against accidental password config changes.


Mobile App

[Low] isAuthenticated doesn't validate token expiry client-side

apps/mobile/lib/auth.tsx:62

isAuthenticated: !!state.token,

A stored token that has expired server-side will still make isAuthenticated return true. The app won't discover this until a tRPC call fails with UNAUTHORIZED. Consider adding a trpc.me.get preflight or handling the 401 error globally in the tRPC client to redirect to login.

[Info] Login screen is a placeholder

apps/mobile/app/(auth)/login.tsx contains only UI text describing what will be implemented. This is fine for a skeletal scaffold but should be tracked as a follow-up issue before the mobile app is usable.

[Info] No production API URL configured

apps/mobile/lib/api-url.ts falls back to http://localhost:3000 when no apiBaseUrl extra is set in app.json. The app.json doesn't configure a production value in extra. Before any real deployment, app.json needs an extra.apiBaseUrl pointing to the deployed web app.


CI / Deployment

[Low] CI runs only test, not check-types or lint

.github/workflows/test.yaml:36

- name: Run tests
  run: pnpm run test

TypeScript errors and lint failures won't be caught on CI. Adding pnpm check-types and optionally pnpm lint would give earlier signal on PR diffs.

Fix this →

[Info] Vercel root directory

The Vercel deployment comment shows rootDirectory: null. With the web app now at apps/web/, Vercel needs to be configured with rootDirectory: apps/web in the project settings to locate the Next.js app correctly. The build shows as "Building" so this may already be configured in the Vercel dashboard, but it's worth double-checking.


Minor Observations

  • The Turborepo build task uses glob KV_* and SARAFU_* for env passthrough — this is a good pattern that avoids needing to enumerate every variable.
  • packages/core has no build script (just source .ts exports), which is fine since the web bundler handles compilation, but turbo.json's build depending on ^build means it'll try to build all workspace deps. Since @sarafu/core has no build script Turbo will just skip it silently — OK but worth being aware of.
  • packages/contracts/src/env.ts uses process.env as unknown as z.infer<typeof schema> when SKIP_ENV_VALIDATION is set — this bypasses type safety. packages/api/src/env.ts does the same. The pattern is consistent with the test setup but it means env types are a lie in test contexts. Not a blocker.
  • Session cache TTL in enrichSession is 60 seconds (auth:session:${address}). This means role/status changes (e.g. gas approval, admin promotion) take up to 60s to propagate. This was present before this PR but is now in the shared package — worth documenting.

Summary

The core restructure is solid. The package boundaries make sense, the Turborepo pipeline is well-configured, and the Bearer token auth implementation is clean and tested for the happy/unhappy paths. The main items to address before merge are:

  1. Add the missing token assertion in verify.test.ts
  2. Fix the IP extraction for noUncheckedIndexedAccess correctness
  3. Replace the unstable-core-do-not-import tRPC path
  4. Add pnpm check-types to CI

@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 10, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​react-navigation/​native@​7.2.2991007599100
Added@​expo/​vector-icons@​15.1.11001009190100

View full report

@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 10, 2026

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm @react-native/debugger-frontend is 96.0% likely obfuscated

Confidence: 0.96

Location: Package overview

From: pnpm-lock.yamlnpm/@react-native/debugger-frontend@0.81.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@react-native/debugger-frontend@0.81.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

The verify route now imports createAuthToken which uses sealData from
iron-session. The test mock was missing this export, causing CI failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 10, 2026

Claude finished @williamluke4's task in 2m 42s —— View job


Code Review: Turborepo Monorepo Restructure

Tasks

  • Gather context and understand PR scope
  • Review changed files (auth, context, CI, tests, mobile)
  • Security analysis
  • Code quality and architecture review
  • Testing coverage review
  • Finalize and post review

The restructure is well-executed — package boundaries are clean, the Turborepo pipeline is configured correctly, and Bearer token auth is a solid addition for the mobile app. The main things that need attention before merge are listed below.


Security

[Medium] Bearer token returned unconditionally

apps/web/src/app/api/auth/verify/route.ts:89–91

The sealed token is returned to every client, including web browsers that already have a cookie. It's encrypted, but there's no reason to expose it to clients that don't need it. Consider gating on a request header:

// Only return the token for non-cookie clients
const wantToken = req.headers.get("x-client-type") === "mobile";
return NextResponse.json({ ok: true, ...(wantToken ? { token } : {}) });

[Low] No token revocation path

packages/api/src/auth/index.ts:97–106

createAuthToken seals with a 30-day TTL and there is no revocation mechanism. If a token is compromised, the only recourse is rotating NEXT_IRON_PASSWORD, which invalidates all sessions. This is an inherent limitation of the stateless approach — worth adding a comment on createAuthToken to document it explicitly.


Bugs / Type Safety

[Medium] IP extraction undefined with noUncheckedIndexedAccess

packages/api/src/context.ts:11–13

const ip = (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0];

With noUncheckedIndexedAccess: true (set in tsconfig.base.json), .split(",")[0] is typed string | undefined, so ctx.ip becomes string | undefined. Note that get-ip.ts handles this correctly with ?.trim() ?? "127.0.0.1" — the same fix should be applied here:

const ip = (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0] ?? "127.0.0.1";

Fix this →

[Low] Unstable tRPC internal import

apps/web/src/server/api/routers/_app.ts:5

import { createCallerFactory } from "@trpc/server/unstable-core-do-not-import";

createCallerFactory is already exported from packages/api/src/trpc.ts and accessible as @sarafu/api/trpc. The unstable path risks silent breakage on tRPC upgrades.

Fix this →


Testing

[Medium] New commit mocks sealData but still doesn't assert body.token

apps/web/__tests__/server/api/auth/verify.test.ts:95–106

The most recent commit (fix: add sealData to iron-session mock in verify test) correctly added:

sealData: vi.fn().mockResolvedValue("mock-sealed-token"),

But the success test case still only checks:

expect(body.ok).toBe(true);
expect(response.status).toBe(200);

The token field — the primary new feature of this PR — remains untested. The mock is wired up; just add the assertion:

expect(body.token).toBe("mock-sealed-token");

Fix this →

[Low] No unit tests for createAuthToken / getSessionFromToken

packages/api/src/auth/index.ts:99–121

A round-trip test (seal then unseal) would validate the token flow and guard against accidental password config changes. These are pure async functions that are straightforward to test.


Architecture

[Medium] @sarafu/api has multiple Next.js couplings

Beyond auth/index.ts (already noted), two other files in the shared package import directly from Next.js:

  • packages/api/src/auth/check-rate-limit.ts — imports NextResponse from next/server
  • packages/api/src/auth/get-ip.ts — imports headers from next/headers

This means @sarafu/api is not actually framework-agnostic. The peerDependencies on next >= 15 documents this, but it's worth being aware of for future portability (e.g. Hono/Express adapters). A comment in the package README or a // NOTE: This package requires Next.js App Router header in the affected files would make the constraint explicit.

[Low] Mobile _layout.tsx has no auth redirect logic

apps/mobile/app/_layout.tsx

unstable_settings = { initialRouteName: "(tabs)" } routes unauthenticated users to the tab screens, where each screen renders a "Sign In" prompt inline. There's no automatic redirect to (auth)/login. While this is acceptable for a scaffold, when real functionality is added, the lack of a centralized auth guard means every screen needs its own auth check. Consider an expo-router redirect in the root layout or a useProtectedRoute hook early on.

[Low] Unused font asset

apps/mobile/assets/fonts/SpaceMono-Regular.ttf exists but _layout.tsx only loads FontAwesome.fontSpaceMono-Regular.ttf is never registered. Either load it in useFonts or remove the file.


CI

[Low] No type-checking or linting step in CI

.github/workflows/test.yaml

The workflow now runs tests but has no pnpm check-types or pnpm lint step. TypeScript errors and lint violations won't be caught on pull requests, only locally via the pre-push hook.

- name: Type check
  run: pnpm check-types

Fix this →


Minor Observations

  • CI upgrades are clean: actions/checkout@v4, actions/setup-node@v4, pnpm/action-setup@v4, and Node.js 22 are all correct upgrades. Moving pnpm install to the official action (away from the curl pipe) is a security improvement.
  • Turbo env passthrough using glob patterns (KV_*, SARAFU_*) is a good pattern that avoids needing to enumerate every variable.
  • packages/core has no build script — Turbo will skip it silently since ^build dependency only runs what exists. This is fine but worth being aware of.
  • app.json has no extra.apiBaseUrl — the fallback to http://localhost:3000 is intentional for development, but a production value needs to be configured before any real deployment.
  • Session cache TTL is 60 seconds (auth:session:${address}) — role and status changes take up to 60s to propagate. This predates this PR but is now in the shared package; worth documenting.
  • Mobile isAuthenticated: !!state.token — an expired token still returns true until a tRPC call fails. A global UNAUTHORIZED error handler in the tRPC client that calls signOut() would handle this gracefully.

Summary

The core restructure is solid. The four items worth addressing before merge:

  1. Add body.token assertion in verify.test.ts (the mock is already in place — one line needed)
  2. Fix IP extraction in context.ts for noUncheckedIndexedAccess correctness
  3. Replace the unstable-core-do-not-import tRPC path in _app.ts
  4. Add pnpm check-types to CI

Vercel needs framework and build settings in the app directory
since Next.js moved from repo root to apps/web/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 10, 2026

Claude finished @williamluke4's task in 3m 3s —— View job


Code Review: Turborepo Monorepo Restructure

Tasks

  • Gather context and understand PR scope
  • Read key changed files (auth, context, CI, tests, mobile)
  • Security analysis
  • Code quality and architecture review
  • Testing coverage review
  • Prepare fixes (push blocked by repo permissions — see below)
  • Finalize and post review

The restructure is well-executed. Package boundaries are sensible, the Turborepo pipeline is correct, and the Bearer token auth is a clean addition for the mobile client. Previous reviews identified several issues; here's the current status plus three fixes I've prepared locally (push blocked by repo permissions — apply them manually or grant push access to the GitHub App).


Prepared Fixes (ready to apply)

The following three fixes are trivial but blocked from being pushed. Apply them to close out the review:

1. Missing body.token assertionapps/web/__tests__/server/api/auth/verify.test.ts:107

The sealData mock was added in f122aff but the success test never asserts the returned token. The mock returns "mock-sealed-token" already — one line needed:

  expect(body.ok).toBe(true);
  expect(response.status).toBe(200);
+ expect(body.token).toBe("mock-sealed-token");

2. IP extraction undefined with noUncheckedIndexedAccesspackages/api/src/context.ts:11

packages/api/tsconfig.json extends tsconfig.base.json which has "noUncheckedIndexedAccess": true. .split(",")[0] is therefore string | undefined, making ctx.ip typed as string | undefined. Fix to match the pattern already used in get-ip.ts:

- const ip = (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(
-   ","
- )[0];
+ const ip =
+   (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0] ??
+   "127.0.0.1";

3. Unstable tRPC internal importapps/web/src/server/api/routers/_app.ts:5

createCallerFactory is already exported from @sarafu/api/trpc (line 36 of packages/api/src/trpc.ts). No reason to use the internal path:

- import { createCallerFactory } from "@trpc/server/unstable-core-do-not-import";
+ import { createCallerFactory } from "@sarafu/api/trpc";

Remaining Issues (not yet addressed)

[Medium] Bearer token returned unconditionallyapps/web/src/app/api/auth/verify/route.ts:89–91

The sealed token is sent to every client, including web browsers that don't need it. While the token is encrypted (iron-session seal), returning it unnecessarily widens the exposure surface. Gate it on a request header:

const wantToken = req.headers.get("x-client-type") === "mobile";
return NextResponse.json({ ok: true, ...(wantToken ? { token } : {}) });

Fix this →

[Low] No type-checking or lint step in CI.github/workflows/test.yaml

The workflow runs pnpm run test but has no pnpm check-types step. TypeScript errors won't be caught on CI (only locally via pre-push). The turbo.json already defines a check-types task that runs across all packages. Adding a step is straightforward — note that workflow files cannot be modified via the GitHub App, so this requires a manual edit:

- name: Type check
  run: pnpm check-types

[Low] No token revocationpackages/api/src/auth/index.ts:97–106

createAuthToken uses a 30-day TTL with no revocation mechanism. If a token is compromised, rotating NEXT_IRON_PASSWORD is the only recourse (invalidates all sessions). Worth documenting with a comment on the function.

[Low] Mobile: isAuthenticated doesn't detect expired tokensapps/mobile/lib/auth.tsx:62

isAuthenticated: !!state.token,

A token expired on the server still returns true here until a tRPC call fails with UNAUTHORIZED. Adding a global error handler in trpc.tsx that calls signOut() on UNAUTHORIZED would handle this gracefully:

// In httpBatchLink options or a terminating link:
onError(error) {
  if (error.data?.code === "UNAUTHORIZED") auth.signOut();
}

[Low] @sarafu/api has Next.js couplings — multiple files in packages/api/src/auth/

  • auth/index.ts — imports cookies from next/headers, cache from react
  • auth/check-rate-limit.ts — imports NextResponse from next/server
  • auth/get-ip.ts — imports headers from next/headers

The peerDependencies on next >= 15 documents this, but a comment in the package or the affected files would make the constraint explicit for future maintainers.

[Info] app.json missing extra.apiBaseUrlapps/mobile/app.json

getApiBaseUrl() falls back to http://localhost:3000 when no extra.apiBaseUrl is set. app.json has no extra field, so any EAS build will hit localhost. Needs configuring before any real deployment.

[Info] Unused font assetapps/mobile/assets/fonts/SpaceMono-Regular.ttf

_layout.tsx loads only FontAwesome.font via useFonts. SpaceMono-Regular.ttf is bundled but never registered. Either load it in useFonts or remove the file.

[Info] Login screen is a placeholderapps/mobile/app/(auth)/login.tsx

Only contains UI text describing what will be implemented. Fine for a scaffold, but worth tracking as a follow-up issue before any user-facing deployment.


What's Good

  • Turborepo pipeline configuration is correct (dependsOn, glob env passthrough KV_*, SARAFU_*)
  • CI upgrades are clean: actions/checkout@v4, pnpm/action-setup@v4, Node.js 22; moving pnpm install to the official action is a security improvement over curl-pipe installs
  • Bearer token implementation is cleanly isolated: sealData/unsealData flow is straightforward and the getSessionFromToken fallback in context.ts is transparent
  • expo-secure-store for token persistence is the right choice on mobile
  • trpc.tsx correctly uses a tokenRef to keep the header fresh without recreating the client on auth state changes

Vercel reads config from the Root Directory, which is still `.` (repo root).
The previous `apps/web/vercel.json` was ignored. Moving it to the root with
`outputDirectory: "apps/web/.next"` and a turbo-filtered build command fixes
framework detection without changing the project's Root Directory setting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 10, 2026

Claude finished @williamluke4's task in 2m 19s —— View job


Code Review: Turborepo Monorepo Restructure

Tasks

  • Gather context and understand PR scope
  • Read key changed files (verify test, context, CI, auth, app router)
  • Check status of previously identified issues
  • Security analysis
  • Code quality and architecture review
  • Testing coverage review
  • Finalize and post review

Three previous reviews identified the same set of issues. None of the four actionable items have been addressed in the subsequent commits (f122aff, 1670b0b, 8b59c83). The Vercel config was fixed correctly; the code issues remain. Here's the current state:


Blocking Issues (unchanged from prior reviews)

[Medium] body.token still not asserted in the success test

apps/web/__tests__/server/api/auth/verify.test.ts:102-109

sealData has been mocked to return "mock-sealed-token" since f122aff, but the success case never checks the response contains it:

it("returns ok on valid SIWE verification", async () => {
  const response = await POST(makeRequest());
  const body = await response.json();
  expect(body.ok).toBe(true);
  expect(response.status).toBe(200);
  // Missing: expect(body.token).toBe("mock-sealed-token");
});

The mock is already in place — this is a one-line fix.

Fix this →

[Medium] IP extraction is string | undefined — type error with noUncheckedIndexedAccess

packages/api/src/context.ts:11-13

packages/api/tsconfig.json extends tsconfig.base.json which sets "noUncheckedIndexedAccess": true. .split(",")[0] is therefore string | undefined, making ctx.ip typed as string | undefined and any downstream code expecting string a type error. get-ip.ts:7 already has the correct pattern:

// get-ip.ts (correct)
return forwarded.split(",")[0]?.trim() ?? "127.0.0.1";

// context.ts (broken)
const ip = (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0];
//                                                                                 ^ string | undefined

Fix:

const ip =
  (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0] ??
  "127.0.0.1";

Fix this →


Non-Blocking Issues (unchanged from prior reviews)

[Low] Unstable tRPC internal import

apps/web/src/server/api/routers/_app.ts:5

import { createCallerFactory } from "@trpc/server/unstable-core-do-not-import";

createCallerFactory is exported from packages/api/src/trpc.ts:36 (export const createCallerFactory = t.createCallerFactory) and accessible as @sarafu/api/trpc via the "./*": "./src/*.ts" export in packages/api/package.json. The internal path risks silent breakage on tRPC version upgrades.

Fix this →

[Low] No pnpm check-types in CI

.github/workflows/test.yaml:36

The workflow only runs pnpm run test. The turbo.json already defines a check-types task that cascades across all packages via "dependsOn": ["^check-types"]. The IP extraction issue above would be caught by this step. Note: workflow files cannot be modified via the GitHub App — this requires a manual edit.

- name: Type check
  run: pnpm check-types

[Medium] Bearer token returned unconditionally

apps/web/src/app/api/auth/verify/route.ts:91

The sealed token is returned to every client. Web browsers using cookie auth don't need it and shouldn't receive it. While the token is encrypted (iron-session seal), reducing unnecessary exposure is good hygiene:

const wantToken = req.headers.get("x-client-type") === "mobile";
return NextResponse.json({ ok: true, ...(wantToken ? { token } : {}) });

New Observations

vercel.json build filter matches package name

vercel.json:5 uses --filter=@sarafu/web, which correctly matches apps/web/package.json's "name": "@sarafu/web". The recent Vercel configuration commits (1670b0b, 8b59c83) look correct.

Mobile: no global UNAUTHORIZED handler in tRPC client

apps/mobile/lib/trpc.tsx:17-38

The tRPC client has no error handler. When a stored token expires server-side, isAuthenticated remains true (based on !!state.token) until a tRPC call fails — and even then, the user won't be redirected to login automatically. Adding a link-level error handler would cover this:

// In the links array, before httpBatchLink:
() => (op) =>
  observable((observer) =>
    next(op).subscribe({
      ...observer,
      error(err) {
        if (err.data?.code === "UNAUTHORIZED") signOut();
        observer.error(err);
      },
    })
  ),

app.json missing extra.apiBaseUrl

apps/mobile/app.json has no extra field. getApiBaseUrl() will return http://localhost:3000 for any EAS build until this is configured. Needs to be set before any production or TestFlight deployment.


Summary

The monorepo restructure itself is solid — Turborepo pipeline, package boundaries, Bearer token auth implementation, and the Vercel configuration are all correct. The four outstanding items from prior reviews remain unaddressed. The two most important:

  1. Add body.token assertion in verify.test.ts — mock is ready, one line needed
  2. Fix IP extraction in context.ts — confirmed type error with noUncheckedIndexedAccess

Vercel's Next.js builder requires `next` in the Root Directory's
package.json to detect the framework version, even when the actual
app lives in apps/web/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 10, 2026

Claude finished @williamluke4's task in 3m 39s —— View job


Code Review: Turborepo Monorepo Restructure

Tasks

  • Gather context and understand PR scope
  • Read key changed files (auth, context, CI, tests, mobile, turbo config)
  • Check status of previously identified issues
  • Apply code fixes (prepared locally — push blocked by repo permissions, apply manually below)
  • Security analysis
  • Code quality and architecture review
  • Testing coverage review
  • Finalize and post review

The monorepo restructure is well-executed: package boundaries are sensible, Turborepo pipeline is correct, and the Vercel configuration commits (1670b0bb0487cd) resolved the deployment issue. Four issues flagged in prior reviews remain unresolved after five commits. I've prepared fixes for three of them — push was blocked by repo permissions, so they need to be applied manually.


Prepared Fixes (apply manually — 3 files, ~6 lines)

1. apps/web/__tests__/server/api/auth/verify.test.ts:107 — Add the missing token assertion. The sealData mock has returned "mock-sealed-token" since f122aff but the success case never checked it:

  expect(body.ok).toBe(true);
  expect(response.status).toBe(200);
+ expect(body.token).toBe("mock-sealed-token");

2. packages/api/src/context.ts:11-13 — Fix string | undefined with noUncheckedIndexedAccess:

- const ip = (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(
-   ","
- )[0];
+ const ip =
+   (opts.req.headers.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0] ??
+   "127.0.0.1";

3. apps/web/src/server/api/routers/_app.ts:5,25 — Replace the unstable tRPC import. Note the call site must also be updated because t.createCallerFactory (the stable export) takes a router directly, without the extra ():

- import { createCallerFactory } from "@trpc/server/unstable-core-do-not-import";
+ import { createCallerFactory } from "@sarafu/api/trpc";
  // ...
- export const caller = createCallerFactory()(appRouter)(createCallerContext);
+ export const caller = createCallerFactory(appRouter)(createCallerContext);

Remaining Issues

[Low] No pnpm check-types in CI.github/workflows/test.yaml

The workflow only runs pnpm run test. Fix 2 above (IP extraction) is a type error that check-types would catch. The turbo.json already defines a check-types task that cascades via "dependsOn": ["^check-types"], so adding one step covers all packages. (Workflow files cannot be modified via the GitHub App — manual edit required.)

- name: Type check
  run: pnpm check-types

Fix this →

[Medium] Bearer token returned unconditionallyapps/web/src/app/api/auth/verify/route.ts:88-91

The comment says "Return a sealed token for non-cookie clients (e.g. mobile)" but the implementation returns it to every client including web browsers. The token is encrypted (iron-session seal), but there's no reason to send it to clients that don't need it:

// Gate on a request header
const wantToken = req.headers.get("x-client-type") === "mobile";
return NextResponse.json({ ok: true, ...(wantToken ? { token } : {}) });

Fix this →

[Low] No token revocationpackages/api/src/auth/index.ts:97-106

createAuthToken seals with a 30-day TTL and there's no revocation path. If a token is compromised, the only recourse is rotating NEXT_IRON_PASSWORD, invalidating all sessions. Worth a comment on the function to document this limitation explicitly.


Architecture

[Medium] @sarafu/api has three Next.js-specific couplings

Despite peerDependencies: { next: ">= 15" }, the coupling is spread across multiple files in the shared package:

File Next.js import
packages/api/src/auth/index.ts cookies from next/headers, cache from react
packages/api/src/auth/check-rate-limit.ts NextResponse from next/server
packages/api/src/auth/get-ip.ts headers from next/headers

The peer dep documents this, but a comment block in packages/api/src/auth/index.ts (or a package README) stating "This package requires Next.js App Router" would save future maintainers from confusion when the package is added to a non-Next.js context (e.g., a CLI tool or an Express adapter).

[Low] Mobile: no global UNAUTHORIZED handler

apps/mobile/lib/auth.tsx:62isAuthenticated: !!state.token returns true for tokens that have expired server-side. The user won't be redirected to login until a tRPC call fails. A link-level error handler in trpc.tsx would handle this transparently:

// Before httpBatchLink in the links array:
() => (op) =>
  observable((observer) =>
    next(op).subscribe({
      ...observer,
      error(err) {
        if (err.data?.code === "UNAUTHORIZED") signOut();
        observer.error(err);
      },
    })
  ),

[Low] app.json has no extra.apiBaseUrl

apps/mobile/lib/api-url.ts falls back to http://localhost:3000 when Constants.expoConfig?.extra?.apiBaseUrl is unset, and apps/mobile/app.json has no extra field. Any EAS/TestFlight build will hit localhost until this is configured.

[Low] Unused font asset

apps/mobile/assets/fonts/SpaceMono-Regular.ttf is in the bundle but _layout.tsx only loads FontAwesome.font via useFonts. Either register it or remove the file to avoid unnecessary bundle weight.


Minor Observations

  • CI upgrades are clean: actions/checkout@v4, pnpm/action-setup@v4, Node.js 22. Moving pnpm install to the official action is a security improvement over curl-pipe installs.
  • Vercel configuration (vercel.json:5) uses --filter=@sarafu/web which correctly matches apps/web/package.json "name": "@sarafu/web". The root-level vercel.json is the right placement for monorepo builds.
  • Turborepo env passthrough using glob patterns (KV_*, SARAFU_*, GAS_*) is a good pattern that avoids enumerating every variable.
  • packages/core has no build script — Turbo skips it silently since ^build only runs scripts that exist. Fine, but worth knowing.
  • Session cache TTL in enrichSession is 60s (auth:session:${address}). Role/status changes take up to 60s to propagate. This predates the PR but is now in the shared package; worth a comment.

Summary

The restructure itself is solid. The four items to resolve before merge:

  1. Prepared fix — Add body.token assertion in verify.test.ts
  2. Prepared fix — Fix IP extraction in context.ts for noUncheckedIndexedAccess
  3. Prepared fix — Replace unstable-core-do-not-import tRPC path in _app.ts (+ call site)
  4. ⚠️ Manual edit required — Add pnpm check-types to .github/workflows/test.yaml (workflow files cannot be modified via GitHub App)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant