Work in progress, not ready for production use. This project is under active development and has open, unresolved security issues (tracked publicly in the issue tracker, see the roadmap issue for current status). It is not installed on any Freeshard shard today. Do not deploy this for real calendar data yet.
A self-hosted CalDAV web calendar for Freeshard shards. Month/week/day/agenda views, recurring events, calendar sharing between shard users (once shards have an identity provider, see below), public ICS links, app-passwords for internal service integration, and optional two-way sync with Google Calendar and external CalDAV servers.
Three services around one shared SQLite database:
| Service | Stack | Role |
|---|---|---|
webcalendar |
Node 22 / Express, vanilla-JS SPA | Web UI + REST API, talks CalDAV to any server |
caldav-server |
Radicale + custom auth/rights plugins | Internal CalDAV storage engine (unexposed to external clients in shipping topology); auth against shared DB |
sync-worker |
Python + vdirsyncer | Background two-way sync of linked Google/CalDAV calendars into Radicale |
The internal Radicale server (caldav-server) runs strictly as an unexposed backend container within the private container network. Direct connection from external CalDAV desktop or mobile clients (e.g. Thunderbird, iOS Calendar, DAVx5) is not exposed in the shipping topology. Users can link external CalDAV accounts (Nextcloud, Fastmail, etc.) inside the WebCalendar UI for background two-way synchronization.
Who signs in. A shard ships with AUTH_METHOD=env: a single owner whose user id is
CALDAV_USERNAME (the store template sets owner), authenticated by the platform proxy's
HTTP_HEADER_AUTH_SECRET. AUTH_METHOD=oidc signs the shard's owner and members in through the
shard's own OIDC provider — but shard_core does not provide one yet, so no shard has an issuer
for OIDC_ISSUER_URL.
Decision (#121): multi-user features wait for the embedded OIDC provider in shard_core; they
are not cut. Sharing calendars between shard users (user_calendar_shares,
radicale_custom_rights) is kept, because a shard has an owner and members (CONCEPTS.md), but
on a shard today there is only the owner, so there is nobody to share with. A calendar can only be
shared with a user who has signed in to this shard; any other name is 404.
External CalDAV accounts (Nextcloud, …) can be linked per user; credentials are
stored AES-encrypted. See agents.md for a deeper code map.
cp .env.example .env # set SESSION_SECRET, ENCRYPTION_KEY, SYSTEM_SYNC_SECRET, …
docker compose up --buildThe app listens on http://localhost:3000 (Radicale runs unexposed on the internal container network).
The compose file has the shape of the store template, so local runs exercise what ships: all three
services share one bind-mounted data directory (./data, gitignored), the app reaches Radicale over
plain HTTP on the compose network (http://caldav-server:5232, RADICALE_SSL=false), and every
setting comes from a variable that Compose fills from .env; docker compose up refuses to start
while a secret is missing. The app also runs on plain HTTP (SSL_KEY_PATH/SSL_CERT_PATH are
blank), as on a shard, where the platform proxy terminates TLS. With COOKIE_SECURE=auto the
session cookie is marked Secure only on HTTPS requests, so the session works on this local HTTP
URL as well.
npm ci
npm run dev # server with --watch on :3000Without Docker you still need a Radicale for the internal calendar — see the
local recipe in agents.md, or point CALDAVURL at any external CalDAV server.
npm test # node:test suite, starts its own servers
npx playwright test # e2e (needs internal Radicale)Do not start a server before npm test: each test file listens on its own fixed port and fails
with EADDRINUSE if that port is taken. The suite uses webcalendar.test.db (or DATABASE_PATH)
and empties it, and it must run inside a git checkout (one test runs git ls-files). CalDAV route
tests use an in-process mock DAV server. Details in agents.md.
All configuration is via environment variables — see .env.example
for the full annotated list.
Linked-account passwords and Google / Microsoft tokens are encrypted with the install's
key: ENCRYPTION_KEY, persisted to .encryption_key next to the database
(or ENCRYPTION_KEY_FILE). The file wins over the environment variable.
- Fresh install only: a key is generated only when neither the variable nor the file exists and the database holds no encrypted values.
- Key check: the database stores one value encrypted with the key
(
encryption_key_check). If the configured key cannot decrypt it, the web app and the sync worker refuse to start instead of producing unreadable credentials. Databases from before this check adopt it on the first boot whose key decrypts their credentials. - Backups must contain
webcalendar.dband.encryption_key(or theENCRYPTION_KEYvalue). A database restored without its key cannot be used. Restore the key and the service starts again. - Consistent snapshot: the shard backup copies the data directory file by file, which can
tear the live WAL-mode
webcalendar.db/-wal/-shmwhile writes happen. The web app therefore writeswebcalendar.db.backupnext to the database at boot and then every hour (VACUUM INTO, checked withPRAGMA integrity_check, renamed into place). To restore, stop all services, replacewebcalendar.dbwithwebcalendar.db.backup, deletewebcalendar.db-walandwebcalendar.db-shm, and start again. Changes made after the snapshot (at most an hour before the backup) are lost. - Rotation: there is no re-encryption tool yet. To replace a key, stop all services,
delete the rows of
linked_accounts,msgraph_accountsandencryption_key_check, remove.encryption_key, set the newENCRYPTION_KEY, start, and re-link the accounts. The.encryption_keycommitted in23ec28d(untracked again in03044ac) is still in the git history. Treat it as public and never reuse it for a real install.