From 917ad587c635aac55a97fc994fa939e6e0874a4c Mon Sep 17 00:00:00 2001
From: huyplb
Date: Tue, 29 Sep 2026 09:09:56 -0600
Subject: [PATCH] feat(auth): require sign-in on every install
Fox used to open straight into the workspace on a personal install, and a
multi-user server let anyone register. Now every install signs in, and only
an administrator creates accounts.
- First launch is setup: it creates the admin, or on an install used before
claims the existing local account so connections and history stay. A
bound APP_USER_EMAIL is used as-is.
- Setup from another machine (anything not a direct loopback request, so a
reverse proxy or the Vite proxy counts as remote) needs a one-time code
printed to the server log. Setup closes once an account can sign in, and a
server already running multi-user is never offered it.
- /api/auth/register answers 403; admins add users (POST /api/admin/users and
an Add user form). SSO signs in existing accounts only.
- Sign-in is rate-limited to 20 per 15 minutes.
- users.password_set (migration 19) records who can sign in.
- AUTH_REQUIRED is gone; LOCAL_SINGLE_USER now only marks a personal install
for machine-level routes. The CLI resolves the same owner account.
- e2e suites sign in through a cached session (apps/e2e/.env); the nightly
cloud workflow makes a password per run.
Co-Authored-By: Claude Opus 5.5
---
.env.example | 9 +-
.github/workflows/e2e-cloud.yml | 5 +
AGENTS.md | 2 +-
CONTRIBUTING.md | 2 +-
Dockerfile | 3 +-
apps/cli/src/commands/open.ts | 1 -
apps/cli/src/runtime/bootstrap.ts | 1 -
apps/cli/src/runtime/ensureUiEnv.ts | 3 -
apps/cli/src/runtime/store.ts | 6 +-
apps/e2e/.env.example | 9 +
apps/e2e/src/helpers/app-session.ts | 103 ++++++++++
apps/e2e/src/helpers/driver.ts | 5 +-
apps/e2e/src/helpers/sql-exec.ts | 8 +-
apps/web/package.json | 6 +-
apps/web/src/frontend/App.tsx | 2 +-
.../frontend/app/shell/ActivityRail.test.tsx | 1 -
.../app/shell/CommandPalette.test.tsx | 1 -
.../frontend/app/shell/ProfileMenu.test.tsx | 2 -
.../src/frontend/app/shell/ProfileMenu.tsx | 18 +-
apps/web/src/frontend/app/store/authStore.ts | 45 ++---
.../components/AccessPermissionPanel.test.tsx | 1 -
.../access/components/AccessView.test.tsx | 1 -
.../components/GeneratedPassword.test.tsx | 1 -
.../components/AdminAccessPanel.test.tsx | 28 ++-
.../admin/components/AdminAccessPanel.tsx | 108 ++++++++---
.../features/admin/lib/adminAccess.test.ts | 15 +-
.../features/admin/lib/adminAccess.ts | 16 +-
.../auth/components/AuthPage.test.tsx | 98 ++++++++++
.../features/auth/components/AuthPage.tsx | 131 +++++++++----
.../components/DatabaseAccessModal.test.tsx | 1 -
.../workflow/components/WorkflowView.test.tsx | 1 -
apps/web/src/frontend/shared/api/authApi.ts | 45 +++--
apps/web/vite.config.ts | 5 +
docker-compose.app.yml | 1 -
docs/ARCHITECTURE.md | 2 +-
docs/DEPLOYMENT.md | 58 +++---
docs/releases/UNRELEASED.md | 24 +++
packages/server/src/api/deployment.ts | 8 +-
packages/server/src/api/http-contract.test.ts | 61 +++++-
packages/server/src/api/routes.ts | 2 +-
packages/server/src/api/server.ts | 26 +--
packages/server/src/database/schema.ts | 15 ++
.../server/src/features/admin/admin.routes.ts | 33 +++-
.../admin/app-secrets.service.test.ts | 4 +-
...cloud-provider-credentials.service.test.ts | 2 +-
.../server/src/features/auth/auth.routes.ts | 80 +++++---
.../src/features/auth/auth.service.test.ts | 60 +++---
.../server/src/features/auth/auth.service.ts | 176 +++++++++++++-----
.../src/features/auth/auth.setup.test.ts | 125 +++++++++++++
.../server/src/features/auth/setup-code.ts | 56 ++++++
.../src/features/auth/setup.routes.test.ts | 124 ++++++++++++
.../connection-store.service.test.ts | 4 +-
.../features/users/signup-wizard.routes.ts | 2 +-
.../src/features/users/user.service.test.ts | 2 +-
...workflow-connection-grants.service.test.ts | 4 +-
55 files changed, 1208 insertions(+), 344 deletions(-)
create mode 100644 apps/e2e/src/helpers/app-session.ts
create mode 100644 apps/web/src/frontend/features/auth/components/AuthPage.test.tsx
create mode 100644 packages/server/src/features/auth/auth.setup.test.ts
create mode 100644 packages/server/src/features/auth/setup-code.ts
create mode 100644 packages/server/src/features/auth/setup.routes.test.ts
diff --git a/.env.example b/.env.example
index 10d17934..55f86759 100644
--- a/.env.example
+++ b/.env.example
@@ -31,14 +31,13 @@ APP_KEY_SCHEME=v1
# APP_DB_URL=mysql://user:pass@host:3306/foxmeta
# ── Access mode ──
-# Default is OPEN single-user (no login). Only safe behind your own reverse
-# proxy / VPN — do NOT expose this mode raw to the public internet.
+# Every install requires sign-in; the first launch creates the admin account
+# (see docs/DEPLOYMENT.md). LOCAL_SINGLE_USER=true marks a personal install,
+# which also allows machine-level actions such as driver install and updates.
LOCAL_SINGLE_USER=true
-AUTH_REQUIRED=false
#
-# For a public deployment, enable multi-user accounts + SSO (see docs/DEPLOYMENT.md):
+# For a shared deployment, turn those off and add SSO (see docs/DEPLOYMENT.md):
# LOCAL_SINGLE_USER=false
-# AUTH_REQUIRED=true
# SSO_REDIRECT_BASE=https://fox.example.com
# SSO_GOOGLE_CLIENT_ID=
# SSO_GOOGLE_CLIENT_SECRET=
diff --git a/.github/workflows/e2e-cloud.yml b/.github/workflows/e2e-cloud.yml
index 15de7ee5..0753676f 100644
--- a/.github/workflows/e2e-cloud.yml
+++ b/.github/workflows/e2e-cloud.yml
@@ -60,7 +60,12 @@ jobs:
E2E_SQLITE_TARGET_DB=/tmp/foxschema-sqlite/demo_b.db
E2E_SQLITE_TARGET_USER=sqlite
E2E_SQLITE_TARGET_PASS=
+ E2E_APP_EMAIL=e2e-admin@foxschema.test
+ E2E_API_URL=http://127.0.0.1:3210
EOF
+ # The suites sign in; on this fresh database they create the admin
+ # through first-run setup with a password made for this run.
+ echo "E2E_APP_PASSWORD=$(openssl rand -hex 16)" >> apps/e2e/.env
- name: Start app (API + Vite)
run: |
diff --git a/AGENTS.md b/AGENTS.md
index 46ab9306..9ac12e43 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,7 @@ Standard commands live in `CONTRIBUTING.md` and `package.json` scripts (`npm run
### Running the app
- `npm run dev` runs the Fastify API (`:3210`) and Vite UI (`:5173`) together; open the
UI at http://localhost:5173. API liveness: `GET http://localhost:3210/api/health`
- → `{"ok":true}`. Default mode is single-user (no login). Workflow engine:
+ → `{"ok":true}`. Every install requires sign-in; first open runs admin setup. Workflow engine:
`npm run dev:with-workflow` (needs `WORKFLOW_ENGINE_TOKEN` and
`FOXFLOW_ENCRYPTION_KEY` — [docs/WORKFLOW.md](docs/WORKFLOW.md)).
- Vite is configured with `server.host: true`, `server.strictPort: true`, and
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 51e5631f..3ea31943 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,7 +25,7 @@ npm install # installs the whole workspace
docker compose up -d
bash scripts/seed/seed-all.sh all # seed demo_a/demo_b schemas into each
-npm run dev # Fastify API + Vite UI (single-user mode)
+npm run dev # Fastify API + Vite UI (sign in; first open runs setup)
```
`npm run dev` serves the UI on **http://localhost:5173** and the **Fastify** API
diff --git a/Dockerfile b/Dockerfile
index f249ed84..82b45ef3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -76,8 +76,7 @@ ENV NODE_ENV=production \
APP_DB_ENGINE=sqlite \
APP_DB_PATH=/data/foxschema.db \
APP_KEY_SCHEME=v1 \
- LOCAL_SINGLE_USER=true \
- AUTH_REQUIRED=false
+ LOCAL_SINGLE_USER=true
# APP_ENCRYPTION_KEY is optional for pull-and-run: entrypoint generates one into
# /data/.app_encryption_key on first boot. Set -e APP_ENCRYPTION_KEY=… to override.
diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts
index b91bed9b..e60de721 100644
--- a/apps/cli/src/commands/open.ts
+++ b/apps/cli/src/commands/open.ts
@@ -395,7 +395,6 @@ export async function runOpen(opts: OpenOptions = {}): Promise {
PORT: String(port),
LISTEN_HOST: '127.0.0.1',
STATIC_DIR: staticDir,
- AUTH_REQUIRED: 'false',
LOCAL_SINGLE_USER: 'true',
APP_ENCRYPTION_KEY: process.env.APP_ENCRYPTION_KEY,
APP_KEY_SCHEME: process.env.APP_KEY_SCHEME || 'v1',
diff --git a/apps/cli/src/runtime/bootstrap.ts b/apps/cli/src/runtime/bootstrap.ts
index 1fccba1f..3c5af477 100644
--- a/apps/cli/src/runtime/bootstrap.ts
+++ b/apps/cli/src/runtime/bootstrap.ts
@@ -24,7 +24,6 @@ export function applyEnv(): boolean {
process.env.APP_DB_URL = c.dbUrl;
}
process.env.EDITION = process.env.EDITION || 'community';
- process.env.AUTH_REQUIRED = 'false';
return !!dek;
}
diff --git a/apps/cli/src/runtime/ensureUiEnv.ts b/apps/cli/src/runtime/ensureUiEnv.ts
index 31976047..c3c0f0aa 100644
--- a/apps/cli/src/runtime/ensureUiEnv.ts
+++ b/apps/cli/src/runtime/ensureUiEnv.ts
@@ -35,7 +35,6 @@ export function ensureUiEnv(): { source: 'keychain' | 'env' | 'file' | 'generate
if (!process.env.APP_ENCRYPTION_KEY && process.env.FOXSCHEMA_KEY) {
process.env.APP_ENCRYPTION_KEY = process.env.FOXSCHEMA_KEY;
}
- process.env.AUTH_REQUIRED = 'false';
process.env.EDITION = process.env.EDITION || 'community';
return { source: process.env.FOXSCHEMA_KEY ? 'env' : 'keychain' };
}
@@ -45,7 +44,6 @@ export function ensureUiEnv(): { source: 'keychain' | 'env' | 'file' | 'generate
const dek = getDek(c.email);
if (dek) {
applyEnv();
- process.env.AUTH_REQUIRED = 'false';
return { source: 'keychain' };
}
}
@@ -64,7 +62,6 @@ export function ensureUiEnv(): { source: 'keychain' | 'env' | 'file' | 'generate
const dbPath = c.dbPath || DEFAULT_DB_PATH;
mkdirSync(dirname(dbPath), { recursive: true });
process.env.APP_DB_PATH = dbPath;
- process.env.AUTH_REQUIRED = 'false';
process.env.EDITION = process.env.EDITION || 'community';
process.env.LOCAL_SINGLE_USER = 'true';
diff --git a/apps/cli/src/runtime/store.ts b/apps/cli/src/runtime/store.ts
index 677c5a04..a4026ce2 100644
--- a/apps/cli/src/runtime/store.ts
+++ b/apps/cli/src/runtime/store.ts
@@ -13,13 +13,15 @@ let ctx: CliContext | null = null;
/**
* Ready-to-use context: applies the stored config + keychain key to the env
- * (so the shared store/crypto run), ensures the local user, and returns the
+ * (so the shared store/crypto run), resolves the install owner, and returns the
* connection + history stores. Throws a clear message if not set up.
*/
export async function getContext(): Promise {
if (ctx) return ctx;
requireReady();
- const user = await new AuthModule().ensureLocalUser();
+ // The CLI acts as the install owner — the same account the app's first-run
+ // setup claims, so both see the same connections and history.
+ const user = await new AuthModule().ownerAccount();
ctx = { userId: user.id, connections: new ConnectionStore(), history: new MigrationHistoryStore() };
return ctx;
}
diff --git a/apps/e2e/.env.example b/apps/e2e/.env.example
index 3ea4ab1b..31684c29 100644
--- a/apps/e2e/.env.example
+++ b/apps/e2e/.env.example
@@ -1,5 +1,14 @@
# Copy to apps/e2e/.env (gitignored). Matches docker-compose.yml Postgres.
E2E_BASE_URL=http://127.0.0.1:5173
+
+# The Fox account the browser suites sign in as. Every install requires sign-in;
+# on a fresh dev database the suites create this admin through first-run setup
+# (on the API port directly, so no setup code is needed). If you already set up
+# this dev instance by hand, put an admin account of yours here instead.
+E2E_APP_EMAIL=e2e-admin@foxschema.test
+E2E_APP_PASSWORD=e2e-3e2cf9bc8f9dc5d661
+# The API itself, for setup and sign-in (not through the Vite proxy).
+E2E_API_URL=http://127.0.0.1:3210
E2E_POSTGRES_SOURCE_HOST=127.0.0.1
E2E_POSTGRES_SOURCE_PORT=5432
E2E_POSTGRES_SOURCE_DB=foxdb
diff --git a/apps/e2e/src/helpers/app-session.ts b/apps/e2e/src/helpers/app-session.ts
new file mode 100644
index 00000000..5bc810ae
--- /dev/null
+++ b/apps/e2e/src/helpers/app-session.ts
@@ -0,0 +1,103 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Signing the browser suites in.
+ *
+ * Every install requires sign-in. The suites use one admin account
+ * (`E2E_APP_EMAIL` / `E2E_APP_PASSWORD` in apps/e2e/.env). On a dev database
+ * nobody has set up yet, they create it through first-run setup; after that
+ * they sign in.
+ *
+ * Both calls go to the API port directly. Through the Vite proxy a request
+ * carries forwarding headers, so the API treats it as remote and setup would
+ * need the code from the server log.
+ *
+ * The session is cached in a temp file and reused across processes while it is
+ * still valid: `run-all.mjs` starts a process per suite, and signing in two
+ * dozen times would run into the sign-in limit (20 per 15 minutes).
+ */
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import type { Page } from 'playwright';
+
+const API_URL = process.env.E2E_API_URL ?? 'http://127.0.0.1:3210';
+const EMAIL = process.env.E2E_APP_EMAIL ?? 'e2e-admin@foxschema.test';
+const PASSWORD = process.env.E2E_APP_PASSWORD ?? '';
+
+const CACHE_DIR = join(tmpdir(), 'foxschema-e2e');
+const CACHE_FILE = join(CACHE_DIR, `session-${Buffer.from(`${API_URL}|${EMAIL}`).toString('hex')}.txt`);
+
+/** The session cookie's name and value, `sid=…`. */
+let cached: string | undefined;
+
+async function stillValid(cookie: string): Promise {
+ try {
+ const res = await fetch(`${API_URL}/api/auth/me`, { headers: { cookie } });
+ const body = (await res.json()) as { user?: unknown };
+ return res.ok && !!body.user;
+ } catch {
+ return false;
+ }
+}
+
+function readCache(): string | undefined {
+ try {
+ return readFileSync(CACHE_FILE, 'utf8').trim() || undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function writeCache(cookie: string): void {
+ try {
+ mkdirSync(CACHE_DIR, { recursive: true });
+ writeFileSync(CACHE_FILE, cookie, { mode: 0o600 });
+ } catch {
+ /* a cache, not a requirement */
+ }
+}
+
+async function post(path: string, body: unknown): Promise {
+ return fetch(`${API_URL}${path}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+}
+
+/** Sign in (running first-run setup if needed) and return `sid=…`. */
+export async function sessionCookie(): Promise {
+ if (cached && (await stillValid(cached))) return cached;
+ const fromFile = readCache();
+ if (fromFile && (await stillValid(fromFile))) return (cached = fromFile);
+
+ if (!PASSWORD) {
+ throw new Error('E2E_APP_PASSWORD is not set. Copy it from apps/e2e/.env.example into apps/e2e/.env.');
+ }
+
+ const setup = (await (await fetch(`${API_URL}/api/auth/setup`)).json()) as { setupRequired?: boolean };
+ const res = setup.setupRequired
+ ? await post('/api/auth/setup', { email: EMAIL, password: PASSWORD })
+ : await post('/api/auth/login', { email: EMAIL, password: PASSWORD });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ throw new Error(
+ `Could not sign the e2e suites in as ${EMAIL} (HTTP ${res.status} ${detail}). ` +
+ 'If this dev instance was set up by hand, set E2E_APP_EMAIL / E2E_APP_PASSWORD in ' +
+ 'apps/e2e/.env to an admin account on it.'
+ );
+ }
+ const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0] ?? '';
+ if (!cookie.startsWith('sid=')) throw new Error('Sign-in answered without a session cookie.');
+ writeCache(cookie);
+ return (cached = cookie);
+}
+
+/** Put the session on the browser, for pages served at `baseUrl`. */
+export async function signInBrowser(page: Page, baseUrl: string): Promise {
+ const [name, ...rest] = (await sessionCookie()).split('=');
+ await page.context().addCookies([{ name: name!, value: rest.join('='), url: baseUrl }]);
+}
diff --git a/apps/e2e/src/helpers/driver.ts b/apps/e2e/src/helpers/driver.ts
index 303620a4..a78d718a 100644
--- a/apps/e2e/src/helpers/driver.ts
+++ b/apps/e2e/src/helpers/driver.ts
@@ -1,11 +1,12 @@
import { chromium, type Browser, type Page, type Locator } from 'playwright';
+import { signInBrowser } from './app-session.js';
export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
// Track which Browser owns each Page so quitDriver can close both.
const pageToBrowser = new Map();
-/** Launch a Chromium browser and return its first Page. Set HEADLESS=false to watch. */
+/** Launch a signed-in Chromium browser and return its first Page. Set HEADLESS=false to watch. */
export async function buildDriver(): Promise {
const headless = process.env.HEADLESS !== 'false';
const browser = await chromium.launch({
@@ -18,6 +19,8 @@ export async function buildDriver(): Promise {
const page = await browser.newPage();
await page.setViewportSize({ width: 1440, height: 900 });
pageToBrowser.set(page, browser);
+ // Every install requires sign-in; each suite's browser starts signed in.
+ await signInBrowser(page, BASE_URL);
return page;
}
diff --git a/apps/e2e/src/helpers/sql-exec.ts b/apps/e2e/src/helpers/sql-exec.ts
index 3f668a80..229f9987 100644
--- a/apps/e2e/src/helpers/sql-exec.ts
+++ b/apps/e2e/src/helpers/sql-exec.ts
@@ -20,6 +20,7 @@
* reported rather than tolerated.
*/
import { getSourceConfig, type DbConfig } from './db-config.js';
+import { sessionCookie } from './app-session.js';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
@@ -104,7 +105,7 @@ export async function runStatements(
const res = await fetch(`${BASE_URL}/api/sql/execute`, {
method: 'POST',
- headers: { 'content-type': 'application/json' },
+ headers: { 'content-type': 'application/json', cookie: await sessionCookie() },
body: JSON.stringify({ dialect, option: optionOf(cfg), statements }),
});
const body = (await res.json()) as {
@@ -182,7 +183,8 @@ export async function deleteSavedConnections(names: readonly string[]): Promise<
if (names.length === 0) return 0;
const wanted = new Set(names);
try {
- const res = await fetch(`${BASE_URL}/api/connections`);
+ const cookie = await sessionCookie();
+ const res = await fetch(`${BASE_URL}/api/connections`, { headers: { cookie } });
const body = (await res.json()) as unknown;
const rows = Array.isArray(body)
? (body as Array<{ id?: string; name?: string }>)
@@ -191,7 +193,7 @@ export async function deleteSavedConnections(names: readonly string[]): Promise<
let removed = 0;
for (const row of rows) {
if (!row?.id || !row.name || !wanted.has(row.name)) continue;
- const gone = await fetch(`${BASE_URL}/api/connections/${row.id}`, { method: 'DELETE' })
+ const gone = await fetch(`${BASE_URL}/api/connections/${row.id}`, { method: 'DELETE', headers: { cookie } })
.then((r) => r.ok)
.catch(() => false);
if (gone) removed++;
diff --git a/apps/web/package.json b/apps/web/package.json
index 3f491d89..fa803d84 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,11 +8,11 @@
},
"scripts": {
"dev": "vite",
- "dev:api": "cross-env AUTH_REQUIRED=true APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts",
+ "dev:api": "cross-env APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts",
"dev:all": "concurrently -n api,web -c cyan,magenta \"npm run dev:api\" \"npm run dev\"",
"dev:all:workflow": "concurrently -n api,web,workflow -c cyan,magenta,green \"npm run dev:api\" \"npm run dev\" \"npm -w @foxschema/workflow-server run dev\"",
- "dev:auth": "cross-env AUTH_REQUIRED=true LOCAL_SINGLE_USER=false vite",
- "dev:api:auth": "cross-env AUTH_REQUIRED=true LOCAL_SINGLE_USER=false APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts",
+ "dev:auth": "cross-env LOCAL_SINGLE_USER=false vite",
+ "dev:api:auth": "cross-env LOCAL_SINGLE_USER=false APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts",
"dev:all:auth": "concurrently -n api,web -c cyan,magenta \"npm run dev:api:auth\" \"npm run dev:auth\"",
"build": "tsc && vite build",
"preview": "vite preview",
diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx
index 6706b726..ea8e434e 100644
--- a/apps/web/src/frontend/App.tsx
+++ b/apps/web/src/frontend/App.tsx
@@ -232,7 +232,7 @@ const App: React.FC = () => {
);
}
- if (status === 'anon') return ;
+ if (status === 'anon' || status === 'setup') return ;
if (status === 'onboarding') return ;
return ;
};
diff --git a/apps/web/src/frontend/app/shell/ActivityRail.test.tsx b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx
index 17c3475f..a60e457c 100644
--- a/apps/web/src/frontend/app/shell/ActivityRail.test.tsx
+++ b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx
@@ -21,7 +21,6 @@ describe('ActivityRail', () => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
- localSingleUser: true,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
diff --git a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx
index 2ec0c440..ffaa7f0e 100644
--- a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx
+++ b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx
@@ -48,7 +48,6 @@ describe('CommandPalette', () => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
- localSingleUser: true,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx
index 6e1f9b08..45f73863 100644
--- a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx
+++ b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx
@@ -35,7 +35,6 @@ beforeEach(() => {
permissions: [],
},
status: 'ready',
- localSingleUser: true,
error: null,
busy: false,
});
@@ -58,7 +57,6 @@ describe('ProfileMenu', () => {
role: 'editor',
permissions: [...DEFAULT_ROLE_PERMISSIONS.editor],
},
- localSingleUser: false,
});
render();
fireEvent.click(screen.getByTestId('profile-menu-trigger'));
diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx
index 28329d53..6c553a1d 100644
--- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx
+++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx
@@ -9,7 +9,7 @@ import { maybeToastUpdateAvailable } from '@/app/shell/updateToast';
import { AdminAccessPanel } from '@/features/admin';
export function ProfileMenu(): React.ReactElement | null {
- const { user, logout, localSingleUser } = useAuthStore();
+ const { user, logout } = useAuthStore();
const setActiveView = useUiStore((s) => s.setActiveView);
const canAdminAccess = useAuthStore((s) => s.can('admin.users') || s.can('admin.roles'));
const [open, setOpen] = useState(false);
@@ -117,15 +117,13 @@ export function ProfileMenu(): React.ReactElement | null {
foxschema.com
- {!localSingleUser && (
-
- )}
+
,
document.body
)
diff --git a/apps/web/src/frontend/app/store/authStore.ts b/apps/web/src/frontend/app/store/authStore.ts
index 3388ef54..16181438 100644
--- a/apps/web/src/frontend/app/store/authStore.ts
+++ b/apps/web/src/frontend/app/store/authStore.ts
@@ -7,37 +7,31 @@ import { create } from 'zustand';
import {
apiMe,
apiLogin,
- apiRegister,
apiLogout,
apiPutPreferences,
- apiAppConfig,
+ apiSetup,
+ apiSetupState,
type AuthUser,
+ type SetupState,
type UserPreferences,
} from '@/shared/api/authApi';
import type { Permission } from '@/shared/lib/permissions';
import { userCan } from '@/shared/lib/permissions';
-type AuthStatus = 'loading' | 'anon' | 'onboarding' | 'ready';
-
-const LOCAL_USER: AuthUser = {
- id: 'local',
- email: 'local@foxschema.app',
- onboardingCompleted: true,
- role: 'admin',
- permissions: [],
-};
+/** `setup`: no account can sign in yet, so the first admin must be created. */
+type AuthStatus = 'loading' | 'setup' | 'anon' | 'onboarding' | 'ready';
interface AuthState {
status: AuthStatus;
user: AuthUser | null;
error: string | null;
busy: boolean;
- /** True when the API runs in local single-user mode (no login UI). */
- localSingleUser: boolean;
+ /** First-run setup details, while status is `setup`. */
+ setupState: SetupState | null;
init: () => Promise;
login: (email: string, password: string) => Promise;
- register: (email: string, password: string) => Promise;
+ setup: (email: string, password: string, code?: string) => Promise;
logout: () => Promise;
completeOnboarding: (prefs: Partial) => Promise;
refreshMe: () => Promise;
@@ -55,23 +49,22 @@ export const useAuthStore = create((set, get) => ({
user: null,
error: null,
busy: false,
- localSingleUser: true,
+ setupState: null,
can: (permission) => userCan(get().user, permission),
+ // Every install signs in. Until one account can, the first admin is set up.
init: async () => {
- const cfg = await apiAppConfig();
- set({ localSingleUser: cfg.localSingleUser });
+ const setupState = await apiSetupState();
+ if (setupState.setupRequired) {
+ set({ setupState, user: null, status: 'setup' });
+ return;
+ }
await get().refreshMe();
},
- /** Prefer the server-enriched user (real id + permissions) when available. */
refreshMe: async () => {
const user = await apiMe();
- if (!user && get().localSingleUser) {
- set({ user: LOCAL_USER, status: 'ready' });
- return;
- }
set({ user, status: statusFor(user) });
},
@@ -88,14 +81,14 @@ export const useAuthStore = create((set, get) => ({
}
},
- register: async (email, password) => {
+ setup: async (email, password, code) => {
set({ busy: true, error: null });
try {
- const user = await apiRegister(email, password);
- set({ user, status: statusFor(user), busy: false });
+ const user = await apiSetup(email, password, code);
+ set({ user, setupState: null, status: statusFor(user), busy: false });
} catch (e: unknown) {
set({
- error: e instanceof Error ? e.message : 'Registration failed',
+ error: e instanceof Error ? e.message : 'Setup failed',
busy: false,
});
}
diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx
index 2e85df23..dc8a42d8 100644
--- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx
+++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx
@@ -117,7 +117,6 @@ beforeEach(() => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
- localSingleUser: false,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
diff --git a/apps/web/src/frontend/features/access/components/AccessView.test.tsx b/apps/web/src/frontend/features/access/components/AccessView.test.tsx
index 99c86a3f..e8170061 100644
--- a/apps/web/src/frontend/features/access/components/AccessView.test.tsx
+++ b/apps/web/src/frontend/features/access/components/AccessView.test.tsx
@@ -58,7 +58,6 @@ beforeEach(() => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
- localSingleUser: false,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
diff --git a/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx b/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx
index 5a3d66d0..6d240dc2 100644
--- a/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx
+++ b/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx
@@ -90,7 +90,6 @@ beforeEach(() => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
- localSingleUser: false,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
diff --git a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx
index 25368792..9c1154c9 100644
--- a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx
+++ b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx
@@ -9,6 +9,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { DEFAULT_ROLE_PERMISSIONS, PERMISSION_META } from '@foxschema/shared';
import { useAuthStore } from '@/app/store/authStore';
+const apiAdminCreateUser = vi.fn();
const apiAdminListUsers = vi.fn();
const apiAdminRolePermissions = vi.fn();
const apiAdminSetRolePermissions = vi.fn();
@@ -17,6 +18,7 @@ const apiAdminSetUserPassword = vi.fn();
const apiAdminSetUserRole = vi.fn();
vi.mock('@/shared/api/authApi', () => ({
+ apiAdminCreateUser: (...args: unknown[]) => apiAdminCreateUser(...args),
apiAdminListUsers: (...args: unknown[]) => apiAdminListUsers(...args),
apiAdminRolePermissions: (...args: unknown[]) => apiAdminRolePermissions(...args),
apiAdminSetRolePermissions: (...args: unknown[]) => apiAdminSetRolePermissions(...args),
@@ -37,6 +39,7 @@ const localUser = {
};
beforeEach(() => {
+ apiAdminCreateUser.mockReset();
apiAdminListUsers.mockReset();
apiAdminRolePermissions.mockReset();
apiAdminSetRolePermissions.mockReset();
@@ -67,7 +70,6 @@ beforeEach(() => {
permissions: [],
},
status: 'ready',
- localSingleUser: true,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
@@ -75,7 +77,7 @@ beforeEach(() => {
});
describe('AdminAccessPanel', () => {
- it('locks role and Active for the local single-user admin', async () => {
+ it('locks role and Active for the only admin, and for your own account', async () => {
render( undefined} />);
await waitFor(() => {
@@ -85,7 +87,6 @@ describe('AdminAccessPanel', () => {
expect(screen.getByTestId('admin-tab-users').textContent).toMatch(/app users/i);
expect(screen.getByTestId('admin-tab-roles').textContent).toMatch(/app roles/i);
expect(screen.getByTestId('admin-tab-users-roles').textContent).toMatch(/users and roles/i);
- expect(screen.getByTestId('admin-single-user-hint').textContent).toMatch(/single-user/i);
expect((screen.getByTestId(`admin-user-role-${localUser.id}`) as HTMLSelectElement).disabled).toBe(
true
);
@@ -94,6 +95,23 @@ describe('AdminAccessPanel', () => {
);
});
+ it('adds an account, since nobody can register themselves', async () => {
+ apiAdminCreateUser.mockResolvedValue(undefined);
+ render( undefined} />);
+ await waitFor(() => expect(screen.getByTestId('admin-add-user')).toBeTruthy());
+
+ fireEvent.change(screen.getByLabelText(/add user/i), { target: { value: 'Teammate@Example.com' } });
+ fireEvent.change(screen.getByLabelText(/starting password/i), { target: { value: 'teammate-pass' } });
+ fireEvent.change(screen.getByLabelText(/^role$/i), { target: { value: 'editor' } });
+ fireEvent.submit(screen.getByTestId('admin-add-user'));
+
+ await waitFor(() =>
+ expect(apiAdminCreateUser).toHaveBeenCalledWith('Teammate@Example.com', 'teammate-pass', 'editor')
+ );
+ // The list is read again so the new account appears.
+ await waitFor(() => expect(apiAdminListUsers).toHaveBeenCalledTimes(2));
+ });
+
it('keeps Save visible and persists checkbox edits for a non-admin role', async () => {
render( undefined} />);
@@ -201,7 +219,6 @@ describe('AdminAccessPanel', () => {
role: 'viewer',
permissions: [...DEFAULT_ROLE_PERMISSIONS.viewer],
},
- localSingleUser: false,
});
render( undefined} />);
expect(screen.getByTestId('admin-access-panel')).toBeTruthy();
@@ -220,7 +237,6 @@ describe('AdminAccessPanel', () => {
role: 'editor',
permissions: [...DEFAULT_ROLE_PERMISSIONS.editor],
},
- localSingleUser: false,
});
render( undefined} />);
await waitFor(() => expect(screen.getByTestId('admin-tab-users-roles')).toBeTruthy());
@@ -240,7 +256,6 @@ describe('AdminAccessPanel', () => {
role: 'owner',
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner, 'admin.roles'],
},
- localSingleUser: false,
});
render( undefined} />);
await waitFor(() => expect(screen.getByTestId('admin-tab-roles')).toBeTruthy());
@@ -258,7 +273,6 @@ describe('AdminAccessPanel', () => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.editor],
};
apiAdminListUsers.mockResolvedValue({ users: [localUser, editorUser] });
- useAuthStore.setState({ localSingleUser: false });
render( undefined} />);
diff --git a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx
index b5720d2b..54ba2bc3 100644
--- a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx
+++ b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx
@@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, ChevronRight, KeyRound, Loader2, Shield, UserCog, Users, X } from 'lucide-react';
import {
+ apiAdminCreateUser,
apiAdminListUsers,
apiAdminRolePermissions,
apiAdminSetRolePermissions,
@@ -52,7 +53,6 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }>
}) => {
const refreshMe = useAuthStore((s) => s.refreshMe);
const me = useAuthStore((s) => s.user);
- const localSingleUser = useAuthStore((s) => s.localSingleUser);
const canUsers = useAuthStore((s) => s.can('admin.users'));
const canRoles = useAuthStore((s) => s.can('admin.roles'));
const canUsersRoles = useAuthStore((s) => s.can('utility.access'));
@@ -70,6 +70,9 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }>
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordMsg, setPasswordMsg] = useState(null);
const [expandedUserIds, setExpandedUserIds] = useState>(() => new Set());
+ const [addEmail, setAddEmail] = useState('');
+ const [addPassword, setAddPassword] = useState('');
+ const [addRole, setAddRole] = useState('viewer');
const load = useCallback(async () => {
setBusy(true);
@@ -241,6 +244,26 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }>
}
};
+ /** No self-registration: this is how anyone besides the first admin gets in. */
+ const addUser = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setBusy(true);
+ setError(null);
+ setSavedMsg(null);
+ try {
+ await apiAdminCreateUser(addEmail, addPassword, addRole);
+ setSavedMsg(`Added ${addEmail.trim().toLowerCase()} as ${addRole}. Share the password with them directly.`);
+ setAddEmail('');
+ setAddPassword('');
+ setAddRole('viewer');
+ await load();
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : 'Could not add the account');
+ } finally {
+ setBusy(false);
+ }
+ };
+
const saveRolePerms = async () => {
if (editRole === 'admin') return;
setBusy(true);
@@ -364,26 +387,68 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }>
{tab === 'users' && canUsers && (
<>
- {localSingleUser && (
-
+ FoxSchema logins grouped by app role. Expand a row to see that role’s permissions —
+ they are not per-user overrides. Who can access what on the database is on Users
+ and Roles; GRANT / REVOKE is under Access → Permission.
+
+
- )}
- {!localSingleUser && (
-
- FoxSchema logins grouped by app role. Expand a row to see that role’s permissions —
- they are not per-user overrides. Who can access what on the database is on Users
- and Roles; GRANT / REVOKE is under Access → Permission.
-
- )}
+ Add user
+
+
{userGroups.map((group) => (
void }>
) : (
{group.users.map((u) => {
- const roleLock = userRoleSelectLock(u, { busy, localSingleUser, users });
+ const roleLock = userRoleSelectLock(u, { busy, users });
const activeLock = userActiveCheckboxLock(u, {
busy,
- localSingleUser,
meId: me?.id,
users,
});
diff --git a/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts b/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts
index a5d21c5f..db727281 100644
--- a/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts
+++ b/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts
@@ -34,27 +34,22 @@ describe('permissionSetEqual', () => {
});
});
-describe('last-admin / single-user locks', () => {
+describe('last-admin and own-account locks', () => {
it('counts only active admins', () => {
expect(activeAdminCount([admin, editor, inactiveAdmin])).toBe(1);
expect(activeAdminCount([admin, { ...admin, id: 'a2' }])).toBe(2);
});
- it('locks the local singleton role and Active checkbox', () => {
- const role = userRoleSelectLock(admin, { localSingleUser: true, users: [admin] });
- expect(role.disabled).toBe(true);
- expect(role.reason).toMatch(/single-user/i);
-
+ it('never lets you deactivate your own account', () => {
const active = userActiveCheckboxLock(admin, {
- localSingleUser: true,
meId: admin.id,
- users: [admin],
+ users: [admin, { ...admin, id: 'a2' }],
});
expect(active.disabled).toBe(true);
- expect(active.reason).toMatch(/single-user/i);
+ expect(active.reason).toMatch(/your own account/i);
});
- it('locks the last active admin’s role even in multi-user mode', () => {
+ it('locks the last active admin’s role', () => {
const lock = userRoleSelectLock(admin, { users: [admin, editor] });
expect(lock.disabled).toBe(true);
expect(lock.reason).toMatch(/last active admin/i);
diff --git a/apps/web/src/frontend/features/admin/lib/adminAccess.ts b/apps/web/src/frontend/features/admin/lib/adminAccess.ts
index 968f0021..19d9f6a2 100644
--- a/apps/web/src/frontend/features/admin/lib/adminAccess.ts
+++ b/apps/web/src/frontend/features/admin/lib/adminAccess.ts
@@ -39,16 +39,9 @@ export type ControlLock = { disabled: boolean; reason?: string };
export function userRoleSelectLock(
user: AdminUserLike,
- opts: { busy?: boolean; localSingleUser?: boolean; users: readonly AdminUserLike[] }
+ opts: { busy?: boolean; users: readonly AdminUserLike[] }
): ControlLock {
if (opts.busy) return { disabled: true };
- if (opts.localSingleUser) {
- return {
- disabled: true,
- reason:
- 'Single-user mode keeps this account as admin. Enable multi-user login to change roles.',
- };
- }
if (isActiveAdmin(user) && activeAdminCount(opts.users) <= 1) {
return { disabled: true, reason: 'Cannot change the last active admin’s role' };
}
@@ -60,17 +53,10 @@ export function userActiveCheckboxLock(
opts: {
busy?: boolean;
meId?: string;
- localSingleUser?: boolean;
users: readonly AdminUserLike[];
}
): ControlLock {
if (opts.busy) return { disabled: true };
- if (opts.localSingleUser) {
- return {
- disabled: true,
- reason: 'Single-user mode cannot deactivate this account.',
- };
- }
if (user.id === opts.meId) {
return { disabled: true, reason: 'You cannot deactivate your own account' };
}
diff --git a/apps/web/src/frontend/features/auth/components/AuthPage.test.tsx b/apps/web/src/frontend/features/auth/components/AuthPage.test.tsx
new file mode 100644
index 00000000..a383e597
--- /dev/null
+++ b/apps/web/src/frontend/features/auth/components/AuthPage.test.tsx
@@ -0,0 +1,98 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Every install signs in. Before any account can, the sign-in page is
+ * first-run setup; after, it is sign-in with no way to register yourself.
+ */
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+const apiSetupState = vi.fn();
+const apiSetup = vi.fn();
+const apiMe = vi.fn();
+const apiLogin = vi.fn();
+
+vi.mock('@/shared/api/authApi', () => ({
+ apiSetupState: (...a: unknown[]) => apiSetupState(...a),
+ apiSetup: (...a: unknown[]) => apiSetup(...a),
+ apiMe: (...a: unknown[]) => apiMe(...a),
+ apiLogin: (...a: unknown[]) => apiLogin(...a),
+ apiLogout: vi.fn(async () => undefined),
+ apiPutPreferences: vi.fn(async () => ({})),
+}));
+vi.mock('./SsoButtons', () => ({ SsoButtons: () => null }));
+
+import { useAuthStore } from '@/app/store/authStore';
+import { AuthPage } from './AuthPage';
+
+const ADMIN = { id: 'u1', email: 'owner@example.com', onboardingCompleted: true, role: 'admin', permissions: [] };
+
+beforeEach(() => {
+ for (const m of [apiSetupState, apiSetup, apiMe, apiLogin]) m.mockReset();
+ useAuthStore.setState({ status: 'loading', user: null, error: null, busy: false, setupState: null });
+});
+
+const type = (label: RegExp, value: string) =>
+ fireEvent.change(screen.getByLabelText(label), { target: { value } });
+
+describe('the sign-in page', () => {
+ it('goes to setup when no account can sign in yet', async () => {
+ apiSetupState.mockResolvedValue({ setupRequired: true, setupEmail: null, setupCodeRequired: false });
+ await useAuthStore.getState().init();
+ expect(useAuthStore.getState().status).toBe('setup');
+ expect(apiMe).not.toHaveBeenCalled();
+ });
+
+ it('creates the admin account, refusing mismatched passwords first', async () => {
+ apiSetup.mockResolvedValue(ADMIN);
+ useAuthStore.setState({
+ status: 'setup',
+ setupState: { setupRequired: true, setupEmail: null, setupCodeRequired: false },
+ });
+ render();
+ expect(screen.queryByLabelText(/setup code/i)).toBeNull();
+
+ type(/^email$/i, 'owner@example.com');
+ type(/^password$/i, 'owner-pass-1');
+ type(/confirm password/i, 'owner-pass-2');
+ fireEvent.submit(screen.getByTestId('auth-setup-form'));
+ expect(screen.getByText(/do not match/i)).toBeTruthy();
+ expect(apiSetup).not.toHaveBeenCalled();
+
+ type(/confirm password/i, 'owner-pass-1');
+ fireEvent.submit(screen.getByTestId('auth-setup-form'));
+ await waitFor(() => expect(apiSetup).toHaveBeenCalledWith('owner@example.com', 'owner-pass-1', undefined));
+ await waitFor(() => expect(useAuthStore.getState().status).toBe('ready'));
+ });
+
+ it('uses the bound email and asks for the setup code when not on the server machine', async () => {
+ apiSetup.mockResolvedValue(ADMIN);
+ useAuthStore.setState({
+ status: 'setup',
+ setupState: { setupRequired: true, setupEmail: 'bound@example.com', setupCodeRequired: true },
+ });
+ render();
+ const email = screen.getByLabelText(/^email$/i) as HTMLInputElement;
+ expect(email.value).toBe('bound@example.com');
+ expect(email.readOnly).toBe(true);
+
+ type(/^password$/i, 'owner-pass-1');
+ type(/confirm password/i, 'owner-pass-1');
+ type(/setup code/i, 'ABCD-EFGH');
+ fireEvent.submit(screen.getByTestId('auth-setup-form'));
+ await waitFor(() =>
+ expect(apiSetup).toHaveBeenCalledWith('bound@example.com', 'owner-pass-1', 'ABCD-EFGH')
+ );
+ });
+
+ it('offers sign-in only, with no way to register', async () => {
+ useAuthStore.setState({ status: 'anon', setupState: null });
+ render();
+ expect(screen.getByTestId('auth-login-form')).toBeTruthy();
+ expect(screen.queryByText(/sign up/i)).toBeNull();
+ expect(screen.getByText(/ask your administrator/i)).toBeTruthy();
+ });
+});
diff --git a/apps/web/src/frontend/features/auth/components/AuthPage.tsx b/apps/web/src/frontend/features/auth/components/AuthPage.tsx
index 925e5a75..8ea218e2 100644
--- a/apps/web/src/frontend/features/auth/components/AuthPage.tsx
+++ b/apps/web/src/frontend/features/auth/components/AuthPage.tsx
@@ -12,23 +12,43 @@ function readSsoError(): string | null {
return err ? decodeURIComponent(err) : null;
}
+const inputCls =
+ 'bg-slate-950 border border-slate-800 accent-focus rounded-md px-3 py-2 text-sm outline-none';
+const labelCls = 'text-xs font-semibold text-slate-400 uppercase tracking-wider';
+
+/**
+ * Sign in, or on first run create the admin account.
+ *
+ * Every install signs in; there is no self-registration. Until an account can
+ * sign in, this page is first-run setup, which on an install used before keeps
+ * its saved connections and history.
+ */
export const AuthPage: React.FC = () => {
- const { login, register, error, busy, clearError } = useAuthStore();
- const [mode, setMode] = useState<'login' | 'register'>('login');
- const [email, setEmail] = useState('');
+ const { login, setup, setupState, status, error, busy } = useAuthStore();
+ const settingUp = status === 'setup' && !!setupState;
+ const boundEmail = setupState?.setupEmail ?? null;
+ const [email, setEmail] = useState(boundEmail ?? '');
const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [code, setCode] = useState('');
+ const [mismatch, setMismatch] = useState(false);
const [ssoError] = useState(readSsoError);
const submit = (e: React.FormEvent) => {
e.preventDefault();
- if (mode === 'login') login(email, password);
- else register(email, password);
+ if (!settingUp) {
+ login(email, password);
+ return;
+ }
+ if (password !== confirm) {
+ setMismatch(true);
+ return;
+ }
+ setMismatch(false);
+ setup(boundEmail ?? email, password, setupState?.setupCodeRequired ? code : undefined);
};
- const switchMode = (next: 'login' | 'register') => {
- clearError();
- setMode(next);
- };
+ const shownError = mismatch ? 'The two passwords do not match.' : error || ssoError;
return (
- {mode === 'login' ? 'Sign in to your workspace' : 'Create your account'}
+ {settingUp ? 'Create the administrator account' : 'Sign in to your workspace'}
- First-time sign-in opens a short setup wizard before you reach the workspace.
+ {settingUp
+ ? 'Fox now asks everyone to sign in. Choose the password for this install. Saved connections and history stay as they are.'
+ : 'First-time sign-in opens a short setup wizard before you reach the workspace.'}
-
diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx
index 149937c9..c2e5c640 100644
--- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx
+++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx
@@ -93,7 +93,6 @@ beforeEach(() => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
- localSingleUser: false,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
diff --git a/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx b/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx
index f8f14cf8..04eef145 100644
--- a/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx
+++ b/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx
@@ -24,7 +24,6 @@ function seedRole(role: 'viewer' | 'editor' | 'owner') {
permissions: [...DEFAULT_ROLE_PERMISSIONS[role]],
},
status: 'ready',
- localSingleUser: false,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
diff --git a/apps/web/src/frontend/shared/api/authApi.ts b/apps/web/src/frontend/shared/api/authApi.ts
index 03248fc9..5f6847bb 100644
--- a/apps/web/src/frontend/shared/api/authApi.ts
+++ b/apps/web/src/frontend/shared/api/authApi.ts
@@ -6,6 +6,9 @@
import { api, type RequestOptions } from './client';
import type { AppRole, Permission, PermissionMeta } from '../lib/permissions';
+/** These routes predate the shared client and tolerate an empty reply; keep that. */
+const EMPTY_OK: RequestOptions = { allowEmpty: true };
+
export interface AuthUser {
id: string;
email: string;
@@ -22,22 +25,38 @@ export interface UserPreferences {
onboardingCompleted: boolean;
}
-export interface AppConfig {
- localSingleUser: boolean;
+/**
+ * First-run setup state. Every install signs in; until one account can, the
+ * sign-in screen offers setup instead.
+ */
+export interface SetupState {
+ setupRequired: boolean;
+ /** The install's bound email; setup must use it. */
+ setupEmail: string | null;
+ /** This browser is not on the server's machine: setup needs the code from the server log. */
+ setupCodeRequired: boolean;
}
-/** These routes predate the shared client and tolerate an empty reply; keep that. */
-const EMPTY_OK: RequestOptions = { allowEmpty: true };
+const NO_SETUP: SetupState = { setupRequired: false, setupEmail: null, setupCodeRequired: false };
-/** Public SPA boot config (login required?). */
-export async function apiAppConfig(): Promise {
+export async function apiSetupState(): Promise {
try {
- return await api.get('/config', EMPTY_OK);
+ return await api.get('/auth/setup', EMPTY_OK);
} catch {
- return { localSingleUser: true };
+ return NO_SETUP;
}
}
+/** Create (or claim) the first admin account and sign in as it. */
+export async function apiSetup(email: string, password: string, code?: string): Promise {
+ const { user } = await api.post<{ user: AuthUser }>(
+ '/auth/setup',
+ { email, password, ...(code ? { code } : {}) },
+ EMPTY_OK
+ );
+ return user;
+}
+
/** Current session, or null if not signed in. */
export async function apiMe(): Promise {
try {
@@ -48,11 +67,6 @@ export async function apiMe(): Promise {
}
}
-export async function apiRegister(email: string, password: string): Promise {
- const { user } = await api.post<{ user: AuthUser }>('/auth/register', { email, password }, EMPTY_OK);
- return user;
-}
-
export async function apiLogin(email: string, password: string): Promise {
const { user } = await api.post<{ user: AuthUser }>('/auth/login', { email, password }, EMPTY_OK);
return user;
@@ -85,6 +99,11 @@ export async function apiAdminListUsers(): Promise<{
return api.get('/admin/users', EMPTY_OK);
}
+/** Admin adds an account (there is no self-registration). */
+export async function apiAdminCreateUser(email: string, password: string, role: AppRole): Promise {
+ await api.post('/admin/users', { email, password, role }, EMPTY_OK);
+}
+
export async function apiAdminSetUserRole(userId: string, role: AppRole): Promise {
await api.put(`/admin/users/${encodeURIComponent(userId)}/role`, { role }, EMPTY_OK);
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index f65d5309..73d622e9 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -63,6 +63,11 @@ export default defineConfig({
// Match DEFAULT_API_PORT (3210). Override with API_PORT when needed.
target: `http://localhost:${process.env.API_PORT || 3210}`,
changeOrigin: true,
+ // Say it was forwarded. The dev server listens on every interface, and
+ // without these headers the API would take a browser elsewhere on the
+ // LAN for someone at this machine, who may run first-run setup without
+ // the code from the server log.
+ xfwd: true,
// Origin is forwarded untouched, on purpose. Rewriting it to a trusted
// value would switch the API's origin check off for everything that
// reaches this dev server, including a DNS-rebound page (Vite serves any
diff --git a/docker-compose.app.yml b/docker-compose.app.yml
index 45bc8cf2..ba057b7e 100644
--- a/docker-compose.app.yml
+++ b/docker-compose.app.yml
@@ -34,7 +34,6 @@ services:
APP_DB_URL: ${APP_DB_URL:-}
APP_KEY_SCHEME: ${APP_KEY_SCHEME:-v1}
LOCAL_SINGLE_USER: ${LOCAL_SINGLE_USER:-true}
- AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
NODE_ENV: production
volumes:
- fox_data:/data
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 959a1822..9b5626bd 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -20,7 +20,7 @@ foxschema stop
foxschema doctor
# Development (starts both the Fastify API + Vite frontend)
-npm run dev # single-user mode (no login)
+npm run dev # sign-in required; first open runs setup
npm run dev:auth # multi-user auth mode
npm run dev:with-workflow # plus workflow-server on :8081
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 2947073b..61a38655 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -14,17 +14,23 @@ foxschema shortcut
Maintainers: **[PUBLISH.md](PUBLISH.md)**.
-### Multi-user login + RBAC
-
-For team installs, require login and enable roles (`admin` / `editor` / `viewer`):
-
-```bash
-LOCAL_SINGLE_USER=false
-# AUTH_REQUIRED defaults to true when LOCAL_SINGLE_USER=false
-```
-
-- First UI open can still show the **email subscriber wizard** (public; before login).
-- First registered account becomes **admin**; later signups default to **viewer**.
+### Sign-in + RBAC
+
+Every install requires sign-in with an email and password — Docker, `fox open`
+and the desktop app alike. Roles are `admin` / `editor` / `owner` / `viewer`.
+
+- **First run** shows a setup screen that creates the administrator account. On
+ an install that was used before sign-in was required, setup *claims* the
+ existing local account (email pre-filled from `fox setup` when bound), so
+ saved connections and history carry over.
+- **Setup from another machine** (a server, a container) asks for a one-time
+ **setup code**, printed in the Fox server log when someone opens the setup
+ screen remotely. From the machine itself no code is needed. Behind a reverse
+ proxy every visitor counts as remote.
+- **No self-registration.** Admins add people under **Profile → Access control →
+ Add user**, with a starting password to hand over. SSO signs in existing
+ accounts only.
+- First UI open can still show the **email subscriber wizard** (public; before sign-in).
- Admins configure role permissions and assign users under **Profile → Access control**.
- Permissions cover Schema Sync (browse / compare / migrate), SQL Editor (sidebar, variables, writes, Data grid insert/update/delete, code cells), Utilities, Secrets, Access, and Workflow (`workflow.access` / `design` / `run` / `admin`).
- **Database Access** catalog (`POST /schema/db-access`) is an OR gate: **Use utilities** *or* any Access-workspace permission (`access.access`, Users, builder, diff, inspector, report) may load it. GRANT / REVOKE still needs **Grant privileges**.
@@ -39,7 +45,7 @@ Db2) that serves both the UI and the API on one configurable port (default **321
- [The encryption key](#the-encryption-key)
- [Choosing a port](#choosing-a-port)
- [Where app data lives](#where-app-data-lives)
-- [Access: single-user vs. multi-user + SSO](#access-single-user-vs-multi-user--sso)
+- [Access: sign-in + SSO](#access-sign-in--sso)
- [Cloud platforms](#cloud-platforms)
- [Database drivers](#database-drivers)
- [Building the image](#building-the-image)
@@ -59,7 +65,8 @@ docker run -d --name foxschema \
Open http://localhost:3210
-Defaults baked into the image: single-user mode (no login), SQLite metadata on
+Defaults baked into the image: sign-in required (first open creates the admin —
+read the setup code from `docker logs`), SQLite metadata on
`/data`, port `3210`, **Db2 client included**. Image is **linux/amd64** only
(`ibm_db` has no linux/arm64 build). Keep the same volume across upgrades so saved
connections and the encryption key survive.
@@ -94,8 +101,7 @@ docker compose -f docker-compose.app.yml up -d
| `APP_DB_PATH` | `/data/foxschema.db` | SQLite file location (when `APP_DB_ENGINE=sqlite`). |
| `APP_DB_URL` | — | Connection URL for the metadata store when engine is `postgres`/`mysql`. |
| `APP_KEY_SCHEME` | `v1` | `v1` = key used directly. `v2` = key bound to `APP_USER_EMAIL` (anti-copy); leave `v1` for stateless servers. |
-| `LOCAL_SINGLE_USER` | `true` | `true` = no login (open, single user). `false` = real accounts. |
-| `AUTH_REQUIRED` | see note | When `LOCAL_SINGLE_USER=false`, defaults to **true** (login required). Set `AUTH_REQUIRED=false` only if you intentionally want open API access. |
+| `LOCAL_SINGLE_USER` | `true` | Whether this is a personal install (`true`) or a shared server (`false`). Sign-in is required either way; this only decides machine-level actions (installing drivers, self-update, changing the metadata DB, host cloud credentials), which a shared server refuses. |
| `SIGNUP_WEBHOOK_URL` | — | Optional. First-open email subscriber wizard posts here (WordPress `/foxschema/v1/signup`). Without it, subscribe still dismisses the wizard locally. |
| `SIGNUP_WEBHOOK_SECRET` | — | Optional shared secret sent as `X-Foxschema-Signup-Secret`. |
| `UPDATE_FEED_URL` | npm `foxschema/latest` | Version check for in-app update toasts. Default is the npm registry. The “What’s new” link opens the matching GitHub Release page. Set `off` to disable. |
@@ -160,9 +166,10 @@ otherwise subscribe still dismisses locally.
The upgrade migration marks the wizard as already shown, so people who already
use Fox are not interrupted. Only greenfield metadata DBs see the prompt.
-**RBAC upgrade:** existing `users` rows get `app_role = admin`. Default
-`LOCAL_SINGLE_USER=true` still means no login. Switching later to
-`LOCAL_SINGLE_USER=false` keeps those admins; new registrations become viewers.
+**Sign-in upgrade:** installs that ran without sign-in open on the setup screen,
+which claims the existing local account — nothing is lost. Deployments that
+already ran with `LOCAL_SINGLE_USER=false` keep their accounts and passwords and
+never show setup; their local account cannot be claimed.
**Query files / SQL Editor:** browser localStorage (`foxschema-sql-editor`) and
saved credentials keep working. New **Utilities → Query files** workspaces appear
@@ -222,18 +229,18 @@ databases you compare) defaults to a SQLite file on the **`/data` volume**.
Then you don't need the `/data` volume at all.
-## Access: single-user vs. multi-user + SSO
+## Access: sign-in + SSO
-**Default is open single-user** (`LOCAL_SINGLE_USER=true`, no login). This is fine for
-local use or a trusted network, **but do not expose it directly to the public internet
-— anyone who can reach the URL gets full access.** Put it behind a reverse proxy with
-its own authentication, a VPN, or your platform's access controls.
+**Every install requires sign-in.** Until the first admin exists the server is
+in setup, and anyone who completes setup owns it — which is why setup from
+another machine needs the one-time code from the server log. Complete setup
+before exposing a new deployment, and still keep internet-facing installs behind
+TLS and, ideally, a VPN or your platform's access controls.
-For a public deployment, enable real accounts + SSO:
+For a shared deployment, declare it and add SSO if you use one:
```bash
LOCAL_SINGLE_USER=false
-AUTH_REQUIRED=true
SSO_REDIRECT_BASE=https://fox.example.com # your public URL
# Enable one or more providers (both ID and SECRET required per provider):
@@ -247,6 +254,7 @@ SSO_MICROSOFT_TENANT=common
```
Set each provider's OAuth redirect/callback to `${SSO_REDIRECT_BASE}/api/auth/sso//callback`.
+SSO proves who someone is; an admin still has to add their account first.
**App Secrets / cloud credentials on multi-user hosts:** with `LOCAL_SINGLE_USER=false`,
resolving AWS/GCP/Azure secrets requires a saved credential under **Credentials → Cloud
diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md
index ac216725..841ea876 100644
--- a/docs/releases/UNRELEASED.md
+++ b/docs/releases/UNRELEASED.md
@@ -33,3 +33,27 @@ that capture, versions appear only for real changes.
Revert and force-migrate are not affected by the boundary: when two stored
versions' hashes differ, both sides are re-hashed with the current rule before
deciding whether an object changed.
+
+## Sign-in is required on every install
+
+Fox used to open straight into the workspace on a personal install, and a
+multi-user server let anyone register. Both are gone: **every install now asks
+for an email and password**, and only an administrator can create accounts.
+
+- **First launch** shows *Create the administrator account*. On an install used
+ before, this claims the existing local account, so saved connections, history
+ and workflows stay where they are. When the install is bound to an email
+ (`APP_USER_EMAIL`), that email is used.
+- **Setup code.** From the machine Fox runs on, setup needs only the password.
+ From anywhere else, including through a reverse proxy, setup also asks for a
+ one-time code that the server prints to its log (`docker logs ` on
+ Docker). Setup closes for good once an account can sign in.
+- **No self-registration.** `POST /api/auth/register` answers 403. Admins add
+ people from the **Add user** form in the admin Access panel with a starting password and a role.
+- **SSO signs in existing accounts only.** A first SSO sign-in no longer creates
+ an account; an admin adds the email first.
+- **Sign-in is rate-limited** to 20 attempts per 15 minutes per client.
+- A server that already ran with `LOCAL_SINGLE_USER=false` keeps its accounts and
+ is never offered setup. `AUTH_REQUIRED` is no longer read; `LOCAL_SINGLE_USER`
+ now only marks a personal install (machine-level actions such as driver
+ install and updates).
diff --git a/packages/server/src/api/deployment.ts b/packages/server/src/api/deployment.ts
index db002831..a6c07787 100644
--- a/packages/server/src/api/deployment.ts
+++ b/packages/server/src/api/deployment.ts
@@ -15,7 +15,13 @@
* module-load snapshot silently ignores them.
*/
-/** Default is single-user (no login). `LOCAL_SINGLE_USER=false` opts out. */
+/**
+ * A personal install (desktop app, `fox open`) rather than a shared server.
+ * `LOCAL_SINGLE_USER=false` declares a shared server.
+ *
+ * This no longer decides whether anyone signs in: every install does. It
+ * decides only which machine-level actions a signed-in user may take.
+ */
export function isLocalSingleUser(): boolean {
return process.env.LOCAL_SINGLE_USER !== 'false';
}
diff --git a/packages/server/src/api/http-contract.test.ts b/packages/server/src/api/http-contract.test.ts
index 2a8a9ea2..6be7b15b 100644
--- a/packages/server/src/api/http-contract.test.ts
+++ b/packages/server/src/api/http-contract.test.ts
@@ -32,6 +32,10 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
+
+// Its own metadata DB. It used to fall through to the developer's dev
+// database and write preferences and signup state into it.
+process.env.APP_DB_PATH = ':memory:';
import { isApiErrorBody } from '@foxschema/shared';
interface RouteExpectation {
@@ -53,6 +57,7 @@ const ROUTES: RouteExpectation[] = [
{ method: 'GET', path: '/api/admin/role-permissions', status: 200 },
{ method: 'PUT', path: '/api/admin/role-permissions/:role', status: 400 },
{ method: 'GET', path: '/api/admin/users', status: 200 },
+ { method: 'POST', path: '/api/admin/users', status: 400 },
{ method: 'PUT', path: '/api/admin/users/:id/active', status: 400 },
{ method: 'PUT', path: '/api/admin/users/:id/password', status: 400 },
{ method: 'PUT', path: '/api/admin/users/:id/role', status: 400 },
@@ -69,7 +74,9 @@ const ROUTES: RouteExpectation[] = [
{ method: 'POST', path: '/api/auth/login', status: 401 },
{ method: 'POST', path: '/api/auth/logout', status: 200 },
{ method: 'GET', path: '/api/auth/me', status: 200 },
- { method: 'POST', path: '/api/auth/register', status: 400 },
+ { method: 'POST', path: '/api/auth/register', status: 403 },
+ { method: 'GET', path: '/api/auth/setup', status: 200 },
+ { method: 'POST', path: '/api/auth/setup', status: 409 },
{ method: 'GET', path: '/api/auth/sso/:provider/callback', status: 302 },
{ method: 'GET', path: '/api/auth/sso/:provider/start', status: 404 },
{ method: 'GET', path: '/api/auth/sso/providers', status: 200 },
@@ -134,6 +141,20 @@ const ROUTES: RouteExpectation[] = [
const KEY = '0'.repeat(64);
+/**
+ * Routes that answer without a session. Everything else must refuse one:
+ * every install signs in, so a route missing its guard is a hole.
+ */
+const PUBLIC = [
+ /^\/api\/health$/,
+ /^\/api\/config$/,
+ /^\/api\/auth\//,
+ /^\/api\/signup/,
+];
+
+/** The session the probes run as: the admin first-run setup creates. */
+let sessionCookie = '';
+
function url(path: string): string {
return path.replace(/:(\w+)/g, '00000000-0000-0000-0000-000000000000');
}
@@ -153,12 +174,18 @@ interface Probe {
text: string;
}
-async function probe(port: number, route: RouteExpectation): Promise {
+async function probe(port: number, route: RouteExpectation, withSession = true): Promise {
const hasBody = ['POST', 'PUT', 'PATCH'].includes(route.method);
+ // Auth routes run without the session: logout would otherwise end it for
+ // every probe after it.
+ const headers: Record = {};
+ if (hasBody) headers['content-type'] = 'application/json';
+ if (withSession && !route.path.startsWith('/api/auth/')) headers.cookie = sessionCookie;
const res = await fetch(`http://127.0.0.1:${port}${url(route.path)}`, {
method: route.method,
redirect: 'manual',
- ...(hasBody ? { headers: { 'content-type': 'application/json' }, body: '{}' } : {}),
+ headers,
+ ...(hasBody ? { body: '{}' } : {}),
});
const text = await res.text();
let body: unknown = null;
@@ -175,9 +202,16 @@ describe('HTTP contract', () => {
let stop: () => Promise;
beforeAll(async () => {
- process.env.LOCAL_SINGLE_USER = 'true';
process.env.APP_ENCRYPTION_KEY ||= KEY;
({ port, stop } = await startFastify());
+ // First-run setup from this machine (no code needed) creates the admin.
+ const res = await fetch(`http://127.0.0.1:${port}/api/auth/setup`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ email: 'contract-admin@example.com', password: 'contract-pass-1' }),
+ });
+ expect(res.status, await res.clone().text()).toBe(200);
+ sessionCookie = (res.headers.get('set-cookie') ?? '').split(';')[0]!;
}, 120_000);
afterAll(async () => {
@@ -191,7 +225,10 @@ describe('HTTP contract', () => {
//
// 80 -> 81: POST /api/schema/table-insight, behind dbaUtilityLimiter and
// requirePermissions('editor.run') because it powers Data Peek.
- expect(ROUTES.length).toBe(81);
+ //
+ // 81 -> 84: POST /api/admin/users and GET/POST /api/auth/setup, when
+ // sign-in became mandatory and self-registration closed.
+ expect(ROUTES.length).toBe(84);
expect(new Set(ROUTES.map((r) => `${r.method} ${r.path}`)).size).toBe(ROUTES.length);
});
@@ -233,4 +270,18 @@ describe('HTTP contract', () => {
},
30_000
);
+
+ it.each(
+ ROUTES.filter((r) => !PUBLIC.some((p) => p.test(r.path))).map(
+ (r) => [`${r.method} ${r.path}`, r] as const
+ )
+ )(
+ '%s refuses a request with no session',
+ async (key, route) => {
+ const { status, body } = await probe(port, route, false);
+ expect(status, `${key} answered ${status} without a session`).toBe(401);
+ expect(isApiErrorBody(body)).toBe(true);
+ },
+ 30_000
+ );
});
diff --git a/packages/server/src/api/routes.ts b/packages/server/src/api/routes.ts
index fa49a037..a442dc7d 100644
--- a/packages/server/src/api/routes.ts
+++ b/packages/server/src/api/routes.ts
@@ -167,7 +167,7 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
});
// First-open email subscriber wizard lives on public /api/signup/* (see
- // signup.routes.ts) so it works before login when AUTH_REQUIRED=true.
+ // signup.routes.ts) so it works before sign-in.
// Non-secret info about where the app's metadata DB lives and how the
// credential-encryption key is bound — for the "Database & Security" settings
diff --git a/packages/server/src/api/server.ts b/packages/server/src/api/server.ts
index 2d397414..2a276a6e 100644
--- a/packages/server/src/api/server.ts
+++ b/packages/server/src/api/server.ts
@@ -18,7 +18,7 @@
* requests per user rather than per IP.
*/
import type { FastifyReply } from 'fastify';
-import type { AppRequest, NextFunction } from '../platform/http/types';
+import type { AppRequest } from '../platform/http/types';
import { ConnectionModule, ConnectionFactory } from '@foxschema/db';
import { AuthModule } from '../features/auth/auth.service';
import { ConnectionStore } from '../features/connections/connection-store.service';
@@ -26,7 +26,7 @@ import { sweepOrphanedUploadFiles } from '../features/files/file-session.service
import { UserModule } from '../features/users/user.service';
import { createApiRoutes } from './routes';
import { defaultApiRateLimit } from '../platform/guards/rate-limit';
-import { createAuthRoutes, authGuard, localUserGuard } from '../features/auth/auth.routes';
+import { createAuthRoutes, authGuard } from '../features/auth/auth.routes';
import { createSsoRoutes } from '../features/auth/sso.routes';
import { createConnectionStoreRoutes } from '../features/connections/connections.routes';
import { createAppSecretsRoutes } from '../features/admin/app-secrets.routes';
@@ -43,11 +43,6 @@ import { resolveAppVersion } from '../internal/updates.service';
import { asAppLogger, getLogger } from '../platform/logger/logger';
import { Router, type RouteDefinition } from '../platform/http/router';
-// Default to single-user (no login). Set LOCAL_SINGLE_USER=false to enable
-// multi-user auth. In multi-user mode AUTH_REQUIRED defaults to true (safe).
-const LOCAL_SINGLE_USER = process.env.LOCAL_SINGLE_USER !== 'false';
-const AUTH_REQUIRED = LOCAL_SINGLE_USER ? false : process.env.AUTH_REQUIRED !== 'false';
-
/** Largest request body the API accepts. Enforced by Fastify as bytes arrive. */
export const BODY_LIMIT = process.env.FOX_BODY_LIMIT || '10mb';
@@ -66,8 +61,10 @@ export function buildApiRoutes(): RouteDefinition[] {
res.send({ ok: true, version: resolveAppVersion() });
});
+ // Kept for clients built before sign-in became mandatory: every install now
+ // signs in. First-run setup state is GET /api/auth/setup.
root.get('/api/config', (_req: AppRequest, res: FastifyReply) => {
- res.send({ localSingleUser: LOCAL_SINGLE_USER });
+ res.send({ localSingleUser: false });
});
// Auth endpoints are public. SSO is mounted first so its sub-paths take
@@ -81,9 +78,10 @@ export function buildApiRoutes(): RouteDefinition[] {
// service token instead.
root.use(WORKFLOW_INTERNAL_PREFIX, createWorkflowInternalRoutes());
- // In local single-user mode (community desktop) the singleton local user is
- // attached automatically; otherwise per-user routes require a real session.
- const userGuard = LOCAL_SINGLE_USER ? localUserGuard(auth) : authGuard(auth);
+ // Every install requires sign-in, desktop and CLI included. There used to be
+ // a single-user mode that attached a built-in local user to every request;
+ // anyone who could reach the port was that user.
+ const userGuard = authGuard(auth);
const connectionStore = new ConnectionStore();
root.use('/api/connections', userGuard, createConnectionStoreRoutes(connectionStore));
@@ -95,11 +93,7 @@ export function buildApiRoutes(): RouteDefinition[] {
// CSV / JSON / fixed-width text → temp SQLite credential for SQL Editor.
root.use('/api/files', userGuard, createFileQueryRoutes(connectionStore));
- const guard = LOCAL_SINGLE_USER
- ? localUserGuard(auth)
- : AUTH_REQUIRED
- ? authGuard(auth)
- : (_req: AppRequest, _res: FastifyReply, next: NextFunction) => next();
+ const guard = userGuard;
// The guard runs first so the limiter can charge an authenticated user
// rather than lumping everyone behind one shared IP bucket.
root.use('/api', guard, defaultApiRateLimit(), createApiRoutes(connectionModule, connectionStore));
diff --git a/packages/server/src/database/schema.ts b/packages/server/src/database/schema.ts
index 488c3d67..38d677c3 100644
--- a/packages/server/src/database/schema.ts
+++ b/packages/server/src/database/schema.ts
@@ -452,6 +452,21 @@ const MIGRATIONS: Migration[] = [
];
},
},
+ {
+ id: 19,
+ name: 'users_password_set',
+ statements: (d) => {
+ const t = types(d);
+ return [
+ // Whether someone chose this account's password. Sign-in is now
+ // required everywhere; an install that ran without it holds one local
+ // account whose password nobody knows, and first-run setup lets its
+ // owner claim it. Existing rows start at 0 and become 1 on the first
+ // successful sign-in, so nothing here decides who may sign in.
+ `ALTER TABLE users ADD COLUMN password_set ${t.int} NOT NULL DEFAULT 0`,
+ ];
+ },
+ },
];
const SIGNUP_WIZARD_SHOWN_KEY = 'signup.wizard_shown';
diff --git a/packages/server/src/features/admin/admin.routes.ts b/packages/server/src/features/admin/admin.routes.ts
index ba170597..d9d76f04 100644
--- a/packages/server/src/features/admin/admin.routes.ts
+++ b/packages/server/src/features/admin/admin.routes.ts
@@ -3,7 +3,7 @@
* Copyright 2024-2026 Huy Phan
* SPDX-License-Identifier: Apache-2.0
*
- * Admin APIs: list/assign users roles, activate/deactivate, set passwords,
+ * Admin APIs: add users, assign roles, activate/deactivate, set passwords,
* configure role permission matrices.
*/
import type { FastifyReply } from 'fastify';
@@ -27,6 +27,37 @@ export function createAdminRoutes(rbac = new RbacModule(), auth = new AuthModule
}
);
+ /**
+ * Add an account. The only way a second person gets in: self-registration
+ * is closed, and SSO signs in existing accounts only.
+ */
+ router.post(
+ '/users',
+ requirePermissions('admin.users'),
+ async (req: AuthedRequest, res: FastifyReply) => {
+ const { email, password, role = 'viewer' } = (req.body ?? {}) as {
+ email?: unknown;
+ password?: unknown;
+ role?: unknown;
+ };
+ if (typeof email !== 'string' || typeof password !== 'string') {
+ sendError(res, 'invalid_input', 'email and password are required.');
+ return;
+ }
+ if (!isAppRole(role)) {
+ sendError(res, 'invalid_input', `role must be one of: ${APP_ROLES.join(', ')}`);
+ return;
+ }
+ try {
+ const user = await auth.createUser(email, password, role);
+ res.send({ user });
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : 'Could not add the account';
+ sendError(res, msg.includes('already exists') ? 'conflict' : 'invalid_input', msg);
+ }
+ }
+ );
+
router.put(
'/users/:id/role',
requirePermissions('admin.users'),
diff --git a/packages/server/src/features/admin/app-secrets.service.test.ts b/packages/server/src/features/admin/app-secrets.service.test.ts
index fb04ed77..8d867d6b 100644
--- a/packages/server/src/features/admin/app-secrets.service.test.ts
+++ b/packages/server/src/features/admin/app-secrets.service.test.ts
@@ -15,8 +15,8 @@ let alice: string;
let bob: string;
beforeAll(async () => {
- alice = (await auth.register('alice-secrets@example.com', 'password123')).user.id;
- bob = (await auth.register('bob-secrets@example.com', 'password123')).user.id;
+ alice = (await auth.createUser('alice-secrets@example.com', 'password123', 'viewer')).id;
+ bob = (await auth.createUser('bob-secrets@example.com', 'password123', 'viewer')).id;
});
describe('AppSecretsStore', () => {
diff --git a/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts b/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts
index f31f3cd5..20571202 100644
--- a/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts
+++ b/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts
@@ -13,7 +13,7 @@ const store = new CloudProviderCredentialsStore();
let alice: string;
beforeAll(async () => {
- alice = (await auth.register('alice-cloud-creds@example.com', 'password123')).user.id;
+ alice = (await auth.createUser('alice-cloud-creds@example.com', 'password123', 'viewer')).id;
});
describe('CloudProviderCredentialsStore', () => {
diff --git a/packages/server/src/features/auth/auth.routes.ts b/packages/server/src/features/auth/auth.routes.ts
index 974f1f58..2f4cdde8 100644
--- a/packages/server/src/features/auth/auth.routes.ts
+++ b/packages/server/src/features/auth/auth.routes.ts
@@ -9,6 +9,9 @@ import type { AppRequest, AuthedRequest, NextFunction } from '../../platform/htt
import { Router } from '../../platform/http/router';
import { AuthModule, SESSION_COOKIE, SESSION_MAX_AGE_MS, type AuthUser } from '../auth/auth.service';
import { sendError } from '../../platform/http/respond';
+import { rateLimit } from '../../platform/guards/rate-limit';
+import { getLogger } from '../../platform/logger/logger';
+import { isDirectLocalRequest, resetSetupCode, setupCode, setupCodeMatches } from './setup-code';
export type { AuthedRequest };
@@ -47,18 +50,49 @@ export function setSessionCookie(res: FastifyReply, token: string): void {
export function createAuthRoutes(auth: AuthModule): Router {
const router = Router();
- router.post('/register', async (req: AppRequest, res: FastifyReply) => {
- const { email, password } = req.body as { email: string; password: string };
+ // Sign-in is the only way in, so it is what gets guessed at. Per address,
+ // before any account exists to charge.
+ const signInLimiter = rateLimit({ name: 'sign-in', windowMs: 15 * 60 * 1000, max: 20 });
+
+ // No self-registration: an admin adds accounts (POST /api/admin/users).
+ router.post('/register', (_req: AppRequest, res: FastifyReply) => {
+ sendError(res, 'forbidden', 'Accounts are created by an administrator. Ask yours to add you.');
+ });
+
+ /**
+ * First-run setup state. Says whether setup is still open and whether this
+ * caller will need the setup code, so the sign-in screen can ask for it.
+ */
+ router.get('/setup', async (req: AppRequest, res: FastifyReply) => {
+ const state = await auth.setupState();
+ const codeRequired = state.setupRequired && !isDirectLocalRequest(req);
+ if (codeRequired) announceSetupCode();
+ res.send({ ...state, setupCodeRequired: codeRequired });
+ });
+
+ router.post('/setup', signInLimiter, async (req: AppRequest, res: FastifyReply) => {
+ const { email, password, code } = (req.body ?? {}) as { email?: string; password?: string; code?: string };
+ const state = await auth.setupState();
+ if (!state.setupRequired) {
+ sendError(res, 'conflict', 'Setup is already complete. Sign in instead.');
+ return;
+ }
+ if (!isDirectLocalRequest(req) && !setupCodeMatches(code)) {
+ announceSetupCode();
+ sendError(res, 'forbidden', 'Enter the setup code printed in the Fox server log.');
+ return;
+ }
try {
- const { user, token } = await auth.register(email, password);
+ const { user, token } = await auth.completeSetup(email ?? '', password ?? '');
+ resetSetupCode();
setSessionCookie(res, token);
res.send({ user });
} catch (error: unknown) {
- sendError(res, 'invalid_input', error instanceof Error ? error.message : 'Registration failed');
+ sendError(res, 'invalid_input', error instanceof Error ? error.message : 'Setup failed');
}
});
- router.post('/login', async (req: AppRequest, res: FastifyReply) => {
+ router.post('/login', signInLimiter, async (req: AppRequest, res: FastifyReply) => {
const { email, password } = req.body as { email: string; password: string };
try {
const { user, token } = await auth.login(email, password);
@@ -81,15 +115,9 @@ export function createAuthRoutes(auth: AuthModule): Router {
res.send({ user });
return;
}
- // Local single-user installs have no login cookie — return the singleton
- // admin so SPA boot does not 401 (which the browser logs as a console error
- // and breaks e2e "no SEVERE console errors" checks).
- if (process.env.LOCAL_SINGLE_USER !== 'false') {
- const local = await auth.ensureLocalUser();
- res.send({ user: local });
- return;
- }
- sendError(res, 'unauthenticated', 'Not authenticated');
+ // Nobody signed in is an answer, not an error: the app asks this on every
+ // boot, and a 401 here is logged by the browser as a failed request.
+ res.send({ user: null });
});
return router;
@@ -112,18 +140,14 @@ export function authGuard(auth: AuthModule) {
};
}
-/**
- * Local single-user guard: skips cookies/login and attaches the singleton
- * local user as admin so per-user routes work without an auth flow.
- */
-export function localUserGuard(auth: AuthModule) {
- return async (req: AuthedRequest, _res: FastifyReply, next: NextFunction) => {
- try {
- const user = await auth.ensureLocalUser();
- attachAuthUser(user, req);
- next();
- } catch (err) {
- next(err);
- }
- };
+let announced = false;
+
+/** Print the setup code to the server log, once, when someone may need it. */
+function announceSetupCode(): void {
+ if (announced) return;
+ announced = true;
+ getLogger().warn(
+ `First-run setup: enter code ${setupCode()} on the sign-in screen to create the admin account ` +
+ '(only needed when setting up from another machine).'
+ );
}
diff --git a/packages/server/src/features/auth/auth.service.test.ts b/packages/server/src/features/auth/auth.service.test.ts
index 7a1c87cf..47e2f29a 100644
--- a/packages/server/src/features/auth/auth.service.test.ts
+++ b/packages/server/src/features/auth/auth.service.test.ts
@@ -14,41 +14,35 @@ describe('AuthModule', () => {
await auth.getUserByToken('none');
});
- it('registers and auto-creates a session', async () => {
- const { user, token } = await auth.register('Alice@Example.com', 'password123');
- expect(user.email).toBe('alice@example.com'); // normalized
- expect(user.onboardingCompleted).toBe(false);
- // First account on a fresh install is admin with full permissions.
- expect(user.role).toBe('admin');
- expect(user.permissions.length).toBeGreaterThan(0);
- expect((await auth.getUserByToken(token))?.id).toBe(user.id);
- });
-
- it('assigns viewer to subsequent registrations', async () => {
- const { user } = await auth.register('viewer2@example.com', 'password123');
- expect(user.role).toBe('viewer');
- expect(user.permissions).not.toContain('admin.users');
- expect(user.permissions).toContain('editor.run');
+ it('an admin-created account can sign in, with the role it was given', async () => {
+ const created = await auth.createUser('Alice@Example.com', 'password123', 'viewer');
+ expect(created.email).toBe('alice@example.com'); // normalized
+ expect(created.role).toBe('viewer');
+ expect(created.permissions).not.toContain('admin.users');
+ expect(created.permissions).toContain('editor.run');
+ const { user, token } = await auth.login('alice@example.com', 'password123');
+ expect(user.id).toBe(created.id);
+ expect((await auth.getUserByToken(token))?.id).toBe(created.id);
});
it('rejects a duplicate email', async () => {
- await auth.register('dup@example.com', 'password123');
- await expect(auth.register('dup@example.com', 'password123')).rejects.toThrow(/already exists/);
+ await auth.createUser('dup@example.com', 'password123', 'viewer');
+ await expect(auth.createUser('dup@example.com', 'password123', 'viewer')).rejects.toThrow(/already exists/);
});
it('rejects weak passwords and bad emails', async () => {
- await expect(auth.register('a@b.com', 'short')).rejects.toThrow(/8 characters/);
- await expect(auth.register('not-an-email', 'password123')).rejects.toThrow(/valid email/);
+ await expect(auth.createUser('a@b.com', 'short', 'viewer')).rejects.toThrow(/8 characters/);
+ await expect(auth.createUser('not-an-email', 'password123', 'viewer')).rejects.toThrow(/valid email/);
});
it('logs in with correct credentials', async () => {
- await auth.register('bob@example.com', 'password123');
+ await auth.createUser('bob@example.com', 'password123', 'viewer');
const { user } = await auth.login('bob@example.com', 'password123');
expect(user.email).toBe('bob@example.com');
});
it('rejects wrong password and unknown user the same way', async () => {
- await auth.register('carol@example.com', 'password123');
+ await auth.createUser('carol@example.com', 'password123', 'viewer');
await expect(auth.login('carol@example.com', 'wrongpass')).rejects.toThrow(/Invalid email or password/);
await expect(auth.login('ghost@example.com', 'password123')).rejects.toThrow(/Invalid email or password/);
});
@@ -57,7 +51,7 @@ describe('AuthModule', () => {
// toAuthUser. Dropping that column anywhere fails silently as a demotion to
// viewer, so pin the role across login and the per-request token lookup.
it('preserves the stored role through login and getUserByToken', async () => {
- const { user: created } = await auth.register('editorrole@example.com', 'password123');
+ const created = await auth.createUser('editorrole@example.com', 'password123', 'viewer');
await new RbacModule().setUserRole(created.id, 'editor');
const { user, token } = await auth.login('editorrole@example.com', 'password123');
@@ -72,23 +66,24 @@ describe('AuthModule', () => {
expect(resolved?.permissions).not.toContain('admin.users');
});
- it('ensureLocalUser returns an admin with full permissions', async () => {
- const local = await auth.ensureLocalUser();
- expect(local.role).toBe('admin');
- expect(local.permissions).toContain('admin.users');
- // Idempotent: a second call re-resolves the same singleton as admin.
- expect((await auth.ensureLocalUser()).id).toBe(local.id);
+ it('SSO signs in an existing account and never creates one', async () => {
+ const created = await auth.createUser('sso-user@example.com', 'password123', 'editor');
+ const { user } = await auth.loginWithEmail('SSO-User@example.com');
+ expect(user.id).toBe(created.id);
+ await expect(auth.loginWithEmail('stranger@example.com')).rejects.toThrow(/Ask an administrator/);
});
it('invalidates the session on logout', async () => {
- const { token } = await auth.register('dave@example.com', 'password123');
+ await auth.createUser('dave@example.com', 'password123', 'viewer');
+ const { token } = await auth.login('dave@example.com', 'password123');
expect(await auth.getUserByToken(token)).not.toBeNull();
await auth.logout(token);
expect(await auth.getUserByToken(token)).toBeNull();
});
it('rejects login for deactivated users', async () => {
- const { user, token } = await auth.register('inactive@example.com', 'password123');
+ await auth.createUser('inactive@example.com', 'password123', 'viewer');
+ const { user, token } = await auth.login('inactive@example.com', 'password123');
await new RbacModule().setUserActive(user.id, false);
await expect(auth.login('inactive@example.com', 'password123')).rejects.toThrow(
/deactivated/
@@ -97,7 +92,8 @@ describe('AuthModule', () => {
});
it('adminSetPassword updates credentials and clears sessions', async () => {
- const { user, token } = await auth.register('pwreset@example.com', 'password123');
+ await auth.createUser('pwreset@example.com', 'password123', 'viewer');
+ const { user, token } = await auth.login('pwreset@example.com', 'password123');
await auth.adminSetPassword(user.id, 'newpassword99');
expect(await auth.getUserByToken(token)).toBeNull();
await expect(auth.login('pwreset@example.com', 'password123')).rejects.toThrow(
@@ -108,7 +104,7 @@ describe('AuthModule', () => {
});
it('adminSetPassword rejects short passwords', async () => {
- const { user } = await auth.register('pwshort@example.com', 'password123');
+ const user = await auth.createUser('pwshort@example.com', 'password123', 'viewer');
await expect(auth.adminSetPassword(user.id, 'short')).rejects.toThrow(/8 characters/);
});
});
diff --git a/packages/server/src/features/auth/auth.service.ts b/packages/server/src/features/auth/auth.service.ts
index 32e36dcd..f4b9a755 100644
--- a/packages/server/src/features/auth/auth.service.ts
+++ b/packages/server/src/features/auth/auth.service.ts
@@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto';
import { getStore } from '../../database/store';
import { hashPassword, verifyPassword, newToken } from '../../platform/crypto/crypto';
import { RbacModule, toAppRole } from '../authorization/rbac.service';
-import type { MetadataStore } from '../../database/stores/types';
import type { AppRole, Permission } from '@foxschema/shared';
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
@@ -47,10 +46,31 @@ function assertUserActive(row: UserRow): void {
}
}
-/** First account on the install becomes admin; later signups default to viewer. */
-async function nextSignupRole(store: MetadataStore): Promise {
- const countRow = await store.get<{ n: number }>('SELECT COUNT(*) AS n FROM users');
- return Number(countRow?.n ?? 0) === 0 ? 'admin' : 'viewer';
+/** The account an install that ran without sign-in stored everything under. */
+const LEGACY_LOCAL_EMAIL = 'local@foxschema.app';
+
+/** The email this install is bound to (`fox setup`), if any. */
+function boundEmail(): string {
+ return (process.env.APP_USER_EMAIL || '').trim().toLowerCase();
+}
+
+/**
+ * Whether this deployment ran multi-user before sign-in became mandatory.
+ *
+ * Those installs already have real accounts with passwords. Their local
+ * account is never claimable: setup only runs there on an empty users table.
+ */
+function explicitMultiUser(): boolean {
+ return process.env.LOCAL_SINGLE_USER === 'false';
+}
+
+let setupChain: Promise = Promise.resolve();
+
+export interface SetupState {
+ /** No account can sign in yet: first-run setup must create or claim one. */
+ setupRequired: boolean;
+ /** Email the setup account must use (the install's bound email), if any. */
+ setupEmail: string | null;
}
export class AuthModule {
@@ -67,8 +87,11 @@ export class AuthModule {
};
}
- /** Create an account and start a session (register auto-logs-in). */
- async register(email: string, password: string): Promise<{ user: AuthUser; token: string }> {
+ /**
+ * An admin adds an account. There is no self-registration: once the first
+ * admin exists, people get in only when an admin adds them.
+ */
+ async createUser(email: string, password: string, role: AppRole): Promise {
validateCredentials(email, password);
const store = await getStore();
const normalized = email.trim().toLowerCase();
@@ -76,19 +99,79 @@ export class AuthModule {
const existing = await store.get('SELECT id FROM users WHERE email = ?', [normalized]);
if (existing) throw new Error('An account with this email already exists.');
- const role = await nextSignupRole(store);
const id = randomUUID();
await store.run(
- 'INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, ?)',
+ 'INSERT INTO users (id, email, password_hash, created_at, app_role, password_set) VALUES (?, ?, ?, ?, ?, 1)',
[id, normalized, hashPassword(password), new Date().toISOString(), role]
);
+ return this.toAuthUser({ id, email: normalized, onboarding_completed: 0, app_role: role });
+ }
+
+ /** Whether first-run setup is still open, and which email it must use. */
+ async setupState(): Promise {
+ const store = await getStore();
+ const counts = await store.get<{ total: number; usable: number }>(
+ 'SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN password_set = 1 THEN 1 ELSE 0 END), 0) AS usable FROM users'
+ );
+ const total = Number(counts?.total ?? 0);
+ const usable = Number(counts?.usable ?? 0);
+ const setupRequired = usable === 0 && (total === 0 || !explicitMultiUser());
+ return { setupRequired, setupEmail: boundEmail() || null };
+ }
- const user = await this.toAuthUser({
- id,
- email: normalized,
- onboarding_completed: 0,
- app_role: role,
- });
+ /**
+ * First-run setup: the first admin account, and a session for it.
+ *
+ * An install that ran without sign-in stored its connections and history
+ * under one local account; that account is claimed (given this email and
+ * password) rather than replaced, so nothing is left behind. Otherwise a new
+ * admin is created. Refused once any account can sign in.
+ */
+ async completeSetup(email: string, password: string): Promise<{ user: AuthUser; token: string }> {
+ // One at a time: two requests racing through the "still open?" check would
+ // otherwise both succeed, and the second would own the install.
+ const run = setupChain.then(() => this.runSetup(email, password));
+ setupChain = run.catch(() => undefined);
+ return run;
+ }
+
+ private async runSetup(email: string, password: string): Promise<{ user: AuthUser; token: string }> {
+ const bound = boundEmail();
+ const normalized = (bound || email || '').trim().toLowerCase();
+ validateCredentials(normalized, password);
+ if (!(await this.setupState()).setupRequired) {
+ throw new Error('Setup is already complete. Sign in instead.');
+ }
+ const store = await getStore();
+ const findByEmail = (e: string) =>
+ store.get(
+ 'SELECT id, email, onboarding_completed, app_role, active FROM users WHERE email = ?',
+ [e]
+ );
+ const local =
+ (bound ? await findByEmail(bound) : undefined) ?? (await findByEmail(LEGACY_LOCAL_EMAIL));
+ const clash = await findByEmail(normalized);
+ if (clash && (!local || clash.id !== local.id)) {
+ throw new Error('An account with this email already exists.');
+ }
+
+ let id: string;
+ let onboarded = 0;
+ if (local) {
+ id = local.id;
+ onboarded = local.onboarding_completed;
+ await store.run(
+ "UPDATE users SET email = ?, password_hash = ?, app_role = 'admin', active = 1, password_set = 1 WHERE id = ?",
+ [normalized, hashPassword(password), id]
+ );
+ } else {
+ id = randomUUID();
+ await store.run(
+ "INSERT INTO users (id, email, password_hash, created_at, app_role, password_set) VALUES (?, ?, ?, ?, 'admin', 1)",
+ [id, normalized, hashPassword(password), new Date().toISOString()]
+ );
+ }
+ const user = await this.toAuthUser({ id, email: normalized, onboarding_completed: onboarded, app_role: 'admin' });
return { user, token: await this.createSession(id) };
}
@@ -105,6 +188,8 @@ export class AuthModule {
throw new Error('Invalid email or password.');
}
assertUserActive(row);
+ // Someone knows this password, so the install is past first-run setup.
+ await store.run('UPDATE users SET password_set = 1 WHERE id = ? AND password_set = 0', [row.id]);
return { user: await this.toAuthUser(row), token: await this.createSession(row.id) };
}
@@ -119,7 +204,7 @@ export class AuthModule {
const store = await getStore();
const exists = await store.get('SELECT id FROM users WHERE id = ?', [userId]);
if (!exists) throw new Error('User not found.');
- await store.run('UPDATE users SET password_hash = ? WHERE id = ?', [
+ await store.run('UPDATE users SET password_hash = ?, password_set = 1 WHERE id = ?', [
hashPassword(password),
userId,
]);
@@ -127,58 +212,51 @@ export class AuthModule {
}
/**
- * Log in via a verified external identity (SSO): find the user by email or
- * create a passwordless account, then start a session.
+ * Log in via a verified external identity (SSO). The identity provider
+ * proves who someone is; whether they may use this install is still an
+ * admin's decision, so only an existing account signs in.
*/
async loginWithEmail(email: string): Promise<{ user: AuthUser; token: string }> {
const store = await getStore();
const normalized = (email ?? '').trim().toLowerCase();
if (!normalized.includes('@')) throw new Error('SSO did not return a valid email.');
- let row = await store.get(
+ const row = await store.get(
'SELECT id, email, onboarding_completed, app_role, active FROM users WHERE email = ?',
[normalized]
);
if (!row) {
- const role = await nextSignupRole(store);
- const id = randomUUID();
- await store.run(
- 'INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, ?)',
- [id, normalized, hashPassword(randomUUID()), new Date().toISOString(), role]
- );
- row = { id, email: normalized, onboarding_completed: 0, app_role: role, active: 1 };
+ throw new Error(`No account for ${normalized}. Ask an administrator to add you.`);
}
assertUserActive(row);
return { user: await this.toAuthUser(row), token: await this.createSession(row.id) };
}
/**
- * Local single-user mode: return the singleton local user (always admin).
+ * The install's owner, for the CLI.
+ *
+ * The CLI works on the metadata database directly, so whoever can run it
+ * already holds the data; it acts as the owner rather than signing in. It
+ * must resolve to the same account the app's first-run setup claims, or the
+ * CLI and the app would each see their own connections and history: the
+ * bound email, else the legacy local account, else the earliest admin. Only
+ * a brand-new install gets a fresh local account, which setup then claims.
*/
- async ensureLocalUser(): Promise {
+ async ownerAccount(): Promise {
const store = await getStore();
- const boundEmail = (process.env.APP_USER_EMAIL || '').trim().toLowerCase();
- const email = boundEmail || 'local@foxschema.app';
- const find = (e: string) =>
- store.get(
- 'SELECT id, email, onboarding_completed, app_role, active FROM users WHERE email = ?',
- [e]
- );
- const existing =
- (await find(email)) || (boundEmail ? await find('local@foxschema.app') : undefined);
- if (existing) {
- if (existing.app_role !== 'admin') {
- await store.run("UPDATE users SET app_role = 'admin' WHERE id = ?", [existing.id]);
- }
- // Local singleton stays usable even if accidentally deactivated in Access UI.
- if (existing.active === 0) {
- await store.run('UPDATE users SET active = 1 WHERE id = ?', [existing.id]);
- }
- return this.toAuthUser({ ...existing, app_role: 'admin', active: 1 });
- }
+ const select = 'SELECT id, email, onboarding_completed, app_role, active FROM users';
+ const bound = boundEmail();
+ const row =
+ (bound ? await store.get(`${select} WHERE email = ?`, [bound]) : undefined) ??
+ (await store.get(`${select} WHERE email = ?`, [LEGACY_LOCAL_EMAIL])) ??
+ (await store.get(
+ `${select} WHERE app_role = 'admin' AND (active IS NULL OR active = 1) ORDER BY created_at LIMIT 1`
+ ));
+ if (row) return this.toAuthUser(row);
const id = randomUUID();
+ const email = bound || LEGACY_LOCAL_EMAIL;
await store.run(
- 'INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, ?)',
- [id, email, hashPassword(randomUUID()), new Date().toISOString(), 'admin']
+ "INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, 'admin')",
+ [id, email, hashPassword(randomUUID()), new Date().toISOString()]
);
return this.toAuthUser({ id, email, onboarding_completed: 0, app_role: 'admin' });
}
diff --git a/packages/server/src/features/auth/auth.setup.test.ts b/packages/server/src/features/auth/auth.setup.test.ts
new file mode 100644
index 00000000..cdcf5ee5
--- /dev/null
+++ b/packages/server/src/features/auth/auth.setup.test.ts
@@ -0,0 +1,125 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * First-run setup, now that every install signs in.
+ *
+ * An install that ran without sign-in kept everything under one local account
+ * whose password nobody knows. Setup must hand that account to its owner (so
+ * saved connections and history survive), create the first admin on a fresh
+ * install, and never open again once anyone can sign in.
+ */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+process.env.APP_DB_PATH = ':memory:';
+
+import { AuthModule } from './auth.service';
+import { getStore } from '../../database/store';
+
+const auth = new AuthModule();
+
+async function clearUsers(): Promise {
+ const store = await getStore();
+ await store.run('DELETE FROM sessions');
+ await store.run('DELETE FROM users');
+}
+
+/** A row as the old single-user mode created it: random password, never set. */
+async function legacyLocalUser(email = 'local@foxschema.app'): Promise {
+ const store = await getStore();
+ const id = `legacy-${email}`;
+ await store.run(
+ "INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, 'x', ?, 'admin')",
+ [id, email, new Date().toISOString()]
+ );
+ return id;
+}
+
+beforeEach(async () => {
+ await auth.getUserByToken('none'); // run migrations
+ await clearUsers();
+ delete process.env.APP_USER_EMAIL;
+ delete process.env.LOCAL_SINGLE_USER;
+});
+afterEach(() => {
+ delete process.env.APP_USER_EMAIL;
+ delete process.env.LOCAL_SINGLE_USER;
+});
+
+describe('first-run setup', () => {
+ it('is required on a fresh install and creates the first admin', async () => {
+ expect((await auth.setupState()).setupRequired).toBe(true);
+ const { user, token } = await auth.completeSetup('Owner@Example.com', 'password123');
+ expect(user.email).toBe('owner@example.com');
+ expect(user.role).toBe('admin');
+ expect((await auth.getUserByToken(token))?.id).toBe(user.id);
+ expect((await auth.setupState()).setupRequired).toBe(false);
+ });
+
+ it('claims the legacy local account instead of replacing it', async () => {
+ const legacyId = await legacyLocalUser();
+ expect((await auth.setupState()).setupRequired).toBe(true);
+ const { user } = await auth.completeSetup('owner@example.com', 'password123');
+ // Same row: everything saved under the old local account now belongs to the owner.
+ expect(user.id).toBe(legacyId);
+ expect(user.email).toBe('owner@example.com');
+ const { user: signedIn } = await auth.login('owner@example.com', 'password123');
+ expect(signedIn.id).toBe(legacyId);
+ });
+
+ it('uses the email the install is bound to, whatever is typed', async () => {
+ process.env.APP_USER_EMAIL = 'bound@example.com';
+ const legacyId = await legacyLocalUser('bound@example.com');
+ expect((await auth.setupState()).setupEmail).toBe('bound@example.com');
+ const { user } = await auth.completeSetup('someone-else@example.com', 'password123');
+ expect(user.id).toBe(legacyId);
+ expect(user.email).toBe('bound@example.com');
+ });
+
+ it('closes for good once any account can sign in', async () => {
+ await auth.completeSetup('owner@example.com', 'password123');
+ await expect(auth.completeSetup('intruder@example.com', 'password123')).rejects.toThrow(
+ /already complete/
+ );
+ });
+
+ it('only one of two racing setups succeeds', async () => {
+ const results = await Promise.allSettled([
+ auth.completeSetup('first@example.com', 'password123'),
+ auth.completeSetup('second@example.com', 'password123'),
+ ]);
+ expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
+ });
+
+ it('never claims an account on a deployment that already ran multi-user', async () => {
+ // Real accounts with real passwords exist there; none has signed in since
+ // the upgrade, so password_set is still 0 for all of them.
+ process.env.LOCAL_SINGLE_USER = 'false';
+ await legacyLocalUser('admin@corp.example');
+ expect((await auth.setupState()).setupRequired).toBe(false);
+ await expect(auth.completeSetup('attacker@example.com', 'password123')).rejects.toThrow(
+ /already complete/
+ );
+ });
+
+ it('the CLI and the app resolve the same owner account, before and after setup', async () => {
+ const fresh = await auth.ownerAccount();
+ expect(fresh.email).toBe('local@foxschema.app');
+ const { user } = await auth.completeSetup('owner@example.com', 'password123');
+ // Setup claimed the account the CLI created, and the CLI still finds it
+ // under its new email.
+ expect(user.id).toBe(fresh.id);
+ expect((await auth.ownerAccount()).id).toBe(fresh.id);
+ });
+
+ it('a successful sign-in by an existing account also closes setup', async () => {
+ const created = await auth.createUser('existing@example.com', 'password123', 'admin');
+ const store = await getStore();
+ // As after the migration: the flag starts at 0 on rows that predate it.
+ await store.run('UPDATE users SET password_set = 0 WHERE id = ?', [created.id]);
+ expect((await auth.setupState()).setupRequired).toBe(true);
+ await auth.login('existing@example.com', 'password123');
+ expect((await auth.setupState()).setupRequired).toBe(false);
+ });
+});
diff --git a/packages/server/src/features/auth/setup-code.ts b/packages/server/src/features/auth/setup-code.ts
new file mode 100644
index 00000000..0db033fb
--- /dev/null
+++ b/packages/server/src/features/auth/setup-code.ts
@@ -0,0 +1,56 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Who may run first-run setup.
+ *
+ * Setup creates (or claims) the first admin account, so on a freshly started
+ * server whoever reaches it first owns the install. Someone at the machine is
+ * the owner by definition; anyone else has to prove access to the machine by
+ * reading a one-time code from the server's own log.
+ */
+import { randomBytes, timingSafeEqual } from 'node:crypto';
+import type { AppRequest } from '../../platform/http/types';
+
+let code: string | undefined;
+
+/** The one-time setup code for this process, generated on first use. */
+export function setupCode(): string {
+ if (!code) {
+ // 40 bits, grouped for reading off a terminal: ABCD-EFGH.
+ const raw = randomBytes(5).toString('hex').toUpperCase().slice(0, 8);
+ code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
+ }
+ return code;
+}
+
+/** Forget the code (after setup succeeds, and between tests). */
+export function resetSetupCode(): void {
+ code = undefined;
+}
+
+const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
+
+/**
+ * True when the request comes from this machine directly.
+ *
+ * The raw socket address, never `req.ip`: the server trusts proxy headers, so
+ * `req.ip` is whatever `X-Forwarded-For` says. And a request that carries
+ * forwarding headers is not treated as local even from a loopback socket: a
+ * reverse proxy on the same host makes every visitor look local.
+ */
+export function isDirectLocalRequest(req: AppRequest): boolean {
+ const address = req.raw?.socket?.remoteAddress ?? '';
+ if (!LOOPBACK.has(address)) return false;
+ const headers = req.headers ?? {};
+ return !headers['x-forwarded-for'] && !headers.forwarded && !headers['x-real-ip'];
+}
+
+/** Whether `supplied` matches this process's setup code (case-insensitive). */
+export function setupCodeMatches(supplied: unknown): boolean {
+ if (typeof supplied !== 'string') return false;
+ const want = Buffer.from(setupCode());
+ const got = Buffer.from(supplied.trim().toUpperCase());
+ return got.length === want.length && timingSafeEqual(got, want);
+}
diff --git a/packages/server/src/features/auth/setup.routes.test.ts b/packages/server/src/features/auth/setup.routes.test.ts
new file mode 100644
index 00000000..1c3a06fe
--- /dev/null
+++ b/packages/server/src/features/auth/setup.routes.test.ts
@@ -0,0 +1,124 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * First-run setup and account creation over HTTP, on a real listener.
+ *
+ * Setup makes whoever completes it the admin, so the protection is the point:
+ * from anywhere but this machine it needs the code printed in the server log,
+ * a proxy header never makes a request look local, it runs once, and after it
+ * only an admin can let anyone else in.
+ */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+
+process.env.APP_DB_PATH = ':memory:';
+process.env.APP_ENCRYPTION_KEY ||= '0'.repeat(64);
+
+import { setupCode } from './setup-code';
+
+let app: FastifyInstance;
+let base = '';
+
+async function call(
+ method: string,
+ path: string,
+ body?: unknown,
+ headers: Record = {}
+): Promise<{ status: number; json: any; cookie: string }> {
+ const res = await fetch(`${base}${path}`, {
+ method,
+ headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...headers },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ const text = await res.text();
+ let json: unknown = null;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ /* not JSON */
+ }
+ return { status: res.status, json, cookie: (res.headers.get('set-cookie') ?? '').split(';')[0]! };
+}
+
+/** Looks like it came through a reverse proxy on this host. */
+const PROXIED = { 'x-forwarded-for': '203.0.113.7' };
+
+beforeAll(async () => {
+ const { createFastifyApp } = await import('../../api/fastify-server');
+ app = await createFastifyApp({});
+ await app.listen({ port: 0, host: '127.0.0.1' });
+ base = `http://127.0.0.1:${(app.server.address() as { port: number }).port}/api`;
+}, 120_000);
+
+afterAll(async () => {
+ await app?.close();
+});
+
+describe('first-run setup over HTTP', () => {
+ let adminCookie = '';
+
+ it('nobody signed in is an answer, not an error', async () => {
+ const me = await call('GET', '/auth/me');
+ expect(me.status).toBe(200);
+ expect(me.json).toEqual({ user: null });
+ });
+
+ it('asks a proxied caller for the setup code, and not a local one', async () => {
+ expect((await call('GET', '/auth/setup')).json).toMatchObject({
+ setupRequired: true,
+ setupCodeRequired: false,
+ });
+ expect((await call('GET', '/auth/setup', undefined, PROXIED)).json).toMatchObject({
+ setupRequired: true,
+ setupCodeRequired: true,
+ });
+ });
+
+ it('refuses a proxied setup without the right code', async () => {
+ const creds = { email: 'owner@example.com', password: 'owner-pass-1' };
+ expect((await call('POST', '/auth/setup', creds, PROXIED)).status).toBe(403);
+ expect((await call('POST', '/auth/setup', { ...creds, code: 'AAAA-AAAA' }, PROXIED)).status).toBe(403);
+ });
+
+ it('accepts a proxied setup with the code from the log, once', async () => {
+ const creds = { email: 'owner@example.com', password: 'owner-pass-1', code: setupCode().toLowerCase() };
+ const done = await call('POST', '/auth/setup', creds, PROXIED);
+ expect(done.status).toBe(200);
+ expect(done.json.user.role).toBe('admin');
+ adminCookie = done.cookie;
+
+ const again = await call('POST', '/auth/setup', { email: 'late@example.com', password: 'late-pass-11' });
+ expect(again.status).toBe(409);
+ expect((await call('GET', '/auth/setup')).json.setupRequired).toBe(false);
+ });
+
+ it('keeps self-registration closed', async () => {
+ const res = await call('POST', '/auth/register', { email: 'walk-in@example.com', password: 'walk-in-pass' });
+ expect(res.status).toBe(403);
+ });
+
+ it('lets an admin add an account that can then sign in, and nobody else add one', async () => {
+ const added = await call(
+ 'POST',
+ '/admin/users',
+ { email: 'teammate@example.com', password: 'teammate-pass', role: 'viewer' },
+ { cookie: adminCookie }
+ );
+ expect(added.status).toBe(200);
+ expect(added.json.user.role).toBe('viewer');
+
+ const login = await call('POST', '/auth/login', { email: 'teammate@example.com', password: 'teammate-pass' });
+ expect(login.status).toBe(200);
+
+ const byViewer = await call(
+ 'POST',
+ '/admin/users',
+ { email: 'another@example.com', password: 'another-pass', role: 'admin' },
+ { cookie: login.cookie }
+ );
+ expect(byViewer.status).toBe(403);
+ expect((await call('POST', '/admin/users', { email: 'x@example.com', password: 'xxxxxxxx' })).status).toBe(401);
+ });
+});
diff --git a/packages/server/src/features/connections/connection-store.service.test.ts b/packages/server/src/features/connections/connection-store.service.test.ts
index 8856122c..4e2deaa0 100644
--- a/packages/server/src/features/connections/connection-store.service.test.ts
+++ b/packages/server/src/features/connections/connection-store.service.test.ts
@@ -15,8 +15,8 @@ let alice: string;
let bob: string;
beforeAll(async () => {
- alice = (await auth.register('alice@example.com', 'password123')).user.id;
- bob = (await auth.register('bob@example.com', 'password123')).user.id;
+ alice = (await auth.createUser('alice@example.com', 'password123', 'viewer')).id;
+ bob = (await auth.createUser('bob@example.com', 'password123', 'viewer')).id;
});
const sample = {
diff --git a/packages/server/src/features/users/signup-wizard.routes.ts b/packages/server/src/features/users/signup-wizard.routes.ts
index fbcdb47f..3b944334 100644
--- a/packages/server/src/features/users/signup-wizard.routes.ts
+++ b/packages/server/src/features/users/signup-wizard.routes.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*
* Public first-open email subscriber wizard (no login required).
- * Mounted before authGuard so it still appears when AUTH_REQUIRED=true.
+ * Mounted before authGuard so it still appears before sign-in.
*/
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
diff --git a/packages/server/src/features/users/user.service.test.ts b/packages/server/src/features/users/user.service.test.ts
index cd7d0dc5..28f5f075 100644
--- a/packages/server/src/features/users/user.service.test.ts
+++ b/packages/server/src/features/users/user.service.test.ts
@@ -11,7 +11,7 @@ const users = new UserModule();
let userId: string;
beforeAll(async () => {
- userId = (await auth.register('pref@example.com', 'password123')).user.id;
+ userId = (await auth.createUser('pref@example.com', 'password123', 'viewer')).id;
});
describe('UserModule preferences', () => {
diff --git a/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts b/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts
index e4a5d988..54f71234 100644
--- a/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts
+++ b/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts
@@ -31,8 +31,8 @@ const saved = (password?: string) => ({
});
beforeAll(async () => {
- alice = (await auth.register('grant-alice@example.com', 'password123')).user.id;
- bob = (await auth.register('grant-bob@example.com', 'password123')).user.id;
+ alice = (await auth.createUser('grant-alice@example.com', 'password123', 'viewer')).id;
+ bob = (await auth.createUser('grant-bob@example.com', 'password123', 'viewer')).id;
});
describe('WorkflowConnectionGrants', () => {