Chronos-Ledger
A multi-dimensional timetable engine, self-hosted and offline-first.
A multi-dimensional timetable engine for scheduling and attendance, generalizing to anything that needs to slice and query such a timetable efficiently. It replaces paper registers and spreadsheet timetables, runs entirely on an organization intranet with Docker as its only runtime dependency, and keeps working read-only, queueing writes, when the network drops.
Built with
Highlights
- ▸Runs wholly on the organization intranet, no cloud subscription and no data leaving the network.
- ▸Stays usable when the organization network drops: reads continue and writes are queued rather than lost.
- ▸Deploys with Docker and Docker Compose as the only runtime dependencies, via a setup script that detects the LAN IP and generates its own secrets.
- ▸Publishes container images to GHCR and Docker Hub with SLSA build provenance and SBOM attestations, so a deployment can pin a release instead of building from source.
Why self-hosted was the constraint, not a preference
Organization networks are unreliable, and attendance is a legal record. A cloud tool fails both ways at once: it stops working exactly when the network does, and it moves member attendance data off the institution's premises. Chronos Ledger is built so the whole stack sits inside the organization, and so a network partition degrades it to read-only-with-queued-writes rather than taking it down.
From the README
Chronos Ledger
Organization Schedule & Attendance Management, self-hosted, offline-first, production-ready.
The Problem
Universities and organizations track attendance on paper, manage timetables in Excel, and learn of staff absences only when members complain. Organization networks are unreliable. Staff don't know where their colleagues are. Visitors have no formal check-in system.
Chronos Ledger solves all of this in a single, self-hosted, Docker-deployable stack that runs entirely on your organization intranet, no cloud subscription, no data leaving your network.
Key Metrics
| Metric | Value |
|---|---|
| REST API endpoints | 30+ |
| Role-specific dashboards | 4 |
| Database tables | 10 |
| Offline capability | Reads cached; attendance marks queue and auto-sync |
| Setup time | < 5 minutes |
| Runtime dependencies | Docker + Docker Compose only |
| License | Apache 2.0 |
Quick Start: One Command
# Clone, configure, and launch everything
git clone https://github.com/Life-Experimentalist/chronos-ledger.git
cd chronos-ledger
chmod +x setup.sh && ./setup.sh
setup.sh auto-detects your LAN IP, generates cryptographic secrets, and launches all services. When it finishes:
- App →
http://<your-server-ip> - API docs →
http://<your-server-ip>/docs - Login →
admin@org.internal, with the passwordsetup.shprinted (also in.envasINITIAL_ADMIN_PASSWORD)
Want to demo it to someone? ./setup.sh --build --demo boots it with sample data; docs/demo.md has the five-minute walkthrough.
Or pull a published image (no build required)
curl -fsSL https://chronos.vkrishna04.me/compose.yml \
-o docker-compose.prod.yml
export JWT_SECRET_SIGNING_KEY=$(openssl rand -hex 32)
export DB_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
export GHCR_OWNER=Life-Experimentalist
docker compose -f docker-compose.prod.yml up -d
Pin any release: VERSION=v1.2.0 docker compose -f docker-compose.prod.yml up -d
The compose file pulls from GHCR, which does not rate-limit anonymous pulls of a public image. The same two images go to Docker Hub as well, for anyone who would rather not type a registry prefix:
docker pull vkrishna04/chronos-ledger-backend
docker pull vkrishna04/chronos-ledger-web
Or install the chronos CLI
The CLI writes the configuration, starts the stack, upgrades it with a backup first and diagnoses an install (docs/cli.md). Pick one channel:
curl -fsSL https://chronos.vkrishna04.me/install.sh | sh # Linux, macOS
brew install life-experimentalist/tap/chronos
# From 0.18.0, also on the package registries
npm i -g chronosledger # or: npx chronosledger@latest
uv tool install chronosledger # or: uvx chronosledger@latest
pipx install chronosledger
cargo install --locked chronosledger # or: cargo binstall chronosledger
npx and uvx reuse a cached copy, so add @latest to get the newest release.
Architecture
Clients (PWA) Organization Server
┌─────────────┐ ┌────────────────────────────────────────┐
│ Member │ │ Docker network: chronos_net │
│ Staff │──HTTPS/WSS──▶│ ┌──────────┐ ┌────────────────┐ │
│ Admin │ │ │ Nginx │────▶│ FastAPI │ │
│ Guest Kiosk │ │ │ :80/443 │ │ :8000 │ │
└─────────────┘ │ └──────────┘ └───────┬────────┘ │
│ │ │ │
│ Static files ┌───┴──────┐ │
│ (Next.js export) │PostgreSQL│ │
│ │ Redis │ │
└───────────────────────┴──────────┴────┘
Full diagrams with Mermaid charts: docs/architecture.md
Feature Highlights
Geofenced Attendance
Members mark attendance via GPS. The server runs a Haversine distance check, plus an altitude delta (|Δalt| <= 4m) when both the device and the room report an altitude, so that somebody a floor above or below does not register. Altitude is optional on both sides: a fix without one is judged on the horizontal radius alone. The client refuses to mark at all when the reported accuracy is worse than 30m, and the server refuses a fix coarser than GEOFENCE_ACCURACY_FACTOR times the fence radius, which by default is the same 30m on a 15m fence.
4-Tier Staff Location Resolution
Always know where staff are, in priority order:
- Override: a
state_override:<id>value an integration writes to Redis. Chronos never writes one itself, and the tier is skipped when Redis is down. - The generated day: the room of a slot they cover, or of their own slot when nobody else covers it. OFF_SITE when that day marks them on leave.
- Weekly timetable: the room of a slot they lead, on a date the nightly job has not generated yet.
- Base station: OFF_SITE on a day of approved leave, otherwise their assigned base station.
The full rules are in docs/architecture.md.
Reverse RSVP Absence System
Presence is the default state. Staff file absences rather than confirming presence. Requests route to the line manager for approval. Approved absences cascade ON_LEAVE to every affected ledger entry and fire a WebSocket notification to the staff member.
Offline-First PWA
Organization Wi-Fi drops. Chronos Ledger keeps working:
- Attendance marks queue to IndexedDB and flush on reconnect (Background Sync where available, the app itself otherwise)
- Today's schedule cached locally for 12 hours
- Class reminders fire up to 20 minutes before start, even with the app closed, via
periodicsyncin the service worker
Real-Time WebSocket Hub
JWT-authenticated persistent connections. Guest handshake requests, absence approvals, and ledger state changes arrive in milliseconds, no polling.
CSV Bulk Import
Drop a member-centric CSV on the Admin dashboard. One upload creates/updates users, activity offerings, master timetable slots, and member registrations atomically and idempotently.
Role Dashboards
| Role | Path | Core capabilities |
|---|---|---|
| Organization Admin | /admin/dashboard | CSV import, cycle management, proxy assignment, user provisioning |
| Unit Admin | /admin/dashboard | Absence approvals, ledger overrides for own unit |
| Staff | /staff/dashboard | Availability switcher, attendance matrix, absence requests, guest desk |
| Member | /member/dashboard | Live timeline, geofenced self-mark, staff locator, offline queue |
| Guest | /guest/kiosk | No visitor login, check-in form, real-time staff notification |
Tech Stack
| Layer | Technology |
|---|---|
| Backend | Python 3.11 · FastAPI 0.141 · SQLAlchemy 2 · APScheduler |
| Auth | PyJWT 2.14 · bcrypt 5.0 (no CVE-affected packages) |
| Database | PostgreSQL 17 |
| Cache / PubSub | Redis 7.4 |
| Frontend | Next.js 16 App Router · React 19 · TypeScript · Tailwind CSS |
| State | Zustand · React Hook Form · Zod |
| PWA | @ducanh2912/next-pwa (Workbox) · IndexedDB (idb) · Web Push (VAPID) |
| Reverse proxy | Nginx 1.27 |
| Packaging | uv (Python) · npm (Node.js) |
| Container | Docker 24 · Docker Compose 2.20 |
| CI/CD | GitHub Actions · GHCR · Docker Hub |
| Releases | Release Please (semver, CHANGELOG) |
Project Structure
chronos-ledger/
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Lint, tests, type-check, build, Trivy, publish, release
│ │ ├── cd.yml # Build & push to GHCR + Docker Hub (main + releases)
│ │ └── release.yml # Release Please semver releases (called by ci.yml)
│ └── dependabot.yml # Automated dep updates (pip, npm, Docker, Actions)
├── assets/
│ ├── logo.svg # Vector wordmark
│ ├── Logo.png # Raster logo
│ └── Icon.png # App icon
├── backend/ # FastAPI application (uv managed)
│ ├── pyproject.toml
│ └── app/
│ ├── core/ # Config, DB, security, Redis, WebSocket manager
│ ├── models/ # SQLAlchemy ORM (9 tables)
│ ├── schemas/ # Pydantic request/response models
│ ├── api/v1/ # Route handlers per domain
│ ├── services/ # Business logic (geo-fence, RSVP, ingestion)
│ └── cron/ # Nightly ledger generator (APScheduler)
├── frontend/ # Next.js 16 PWA (npm managed)
│ ├── public/
│ │ ├── manifest.webmanifest
│ │ └── sw-custom.js # Background sync + push + periodicsync
│ └── src/
│ ├── app/ # App Router pages per role
│ ├── components/ # Role-scoped UI components
│ ├── hooks/ # useWebSocket, useGeolocation, useAuth, useScheduleNotifications
│ ├── lib/ # Axios API client, IndexedDB helpers, auth utils
│ └── store/ # Zustand: auth + notifications
├── nginx/
│ ├── nginx.conf # Reverse proxy + static serving + security headers
│ └── Dockerfile # Multi-stage: Next.js build → nginx image (for GHCR)
├── docs/
│ ├── openapi.yaml # Static OpenAPI 3.1 contract (all 30+ endpoints)
│ ├── architecture.md # Mermaid system diagrams
│ ├── data-model.md # Mermaid ERD (all 9 tables)
│ ├── flows.md # Sequence diagrams (attendance, RSVP, guest, cron)
│ ├── api.md # Human-readable API reference with examples
│ └── deployment.md # Deployment guide with topology diagram
├── bin/
│ ├── chronos_intranet_autodiscover.sh
│ └── chronos_maintenance_vault.sh
├── docker-compose.yml # Local dev / self-build (builds from source)
├── docker-compose.prod.yml # Production (pulls from GHCR, no build)
├── setup.sh # One-command setup script
├── .env.example # Environment template
└── version.txt # Source of truth for semver (managed by Release Please)
Local Development
Backend
cd backend
uv sync # install deps (including dev)
uv run uvicorn app.main:app --reload --port 8000 # builds the schema on start
API: http://localhost:8000 · Swagger: http://localhost:8000/docs
Swagger and ReDoc follow DOCS_ENABLED, which is unset here, so they are
on in development and off under APP_ENV=production.
Frontend
cd frontend
npm install
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 \
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws \
npm run dev
App: http://localhost:3000
CI/CD Pipeline
Pull Request ──▶ ci.yml ──▶ ruff format + ruff check
├──▶ pytest
├──▶ eslint + tsc + next build
├──▶ Trivy scan (critical fails the build)
└──▶ Docker build check (no push)
Push to main ──▶ ci.yml (all of the above)
├──▶ every gate green ──▶ cd.yml
│ └──▶ Push to GHCR + Docker Hub
│ (sha tag + latest)
└──▶ every gate green ──▶ release.yml
└──▶ Release Please opens
or updates a release PR
Merge release PR ──▶ ci.yml ──▶ release.yml ──▶ GitHub Release created
├──▶ CHANGELOG.md updated
├──▶ version.txt bumped
└──▶ cd.yml (version tag) ──▶ GHCR + Docker Hub, vX.Y.Z
Published images, one build pushed to both:
| Registry | Backend | Web |
|---|---|---|
GHCR (canonical, what docker-compose.prod.yml pulls) | ghcr.io/life-experimentalist/chronos-ledger-backend | ghcr.io/life-experimentalist/chronos-ledger-web |
| Docker Hub | vkrishna04/chronos-ledger-backend | vkrishna04/chronos-ledger-web |
Both carry the same tags: latest, main, a short commit sha, and vX.Y.Z on a
release. GHCR is the one the compose file points at because it does not
rate-limit anonymous pulls of a public image; Docker Hub does. A fork that sets
neither DOCKERHUB_NAMESPACE nor DOCKERHUB_TOKEN publishes to GHCR alone.
Every image carries an SBOM and build provenance, plus a Sigstore-signed SLSA attestation naming the workflow and commit it was built from:
gh attestation verify oci://ghcr.io/life-experimentalist/chronos-ledger-backend:latest \
--owner Life-Experimentalist
docs/faq.md has the Docker Hub form and the buildkit attestations that ride along inside the image itself.
Environment Reference
| Variable | Required | Description |
|---|---|---|
JWT_SECRET_SIGNING_KEY | yes | 64-char hex: openssl rand -hex 32. Production refuses to start on the placeholder or on anything under 32 characters. |
DB_PASSWORD | yes | PostgreSQL password |
ORG_TIMEZONE | recommended | IANA name of where the organization is, such as Asia/Kolkata. Decides what "today" means. Defaults to UTC, which is what a container runs. An offset is refused. |
LABEL_STAFF and five more | optional | What the interface calls the engine's six nouns: LABEL_STAFF, LABEL_MEMBER, LABEL_ACTIVITY, LABEL_UNIT, LABEL_LEAD, LABEL_CYCLE. Blank means the engine's own neutral word (Staff, Member, Activity, Unit, Lead, Cycle). Labels only, never the schema and never the API, so they are safe to change on a running instance. There are no domain presets on purpose. docs/vocabulary.md explains why, and what labels never change. |
PASSWORD_MIN_LENGTH | optional | Shortest password a person may choose, default 12. Applies to the first-login change and to any account an admin creates with a typed password. Generated passwords are random and not measured against it. Refused below 8. |
RATE_LIMIT_ENABLED | optional | A budget per caller on sign-in, the visitor kiosk and the calendar feed. On by default. Counted in Redis, and if Redis is unreachable the limits stop applying rather than the requests failing. |
RATE_LIMIT_LOGIN_PER_IP | optional | Failed sign-ins per calling address per 15 minutes, default 10. A correct password costs nothing. 0 turns it off. |
RATE_LIMIT_LOGIN_PER_EMAIL | optional | Failed sign-ins per account per 15 minutes, default 5. 0 turns it off. |
RATE_LIMIT_GUEST_CHECKIN | optional | Visitor check-ins per kiosk account per hour, default 300. 0 turns it off. |
RATE_LIMIT_CALENDAR_FEED | optional | Calendar feed fetches per feed token per hour, default 60. 0 turns it off. |
CSV_UPLOAD_MAX_MB | optional | Largest CSV POST /ingestion/upload-csv accepts, in MB, default 25. A bigger file is refused with 413 before anything is imported. nginx refuses requests over 25 MB on its own, so going past 25 means raising client_max_body_size in nginx/nginx.conf too. |
GEOFENCE_ACCURACY_FACTOR | optional | How coarse a member's location fix may be, as a multiple of the session's fence radius, default 2 (30 m on the default 15 m radius). A mark whose reported accuracy is coarser gets 400. 0 turns the check off. A mark that reports no accuracy is not refused for it. |
LEDGER_HORIZON_DAYS | optional | How many days ahead the ledger is kept written, counted from tomorrow, default 28. The job at 23:00 in ORG_TIMEZONE adds whichever of those days are missing, and a CSV import writes a new cycle's days at once. Nothing is written past a cycle's end date, a day that already exists is left as it is, and a day inside approved leave is written ON_LEAVE. Refused outside 1 to 366. |
GUEST_RETENTION_DAYS | optional | How many days a visitor check-in is kept, default 0, which keeps them forever. Each one holds the visitor's name and phone number. Set a number of days, such as 90, and a job at 03:30 in ORG_TIMEZONE deletes the older check-ins every day, whether or not anyone acted on them. Refused below 0. |
IDEMPOTENCY_RETENTION_HOURS | optional | How many hours an Idempotency-Key is remembered, counted from its first use, default 24. A retry inside the window gets the first response back; after it the key is new again. A job at 03:45 in ORG_TIMEZONE deletes the older records. Refused below 1. |
GUEST_ADMIN_CAN_DECIDE | optional | Whether an admin may decide a visit that was not meant for them, default false, which leaves the person the visitor came to see as the only one who can let them in. true lets an ORG_ADMIN decide any visit and a UNIT_ADMIN those meant for their own unit, and the row records which of them did. A front desk attendant is not an admin: give the desk a guest-scoped API key on an admin account rather than an admin's password. |
GUEST_VISIT_CODE_TTL_MINUTES | optional | How many minutes the visit code a check-in hands the visitor keeps opening GET /guest/visit/{code}, default 0, which leaves it working for as long as the check-in is kept. Past the window the lookup answers 404, the same as an unknown code, so trying codes says nothing about which ones were real. Codes issued while this was 0 never expire. Refused below 0. |
GUEST_VISIT_LENGTH_MINUTES | optional | How many minutes an approved visit lasts, counted from the decision rather than from the check-in, default 0, which leaves an approved visit open-ended as it was before this setting existed. Past the end the visit appears at GET /guest/overstays and a notice goes once to the person the visitor came to see and to whoever let them in. Chronos revokes nothing and turns nobody out: it does not hold the door, so the row says the time is up and a person decides what to do about it. Refused below 0. |
GUEST_VISIT_EXTENSION_MAX_MINUTES | optional | The most a single PATCH /guest/{entry_id}/extend may add to a visit, default 240. More than this is refused rather than quietly trimmed, so a desk that meant 30 and typed 300 hears about it. A visit can be extended again as often as somebody keeps deciding to, so this bounds one grant and not the whole visit; the audit record of each extension is what an organization gets in place of a hard ceiling. 0 refuses every extension, for one that would rather the visitor checked in again. Refused below 0. |
CHANGE_FEED_ENABLED | optional | Whether a change to a room or a booking is recorded for an outside system to poll for at GET /changes/, default off. On, every such write also inserts a row in change_markers, which nothing deletes, so leave it off unless something is reading the feed. Recording starts when it is turned on: a consumer reads the rooms and the bookings once in full and polls from there. The route needs ORG_ADMIN and, for a key, changes:read. |
WEBHOOK_DELIVERY_INTERVAL_SECONDS | optional | How often, in seconds, the webhook worker turns new changes into deliveries and sends what is due, default 5. Webhooks are sent from the change feed, so nothing runs unless CHANGE_FEED_ENABLED is on. Refused outside 1 to 3600. docs/webhooks.md is the design. |
WEBHOOK_ALLOW_HTTP_HOSTS | optional | Host names a webhook may be sent to over plain http, comma separated, matched exactly and without the port, default empty, so every webhook URL must be https. |
WEBHOOK_ALLOW_PRIVATE_TARGETS | optional | Whether a webhook may go to a loopback, private or link-local address, default false. Off, because a URL an admin types is otherwise a way to make the server call into its own network. Multicast, reserved and unspecified addresses are refused either way. |
FEDERATED_IDENTITY_ENABLED | optional | Whether a registered outside issuer may vouch for a person, so somebody already signed in there reaches POST /auth/federated and arrives here without a second password, default off. Off, the route answers 404 rather than 403: whether an instance has an integration is not something probing it should reveal. On is not enough on its own, because nothing verifies until an ORG_ADMIN registers an issuer and a signing key, and neither admin role can be reached from outside at all. docs/federated-identity.md is the design, threat model first. |
FEDERATED_TOKEN_MAX_LIFETIME_SECONDS | optional | The longest life this instance accepts in one of those notes, in seconds, counted from when it arrives, default 120. The note says how long it should work and this is the second opinion: an issuer that sets that a year out has minted a bearer token for a year. Two minutes is long enough for a browser to hand a note over and short enough that a stolen one is worth little. Refused below 1, because a cap of 0 refuses every note rather than turning anything off. |
FEDERATED_CLOCK_SKEW_SECONDS | optional | How far this instance and that issuer may disagree about the time before a good note is refused, in seconds, default 60. Not symmetric: a note may have expired this recently, or claim to have been minted this far ahead, and there is no limit on how old it may claim to be because its own expiry already answers that. Widening it widens the window a stolen note can be replayed in by the same amount, so an operator running NTP everywhere can set it lower. Refused below 0. |
CLOCK_MODE | optional | system, the default, reads the real clock and runs the nightly jobs on APScheduler. host freezes the application's clock at CLOCK_START until a host program embedding the engine moves it with backend/app/cron/scheduled.py::advance, or with POST /api/v1/clock/advance in the embedded flavour (docs/embedded.md), which also runs the jobs that came due on the way; APScheduler does not start. For simulation and replay, not for a deployment. docs/clock.md says what moves with the clock and what stays on real time. |
CLOCK_START | optional | The instant the host clock starts at, as ISO 8601 with an offset, such as 2026-09-07T08:00:00+00:00. Required when CLOCK_MODE=host, ignored otherwise. Refused without an offset. |
ID_SEED | optional | An integer. Set, the identifiers that are not secrets (a refresh token family id, a federated account id) follow one fixed sequence, so the same script writes the same rows twice. Secrets stay random whatever this is set to. Unset, those identifiers are random. |
DOCS_ENABLED | optional | Whether /docs, /redoc and /openapi.json are served. Blank follows APP_ENV: off in production, on everywhere else. Set true to publish them from a production instance anyway. |
FORWARDED_ALLOW_IPS | optional | Which upstream addresses uvicorn believes X-Forwarded-For from, default *. The app container publishes no ports, so nginx is the only way in. The sign-in limit needs it to tell callers apart from the proxy, and the proxy overwrites that header rather than appending to it, so a caller cannot forge an address. |
VAPID_PUBLIC_KEY | recommended | Web Push: npx web-push generate-vapid-keys |
VAPID_PRIVATE_KEY | recommended | Web Push |
VAPID_CONTACT_EMAIL | recommended | Admin contact for push service |
NEXT_PUBLIC_API_URL | dev only | Baked in at build time; defaults to /api/v1 in GHCR image |
NEXT_PUBLIC_WS_URL | dev only | Defaults to /ws in GHCR image |
APP_CORS_ORIGINS | prod | Comma-separated allowed origins |
GHCR_OWNER | prod only | GitHub org/username for GHCR image pull |
VERSION | prod only | Image tag to deploy (default: latest) |
See .env.example for the full template.
First Boot Credentials
The schema step the server runs on start creates one organization admin. It
seeds a hash of a random string it throws away, so the account exists, nothing can log into
it, and this repository publishes no password for anyone to find. What the
first password is comes from your .env:
| Field | Value |
|---|---|
admin@org.internal | |
| Password | INITIAL_ADMIN_PASSWORD from your .env |
setup.sh generates that password and prints it once, in its summary. It is
applied on boot while the account is still waiting for a password of its own,
and does nothing once you have logged in and chosen one. Until you have, the
account is refused by every endpoint except the password change itself, so
the first login is the only thing it can do: use Admin Portal → Profile
→ Change Password, or the Onboarding Wizard, which opens on its own.
Documentation
| Document | Description |
|---|---|
docs/openapi.yaml | OpenAPI 3.1 contract, all endpoints, schemas, enums |
docs/architecture.md | System topology, module deps, location resolution, WebSocket lifecycle |
docs/data-model.md | ERD for all 10 database tables |
docs/flows.md | Sequence diagrams: attendance, RSVP, guest handshake, ledger cron |
docs/api.md | Human-readable API reference with examples |
docs/deployment.md | Deployment guide, cycle rollover, TLS, scaling |
docs/hardware.md | Hardware requirements, load test results, when to add replicas |
docs/vocabulary.md | What an instance calls things, and which value sets are closed |
docs/faq.md | FAQ & troubleshooting, setup, auth, CSV import, geofencing, CI/CD |
docs/quickstart.md | Call the REST API from any language: auth, keys, paging, idempotency |
docs/roadmap.md | Planned releases 0.13 to 0.16 with scope and acceptance checks |
docs/distribution.md | Planned installer, channels, drift handling and signing |
docs/publishing.md | One-time setup for publishing to npm, PyPI and crates.io |
docs/sdks.md | TypeScript, Python, Go and Rust client libraries (registries from 0.18.0) |
docs/federated-identity.md | Planned sign-in from a registered outside issuer, threat model first |
docs/clock.md | The one clock the backend reads, the host clock a simulation drives, seeded ids |
docs/webhooks.md | Signed outgoing webhooks from the change feed: events, signatures, retries, URL rules |
CONTRIBUTING.md | Development setup, commit conventions, PR checklist |
CODE_OF_CONDUCT.md | Community standards and enforcement |
SECURITY.md | Vulnerability reporting and disclosure policy |
CHANGELOG.md | Release history (managed by Release Please) |
Telemetry
Telemetry is off by default, a stock build sends nothing anywhere, and the ghcr.io image is built from nginx/Dockerfile with those same defaults. Nothing in a default install reaches a host you do not run.
If you turn it on, Chronos Ledger reports anonymous, aggregate view counts to a CFlair-Counter instance, a privacy-first, self-hostable counter. No IP addresses, usernames, or session data are collected or transmitted.
What is tracked, if you opt in
| Event | Project key |
|---|---|
| Landing page visits | chronos-ledger-landing |
| Admin dashboard logins | chronos-ledger-app |
Opt in
Set both at build time, either is enough to keep it off:
NEXT_PUBLIC_TELEMETRY_ENABLED=true
NEXT_PUBLIC_TELEMETRY_ENDPOINT=https://counter.example.internal # a CFlair-Counter you run
An empty endpoint disables the pings regardless of the enabled flag.
Per-browser opt out: once enabled, Admin Dashboard → Overview → Privacy & Telemetry toggles it off for that browser. The preference is stored locally and persists across sessions.
Contributing
Contributions are welcome. Please:
- Fork the repo and create a feature branch.
- Follow Conventional Commits so Release Please can generate the changelog.
- Open a PR: CI runs automatically (lint, tests, type-check, build, Trivy scan).
- All checks must pass before merge.
Commit examples:
feat: add unit-level attendance export to CSV
fix: resolve geofence false positive on altitude boundary
perf: cache staff location resolver result in Redis
security: upgrade cryptography to 44.0.1
License
Copyright 2026 Chronos Ledger Contributors
Licensed under the Apache License, Version 2.0.