Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/e2e-cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 0 additions & 1 deletion apps/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,6 @@ export async function runOpen(opts: OpenOptions = {}): Promise<void> {
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',
Expand Down
1 change: 0 additions & 1 deletion apps/cli/src/runtime/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
3 changes: 0 additions & 3 deletions apps/cli/src/runtime/ensureUiEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
Expand All @@ -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' };
}
}
Expand All @@ -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';

Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/runtime/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliContext> {
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;
}
9 changes: 9 additions & 0 deletions apps/e2e/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
103 changes: 103 additions & 0 deletions apps/e2e/src/helpers/app-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* 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<boolean> {
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<Response> {
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<string> {
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<void> {
const [name, ...rest] = (await sessionCookie()).split('=');
await page.context().addCookies([{ name: name!, value: rest.join('='), url: baseUrl }]);
}
5 changes: 4 additions & 1 deletion apps/e2e/src/helpers/driver.ts
Original file line number Diff line number Diff line change
@@ -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<Page, Browser>();

/** 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<Page> {
const headless = process.env.HEADLESS !== 'false';
const browser = await chromium.launch({
Expand All @@ -18,6 +19,8 @@ export async function buildDriver(): Promise<Page> {
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;
}

Expand Down
8 changes: 5 additions & 3 deletions apps/e2e/src/helpers/sql-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }>)
Expand All @@ -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++;
Expand Down
6 changes: 3 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ const App: React.FC = () => {
</div>
);
}
if (status === 'anon') return <AuthPage />;
if (status === 'anon' || status === 'setup') return <AuthPage />;
if (status === 'onboarding') return <OnboardingWizard />;
return <Workspace />;
};
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/frontend/app/shell/ActivityRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ describe('ActivityRail', () => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
localSingleUser: true,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/frontend/app/shell/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ describe('CommandPalette', () => {
permissions: [...DEFAULT_ROLE_PERMISSIONS.owner],
},
status: 'ready',
localSingleUser: true,
error: null,
busy: false,
refreshMe: vi.fn(async () => {}),
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/frontend/app/shell/ProfileMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ beforeEach(() => {
permissions: [],
},
status: 'ready',
localSingleUser: true,
error: null,
busy: false,
});
Expand All @@ -58,7 +57,6 @@ describe('ProfileMenu', () => {
role: 'editor',
permissions: [...DEFAULT_ROLE_PERMISSIONS.editor],
},
localSingleUser: false,
});
render(<ProfileMenu />);
fireEvent.click(screen.getByTestId('profile-menu-trigger'));
Expand Down
18 changes: 8 additions & 10 deletions apps/web/src/frontend/app/shell/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -117,15 +117,13 @@ export function ProfileMenu(): React.ReactElement | null {
<Globe className="w-4 h-4" /> foxschema.com
</a>

{!localSingleUser && (
<button
type="button"
onClick={logout}
className="w-full flex items-center gap-2.5 px-4 py-3 text-sm font-bold text-rose-400 hover:text-rose-300 hover:bg-rose-950/30 transition cursor-pointer border-t border-slate-800 bg-slate-950/40"
>
<LogOut className="w-4 h-4" /> Sign out
</button>
)}
<button
type="button"
onClick={logout}
className="w-full flex items-center gap-2.5 px-4 py-3 text-sm font-bold text-rose-400 hover:text-rose-300 hover:bg-rose-950/30 transition cursor-pointer border-t border-slate-800 bg-slate-950/40"
>
<LogOut className="w-4 h-4" /> Sign out
</button>
</div>,
document.body
)
Expand Down
Loading
Loading