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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,11 @@ AUTH_REQUIRED=false
# SSO_MICROSOFT_CLIENT_ID=
# SSO_MICROSOFT_CLIENT_SECRET=
# SSO_MICROSOFT_TENANT=common

# ── First-run signup wizard (optional) ──
# On first open, the app offers a skippable "stay in the loop" email prompt.
# Left unset, the wizard still shows but any submission just dismisses itself
# without being sent anywhere (fine for local dev). foxschema.com's own build
# points this at its WordPress signup webhook.
# SIGNUP_WEBHOOK_URL=https://foxschema.com/wp-json/foxschema/v1/signup
# SIGNUP_WEBHOOK_SECRET=
80 changes: 80 additions & 0 deletions apps/web/src/backend/api/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from 'vitest';
import type { Request, Response } from 'express';
import { rateLimit } from './rate-limit';

/** Minimal Express req/res doubles for driving the middleware. */
function reqFor(ip: string): Request {
return { ip } as unknown as Request;
}
function resDouble(): Response & { statusCode?: number; body?: unknown; headers: Record<string, string> } {
const res = {
headers: {} as Record<string, string>,
statusCode: undefined as number | undefined,
body: undefined as unknown,
setHeader(k: string, v: string) {
this.headers[k] = v;
},
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: unknown) {
this.body = payload;
return this;
},
};
return res as unknown as Response & { statusCode?: number; body?: unknown; headers: Record<string, string> };
}

describe('rateLimit', () => {
it('allows up to `max` requests then 429s further ones from the same IP', () => {
const limit = rateLimit({ windowMs: 60_000, max: 3 });
const next = vi.fn();

for (let i = 0; i < 3; i++) limit(reqFor('1.1.1.1'), resDouble(), next);
expect(next).toHaveBeenCalledTimes(3);

const res = resDouble();
limit(reqFor('1.1.1.1'), res, next);
expect(next).toHaveBeenCalledTimes(3); // not called again
expect(res.statusCode).toBe(429);
expect(res.headers['Retry-After']).toBeTruthy();
});

it('tracks each IP independently', () => {
const limit = rateLimit({ windowMs: 60_000, max: 1 });
const next = vi.fn();

limit(reqFor('1.1.1.1'), resDouble(), next);
const blocked = resDouble();
limit(reqFor('1.1.1.1'), blocked, next);
expect(blocked.statusCode).toBe(429);

// A different IP is unaffected.
const other = resDouble();
limit(reqFor('2.2.2.2'), other, next);
expect(other.statusCode).toBeUndefined();
expect(next).toHaveBeenCalledTimes(2); // 1.1.1.1 first hit + 2.2.2.2 first hit
});

it('resets the window after windowMs elapses', () => {
vi.useFakeTimers();
try {
const limit = rateLimit({ windowMs: 1000, max: 1 });
const next = vi.fn();

limit(reqFor('9.9.9.9'), resDouble(), next);
const blocked = resDouble();
limit(reqFor('9.9.9.9'), blocked, next);
expect(blocked.statusCode).toBe(429);

vi.advanceTimersByTime(1001);
const afterReset = resDouble();
limit(reqFor('9.9.9.9'), afterReset, next);
expect(afterReset.statusCode).toBeUndefined();
expect(next).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
41 changes: 41 additions & 0 deletions apps/web/src/backend/api/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';

/**
* Minimal in-memory, per-IP fixed-window rate limiter — dependency-free (this
* app deliberately avoids heavyweight middleware). Suited to a low-traffic
* endpoint whose abuse fans out to an external side effect (e.g. the signup
* webhook's WordPress post + notification email). Not a distributed limiter:
* counters live in this process, so a multi-instance deployment gets the limit
* per instance — good enough as a floodgate, not a precise quota.
*/
export function rateLimit({ windowMs, max }: { windowMs: number; max: number }): RequestHandler {
const hits = new Map<string, { count: number; resetAt: number }>();

return (req: Request, res: Response, next: NextFunction): void => {
const now = Date.now();

// Opportunistic sweep so the map can't grow without bound under a flood of
// distinct IPs — only runs once the map is already large.
if (hits.size > 5000) {
for (const [k, v] of hits) if (now >= v.resetAt) hits.delete(k);
}

const key = req.ip || 'unknown';
const entry = hits.get(key);

if (!entry || now >= entry.resetAt) {
hits.set(key, { count: 1, resetAt: now + windowMs });
next();
return;
}

if (entry.count >= max) {
res.setHeader('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000)));
res.status(429).json({ ok: false, error: 'Too many attempts. Please try again later.' });
return;
}

entry.count++;
next();
};
}
28 changes: 28 additions & 0 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import { ConnectionStore } from '../modules/connection-store.module';
import { MigrationHistoryStore, type MigrationObjectResult, type MigrationRunStatus } from '../modules/migration-history.module';
import { AppSettingsStore } from '../modules/app-settings.module';
import { SignupModule } from '../modules/signup.module';
import { rateLimit } from './rate-limit';
import { getMetadataDbConfig, SUPPORTED_ENGINES, type DbEngine } from '../database/config';
import { createMetadataStore } from '../database/stores/registry';
import { keySchemeInfo } from '../cores/crypto';
Expand Down Expand Up @@ -105,6 +107,7 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
const sqlGenerator = new SqlGeneratorModule();
const migrationHistory = new MigrationHistoryStore();
const appSettings = new AppSettingsStore();
const signupModule = new SignupModule(appSettings);

/** Resolve a ConnectionRef to concrete credentials (decrypting a saved one). */
async function resolveRef(
Expand Down Expand Up @@ -167,6 +170,31 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
res.json(await checkForUpdate());
});

// First-run "stay in the loop" wizard — see modules/signup.module.ts.
// The write endpoints fan out to an external side effect (WordPress post +
// notification email), so cap them per IP: legit use is one or two calls
// (submit, maybe a retry, or skip), 10 / 15 min leaves generous headroom
// while stopping a flood. Shared bucket across submit + skip.
const signupLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });

router.get('/signup/state', async (_req: Request, res: Response) => {
res.json(await signupModule.getState());
});

router.post('/signup', signupLimiter, async (req: Request, res: Response) => {
const { email, source } = req.body as { email?: string; source?: string };
if (!email) {
res.status(400).json({ ok: false, error: 'Email is required.' });
return;
}
res.json(await signupModule.submit(email, source === 'desktop' ? 'desktop' : 'web'));
});

router.post('/signup/skip', signupLimiter, async (_req: Request, res: Response) => {
await signupModule.skip();
res.json({ ok: true });
});

// Non-secret info about where the app's metadata DB lives and how the
// credential-encryption key is bound — for the "Database & Security" settings
// section. Never exposes the key itself.
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/backend/modules/signup.module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';

process.env.APP_DB_PATH = ':memory:';
process.env.APP_ENCRYPTION_KEY = '0'.repeat(64);

import { AppSettingsStore } from './app-settings.module';
import { SignupModule } from './signup.module';

const appSettings = new AppSettingsStore();
const signup = new SignupModule(appSettings);

describe('SignupModule', () => {
beforeEach(async () => {
vi.unstubAllGlobals();
delete process.env.SIGNUP_WEBHOOK_URL;
delete process.env.SIGNUP_WEBHOOK_SECRET;
// Each test's assertions assume a fresh "not yet resolved" wizard — reset
// the shared in-memory app_settings row rather than isolating per-test DBs.
await appSettings.set('signup.wizard_shown', 'false');
});

it('starts unshown', async () => {
expect(await signup.getState()).toEqual({ shown: false });
});

it('skip() marks the wizard shown without any network call', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
await signup.skip();
expect(await signup.getState()).toEqual({ shown: true });
expect(fetchSpy).not.toHaveBeenCalled();
});

it('rejects a malformed email without touching the network', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
process.env.SIGNUP_WEBHOOK_URL = 'https://example.com/hook';
const result = await signup.submit('not-an-email', 'web');
expect(result.ok).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('submit() resolves the wizard immediately when no webhook is configured', async () => {
const result = await signup.submit('dev@example.com', 'web');
expect(result).toEqual({ ok: true });
expect(await signup.getState()).toEqual({ shown: true });
});

it('submit() posts to the configured webhook with the secret header and marks shown on success', async () => {
process.env.SIGNUP_WEBHOOK_URL = 'https://example.com/hook';
process.env.SIGNUP_WEBHOOK_SECRET = 's3cret';
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchSpy);

const result = await signup.submit(' New@Example.com ', 'desktop');

expect(result).toEqual({ ok: true });
expect(await signup.getState()).toEqual({ shown: true });
expect(fetchSpy).toHaveBeenCalledWith(
'https://example.com/hook',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'X-Foxschema-Signup-Secret': 's3cret' }),
body: JSON.stringify({ email: 'New@Example.com', source: 'desktop' }),
})
);
});

it('submit() leaves the wizard showing (not marked shown) when the webhook call fails', async () => {
process.env.SIGNUP_WEBHOOK_URL = 'https://example.com/hook';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));

const result = await signup.submit('retry@example.com', 'web');

expect(result.ok).toBe(false);
expect(result.error).toBeTruthy();
expect(await signup.getState()).toEqual({ shown: false });
});

it('submit() leaves the wizard showing when the webhook is unreachable', async () => {
process.env.SIGNUP_WEBHOOK_URL = 'https://example.com/hook';
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));

const result = await signup.submit('offline@example.com', 'web');

expect(result.ok).toBe(false);
expect(await signup.getState()).toEqual({ shown: false });
});

it('submit() short-circuits (no webhook forward) once the wizard is already resolved', async () => {
process.env.SIGNUP_WEBHOOK_URL = 'https://example.com/hook';
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchSpy);
await signup.skip(); // resolve the wizard

const result = await signup.submit('late@example.com', 'web');

// Caps external side effects to ~one per install: an already-resolved
// install can't be driven to POST again to WordPress / send more emails.
expect(result).toEqual({ ok: true });
expect(fetchSpy).not.toHaveBeenCalled();
});

it('submit() aborts the webhook call with a timeout signal', async () => {
process.env.SIGNUP_WEBHOOK_URL = 'https://example.com/hook';
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchSpy);

await signup.submit('timeout@example.com', 'web');

const opts = fetchSpy.mock.calls[0][1];
expect(opts.signal).toBeInstanceOf(AbortSignal);
});
});
70 changes: 70 additions & 0 deletions apps/web/src/backend/modules/signup.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { AppSettingsStore } from './app-settings.module';

const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const SHOWN_KEY = 'signup.wizard_shown';
// Cap on how long the outbound WordPress webhook call may block a request —
// without it a hung/slow endpoint ties up the Node request indefinitely.
const WEBHOOK_TIMEOUT_MS = 5000;

/**
* First-run "stay in the loop" wizard: captures an email, forwards it to the
* WordPress signup webhook (foxschema.com owns storage/export/notification —
* see docs/DEPLOYMENT.md), and remembers (via app_settings) that the wizard
* has been resolved (submitted OR skipped) so it never shows again.
*/
export class SignupModule {
constructor(private appSettings: AppSettingsStore) {}

async getState(): Promise<{ shown: boolean }> {
return { shown: (await this.appSettings.get(SHOWN_KEY)) === 'true' };
}

async skip(): Promise<void> {
await this.appSettings.set(SHOWN_KEY, 'true');
}

/**
* Forward `email` to the WordPress webhook. Only marks the wizard as shown
* on a confirmed WordPress accept — a transient failure (webhook down,
* misconfigured) leaves the wizard showing next launch instead of silently
* dropping the signup, since capturing it is the entire point of this flow.
*/
async submit(email: string, source: 'web' | 'desktop'): Promise<{ ok: boolean; error?: string }> {
// The wizard is a one-time flow: once it's been resolved (submitted or
// skipped) there's nothing more to forward. Short-circuiting here caps the
// outbound side effects (WordPress post + notification email) to essentially
// one per install, so a script hammering this endpoint after resolution
// can't amplify into unbounded posts/emails.
if ((await this.getState()).shown) return { ok: true };

const trimmed = email.trim();
if (!EMAIL_RE.test(trimmed)) return { ok: false, error: 'Enter a valid email address.' };

const url = process.env.SIGNUP_WEBHOOK_URL;
if (!url) {
// Not configured (e.g. local dev without the WordPress webhook set up) —
// don't block the user on infra that isn't there; just resolve the wizard.
await this.appSettings.set(SHOWN_KEY, 'true');
return { ok: true };
}

try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(process.env.SIGNUP_WEBHOOK_SECRET ? { 'X-Foxschema-Signup-Secret': process.env.SIGNUP_WEBHOOK_SECRET } : {}),
},
body: JSON.stringify({ email: trimmed, source }),
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`Webhook responded ${res.status}`);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Could not reach the signup service';
return { ok: false, error: `Couldn't save your email right now (${message}). Try again, or skip for now.` };
}

await this.appSettings.set(SHOWN_KEY, 'true');
return { ok: true };
}
}
22 changes: 22 additions & 0 deletions apps/web/src/frontend/api/signupApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { isTauri, getApiBase } from './apiBase';

/** Whether the first-run signup wizard has already been resolved (submitted or skipped). */
export async function getSignupState(): Promise<{ shown: boolean }> {
const res = await fetch(`${getApiBase()}/signup/state`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load signup state');
return res.json();
}

export async function submitSignup(email: string): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${getApiBase()}/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, source: isTauri() ? 'desktop' : 'web' }),
});
return res.json();
}

export async function skipSignup(): Promise<void> {
await fetch(`${getApiBase()}/signup/skip`, { method: 'POST', credentials: 'include' });
}
Loading
Loading