m's ask: per-item CalDAV linking should support existing lists, not
just create-new. Athena's design update extended it: also tag VTODOs
on create so multiple projax items can SHARE one CalDAV list, with
projax doing tag-based slicing on read.
Three layers, one branch:
## 1. Link-existing picker (the original ask)
- New POST /i/{path}/caldav/link-existing handler validates the
submitted calendar_url is in the discoverable PROPFIND set (defence
against crafted forms pointing at arbitrary HTTP servers), then
inserts the item_link row with display_name + color metadata
preserved from the discovery payload.
- handleDetail + renderTasksSection pre-load
availableCalendarsForItem(ctx, links) — calendars from
s.CalDAV.Client.ListCalendars MINUS the ones already linked to this
item. Errors degrade to an empty picker (non-fatal).
- tasks_section.tmpl gains a .caldav-actions block rendering the
picker (<select> of available calendars) when AvailableCalendars
is non-empty AND the Create-new button (when the item has no
linked list yet). Same surface serves both the "first link" flow
and the "+ link another" flow per athena's brief.
## 2. Tag-on-create (CATEGORIES carries projax:<path>)
- caldav package gains Categories []string on Todo + the same on
VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
parseVTodos parses CATEGORIES comma-list into the slice with per-
entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
`Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
edit/complete/delete paths preserve existing CATEGORIES via the
unknown-property pass-through that's been tested since Phase 5
(TestApplyVTodoEditPreservesUnknown).
## 3. Per-item filter (managed-vs-legacy)
- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
whether the linked list is projax-managed (any projax: tag
anywhere) or legacy/unmanaged (zero projax: tags).
- Managed → filter to VTODOs whose CATEGORIES include this
item's projax:<path>. Multiple projax: tags are AND-of-OR — a
VTODO with two projax tags appears on both items per athena's
multi-tag contract.
- Legacy → show every VTODO untouched. Existing pre-5j users with
untagged lists keep seeing everything; the detail page doesn't
suddenly hide their tasks.
## Helpers (caldav package, exported)
- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal
## Tests
caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
TestParseVTodosMultiCategory.
web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
picker renders, POST link-existing creates the link, second GET
drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
Trip A and Trip B with three tagged VTODOs; A sees A+shared,
B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
tags renders ALL VTODOs (legacy fallback).
Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.
Net: +795 / -14.
projax
m's personal data backbone for self-management — areas of life, projects within them, and aggregated views over tasks that live elsewhere. Subsumes scattered state currently held in mai.projects, CalDAV task lists, Gitea issues, and mBrian topic hubs.
Spec: docs/design.md. Project conventions: CLAUDE.md.
Run locally
export PROJAX_DB_URL=postgres://postgres:<pw>@<msupabase-host>:6789/postgres?sslmode=disable
go run ./cmd/projax
Defaults:
PROJAX_LISTEN_ADDR=:8080PROJAX_AUTO_MIGRATE=on(set tooffto skip on-start migration apply)SUPABASE_URL+SUPABASE_ANON_KEYenable projax's own/login. Same Supabase backend as the rest of the m/* fleet, but every tool runs its own login page and scopes cookies per-host. Leave both unset for local dev — every request is anonymous.DAV_URL+DAV_USER+DAV_PASSWORDenable the CalDAV integration:/admin/caldavdiscovery, the Tasks section on item detail pages, and the "Create CalDAV list" action. Leave unset to disable (admin page shows a "not configured" notice).
Visit http://localhost:8080/. Routes:
| Route | Purpose |
|---|---|
GET / |
Tree of areas + projects, plus orphan mai.projects |
GET /i/{path} |
Item detail; editable for projax, read-only for mai |
POST /i/{path} |
Save edits to a projax-native item |
POST /i/{path}/promote |
Promote a mai.projects orphan into a projax item |
GET /new?parent={path} |
Create a new item (area at root, project under parent) |
POST /new |
Submit |
GET /admin/classify |
Orphan list with inline HTMX promote |
GET /login |
Sign-in form (open) |
POST /login |
Sign-in submit (open) |
POST /logout |
Clear cookies, redirect to /login |
GET /healthz |
DB ping (open) |
GET /static/style.css |
Embedded CSS |
Test
DB-backed integration tests are skipped automatically when no PROJAX_DB_URL / SUPABASE_DATABASE_URL is set:
SUPABASE_DATABASE_URL=postgres://... go test ./...
Covers: migration idempotency, path-trigger semantics (nest, rename, re-parent, cycle, structural rules), items_unified source split + promotion hiding, every HTTP handler, and a Promote round-trip.
Deploy (Dokploy on mlake)
0. Manual prerequisite — create the dedicated DB role (run once)
The binary connects as a dedicated projax_admin role so its blast radius is bounded to the projax schema (cannot reach mai.workers, otto.*, vault.*, etc.). The role lives outside the migrations because it carries credentials.
As a superuser on msupabase (e.g. via the Supabase SQL editor):
CREATE ROLE projax_admin WITH LOGIN PASSWORD '<choose-strong-pw>';
-- Cross-schema read of mai.projects (consumed by projax.items_unified):
GRANT USAGE ON SCHEMA mai TO projax_admin;
GRANT SELECT ON mai.projects TO projax_admin;
-- mai.projects has RLS enabled; standard SELECT grants aren't enough.
-- Add an explicit policy so projax_admin sees every row:
CREATE POLICY projax_read ON mai.projects FOR SELECT TO projax_admin USING (true);
Then store the credential in .env.age and surface it to Dokploy as the secret PROJAX_DB_URL:
PROJAX_DB_URL=postgres://projax_admin:<pw>@<msupabase-tailscale-host>:6789/postgres?sslmode=disable
After this, migration 0005_reown_to_projax_admin.sql will detect the role on the next deploy and transfer ownership of every projax-namespaced object. Migrations before/after that point are idempotent.
1. Dokploy app
deploy/dokploy.yaml is a reference manifest. Translate to the Dokploy UI:
- Create an app
projaxwithDockerfilebuild context = repo root. - Set domain
projax.msbls.de(public via Traefik + Let's Encrypt — auth gating is at the application layer, see Trust model). - Secret
PROJAX_DB_URLfrom step 0. - Env
SUPABASE_URL=https://supa.flexsiebels.de, secretSUPABASE_ANON_KEY(from.env.age). - Health check path
/healthz. - Single replica.
The image is a distroless static container running as nonroot. Total image size is well under 20 MiB because everything (templates, CSS, migrations) is embed-bundled.
Trust model (v1)
Single-user. Public over HTTPS, gated by projax's own Supabase login. No anonymous routes except /healthz (Dokploy/Traefik probe), /login and /logout.
- Browser arrives without a session →
302 /login?redirectTo=<safe-path>. /loginposts to<SUPABASE_URL>/auth/v1/token?grant_type=passwordwith the m/* user account. On success projax setsaccess_tokenandrefresh_tokencookies (HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=1y, no Domain attribute so they are scoped toprojax.msbls.deonly).- Every request after that validates the cookie against
/auth/v1/user. On expiry, projax silently refreshes via/auth/v1/token?grant_type=refresh_tokenand rotates both cookies. The middleware also acceptsAuthorization: Bearer <token>for scripted clients. /logoutclears both cookies and bounces to/login.redirectTois path-only (/-prefixed, no//, no escape sequences). Cross-host bounces are rejected and fall back to/.- Same Supabase backend as the rest of the m/* fleet (mBrian, flexsiebels, …); each tool keeps its own login + cookie scope.
- DB role is
projax_admin— full rights onprojax.*, read-only onmai.projectsvia an explicit RLS policy, blocked on every other schema (see deploy step 0). PROJAX_DB_URL+SUPABASE_ANON_KEYlive in Dokploy secrets, never the repo.
If projax later needs auth (multi-device, shared with people, etc.), the natural fit is the same Supabase auth used by flexsiebels — defer until projax has actually outgrown the Tailscale fence.
Schema
projax.items (id, kind[], title, slug, path, parent_id, content_md,
aliases[], metadata jsonb, status, pinned, archived,
start_time, end_time, created_at, updated_at, deleted_at)
projax.item_links (item_id, ref_type, ref_id, rel, note, metadata, created_at)
projax.items_unified VIEW = projax.items UNION ALL adapter over mai.projects
A BEFORE trigger maintains items.path via parent walk and enforces structural rules (areas at root, projects not at root, no cycles). An AFTER trigger rewrites descendant paths on rename / re-parent.
A mai.projects row drops out of items_unified as soon as any projax.item_links row with ref_type='mai-project' points back at it — that's how the Promote flow makes the duplicate disappear without ever mutating mai.projects.
Migrations live in db/migrations/, are embedded into the binary, and applied lexicographically on boot.