Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
23 changes: 18 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,21 @@ FLANK_ALERT_FROM=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

# Web session signing — HMAC key for the signed session cookie. Must be >= 32 bytes; REQUIRED in
# production (the app refuses to start without it). AUTH_SESSION_SECRET wins; NEXTAUTH_SECRET is a
# fallback. Generate one with: openssl rand -base64 48
AUTH_SESSION_SECRET=
NEXTAUTH_SECRET=
# Auth.js (NextAuth v5) session/JWT encryption key. REQUIRED in production (the app refuses to start
# without it); auto-generated in dev. Generate one with: openssl rand -base64 48
AUTH_SECRET=

# FerrisKey OIDC (identity provider). Provision the local stack with:
# just ferriskey-up && just ferriskey-bootstrap (prints these values to paste here)
# Issuer is the API base; the realm is appended to form the OIDC issuer used for discovery.
FERRISKEY_ISSUER=http://localhost:3333
FERRISKEY_REALM=flank
FERRISKEY_CLIENT_ID=flank-web
FERRISKEY_CLIENT_SECRET=

# Admin credentials for the local FerrisKey console + bootstrap script.
# DEV ONLY — admin/admin is fine for localhost. A FerrisKey reachable beyond localhost MUST use a
# strong password: admin access can mint OIDC tokens for any realm user and bypass all app auth.
FERRISKEY_ADMIN_USERNAME=admin
FERRISKEY_ADMIN_PASSWORD=admin
FERRISKEY_ADMIN_EMAIL=admin@flank.local
13 changes: 13 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,23 @@ All history tables are **append-only** (Invariant 5). Workspace-scoped throughou
| **battlecard_section** | id, competitor_id, kind (why_we_win/landmines/pricing_counter/objections), version, content_md, claim_ids[], supersedes_id — append-only versions |
| **alert** | id, workspace_id, delta_id, channel (slack/email/crm), payload, status (queued/delivered/failed), delivered_at |
| **coverage_run** | id, workspace_id, period, sources_checked, fetch_failures, deltas_found, material_deltas, llm_cost_cents — feeds digest receipts and COGS metering |
| **app_user** | id, email, name, external_subject (FerrisKey OIDC `sub`, nullable — linked on first login), created_at — global identity, not workspace-scoped |
| **membership** | id, user_id, workspace_id, role (owner/member), created_at — the ONLY thing that confers tenancy (Invariant 8); mutable |

pgvector lives on `snapshot.normalized_text` embeddings (changed-span similarity, "have we
seen this repositioning before") — an enhancement, not on the M1 critical path.

### Authentication & tenancy origin

Identity is delegated to **FerrisKey** (self-hosted, Keycloak-alternative IAM) over OIDC; the web
app is a relying party via **Auth.js v5** (`apps/web/auth.ts`). FerrisKey answers _who_ the user is;
it never carries _tenancy_. On each sign-in the verified identity is mapped to exactly one local
`app_user` (`linkOrCreateUserBySubject`, keyed by the immutable `sub`, email-backfilled for seed
rows), and that local id is pinned on the session. Every authed request then re-derives its
`workspaceId` + role from **live** `membership` rows (`resolveActiveWorkspace` → pure
`resolveWorkspace`), so a revoked grant takes effect immediately and a user with zero memberships is
fail-closed to the "no workspace" screen — Invariant 8 holds without trusting anything in the token.

## Key Flows

### 1. Ingest & diff (per source, on cadence)
Expand Down
59 changes: 43 additions & 16 deletions TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@

## just Recipes

| Recipe | What it does | When to run |
| ---------------- | -------------------------------------------------------- | --------------------------------------------------- |
| `just` | Lists all recipes | Orientation |
| `just setup` | `corepack enable` + `pnpm install` | First clone; after lockfile changes |
| `just dev` | `pnpm dev` — Next.js web + Inngest dev server/workers | Daily development |
| `just db-up` | `docker compose up -d postgres` (pgvector/pgvector:pg16) | Before `just migrate`, `just dev`, `just test` |
| `just db-down` | Stops the local Postgres container | Cleanup |
| `just migrate` | Applies Drizzle migrations to `DATABASE_URL` | After schema changes; after `db-up` on fresh volume |
| `just test` | `pnpm test` — Vitest across packages | TDD loop; before commit |
| `just e2e` | `pnpm e2e` — Playwright suite | Before merging UI/flow changes |
| `just lint` | `pnpm lint` — ESLint workspace-wide | Before commit |
| `just format` | `pnpm format` — Prettier write | When the hook didn't catch a file |
| `just typecheck` | `pnpm typecheck` — `tsc --noEmit` | Before commit |
| `just build` | `pnpm build` — production build all packages | Verifying release readiness |
| `just ci` | lint + typecheck + test + build | Mirror of GitHub Actions; run before pushing |
| Recipe | What it does | When to run |
| -------------------------- | -------------------------------------------------------- | --------------------------------------------------- |
| `just` | Lists all recipes | Orientation |
| `just setup` | `corepack enable` + `pnpm install` | First clone; after lockfile changes |
| `just dev` | `pnpm dev` — Next.js web + Inngest dev server/workers | Daily development |
| `just db-up` | `docker compose up -d postgres` (pgvector/pgvector:pg16) | Before `just migrate`, `just dev`, `just test` |
| `just db-down` | Stops the local Postgres container | Cleanup |
| `just ferriskey-up` | FerrisKey IAM stack (API :3333, console :5555, db :5434) | Before signing in locally |
| `just ferriskey-down` | Stops the FerrisKey IAM stack | Cleanup |
| `just ferriskey-bootstrap` | Realm + client + demo user; prints `FERRISKEY_*` env | Once after `ferriskey-up` |
| `just migrate` | Applies Drizzle migrations to `DATABASE_URL` | After schema changes; after `db-up` on fresh volume |
| `just test` | `pnpm test` — Vitest across packages | TDD loop; before commit |
| `just e2e` | `pnpm e2e` — Playwright suite | Before merging UI/flow changes |
| `just lint` | `pnpm lint` — ESLint workspace-wide | Before commit |
| `just format` | `pnpm format` — Prettier write | When the hook didn't catch a file |
| `just typecheck` | `pnpm typecheck` — `tsc --noEmit` | Before commit |
| `just build` | `pnpm build` — production build all packages | Verifying release readiness |
| `just ci` | lint + typecheck + test + build | Mirror of GitHub Actions; run before pushing |

All recipes exit with a helpful message until the pnpm workspace exists (M0 in DESIGN.md).

Expand Down Expand Up @@ -50,15 +53,39 @@ All recipes exit with a helpful message until the pnpm workspace exists (M0 in D
| `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` | Slack app delivery |
| `RESEND_API_KEY` | Email digests |
| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | Billing (M3) |
| `NEXTAUTH_SECRET` (or equivalent) | Web session signing |
| `AUTH_SECRET` | Auth.js (NextAuth v5) session/JWT key |
| `FERRISKEY_ISSUER`, `FERRISKEY_REALM` | FerrisKey OIDC issuer base + realm (discovery) |
| `FERRISKEY_CLIENT_ID`, `FERRISKEY_CLIENT_SECRET` | Confidential OIDC client for the web app |
| `FERRISKEY_ADMIN_USERNAME/PASSWORD/EMAIL` | Local FerrisKey console + bootstrap (dev: admin) |

No values in the repo, ever. `packages/core` exposes a startup validator that fails fast when
a required var for the running surface is missing. Keep a `.env.example` with names only.

## Authentication (FerrisKey OIDC)

Identity is owned by **FerrisKey** (self-hosted Keycloak-alternative IAM); the web app is an OIDC
relying party via **Auth.js v5**. Tenancy is NOT in the token — every request re-derives workspace

- role from the local `memberships` table (Invariant 8). First-time logins are JIT-provisioned into
`app_user` and linked by the IdP `sub` (`linkOrCreateUserBySubject`); a user with no membership is
fail-closed to the "no workspace" screen. Local setup:

```
just ferriskey-up # API :3333, console :5555, its own Postgres :5434
just ferriskey-bootstrap # realm `flank` + client `flank-web` + demo user; prints FERRISKEY_* env
# paste FERRISKEY_CLIENT_SECRET into .env, then:
just db-up && just migrate && just seed && just dev
```

Console: http://localhost:5555 (default `admin`/`admin`) is the source of truth if the bootstrap
script drifts from a future FerrisKey API.

## Local Services

- **Postgres 16 + pgvector** via `docker compose` (`pgvector/pgvector:pg16`), port 5432,
started with `just db-up`. Used by dev, Vitest integration tests, and Drizzle migrations.
- **FerrisKey IAM** via `docker compose --profile auth` (`just ferriskey-up`): API `:3333`,
console `:5555`, dedicated Postgres `:5434`. The OIDC identity provider for sign-in.
- **Inngest dev server** runs as part of `just dev` for local function execution/cron simulation.
- S3 is mocked or pointed at a local MinIO container if snapshot tests need it (add to compose when M1 lands).

Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// FerrisKey OIDC handshake endpoints (authorize, callback, token, signout) handled by Auth.js.
// Runs on the Node runtime (default) — the jwt callback touches the Postgres-backed store.
import { handlers } from '../../../../auth';

export const { GET, POST } = handlers;
22 changes: 6 additions & 16 deletions apps/web/app/auth/sign-in/actions.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,12 @@
'use server';

import { EmailSchema } from '@flank/core';
import { redirect } from 'next/navigation';
import { startSession } from '../../../lib/auth/session';
import { getStore } from '../../../lib/store';
import { signIn } from '../../../auth';

/**
* Dev sign-in: look up a user by email and mint a session. No password / OAuth / magic-link yet
* (deferred) — but the failure path is already constant: an unparseable or unknown email yields the
* SAME generic error, so this endpoint can never be used to enumerate which emails have accounts.
* Start the FerrisKey OIDC login. Auth.js redirects to the realm's authorization endpoint (PKCE),
* and the callback returns the user to `/authed`. No email/password is handled here — identity is
* owned entirely by FerrisKey.
*/
export const signIn = async (formData: FormData): Promise<void> => {
const parsed = EmailSchema.safeParse(formData.get('email'));
if (!parsed.success) redirect('/auth/sign-in?e=invalid');

const user = await getStore().findUserByEmail(parsed.data);
if (user === null) redirect('/auth/sign-in?e=invalid');

await startSession(user.id);
redirect('/authed');
export const signInWithFerrisKey = async (): Promise<void> => {
await signIn('ferriskey', { redirectTo: '/authed' });
};
28 changes: 7 additions & 21 deletions apps/web/app/auth/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { Metadata } from 'next';
import { signIn } from './actions';
import { signInWithFerrisKey } from './actions';

export const metadata: Metadata = { title: 'Sign in — Flank' };

const MESSAGES: Readonly<Record<string, string>> = {
invalid: 'We could not sign you in with that email. Check it and try again.',
no_workspace: 'That account has no workspace yet. Ask an owner to add you.',
oidc: 'Sign-in failed. Please try again.',
};

export default async function SignInPage({
Expand All @@ -22,8 +22,8 @@ export default async function SignInPage({
<p className="masthead-kicker mono">Flank · competitor radar</p>
<h1 className="auth-title">Sign in</h1>
<p className="auth-sub">
Enter the email tied to your workspace. We resolve every request to a live membership — no
stale tenancy is ever trusted from the cookie.
Continue with FerrisKey to verify your identity. We resolve every request to a live
membership — no tenancy is ever trusted from the token.
</p>

{message !== undefined ? (
Expand All @@ -32,27 +32,13 @@ export default async function SignInPage({
</p>
) : null}

<form action={signIn} className="auth-form">
<label className="auth-label" htmlFor="email">
Work email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
placeholder="you@company.com"
className="auth-input mono"
/>
<form action={signInWithFerrisKey} className="auth-form">
<button type="submit" className="auth-button">
Continue
Continue with FerrisKey
</button>
</form>

<p className="auth-foot mono">
dev sign-in · password &amp; SSO deferred to a later milestone
</p>
<p className="auth-foot mono">single sign-on · identity managed by FerrisKey (OIDC)</p>
</main>
</div>
);
Expand Down
19 changes: 16 additions & 3 deletions apps/web/app/authed/actions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
'use server';

import { redirect } from 'next/navigation';
import { endSession, setWorkspaceHint } from '../../lib/auth/session';
import { signOut as authSignOut } from '../../auth';
import {
clearWorkspaceHint,
resolveActiveWorkspace,
setWorkspaceHint,
} from '../../lib/auth/session';

/**
* Sign out: drop the workspace-hint cookie, then clear the Auth.js session and return to sign-in.
* (Local session only — full RP-initiated FerrisKey logout via the realm end-session endpoint is a
* later enhancement; the next protected request already redirects to a fresh FerrisKey login.)
*/
export const signOut = async (): Promise<void> => {
await endSession();
redirect('/auth/sign-in');
await clearWorkspaceHint();
await authSignOut({ redirectTo: '/auth/sign-in' });
};

/**
Expand All @@ -14,6 +24,9 @@ export const signOut = async (): Promise<void> => {
* back to the first membership), so a forged id can never widen access.
*/
export const switchWorkspace = async (formData: FormData): Promise<void> => {
// Fail closed before touching any state: an unauthenticated POST to this action redirects to
// sign-in instead of writing a hint cookie.
await resolveActiveWorkspace();
const workspaceId = formData.get('workspaceId');
if (typeof workspaceId === 'string' && workspaceId !== '') {
await setWorkspaceHint(workspaceId);
Expand Down
Loading