mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
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:
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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: .
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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),
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user