mAi 092a56cf24 feat(db): pivot to dedicated projax_admin role
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)
2026-05-15 13:32:56 +02:00

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)

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 /healthz DB ping
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:

  1. Create an app projax with Dockerfile build context = repo root.
  2. Set domain projax.msbls.de (Tailscale-only — do not publish through public reverse proxy).
  3. Secret PROJAX_DB_URL from step 0.
  4. Health check path /healthz.
  5. 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, Tailscale-only. No HTTP-side authentication layer. The deployment relies on:

  • Dokploy app exposed only to Tailscale (no public DNS / reverse proxy outside Tailscale).
  • msupabase reachable only inside the same Tailscale network.
  • PROJAX_DB_URL is a Dokploy secret, not in the repo.
  • 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).

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.

Description
m's personal project + self-management system. Data backbone with multiple interfaces (Otto-PWA, web UI, Excalidraw views, CalDAV tasks).
Readme 1.1 MiB
Languages
Go 89.8%
CSS 5.8%
PLpgSQL 4.1%
JavaScript 0.2%