11 Commits

Author SHA1 Message Date
mAi
2702c699d1 feat(snapshot): Phase 6 slice 0 — projax_snapshot.json export helper
Read-only export of projax.items + projax.item_links to a JSON file the
mBrian-side migration script (m/mBrian#73) consumes. First implementation
slice of the Phase 6 mBrian-backend migration.

Tool:
- cmd/projax-snapshot/main.go: standalone binary, takes --out flag
  (default ./projax_snapshot.json). Reads PROJAX_DB_URL or
  SUPABASE_DATABASE_URL like the main projax binary.
- Pure read-only: SELECT FROM projax.items WHERE deleted_at IS NULL
  + SELECT FROM projax.item_links. No writes, no schema changes.
- Re-runnable: each invocation produces a fresh deterministic file;
  no state, no DB side effects.

Output shape (Snapshot struct):
- version: "1" — bumped on shape changes for downstream version-pinning.
- generated_at: timestamp.
- items: every live projax.items row with all columns mapped 1:1 to
  JSON-friendly types (uuid → string, jsonb → map, timestamptz →
  RFC3339). Empty slices coerced to [] so the mBrian-side script doesn't
  see null-array surprises.
- links: every projax.item_links row, ordered by item_id + ref_type
  for stable diffs across runs.
- spot_checks: the 5 representative items the mBrian-side script
  verifies post-migration per m/mBrian#73 §3. Selected at runtime by
  characteristic (root area, single-parent, multi-parent, caldav-linked,
  public-listing-populated) so the picks self-update as the dataset
  evolves.

Smoke-tested against the live msupabase dataset:
  wrote /tmp/projax_snapshot.json — 65 items, 81 links, 5 spot-checks

Selected spot-checks (live):
  dev      — root area
  paliad   — single-parent project
  services — multi-parent (2 parents)
  mhome    — caldav-list-linked
  fdbck    — public-listing populated

Out of scope (slices B+ pick up):
- The mBrian-side script itself lives in m/mBrian per "mbrian must own
  the migration" (Q4=(a)).
- projax-side adapter rewriting waits on the mBrian-side migration run.
- No tests yet: this is a one-off helper against live data; smoke run
  above is the validation surface. A go-test suite can land if the
  snapshot shape needs evolution before mBrian-side consumes it.
2026-05-29 14:02:16 +02:00
mAi
825894f511 refactor(mcp): wire aggregator directly, drop TimelineBuilder seam
Phase 5a slice D. The MCP timeline tool no longer depends on
*web.Server — it talks to *aggregate.Aggregator directly. The wrong-way
mcp → web layering that necessitated the TimelineBuilder interface is
gone.

- mcp/tools.go: TimelineBuilder interface deleted.
  RegisterProjaxTools(s, st, agg *aggregate.Aggregator) now takes the
  aggregator directly; passing nil keeps the timeline tool unregistered
  (kill-switch contract unchanged).
- mcp/tools.go: TimelineArgs moved from web/ to mcp/ since it is the
  MCP-facing input shape. The timeline tool runs the full pipeline:
  store.ListByFilters → in-mem timeline-exclude + has-link narrowing →
  agg.All(...) → Result.ToTimelineRows() → aggregate.BuildTimelineDays
  → timelineView. No web/ import in the timeline path.
- internal/aggregate/rows.go: new Result.ToTimelineRows() helper that
  projects the typed rows into the flat TimelineRow sum-type both
  web/timeline.go and mcp/tools.go consume. Single source of truth for
  the Date-anchor choice across kinds.
- internal/aggregate/timeline_days.go: FormatPERDate lifted from web/
  so timeline-row builders outside web/ can render PER strings without
  re-importing web/.
- web/timeline.go: BuildTimelinePayloadFromArgs + TimelineArgs deleted
  (no remaining callers — slice D inlined the MCP path).
- cmd/projax/main.go: pass srv.Aggregator() into RegisterProjaxTools.

MCP tree-filter parity note: the move to store.ListByFilters narrows
status to a single value (first of args.Status) and AND-matches
management (vs the web TreeFilter's OR). m's documented MCP uses
(tag + default status) round-trip identically. Logged as a footnote in
docs/plans/aggregator-refactor.md.

All mcp + web + aggregate tests green.

Task: t-projax-5a-aggregator
2026-05-22 00:15:07 +02:00
mAi
8b51746183 feat(phase 4c-B slice 1): MCP timeline tool wrapping the chronological view
Exposes projax's /timeline aggregation (Phase 4a) over MCP-RPC so the
PWA (mAi#228) can fetch it without a session cookie against
projax.msbls.de. Same tool surface m's other agents already use.

## Changes

- web/timeline.go: export TimelineQuery, TimelinePayload, add typed
  TimelineArgs + BuildTimelinePayloadFromArgs entrypoint. The web cache
  stays scoped to the HTTP handler; MCP path re-aggregates per call.
- mcp/tools.go: register `timeline` tool when a TimelineBuilder is
  passed. Output mirrors the web template's shape but stringifies
  timestamps to YYYY-MM-DD or ISO-8601 UTC so JSON-RPC consumers don't
  need Go time semantics.
- mcp/tools_test.go: existing tests pass nil builder (no behaviour
  change to the rest of the tool surface).
- mcp/timeline_test.go: 7 unit tests covering registration, arg
  forwarding, error propagation, empty payload, and view serialisation.
- cmd/projax/main.go: pass the running *web.Server as the third arg so
  the timeline tool registers on the live server (CalDAV-aware).
- docs/design.md §14: documents the tool, schema, output shape, cache
  semantics.

## Out of scope

- Caching the MCP path (rejected — re-aggregation per call is cheap;
  divergent cache keys aren't worth invalidation complexity).
- Wrapping CalDAV writes (S2 — separate slice once m greenlights).
- PWA backend bridge + frontend (S2/S3 — m/mAi side, after this deploys).
2026-05-17 18:42:48 +02:00
mAi
dfa81fd58e feat(phase 3p): bake git SHA into binary + surface on /healthz
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.
2026-05-16 15:35:28 +02:00
mAi
1d5db0fe7b feat(phase 3j pwa): manifest + service worker + icons → installable PWA
- 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
2026-05-15 19:32:48 +02:00
mAi
dc50823860 feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax
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.
2026-05-15 17:59:03 +02:00
mAi
1ffbfc6e69 feat(phase 2.d gitea): read-only issue ingest on items with gitea-repo links
gitea package (new): minimal client mirroring caldav's structure
- client.go: token auth, 5s timeout, ErrNotFound
- issues.go: ListIssues(owner, repo, opts) hitting
  /repos/{o}/{r}/issues?type=issues&state=…&since=…, ParseRepoRef,
  RepoHTMLURL. PullRequest-flagged rows dropped server- and client-side.
- httptest stubs covering parse, 404, ParseRepoRef variants.

web wiring:
- Server.Gitea optional GiteaDeps (Client + in-memory 3-min TTL cache
  keyed by owner/repo|state).
- detailIssues iterates every gitea-repo link, sums open issues, captures
  last-30d closed (≤20) into a disclosure. Per-repo failures surface as
  banner; one missing repo never blanks the section.
- relativeTime renders "Nm/h/d ago" / "yesterday" / fallback date.

Templates:
- issues_section.tmpl: per-repo block, header "Issues (n) + ↗ Gitea repo",
  rows with #N · title · labels · milestone · assignees · updated.
  Titles open in new tab.
- detail.tmpl: include the partial when Gitea is on and issues != nil.
- CSS: matches the Tasks section visual language.

main.go: GITEA_URL gates the integration (off when unset). GITEA_URL set
but GITEA_TOKEN missing → refuse to start.

deploy/dokploy.yaml: GITEA_URL env + GITEA_TOKEN secret added.

docs/design.md: new §6 mirroring §5's structure (link model, listing
semantics, caching, env contract, parked items).
2026-05-15 17:27:01 +02:00
mAi
96b61f7ed4 feat(phase 2 caldav): list + link + create CalDAV calendars
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.
2026-05-15 16:57:43 +02:00
mAi
360060b152 feat(auth): rip federation, give projax its own /login
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.
2026-05-15 15:16:55 +02:00
mAi
840c1760c9 feat(auth): federate with mgmt.msbls.de via Supabase cookies
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.
2026-05-15 14:58:43 +02:00
mAi
9f905de461 feat: Go HTTP server with tree / detail / new / classify
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.
2026-05-15 13:24:44 +02:00