A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
Q1 composition: primary + spawned (v1); multi-proceeding peer
compose is the v2 goal (spec.proceedings[] array)
Q2 scope: per-project + abstract (project_id NULL = abstract)
Q3 trigger: per-anchor overrides over one base date
Q4 storage: NEW paliad.scenarios table with jsonb spec
(NOT a project_event_choices column extension)
Migration 145 — additive only. Pre-flight coordination check:
- On-disk max: 138 (Berufung backfill, just merged).
- Live DB tracker: 106 (significantly behind — many migs pending
deploy).
- curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
as buffer; claimed 145 as the safe minimum that won't collide.
- paliad.scenarios has audit_reason NOT applicable (no audit
trigger on the table); updated_at trigger added on the table
itself.
- paliad.projects gains active_scenario_id uuid NULL FK with ON
DELETE SET NULL (mig 134 lesson — no updated_at clauses on
proceeding_types-style assumptions).
Schema:
paliad.scenarios (
id uuid pk,
project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
name text NOT NULL CHECK char_length > 0,
description text NULL,
spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
created_by uuid NULL FK → users(id) ON DELETE SET NULL,
created_at + updated_at timestamptz,
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
);
paliad.projects.active_scenario_id uuid NULL FK;
RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
Trigger: scenarios_touch_updated_at_trg.
pkg/litigationplanner additions:
- Scenario struct (db + json tags)
- ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
view of the jsonb (version-1 today, v2 multi-peer-ready)
- ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
- ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
- CalculateFromScenario(scenario, catalog, holidays, courts) — high-
level engine entry: parses spec → builds CalcOptions → delegates
to Calculate
- Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
ErrScenarioNoPrimary
paliadCatalog impl:
- LoadScenarios with progressively-built WHERE clauses (project-id
filter, abstract-for-user filter, or all)
- MatchScenario by id — returns ErrUnknownScenario on not-found
- Services connection bypasses RLS; ScenarioService enforces
visibility at the application layer (mirrors EventChoiceService
pattern from t-paliad-265)
SnapshotCatalog impl (embedded/upc):
- LoadScenarios returns empty slice (no scenarios in the snapshot)
- MatchScenario returns ErrUnknownScenario
internal/services/scenario_service.go:
- Create / Get / ListForProject / ListAbstractForUser / Patch /
SetActive / Delete with visibility checks
- validateSpec checks version, base_trigger_date format, every
proceedings[*].code resolves to an active paliad.proceeding_types
row, every appeal_target is valid, every anchor_overrides date
parses, every role ∈ {primary, peer}
- SetActive validates the scenario belongs to the requested project
(a scenario from a different project can't be active here)
- Returns ErrScenarioNotVisible for failed visibility checks
REST endpoints (registered in handlers.go):
GET /api/scenarios?project=<id> — list project's
GET /api/scenarios?abstract=true — list user's abstract
GET /api/scenarios/{id} — one
POST /api/scenarios — create
PATCH /api/scenarios/{id} — partial update
DELETE /api/scenarios/{id} — remove
PUT /api/projects/{id}/active-scenario — set / clear active
Handler error mapping:
- ErrUnknownScenario / ErrScenarioNotVisible → 404
- ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
- everything else → 500
Tests:
- pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
(well-formed + unknown version + malformed json),
PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
unpack, trigger_date_override path, no-base-trigger safety check.
8 cases total, all DB-free.
Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).
Acceptance:
- go build ./... clean
- go test ./... all green (incl. new scenarios tests)
- Pre-flight audit confirmed mig 145 number is safe vs curie's
pending B.2-B.6 range
536 lines
22 KiB
Go
536 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
|
|
// Embed Go's IANA tz database into the binary so time.LoadLocation works
|
|
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
|
|
// zoneinfo — without this import, every reminder timezone lookup fails
|
|
// silently and the hourly reminder slot fires in UTC instead of the
|
|
// user's chosen tz (t-paliad-064 root cause). Adds ~450KB to the binary.
|
|
_ "time/tzdata"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/auth"
|
|
"mgit.msbls.de/m/paliad/internal/branding"
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
"mgit.msbls.de/m/paliad/internal/handlers"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
func main() {
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
// Surface the firm name in the boot log so a deployer can confirm
|
|
// FIRM_NAME took effect without curl-ing a rendered page.
|
|
log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name)
|
|
|
|
supabaseURL := os.Getenv("SUPABASE_URL")
|
|
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
|
|
if supabaseURL == "" || supabaseAnonKey == "" {
|
|
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
|
|
}
|
|
|
|
jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
|
|
if jwtSecret == "" {
|
|
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
|
|
}
|
|
|
|
client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))
|
|
|
|
giteaToken := os.Getenv("GITEA_TOKEN")
|
|
if giteaToken == "" {
|
|
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
|
|
}
|
|
|
|
// MailService is wired regardless of DB availability — it no-ops when
|
|
// SMTP env vars are unset, so the server stays runnable for knowledge-
|
|
// platform-only deployments. Template-parse errors at boot are fatal.
|
|
mailSvc, err := services.NewMailService()
|
|
if err != nil {
|
|
log.Fatalf("mail service init: %v", err)
|
|
}
|
|
|
|
// Shared context for background goroutines (CalDAV sync + reminder job).
|
|
bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// DATABASE_URL is optional during the Phase A → Phase D transition. The
|
|
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
|
|
// without a DB. matter-management endpoints return 503 until DATABASE_URL is set.
|
|
dbURL := os.Getenv("DATABASE_URL")
|
|
var svcBundle *handlers.Services
|
|
var caldavSvc *services.CalDAVService
|
|
|
|
if dbURL != "" {
|
|
log.Println("applying database migrations…")
|
|
if err := db.ApplyMigrations(dbURL); err != nil {
|
|
log.Fatalf("migration failed: %v", err)
|
|
}
|
|
log.Println("database migrations applied")
|
|
|
|
pool, err := db.OpenPool(dbURL)
|
|
if err != nil {
|
|
log.Fatalf("open db pool: %v", err)
|
|
}
|
|
|
|
// Refresh paliad.deadline_search whenever migrations run, so
|
|
// search reflects any newly-seeded rule / concept / trigger.
|
|
// Migration 047 created the matview already-populated; this
|
|
// is only a no-op for the boot that introduced it. CONCURRENTLY
|
|
// keeps reads online and stays well under 100 ms at < 1k rows.
|
|
if err := services.RefreshSearchView(bgCtx, pool); err != nil {
|
|
log.Printf("refresh deadline_search: %v", err)
|
|
}
|
|
holidays := services.NewHolidayService(pool)
|
|
courts := services.NewCourtService(pool)
|
|
users := services.NewUserService(pool)
|
|
projectSvc := services.NewProjectService(pool, users)
|
|
teamSvc := services.NewTeamService(pool, projectSvc)
|
|
partnerUnitSvc := services.NewPartnerUnitService(pool, users)
|
|
rules := services.NewDeadlineRuleService(pool)
|
|
|
|
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
|
|
// the service exists but Enabled() reports false; handlers return 501.
|
|
// If the env var is malformed, fail fast — silently skipping would
|
|
// leave plaintext-credential bugs hidden.
|
|
cipher, err := services.LoadCalDAVCipher()
|
|
if err != nil {
|
|
log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
|
|
}
|
|
if cipher == nil {
|
|
log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
|
|
} else {
|
|
log.Println("CalDAV encryption configured (AES-256-GCM)")
|
|
}
|
|
|
|
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
|
bindingSvc := services.NewCalendarBindingService(pool)
|
|
targetSvc := services.NewAppointmentTargetService(pool)
|
|
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
|
|
// Wire the push hook so user-driven mutations sync to the external
|
|
// calendar without waiting for the next 60-second tick.
|
|
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
|
|
|
baseURL := os.Getenv("PALIAD_BASE_URL")
|
|
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
|
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
|
|
|
|
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
|
|
// new "Konto direkt anlegen" path on /admin/team. The key is
|
|
// optional: when unset the client still wires (so dependents
|
|
// don't panic) but every call short-circuits with
|
|
// ErrSupabaseAdminUnavailable so the rest of the server stays
|
|
// runnable.
|
|
supabaseAdminClient := services.LoadSupabaseAdminClient()
|
|
if supabaseAdminClient.Enabled() {
|
|
log.Println("supabase admin API configured — /admin/team Add-User path active")
|
|
} else {
|
|
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
|
|
}
|
|
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
|
|
|
|
// Wire EmailTemplateService onto the MailService so DB-backed admin
|
|
// edits propagate without a process restart. The constructor is split
|
|
// from MailService creation because the DB pool isn't available yet
|
|
// at the point we build mailSvc above.
|
|
emailTemplateSvc := services.NewEmailTemplateService(pool)
|
|
mailSvc.SetTemplateService(emailTemplateSvc)
|
|
|
|
eventTypeSvc := services.NewEventTypeService(pool, users)
|
|
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
|
partySvc := services.NewPartyService(pool, projectSvc)
|
|
// t-paliad-238 — dedicated submission draft editor. The variable
|
|
// bag service is shared between the renderer (export) and the
|
|
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
|
|
// (commits 3677c81 + 1765d5e + 8ea3509).
|
|
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
|
submissionRenderer := services.NewSubmissionRenderer()
|
|
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
|
// t-paliad-225 Slice A — user-authored checklist templates.
|
|
// Slice B adds checklist_shares grants + admin promotion.
|
|
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
|
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
|
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
|
svcBundle = &handlers.Services{
|
|
Pool: pool,
|
|
Project: projectSvc,
|
|
Team: teamSvc,
|
|
PartnerUnit: partnerUnitSvc,
|
|
Party: partySvc,
|
|
SubmissionDraft: submissionDraftSvc,
|
|
Deadline: deadlineSvc,
|
|
Appointment: appointmentSvc,
|
|
CalDAV: caldavSvc,
|
|
CalDAVBindings: bindingSvc,
|
|
Rules: rules,
|
|
Calculator: services.NewDeadlineCalculator(holidays),
|
|
Users: users,
|
|
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
|
|
EventDeadline: services.NewEventDeadlineService(
|
|
pool,
|
|
services.NewDeadlineCalculator(holidays),
|
|
holidays,
|
|
courts,
|
|
services.NewFristenrechnerService(rules, holidays, courts),
|
|
),
|
|
EventTrigger: services.NewEventTriggerService(pool, rules, holidays, courts),
|
|
RuleEditor: services.NewRuleEditorService(pool, rules),
|
|
Courts: courts,
|
|
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
|
EventCategory: nil, // wired below; cross-link order matters
|
|
EventType: eventTypeSvc,
|
|
Dashboard: services.NewDashboardService(pool, users),
|
|
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
|
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
|
|
ChecklistCatalog: checklistCatalogSvc,
|
|
ChecklistTemplate: checklistTemplateSvc,
|
|
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
|
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
|
Mail: mailSvc,
|
|
Invite: inviteSvc,
|
|
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
|
Audit: services.NewAuditService(pool),
|
|
EmailTemplate: emailTemplateSvc,
|
|
Link: services.NewLinkService(pool),
|
|
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
|
|
Approval: services.NewApprovalService(pool, users),
|
|
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
|
|
UserView: services.NewUserViewService(pool),
|
|
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
|
Pin: services.NewPinService(pool, projectSvc),
|
|
CardLayout: services.NewCardLayoutService(pool),
|
|
DashboardLayout: services.NewDashboardLayoutService(pool),
|
|
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
|
|
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
|
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
|
// is captured into __meta of every export and printed in the
|
|
// embedded README.
|
|
Export: services.NewExportService(pool, branding.Name),
|
|
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
|
|
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
|
|
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
|
|
Scenario: services.NewScenarioService(pool, projectSvc, rules),
|
|
}
|
|
|
|
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
|
// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
|
|
// directory). Without it the /admin/backups handlers return 503
|
|
// in the same shape as Paliadin's gate. The directory is created
|
|
// (0700) on first use; a malformed path fails fast at boot so
|
|
// misconfig surfaces before the server starts taking traffic.
|
|
if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
|
|
store, err := services.NewLocalDiskStore(exportDir)
|
|
if err != nil {
|
|
log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
|
|
}
|
|
svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
|
|
log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
|
|
} else {
|
|
log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
|
|
}
|
|
|
|
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
|
// for the inbox-approvals widget. Done post-construction to avoid
|
|
// a circular constructor dependency (ApprovalService doesn't need
|
|
// the dashboard, and DashboardService can render its other widgets
|
|
// without approvals — so keeping this a setter keeps both
|
|
// constructors simple).
|
|
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
|
|
// Slice C wires PinService into DashboardService for the
|
|
// pinned-projects widget. Pin pre-dates t-paliad-219; no new
|
|
// schema, no circular dependency (Pin doesn't know about the
|
|
// dashboard).
|
|
svcBundle.Dashboard.SetPinService(svcBundle.Pin)
|
|
// Slice C wires the firm-wide dashboard default into the
|
|
// per-user layout service so GetOrSeed/ResetToDefault prefer
|
|
// the admin-set firm default over the code-resident factory.
|
|
// Nil-safe: empty firm row falls back to the factory layout.
|
|
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
|
|
|
|
// t-paliad-230 — submission generator (format-only). No
|
|
// service wiring needed: handlers/submissions.go reuses the
|
|
// existing files.go HL Patents Style cache and calls
|
|
// services.ConvertDotmToDocx (stateless function).
|
|
|
|
// Paliadin backend selection.
|
|
//
|
|
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
|
|
// "aichat" → AichatPaliadinService (HTTP client of the
|
|
// centralized aichat backend on mRiver,
|
|
// shipped in m/mAi#207 Phase A).
|
|
// "legacy" / unset / etc → fall through to the pre-aichat tree:
|
|
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh shim)
|
|
// else: local tmux available → LocalPaliadinService (PoC path)
|
|
// else → DisabledPaliadinService
|
|
//
|
|
// The aichat path is opt-in for the migration window so a flip
|
|
// back is one env-var change. Once aichat soaks, legacy can be
|
|
// retired in a follow-up slice.
|
|
//
|
|
// All four implementations satisfy services.Paliadin; the per-
|
|
// request handler gate (requirePaliadinOwner) is unchanged.
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("PALIADIN_BACKEND"))) {
|
|
case "aichat":
|
|
cfg, err := buildAichatPaliadinConfig(jwtSecret)
|
|
if err != nil {
|
|
log.Fatalf("paliadin: aichat config: %v", err)
|
|
}
|
|
svcBundle.Paliadin = services.NewAichatPaliadinService(pool, users, cfg)
|
|
log.Printf("paliadin: aichat mode → %s persona=%s (owner=%s, rls=%s)",
|
|
cfg.BaseURL, cfg.Persona, services.PaliadinOwnerEmail,
|
|
rlsModeLabel(cfg.JWTSecret))
|
|
default:
|
|
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
|
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
|
if err != nil {
|
|
log.Fatalf("paliadin: remote config: %v", err)
|
|
}
|
|
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
|
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
|
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
|
} else if _, err := exec.LookPath("tmux"); err == nil {
|
|
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
|
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
|
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
|
// Late-response janitor — patches rows when Claude writes the
|
|
// response file after the 60 s pollForResponse window expires.
|
|
// Runs for the process lifetime; cleaned up when bgCtx
|
|
// cancels on SIGTERM.
|
|
local.StartJanitor(bgCtx)
|
|
svcBundle.Paliadin = local
|
|
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
|
} else {
|
|
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
|
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
|
services.PaliadinOwnerEmail)
|
|
}
|
|
}
|
|
// Wire ApprovalService into the entity services so Create / Update /
|
|
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
|
// Without this wiring, the policies and request tables exist but no
|
|
// mutation path consults them — paliad behaves as before.
|
|
deadlineSvc.SetApprovalService(svcBundle.Approval)
|
|
appointmentSvc.SetApprovalService(svcBundle.Approval)
|
|
// v3 (t-paliad-133): wire EventCategoryService and cross-link
|
|
// it into DeadlineSearchService so ?event_category_slug= can
|
|
// resolve to a concept-id allow-list during search.
|
|
eventCategorySvc := services.NewEventCategoryService(pool)
|
|
svcBundle.EventCategory = eventCategorySvc
|
|
svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
|
|
log.Println("Phase B services initialised")
|
|
|
|
// Spawn background goroutines: CalDAV sync (one per enabled user)
|
|
// and the hourly reminder scanner. Both live for the process
|
|
// lifetime; the signal-scoped context cleans them up on SIGTERM.
|
|
if err := caldavSvc.Start(bgCtx); err != nil {
|
|
log.Printf("CalDAV start: %v", err)
|
|
}
|
|
reminderSvc.Start(bgCtx)
|
|
go func() {
|
|
<-bgCtx.Done()
|
|
log.Println("background services: shutdown signal received")
|
|
caldavSvc.Stop()
|
|
}()
|
|
} else {
|
|
log.Println("DATABASE_URL not set — matter-management endpoints will return 503")
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
handlers.Register(mux, client, giteaToken, svcBundle)
|
|
|
|
log.Printf("paliad server starting on :%s", port)
|
|
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// buildPaliadinRemoteConfig assembles a RemotePaliadinConfig from
|
|
// environment variables, materialising the SSH private key and
|
|
// known_hosts blobs into chmod-600/644 tmpfiles for OpenSSH to read.
|
|
//
|
|
// The blobs travel as Dokploy secrets (multi-line env vars). We never
|
|
// persist them to disk — tmpfiles live for the process lifetime in
|
|
// /tmp and disappear on container restart. Re-creating them every boot
|
|
// is fine; the keys themselves rotate independently via Dokploy
|
|
// secret updates.
|
|
//
|
|
// Required: PALIADIN_REMOTE_HOST, PALIADIN_SSH_PRIVATE_KEY, PALIADIN_KNOWN_HOSTS.
|
|
// Optional: PALIADIN_REMOTE_USER (default "m"), PALIADIN_REMOTE_PORT
|
|
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
|
|
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
|
|
cfg := services.RemotePaliadinConfig{
|
|
SSHHost: host,
|
|
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
|
|
SSHPort: 22022,
|
|
SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
|
|
}
|
|
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
|
|
n, err := strconv.Atoi(p)
|
|
if err != nil || n <= 0 || n > 65535 {
|
|
return cfg, fmt.Errorf("PALIADIN_REMOTE_PORT %q: not a valid port", p)
|
|
}
|
|
cfg.SSHPort = n
|
|
}
|
|
|
|
// Dokploy stores compose env vars in a single-line .env file: multi-line
|
|
// PEM bodies get truncated to the first line. Base64-encode the
|
|
// private key in the secret to survive that round-trip; here we
|
|
// detect base64 vs raw PEM and decode either way.
|
|
keyBlob, err := decodePaliadinPrivateKey(os.Getenv("PALIADIN_SSH_PRIVATE_KEY"))
|
|
if err != nil {
|
|
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
|
|
}
|
|
keyPath, err := writeSecretFile("paliadin-id_ed25519-", keyBlob, 0o600)
|
|
if err != nil {
|
|
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
|
|
}
|
|
if keyPath == "" {
|
|
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_SSH_PRIVATE_KEY empty")
|
|
}
|
|
cfg.SSHKeyPath = keyPath
|
|
|
|
knownHostsPath, err := writeSecretFile("paliadin-known_hosts-", os.Getenv("PALIADIN_KNOWN_HOSTS"), 0o644)
|
|
if err != nil {
|
|
return cfg, fmt.Errorf("PALIADIN_KNOWN_HOSTS: %w", err)
|
|
}
|
|
if knownHostsPath == "" {
|
|
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_KNOWN_HOSTS empty")
|
|
}
|
|
cfg.KnownHostsPath = knownHostsPath
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// decodePaliadinPrivateKey accepts either a raw PEM (multi-line) or a
|
|
// base64-encoded PEM. Returns the raw PEM bytes ready to write to a
|
|
// keyfile. Empty input → ("", nil) so the caller can distinguish
|
|
// "secret not set" from "decode failed".
|
|
//
|
|
// Why base64: Dokploy stores compose env vars in a one-line-per-key
|
|
// .env file, which silently truncates multi-line values to their first
|
|
// line. Empirically, a multi-line `-----BEGIN OPENSSH PRIVATE KEY-----`
|
|
// arrived inside the container as just the BEGIN header (36 bytes).
|
|
// Base64-encoding the key in the Dokploy secret survives that
|
|
// round-trip. We still accept raw PEM for local-dev convenience.
|
|
func decodePaliadinPrivateKey(blob string) (string, error) {
|
|
blob = strings.TrimSpace(blob)
|
|
if blob == "" {
|
|
return "", nil
|
|
}
|
|
// Raw PEM: starts with ----- and contains a newline. Use as-is.
|
|
if strings.HasPrefix(blob, "-----") && strings.Contains(blob, "\n") {
|
|
return blob + "\n", nil
|
|
}
|
|
// Otherwise treat as base64. Strip any whitespace OpenSSH keygen
|
|
// helpers might insert (line breaks every 64 chars in some tools).
|
|
clean := strings.Map(func(r rune) rune {
|
|
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, blob)
|
|
decoded, err := base64.StdEncoding.DecodeString(clean)
|
|
if err != nil {
|
|
return "", fmt.Errorf("not raw PEM (no newline) and base64 decode failed: %w", err)
|
|
}
|
|
out := string(decoded)
|
|
if !strings.HasPrefix(out, "-----BEGIN") {
|
|
return "", fmt.Errorf("decoded body does not look like a PEM key (no -----BEGIN prefix)")
|
|
}
|
|
if !strings.HasSuffix(out, "\n") {
|
|
out += "\n"
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// writeSecretFile writes blob to a tmpfile with the given mode and
|
|
// returns its path. Returns ("", nil) when blob is empty so callers
|
|
// can distinguish "not set" from real I/O errors.
|
|
func writeSecretFile(prefix, blob string, mode os.FileMode) (string, error) {
|
|
if blob == "" {
|
|
return "", nil
|
|
}
|
|
f, err := os.CreateTemp("", prefix+"*")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := f.WriteString(blob); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(f.Name())
|
|
return "", err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.Chmod(f.Name(), mode); err != nil {
|
|
return "", err
|
|
}
|
|
return f.Name(), nil
|
|
}
|
|
|
|
func cmpOr(s, fallback string) string {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// buildAichatPaliadinConfig assembles an AichatPaliadinConfig from the
|
|
// environment for PALIADIN_BACKEND=aichat (t-paliad-194 / m/paliad#38).
|
|
//
|
|
// Required:
|
|
//
|
|
// AICHAT_URL — service root (e.g. http://100.99.98.203:8765).
|
|
// AICHAT_TOKEN — raw bearer token paliad's app_id is registered
|
|
// under in aichat's tokens.yaml (see m/mAi
|
|
// docs/reference/aichat-deploy.md).
|
|
//
|
|
// Optional:
|
|
//
|
|
// AICHAT_PERSONA — persona id; defaults to "paliadin".
|
|
//
|
|
// jwtSecret comes from the same SUPABASE_JWT_SECRET that auth.NewClient
|
|
// already requires at boot — never empty when we reach this code path.
|
|
// It's threaded in so the aichat service can mint per-turn user-scoped
|
|
// JWTs (folded-in t-paliad-156 work).
|
|
func buildAichatPaliadinConfig(jwtSecret string) (services.AichatPaliadinConfig, error) {
|
|
cfg := services.AichatPaliadinConfig{
|
|
BaseURL: strings.TrimRight(os.Getenv("AICHAT_URL"), "/"),
|
|
BearerToken: os.Getenv("AICHAT_TOKEN"),
|
|
Persona: cmpOr(os.Getenv("AICHAT_PERSONA"), services.DefaultAichatPersona),
|
|
JWTSecret: []byte(jwtSecret),
|
|
}
|
|
if cfg.BaseURL == "" {
|
|
return cfg, fmt.Errorf("AICHAT_URL must be set when PALIADIN_BACKEND=aichat")
|
|
}
|
|
if cfg.BearerToken == "" {
|
|
return cfg, fmt.Errorf("AICHAT_TOKEN must be set when PALIADIN_BACKEND=aichat")
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// rlsModeLabel labels the boot log so the operator can confirm whether
|
|
// the per-user JWT mint is active. "per-user" means we're handing the
|
|
// claude pane user-scoped claims; "service-role" means we're not (no
|
|
// SUPABASE_JWT_SECRET) and the skill will reject queries rather than
|
|
// run as supabase_admin.
|
|
func rlsModeLabel(secret []byte) string {
|
|
if len(secret) == 0 {
|
|
return "service-role"
|
|
}
|
|
return "per-user"
|
|
}
|