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-313 Composer Slice A — base catalog + section seeding. // AttachComposer wires both into the draft service so Create // seeds base_id + submission_sections rows on new drafts. v1 // fallback path stays active for pre-Composer drafts (base_id // NULL, no section rows). submissionBaseSvc := services.NewBaseService(pool) submissionSectionSvc := services.NewSectionService(pool) submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name) // t-paliad-313 Slice B — render-pipeline assembler. Reuses the // existing SubmissionRenderer for the final placeholder pass so // the {{rule.X}} alias contract stays preserved inside the // composed body. submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer) // t-paliad-315 Slice C — building-block library. submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name) // 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, SubmissionBase: submissionBaseSvc, SubmissionSection: submissionSectionSvc, SubmissionComposer: submissionComposerSvc, SubmissionBuildingBlock: submissionBuildingBlockSvc, 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), // m/paliad#149 Phase 2 P0 (mig 154) — per-project scenario_flags // SSoT. Drives Verfahrensablauf + Mode B result-view conditional // rendering and per-rule selection state (`rule:` keys). ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc), // t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder. // CRUD over the new normalised scenarios + scenario_proceedings // + scenario_events + scenario_shares tables. ScenarioBuilder: services.NewScenarioBuilderService(pool), } // 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) // Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules // dropped. The B.2 dual-write drift-check loop is retired — the // procedural_events / sequencing_rules / legal_sources tables // are now the source of truth and there is no parallel side to // compare against. Pre-drop drift was verified clean in mig 140. 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" }