- C# 39.6%
- JavaScript 22.2%
- HTML 21.8%
- CSS 12.1%
- Shell 4%
- Other 0.3%
| .claude/skills | ||
| .forgejo/workflows | ||
| deploy | ||
| docs | ||
| src/Ponente.Kanban | ||
| tests | ||
| tools | ||
| .dockerignore | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| docker-compose.test.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Ponente.sln | ||
| README.md | ||
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
/requestform 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.icsfile (all-day events, tags asCATEGORIES) 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—--buildalone 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