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: 6 additions & 2 deletions .env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -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
134 changes: 134 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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 /<repo>/, 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, "/<repo>" 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
7 changes: 4 additions & 3 deletions .github/workflows/release-finalize.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/release-prepare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 23 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@
# targets from one Dockerfile: the API (`runtime`) and the migration/operator
# toolchain (`tools`, published as a `<version>-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.
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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
# `<tag>-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}}
Expand Down Expand Up @@ -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}}
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ 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]

### Security

- **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

Initial public release — Custotal is a self-hosted CRM that keeps your customer data in your
Expand Down Expand Up @@ -62,4 +70,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
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -267,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 `<tag>-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 `<tag>-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
Expand Down Expand Up @@ -323,6 +332,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` |
Expand Down
5 changes: 4 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tag>-tools` variant below.
name: custotal

# Shared API configuration, injected ahead of the single `.env.docker` file so a
Expand Down
Loading
Loading