diff --git a/.env.example b/.env.example
index 2e85ebf..ff5ad97 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/DESIGN.md b/DESIGN.md
index 3efdc54..aadb342 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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)
diff --git a/TOOLS.md b/TOOLS.md
index 6158c4c..c3363e2 100644
--- a/TOOLS.md
+++ b/TOOLS.md
@@ -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).
@@ -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).
diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..4df80d2
--- /dev/null
+++ b/apps/web/app/api/auth/[...nextauth]/route.ts
@@ -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;
diff --git a/apps/web/app/auth/sign-in/actions.ts b/apps/web/app/auth/sign-in/actions.ts
index 682b395..e2535a8 100644
--- a/apps/web/app/auth/sign-in/actions.ts
+++ b/apps/web/app/auth/sign-in/actions.ts
@@ -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 => {
- 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 => {
+ await signIn('ferriskey', { redirectTo: '/authed' });
};
diff --git a/apps/web/app/auth/sign-in/page.tsx b/apps/web/app/auth/sign-in/page.tsx
index f32543e..ad3b86e 100644
--- a/apps/web/app/auth/sign-in/page.tsx
+++ b/apps/web/app/auth/sign-in/page.tsx
@@ -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> = {
- 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({
@@ -22,8 +22,8 @@ export default async function SignInPage({
Flank · competitor radar
Sign in
- 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.
{message !== undefined ? (
@@ -32,27 +32,13 @@ export default async function SignInPage({
) : null}
-
-
- dev sign-in · password & SSO deferred to a later milestone
-
+ single sign-on · identity managed by FerrisKey (OIDC)
);
diff --git a/apps/web/app/authed/actions.ts b/apps/web/app/authed/actions.ts
index d0f4e05..94e26bc 100644
--- a/apps/web/app/authed/actions.ts
+++ b/apps/web/app/authed/actions.ts
@@ -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 => {
- await endSession();
- redirect('/auth/sign-in');
+ await clearWorkspaceHint();
+ await authSignOut({ redirectTo: '/auth/sign-in' });
};
/**
@@ -14,6 +24,9 @@ export const signOut = async (): Promise => {
* back to the first membership), so a forged id can never widen access.
*/
export const switchWorkspace = async (formData: FormData): Promise => {
+ // 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);
diff --git a/apps/web/auth.ts b/apps/web/auth.ts
new file mode 100644
index 0000000..c99df74
--- /dev/null
+++ b/apps/web/auth.ts
@@ -0,0 +1,90 @@
+import { EmailSchema, type ExternalIdentity } from '@flank/core';
+import NextAuth, { type DefaultSession, type Profile } from 'next-auth';
+import { getStore } from './lib/store';
+
+/**
+ * Auth.js (NextAuth v5) wired to FerrisKey as the sole identity provider.
+ *
+ * Scope is authentication ONLY: FerrisKey proves *who* the user is over OIDC; *tenancy* (which
+ * workspace, which role) is never carried in the token — it is re-derived per request from the
+ * local `memberships` table in {@link resolveActiveWorkspace} (Product Invariant 8). The bridge
+ * between the two is the local `AppUser`: on every sign-in we map the verified OIDC identity to
+ * exactly one local user via {@link FlankStore.linkOrCreateUserBySubject} and stash that local id
+ * on the token, so all downstream code keys off a stable internal id, not the raw IdP `sub`.
+ *
+ * Lazy config form (`NextAuth(() => ...)`) defers every env read to request time, mirroring the
+ * repo's deferred-validation pattern (see `lib/store.ts`) so `next build` never needs the secrets.
+ */
+
+const requiredEnv = (key: string): string => {
+ const value = process.env[key];
+ if (value === undefined || value === '') {
+ throw new Error(`${key} is required for FerrisKey OIDC (see TOOLS.md / .env.example)`);
+ }
+ return value;
+};
+
+/** Realm-scoped OIDC issuer; Auth.js discovers endpoints from its `.well-known/openid-configuration`. */
+const ferriskeyIssuer = (): string =>
+ `${requiredEnv('FERRISKEY_ISSUER').replace(/\/$/, '')}/realms/${requiredEnv('FERRISKEY_REALM')}`;
+
+/** Validate the OIDC profile at the boundary (AGENTS.md: never trust external data). */
+const toIdentity = (profile: Profile | undefined): ExternalIdentity | null => {
+ if (profile === undefined) return null;
+ const subject = profile.sub;
+ const email = EmailSchema.safeParse(profile.email);
+ if (typeof subject !== 'string' || subject === '' || !email.success) return null;
+ const name = typeof profile.name === 'string' && profile.name !== '' ? profile.name : null;
+ // Only a verified email may adopt a pre-provisioned local account (anti-hijack); absent/false fails closed.
+ const emailVerified = profile.email_verified === true;
+ return { subject, email: email.data, emailVerified, name };
+};
+
+export const { handlers, auth, signIn, signOut } = NextAuth(() => ({
+ // Route Auth.js's built-in sign-in + error pages to our own surface, so an OIDC error (cancelled
+ // login, state mismatch) lands on the branded sign-in card instead of the default Auth.js page.
+ pages: { signIn: '/auth/sign-in', error: '/auth/sign-in' },
+ providers: [
+ {
+ id: 'ferriskey',
+ name: 'FerrisKey',
+ type: 'oidc',
+ issuer: ferriskeyIssuer(),
+ clientId: requiredEnv('FERRISKEY_CLIENT_ID'),
+ clientSecret: requiredEnv('FERRISKEY_CLIENT_SECRET'),
+ authorization: { params: { scope: 'openid email profile' } },
+ checks: ['pkce', 'state'],
+ },
+ ],
+ callbacks: {
+ // First leg of a sign-in carries `profile`; provision/link the local user and pin its id.
+ // Throwing here fails the sign-in closed — we never establish a session we can't attribute.
+ async jwt({ token, profile }) {
+ if (profile !== undefined) {
+ const identity = toIdentity(profile);
+ if (identity === null) {
+ throw new Error('FerrisKey did not return a usable subject + email');
+ }
+ const user = await getStore().linkOrCreateUserBySubject(identity);
+ token.uid = user.id;
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ if (typeof token.uid === 'string') {
+ session.user.id = token.uid;
+ }
+ return session;
+ },
+ },
+}));
+
+declare module 'next-auth' {
+ /** The local AppUser id (not the IdP `sub`) every server surface reads. */
+ interface Session {
+ user: { id: string } & DefaultSession['user'];
+ }
+}
+
+// JWT carries the local user id as `token.uid`. Auth.js's JWT extends Record, so
+// no module augmentation is needed — reads are narrowed with `typeof token.uid === 'string'`.
diff --git a/apps/web/lib/auth/resolver.test.ts b/apps/web/lib/auth/resolver.test.ts
index 17b441f..fea188f 100644
--- a/apps/web/lib/auth/resolver.test.ts
+++ b/apps/web/lib/auth/resolver.test.ts
@@ -1,11 +1,8 @@
import { MemoryFlankStore } from '@flank/pipeline';
import { beforeEach, describe, expect, it } from 'vitest';
import { resolveWorkspace } from './resolver';
-import { signSession } from './session-crypto';
-const SECRET = 'resolver-test-secret-resolver-test-secret';
const NOW = Date.UTC(2026, 5, 20, 12);
-const FUTURE = NOW + 60_000;
const seeded = async (): Promise => {
const store = new MemoryFlankStore();
@@ -15,6 +12,7 @@ const seeded = async (): Promise => {
id: 'u-1',
email: 'lead@acme.test',
name: 'Lead',
+ externalSubject: 'fk-sub-1',
createdAt: new Date(NOW - 1_000),
});
return store;
@@ -29,97 +27,45 @@ const grant = (store: MemoryFlankStore, id: string, workspaceId: string, created
createdAt: new Date(createdAt),
});
-const token = (uid = 'u-1', exp = FUTURE) => signSession({ uid, exp }, SECRET);
-
describe('resolveWorkspace', () => {
let store: MemoryFlankStore;
beforeEach(async () => {
store = await seeded();
});
- it('reports no_session when the cookie is absent', async () => {
- const result = await resolveWorkspace({
- token: null,
- workspaceHint: null,
- secret: SECRET,
- store,
- nowMs: NOW,
- });
- expect(result).toEqual({ ok: false, reason: 'no_session' });
- });
-
- it('reports no_session for a token signed with the wrong secret', async () => {
- const forged = signSession(
- { uid: 'u-1', exp: FUTURE },
- 'a-totally-different-secret-aaaaaaaaaa',
- );
- const result = await resolveWorkspace({
- token: forged,
- workspaceHint: null,
- secret: SECRET,
- store,
- nowMs: NOW,
- });
+ it('reports no_session when the request is unauthenticated (no user id)', async () => {
+ const result = await resolveWorkspace({ userId: null, workspaceHint: null, store });
expect(result).toEqual({ ok: false, reason: 'no_session' });
});
- it('reports no_session for an expired token', async () => {
- const result = await resolveWorkspace({
- token: token('u-1', NOW - 1),
- workspaceHint: null,
- secret: SECRET,
- store,
- nowMs: NOW,
- });
- expect(result).toEqual({ ok: false, reason: 'no_session' });
+ it('reports no_workspace when the authenticated user has no memberships', async () => {
+ const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: null, store });
+ expect(result).toEqual({ ok: false, reason: 'no_workspace' });
});
- it('reports no_workspace when the verified user has no memberships', async () => {
- const result = await resolveWorkspace({
- token: token(),
- workspaceHint: null,
- secret: SECRET,
- store,
- nowMs: NOW,
- });
+ it('reports no_workspace for an authenticated user that does not exist locally', async () => {
+ // A freshly provisioned IdP identity with no grant yet — fail closed, never widen access.
+ const result = await resolveWorkspace({ userId: 'u-unknown', workspaceHint: null, store });
expect(result).toEqual({ ok: false, reason: 'no_workspace' });
});
it('resolves the only membership with no hint', async () => {
await grant(store, 'm-a', 'ws-a', NOW - 500);
- const result = await resolveWorkspace({
- token: token(),
- workspaceHint: null,
- secret: SECRET,
- store,
- nowMs: NOW,
- });
+ const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: null, store });
expect(result).toMatchObject({ ok: true, userId: 'u-1', workspaceId: 'ws-a', role: 'member' });
});
it('honors a hint that matches a live membership', async () => {
await grant(store, 'm-a', 'ws-a', NOW - 500); // earliest -> default
await grant(store, 'm-b', 'ws-b', NOW - 100);
- const result = await resolveWorkspace({
- token: token(),
- workspaceHint: 'ws-b',
- secret: SECRET,
- store,
- nowMs: NOW,
- });
+ const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: 'ws-b', store });
expect(result).toMatchObject({ ok: true, workspaceId: 'ws-b' });
});
it('falls back to the first membership when the hint is not a member workspace', async () => {
await grant(store, 'm-a', 'ws-a', NOW - 500);
await grant(store, 'm-b', 'ws-b', NOW - 100);
- const result = await resolveWorkspace({
- token: token(),
- workspaceHint: 'ws-not-mine',
- secret: SECRET,
- store,
- nowMs: NOW,
- });
+ const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: 'ws-not-mine', store });
// hint cannot widen access: unmatched -> earliest membership (ws-a)
expect(result).toMatchObject({ ok: true, workspaceId: 'ws-a' });
});
diff --git a/apps/web/lib/auth/resolver.ts b/apps/web/lib/auth/resolver.ts
index 3010d64..a2742fc 100644
--- a/apps/web/lib/auth/resolver.ts
+++ b/apps/web/lib/auth/resolver.ts
@@ -1,10 +1,10 @@
import type { FlankStore, MembershipRole, MembershipWithWorkspace } from '@flank/core';
-import { verifySession } from './session-crypto';
/**
- * The fully-resolved request principal: a verified user, the one workspace this request acts in, and
- * the role the user holds there. Tenancy is re-derived from LIVE memberships every request — never
- * trusted from the cookie — so a revoked membership takes effect immediately (fail-closed).
+ * The fully-resolved request principal: an authenticated user, the one workspace this request acts
+ * in, and the role the user holds there. Identity comes from the Auth.js / FerrisKey session, but
+ * tenancy is re-derived from LIVE memberships every request — never trusted from the token — so a
+ * revoked membership takes effect immediately (fail-closed, Product Invariant 8).
*/
export type WorkspaceResolution =
| {
@@ -17,30 +17,26 @@ export type WorkspaceResolution =
| { readonly ok: false; readonly reason: 'no_session' | 'no_workspace' };
export interface ResolveWorkspaceInput {
- /** Raw signed cookie value, or null when the cookie is absent. */
- readonly token: string | null;
+ /** Local AppUser id from the authenticated Auth.js session, or null when unauthenticated. */
+ readonly userId: string | null;
/** Preferred workspace id from a non-authoritative hint cookie, or null. */
readonly workspaceHint: string | null;
- readonly secret: string;
readonly store: FlankStore;
- readonly nowMs: number;
}
/**
- * Resolve a request to an authorized workspace. Pure over an injected store, so it is unit-testable
- * without Next wiring. A bad/absent/expired cookie is `no_session`; a valid user with zero live
- * memberships is `no_workspace`. The hint only ever SELECTS among memberships the user actually has —
- * it can never widen access — and an unmatched hint falls back to the first membership.
+ * Resolve an authenticated user id to an authorized workspace. Pure over an injected store, so it is
+ * unit-testable without Next/Auth.js wiring. A null user id is `no_session`; an authenticated user
+ * with zero live memberships is `no_workspace`. The hint only ever SELECTS among memberships the
+ * user actually has — it can never widen access — and an unmatched hint falls back to the first
+ * membership (memberships arrive ordered by createdAt, id).
*/
export const resolveWorkspace = async (
input: ResolveWorkspaceInput,
): Promise => {
- if (input.token === null) return { ok: false, reason: 'no_session' };
+ if (input.userId === null) return { ok: false, reason: 'no_session' };
- const verified = verifySession(input.token, input.secret, input.nowMs);
- if (!verified.ok) return { ok: false, reason: 'no_session' };
-
- const memberships = await input.store.listMembershipsForUser(verified.principal.uid);
+ const memberships = await input.store.listMembershipsForUser(input.userId);
if (memberships.length === 0) return { ok: false, reason: 'no_workspace' };
const hinted =
@@ -51,7 +47,7 @@ export const resolveWorkspace = async (
return {
ok: true,
- userId: verified.principal.uid,
+ userId: input.userId,
workspaceId: active.workspace.id,
role: active.membership.role,
memberships,
diff --git a/apps/web/lib/auth/secret.test.ts b/apps/web/lib/auth/secret.test.ts
deleted file mode 100644
index b99f8ad..0000000
--- a/apps/web/lib/auth/secret.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { getSessionSecret } from './secret';
-
-const STRONG = 'a'.repeat(32);
-
-describe('getSessionSecret', () => {
- it('returns a provided AUTH_SESSION_SECRET when long enough', () => {
- expect(getSessionSecret({ AUTH_SESSION_SECRET: STRONG })).toEqual({
- value: STRONG,
- isDevDefault: false,
- });
- });
-
- it('falls back to NEXTAUTH_SECRET when AUTH_SESSION_SECRET is absent', () => {
- const result = getSessionSecret({ NEXTAUTH_SECRET: STRONG });
- expect(result.value).toBe(STRONG);
- expect(result.isDevDefault).toBe(false);
- });
-
- it('prefers AUTH_SESSION_SECRET over NEXTAUTH_SECRET', () => {
- const result = getSessionSecret({
- AUTH_SESSION_SECRET: STRONG,
- NEXTAUTH_SECRET: 'b'.repeat(40),
- });
- expect(result.value).toBe(STRONG);
- });
-
- it('rejects a too-short provided secret in every environment', () => {
- expect(() => getSessionSecret({ AUTH_SESSION_SECRET: 'short' })).toThrow(/at least 32 bytes/);
- expect(() =>
- getSessionSecret({ AUTH_SESSION_SECRET: 'short', NODE_ENV: 'production' }),
- ).toThrow(/at least 32 bytes/);
- });
-
- it('treats an empty-string secret as absent', () => {
- const result = getSessionSecret({ AUTH_SESSION_SECRET: '', NODE_ENV: 'development' });
- expect(result.isDevDefault).toBe(true);
- });
-
- it('throws when no secret is set in production', () => {
- expect(() => getSessionSecret({ NODE_ENV: 'production' })).toThrow(/required in production/);
- });
-
- it('falls back to an obvious insecure default outside production', () => {
- const result = getSessionSecret({ NODE_ENV: 'development' });
- expect(result.isDevDefault).toBe(true);
- expect(result.value.length).toBeGreaterThanOrEqual(32);
- expect(result.value).toMatch(/insecure/);
- });
-});
diff --git a/apps/web/lib/auth/secret.ts b/apps/web/lib/auth/secret.ts
deleted file mode 100644
index 8063d16..0000000
--- a/apps/web/lib/auth/secret.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-const MIN_SECRET_BYTES = 32;
-/** An obvious, never-confusable-with-real insecure default for non-production only. */
-const DEV_DEFAULT_SECRET = 'flank-dev-insecure-session-secret-do-not-ship-0000';
-
-export interface SessionSecret {
- readonly value: string;
- /** True when the insecure dev fallback is in use — callers must NOT set Secure cookies. */
- readonly isDevDefault: boolean;
-}
-
-/**
- * Resolve and VALIDATE the session HMAC secret (pure; env injected). A provided secret is rejected in
- * ALL environments if shorter than 32 bytes (a weak secret forges every session). An absent secret
- * throws in production and falls back to an obvious insecure default elsewhere.
- */
-export const getSessionSecret = (
- env: Readonly> = process.env,
-): SessionSecret => {
- const provided = env.AUTH_SESSION_SECRET ?? env.NEXTAUTH_SECRET;
- if (provided !== undefined && provided !== '') {
- if (Buffer.byteLength(provided, 'utf8') < MIN_SECRET_BYTES) {
- throw new Error(`session secret must be at least ${MIN_SECRET_BYTES} bytes`);
- }
- return Object.freeze({ value: provided, isDevDefault: false });
- }
- if (env.NODE_ENV === 'production') {
- throw new Error('AUTH_SESSION_SECRET (or NEXTAUTH_SECRET) is required in production');
- }
- return Object.freeze({ value: DEV_DEFAULT_SECRET, isDevDefault: true });
-};
diff --git a/apps/web/lib/auth/session-crypto.test.ts b/apps/web/lib/auth/session-crypto.test.ts
deleted file mode 100644
index 25db0f2..0000000
--- a/apps/web/lib/auth/session-crypto.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { createHmac } from 'node:crypto';
-import { describe, expect, it } from 'vitest';
-import { signSession, verifySession, SESSION_VERSION } from './session-crypto';
-
-const SECRET = 'a'.repeat(32);
-const NOW = 1_900_000_000_000;
-const future = NOW + 60_000;
-
-describe('session crypto', () => {
- it('round-trips a signed session', () => {
- const token = signSession({ uid: 'u-1', exp: future }, SECRET);
- const result = verifySession(token, SECRET, NOW);
- expect(result).toEqual({
- ok: true,
- principal: { v: SESSION_VERSION, uid: 'u-1', exp: future },
- });
- });
-
- it('rejects a token signed with a different secret', () => {
- const token = signSession({ uid: 'u-1', exp: future }, SECRET);
- expect(verifySession(token, 'b'.repeat(32), NOW)).toEqual({
- ok: false,
- reason: 'bad_signature',
- });
- });
-
- it('rejects a tampered payload (signature no longer matches)', () => {
- const token = signSession({ uid: 'u-1', exp: future }, SECRET);
- const [, sig] = token.split('.');
- const forged = `${Buffer.from(JSON.stringify({ v: 1, uid: 'admin', exp: future })).toString('base64url')}.${sig}`;
- expect(verifySession(forged, SECRET, NOW).ok).toBe(false);
- });
-
- it('rejects an expired token', () => {
- const token = signSession({ uid: 'u-1', exp: NOW - 1 }, SECRET);
- expect(verifySession(token, SECRET, NOW)).toEqual({ ok: false, reason: 'expired' });
- });
-
- it('rejects a wrong version', () => {
- const payload = Buffer.from(JSON.stringify({ v: 99, uid: 'u-1', exp: future })).toString(
- 'base64url',
- );
- // sign manually with a valid mac so the signature passes but the version is wrong
- const sig = createHmac('sha256', SECRET).update(payload).digest('base64url');
- expect(verifySession(`${payload}.${sig}`, SECRET, NOW)).toEqual({
- ok: false,
- reason: 'bad_version',
- });
- });
-
- it('rejects malformed tokens without throwing', () => {
- for (const bad of ['', 'nodot', 'a.b.c', '.sig', 'payload.', 'not%base64.x']) {
- expect(verifySession(bad, SECRET, NOW).ok).toBe(false);
- }
- });
-});
diff --git a/apps/web/lib/auth/session-crypto.ts b/apps/web/lib/auth/session-crypto.ts
deleted file mode 100644
index a2f7d0e..0000000
--- a/apps/web/lib/auth/session-crypto.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { createHmac, timingSafeEqual } from 'node:crypto';
-
-/** Bump to invalidate every existing session (e.g. payload-shape change). */
-export const SESSION_VERSION = 1;
-
-/** The signed session payload — carries ONLY identity + expiry, NEVER tenancy (re-derived per request). */
-export interface SessionPrincipal {
- readonly v: number;
- readonly uid: string;
- readonly exp: number; // epoch milliseconds
-}
-
-export type SessionVerifyResult =
- | { readonly ok: true; readonly principal: SessionPrincipal }
- | {
- readonly ok: false;
- readonly reason: 'malformed' | 'bad_signature' | 'expired' | 'bad_version';
- };
-
-const encode = (text: string): string => Buffer.from(text, 'utf8').toString('base64url');
-const decode = (b64: string): string => Buffer.from(b64, 'base64url').toString('utf8');
-
-/** HMAC-SHA256 over the EXACT payload string (the base64url text before the dot), not re-serialized
- * JSON — so key order / whitespace / unicode escaping can never desync sign and verify. */
-const mac = (payloadB64: string, secret: string): string =>
- createHmac('sha256', secret).update(payloadB64).digest('base64url');
-
-/** Mint `payloadB64.sigB64`. Total — always returns a token. */
-export const signSession = (
- principal: { readonly uid: string; readonly exp: number },
- secret: string,
-): string => {
- const payload: SessionPrincipal = { v: SESSION_VERSION, uid: principal.uid, exp: principal.exp };
- const payloadB64 = encode(JSON.stringify(payload));
- return `${payloadB64}.${mac(payloadB64, secret)}`;
-};
-
-const isPrincipal = (value: unknown): value is SessionPrincipal => {
- if (typeof value !== 'object' || value === null) return false;
- const candidate = value as Record;
- return (
- typeof candidate.v === 'number' &&
- typeof candidate.uid === 'string' &&
- candidate.uid !== '' &&
- typeof candidate.exp === 'number'
- );
-};
-
-/**
- * Verify a session token. NEVER throws — returns a discriminated result. Signature is checked first
- * in constant time (length mismatch fails before {@link timingSafeEqual}); only then is the payload
- * trusted enough to parse, version-check, and expiry-check.
- */
-export const verifySession = (
- token: string,
- secret: string,
- nowMs: number = Date.now(),
-): SessionVerifyResult => {
- const parts = token.split('.');
- if (parts.length !== 2 || parts[0] === '' || parts[1] === '') {
- return { ok: false, reason: 'malformed' };
- }
- const [payloadB64, sigB64] = parts;
- const actual = Buffer.from(sigB64, 'utf8');
- const expected = Buffer.from(mac(payloadB64, secret), 'utf8');
- if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
- return { ok: false, reason: 'bad_signature' };
- }
-
- let parsed: unknown;
- try {
- parsed = JSON.parse(decode(payloadB64));
- } catch {
- return { ok: false, reason: 'malformed' };
- }
- if (!isPrincipal(parsed)) return { ok: false, reason: 'malformed' };
- if (parsed.v !== SESSION_VERSION) return { ok: false, reason: 'bad_version' };
- if (parsed.exp <= nowMs) return { ok: false, reason: 'expired' };
- return { ok: true, principal: parsed };
-};
diff --git a/apps/web/lib/auth/session.ts b/apps/web/lib/auth/session.ts
index f01ce40..c084cff 100644
--- a/apps/web/lib/auth/session.ts
+++ b/apps/web/lib/auth/session.ts
@@ -3,15 +3,13 @@ import type { MembershipRole, MembershipWithWorkspace } from '@flank/core';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { cache } from 'react';
+import { auth } from '../../auth';
import { getStore } from '../store';
import { resolveWorkspace } from './resolver';
-import { getSessionSecret } from './secret';
-import { signSession } from './session-crypto';
-export const SESSION_COOKIE = 'flank_session';
/** Non-authoritative: only ever SELECTS among the user's live memberships (see resolveWorkspace). */
export const WORKSPACE_HINT_COOKIE = 'flank_ws';
-const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+const HINT_TTL_MS = 180 * 24 * 60 * 60 * 1000; // 180 days — a UI preference, not a credential
const SIGN_IN_PATH = '/auth/sign-in';
export interface ActiveWorkspace {
@@ -21,24 +19,30 @@ export interface ActiveWorkspace {
readonly memberships: readonly MembershipWithWorkspace[];
}
-// HttpOnly + SameSite=Lax + signed cookie. Secure tracks "is this a real secret": a real secret
-// implies a deployed (HTTPS) origin, while the insecure dev default must work over plain-http localhost.
-const cookieOptions = (secure: boolean) =>
- ({ httpOnly: true, sameSite: 'lax', secure, path: '/', maxAge: SESSION_TTL_MS / 1000 }) as const;
+// The hint is a plain (unsigned) preference cookie — it carries no authority, so it never needs a
+// signature. Secure tracks deployment: HTTPS in production, plain-http localhost in dev.
+const hintCookieOptions = () =>
+ ({
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production',
+ path: '/',
+ maxAge: HINT_TTL_MS / 1000,
+ }) as const;
/**
- * Resolve the request's active workspace, or redirect to sign-in. `cache` dedupes the cookie read +
- * membership query across every Server Component in a single render. Tenancy is re-derived from live
- * memberships each request, so this is the single trusted gate every authed surface calls.
+ * Resolve the request's active workspace, or redirect to sign-in. Identity comes from the Auth.js /
+ * FerrisKey session; tenancy is re-derived from live memberships. `cache` dedupes the session read +
+ * membership query across every Server Component in a single render. This is the single trusted gate
+ * every authed surface calls.
*/
export const resolveActiveWorkspace = cache(async (): Promise => {
+ const session = await auth();
const jar = await cookies();
const result = await resolveWorkspace({
- token: jar.get(SESSION_COOKIE)?.value ?? null,
+ userId: session?.user?.id ?? null,
workspaceHint: jar.get(WORKSPACE_HINT_COOKIE)?.value ?? null,
- secret: getSessionSecret().value,
store: getStore(),
- nowMs: Date.now(),
});
if (!result.ok) {
redirect(result.reason === 'no_workspace' ? `${SIGN_IN_PATH}?e=no_workspace` : SIGN_IN_PATH);
@@ -46,24 +50,14 @@ export const resolveActiveWorkspace = cache(async (): Promise =
return result;
});
-/** Mint + set the signed session cookie for a user (called from the sign-in Server Action). */
-export const startSession = async (userId: string): Promise => {
- const secret = getSessionSecret();
- const token = signSession({ uid: userId, exp: Date.now() + SESSION_TTL_MS }, secret.value);
+/** Persist the user's preferred workspace; only ever selects among live memberships at resolve time. */
+export const setWorkspaceHint = async (workspaceId: string): Promise => {
const jar = await cookies();
- jar.set(SESSION_COOKIE, token, cookieOptions(!secret.isDevDefault));
+ jar.set(WORKSPACE_HINT_COOKIE, workspaceId, hintCookieOptions());
};
-/** Clear session + workspace-hint cookies (sign-out). */
-export const endSession = async (): Promise => {
+/** Drop the workspace-hint cookie (called on sign-out, alongside Auth.js signOut). */
+export const clearWorkspaceHint = async (): Promise => {
const jar = await cookies();
- jar.delete(SESSION_COOKIE);
jar.delete(WORKSPACE_HINT_COOKIE);
};
-
-/** Persist the user's preferred workspace; only ever selects among live memberships at resolve time. */
-export const setWorkspaceHint = async (workspaceId: string): Promise => {
- const secret = getSessionSecret();
- const jar = await cookies();
- jar.set(WORKSPACE_HINT_COOKIE, workspaceId, cookieOptions(!secret.isDevDefault));
-};
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 8d367b8..6403bff 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -3,8 +3,30 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Workspace packages export TypeScript sources directly.
transpilePackages: ['@flank/core', '@flank/db', '@flank/pipeline'],
+ // Allow loading the dev server over the LAN (e.g. testing on a phone) without
+ // tripping Next's cross-origin dev-resource guard.
+ allowedDevOrigins: ['192.168.0.86'],
// next 16 removed the built-in `eslint` build integration; linting runs at the workspace root
// (`just lint`) instead, so there is nothing to configure here.
+
+ // Baseline security headers on every response. Deliberately conservative: clickjacking, MIME
+ // sniffing, referrer leakage, and (in production) transport downgrade. A full nonce-based CSP is
+ // a follow-up — it needs per-surface testing against the OIDC redirect + Inngest routes.
+ async headers() {
+ const securityHeaders = [
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
+ ];
+ if (process.env.NODE_ENV === 'production') {
+ securityHeaders.push({
+ key: 'Strict-Transport-Security',
+ value: 'max-age=31536000; includeSubDomains; preload',
+ });
+ }
+ return [{ source: '/:path*', headers: securityHeaders }];
+ },
};
export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
index d4e39d1..b39fce7 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -17,6 +17,7 @@
"@flank/pipeline": "workspace:*",
"inngest": "^4.7.0",
"next": "^16.2.9",
+ "next-auth": "5.0.0-beta.31",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
diff --git a/docker-compose.yml b/docker-compose.yml
index c396ce0..ec5e2c2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,5 +18,76 @@ services:
timeout: 5s
retries: 10
+ # --- FerrisKey IAM (OIDC identity provider) — started via `just ferriskey-up` (profile: auth) ---
+ # Kept off the default profile so `just db-up` (app Postgres only) stays lean. Its own Postgres
+ # uses host port 5434 to avoid the app DB (5432) and the integration-test DB (5433).
+ ferriskey-db:
+ image: postgres:18.2
+ container_name: flank-ferriskey-db
+ profiles: ['auth']
+ environment:
+ POSTGRES_USER: ferriskey
+ POSTGRES_PASSWORD: ferriskey
+ POSTGRES_DB: ferriskey
+ PGUSER: ferriskey
+ ports:
+ - '5434:5432'
+ volumes:
+ - ferriskey-pgdata:/var/lib/postgresql
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -d ferriskey']
+ interval: 10s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+
+ # One-shot: apply FerrisKey's own SQLx migrations, then exit. The API waits on its success.
+ ferriskey-migrations:
+ image: ghcr.io/ferriskey/ferriskey-api
+ container_name: flank-ferriskey-migrations
+ profiles: ['auth']
+ entrypoint: ['sqlx']
+ command: ['migrate', 'run', '--source', '/usr/local/src/ferriskey/migrations']
+ environment:
+ DATABASE_URL: postgresql://ferriskey:ferriskey@ferriskey-db/ferriskey
+ restart: 'no'
+ depends_on:
+ ferriskey-db:
+ condition: service_healthy
+
+ ferriskey:
+ image: ghcr.io/ferriskey/ferriskey-api
+ container_name: flank-ferriskey
+ profiles: ['auth']
+ environment:
+ DATABASE_HOST: ferriskey-db
+ DATABASE_NAME: ferriskey
+ DATABASE_USER: ferriskey
+ DATABASE_PASSWORD: ferriskey
+ # Console origin + the Flank web app origin (OIDC browser redirects originate here).
+ ALLOWED_ORIGINS: http://localhost:5555,http://localhost:3000
+ WEBAPP_URL: http://localhost:5555
+ ADMIN_USERNAME: ${FERRISKEY_ADMIN_USERNAME:-admin}
+ ADMIN_PASSWORD: ${FERRISKEY_ADMIN_PASSWORD:-admin}
+ ADMIN_EMAIL: ${FERRISKEY_ADMIN_EMAIL:-admin@flank.local}
+ ports:
+ - '3333:3333'
+ depends_on:
+ ferriskey-migrations:
+ condition: service_completed_successfully
+
+ # Admin console (http://localhost:5555, default admin/admin) — manage realms/clients/users.
+ ferriskey-console:
+ image: ghcr.io/ferriskey/ferriskey-webapp
+ container_name: flank-ferriskey-console
+ profiles: ['auth']
+ environment:
+ API_URL: http://localhost:3333
+ ports:
+ - '5555:80'
+ depends_on:
+ - ferriskey
+
volumes:
flank-pgdata:
+ ferriskey-pgdata:
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index f69030d..f2a8509 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -103,10 +103,10 @@ Split into two PRs (keystone stays small and invariant-critical):
### M1-hardening — Auth & tenancy origin
-| # | Task | Effort | Depends on |
-| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------- |
-| 1 | `users` + `memberships` tables + a Next 15 App Router auth/session layer resolving each request to an authorized `workspaceId` | L | store (#4 above) |
-| 2 | Zod-parsed runtime env module validated at process start (`ANTHROPIC_API_KEY`, signing/API secrets) for app + worker entrypoints | S | — (parallel) |
+| # | Task | Effort | Depends on |
+| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------- |
+| 1 | `users` + `memberships` tables + a Next App Router auth/session layer resolving each request to an authorized `workspaceId`. **Done — identity delegated to FerrisKey (OIDC) via Auth.js v5; tenancy still re-derived from live `memberships` locally (Invariant 8). `app_user.external_subject` links the IdP `sub`.** | L | store (#4 above) |
+| 2 | Zod-parsed runtime env module validated at process start (`ANTHROPIC_API_KEY`, signing/API secrets) for app + worker entrypoints | S | — (parallel) |
### M1-hardening — Real fetch + schedule runtime
diff --git a/justfile b/justfile
index 5d2290f..e41589d 100644
--- a/justfile
+++ b/justfile
@@ -1,6 +1,11 @@
# Flank — living competitor radar. Run `just` to list recipes.
# All recipes guard against the not-yet-bootstrapped state (no package.json, see DESIGN.md M0).
+# Load the local .env (gitignored) into every recipe's environment. Nothing else auto-loads it:
+# next dev runs in apps/web/ and the tsx scripts don't import dotenv, so without this DATABASE_URL
+# (and the other vars in .env.example) never reach the running surface. Falls back silently if absent.
+set dotenv-load := true
+
# List available recipes
default:
@just --list
@@ -43,6 +48,19 @@ db-down:
fi
docker compose down
+# Start the FerrisKey IAM stack (API :3333, console :5555, its own Postgres :5434) — the OIDC IdP
+ferriskey-up:
+ docker compose --profile auth up -d ferriskey ferriskey-console
+
+# Stop the FerrisKey IAM stack (leaves the app Postgres running)
+ferriskey-down:
+ docker compose --profile auth down
+
+# Provision FerrisKey for Flank: realm + confidential client + redirect URIs + demo user.
+# Prints the FERRISKEY_* env values to paste into .env. Re-runnable.
+ferriskey-bootstrap:
+ pnpm ferriskey:bootstrap
+
# Apply Drizzle migrations to DATABASE_URL
migrate: _bootstrapped
pnpm migrate
diff --git a/package.json b/package.json
index a3396d8..53f929c 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"build": "pnpm --filter @flank/web build",
"migrate": "pnpm --filter @flank/db migrate",
"seed": "tsx packages/db/scripts/seed.ts",
+ "ferriskey:bootstrap": "tsx scripts/ferriskey-bootstrap.ts",
"eval:triage": "tsx scripts/eval-triage.ts"
},
"dependencies": {
diff --git a/packages/core/src/entities.ts b/packages/core/src/entities.ts
index 8742821..9f682f0 100644
--- a/packages/core/src/entities.ts
+++ b/packages/core/src/entities.ts
@@ -179,9 +179,29 @@ export interface AppUser {
readonly id: string;
readonly email: string;
readonly name: string | null;
+ /**
+ * Stable subject (`sub`) of the external identity provider (FerrisKey OIDC), or null for
+ * pre-OIDC/seed rows not yet linked. Linking by this immutable id — not email — keeps a user's
+ * local identity stable across an IdP email change.
+ */
+ readonly externalSubject: string | null;
readonly createdAt: Date;
}
+/**
+ * An identity arriving from the OIDC provider (FerrisKey), validated at the auth callback boundary.
+ * Email is normalized; subject is the IdP's immutable `sub` claim. `emailVerified` reflects the
+ * `email_verified` claim — it gates the email-backfill linking path (see linkOrCreateUserBySubject):
+ * an unverified email must never adopt a pre-existing local account, or anyone who can register that
+ * address at the IdP could hijack the workspace it belongs to.
+ */
+export interface ExternalIdentity {
+ readonly subject: string;
+ readonly email: string;
+ readonly emailVerified: boolean;
+ readonly name: string | null;
+}
+
/** Grants a user access to a workspace with a role — the only thing that confers tenancy. */
export interface Membership {
readonly id: string;
diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts
index e32b5fa..cfab730 100644
--- a/packages/core/src/store.ts
+++ b/packages/core/src/store.ts
@@ -12,6 +12,7 @@ import type {
DeltaState,
DossierSection,
DossierSectionKind,
+ ExternalIdentity,
Membership,
Snapshot,
Source,
@@ -52,6 +53,17 @@ export class CrossTenantError extends Error {
}
}
+/**
+ * An unverified OIDC email collided with an existing local account during identity linking. Thrown
+ * (never linked) to prevent account takeover: only a verified email may adopt a pre-provisioned user.
+ */
+export class IdentityConflictError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'IdentityConflictError';
+ }
+}
+
/**
* Structural delta state machine: which states may follow which. The pricing-confirmation
* refinement (Invariant 3) is `triageClass`-dependent and therefore cannot live in this static
@@ -280,6 +292,16 @@ export interface FlankStore {
// --- Identity & membership (M2 auth) ---
seedUser(user: AppUser): Promise;
seedMembership(membership: Membership): Promise;
+ /**
+ * Resolve a verified OIDC identity (FerrisKey) to the one local {@link AppUser}, creating or
+ * linking exactly one row — the JIT-provisioning seam called from the auth callback. Match order:
+ * (1) by immutable `externalSubject`; (2) by normalized email — but ONLY when `emailVerified`,
+ * backfilling the subject so a pre-OIDC/seed row adopts its IdP identity on first login (an
+ * unverified email colliding with an existing account is rejected, never linked — anti-hijack);
+ * (3) else create a fresh user. Never grants a workspace — a brand-new user has zero memberships
+ * and fails closed at `resolveWorkspace`.
+ */
+ linkOrCreateUserBySubject(identity: ExternalIdentity, createdAt?: Date): Promise;
/** Look up a user by (normalized) email; null if none. Identity is global, not workspace-scoped. */
findUserByEmail(email: string): Promise;
getUserById(userId: string): Promise;
diff --git a/packages/db/drizzle/0006_link_external_subject.sql b/packages/db/drizzle/0006_link_external_subject.sql
new file mode 100644
index 0000000..ff81831
--- /dev/null
+++ b/packages/db/drizzle/0006_link_external_subject.sql
@@ -0,0 +1,4 @@
+-- Link a local app_user to its external IdP identity (FerrisKey OIDC `sub`). Nullable so existing
+-- rows backfill on first login; unique so one IdP identity maps to exactly one local user.
+ALTER TABLE "app_user" ADD COLUMN "external_subject" text;--> statement-breakpoint
+ALTER TABLE "app_user" ADD CONSTRAINT "app_user_external_subject_unique" UNIQUE("external_subject");
diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json
new file mode 100644
index 0000000..94874ca
--- /dev/null
+++ b/packages/db/drizzle/meta/0006_snapshot.json
@@ -0,0 +1,1429 @@
+{
+ "id": "55c1e24b-02de-4eaa-94e4-dedd4bebf6dd",
+ "prevId": "4fa87f27-8259-4879-aa1d-ea7655a66725",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.alert_channel_config": {
+ "name": "alert_channel_config",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "alert_channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "alert_channel_config_workspace_idx": {
+ "name": "alert_channel_config_workspace_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "alert_channel_config_workspace_id_workspace_id_fk": {
+ "name": "alert_channel_config_workspace_id_workspace_id_fk",
+ "tableFrom": "alert_channel_config",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "alert_channel_config_dest_uq": {
+ "name": "alert_channel_config_dest_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "workspace_id",
+ "channel",
+ "destination"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert": {
+ "name": "alert",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "delta_id": {
+ "name": "delta_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "alert_channel",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_config_id": {
+ "name": "channel_config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "alert_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enqueued_at": {
+ "name": "enqueued_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "alert_workspace_status_idx": {
+ "name": "alert_workspace_status_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "alert_workspace_id_workspace_id_fk": {
+ "name": "alert_workspace_id_workspace_id_fk",
+ "tableFrom": "alert",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "alert_delta_id_delta_id_fk": {
+ "name": "alert_delta_id_delta_id_fk",
+ "tableFrom": "alert",
+ "tableTo": "delta",
+ "columnsFrom": [
+ "delta_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "alert_channel_config_id_alert_channel_config_id_fk": {
+ "name": "alert_channel_config_id_alert_channel_config_id_fk",
+ "tableFrom": "alert",
+ "tableTo": "alert_channel_config",
+ "columnsFrom": [
+ "channel_config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "alert_delta_config_uq": {
+ "name": "alert_delta_config_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "delta_id",
+ "channel_config_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.app_user": {
+ "name": "app_user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_subject": {
+ "name": "external_subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "app_user_email_unique": {
+ "name": "app_user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ },
+ "app_user_external_subject_unique": {
+ "name": "app_user_external_subject_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_subject"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.battlecard_section": {
+ "name": "battlecard_section",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "competitor_id": {
+ "name": "competitor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "battlecard_section_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_md": {
+ "name": "content_md",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "claim_ids": {
+ "name": "claim_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "supersedes_id": {
+ "name": "supersedes_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "battlecard_section_competitor_id_competitor_id_fk": {
+ "name": "battlecard_section_competitor_id_competitor_id_fk",
+ "tableFrom": "battlecard_section",
+ "tableTo": "competitor",
+ "columnsFrom": [
+ "competitor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "battlecard_section_competitor_kind_version_uq": {
+ "name": "battlecard_section_competitor_kind_version_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "competitor_id",
+ "kind",
+ "version"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.claim": {
+ "name": "claim",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "delta_id": {
+ "name": "delta_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "quote_text": {
+ "name": "quote_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "char_start": {
+ "name": "char_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "char_end": {
+ "name": "char_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "claim_delta_id_delta_id_fk": {
+ "name": "claim_delta_id_delta_id_fk",
+ "tableFrom": "claim",
+ "tableTo": "delta",
+ "columnsFrom": [
+ "delta_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "claim_workspace_id_workspace_id_fk": {
+ "name": "claim_workspace_id_workspace_id_fk",
+ "tableFrom": "claim",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "claim_snapshot_id_snapshot_id_fk": {
+ "name": "claim_snapshot_id_snapshot_id_fk",
+ "tableFrom": "claim",
+ "tableTo": "snapshot",
+ "columnsFrom": [
+ "snapshot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.competitor": {
+ "name": "competitor",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "primary_domain": {
+ "name": "primary_domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aliases": {
+ "name": "aliases",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "competitor_workspace_id_workspace_id_fk": {
+ "name": "competitor_workspace_id_workspace_id_fk",
+ "tableFrom": "competitor",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.coverage_run": {
+ "name": "coverage_run",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "period": {
+ "name": "period",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sources_checked": {
+ "name": "sources_checked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fetch_failures": {
+ "name": "fetch_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deltas_found": {
+ "name": "deltas_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "material_deltas": {
+ "name": "material_deltas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "llm_calls": {
+ "name": "llm_calls",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "llm_cost_micros": {
+ "name": "llm_cost_micros",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "coverage_run_workspace_id_workspace_id_fk": {
+ "name": "coverage_run_workspace_id_workspace_id_fk",
+ "tableFrom": "coverage_run",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.delta": {
+ "name": "delta",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_snapshot_id": {
+ "name": "from_snapshot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_snapshot_id": {
+ "name": "to_snapshot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "changed_spans": {
+ "name": "changed_spans",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "triage_class": {
+ "name": "triage_class",
+ "type": "triage_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "materiality": {
+ "name": "materiality",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rationale": {
+ "name": "rationale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "delta_state",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "confirmed_by_snapshot_id": {
+ "name": "confirmed_by_snapshot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "delta_source_id_source_id_fk": {
+ "name": "delta_source_id_source_id_fk",
+ "tableFrom": "delta",
+ "tableTo": "source",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "delta_workspace_id_workspace_id_fk": {
+ "name": "delta_workspace_id_workspace_id_fk",
+ "tableFrom": "delta",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "delta_from_snapshot_id_snapshot_id_fk": {
+ "name": "delta_from_snapshot_id_snapshot_id_fk",
+ "tableFrom": "delta",
+ "tableTo": "snapshot",
+ "columnsFrom": [
+ "from_snapshot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "delta_to_snapshot_id_snapshot_id_fk": {
+ "name": "delta_to_snapshot_id_snapshot_id_fk",
+ "tableFrom": "delta",
+ "tableTo": "snapshot",
+ "columnsFrom": [
+ "to_snapshot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "delta_confirmed_by_snapshot_id_snapshot_id_fk": {
+ "name": "delta_confirmed_by_snapshot_id_snapshot_id_fk",
+ "tableFrom": "delta",
+ "tableTo": "snapshot",
+ "columnsFrom": [
+ "confirmed_by_snapshot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dossier_section": {
+ "name": "dossier_section",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "competitor_id": {
+ "name": "competitor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "dossier_section_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_md": {
+ "name": "content_md",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "claim_ids": {
+ "name": "claim_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "batch_id": {
+ "name": "batch_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "supersedes_id": {
+ "name": "supersedes_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dossier_section_competitor_id_competitor_id_fk": {
+ "name": "dossier_section_competitor_id_competitor_id_fk",
+ "tableFrom": "dossier_section",
+ "tableTo": "competitor",
+ "columnsFrom": [
+ "competitor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dossier_section_competitor_kind_version_uq": {
+ "name": "dossier_section_competitor_kind_version_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "competitor_id",
+ "kind",
+ "version"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.membership": {
+ "name": "membership",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "membership_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "membership_user_idx": {
+ "name": "membership_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "membership_user_id_app_user_id_fk": {
+ "name": "membership_user_id_app_user_id_fk",
+ "tableFrom": "membership",
+ "tableTo": "app_user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "membership_workspace_id_workspace_id_fk": {
+ "name": "membership_workspace_id_workspace_id_fk",
+ "tableFrom": "membership",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "membership_user_workspace_uq": {
+ "name": "membership_user_workspace_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "workspace_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.snapshot": {
+ "name": "snapshot",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "s3_key": {
+ "name": "s3_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "normalized_text": {
+ "name": "normalized_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vantage": {
+ "name": "vantage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "http_status": {
+ "name": "http_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "snapshot_source_fetched_idx": {
+ "name": "snapshot_source_fetched_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fetched_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "snapshot_source_id_source_id_fk": {
+ "name": "snapshot_source_id_source_id_fk",
+ "tableFrom": "snapshot",
+ "tableTo": "source",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "snapshot_workspace_id_workspace_id_fk": {
+ "name": "snapshot_workspace_id_workspace_id_fk",
+ "tableFrom": "snapshot",
+ "tableTo": "workspace",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.source": {
+ "name": "source",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "competitor_id": {
+ "name": "competitor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "source_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url_or_endpoint": {
+ "name": "url_or_endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "adapter": {
+ "name": "adapter",
+ "type": "source_adapter",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cadence": {
+ "name": "cadence",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "legal_status": {
+ "name": "legal_status",
+ "type": "legal_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "last_fetched_at": {
+ "name": "last_fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consecutive_failures": {
+ "name": "consecutive_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "source_competitor_id_competitor_id_fk": {
+ "name": "source_competitor_id_competitor_id_fk",
+ "tableFrom": "source",
+ "tableTo": "competitor",
+ "columnsFrom": [
+ "competitor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace": {
+ "name": "workspace",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plan_tier": {
+ "name": "plan_tier",
+ "type": "plan_tier",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'starter'"
+ },
+ "competitor_limit": {
+ "name": "competitor_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.alert_channel": {
+ "name": "alert_channel",
+ "schema": "public",
+ "values": [
+ "slack",
+ "email",
+ "crm"
+ ]
+ },
+ "public.alert_status": {
+ "name": "alert_status",
+ "schema": "public",
+ "values": [
+ "queued",
+ "delivered",
+ "failed"
+ ]
+ },
+ "public.battlecard_section_kind": {
+ "name": "battlecard_section_kind",
+ "schema": "public",
+ "values": [
+ "why_we_win",
+ "landmines",
+ "pricing_counter",
+ "objections"
+ ]
+ },
+ "public.delta_state": {
+ "name": "delta_state",
+ "schema": "public",
+ "values": [
+ "pending",
+ "confirmed",
+ "dismissed",
+ "published"
+ ]
+ },
+ "public.legal_status": {
+ "name": "legal_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "licensed",
+ "blocked"
+ ]
+ },
+ "public.membership_role": {
+ "name": "membership_role",
+ "schema": "public",
+ "values": [
+ "owner",
+ "member"
+ ]
+ },
+ "public.plan_tier": {
+ "name": "plan_tier",
+ "schema": "public",
+ "values": [
+ "starter",
+ "growth",
+ "team"
+ ]
+ },
+ "public.dossier_section_kind": {
+ "name": "dossier_section_kind",
+ "schema": "public",
+ "values": [
+ "overview",
+ "pricing",
+ "product",
+ "gtm",
+ "team"
+ ]
+ },
+ "public.source_adapter": {
+ "name": "source_adapter",
+ "schema": "public",
+ "values": [
+ "rss",
+ "json",
+ "html",
+ "firecrawl",
+ "zyte"
+ ]
+ },
+ "public.source_type": {
+ "name": "source_type",
+ "schema": "public",
+ "values": [
+ "pricing",
+ "changelog",
+ "docs",
+ "jobs",
+ "reviews",
+ "status",
+ "blog",
+ "appstore"
+ ]
+ },
+ "public.triage_class": {
+ "name": "triage_class",
+ "schema": "public",
+ "values": [
+ "pricing_change",
+ "feature_launch",
+ "repositioning",
+ "leadership_hire",
+ "hiring_signal",
+ "noise"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index ec5b333..cfcf52a 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -43,6 +43,13 @@
"when": 1782000000000,
"tag": "0005_alert_delivery",
"breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "7",
+ "when": 1782037213247,
+ "tag": "0006_link_external_subject",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts
index 0c05c13..0c4068a 100644
--- a/packages/db/scripts/seed.ts
+++ b/packages/db/scripts/seed.ts
@@ -76,6 +76,8 @@ const main = async (): Promise => {
id: 'u-demo',
email: SEED_EMAIL,
name: 'Dana Founder',
+ // Null until first FerrisKey login: linkOrCreateUserBySubject backfills the IdP `sub` by email.
+ externalSubject: null,
createdAt: at('2026-05-01T00:00:00Z'),
});
await store.seedMembership({
diff --git a/packages/db/src/drizzle-mappers.ts b/packages/db/src/drizzle-mappers.ts
index 8921cdf..d4c8155 100644
--- a/packages/db/src/drizzle-mappers.ts
+++ b/packages/db/src/drizzle-mappers.ts
@@ -145,7 +145,13 @@ export const toBattlecardSection = (
});
export const toAppUser = (row: typeof appUsers.$inferSelect): AppUser =>
- Object.freeze({ id: row.id, email: row.email, name: row.name, createdAt: row.createdAt });
+ Object.freeze({
+ id: row.id,
+ email: row.email,
+ name: row.name,
+ externalSubject: row.externalSubject,
+ createdAt: row.createdAt,
+ });
export const toMembership = (row: typeof memberships.$inferSelect): Membership =>
Object.freeze({
diff --git a/packages/db/src/drizzle-store.ts b/packages/db/src/drizzle-store.ts
index f4c744c..a3afcf5 100644
--- a/packages/db/src/drizzle-store.ts
+++ b/packages/db/src/drizzle-store.ts
@@ -2,6 +2,7 @@ import {
AppendOnlyViolationError,
assertDeltaTransition,
CrossTenantError,
+ IdentityConflictError,
UnknownEntityError,
type Alert,
type AlertChannelConfig,
@@ -18,6 +19,7 @@ import {
type DossierSection,
type DossierSectionKind,
type EnabledChannel,
+ type ExternalIdentity,
type FlankStore,
type Membership,
type MembershipWithWorkspace,
@@ -28,6 +30,7 @@ import {
type SynthesisCompetitor,
type Workspace,
} from '@flank/core';
+import { randomUUID } from 'node:crypto';
import { and, desc, eq, inArray, like, ne, sql } from 'drizzle-orm';
import type { FlankDatabase } from './client';
import {
@@ -620,6 +623,7 @@ export class DrizzleFlankStore implements FlankStore {
id: user.id,
email: user.email.toLowerCase(),
name: user.name,
+ externalSubject: user.externalSubject,
createdAt: user.createdAt,
})
.returning(),
@@ -646,6 +650,51 @@ export class DrizzleFlankStore implements FlankStore {
);
}
+ async linkOrCreateUserBySubject(
+ identity: ExternalIdentity,
+ createdAt: Date = new Date(),
+ ): Promise {
+ const email = identity.email.toLowerCase();
+ // (1) Stable subject match — the authoritative link once established.
+ const bySubject = await this.db
+ .select()
+ .from(appUsers)
+ .where(eq(appUsers.externalSubject, identity.subject))
+ .limit(1);
+ if (bySubject[0]) return toAppUser(bySubject[0]);
+
+ // (2) Email match — a pre-OIDC/seed row adopts its IdP subject on first login (backfill), but
+ // ONLY for a verified email. An unverified email colliding with an existing account is rejected,
+ // never linked: otherwise anyone who registers that address at the IdP hijacks the workspace.
+ const byEmail = await this.db.select().from(appUsers).where(eq(appUsers.email, email)).limit(1);
+ if (byEmail[0]) {
+ if (!identity.emailVerified) {
+ throw new IdentityConflictError(
+ `unverified email ${email} collides with an existing account — refusing to link`,
+ );
+ }
+ const updated = await this.db
+ .update(appUsers)
+ .set({ externalSubject: identity.subject, name: byEmail[0].name ?? identity.name })
+ .where(eq(appUsers.id, byEmail[0].id))
+ .returning();
+ return toAppUser(updated[0]);
+ }
+
+ // (3) First time we have ever seen this identity — JIT-create. Confers NO membership.
+ const inserted = await this.db
+ .insert(appUsers)
+ .values({
+ id: `user_${randomUUID()}`,
+ email,
+ name: identity.name,
+ externalSubject: identity.subject,
+ createdAt,
+ })
+ .returning();
+ return toAppUser(inserted[0]);
+ }
+
async findUserByEmail(email: string): Promise {
const rows = await this.db
.select()
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 729485b..8de66b8 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -269,6 +269,9 @@ export const appUsers = pgTable('app_user', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
+ // Stable `sub` of the external IdP (FerrisKey OIDC). Nullable: pre-OIDC/seed rows link on first
+ // login. Unique so one IdP identity maps to exactly one local user (Invariant 8 starts here).
+ externalSubject: text('external_subject').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
diff --git a/packages/pipeline/src/memory-store.ts b/packages/pipeline/src/memory-store.ts
index f3b3282..8f451eb 100644
--- a/packages/pipeline/src/memory-store.ts
+++ b/packages/pipeline/src/memory-store.ts
@@ -1,8 +1,10 @@
+import { randomUUID } from 'node:crypto';
import {
AppendOnlyViolationError,
assertAlertTransition,
assertDeltaTransition,
CrossTenantError,
+ IdentityConflictError,
UnknownEntityError,
type Alert,
type AlertChannelConfig,
@@ -19,6 +21,7 @@ import {
type DossierSection,
type DossierSectionKind,
type EnabledChannel,
+ type ExternalIdentity,
type FlankStore,
type Membership,
type MembershipWithWorkspace,
@@ -482,6 +485,45 @@ export class MemoryFlankStore implements FlankStore {
return this.insertUnique(this.state.memberships, membership, 'membership');
}
+ async linkOrCreateUserBySubject(
+ identity: ExternalIdentity,
+ createdAt: Date = new Date(),
+ ): Promise {
+ const email = identity.email.toLowerCase();
+ // (1) Stable subject match — the authoritative link once established.
+ for (const user of this.state.users.values()) {
+ if (user.externalSubject !== null && user.externalSubject === identity.subject) return user;
+ }
+ // (2) Email match — a pre-OIDC/seed row adopts its IdP subject on first login (backfill), but
+ // ONLY for a verified email. An unverified email colliding with an existing account is rejected,
+ // never linked: otherwise anyone who registers that address at the IdP hijacks the workspace.
+ for (const user of this.state.users.values()) {
+ if (user.email.toLowerCase() === email) {
+ if (!identity.emailVerified) {
+ throw new IdentityConflictError(
+ `unverified email ${email} collides with an existing account — refusing to link`,
+ );
+ }
+ const linked = freezeDeep({
+ ...user,
+ externalSubject: identity.subject,
+ name: user.name ?? identity.name,
+ });
+ this.state.users.set(user.id, linked);
+ return linked;
+ }
+ }
+ // (3) First time we have ever seen this identity — JIT-create. Confers NO membership.
+ const created: AppUser = {
+ id: `user_${randomUUID()}`,
+ email,
+ name: identity.name,
+ externalSubject: identity.subject,
+ createdAt,
+ };
+ return this.insertUnique(this.state.users, created, 'app_user');
+ }
+
async findUserByEmail(email: string): Promise {
const normalized = email.toLowerCase();
for (const user of this.state.users.values()) {
diff --git a/packages/pipeline/src/store-contract.ts b/packages/pipeline/src/store-contract.ts
index 91be330..ae0f6b1 100644
--- a/packages/pipeline/src/store-contract.ts
+++ b/packages/pipeline/src/store-contract.ts
@@ -1,6 +1,7 @@
import {
AppendOnlyViolationError,
CrossTenantError,
+ IdentityConflictError,
IllegalTransitionError,
UnknownEntityError,
type Alert,
@@ -571,6 +572,7 @@ export const runFlankStoreContract = (label: string, makeStore: () => FlankStore
id,
email,
name: null,
+ externalSubject: null,
createdAt: AT,
});
const grantOn = (
@@ -588,6 +590,56 @@ export const runFlankStoreContract = (label: string, makeStore: () => FlankStore
expect(await store.getUserById('missing')).toBeNull();
});
+ describe('linkOrCreateUserBySubject (OIDC JIT provisioning)', () => {
+ const identity = (
+ over: Partial[0]> = {},
+ ) => ({
+ subject: 'fk-sub-123',
+ email: 'ada@example.com',
+ emailVerified: true,
+ name: 'Ada Lovelace',
+ ...over,
+ });
+
+ it('backfills the subject onto a pre-existing user matched by email', async () => {
+ await store.seedUser(userOn('u-1', 'Ada@Example.com')); // seed row, externalSubject null
+ const linked = await store.linkOrCreateUserBySubject(identity());
+ expect(linked.id).toBe('u-1'); // same row, not a new user
+ expect(linked.externalSubject).toBe('fk-sub-123');
+ expect(await store.findUserByEmail('ada@example.com')).toMatchObject({ id: 'u-1' });
+ });
+
+ it('returns the same user on a repeat login, matching by stable subject', async () => {
+ await store.seedUser(userOn('u-1', 'ada@example.com'));
+ await store.linkOrCreateUserBySubject(identity());
+ // IdP email changed but the subject is stable → still resolves to u-1, no new row.
+ const again = await store.linkOrCreateUserBySubject(
+ identity({ email: 'ada@new.example' }),
+ );
+ expect(again.id).toBe('u-1');
+ expect(again.email).toBe('ada@example.com'); // email is not rewritten by a relogin
+ });
+
+ it('creates a fresh user the first time an identity is seen', async () => {
+ const created = await store.linkOrCreateUserBySubject(identity({ subject: 'fk-new' }));
+ expect(created.externalSubject).toBe('fk-new');
+ expect(created.email).toBe('ada@example.com');
+ // Fail-closed: a brand-new user has zero memberships until an owner grants one.
+ expect(await store.listMembershipsForUser(created.id)).toHaveLength(0);
+ });
+
+ it('refuses to link an UNVERIFIED email onto a pre-existing account (anti-hijack)', async () => {
+ await store.seedUser(userOn('u-1', 'ada@example.com')); // pre-provisioned owner, no subject
+ await expect(
+ store.linkOrCreateUserBySubject(
+ identity({ subject: 'fk-attacker', emailVerified: false }),
+ ),
+ ).rejects.toBeInstanceOf(IdentityConflictError);
+ // The pre-existing account is untouched — no subject was written.
+ expect((await store.findUserByEmail('ada@example.com'))?.externalSubject).toBeNull();
+ });
+ });
+
it('grants memberships joined to the workspace, rejecting a duplicate grant', async () => {
await store.seedUser(userOn('u-1', 'ada@example.com'));
await store.seedMembership(grantOn('m-a', 'u-1', WS_A.id, 'owner'));
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1b9efc6..21bdfba 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,6 +72,9 @@ importers:
next:
specifier: ^16.2.9
version: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ next-auth:
+ specifier: 5.0.0-beta.31
+ version: 5.0.0-beta.31(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
react:
specifier: ^19.2.7
version: 19.2.7
@@ -181,6 +184,20 @@ packages:
zod:
optional: true
+ '@auth/core@0.41.2':
+ resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
+ peerDependencies:
+ '@simplewebauthn/browser': ^9.0.1
+ '@simplewebauthn/server': ^9.0.2
+ nodemailer: ^7.0.7
+ peerDependenciesMeta:
+ '@simplewebauthn/browser':
+ optional: true
+ '@simplewebauthn/server':
+ optional: true
+ nodemailer:
+ optional: true
+
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -1135,6 +1152,9 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.1.0
+ '@panva/hkdf@1.2.1':
+ resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
+
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1976,6 +1996,9 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
+ jose@6.2.3:
+ resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
+
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -2057,6 +2080,22 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ next-auth@5.0.0-beta.31:
+ resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
+ peerDependencies:
+ '@simplewebauthn/browser': ^9.0.1
+ '@simplewebauthn/server': ^9.0.2
+ next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
+ nodemailer: ^7.0.7
+ react: ^18.2.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@simplewebauthn/browser':
+ optional: true
+ '@simplewebauthn/server':
+ optional: true
+ nodemailer:
+ optional: true
+
next@16.2.9:
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
engines: {node: '>=20.9.0'}
@@ -2096,6 +2135,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ oauth4webapi@3.8.6:
+ resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
+
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
@@ -2175,6 +2217,14 @@ packages:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
+ preact-render-to-string@6.5.11:
+ resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
+ peerDependencies:
+ preact: '>=10'
+
+ preact@10.24.3:
+ resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -2549,6 +2599,14 @@ snapshots:
optionalDependencies:
zod: 4.4.3
+ '@auth/core@0.41.2':
+ dependencies:
+ '@panva/hkdf': 1.2.1
+ jose: 6.2.3
+ oauth4webapi: 3.8.6
+ preact: 10.24.3
+ preact-render-to-string: 6.5.11(preact@10.24.3)
+
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
@@ -3579,6 +3637,8 @@ snapshots:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1)
+ '@panva/hkdf@1.2.1': {}
+
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -4346,6 +4406,8 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
+ jose@6.2.3: {}
+
js-tokens@10.0.0: {}
json-bigint@1.0.0:
@@ -4416,6 +4478,12 @@ snapshots:
natural-compare@1.4.0: {}
+ next-auth@5.0.0-beta.31(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7):
+ dependencies:
+ '@auth/core': 0.41.2
+ next: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react: 19.2.7
+
next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@next/env': 16.2.9
@@ -4453,6 +4521,8 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
+ oauth4webapi@3.8.6: {}
+
obug@2.1.3: {}
optionator@0.9.4:
@@ -4521,6 +4591,12 @@ snapshots:
postgres@3.4.9: {}
+ preact-render-to-string@6.5.11(preact@10.24.3):
+ dependencies:
+ preact: 10.24.3
+
+ preact@10.24.3: {}
+
prelude-ls@1.2.1: {}
prettier@3.8.4: {}
diff --git a/scripts/ferriskey-bootstrap.ts b/scripts/ferriskey-bootstrap.ts
new file mode 100644
index 0000000..d68ce59
--- /dev/null
+++ b/scripts/ferriskey-bootstrap.ts
@@ -0,0 +1,212 @@
+/**
+ * Provision the local FerrisKey instance for Flank's OIDC login. Idempotent-ish: every step
+ * tolerates an already-exists (409/422) response so re-runs are safe. Targets the FerrisKey v0.6.x
+ * admin REST API (paths verified against ferriskey/ferriskey @ v0.6.1).
+ *
+ * Steps, all against FERRISKEY_API_URL (default http://localhost:3333):
+ * 1. Mint an admin token — password grant on the `master` realm via `security-admin-console`.
+ * 2. Create realm `flank`.
+ * 3. Create a confidential client `flank-web` and read its generated secret.
+ * 4. Register the redirect + post-logout URIs for the Next.js app.
+ * 5. Create the demo user (matching the seed email) and set its password.
+ *
+ * It then prints the exact env values to paste into `.env`. The FerrisKey console
+ * (http://localhost:5555, admin/admin) remains the source of truth if anything drifts.
+ *
+ * just ferriskey-bootstrap
+ */
+
+const API = (process.env.FERRISKEY_API_URL ?? 'http://localhost:3333').replace(/\/$/, '');
+const ADMIN_USER = process.env.FERRISKEY_ADMIN_USERNAME ?? 'admin';
+const ADMIN_PASS = process.env.FERRISKEY_ADMIN_PASSWORD ?? 'admin';
+const REALM = process.env.FERRISKEY_REALM ?? 'flank';
+const CLIENT_ID = process.env.FERRISKEY_CLIENT_ID ?? 'flank-web';
+const APP_ORIGIN = (process.env.FLANK_WEB_ORIGIN ?? 'http://localhost:3000').replace(/\/$/, '');
+const REDIRECT_URI = `${APP_ORIGIN}/api/auth/callback/ferriskey`;
+const DEMO_EMAIL = process.env.SEED_EMAIL ?? 'founder@northwind.test';
+const DEMO_PASSWORD = process.env.FERRISKEY_DEMO_PASSWORD ?? 'flank-demo-password';
+
+/** A response that means "already created" — treated as success so the script is re-runnable. */
+const ALREADY_EXISTS = new Set([409, 422]);
+
+const fail = (message: string): never => {
+ console.error(`\n✗ ${message}`);
+ process.exit(1);
+};
+
+interface ApiCall {
+ readonly method: string;
+ readonly path: string;
+ readonly token?: string;
+ readonly json?: unknown;
+ readonly form?: Record;
+ /** Status codes (besides 2xx) to accept as success (e.g. already-exists). */
+ readonly tolerate?: ReadonlySet;
+}
+
+const call = async ({ method, path, token, json, form, tolerate }: ApiCall): Promise => {
+ const headers: Record = {};
+ let body: string | undefined;
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
+ if (form !== undefined) {
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
+ body = new URLSearchParams(form).toString();
+ } else if (json !== undefined) {
+ headers['Content-Type'] = 'application/json';
+ body = JSON.stringify(json);
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(`${API}${path}`, { method, headers, body });
+ } catch (error: unknown) {
+ return fail(
+ `cannot reach FerrisKey at ${API} — is it running? (\`just ferriskey-up\`). ${String(error)}`,
+ );
+ }
+
+ const text = await res.text();
+ const parsed = text === '' ? undefined : safeJson(text);
+ if (!res.ok && !(tolerate?.has(res.status) ?? false)) {
+ return fail(`${method} ${path} → ${res.status} ${res.statusText}\n${text}`);
+ }
+ return parsed;
+};
+
+const safeJson = (text: string): unknown => {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+};
+
+const asRecord = (value: unknown): Record =>
+ typeof value === 'object' && value !== null ? (value as Record) : {};
+
+const main = async (): Promise => {
+ console.log(`→ FerrisKey bootstrap against ${API} (realm: ${REALM}, client: ${CLIENT_ID})`);
+
+ // 1. Admin token (master realm, default console client, direct password grant).
+ const tokenRes = asRecord(
+ await call({
+ method: 'POST',
+ path: '/realms/master/protocol/openid-connect/token',
+ form: {
+ grant_type: 'password',
+ client_id: 'security-admin-console',
+ username: ADMIN_USER,
+ password: ADMIN_PASS,
+ },
+ }),
+ );
+ const token = tokenRes.access_token;
+ if (typeof token !== 'string') return fail('no access_token in admin token response');
+ console.log('✓ admin token acquired');
+
+ // 2. Realm.
+ await call({
+ method: 'POST',
+ path: '/realms',
+ token,
+ json: { name: REALM },
+ tolerate: ALREADY_EXISTS,
+ });
+ console.log(`✓ realm "${REALM}" ready`);
+
+ // 3. Confidential client — read back its generated secret (or fetch it if the client pre-existed).
+ const created = asRecord(
+ await call({
+ method: 'POST',
+ path: `/realms/${REALM}/clients`,
+ token,
+ json: {
+ name: 'Flank Web',
+ client_id: CLIENT_ID,
+ client_type: 'confidential',
+ public_client: false,
+ protocol: 'openid-connect',
+ enabled: true,
+ service_account_enabled: false,
+ direct_access_grants_enabled: true,
+ oauth_device_code_grant_enabled: false,
+ },
+ tolerate: ALREADY_EXISTS,
+ }),
+ );
+
+ let client = asRecord(created.data ?? created);
+ if (typeof client.id !== 'string') {
+ // Pre-existing client: look it up to recover the UUID + secret.
+ const list = asRecord(await call({ method: 'GET', path: `/realms/${REALM}/clients`, token }));
+ const items = (
+ Array.isArray(list.data) ? list.data : Array.isArray(list) ? list : []
+ ) as unknown[];
+ const match = items.map(asRecord).find((c) => c.client_id === CLIENT_ID);
+ if (match === undefined) return fail(`client "${CLIENT_ID}" not found after create`);
+ client = match;
+ }
+ const clientUuid = client.id;
+ const clientSecret = client.secret;
+ if (typeof clientUuid !== 'string') return fail('client has no id');
+ console.log(`✓ client "${CLIENT_ID}" ready (${clientUuid})`);
+
+ // 4. Redirect + post-logout URIs.
+ await call({
+ method: 'POST',
+ path: `/realms/${REALM}/clients/${clientUuid}/redirects`,
+ token,
+ json: { value: REDIRECT_URI, enabled: true },
+ tolerate: ALREADY_EXISTS,
+ });
+ await call({
+ method: 'POST',
+ path: `/realms/${REALM}/clients/${clientUuid}/post-logout-redirects`,
+ token,
+ json: { value: APP_ORIGIN, enabled: true },
+ tolerate: ALREADY_EXISTS,
+ });
+ console.log(`✓ redirect URIs registered (${REDIRECT_URI})`);
+
+ // 5. Demo user + password (matches `just seed`'s owner email so memberships line up).
+ const userRes = asRecord(
+ await call({
+ method: 'POST',
+ path: `/realms/${REALM}/users`,
+ token,
+ json: {
+ username: DEMO_EMAIL,
+ email: DEMO_EMAIL,
+ firstname: 'Dana',
+ lastname: 'Founder',
+ email_verified: true,
+ },
+ tolerate: ALREADY_EXISTS,
+ }),
+ );
+ const user = asRecord(userRes.data ?? userRes);
+ const userUuid = user.id;
+ if (typeof userUuid === 'string') {
+ await call({
+ method: 'PUT',
+ path: `/realms/${REALM}/users/${userUuid}/reset-password`,
+ token,
+ json: { temporary: false, credential_type: 'password', value: DEMO_PASSWORD },
+ });
+ console.log(`✓ demo user ${DEMO_EMAIL} ready (password: ${DEMO_PASSWORD})`);
+ } else {
+ console.log(`• demo user ${DEMO_EMAIL} already existed — left untouched`);
+ }
+
+ console.log('\nDone. Put these in your .env (then run `just dev`):\n');
+ console.log(`FERRISKEY_ISSUER=${API}`);
+ console.log(`FERRISKEY_REALM=${REALM}`);
+ console.log(`FERRISKEY_CLIENT_ID=${CLIENT_ID}`);
+ console.log(
+ typeof clientSecret === 'string'
+ ? `FERRISKEY_CLIENT_SECRET=${clientSecret}`
+ : 'FERRISKEY_CLIENT_SECRET=',
+ );
+};
+
+main().catch((error: unknown) => fail(String(error)));
diff --git a/vitest.config.ts b/vitest.config.ts
index 7c41ed8..83263f4 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -12,9 +12,9 @@ export default defineConfig({
// The drizzle-*.ts files (store, alert store, row mappers) are exercised by the DB-backed
// integration suite, not the unit run, so they would otherwise report 0% and drag the gate
// down — they are covered, just elsewhere (the shared FlankStore contract runs against Postgres).
- // session.ts / store.ts are thin server-only Next wiring (cookies/redirect/react cache, DB pool)
- // that imports `server-only` (throws under plain Node/vitest); their pure logic lives in the
- // covered resolver.ts / secret.ts / session-crypto.ts seams instead.
+ // session.ts / store.ts are thin server-only Next wiring (cookies/redirect/react cache, DB pool,
+ // Auth.js session) that imports `server-only` (throws under plain Node/vitest); their pure logic
+ // lives in the covered resolver.ts seam instead. (auth.ts lives outside apps/web/lib, uncovered.)
exclude: [
'**/*.test.ts',
'**/*.integration.test.ts',