No description
  • Go 72.3%
  • TypeScript 24%
  • Shell 2.2%
  • PLpgSQL 0.8%
  • JavaScript 0.4%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ymage 72d7d63171
Some checks failed
Release / vuln (push) Successful in 2m24s
CI / vuln (push) Successful in 3m52s
Release / drift (push) Successful in 4m55s
Release / fast (push) Successful in 5m23s
Release / build-test (push) Successful in 0s
Release / build-image (push) Successful in 6m0s
CI / drift (push) Failing after 16m39s
CI / fast (push) Successful in 26m22s
chore(version): Release v0.5.1
2026-09-26 01:40:08 +02:00
.claude docs: refresh CLAUDE.md project/stack/conventions from codebase state 2026-09-24 18:18:55 +02:00
.forgejo/workflows fix(ci): raise fast/vuln timeout-minutes for cold-cache safety margin 2026-09-26 01:36:40 +02:00
api fix(deps): bump golang.org/x/image and moby/go-archive to close CVEs 2026-09-26 00:34:18 +02:00
deploy/quadlet docs(deploy-tcp): document and execute the bootstrap-secret rotation order 2026-09-23 08:31:28 +02:00
docs docs(conventions): document the 400-vs-422 huma validation split 2026-09-25 14:26:37 +02:00
scripts feat(auth): add dev-auth config gate and session-issuing DevAuthenticator 2026-09-08 22:26:53 +02:00
web fix(httpapi): stream export downloads and cap month range at 24 months 2026-09-25 14:26:36 +02:00
.containerignore feat(02-01): one container, one origin, one socket 2026-08-11 06:48:50 +02:00
.env.example docs(10): document CHRONOS_DRILL_TOKEN in .env.example 2026-09-11 11:22:54 +02:00
.gitignore chore: stop tracking .planning/ in git 2026-09-23 08:31:27 +02:00
.tagme-authors.json chore(version): Release v0.1.0 2026-09-22 23:01:25 +02:00
CHANGELOG.md chore(version): Release v0.5.1 2026-09-26 01:40:08 +02:00
cliff.toml chore(cliff): fix default first tag version 2026-09-22 21:23:13 +02:00
compose.dev.yaml docs(deploy-tcp): author CLAUDE.md's PostgreSQL section, flip Alternatives row, fix compose.dev.yaml header 2026-09-23 08:31:29 +02:00
README.md feat(quick-260922-tw2): bump Go toolchain to 1.27.1 and nine routine module deps 2026-09-22 22:03:28 +02:00
Taskfile.yml fix(ci): add non-blocking govulncheck and npm audit vulnerability scan 2026-09-25 14:26:34 +02:00

Chronos — Suivi d'activité & CRA

Time-and-activity management for a small French consultancy team. Each person declares their worked hours and absences on a monthly grid, submits the month for manager approval, and the approved month becomes an immutable, versioned record that feeds a client-facing CRA, invoicing data, and payroll data.

Governed by Syntec (IDCC 1486), modalité 2. Approved months are working-time and financial records: immutability and audit attribution are requirements, not features.

  • Go API · React SPA · PostgreSQL 18 · Ant Design
  • French UI and French CRA output, dd/MM/yyyy, comma decimals, EUR, Monday-first weeks — with a full i18n layer from day one
  • Code, schema and API identifiers in English

Prerequisites

Tool Version Notes
Go 1.27.1 The four CLI tools (sqlc, goose, goi18n, golangci-lint) are pinned as tool directives in api/go.mod — run them with go tool <name>, never a globally installed copy.
Task v3 go install github.com/go-task/task/v3/cmd/task@latest
Podman 6.x rootless Or Docker. Used for the dev database and for integration tests.
Node 22+ For web/. Run npm ci inside web/ once, after cloning.

Rootless podman, for the integration tests

The Go integration tests start a real PostgreSQL 18.4 container via testcontainers. Without the user socket they fail in a way that reads like a network problem rather than a configuration one:

systemctl --user enable --now podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
export TESTCONTAINERS_RYUK_DISABLED=true   # Ryuk is unreliable rootless

api/internal/db/testdb sets both variables itself when they are unset and the podman socket is present, so go test ./... usually works with no setup. Export them explicitly if you use a non-default socket path.

Because Ryuk is disabled, a test binary killed with SIGKILL leaves its container running. Clear it with podman ps then podman rm -f <id>.

Quick Start

From a clean clone:

npm --prefix web ci    # install the SPA's dependencies (once)
cp .env.example .env   # see below — five variables have no Taskfile default
task db:up             # start PostgreSQL 18.4 and wait for it to report healthy
task dev                # run the API on :8080 and the SPA on :5173

A genuinely clean checkout has no .env, and task dev refuses to start without five variables that carry no Taskfile default: CHRONOS_PUBLIC_URL, CHRONOS_OIDC_ISSUER, CHRONOS_OIDC_CLIENT_ID, CHRONOS_OIDC_CLIENT_SECRET, CHRONOS_DRILL_TOKEN (the full 16-variable surface is in the Configuration table below). Without a live Authentik instance to point at, .env.example's values enable the dev-only mock-auth bypass instead: CHRONOS_PUBLIC_URL=https://localhost:8080, CHRONOS_OIDC_ISSUER=https://unreachable.invalid/, CHRONOS_OIDC_CLIENT_ID=placeholder, CHRONOS_OIDC_CLIENT_SECRET=placeholder, CHRONOS_DEV_AUTH=true, and CHRONOS_DRILL_TOKEN set to any non-empty value (for example the output of openssl rand -hex 32).

Then open http://localhost:5173 — a French page, dd/MM/yyyy dates, comma decimals, Monday-first weeks.

Check the API directly:

curl -s http://localhost:8080/api/v1/health
# {"status":"ok","schema_version":42}

schema_version reports the highest applied migration. The response deliberately carries no field that would let an unauthenticated caller learn how many people work at the company.

Stop the database with task db:down (the volume is kept).

Everyday commands

task              # list every target
task check        # the complete gate suite — check:fast then check:full
task check:fast   # every gate that returns in seconds, no database container (< 30s)
task gates        # only the convention scripts under scripts/, no Go/Node toolchain
task spec         # regenerate api/openapi.yaml and web/src/api.d.ts from the Go types
task db:up        # start the dev database
task db:down      # stop it
task api:dev      # API only, on :8080
task web:dev      # SPA only, on :5173

Faster inner loop, without the container-backed tests:

cd api && go test ./... -short

The short-mode guard lives in api/internal/db/testdb, not in each test, so a container can never start under -short. Run task check:fast after every commit; save task check (or task check:full) for a wave boundary — it starts real PostgreSQL containers and takes minutes, not seconds.

Continuous integration

.github/workflows/ci.yml runs task check on every push and pull request — the same named targets documented above, not a restated command list, so the local suite and CI cannot silently drift apart. It installs the pinned Go toolchain and Node version, builds the pinned CLI tools from api/go.mod's tool directives, and runs the container-backed tests against the runner's own Docker daemon directly (the rootless-podman variables below are for local dev only and are never exported in CI).

Gates

Every row is a task target (or an npm script task wraps) that fails the build on a specific mistake. Read docs/conventions.md for the reason behind each rule; this table is only the map from a red gate to what it is protecting.

Gate Command Protects against
Go build task check:build A compile error — the cheapest possible signal, runs first
Go tests (short) cd api && go test ./... -short Broken behaviour, container-free
Go tests (full) task check:test The same, plus every container-backed integration assertion (enum membership, the Paris-DST round trip, rate overflow, the health smoke test)
Go lint task check:lint A handler importing the generated dbgen package directly (depguard, FOUND-01's one bypass route); a switch over an enum missing a case (exhaustive); unchecked errors, unclosed rows, unclosed response bodies
Generated DB code drift task check:sqlc internal/db/dbgen out of date with the hand-written SQL it was generated from
Generated API types drift task check:spec api/openapi.yaml or web/src/api.d.ts out of date with the Go request/response types
Schema conventions scripts/check-schema-conventions.sh (part of task gates) A bare timestamp/time column, real/double precision/money, or a deleted_at soft-delete column in a migration
Money conventions scripts/check-money-conventions.sh (part of task gates) A binary float in the money/worktime path; an aggregate over a centime column with no ::bigint cast; a second minutes-to-days conversion point
Scope-all allow-list scripts/check-scope-all.sh (part of task gates) An unscoped read (authz.ScopeAll()) that is not a justified, reviewed line in scripts/scope-all-allowlist.txt
Server i18n catalogue scripts/check-go-catalogue.sh (part of task gates) A Go-side message added to internal/platform/i18n without re-extracting the TOML catalogue, or a blank catalogue value
Compliance documents scripts/check-compliance-docs.sh (part of task gates) docs/compliance/dpia-screening.md or registre-des-traitements.md losing a legally required heading
Web lint cd web && npm run lint A bare, untranslated French string as JSX text or in a human-facing attribute
Web typecheck cd web && npm run typecheck An unknown t() translation key — a TypeScript error, not a runtime blank
Web i18n drift cd web && npm run i18n:check A browser-side catalogue key added, orphaned, or edited without re-extracting (i18next-cli)
Web i18n empty values cd web && npm run i18n:empty A blank catalogue value neither extractor-based gate above can see
Web tests cd web && npm run test The locale contract (French, Monday-first), the formatter's separator codepoints, and every component test

Configuration

Environment only — this is what Quadlet's Environment= hands the container in production, and it keeps configuration out of the image.

Variable Default Purpose
CHRONOS_DATABASE_URL (required) pgx/libpq connection string for the application pool — names chronos_app, not the bootstrap superuser
CHRONOS_MIGRATION_DATABASE_URL (required) Bootstrap-superuser DSN for the migration-only pool, opened only to run migrations at start then closed
CHRONOS_PUBLIC_URL (required) Public https origin — the sole source of the OIDC redirect URI
CHRONOS_OIDC_ISSUER (required) Full per-provider issuer, trailing slash included, byte-identical to the discovery document
CHRONOS_OIDC_CLIENT_ID (required) Identifies Chronos to the identity provider
CHRONOS_OIDC_CLIENT_SECRET (required) Confidential client secret, never reaches the browser
CHRONOS_DRILL_TOKEN (required) Shared secret the OPS-04 restore drill presents when posting its result, never defaulted
CHRONOS_HTTP_ADDR :8080 API listen address
CHRONOS_ENV dev dev selects the human-readable log handler; anything else logs JSON for journald
CHRONOS_STANDARD_DAY_MINUTES 480 The one numeric standard-day length in the tree; every minutes-to-days conversion resolves through it
CHRONOS_COOKIE_SECURE true Session cookie's Secure flag — false only for plain-HTTP local dev
CHRONOS_SESSION_LIFETIME 10h Absolute session lifetime, independent of activity
CHRONOS_TRUSTED_PROXY_CIDRS 127.0.0.1/32,::1/128 Address range trusted to set X-Forwarded-For — used only for audit logging
CHRONOS_DEV_AUTH false TEMPORARY dev-only mock-auth bypass, see Quick Start above — never enable outside local development
CHRONOS_LOCALE fr-FR Read once at approval time, stored verbatim on the approved period_version row
CHRONOS_TIMEZONE Europe/Paris Same read-once-at-approval note as Locale

The database password is redacted whenever the config is rendered. Log cfg.String(), never the struct — slog resolves an arbitrary value by reflection and would print the password.

Layout

api/                       Go API
  cmd/chronos/             process wiring: env -> slog -> pgxpool -> goose -> chi/huma
  cmd/genspec/             writes api/openapi.yaml from the Go types (`task spec`)
  internal/authz/          ScopeFilter — the mandatory authorization seam
  internal/auth/           OIDC client + server-side session lifecycle
  internal/money/          money.Cents, money.FromRate — the one rounding site
  internal/worktime/       worktime.Minutes/Hundredths, the one ÷8 conversion
  internal/calendar/       jours fériés, computed algorithmically — no DB
  internal/cra/            renders the CRA — Compte Rendu d'Activité — PDF
  internal/platform/config/     env-only configuration surface
  internal/platform/i18n/       server-side catalogue — go-i18n, embed.FS TOML
  internal/platform/frcsv/      shared French-Excel-compatible CSV writer — BOM, semicolon delimiter, comma decimals
  internal/platform/xlsx/       shared excelize style/writer helpers
  internal/platform/pgxdecimal/ hand-rolled numeric-to-decimal.Decimal codec
  internal/db/migrations/  goose SQL, embedded in the binary
  internal/db/queries/     hand-written SQL, sqlc input
  internal/db/dbgen/       sqlc OUTPUT — committed, never hand-edited
  internal/db/testdb/      testcontainers helper (one container per package)
  internal/store/          the adapter layer — the ONLY importer of dbgen
  internal/httpapi/        huma operations; handlers construct the ScopeFilter
web/                       React SPA
  src/lib/format.ts        the only formatting surface — the SPA formats, never computes
  src/i18n/                browser-side catalogue — i18next, typed t()
  eslint.config.mjs        the bare-string i18n lint gate
  i18next.config.mjs       i18next-cli extraction config (NOT i18next-parser — deprecated, see docs/conventions.md)
scripts/                   gate scripts — each is its own bare, unpiped command
docs/
  conventions.md               every convention this phase established, with its reason
  deployment-contract.md       orchestrator-agnostic deployment contract; docs/ops-runbook.md is its Quadlet-specific implementation
  user-manual.fr.md            end-user manual for staff & managers (French); English translation is user-manual.en.md
  user-manual.en.md            end-user manual for staff & managers (English translation)
  admin-manual.fr.md           administrator manual (French); English translation is admin-manual.en.md
  admin-manual.en.md           administrator manual (English translation)
  ops-runbook.md               Quadlet-specific deployment/operations runbook; audience is whoever operates the VPS
  i18n-gate-demonstration.md   red/green proof of every browser i18n gate (Phase 01-08)
  compliance/                  DPIA screening and registre des traitements (French, OPS-06)
.github/workflows/ci.yml   runs `task check` on every push and pull request
compose.dev.yaml           LOCAL DEV ONLY — never the VPS

Documentation

Role-specific manuals live under docs/. French is primary; English is a translation of the same content.

Audience Manual
Staff & managers docs/user-manual.fr.md (EN)
Admins docs/admin-manual.fr.md (EN)
Operators docs/ops-runbook.md

Two rules worth knowing before you write code

Every scoped read carries an authz.ScopeFilter as its second argument. Its fields are unexported, so it can only come from ScopeSelf or ScopeAll, and the archived disposition must be stated explicitly with WithArchived. Omitting the argument is a compile error; a zero-value filter is rejected at runtime with ErrUnscopedQuery before any SQL is issued. ScopeAll() is the escape hatch — grep -rn 'authz.ScopeAll()' api/ is meant to stay a short, reviewable list.

Only internal/store may import internal/db/dbgen. A handler that imports it directly routes around the seam entirely, and nothing in the type system stops it.

Migrations

Migrations are embedded in the binary and applied at every start; a start with nothing pending is a no-op. There is no separate migration step to run.

api/internal/db/migrations/00001_foundations.sql is a one-way door in two respects. PostgreSQL has no DROP VALUE — a wrong enum member costs a new type plus a full table rewrite. And phase 8 freezes time_entry.id as the snapshot diff key against records retained for three years. Add a new migration file; never edit an applied one.