Self-hosted Kanban board for small groups. Blazor Server + Atmosphere (AT Protocol) OAuth + DID allowlist.
  • C# 39.6%
  • JavaScript 22.2%
  • HTML 21.8%
  • CSS 12.1%
  • Shell 4%
  • Other 0.3%
Find a file
Claude (bot) 016f14dc76
All checks were successful
Publish / build-and-push (push) Successful in 32s
CI / build (push) Successful in 40s
Merge pull request 'Changelog for 2.5.1' (#153) from release/v2.5.1 into main
2026-08-23 15:01:28 +00:00
.claude/skills Claude: changes for issue #107 (#110) 2026-08-18 14:12:16 +00:00
.forgejo/workflows Claude: changes for issue #117 (#118) 2026-08-19 03:08:52 +00:00
deploy Record the backup rehearsal, and that production has no remote 2026-08-21 18:43:59 +02:00
docs Claude: changes for issue #150 (#151) 2026-08-23 14:55:37 +00:00
src/Ponente.Kanban Changelog for 2.5.1 2026-08-23 15:00:27 +00:00
tests Claude: changes for issue #150 (#151) 2026-08-23 14:55:37 +00:00
tools Claude: changes for issue #35 (#40) 2026-07-27 14:34:50 +00:00
.dockerignore Milestone 1: skeleton 2026-04-09 22:22:06 +02:00
.gitignore Claude: changes for issue #132 (#134) 2026-08-21 01:54:18 +00:00
AGENTS.md Claude: changes for issue #107 (#110) 2026-08-18 14:12:16 +00:00
CLAUDE.md Claude: changes for issue #144 (#145) 2026-08-21 16:09:54 +00:00
docker-compose.test.yml Claude: changes for issue #114 (#122) 2026-08-19 17:15:26 +00:00
docker-compose.yml Claude: changes for issue #140 (#142) 2026-08-21 14:33:15 +00:00
Dockerfile Claude: changes for issue #79 (#81) 2026-08-14 13:43:11 +00:00
Ponente.sln Claude: changes for issue #79 (#81) 2026-08-14 13:43:11 +00:00
README.md Claude: changes for issue #140 (#142) 2026-08-21 14:33:15 +00:00

Ponente

A small self-hosted Kanban board for a private member group, built on the Atmosphere (AT Protocol) network. Any number of boards over one membership, two ways in — Atmosphere OAuth with an existing account, or an account on the group's own bundled PDS (invite-code only) — and everything the app stores is encrypted at rest.

Features

  • Any number of Kanban boards with admin-managed columns (default: To Do / Doing / Done), each a link of its own and switchable from the nav
  • Per-board access — a board is open to every member or shared with the ones an admin picks. There is nothing to learn on the member's side: a board you have not been given is simply not in your switcher, and admins see every board
  • Atmosphere OAuth login — sign in with your Bluesky (or any AT Protocol) username, access controlled by a DID allowlist
  • Own federated PDS — the official Bluesky PDS (ghcr.io/bluesky-social/pds) runs in the stack; members without an Atmosphere account sign up here with a single-use invite code minted by an admin, and their accounts federate with the whole Atmosphere network (PLC-registered DIDs, relay crawling)
  • Access requests — a public /request form for both cases ("I have an account" / "I need an invite"), with a secret status link for the requester and an admin approval queue
  • Encryption at rest — all user-supplied board content (cards, comments, tags, names, handles, access requests) is AES-256-GCM encrypted before it reaches Postgres; OAuth tokens are encrypted via ASP.NET Data Protection. (PDS data lives in the official PDS's own volume, unencrypted at the app level.)
  • Cards with markdown descriptions (safe pipeline — no raw HTML, no javascript: links), drag and drop, tags, assignees, flat comments, optional due dates
  • Calendar (/calendar) — the board read by date, in two views: a month grid and a timeline with one lane per tag. The tag legend is also the filter, and the dated cards export as an .ics file (all-day events, tags as CATEGORIES) that any calendar app can import
  • Live updates — edit in one tab/browser, see it in another within ~1s
  • Admin pages — boards, board access, members (allowlist), tags, columns, invites, access requests
  • Installable as an app — a web app manifest and a service worker, offered from the nav menu (and pointed at the share sheet on iOS, which has no install event)
  • i18n — English + German

Stack

  • .NET 10 + Blazor Server (interactive server rendering via SignalR)
  • EF Core 10 + Postgres 17
  • ATProto.NET 0.5.0 — OAuth (DPoP + PAR + PKCE), EF Core token store, PDS administration (PdsAdminClient)
  • Markdig (safe markdown rendering), SortableJS (vendored)

Working on this codebase

CLAUDE.md is the router and docs/orientation.md is the ten-minute tour — the shape of the app, where everything lives, every service in one line, the invariants that will surprise you, and what a finished change has to include. CLAUDE.md then maps what you are about to touch to the one page in docs/ that explains why it is the way it is. Read that page before editing; the reasoning in it is the part the code cannot tell you.

First run (local)

1. Set your admin DID

Edit docker-compose.yml and put your DID in Allowlist__Admins__0 (find it at web.plc.directory). For host-side dev, edit appsettings.Development.json instead.

2. Start the stack

podman compose up --build   # or: docker compose up --build
  • Kanban app: http://localhost:8090
  • PDS: http://localhost:8091

To start over from scratch: podman compose down -v && podman compose up --build.

Note (podman-compose): after code changes use up -d --build --force-recreate--build alone rebuilds the image but keeps the old container running.

3. Log in

Sign in at http://localhost:8090 with your Bluesky username. Add members via Admin → Members (existing Atmosphere accounts) or mint invite codes via Admin → Invites (accounts on the bundled PDS).

Run locally for development

Requires a local Postgres on localhost:5432 (db/user/pass ponente, matching appsettings.Development.json):

podman run -d --name ponente-db \
  -e POSTGRES_DB=ponente -e POSTGRES_USER=ponente -e POSTGRES_PASSWORD=ponente \
  -p 5432:5432 docker.io/library/postgres:17-alpine

cd src/Ponente.Kanban && dotnet run        # app on http://127.0.0.1:5000

Migrations apply automatically on startup. The PDS only runs in the compose stack (podman compose up pds).

E2E tests

podman compose -f docker-compose.yml -f docker-compose.test.yml up -d --build --force-recreate
cd tests/e2e && npx playwright test

Configuration

Kanban app

Env var Description
ConnectionStrings__KanbanDb Postgres connection string
Encryption__Key Required. Base64 32-byte key for encryption at rest (openssl rand -base64 32). Losing it = losing the data.
Allowlist__Admins__0 Bootstrap admin DID (seeded on first run)
AtProto__BaseUrl Public base URL (derives the OAuth redirect_uri; loopback IP in dev)
AtProto__ProfileScope What sign-in asks of a member's account: granular (the default — permission to write the profile record and upload an image, and nothing else), generic (transition:generic, the blanket fallback for an account server too old for the above), none (authentication only, and /profile then shows the account without editing it). See docs/auth.md.
Pds__Url Internal URL of the PDS service (e.g. http://pds:8080); unset = signup/invites UI hidden
Pds__PublicUrl / Pds__HandleDomain User-facing PDS URL and handle suffix (e.g. .ponente.app)
Pds__AdminPassword Shared secret for the PDS invite admin endpoints
AccessRequests__RateLimit Per-IP submissions per 15 min on /request (default 5)
Mail__SmtpUrl Where outgoing mail is sent through, e.g. smtps://user%40host:pass@mail.example.com:465/ (percent-encode the @). Unset = no mail at all.
Mail__FromAddress / Mail__FromName Sender of that mail. The address defaults to the SMTP username when that is address-shaped; the name to Ponente.
Mail__AdminRecipients Who the admin notifications go to, comma-separated (default in appsettings.json)
Feedback__Repository Repository the nav menu's "Report a problem" files issues on, as a browser URL (https://git.example.net/owner/name). Unset = the row is hidden.
Feedback__Token Access token allowed to open issues there. Both are needed; either one alone is logged as a misconfiguration at startup.

Mail goes two ways. A new access request is mailed to Mail__AdminRecipients as soon as it is submitted — the request page is anonymous and nothing else announces it, so without this an admin only finds out by opening /admin/members. And approving a request mails the requester back, carrying the invite code and a signup link, if they filled in the optional address field on /request; without it the code stays behind their status link and in the admin invites panel, waiting to be passed along by hand. That mail is written in the language the requester was using when they submitted the form, not the approving admin's.

Nothing is mailed on denial (the status link carries that), and nothing is mailed on submission — /request is anonymous, so a mail triggered by submitting it would be a way to make this domain write to any address a stranger types. Mail is optional and entirely best-effort: it is queued rather than sent inline (an unreachable mail server must not fail a visitor's submission or hang an admin's click), retried twice, and then given up on with a log line. Whether it is on, off or misconfigured is logged once at startup.

PDS (official ghcr.io/bluesky-social/pds)

Configured via the PDS_* env vars in the compose files (see deploy/.env.example): hostname, JWT secret (hex), admin password, PLC rotation key (hex — back it up, it controls account identity recovery), invite-required mode, and the PLC / AppView / relay endpoints for federation.

The PDS also needs a mail server for password resets and email confirmations (PDS_EMAIL_SMTP_URL, PDS_EMAIL_FROM_ADDRESS). Without them it still starts and silently cannot mail anyone. Percent-encode the @ in the SMTP username, and pick a from-address on a domain whose SPF/DKIM/DMARC cover the sending server — otherwise the mail leaves and is quietly dropped.

Member handles do not have to sit under PDS_HOSTNAME: PDS_HANDLE_DOMAIN (→ PDS_SERVICE_HANDLE_DOMAINS, defaulting to .$PDS_HOSTNAME) decides which suffix the PDS mints and answers for. Production runs the app on the apex and the PDS on pds.ponente.app, while handles are alice.ponente.app — a DNS wildcard matches a single label, so *.ponente.app can point at the PDS without taking ponente.app away from the app.

That wildcard needs no wildcard certificate: the deployment relies on Caddy's on-demand TLS, which issues each handle its own certificate at the first TLS handshake, so there is no DNS-01 challenge and no DNS API token anywhere. The trade-off is that on-demand issuance is driven by whatever hostname a client asks for, so Caddy's on_demand_tls.ask endpoint is what decides whether a name deserves a certificate. Point it at something that actually validates the handle, or subdomain enumeration will burn the CA's rate limit for the domain.

On the deployment this repo targets that gatekeeper lives with Caddy rather than here (ask.Caddyfile, merged via CADDY_DOCKER_CADDYFILE_PATH): it proxies the requested name to the owning PDS's /.well-known/atproto-did with that name as the Host header, so only a handle the PDS actually serves gets a certificate.

Releases & deployment

deploy/README.md is the runbook. What follows is the shape of it.

Two stacks run on one server from one compose file: production on ponente.app, and a dev stack on dev.ponente.app that runs whatever is on main. They share the host and the caddy network and nothing else — separate databases, PDSs, volumes and encryption keys. STACK_NAME in the env file names the compose project and therefore everything in it, which is what keeps deploy/docker-compose.prod.yml from having to be forked into a dev copy.

Tagging a release builds and pushes the image:

git tag v2.0.2 && git push origin v2.0.2
# → git.grandiras.net/grandiras/ponente-kanban:v2.0.2 (+ :latest)

The same workflow fires on every push to main and publishes :main instead, moving nothing else — a merge is not a decision to ship.

That is the whole release. A systemd timer on the server follows :latest for production and :main for dev, and recreates the app container within a minute of either moving. So a release is git tag vX.Y.Z && git push, with no deploy step and no workstation — which is what lets one be done from a phone, or by an agent dispatched from an issue. Deploying a version with deploy.sh pins production and stands the timer down, which is what makes a rollback hold.

Nothing can push that update the other way: from a job container inside the dind daemon the host's SSH is unreachable by every route, so the direction is inverted and the host asks the registry.

.forgejo/workflows/release.yml does the build. The job environment has no container engine and no docker CLI of its own, so the workflow installs a static client and finds the daemon on the default gateway — jobs run inside the dind daemon, which listens there on :2375.

./deploy/release.sh v2.0.2 builds and pushes the same image from your machine, for when CI is unavailable (one-time podman login git.grandiras.net).

Deploying runs podman-compose on your machine against the server's podman socket over SSH, so nothing is copied to the server and deploy/.env never leaves the workstation — its secrets reach the server only as container environment:

cd deploy && cp .env.example .env     # fill in every value (first time only)
./deploy/deploy.sh                    # deploy the IMAGE_TAG from .env
./deploy/deploy.sh v2.0.0             # …or pin a tag for this deploy
./deploy/deploy.sh --env dev          # the dev stack, from deploy/.env.dev

Prereqs: podman-compose + podman-remote locally, a podman connection to the server (podman system connection ls; override the name with PONENTE_CONNECTION), and a caddy-docker-proxy already running there owning an external caddy network.

Nothing is published on a host port. Caddy discovers the stack from the caddy_* labels in docker-compose.prod.yml and routes ponente.app → app, pds.ponente.app + *.ponente.app → PDS, with www redirected to the apex. The script finishes by checking /healthz and /xrpc/_health over HTTPS on the public hostnames, which is the only check that proves Caddy is routing and holds a certificate.

Healthcheck

GET /healthz on both services returns 200 OK.

Project layout

src/Ponente.Kanban/                   # The Blazor Server board app — the only project
  Program.cs                          # DI, middleware, signup/login/request endpoints
  Auth/                               # OAuth + allowlist authorization
  Data/                               # EF Core schema, migrations, startup init
  Encryption/                         # AES-256-GCM encryption at rest
  Services/                           # Board/Tag/Member/Comment/Column services,
                                      # BoardNotifier (live updates), PdsGateway,
                                      # AccessRequestService, LocaleService (en/de)
  Components/Pages/                   # Board, Calendar, Login, Signup, Request, Admin pages
  Components/Pages/Admin/             # Members, Tags, Columns, Invites, Requests
tests/e2e/                            # Playwright suite (board, boards, access, calendar, mobile, pds, requests, install)
deploy/                               # Deployment runbook, compose + env templates,
                                      # deploy.sh, and the dev stack's auto-updater
.forgejo/workflows/                   # CI build + tag-triggered image releases
Dockerfile  docker-compose.yml