mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
d76f06d199
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938) RegisterPushDevice is one transaction: delete rows on either unique key, evict the oldest at the 20-device cap, insert. RotateUserSessions mints the replacement, rebinds the caller's own devices, deletes the other sessions, and fails closed to revoke-all. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * feat(server): push device and notification-settings routes, push_available (TASK-3938) Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT /me/notification-settings, push_available on /auth/session, PAD_PUSH_GATEWAY_URL, and all credential-change rotations through RotateUserSessions. docs/api.md updated. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(server): classify GET /me/notification-settings in the compress census (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1) * fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1) * test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938) Reuse sessions across rounds and widen the count-to-insert window with a test-owned slow insert trigger, so removing the user-row lock overshoots the cap on every run. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1) * fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
1522 lines
66 KiB
Go
1522 lines
66 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
neturl "net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
pad "github.com/PerpetualSoftware/pad"
|
|
"github.com/PerpetualSoftware/pad/internal/accesskick"
|
|
"github.com/PerpetualSoftware/pad/internal/attachments"
|
|
"github.com/PerpetualSoftware/pad/internal/cli"
|
|
"github.com/PerpetualSoftware/pad/internal/cmdhelp"
|
|
"github.com/PerpetualSoftware/pad/internal/config"
|
|
"github.com/PerpetualSoftware/pad/internal/decision"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/billing"
|
|
"github.com/PerpetualSoftware/pad/internal/collab"
|
|
"github.com/PerpetualSoftware/pad/internal/email"
|
|
"github.com/PerpetualSoftware/pad/internal/events"
|
|
"github.com/PerpetualSoftware/pad/internal/logging"
|
|
mcpserver "github.com/PerpetualSoftware/pad/internal/mcp"
|
|
"github.com/PerpetualSoftware/pad/internal/metrics"
|
|
"github.com/PerpetualSoftware/pad/internal/redisdial"
|
|
"github.com/PerpetualSoftware/pad/internal/redisns"
|
|
"github.com/PerpetualSoftware/pad/internal/server"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
"github.com/PerpetualSoftware/pad/internal/watchevents"
|
|
"github.com/PerpetualSoftware/pad/internal/webhooks"
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
func serveCmd() *cobra.Command {
|
|
var host string
|
|
var port int
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "start",
|
|
Short: "Start the Pad API server",
|
|
Example: ` pad server start --port 7777`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := getConfig()
|
|
|
|
// Initialize structured logging
|
|
logLevel := os.Getenv("PAD_LOG_LEVEL")
|
|
if logLevel == "" {
|
|
logLevel = "info"
|
|
}
|
|
logFormat := os.Getenv("PAD_LOG_FORMAT")
|
|
if logFormat == "" {
|
|
logFormat = "text"
|
|
}
|
|
logging.Setup(logLevel, logFormat)
|
|
|
|
if cmd.Flags().Changed("host") {
|
|
cfg.Host = host
|
|
}
|
|
if cmd.Flags().Changed("port") {
|
|
cfg.Port = port
|
|
}
|
|
|
|
// Bind FIRST, before anything touches shared state (TASK-3620). A
|
|
// start that loses the port must exit without having opened the
|
|
// database, run migrations or started a background worker: the CLI
|
|
// spawned 206 such losing starts in two days (a rate-limited health
|
|
// probe read as "no server"), and each one opened the live database
|
|
// and started its outbox drain, op-log recovery and decision and
|
|
// reminder ticks before its bind failed.
|
|
ln, lerr := net.Listen("tcp", cfg.Addr())
|
|
if lerr != nil {
|
|
return lerr
|
|
}
|
|
// Serve closes it on the normal path; this covers every early
|
|
// return between here and Serve. A second Close is harmless.
|
|
defer func() { _ = ln.Close() }()
|
|
|
|
// We hold the port, so the PID file is ours to claim. It must
|
|
// follow the bind (BUG-2965, codex round 2): the file names the
|
|
// process `pad server stop` will signal, and a start that wrote it
|
|
// before losing the race would, on its way out, remove the file
|
|
// the winner relies on. Claimed here rather than after startup, so
|
|
// `stop` can also address a server that is still migrating.
|
|
releasePIDFile := cli.ClaimPIDFile(cfg.PIDFile())
|
|
// Backstop for the paths that never reach the shutdown sequence
|
|
// (an early return, or Serve returning an error). Safe to call
|
|
// twice: it only removes a file that still names us, and a second
|
|
// call finds nothing.
|
|
defer releasePIDFile()
|
|
|
|
// Label pre-migration snapshots with this build's version, and
|
|
// honor the schema-ahead-guard escape hatch (--force or
|
|
// PAD_ALLOW_SCHEMA_AHEAD=1). See internal/store/migration_guard.go.
|
|
store.BinaryVersion = version
|
|
forceMigrate, _ := cmd.Flags().GetBool("force")
|
|
if forceMigrate || truthyEnv(os.Getenv("PAD_ALLOW_SCHEMA_AHEAD")) {
|
|
store.AllowSchemaAhead = true
|
|
}
|
|
|
|
// Open database (SQLite default, PostgreSQL via PAD_DB_DRIVER)
|
|
var s *store.Store
|
|
var err error
|
|
dbDriver := os.Getenv("PAD_DB_DRIVER")
|
|
if dbDriver == "postgres" {
|
|
pgURL := os.Getenv("PAD_DATABASE_URL")
|
|
if pgURL == "" {
|
|
return fmt.Errorf("PAD_DATABASE_URL is required when PAD_DB_DRIVER=postgres")
|
|
}
|
|
s, err = store.NewPostgres(pgURL)
|
|
if err != nil {
|
|
return fmt.Errorf("open postgres: %w", err)
|
|
}
|
|
slog.Info("Database using PostgreSQL")
|
|
} else {
|
|
s, err = store.New(cfg.DBPath)
|
|
if err != nil {
|
|
return fmt.Errorf("open database: %w", err)
|
|
}
|
|
slog.Info("Database using SQLite", "path", cfg.DBPath)
|
|
}
|
|
defer s.Close()
|
|
|
|
// Configure encryption key for sensitive fields (TOTP secrets).
|
|
// EnsureEncryptionKey resolves the key from (in order) env,
|
|
// config file, the persisted ~/.pad/encryption.key file, or a
|
|
// freshly-generated one written with 0600.
|
|
//
|
|
// Auto-generation is scoped to non-Postgres deployments.
|
|
// SQLite is single-instance by construction, so a generated
|
|
// local key is always correct. Postgres deployments may run
|
|
// multiple replicas behind a load balancer with separate
|
|
// filesystems — each replica would generate its own key and
|
|
// cross-replica decryption would fail. Operators MUST
|
|
// configure PAD_ENCRYPTION_KEY explicitly for Postgres; the
|
|
// repo's docker-compose.yml and deploy/k8s/secret.yaml both
|
|
// require it.
|
|
allowGenerate := dbDriver != "postgres"
|
|
if err := cfg.EnsureEncryptionKey(allowGenerate); err != nil {
|
|
return fmt.Errorf("encryption key: %w", err)
|
|
}
|
|
keyBytes, err := hex.DecodeString(cfg.EncryptionKey)
|
|
if err != nil || len(keyBytes) != 32 {
|
|
return fmt.Errorf("encryption key must be a 64-character hex string (32 bytes / 256 bits); got source=%q len=%d", cfg.EncryptionKeySource, len(cfg.EncryptionKey))
|
|
}
|
|
s.SetEncryptionKey(keyBytes)
|
|
switch cfg.EncryptionKeySource {
|
|
case "generated":
|
|
// Loud warning: operator should back this up and/or promote
|
|
// to a managed secret store in production. Logging at WARN
|
|
// level so it shows up in typical deployments that only
|
|
// surface warnings-and-above.
|
|
slog.Warn("Encryption key generated and persisted — back up the file, or set PAD_ENCRYPTION_KEY explicitly",
|
|
"path", cfg.EncryptionKeyFile())
|
|
case "file":
|
|
slog.Info("Encryption key loaded from file", "path", cfg.EncryptionKeyFile())
|
|
case "env":
|
|
slog.Info("Encryption key loaded from PAD_ENCRYPTION_KEY env var")
|
|
default:
|
|
slog.Info("Encryption key configured", "source", cfg.EncryptionKeySource)
|
|
}
|
|
|
|
// Backfill: encrypt any plaintext TOTP secrets.
|
|
if n, err := s.BackfillEncryptTOTPSecrets(); err != nil {
|
|
return fmt.Errorf("backfill TOTP encryption: %w", err)
|
|
} else if n > 0 {
|
|
slog.Info("Encrypted plaintext TOTP secrets", "count", n)
|
|
}
|
|
|
|
// Backfill: encrypt any plaintext webhook HMAC secrets (BUG-2057).
|
|
if n, err := s.EncryptWebhookSecretsAtRest(); err != nil {
|
|
return fmt.Errorf("encrypt webhook secrets at rest: %w", err)
|
|
} else if n > 0 {
|
|
slog.Info("Encrypted plaintext webhook secrets", "count", n)
|
|
}
|
|
|
|
// Backfill: populate item_wiki_links from existing item bodies
|
|
// (PLAN-1593 / TASK-1594). Idempotent — items already indexed
|
|
// at write time get a cheap EXISTS-skip; only newly-introduced
|
|
// items (e.g. from a fresh migration) actually parse. Failures
|
|
// here aren't fatal: a partial run leaves the table consistent
|
|
// and the next boot picks up where this one stopped.
|
|
if bf, err := s.BackfillWikiLinks(); err != nil {
|
|
slog.Warn("wiki-link backfill failed; non-fatal", "error", err)
|
|
} else if bf.ItemsIndexed > 0 {
|
|
slog.Info("Wiki-link backfill complete",
|
|
"items_scanned", bf.ItemsScanned,
|
|
"items_indexed", bf.ItemsIndexed,
|
|
"links_inserted", bf.LinksInserted,
|
|
"errors", bf.Errors,
|
|
)
|
|
} else if bf.ItemsScanned > 0 {
|
|
// Steady-state: scanned but nothing new to do.
|
|
slog.Debug("Wiki-link backfill no-op",
|
|
"items_scanned", bf.ItemsScanned, "errors", bf.Errors)
|
|
}
|
|
|
|
// The same job for `relation` field values (PLAN-2857 U5).
|
|
// Non-fatal for the same reason: a database without the reverse
|
|
// index is a database missing a "Referenced by" section, not a
|
|
// broken one, and refusing to boot over it would be the worse
|
|
// failure.
|
|
if rb, err := s.BackfillRelationLinks(); err != nil {
|
|
slog.Warn("relation-link backfill failed; non-fatal", "error", err)
|
|
} else if rb.LinksInserted > 0 {
|
|
slog.Info("Relation-link backfill complete",
|
|
"items_scanned", rb.ItemsScanned,
|
|
"links_inserted", rb.LinksInserted,
|
|
)
|
|
} else if !rb.Skipped {
|
|
slog.Debug("Relation-link backfill found nothing to index",
|
|
"items_scanned", rb.ItemsScanned)
|
|
}
|
|
|
|
// comment_mentions for comments written before the table existed
|
|
// (TASK-3917). Non-fatal: a missing index row means a missed
|
|
// mention routing, not a broken database.
|
|
if mb, err := s.BackfillCommentMentions(); err != nil {
|
|
slog.Warn("comment-mention backfill failed; non-fatal", "error", err)
|
|
} else if !mb.Skipped {
|
|
slog.Info("Comment-mention backfill complete", "comments_scanned", mb.CommentsScanned)
|
|
}
|
|
|
|
// Classify op-log rows written before migration 091 (BUG-3124), so
|
|
// content_state stops reporting SyncStep1 frames and byte-identical
|
|
// re-sends as a pending edit. Non-fatal: until it finishes, an
|
|
// unclassified row counts as content-bearing, which is the old
|
|
// behaviour, not a broken one.
|
|
if yb, err := s.BackfillYjsContentBearing(); err != nil {
|
|
slog.Warn("op-log content_bearing backfill failed; non-fatal", "error", err)
|
|
} else if yb.RowsClassified > 0 {
|
|
slog.Info("Op-log content_bearing backfill complete",
|
|
"rows_classified", yb.RowsClassified,
|
|
"rows_non_content", yb.RowsNonContent,
|
|
)
|
|
}
|
|
|
|
// Give conventions and playbooks made before built-in origins were
|
|
// recorded the origin their exact title names (TASK-3462 U4), once
|
|
// per instance. Non-fatal: an unadopted item simply offers no
|
|
// update, which is how it behaved before.
|
|
if la, err := s.AdoptLegacyBuiltins(); err != nil {
|
|
slog.Warn("legacy built-in adoption failed; non-fatal", "error", err)
|
|
} else if !la.Skipped {
|
|
slog.Info("Legacy built-in adoption complete", "considered", la.Considered, "adopted", la.Adopted)
|
|
}
|
|
|
|
// Rewrite attachment names stored with Bidi_Control characters
|
|
// (BUG-3153), which display an extension the file does not have.
|
|
// Each rewrite is logged with its old name; the count is logged
|
|
// on every boot, 0 included, so an instance's exposure is visible.
|
|
// Non-fatal: an unrewritten row still downloads under its served
|
|
// name, which drops the characters.
|
|
if bb, err := s.BackfillBidiAttachmentFilenames(); err != nil {
|
|
slog.Warn("attachment bidi filename backfill failed; non-fatal", "error", err,
|
|
"rows_rewritten_before_error", bb.RowsRewritten)
|
|
} else {
|
|
slog.Info("Attachment bidi filename backfill complete", "rows_rewritten", bb.RowsRewritten)
|
|
}
|
|
|
|
// Copy a convention's free-text `role` field into its empty
|
|
// agent-role column where it names a live role (PLAN-3598): the
|
|
// column is what role-scoped reads use. Each copy logs itself; a
|
|
// value naming no role never self-heals, so it is summarized per
|
|
// boot with details for the first few rows only. Non-fatal: an
|
|
// uncopied convention is just unscoped, as before.
|
|
if cr, err := s.BackfillConventionRoles(); err != nil {
|
|
slog.Warn("convention role backfill failed; non-fatal", "error", err,
|
|
"copied_before_error", cr.Copied)
|
|
} else {
|
|
slog.Info("Convention role backfill complete", "copied", cr.Copied,
|
|
"invalid", cr.Invalid, "raced", cr.Raced)
|
|
for _, inv := range cr.InvalidSample {
|
|
slog.Warn("convention role field names no role in its workspace; left as is",
|
|
"workspace_id", inv.WorkspaceID, "item_id", inv.ItemID, "value", inv.Value)
|
|
}
|
|
}
|
|
|
|
// Persist an id on every implementation note / decision entry
|
|
// stored without a usable, unique one (BUG-2788), so a timeline
|
|
// entry's identity no longer depends on its position in the array.
|
|
// Silent by ruling (no seq, no event); the premise is on the
|
|
// function. Non-fatal: an unrepaired entry still renders, on the
|
|
// timeline's positional fallback, as it always has.
|
|
if sb, err := s.BackfillStructuredEntryIDs(); err != nil {
|
|
slog.Warn("structured entry id backfill failed; non-fatal", "error", err,
|
|
"rows_repaired_before_error", sb.RowsRepaired)
|
|
} else {
|
|
slog.Info("Structured entry id backfill complete", "rows_repaired", sb.RowsRepaired)
|
|
}
|
|
|
|
// Backfill: populate status_transitions from the historical
|
|
// activity log (PLAN-1628 / TASK-1637). Idempotent — gated on an
|
|
// empty table, so it replays history exactly once on the first
|
|
// boot after migration 063/042 and short-circuits thereafter (the
|
|
// write-path hook keeps the table populated). Non-fatal: a failed
|
|
// run leaves live data intact and only affects the pre-upgrade
|
|
// report window.
|
|
if st, err := s.BackfillStatusTransitions(); err != nil {
|
|
slog.Warn("status-transition backfill failed; non-fatal", "error", err)
|
|
} else if !st.Skipped && st.Inserted > 0 {
|
|
slog.Info("Status-transition backfill complete",
|
|
"activities_scanned", st.ActivitiesScanned,
|
|
"inserted", st.Inserted,
|
|
"errors", st.Errors,
|
|
)
|
|
}
|
|
|
|
// Auto-upgrade hook removed in IDEA-1479. The historical
|
|
// SeedDefaultCollections backfill was incompatible with templates
|
|
// that intentionally diverge from Defaults() (e.g. `blank`). Future
|
|
// changes that need to backfill collections into existing workspaces
|
|
// should be implemented as explicit migrations in
|
|
// internal/store/migrations/.
|
|
|
|
srv := server.New(s)
|
|
srv.SetVersion(version, commit, buildTime)
|
|
// Protected share-link image signatures, on every install
|
|
// (BUG-3305). keyBytes always exists here: EnsureEncryptionKey
|
|
// generates one on SQLite and refuses to start Postgres without
|
|
// one. Until BUG-3305 this was cloud-only, so a self-hosted
|
|
// password or require-auth share page showed a placeholder for
|
|
// every image. The same key also keys encryption at rest, OAuth
|
|
// and claim codes. The share-asset MAC input carries its own
|
|
// label (shareAssetSigDomain) and a different shape from the
|
|
// claim-code input, and each verifier accepts only its own
|
|
// format (a full hex MAC here, a six-digit code there).
|
|
srv.SetShareAssetSecret(keyBytes)
|
|
// PublicLinkBaseURL — not BaseURL() — so the server picks up
|
|
// PUBLIC_URL from the deployment env (BUG-899). BaseURL() is
|
|
// CLI-client-only and would leak the same env var into local
|
|
// CLI API routing on developer hosts.
|
|
srv.SetBaseURL(cfg.PublicLinkBaseURL())
|
|
srv.SetCORSOrigins(cfg.CORSOrigins)
|
|
srv.SetSecureCookies(cfg.SecureCookies)
|
|
|
|
// MCP addressing and the PAD_MCP_ENABLED override (PLAN-2310 DR-2,
|
|
// DR-3), on every install. An unusable value never stops startup,
|
|
// because PAD_URL also serves the CLI: it is logged, and it keeps
|
|
// MCP unavailable until fixed.
|
|
mcpEndpoints := cfg.ResolveMCPEndpoints()
|
|
for _, problem := range mcpEndpoints.Problems() {
|
|
slog.Warn("mcp: configuration value not usable", "problem", problem)
|
|
}
|
|
if cfg.MCPEnabledEnvRaw != "" && cfg.MCPEnabledEnv == nil {
|
|
slog.Warn("mcp: PAD_MCP_ENABLED is not true/false; ignoring it", "value", cfg.MCPEnabledEnvRaw)
|
|
}
|
|
srv.SetMCPConfig(mcpEndpoints, cfg.MCPEnabledEnv)
|
|
// TASK-1069: MCP turned on (setting or PAD_MCP_ENABLED) but not
|
|
// servable says so once, loudly, naming what to set. Self-host
|
|
// only: cloud refuses to start instead, below.
|
|
if !cfg.IsCloudServer() {
|
|
if reason := srv.MCPBlockedReason(); reason != "" {
|
|
slog.Error("mcp: MCP is turned on but cannot be served", "reason", reason)
|
|
}
|
|
}
|
|
srv.SetTrustedProxies(cfg.TrustedProxies)
|
|
srv.SetMetricsToken(cfg.MetricsToken)
|
|
srv.SetIPChangeEnforce(cfg.IPChangeEnforce)
|
|
srv.SetSSELimits(cfg.SSEMaxConnections, cfg.SSEMaxPerWorkspace, cfg.SSEMaxPerUser)
|
|
// Logged at startup so the BUG-2726 re-point is visible in an
|
|
// operator's own logs: PAD_SSE_MAX_CONNECTIONS now bounds
|
|
// /api/v1/events and /api/v1/events/stream TOGETHER, where it
|
|
// used to bound only the first. Someone who tuned it for one
|
|
// endpoint should see the effective numbers without having to
|
|
// read release notes to find out anything changed.
|
|
slog.Info("Stream connection limits (PER INSTANCE — not deployment-wide; there is no shared counter)",
|
|
"per_instance", cfg.SSEMaxConnections,
|
|
"per_workspace", cfg.SSEMaxPerWorkspace,
|
|
"per_principal", cfg.SSEMaxPerUser,
|
|
"per_instance_and_per_principal_cover", "/api/v1/events + /api/v1/events/stream + MCP streams (GET streams on /mcp and /mcp/chatgpt; subscriptions/listen once a modern protocol version is served)",
|
|
"per_workspace_covers", "/api/v1/events")
|
|
srv.SetCollabLimits(cfg.CollabMaxPerUser)
|
|
// Test builds only (TASK-3415): nil in every default build, so
|
|
// no environment variable can widen what a shipped binary trusts.
|
|
if e2eConfigureServer != nil {
|
|
if err := e2eConfigureServer(srv); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
slog.Info("Collab connection limit (PER INSTANCE — not deployment-wide)",
|
|
"per_principal", cfg.CollabMaxPerUser,
|
|
"covers", "/api/v1/collab/{itemID}")
|
|
|
|
// MCP tool-surface descriptor endpoint (PLAN-1888 / TASK-1891).
|
|
// Inject the cycle-free catalog→JSON serializer so the authed
|
|
// GET /api/v1/mcp/tool-surface route can serve it. Wired here
|
|
// (not in the cloud block) because the browser-side WebMCP layer
|
|
// needs the descriptors on BOTH cloud and self-host. Mirrors the
|
|
// SetMCPTransport injection: internal/server can't import
|
|
// internal/mcp (cycle), so cmd/pad — which imports both — hands
|
|
// the serializer down. Must be set before setupRouter runs.
|
|
srv.SetToolSurfaceHandler(mcpserver.ToolSurfaceJSON)
|
|
|
|
// The remote MCP capability, constructed on every install and
|
|
// served only while it is available (PLAN-2310 DR-4, DR-5).
|
|
if err := wireMCP(cmd, srv, s, mcpEndpoints, keyBytes); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Billing mode (TASK-3577): PAD_BILLING=auto|off, with the
|
|
// deprecated PAD_BILLING_AVAILABLE read for one release. In auto,
|
|
// billing is offered only while the sidecar says it can be bought
|
|
// (wired below, in cloud mode with a sidecar); off forces "coming
|
|
// soon". With no sidecar the offer source stays nil: never offered.
|
|
padBilling, padBillingSet := os.LookupEnv("PAD_BILLING")
|
|
legacyBilling, legacyBillingSet := os.LookupEnv("PAD_BILLING_AVAILABLE")
|
|
billingMode, billingWarning := billing.ResolveMode(padBilling, padBillingSet, legacyBilling, legacyBillingSet)
|
|
if billingWarning != "" {
|
|
slog.Warn(billingWarning)
|
|
}
|
|
srv.SetBilling(billingMode, nil)
|
|
// Stops the offer refresh loop when the server returns.
|
|
billingCtx, stopBilling := context.WithCancel(context.Background())
|
|
defer stopBilling()
|
|
|
|
// Cloud-tenant mode: enable cloud-specific endpoints and
|
|
// behavior. Gated on IsCloudServer() (env-var opt-in) rather
|
|
// than IsCloud() (which is also true when a CLI user has
|
|
// picked "Cloud" as their `pad init` connection mode — that
|
|
// is a client signal, not a server-runtime signal).
|
|
if cfg.IsCloudServer() {
|
|
if cfg.CloudSecret == "" {
|
|
return fmt.Errorf("PAD_CLOUD_SECRET is required when running in cloud mode (PAD_MODE=cloud or PAD_CLOUD=true)")
|
|
}
|
|
// B7 (TASK-1932): fail fast rather than relying on the
|
|
// operator to always pair PAD_CLOUD with PAD_SECURE_COOKIES.
|
|
if err := cfg.ValidateCloudSecureCookies(); err != nil {
|
|
return err
|
|
}
|
|
// TASK-1069: no silent PAT-only /mcp on cloud.
|
|
if err := validateCloudMCPOAuth(mcpEndpoints); err != nil {
|
|
return err
|
|
}
|
|
srv.SetCloudMode(cfg.CloudSecret)
|
|
slog.Info("Cloud mode enabled")
|
|
|
|
// Reverse pad → pad-cloud client (TASK-690). Used by
|
|
// handleDeleteAccount to cancel Stripe subscriptions + delete
|
|
// the Stripe customer before the local user row is purged.
|
|
// When PAD_CLOUD_SIDECAR_URL is unset we leave the sidecar
|
|
// hook nil — a cloud deploy without Stripe billing is
|
|
// unusual but valid (e.g. a staging instance), and in that
|
|
// case there's no upstream state to cancel.
|
|
//
|
|
// Outbound secret selection:
|
|
// pad-cloud validates inbound calls against a SINGLE secret
|
|
// (no rotation parsing on its side). During a rotation,
|
|
// operators roll pad first (so pad accepts both "new" and
|
|
// "old" inbound), then roll pad-cloud to the new key. Until
|
|
// pad-cloud has been rolled, it is still validating against
|
|
// the OLD secret — so the reverse call must send that old
|
|
// secret, not the new one.
|
|
//
|
|
// Resolution order:
|
|
// 1. PAD_CLOUD_OUTBOUND_SECRET explicitly set → use as-is.
|
|
// Correct for any rotation state when ops pin it.
|
|
// 2. Fall back to the LAST entry of PAD_CLOUD_SECRET (the
|
|
// older rotation value). This assumes the "new,old"
|
|
// convention during rollover and gracefully tracks the
|
|
// pad-cloud side without a separate env var. After
|
|
// rollover completes and CloudSecret collapses to a
|
|
// single value, first == last so it's still correct.
|
|
if cfg.CloudSidecarURL != "" {
|
|
outboundSecret := billing.ResolveOutboundSecret(cfg.CloudOutboundSecret, cfg.CloudSecret)
|
|
if outboundSecret == "" {
|
|
return fmt.Errorf("PAD_CLOUD_SIDECAR_URL is set but neither PAD_CLOUD_OUTBOUND_SECRET nor PAD_CLOUD_SECRET supplies a usable outbound secret")
|
|
}
|
|
sidecar := billing.NewCloudClient(cfg.CloudSidecarURL, outboundSecret)
|
|
srv.SetCloudSidecar(sidecar)
|
|
// TASK-3577: the billing offer, read from the sidecar at
|
|
// boot and about every 5 minutes; unavailable until it
|
|
// answers, and whenever it cannot.
|
|
if billingMode == billing.ModeAuto {
|
|
offers := billing.NewOfferCache(sidecar)
|
|
srv.SetBilling(billingMode, offers)
|
|
go offers.Run(billingCtx)
|
|
}
|
|
slog.Info("Billing mode", "mode", billingMode)
|
|
outboundSource := "PAD_CLOUD_SECRET[last]"
|
|
if strings.TrimSpace(cfg.CloudOutboundSecret) != "" {
|
|
outboundSource = "PAD_CLOUD_OUTBOUND_SECRET"
|
|
}
|
|
slog.Info("Reverse pad-cloud sidecar wired", "url", cfg.CloudSidecarURL,
|
|
"outbound_source", outboundSource)
|
|
} else {
|
|
slog.Warn("PAD_CLOUD_SIDECAR_URL not set — account delete will NOT cancel Stripe subscriptions. Set this env var to cascade deletes.")
|
|
}
|
|
|
|
// Seed default plan limits (idempotent — won't overwrite admin changes)
|
|
if err := s.SeedPlanLimits(); err != nil {
|
|
return fmt.Errorf("seed plan limits: %w", err)
|
|
}
|
|
|
|
if err := bootPlanBackfill(s, true); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := bootPlanBackfill(s, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Wire attachment storage. Phase 1 = filesystem only; Phase 2 will
|
|
// register an "s3" backend alongside (or via a MigratingStore wrapping
|
|
// both during the cutover). Per-file cap is set by PAD_ATTACHMENT_MAX_BYTES
|
|
// or falls back to the package default (25 MiB).
|
|
attachDir := filepath.Join(cfg.DataDir, "attachments")
|
|
fsStore, err := attachments.NewFSStore(attachDir)
|
|
if err != nil {
|
|
return fmt.Errorf("init FS attachment store: %w", err)
|
|
}
|
|
attachReg := attachments.NewRegistry()
|
|
attachReg.Register(attachments.FSPrefix, fsStore)
|
|
var attachMax int64
|
|
if v := os.Getenv("PAD_ATTACHMENT_MAX_BYTES"); v != "" {
|
|
if n, perr := strconv.ParseInt(v, 10, 64); perr == nil && n > 0 {
|
|
attachMax = n
|
|
} else {
|
|
slog.Warn("PAD_ATTACHMENT_MAX_BYTES ignored — not a positive integer", "value", v)
|
|
}
|
|
}
|
|
srv.SetAttachments(attachReg, attachMax)
|
|
slog.Info("Attachment storage wired", "backend", "fs", "dir", attachDir)
|
|
|
|
// Workspace bundle import cap. Default is 2 GiB inside
|
|
// internal/server; PAD_IMPORT_BUNDLE_MAX_BYTES lets
|
|
// operators with larger exports raise the ceiling without
|
|
// recompiling (Codex review on PR #306 round 3).
|
|
if v := os.Getenv("PAD_IMPORT_BUNDLE_MAX_BYTES"); v != "" {
|
|
if n, perr := strconv.ParseInt(v, 10, 64); perr == nil && n > 0 {
|
|
srv.SetImportBundleMaxBytes(n)
|
|
slog.Info("Import bundle cap overridden", "max_bytes", n)
|
|
} else {
|
|
slog.Warn("PAD_IMPORT_BUNDLE_MAX_BYTES ignored — not a positive integer", "value", v)
|
|
}
|
|
}
|
|
|
|
// Single-artifact import cap. Default is 1 MiB inside
|
|
// internal/server; PAD_IMPORT_ARTIFACT_MAX_BYTES lets
|
|
// operators raise the ceiling without recompiling.
|
|
if v := os.Getenv("PAD_IMPORT_ARTIFACT_MAX_BYTES"); v != "" {
|
|
if n, perr := strconv.ParseInt(v, 10, 64); perr == nil && n > 0 {
|
|
srv.SetImportArtifactMaxBytes(n)
|
|
slog.Info("Import artifact cap overridden", "max_bytes", n)
|
|
} else {
|
|
slog.Warn("PAD_IMPORT_ARTIFACT_MAX_BYTES ignored — not a positive integer", "value", v)
|
|
}
|
|
}
|
|
|
|
// Orphan GC (TASK-886). Periodic sweep that reclaims
|
|
// attachments tombstoned past the grace period, plus
|
|
// uploads that were never associated with an item.
|
|
// Defaults: 24h interval, 30-day grace. Both override-
|
|
// able via env (e.g. PAD_ORPHAN_GC_INTERVAL=1m for tests
|
|
// where you want to see the sweep land within a CI run).
|
|
gcInterval := parseDurationEnv("PAD_ORPHAN_GC_INTERVAL", 0)
|
|
gcGrace := parseDurationEnv("PAD_ORPHAN_GC_GRACE", 0)
|
|
if gcInterval != 0 || gcGrace != 0 {
|
|
srv.SetOrphanGCConfig(gcInterval, gcGrace)
|
|
}
|
|
srv.StartOrphanGC()
|
|
|
|
// Wire the image processor used for thumbnail derivation
|
|
// (TASK-878) and the editor's rotate/crop tools (TASK-879/880).
|
|
// The default build picks the pure-Go backend (no cgo);
|
|
// `-tags libvips` will swap in the native backend in Phase 2.
|
|
//
|
|
// NewProcessor returns nil on the libvips build until Phase 2
|
|
// lands the real implementation — the server runs degraded
|
|
// (no thumbnail derivation, capabilities endpoint reports
|
|
// empty formats), but the binary boots cleanly. Skipping
|
|
// SetImageProcessor when the processor is nil keeps the
|
|
// wired-vs-unwired states cleanly distinct.
|
|
if imgProc := attachments.NewProcessor(); imgProc != nil {
|
|
srv.SetImageProcessor(imgProc)
|
|
slog.Info("Image processor wired", "formats", imgProc.Capabilities().ImageFormats)
|
|
} else {
|
|
slog.Info("Image processor not wired — thumbnail derivation disabled for this build")
|
|
}
|
|
|
|
// Initialize Prometheus metrics
|
|
m := metrics.New()
|
|
m.RegisterDBCollector(s.DB())
|
|
srv.SetMetrics(m)
|
|
slog.Info("Prometheus metrics enabled at /metrics")
|
|
|
|
// Attach event bus for real-time SSE. The client built here is
|
|
// shared with the watch bus below (BUG-2651) — nil when this is
|
|
// a single-instance deployment.
|
|
// ONE namespace value for all three Redis keyspaces (BUG-2724).
|
|
// Built here, before any of them, and passed into each
|
|
// constructor. THIS IS THE CONVENTION, NOT A GUARANTEE — each
|
|
// constructor takes its own Keys and nothing in the type
|
|
// system stops a future edit passing a different one;
|
|
// redis_keyspace_wiring_test.go enforces it by reading this
|
|
// file, which is a weaker instrument than a compiler.
|
|
//
|
|
// Validated eagerly: a namespace that cannot be used is a
|
|
// startup error, not a set of oddly-named keys discovered
|
|
// later.
|
|
redisKeys, err := redisns.Parse(cfg.RedisNamespace)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid PAD_REDIS_NAMESPACE: %w", err)
|
|
}
|
|
|
|
var eventBus events.EventBus
|
|
var watchRedis *redis.Client
|
|
if redisURL := os.Getenv("PAD_REDIS_URL"); redisURL != "" {
|
|
opts, err := redis.ParseURL(redisURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid PAD_REDIS_URL: %w", err)
|
|
}
|
|
// A CONTEXT-AWARE DIALER ON EVERY CLIENT (BUG-2754).
|
|
// go-redis's default hands TLS connections to
|
|
// tls.DialWithDialer, which takes no context, so on a
|
|
// rediss:// deployment a cancelled caller could not shorten
|
|
// the dial — the one segment BUG-2749's request-scoped
|
|
// establishment could not reach. Installed here rather than
|
|
// in any consumer because it is a property of the CLIENT: the
|
|
// same dial serves Publish, the Lua scripts, the presence
|
|
// registry and the watch bus's reads.
|
|
opts.Dialer = redisdial.New(opts.TLSConfig, opts.DialTimeout)
|
|
rc := redis.NewClient(opts)
|
|
if err := rc.Ping(context.Background()).Err(); err != nil {
|
|
return fmt.Errorf("redis connection failed: %w", err)
|
|
}
|
|
// Route go-redis's own diagnostics into slog (BUG-2727).
|
|
// Its default logger writes to the standard log package,
|
|
// so its messages bypass the structured pipeline entirely
|
|
// — including the one that matters most here: "channel is
|
|
// full ... message is dropped", emitted when a
|
|
// subscription's 100-deep buffer stays full past the 60s
|
|
// send timeout. That is a silent notification loss whose
|
|
// only trace was a line nobody's log aggregator was
|
|
// shaped to catch.
|
|
redis.SetLogger(redisSlogLogger{})
|
|
eventBus = newObservedEventBus(cfg, rc, redisKeys, m)
|
|
watchRedis = rc
|
|
// THE EFFECTIVE PHASE IS LOGGED, not just configured
|
|
// (BUG-2736, codex round 9). An operator reading
|
|
// pad_event_sequence_resets_total cannot interpret it without
|
|
// knowing which phase this instance publishes in — a
|
|
// counter_backward rate is expected on phase 1 and an anomaly
|
|
// on phase 2 — and the config can arrive from an env var, a
|
|
// TOML file, or neither.
|
|
phase := 1
|
|
if cfg.EventsPublishEpoch {
|
|
phase = 2
|
|
}
|
|
// The heartbeat rollout is logged for the SAME reason and is a
|
|
// SEPARATE, independently-flipped migration (BUG-2738): an
|
|
// instance can be on id-space phase 2 and heartbeat phase 1 or
|
|
// any other combination. pad_event_subscription_cycled_total
|
|
// reads differently per phase — on heartbeat phase 1 a QUIET
|
|
// workspace's wedged route is undetectable, so a zero there
|
|
// means less than it does on phase 2.
|
|
heartbeatPhase := 1
|
|
if cfg.EventsHeartbeat {
|
|
heartbeatPhase = 2
|
|
}
|
|
slog.Info("Event bus using Redis pub/sub", "addr", opts.Addr, "db", opts.DB,
|
|
"namespace", redisKeys.Namespace(), "id_space_phase", phase,
|
|
"heartbeat_phase", heartbeatPhase)
|
|
} else {
|
|
eventBus = newObservedEventBus(cfg, nil, redisKeys, m)
|
|
slog.Info("Event bus using in-memory (single instance)")
|
|
}
|
|
// Wrap event bus with Prometheus instrumentation
|
|
eventBus = metrics.NewInstrumentedBus(eventBus, m)
|
|
srv.SetEventBus(eventBus)
|
|
|
|
// Watch/nudge notification bus (TASK-2533), on the SAME
|
|
// PAD_REDIS_URL switch as the event bus above (BUG-2651).
|
|
// Reusing that client rather than dialing a second one: they
|
|
// address the same server, and one connection pool with two
|
|
// logical channels is the shape events already assumes.
|
|
//
|
|
// The branch is here rather than inside the package because a
|
|
// self-hosted single-process binary should keep MemoryBus and
|
|
// never touch Redis at all — see internal/watchevents' package
|
|
// doc for what each implementation does and does not fix.
|
|
var watchBus watchevents.Bus
|
|
if watchRedis != nil {
|
|
// The watch bus's phase is logged for the same reason the
|
|
// activity bus's is (BUG-2769): an operator reading
|
|
// pad_watchevents_sequence_resets_total cannot interpret an
|
|
// absence of idle_timeout without knowing whether the detector
|
|
// was running, and the two flags are independent.
|
|
watchPhase := 1
|
|
if cfg.WatchHeartbeat {
|
|
watchPhase = 2
|
|
}
|
|
slog.Info("Watch bus using Redis pub/sub", "namespace", redisKeys.Namespace(),
|
|
"heartbeat_phase", watchPhase)
|
|
redisWatchBus := watchevents.NewRedisBusWithKeys(watchRedis, watchevents.DefaultReplayBufferSize, redisKeys, cfg.WatchHeartbeat)
|
|
// Operational instrumentation (BUG-2727). Attached to the
|
|
// concrete type because the conditions it reports —
|
|
// dropped notifications, sequence gaps, id-space resets,
|
|
// the receive loop stopping — are detected inside the bus
|
|
// and are not visible at the Bus interface, so a wrapper
|
|
// of the events.EventBus kind cannot see them.
|
|
redisWatchBus.SetObserver(metrics.NewWatchEventsObserver(m))
|
|
watchBus = redisWatchBus
|
|
slog.Info("Watch notification bus using Redis pub/sub")
|
|
} else {
|
|
memWatchBus := watchevents.New()
|
|
memWatchBus.SetObserver(metrics.NewWatchEventsObserver(m))
|
|
watchBus = memWatchBus
|
|
slog.Info("Watch notification bus using in-memory (single instance)")
|
|
}
|
|
srv.SetWatchEventsBus(watchBus)
|
|
// TASK-3365: access kicks cross instances on their own Redis
|
|
// channel, on the same PAD_REDIS_URL switch as the buses above.
|
|
// Without Redis there is one instance and kicks stay local.
|
|
if watchRedis != nil {
|
|
srv.SetAccessKickTransport(accesskick.NewRedisTransport(watchRedis, redisKeys))
|
|
}
|
|
|
|
// Live-session presence registry (PLAN-2558 S1), on the SAME
|
|
// PAD_REDIS_URL switch as both buses above (BUG-2698). The
|
|
// three now cross instance boundaries together, which is the
|
|
// point: a shared bus with a per-process registry was worse
|
|
// than either being consistent, because a session-targeted
|
|
// push was resolved against the answering instance's view and
|
|
// skipped the publish for a session the bus could have
|
|
// reached.
|
|
//
|
|
// A self-hosted single-process binary keeps the in-memory
|
|
// registry and never touches Redis, same as the buses.
|
|
var sessionPresence server.SessionPresence
|
|
// Declared out here so the shutdown sequence below can close it
|
|
// at the right point in the order — see there for why that point
|
|
// is before http.Server.Shutdown rather than after.
|
|
var redisPresence *server.RedisSessionPresence
|
|
if watchRedis != nil {
|
|
redisPresence = server.NewRedisSessionPresenceWithKeys(watchRedis, redisKeys)
|
|
// Presence is fail-soft by design — a failed write risks
|
|
// leaving a live session unlisted rather than dropping
|
|
// its connection — so its failures have no user-visible
|
|
// signal beyond a push that quietly reaches fewer
|
|
// sessions. The counter is the only alertable trace
|
|
// (BUG-2727).
|
|
redisPresence.SetFailureObserver(func(op string) {
|
|
m.SessionPresenceFailuresTotal.WithLabelValues(op).Inc()
|
|
})
|
|
// Backstop for early-return paths that never reach the
|
|
// shutdown sequence; Close is idempotent. Deliberately does
|
|
// NOT delete this instance's entries — a shutdown racing a
|
|
// reconnect elsewhere would then delete a session that had
|
|
// already re-registered — so they clear on their TTL.
|
|
defer redisPresence.Close()
|
|
sessionPresence = redisPresence
|
|
slog.Info("Session presence registry using Redis (shared across instances)")
|
|
} else {
|
|
sessionPresence = server.NewMemorySessionPresence()
|
|
slog.Info("Session presence registry using in-memory (single instance)")
|
|
}
|
|
srv.SetSessionPresence(sessionPresence)
|
|
|
|
// Redis reachability prober (BUG-2727). Reports into
|
|
// /health/ready's payload and pad_redis_up; deliberately does
|
|
// NOT gate readiness — see server.RedisHealth's doc comment.
|
|
// Only wired when there IS a Redis, so a single-process
|
|
// deployment reports no redis block rather than a false one.
|
|
if watchRedis != nil {
|
|
// Registered here rather than in metrics.New so a
|
|
// Redis-less deployment exports no pad_redis_up series at
|
|
// all — a permanent 0 would read as an outage.
|
|
m.RegisterRedisUp()
|
|
redisHealth := server.NewRedisHealth(watchRedis, func(ok bool) {
|
|
if ok {
|
|
m.RedisUp.Set(1)
|
|
} else {
|
|
m.RedisUp.Set(0)
|
|
}
|
|
})
|
|
redisHealth.Start()
|
|
defer redisHealth.Stop()
|
|
srv.SetRedisHealth(redisHealth)
|
|
}
|
|
|
|
// Yjs collab room manager (PLAN-1248). Single-instance only
|
|
// today; multi-replica fanout via Redis is a deferred IDEA.
|
|
// MemoryOpBus is in-process; the OpBus interface keeps the
|
|
// door open for a RedisOpBus drop-in later.
|
|
collabBus := collab.NewMemoryOpBus()
|
|
srv.SetCollabRoomManager(newObservedRoomManager(s, collabBus, m))
|
|
slog.Info("Collab room manager wired (Yjs over /api/v1/collab/{itemID})")
|
|
|
|
// Op-log prune sweeper (TASK-1309). Periodic background
|
|
// loop that deletes Yjs op-log rows older than minAge.
|
|
// Defaults: 1h interval, 24h minAge — both override-able
|
|
// via env (PAD_OPLOG_GC_INTERVAL=5m for tests where you
|
|
// want to see the sweep land within a CI run, etc.).
|
|
oplogGCInterval := parseDurationEnv("PAD_OPLOG_GC_INTERVAL", 0)
|
|
oplogGCMinAge := parseDurationEnv("PAD_OPLOG_GC_MIN_AGE", 0)
|
|
if oplogGCInterval != 0 || oplogGCMinAge != 0 {
|
|
srv.SetOpLogGCConfig(oplogGCInterval, oplogGCMinAge)
|
|
}
|
|
// Built before the GC starts, because dormancy compaction
|
|
// (TASK-3531) runs on the same worker as op-log recovery.
|
|
mat := newMaterializerFromEnv(os.Getenv, slog.Default(), newSupervisorMaterializer)
|
|
srv.SetMaterializer(mat)
|
|
srv.SetOpLogCompactor(opLogCompactorFromEnv(os.Getenv, slog.Default(), mat))
|
|
srv.StartOpLogGC()
|
|
|
|
// Op-log recovery (TASK-2198 U4). Rebuilds items.content from the
|
|
// collaborative op-log when a tab went away without flushing,
|
|
// in a capped child process (`pad __materialize-worker`) started
|
|
// on the first job, not here. PAD_MATERIALIZE=off disables it
|
|
// entirely; PAD_MATERIALIZE_TIMEOUT / PAD_MATERIALIZE_MEM_LIMIT
|
|
// tune the worker. docs/deployment.md, "Op-log materializer worker".
|
|
// (Its worker was built above, before the op-log GC.)
|
|
srv.StartMaterializeRecovery()
|
|
|
|
// Token reaper (PLAN-1933 DR-5 / TASK-1936). Periodic sweep
|
|
// that deletes expired/used email-verification tokens,
|
|
// password-reset tokens, sessions, CLI-auth sessions and expired
|
|
// OAuth token/code/PKCE rows (BUG-3301) —
|
|
// the CleanExpired* methods existed but were never called.
|
|
// Default: 1h interval, override-able via env
|
|
// (PAD_TOKEN_REAPER_INTERVAL=1m for tests/CI).
|
|
if reaperInterval := parseDurationEnv("PAD_TOKEN_REAPER_INTERVAL", 0); reaperInterval != 0 {
|
|
srv.SetTokenReaperConfig(reaperInterval)
|
|
}
|
|
srv.StartTokenReaper()
|
|
|
|
// Item reminder scheduler (IDEA-2641 / GitHub #1010). The only
|
|
// thing in Pad that ACTS at a target time rather than reporting
|
|
// on one when asked. Default: 30s, override-able via env
|
|
// (PAD_REMINDER_TICK_INTERVAL) the same way the reaper is.
|
|
if reminderInterval := parseDurationEnv("PAD_REMINDER_TICK_INTERVAL", 0); reminderInterval != 0 {
|
|
srv.SetReminderTickConfig(reminderInterval, 0)
|
|
}
|
|
srv.StartReminderTick()
|
|
|
|
// Expired pending app installs and their staged bytes (TASK-3397).
|
|
srv.StartAppPendingSweep()
|
|
|
|
// Hooks for installs provisioned before app webhooks existed
|
|
// (TASK-3408 U10a), created held until the app's next redeem.
|
|
srv.EnsureAppWebhooks(cmd.Context())
|
|
// And their item actions (TASK-3414 U11).
|
|
srv.EnsureAppItemActions(cmd.Context())
|
|
|
|
// Typed decisions (PLAN-3114 / TASK-3117). With no provider
|
|
// configured the runner is nil: no write enqueues a job and no
|
|
// tick starts. A provider that is NAMED but cannot be built (an
|
|
// unknown name, a missing key) is logged at ERROR and the server
|
|
// runs without decisions — an operator asked for them and is not
|
|
// getting them, which must be visible, but it is not a reason to
|
|
// refuse to serve everything else.
|
|
//
|
|
// The config file is one source; the instance-admin setting and
|
|
// the environment are resolved by the server itself, which
|
|
// rebuilds the provider whenever an admin changes the setting —
|
|
// no restart (TASK-3121).
|
|
decisionSets, rerr := decision.ProductionRegistry()
|
|
if rerr != nil {
|
|
slog.Error("decision question sets failed to register; decisions are OFF", "error", rerr)
|
|
decisionSets = nil
|
|
}
|
|
srv.SetDecisionBase(decisionFileConfig(cfg), decisionSets)
|
|
if derr := srv.ConfigureDecisions(); derr != nil {
|
|
slog.Error("decision provider misconfigured; decisions are OFF", "error", derr)
|
|
}
|
|
|
|
// Workspace hard-purge sweeper (TASK-1966). Periodic sweep
|
|
// that hard-deletes workspaces soft-deleted more than 30 days
|
|
// ago — cascading every child row and reclaiming attachment
|
|
// blobs — to honor the /privacy 30-day GDPR erasure SLA.
|
|
// DeleteAccountAtomic / DeleteWorkspace only soft-delete
|
|
// (workspaces.deleted_at); nothing else ever expunges them.
|
|
// Defaults: 24h interval, 30-day retention — both override-
|
|
// able via env (PAD_WORKSPACE_PURGE_INTERVAL=1m /
|
|
// PAD_WORKSPACE_PURGE_RETENTION=1s for tests/CI). Must run
|
|
// AFTER the attachment registry is wired (above) so blob
|
|
// reclamation has a backend.
|
|
wsPurgeInterval := parseDurationEnv("PAD_WORKSPACE_PURGE_INTERVAL", 0)
|
|
wsPurgeRetention := parseDurationEnv("PAD_WORKSPACE_PURGE_RETENTION", 0)
|
|
if wsPurgeInterval != 0 || wsPurgeRetention != 0 {
|
|
srv.SetWorkspacePurgeConfig(wsPurgeInterval, wsPurgeRetention)
|
|
}
|
|
srv.StartWorkspacePurgeSweeper()
|
|
|
|
// Attach webhook dispatcher for outgoing notifications
|
|
srv.SetWebhookDispatcher(webhooks.NewDispatcher(s))
|
|
|
|
// SPEC-3 event outbox drain (TASK-2714). Started AFTER the
|
|
// dispatcher is attached: a drain running without one acks its
|
|
// rows as having nowhere to go, which for the first few seconds
|
|
// of a boot would silently discard real events.
|
|
outboxInterval := parseDurationEnv("PAD_OUTBOX_DRAIN_INTERVAL", 0)
|
|
outboxLease := parseDurationEnv("PAD_OUTBOX_CLAIM_LEASE", 0)
|
|
outboxRetention := parseDurationEnv("PAD_OUTBOX_RETENTION", 0)
|
|
outboxMaxAge := parseDurationEnv("PAD_OUTBOX_MAX_AGE", 0)
|
|
if outboxInterval != 0 || outboxLease != 0 || outboxRetention != 0 || outboxMaxAge != 0 {
|
|
srv.SetOutboxDrainConfig(outboxInterval, outboxLease, outboxRetention, outboxMaxAge, 0)
|
|
}
|
|
srv.StartOutboxDrain()
|
|
|
|
// Attach email sender: env vars first, then platform settings overlay
|
|
if cfg.MailerooAPIKey != "" {
|
|
fromAddr := cfg.EmailFrom
|
|
if fromAddr == "" {
|
|
fromAddr = "noreply@getpad.dev"
|
|
}
|
|
fromName := cfg.EmailFromName
|
|
if fromName == "" {
|
|
fromName = "Pad"
|
|
}
|
|
// PublicLinkBaseURL — emailed links must use the deployment's
|
|
// public URL (PUBLIC_URL), not the CLI BaseURL().
|
|
srv.SetEmailSender(email.NewSender(cfg.MailerooAPIKey, fromAddr, fromName, cfg.PublicLinkBaseURL()), cfg.MailerooAPIKey)
|
|
slog.Info("Email sending enabled via Maileroo (env)")
|
|
}
|
|
// Platform settings can override or provide email config
|
|
srv.InitEmailFromSettings()
|
|
|
|
// Initialize 2FA challenge signing key (persisted in platform_settings)
|
|
if err := srv.Init2FASecret(); err != nil {
|
|
return fmt.Errorf("init 2FA secret: %w", err)
|
|
}
|
|
|
|
// Mount embedded web UI if available
|
|
webFS, err := fs.Sub(pad.WebUI, "web/build")
|
|
if err == nil {
|
|
if entries, err := fs.ReadDir(webFS, "."); err == nil && len(entries) > 0 {
|
|
srv.SetWebUI(webFS)
|
|
slog.Info("Serving embedded web UI")
|
|
}
|
|
}
|
|
|
|
// First-run bootstrap token (TASK-1167 / PLAN-1166) +
|
|
// PAD_BYPASS_SETUP_TOKEN open-mode escape hatch.
|
|
//
|
|
// The bypass env-var lets operators on trusted networks
|
|
// (Unraid behind a firewall, Tailscale-only deployments)
|
|
// claim the first admin via the web UI without copying a
|
|
// token out of `docker logs`. Cloud mode always ignores it.
|
|
//
|
|
// Branches by current state:
|
|
//
|
|
// 1. UserCount > 0 → mop up any stale token file left by a
|
|
// previous successful bootstrap whose os.Remove somehow
|
|
// failed (D4). No banner.
|
|
// 2. UserCount == 0 + self-host + bypass on → wire the
|
|
// bypass into the server, skip token generation
|
|
// entirely (no .bootstrap-token file written), log a
|
|
// distinct open-mode banner so the operator sees
|
|
// explicitly that the surface is unprotected.
|
|
// 3. UserCount == 0 + self-host + bypass off → ensure a
|
|
// token exists, load it into the server, log the
|
|
// banner so the operator can grab it from `docker
|
|
// logs`. Failures are WARN-and-continue (D7) — never
|
|
// abort startup.
|
|
// 4. UserCount == 0 + cloud mode → no-op (D10). Cloud
|
|
// bootstrap stays loopback-only regardless of bypass.
|
|
bypassEnv := os.Getenv("PAD_BYPASS_SETUP_TOKEN")
|
|
bypassSetupToken := bypassEnv == "true" || bypassEnv == "1"
|
|
srv.SetBypassSetupToken(bypassSetupToken && !cfg.IsCloudServer())
|
|
srv.SetPushGateway(cfg.PushGatewayURL)
|
|
if userCount, ucErr := s.UserCount(); ucErr != nil {
|
|
slog.Warn("could not check user count for bootstrap token wiring", "error", ucErr)
|
|
} else if userCount > 0 {
|
|
if cleanupErr := server.CleanupStaleBootstrapToken(cfg.DataDir); cleanupErr != nil {
|
|
slog.Warn("stale bootstrap token cleanup failed", "error", cleanupErr)
|
|
}
|
|
} else if !cfg.IsCloudServer() {
|
|
if bypassSetupToken {
|
|
// Don't generate or persist a token; the surface is
|
|
// already open and a token file would just be a
|
|
// confusing artifact for the operator.
|
|
if cleanupErr := server.CleanupStaleBootstrapToken(cfg.DataDir); cleanupErr != nil {
|
|
slog.Warn("stale bootstrap token cleanup failed", "error", cleanupErr)
|
|
}
|
|
logOpenBootstrapBanner(cfg)
|
|
} else {
|
|
token, tokenPath, terr := server.EnsureBootstrapToken(cfg.DataDir)
|
|
if terr != nil {
|
|
slog.Warn("first-run bootstrap token unavailable; falling back to loopback-only setup",
|
|
"error", terr,
|
|
"hint", "operator can run `pad auth setup` from inside the container")
|
|
} else {
|
|
srv.SetBootstrapToken(token, tokenPath)
|
|
logBootstrapBanner(token, cfg, tokenPath)
|
|
}
|
|
}
|
|
} else if bypassSetupToken {
|
|
// Cloud mode + bypass: defensively log a warning so a
|
|
// misconfigured operator notices their flag was ignored.
|
|
slog.Warn("PAD_BYPASS_SETUP_TOKEN is ignored in cloud mode (PAD_CLOUD/PAD_MODE=cloud)",
|
|
"hint", "cloud bootstrap stays loopback-only by design")
|
|
}
|
|
|
|
// The port was bound and the PID file claimed at the top of this
|
|
// command (TASK-3620, BUG-2965). The PID file lets `pad server
|
|
// stop` address a server started HERE and not only one
|
|
// auto-started by EnsureServer. A SIGKILL leaves it behind, which
|
|
// is why StopServer treats the file as a handle rather than as
|
|
// evidence.
|
|
srv.Prepare(cfg.Addr())
|
|
|
|
// Graceful shutdown: listen for SIGINT/SIGTERM
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// Start server in a goroutine
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- srv.Serve(ln)
|
|
}()
|
|
|
|
// Wait for signal or server error
|
|
select {
|
|
case err := <-errCh:
|
|
// Server failed to start or crashed
|
|
return err
|
|
case <-ctx.Done():
|
|
// Received shutdown signal
|
|
slog.Info("Shutting down server (30s grace period)...")
|
|
stop() // Reset signal handling so a second signal force-kills
|
|
|
|
// Release the PID file BEFORE the listener closes, not after
|
|
// (codex round 4, P1). Cleanup is a read-then-remove: it checks
|
|
// the file still names us, then unlinks. Run after the port is
|
|
// released, a successor can bind and claim between those two
|
|
// steps, and we then delete ITS entry — the live server ends up
|
|
// unaddressable, silently. While we still hold the port no
|
|
// other server can legitimately own this file, so there is
|
|
// nothing to race with.
|
|
//
|
|
// The cost is a window during the drain where a healthy server
|
|
// has no PID file, so a concurrent `pad server stop` answers
|
|
// "a server is answering on <addr> but this CLI did not start
|
|
// it". That is honest and actionable, and it is the trade this
|
|
// unit is about: a wrong silence for a true message.
|
|
releasePIDFile()
|
|
|
|
// Close event bus first — this terminates SSE handler
|
|
// goroutines so http.Server.Shutdown won't block on them.
|
|
// eventBus is always non-nil here: assigned a few lines
|
|
// above to a concrete *metrics.InstrumentedBus return value.
|
|
eventBus.Close()
|
|
slog.Info("Event bus closed")
|
|
|
|
// Presence closes FIRST — before the watch bus, and well before
|
|
// Shutdown. The ordering is load-bearing twice over (codex
|
|
// rounds 4 and 5 on BUG-2698).
|
|
//
|
|
// Remove waits for a session's renewal goroutine. Closing the bus
|
|
// is what RELEASES the SSE handlers, so each one immediately runs
|
|
// its deferred Remove — and any Remove that runs before Close
|
|
// still finds a live renewal to wait for, bypassing the drain
|
|
// bound entirely and putting that wait in front of Shutdown.
|
|
// Closing presence first cancels every renewal and drains them,
|
|
// so the Removes that follow have nothing left to wait on.
|
|
//
|
|
// Note it does NOT empty the registry — an earlier version of
|
|
// this comment said so, and Close deliberately retains the
|
|
// entries precisely so a concurrent Remove can still find and
|
|
// await a renewal (codex rounds 6 and 10). Remove's own wait is
|
|
// bounded by the same drain deadline, so a renewal Close could
|
|
// not drain cannot hold Shutdown either.
|
|
//
|
|
// Nothing here depends on the bus, so there is no cost to going
|
|
// first. Idempotent, so the deferred Close at the wiring site
|
|
// stays a harmless backstop for early-return paths.
|
|
if redisPresence != nil {
|
|
redisPresence.Close()
|
|
slog.Info("Session presence registry closed")
|
|
}
|
|
|
|
// Same reasoning for the watch bus, and it matters for the
|
|
// same reason: GET /api/v1/events/stream is a long-lived
|
|
// handler blocked on this bus's channel, so leaving it open
|
|
// keeps http.Server.Shutdown waiting out its full 30s
|
|
// deadline (codex round 2 on BUG-2651). Closing here rather
|
|
// than only in srv.Stop() below also tears the Redis
|
|
// subscription down promptly instead of at the very end.
|
|
// Both Close implementations are idempotent, so the second
|
|
// call inside srv.Stop() is a no-op.
|
|
//
|
|
// THE TRADE, which is the same one eventBus above already
|
|
// makes and is worth naming: closing BEFORE Shutdown drains
|
|
// handlers means a push already in flight publishes into a
|
|
// closed bus and is not delivered. Closing AFTER instead would
|
|
// hold Shutdown for its full 30s deadline on any open stream,
|
|
// every time. Neither is free; this side loses a message in a
|
|
// window measured in milliseconds during a deliberate shutdown,
|
|
// the other side delays every shutdown by half a minute.
|
|
//
|
|
// WHAT IT NO LONGER COSTS is the caller's understanding of it
|
|
// (BUG-2699). This comment used to end "...and still return HTTP
|
|
// 200 with pushed:true", and then note that fixing it needed an
|
|
// interface change and a different unit. That unit landed:
|
|
// Bus.Publish reports acceptance, so a push publishing into a
|
|
// closed bus gets ErrBusClosed and answers 503. The message is
|
|
// still lost; the caller is no longer told it was sent, and can
|
|
// safely re-send once the server is back.
|
|
watchBus.Close()
|
|
slog.Info("Watch notification bus closed")
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("HTTP server shutdown error", "error", err)
|
|
}
|
|
|
|
// http.Server.Shutdown doesn't terminate hijacked
|
|
// connections (WebSockets), so collab sessions keep
|
|
// running until something explicitly closes them.
|
|
// srv.Stop() runs the collab RoomManager.Close path
|
|
// (TASK-1255) plus the other long-running background
|
|
// loops (orphan GC, MCP audit writer, etc.). Without
|
|
// this, an open collab WS would race the deferred
|
|
// store close on process exit.
|
|
srv.Stop()
|
|
|
|
slog.Info("Server stopped")
|
|
return nil
|
|
}
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&host, "host", "127.0.0.1", "host address to listen on")
|
|
cmd.Flags().IntVar(&port, "port", 7777, "port to listen on")
|
|
cmd.Flags().Bool("force", false, "start even if the database schema is newer than this binary (a downgrade — risks data corruption; see the README \"Upgrading Pad\" section)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// logBootstrapBanner emits a single multi-line INFO log entry pointing the
|
|
// operator at the /setup#token=<x> URL they need to visit to claim the
|
|
// first admin. The banner is greppable by "Pad first-run setup" so log
|
|
// aggregators can surface it. Token is in a URL fragment (#token=) so it
|
|
// is never transmitted to the server in HTTP requests — only the
|
|
// browser sees it. The frontend strips it from the URL via
|
|
// history.replaceState on mount and submits via the X-Bootstrap-Token
|
|
// header.
|
|
//
|
|
// See TASK-1167 / PLAN-1166. Called only on first start with zero
|
|
// users in self-host mode.
|
|
func logBootstrapBanner(token string, cfg *config.Config, tokenPath string) {
|
|
host := cfg.Host
|
|
switch host {
|
|
case "", "0.0.0.0", "::", "[::]":
|
|
// PAD_HOST not bound to a specific interface — render as
|
|
// <your-host> placeholder in the banner since we don't know
|
|
// which network interface the operator wants to reach this
|
|
// instance from.
|
|
host = "<your-host>"
|
|
}
|
|
url := fmt.Sprintf("http://%s:%d/setup#token=%s", host, cfg.Port, token)
|
|
banner := fmt.Sprintf(`
|
|
========================================================================
|
|
Pad first-run setup
|
|
========================================================================
|
|
|
|
No users exist yet. To create the first admin account, visit:
|
|
|
|
%s
|
|
|
|
This token is one-time. After the first admin is created the token
|
|
is consumed and this banner stops appearing.
|
|
|
|
To regenerate, delete %s and restart.
|
|
|
|
========================================================================
|
|
`, url, tokenPath)
|
|
|
|
// BUG-1182: bypass slog for the banner. slog's text handler is
|
|
// contractually one-line-per-record and escapes literal newlines as
|
|
// `\n`, which renders the multi-line banner as a single wide line in
|
|
// `docker logs` — exactly the surface where operators look for it.
|
|
// Banner-style operator output isn't structured logging; stderr is the
|
|
// conventional channel for it (kubectl / docker / helm / systemd all
|
|
// do this). docker logs captures stderr alongside stdout, so the
|
|
// banner stays visible.
|
|
fmt.Fprint(os.Stderr, banner)
|
|
|
|
// Companion structured log so log aggregators that parse slog JSON
|
|
// still record the event. Deliberately does NOT include the URL or
|
|
// token — those are in the stderr banner where the operator looks
|
|
// for them. Repeating the URL as a parseable structured field would
|
|
// give log aggregators an easy-to-extract token, which is precisely
|
|
// what the URL-fragment design (TASK-1167 F10) is trying to avoid.
|
|
// Operators / agents that want the token programmatically should
|
|
// read the token_path file directly.
|
|
slog.Info("first-run bootstrap setup banner emitted to stderr — see container logs",
|
|
"token_path", tokenPath)
|
|
}
|
|
|
|
// logOpenBootstrapBanner is the PAD_BYPASS_SETUP_TOKEN companion to
|
|
// logBootstrapBanner. When the operator opts into the bypass, no token
|
|
// is generated — the bootstrap endpoint accepts the first-admin POST
|
|
// from any IP without an X-Bootstrap-Token header. The banner makes
|
|
// the security trade-off explicit so an operator who set the flag
|
|
// without thinking through the implications sees an obvious WARN.
|
|
//
|
|
// Self-host only; cmd/pad/main.go gates this call behind the
|
|
// !cfg.IsCloudServer() branch.
|
|
func logOpenBootstrapBanner(cfg *config.Config) {
|
|
host := cfg.Host
|
|
switch host {
|
|
case "", "0.0.0.0", "::", "[::]":
|
|
host = "<your-host>"
|
|
}
|
|
url := fmt.Sprintf("http://%s:%d/setup", host, cfg.Port)
|
|
banner := fmt.Sprintf(`
|
|
========================================================================
|
|
Pad first-run setup (open mode)
|
|
========================================================================
|
|
|
|
PAD_BYPASS_SETUP_TOKEN is set. The first admin can be created
|
|
directly from the web UI — no bootstrap token required:
|
|
|
|
%s
|
|
|
|
WARNING: anyone who can reach this URL can claim the first admin
|
|
account until you create one. Only leave this enabled on networks
|
|
you trust (LAN behind a firewall, Tailscale, etc.).
|
|
|
|
To re-enable token-protected setup, unset PAD_BYPASS_SETUP_TOKEN
|
|
and restart. Once a user exists this banner stops appearing.
|
|
|
|
========================================================================
|
|
`, url)
|
|
fmt.Fprint(os.Stderr, banner)
|
|
slog.Warn("first-run bootstrap is OPEN (PAD_BYPASS_SETUP_TOKEN=true) — anyone reachable on the WebUI port can claim the first admin until one is created")
|
|
}
|
|
|
|
// --- stop ---
|
|
|
|
func stopCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop the background Pad server",
|
|
Example: ` pad server stop`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := getConfig()
|
|
if err := cli.StopServer(cfg); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("Server stopped.")
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- open ---
|
|
|
|
func openCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "open",
|
|
Short: "Open the Pad web UI in your browser",
|
|
Example: ` pad server open`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := getConfiguredConfig()
|
|
if err := cli.EnsureServer(cfg); err != nil {
|
|
return fmt.Errorf("start server: %w", err)
|
|
}
|
|
|
|
url := cfg.BrowserURL()
|
|
|
|
// If there's a workspace, go directly to it
|
|
ws, _ := cli.DetectWorkspace(workspaceFlag)
|
|
if ws != "" {
|
|
url += "/" + neturl.PathEscape(ws)
|
|
}
|
|
|
|
fmt.Printf("Opening %s\n", url)
|
|
return openBrowser(url)
|
|
},
|
|
}
|
|
}
|
|
|
|
func openBrowser(url string) error {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return exec.Command("open", url).Start()
|
|
case "linux":
|
|
return exec.Command("xdg-open", url).Start()
|
|
case "windows":
|
|
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
|
default:
|
|
return fmt.Errorf("unsupported platform — open %s manually", url)
|
|
}
|
|
}
|
|
|
|
// --- auth commands ---
|
|
|
|
// padMCPGenerateOnlySessionIDManager is the SessionIdManager used for
|
|
// the /mcp Streamable HTTP transport (TASK-1120). It produces a fresh
|
|
// UUID on every initialize so the response carries Mcp-Session-Id
|
|
// (which the active-sessions tracker reads), but accepts ANY incoming
|
|
// session-id value — including empty strings — without rejection.
|
|
//
|
|
// This intentionally diverges from both shipped mcp-go managers:
|
|
//
|
|
// - StatelessSessionIdManager: Generate() returns "" → tracker can't
|
|
// observe sessions in production. Original choice; broken for
|
|
// observability after TASK-1120.
|
|
// - StatelessGeneratingSessionIdManager: Generate() works, BUT
|
|
// Validate() rejects clients that don't echo the spec'd UUID
|
|
// prefix → breaking change for any client previously running
|
|
// under WithStateLess(true) that didn't track session-id.
|
|
//
|
|
// We're truly stateless server-side (no DB, no map of session IDs to
|
|
// validate against), so accepting any incoming value is correct: the
|
|
// "session" is purely a per-request observability label and the
|
|
// server doesn't depend on any client honoring it. Termination is a
|
|
// no-op for the same reason — there's no per-session state to free
|
|
// when DELETE arrives, and the active-sessions tracker handles its
|
|
// own bookkeeping.
|
|
type padMCPGenerateOnlySessionIDManager struct{}
|
|
|
|
func (padMCPGenerateOnlySessionIDManager) Generate() string {
|
|
return "pad-mcp-" + uuid.NewString()
|
|
}
|
|
|
|
func (padMCPGenerateOnlySessionIDManager) Validate(string) (isTerminated bool, err error) {
|
|
return false, nil
|
|
}
|
|
|
|
func (padMCPGenerateOnlySessionIDManager) Terminate(string) (isNotAllowed bool, err error) {
|
|
return false, nil
|
|
}
|
|
|
|
// humanBytes formats a byte count with the smallest IEC unit that
|
|
// keeps the value under 1024 — matches the convention used across
|
|
// the web UI's storage bar so CLI and browser reads agree.
|
|
// parseDurationEnv reads a duration env var (Go syntax: 1h, 30m,
|
|
// 24h, 720h, etc). Returns the default when the var is unset; logs
|
|
// a warning and returns the default on a parse error so a typo
|
|
// doesn't silently break the GC schedule.
|
|
func parseDurationEnv(name string, def time.Duration) time.Duration {
|
|
v := os.Getenv(name)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
slog.Warn(name+" ignored — not a valid Go duration", "value", v, "error", err)
|
|
return def
|
|
}
|
|
return d
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
if n < 0 {
|
|
return "?"
|
|
}
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for x := n / unit; x >= unit; x /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
|
|
if exp >= len(suffixes) {
|
|
exp = len(suffixes) - 1
|
|
}
|
|
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), suffixes[exp])
|
|
}
|
|
|
|
// newObservedEventBus builds the event bus for this deployment shape with its
|
|
// operational observer already attached (BUG-2731). A nil client selects the
|
|
// in-process bus.
|
|
//
|
|
// EXTRACTED SO THE WIRING IS TESTABLE, which is the whole point (CONVE-19:
|
|
// wiring is a claim). Inline in the command's RunE, the SetObserver call was a
|
|
// claim no test could reach: an events-package test proves the bus calls its
|
|
// observer and a metrics-package test proves the adapter maps it, and both
|
|
// pass with that line deleted.
|
|
//
|
|
// The observer attaches to the CONCRETE bus because SetObserver is not part of
|
|
// the EventBus interface the wrapper implements, so it has to happen before
|
|
// the value is widened — a typing constraint, not a subtle ordering
|
|
// requirement. What the wrapper genuinely cannot do is REPLACE this: the
|
|
// conditions reported here are detected inside the bus, on the receive path
|
|
// and inside the coverage rules, not at the interface it wraps.
|
|
//
|
|
// The in-process bus is wired too, deliberately: a single-process deployment
|
|
// restarts, and the cold-buffer resume gap is exactly as real there. Its reset
|
|
// counter stays at zero by construction — MemoryBus owns its own IDs and has
|
|
// no shared counter to lose.
|
|
//
|
|
// IT TAKES THE WHOLE CONFIG rather than the one field it needs, and that is
|
|
// the same CONVE-19 argument one level along (BUG-2736). The phase-2 flip
|
|
// began as a hand-picked `cfg.EventsPublishEpoch` argument at the two call
|
|
// sites in RunE; replacing it with `false` there compiled, passed every test
|
|
// in the tree, and left the deployment silently stuck on phase 1 —
|
|
// indistinguishable from a correct phase-1 deployment, since phase 1 is the
|
|
// default. Reading the field HERE puts the link inside a function a test can
|
|
// call, and a caller that drops the config does not compile.
|
|
//
|
|
// The flip reaches the Redis bus only. The in-process bus has no wire and
|
|
// identifies its ID space by its own incarnation base instead (see
|
|
// internal/idspace), so it ignores the field.
|
|
func newObservedEventBus(cfg *config.Config, rc *redis.Client, redisKeys redisns.Keys, m *metrics.Metrics) events.EventBus {
|
|
if rc != nil {
|
|
bus := events.NewRedisBusWithKeys(rc, redisKeys, cfg.EventsPublishEpoch, cfg.EventsHeartbeat)
|
|
bus.SetObserver(metrics.NewEventsObserver(m))
|
|
return bus
|
|
}
|
|
bus := events.New()
|
|
bus.SetObserver(metrics.NewEventsObserver(m))
|
|
return bus
|
|
}
|
|
|
|
// newObservedRoomManager builds the collab room manager with its metrics
|
|
// observer attached (TASK-3501). Extracted for the same reason as
|
|
// newObservedEventBus: inline, the SetObserver line is a claim no test can
|
|
// reach (CONVE-19).
|
|
func newObservedRoomManager(s *store.Store, bus collab.OpBus, m *metrics.Metrics) *collab.RoomManager {
|
|
rm := collab.NewRoomManager(s, bus)
|
|
rm.SetObserver(metrics.NewCollabObserver(m))
|
|
return rm
|
|
}
|
|
|
|
// registerRemoteMCP registers everything the remote /mcp transport serves on
|
|
// mcpSrv: the catalog, prompts, meta and resources. Extracted from the
|
|
// cloud branch of serverStartCmd (TASK-2306) so the wire-golden test can
|
|
// build the remote server through this same function rather than
|
|
// assembling its own, which would vouch for the test instead of this binding.
|
|
func registerRemoteMCP(mcpSrv *mcpserver.Server, mcpDoc *cmdhelp.Document, dispatcher *mcpserver.HTTPHandlerDispatcher) error {
|
|
// One in-process fetcher serves both the resources and
|
|
// pad_set_workspace's bootstrap embed (BUG-3326), so the embed reads
|
|
// through the same per-user auth/consent perimeter as the resource.
|
|
resourceFetcher := mcpserver.NewHTTPResourceFetcher(dispatcher)
|
|
if _, regErr := mcpserver.Register(mcpSrv.MCP(), mcpserver.RegistryOptions{
|
|
Doc: mcpDoc,
|
|
// Shared multi-user state: this one stateless process
|
|
// dispatches for every OAuth user, so the session
|
|
// workspace must NEVER be trusted as a per-call
|
|
// resolution default — it would bleed across users /
|
|
// concurrent sessions (BUG-1865). NewSharedWorkspaceState
|
|
// makes ResolveDefault() always return "", forcing
|
|
// per-call explicit `workspace` (or the per-user
|
|
// maybeInjectWorkspace default). Local `pad mcp serve`
|
|
// (cmd/pad/mcp.go) keeps NewWorkspaceState — it's
|
|
// single-user-per-process and safe to inject.
|
|
Workspace: mcpserver.NewSharedWorkspaceState(),
|
|
Dispatcher: dispatcher,
|
|
BootstrapFetcher: resourceFetcher,
|
|
PadVersion: fullVersion(),
|
|
}); regErr != nil {
|
|
return fmt.Errorf("register MCP catalog: %w", regErr)
|
|
}
|
|
mcpserver.RegisterPrompts(mcpSrv.MCP())
|
|
mcpserver.RegisterMeta(mcpSrv.MCP(), fullVersion())
|
|
|
|
// Read-only resource templates (TASK-2101). Parity with
|
|
// `pad mcp serve` (cmd/pad/mcp.go), which registers them via
|
|
// an ExecResourceFetcher. That fetcher shells out to the pad
|
|
// binary, inheriting one user's ~/.pad credentials — unusable
|
|
// in this shared multi-OAuth-user process. HTTPResourceFetcher
|
|
// is the in-process equivalent the original TASK-950 comment
|
|
// deferred: it dispatches each resource read through the same
|
|
// handler chain (reusing the dispatcher's user resolution +
|
|
// auth/consent perimeter), so the full resource set — including
|
|
// the bounded attachment image resource from PR #930 — is now
|
|
// available over remote /mcp.
|
|
mcpserver.RegisterResources(
|
|
mcpSrv.MCP(),
|
|
resourceFetcher,
|
|
nil, // no root flags on the remote transport (no --url)
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// bootPlanBackfill is the plan step of `pad server start` (TASK-3551).
|
|
//
|
|
// Cloud: mark the database cloud-owned (sticky, before anything else), then
|
|
// give empty plans 'free' (the first cloud boot after an upgrade from
|
|
// self-hosted).
|
|
//
|
|
// Self-hosted: convert free and empty plans to 'self-hosted' (no limits),
|
|
// unless the database belongs to a Pad Cloud instance. Then nothing is
|
|
// converted and the boot goes on: a debug or restore boot must not be an
|
|
// outage, and free users keep free-plan limits instead of becoming unlimited
|
|
// for good. Only a failure to write the cloud marker stops the boot.
|
|
func bootPlanBackfill(s *store.Store, cloud bool) error {
|
|
if cloud {
|
|
if err := s.MarkCloudOwned(); err != nil {
|
|
return fmt.Errorf("mark database cloud-owned: %w", err)
|
|
}
|
|
if err := s.BackfillUserPlans("free"); err != nil {
|
|
slog.Warn("failed to backfill user plans", "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
if _, own, err := s.BackfillSelfHostedPlans(); errors.Is(err, store.ErrCloudOwnedDatabase) {
|
|
slog.Error("this database belongs to a Pad Cloud instance; free plans were NOT converted to self-hosted. "+
|
|
"Start it with PAD_MODE=cloud, or, if you are deliberately moving it off Pad Cloud, run `pad db release-cloud` on this host",
|
|
"reason", own.Reason())
|
|
} else if err != nil {
|
|
slog.Warn("failed to set self-hosted plans", "error", err)
|
|
}
|
|
return nil
|
|
}
|