diff --git a/.env.example b/.env.example index 9c3ee40..6550638 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/web/src/backend/api/rate-limit.test.ts b/apps/web/src/backend/api/rate-limit.test.ts new file mode 100644 index 0000000..1787362 --- /dev/null +++ b/apps/web/src/backend/api/rate-limit.test.ts @@ -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 } { + const res = { + headers: {} as Record, + 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 }; +} + +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(); + } + }); +}); diff --git a/apps/web/src/backend/api/rate-limit.ts b/apps/web/src/backend/api/rate-limit.ts new file mode 100644 index 0000000..b34f848 --- /dev/null +++ b/apps/web/src/backend/api/rate-limit.ts @@ -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(); + + 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(); + }; +} diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 9be0ffe..9b18bcd 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -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'; @@ -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( @@ -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. diff --git a/apps/web/src/backend/modules/signup.module.test.ts b/apps/web/src/backend/modules/signup.module.test.ts new file mode 100644 index 0000000..f4dd232 --- /dev/null +++ b/apps/web/src/backend/modules/signup.module.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/backend/modules/signup.module.ts b/apps/web/src/backend/modules/signup.module.ts new file mode 100644 index 0000000..aaf1ddf --- /dev/null +++ b/apps/web/src/backend/modules/signup.module.ts @@ -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 { + 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 }; + } +} diff --git a/apps/web/src/frontend/api/signupApi.ts b/apps/web/src/frontend/api/signupApi.ts new file mode 100644 index 0000000..89d3233 --- /dev/null +++ b/apps/web/src/frontend/api/signupApi.ts @@ -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 { + await fetch(`${getApiBase()}/signup/skip`, { method: 'POST', credentials: 'include' }); +} diff --git a/apps/web/src/frontend/components/SignupWizard.tsx b/apps/web/src/frontend/components/SignupWizard.tsx new file mode 100644 index 0000000..35304cd --- /dev/null +++ b/apps/web/src/frontend/components/SignupWizard.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { Loader2, AlertCircle, Mail } from 'lucide-react'; +import { submitSignup, skipSignup } from '../api/signupApi'; +import { Brand } from './Brand'; + +/** + * One-time, skippable first-run prompt: "stay in the loop" email capture. + * Shown after any required setup (desktop's silent key-binding step) resolves, + * on both web and desktop. Submitting or skipping both dismiss it for good — + * see signupApi.ts / backend modules/signup.module.ts. + */ +export const SignupWizard: React.FC<{ onDone: () => void }> = ({ onDone }) => { + const [email, setEmail] = useState(''); + const [busy, setBusy] = useState<'submit' | 'skip' | null>(null); + const [error, setError] = useState(null); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy('submit'); + setError(null); + try { + const result = await submitSignup(email.trim()); + if (result.ok) { + onDone(); + } else { + setError(result.error ?? 'Something went wrong. Try again, or skip for now.'); + } + } catch { + setError("Couldn't reach the server. Try again, or skip for now."); + } finally { + setBusy(null); + } + }; + + const skip = async () => { + setBusy('skip'); + try { + await skipSignup(); + } catch { + /* best-effort — don't trap the user behind a network hiccup */ + } finally { + onDone(); + } + }; + + return ( +
+
+ + +
+
+

Stay in the loop

+

+ Get notified about new dialects, releases, and features. No spam — unsubscribe anytime. +

+
+ + setEmail(e.target.value)} + placeholder="your@email.com" + className="w-full bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-3 py-2.5 text-sm outline-none" + /> + + {error && ( +
+ + {error} +
+ )} + + + + +
+
+
+ ); +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index aa571a1..ffaa5d4 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,9 +2,11 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './frontend/App.tsx' import { SetupScreen } from './frontend/components/SetupScreen' +import { SignupWizard } from './frontend/components/SignupWizard' import { LoadingScreen } from './frontend/components/LoadingScreen' import { resolveApiBase, setApiBase } from './frontend/api/apiBase' import { getSetupState, type SetupState } from './frontend/api/setupApi' +import { getSignupState } from './frontend/api/signupApi' import { hardenAgainstInspect } from './frontend/lib/harden' import './style.css' @@ -21,6 +23,22 @@ function renderApp() { ) } +// After the required setup gate (if any) resolves: offer the skippable +// "stay in the loop" signup wizard once, then render the app. Fails open on +// a network hiccup — never let this optional step block boot. +async function afterSetup() { + const signup = await getSignupState().catch(() => ({ shown: true })) + if (!signup.shown) { + root.render( + + + , + ) + return + } + renderApp() +} + // Boot: on the desktop shell, gate on first-run setup (the sidecar isn't spawned // until the user binds an encryption key). On the web there's nothing to set up. // A splash renders immediately so there's never a blank frame while that check @@ -41,7 +59,7 @@ async function boot() { initial={setup} onDone={(s: SetupState) => { setApiBase(s.api_base) - renderApp() + afterSetup() }} /> , @@ -52,7 +70,7 @@ async function boot() { // Already set up (or web): resolve the API base, then render. if (setup?.api_base) setApiBase(setup.api_base) else await resolveApiBase() - renderApp() + afterSetup() } boot()