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.
mgmt.msbls.de is being retired; depending on it for auth was the wrong
direction. Match the mBrian / flexsiebels pattern instead — same
Supabase backend, but every tool runs its own login page and scopes
cookies to its own host.
Routes
- GET /login render a sign-in form (mBrian dark visual). If the
request already has a valid session, jump to a safe
redirectTo (or /).
- POST /login exchange email+password at /auth/v1/token?grant_type=
password, set cookies, 302 → redirectTo or /. On
Supabase 4xx, re-render the form with the error.
- POST /logout clear both cookies (Max-Age=-1) + 302 → /login.
Cookies
- access_token + refresh_token only. No Domain attribute → scope is
projax.msbls.de exclusively. HttpOnly, Secure, SameSite=Lax, Path=/,
Max-Age=1y. Matches mBrian + flexsiebels per-host pattern.
Middleware
- /healthz, /login, /logout always pass through (otherwise infinite
redirect on the probe / login page).
- On invalid/expired session → 302 /login?redirectTo=<safe-path>,
RELATIVE to projax. No more cross-host bounce.
- Cookie refresh on expiry still rotates both cookies in place.
- Bearer header path kept for scripted clients.
safeRedirect
- Path-only. Rejects "", "//*", "https://*", "\*", control-char
injection. Cross-host or scheme bounces fall back to "/". Tested
against the obvious bypasses.
Cleanup
- Drop PROJAX_LOGIN_URL + PROJAX_COOKIE_DOMAIN env vars (unused now).
- main.go: log "auth: own-login enabled" with the supabase URL on
startup; warn loudly when SUPABASE_URL is unset.
- README trust-model section rewritten: own login, per-host cookies,
same backend.
- layout.tmpl gains a "sign out" form-button in the nav so the tree /
detail / classify pages can log out without curl.
Tests (14, no DB needed): stub Supabase via httptest covers
healthz/login/logout exemption, anonymous→/login redirect, valid
cookie + Bearer pass-through, stale-refresh rotation with NO Domain
attribute, hard-fail redirect, GET form render with redirectTo carry,
already-signed-in short-circuit, POST success with correct cookies,
POST bad-creds error surface, redirectTo safety (path-only, no //,
no absolute URLs), logout cookie clearance.
Full suite (incl. DB-backed): 27/27 green with PROJAX_SKIP_MIGRATE=1.
projax was deployed publicly through Dokploy/Traefik with a Let's
Encrypt cert; the earlier "Tailscale-only" claim was never true. Gate
every request at the application layer using the same Supabase JWT
cookie pair that mgmt.msbls.de issues, so projax inherits SSO without
running its own login.
Middleware (web/auth.go):
- GET <SUPABASE_URL>/auth/v1/user with the access_token cookie or a
Bearer header. On 2xx → pass through.
- On expiry, swap the refresh_token via /auth/v1/token?grant_type=
refresh_token and rotate both cookies (Domain=msbls.de, HttpOnly,
Secure, SameSite=Lax, Path=/, Max-Age=1y). Cookie attributes match
mgmt/auth.ts verbatim — refreshed sessions stay drop-in compatible
with the rest of the .msbls.de fleet.
- Anything still invalid → 302 to <PROJAX_LOGIN_URL>?redirectTo=
<original-absolute-url>. mgmt's safeRedirect() rejects absolute URLs
and falls back to /, so after login the user lands on mgmt; manual
click back to projax then succeeds with the fresh cookie. UX is
rough but functional; broadening mgmt's safeRedirect is parked for a
separate PR.
- /healthz remains ungated so Dokploy/Traefik probes don't hit the
redirect.
main.go: enable the middleware only when SUPABASE_URL is set; require
SUPABASE_ANON_KEY when it is (refuse to start otherwise). New env
overrides: PROJAX_LOGIN_URL (default https://mgmt.msbls.de/login),
PROJAX_COOKIE_DOMAIN (default msbls.de). Local dev with no env stays
fully anonymous.
Tests (7 cases, no DB needed): stub Supabase via httptest covers
healthz-open, anonymous-redirect, bad-cookie-redirect, good-cookie
pass-through, Bearer-pass-through, stale-but-refreshable rotation
(verifies cookie Domain/HttpOnly/Secure/SameSite), final fail
redirect.
DB-backed integration tests now honour PROJAX_SKIP_MIGRATE=1 so they
don't deadlock against the live container's auto-migrate during a
deploy window.
README + dokploy.yaml: kill the Tailscale-only claim, document the
federated-auth trust model and the new SUPABASE_* env contract.
Mirrors the mbrian_admin pattern: the binary connects as a role bounded
to the projax schema, so even a compromised projax process cannot read
mai.workers, otto.*, vault.*, etc.
- 0001: switch grants block from postgres → projax_admin (conditional
on the role existing — bootstrap still works as superuser before the
role is created). Wrap `create schema` in a guard so the migration
is idempotent when re-run as a non-superuser app role that lacks
database-level CREATE.
- 0005_reown_to_projax_admin.sql: enumerate every projax-namespaced
object via pg_namespace + pg_class / pg_proc and ALTER OWNER to
projax_admin. Explicitly scoped — no global REASSIGN OWNED that
would yank ownership from other projects sharing the postgres role.
Strips residual postgres grants. No-ops with a NOTICE when the role
is missing.
- README: new "Manual prerequisite" deploy section. Documents the
CREATE ROLE statement, the cross-schema USAGE + SELECT grants, AND
the RLS policy `projax_read ON mai.projects` that's required because
mai.projects has row-level security with policies scoped to `mai`
and `anon` only. Without the policy, items_unified silently returns
zero mai-source rows.
- deploy/dokploy.yaml: DSN comment now reflects projax_admin and
points at the README prereq.
Verified locally against msupabase with a throwaway projax_admin role:
- 13/13 tests green
- mai.workers SELECT → permission denied
- mai.sessions SELECT → permission denied
- mai.projects SELECT → 59 rows (RLS policy in effect)
- projax.items_unified SELECT → 66 rows (7 projax + 59 mai)
- Multi-stage Dockerfile: golang:1.25-alpine builder → distroless static
runtime as nonroot. Image weighs ~15 MB. Embeds templates, static
assets and migrations into the single binary.
- deploy/dokploy.yaml documents the Dokploy app for projax.msbls.de:
Tailscale-only, healthz path, single replica, secret PROJAX_DB_URL.
Translates to the Dokploy UI; not auto-applied.
- README rewritten as runbook: env vars, route table, test command,
deploy notes, trust model (Tailscale + no auth in v1, defer to
Supabase auth if it ever outgrows the fence), schema summary.
- .dockerignore strips .git, .m, .claude, docs, tests from build ctx.
- .gitignore covers ad-hoc binary and dist artefacts.
Verified locally: docker build succeeds, container responds to /healthz
and / against msupabase via --network host.