From 6271d5cba668a03688a68ff71947953bc4d70c26 Mon Sep 17 00:00:00 2001 From: orbivort <273379167+orbivort@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:59:56 +0800 Subject: [PATCH 1/4] chore: fix codeql reported issues --- .github/codeql/codeql-config.yml | 30 ++++++ .github/workflows/security.yml | 4 + SECURITY.md | 5 +- packages/backend/docs/operations-notes.md | 94 +++++++++++++++++++ packages/backend/scripts/create-admin.ts | 4 +- packages/backend/src/lib/tokens.ts | 7 ++ packages/backend/src/lib/validation.ts | 34 ++++++- .../backend/src/services/admin-service.ts | 6 +- packages/backend/src/services/auth-service.ts | 4 +- .../backend/src/services/contact-service.ts | 9 +- .../backend/src/services/import-service.ts | 4 +- .../backend/test/unit/lib/validation.test.ts | 37 ++++++-- .../src/features/contacts/ContactFormPage.tsx | 5 +- packages/frontend/src/lib/validation.test.ts | 34 +++++++ packages/frontend/src/lib/validation.ts | 32 +++++++ packages/frontend/src/mocks/handlers/admin.ts | 7 +- .../frontend/src/mocks/handlers/contacts.ts | 5 +- .../frontend/src/mocks/handlers/import.ts | 5 +- 18 files changed, 290 insertions(+), 36 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 packages/frontend/src/lib/validation.test.ts create mode 100644 packages/frontend/src/lib/validation.ts diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..432677b --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,30 @@ +# CodeQL configuration for the `codeql` job in .github/workflows/security.yml. +# +# Everything in this file is a reviewed decision, not a convenience: an alert is +# only suppressed when it cannot be fixed in the codebase (a generated or +# vendored file) or when the query's model cannot express this application's +# design and the mitigation is pinned by tests. Findings that CAN be fixed +# belong in the code — the evaluation of each alert is recorded in the +# "CodeQL alert triage" section of packages/backend/docs/operations-notes.md. +name: Custotal CodeQL configuration + +paths-ignore: + # The MSW service worker is generated by `npx msw init` ("Please do NOT modify + # this file") and re-generated on every MSW upgrade, so an in-file fix would be + # lost on the next upgrade. It is development-only tooling: the worker is + # registered only under `import.meta.env.DEV` (src/main.tsx -> + # src/mocks/browser.ts), so it never runs in a deployed build. + - packages/frontend/public/mockServiceWorker.js + +query-filters: + # js/missing-token-validation only recognises cookie-based sessions guarded by + # one of the packages it knows about (csurf/lusca). Custotal guards them with + # its own Origin/Referer allow-list (src/middleware/csrf.ts, mounted before the + # routers in src/app.ts) on top of a SameSite=Lax session cookie. `csurf` is + # unmaintained, so "satisfying" the query would mean shipping a dead dependency. + # The mitigation is pinned by test/unit/middleware/csrf.test.ts and + # test/integration/api/csrf.test.ts -- re-enable this rule (delete this filter) + # if src/middleware/csrf.ts is removed, or stops being mounted for the + # state-changing routes. + - exclude: + id: js/missing-token-validation diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8bfa52d..7898ba0 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -45,6 +45,10 @@ jobs: languages: javascript-typescript # Security-extended query suite for higher-severity coverage. queries: security-extended + # Reviewed suppressions (generated files, query false positives) with + # the reasoning for each; see the "CodeQL alert triage" section of + # packages/backend/docs/operations-notes.md. + config-file: ./.github/codeql/codeql-config.yml - uses: github/codeql-action/autobuild@v4 - uses: github/codeql-action/analyze@v4 with: diff --git a/SECURITY.md b/SECURITY.md index 9cd5836..8e39452 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -67,7 +67,10 @@ security headers, and no PII in production logs. ## Automated scanning -- **CodeQL** (security-extended) on every push and pull request, plus weekly. +- **CodeQL** (security-extended) on every push and pull request, plus weekly. The + evaluated alerts — fixed, and the few reviewed false positives suppressed in + `.github/codeql/codeql-config.yml` — are recorded in + `packages/backend/docs/operations-notes.md` (§9). - **Dependency review** on pull requests — fails on high-severity advisories and GPL/AGPL-family licenses. - **`pnpm audit`** weekly on the full lockfile. diff --git a/packages/backend/docs/operations-notes.md b/packages/backend/docs/operations-notes.md index 35bb7ca..b15ffe5 100644 --- a/packages/backend/docs/operations-notes.md +++ b/packages/backend/docs/operations-notes.md @@ -225,3 +225,97 @@ password. There is no database seed: demo/mock data lives with the frontend mock dataset (`packages/frontend/src/mocks`, enabled via `VITE_ENABLE_MOCKS`), while the backend integration/e2e suites build their own fixture (`packages/backend/test/fixtures/demo-workspace.ts`). + +## 9. CodeQL alert triage (September 2026) + +The `security-extended` CodeQL run (`.github/workflows/security.yml`) raised nine +alerts against the v1.0.0 tree. Five were real and are fixed; four are recorded +here because "fixing" them would break a documented flow or would weaken the +signal elsewhere. Suppressions that are expressible in configuration live in +`.github/codeql/codeql-config.yml`; the rest are dismissed per alert in the +Security tab with the reason given below. + +### 9.1 Fixed — `js/polynomial-redos` (5 alerts, High) + +Reported at the five backend call sites that validated an email address +(`auth-service` login, `admin-service` create/update user, `contact-service` +validate, `import-service` dry run). + +The pattern was `^[^\s@]+@[^\s@]+\.[^\s@]+$`. The literal `.` separator is also +matched by `[^\s@]`, so the classes overlap and the engine has many ways to split +a candidate. With an invalid tail (many dots, no satisfying end) the match +backtracks quadratically: measured at **~1.4 s for a 40 kB field**, and the CSV +import endpoint accepts a **12 MB body**, so one request could block the event +loop for minutes. The backend is single-instance (§1), so this was a real +availability problem, not a theoretical one. + +Fixed in `src/lib/validation.ts`. `isValidEmail()` is now the single entry point +and the ambiguous pattern is gone: + +- An `EMAIL_MAX_LENGTH` cap (254, the RFC 5321 mailbox limit) is applied first, + so an oversized field is rejected without any matching work at all. +- The shape rules are explicit and linear: exactly one `@` with a non-empty, + whitespace-free local part, plus a domain of at least two non-empty labels split + on `.` (each label checked with a single-quantifier character class). +- Replacing the pattern entirely also matters for tooling: "at least one dot in + the domain" can only be written as a repeated group (`(?:label\.)+label`), which + CodeQL _and_ ESLint's `security/detect-unsafe-regex` (which flagged the first, + otherwise linear, rewrite) both have to treat as ambiguous quantifiers. +- Tightened (invalid addresses the loose pattern used to accept): a trailing dot + and an empty label (`a@b.`, `a@b..c`) are now rejected. +- Pinned by `test/unit/lib/validation.test.ts` (including a hostile-input timing + assertion) and mirrored for contract parity in + `packages/frontend/src/lib/validation.ts`, used by the contact form and the MSW + handlers. + +### 9.2 Dismissed — `js/insufficient-password-hash` (High) + +`src/lib/tokens.ts:12` (`hashToken`, `createHash('sha256')`). SHA-256 is used +here as a **lookup digest for opaque session/reset tokens**, not as a password +KDF: the input is `randomBytes(32)` (256 bits of entropy), so there is no +dictionary or offline search to slow down, and only the digest is stored — a +database leak exposes no usable credential. A deliberately slow KDF would instead +put a bcrypt/scrypt cost on **every authenticated request**, because +`sessionLoader` re-hashes the cookie token on each call. Passwords themselves are +hashed with bcrypt (`auth-service.hashPassword`, 10 rounds — §2). +_Disposition_: dismiss as a false positive. + +### 9.3 Dismissed — `js/clear-text-logging` (High) + +`scripts/create-admin.ts:118` prints the generated temporary administrator +password. That is the point of the script: it is an interactive operator tool +(`db:create-admin`, §8) whose job is to hand the first credential to the person +running it. The value is printed **once**, the account is created with +`mustChangePassword = true`, and the operator is told to share it out of band and +clear their shell history. The application itself never logs credentials (§5, +`admin-service` returns the temporary password in the HTTP response only, and +`admin-service.test.ts` pins that no temporary password reaches the logger), and +the terminal output of a local/`docker compose exec` invocation is not captured +by the container log driver. +_Disposition_: dismiss as "won't fix" (accepted risk). Revisit if the bootstrap +ever stops being an interactive operator step. + +### 9.4 Suppressed — `js/missing-token-validation` (High) + +`src/app.ts:61` (`cookieParser`). False positive: the query only models sessions +guarded by a package it knows (csurf/lusca). Custotal mounts its own +`csrfProtection` middleware (`src/middleware/csrf.ts`) **before** the routers, +which rejects state-changing requests whose `Origin`/`Referer` is neither the +request host nor a `CORS_ORIGINS` entry, and the session cookie is `SameSite=Lax` +(§6). `csurf` is unmaintained, so satisfying the query literally would mean +shipping a dead dependency. The mitigation is pinned by +`test/unit/middleware/csrf.test.ts` and `test/integration/api/csrf.test.ts`. +_Disposition_: rule excluded in `.github/codeql/codeql-config.yml`, with an +explicit "re-enable this rule if `csrf.ts` is removed or unmounted" instruction. + +### 9.5 Suppressed — `js/missing-origin-check` (Medium) + +`packages/frontend/public/mockServiceWorker.js:23`. The file is generated by +`npx msw init` and says "Please do NOT modify this file"; it is re-generated on +every MSW upgrade, so an in-file fix would be lost. It is development-only +tooling — the worker is registered only under `import.meta.env.DEV` +(`src/main.tsx` → `src/mocks/browser.ts`) and is never registered in a deployed +build. Its `message` listener also only ever hears from same-origin documents in +its own scope, which are trusted by definition. +_Disposition_: path ignored in `.github/codeql/codeql-config.yml` (file-scoped: +no signal lost for the rest of the codebase). diff --git a/packages/backend/scripts/create-admin.ts b/packages/backend/scripts/create-admin.ts index e671cf3..9eff6de 100644 --- a/packages/backend/scripts/create-admin.ts +++ b/packages/backend/scripts/create-admin.ts @@ -31,7 +31,7 @@ import bcrypt from 'bcryptjs'; import { randomBytes } from 'node:crypto'; import { join } from 'node:path'; import { loadEnvFile } from 'node:process'; -import { EMAIL_RE } from '../src/lib/validation.ts'; +import { isValidEmail } from '../src/lib/validation.ts'; // Must match BCRYPT_ROUNDS in src/services/auth-service.ts so the credential this // script writes verifies identically at sign-in. @@ -67,7 +67,7 @@ async function main(): Promise { const suppliedPassword = process.env.ADMIN_PASSWORD ?? ''; if (!email) fail('ADMIN_EMAIL is required (e.g. ADMIN_EMAIL=admin@example.com).'); - if (!EMAIL_RE.test(email)) fail(`ADMIN_EMAIL "${email}" is not a valid email address.`); + if (!isValidEmail(email)) fail(`ADMIN_EMAIL "${email}" is not a valid email address.`); if (suppliedPassword && suppliedPassword.length < MIN_PASSWORD_LENGTH) { fail(`ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`); } diff --git a/packages/backend/src/lib/tokens.ts b/packages/backend/src/lib/tokens.ts index bd89155..464f99c 100644 --- a/packages/backend/src/lib/tokens.ts +++ b/packages/backend/src/lib/tokens.ts @@ -1,5 +1,12 @@ // Opaque session / reset tokens. Only SHA-256 digests are persisted so a database // leak does not expose usable credentials. +// +// A fast, unsalted hash is the right tool here (and is deliberately not a +// password KDF): the input is 256 bits of entropy from randomBytes, so there is +// no dictionary or offline search to slow down, and a slow hash would be paid on +// every authenticated request because sessionLoader digests the cookie on each +// call. CodeQL's js/insufficient-password-hash flags this line; the review and +// disposition are recorded in docs/operations-notes.md §9.2. import { createHash, randomBytes } from 'node:crypto'; /** 32 random bytes, base64url — sent to the client once. */ diff --git a/packages/backend/src/lib/validation.ts b/packages/backend/src/lib/validation.ts index 26107b2..7106942 100644 --- a/packages/backend/src/lib/validation.ts +++ b/packages/backend/src/lib/validation.ts @@ -1,7 +1,39 @@ // Shared field validation helpers. Messages mirror the frontend mock exactly. import { errors } from './errors.ts'; -export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** RFC 5321 §4.5.3.1.3 mailbox limit (local-part + "@" + domain). */ +export const EMAIL_MAX_LENGTH = 254; + +/** Local part: non-empty and free of whitespace (the "@" was already split off). */ +const EMAIL_LOCAL_RE = /^\S+$/; + +/** One domain label: non-empty, and neither whitespace nor the "." separator. */ +const EMAIL_LABEL_RE = /^[^\s@.]+$/; + +/** + * Shape and length check for an email address. Callers pass the value they will + * persist (already trimmed; callers normalize case where relevant). + * + * The rules are expressed as a length cap plus a split on "." rather than as one + * pattern, because "at least one dot in the domain" can only be written as a + * repeated group (`(?:label\.)+label`) — which both CodeQL (`js/polynomial-redos`, + * the alert this replaced) and ESLint's `security/detect-unsafe-regex` have to + * treat as ambiguous quantifiers, and which really was quadratic here: the old + * `^[^\s@]+@[^\s@]+\.[^\s@]+$` overlapped the literal dot with `[^\s@]`, costing + * ~1.4 s on a craftable value through the 12 MB CSV import body. This version is + * strictly linear in the input, and the cap runs before any matching or + * splitting. + */ +export function isValidEmail(value: string): boolean { + if (value.length === 0 || value.length > EMAIL_MAX_LENGTH) return false; + const at = value.indexOf('@'); + // Exactly one "@", and something before it. + if (at <= 0 || at !== value.lastIndexOf('@')) return false; + if (!EMAIL_LOCAL_RE.test(value.slice(0, at))) return false; + const labels = value.slice(at + 1).split('.'); + // A domain needs at least two labels, and none of them may be empty. + return labels.length > 1 && labels.every((label) => EMAIL_LABEL_RE.test(label)); +} export const CONTACT_STATUSES = ['active', 'inactive'] as const; export const INTERACTION_TYPES = ['email', 'call', 'meeting', 'note', 'other'] as const; diff --git a/packages/backend/src/services/admin-service.ts b/packages/backend/src/services/admin-service.ts index 7949e12..1e7c186 100644 --- a/packages/backend/src/services/admin-service.ts +++ b/packages/backend/src/services/admin-service.ts @@ -6,7 +6,7 @@ import { env } from '../config.ts'; import { logger } from '../logger.ts'; import { errors, type FieldError } from '../lib/errors.ts'; import { ALL_ROLES } from '../lib/rbac.ts'; -import { EMAIL_RE, optionalString, STAGE_CLASSIFICATIONS } from '../lib/validation.ts'; +import { isValidEmail, optionalString, STAGE_CLASSIFICATIONS } from '../lib/validation.ts'; import { hashToken, generateToken } from '../lib/tokens.ts'; import { authLinkUrl, hashPassword } from './auth-service.ts'; import { SOFT_DELETE_RETENTION_DAYS } from './purge-service.ts'; @@ -55,7 +55,7 @@ export async function createUser(input: { const email = typeof input.email === 'string' ? input.email.trim().toLowerCase() : ''; const role = (input.role as Role | undefined) ?? 'rep'; if (!name) details.push({ field: 'name', message: 'Full name is required.' }); - if (!EMAIL_RE.test(email)) details.push({ field: 'email', message: 'Invalid email format.' }); + if (!isValidEmail(email)) details.push({ field: 'email', message: 'Invalid email format.' }); else if (await prisma.user.findUnique({ where: { email } })) { details.push({ field: 'email', message: 'A user with this email already exists.' }); } @@ -125,7 +125,7 @@ export async function updateUser( } if (input.email !== undefined) { const email = typeof input.email === 'string' ? input.email.trim().toLowerCase() : ''; - if (!EMAIL_RE.test(email)) details.push({ field: 'email', message: 'Invalid email format.' }); + if (!isValidEmail(email)) details.push({ field: 'email', message: 'Invalid email format.' }); else if (await prisma.user.findFirst({ where: { email, id: { not: id } } })) { details.push({ field: 'email', message: 'A user with this email already exists.' }); } else next.email = email; diff --git a/packages/backend/src/services/auth-service.ts b/packages/backend/src/services/auth-service.ts index 057f3ec..2706b66 100644 --- a/packages/backend/src/services/auth-service.ts +++ b/packages/backend/src/services/auth-service.ts @@ -7,7 +7,7 @@ import { env } from '../config.ts'; import { logger } from '../logger.ts'; import { errors, type FieldError } from '../lib/errors.ts'; import { hashToken, generateToken } from '../lib/tokens.ts'; -import { EMAIL_RE } from '../lib/validation.ts'; +import { isValidEmail } from '../lib/validation.ts'; import { toUser } from '../serializers.ts'; import type { User } from '../types/domain.ts'; import { sendPasswordReset } from './mailer.ts'; @@ -48,7 +48,7 @@ export interface LoginResult { } export async function login(email: string, password: string): Promise { - if (!EMAIL_RE.test(email.trim())) throw errors.invalidCredentials(); + if (!isValidEmail(email.trim())) throw errors.invalidCredentials(); const user = await findUserByEmail(email); if (!user || !(await verifyPassword(password, user.passwordHash))) { throw errors.invalidCredentials(); diff --git a/packages/backend/src/services/contact-service.ts b/packages/backend/src/services/contact-service.ts index 55ba209..db2e1f3 100644 --- a/packages/backend/src/services/contact-service.ts +++ b/packages/backend/src/services/contact-service.ts @@ -2,7 +2,12 @@ // account-link replacement, and the single-contact data export (FR-CC-16). import { prisma } from '../db.ts'; import { errors, FIELD_MESSAGES, type FieldError } from '../lib/errors.ts'; -import { assertNoConflict, CONTACT_STATUSES, EMAIL_RE, optionalString } from '../lib/validation.ts'; +import { + assertNoConflict, + CONTACT_STATUSES, + isValidEmail, + optionalString, +} from '../lib/validation.ts'; import { toContact } from '../serializers.ts'; import type { Contact, User } from '../types/domain.ts'; @@ -51,7 +56,7 @@ function validateContact(body: { }): FieldError[] { const details: FieldError[] = []; const email = optionalString(body.email); - if (email && !EMAIL_RE.test(email)) { + if (email && !isValidEmail(email)) { details.push({ field: 'email', message: FIELD_MESSAGES.invalidEmail }); } if (!email && !optionalString(body.phone)) { diff --git a/packages/backend/src/services/import-service.ts b/packages/backend/src/services/import-service.ts index 2131d6b..40536cd 100644 --- a/packages/backend/src/services/import-service.ts +++ b/packages/backend/src/services/import-service.ts @@ -4,7 +4,7 @@ import { prisma } from '../db.ts'; import { errors } from '../lib/errors.ts'; import { - EMAIL_RE, + isValidEmail, optionalString, CONTACT_IMPORT_FIELDS, ACCOUNT_IMPORT_FIELDS, @@ -37,7 +37,7 @@ function validateContactRow(data: Record): string[] { if (!cell(data, 'lastName')) reasons.push('Last name is required.'); const email = cell(data, 'email'); const phone = cell(data, 'phone'); - if (email && !EMAIL_RE.test(email)) reasons.push('Invalid email format.'); + if (email && !isValidEmail(email)) reasons.push('Invalid email format.'); if (!email && !phone) reasons.push('At least one of email or phone is required.'); const status = cell(data, 'status'); if (status && status !== 'active' && status !== 'inactive') { diff --git a/packages/backend/test/unit/lib/validation.test.ts b/packages/backend/test/unit/lib/validation.test.ts index 8c1c4f5..99f3d93 100644 --- a/packages/backend/test/unit/lib/validation.test.ts +++ b/packages/backend/test/unit/lib/validation.test.ts @@ -9,9 +9,10 @@ import { assertNoConflict, CONTACT_IMPORT_FIELDS, CONTACT_STATUSES, - EMAIL_RE, + EMAIL_MAX_LENGTH, INTERACTION_DIRECTIONS, INTERACTION_TYPES, + isValidEmail, optionalString, optionalStringOrNull, STAGE_CLASSIFICATIONS, @@ -19,7 +20,7 @@ import { TASK_STATUSES, } from '../../../src/lib/validation.ts'; -describe('EMAIL_RE', () => { +describe('isValidEmail', () => { it.each([ 'user@test.example', 'first.last+tag@sub.domain.co', @@ -27,7 +28,7 @@ describe('EMAIL_RE', () => { 'a@b.c', "o'brien@example.com", ])('accepts %s', (email) => { - expect(EMAIL_RE.test(email)).toBe(true); + expect(isValidEmail(email)).toBe(true); }); it.each([ @@ -35,6 +36,8 @@ describe('EMAIL_RE', () => { 'plain', 'a@b', 'a@b.', + 'a@b.c.', + 'a@b..c', '@example.com', 'user@', 'user@@example.com', @@ -43,13 +46,27 @@ describe('EMAIL_RE', () => { 'user@example.com ', 'user\t@example.com', ])('rejects %s', (email) => { - expect(EMAIL_RE.test(email)).toBe(false); - }); - - it('is stateless (no /g flag, so repeated tests agree)', () => { - expect(EMAIL_RE.global).toBe(false); - expect(EMAIL_RE.test('user@test.example')).toBe(true); - expect(EMAIL_RE.test('user@test.example')).toBe(true); + expect(isValidEmail(email)).toBe(false); + }); + + it('rejects an address longer than EMAIL_MAX_LENGTH', () => { + const suffix = '@example.com'; + const atLimit = `${'a'.repeat(EMAIL_MAX_LENGTH - suffix.length)}${suffix}`; + expect(atLimit).toHaveLength(EMAIL_MAX_LENGTH); + expect(isValidEmail(atLimit)).toBe(true); + expect(isValidEmail(`x${atLimit}`)).toBe(false); + }); + + it('rejects hostile input without backtracking (regression for js/polynomial-redos)', () => { + // Many dots with an invalid tail: the old `^[^\s@]+@[^\s@]+\.[^\s@]+$` (whose + // literal dot overlapped `[^\s@]`) needed ~1.4 s for this value and was + // reachable through the 12 MB CSV import body. It is now rejected by the + // length cap, and the "many dots" shape is rejected by the label split. + const oversized = `a@${'b.'.repeat(40_000)} `; + const started = Date.now(); + expect(isValidEmail(oversized)).toBe(false); + expect(Date.now() - started).toBeLessThan(100); + expect(isValidEmail(`a@${'b.'.repeat(40)} `)).toBe(false); }); }); diff --git a/packages/frontend/src/features/contacts/ContactFormPage.tsx b/packages/frontend/src/features/contacts/ContactFormPage.tsx index 5b881ff..ac7c8ba 100644 --- a/packages/frontend/src/features/contacts/ContactFormPage.tsx +++ b/packages/frontend/src/features/contacts/ContactFormPage.tsx @@ -6,11 +6,10 @@ import { Field, Input, Select, Textarea } from '../../components/ui/Field'; import { ErrorBanner, LoadingBlock } from '../../components/ui/Feedback'; import { ArrowLeftIcon, PlusIcon, XIcon } from '../../components/icons'; import { useMeta } from '../meta/MetaContext'; +import { isValidEmail } from '../../lib/validation'; import { createContact, getContact, updateContact } from './contactsApi'; import type { AccountLink } from '../../types/domain'; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - interface LinkRow { accountId: string; primary: boolean; @@ -113,7 +112,7 @@ export default function ContactFormPage() { const e: Record = {}; if (!form.firstName.trim()) e.firstName = 'First name is required.'; if (!form.lastName.trim()) e.lastName = 'Last name is required.'; - if (form.email.trim() && !EMAIL_RE.test(form.email.trim())) e.email = 'Invalid email format.'; + if (form.email.trim() && !isValidEmail(form.email.trim())) e.email = 'Invalid email format.'; if (!form.email.trim() && !form.phone.trim()) { e.phone = 'At least one of email or phone is required.'; } diff --git a/packages/frontend/src/lib/validation.test.ts b/packages/frontend/src/lib/validation.test.ts new file mode 100644 index 0000000..92b79ad --- /dev/null +++ b/packages/frontend/src/lib/validation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { EMAIL_MAX_LENGTH, isValidEmail } from './validation'; + +describe('isValidEmail', () => { + it.each(['user@test.example', 'first.last+tag@sub.domain.co', 'a@b.c'])('accepts %s', (email) => { + expect(isValidEmail(email)).toBe(true); + }); + + it.each([ + '', + 'plain', + 'a@b', + 'a@b.', + 'a@b..c', + 'user@@example.com', + 'user@exa mple.com', + 'user@example.com ', + ])('rejects %s', (email) => { + expect(isValidEmail(email)).toBe(false); + }); + + it('rejects an address longer than EMAIL_MAX_LENGTH', () => { + const atLimit = `${'a'.repeat(EMAIL_MAX_LENGTH - '@example.com'.length)}@example.com`; + expect(isValidEmail(atLimit)).toBe(true); + expect(isValidEmail(`x${atLimit}`)).toBe(false); + }); + + it('rejects hostile input without backtracking', () => { + const hostile = `a@${'b.'.repeat(20_000)} `; + const started = Date.now(); + expect(isValidEmail(hostile)).toBe(false); + expect(Date.now() - started).toBeLessThan(100); + }); +}); diff --git a/packages/frontend/src/lib/validation.ts b/packages/frontend/src/lib/validation.ts new file mode 100644 index 0000000..13e6295 --- /dev/null +++ b/packages/frontend/src/lib/validation.ts @@ -0,0 +1,32 @@ +// Email validation shared by the contact form and the mock handlers. It mirrors +// `packages/backend/src/lib/validation.ts` so the form, the MSW mocks, and the +// API accept exactly the same addresses. + +/** RFC 5321 §4.5.3.1.3 mailbox limit (local-part + "@" + domain). */ +export const EMAIL_MAX_LENGTH = 254; + +/** Local part: non-empty and free of whitespace (the "@" was already split off). */ +const EMAIL_LOCAL_RE = /^\S+$/; + +/** One domain label: non-empty, and neither whitespace nor the "." separator. */ +const EMAIL_LABEL_RE = /^[^\s@.]+$/; + +/** + * Shape and length check for a trimmed email address. + * + * A length cap plus a split on "." rather than a single pattern: "at least one + * dot in the domain" can only be written as a repeated group + * (`(?:label\.)+label`), which a tool like ESLint's `security/detect-unsafe-regex` + * has to treat as ambiguous quantifiers. This form is strictly linear in the + * (capped) input and keeps the rules explicit. + */ +export function isValidEmail(value: string): boolean { + if (value.length === 0 || value.length > EMAIL_MAX_LENGTH) return false; + const at = value.indexOf('@'); + // Exactly one "@", and something before it. + if (at <= 0 || at !== value.lastIndexOf('@')) return false; + if (!EMAIL_LOCAL_RE.test(value.slice(0, at))) return false; + const labels = value.slice(at + 1).split('.'); + // A domain needs at least two labels, and none of them may be empty. + return labels.length > 1 && labels.every((label) => EMAIL_LABEL_RE.test(label)); +} diff --git a/packages/frontend/src/mocks/handlers/admin.ts b/packages/frontend/src/mocks/handlers/admin.ts index 9cf048d..4778757 100644 --- a/packages/frontend/src/mocks/handlers/admin.ts +++ b/packages/frontend/src/mocks/handlers/admin.ts @@ -1,11 +1,10 @@ import { http } from 'msw'; import type { Role, Stage, StageClassification, User } from '../../types/domain'; import { ALL_ROLES } from '../../lib/rbac'; +import { isValidEmail } from '../../lib/validation'; import { getDB, persist } from '../db/store'; import { adminGate, err, genId, json, nowISO } from './helpers'; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - function stageInUse(db: ReturnType, stageId: string): number { return db.opportunities.filter((o) => o.stageId === stageId).length; } @@ -46,7 +45,7 @@ export const adminHandlers = [ const email = (body.email ?? '').trim().toLowerCase(); const role = body.role ?? 'rep'; if (!name) details.push({ field: 'name', message: 'Full name is required.' }); - if (!EMAIL_RE.test(email)) details.push({ field: 'email', message: 'Invalid email format.' }); + if (!isValidEmail(email)) details.push({ field: 'email', message: 'Invalid email format.' }); else if (db.users.some((u) => u.email.toLowerCase() === email)) { details.push({ field: 'email', message: 'A user with this email already exists.' }); } @@ -81,7 +80,7 @@ export const adminHandlers = [ } if (body.email !== undefined) { const email = body.email.trim().toLowerCase(); - if (!EMAIL_RE.test(email)) details.push({ field: 'email', message: 'Invalid email format.' }); + if (!isValidEmail(email)) details.push({ field: 'email', message: 'Invalid email format.' }); else if (db.users.some((u) => u.email.toLowerCase() === email && u.id !== existing.id)) { details.push({ field: 'email', message: 'A user with this email already exists.' }); } else next.email = email; diff --git a/packages/frontend/src/mocks/handlers/contacts.ts b/packages/frontend/src/mocks/handlers/contacts.ts index 0307bb2..35e3c5d 100644 --- a/packages/frontend/src/mocks/handlers/contacts.ts +++ b/packages/frontend/src/mocks/handlers/contacts.ts @@ -1,13 +1,12 @@ import { http } from 'msw'; import type { AccountLink, Contact } from '../../types/domain'; +import { isValidEmail } from '../../lib/validation'; import { getDB, persist } from '../db/store'; import { canEdit, err, genId, json, nowISO, requireUser } from './helpers'; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - function validateContact(body: Partial): { field: string; message: string }[] { const details: { field: string; message: string }[] = []; - if (body.email && !EMAIL_RE.test(body.email)) { + if (body.email && !isValidEmail(body.email)) { details.push({ field: 'email', message: 'Invalid email format.' }); } if (!body.email && !body.phone) { diff --git a/packages/frontend/src/mocks/handlers/import.ts b/packages/frontend/src/mocks/handlers/import.ts index 52d4127..69691f9 100644 --- a/packages/frontend/src/mocks/handlers/import.ts +++ b/packages/frontend/src/mocks/handlers/import.ts @@ -9,11 +9,10 @@ import type { ImportEntity, ImportMappingTemplate, } from '../../types/domain'; +import { isValidEmail } from '../../lib/validation'; import { getDB, persist } from '../db/store'; import { adminGate, err, genId, json, nowISO } from './helpers'; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - export const CONTACT_IMPORT_FIELDS = [ 'firstName', 'lastName', @@ -75,7 +74,7 @@ function validateContactRow(data: Record): string[] { if (!cell(data, 'lastName')) reasons.push('Last name is required.'); const email = cell(data, 'email'); const phone = cell(data, 'phone'); - if (email && !EMAIL_RE.test(email)) reasons.push('Invalid email format.'); + if (email && !isValidEmail(email)) reasons.push('Invalid email format.'); if (!email && !phone) reasons.push('At least one of email or phone is required.'); const status = cell(data, 'status'); if (status && status !== 'active' && status !== 'inactive') { From ac4fed09024eabecd2940a342d7c7754454f8a79 Mon Sep 17 00:00:00 2001 From: orbivort <273379167+orbivort@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:40:18 +0800 Subject: [PATCH 2/4] chore: add github pages --- .github/workflows/pages.yml | 134 ++++++++++++++++++++++ README.md | 6 + package.json | 1 + packages/frontend/.env.example | 11 +- packages/frontend/docs/api-integration.md | 64 +++++++---- packages/frontend/package.json | 1 + packages/frontend/src/App.tsx | 15 ++- packages/frontend/src/config/env.ts | 24 +++- packages/frontend/src/main.tsx | 17 ++- packages/frontend/src/mocks/browser.ts | 19 ++- packages/frontend/vite.config.ts | 16 +++ 11 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..83e32f6 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,134 @@ +# GitHub Pages deployment — publishes the frontend as a backend-free demo site. +# +# The site is a static bundle with no API behind it, so it is built with +# `--mode demo` (see `build:demo`): Mock Service Worker boots the seeded demo +# workspace in the browser and the sign-in form arrives prefilled with the demo +# administrator. This is the only deployment that intentionally ships the mock +# graph — `pnpm build`, the bundle released and containerised for self-hosting, +# still drops it entirely (see packages/frontend/src/config/env.ts). +# +# Two details are specific to Pages and are handled here rather than in the app: +# - A project site is served from //, so `base` is set from the +# `base_path` reported by actions/configure-pages. +# - Pages has no rewrite rules. index.html is therefore also emitted as +# 404.html, which is what Pages serves for an unmatched path: the SPA boots +# and the client-side router resolves the deep link on a hard refresh. +# +# One-time setup: Settings → Pages → Build and deployment → Source must be +# "GitHub Actions". `actions/configure-pages` cannot do it from here — its +# `enablement` input needs a token other than the built-in GITHUB_TOKEN. +# +# Gate: this deploys on every push to main, like the docs/marketing artifact it +# is. The `quality`, `unit` and `build` jobs in ci.yml run on the same commits; +# this workflow deliberately re-runs only the typecheck and build that its own +# artifact depends on, so a broken demo is caught here instead of going live. +name: Pages + +on: + push: + branches: [main] + # Rebuild only for changes that can alter the site. The manifests are + # included because a dependency bump rewrites the bundle without touching + # frontend sources. + paths: + - 'packages/frontend/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.base.json' + - '.nvmrc' + - '.github/workflows/pages.yml' + workflow_dispatch: + +# Never cancel a deployment in flight: a half-published site is worse than a +# slightly stale one. Queued runs wait their turn instead. +concurrency: + group: pages + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + name: Build demo bundle + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 # reads packageManager from package.json + - uses: actions/setup-node@v5 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + # Reports the site's base path and deployment URL. Must run before the + # build, which derives VITE_BASE_PATH from the `base_path` output. + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + + # Only the frontend's dependency subtree: the demo build pulls in no + # backend code, so the Prisma client never has to be generated here. + - run: pnpm install --frozen-lockfile --filter @custotal/frontend... + + - name: Build the frontend (demo mode) + env: + # "" for a user/org site or a custom domain, "/" for a project + # site. vite.config.ts normalises it into a usable `base`. + VITE_BASE_PATH: ${{ steps.pages.outputs.base_path }} + # Runs `tsc --noEmit` before bundling, so a type error fails the deploy + # instead of publishing a bundle that cannot work. + run: pnpm --filter @custotal/frontend build:demo + + # A demo bundle with the mock graph tree-shaken away would ship a sign-in + # form that can never authenticate: there is no backend behind this site. + # The storage key only exists in the mock dataset, so finding it proves + # MSW was bundled. The inverse check — that `pnpm build` leaves it out — is + # the invariant documented in packages/frontend/docs/api-integration.md. + - name: Verify the demo bundle carries the mock dataset + run: | + set -euo pipefail + if grep -rqF 'custotal-db-v5-' packages/frontend/dist; then + echo "mock dataset present in the demo bundle" + else + echo "::error::No mock dataset in the demo bundle — MSW was tree-shaken, so sign-in cannot work." + exit 1 + fi + + # Client-side routing fallback. Pages answers an unmatched path with + # 404.html, and a copy of the shell lets the router resolve it. + - name: Emit the SPA fallback document + run: cp packages/frontend/dist/index.html packages/frontend/dist/404.html + + # Vite emits no underscore-prefixed assets today, so Jekyll would leave + # them alone — but the file costs nothing and removes the whole class of + # "an asset silently disappeared" failures. + - name: Disable Jekyll processing + run: touch packages/frontend/dist/.nojekyll + + - name: Upload the Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: packages/frontend/dist + # `.nojekyll` is a dotfile, and dotfiles are excluded by default. + include-hidden-files: true + + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: build + # The `github-pages` environment carries the deployment URL and is where + # deployment protection rules (e.g. a required reviewer) would be attached. + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + contents: read + pages: write + id-token: write + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 936cb90..2b6d807 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,11 @@ To try the UI **without a database**, set `VITE_ENABLE_MOCKS=true` in seeded demo workspace in development only. It is never active outside the Vite dev server, and the flag must not be `true` in a production build. +The same demo is available as a standalone static site: `pnpm build:demo` +produces the bundle published to GitHub Pages by +[`.github/workflows/pages.yml`](.github/workflows/pages.yml). That build boots the +mock workspace on purpose — the site it produces has no backend behind it. + For a production deployment, see [`docs/self-hosting.md`](docs/self-hosting.md). ## Docker @@ -323,6 +328,7 @@ Run from the repository root: | ----------------------------- | ------------------------------- | | Both dev servers | `pnpm dev` | | Frontend production build | `pnpm build` | +| Frontend demo build (Pages) | `pnpm build:demo` | | Typecheck (frontend+backend) | `pnpm typecheck` | | ESLint (whole workspace) | `pnpm lint` | | Stylelint (frontend CSS) | `pnpm lint:css` | diff --git a/package.json b/package.json index 4f089af..62a0e95 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "scripts": { "dev": "pnpm --parallel --filter @custotal/frontend --filter @custotal/backend dev", "build": "pnpm --filter @custotal/frontend build", + "build:demo": "pnpm --filter @custotal/frontend build:demo", "preview": "pnpm --filter @custotal/frontend preview", "typecheck": "pnpm --filter @custotal/frontend typecheck && pnpm --filter @custotal/backend typecheck", "lint": "eslint .", diff --git a/packages/frontend/.env.example b/packages/frontend/.env.example index 1ccd470..92e7450 100644 --- a/packages/frontend/.env.example +++ b/packages/frontend/.env.example @@ -6,7 +6,9 @@ # When unset or false, the app calls the real backend (default). When set to # "true", Mock Service Worker intercepts /api/* in development only and serves # the seeded mock data (see src/mocks). MSW is never active outside the Vite -# dev server regardless of this flag. +# dev server regardless of this flag: the standalone demo site published to +# GitHub Pages is produced by `pnpm build:demo` (`vite build --mode demo`), where +# the mode — not this flag — is what enables the mocks. VITE_ENABLE_MOCKS=true # ---- Backend base URL --------------------------------------------------------- @@ -22,6 +24,13 @@ VITE_ENABLE_MOCKS=true # http://localhost:4000). Rarely needed. # VITE_API_PROXY_TARGET=http://localhost:4000 +# ---- Deployment sub-path -------------------------------------------------------- +# Sub-path the built bundle is served from. Set it to "/" for a GitHub +# Pages project site — the Pages workflow does this automatically from the +# base_path reported by actions/configure-pages. Leave it unset for a root +# deployment, a user/org site, or a custom domain. Build-time only. +# VITE_BASE_PATH=/custotal + # ---- Default currency ------------------------------------------------------------ # ISO 4217 currency code used app-wide as the default currency. It drives the # "Value (XXX)" form label, the "(XXX)" suffixes in CSV export headers, the diff --git a/packages/frontend/docs/api-integration.md b/packages/frontend/docs/api-integration.md index 4a31838..4ea4303 100644 --- a/packages/frontend/docs/api-integration.md +++ b/packages/frontend/docs/api-integration.md @@ -31,11 +31,11 @@ HTTP client (src/lib/api.ts) → base URL + credentials ## Data source toggle (`VITE_ENABLE_MOCKS`) -| Value | Behavior | -| -------------------------- | ------------------------------------------------------------------------- | -| unset (default) | **Real API.** Requests go to the same origin (dev proxy / deployed host). | -| `true` | MSW intercepts `/api/*` in the Vite dev server only. | -| anything else / prod build | MSW never boots. | +| Value | Behavior | +| -------------------------------- | ------------------------------------------------------------------------- | +| unset (default) | **Real API.** Requests go to the same origin (dev proxy / deployed host). | +| `true` | MSW intercepts `/api/*` in the Vite dev server only. | +| anything else / production build | MSW never boots — the opt-in demo build is the one exception (see below). | Set it for local mock-driven work in a frontend-only `.env.local`: @@ -43,19 +43,34 @@ Set it for local mock-driven work in a frontend-only `.env.local`: VITE_ENABLE_MOCKS=true ``` -MSW is **excluded from non-development builds**. `src/main.tsx` gates the MSW -boot behind `if (import.meta.env.DEV)`. Vite replaces `import.meta.env.DEV` -with `false` during production builds, so the block — and the entire mock -module graph — is eliminated. The `validate` workflow confirms this by grepping -the production bundle for the mock database marker (`custotal-db-v1`). +MSW is **excluded from production builds**. `src/main.tsx` gates the MSW boot +behind `if (import.meta.env.DEV || import.meta.env.MODE === 'demo')`. Vite +replaces both operands with literals at build time, so in a production build the +condition folds to `false` and the block — with the entire mock module graph — is +eliminated. + +### Demo build (`pnpm build:demo`) + +The one build where MSW _is_ expected to boot is the **demo build** +(`vite build --mode demo`), which `.github/workflows/pages.yml` publishes to +GitHub Pages. That site is static and has no backend behind it, so it serves the +seeded demo workspace and the sign-in form arrives prefilled with the demo +administrator. + +`VITE_ENABLE_MOCKS` is deliberately **not** consulted there — the mode is the +switch — so a demo build cannot be published as a site whose only data source was +silently tree-shaken away. The Pages workflow asserts the mock dataset is present +before deploying; the inverse assertion (that `pnpm build` leaves it out) is in +[Validation](#validation). ## Environment variables -| Variable | Default | Purpose | -| ----------------------- | ----------------------- | ----------------------------------------------------- | -| `VITE_ENABLE_MOCKS` | unset → off | Opt into Mock Service Worker (dev only). | -| `VITE_API_BASE_URL` | empty | Absolute backend origin for cross-origin deployments. | -| `VITE_API_PROXY_TARGET` | `http://localhost:4000` | Dev-proxy target override (rarely needed). | +| Variable | Default | Purpose | +| ----------------------- | ----------------------- | ------------------------------------------------------------------ | +| `VITE_ENABLE_MOCKS` | unset → off | Opt into Mock Service Worker in development only. | +| `VITE_API_BASE_URL` | empty | Absolute backend origin for cross-origin deployments. | +| `VITE_API_PROXY_TARGET` | `http://localhost:4000` | Dev-proxy target override (rarely needed). | +| `VITE_BASE_PATH` | `/` | Sub-path the bundle is served from (build only, e.g. `/custotal`). | All variables are parsed once in `src/config/env.ts` (`env.apiBaseUrl`, `env.mocksEnabled`) and read from there everywhere. @@ -157,9 +172,11 @@ handler under `src/mocks/handlers/`. - **In-house data hooks instead of a data-fetching library.** The existing `useQuery` is small and dependency-free; adding `useMutation` keeps the bundle lean for a codebase this size (no react-query/tanstack dependency). -- **MSW excluded from production.** Gating on the statically-replaced - `import.meta.env.DEV` lets the bundler drop the mock graph, verified by - checking the production bundle for mock markers. +- **MSW excluded from production, explicit in the demo.** Gating on the + statically-replaced `import.meta.env.DEV` / `MODE` literals lets the bundler + drop the mock graph from production builds, while the opt-in `demo` build mode + keeps the backend-free GitHub Pages site working. Both directions are asserted + with a mock-dataset marker, so neither can regress silently. ## Validation @@ -175,10 +192,11 @@ pnpm --filter @custotal/frontend build Then confirm the mock code was not bundled: ```bash -findstr /S /M /C:"custotal-db-v1" packages/frontend/dist +findstr /S /M /C:"custotal-db-v5-" packages/frontend/dist ``` -No matches means the production bundle is MSW-free. To exercise the real API -locally: start the backend (`pnpm backend:dev`) and the frontend (`pnpm dev`); -to exercise mocks, create a frontend `.env.local` with -`VITE_ENABLE_MOCKS=true` and start `pnpm dev`. +No matches means the production bundle is MSW-free. The demo build +(`pnpm build:demo`) is the opposite case and must match, because that bundle has +no backend to fall back on. To exercise the real API locally: start the backend +(`pnpm backend:dev`) and the frontend (`pnpm dev`); to exercise mocks, create a +frontend `.env.local` with `VITE_ENABLE_MOCKS=true` and start `pnpm dev`. diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 1cbeece..5921969 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -17,6 +17,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "build:demo": "tsc --noEmit && vite build --mode demo", "preview": "vite preview", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.e2e.json", "lint": "eslint .", diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 7d44b75..b85403d 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { lazy } from 'react'; -import { createBrowserRouter, Navigate, RouterProvider } from 'react-router'; +import { createBrowserRouter, Navigate, RouterProvider, type RouteObject } from 'react-router'; import { ToastProvider } from './components/toast'; import { RequireAuth } from './components/RequireAuth'; import { RequireRole } from './components/RequireRole'; @@ -33,7 +33,14 @@ const AdminStagesPage = lazy(() => import('./features/admin/AdminStagesPage')); const ImportWizardPage = lazy(() => import('./features/admin/ImportWizardPage')); const RecoveryPage = lazy(() => import('./features/admin/RecoveryPage')); -const router = createBrowserRouter([ +// The bundle is not always served from the origin root: the GitHub Pages demo +// lives under //. Vite injects the configured `base` as BASE_URL, and the +// router has to strip that prefix before matching routes — otherwise neither the +// shell nor any deep link resolves. "/" is what a root deployment produces, and +// React Router treats it as "no prefix". +const routerBasename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/'; + +const routes: RouteObject[] = [ { path: '/login', errorElement: , @@ -132,7 +139,9 @@ const router = createBrowserRouter([ { path: '*', element: }, ], }, -]); +]; + +const router = createBrowserRouter(routes, { basename: routerBasename }); export default function App() { return ( diff --git a/packages/frontend/src/config/env.ts b/packages/frontend/src/config/env.ts index 7d29fe6..b85e200 100644 --- a/packages/frontend/src/config/env.ts +++ b/packages/frontend/src/config/env.ts @@ -28,9 +28,17 @@ export interface EnvConfig { /** True when running under the Vite dev server. */ readonly isDev: boolean; /** - * Whether Mock Service Worker should boot. Only possible in development and - * requires `VITE_ENABLE_MOCKS === 'true'`. Defaults to false: the app talks - * to the real backend unless a developer explicitly opts into mock data. + * True for a `vite build --mode demo` bundle: the GitHub Pages deployment that + * publishes the seeded mock workspace as a backend-free static preview. False + * under the dev server and in every production build. + */ + readonly isDemoBuild: boolean; + /** + * Whether Mock Service Worker should boot. True in development when + * `VITE_ENABLE_MOCKS === 'true'` — the flag defaults to false, so the app + * talks to the real backend unless a developer explicitly opts into mock + * data — and true in a demo build, which has no backend by design. Never true + * in a production build. */ readonly mocksEnabled: boolean; /** @@ -79,13 +87,21 @@ function resolveCurrencySymbol(code: string): string { } } -const mocksEnabled = import.meta.env.DEV && readBool(import.meta.env.VITE_ENABLE_MOCKS); +// A demo build is the only non-development build allowed to boot MSW. A normal +// production build resolves MODE to "production", so the gate below stays closed +// and the mock module graph is eliminated from the bundle exactly as before. +const isDemoBuild = import.meta.env.MODE === 'demo'; + +const mocksEnabled = import.meta.env.DEV + ? readBool(import.meta.env.VITE_ENABLE_MOCKS) + : isDemoBuild; const rawBaseUrl = String(import.meta.env.VITE_API_BASE_URL ?? ''); const defaultCurrency = resolveDefaultCurrency(); export const env: EnvConfig = { isDev: import.meta.env.DEV, + isDemoBuild, mocksEnabled, apiBaseUrl: mocksEnabled ? '' : stripTrailingSlashes(rawBaseUrl), defaultCurrency, diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 05e031f..c44971f 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -16,15 +16,22 @@ import './index.css'; import { env } from './config/env'; async function bootstrap() { - // MSW is an opt-in development tool (VITE_ENABLE_MOCKS=true). The outer - // `import.meta.env.DEV` test is replaced with `false` by Vite in production - // and preview builds, so this whole block — including the dynamic import of - // the MSW module graph — is removed from the production bundle. + // MSW is booted in development, where it is an opt-in tool + // (VITE_ENABLE_MOCKS=true), and in the demo build published to GitHub Pages — + // the one deployment that intentionally has no backend behind it. + // + // The test is written against the statically replaced `import.meta.env` + // literals rather than `env.mocksEnabled` on purpose. Vite replaces `DEV` with + // `false` and `MODE` with the build mode in a production build, so the whole + // condition folds to `false` and this block — including the dynamic import of + // the MSW module graph — is removed from the bundle. Reading it off the `env` + // object instead would make the condition opaque to the minifier and quietly + // ship the mock graph to every deployment. // // Starting the mock API must never gate the render: the boot can fail (see // startMockApi), and an unhandled rejection here would leave the user staring // at a blank page. Mocking is a convenience; the UI is not. - if (import.meta.env.DEV) { + if (import.meta.env.DEV || import.meta.env.MODE === 'demo') { if (env.mocksEnabled) { try { await (await import('./mocks/browser')).startMockApi(); diff --git a/packages/frontend/src/mocks/browser.ts b/packages/frontend/src/mocks/browser.ts index 280a63c..ce9e9e4 100644 --- a/packages/frontend/src/mocks/browser.ts +++ b/packages/frontend/src/mocks/browser.ts @@ -11,8 +11,13 @@ import { reportHandlers } from './handlers/reports'; import { searchHandlers } from './handlers/search'; import { taskHandlers } from './handlers/tasks'; -/** The worker script itself — it is served out of `public/`, not from this graph. */ -const WORKER_SCRIPT_URL = '/mockServiceWorker.js'; +/** + * The worker script itself — it is served out of `public/`, not from this graph. + * Resolved against Vite's BASE_URL rather than hardcoded to the origin root, so + * the registration follows the deployed sub-path (GitHub Pages serves the demo + * build from //) and the worker's scope still covers the application. + */ +const WORKER_SCRIPT_URL = `${import.meta.env.BASE_URL}mockServiceWorker.js`; export const worker = setupWorker( ...authHandlers, @@ -78,5 +83,13 @@ async function dropStaleRegistration(): Promise { */ export async function startMockApi(): Promise { await dropStaleRegistration(); - await worker.start({ onUnhandledRequest: 'bypass' }); + await worker.start({ + onUnhandledRequest: 'bypass', + // MSW registers its worker at the origin root ("/mockServiceWorker.js") by + // default, which 404s whenever the bundle is served from a sub-path — the + // GitHub Pages demo lives under //. Passing the base-prefixed URL also + // keeps the registration's scope over the application; a worker registered a + // level up would leave the page uncontrolled and intercept nothing. + serviceWorker: { url: WORKER_SCRIPT_URL }, + }); } diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index db3c533..76f0755 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -24,6 +24,20 @@ function appVersionPlugin(): Plugin { }; } +// Deployment sub-path the bundle is served from. A GitHub Pages project site +// lives under //, so the Pages workflow passes the `base_path` reported by +// actions/configure-pages (e.g. "/custotal"); a user/org site or a custom domain +// reports "" and stays at the root. Vite accepts only "/" or a value that both +// starts and ends with "/", so the normalisation lives here instead of in every +// consumer of import.meta.env.BASE_URL. +function resolveBasePath(raw: string | undefined): string { + const value = (raw ?? '').trim(); + if (value === '' || value === '/') return '/'; + // An absolute URL (e.g. a CDN origin) is already well-formed: pass it through. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value; + return `/${value.replace(/^\/+/, '').replace(/\/+$/, '')}/`; +} + export default defineConfig(({ mode }) => { // Dev-only transport for real API traffic. When the app is not running with // MSW (VITE_ENABLE_MOCKS=true), relative /api/* requests are forwarded to the @@ -35,6 +49,8 @@ export default defineConfig(({ mode }) => { viteEnv.VITE_API_PROXY_TARGET || viteEnv.VITE_API_BASE_URL || 'http://localhost:4000'; return { + base: resolveBasePath(viteEnv.VITE_BASE_PATH), + plugins: [appVersionPlugin(), react(), tailwindcss()], server: { From 1c57d3c33838637d3cd5fe861ebe18be6a062a95 Mon Sep 17 00:00:00 2001 From: orbivort <273379167+orbivort@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:05:32 +0800 Subject: [PATCH 3/4] chore: update release workflow --- .env.docker.example | 8 ++++++-- .github/workflows/release-finalize.yml | 7 ++++--- .github/workflows/release-prepare.yml | 5 +++-- .github/workflows/release.yml | 26 +++++++++++++++++++++++--- CHANGELOG.md | 11 +++++++++++ README.md | 18 +++++++++++------- docker-compose.yml | 3 +++ docs/self-hosting.md | 10 ++++++++-- 8 files changed, 69 insertions(+), 19 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 8c27a95..3b709f0 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -49,8 +49,12 @@ INSTANCE_HOSTNAME=localhost COOKIE_SECURE=false # ---- Images ------------------------------------------------------------------ -# `local` builds the images in this repository. Set a released version (or -# `latest`) to pull prebuilt images from the GitHub Container Registry instead. +# `local` builds the images in this repository. Set a released version to pull +# prebuilt images from the GitHub Container Registry instead. Keep the `v`: +# the pipeline tags images with the release ref verbatim (`CUSTOTAL_TAG=v1.0.0`). +# One value covers the whole stack — the API, the SPA, and the `-tools` image the +# `migrate` service runs — because released tags are published as a matching pair. +# There is no `latest` tag to fall back on; upgrades are an explicit version bump. CUSTOTAL_TAG=local # Owner-prefixed registry path the release pipeline publishes to. Required for a # released `CUSTOTAL_TAG` to resolve to a published image; leave unset to build. diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 1ce9f2e..fa5f130 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -205,9 +205,10 @@ jobs: fi echo "- Publish pipeline: ${RUN_URL:-not started}" echo "- GitHub Release: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" - echo "- Images: \`ghcr.io/${GITHUB_REPOSITORY_OWNER}/custotal-backend:${TAG#v}\`," - echo " \`ghcr.io/${GITHUB_REPOSITORY_OWNER}/custotal-backend:${TAG#v}-tools\`," - echo " \`ghcr.io/${GITHUB_REPOSITORY_OWNER}/custotal-frontend:${TAG#v}\`" + echo "- Images: \`ghcr.io/${GITHUB_REPOSITORY_OWNER}/custotal-backend:${TAG}\`," + echo " \`ghcr.io/${GITHUB_REPOSITORY_OWNER}/custotal-backend:${TAG}-tools\`," + echo " \`ghcr.io/${GITHUB_REPOSITORY_OWNER}/custotal-frontend:${TAG}\`" + echo " (pin all three with \`CUSTOTAL_TAG=${TAG}\`; no \`latest\` is published)" echo echo "To recover from a failure, re-run this job: the tag step is idempotent and" echo "re-drives the publish pipeline." diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index aa79bd6..1753f07 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -460,8 +460,9 @@ jobs: The merge commit is tagged \`${TAG}\` and the [Release](${repoBase}/actions/workflows/release.yml) pipeline runs: it creates the GitHub Release with generated notes, attaches the frontend bundle, and publishes the - \`custotal-backend\`, \`custotal-backend:${VERSION}-tools\` and \`custotal-frontend\` - container images to GHCR. + \`custotal-backend:${TAG}\` (API), \`custotal-backend:${TAG}-tools\` (migrations) and + \`custotal-frontend:${TAG}\` container images to GHCR. Every image carries the same tag, so + \`CUSTOTAL_TAG=${TAG}\` pins the whole stack, and no floating \`latest\` is published. Re-dispatching **Release: prepare** rebuilds this branch from \`${process.env.BASE}\` and overwrites edits made here, so prefer to fix the branch in place. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6fa18f..1b54903 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,16 @@ # targets from one Dockerfile: the API (`runtime`) and the migration/operator # toolchain (`tools`, published as a `-tools` tag variant of the same # repository). All images are published with an SBOM and a provenance -# attestation; pin a released tag (never `latest`) when self-hosting — see -# docs/self-hosting.md. +# attestation, and every tag is derived from the release ref, so a self-hoster +# pins one `CUSTOTAL_TAG` for the whole stack — see docs/self-hosting.md. +# +# The action's implicit `latest` tag is switched off on every image (the +# `flavor` blocks below). `latest` is never suffixed by the `-tools` flavor, so +# it would exist for the API and the SPA but not for the `tools` image, which +# Compose derives as `${CUSTOTAL_TAG}-tools`: a floating tag that resolves two of +# the three images and hard-fails the `migrate` job is worse than no floating tag +# at all. Releases therefore publish only immutable tags (`v1.0.0`, `1.0.0`, +# `1.0` and their `-tools` counterparts). # # The `gh` CLI is preinstalled on GitHub-hosted runners, so no third-party # release action is required. @@ -139,6 +147,9 @@ jobs: uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository_owner }}/custotal-backend + # Released tags only — see the header for why `latest` is off. + flavor: | + latest=false tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} @@ -167,7 +178,13 @@ jobs: # Same repository as the API image, published as a tag variant # (v1.0.0-tools): the compose `migrate` service pulls the matching # `-tools`, so both must be released together. - flavor: suffix=-tools + # `latest=false` mirrors the API image's flavor block: the suffix is + # never applied to `latest`, so leaving it on would publish a tag for + # the API but not for this variant. `flavor` is a newline-delimited + # list, hence one attribute per line in a single block. + flavor: | + latest=false + suffix=-tools tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} @@ -195,6 +212,9 @@ jobs: uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository_owner }}/custotal-frontend + # Same policy as the backend images: released tags only. + flavor: | + latest=false tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/CHANGELOG.md b/CHANGELOG.md index baa05bc..490dbfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ applicable change types — `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed` change type is omitted for releases that have no changes of that kind. Entries describe the effect on people who run and use Custotal, not the internal commit history. +## [Unreleased] + +### Fixed + +- **Release images** — the release pipeline no longer publishes an implicit `latest` tag for the API + and SPA images. It was never derived for the `-tools` variant the `migrate` service pulls, so + pinning `CUSTOTAL_TAG=latest` gave a stack whose migration job had no image to resolve. Pin a + released version (`CUSTOTAL_TAG=v1.0.0`) instead; the published tags are listed in the Docker + deployment documentation. + ## [1.0.0] - 2026-09-13 Initial public release — Custotal is a self-hosted CRM that keeps your customer data in your @@ -62,4 +72,5 @@ control. attestation, and the dependency-review workflow blocks newly introduced high-severity advisories and copyleft licenses. +[Unreleased]: https://github.com/orbivort/custotal/compare/v1.0.0...HEAD [1.0.0]: https://github.com/orbivort/custotal/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 2b6d807..8bce044 100644 --- a/README.md +++ b/README.md @@ -272,13 +272,17 @@ PowerShell, where `\` is not a line continuation. Both images are multi-stage and run as a non-root user with a container health check. Tagged builds publish them to the GitHub Container Registry (`ghcr.io/orbivort/custotal-backend`, `ghcr.io/orbivort/custotal-frontend`); set -`CUSTOTAL_TAG` in `.env.docker` to pull a released version instead of building -locally. The backend is published twice from one Dockerfile: the API image -(production dependencies only) and the matching `-tools` variant that the -one-shot `migrate` service uses — the only one shipping the Prisma CLI. The -Compose stack runs production-parity images; use `pnpm dev` for live-reload -development. See [Docker deployment](docs/self-hosting.md#docker) for -configuration, migrations, backups, and upgrades. +`CUSTOTAL_TAG=v1.0.0` in `.env.docker` to pull a released version instead of +building locally (`v` included — images are tagged with the release ref). The +backend is published twice from one Dockerfile: the API image (production +dependencies only) and the matching `-tools` variant that the one-shot +`migrate` service uses — the only one shipping the Prisma CLI. The two variants +are released under the same version (`v1.0.0` and `v1.0.0-tools`), so a single +`CUSTOTAL_TAG` pins the whole stack; only released tags are published, with no +`latest` to float underneath a deployment. The Compose stack runs +production-parity images; use `pnpm dev` for live-reload development. See +[Docker deployment](docs/self-hosting.md#docker) for configuration, migrations, +backups, and upgrades. Both images resolve their npm/pnpm packages from `https://registry.npmjs.org/` by default. To build through a mirror, set `NPM_REGISTRY` in `.env.docker` (used diff --git a/docker-compose.yml b/docker-compose.yml index a8ac7d4..277fc68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,11 @@ # # Images are built locally from this repository by default. To run the images # the release pipeline publishes to GHCR, set CUSTOTAL_TAG to a released version +# (`v1.0.0`, keeping the `v` — the pipeline tags with the release ref verbatim) # and CUSTOTAL_REGISTRY to the owner-prefixed registry path (e.g. # `ghcr.io/orbivort/`); `pnpm docker:pull` then fetches them instead of building. +# Only released tags are published — there is no `latest` — and one tag resolves +# all three images, including the `-tools` variant below. name: custotal # Shared API configuration, injected ahead of the single `.env.docker` file so a diff --git a/docs/self-hosting.md b/docs/self-hosting.md index de56d0f..031569b 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -96,7 +96,9 @@ export COMPOSE_ENV_FILES=.env.docker # PowerShell: $env:COMPOSE_ENV_FILES = ' `COOKIE_SECURE` must be `true` once TLS terminates in front of `web`, and `APP_PUBLIC_URL` / `CORS_ORIGINS` must be the exact public origin — invite/reset links and the CSRF origin check depend on them. Set `CUSTOTAL_TAG` to a released version to pull prebuilt images from the GitHub Container -Registry instead of building locally. +Registry instead of building locally. Keep the `v` (`CUSTOTAL_TAG=v1.0.0`): the pipeline tags images +with the release ref as-is. Only released tags are published — there is no `latest` — so an upgrade +is always an explicit version bump in `.env.docker` followed by `pnpm docker:pull`. ### Package registry mirror @@ -152,7 +154,11 @@ The backend Dockerfile builds two targets and Compose uses both: Only the `runtime` image runs continuously, so only it is size-optimised; `tools` is pulled, used for a few seconds per upgrade, and can be dropped again with `docker image rm custotal-backend:-tools`. -Released tags publish both (`v1.0.0` and `v1.0.0-tools`), so pin the pair together. +Released versions publish every image under the same tag — `custotal-backend:v1.0.0`, +`custotal-backend:v1.0.0-tools` and `custotal-frontend:v1.0.0` — so one `CUSTOTAL_TAG` pins the whole +stack. Shorter aliases (`1.0.0`, `1.0`) are published too, but prefer the full release tag. There is +deliberately no `latest`: the `-tools` variant is derived as `${CUSTOTAL_TAG}-tools`, so a floating +tag could never resolve the `migrate` job's image. Because the API image ships no Prisma CLI, `RUN_MIGRATIONS=true` works on the `tools` image only: the API entrypoint detects the missing CLI, says so, and exits instead of serving an unmigrated database. From 234f840e4ce993a9e944974549dd0c15399f84b2 Mon Sep 17 00:00:00 2001 From: orbivort <273379167+orbivort@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:12:29 +0800 Subject: [PATCH 4/4] docs: update changelog --- CHANGELOG.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 490dbfd..edda5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,11 @@ on people who run and use Custotal, not the internal commit history. ## [Unreleased] -### Fixed +### Security -- **Release images** — the release pipeline no longer publishes an implicit `latest` tag for the API - and SPA images. It was never derived for the `-tools` variant the `migrate` service pulls, so - pinning `CUSTOTAL_TAG=latest` gave a stack whose migration job had no image to resolve. Pin a - released version (`CUSTOTAL_TAG=v1.0.0`) instead; the published tags are listed in the Docker - deployment documentation. +- **Email validation hardening** — an email address is now checked with a linear-time validator that + caps the field at the RFC 5321 maximum (254 characters) before any matching, instead of a pattern + that backtracked. ## [1.0.0] - 2026-09-13