m's CalDAV server (dav.msbls.de, SabreDAV) now feeds projax via a thin
read-only-plus-create-on-demand integration. No background sync; tasks
fetched live on detail-page render.
New caldav/ package
- ListCalendars (PROPFIND Depth: 1, filters non-calendar collections)
- ListTodos (REPORT calendar-query for VTODO; hand-rolled iCalendar
parser for UID/SUMMARY/STATUS/DUE/PRIORITY/LAST-MODIFIED — RFC 5545
line-folding aware)
- CreateCalendar (MKCALENDAR, 405 → ErrCalendarExists for the "link
instead" branch)
- httptest-stubbed tests cover all four paths.
Store
- ItemLink shape + LinksByType / LinksByRefType / AddLink / DeleteLink.
AddLink upserts on (item_id, ref_type, ref_id, rel) so re-linking the
same calendar is idempotent.
Web
- GET /admin/caldav — discovery + auto-suggested matches + manual
linker. Suggestion = lowercased displayname == projax slug or title.
- POST /admin/caldav/link — insert item_links row.
- POST /admin/caldav/unlink — delete by link id.
- POST /i/{path}/caldav/create — MKCALENDAR at <base>/<slug>/, then
AddLink. On 405 (already exists), fall back to link-only.
- Detail page Tasks section: per-calendar block with open VTODOs +
collapsed completed (30d window). Errors per calendar logged and
skipped, so one bad calendar does not blank the page.
- nav adds /admin/caldav link.
main.go
- DAV_URL + DAV_USER + DAV_PASSWORD optional. Missing DAV_URL → CalDAV
off (admin page renders "not configured" notice). DAV_URL set but
user/pass missing → fail fast at boot.
docs/design.md gains §5 documenting the integration shape.
deploy/dokploy.yaml lists the two new secrets + the env var.
Phase 2.b (writeback / two-way / background sync) is parked.
118 lines
7.0 KiB
Markdown
118 lines
7.0 KiB
Markdown
# 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=:8080`
|
|
- `PROJAX_AUTO_MIGRATE=on` (set to `off` to skip on-start migration apply)
|
|
- `SUPABASE_URL` + `SUPABASE_ANON_KEY` enable 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_PASSWORD` enable the CalDAV integration: `/admin/caldav` discovery, 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):
|
|
|
|
```sql
|
|
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:
|
|
|
|
1. Create an app `projax` with `Dockerfile` build context = repo root.
|
|
2. Set domain `projax.msbls.de` (public via Traefik + Let's Encrypt — auth gating is at the application layer, see Trust model).
|
|
3. Secret `PROJAX_DB_URL` from step 0.
|
|
4. Env `SUPABASE_URL=https://supa.flexsiebels.de`, secret `SUPABASE_ANON_KEY` (from `.env.age`).
|
|
5. Health check path `/healthz`.
|
|
6. 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>`.
|
|
- `/login` posts to `<SUPABASE_URL>/auth/v1/token?grant_type=password` with the m/* user account. On success projax sets `access_token` and `refresh_token` cookies (HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=1y, **no Domain attribute** so they are scoped to `projax.msbls.de` only).
|
|
- Every request after that validates the cookie against `/auth/v1/user`. On expiry, projax silently refreshes via `/auth/v1/token?grant_type=refresh_token` and rotates both cookies. The middleware also accepts `Authorization: Bearer <token>` for scripted clients.
|
|
- `/logout` clears both cookies and bounces to `/login`.
|
|
- `redirectTo` is 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 on `projax.*`, read-only on `mai.projects` via an explicit RLS policy, blocked on every other schema (see deploy step 0).
|
|
- `PROJAX_DB_URL` + `SUPABASE_ANON_KEY` live 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.
|