Compare commits

...

29 Commits

Author SHA1 Message Date
Ami
dc78c0550e fix(alembic): strip sslmode before psycopg2, ensure postgresql:// dialect
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 19:42:04 +07:00
Ami
0371a36209 fix(alembic): strip sslmode param before passing to psycopg2
Some checks failed
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 17:56:09 +07:00
Ami
6cd6ce01eb fix(alembic): always read DATABASE_URL env directly to avoid ini override issues
Some checks failed
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 17:50:17 +07:00
Ami
ac719b219f fix(alembic): ensure postgresql:// dialect and bypass ConfigParser URL parsing
Some checks failed
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 17:45:04 +07:00
Ami
283f75a5e2 fix(alembic): URL-decode DATABASE_URL before passing to ConfigParser
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
2026-04-21 17:43:17 +07:00
Ami
6a513a97ce fix: copy alembic.ini into image
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 17:39:24 +07:00
Ami
6211290923 fix: add alembic dir to image + run migrations in entrypoint
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 17:36:47 +07:00
Ami
6b40c04b0d fix(nginx): remove trailing slash from proxy_pass to preserve /api prefix
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 16:25:32 +07:00
Ami
355b5156a5 fix: add postgresql-client for pg_isready in entrypoint
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 15:57:41 +07:00
Ami
51b8e15726 fix: add entrypoint script to wait for postgres before starting
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 15:38:56 +07:00
Ami
35ea49d6d2 fix: strip sslmode from database URL (asyncpg doesn't support it)
Some checks failed
CI / API Lint (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 14:54:18 +07:00
Ami
d3af80145b fix: auto-convert postgres:// and postgresql:// to postgresql+asyncpg:// in settings
Some checks failed
CI / API Lint (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 14:29:23 +07:00
Ami
1c2bdbf310 fix(nginx): strip /api prefix in proxy_pass (api_prefix is /api/v1)
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 11:36:01 +07:00
Ami
f8cdbf8d74 feat: combine Admin UI into single container with nginx proxy
Some checks failed
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 11:30:56 +07:00
Ami
f195a44707 fix: remove env var substitution in supervisord.conf (supervisord doesn't support it)
Some checks failed
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
2026-04-21 11:01:52 +07:00
Ami
cb59bea178 fix: supervisor package name (debian), not supervisord
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 10:59:06 +07:00
Ami
7680b0eb91 fix: install supervisord in runtime stage (closes deploy)
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-21 10:49:59 +07:00
Ami Bot
08e0ae7e83 fix: Dockerfile - use PYTHONPATH for playwright install chromium
Some checks failed
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
2026-04-20 20:32:30 +07:00
Ami Bot
3265228ce6 rename: Dockerfile.app -> Dockerfile for Easypanel compatibility
Some checks failed
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
2026-04-20 20:14:45 +07:00
Ami Bot
062a384444 feat: add Easypanel deployment config
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
- Dockerfile.app: single container with supervisord (API + Worker + Beat + Scanner)
- supervisord.conf: process manager for 4 services in one container
- EASYPANEL.md: step-by-step deploy guide for Easypanel
- EASYPANEL-README.md: repo structure and deploy flow overview
2026-04-20 18:37:20 +07:00
James Cottrill
d8e0a34e04 feat: account management — change email, password, and CLI reset (#10)
API:
- PATCH /auth/me — update email and display name
- PATCH /auth/me/password — change password (requires current)
- GET /auth/me now returns full profile (email, full_name, role)

CLI:
- python -m src.cli.reset_password --email <email> --password <pw>
  for recovery when locked out (run via docker exec)

Admin UI:
- User menu dropdown on the top nav (click username → Account /
  Sign out) replaces the inline sign-out link
- /account page with profile form (email + display name) and
  change password form (current + new + confirm)
2026-04-18 21:53:32 +01:00
James Cottrill
142e2373d3 feat: consent records page, tab persistence, and snippet copy fix (#9)
feat: consent records list endpoint and top-level admin page
2026-04-18 21:22:06 +01:00
James Cottrill
bebcf901f4 chore: remove compliance UI from admin dashboard (#8) 2026-04-18 20:33:20 +01:00
James Cottrill
e0f1dd43e8 fix(scanner): reliable cookie discovery, auto-categorisation, and scan scheduling UI (#7)
Scanner fixes:
- Remove conflicting ``path`` from consent pre-seed cookie (Playwright
  rejects cookies with both ``url`` and ``path``).
- Switch to ``networkidle`` + 5s + 2s delayed second-pass for reliable
  cookie capture.
- Check sitemap Content-Type to skip SPA HTML fallbacks.
- Propagate ``auto_category`` from scan results to the cookies table
  during sync (was silently dropped).
- Add ``_gcl_ls`` to the Open Cookie Database CSV.
- Classify ``_consentos_*`` cookies as necessary directly in the
  classification engine.
- Add ``seed_known_cookies`` to the bootstrap init container command.

Admin UI:
- Add scan schedule control to the Scans tab — preset options
  (disabled/daily/weekly/fortnightly/monthly) plus custom cron input.
  Saves ``scan_schedule_cron`` on the site config.
2026-04-18 20:14:32 +01:00
James Cottrill
80dfc15319 ci: release workflow — build + push container images to GHCR on release (#6)
* feat: add release workflow to build and push container images to GHCR

Triggers on GitHub Release publish. Builds three container images
(consentos-api, consentos-scanner, consentos-admin-ui) and pushes
them to ghcr.io/consentos/ tagged with the semver release version
(e.g. v1.0.0, 1.0), plus ``latest``.

Release flow:
  1. Merge PRs to master.
  2. Tag: ``git tag v1.0.0 && git push origin v1.0.0``
  3. Create a GitHub Release from the tag.
  4. Workflow fires, images land on GHCR.
  5. Deploy by pointing Helm values or docker-compose at the tag.

Uses ``docker/metadata-action`` for tag derivation and
``docker/build-push-action`` for the builds. Auth uses the
default ``GITHUB_TOKEN`` with ``packages: write`` — no extra
secrets needed.

The admin-ui image uses the repo root as the build context (same
as ``docker-compose.prod.yml``) so the Dockerfile can pull in
``apps/banner/`` alongside ``apps/admin-ui/`` and bundle the
banner output at the nginx root.

* chore: auto-graduate changelog on release + CI path filters

CI workflow (``ci.yml``):
  - Uses ``dorny/paths-filter`` to detect which apps changed. Each
    job group (api, scanner, banner, admin-ui) now has an
    ``if: needs.changes.outputs.<app> == 'true'`` guard so it only
    runs when files under its ``apps/<app>/`` directory were
    modified. A docs-only or infra-only PR no longer triggers the
    full lint + test + build matrix.
2026-04-18 16:14:40 +01:00
James Cottrill
10e5c92882 docs: add deployment guide (Docker Compose, Kubernetes, Cloud Run) (#5)
Step-by-step deployment guide covering three paths: Docker Compose
on a single VM, Kubernetes via the existing Helm chart, and Google
Cloud Run / serverless. Includes a full environment variables
reference, GeoIP CDN header configuration table (Cloudflare, Vercel,
GCP, AWS, custom), banner integration checklist, and troubleshooting
section covering common issues seen during the dev deployment
(async loader race, CORS, scanner PORT leak, blocker bridge).
2026-04-17 11:26:33 +01:00
James Cottrill
bd465008e5 fix(banner): bridge blocker state loader↔bundle and sweep stale cookies on consent change (#4)
* fix(banner): bridge blocker state between loader and bundle

``consent-loader.js`` and ``consent-bundle.js`` are built as two
separate rollup IIFEs, so each inlines a private copy of
``blocker.ts``. The loader's copy is the one that actually matters
— it installs the ``document.cookie`` / ``Storage.prototype.setItem``
/ ``MutationObserver`` proxies, and those proxies close over the
loader's private ``acceptedCategories`` set and its
``blockedScripts`` queue.

The bundle used to ``import { updateAcceptedCategories } from './blocker'``
and call it from ``handleConsent``. That updated the **bundle's**
dead-end copy of the blocker module — a different ``Set`` object in
a different scope from the one the proxies read — so after the user
granted consent the loader's proxies stayed stuck on
``Set(['necessary'])``, any non-necessary cookie write kept being
silently dropped, and the loader's queue of blocked scripts was
never released.

Fix by exposing the loader's live ``updateAcceptedCategories`` on
``window.__consentos._updateBlocker`` right after
``installBlocker()``, and replacing the bundle's dead-end import
with a helper that calls through the bridge. The bundle no longer
imports from ``./blocker`` at all; rollup tree-shakes the bundle's
copy out, so ``consent-bundle.js`` gets slightly smaller.

Tests (``__tests__/blocker-bridge.test.ts``) cover:
* Bridge is called with the exact accepted list.
* Bridge is forwarded verbatim (no slug filtering).
* Missing bridge logs a warning and doesn't throw.
* Missing ``window.__consentos`` logs a warning and doesn't throw.

``vi.hoisted`` seeds ``window.__consentos`` before banner.ts's
module-level ``init()`` runs so the import-time IIFE doesn't throw
on the empty global.

* fix(banner): sweep disallowed cookies + storage on consent update

When the loader runs it now proactively deletes any pre-existing
cookies (and localStorage / sessionStorage keys) that classify into
a category the visitor hasn't consented to. Fixes the common case
of an ``_ga`` that slipped through before the loader was installed
— e.g. on a host page that loads the loader with ``async`` or
places another tracker above it in ``<head>`` — sitting there
forever because the blocker's only defence was a setter proxy on
future writes.

Sweep semantics:

- Runs on every ``updateAcceptedCategories`` call (consent narrowed
  → the just-revoked categories' cookies are wiped) plus an
  explicit call from the loader's "no consent yet" branch (initial
  visit with pre-existing trackers from elsewhere).
- Only deletes cookies / keys whose classifier hits a known
  pattern (``_ga``, ``_fbp``, ``intercom-*`` etc. — same lists as
  the proxied setters). Unknown / unclassified cookies are left
  alone: we can't attribute them and won't risk clobbering
  first-party session state.
- ``_consentos_*`` is always preserved.
- For cookies, we don't know the original ``domain`` / ``path``
  (``document.cookie`` doesn't expose them), so we fire deletes
  against every plausible domain variant — bare hostname, leading
  ``.``, and every parent domain walked up from the left. Covers
  the GA "set on ``.example.com`` from a subdomain page" case
  without over-deleting.
- Deletions bypass the proxied setter and go directly through the
  cached ``originalCookieDescriptor`` captured before the proxy was
  installed, so the blocker doesn't eat its own expiry writes.
- Storage access is wrapped in ``safeStorage`` — sandboxed /
  cross-origin iframes that throw on ``window.localStorage`` are
  skipped rather than crashing the loader.

Tests in ``__tests__/blocker.test.ts`` cover: non-consented analytics
cookies are deleted, consented ones are preserved, unknown cookies
survive, ``_consentos_*`` is untouched, revoking a category after
seeding new cookies triggers a follow-up sweep, and localStorage
cleanup follows the same rules. 6 new cases, 373 passing total in
the banner suite.
2026-04-14 17:30:02 +01:00
James Cottrill
0fbe2717f2 fix(scanner): pre-seed ConsentOS consent so crawls see post-consent state (#2)
* fix(scanner): pre-seed accepted ConsentOS consent before crawling

A site running ConsentOS exposes one set of cookies before consent
(strictly necessary only) and a much larger set after the visitor
accepts analytics/marketing/personalisation. The scanner is meant to
answer "what does this site actually load?" — but because the crawler
clears cookies and navigates without ever interacting with the
banner, every scan returned the pre-consent view. Useful for spotting
trackers that fire before consent (which is what
``consent_validator.py`` does), useless for the cookie inventory the
admin UI exists to display.

Plant ``_consentos_consent`` on the browser context with all
categories accepted before ``page.goto``. The cookie payload mirrors
``apps/banner/src/consent.ts:writeConsent`` exactly (URL-encoded
``ConsentState`` JSON, ``Lax`` SameSite, year-long expiry) so the
loader's ``readConsent`` short-circuits straight to
``updateAcceptedCategories(['necessary','functional','analytics',
'marketing','personalisation'])`` — the blocker is bypassed and the
crawl sees what the visitor would see.

Pre-consent compliance checks live in ``consent_validator.py`` and
use a separate code path; this change only touches the cookie
inventory crawl.

* style: ruff format crawler.py
2026-04-14 14:05:35 +01:00
James Cottrill
8d15ec4398 Per-site configurable cookie categories (#3)
* feat: per-site configurable cookie categories

Operators can now choose which cookie categories the banner displays
for a given site — useful for sites that genuinely don't use
e.g. marketing cookies and shouldn't be forced to show the toggle.

**Backend**

* New ``enabled_categories`` JSONB column on ``site_configs``,
  ``site_group_configs``, and ``org_configs`` (migration 0003).
  NULL at a level means "inherit"; an explicit list overrides.
* ``config_resolver`` merges ``enabled_categories`` through the
  existing cascade (system → org → group → site) and normalises
  the result via ``_normalise_enabled_categories``:
  - Unknown slugs stripped.
  - ``necessary`` is forced in regardless of the operator's input
    — it's never optional.
  - Empty / invalid values fall back to the full five-category
    default so a cleared field doesn't silently hide the banner.
  - Output is returned in canonical display order so insertion
    order from the cascade doesn't leak into the UI.
* ``build_public_config`` surfaces ``enabled_categories`` to the
  banner-facing public config endpoint.
* Schemas for site/group/org config create + update + response all
  include the new field.

**Banner**

* ``apps/banner/src/banner.ts`` replaces the hard-coded
  ``ALL_CATEGORIES`` / ``NON_ESSENTIAL`` constants with a runtime
  ``resolveEnabledCategories(config)`` helper. ``renderCategories``
  takes the enabled list and only renders toggles for those
  categories; ``nonEssentialFor(enabled)`` derives the user-toggleable
  subset. Falls back to all five when the field is missing in the
  config payload so older banner bundles against newer APIs (and
  vice versa) don't break.
* ``SiteConfig`` type in ``apps/banner/src/types.ts`` has
  ``enabled_categories?: CategorySlug[]`` to match.

**Admin UI**

* New ``SiteCategoriesTab`` component — five checkboxes, ``necessary``
  locked on, with "Reset to inherited" to clear the site override.
  Wired in as a new core tab on ``SiteDetailPage`` between
  Configuration and Cookies.
* ``SiteConfig`` type in ``types/api.ts`` declares ``enabled_categories``
  and a new ``ALL_COOKIE_CATEGORIES`` constant exposing label/description
  metadata shared between the tab component and any future display of
  the list.

**Semantics of a disabled category**

When the operator unticks e.g. ``marketing`` for a site:

* The toggle is not rendered in the banner.
* A visitor can never grant consent for ``marketing``.
* Any cookie or script that classifies into ``marketing`` stays
  blocked permanently by the auto-blocker.

That's the correct behaviour for sites that genuinely don't use a
category: declare it, hide it from the visitor, have the blocker
enforce it.

**Tests**

* ``test_config_resolver.py`` — 13 new cases covering the full
  cascade, ``necessary`` forcing, unknown-slug stripping, empty /
  non-list values, canonical display order, and the public-config
  surface. 37 passed total.
* ``test_SiteCategoriesTab.test.tsx`` — renders all five, locks
  ``necessary``, pre-fills from an override, saves the explicit
  list, and resets to inherited by sending NULL. 6 cases.
* Full API suite (610) and admin-ui suite (139) both green;
  banner bundle builds cleanly with 363 tests passing.

* style: ruff format config_resolver.py
2026-04-14 14:05:31 +01:00
56 changed files with 4055 additions and 153 deletions

View File

@@ -11,8 +11,35 @@ concurrency:
cancel-in-progress: true
jobs:
# ── Detect which apps changed ──────────────────────────────────────
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
scanner: ${{ steps.filter.outputs.scanner }}
banner: ${{ steps.filter.outputs.banner }}
admin-ui: ${{ steps.filter.outputs.admin-ui }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'apps/api/**'
scanner:
- 'apps/scanner/**'
banner:
- 'apps/banner/**'
admin-ui:
- 'apps/admin-ui/**'
# ── API ────────────────────────────────────────────────────────────
api-lint:
name: API Lint
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -29,6 +56,8 @@ jobs:
api-test:
name: API Tests
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -74,8 +103,11 @@ jobs:
DATABASE_URL: postgresql://consentos_test:consentos_test@localhost:5432/consentos_test
- run: pytest --cov=src --cov-report=term-missing -v
# ── Scanner ────────────────────────────────────────────────────────
scanner-lint:
name: Scanner Lint
needs: changes
if: needs.changes.outputs.scanner == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -92,6 +124,8 @@ jobs:
scanner-test:
name: Scanner Tests
needs: changes
if: needs.changes.outputs.scanner == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -105,8 +139,11 @@ jobs:
- run: pip install -e ".[dev]"
- run: pytest --cov=src --cov-report=term-missing -v
# ── Banner ─────────────────────────────────────────────────────────
banner-lint:
name: Banner Lint & Typecheck
needs: changes
if: needs.changes.outputs.banner == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -123,6 +160,8 @@ jobs:
banner-test:
name: Banner Tests
needs: changes
if: needs.changes.outputs.banner == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -139,8 +178,9 @@ jobs:
banner-build:
name: Banner Build
runs-on: ubuntu-latest
needs: [banner-test, banner-lint]
if: needs.changes.outputs.banner == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/banner
@@ -163,8 +203,11 @@ jobs:
echo "::warning::consent-loader.js is ${LOADER_SIZE} bytes (>20KB) — consider optimising"
fi
# ── Admin UI ───────────────────────────────────────────────────────
admin-ui-lint:
name: Admin UI Typecheck
needs: changes
if: needs.changes.outputs.admin-ui == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -181,6 +224,8 @@ jobs:
admin-ui-test:
name: Admin UI Tests
needs: changes
if: needs.changes.outputs.admin-ui == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -197,8 +242,9 @@ jobs:
admin-ui-build:
name: Admin UI Build
runs-on: ubuntu-latest
needs: [admin-ui-test, admin-ui-lint]
if: needs.changes.outputs.admin-ui == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/admin-ui

30
.github/workflows/pr-title.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: PR Title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
jobs:
lint:
name: Conventional commit title
runs-on: ubuntu-latest
steps:
- name: Check PR title
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
chore
refactor
docs
test
style
perf
ci
build
requireScope: false
subjectPattern: ^.+$
subjectPatternError: "PR title must follow conventional commits: type: description (e.g. feat: add cookie categories)"

181
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,181 @@
# Cut a release and build container images.
#
# Triggered manually via ``workflow_dispatch`` (Actions tab → Run
# workflow) when you're ready to ship. Not on every PR merge.
#
# Flow:
# 1. ``ietf-tools/semver-action`` derives the next version from
# conventional commit messages since the last tag (feat → minor,
# fix → patch, breaking → major).
# 2. ``requarks/changelog-action`` generates release notes from the
# commit diff between the new and previous tags.
# 3. ``ncipollo/release-action`` creates the GitHub Release.
# 4. ``requarks/changelog-action`` writes CHANGELOG.md and
# ``stefanzweifel/git-auto-commit-action`` commits it back to
# master with ``[skip ci]``.
# 5. All three container images are built and pushed to GHCR,
# tagged with the semver version + ``latest``.
name: Release
on:
workflow_dispatch:
jobs:
# ── Version + Release + Changelog ────────────────────────────────
version:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
new: ${{ steps.semver.outputs.next }}
newStrict: ${{ steps.semver.outputs.nextStrict }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get next version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: master
- name: Generate release notes
id: changelog
uses: requarks/changelog-action@v1
with:
token: ${{ github.token }}
fromTag: master
toTag: ${{ steps.semver.outputs.current }}
writeToFile: false
- name: Create release
uses: ncipollo/release-action@v1.12.0
with:
allowUpdates: true
draft: false
makeLatest: true
tag: ${{ steps.semver.outputs.next }}
body: ${{ steps.changelog.outputs.changes }}
token: ${{ github.token }}
- name: Write CHANGELOG.md
uses: requarks/changelog-action@v1
with:
token: ${{ github.token }}
fromTag: ${{ steps.semver.outputs.next }}
toTag: ${{ steps.semver.outputs.current }}
writeToFile: true
- name: Commit CHANGELOG.md
uses: stefanzweifel/git-auto-commit-action@v4
with:
branch: master
commit_message: "docs: update CHANGELOG.md for ${{ steps.semver.outputs.next }} [skip ci]"
file_pattern: CHANGELOG.md
# ── Build and push container images ──────────────────────────────
build-api:
name: API image
needs: version
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/consentos/consentos-api
tags: |
type=semver,pattern={{version}},value=${{ needs.version.outputs.newStrict }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.newStrict }}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: apps/api
file: apps/api/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-scanner:
name: Scanner image
needs: version
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/consentos/consentos-scanner
tags: |
type=semver,pattern={{version}},value=${{ needs.version.outputs.newStrict }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.newStrict }}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: apps/scanner
file: apps/scanner/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-admin-ui:
name: Admin UI image
needs: version
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/consentos/consentos-admin-ui
tags: |
type=semver,pattern={{version}},value=${{ needs.version.outputs.newStrict }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.newStrict }}
type=raw,value=latest
# Context is the repo root — the admin-ui Dockerfile pulls in
# apps/banner/ alongside apps/admin-ui/ and bundles the banner
# output at the nginx root.
- uses: docker/build-push-action@v6
with:
context: .
file: apps/admin-ui/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -5,8 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2026-03-18
Initial public release of ConsentOS.

71
Dockerfile Normal file
View File

@@ -0,0 +1,71 @@
# ── Build stage: Python deps ────────────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
COPY apps/api/pyproject.toml ./api/pyproject.toml
COPY apps/scanner/pyproject.toml ./scanner/pyproject.toml
RUN pip install --no-cache-dir --prefix=/install api/.
RUN pip install --no-cache-dir --prefix=/install scanner/. \
&& PYTHONPATH=/install/lib/python3.12/site-packages \
/install/bin/playwright install chromium --with-deps
# ── Build stage: banner bundle ─────────────────────────────────────────
FROM node:20-slim AS banner-builder
WORKDIR /build/banner
COPY apps/banner/package.json apps/banner/package-lock.json ./
RUN npm ci
COPY apps/banner/ .
RUN npm run build
# ── Build stage: admin UI ──────────────────────────────────────────────
FROM node:20-slim AS admin-builder
WORKDIR /build/admin
COPY apps/admin-ui/package.json apps/admin-ui/package-lock.json ./
RUN npm ci
COPY apps/admin-ui/ .
COPY --from=banner-builder /build/banner/dist/ ./public/
RUN npx vite build
# ── Runtime stage ──────────────────────────────────────────────────────
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 postgresql-client curl tini supervisor nginx \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Copy Python deps from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY apps/api/src ./src
COPY apps/api/alembic ./alembic
COPY apps/api/alembic.ini ./alembic.ini
COPY apps/scanner/src ./src_scanner
RUN if [ -d src_scanner ]; then \
cp -r src_scanner/* src/ 2>/dev/null || true; \
fi
# Copy built Admin UI static files
COPY --from=admin-builder /build/admin/dist /var/www/html
# Copy configs
COPY apps/admin-ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY supervisord.conf /etc/supervisord.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/tini", "--", "supervisord", "-c", "/etc/supervisord.conf"]

88
EASYPANEL-README.md Normal file
View File

@@ -0,0 +1,88 @@
# ConsentOS — Repository Structure for Easypanel
## Files to Add to Repository
```
consent-os/ ← ต้อง push ขึ้น Git
├── Dockerfile.app ← ✅ สร้างใหม่ (API container)
├── supervisord.conf ← ✅ สร้างใหม่ (process manager)
├── docker-compose.yml ← ✅ สร้างให้แล้ว
├── .env.example ← ✅ สร้างให้แล้ว
├── DEPLOY.md ← ✅ คู่มือ deploy ทั่วไป
└── EASYPANEL.md ← ✅ คู่มือ deploy บน Easypanel
```
## Files ที่มีอยู่แล้วใน Repo (อย่าลบ)
```
consent-os/
├── apps/
│ ├── api/ ← ✅ keep
│ │ ├── Dockerfile ← ใช้แทน Dockerfile.app สำหรับ dev
│ │ ├── src/ ← ✅ keep
│ │ ├── pyproject.toml ← ✅ keep
│ │ └── ...
│ ├── scanner/ ← ✅ keep
│ │ ├── Dockerfile ← ใช้แทน Dockerfile.app สำหรับ dev
│ │ └── ...
│ ├── admin-ui/ ← ✅ keep (deploy แยก)
│ │ └── Dockerfile
│ └── banner/ ← ✅ keep (admin-ui ดึงมาตอน build)
└── ...
```
## Deploy Flow on Easypanel
```
Git push
├──► Project: consentos
│ Service: consentos-db (Postgres) ───┐
│ Service: consentos-redis (Redis) ───┤
│ Service: consentos-app (App) ───┤
│ ───┘
│ (auto network)
└──► Project: consentos-admin
Service: consentos-admin (Admin UI)
```
## Important Notes
### 1. Database Connection
- Easypanel ใช้ **service name** เป็น hostname ภายใน Docker network
- `DATABASE_URL=postgresql+asyncpg://consentos:PASS@consentos-db:5432/consentos`
- `REDIS_URL=redis://:PASS@consentos-redis:6379/0`
### 2. Dockerfile.app Location
- ต้องอยู่ที่ **root ของ repo** ไม่ใช่ใน apps/
- เพราะ Easypanel build จาก repo root
### 3. Admin UI Deploy แยก
- Admin UI deploy เป็น **separate project/service** เพราะ:
- ใช้ Dockerfile คนละตัว
- ต้องการ domain + SSL แยก
- nginx รันใน container ของตัวเอง
### 4. CORS
- หลัง deploy admin UI → copy domain ไปใส่ใน `ALLOWED_ORIGINS` ของ consentos-app
## Quick Setup Commands
```bash
# Clone repo
git clone https://github.com/kunthawat/consentos.git
cd consentos
# Copy deployment files
cp ~/consent-deploy/Dockerfile.app .
cp ~/consent-deploy/supervisord.conf .
cp ~/consent-deploy/docker-compose.yml .
cp ~/consent-deploy/.env.example .env
cp ~/consent-deploy/EASYPANEL.md .
# Push to your Git
git add .
git commit -m "Add Easypanel deployment config"
git push
```

273
EASYPANEL.md Normal file
View File

@@ -0,0 +1,273 @@
# Deploy ConsentOS on Easypanel
## Architecture
```
consentos (Project)
├── consentos-db ← PostgreSQL (Easypanel managed)
├── consentos-redis ← Redis (Easypanel managed)
└── consentos-app ← 1 container รันทุกอย่าง (API + Worker + Beat + Scanner)
└── 5 services ภายใน via supervisord
consentos-admin (Separate Project)
└── consentos-admin ← Admin UI (nginx, static files)
```
---
## Step 1: Clone Repo to Git
```bash
git clone https://github.com/kunthawat/consentos.git
# Push to your own Git repo (GitHub/Gitea)
# ต้องมี Dockerfile.app + supervisord.conf + apps/ ที่ root
```
> ถ้าต้องการแยก repo — ต้อง push เฉพาะ apps/ กับ Dockerfile.app + supervisord.conf + docker-compose.yml
---
## Step 2: Create Database Services
### 2.1 PostgreSQL
ใน Easypanel → สร้าง **Postgres Service**:
| Field | Value |
|-------|-------|
| Name | `consentos-db` |
| Database | `consentos` |
| User | `consentos` |
| Password | (generate strong password) |
**Copy connection details** — จะได้ใช้ใน env:
- Host: `consentos-db` (internal Docker network)
- Port: `5432`
- User: `consentos`
- Database: `consentos`
### 2.2 Redis
ใน Easypanel → สร้าง **Redis Service**:
| Field | Value |
|-------|-------|
| Name | `consentos-redis` |
| Password | (generate strong password) |
---
## Step 3: Create App Service (Backend)
ใน Easypanel → สร้าง **App Service**:
### Source
| Field | Value |
|-------|-------|
| Build Method | **Dockerfile** |
| Dockerfile Path | `Dockerfile.app` |
### Environment Variables
```env
# ── Application ───────────────────────────────────────────────────────
APP_NAME=ConsentOS
ENVIRONMENT=production
DEBUG=false
LOG_LEVEL=INFO
# ── Database (use Easypanel service names as host) ───────────────────
DATABASE_URL=postgresql+asyncpg://consentos:PASSWORD@consentos-db:5432/consentos
# ── Redis ─────────────────────────────────────────────────────────────
REDIS_URL=redis://:PASSWORD@consentos-redis:6379/0
# ── Authentication ────────────────────────────────────────────────────
# Generate with: openssl rand -base64 48
JWT_SECRET_KEY=YOUR_JWT_SECRET_HERE
PSEUDONYMISATION_SECRET=YOUR_JWT_SECRET_HERE
# ── Admin Bootstrap (runs once on first deploy) ──────────────────────
INITIAL_ADMIN_EMAIL=admin@yourdomain.com
INITIAL_ADMIN_PASSWORD=YOUR_ADMIN_PASSWORD
INITIAL_ADMIN_FULL_NAME=Admin
INITIAL_ORG_NAME=Your Company
INITIAL_ORG_SLUG=your-company
# ── CORS ───────────────────────────────────────────────────────────────
# ตั้ง domain ของ admin UI ที่จะ deploy ใน step ถัดไป
ALLOWED_ORIGINS=https://admin.yourdomain.com,https://consent.yourdomain.com
# ── Scanner (optional) ────────────────────────────────────────────────
ENABLE_SCANNER=false
CRAWLER_HEADLESS=true
CRAWLER_TIMEOUT_MS=30000
MAX_PAGES_PER_SCAN=50
# ── Performance ──────────────────────────────────────────────────────
API_WORKERS=2
```
### Mounts (Data Persistence)
| Type | mountPath |
|------|-----------|
| **Volume** | `/var/log/supervisor` |
### Ports
| Published | Target |
|-----------|--------|
| `8000` | `8000` |
### Deploy Settings
| Field | Value |
|-------|-------|
| Container Replicas | `1` |
| Shm Size | `256mb` |
---
## Step 4: Create Admin UI (Separate App)
สร้าง **อีก Project** ชื่อ `consentos-admin`:
### Source
| Field | Value |
|-------|-------|
| Build Method | **Dockerfile** |
| Dockerfile Path | `apps/admin-ui/Dockerfile` |
### Environment Variables
```env
# URL ของ API service (ใช้ service name ของ Easypanel)
VITE_API_URL=https://consent.yourdomain.com
```
### Domains
เพิ่ม domain `admin.yourdomain.com` → ใช้ SSL auto ของ Easypanel
---
## Step 5: Update CORS + Deploy
หลัง deploy admin UI ได้ domain แล้ว:
1. กลับไปที่ `consentos-app`**Environment** → แก้ `ALLOWED_ORIGINS`:
```
ALLOWED_ORIGINS=https://admin.yourdomain.com,https://consent.yourdomain.com
```
2. **Redeploy** `consentos-app`
---
## Step 6: First-Time Setup
หลัง container start ครั้งแรก → bootstrap script รันอัตโนมัติ:
- Database migrations (Alembic)
- Initial admin user creation
- Seed known cookies
**ตรวจสอบ logs:**
```
Easypanel → consentos-app → Logs
```
---
## Environment Variables Reference
### Required (ต้องกำหนดเอง)
| Variable | Example | ที่ไหนได้มา |
|----------|---------|-------------|
| `JWT_SECRET_KEY` | `openssl rand -base64 48` | Generate |
| `DATABASE_URL` | `postgresql+asyncpg://consentos:PASS@consentos-db:5432/consentos` | From Easypanel PostgreSQL |
| `REDIS_URL` | `redis://:PASS@consentos-redis:6379/0` | From Easypanel Redis |
| `INITIAL_ADMIN_EMAIL` | `admin@example.com` | กำหนดเอง |
| `INITIAL_ADMIN_PASSWORD` | `Str0ng!Pass` | กำหนดเอง |
| `ALLOWED_ORIGINS` | `https://admin.example.com` | หลัง deploy admin UI |
### Optional (มี default แล้ว)
| Variable | Default | คำอธิบาย |
|----------|---------|-----------|
| `API_WORKERS` | `2` | จำนวน uvicorn workers |
| `ENABLE_SCANNER` | `false` | เปิด scanner (ใช้ RAM เยอะ) |
| `LOG_LEVEL` | `INFO` | DEBUG สำหรับ verbose logs |
| `DEBUG` | `false` | เปิด FastAPI debug mode |
---
## Data Persistence
| Data | Storage | หายไหมตอน redeploy? |
|------|---------|---------------------|
| Database | Easypanel `consentos-db` volume | ✅ ไม่หาย |
| Redis | Easypanel `consentos-redis` volume | ✅ ไม่หาย |
| Code | Container image | ❌ Rebuild ตามปกติ |
| Logs | `/var/log/supervisor` mount | ✅ Mounted volume |
---
## Update / Redeploy
```bash
# 1. Pull code ใน Git repo
git pull
# 2. Redeploy ใน Easypanel
# consentos-app → Deploy (Redeploy button)
# consentos-admin → Deploy (Redeploy button)
```
หรือตั้ง **Auto Deploy** → Easypanel จะ deploy อัตโนมัติเมื่อ push ไปที่ Git
---
## Troubleshooting
### Bootstrap failed
```bash
# ดู logs
consentos-app → Logs
# ถ้า admin สร้างไปแล้ว → bootstrap script จะ skip
# ถ้าต้องการ reset admin:
# ไปที่ console แล้ว:
docker exec -it consentos-app python -m src.cli.bootstrap_admin
```
### CORS errors
เพิ่ม domain ใหม่เข้า `ALLOWED_ORIGINS` แล้ว redeploy
### Scanner กิน RAM เยอะ
```env
ENABLE_SCANNER=false # ปิดไปก่อน
```
### Celery worker ไม่ทำงาน
```bash
# ดู worker logs
consentos-app → Console
supervisorctl status
supervisorctl tail worker
```
---
## Memory Requirements
| Service | RAM (approximate) |
|---------|------------------|
| consentos-db | ~256-512 MB |
| consentos-redis | ~64-128 MB |
| consentos-app (API) | ~256-512 MB |
| consentos-app (Worker) | ~256-512 MB |
| consentos-app (Scanner) | ~512-1024 MB (ถ้าเปิด) |
| **Total (without scanner)** | ~576-1152 MB |
| **Total (with scanner)** | ~1088-2176 MB |
**แนะนำ:** VPS/Server อย่างน้อย **2 GB RAM** (ถ้าไม่ใช้ scanner) หรือ **4 GB** (ถ้าใช้ scanner)

View File

@@ -1,49 +1,70 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
# Banner entry points — cross-origin script loads from customer
# sites, so they need permissive CORS. Served from the web root
# because the loader derives the bundle URL from its own origin
# (see apps/banner/src/loader.ts). Declared before the SPA
# fallback so nginx doesn't rewrite them to index.html when the
# files aren't yet built in dev.
location = /consent-loader.js {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Cache-Control "public, max-age=3600" always;
try_files $uri =404;
}
events {
worker_connections 1024;
}
location = /consent-bundle.js {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Cache-Control "public, max-age=3600" always;
try_files $uri =404;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
# SPA fallback — serve index.html for all other routes
location / {
try_files $uri $uri/ /index.html;
}
server {
listen 80;
root /var/www/html;
index index.html;
# Proxy API requests to the backend
# Uses Docker's embedded DNS with a variable so nginx resolves at request
# time rather than at startup — prevents crash if api is temporarily down.
location /api/ {
resolver 127.0.0.11 valid=10s;
set $upstream http://api:8000;
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check endpoint
location = /health {
access_log off;
return 200 "nginx ok\n";
add_header Content-Type text/plain;
}
# Cache static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
# Banner entry points
location = /consent-loader.js {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Cache-Control "public, max-age=3600" always;
try_files $uri =404;
}
location = /consent-bundle.js {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Cache-Control "public, max-age=3600" always;
try_files $uri =404;
}
# Proxy API requests to FastAPI backend — strip /api prefix
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /docs {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
}
location /openapi.json {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

View File

@@ -11,7 +11,8 @@ import {
import Layout from './components/Layout';
import { trackPageView } from './services/analytics';
import ProtectedRoute from './components/ProtectedRoute';
import ComplianceDashboardPage from './pages/ComplianceDashboardPage';
import AccountPage from './pages/AccountPage';
import ConsentRecordsPage from './pages/ConsentRecordsPage';
import LoginPage from './pages/LoginPage';
import SettingsPage from './pages/SettingsPage';
import SiteDetailPage from './pages/SiteDetailPage';
@@ -58,7 +59,8 @@ function AppRoutes() {
<Route path="/sites" element={<SitesPage />} />
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
<Route path="/compliance" element={<ComplianceDashboardPage />} />
<Route path="/consent" element={<ConsentRecordsPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
{extensionPages
.filter((p) => p.protected !== false)

View File

@@ -17,3 +17,28 @@ export async function getMe(): Promise<User> {
const { data } = await apiClient.get<User>('/auth/me');
return data;
}
export interface Profile {
id: string;
email: string;
full_name: string;
role: string;
organisation_id: string;
}
export async function getProfile(): Promise<Profile> {
const { data } = await apiClient.get<Profile>('/auth/me');
return data;
}
export async function updateProfile(body: { email?: string; full_name?: string }): Promise<Profile> {
const { data } = await apiClient.patch<Profile>('/auth/me', body);
return data;
}
export async function changePassword(body: {
current_password: string;
new_password: string;
}): Promise<void> {
await apiClient.patch('/auth/me/password', body);
}

View File

@@ -0,0 +1,12 @@
import type { ConsentRecord, PaginatedResponse } from '../types/api';
import apiClient from './client';
export async function listConsentRecords(
siteId: string,
params?: { visitor_id?: string; page?: number; page_size?: number },
): Promise<PaginatedResponse<ConsentRecord>> {
const { data } = await apiClient.get<PaginatedResponse<ConsentRecord>>('/consent/', {
params: { site_id: siteId, ...params },
});
return data;
}

View File

@@ -1,19 +1,34 @@
import { useMemo, useState } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
import { getNavItems } from '../extensions/registry';
const CORE_NAV_ITEMS = [
{ path: '/sites', label: 'Sites', order: 10 },
{ path: '/compliance', label: 'Compliance', order: 20 },
{ path: '/consent', label: 'Consent Records', order: 15 },
{ path: '/settings', label: 'Settings', order: 90 },
];
export default function Layout() {
const { user, logout } = useAuthStore();
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
// Close user menu on outside click
useEffect(() => {
if (!userMenuOpen) return;
const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
setUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [userMenuOpen]);
const NAV_ITEMS = useMemo(() => {
const extensionItems = getNavItems().map((item) => ({
@@ -65,18 +80,47 @@ export default function Layout() {
</nav>
</div>
{/* Right: user info + mobile hamburger */}
{/* Right: user menu + mobile hamburger */}
<div className="flex items-center gap-4">
<div className="hidden items-center gap-3 md:flex">
<span className="text-sm text-text-secondary">
{user?.full_name ?? user?.email}
</span>
<div className="relative hidden md:block" ref={userMenuRef}>
<button
onClick={logout}
className="text-sm text-text-tertiary hover:text-foreground"
type="button"
onClick={() => setUserMenuOpen((v) => !v)}
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-text-secondary transition-colors hover:bg-mist hover:text-foreground"
>
Sign out
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-copper/10 font-heading text-xs font-semibold text-copper">
{(user?.full_name ?? user?.email ?? '?')[0].toUpperCase()}
</span>
{user?.full_name ?? user?.email}
<svg className="h-4 w-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{userMenuOpen && (
<div className="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-border bg-card shadow-lg">
<button
type="button"
onClick={() => { setUserMenuOpen(false); navigate('/account'); }}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-foreground hover:bg-mist"
>
<svg className="h-4 w-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Account
</button>
<div className="border-t border-border" />
<button
type="button"
onClick={() => { setUserMenuOpen(false); logout(); }}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-text-tertiary hover:bg-mist hover:text-foreground"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
)}
</div>
{/* Mobile hamburger */}

View File

@@ -0,0 +1,186 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { updateSiteConfig } from '../api/sites';
import { trackConfigChange } from '../services/analytics';
import type { CategorySlug, SiteConfig } from '../types/api';
import { ALL_COOKIE_CATEGORIES } from '../types/api';
import { Alert } from './ui/alert';
import { Button } from './ui/button';
import { Card } from './ui/card';
interface Props {
siteId: string;
config: SiteConfig | null;
}
/**
* Per-site control over which cookie categories the banner displays.
*
* ``necessary`` is always on and can't be disabled. A category that's
* unchecked here is hidden from the banner AND treated as permanently
* unconsented, so any cookie in that category stays blocked. That's
* the correct semantics for a site that genuinely doesn't use e.g.
* marketing cookies: the operator declares it, the visitor never
* sees the toggle, and the auto-blocker enforces it.
*
* ``null`` on the site config means "inherit from the cascade"
* (group → org → system default of all five). The save button
* always writes an explicit list; the "Reset to inherited" button
* clears the override by sending ``null``.
*/
export default function SiteCategoriesTab({ siteId, config }: Props) {
const queryClient = useQueryClient();
const initiallyEnabled = useMemo<Set<CategorySlug>>(() => {
const raw = config?.enabled_categories;
if (!raw || raw.length === 0) {
return new Set(ALL_COOKIE_CATEGORIES.map((c) => c.slug));
}
const known = new Set<CategorySlug>(ALL_COOKIE_CATEGORIES.map((c) => c.slug));
const picked = new Set<CategorySlug>(raw.filter((s): s is CategorySlug => known.has(s)));
picked.add('necessary');
return picked;
}, [config?.enabled_categories]);
const [enabled, setEnabled] = useState<Set<CategorySlug>>(initiallyEnabled);
const [saved, setSaved] = useState(false);
const isInherited = config?.enabled_categories == null;
const mutation = useMutation({
mutationFn: (body: Partial<SiteConfig>) => updateSiteConfig(siteId, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'config'] });
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'inheritance'] });
trackConfigChange('site_categories', { site_id: siteId });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const toggle = (slug: CategorySlug, locked: boolean) => {
if (locked) return;
setEnabled((prev) => {
const next = new Set(prev);
if (next.has(slug)) {
next.delete(slug);
} else {
next.add(slug);
}
next.add('necessary');
return next;
});
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const payload = ALL_COOKIE_CATEGORIES.map((c) => c.slug).filter((slug) => enabled.has(slug));
mutation.mutate({ enabled_categories: payload });
};
const handleResetToInherited = () => {
mutation.mutate({ enabled_categories: null });
};
const allActive = ALL_COOKIE_CATEGORIES.every((c) => enabled.has(c.slug));
const dirty =
!isInherited &&
(config?.enabled_categories ?? []).slice().sort().join(',') !==
Array.from(enabled).sort().join(',');
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border border-dashed border-border bg-surface p-4">
<p className="text-xs text-text-secondary">
<strong>Cookie categories.</strong> Untick any category this site doesn&rsquo;t use it
will be hidden from the banner and permanently unconsented, so any cookie that falls
into it stays blocked. <em>Necessary</em> is always on and can&rsquo;t be disabled.
{isInherited && (
<> This site is currently <strong>inheriting</strong> its category list from the
cascade (group &rarr; organisation &rarr; system default).</>
)}
</p>
</div>
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">
Categories shown in the banner
</h3>
<div className="space-y-3">
{ALL_COOKIE_CATEGORIES.map((cat) => {
const active = enabled.has(cat.slug);
return (
<label
key={cat.slug}
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors ${
active
? 'border-copper bg-copper/5'
: 'border-border bg-transparent hover:bg-surface'
} ${cat.locked ? 'cursor-not-allowed opacity-80' : ''}`}
>
<input
type="checkbox"
className="mt-1"
checked={active}
disabled={cat.locked}
onChange={() => toggle(cat.slug, cat.locked)}
aria-labelledby={`cat-${cat.slug}-label`}
aria-describedby={`cat-${cat.slug}-desc`}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
id={`cat-${cat.slug}-label`}
className="font-heading text-sm font-medium text-foreground"
>
{cat.label}
</span>
{cat.locked && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
Always on
</span>
)}
</div>
<p id={`cat-${cat.slug}-desc`} className="mt-1 text-xs text-text-secondary">
{cat.description}
</p>
</div>
</label>
);
})}
</div>
</Card>
{saved && <Alert variant="success">Categories saved.</Alert>}
{mutation.isError && (
<Alert variant="error">
Couldn&rsquo;t save: {(mutation.error as Error)?.message ?? 'unknown error'}
</Alert>
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={mutation.isPending || (!dirty && !isInherited)}>
{mutation.isPending ? 'Saving…' : 'Save categories'}
</Button>
{!isInherited && (
<Button
type="button"
variant="secondary"
onClick={handleResetToInherited}
disabled={mutation.isPending}
>
Reset to inherited
</Button>
)}
{allActive && !isInherited && (
<span className="text-xs text-text-secondary">
All five categories enabled same as the system default.
</span>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,235 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { listConsentRecords } from '../api/consent';
import type { ConsentRecord } from '../types/api';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { LoadingState } from './ui/loading-state';
interface Props {
siteId: string;
}
function actionVariant(action: string): 'success' | 'error' | 'warning' | 'neutral' {
const map: Record<string, 'success' | 'error' | 'warning'> = {
accept_all: 'success',
reject_all: 'error',
custom: 'warning',
withdraw: 'error',
};
return map[action] ?? 'neutral';
}
function actionLabel(action: string): string {
const map: Record<string, string> = {
accept_all: 'Accept all',
reject_all: 'Reject all',
custom: 'Custom',
withdraw: 'Withdrawn',
};
return map[action] ?? action;
}
function RecordDetail({ record }: { record: ConsentRecord }) {
return (
<tr>
<td colSpan={5} className="bg-mist px-4 py-3">
<div className="grid gap-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
<div>
<span className="font-medium text-text-secondary">Visitor ID</span>
<p className="mt-0.5 break-all font-mono">{record.visitor_id}</p>
</div>
<div>
<span className="font-medium text-text-secondary">Page URL</span>
<p className="mt-0.5 break-all">{record.page_url ?? '—'}</p>
</div>
<div>
<span className="font-medium text-text-secondary">Accepted</span>
<p className="mt-0.5">{record.categories_accepted.join(', ') || '—'}</p>
</div>
<div>
<span className="font-medium text-text-secondary">Rejected</span>
<p className="mt-0.5">{record.categories_rejected?.join(', ') || '—'}</p>
</div>
{record.country_code && (
<div>
<span className="font-medium text-text-secondary">Location</span>
<p className="mt-0.5">{record.region_code ? `${record.country_code}-${record.region_code}` : record.country_code}</p>
</div>
)}
{record.tc_string && (
<div>
<span className="font-medium text-text-secondary">TC String</span>
<p className="mt-0.5 break-all font-mono text-[11px]">{record.tc_string}</p>
</div>
)}
{record.gpc_detected != null && (
<div>
<span className="font-medium text-text-secondary">GPC</span>
<p className="mt-0.5">
Detected: {record.gpc_detected ? 'Yes' : 'No'}
{record.gpc_honoured != null && ` · Honoured: ${record.gpc_honoured ? 'Yes' : 'No'}`}
</p>
</div>
)}
</div>
</td>
</tr>
);
}
export default function SiteConsentTab({ siteId }: Props) {
const [search, setSearch] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [page, setPage] = useState(1);
const [expandedId, setExpandedId] = useState<string | null>(null);
const pageSize = 25;
const { data, isLoading } = useQuery({
queryKey: ['consent', siteId, activeSearch, page],
queryFn: () =>
listConsentRecords(siteId, {
visitor_id: activeSearch || undefined,
page,
page_size: pageSize,
}),
});
const handleSearch = () => {
setActiveSearch(search.trim());
setPage(1);
};
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
return (
<div>
{/* Search */}
<Card className="mb-6 p-5">
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">
Search Consent Records
</h3>
<div className="flex flex-wrap gap-3">
<input
type="text"
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-text-tertiary focus:border-copper focus:outline-none"
placeholder="Search by visitor ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button onClick={handleSearch}>Search</Button>
{activeSearch && (
<Button
variant="secondary"
onClick={() => {
setSearch('');
setActiveSearch('');
setPage(1);
}}
>
Clear
</Button>
)}
</div>
{activeSearch && (
<p className="mt-2 text-xs text-text-secondary">
Showing results for visitor: <code className="rounded bg-mist px-1.5 py-0.5 font-mono">{activeSearch}</code>
</p>
)}
</Card>
{/* Results */}
{isLoading ? (
<LoadingState message="Loading consent records..." />
) : !data || data.items.length === 0 ? (
<div className="py-8 text-center text-sm text-text-secondary">
{activeSearch
? 'No consent records found for this visitor.'
: 'No consent records yet.'}
</div>
) : (
<>
<div className="mb-3 flex items-center justify-between text-xs text-text-secondary">
<span>{data.total} record{data.total !== 1 ? 's' : ''}</span>
<span>Page {page} of {totalPages}</span>
</div>
<div className="overflow-hidden rounded-lg border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Visitor</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Action</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Categories</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Date</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.items.map((record) => (
<>
<tr
key={record.id}
className="cursor-pointer hover:bg-mist"
onClick={() => setExpandedId(expandedId === record.id ? null : record.id)}
>
<td className="px-4 py-3 font-mono text-xs">
{record.visitor_id.length > 16
? record.visitor_id.slice(0, 8) + '…' + record.visitor_id.slice(-8)
: record.visitor_id}
</td>
<td className="px-4 py-3">
<Badge variant={actionVariant(record.action)}>
{actionLabel(record.action)}
</Badge>
</td>
<td className="px-4 py-3 text-text-secondary">
{record.categories_accepted.join(', ')}
</td>
<td className="px-4 py-3 text-text-secondary">
{new Date(record.consented_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-text-tertiary">
{expandedId === record.id ? '▲' : '▼'}
</td>
</tr>
{expandedId === record.id && (
<RecordDetail key={`${record.id}-detail`} record={record} />
)}
</>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="secondary"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span className="text-xs text-text-secondary">
{page} / {totalPages}
</span>
<Button
variant="secondary"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { MetricCard } from './ui/metric-card';
import type { Site, SiteConfig } from '../types/api';
@@ -8,7 +10,8 @@ interface Props {
}
export default function SiteOverviewTab({ site, config }: Props) {
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}" async></script>`;
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}"></script>`;
const [copied, setCopied] = useState(false);
return (
<div className="space-y-6">
@@ -36,17 +39,38 @@ export default function SiteOverviewTab({ site, config }: Props) {
<p className="mb-3 text-sm text-text-secondary">
Add this script tag to the {'<head>'} of your website, before any other scripts.
</p>
<div className="relative">
<pre className="overflow-x-auto rounded-lg bg-foreground p-4 text-sm text-status-success-fg">
{scriptTag}
</pre>
<div className="flex items-stretch">
<input
type="text"
readOnly
value={scriptTag}
className="block w-full min-w-0 rounded-l-lg border border-r-0 border-border bg-mist px-3 py-2.5 font-mono text-xs text-foreground focus:outline-none"
/>
<button
onClick={() => navigator.clipboard.writeText(scriptTag)}
className="absolute right-3 top-3 rounded bg-foreground/80 px-2 py-1 text-xs text-card hover:bg-foreground/70"
type="button"
onClick={() => {
navigator.clipboard.writeText(scriptTag).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="inline-flex shrink-0 items-center gap-2 rounded-r-lg border border-copper bg-copper px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-copper/90 focus:outline-none focus:ring-2 focus:ring-copper/50"
>
Copy
{copied ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-3 5h3m-6 0h.01M12 16h3m-6 0h.01M10 3v4h4V3h-4Z" />
</svg>
)}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<p className="mt-2 text-xs text-text-secondary">
Must be the first {'<script>'} in {'<head>'} no <code>async</code> or <code>defer</code>.
</p>
</Card>
{/* Features */}

View File

@@ -2,17 +2,34 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { getScan, getScanDiff, listScans, triggerScan } from '../api/scanner';
import { getSiteConfig, updateSiteConfig } from '../api/sites';
import { trackFeatureUsage } from '../services/analytics';
import type { CookieDiffItem, ScanDiff, ScanJob, ScanJobDetail, ScanResult } from '../types/api';
import type { CookieDiffItem, ScanDiff, ScanJob, ScanJobDetail, ScanResult, SiteConfig } from '../types/api';
import { Alert } from './ui/alert';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { LoadingState } from './ui/loading-state';
import { Select } from './ui/select';
interface Props {
siteId: string;
}
const SCHEDULE_OPTIONS: { value: string; label: string; cron: string | null }[] = [
{ value: 'disabled', label: 'Disabled', cron: null },
{ value: 'daily', label: 'Daily', cron: '0 3 * * *' },
{ value: 'weekly', label: 'Weekly', cron: '0 3 * * 0' },
{ value: 'fortnightly', label: 'Fortnightly', cron: '0 3 1,15 * *' },
{ value: 'monthly', label: 'Monthly', cron: '0 3 1 * *' },
];
function cronToScheduleValue(cron: string | null | undefined): string {
if (!cron) return 'disabled';
const match = SCHEDULE_OPTIONS.find((o) => o.cron === cron);
return match?.value ?? 'custom';
}
function statusVariant(status: string): 'warning' | 'info' | 'success' | 'error' | 'neutral' {
const map: Record<string, 'warning' | 'info' | 'success' | 'error'> = {
pending: 'warning',
@@ -183,6 +200,45 @@ export default function SiteScannerTab({ siteId }: Props) {
const queryClient = useQueryClient();
const [expandedScanId, setExpandedScanId] = useState<string | null>(null);
const { data: config } = useQuery<SiteConfig>({
queryKey: ['sites', siteId, 'config'],
queryFn: () => getSiteConfig(siteId),
});
const currentCron = config?.scan_schedule_cron ?? null;
const savedValue = cronToScheduleValue(currentCron);
const [selectedSchedule, setSelectedSchedule] = useState<string | null>(null);
const [customCron, setCustomCron] = useState('');
// Use local selection if the user has interacted, otherwise fall
// back to what's saved on the server.
const activeValue = selectedSchedule ?? savedValue;
const showCustomInput = activeValue === 'custom';
const scheduleMutation = useMutation({
mutationFn: (cron: string | null) => updateSiteConfig(siteId, { scan_schedule_cron: cron } as Partial<SiteConfig>),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'config'] });
trackFeatureUsage('scan', 'schedule_change', { site_id: siteId });
setSelectedSchedule(null); // reset to server state
},
});
const handleScheduleChange = (value: string) => {
setSelectedSchedule(value);
if (value === 'custom') {
setCustomCron(currentCron ?? '');
return;
}
const option = SCHEDULE_OPTIONS.find((o) => o.value === value);
scheduleMutation.mutate(option?.cron ?? null);
};
const handleCustomSave = () => {
const trimmed = customCron.trim();
scheduleMutation.mutate(trimmed || null);
};
const { data: scans, isLoading } = useQuery<ScanJob[]>({
queryKey: ['scans', siteId],
queryFn: () => listScans(siteId),
@@ -202,6 +258,64 @@ export default function SiteScannerTab({ siteId }: Props) {
return (
<div>
{/* Scan schedule */}
<Card className="mb-6 p-5">
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">Scan Schedule</h3>
<p className="mb-3 text-xs text-text-secondary">
Scheduled scans run automatically and re-discover cookies so your inventory stays
current. Select a preset or enter a custom cron expression.
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[180px]">
<Select
value={activeValue}
onChange={(e) => handleScheduleChange(e.target.value)}
disabled={scheduleMutation.isPending}
>
{SCHEDULE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
<option value="custom">Custom cron</option>
</Select>
</div>
{showCustomInput && (
<>
<input
type="text"
className="rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-text-tertiary focus:border-copper focus:outline-none"
placeholder="0 3 * * 0"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
/>
<Button
variant="secondary"
size="sm"
onClick={handleCustomSave}
disabled={scheduleMutation.isPending || !customCron.trim()}
>
Save
</Button>
<a
href="https://crontab.guru"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-copper hover:underline"
>
Need help? Use crontab.guru &rarr;
</a>
</>
)}
{scheduleMutation.isPending && (
<span className="text-xs text-text-secondary">Saving</span>
)}
</div>
{currentCron && (
<p className="mt-2 text-xs text-text-secondary">
Current schedule: <code className="rounded bg-mist px-1.5 py-0.5 font-mono">{currentCron}</code>
</p>
)}
</Card>
{/* Header with trigger button */}
<div className="mb-4 flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold text-foreground">Cookie Scans</h2>

View File

@@ -0,0 +1,178 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { changePassword, getProfile, updateProfile } from '../api/auth';
import { Alert } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { FormField } from '../components/ui/form-field';
import { Input } from '../components/ui/input';
export default function AccountPage() {
const queryClient = useQueryClient();
const { data: profile, isLoading } = useQuery({
queryKey: ['profile'],
queryFn: getProfile,
});
// Profile form
const [email, setEmail] = useState('');
const [fullName, setFullName] = useState('');
const [profileInit, setProfileInit] = useState(false);
const [profileSaved, setProfileSaved] = useState(false);
if (profile && !profileInit) {
setEmail(profile.email);
setFullName(profile.full_name);
setProfileInit(true);
}
const profileMutation = useMutation({
mutationFn: (body: { email?: string; full_name?: string }) => updateProfile(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
setProfileSaved(true);
setTimeout(() => setProfileSaved(false), 2000);
},
});
const handleProfileSubmit = (e: FormEvent) => {
e.preventDefault();
const body: { email?: string; full_name?: string } = {};
if (email !== profile?.email) body.email = email;
if (fullName !== profile?.full_name) body.full_name = fullName;
if (Object.keys(body).length === 0) return;
profileMutation.mutate(body);
};
// Password form
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordSaved, setPasswordSaved] = useState(false);
const [passwordError, setPasswordError] = useState('');
const passwordMutation = useMutation({
mutationFn: (body: { current_password: string; new_password: string }) => changePassword(body),
onSuccess: () => {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
setPasswordSaved(true);
setTimeout(() => setPasswordSaved(false), 2000);
},
onError: (err: Error & { response?: { data?: { detail?: string } } }) => {
setPasswordError(err.response?.data?.detail ?? 'Failed to change password');
},
});
const handlePasswordSubmit = (e: FormEvent) => {
e.preventDefault();
setPasswordError('');
if (newPassword.length < 8) {
setPasswordError('Password must be at least 8 characters');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Passwords do not match');
return;
}
passwordMutation.mutate({
current_password: currentPassword,
new_password: newPassword,
});
};
if (isLoading) {
return <div className="py-12 text-center text-sm text-text-secondary">Loading...</div>;
}
return (
<div className="mx-auto max-w-xl">
<div className="mb-6">
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Account</h1>
<p className="mt-1 text-sm text-text-secondary">Manage your profile and password.</p>
</div>
{/* Profile */}
<form onSubmit={handleProfileSubmit} className="mb-6">
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Profile</h3>
<div className="space-y-4">
<FormField label="Email">
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</FormField>
<FormField label="Display name">
<Input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</FormField>
</div>
{profileSaved && <Alert variant="success" className="mt-4">Profile updated.</Alert>}
{profileMutation.isError && (
<Alert variant="error" className="mt-4">
{(profileMutation.error as Error & { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Failed to update profile'}
</Alert>
)}
<div className="mt-4">
<Button type="submit" disabled={profileMutation.isPending}>
{profileMutation.isPending ? 'Saving...' : 'Save profile'}
</Button>
</div>
</Card>
</form>
{/* Password */}
<form onSubmit={handlePasswordSubmit}>
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Change password</h3>
<div className="space-y-4">
<FormField label="Current password">
<Input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</FormField>
<FormField label="New password">
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</FormField>
<FormField label="Confirm new password">
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</FormField>
</div>
{passwordSaved && <Alert variant="success" className="mt-4">Password changed.</Alert>}
{passwordError && <Alert variant="error" className="mt-4">{passwordError}</Alert>}
<div className="mt-4">
<Button type="submit" disabled={passwordMutation.isPending}>
{passwordMutation.isPending ? 'Changing...' : 'Change password'}
</Button>
</div>
</Card>
</form>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { listSites } from '../api/sites';
import SiteConsentTab from '../components/SiteConsentTab';
import { Select } from '../components/ui/select';
import type { Site } from '../types/api';
export default function ConsentRecordsPage() {
const [selectedSiteId, setSelectedSiteId] = useState<string>('');
const { data: sites, isLoading: sitesLoading } = useQuery<Site[]>({
queryKey: ['sites'],
queryFn: listSites,
});
return (
<div>
<div className="mb-6">
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
Consent Records
</h1>
<p className="mt-1 text-sm text-text-secondary">
View and search consent records across your sites.
</p>
</div>
<div className="mb-6 max-w-xs">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Site
</label>
<Select
value={selectedSiteId}
onChange={(e) => setSelectedSiteId(e.target.value)}
disabled={sitesLoading}
>
<option value="">Select a site</option>
{sites?.map((site) => (
<option key={site.id} value={site.id}>
{site.display_name ?? site.domain}
</option>
))}
</Select>
</div>
{selectedSiteId ? (
<SiteConsentTab siteId={selectedSiteId} />
) : (
<div className="py-12 text-center text-sm text-text-secondary">
Select a site to view its consent records.
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
import SiteComplianceTab from '../components/SiteComplianceTab';
import SiteCategoriesTab from '../components/SiteCategoriesTab';
import SiteConfigTab from '../components/SiteConfigTab';
import SiteCookiesTab from '../components/SiteCookiesTab';
import SiteOverviewTab from '../components/SiteOverviewTab';
@@ -16,16 +16,24 @@ import { getSiteDetailTabs } from '../extensions/registry';
const CORE_TABS: { id: string; label: string; order: number }[] = [
{ id: 'overview', label: 'Overview', order: 10 },
{ id: 'config', label: 'Configuration', order: 20 },
{ id: 'categories', label: 'Categories', order: 25 },
{ id: 'cookies', label: 'Cookies', order: 30 },
{ id: 'banner', label: 'Banner', order: 40 },
{ id: 'translations', label: 'Translations', order: 50 },
{ id: 'scanner', label: 'Scans', order: 60 },
{ id: 'compliance', label: 'Compliance', order: 70 },
];
export default function SiteDetailPage() {
const { siteId } = useParams<{ siteId: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const location = useLocation();
const navigate = useNavigate();
// Persist the active tab in the URL hash so a page refresh restores it.
const activeTab = location.hash.replace('#', '') || 'overview';
const setActiveTab = useCallback(
(tab: string) => navigate({ hash: tab }, { replace: true }),
[navigate],
);
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
const allTabs = useMemo(() => {
@@ -89,6 +97,9 @@ export default function SiteDetailPage() {
{/* Tab content — core tabs */}
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
{activeTab === 'config' && siteId && <SiteConfigTab siteId={siteId} config={config ?? null} />}
{activeTab === 'categories' && siteId && (
<SiteCategoriesTab siteId={siteId} config={config ?? null} />
)}
{activeTab === 'cookies' && siteId && <SiteCookiesTab siteId={siteId} />}
{activeTab === 'banner' && siteId && (
<BannerBuilderTab
@@ -100,7 +111,6 @@ export default function SiteDetailPage() {
)}
{activeTab === 'translations' && siteId && <SiteTranslationsTab siteId={siteId} />}
{activeTab === 'scanner' && siteId && <SiteScannerTab siteId={siteId} />}
{activeTab === 'compliance' && siteId && <SiteComplianceTab siteId={siteId} config={config ?? null} />}
{/* Extension tabs */}
{extensionTabs.map(
(ext) =>

View File

@@ -0,0 +1,139 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SiteCategoriesTab from '../components/SiteCategoriesTab';
import type { SiteConfig } from '../types/api';
vi.mock('../api/sites', () => ({
updateSiteConfig: vi.fn().mockResolvedValue({}),
}));
vi.mock('../services/analytics', () => ({
trackConfigChange: vi.fn(),
}));
import { updateSiteConfig } from '../api/sites';
function renderWithProviders(ui: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
const BASE_CONFIG: SiteConfig = {
id: 'cfg-1',
site_id: 'site-1',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: true,
gpp_supported_apis: ['usnat'],
gpc_enabled: true,
gpc_jurisdictions: null,
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
scan_enabled: true,
scan_frequency_hours: 168,
scan_max_pages: 50,
scan_schedule_cron: null,
enabled_categories: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};
describe('SiteCategoriesTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders all five categories with necessary locked', () => {
renderWithProviders(<SiteCategoriesTab siteId="site-1" config={BASE_CONFIG} />);
expect(screen.getByRole('checkbox', { name: /Necessary/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Functional/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Analytics/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Marketing/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Personalisation/i })).toBeInTheDocument();
const necessary = screen.getByRole('checkbox', { name: /Necessary/i });
expect(necessary).toBeChecked();
expect(necessary).toBeDisabled();
});
it('shows "inheriting" copy when config has no override', () => {
renderWithProviders(<SiteCategoriesTab siteId="site-1" config={BASE_CONFIG} />);
expect(screen.getByText(/inheriting/i)).toBeInTheDocument();
});
it('pre-fills from existing override', () => {
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics'] }}
/>,
);
expect(screen.getByRole('checkbox', { name: /Necessary/i })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Analytics/i })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Functional/i })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Marketing/i })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Personalisation/i })).not.toBeChecked();
});
it('saves an explicit category list on submit', async () => {
const user = userEvent.setup();
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics', 'marketing'] }}
/>,
);
// Drop marketing
await user.click(screen.getByRole('checkbox', { name: /Marketing/i }));
await user.click(screen.getByRole('button', { name: /Save categories/i }));
expect(updateSiteConfig).toHaveBeenCalledWith('site-1', {
enabled_categories: ['necessary', 'analytics'],
});
});
it('refuses to unlock necessary', async () => {
const user = userEvent.setup();
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics'] }}
/>,
);
// Clicking the locked checkbox is a no-op
const necessary = screen.getByRole('checkbox', { name: /Necessary/i });
await user.click(necessary);
expect(necessary).toBeChecked();
});
it('resets to inherited by sending null', async () => {
const user = userEvent.setup();
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary'] }}
/>,
);
await user.click(screen.getByRole('button', { name: /Reset to inherited/i }));
expect(updateSiteConfig).toHaveBeenCalledWith('site-1', {
enabled_categories: null,
});
});
});

View File

@@ -41,6 +41,8 @@ const BASE_CONFIG: SiteConfig = {
scan_enabled: true,
scan_frequency_hours: 168,
scan_max_pages: 50,
scan_schedule_cron: null,
enabled_categories: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};

View File

@@ -129,10 +129,63 @@ export interface SiteConfig {
scan_enabled: boolean;
scan_frequency_hours: number;
scan_max_pages: number;
scan_schedule_cron: string | null;
/**
* Cookie categories the banner should display. ``null`` means
* "inherit from the cascade" (group → org → system default of all
* five). An explicit list overrides; ``necessary`` is always
* implicit and re-added by the resolver if missing.
*/
enabled_categories: CategorySlug[] | null;
created_at: string;
updated_at: string;
}
export type CategorySlug =
| 'necessary'
| 'functional'
| 'analytics'
| 'marketing'
| 'personalisation';
export const ALL_COOKIE_CATEGORIES: {
slug: CategorySlug;
label: string;
description: string;
locked: boolean;
}[] = [
{
slug: 'necessary',
label: 'Necessary',
description: 'Essential for the website to function. Always active and cannot be disabled.',
locked: true,
},
{
slug: 'functional',
label: 'Functional',
description: 'Remember preferences and enable enhanced features (e.g. language, chat widgets).',
locked: false,
},
{
slug: 'analytics',
label: 'Analytics',
description: 'Measure traffic and interaction so you can understand how visitors use the site.',
locked: false,
},
{
slug: 'marketing',
label: 'Marketing',
description: 'Advertising, remarketing, and cross-site tracking.',
locked: false,
},
{
slug: 'personalisation',
label: 'Personalisation',
description: 'Tailor content, recommendations, and the banner appearance to the visitor.',
locked: false,
},
];
export interface ButtonConfig {
backgroundColour?: string;
textColour?: string;
@@ -673,3 +726,28 @@ export interface ConsentReceiptResponse {
banner_version_hash: string | null;
created_at: string;
}
export interface ConsentRecord {
id: string;
site_id: string;
visitor_id: string;
action: string;
categories_accepted: string[];
categories_rejected: string[] | null;
tc_string: string | null;
gcm_state: Record<string, string> | null;
gpp_string: string | null;
gpc_detected: boolean | null;
gpc_honoured: boolean | null;
page_url: string | null;
country_code: string | null;
region_code: string | null;
consented_at: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}

View File

@@ -1,22 +1,28 @@
import os
from logging.config import fileConfig
from urllib.parse import urlparse, parse_qs, urlencode, unquote
from sqlalchemy import engine_from_config, pool
from sqlalchemy import create_engine, pool
from alembic import context
from src.models import Base
# Alembic Config object
config = context.config
# Override sqlalchemy.url from environment if set
database_url = os.environ.get("DATABASE_URL")
if database_url:
# Alembic needs the synchronous driver
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", database_url)
raw_url = os.environ.get("DATABASE_URL", "")
if raw_url:
# Convert async driver to sync driver
url = raw_url.replace("postgresql+asyncpg://", "postgresql://")
url = unquote(url)
# Strip sslmode (not supported by psycopg2)
parsed = urlparse(url)
if parsed.query:
params = parse_qs(parsed.query)
params.pop("sslmode", None)
new_query = urlencode(params, doseq=True)
url = parsed._replace(query=new_query).geturl()
config.set_main_option("sqlalchemy.url", url)
# Set up Python logging from the config file
if config.config_file_name is not None:
fileConfig(config.config_file_name)
@@ -24,7 +30,6 @@ target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
@@ -32,25 +37,26 @@ def run_migrations_offline() -> None:
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
# Use DATABASE_URL env directly, properly converted for psycopg2
raw_url = os.environ.get("DATABASE_URL", "")
url = raw_url.replace("postgresql+asyncpg://", "postgresql://")
url = unquote(url)
# Strip sslmode
parsed = urlparse(url)
if parsed.query:
params = parse_qs(parsed.query)
params.pop("sslmode", None)
new_query = urlencode(params, doseq=True)
url = parsed._replace(query=new_query).geturl()
connectable = create_engine(url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()

View File

@@ -0,0 +1,46 @@
"""enabled_categories on site / group / org configs
Revision ID: 0003
Revises: 0002
Create Date: 2026-04-14
Per-site control over which cookie categories the banner displays.
Cascades the same way every other config setting does — site overrides
site-group overrides org overrides system default (all 5 categories).
Stored as JSONB rather than an array column so the resolver sees a
plain Python list via SQLAlchemy's JSONB codec and doesn't need
PostgreSQL-specific array handling in the merge logic.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0003"
down_revision: str | Sequence[str] | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLES = ("site_configs", "site_group_configs", "org_configs")
def upgrade() -> None:
for table in _TABLES:
op.add_column(
table,
sa.Column(
"enabled_categories",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
)
def downgrade() -> None:
for table in _TABLES:
op.drop_column(table, "enabled_categories")

View File

@@ -2263,3 +2263,4 @@ c7d8e9f0-0012-4567-890a-000000000012,Plausible Analytics,Analytics,plausible_,,"
c7d8e9f0-0013-4567-890a-000000000013,Fathom Analytics,Analytics,_fathom,,"Privacy-focused simple website analytics with minimal data collection.",Varies,Conva Ventures Inc,https://usefathom.com/privacy,1
c7d8e9f0-0014-4567-890a-000000000014,Umami,Analytics,umami.,,"Open-source privacy-friendly web analytics alternative.",Varies,Website operator,https://umami.is/docs/about,1
c7d8e9f0-0015-4567-890a-000000000015,Vercel,Functional,_vercel_,,"Vercel platform cookies for deployment previews and analytics.",Varies,Vercel Inc,https://vercel.com/legal/privacy-policy,1
c7d8e9f0-0016-4567-890a-000000000016,Google Ads,Marketing,_gcl_ls,,"Google Click Identifier for localStorage-based ad conversion tracking.",90 Days,Google,https://business.safety.google/privacy/,0
Can't render this file because it is too large.

View File

@@ -0,0 +1,67 @@
"""Reset a user's password from the command line.
Usage:
docker exec consentos-api python -m src.cli.reset_password \\
--email admin@example.com --password new-secret
For use when the password has been forgotten and the admin UI is
inaccessible. Connects directly to the database, so it must run
inside a container (or host) that can reach PostgreSQL.
"""
from __future__ import annotations
import argparse
import sys
import sqlalchemy as sa
def _build_sync_url(async_url: str) -> str:
return async_url.replace("postgresql+asyncpg://", "postgresql://")
def reset(email: str, password: str) -> bool:
"""Reset the password for the given email. Returns True on success."""
from src.config.settings import get_settings
from src.services.auth import hash_password
settings = get_settings()
engine = sa.create_engine(_build_sync_url(settings.database_url))
with engine.begin() as conn:
result = conn.execute(
sa.text("SELECT id FROM users WHERE email = :email AND deleted_at IS NULL"),
{"email": email},
)
row = result.fetchone()
if row is None:
return False
conn.execute(
sa.text("UPDATE users SET password_hash = :pw, updated_at = NOW() WHERE id = :id"),
{"pw": hash_password(password), "id": str(row[0])},
)
return True
def main() -> None:
parser = argparse.ArgumentParser(description="Reset a user's password")
parser.add_argument("--email", required=True, help="User email address")
parser.add_argument("--password", required=True, help="New password")
args = parser.parse_args()
if len(args.password) < 8:
print("Error: password must be at least 8 characters", file=sys.stderr)
sys.exit(1)
if reset(args.email, args.password):
print(f"Password reset for {args.email}")
else:
print(f"Error: no active user found with email {args.email}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -117,6 +117,40 @@ class Settings(BaseSettings):
rate_limit_enabled: bool = True
rate_limit_per_minute: int = 120
@model_validator(mode="after")
def _normalize_database_url(self) -> "Settings":
"""Auto-fix common database URL schemes for asyncpg compatibility.
Platforms like Easypanel emit DATABASE_URL as ``postgres://...``
(shortcut or legacy scheme). SQLAlchemy expects the dialect name
``postgresql://`` and we need the ``+asyncpg`` driver suffix for
the async engine. Normalise both cases here so the rest of the
codebase can always assume ``postgresql+asyncpg://``.
Also strips ``sslmode`` from query strings — asyncpg does not
accept this psycopg2 parameter and would raise TypeError.
"""
url = self.database_url
# Fix dialect scheme
if url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql+asyncpg://", 1)
elif url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Strip sslmode from query string (asyncpg doesn't support it)
if "?sslmode=" in url or "&sslmode=" in url:
from urllib.parse import urlparse, urlencode, parse_qs
parsed = urlparse(url)
params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("sslmode", None)
query = urlencode(params, doseq=True)
url = parsed._replace(query=query).geturl()
self.database_url = url
return self
@model_validator(mode="after")
def _check_production_safety(self) -> "Settings":
"""Refuse to start with unsafe defaults in non-dev environments."""

View File

@@ -52,6 +52,11 @@ class OrgConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Cookie categories shown in the banner. NULL = inherit (system
# default is all five). See ``SiteConfig.enabled_categories`` for
# the full cascade semantics.
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Scanning
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)

View File

@@ -51,6 +51,13 @@ class SiteConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Cookie categories shown in the banner. When NULL, inherit from the
# cascade (site-group → org → system default of all five). An explicit
# list overrides. ``necessary`` is always implicit and will be forced
# back into the merged result by the resolver, so operators can't
# accidentally drop it.
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Scanning
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
scan_max_pages: Mapped[int] = mapped_column(Integer, server_default="50", nullable=False)

View File

@@ -52,6 +52,11 @@ class SiteGroupConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Cookie categories shown in the banner. NULL = inherit (system
# default is all five). See ``SiteConfig.enabled_categories`` for
# the full cascade semantics.
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Scanning
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)

View File

@@ -8,11 +8,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.config.settings import get_settings
from src.db import get_db
from src.models.user import User
from src.schemas.auth import CurrentUser, LoginRequest, RefreshRequest, TokenResponse
from src.schemas.auth import (
ChangePasswordRequest,
CurrentUser,
LoginRequest,
ProfileResponse,
RefreshRequest,
TokenResponse,
UpdateProfileRequest,
)
from src.services.auth import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from src.services.dependencies import get_current_user
@@ -102,7 +111,74 @@ async def refresh(
)
@router.get("/me", response_model=CurrentUser)
async def get_me(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""Return the currently authenticated user's profile from the JWT."""
return current_user
@router.get("/me", response_model=ProfileResponse)
async def get_me(
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
"""Return the currently authenticated user's profile."""
result = await db.execute(
select(User).where(User.id == current_user.id, User.deleted_at.is_(None))
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
@router.patch("/me", response_model=ProfileResponse)
async def update_profile(
body: UpdateProfileRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
"""Update the current user's email or display name."""
result = await db.execute(
select(User).where(User.id == current_user.id, User.deleted_at.is_(None))
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if body.email is not None:
# Check uniqueness
existing = await db.execute(
select(User).where(User.email == body.email, User.id != current_user.id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already in use",
)
user.email = body.email
if body.full_name is not None:
user.full_name = body.full_name
await db.flush()
await db.refresh(user)
return user
@router.patch("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
body: ChangePasswordRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Change the current user's password. Requires the current password."""
result = await db.execute(
select(User).where(User.id == current_user.id, User.deleted_at.is_(None))
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
user.password_hash = hash_password(body.new_password)
await db.flush()

View File

@@ -1,7 +1,7 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
@@ -11,6 +11,7 @@ from src.models.site import Site
from src.schemas.auth import CurrentUser
from src.schemas.consent import (
ConsentRecordCreate,
ConsentRecordListResponse,
ConsentRecordResponse,
ConsentVerifyResponse,
)
@@ -86,6 +87,63 @@ async def _load_record_for_org(
return record
@router.get("/", response_model=ConsentRecordListResponse)
async def list_consent_records(
site_id: uuid.UUID = Query(..., description="Filter by site"),
visitor_id: str | None = Query(None, description="Filter by visitor ID (exact match)"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""List consent records for a site, with optional visitor_id filter.
Tenant-isolated — the site must belong to the caller's organisation.
Returns newest records first.
"""
# Verify site belongs to the caller's org.
site = (
await db.execute(
select(Site).where(
Site.id == site_id,
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if site is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
base = select(ConsentRecord).where(ConsentRecord.site_id == site_id)
count_base = (
select(func.count()).select_from(ConsentRecord).where(ConsentRecord.site_id == site_id)
)
if visitor_id:
base = base.where(ConsentRecord.visitor_id == visitor_id)
count_base = count_base.where(ConsentRecord.visitor_id == visitor_id)
total = await db.scalar(count_base) or 0
items = (
(
await db.execute(
base.order_by(ConsentRecord.consented_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
)
.scalars()
.all()
)
return {
"items": list(items),
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
async def get_consent(
consent_id: uuid.UUID,

View File

@@ -29,6 +29,26 @@ class TokenPayload(BaseModel):
type: str = "access" # "access" or "refresh"
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class UpdateProfileRequest(BaseModel):
email: EmailStr | None = None
full_name: str | None = None
class ProfileResponse(BaseModel):
id: uuid.UUID
email: str
full_name: str
role: str
organisation_id: uuid.UUID
model_config = {"from_attributes": True}
class CurrentUser(BaseModel):
"""Represents the authenticated user extracted from a JWT."""

View File

@@ -50,6 +50,15 @@ class ConsentRecordResponse(BaseModel):
model_config = {"from_attributes": True}
class ConsentRecordListResponse(BaseModel):
"""Paginated list of consent records."""
items: list[ConsentRecordResponse]
total: int
page: int
page_size: int
class ConsentVerifyResponse(BaseModel):
"""Audit proof that a consent record exists."""

View File

@@ -31,6 +31,7 @@ class OrgConfigUpdate(BaseModel):
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
enabled_categories: list[str] | None = None
class OrgConfigResponse(BaseModel):
@@ -55,6 +56,7 @@ class OrgConfigResponse(BaseModel):
scan_max_pages: int | None
consent_expiry_days: int | None
consent_retention_days: int | None
enabled_categories: list[str] | None = None
created_at: datetime
updated_at: datetime

View File

@@ -65,6 +65,10 @@ class SiteConfigCreate(BaseModel):
scan_max_pages: int = Field(default=50, ge=1, le=1000)
consent_expiry_days: int = Field(default=365, ge=1, le=730)
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
# None = inherit from the cascade (group → org → system). An
# explicit list overrides; the resolver re-adds ``necessary``
# if omitted and drops any unknown slugs.
enabled_categories: list[str] | None = None
class SiteConfigUpdate(BaseModel):
@@ -87,6 +91,7 @@ class SiteConfigUpdate(BaseModel):
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
enabled_categories: list[str] | None = None
class SiteConfigResponse(BaseModel):
@@ -111,6 +116,7 @@ class SiteConfigResponse(BaseModel):
scan_max_pages: int = 50
consent_expiry_days: int = 365
consent_retention_days: int | None = None
enabled_categories: list[str] | None = None
created_at: datetime
updated_at: datetime

View File

@@ -30,6 +30,7 @@ class SiteGroupConfigUpdate(BaseModel):
scan_schedule_cron: str | None = None
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
enabled_categories: list[str] | None = None
class SiteGroupConfigResponse(BaseModel):
@@ -53,6 +54,7 @@ class SiteGroupConfigResponse(BaseModel):
scan_schedule_cron: str | None
scan_max_pages: int | None
consent_expiry_days: int | None
enabled_categories: list[str] | None = None
created_at: datetime
updated_at: datetime

View File

@@ -174,6 +174,26 @@ def classify_cookie(
This is a pure function — all data is passed in, no DB calls.
"""
# 0. ConsentOS's own cookies are always necessary. The banner's
# blocker already treats ``_consentos_*`` as exempt; the
# classifier must agree so the admin UI shows them in the
# right category without requiring a known-cookies DB entry.
if cookie_name.startswith("_consentos_"):
necessary = next(
(cat for cat in category_map.values() if cat.slug == "necessary"),
None,
)
return ClassificationResult(
cookie_name=cookie_name,
cookie_domain=cookie_domain,
category_id=necessary.id if necessary else None,
category_slug="necessary",
vendor="ConsentOS",
description="ConsentOS consent management cookie.",
match_source=MatchSource.KNOWN_EXACT,
matched=True,
)
# 1. Check allow-list first (site-specific overrides)
allow_match = _match_allow_list(cookie_name, cookie_domain, allow_list)
if allow_match:

View File

@@ -10,6 +10,23 @@ from __future__ import annotations
from typing import Any
# Every known cookie category, in the canonical display order the
# banner uses. The system default for ``enabled_categories`` is this
# full list; operators subset from the top via the cascade.
ALL_CATEGORIES: list[str] = [
"necessary",
"functional",
"analytics",
"marketing",
"personalisation",
]
# ``necessary`` is never optional — operators can't hide it and the
# merged result always contains it, even if it's been accidentally
# dropped from every layer of the cascade.
REQUIRED_CATEGORIES: frozenset[str] = frozenset({"necessary"})
# System-level defaults (hard-coded, lowest priority)
SYSTEM_DEFAULTS: dict[str, Any] = {
"blocking_mode": "opt_in",
@@ -34,6 +51,10 @@ SYSTEM_DEFAULTS: dict[str, Any] = {
"privacy_policy_url": None,
"terms_url": None,
"consent_expiry_days": 365,
# All five categories visible by default; any cascade layer may
# narrow this to a subset. The resolver normalises the result
# via ``_normalise_enabled_categories``.
"enabled_categories": ALL_CATEGORIES,
}
@@ -77,9 +98,33 @@ def resolve_config(
if regional_mode:
resolved["blocking_mode"] = regional_mode
resolved["enabled_categories"] = _normalise_enabled_categories(
resolved.get("enabled_categories")
)
return resolved
def _normalise_enabled_categories(value: Any) -> list[str]:
"""Clean a merged ``enabled_categories`` value into a canonical list.
- ``None`` / empty / invalid types fall back to the full default.
- Unknown slugs are stripped so a typo can't light up a category
the banner doesn't actually render.
- ``necessary`` is always forced into the output — required
categories can never be absent, regardless of what the operator
configured. The order mirrors ``ALL_CATEGORIES`` so the banner
renders tabs in a consistent order no matter the insertion order.
"""
if not isinstance(value, list) or not value:
return list(ALL_CATEGORIES)
known = set(ALL_CATEGORIES)
picked = {slug for slug in value if isinstance(slug, str) and slug in known}
picked.update(REQUIRED_CATEGORIES)
return [slug for slug in ALL_CATEGORIES if slug in picked]
def build_public_config(
site_id: str,
resolved: dict[str, Any],
@@ -108,6 +153,9 @@ def build_public_config(
"consent_expiry_days": resolved["consent_expiry_days"],
"consent_group_id": resolved.get("consent_group_id"),
"ab_test": resolved.get("ab_test"),
# Public name is ``enabled_categories`` here; the banner schema
# converts that to ``enabledCategories`` when it serialises.
"enabled_categories": _normalise_enabled_categories(resolved.get("enabled_categories")),
}
@@ -128,6 +176,7 @@ CONFIG_FIELDS = (
"privacy_policy_url",
"terms_url",
"consent_expiry_days",
"enabled_categories",
)

View File

@@ -12,7 +12,7 @@ from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.cookie import Cookie
from src.models.cookie import Cookie, CookieCategory
from src.models.scan import ScanJob, ScanResult
from src.models.site import Site
from src.schemas.scanner import (
@@ -261,7 +261,13 @@ async def sync_scan_results_to_cookies(
"""Upsert scan results into the site's cookie inventory.
Creates new Cookie records for newly discovered items or updates
last_seen_at for existing ones. Returns the number of new cookies.
``last_seen_at`` for existing ones. When ``auto_category`` is set
on the scan result and the cookie doesn't already have a
manually-assigned category, the auto-classified category is
propagated to the cookie inventory so it shows up categorised in
the admin UI without requiring manual review.
Returns the number of new cookies.
"""
results = await db.execute(select(ScanResult).where(ScanResult.scan_job_id == scan_job_id))
items = list(results.scalars().all())
@@ -269,6 +275,10 @@ async def sync_scan_results_to_cookies(
now_iso = datetime.now(UTC).isoformat()
new_count = 0
# Pre-load the category slug → id mapping so we don't query per cookie.
cat_rows = await db.execute(select(CookieCategory))
slug_to_id: dict[str, uuid.UUID] = {cat.slug: cat.id for cat in cat_rows.scalars().all()}
for item in items:
existing = await db.execute(
select(Cookie).where(
@@ -280,14 +290,21 @@ async def sync_scan_results_to_cookies(
)
cookie = existing.scalar_one_or_none()
# Resolve the auto-category slug to a category_id.
auto_cat_id = slug_to_id.get(item.auto_category) if item.auto_category else None
if cookie:
cookie.last_seen_at = now_iso
# Back-fill the category if not manually assigned yet.
if auto_cat_id and not cookie.category_id:
cookie.category_id = auto_cat_id
else:
cookie = Cookie(
site_id=site_id,
name=item.cookie_name,
domain=item.cookie_domain,
storage_type=item.storage_type,
category_id=auto_cat_id,
review_status="pending",
first_seen_at=now_iso,
last_seen_at=now_iso,

View File

@@ -4,6 +4,7 @@ import uuid
from datetime import UTC, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from jose import JWTError, jwt
from src.config.settings import get_settings
@@ -140,7 +141,11 @@ class TestAuthEndpoints:
response = await client.get("/api/v1/auth/me")
assert response.status_code == 401
async def test_me_with_valid_token(self, client):
async def test_me_with_valid_token(self, app):
from unittest.mock import AsyncMock, MagicMock
from src.db import get_db
user_id = uuid.uuid4()
org_id = uuid.uuid4()
token = create_access_token(
@@ -149,10 +154,34 @@ class TestAuthEndpoints:
role="editor",
email="user@example.com",
)
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
mock_user = MagicMock()
mock_user.id = user_id
mock_user.organisation_id = org_id
mock_user.email = "user@example.com"
mock_user.full_name = "Test User"
mock_user.role = "editor"
mock_user.deleted_at = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_user
mock_session.execute.return_value = mock_result
async def _override():
yield mock_session
app.dependency_overrides[get_db] = _override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
app.dependency_overrides.pop(get_db, None)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(user_id)

View File

@@ -5,7 +5,9 @@ import uuid
import pytest
from src.services.config_resolver import (
ALL_CATEGORIES,
SYSTEM_DEFAULTS,
_normalise_enabled_categories,
build_public_config,
resolve_config,
)
@@ -256,3 +258,72 @@ class TestConfigRoutes:
site_id = uuid.uuid4()
resp = await client.post(f"/api/v1/config/sites/{site_id}/publish")
assert resp.status_code == 401
class TestEnabledCategories:
"""Cascade semantics for ``enabled_categories``."""
def test_system_default_is_all_five(self):
result = resolve_config({})
assert result["enabled_categories"] == ALL_CATEGORIES
def test_site_override_narrows_system_default(self):
result = resolve_config({"enabled_categories": ["necessary", "analytics"]})
assert result["enabled_categories"] == ["necessary", "analytics"]
def test_site_override_beats_org_override(self):
result = resolve_config(
site_config={"enabled_categories": ["necessary", "marketing"]},
org_defaults={"enabled_categories": ["necessary", "analytics"]},
)
assert result["enabled_categories"] == ["necessary", "marketing"]
def test_group_override_beats_org_override_when_site_unset(self):
result = resolve_config(
site_config={},
org_defaults={"enabled_categories": ["necessary", "analytics"]},
group_defaults={"enabled_categories": ["necessary", "functional"]},
)
assert result["enabled_categories"] == ["necessary", "functional"]
def test_unset_site_inherits_org(self):
result = resolve_config(
site_config={},
org_defaults={"enabled_categories": ["necessary", "marketing"]},
)
assert result["enabled_categories"] == ["necessary", "marketing"]
def test_necessary_forced_in_when_missing_from_override(self):
"""Operators can't accidentally drop ``necessary``."""
result = resolve_config({"enabled_categories": ["analytics", "marketing"]})
assert "necessary" in result["enabled_categories"]
assert result["enabled_categories"] == ["necessary", "analytics", "marketing"]
def test_unknown_slugs_are_stripped(self):
result = resolve_config({"enabled_categories": ["necessary", "spam", "analytics"]})
assert result["enabled_categories"] == ["necessary", "analytics"]
def test_empty_list_falls_back_to_default(self):
"""An empty list is treated as 'no categories configured' → default."""
result = resolve_config({"enabled_categories": []})
assert result["enabled_categories"] == ALL_CATEGORIES
def test_non_list_value_falls_back_to_default(self):
result = resolve_config({"enabled_categories": "not-a-list"}) # type: ignore[dict-item]
assert result["enabled_categories"] == ALL_CATEGORIES
def test_result_is_in_canonical_display_order(self):
"""Insertion order from the cascade must not leak into the output."""
result = resolve_config({"enabled_categories": ["marketing", "necessary", "analytics"]})
assert result["enabled_categories"] == ["necessary", "analytics", "marketing"]
def test_public_config_includes_enabled_categories(self):
resolved = resolve_config({"enabled_categories": ["necessary", "analytics"]})
public = build_public_config("site-xyz", resolved)
assert public["enabled_categories"] == ["necessary", "analytics"]
def test_normalise_handles_none(self):
assert _normalise_enabled_categories(None) == ALL_CATEGORIES
def test_normalise_preserves_explicit_full_list(self):
assert _normalise_enabled_categories(list(ALL_CATEGORIES)) == ALL_CATEGORIES

View File

@@ -117,7 +117,8 @@ class TestMeEndpoint:
role="owner",
email="admin@test.com",
)
db = _mock_db()
mock_user = _make_user(id=user_id, org_id=org_id, email="admin@test.com", role="owner")
db = _mock_db(scalar_one_or_none=mock_user)
async with await _client(mock_app, db) as client:
resp = await client.get(
"/api/v1/auth/me",

View File

@@ -0,0 +1,116 @@
/**
* Tests for the loader ↔ bundle blocker bridge.
*
* ``consent-loader.js`` and ``consent-bundle.js`` are compiled as
* separate rollup IIFEs, so each one inlines its own copy of
* ``blocker.ts`` with private module state. The bundle therefore
* can't reach the loader's ``acceptedCategories`` set via a direct
* import — it has to call through ``window.__consentos._updateBlocker``,
* which the loader sets after ``installBlocker()``.
*
* We mock the imports the banner module pulls in so importing
* ``banner.ts`` here doesn't try to hit real network / timers.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Seed ``window.__consentos`` before banner.ts's init() IIFE runs at
// import time. Without this, destructuring ``window.__consentos`` at
// the top of init() throws and fills the test output with noise.
vi.hoisted(() => {
(globalThis as any).window = (globalThis as any).window || globalThis;
(globalThis as any).window.__consentos = {
siteId: '',
apiBase: '',
cdnBase: '',
loaded: false,
};
});
vi.mock('../consent', () => ({
buildConsentState: vi.fn(() => ({ accepted: [], rejected: [] })),
readConsent: vi.fn(() => null),
writeConsent: vi.fn(),
}));
vi.mock('../gcm', () => ({
buildGcmStateFromCategories: vi.fn(() => ({})),
updateGcm: vi.fn(),
}));
vi.mock('../i18n', () => ({
DEFAULT_TRANSLATIONS: {},
detectLocale: vi.fn(() => 'en'),
interpolate: vi.fn((s: string) => s),
loadTranslations: vi.fn(async () => ({})),
renderLinks: vi.fn((s: string) => s),
}));
vi.mock('../a11y', () => ({
announce: vi.fn(),
createLiveRegion: vi.fn(),
focusFirst: vi.fn(),
onEscape: vi.fn(() => () => {}),
prefersReducedMotion: vi.fn(() => false),
trapFocus: vi.fn(() => () => {}),
}));
// Prevent banner.ts's init() IIFE from running against real globals.
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('mocked'))));
import { updateAcceptedCategories } from '../banner';
describe('loader ↔ bundle blocker bridge', () => {
beforeEach(() => {
window.__consentos = {
siteId: 'test',
apiBase: 'https://api.example.com',
cdnBase: 'https://cdn.example.com',
loaded: false,
};
});
it('calls window.__consentos._updateBlocker when the bridge is present', () => {
const bridge = vi.fn();
window.__consentos._updateBlocker = bridge;
updateAcceptedCategories(['necessary', 'analytics']);
expect(bridge).toHaveBeenCalledTimes(1);
expect(bridge).toHaveBeenCalledWith(['necessary', 'analytics']);
});
it('forwards the exact array reference so the loader sees every slug', () => {
const bridge = vi.fn();
window.__consentos._updateBlocker = bridge;
const accepted = ['necessary', 'functional', 'marketing'] as const;
updateAcceptedCategories([...accepted]);
const args = bridge.mock.calls[0][0];
expect(args).toEqual(['necessary', 'functional', 'marketing']);
});
it('warns and returns cleanly when the bridge is missing', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
delete window.__consentos._updateBlocker;
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('blocker bridge missing'),
);
warn.mockRestore();
});
it('warns when window.__consentos is missing entirely', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// @ts-expect-error — simulating a pre-init state
window.__consentos = undefined;
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@@ -7,6 +7,7 @@ import {
isCategoryAllowed,
addScriptPatterns,
loadInitiatorMappings,
sweepDisallowedState,
} from '../blocker';
describe('blocker', () => {
@@ -330,4 +331,109 @@ describe('blocker', () => {
expect(isCategoryAllowed('necessary')).toBe(true);
});
});
describe('sweepDisallowedState', () => {
/**
* Reset the cookie jar by expiring any test cookies we know
* about. ``document.cookie =`` runs through the proxy so
* ``_consentos_*`` passes the shortcut and everything else gets
* eaten by the blocker's classifier. Expiring the well-known
* analytics cookies here gives the individual tests a clean
* starting point without relying on the jsdom harness to wipe
* between tests.
*/
function resetCookies(names: string[]) {
for (const name of names) {
document.cookie = `_consentos_reset_marker=${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
}
beforeEach(() => {
// Directly seed cookies via the native descriptor so the
// proxied setter doesn't eat them as "tracker writes with
// no consent".
const nativeSet = Object.getOwnPropertyDescriptor(
Document.prototype,
'cookie',
)?.set;
if (!nativeSet) throw new Error('cannot locate native cookie setter');
nativeSet.call(
document,
'_ga=GA1.2.seed; path=/',
);
nativeSet.call(
document,
'_fbp=fb.2.seed; path=/',
);
nativeSet.call(
document,
'unknown_cookie=opaque; path=/',
);
nativeSet.call(
document,
'_consentos_consent=%7B%7D; path=/',
);
});
afterEach(() => {
resetCookies(['_ga', '_fbp', 'unknown_cookie', '_consentos_consent']);
});
it('deletes non-consented analytics cookies', () => {
updateAcceptedCategories(['necessary']);
// ^^ updateAcceptedCategories calls sweep internally; the
// assertions below verify the post-sweep cookie jar.
expect(document.cookie).not.toContain('_ga=');
expect(document.cookie).not.toContain('_fbp=');
});
it('leaves consented cookies alone', () => {
updateAcceptedCategories(['necessary', 'analytics', 'marketing']);
expect(document.cookie).toContain('_ga=');
expect(document.cookie).toContain('_fbp=');
});
it('leaves unknown cookies alone even without consent', () => {
updateAcceptedCategories(['necessary']);
expect(document.cookie).toContain('unknown_cookie=');
});
it('never touches _consentos_* cookies', () => {
updateAcceptedCategories(['necessary']);
expect(document.cookie).toContain('_consentos_consent=');
});
it('standalone sweepDisallowedState respects the current set', () => {
updateAcceptedCategories(['necessary', 'analytics']);
// Re-seed _ga after the first sweep would have left it (analytics consented).
const nativeSet = Object.getOwnPropertyDescriptor(
Document.prototype,
'cookie',
)?.set;
nativeSet?.call(document, '_fbp=fb.2.reseed; path=/');
// Revoke marketing, sweep again.
updateAcceptedCategories(['necessary', 'analytics']);
sweepDisallowedState();
expect(document.cookie).toContain('_ga=');
expect(document.cookie).not.toContain('_fbp=');
});
it('cleans non-consented localStorage keys', () => {
localStorage.setItem('_consentos_keep', 'yes');
// Seed via a direct setItem — the proxied setter would block
// non-necessary writes, but we want a pre-existing key.
const nativeSetItem = Object.getPrototypeOf(localStorage).setItem;
nativeSetItem.call(localStorage, '_ga_stuff', 'tracker');
nativeSetItem.call(localStorage, 'opaque_key', 'leave-alone');
updateAcceptedCategories(['necessary']);
expect(localStorage.getItem('_consentos_keep')).toBe('yes');
expect(localStorage.getItem('_ga_stuff')).toBeNull();
expect(localStorage.getItem('opaque_key')).toBe('leave-alone');
localStorage.clear();
});
});
});

View File

@@ -10,12 +10,39 @@
*/
import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y';
import { updateAcceptedCategories } from './blocker';
// NB: intentionally NOT importing from './blocker'. The loader already
// installed the blocker proxies in its own IIFE module scope, and
// the bundle can't share that state via a direct import — rollup
// builds ``consent-loader.js`` and ``consent-bundle.js`` as separate
// IIFEs so each one inlines its own private copy of every module.
// The loader exposes ``_updateBlocker`` on ``window.__consentos``
// for us to drive its proxies — see ``updateAcceptedCategories``
// below and ``apps/banner/src/loader.ts``.
import { buildConsentState, readConsent, writeConsent } from './consent';
import { buildGcmStateFromCategories, updateGcm } from './gcm';
import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n';
import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types';
/**
* Drive the loader's blocker proxies with a new accepted-categories
* set. Falls back to a ``console.warn`` if the bridge is missing,
* which would mean the loader hasn't finished ``installBlocker`` yet
* (shouldn't happen — the bundle only loads after the loader's
* synchronous init phase). Exported for unit testing only.
*/
export function updateAcceptedCategories(accepted: CategorySlug[]): void {
const bridge = window.__consentos?._updateBlocker;
if (typeof bridge === 'function') {
bridge(accepted);
} else if (typeof console !== 'undefined') {
console.warn(
'[ConsentOS] blocker bridge missing — consent granted but ' +
'cookie/script blocker state was not updated. The loader ' +
'may not have initialised correctly.',
);
}
}
// -- Preference-centre closure captured during init() ---------------------
/**
@@ -89,6 +116,12 @@ export function registerEEHooks(hooks: Partial<EEHooks>): void {
// Expose for EE bundle
(window as any).__consentos_register_ee = registerEEHooks;
/**
* Every known category, in canonical display order. Used as the
* fallback when ``SiteConfig.enabled_categories`` isn't present in
* the API response (older deployments) and as the reference order
* for deduping / sorting runtime subsets.
*/
const ALL_CATEGORIES: CategorySlug[] = [
'necessary',
'functional',
@@ -97,12 +130,33 @@ const ALL_CATEGORIES: CategorySlug[] = [
'personalisation',
];
const NON_ESSENTIAL: CategorySlug[] = [
'functional',
'analytics',
'marketing',
'personalisation',
];
/**
* Return the categories the banner should render for this config.
* ``necessary`` is always implicit and forced back in if missing;
* unknown slugs are filtered; the result is sorted into the canonical
* display order so toggle positions don't jump around based on the
* cascade's insertion order. When the field is absent we return the
* full five — matches legacy behaviour and keeps older banner
* bundles working against an older API.
*/
function resolveEnabledCategories(config: SiteConfig): CategorySlug[] {
const raw = config.enabled_categories;
if (!raw || !Array.isArray(raw) || raw.length === 0) {
return [...ALL_CATEGORIES];
}
const picked = new Set<CategorySlug>(
raw.filter((slug): slug is CategorySlug =>
(ALL_CATEGORIES as string[]).includes(slug as string),
),
);
picked.add('necessary');
return ALL_CATEGORIES.filter((slug) => picked.has(slug));
}
/** Categories the user can toggle — everything except ``necessary``. */
function nonEssentialFor(enabled: CategorySlug[]): CategorySlug[] {
return enabled.filter((slug) => slug !== 'necessary');
}
/** Initialise the banner. Called when the bundle loads. */
async function init(): Promise<void> {
@@ -240,6 +294,7 @@ function buildDefaultConfig(siteId: string): SiteConfig {
consent_group_id: null,
ab_test: null,
initiator_map: null,
enabled_categories: [...ALL_CATEGORIES],
};
}
@@ -266,6 +321,9 @@ function renderBanner(
const titleId = 'cmp-title';
const descId = 'cmp-desc';
const enabledCategories = resolveEnabledCategories(config);
const nonEssential = nonEssentialFor(enabledCategories);
shadow.innerHTML = `
<style>${getBannerStyles(config)}</style>
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
@@ -277,7 +335,7 @@ function renderBanner(
</p>
</div>
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
${renderCategories(t)}
${renderCategories(t, enabledCategories)}
</div>
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
@@ -321,7 +379,7 @@ function renderBanner(
// Set up keyboard navigation
const cleanupFocusTrap = trapFocus(banner);
const cleanupEscape = onEscape(banner, () => {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
});
@@ -329,11 +387,12 @@ function renderBanner(
btn.addEventListener('click', (e) => {
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
if (action === 'accept') {
// Explicit Accept All overrides GPC — user choice takes precedence
handleConsent(ALL_CATEGORIES, [], config, gpcResult, abAssignment, t);
// Explicit Accept All overrides GPC — user choice takes precedence.
// "All" only includes the categories the operator has enabled.
handleConsent([...enabledCategories], [], config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'reject') {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'settings') {
const isHidden = categoriesDiv.style.display === 'none';
@@ -342,7 +401,7 @@ function renderBanner(
announce(liveRegion, isHidden ? t.managePreferences : t.title);
} else if (action === 'save') {
const accepted = getSelectedCategories(shadow);
const rejected = NON_ESSENTIAL.filter((c) => !accepted.includes(c));
const rejected = nonEssential.filter((c) => !accepted.includes(c));
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
}
@@ -355,16 +414,20 @@ function renderBanner(
focusFirst(banner);
}
/** Render category toggles HTML. */
function renderCategories(t: TranslationStrings): string {
const categories = [
{ slug: 'necessary', name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
{ slug: 'functional', name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
{ slug: 'analytics', name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
{ slug: 'marketing', name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
{ slug: 'personalisation', name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
/** Render category toggles HTML. Only renders the categories the
* config has enabled — ``necessary`` is always present and locked. */
function renderCategories(t: TranslationStrings, enabled: CategorySlug[]): string {
const all = [
{ slug: 'necessary' as const, name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
{ slug: 'functional' as const, name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
{ slug: 'analytics' as const, name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
{ slug: 'marketing' as const, name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
{ slug: 'personalisation' as const, name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
];
const enabledSet = new Set(enabled);
const categories = all.filter((cat) => enabledSet.has(cat.slug));
return (
categories
.map(

View File

@@ -122,12 +122,29 @@ export function loadInitiatorMappings(mappings: InitiatorMapping[]): void {
}
/**
* Update the set of accepted categories and release any blocked scripts
* that now have consent.
* Update the set of accepted categories, release any blocked scripts
* that now have consent, and sweep any existing cookies / storage
* items that belong to a category the visitor has **not** consented
* to. Consented categories are left untouched — those cookies are
* presumed to be in use by the site.
*/
export function updateAcceptedCategories(categories: CategorySlug[]): void {
acceptedCategories = new Set(categories);
releaseBlockedScripts();
sweepDisallowedState();
}
/**
* Delete any existing cookies and storage items whose classified
* category isn't currently consented. Runs on install and every
* consent-state update so historical trackers from pre-consent, a
* previous session, or a narrowed consent decision get removed.
* Unknown / unclassified cookies are left alone since we can't
* attribute them to a category.
*/
export function sweepDisallowedState(): void {
sweepDisallowedCookies();
sweepDisallowedStorage();
}
/** Get the current blocked script count (useful for debugging/reporting). */
@@ -492,6 +509,114 @@ function allNonEssentialConsented(): boolean {
);
}
// ─── Sweep existing state ──────────────────────────────────────────────
/** Delete classified cookies that aren't in a consented category. */
function sweepDisallowedCookies(): void {
if (typeof document === 'undefined') return;
if (!originalCookieDescriptor?.set) return;
const cookieHeader = document.cookie || '';
if (!cookieHeader) return;
const nativeSet = originalCookieDescriptor.set.bind(document);
const seen = new Set<string>();
for (const entry of cookieHeader.split(';')) {
const name = parseCookieName(entry);
if (!name || seen.has(name)) continue;
seen.add(name);
// Never touch ConsentOS's own cookies.
if (name.startsWith('_consentos_')) continue;
const category = classifyCookie(name);
// Unknown / unclassified cookies get left alone — we can't
// attribute them so we can't safely delete them.
if (!category || category === 'necessary') continue;
if (acceptedCategories.has(category)) continue;
// Expire the cookie. We don't know the domain / path the cookie
// was set on, so we fire deletes for every plausible combination:
// the current hostname bare, the leading-dot form, and every
// parent domain walked up from the left. This catches the common
// case of analytics cookies set on ``.example.com`` from a
// subdomain page without over-deleting.
const expired = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
try {
nativeSet(`${name}=; ${expired}`);
for (const domain of domainVariants()) {
nativeSet(`${name}=; ${expired}; domain=${domain}`);
}
} catch {
// Writing a cookie can throw in exotic sandboxed contexts;
// best-effort, don't crash the loader.
}
}
}
/** Derived list of plausible cookie domains for the current hostname. */
function domainVariants(): string[] {
if (typeof location === 'undefined' || !location.hostname) return [];
const hostname = location.hostname;
// IP addresses and ``localhost`` have no "parent domain" concept.
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname === 'localhost') {
return [hostname];
}
const parts = hostname.split('.');
const variants: string[] = [];
for (let i = 0; i < parts.length - 1; i++) {
const parent = parts.slice(i).join('.');
if (parent) {
variants.push(parent, `.${parent}`);
}
}
return Array.from(new Set(variants));
}
/** Delete classified localStorage / sessionStorage keys that aren't consented. */
function sweepDisallowedStorage(): void {
if (typeof Storage === 'undefined') return;
for (const storage of [safeStorage('local'), safeStorage('session')]) {
if (!storage) continue;
const toRemove: string[] = [];
try {
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (!key || key.startsWith('_consentos_')) continue;
const category = classifyStorageKey(key);
if (!category || category === 'necessary') continue;
if (acceptedCategories.has(category)) continue;
toRemove.push(key);
}
} catch {
continue;
}
for (const key of toRemove) {
try {
storage.removeItem(key);
} catch {
// Ignore quota / security errors — best-effort.
}
}
}
}
/** Return the requested Storage instance, or null if inaccessible. */
function safeStorage(kind: 'local' | 'session'): Storage | null {
try {
return kind === 'local' ? window.localStorage : window.sessionStorage;
} catch {
// Access can throw on cross-origin / sandboxed iframes.
return null;
}
}
// ─── Teardown (for testing) ───
/** Remove all interception hooks. Used in tests. */

View File

@@ -8,11 +8,16 @@
* 4. Fetch site config from CDN/API
*/
import { installBlocker, updateAcceptedCategories } from './blocker';
import {
installBlocker,
sweepDisallowedState,
updateAcceptedCategories,
} from './blocker';
import { hasConsent, readConsent } from './consent';
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from './gcm';
import { isGpcEnabled } from './gpc';
import type { GppApiCallback, GppApiFunction, GppQueueEntry } from './gpp-api';
import type { CategorySlug } from './types';
declare global {
interface Window {
@@ -25,6 +30,15 @@ declare global {
visitorRegion?: string;
/** Whether GPC signal was detected by the loader. */
gpcDetected?: boolean;
/**
* Internal: drives the blocker installed by the loader. The
* banner bundle is a separate IIFE with its own module scope,
* so it can't share ``acceptedCategories`` via a direct import
* — it has to call back through this bridge. See
* ``apps/banner/src/blocker.ts`` for the state it mutates.
* Consumers outside the banner bundle should not call this.
*/
_updateBlocker?: (accepted: CategorySlug[]) => void;
};
/** Public ConsentOS API for site integration. */
ConsentOS: {
@@ -101,6 +115,16 @@ declare global {
// 1. Install script/cookie blocker immediately (before any third-party scripts)
installBlocker();
// 1a. Bridge the blocker to the full banner bundle. ``consent-bundle.js``
// is built as a separate rollup IIFE with its own module scope, so it
// gets its own dead-end copy of ``blocker.ts``. Expose the loader's
// live ``updateAcceptedCategories`` on ``window.__consentos`` so
// ``handleConsent`` in the bundle can drive the loader's proxies
// directly. Without this, consent updates from the bundle would only
// mutate the bundle's copy and the cookie/storage proxies running in
// the loader's scope would stay stuck on ``Set(['necessary'])``.
window.__consentos._updateBlocker = updateAcceptedCategories;
// 1b. Install __gpp stub — queues calls until the full bundle loads
installGppStub();
@@ -114,7 +138,11 @@ declare global {
const existingConsent = readConsent();
if (existingConsent) {
// Consent already given — update blocker, GCM, and we're done
// Consent already given — update blocker (which also sweeps any
// cookies / storage in non-accepted categories), update GCM, and
// we're done. ``updateAcceptedCategories`` runs the sweep
// internally so historical trackers from a previously-wider
// consent set get cleaned up.
updateAcceptedCategories(existingConsent.accepted as import('./types').CategorySlug[]);
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
updateGcm(gcmState);
@@ -124,7 +152,15 @@ declare global {
return;
}
// 4. No consent — async-load the full banner bundle
// 4. No consent. Sweep any pre-existing classified trackers
// (typically ``_ga``, ``_fbp`` and friends that slipped in before
// the blocker was installed — e.g. from a script-ordering bug on
// the host page) so the visitor starts from a clean slate. Runs
// against the default ``Set(['necessary'])`` so every non-necessary
// known tracker is deleted.
sweepDisallowedState();
// 5. Async-load the full banner bundle
loadBannerBundle(cdnBase);
})();

View File

@@ -89,6 +89,14 @@ export interface SiteConfig {
ab_test: ABTestConfig | null;
/** Initiator map: root script URL → category for root-level blocking. */
initiator_map: InitiatorMapping[] | null;
/**
* Cookie categories the banner should render. Always contains
* ``necessary``; operators subset the remaining four via the config
* cascade (site → group → org → system default of all five). Older
* API responses may omit this field — callers should fall back to
* every known category in that case.
*/
enabled_categories?: CategorySlug[];
}
/** Maps a root initiator script to the cookie category it ultimately sets. */

View File

@@ -1,16 +1,28 @@
"""Playwright-based headless browser cookie crawler.
For each URL: launches headless Chromium, clears cookies, navigates,
waits for network idle, enumerates document.cookie / localStorage /
sessionStorage, captures Set-Cookie headers from network requests,
and attributes cookies to source scripts via the request chain.
For each URL: launches headless Chromium, **pre-seeds an
"all categories accepted" ConsentOS consent cookie**, clears any other
cookies, navigates, waits for network idle, enumerates
``document.cookie`` / ``localStorage`` / ``sessionStorage``, captures
``Set-Cookie`` headers from network requests, and attributes cookies
to source scripts via the request chain.
The pre-seed is what makes the scan useful: without it the loader
would block analytics/marketing scripts and the scan would only see
strictly-necessary cookies, which tells you nothing about what the
site actually loads in the post-consent state. Pre-consent compliance
checks live in ``consent_validator.py`` and use a separate code path.
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from urllib.parse import urlparse
from datetime import UTC, datetime
from urllib.parse import quote, urlparse
from playwright.async_api import (
BrowserContext,
@@ -22,6 +34,52 @@ from playwright.async_api import (
logger = logging.getLogger(__name__)
# All ConsentOS categories — pre-seeded as accepted on every crawl so
# the loader's "consent already given" branch fires and unblocks all
# scripts/cookies.
_ALL_CATEGORIES: list[str] = [
"necessary",
"functional",
"analytics",
"marketing",
"personalisation",
]
# Must match ``COOKIE_NAME`` in apps/banner/src/consent.ts. If you
# rename it there, rename it here too.
_CONSENT_COOKIE_NAME = "_consentos_consent"
def _build_consent_cookie(url: str) -> dict:
"""Return a Playwright cookie dict pre-seeding ConsentOS consent.
Mirrors the shape that ``apps/banner/src/consent.ts:writeConsent``
produces — URL-encoded JSON of a ``ConsentState`` — so the loader's
``readConsent`` returns a valid object and short-circuits straight
to ``updateAcceptedCategories(...)``. Categories are hard-coded to
every known ConsentOS category; the scanner is a "what does this
site load when the visitor accepts everything?" tool, by design.
"""
state = {
"visitorId": str(uuid.uuid4()),
"accepted": _ALL_CATEGORIES,
"rejected": [],
"consentedAt": datetime.now(UTC).isoformat(),
"bannerVersion": "scanner",
}
value = quote(json.dumps(state, separators=(",", ":")), safe="")
# Playwright's ``add_cookies`` accepts EITHER ``url`` (from which
# it derives domain/path/secure) OR explicit ``domain`` + ``path``
# — but not both. Using ``url`` is simplest.
return {
"name": _CONSENT_COOKIE_NAME,
"value": value,
"url": url,
"expires": time.time() + 365 * 86400,
"sameSite": "Lax",
}
# Realistic Chrome UA so sites don't block the crawler as a bot.
_DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
@@ -145,6 +203,9 @@ class CookieCrawler:
script_cookies: dict[str, str] = {} # cookie name → script URL
initiator_map: dict[str, str] = {} # request URL → initiating URL
initiator_chains: dict[str, list[str]] = {} # cookie name → chain
# Cookies discovered directly from Set-Cookie response headers.
# Keyed by (name, domain) so they can be merged with CDP results.
header_cookies: dict[tuple[str, str], DiscoveredCookie] = {}
context: BrowserContext | None = None
try:
@@ -152,8 +213,13 @@ class CookieCrawler:
user_agent=self._user_agent,
ignore_https_errors=True,
)
# Clear all cookies before visiting
# Start from a clean slate, then plant the ConsentOS consent
# cookie so the loader treats the visitor as having already
# accepted every category. Without this the scan only sees
# strictly-necessary cookies — useless for "what does this
# site actually load?" reporting.
await context.clear_cookies()
await context.add_cookies([_build_consent_cookie(url)])
page: Page = await context.new_page()
@@ -175,7 +241,9 @@ class CookieCrawler:
page.on("request", _on_request)
# Track Set-Cookie headers from responses
# Track Set-Cookie headers from responses and create
# DiscoveredCookie entries directly — CDP's context.cookies()
# may not enumerate cross-domain cookies.
async def _on_response(response: Response) -> None:
try:
headers = await response.all_headers()
@@ -186,25 +254,67 @@ class CookieCrawler:
initiator = _get_script_initiator(request)
# Build the initiator chain for this request
chain = _build_initiator_chain(request.url, initiator_map)
resp_domain = urlparse(response.url).hostname or ""
for cookie_str in set_cookie.split("\n"):
name = cookie_str.split("=")[0].strip()
if name:
if initiator:
script_cookies[name] = initiator
initiator_chains[name] = chain
# Parse optional Domain attribute from
# the Set-Cookie header; fall back to
# the response hostname.
domain = resp_domain
for part in cookie_str.split(";")[1:]:
part = part.strip()
if part.lower().startswith("domain="):
domain = part.split("=", 1)[1].strip()
break
key = (name, domain)
if key not in header_cookies:
header_cookies[key] = DiscoveredCookie(
name=name,
domain=domain,
storage_type="cookie",
script_source=initiator,
page_url=url,
initiator_chain=chain,
)
except Exception:
pass # Non-critical — response may have been aborted
page.on("response", _on_response)
# Navigate
await page.goto(url, wait_until="domcontentloaded", timeout=self._timeout_ms)
# Allow additional time for scripts to set cookies after DOM load.
await page.wait_for_timeout(3000)
# Navigate — networkidle waits until ≤2 active connections for
# 500ms, which catches the GA beacon round-trip that
# domcontentloaded misses.
await page.goto(url, wait_until="networkidle", timeout=self._timeout_ms)
# Safety margin for late-firing scripts (e.g. deferred GTM tags).
await page.wait_for_timeout(5000)
# Enumerate browser cookies via CDP
# First pass — enumerate browser cookies via CDP.
cdp_cookies = await context.cookies()
# Second pass — wait a further 2 seconds for any delayed
# Set-Cookie headers, then merge newly appeared cookies.
await page.wait_for_timeout(2000)
delayed_cookies = await context.cookies()
# Merge: index first-pass cookies by (name, domain), then
# add any that only appeared in the second pass.
seen_keys: set[tuple[str, str]] = set()
all_cdp_cookies: list[dict] = []
for c in cdp_cookies:
key = (c["name"], c["domain"])
seen_keys.add(key)
all_cdp_cookies.append(c)
for c in delayed_cookies:
key = (c["name"], c["domain"])
if key not in seen_keys:
seen_keys.add(key)
all_cdp_cookies.append(c)
for c in all_cdp_cookies:
result.cookies.append(
DiscoveredCookie(
name=c["name"],
@@ -222,6 +332,13 @@ class CookieCrawler:
)
)
# Merge cookies seen in Set-Cookie headers but NOT in the
# CDP cookie jar (e.g. cross-domain cookies that the browser
# scoped to a different origin).
for key, hc in header_cookies.items():
if key not in seen_keys:
result.cookies.append(hc)
# Enumerate localStorage
ls_items = await page.evaluate("""() => {
const items = [];

View File

@@ -75,6 +75,13 @@ async def _fetch_sitemap(
if resp.status_code != 200:
return []
# SPAs with catch-all nginx/Caddy rules return 200 + text/html
# for /sitemap.xml. Don't try to parse HTML as XML.
content_type = resp.headers.get("content-type", "")
if "html" in content_type and "xml" not in content_type:
logger.debug("Sitemap %s returned HTML, skipping", url)
return []
root = ElementTree.fromstring(resp.text)
# Check if it's a sitemap index

View File

@@ -8,10 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.crawler import (
_ALL_CATEGORIES,
_CONSENT_COOKIE_NAME,
CookieCrawler,
CrawlResult,
DiscoveredCookie,
SiteCrawlResult,
_build_consent_cookie,
_build_initiator_chain,
_get_script_initiator,
)
@@ -39,11 +42,35 @@ def _make_mock_page(
return page
def _make_mock_context(page, cookies: list[dict] | None = None):
"""Build a mock BrowserContext."""
def _make_mock_context(
page,
cookies: list[dict] | None = None,
delayed_cookies: list[dict] | None = None,
):
"""Build a mock BrowserContext.
*cookies* is returned on the first ``context.cookies()`` call (the
initial CDP enumeration). *delayed_cookies* is returned on the
second call (the delayed pass); defaults to the same list so
existing tests need no changes.
"""
context = AsyncMock()
context.new_page = AsyncMock(return_value=page)
context.cookies = AsyncMock(return_value=cookies or [])
first = cookies or []
second = delayed_cookies if delayed_cookies is not None else first
# The crawler calls context.cookies() twice per page (initial +
# delayed pass). Using a cycling function instead of a fixed-length
# side_effect list so multi-page tests don't exhaust the mock.
_cycle = [first, second]
_call_count = 0
async def _cycling_cookies(*_args, **_kwargs):
nonlocal _call_count
result = _cycle[_call_count % len(_cycle)]
_call_count += 1
return result
context.cookies = AsyncMock(side_effect=_cycling_cookies)
context.clear_cookies = AsyncMock()
context.close = AsyncMock()
return context
@@ -370,6 +397,44 @@ class TestCrawlPage:
call_kwargs = browser.new_context.call_args[1]
assert call_kwargs["user_agent"] == "CMPBot/1.0"
@pytest.mark.asyncio(loop_scope="session")
async def test_two_pass_cookie_collection_merges_delayed(self):
"""Cookies appearing only in the second CDP pass are still discovered."""
first_pass = [
{"name": "_ga", "domain": ".example.com", "value": "GA1.2.12345"},
]
second_pass = [
{"name": "_ga", "domain": ".example.com", "value": "GA1.2.12345"},
{"name": "_gid", "domain": ".example.com", "value": "GID.99"},
]
page = _make_mock_page()
context = _make_mock_context(page, cookies=first_pass, delayed_cookies=second_pass)
browser = _make_mock_browser(context)
crawler = CookieCrawler()
result = await crawler._crawl_page(browser, "https://example.com/")
cookie_names = [c.name for c in result.cookies if c.storage_type == "cookie"]
assert "_ga" in cookie_names
assert "_gid" in cookie_names
# _ga must not be duplicated
assert cookie_names.count("_ga") == 1
@pytest.mark.asyncio(loop_scope="session")
async def test_uses_networkidle_wait(self):
"""page.goto must use wait_until='networkidle'."""
page = _make_mock_page()
context = _make_mock_context(page)
browser = _make_mock_browser(context)
crawler = CookieCrawler()
await crawler._crawl_page(browser, "https://example.com/")
page.goto.assert_awaited_once()
call_kwargs = page.goto.call_args[1]
assert call_kwargs.get("wait_until") == "networkidle"
# ── CookieCrawler.crawl_site ───────────────────────────────────────────
@@ -438,3 +503,87 @@ class TestCrawlSite:
await crawler.crawl_site(["https://example.com/"])
browser.close.assert_awaited_once()
# ── Consent pre-seed ────────────────────────────────────────────────────
class TestBuildConsentCookie:
"""The pre-seeded ``_consentos_consent`` cookie."""
def test_cookie_name_matches_loader(self):
cookie = _build_consent_cookie("https://example.com/")
assert cookie["name"] == _CONSENT_COOKIE_NAME == "_consentos_consent"
def test_cookie_is_url_scoped_for_playwright(self):
"""``url`` lets Playwright derive domain / path / secure."""
cookie = _build_consent_cookie("https://example.com/page")
assert cookie["url"] == "https://example.com/page"
# ``path`` is NOT set explicitly — Playwright derives it from ``url``.
# Setting both would cause ``add_cookies`` to reject the cookie.
assert "path" not in cookie
def test_cookie_value_decodes_to_consent_state_with_all_categories(self):
import json as _json
from urllib.parse import unquote
cookie = _build_consent_cookie("https://example.com/")
state = _json.loads(unquote(cookie["value"]))
assert sorted(state["accepted"]) == sorted(_ALL_CATEGORIES)
assert state["rejected"] == []
# ConsentState fields the loader's readConsent() relies on
assert "visitorId" in state
assert "consentedAt" in state
assert "bannerVersion" in state
def test_cookie_expires_far_in_future(self):
import time as _time
cookie = _build_consent_cookie("https://example.com/")
# ~1 year, allow generous slack for test timing
assert cookie["expires"] > _time.time() + 300 * 86400
@pytest.mark.asyncio(loop_scope="session")
@patch("src.crawler.async_playwright")
async def test_crawl_seeds_consent_before_navigation(self, mock_pw):
"""``add_cookies`` must be called before ``page.goto``."""
page = _make_mock_page()
context = _make_mock_context(page)
browser = _make_mock_browser(context)
# Track call order on the context
call_order: list[str] = []
original_add = context.add_cookies
original_clear = context.clear_cookies
async def _add(*args, **kwargs):
call_order.append("add_cookies")
return await original_add(*args, **kwargs)
async def _clear(*args, **kwargs):
call_order.append("clear_cookies")
return await original_clear(*args, **kwargs)
async def _goto(*args, **kwargs):
call_order.append("goto")
context.add_cookies = AsyncMock(side_effect=_add)
context.clear_cookies = AsyncMock(side_effect=_clear)
page.goto = AsyncMock(side_effect=_goto)
pw_instance = AsyncMock()
pw_instance.chromium.launch = AsyncMock(return_value=browser)
mock_pw.return_value.__aenter__ = AsyncMock(return_value=pw_instance)
mock_pw.return_value.__aexit__ = AsyncMock(return_value=False)
crawler = CookieCrawler()
await crawler.crawl_site(["https://example.com/"])
assert call_order == ["clear_cookies", "add_cookies", "goto"], call_order
# And the cookie payload was the one we expect
seeded = context.add_cookies.call_args.args[0]
assert len(seeded) == 1
assert seeded[0]["name"] == "_consentos_consent"
assert seeded[0]["url"] == "https://example.com/"

View File

@@ -27,7 +27,7 @@ services:
command:
- "sh"
- "-c"
- "python -m alembic upgrade head && python -m src.cli.bootstrap_admin"
- "python -m alembic upgrade head && python -m src.cli.bootstrap_admin && python -m src.cli.seed_known_cookies"
restart: "no"
depends_on:
postgres:

723
docs/deployment-guide.md Normal file
View File

@@ -0,0 +1,723 @@
# ConsentOS Deployment Guide
This guide covers deploying ConsentOS in production across three environments:
1. [Docker Compose](#1-docker-compose) — single VM, the quickest path to production
2. [Kubernetes (Helm)](#2-kubernetes-helm) — multi-node, auto-scaling, the long-term path
3. [Cloud Run / Serverless](#3-cloud-run--serverless) — managed containers, minimal ops
All three share the same container images, environment variables, and bootstrap flow. Pick whichever matches your infrastructure; mix and match where it makes sense (e.g. Cloud SQL for the database, Cloud Run for the API).
---
## Prerequisites
Before you begin, you'll need:
| Item | Notes |
|------|-------|
| **Domain name** | Two DNS records: one for the admin UI + banner CDN (e.g. `cmp.example.com`), one for each customer site that embeds the banner (their own domains). |
| **TLS certificates** | Terminate TLS at your reverse proxy / load balancer (Caddy, nginx, Cloud Load Balancer). The containers serve plain HTTP internally. |
| **PostgreSQL 16+** | Built-in via Docker / Helm, or managed (RDS, Cloud SQL, Supabase). |
| **Redis 7+** | Built-in or managed (ElastiCache, Memorystore, Upstash). |
| **Docker or container runtime** | Docker Engine 24+ with Compose v2, or a Kubernetes cluster with Helm 3. |
| **Git** | To clone the repository. |
### Generating secrets
Several environment variables require strong random values. Generate them with:
```bash
# JWT secret — used to sign access and refresh tokens
openssl rand -hex 32
# Postgres password
openssl rand -hex 24
# Redis password
openssl rand -hex 24
# Admin bootstrap token (optional — gates runtime org creation)
openssl rand -hex 32
```
---
## Environment Variables Reference
All ConsentOS services read configuration from environment variables (or a `.env` file in Docker Compose). The canonical list with defaults is in `.env.example` at the repository root. The critical ones for production are:
### Application
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `ENVIRONMENT` | Yes | `development` | Set to `production`. The API refuses to start with unsafe defaults (placeholder JWT secret, wildcard CORS) when this is not `development`/`dev`/`test`. |
| `LOG_LEVEL` | No | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`. |
### Database & Redis
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | `postgresql+asyncpg://consentos:consentos@postgres:5432/consentos` | Async SQLAlchemy connection string. |
| `POSTGRES_USER` | Docker only | — | Used by the Postgres container to initialise the database. |
| `POSTGRES_PASSWORD` | Docker only | — | See above. |
| `POSTGRES_DB` | Docker only | — | See above. |
| `REDIS_URL` | Yes | `redis://localhost:6379/0` | Include the password as `redis://default:<password>@host:6379/0` if auth is enabled. |
| `REDIS_PASSWORD` | Docker only | — | Passed to the Redis container's `--requirepass`. |
### Authentication & Security
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `JWT_SECRET_KEY` | Yes | `CHANGE-ME-in-production` | Must be replaced. The API refuses to start in production with the placeholder value. Generate with `openssl rand -hex 32`. |
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | No | `30` | Access token lifetime. |
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | No | `7` | Refresh token lifetime. |
| `ALLOWED_ORIGINS` | Yes | `http://localhost:5173` | Comma-separated list of origins allowed to call the API. Include the admin UI origin and every customer site that embeds the banner. Wildcards are refused when `ENVIRONMENT` is not dev/test. |
### Initial Admin Bootstrap
On first startup, if the `users` table is empty and both credentials below are set, the bootstrap init container creates an organisation and an owner user so you can log in to the admin UI. Idempotent — once any user exists, this is a no-op.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `INITIAL_ADMIN_EMAIL` | Recommended | — | E-mail address for the first admin user. |
| `INITIAL_ADMIN_PASSWORD` | Recommended | — | Password for the first admin user. **Rotate via the admin UI after first login.** |
| `INITIAL_ADMIN_FULL_NAME` | No | `Administrator` | Display name. |
| `INITIAL_ORG_NAME` | No | `Default Organisation` | Name of the initial organisation. |
| `INITIAL_ORG_SLUG` | No | `default` | URL slug for the initial organisation. |
### CDN & Banner
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CDN_BASE_URL` | Yes | `http://localhost:5173` | Public URL where `consent-loader.js` and `consent-bundle.js` are hosted. In the default Docker Compose deployment, this is the same origin as the admin UI (the admin-ui image bundles the banner at its nginx root). |
### GeoIP
ConsentOS resolves visitor location for regional consent modes (e.g. opt-in for EU, opt-out for California). Resolution runs in order: CDN headers → local MaxMind database → external API fallback.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GEOIP_COUNTRY_HEADER` | No | — | Custom HTTP header carrying the visitor's ISO 3166-1 alpha-2 country code. Checked before the built-in list (`cf-ipcountry`, `x-vercel-ip-country`, `x-appengine-country`, `x-country-code`). Case-insensitive. |
| `GEOIP_REGION_HEADER` | No | — | Companion header carrying the ISO 3166-2 subdivision code (e.g. `CA` for California, `SCT` for Scotland). Paired with `GEOIP_COUNTRY_HEADER` to produce region keys like `US-CA` or `GB-SCT`. |
| `GEOIP_MAXMIND_DB_PATH` | No | — | Path to a local MaxMind GeoLite2-City `.mmdb` file. Used when no CDN header resolves. Download from [MaxMind](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data) (free, registration required). |
**Common CDN header configurations:**
| CDN / Load Balancer | `GEOIP_COUNTRY_HEADER` | `GEOIP_REGION_HEADER` |
|---------------------|------------------------|-----------------------|
| Cloudflare (all plans) | `cf-ipcountry` *(built-in, no env needed)* | — |
| Cloudflare (Enterprise) | `cf-ipcountry` *(built-in)* | `cf-region-code` |
| Vercel | `x-vercel-ip-country` *(built-in)* | `x-vercel-ip-country-region` |
| Google Cloud Load Balancer | `x-gclb-country` | `x-gclb-region` |
| AWS CloudFront (functions) | `cloudfront-viewer-country` | `cloudfront-viewer-country-region` |
| Generic / custom | *your header name* | *your header name* |
> **Cloudflare users**: `cf-ipcountry` is in the built-in list, so you don't need to set `GEOIP_COUNTRY_HEADER` at all. Country-level resolution works out of the box. For US-state or UK-region granularity, set `GEOIP_REGION_HEADER=cf-region-code` (requires a Cloudflare Enterprise plan or a Managed Transform rule that exposes the header).
### Scanner
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SCANNER_SERVICE_URL` | Yes (API) | `http://localhost:8001` | URL the Celery worker uses to reach the scanner service. In Docker Compose this is `http://consentos-scanner:8001`. |
> **Important**: the scanner must NOT share the API's `.env` file via `env_file:`. Variables like `PORT` leak across and rebind the scanner off its default `8001`. Use an explicit `environment:` block instead (the prod compose already does this).
---
## 1. Docker Compose
The fastest path to a running ConsentOS instance. One VM, one `docker compose up`, everything behind a reverse proxy like Caddy or nginx.
### 1.1 Clone the repository
```bash
git clone https://github.com/ConsentOS/consentos.git /opt/consentos
cd /opt/consentos
```
### 1.2 Create the `.env` file
```bash
cp .env.example .env
```
Edit `.env` and set at minimum:
```env
ENVIRONMENT=production
# Database
DATABASE_URL=postgresql+asyncpg://consentos:<POSTGRES_PASSWORD>@postgres:5432/consentos
POSTGRES_USER=consentos
POSTGRES_PASSWORD=<generate with openssl rand -hex 24>
POSTGRES_DB=consentos
# Redis
REDIS_URL=redis://default:<REDIS_PASSWORD>@redis:6379/0
REDIS_PASSWORD=<generate with openssl rand -hex 24>
# JWT
JWT_SECRET_KEY=<generate with openssl rand -hex 32>
# CDN — same origin as the admin UI in this setup
CDN_BASE_URL=https://cmp.example.com
# CORS — admin origin + every customer site embedding the banner
ALLOWED_ORIGINS=https://cmp.example.com,https://www.example.com
# Initial admin
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=<strong temporary password>
# GeoIP — if behind Cloudflare, country detection works automatically.
# For state/region granularity behind Cloudflare Enterprise:
# GEOIP_REGION_HEADER=cf-region-code
```
### 1.3 Start the stack
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
The init container (`consentos-bootstrap`) runs Alembic migrations and creates the initial admin user, then exits. All other services wait for it via `service_completed_successfully`.
### 1.4 Verify
```bash
# Check services
docker compose -f docker-compose.prod.yml ps
# API health
curl http://localhost:11001/health
# Deep readiness (checks Postgres + Redis)
curl http://localhost:11001/health/ready
```
### 1.5 Reverse proxy
The API listens on `127.0.0.1:11001` and the admin UI on `127.0.0.1:11002`. Put a reverse proxy in front to terminate TLS.
**Caddy example** (`/etc/caddy/Caddyfile`):
```caddyfile
cmp.example.com {
# API
handle /api/v1/* {
reverse_proxy localhost:11001
}
# Hosted policy pages
handle /c/* {
reverse_proxy localhost:11001
}
# Health check
handle /health {
reverse_proxy localhost:11001
}
# Admin UI + banner CDN (catch-all, must be last)
reverse_proxy localhost:11002
}
```
Caddy handles TLS automatically via Let's Encrypt. Reload after creating the file:
```bash
sudo systemctl reload caddy
```
**nginx example** (`/etc/nginx/sites-enabled/consentos`):
```nginx
server {
listen 443 ssl http2;
server_name cmp.example.com;
ssl_certificate /etc/letsencrypt/live/cmp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cmp.example.com/privkey.pem;
location /api/v1/ {
proxy_pass http://127.0.0.1:11001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /c/ {
proxy_pass http://127.0.0.1:11001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://127.0.0.1:11001;
}
location / {
proxy_pass http://127.0.0.1:11002;
}
}
```
### 1.6 Integrate the banner
Add the loader to every page on your customer site, **as the very first `<script>` in `<head>`** — no `async`, no `defer`:
```html
<script src="https://cmp.example.com/consent-loader.js"
data-site-id="<site-id-from-admin-ui>"
data-api-base="https://cmp.example.com"></script>
```
> **Critical**: the loader must run synchronously before any other script. If another script executes first (e.g. Google Tag Manager), it can set cookies before the blocker is installed. The loader will sweep classified pre-existing cookies on load, but `Set-Cookie` response headers from network requests cannot be intercepted from JavaScript — only blocking the source script prevents those.
### 1.7 Updating
```bash
cd /opt/consentos
git pull
docker compose -f docker-compose.prod.yml up -d --build
docker image prune -f
```
The bootstrap init container runs migrations automatically on every start, so schema updates are applied without manual intervention.
---
## 2. Kubernetes (Helm)
For multi-node deployments with auto-scaling, rolling updates, and integration with managed databases and Redis.
### 2.1 Prerequisites
- A Kubernetes cluster (1.24+)
- Helm 3
- Container images pushed to a registry (GHCR, ECR, GCR, etc.)
- A managed PostgreSQL instance (recommended) or an in-cluster one
- A managed Redis instance (recommended) or an in-cluster one
### 2.2 Build and push images
```bash
# API + Celery worker/beat (same image, different entrypoint)
docker build -t ghcr.io/consentos/consentos-api:latest apps/api/
docker push ghcr.io/consentos/consentos-api:latest
# Scanner
docker build -t ghcr.io/consentos/consentos-scanner:latest apps/scanner/
docker push ghcr.io/consentos/consentos-scanner:latest
# Admin UI + banner (build context = repo root)
docker build -f apps/admin-ui/Dockerfile -t ghcr.io/consentos/consentos-admin-ui:latest .
docker push ghcr.io/consentos/consentos-admin-ui:latest
```
### 2.3 Create a values override
```yaml
# values.prod.yaml
api:
replicaCount: 3
env:
ENVIRONMENT: production
LOG_LEVEL: INFO
ALLOWED_ORIGINS: "https://cmp.example.com,https://www.example.com"
CDN_BASE_URL: "https://cmp.example.com"
SCANNER_SERVICE_URL: "http://consentos-scanner:8001"
# GeoIP — behind Cloudflare, country resolves automatically.
# For state-level behind Cloudflare Enterprise:
# GEOIP_REGION_HEADER: cf-region-code
# Or mount a MaxMind DB and set:
# GEOIP_MAXMIND_DB_PATH: /data/GeoLite2-City.mmdb
scanner:
replicaCount: 1
resources:
limits:
memory: 1Gi
adminUi:
replicaCount: 2
# Use managed Postgres (e.g. Cloud SQL, RDS)
postgresql:
enabled: false
externalUrl: "postgresql+asyncpg://consentos:<PASSWORD>@<HOST>:5432/consentos"
# Use managed Redis (e.g. Memorystore, ElastiCache)
redis:
enabled: false
externalUrl: "redis://default:<PASSWORD>@<HOST>:6379/0"
# Ingress (nginx-ingress or similar)
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: cmp.example.com
paths:
- path: /api
pathType: Prefix
service: api
- path: /c
pathType: Prefix
service: api
- path: /health
pathType: Prefix
service: api
- path: /
pathType: Prefix
service: admin-ui
tls:
- secretName: consentos-tls
hosts:
- cmp.example.com
# Secrets
secrets:
jwtSecretKey: "<generate with openssl rand -hex 32>"
postgresqlPassword: "<your managed DB password>"
```
### 2.4 Install the chart
```bash
helm install consentos helm/consentos/ \
-f values.prod.yaml \
--namespace consentos \
--create-namespace
```
### 2.5 Run the bootstrap
The Helm chart doesn't include an init container by default. Run the bootstrap as a one-off Kubernetes Job:
```bash
kubectl run consentos-bootstrap \
--namespace consentos \
--image ghcr.io/consentos/consentos-api:latest \
--restart=Never \
--env="DATABASE_URL=postgresql+asyncpg://consentos:<PW>@<HOST>:5432/consentos" \
--env="INITIAL_ADMIN_EMAIL=admin@example.com" \
--env="INITIAL_ADMIN_PASSWORD=<temporary password>" \
--env="JWT_SECRET_KEY=<your key>" \
--env="ENVIRONMENT=production" \
--command -- sh -c "python -m alembic upgrade head && python -m src.cli.bootstrap_admin"
```
Wait for it to complete, then delete the pod:
```bash
kubectl wait --for=condition=Ready pod/consentos-bootstrap -n consentos --timeout=120s
kubectl delete pod consentos-bootstrap -n consentos
```
### 2.6 Verify
```bash
kubectl get pods -n consentos
curl https://cmp.example.com/health/ready
```
### 2.7 Updating
```bash
# Rebuild and push images with a new tag
docker build -t ghcr.io/consentos/consentos-api:v1.2.0 apps/api/
docker push ghcr.io/consentos/consentos-api:v1.2.0
# Upgrade the Helm release
helm upgrade consentos helm/consentos/ \
-f values.prod.yaml \
--set api.image.tag=v1.2.0 \
--set scanner.image.tag=v1.2.0 \
--set adminUi.image.tag=v1.2.0 \
--namespace consentos
```
Helm performs a rolling update. The API Dockerfile runs migrations on startup (the Dockerfile's `CMD` includes `alembic upgrade head`), so schema updates are applied automatically as new pods come up.
> **Note**: In the Docker Compose deployment, migrations are owned by the init container, and the API's `CMD` only runs `uvicorn`. In Kubernetes, since there's no native "init container completes first" guarantee across separate Deployments, each API pod runs its own `alembic upgrade head` on startup. Alembic migrations are idempotent, so multiple pods running them concurrently is safe.
### 2.8 GeoIP with MaxMind on Kubernetes
If you need local MaxMind lookups (e.g. behind a load balancer that doesn't inject GeoIP headers), mount the database file via a PersistentVolumeClaim or a ConfigMap:
```yaml
# In values.prod.yaml
api:
env:
GEOIP_MAXMIND_DB_PATH: /data/GeoLite2-City.mmdb
extraVolumes:
- name: geoip-db
persistentVolumeClaim:
claimName: geoip-db
extraVolumeMounts:
- name: geoip-db
mountPath: /data
readOnly: true
```
Use a CronJob to refresh the MaxMind database weekly:
```bash
kubectl create cronjob geoip-update \
--namespace consentos \
--schedule="0 3 * * 0" \
--image maxmindinc/geoipupdate \
--env="GEOIPUPDATE_ACCOUNT_ID=<your-id>" \
--env="GEOIPUPDATE_LICENSE_KEY=<your-key>" \
--env="GEOIPUPDATE_EDITION_IDS=GeoLite2-City"
```
---
## 3. Cloud Run / Serverless
For teams that want managed scaling, zero cold-infrastructure, and pay-per-request pricing. This guide uses Google Cloud Run as the reference, but the pattern adapts to AWS App Runner, Azure Container Apps, or Fly.io.
### 3.1 Architecture
| Component | Service | Notes |
|-----------|---------|-------|
| API | Cloud Run service | Scales to zero. Connects to Cloud SQL + Memorystore. |
| Admin UI + banner | Cloud Run service (or Cloud Storage + CDN) | Static files — can also be served from a GCS bucket behind Cloud CDN. |
| Celery worker | Cloud Run Job or always-on instance (min 1) | Must be always-on to process the Redis queue. Cloud Run Jobs work for batch processing but not for long-polling Celery workers — use an always-on revision with `--min-instances=1`. |
| Celery beat | Cloud Run Job (scheduled) or Cloud Scheduler + Pub/Sub | Triggers periodic tasks. Alternatively, use Cloud Scheduler to invoke the API's scan endpoints directly. |
| Scanner | Cloud Run service (or separate VM) | Needs 1 GB+ RAM and `/dev/shm` > 64 MB for Playwright/Chromium. Cloud Run supports custom `/dev/shm` sizes via `--execution-environment=gen2`. |
| PostgreSQL | Cloud SQL | Managed, auto-backups, replicas. |
| Redis | Memorystore for Redis | Or Upstash for a serverless Redis. |
### 3.2 Build and push images
```bash
# Tag for Artifact Registry (or Container Registry)
export REGION=europe-west1
export PROJECT=my-gcp-project
export REGISTRY=${REGION}-docker.pkg.dev/${PROJECT}/consentos
docker build -t ${REGISTRY}/api:latest apps/api/
docker build -t ${REGISTRY}/scanner:latest apps/scanner/
docker build -f apps/admin-ui/Dockerfile -t ${REGISTRY}/admin-ui:latest .
docker push ${REGISTRY}/api:latest
docker push ${REGISTRY}/scanner:latest
docker push ${REGISTRY}/admin-ui:latest
```
### 3.3 Provision managed infrastructure
```bash
# Cloud SQL (Postgres 16)
gcloud sql instances create consentos-db \
--database-version=POSTGRES_16 \
--tier=db-f1-micro \
--region=${REGION} \
--root-password=<POSTGRES_PASSWORD>
gcloud sql databases create consentos --instance=consentos-db
gcloud sql users create consentos --instance=consentos-db --password=<POSTGRES_PASSWORD>
# Memorystore (Redis 7)
gcloud redis instances create consentos-redis \
--size=1 \
--region=${REGION} \
--redis-version=redis_7_0
```
### 3.4 Deploy the API
```bash
gcloud run deploy consentos-api \
--image ${REGISTRY}/api:latest \
--region ${REGION} \
--platform managed \
--allow-unauthenticated \
--min-instances=1 \
--max-instances=10 \
--memory=512Mi \
--cpu=1 \
--port=8000 \
--set-env-vars="ENVIRONMENT=production" \
--set-env-vars="DATABASE_URL=postgresql+asyncpg://consentos:<PW>@<CLOUD_SQL_IP>:5432/consentos" \
--set-env-vars="REDIS_URL=redis://<MEMORYSTORE_IP>:6379/0" \
--set-env-vars="JWT_SECRET_KEY=<your-key>" \
--set-env-vars="CDN_BASE_URL=https://cmp.example.com" \
--set-env-vars="ALLOWED_ORIGINS=https://cmp.example.com,https://www.example.com" \
--set-env-vars="SCANNER_SERVICE_URL=https://consentos-scanner-<hash>.run.app" \
--set-env-vars="INITIAL_ADMIN_EMAIL=admin@example.com" \
--set-env-vars="INITIAL_ADMIN_PASSWORD=<temp-pw>" \
--add-cloudsql-instances=${PROJECT}:${REGION}:consentos-db \
--vpc-connector=consentos-vpc-connector
```
> **Tip**: Use Secret Manager for sensitive values instead of inline `--set-env-vars`:
> ```bash
> --set-secrets="JWT_SECRET_KEY=jwt-secret:latest,POSTGRES_PASSWORD=pg-password:latest"
> ```
### 3.5 Deploy the admin UI
```bash
gcloud run deploy consentos-admin \
--image ${REGISTRY}/admin-ui:latest \
--region ${REGION} \
--platform managed \
--allow-unauthenticated \
--min-instances=0 \
--max-instances=5 \
--memory=128Mi \
--cpu=1 \
--port=80
```
### 3.6 Deploy the scanner
The scanner needs generous memory and `/dev/shm` for Playwright:
```bash
gcloud run deploy consentos-scanner \
--image ${REGISTRY}/scanner:latest \
--region ${REGION} \
--platform managed \
--no-allow-unauthenticated \
--min-instances=0 \
--max-instances=3 \
--memory=1Gi \
--cpu=2 \
--port=8001 \
--execution-environment=gen2 \
--set-env-vars="CRAWLER_HEADLESS=true,LOG_LEVEL=INFO"
```
### 3.7 Deploy the Celery worker
Cloud Run isn't ideal for long-running Celery workers (it expects request-driven traffic). Options:
**Option A — always-on Cloud Run revision:**
```bash
gcloud run deploy consentos-worker \
--image ${REGISTRY}/api:latest \
--region ${REGION} \
--platform managed \
--no-allow-unauthenticated \
--min-instances=1 \
--max-instances=3 \
--memory=512Mi \
--cpu=1 \
--no-cpu-throttling \
--command="celery","-A","src.celery_app","worker","--loglevel=info","--concurrency=2" \
--set-env-vars="DATABASE_URL=...,REDIS_URL=...,SCANNER_SERVICE_URL=..." \
--vpc-connector=consentos-vpc-connector
```
**Option B — Compute Engine (GCE) or a small GKE node** running just the Celery worker and beat. Simpler, cheaper for steady-state workloads.
### 3.8 Run the bootstrap
Run as a one-off Cloud Run Job:
```bash
gcloud run jobs create consentos-bootstrap \
--image ${REGISTRY}/api:latest \
--region ${REGION} \
--command="sh","-c","python -m alembic upgrade head && python -m src.cli.bootstrap_admin" \
--set-env-vars="DATABASE_URL=...,INITIAL_ADMIN_EMAIL=...,INITIAL_ADMIN_PASSWORD=...,JWT_SECRET_KEY=...,ENVIRONMENT=production" \
--vpc-connector=consentos-vpc-connector
gcloud run jobs execute consentos-bootstrap --region ${REGION} --wait
```
### 3.9 Set up routing
Use a Google Cloud Load Balancer (or Cloudflare in front) to route:
| Path | Backend |
|------|---------|
| `/api/v1/*` | `consentos-api` Cloud Run service |
| `/c/*` | `consentos-api` Cloud Run service |
| `/health` | `consentos-api` Cloud Run service |
| `/*` (default) | `consentos-admin` Cloud Run service |
If using Cloudflare as the CDN and reverse proxy, `cf-ipcountry` is injected automatically — no `GEOIP_COUNTRY_HEADER` env var needed. For state-level granularity with Cloudflare Enterprise, set `GEOIP_REGION_HEADER=cf-region-code`.
If using Google Cloud Load Balancer directly (no Cloudflare), set:
```
GEOIP_COUNTRY_HEADER=x-gclb-country
GEOIP_REGION_HEADER=x-gclb-region
```
### 3.10 GeoIP considerations for serverless
Serverless platforms don't have a persistent filesystem for MaxMind databases. Your options:
1. **CDN headers** (recommended) — Cloudflare, Vercel, and GCP Load Balancer all inject country headers. Zero config beyond the env var.
2. **Mount from GCS** — Use a GCS FUSE volume mount to expose the `.mmdb` file:
```bash
gcloud run deploy consentos-api \
--add-volume=name=geoip,type=cloud-storage,bucket=my-geoip-bucket \
--add-volume-mount=volume=geoip,mount-path=/data \
--set-env-vars="GEOIP_MAXMIND_DB_PATH=/data/GeoLite2-City.mmdb"
```
3. **Bake into the image** — Copy the `.mmdb` into the Dockerfile. Simple but stale until you rebuild.
---
## Banner Integration Checklist
Regardless of deployment method, verify these before going live:
- [ ] `consent-loader.js` is the **very first `<script>` in `<head>`** on every customer page. No `async`. No `defer`.
- [ ] `data-site-id` and `data-api-base` attributes are set correctly on the script tag.
- [ ] The API's `ALLOWED_ORIGINS` includes every customer site origin that embeds the banner.
- [ ] `CDN_BASE_URL` points at the origin where `consent-loader.js` and `consent-bundle.js` are served (same as the admin UI in a standard deployment).
- [ ] Google Tag Manager (if used) is loaded **after** the ConsentOS loader, not before.
- [ ] The consent cookie (`_consentos_consent`) is accessible on the customer domain — check that `SameSite=Lax` and the domain/path are correct.
- [ ] Regional modes are configured in the admin UI for any site that needs location-aware consent (e.g. opt-in for EU, opt-out for US-CA).
- [ ] GeoIP headers are flowing from your CDN/load balancer — verify with `curl -I https://cmp.example.com/api/v1/config/sites/<id>` and check for `cf-ipcountry` or your custom header.
---
## Password Reset
If you've forgotten your password and can't log in to the admin UI, reset it from the host machine:
```bash
docker exec consentos-api python -m src.cli.reset_password \
--email admin@example.com \
--password new-secret-here
```
The password must be at least 8 characters. The change takes effect immediately — no restart needed. On Kubernetes, run it as a one-off pod:
```bash
kubectl exec -it deploy/consentos-api -n consentos -- \
python -m src.cli.reset_password --email admin@example.com --password new-secret-here
```
Once logged back in, you can change your email and password from the **Account** page (click your name in the top nav → Account).
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|-------------|-----|
| `_ga` cookie appears before consent | The ConsentOS loader isn't the first script on the page, or it's loaded with `async`/`defer`. | Move the loader to the very top of `<head>` and remove `async`/`defer`. |
| CORS error on banner config fetch | The customer site's origin isn't in `ALLOWED_ORIGINS`. | Add the origin to the comma-separated list and redeploy. |
| Scanner fails with `httpx.ConnectError` | `SCANNER_SERVICE_URL` doesn't match the scanner's actual address/port, or the scanner's port was overridden by a shared `PORT` env var. | Verify the URL and ensure the scanner uses a scoped `environment:` block, not `env_file: .env`. |
| API refuses to start: "unsafe configuration" | `JWT_SECRET_KEY` is the placeholder value, or `ALLOWED_ORIGINS` contains `*`, and `ENVIRONMENT` is set to `production`. | Set real values for both. |
| Cookies still blocked after accepting consent | The loader and banner bundle are separate IIFEs with independent module state. If `window.__consentos._updateBlocker` is missing, the bundle can't drive the loader's blocker. | Upgrade to the latest version — the bridge was added in the `fix/blocker-loader-bundle-bridge` PR. |
| Pre-existing tracker cookies survive after declining | The sweep only deletes cookies matching known patterns (`_ga`, `_fbp`, etc.). Unknown cookie names fall through. | Add the cookie to the scanner's known-cookies database via the admin UI, or extend the patterns in `blocker.ts`. |

39
entrypoint.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/sh
set -e
# Extract host and port from DATABASE_URL
DB_HOST=$(echo "$DATABASE_URL" | sed -E 's|.*@([^/:]+).*|\1|')
DB_PORT=$(echo "$DATABASE_URL" | sed -E 's|.*@[^/:]+:([0-9]+)/.*|\1|')
if [ -z "$DB_PORT" ] || [ "$DB_PORT" = "$DB_HOST" ]; then
DB_PORT="5432"
fi
echo "Waiting for postgres at $DB_HOST:$DB_PORT ..."
max_retries=30
counter=0
until (
pg_isready -h "$DB_HOST" -p "$DB_PORT" -q 2>/dev/null
) || (
(echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null
); do
counter=$((counter + 1))
if [ $counter -ge $max_retries ]; then
echo "ERROR: postgres at $DB_HOST:$DB_PORT not ready after ${max_retries}s"
exit 1
fi
echo " postgres not ready, retrying in 2s ... ($counter/$max_retries)"
sleep 2
done
echo "postgres is ready!"
# Run alembic migrations
if [ -f /app/alembic/env.py ]; then
echo "Running database migrations ..."
python -m alembic upgrade head
echo "Migrations complete!"
fi
exec "$@"

70
supervisord.conf Normal file
View File

@@ -0,0 +1,70 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
user=root
[program:nginx]
command=nginx -g "daemon off;" -c /etc/nginx/conf.d/default.conf
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=5
killasgroup=true
priority=100
[program:api]
command=sh -c "uvicorn src.main:app --host 127.0.0.1 --port 8000 --workers ${API_WORKERS:-4} --access-log --proxy-headers --forwarded-allow-ips '*'"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=10
killasgroup=true
priority=200
[program:worker]
command=celery -A src.celery_app worker --loglevel=info --concurrency=2
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=30
killasgroup=true
priority=300
[program:beat]
command=celery -A src.celery_app beat --loglevel=info
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=10
killasgroup=true
priority=400
[program:scanner]
command=python -m src.worker
directory=/app
autostart=false
autorestart=false
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=10
killasgroup=true
priority=500
environment=PYTHONUNBUFFERED="1"