Serve every route under /v1

next-auth moves to /v1/iam, tRPC to /v1/trpc and the Hono REST API to
/v1/companies, /v1/schema, /v1/swagger and /v1/docs. HIP-0111: no /api/
prefix. The /api copies of the REST handler and docs page, and the empty
apiKeys handler, are deleted.

next-auth v4 takes its server mount from the path of NEXTAUTH_URL, so
env.js refuses anything but <origin>/v1/iam; SessionProvider takes the
same AUTH_BASE_PATH. src/routes.test.ts fails if src/app/api returns or
any source names an /api/ path, and hanzo.yml runs it before the image.

Claude-Session: https://claude.ai/code/session_01TUxvfuta1Z44KVNYuAz71F
This commit is contained in:
2026-09-24 15:05:00 -07:00
parent 55e2652747
commit a802f619cd
18 changed files with 121 additions and 45 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ DATABASE_URL="postgres://captable:password@pg:5432/captable"
# Next Auth # Next Auth
# Run `openssl rand -base64 32` to generate a new secret # Run `openssl rand -base64 32` to generate a new secret
NEXTAUTH_SECRET="xxxxxxxxxx" NEXTAUTH_SECRET="xxxxxxxxxx"
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000/v1/iam"
# Hanzo IAM OAuth (required for production) # Hanzo IAM OAuth (required for production)
IAM_URL="https://hanzo.id" IAM_URL="https://hanzo.id"
+21 -2
View File
@@ -52,7 +52,7 @@ src/
│ ├── (authenticated)/ # Protected routes (dashboard, etc.) │ ├── (authenticated)/ # Protected routes (dashboard, etc.)
│ ├── (unauthenticated)/ # Public routes (login, signup, etc.) │ ├── (unauthenticated)/ # Public routes (login, signup, etc.)
│ ├── (documents)/ # Document-related pages │ ├── (documents)/ # Document-related pages
│ └── api/ # API routes (auth, trpc) │ └── v1/ # every route: iam (next-auth), trpc, the REST API
├── components/ # React components organized by feature ├── components/ # React components organized by feature
├── server/ # Server-side utilities and services ├── server/ # Server-side utilities and services
├── trpc/ # tRPC setup and routers ├── trpc/ # tRPC setup and routers
@@ -126,6 +126,24 @@ src/
- `bucket`: File storage operations - `bucket`: File storage operations
- `common`: Shared utilities - `common`: Shared utilities
## Routes
Everything the app serves is under `/v1` (HIP-0111; no `/api/` prefix):
- `/v1/iam/*` — next-auth (`src/app/v1/iam/[...nextauth]`). The IAM callback is
`https://captable.hanzo.ai/v1/iam/callback/hanzo-iam`, registered on
`admin/hanzo-captable` and declared in universe `iam-provision.yaml`.
`SessionProvider basePath` and the `NEXTAUTH_URL` path both read
`AUTH_BASE_PATH` (`src/constants/auth.ts`).
- `/v1/trpc/*` — tRPC (`src/app/v1/trpc/[trpc]`).
- `/v1/companies`, `/v1/schema`, `/v1/swagger`, `/v1/docs` — the Hono REST API
(`src/app/v1/[[...route]]`, routes name their own `/v1`).
On `captable.hanzo.ai` every other `/v1` path is cloud's (universe
`hanzo-domains.yaml` `v1-hanzo-ai`); the paths above reach this pod through the
higher-priority `captable-hanzo-ai` IngressRoute in universe `captable.yaml`.
`src/routes.test.ts` fails if `src/app/api` returns or any source names an
`/api/` path; `hanzo.yml` runs it before every image.
## Development Patterns ## Development Patterns
### Authentication Flow ### Authentication Flow
@@ -158,7 +176,8 @@ Uses pg-boss for job queuing:
Key environment variables: Key environment variables:
- `DATABASE_URL`: PostgreSQL connection - `DATABASE_URL`: PostgreSQL connection
- `NEXTAUTH_URL/SECRET`: Authentication config - `NEXTAUTH_URL`: `<origin>/v1/iam` — next-auth v4 mounts itself at this URL's path; `src/env.js` refuses any other
- `NEXTAUTH_SECRET`: Authentication config
- `EMAIL_*`: SMTP configuration - `EMAIL_*`: SMTP configuration
- `UPLOAD_*`: S3-compatible storage config - `UPLOAD_*`: S3-compatible storage config
- `NEXT_PUBLIC_BILLING_URL` / `NEXT_PUBLIC_PAY_URL`: override the hosted billing - `NEXT_PUBLIC_BILLING_URL` / `NEXT_PUBLIC_PAY_URL`: override the hosted billing
+1 -1
View File
@@ -23,7 +23,7 @@ Following envrionment variables are required
NODE_ENV="production" NODE_ENV="production"
DATABASE_URL="postgres://user:password@host:port/dbname" DATABASE_URL="postgres://user:password@host:port/dbname"
NEXTAUTH_SECRET="xxx" # Generated by `openssl rand -base64 32` NEXTAUTH_SECRET="xxx" # Generated by `openssl rand -base64 32`
NEXTAUTH_URL="https://your-domain.com" NEXTAUTH_URL="https://your-domain.com/v1/iam" # where next-auth is mounted
NEXT_PUBLIC_BASE_URL="https://your-domain.com" NEXT_PUBLIC_BASE_URL="https://your-domain.com"
# Hanzo IAM application: sign-in, and the identity that sends email through # Hanzo IAM application: sign-in, and the identity that sends email through
+12
View File
@@ -1,6 +1,18 @@
# Next.js + Prisma cap-table app. docker/Dockerfile installs with # Next.js + Prisma cap-table app. docker/Dockerfile installs with
# `pnpm i --frozen-lockfile` and runs `next build`, so a lockfile that disagrees # `pnpm i --frozen-lockfile` and runs `next build`, so a lockfile that disagrees
# with package.json, or a build that fails, publishes no image. # with package.json, or a build that fails, publishes no image.
test:
# src/: every route is /v1 and no /api/ path is served or called
# (src/routes.test.ts), plus the unit suites. goja/test/*.mjs are node
# scripts, not vitest suites, so the run is scoped to src/.
- name: unit
run: |
set -e
corepack enable
pnpm install --frozen-lockfile --ignore-scripts
pnpm exec vitest run src/
images: images:
- name: captable - name: captable
context: . context: .
-7
View File
@@ -1,7 +0,0 @@
import { withServerSession } from "@/server/auth";
import { db } from "@/server/db";
export const POST = async (req: Request) => {
const session = await withServerSession();
const { user } = session;
};
-12
View File
@@ -1,12 +0,0 @@
import api from "@/server/api";
import { handle } from "hono/vercel";
const handler = handle(api);
export {
handler as GET,
handler as POST,
handler as PUT,
handler as DELETE,
handler as PATCH,
};
-13
View File
@@ -1,13 +0,0 @@
import { ApiReference } from "@scalar/nextjs-api-reference";
const config = {
spec: {
url: "/api/v1/schema",
},
metaData: {
title: "Captable API Docs",
description: "Captable API Docs",
},
};
export const GET = ApiReference(config);
@@ -17,7 +17,7 @@ const createContext = (req: NextRequest) => {
const handler = (req: NextRequest) => const handler = (req: NextRequest) =>
fetchRequestHandler({ fetchRequestHandler({
endpoint: "/api/trpc", endpoint: "/v1/trpc",
req, req,
router: appRouter, router: appRouter,
createContext: () => createContext(req), createContext: () => createContext(req),
+6
View File
@@ -1 +1,7 @@
export const IS_IAM_ENABLED = true; export const IS_IAM_ENABLED = true;
// Where next-auth is mounted: src/app/v1/iam/[...nextauth]. next-auth v4 takes
// its server-side mount from the PATH of NEXTAUTH_URL (src/env.js refuses any
// other path) and its browser-side mount from SessionProvider's basePath, so
// both read this one value.
export const AUTH_BASE_PATH = "/v1/iam";
+1 -1
View File
@@ -62,7 +62,7 @@ export const MagicLinkEmail = ({ magicLink }: MagicLinkEmailProps) => (
); );
MagicLinkEmail.PreviewProps = { MagicLinkEmail.PreviewProps = {
magicLink: `${constants.url}/api/auth/callback/email?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fonboarding&token=671d9eac4043bbe1c22aeafd419ddfe79c2282ec755c558ea789671fdaffe8dd&email=ceo%40example.com`, magicLink: `${constants.url}/v1/iam/callback/email?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fonboarding&token=671d9eac4043bbe1c22aeafd419ddfe79c2282ec755c558ea789671fdaffe8dd&email=ceo%40example.com`,
} as MagicLinkEmailProps; } as MagicLinkEmailProps;
export default MagicLinkEmail; export default MagicLinkEmail;
+13 -1
View File
@@ -36,7 +36,19 @@ export const env = createEnv({
.string() .string()
.default("0") .default("0")
.transform((s) => s !== "false" && s !== "0"), .transform((s) => s !== "false" && s !== "0"),
NEXTAUTH_URL: z.string(), // next-auth v4 mounts itself at the PATH of this URL, and the handler lives
// at src/app/v1/iam/[...nextauth] (AUTH_BASE_PATH in src/constants/auth.ts).
// A bare origin would announce a callback under next-auth's default mount,
// which nothing serves, so any other path is refused here.
NEXTAUTH_URL: z
.string()
.url()
.refine(
(str) =>
URL.canParse(str) &&
new URL(str).pathname.replace(/\/$/, "") === "/v1/iam",
"NEXTAUTH_URL must be <origin>/v1/iam",
),
NEXTAUTH_SECRET: z.string(), NEXTAUTH_SECRET: z.string(),
// upload // upload
+7 -1
View File
@@ -4,6 +4,8 @@ import type { Session } from "next-auth";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import type React from "react"; import type React from "react";
import { AUTH_BASE_PATH } from "@/constants/auth";
export type NextAuthProviderProps = { export type NextAuthProviderProps = {
session?: Session | null; session?: Session | null;
children: React.ReactNode; children: React.ReactNode;
@@ -13,5 +15,9 @@ export const NextAuthProvider = ({
session, session,
children, children,
}: NextAuthProviderProps) => { }: NextAuthProviderProps) => {
return <SessionProvider session={session}>{children}</SessionProvider>; return (
<SessionProvider session={session} basePath={AUTH_BASE_PATH}>
{children}
</SessionProvider>
);
}; };
+51
View File
@@ -0,0 +1,51 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { AUTH_BASE_PATH } from "./constants/auth";
// Every route this app serves is under /v1: next-auth at /v1/iam, tRPC at
// /v1/trpc, the public REST API at /v1/companies (HIP-0111). An /api/ path is
// neither served nor called.
const src = dirname(fileURLToPath(import.meta.url));
const root = dirname(src);
// A path literal that starts with /api: '/api/…', "/api", `/api/…`, (/api/….
// A third-party URL (https://host/api/…) has its host in front and is not one.
const API_PATH = /['"`(]\/api(\/|['"`])/;
function files(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = join(dir, e.name);
if (e.isDirectory()) return files(p);
return /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e.name) &&
p !== fileURLToPath(import.meta.url)
? [p]
: [];
});
}
describe("routes", () => {
it("serves nothing from src/app/api", () => {
expect(existsSync(join(src, "app", "api"))).toBe(false);
});
it("mounts next-auth at the auth base path", () => {
expect(AUTH_BASE_PATH).toBe("/v1/iam");
expect(
existsSync(join(src, "app", "v1", "iam", "[...nextauth]", "route.ts")),
).toBe(true);
});
it("names no /api/ path in source", () => {
const hits = [...files(src), join(root, "next.config.js")].flatMap((f) =>
readFileSync(f, "utf8")
.split("\n")
.map((line, i) => ({ line, at: `${relative(root, f)}:${i + 1}` }))
.filter(({ line }) => API_PATH.test(line))
.map(({ at, line }) => `${at}: ${line.trim()}`),
);
expect(hits).toEqual([]);
}, 60_000);
});
+4 -2
View File
@@ -24,9 +24,11 @@ declare module "hono" {
} }
export function PublicAPI() { export function PublicAPI() {
// Routes name their own version (/v1/companies, see utils/endpoint-creator),
// served by src/app/v1/[[...route]]; there is no prefix in front of them.
const api = new OpenAPIHono({ const api = new OpenAPIHono({
defaultHook: handleZodError, defaultHook: handleZodError,
}).basePath("/api"); });
api.onError(handleError); api.onError(handleError);
@@ -36,7 +38,7 @@ export function PublicAPI() {
version: "v1", version: "v1",
title: `${process.env.NEXT_PUBLIC_APP_NAME || "Hanzo Captable"} API (v1)`, title: `${process.env.NEXT_PUBLIC_APP_NAME || "Hanzo Captable"} API (v1)`,
}, },
servers: [{ url: `${env.NEXTAUTH_URL}` }], servers: [{ url: env.NEXT_PUBLIC_BASE_URL }],
})); }));
api.openAPIRegistry.registerComponent( api.openAPIRegistry.registerComponent(
+1 -1
View File
@@ -68,7 +68,7 @@ async function fetchSessionFromAuthUrl(
): Promise<Session> { ): Promise<Session> {
const rawRequest = c.req.raw; const rawRequest = c.req.raw;
const clonedRequest = rawRequest.clone(); const clonedRequest = rawRequest.clone();
const newUrl = new URL("/api/auth/session", authUrl).toString(); const newUrl = new URL("/v1/iam/session", authUrl).toString();
const response = await fetch( const response = await fetch(
new Request(newUrl, { new Request(newUrl, {
+1 -1
View File
@@ -80,7 +80,7 @@ declare module "next-auth/jwt" {
organization?: string; organization?: string;
// The IAM token the cap-table backend authenticates with. It lives here and // The IAM token the cap-table backend authenticates with. It lives here and
// only here: the JWT is an encrypted httpOnly cookie, while `session` is // only here: the JWT is an encrypted httpOnly cookie, while `session` is
// served to the browser verbatim at /api/auth/session. // served to the browser verbatim at /v1/iam/session.
hanzoToken?: string; hanzoToken?: string;
hanzoTokenExpires?: number; hanzoTokenExpires?: number;
} }
+1 -1
View File
@@ -13,7 +13,7 @@ function getBaseUrl() {
export function getUrl() { export function getUrl() {
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
return `${baseUrl}/api/trpc`; return `${baseUrl}/v1/trpc`;
} }
/** /**