m reported "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY
fehlt am Server)" when trying to add a user account on /admin/team.
Root cause: the value was provisioned in Dokploy's compose env block
(I confirmed it via compose.one API), but docker-compose.yml's
`environment:` section never declared the variable. Docker compose
only forwards env vars that are listed in `environment:` — Dokploy's
project-level env is just a source of `${…}` interpolation, not an
automatic injection.
Fix: add `- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}`
alongside the other Supabase keys. The `:-` default keeps the compose
parseable on deployments that haven't provisioned the key (those still
get the existing /admin/team 503 fallback log line).
After the auto-deploy, cmd/server/main.go:139 will log
"supabase admin API configured — /admin/team Add-User path active"
instead of "SUPABASE_SERVICE_ROLE_KEY not set".
The Dokploy compose .env file got the new vars during the operational
flip but the docker-compose.yml environment block didn't list them, so
docker-compose silently dropped them during container start.
Adds PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN / AICHAT_PERSONA to
the environment block with safe defaults (PALIADIN_BACKEND=legacy,
AICHAT_PERSONA=paliadin). Existing deployments without aichat envs set
keep the legacy path; flipping PALIADIN_BACKEND=aichat in Dokploy now
takes effect on next deploy.
Discovered while doing the aichat Phase B activation flip.
Adds the 5 PALIADIN_* env entries to docker-compose.yml so paliad's
container picks them up from Dokploy secrets. With PALIADIN_REMOTE_HOST
set, paliad's main.go switches to RemotePaliadinService (already in
main from B5/0c8a2f1) and shells out to ssh m@mriver paliadin-shim.
**Phase A.5 finding (overrides design §4.2/§4.5 + decision 1):**
The original design assumed `network_mode: host` was needed so paliad
inherited mLake's tailscale0. The first attempt at that (a80652a,
reverted in 82faa3d) failed Dokploy's compose validation:
service web declares mutually exclusive `network_mode` and `networks`:
invalid compose project
Dokploy auto-injects `networks: [dokploy-network, default]` on the
primary service for traefik routing — irreconcilable with `network_mode:
host`. So design decision 1 (host mode) is fundamentally incompatible
with this Dokploy app's compose lifecycle.
But: empirically, paliad does NOT need host mode at all. Verified
(2026-05-08 11:23) by running a plain alpine container on Dokploy's
default bridge:
$ docker run --rm -v /tmp/paliad-prod-key:/tmp/k:ro \
-v /tmp/paliad-known_hosts:/tmp/kh:ro alpine:3.21 \
sh -c 'apk add openssh-client && \
ssh -p 22022 -i /tmp/k -o UserKnownHostsFile=/tmp/kh \
-o IdentitiesOnly=yes m@100.99.98.203 health'
→ ok
Why this works: Docker's outbound NAT masquerades the container's
bridge IP onto mLake's host IPs, including tailscale0
(100.99.98.201). Linux routing on mLake sends 100.99.98.0/24 to
tailscale0. mRiver's sshd sees the connection coming from
100.99.98.201, which matches the from="100.99.98.201" clause on the
paliad-prod authorized_keys entry. No tailscale-in-container, no
sidecar, no host networking — the kernel does it for free.
Resulting compose change is therefore minimal: 5 env entries pulled
through from Dokploy secrets. expose: ["8080"] preserved (no host-mode
side-effects). traefik routing untouched (no network_mode collision).
The amended commit message clarifies what changed; the design doc
needs an A.5 amendment in a follow-up — design §4 (host-mode shape)
is empirically wrong and §7 Phase A.5 needs an "M3: kernel does the
masquerade for you" entry.
Refs m/paliad#12
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
html/template rendering, branded base layout + content templates, silent
no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
overdue / due tomorrow / due within the week (Monday digest). Dedup via
paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
whitelist, in-memory 10/day/user rate limit, audit row in
paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
question; current pilot keeps identity mails on Supabase default sender.
Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
outright and clear cookies.
C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
TerminService.canSee: personal Termine (akte_id IS NULL) are
creator-only; admins do NOT see other users' personal Termine.
C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
partner/admin (or the original creator). Personal Termine stay
creator-only.
C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
(default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.com → name@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.
C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
ANTHROPIC_API_KEY line for Phase H readiness.
Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
Implements docs/design-kanzlai-integration.md §8 Phase A.
Schema (paliad.*):
- users (extends auth.users) with office, practice_group, role
- akten with visibility columns: owning_office, collaborators uuid[],
firm_wide_visible (per design §2)
- parteien, fristen, termine, dokumente, akten_events, notizen
(polymorphic notes; notizen_exactly_one_parent CHECK)
- proceeding_types, deadline_rules, holidays (reference data)
- 4 feedback tables re-namespaced from public.* into paliad.*
(handler swap to direct DB is a follow-up; old public tables stay
intact for now and continue serving via PostgREST)
Visibility (paliad.can_see_akte):
- single SQL function, used by every RLS policy
- predicate: firm_wide_visible OR owning_office matches user's office
OR auth.uid() ∈ collaborators OR user is admin
- mirrored at app layer in Phase B (defense in depth)
RLS (real, not permissive):
- akten: visibility predicate; insert restricted to own office or admin;
delete restricted to partners + admins
- parteien/fristen/dokumente/akten_events: inherit via can_see_akte(akte_id)
- termine: personal (akte_id NULL) visible only to creator; Akte-linked
follow visibility predicate
- notizen: paliad.notiz_is_visible() resolves polymorphic parent
- reference tables: SELECT for any authenticated user
- users: SELECT all; UPDATE/INSERT only self
- feedback tables: INSERT for any authenticated user (write-only)
Seed data (ported from KanzlAI seed_upc_timeline.sql):
- 7 proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL)
- 40 deadline_rules (32 UPC + 4 ZPO + 4 cross-type appeal spawns)
including conditional logic: Reply rule code (RoP.029b → 029a) and
Rejoinder duration (1mo → 2mo) flip when CCR active
- 55 holidays (DE federal 2026/2027 + UPC summer 2026 + UPC winter 26/27)
Indexes per audit §3.3 + visibility-predicate hot paths:
- akten: (status, owning_office), (owning_office), partial on
firm_wide_visible, GIN on collaborators
- fristen: (status, due_date), (akte_id)
- termine: (start_at), (akte_id)
- akten_events: (akte_id, created_at DESC)
- notizen: 4 partial indexes per parent type
- users: (office), (role)
Migration tooling:
- golang-migrate/migrate/v4 with embed.FS source
- Migrations live in internal/db/migrations/ (Go embed can't reach
outside the package; this is the conventional Go layout for embedded
migrations)
- Applied at server startup before HTTP listener binds
- DATABASE_URL is optional today (existing knowledge tools work without
DB); becomes required once Phase B services land
- Mock Supabase auth schema for local testing in
internal/db/migrations/_dev/mock_supabase_auth.sql (excluded from
embed pattern by the underscore prefix)
Other changes:
- Dockerfile: bump golang to 1.24, copy go.sum (audit §2.9), rename
binary patholo → paliad
- docker-compose.yml: add DATABASE_URL passthrough
- README.md: rewritten to reflect Paliad brand + Phase A migration system
Verified locally:
- 11 migrations applied cleanly against postgres:16-alpine
- RLS enabled on all 15 paliad.* tables (verified via pg_class.relrowsecurity)
- Visibility predicate verified with 4-case scenario:
- Alice (Munich associate): sees Munich + firm-wide + collab-on (t f t t)
- Bob (Düsseldorf associate): sees Düsseldorf + firm-wide + collab-on (f t t t)
- Carol (Munich partner): sees Munich + firm-wide only (t f t f)
- Anonymous: sees firm-wide only (f f t f)
- migrate down + re-up cycle clean (initial 007 down had ordering bug,
fixed: drop policies before referenced function)
- Existing endpoints (/, /login) return 302 + 200 — no regressions
Add GET /files/{filename} route (behind auth) that proxies files from
Gitea raw URLs with in-memory caching. Uses SHA-based cache invalidation:
checks Gitea commit API every 5 min, only re-downloads when file changes.
- internal/handlers/files.go: proxy handler with SHA-based cache
- POST /api/files/refresh: cache-bust endpoint
- GITEA_TOKEN env var for private repo access
- Download card on landing page with i18n DE/EN
Go server authenticates against Supabase GoTrue (youpc instance) using
email+password. Login page with login/register tabs, domain restricted
to @hoganlovells.com. Auth middleware protects all routes, refreshes
expired tokens via refresh_token cookie. Lime green branding.
- internal/auth: Supabase client (sign in, sign up, refresh, sign out),
JWT expiry decode, auth middleware, cookie management
- internal/handlers: login/register/logout handlers, per-page template
parsing to avoid content block collisions
- templates/login.html: tabbed login/register form
- 30-day HTTP-only session cookies with SameSite=Lax
- SUPABASE_URL and SUPABASE_ANON_KEY env vars in docker-compose
Go web server (net/http, port 8080) serving HTML templates with a
professional landing page for patholo.de. Multi-stage Dockerfile
and docker-compose.yml ready for Dokploy deployment.
- cmd/server/main.go: HTTP server entry point
- internal/handlers: route registration and template rendering
- templates/: base layout + bilingual landing page (DE/EN)
- static/css/: clean, responsive CSS with HL navy branding
- Dockerfile: multi-stage build (golang:1.23-alpine -> alpine:3.21)
- docker-compose.yml: single web service on port 8080