Post-Phase-A–J full-product audit: UX, code, content, architecture,
ops. 5 Critical findings (JWT signature bypass, dashboard Termine
leak, parteien/termin delete policy gap, @hoganlovells-only email
gate, CALDAV_ENCRYPTION_KEY missing from compose), 8 Important, 10
Polish, 11 Feature ideas, 14 tech-debt items. Each item has a file
reference and a concrete fix.
Top-two exploit-paths (detailed in §1):
1. internal/auth/auth.go:178 — middleware decodes JWT exp but never
verifies the signature; sub-claim is trusted downstream by every
service. Any authenticated cookie → impersonate any user.
2. internal/services/dashboard_service.go:245 — personal Termine
leaked cross-user on the /dashboard "Kommende Termine" list
(missing created_by filter on the akte_id IS NULL branch).
25 KiB
Paliad — Product Audit & Improvement Roadmap
Prepared by cronus (inventor) on 2026-04-18 for task t-paliad-015.
Scope: complete code, UX, content, architecture, and ops audit after the
17 000-line KanzlAI → Paliad integration (Phases A–J, April 2026).
Audit posture: first-real-user (HLC patent lawyer, Munich) and long-term architect. Items are prioritised and actionable — each has a file reference and a suggested fix. No hour estimates; effort is expressed as S / M / L (small / medium / large).
- S = <1 day of focused work; single file or localised change
- M = multi-file change, migration, or non-trivial design
- L = new subsystem, cross-cutting rework, or external dep
TL;DR — the two things to fix today
- The session middleware does not verify JWT signatures and the Go
backend extracts
auth.uid()from that unverified token to gate every database read and write. A forged cookie with anysuband a futureexpgives an attacker access to any user's Akten, including admin. See C-1 below.internal/auth/auth.go:178+internal/auth/user.go:60. - The dashboard leaks every user's personal Termine to every other
logged-in user for the next 7 days (title, location, description,
start/end).
internal/services/dashboard_service.go:245. See C-2.
Both are one-file fixes and must ship before this audit reaches a wider pilot group.
1 — Critical (fix immediately)
C-1. Session JWTs are not signature-verified
File: internal/auth/auth.go:178, internal/auth/user.go:60
Severity: Critical (authZ bypass)
Effort: M
Client.Middleware accepts the session cookie, parses exp via
DecodeJWTExpiry, and calls next if the token is unexpired. The
signature is never checked. WithUserID then base64-decodes the sub
claim straight into the request context. AkteService.GetByID and every
other service trusts that UUID as the authenticated user.
Exploit: craft any JWT with exp in the future and sub = <admin-user-uuid>. Set it as the patholo_session cookie. The Go
backend uses a service-role Postgres connection, so RLS policies do not
run and the app-level visibility check sees the forged UUID as a real
admin. Effectively: anyone with a Paliad cookie can impersonate anyone.
Fix:
- Fetch the Supabase project's JWT signing key (JWKS endpoint:
${SUPABASE_URL}/auth/v1/.well-known/jwks.json) or the sharedSUPABASE_JWT_SECRETand verify the token inMiddlewarebefore trusting any claim. - Cache the JWKS for 1 hour; rotate on kid mismatch.
WithUserIDshould read the verified claims, not re-decode the raw cookie.- Consider
github.com/golang-jwt/jwt/v5— already idiomatic for Go.
Related: internal/services/akte_service.go:18-24 explicitly
documents that RLS "does not kick in because the backend does not
provide a JWT-backed auth.uid()" — so the app-layer predicate is the
only gate. The JWT must therefore be trusted.
C-2. Dashboard leaks every user's personal Termine cross-user
File: internal/services/dashboard_service.go:232-256
Severity: Critical (privacy / confidentiality)
Effort: S
WHERE t.start_at >= $4
AND t.start_at < ($4 + interval '7 days')
AND (t.akte_id IS NULL -- ← ANY personal Termin, any user
OR a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin')
Personal Termine (akte_id IS NULL) are creator-only by contract
(TerminService.canSee enforces created_by = userID). The dashboard
forgets this filter and returns up to 10 such rows for any user —
title, location, description, times included.
In practice an associate's "Bewerbungsgespräch bei Kanzlei X" is visible to partners and vice-versa.
Fix: change the personal-Termin branch to
(t.akte_id IS NULL AND t.created_by = $2::uuid) — mirrors the already-
correct rule in termin_service.go:103.
C-3. Any user with visibility can delete Akte children (Parteien, Termine)
File:
internal/services/parteien_service.go:93-111— Delete has no role gate.internal/services/termin_service.go:357-398— Akte-linked Termine: only personal Termine check creator; Akte-linked pass through with just visibility.
Severity: Critical (data loss; inconsistent with Fristen policy) Effort: S
An associate in London, viewing a Munich firm-wide Akte, can
DELETE /api/parteien/{id} and erase the Klägerin-record, or delete any
hearing from the Akte's calendar. FristService.Delete already scopes
to partner|admin only — the other child services should follow suit.
Fix: require user.Role in {partner, admin} (or created_by = userID) for delete on ParteienService and TerminService. Apply the
same rule to Update on both.
C-4. Email gate still pins to @hoganlovells.com
File: internal/handlers/auth.go:113-116
Severity: Critical (post-merger lockout)
Effort: S
HLC email domains (@hlc.com, @hlc.de) cannot register or log in.
Login-form placeholders still say name@hoganlovells.com.
Fix:
- Replace
isHoganLovellsEmailwith an env-configurable whitelist, defaulthoganlovells.com,hlc.com,hlc.de(design §2 already requested this). - Update placeholders in
frontend/src/login.tsx:26,34+ thelogin.hinti18n key (de + en). - Error strings (
"Zugang nur für @hoganlovells.com …") should become "… für autorisierte HLC-E-Mail-Adressen."
C-5. CALDAV_ENCRYPTION_KEY not wired into production compose
File: docker-compose.yml:4-12
Severity: Critical in effect (feature silently disabled)
Effort: S
The compose file declares SUPABASE_URL, SUPABASE_ANON_KEY,
GITEA_TOKEN, DATABASE_URL. It does not pass
CALDAV_ENCRYPTION_KEY. On the Dokploy compose (Zx147ycurfYagKRl_Zzyo)
this means cmd/server/main.go:66 logs
"CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501" and
the entire Termine-sync feature is dead on paliad.de. Users who save
their CalDAV settings see a 501 from /api/caldav-config and conclude
the product is broken.
Fix: add - CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY} (and
while we're at it, a placeholder ANTHROPIC_API_KEY commented out so
Phase H reactivation is one uncomment). Set the actual key on Dokploy.
2 — Important (fix this week)
I-1. Dokumente tab on Akten detail is a dead placeholder
File: frontend/src/akten-detail.tsx:215-222,
frontend/src/client/i18n.ts:468,1241
Severity: Important (visible UI dead-end; leaks internal phase names)
Effort: S
The tab is rendered, clickable, and shows the text "Dokumenten-Upload folgt in Phase H." — a strong UX signal that the product is unfinished. Phase H is deferred indefinitely.
Fix (pick one):
- Hide the Dokumente tab entirely until there is real content — drop it
from the VALID_TABS list in
akten-detail.ts:62and the TSX tabs strip. - Or keep it visible but replace the copy with a neutral "Dokumenten-
Upload in Planung" (no phase leak) and a discreet CTA to vote/express
interest (write to
paliad_feature_interest).
I'd hide it — dead tabs are worse than missing features.
I-2. Office labels in German not translated to English
File: frontend/src/index.tsx:122-128
Severity: Important (i18n regression visible on landing page)
Effort: S
Only index.munich has a data-i18n key. Düsseldorf, Hamburg,
Amsterdam, London, Paris, Mailand render raw German in EN mode.
"Mailand" → "Milan" is the most obvious miss; Düsseldorf/London/Paris
are correct in both languages but the fact that only one is i18n'd is a
consistency bug waiting for the next new office.
Fix: add index.office.munich|duesseldorf|hamburg|amsterdam|london| paris|milan keys (de: "München / Düsseldorf / Hamburg / Amsterdam /
London / Paris / Mailand"; en: "Munich / Düsseldorf / Hamburg /
Amsterdam / London / Paris / Milan").
I-3. Gerichtsverzeichnis UPC URLs redirect
File: internal/handlers/gerichte.go (45 occurrences of
unifiedpatentcourt.org, no hyphens)
Severity: Important (one redirect per link click; flagged as wrong by
link-checkers)
Effort: S
Canonical UPC website is https://www.unified-patent-court.org (with
hyphens). The no-hyphen form returns HTTP 403 challenge pages from
Cloudflare on naive curl but does resolve to the same destination.
internal/handlers/links.go uses the canonical form — the two files
are inconsistent. Pick one. Canonical (hyphenated) is safer.
Fix: sed-replace unifiedpatentcourt.org → unified-patent-court.org
across gerichte.go (and re-verify the deep paths still resolve —
some /en/court/court-appeal style URLs may have moved).
I-4. loadRecentActivity omits personal Termine audit rows (not a bug yet, but will be)
File: internal/services/dashboard_service.go:259-287
Severity: Medium-Important (correctness risk)
Effort: S
loadRecentActivity joins akten_events → akten, so only
Akte-attached events appear. That is correct today because personal
Termine explicitly skip the audit (per Phase F memory). If a future
contributor adds a personal-Termin event type without reading the
design, this query will silently drop it — add a comment or switch to
LEFT JOIN + WHERE a.id IS NULL OR <visibility>.
I-5. /api/akten/{id}/events has no pagination or size limit
File: internal/services/akte_service.go:368-383
Severity: Important (scalability; long-running Akten)
Effort: M
ListEvents returns every row ever written to akten_events for an
Akte. A three-year litigation with CalDAV sync and daily notizen edits
could easily reach 5–10 k rows. The Verlauf tab will then ship 2 MB of
JSON on each tab-click.
Fix: add ?before=<uuid>&limit=50 cursor pagination and render
"Load more" in the Verlauf tab. Already noted in the Phase E followup
list, never actioned.
I-6. Glossar is missing FRAND / SEP / related commercial-patent terms
File: internal/handlers/glossar.go:33-118 (~86 entries)
Severity: Important (content gap for the target audience)
Effort: S
HLC's patent practice is a heavy SEP/FRAND shop; the glossary has zero entries for: FRAND, SEP, Standard-essentielles Patent, Patentpool, Anti- Anti-Suit Injunction, Injunction gap, Orange-Book-Verfahren, Huawei/ZTE-Verhandlungsmuster, RAND, ETSI IPR Policy, Patent-Hold-up, Patent-Hold-out. These are table-stakes for the intended user.
Fix: add ~12 entries under a new SEP/FRAND category (or stretch
Litigation). Pull definitions from the canonical CJEU/BGH case law.
I-7. README is out of date
File: README.md:30-43,107
Severity: Important (onboarding drag)
Effort: S
- Migration list stops at 013; 014 (
checklist_instances) is live. - Line 107 says "Phase I (Notizen) pending — service and UI aren't
built yet"; it shipped on
mai/knuth/phase-i-notizen(commit5a9f8e5, 2026-04-17) with full service + handlers + UI.
Fix: refresh the migrations block (014_checklist_instances …) and
the status paragraph. Phase I ✅ Done, Phase J docs-only done, infra
retirement pending.
I-8. Legacy "patholo_" names everywhere
File: internal/auth/auth.go:17-18, internal/handlers/links.go:315,
frontend/src/client/i18n.ts:6 (STORAGE_KEY = "patholo-lang"),
frontend/src/client/*.ts (other localStorage keys),
paliad_link_suggestions / paliad_link_feedback table names (compare
vs. the older patholo_* references in the code).
Severity: Important (brand inconsistency; minor confusion)
Effort: M
Cookie name patholo_session, storage key patholo-lang, Supabase
tables patholo_link_suggestions / patholo_link_feedback. Memory
notes a deliberate decision to keep the cookie name so users aren't
logged out — that's fine — but storage keys and table names can migrate
without user impact.
Fix: plan a single branch that:
- Renames
STORAGE_KEY→paliad-langwith a one-shot migration from the old key (read old, write new, delete old) ini18n.tsinit. - Renames tables to
paliad_link_suggestions/paliad_link_feedback(migration + update callers inhandlers/links.go). - Leaves the cookie name alone or migrates it with a dual-read grace period.
3 — Polish (nice to have)
P-1. HL Intern links are stubs
internal/handlers/links.go:206-219 — two entries with URL: "#"
render as clickable cards that go nowhere.
Fix: either remove the entries until real URLs land, or add a "Coming soon — suggest a URL" flag + disabled styling.
P-2. Dashboard says "Meine Mandate" but the nav and URL say "Akten"
frontend/src/dashboard.tsx:82, i18n key dashboard.matters.heading.
The project's naming convention (CLAUDE.md) mandates Akten
throughout — the term "Mandate" is explicitly historical. The dashboard
heading should read "Meine Akten" / "My Matters".
Fix: change i18n text to "Meine Akten" (DE) and leave "My Matters" (EN). Rename i18n key if desired.
P-3. Login page placeholder still says name@hoganlovells.com
Covered by C-4. Mentioned again here because the hint "Nur für @hoganlovells.com Adressen." is a polish concern even after the whitelist change — update to "Nur für autorisierte HLC-Adressen."
P-4. Landing page footer reads "Hogan Lovells Patent Practice"
frontend/src/components/Footer.tsx:7. Brand reads as old on every
page. Switch to "HLC Patent Practice" post-merger (or drop the firm
name entirely since Paliad is supposed to survive renames).
P-5. No explicit empty-state copy on Fristen-Kalender / Termine-Kalender
Check frontend/src/fristen-kalender.tsx, termine-kalender.tsx.
Calendars with no events render an empty grid — add a subtle "Keine
Fristen / Termine im ausgewählten Zeitraum." string.
P-6. Office names in AkteService.isValidOffice diverge from UI labels
akte_service.go:403-408 accepts keys munich, duesseldorf, hamburg, amsterdam, london, paris, milan. The models.go user has the same
list. But the UI (landing page) writes "München", "Düsseldorf",
"Mailand". Currently fine because admins edit office via internal role
only, but any future user-facing office selector must map label →
key — add a single source of truth (internal/calc/offices.go or
similar: {key: "munich", labelDE: "München", labelEN: "Munich"}).
P-7. Glossar CSVs hard-coded in one file (230 lines)
Not a bug, but as the list grows toward 150+ terms it wants to move out
of Go source into internal/handlers/glossar_data.go or a JSON blob in
internal/data/glossar.json. Easier for non-devs to edit.
P-8. Dockerfile lacks HEALTHCHECK and runs as root
Dockerfile:13-19. No HEALTHCHECK, no USER nonroot. Both are easy
wins for Dokploy's health surface and container hardening.
# before the CMD
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/ || exit 1
RUN addgroup -S paliad && adduser -S -G paliad paliad
USER paliad
Add a GET /healthz route that returns 200 without auth; current /
redirects and wgets a 200 anyway, but an explicit probe is cleaner.
P-9. Landing-page copy still frames Paliad as "Paliad — Patentwissen für Hogan Lovells"
frontend/src/client/i18n.ts:38-48. With Phase 0 shipped, Paliad is
more than a knowledge hub — it's the Aktenverwaltung. The hero section
should call that out. Proposed: "Paliad — alles für den Patentalltag:
Akten, Fristen, Termine und Wissen."
P-10. Kostenrechner PDF and Checklisten print — not verified by audit
The design says PDF export works. I did not test it; please verify
after any CSS change that affects .tool-page / .print\:hide.
Consider adding a visual-regression snapshot to the build.
4 — Features (prioritised backlog)
F-1. Global search (Ctrl-K / Cmd-K)
Impact: Very High Effort: M
A single keystroke that searches across Akten, Fristen, Termine,
Parteien, Glossar, Gerichte, Links. Patent lawyers hop between matters
constantly; the current UX requires clicking through 2 sidebar entries
to find anything. Index on the backend with tsvector + pg_trgm, or
build a client-side Lunr index for the static content and a single
/api/search?q=… that fans out for user data. Match by Aktenzeichen,
party name, Frist title.
F-2. Verfahrensleitfäden (procedure guides)
Impact: Very High (per the original roadmap, never shipped) Effort: L
Step-by-step UPC / BPatG / EPO workflows that stitch together checklists, deadline rules, and recommended templates. This is the biggest original-roadmap item still missing.
F-3. Expand the Downloads registry
Impact: High (2/10 on the roadmap, partial) Effort: M
Only HL Patents Style.dotm is wired up. mWorkRepo has more
templates. Wire Vollmacht.dotm, Unterlassungserklärung.dotm,
UPC-Klageschrift-Skeleton.dotm — mirror the existing proxy pattern.
F-4. Benachrichtigungen (notifications)
Impact: Medium-High Effort: L
Email digest of today's overdue/due Fristen + tomorrow's Termine, sent at 07:00 CET per user. Supabase pg_cron + Edge Functions or a Go scheduler in the Paliad binary. Opt-in per user.
F-5. What's New / Changelog in-app
Impact: Medium Effort: S
docs/changelog.md rendered under /whatsnew with a red dot on the
sidebar when there's an entry newer than the user's last_seen_changelog
timestamp. Cheap way to communicate feature releases.
F-6. Akten-Kurzinfo on hover (pop-over)
Impact: Medium Effort: S
Hovering an Aktenzeichen in any list (Dashboard activity, Fristen, Termine) shows a tooltip with matter title, court, next Frist. Reduces tab-switching.
F-7. Parteien mit Kontaktkarten (Update endpoint)
Impact: Medium Effort: S
Currently ParteienService only supports Create + Delete. No Update.
Typos in a party name force a delete + re-add and lose the ID. Add
PATCH /api/parteien/{id}.
F-8. Fristenrechner — "Als Frist speichern" mit Wiedervorlage
Impact: Medium Effort: S
When creating a Frist with a Rule, also derive a Wiedervorlage-Frist (warning-date = due - 14 d) and offer to create both in one click.
F-9. Dark mode
Impact: Low (but free with CSS variables) Effort: S
global.css already uses CSS variables heavily. Add a
@media (prefers-color-scheme: dark) block + a manual toggle in the
sidebar.
F-10. Document upload without AI (revisit Phase H)
Impact: Medium (still open per memory episode) Effort: M
The Supabase Storage upload path already works; only the AI-extraction part was rejected. A plain "upload PDF to Akte" with no auto-extraction is still useful and unblocks the Dokumente tab.
F-11. Akten-Tags / Labels
Impact: Medium Effort: M
Free-form tags on Akten (e.g., SEP, OLG-Düsseldorf, Q4-Priority)
with a filter UI. Cheaper than a full custom-fields system and used
extensively at comparable firms.
5 — Technical Debt
T-1. RLS policies exist but are never enforced
internal/db/migrations/007_rls_policies.up.sql defines full office-
scoped RLS keyed off auth.uid(). The Go backend uses a service-role
connection (db.OpenPool with DATABASE_URL), so those policies never
evaluate — every query bypasses RLS by design.
This is correct today (service-role means app-level checks must be bulletproof, which they mostly are) but it means the RLS layer is dead weight that increases surface for a migration bug to produce a silently-wrong policy. Decide:
- Option A: accept the "belt-and-braces" position, keep RLS, document loudly that it's not active in prod.
- Option B: drop the RLS migration and policies — less code, less false sense of security.
- Option C: switch to PostgREST-style per-request JWT connections so RLS actually runs. This is the most defensive but a substantial rewrite.
My recommendation: Option A, with an internal/db/RLS_NOTE.md and a
SET row_security = off; (or equivalent) in the service-role
connection so the policies don't quietly cost perf.
T-2. Go module path still mgit.msbls.de/m/patholo
go.mod:1 + every import statement. Not breaking, but a daily
reminder of the old name. Rename on the next big refactor, not now —
every file in the repo would churn.
T-3. calDAVClient hand-rolls iCal + WebDAV
Documented in the Phase F memory as a deliberate choice. Fine for now, but re-evaluate when any of these lands:
- Importing foreign UIDs (currently skipped)
- RRULE / VTIMEZONE / DTSTART-with-tzid
- Multi-calendar support per user
At that point switch to github.com/emersion/go-ical +
github.com/emersion/go-webdav.
T-4. _dev/mock_supabase_auth.sql lives inside migrations/
internal/db/migrations/_dev/. Embed.FS includes all files under
migrations/ — the _dev directory is probably ignored by
iofs.New (directories not matching the pattern NNN_*.sql are
filtered) but it's one hop from a build bug. Move to
internal/db/devtools/ or similar, outside the embed root.
T-5. Client-side duplicated helpers
Memory's Phase E followup notes: urgency-color + date-format JS
helpers are duplicated across fristen.ts, fristen-detail.ts,
fristen-kalender.ts, akten-detail.ts. Extract to
frontend/src/client/fristen-shared.ts. Not urgent, but next time
anyone touches the Fristen UI: do this first.
T-6. /api/fristen?status=all returns everything client-side filters
Per Phase E memory: fine at current volumes, but a partner with 500 completed Fristen will see the full 500 on every calendar load. Add server-side date-range filtering before that's a support ticket.
T-7. No error monitoring
The server logs to stdout (which Dokploy captures), but there is no Sentry / log-based alert wiring. Nobody knows when something breaks in prod until a user complains. Options:
- Wire Supabase log drain (if Dokploy supports it).
- Self-host a lightweight OpenTelemetry collector on mlake.
- At minimum:
GET /metrics(Prometheus text) with basic counters, and a cron that posts to Gotify when error-rate crosses a threshold.
Pick the cheapest now — we can evolve later.
T-8. No structured logging
Mixed log.Printf + slog.Info across the codebase. Pick one, default
to slog with a JSON handler in prod. Pre-populate user-id and
request-id in the context for correlation.
T-9. No backup verification for paliad schema
Supabase's automatic backups cover the whole instance, but
paliad.paliad_schema_migrations collision history (memory episode
"paliad migration bootstrap collision") suggests the shared-Postgres
posture is fragile. Add a weekly pg_dump --schema=paliad cron on
mlake that writes to an offsite bucket and a monthly restore-smoke-test
(into an ephemeral Postgres container).
T-10. Test coverage gaps
Only akte_service_test.go, deadline_calculator_test.go,
holidays_test.go, fees_test.go exist. Missing: FristService,
TerminService, NotizService, CalDAVService (unit tests for
iCal encode / decode, encrypt / decrypt, visibility edge-cases).
The CalDAV crypto in particular should have a table-driven test with
known vectors.
T-11. internal/models/models.go is one big file
Not inherently bad, but it's the dumping ground for 14 types. Split by
domain (user.go, akte.go, frist.go, termin.go, …) next time it's
touched.
T-12. Dockerfile build cache hygiene
Every COPY . . invalidates on any source change, forcing a full go mod download. Move go.mod/go.sum copy + go mod download before
COPY . . — already done. But also consider FROM golang:1.24-alpine AS backend → golang:1.24 with CGO_ENABLED=0 so libc-less alpine
runtime doesn't fight any future pgx upgrade. Minor; today it's fine.
T-13. frontend/build.ts has 24 hand-maintained render-and-write pairs
Any new page requires edits in 4 places (build.ts, handlers.go
registration, i18n keys, CSS). Consider a page-manifest — an array
[{slug, render, clientEntry}] — that the build script iterates over.
Less ceremony; harder to forget one of the four places.
T-14. Gitea-backed file proxy has no ETag / If-Modified-Since
internal/handlers/files.go caches bytes and the commit SHA in memory
but never sets an ETag or Last-Modified response header, so every
browser re-downloads a 500 KB .dotm on each click. Add:
w.Header().Set("ETag", entry.sha) + handle If-None-Match with a
304. Cheap win.
Delivery suggestion
- Week 1 (critical): C-1, C-2, C-3, C-4, C-5. All small- to medium-effort and gates production readiness.
- Week 2 (important): I-1, I-2, I-3, I-7, I-8. Polish the post-merger brand story.
- Week 3 (features, pick 2): F-1 (global search) gives the biggest daily-use win; F-2 (Verfahrensleitfäden) is the biggest content win; F-3 (Downloads) closes a roadmap item cheaply.
- Always-on (tech debt): pick one T-item per sprint until they're gone.
None of the critical items are theoretical — the JWT signature bypass and the dashboard Termine leak are real exploit-paths I could walk through today with a curl command and a Paliad cookie. Fix those two first.