kasl-server 0.9.0

Team server for kasl: collects work-time data from employees' kasl agents and turns it into dashboards, reports, and personal pages
Documentation

kasl-server

Team server for kasl. Employees run kasl on their machines; the agents send work-time data to the server. Managers get dashboards, charts, and reports across the whole team; every employee gets a personal page.

Status: pre-alpha. The door for kasl agents is open and survives a bad connection: a day at a time on POST /api/v1/days, a backlog on /days/batch, and a task the employee deleted can be deleted here too. History from before the server arrived can be imported from an agent's own database; people sign in, and an administrator manages the team, its departments and its agent tokens without touching the host, and every such change is recorded. The tables are filled; almost nothing reads them back yet — dashboards and the personal page are the next milestones, and there is still nothing to deploy for real use.

Try it

Requires Rust and Docker.

$ git clone https://github.com/lacodda/kasl-server && cd kasl-server
$ docker compose up -d db
$ export DATABASE_URL=postgres://kasl:kasl@localhost:5433/kasl
$ export KASL_AGENTS=employee@example.com:agent-token
$ cargo run
2026-08-22T00:59:30.690752Z  INFO kasl_server: database schema is up to date version=20260822000001
2026-08-22T00:59:30.821217Z  INFO kasl_server::provision: provisioned agents from KASL_AGENTS agents=1
2026-08-22T00:59:30.828436Z  INFO kasl_server: kasl-server listening version="0.9.0" addr=0.0.0.0:8080 max_batch_days=31 max_body_bytes=4194304

$ curl http://127.0.0.1:8080/health
{"database":"ok","status":"ok","version":"0.9.0"}

# An agent back from three days offline. The middle day is impossible - it ends
# before it starts - and the others land anyway.
$ curl -X POST http://127.0.0.1:8080/api/v1/days/batch \
    -H "Authorization: Bearer agent-token" -H "Content-Type: application/json" \
    -d '{"days":[
         {"date":"2026-08-15","started_at":"2026-08-15T09:04:00-03:00","ended_at":"2026-08-15T18:12:00-03:00",
          "tasks":[{"agent_task_id":7,"recorded_at":"2026-08-15T18:10:00-03:00","name":"Reliable ingest","completeness":60}]},
         {"date":"2026-08-16","started_at":"2026-08-16T19:00:00-03:00","ended_at":"2026-08-16T09:00:00-03:00"},
         {"date":"2026-08-17","started_at":"2026-08-17T09:11:00-03:00","ended_at":"2026-08-17T17:40:00-03:00",
          "tasks":[{"agent_task_id":7,"recorded_at":"2026-08-17T17:38:00-03:00","name":"Reliable ingest","completeness":100}],
          "tasks_are_complete":true}]}'
{"accepted":2,"rejected":1,"results":[
  {"status":"accepted","workday_id":"82ca500d-feeb-4d1f-8fb7-0b376339be02","date":"2026-08-15","pauses":0,"tasks":1,"deleted_tasks":0},
  {"status":"rejected","date":"2026-08-16","error":"ended_at is before started_at"},
  {"status":"accepted","workday_id":"2ea46d40-3aa0-48d3-8d8d-e1bb152a36bc","date":"2026-08-17","pauses":0,"tasks":1,"deleted_tasks":0}]}

# The employee deletes the task in kasl; the agent re-sends the day and says so.
$ curl -X POST http://127.0.0.1:8080/api/v1/days \
    -H "Authorization: Bearer agent-token" -H "Content-Type: application/json" \
    -d '{"date":"2026-08-17","started_at":"2026-08-17T09:11:00-03:00","ended_at":"2026-08-17T17:40:00-03:00",
         "tasks":[],"tasks_are_complete":true}'
{"workday_id":"2ea46d40-3aa0-48d3-8d8d-e1bb152a36bc","date":"2026-08-17","pauses":0,"tasks":0,"deleted_tasks":1}

The task is gone from the 17th - and still there on the 15th, where the employee did not delete it.

The dev database listens on 5433, leaving a PostgreSQL you may already run on 5432 alone; override with KASL_DB_PORT.

The API

/api/v1 from the first endpoint: agents update on their own schedule, so a path keeps meaning what it meant when the agent calling it shipped.

POST /api/v1/days — upload one day. Requires Authorization: Bearer <token>. The body is the workday with its pauses and tasks; ended_at is absent while the day is still running, and so is a pause's, and tasks may be empty.

Two properties are worth knowing before writing a client:

  • Every instant needs a UTC offset, and the day carries its own date. 2026-08-14T09:12:00 without an offset is refused (422): one team's hours have to stay comparable across time zones, and which calendar day work belongs to is the agent's call, not a value derived on the server.
  • The last upload wins. Re-sending a day replaces what is stored, so a correction made in kasl lands and a retry after a lost connection is safe - the same payload twice leaves the same rows. Pauses are replaced as a set; tasks are matched on agent_task_id, so a task carried into the next day moves rather than multiplying.
  • Deleting a task takes one word. Send "tasks_are_complete": true and the date's tasks the payload omits are deleted, which is how a task the employee removed in kasl disappears here too. Other dates are untouched. Leave the flag out - as agents written before it did - and nothing is ever deleted.

POST /api/v1/days/batch — upload a backlog. The body is {"days": [...]} with the same day objects, and the answer reports each one:

{"accepted": 2, "rejected": 1, "results": [
  {"status": "accepted", "date": "2026-08-10", "workday_id": "...", "pauses": 1, "tasks": 3, "deleted_tasks": 0},
  {"status": "rejected", "date": "2026-08-11", "error": "ended_at is before started_at"},
  {"status": "accepted", "date": "2026-08-12", "workday_id": "...", "pauses": 0, "tasks": 1, "deleted_tasks": 0}
]}

Each day is written on its own, so one the server will never accept does not block the rest - an agent that could not deliver any of its backlog because of a single bad row would retry the same request forever. The batch carries at most KASL_MAX_BATCH_DAYS days (31) and the body at most KASL_MAX_BODY_BYTES (4 MiB); past either, 413.

Which failures are worth retrying. 4xx means the payload will not be accepted as sent, however many times it is tried - fix it or drop it. 5xx means the fault is on this side; send it again later. A batch that answers 5xx stopped partway: the days already accepted are stored, and re-sending them is safe because the last upload wins.

A malformed day is refused with 400 and a reason naming the field ({"error":"tasks[0]: completeness must be between 0 and 100"}); an unrecognized, revoked or deactivated token gets 401. The reasoning behind all of this is in ADR 0004 and ADR 0005.

Signing in

People sign in with an email and a password; kasl agents keep using their bearer token and are unaffected by any of this.

$ kasl-server admin --email boss@example.com --password '...'
admin boss@example.com is ready

$ curl -i -X POST http://127.0.0.1:8080/api/v1/auth/login     -H "Content-Type: application/json"     -d '{"email":"boss@example.com","password":"..."}'
HTTP/1.1 200 OK
set-cookie: kasl_session=b7857303342e1a4b...; Path=/; HttpOnly; SameSite=Strict; Max-Age=1209600
{"status":"ok"}

$ curl -H "Cookie: kasl_session=b7857303342e1a4b..." http://127.0.0.1:8080/api/v1/auth/me
{"id":"de836432-5dce-4705-9344-a65b356fc662","email":"boss@example.com","display_name":"boss@example.com","role":"admin"}

POST /auth/logout ends this session, POST /auth/logout-everywhere ends all of them, and GET /auth/me says who the caller is.

Sessions are rows in the database, not signed tokens. The query per request buys the thing a self-contained token cannot give: access ends when it is ended — the afternoon someone leaves, not whenever their token happens to expire. A session lasts a fortnight and each use pushes that out again.

An unknown email, a deactivated account and a wrong password all answer {"error":"wrong email or password"}, so the login form cannot be used to find out who works somewhere.

The first administrator comes from kasl-server admin or KASL_ADMIN=email:password in the environment. Running it again resets the password and promotes the account, which is both the way back in after a forgotten password and the way to make an admin of someone whose account already exists because their agent has been reporting. Accounts created by KASL_AGENTS have no password and cannot be signed into — they exist to own an agent's data.

Set KASL_SECURE_COOKIES=false when serving over plain http://. A Secure cookie is silently dropped by the browser there, which looks exactly like login doing nothing. The reasoning is in ADR 0007.

Managing the team

Once an administrator exists, people and agent tokens are managed over the API rather than through the host's environment.

$ curl -X POST http://127.0.0.1:8080/api/v1/users -H "Cookie: kasl_session=..."     -H "Content-Type: application/json"     -d '{"email":"ivan@example.com","display_name":"Ivan","password":"..."}'
{"id":"9b5c1fd8-cf3d-433e-bb9e-0c2bf1c1cfac"}

$ curl -X POST http://127.0.0.1:8080/api/v1/users/9b5c1fd8-.../agents     -H "Cookie: kasl_session=..." -H "Content-Type: application/json"     -d '{"name":"ivan-laptop"}'
{"id":"b749b090-db08-464d-b48d-4fe15f7acc43","name":"ivan-laptop",
 "token":"kasl_<64 hex chars>",
 "notice":"this token is shown once; the server keeps only its hash"}

$ curl -X DELETE http://127.0.0.1:8080/api/v1/agents/b749b090-... -H "Cookie: kasl_session=..."
# 204; the same token now gets 401 from the ingest routes
Route Who
GET /users, GET /users/{id}/agents admin (everyone), manager (their departments)
GET /departments admin, manager
POST /departments, PATCH/DELETE /departments/{id} admin
PUT /users/{id}/department admin
POST /users, PATCH /users/{id} admin
POST /users/{id}/agents, DELETE /agents/{id} admin
POST /auth/password anyone signed in, for their own password
GET /audit admin

A manager reads their departments and changes nothing. A department names its manager, and a person belongs to one:

$ curl -X POST http://127.0.0.1:8080/api/v1/departments -H "Cookie: kasl_session=..."     -H "Content-Type: application/json"     -d '{"name":"Engineering","manager_id":"d7c9ef3a-..."}'
{"id":"997c3947-4028-45e6-9c1c-9cd334b10c5d"}

$ curl -X PUT http://127.0.0.1:8080/api/v1/users/<id>/department -H "Cookie: kasl_session=..."     -H "Content-Type: application/json" -d '{"department_id":"997c3947-..."}'
# 204; `{"department_id":null}` takes them out again without deleting anything

The manager of Engineering sees the people in Engineering, plus themselves — a manager who runs nothing yet would otherwise get an empty page and think the product was broken. An administrator sees everyone.

Someone with no department is visible to the administrator alone. Showing the unfiled to every manager, so nobody gets lost, fails in the direction nobody observes: forget to file a person and they are exposed company-wide, silently. Missing from a list is reported the same afternoon.

Deleting a department leaves its people unfiled rather than deleting them, and an employee cannot be made to run one — they could not see it, so it would silently have no working head. Issuing an agent token stays with the administrator: it is the authority to write someone's history, and there is no audit log until the next milestone.

An administrator sets an initial password and hands it over; the person changes it with POST /auth/password, which requires the current one. The server has no mail channel, so there is nothing to send an invite link to that would not be handed over the same way a password is.

Some things follow from a change rather than being asked for separately:

  • Deactivating someone, or resetting their password, deletes their sessions.
  • Changing your own password ends every other session and keeps the one you are using.
  • The last administrator cannot be demoted or deactivated — the only way back from that is the admin subcommand on the host.
  • A user is never deleted, only deactivated: their days have to keep an owner.

Agent tokens are shown once and stored as a SHA-256. The reasoning is in ADR 0008 and ADR 0009.

The audit log

Everything that changes people, departments or agent tokens is recorded, along with sign-ins and the attempts that failed:

$ curl -H "Cookie: kasl_session=..." "http://127.0.0.1:8080/api/v1/audit?limit=2"
[{"id":4,"actor_id":null,"actor_email":"ivan@example.com","action":"auth.login_failed",
  "target_id":null,"target_label":null,"details":null,"at":"2026-08-22T00:58:12.880538Z"},
 {"id":3,"actor_id":"85440341-...","actor_email":"boss@example.com","action":"agent.issued",
  "target_id":"dcb60120-...","target_label":"ivan-laptop",
  "details":{"user_id":"9dce4dd0-..."},"at":"2026-08-22T00:58:11.850690Z"}]

Filter with actor_id, target_id, action, since, until, and page with limit (500 at most) and offset. "Everything that happened to this person" is ?target_id=....

Nothing secret goes in. An issued token is recorded as having been issued, never as a value; a password change is recorded as having happened. A failed sign-in keeps the address that was tried — a run of them against one account is the thing worth seeing — but never the password, which is often a real one belonging to somewhere else.

There is no route to delete from it. Not for old entries, not for a date range. A journal the watched party can erase is not a journal, and the administrator is the log'"'"'s main subject; trimming it is an operation for whoever holds the database. Reading the log is not itself recorded — an audit of the audit buries the actions under a log of people looking at the log.

Only an administrator may read it. The reasoning is in ADR 0010.

Importing history from before the server

Someone can track their time with kasl for a year before their team runs a server. That history is an ordinary SQLite file on their machine, and it does not have to be lost because the server arrived second:

$ kasl-server import --db kasl.db --user employee@example.com --timezone -03:00
read 240 workdays, 312 pauses, 460 tasks from kasl.db
skipped 17 tasks the employee had deleted
imported 240 days as employee@example.com at -03:00

--timezone is required and has no default. kasl stores bare wall-clock text, so nothing in the file says which offset it was recorded in - and a wrong guess produces a perfectly plausible-looking year of work at the wrong hour. The answer comes from whoever knows, and is echoed back so it is on the record.

  • --dry-run reads and reports without writing anything.
  • --since / --until bound the import by date, both ends inclusive. This is how someone who moved between time zones is imported correctly: one run per stretch, each with the offset that stretch was recorded in.
  • The account must already exist - an import will not create it, so a typo in the email address cannot quietly file a year of history under a stranger.
  • Re-importing replaces rather than duplicates, so a run that failed partway can simply be repeated, and a wrong offset is fixed by importing again with the right one.

The agent's file is opened read-only and never written to. Details and the trade-offs behind the fixed offset are in ADR 0006.

The data model

Migrations live in migrations/ and are applied on startup. The shape follows kasl's own model, so a reader who knows the agent recognizes it:

Table Holds
users People and their role: admin, manager, employee
sessions Browser sign-ins; a token hash each, never the token
departments Groups of people, each naming the manager who runs it
audit_log Who did what, to whom, and when; append-only
agents Installed kasl instances; a token hash each, never the token
workdays One row per person per date: when the day started and ended
pauses Idle stretches and manual breaks inside a day
tasks, tags, task_tags What was worked on, and how it is labelled
reports That a report was submitted, when, and with which figures

Two differences from the agent's database are deliberate: instants are stored with a time zone (the agent stores bare wall-clock text, which does not survive a team spread across zones), and rows are tied together by foreign keys rather than by comparing dates. Both are recorded in ADR 0003.

Running it somewhere real

docker-compose.prod.yml builds the server and starts it next to PostgreSQL. The image is built on the machine that will run it, so a stand on a Raspberry Pi gets an aarch64 binary without cross-compiling anything:

$ cat > .env <<'ENV'
POSTGRES_PASSWORD=<a long random string>
KASL_AGENTS=employee@example.com:<the agent's token>
ENV
$ chmod 600 .env
$ docker compose -f docker-compose.prod.yml up -d --build

The database publishes no port — only the server reaches it, over the compose network — and the server runs as an unprivileged user. The first build takes a while on a small machine (about fifteen minutes on a Pi 4); later ones reuse the cached layers.

This is a stand, not a supported deployment: backups, restore and an install guide come with the deployment milestone.

Configuration

Everything comes from the environment:

Variable Meaning Default
DATABASE_URL PostgreSQL connection string required
KASL_SERVER_ADDR Address the HTTP server binds to 0.0.0.0:8080
KASL_AGENTS Agents to provision on startup, as email:token pairs separated by commas none
RUST_LOG Log filter (tracing syntax) kasl_server=info,tower_http=info

Database migrations are embedded in the binary and applied on startup.

KASL_AGENTS is how the first agents get in while the admin UI does not exist yet: each entry becomes an employee and an agent holding that token's hash. Re-running with a changed token rotates it and revokes the old one. Tokens are secrets — pass them through your deployment's secret store, not a committed file — and the variable stops being the way in once tokens are issued from the UI.

What it will do

  • Ingest work-time data from kasl agents: workdays, pauses, tasks, reports (days, backfill and history import: done)
  • Manager dashboards: who is working right now, hours per person, trends over time
  • Personal pages: every employee sees their own history
  • Roles: admin, manager, employee
  • Self-hosted: a single binary (Docker image planned) plus PostgreSQL — your data stays on your infrastructure

Stack

Rust REST API (axum) + PostgreSQL (sqlx), React single-page app for the web UI. Architectural decisions are recorded in docs/adr/.

License

MIT