m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.
## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
toggle himself via /admin/bulk or the detail-page form.
## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
before fanning out — items with the kind in their exclude array are
dropped entirely (no CalDAV call wasted on excluded sources). Doc and
creation rows check the per-item flag inline. `?include_excluded=1`
(URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
("todo") or plural ("todos") to bridge the kind-constant / persisted-
value naming choice — see comment for the why.
## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
checkboxes (todos / events / docs / creation) and helper text. Open by
default when any kind is excluded, so m can see at a glance what's
hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
and "Re-include on timeline" — the other three kinds stay editable
per-item only per the task brief (most common use case is just todos).
## MCP
- update_item accepts timeline_exclude as a partial-update field with an
enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
can render the toggle state without a second round-trip.
## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances
## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.
## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
via /admin/bulk after the deploy — schema change stays behaviour-neutral)
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.
## Schema (migration 0014)
- public boolean default false (partial index when true)
- public_description text default ''
- public_live_url text default ''
- public_source_url text default ''
- public_screenshots text[] default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx PARTIAL INDEX WHERE public = true (5% of rows)
## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter
## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB
## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
inputs + screenshot list editor with add/remove rows + inline JS for
the editor. Values persist when public is off so toggling never
destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
round-trip, detail-page affordances, tree-filter narrowing
## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).
## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
## Slice A — explicit dark/light toggle
projax now ships with two palettes and a 1y cookie to remember the choice.
Dark is the new default; ☀ button in the header nav flips to light and
writes projax_theme=light. Server reads the cookie via themeFromRequest(r)
and injects Theme + ThemeColor into every template via the centralised
render(w, r, …) path, so first paint never flashes the wrong theme. Inline
JS in layout.tmpl handles the toggle without a server roundtrip.
Every panel colour now lives in a CSS variable under
:root[data-theme=dark|light]; the only hardcoded hex values left are
inside those two :root blocks. A future palette tweak is one edit, not
30 selectors. Graph node colours, kind-badges, highlights and warn/ok/bad
all have parallel dark/light values picked for contrast.
Standalone SVG download bakes the light palette inline because the
downloaded asset has no parent :root providing vars — m's existing
snapshots stay print-friendly regardless of his current cookie.
Login page keeps its embedded dark CSS — it's the gateway, intentionally
always dark.
Tests: TestThemeDefaultIsDark, TestThemeCookieRoundTrips,
TestThemeCookieUnknownFallsBackToDark, TestThemeTogglePagesShareSameTheme,
TestThemeToggleScriptPresent, TestThemeColorMetaHelper. Full suite green.
## Slice B — file-upload permanently out of scope (m, 2026-05-17)
docs/design.md moves "File uploads / in-projax storage" from the §3c
parked list to a permanent "Out of scope (decided 2026-05-17)" clause
with the rationale: PER is the cross-reference index, not the file
system. docs/standards/per.md gains the same explicit clause so future
shifts working from the PER standard see the constraint where they
look. Memory note filed so future workers don't re-propose multipart
uploads, attachments tables, or documents buckets.
## docs/design.md §13 Theming
Documents the toggle approach, cookie semantics, palette table, the
standalone-SVG carve-out, the login-page exception, and the 4b
out-of-scope (prefers-color-scheme detection, per-page overrides,
transitions on swap).
/timeline braids every dated thing in projax into a single chronological spine:
CalDAV VTODOs (DUE anchor), VEVENTs (DTSTART), dated item_links (event_date),
and item-creation markers. Default window past-30d to future-90d; ?order=
toggles asc/desc; ?kind= narrows by row type; tree filter (?tag/?mgmt/?has)
applies across kinds. Today / Tomorrow get sticky pills; rows > today+30d
fade. 90s in-memory TTL cache keyed by (filter, window, order, kinds);
busted on any VTODO writeback or dated-link change.
Scope expansion (per head message during 4a): the dashboard Tasks card now
has edit + delete affordances on every row, matching the detail page. New
/dashboard/task/{edit,delete} endpoints share a writeback path with /done.
Timeline VTODO rows reuse the same handlers; HX-Target=timeline-section
selects the re-render surface. Timeline item_link rows reuse the existing
/i/{path}/links/remove handler with the same surface-switch.
VEVENT rows on the timeline remain read-only at v1 (3l decision stands).
Item-creation events render as muted "added X to projax" markers.
Tests cover empty state, dated-doc surfacing, kind-filter narrowing, order
toggle, mixed CalDAV todos + all-day events (with the (2 days) duration
hint), and tag-filter cross-kind. New dashboard test asserts the edit/
delete affordances are wired up.
docs/design.md gains §12 with the full source list, layout rules, time
window, filter integration, cache TTL, and deferred items.
Closes the silent-deploy-rot gap caught by Phase 3n's triage. The
problem: a missing Gitea webhook left 11 commits stuck on an old
container while /healthz kept reporting 200 from the stale binary. With
no commit-level evidence on the wire, "deploy rolled" was unverifiable.
Mechanism:
- Dockerfile installs git, reads `git rev-parse --short HEAD` at build
time, injects via `-ldflags="-X main.gitCommit=<sha>"`. Works under
Dokploy's `git clone --depth 1` flow (the .git/ folder is in the
build context) and under plain `docker build .` (same). Local
`go run` falls back to "unknown".
- main.gitCommit assigns to web.Server.Version in main().
- /healthz now emits two lines: "ok" and "version: <sha>". Endpoint
remains unauthenticated so any worker / monitor can verify "deploy
rolled" without a session.
CLAUDE.md gets a mandatory "Post-deploy verification" section: after
every push, compare `git rev-parse --short HEAD` against
`curl /healthz | tail -1`. Mismatch = webhook broken; inspect Gitea
hook 172 (URL pattern `http://mlake.horse-ayu.ts.net:3000/api/deploy/
<refreshToken>` per the working webhooks on m/msbls.de + m/flexsiebels.de).
TestHealthzSurfacesVersion regression-guards the new line. Existing
TestHealthz updated to accept the multi-line body.
The three admin pages (classify, caldav, bulk) had no shared entry point —
m navigated around and couldn't find them. /admin is now their index:
- 3 cards, each linking to the underlying tool, with live counts
(orphan count via projax.items_unified predicate; calendar count via
ListCalendars; item count via projax.items where deleted_at IS NULL
AND archived = false)
- CalDAV card auto-disables when DAV_URL isn't configured
- System panel: version (build-time ldflags hook), last migration
(projax.schema_migrations top row), MCP status (token present
yes/no — token itself never displayed), upstream health (DAV +
Gitea + Supabase, parallel-probed with 1s HTTP timeout each,
cached 30s)
web/admin.go houses the handler + cache + probeURL helper + count
queries. Templates/admin.tmpl renders the cards + system grid.
admin_test.go covers /admin render + nav-link presence on every
chrome-bearing route.
Nav consolidation: the three separate admin links in layout.tmpl
collapse to one /admin entry. Pre-existing TestTreeRenders updated
to assert the new shape.
Probe-URL caveat: probeURL counts any HTTP response as "alive" (incl.
4xx) — the admin panel measures reachability, not authorisation. CalDAV
returns 401 on bare GET; Gitea returns 200 at the root; Supabase same.
All show green when alive.
- web/static/manifest.webmanifest: name/short_name/start_url=/dashboard/
display=standalone/theme_color/background_color + three icons (192, 512,
512-maskable with ~12% safe-zone padding)
- web/static/sw.js: minimal SW — install caches /static/* shell assets,
fetch is network-first with cache fallback on GETs only, skips /mcp/
and non-GETs entirely. CACHE_NAME versioned for clean activate-time
prune.
- cmd/icongen: stdlib-only generator that produces the three PNG icons
from a stylised "p" monogram. Run once at brand-change, commit output.
- web.init() registers .webmanifest → application/manifest+json with
mime.AddExtensionType so Chrome accepts the manifest at all
- layout.tmpl + login.tmpl: manifest link, apple-touch-icon, theme-color,
apple-mobile-web-app-* metas, inline SW-register on load (silent on
failure — older browsers still work)
- design.md gets §"PWA install (Phase 3j)"; CLAUDE.md "Out of scope"
drops the Phase-3j line and adds push/background-sync as the
remaining Otto-PWA territory
- 4 new tests cover manifest MIME, sw.js delivery, all 3 icons, layout
meta tags
- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
- authorisation guard: repo form value must match a gitea-repo item_link
on the target item (rejects form-crafted writes to unrelated repos)
- HTMX re-renders issues_section partial after each action
- busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
+ 403 → inline banner fallback
- migration 0012: one-shot populate empty tags from each item's area-roots
(so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows)
- migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that
cascades soft-delete to item_links going forward — closes the data drift
that made TestItemsUnifiedSurfacesMaiPointer fail since 3c
- /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/
remove tag, set management, set status. Per-row inline chip add/remove
via /admin/bulk/chip. Reuses tree_filter URL params 1:1.
- design.md §3.2 + §4.1 updated; tag+management section notes 0012
- bulk + tag-backfill + soft-delete-cascade tests cover the new surface
migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.
store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
upserts with COALESCE(new, old) for note + event_date so partial
callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.
web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
DatedLinks output and assigns render-time collision tags inside each
date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
date-sorted rows with computed PER, ref-type badge, remove × with
anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
fragment, redirect for non-HTMX callers.
mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.
docs:
- docs/standards/per.md gains an Implementation section pointing at
item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
schema delta, conflict policy, and URL routing rules.
tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
length); collisionTag (1..53); computePERs (bare-then-.a, skips
undated, multi-date grouping).
Tree page (/) gains every navigation dimension m asked for:
- Debounced search input matching title/slug/aliases/content_md/paths
case-insensitively (?q=…)
- Tag chip row (?tag=a,b — AND within tags, as before)
- Management chip row with ?mgmt=mai,self,external,unmanaged
(OR within management; "unmanaged" is the synthetic empty-array case)
- Status chip row with ?status=active,done,archived (default = active;
archived rows only surface when the separate show-archived toggle is on)
- Has-link chip row ?has=caldav-list,gitea-repo
- Each chip carries the count it would yield if toggled — honest user
cue, computed via per-dimension recomputation in pure Go (cheap at
m's scale)
- URL is the source of truth — every filter goes through the query
string, so any view is bookmarkable; HTMX swaps the tree-section in
place with hx-push-url=true on every chip click and on search keyup
- Empty-state copy with a clear-all link
Implementation:
- web/tree_filter.go new: TreeFilter struct + ParseTreeFilter +
QueryString/URL + Toggle* helpers + Matches + applyTreeFilter
(replacement for buildForest) + computeChipCounts.
- web/tree_filter_test.go: parse defaults + every dimension's match +
URL round-trip + ancestor-keep semantics + chip counting.
- Linkages: linkKindsByItem on Server fans across the two has-link
ref_types in one pass and feeds the filter.
- tree.tmpl reduced to a one-liner that calls tree-section; new
tree_section partial powers both the initial page render and HTMX
fragment swaps (matches the pattern from phases 2.a/b/d).
docs/design.md §4: tree-filter contract — URL keys, AND/OR rules,
count semantics, archived ergonomics.
Go 1.22 ServeMux treats prefix patterns as conflicting with the root
catch-all when one matches more methods than the other. Switch from
prefix mounting (`/mcp/`) to two explicit `POST/GET /mcp/rpc` handlers
so the projax server boots cleanly. /healthz crash loop on first deploy
of phase 3a was caused by this.
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools
delegate to *store.Store (no business-logic duplication).
- handler.go: handleRPC routes initialize / tools/list / tools/call /
ping / notifications/initialized; Bearer-token middleware; results
flow through the standard MCP content[].text envelope; tool errors
surface as isError: true (transport errors stay JSON-RPC errors).
- tools.go: 10 tools — list_items / get_item / create_item /
update_item / delete_item / list_links / add_link / remove_link /
search / tree. Multi-parent in/out — parent_paths[] string array,
resolved per call. itemView/linkView keep the wire shape snake_case
and stable.
- mcp_test.go + tools_test.go: protocol primitives (no DB) plus a
full create → get → search → delete round-trip skipping cleanly
when the DB env is absent. Multi-parent assertion discovers the
test pair from the live DB rather than hard-coding a row.
store extensions:
- ListByFilters(SearchFilters) with parent_path/tags/management/kind/
status/q/has_repo/has_caldav predicates.
- Search(q, limit) ranked across title/slug/aliases/content_md.
- GetByPathOrSlug for callers that don't know the full path.
- SoftDeleteCascade refuses on live descendants unless cascade=true.
web:
- New optional Server.MCP http.Handler. main.go mounts an mcp.Server
when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses
the Supabase-cookie auth middleware (its own Bearer auth applies).
- Off cleanly when the token is unset.
ops:
- ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out,
Bearer header).
- .mcp.json adds an http-transport entry for clients that speak
HTTP+MCP natively.
- deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret.
- docs/design.md §7 added: tool list, multi-parent semantics, env
contract, transport + bridge.
caldav package:
- Todo carries URL, ETag, Raw so ListTodos rows can be PUT/DELETEd in place
- BuildVTodoICS for new VTODOs, ApplyVTodoEdit for in-place edits that
preserve unknown properties (DESCRIPTION, CATEGORIES, X-*)
- PutTodo/DeleteTodo with If-Match optimistic concurrency
- ErrPreconditionFailed/ErrNotFound for 412/404
- RFC 5545 fold-at-75 + CRLF + text escape, hand-rolled UUID v4
- httptest round-trip (create -> list -> complete -> delete) plus 412 path
web:
- POST /i/{path}/caldav/todo/{complete,reopen,edit,delete,todo-create}
- Re-fetches the live ETag before each PUT/DELETE so ordinary use never
trips 412; on actual 412 the section reloads with a banner
- Calendar URL must already be linked to the item (anti-forgery guard)
- tasks_section partial drives both the initial page render and HTMX
swaps; detail.tmpl reduces to a one-liner template call
docs/design.md §5: rewrite for full read/write semantics + ETag concurrency.
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.
Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.
Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
collapses the area/project distinction (kind keeps the slot but 'area'
is no longer a special value), drops the structural rules from the
path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
mai.projects without a 'mai-project' item_link, create a projax.items
row under a heuristic-chosen area (mhealth→health, msports/manjin→
sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
mhome→home, default→dev), insert the item_link, and tag the row
management=['mai']. Also flips management='mai' on any already-linked
pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
projax_admin and writes mai.projects directly (after the operator-run
grant + RLS policy widening — documented in the migration header).
sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
cycle suppressed. Slug stays the join key for new rows; the
item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
compute_item_paths walks via parents' precomputed paths (no recursive
CTE in the hot path; cycle detection uses one). New triggers:
items_check_slug_collision (multi-parent uniqueness),
items_after_delete (manual cascade since arrays don't carry FK).
Trigger refresh_item_paths_recursive does parent-first DFS over
descendants, guarded by projax.refreshing_paths GUC.
Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
OtherPaths helpers feed the detail breadcrumb. Source always
'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
(duplicated nodes in distinct branches). Tag-filter prune is
branch-preserving.
Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
"Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
bidirectional sync removed the dichotomy).
Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths inserts an item with two parents,
asserts both inherited paths.
- TestSlugCollisionUnderCommonParent refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow HTTP-level: /i/dev.X and
/i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].
Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.
mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
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.
cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to
SUPABASE_DATABASE_URL), auto-applies embedded migrations on start
(disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR
(default :8080).
store package wraps the unified view + projax.items writes. Item has
helper methods for templates: IsArea, Editable, SourceRefDeref. The
Promote() flow runs the insert + item_links link inside a single
transaction so the source row drops out of items_unified atomically.
web package: per-page html/template instances parsed against a shared
layout.tmpl, embedded static/style.css, HTMX from CDN. Pages:
GET / tree of items_unified
GET /i/{path} detail (editable for projax, read-only +
promote form for mai.projects)
POST /i/{path} update projax-native item
POST /i/{path}/promote one-page promote (HTMX-aware fragment for
inline classify)
GET /new?parent={path} create form
POST /new create projax-native item
GET /admin/classify orphan list with inline HTMX promote
GET /healthz DB ping
GET /static/* embedded assets
Auth is intentionally out of scope for v1 — service binds to whatever
PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale
interface (covered in 1d README).
Tests (skip when DB env is unset):
TestTreeRenders, TestHealthz,
TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly,
TestClassifyListsOrphans, TestPromoteRoundTrip.