# 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:@: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 ''; -- 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:@: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=`. - `/login` posts to `/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 ` 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.