Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions .cursor/rules/frontend/auth.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,35 @@ alwaysApply: false
## Cookie Storage

- Single cookie `api.session` (env: `AUTH_COOKIE_NAME` / `NEXT_PUBLIC_AUTH_COOKIE_NAME`) stores JSON `{ token, refreshToken }`
- No legacy dual-cookie names (`better-auth.*`)
- Next is the only process that **writes** `api.session`. Fastify is the only process that **creates, rotates, or deletes** the session row
- Config in `lib/env.ts` via createEnv
- `httpOnly: false` stays so the browser can send Bearer to Fastify for domain data

## createClient Auth Modes

| Mode | Config | Refresh on 401 |
| --- | --- | --- |
| **apiKey** | `apiKey: 'bask_xxx'` | Never |
| **JWT** | `getAuthToken`, `getRefreshToken`, `onTokensRefreshed` (all three required) | Yes—calls Fastify `POST /auth/session/refresh` directly |
| **JWT (Next web)** | `getAuthToken`, `getRefreshToken`, `onTokensRefreshed`, `refreshTokens` → `POST /api/auth/refresh` | Yes—Next cookie hop, not Fastify from the browser |
| **JWT (CLI / other)** | `getAuthToken`, `getRefreshToken`, `onTokensRefreshed` | Yes—Fastify `POST /auth/session/refresh` |
| **no-auth** | `baseUrl` only | Never |

## Refresh Flow
## Web auth hop

- Core calls Fastify `POST /auth/session/refresh` directly on 401 (no BFF proxy)
- `onTokensRefreshed` → `updateAuthTokens` → `POST /api/auth/update-tokens` persists new tokens in cookie
- Before `Set-Cookie`, `update-tokens` requires same-origin `Origin` and Fastify `POST /auth/session/validate-tokens` approval of the access/refresh pair
- Proxy (`proxy.ts`) refreshes on navigation; core refreshes on client-side 401 (e.g. `useUser`)
- Browser never calls Fastify refresh. 401 → same-origin `POST /api/auth/refresh` → SDK `auth.session.refresh` → `Set-Cookie` → `{ token, refreshToken }`
- `proxy.ts` and `/api/auth/refresh` share `refreshTokensWithRefreshToken` / `createBffClient`. Do not duplicate fetch-to-Fastify in the proxy
- Discriminated refresh: clear cookies only on `invalid`; `unavailable` (429/5xx/network) leaves the cookie
- Logout, callbacks, and in-page issuance (magic-link code, passkey, Google one-tap) use Next route → SDK → Fastify → `Set-Cookie`. Keep `update-tokens` for in-page Fastify token pairs
- Domain mutations stay `client SDK → Fastify`. Do not rewrite them as Server Actions or a general BFF

## Mutations

- Login, profile, passkeys, TOTP, and API keys mutate via `@repo/core` / `@repo/react` → Fastify. Do **not** rewrite them as Next.js Server Actions.
- Pending/optimistic UI stays on TanStack mutation flags; do **not** rewrite to `useFormStatus` / `useOptimistic`.
- Next API routes exist only for SSR/cookie integration (`update-tokens`, auth callbacks, logout).
- Next API routes exist only for cookie + SSR + auth exchange.

## Auth Protection (proxy.ts)

- Proxy (`proxy.ts`) is the single source of truth for route-level auth: unauthenticated users → `/auth/login`; authenticated on `/auth/login` → `/`
- Proxy (`proxy.ts`) is the UI gate: unauthenticated users → `/auth/login`; authenticated on `/auth/login` → `/`. It is not the revocation authority
- **Never** add `getAuthStatus()` + `redirect()` in layouts or pages for auth gating—proxy already enforces this
- Use `getAuthStatus()` or `getUserInfo()` only when you need user/session data for rendering (e.g. shell, profile), not for redirect logic
2 changes: 1 addition & 1 deletion .cursor/rules/frontend/stack.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ alwaysApply: false
- `@repo/react`: React Query hooks and helpers only—never UI components. Route-specific UI (e.g. login form) lives in apps, collocated by route
- `nuqs`: URL-based state (search/filters/tabs/pagination)
- `zod`: Schema validation
- `@repo/lib`: Utility library
- `@repo/utils`: Utility library
- `lodash-es`: Common operations (per-function imports)
- `ahooks`: `useSetState` (grouped state), `useLocalStorageState` (localStorage)

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/api-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-pnpm
- name: Check OpenAPI drift
run: pnpm generate && git diff --exit-code -- apps/api/openapi/openapi.json packages/core/src/gen
run: pnpm generate && git diff --exit-code -- apps/api/openapi/openapi.json packages/core/src/gen packages/core/src/api-wrapper.gen.ts packages/core/src/api-client.gen.ts packages/cli/src/gen
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- name: Unit tests with coverage
run: pnpm --filter @repo/api test:cov
- name: Upload Vitest coverage
Expand Down
19 changes: 13 additions & 6 deletions _first/basilic/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,32 @@ The system has an inspectable structural model at the level its scale requires.
- **Fact:** Deployables: `apps/api` (system of record), `apps/web`, `apps/mobile` (UI scaffold), `apps/docu`
- **Fact:** Packages: `core` (generated client), handwritten `react`, `ui`, `utils`, `error`, `cli`, `email`. Security mail is `@repo/email` + Fastify `emailProvider`.
- **Fact:** Apps depend on packages, never the reverse. `react` depends on `core`. Clients call Fastify over HTTP.
- **Fact:** Store: PostgreSQL via `DATABASE_URL`. PGLite when `PGLITE=true`. Supabase is the managed host, not the auth SDK.
- **Fact:** Externals: Vercel, Resend, OAuth IdPs, AI providers, Sentry/GlitchTip, EAS, scanners
- **Fact:** Contract source is TypeBox on Fastify routes; OpenAPI is generated ([api.mdx](../../apps/docu/content/docs/architecture/api.mdx), [ADR 009](../../apps/docu/content/docs/adrs/009-api-architecture.mdx))
- **Fact:** Shipped deploy path is Vercel + Supabase ([portability.mdx](../../apps/docu/content/docs/architecture/portability.mdx))
- **Fact:** Store: PostgreSQL via `DATABASE_URL`. PGLite when `PGLITE=true` **or** `NODE_ENV=test`. Compiled PGLite requires SQL copied into `dist`. Supabase is the managed host, not the auth SDK.
- **Fact:** Externals: Vercel, Resend, OAuth IdPs, AI providers, Sentry/GlitchTip (**installed, inactive**), EAS, scanners
- **Fact:** Contract source is TypeBox on Fastify routes; OpenAPI is generated; core internals **and** public wrappers plus CLI metadata; React hooks handwritten
- **Fact:** Shipped deploy path is Vercel + `DATABASE_URL` Postgres ([portability.mdx](../../apps/docu/content/docs/architecture/portability.mdx))
- **Fact:** Web auth hop: browser → Next cookie route → SDK → Fastify. Cookie is SSR/security copy. Fastify is session SoT and revocation. Refresh reuse-grace on previous `jti`.
- **Fact:** Next `proxy.ts` is a JWT UI gate (shared `JWT_SECRET`), not revocation.
- **Fact:** `/health` is readiness: 503 when DB probe fails; no deep third-party probes
- **Unresolved:** GCP/AWS as first-class deploy targets; mobile as an API consumer
- **Unresolved:** dedicated ADRs for custom JWT + cookie BFF (rationale lives in authentication MDX)

```mermaid
flowchart LR
web[apps/web]
proxy[proxy.ts UI gate]
mobile[apps/mobile]
cli[packages/cli]
core[packages/core]
api[apps/api]
db[(PostgreSQL)]
web --> core
web --> proxy
proxy -->|"JWT_SECRET verify"| web
web -->|"auth hop"| core
cli --> core
core -->|"HTTP"| api
api --> db
mobile -.->|"not wired"| core
mobile -.->|"tokens later"| core
```

## Minimum Useful Artifact
Expand Down
2 changes: 1 addition & 1 deletion _first/basilic/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Production behavior is observable at the level the project needs. Logs are struc

- **Fact:** [logging.mdx](../../apps/docu/content/docs/architecture/logging.mdx) — Pino `@repo/utils/logger/server` and `/client`. HTTP: Fastify `request.log` with join key **`reqId`**. Secrets redacted. Never `console.*` in app code.
- **Fact:** [error-handling.mdx](../../apps/docu/content/docs/architecture/error-handling.mdx) — `@repo/error` `captureError` is log-only; Sentry packages installed but **inactive**; HTTP catalog `{ code, message }`
- **Fact:** `GET /health` `{ ok: true, dbReady }` — no auth, no deep probes (Resend/AI/IdP)
- **Fact:** `GET /health` is readiness: **200** `{ ok: true, dbReady: true }` when `SELECT 1` succeeds; **503** `{ ok: false, dbReady: false }` when the store is down. No Resend/AI/IdP probes.
- **Fact:** Deploy: [vercel.mdx](../../apps/docu/content/docs/deployment/vercel.mdx), [self-hosted-llm.mdx](../../apps/docu/content/docs/deployment/self-hosted-llm.mdx)
- **Fact:** Rate limits are in-memory per API instance (Security names the policy; this station names the multi-replica blind spot)
- **Fact:** `session_issued` is ops (Pino). Product `auth_succeeded` / `auth_failed` are no-op `capture()` calls, not log lines.
Expand Down
2 changes: 1 addition & 1 deletion _first/basilic/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Must work after clone → `pnpm setup` → `db:start` → `pnpm reset` → `pnpm
- Docs site (`apps/docu`), Cursor rules, basilic-skills playbooks
- `@repo/ui` tokens in `packages/ui/src/styles/tokens.css`
- `@repo/email` for auth mail; CLI with API key only
- Pino `reqId`; `GET /health` `{ ok, dbReady }`
- Pino `reqId`; `GET /health` readiness (503 when DB probe fails)

### Demo chrome

Expand Down
2 changes: 1 addition & 1 deletion _first/basilic/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Trust boundaries are documented. Auth rules are consistent and enforced at bound
- **Fact:** New-device mail: fingerprint on other session rows; `WEB_APP_URL` allowlisted; JWT-only session list/delete; public revoke token CAS.
- **Fact:** CORS SoT: Fastify `ALLOWED_ORIGINS` (`apps/api/src/plugins/cors.ts`). Prod fails on `*` or empty. Not `vercel.json`.
- **Fact:** Login-route rate-limit subset as shipped in security MDX. In-memory per instance (Operations names the replica gap).
- **Fact:** Cookie `api.session` is `httpOnly: false` by design so the browser client can read tokens. Same-origin `update-tokens` + Fastify `validate-tokens` before write.
- **Fact:** Cookie `api.session` is `httpOnly: false` by design so the browser client can send Bearer to Fastify for domain data. Next writes the cookie after Fastify success (`POST /api/auth/refresh`, callbacks, `update-tokens`). Fastify remains issuer and revocation. Refresh reuse-grace on previous `jti`.
- **Fact:** Secrets: `ENCRYPTION_KEY`, `JWT_SECRET`, OAuth client secrets, `RESEND_API_KEY`, AI keys, `SENTRY_DSN` (unused until Sentry is re-enabled), `DATABASE_URL`, `AI_GATEWAY_API_KEY`, `EXPO_TOKEN`
- **Unresolved:** named threat model; data classification list; in-product AI tool permission matrix
- **Unresolved:** accepted-risk register with owner and next review
Expand Down
2 changes: 1 addition & 1 deletion apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Copy `.env.test.example` to `.env.test` (gitignored) for unit tests. Vitest load
## pnpm commands

- `pnpm dev` — Dev server with hot reload (requires db)
- `pnpm build` — Migrations + TypeScript build
- `pnpm build` — OpenAPI generate + TypeScript compile (copies PGLite SQL into `dist`). PostgreSQL migrate is a separate `pnpm db:migrate` / Vercel phase
- `pnpm start` — Production server
- `pnpm test` — Unit tests (Vitest)
- `pnpm test:e2e` — E2E (expects API URL via env or `--api`)
Expand Down
24 changes: 23 additions & 1 deletion apps/api/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"tags": [
"health"
],
"description": "Health check endpoint",
"description": "Readiness: process is up and the database answers SELECT 1",
"security": [],
"responses": {
"200": {
Expand All @@ -51,6 +51,28 @@
}
}
}
},
"503": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ok",
"dbReady"
],
"properties": {
"ok": {
"type": "boolean"
},
"dbReady": {
"type": "boolean"
}
}
}
}
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@
"test": "pnpm test:unit",
"test:unit": "node scripts/check-test-imports.mjs && NODE_OPTIONS='--import tsx' vitest run",
"test:watch": "NODE_OPTIONS='--import tsx' vitest",
"test:cov": "NODE_OPTIONS='--import tsx' vitest run --coverage",
"test:cov": "node scripts/check-test-imports.mjs && NODE_OPTIONS='--import tsx' vitest run --coverage",
"test:e2e": "node scripts/run-e2e.mjs",
"test:e2e:local": "node scripts/run-e2e-local.mjs",
"test:e2e:ui": "pnpm test:e2e -- --ui",
"test:e2e:debug": "pnpm test:e2e -- --debug",
"start": "pnpm build && node dist/server.js",
"start:ci": "NODE_ENV=test ALLOW_TEST=true node --import tsx server.ts",
"build": "pnpm generate:openapi && pnpm build:ts",
"build:ts": "tsc -p tsconfig.build.json",
"build:ts": "tsc -p tsconfig.build.json && node scripts/copy-migrations.mjs",
"watch:ts": "pnpm build:ts && concurrently 'tsc -p tsconfig.build.json -w'",
"dev": "pnpm generate:openapi && concurrently -n server,openapi 'node --watch --import tsx server.ts' 'chokidar \"src/routes/**/*.ts\" \"src/plugins/**/*.ts\" -c \"pnpm generate:openapi\"'",
"lint": "pnpm run lint:biome && pnpm run lint:eslint",
Expand Down
9 changes: 9 additions & 0 deletions apps/api/scripts/copy-migrations.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { cpSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const apiRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const from = join(apiRoot, 'src/db/migrations')
const to = join(apiRoot, 'dist/src/db/migrations')
mkdirSync(to, { recursive: true })
cpSync(from, to, { recursive: true })
16 changes: 2 additions & 14 deletions apps/api/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import swagger from '@fastify/swagger'
import Fastify from 'fastify'
import { openapiSecurity } from '../src/lib/openapi-security.js'

const scriptPath = fileURLToPath(import.meta.url)
const scriptDir = dirname(scriptPath)
Expand Down Expand Up @@ -123,20 +124,7 @@ async function generateOpenAPI() {
version: '1.0.0',
description: 'Basilic API documentation',
},
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
apiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
},
},
},
security: [{ bearerAuth: [] }, { apiKeyAuth: [] }],
...openapiSecurity,
},
})

Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ function shouldUsePGLite(): boolean {
}

export async function getDb() {
const testOverride = (globalThis as { __basilicGetDb?: () => ReturnType<typeof loadDb> })
.__basilicGetDb
if (testOverride) return testOverride()
return loadDb()
}

async function loadDb() {
if (!db)
if (shouldUsePGLite()) {
if (env.NODE_ENV === 'test') {
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export async function runMigrations(logger?: {
const migrationFiles = await readMigrationFiles(migrationsDir)

if (migrationFiles.length === 0) {
const shouldUsePGLite = env.PGLITE === true || env.NODE_ENV === 'test'
if (shouldUsePGLite)
throw new Error(
`No SQL migrations found in ${migrationsDir}. Compiled PGLite/test starts require copied migration assets.`,
)
logger?.info('No migrations found, skipping migration step')
return
}
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/db/migrations/0019_deep_human_cannonball.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "sessions" ADD COLUMN "previous_token" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "current_jti" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "rotated_at" timestamp;
Loading
Loading