Commit Graph
108 Commits
Author SHA1 Message Date
Juan DenisandClaude Opus 4.8 f823c6c74f feat(manifest): fromSecret/fromService/generate reference binders (Phase 3)
Add EnvironmentVariable.value_from (migration 055) so an env var can resolve from
a vault secret or a sibling service's connection property at injection time — the
value never lands in the row, masking stays intact, and a rotated secret
propagates on next deploy. EnvReferenceResolver handles both; the apply engine
plans set_env_ref steps, flags missing secrets as plan-time issues, and generates
a random vault secret for generate:true (idempotently).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 01:34:54 -04:00
Juan DenisandClaude Opus 4.8 ae0f823e90 feat(manifest): persist manifest + seed env + health-check gate (Phase 1)
Stop dropping what import already detects. Add Application.healthcheck_path
(migration 054) and an application_manifests table (one row per project) storing
the normalized manifest, hash and provenance. Import now seeds non-secret env
values and records the health-check path, and the zero-downtime restart gate
polls that path instead of a blind sleep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 01:16:29 -04:00
Juan DenisandClaude Fable 5 5e22aa8510 feat(panel): wire drift/doctor, CLI tools, security suite and hosting polish
Blueprint/model/job registrations and schedule seeding for drift,
doctor, support bundle, bandwidth, FIM, malware-scan and htaccess
tools; API module aggregation, SCSS imports and CHANGELOG for the
batch. Full backend suite: 1793 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:22:07 -04:00
Juan DenisandClaude Fable 5 96d162e9a4 feat(hosting): per-site nginx micro-cache toggle
Opt-in 10s FastCGI/proxy micro-cache per site (migration 053) with safe
bypasses for logged-in/admin/cart cookies and paths, X-SK-Cache header
for verification, and a shared-zone purge. Flows through the drift-safe
vhost render seam so detection and writes always agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:21:50 -04:00
Juan DenisandClaude Fable 5 3cbbcb5e6b feat(hosting): per-domain bandwidth accounting
Daily nginx access-log aggregation (migration 052) into per-site
transfer stats with 30-day sparklines on the Services list and a
monthly figure on the service Overview. Streaming parser handles
combined and vhost-prefixed formats plus rotation spillover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:21:48 -04:00
Juan DenisandClaude Fable 5 91e6f5cb29 feat(panel): wire imports, database tools and quick wins into the app
Blueprint/model/job registrations for the migration pipeline, speed
test, metadata guard, login-link + SSO reapers (seeded with the
builtin schedules), demo-mode middleware, /imports route, API module
aggregation and SCSS imports; CHANGELOG for the batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:25:04 -04:00
Juan DenisandClaude Fable 5 d01d82036b feat(imports): panel-migration pipeline with cPanel, DirectAdmin and Hestia importers
Format-pluggable analyse->plan->run pipeline (migration 051) that maps
legacy panel backups to ServerKit apps, managed DBs (preserving MySQL
password hashes where portable) and cron entries, as resumable jobs
with per-step logs, SSRF/tar-traversal guards, and a 5-step Import a
Site wizard at /imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:24:29 -04:00
Juan DenisandClaude Fable 5 76a688fb49 feat(databases): managed DB users and one-click Adminer SSO
managed_database_users rows (migration 050) track users/grants so they
survive restarts; SSO launches an on-demand Adminer container
pre-authenticated with a single-use, 5-minute shadow credential scoped
to one database, reaped on schedule. Also mounts the config tuner in
the explorer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:24:15 -04:00
Juan DenisandClaude Fable 5 bd7624130c feat(auth): one-time login links and demo mode
Single-use, short-TTL, optionally IP-bound login URLs (hash-at-rest,
migration 049, hourly reap job) minted from Settings > Users; plus a
config-gated demo mode that 403s mutating API calls and seeds read-only
credentials on the login page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:23:50 -04:00
Juan DenisandClaude Fable 5 9edeb68f85 feat(apps): first-class per-app CPU/memory limits
cpu_limit/memory_limit on Application (migration 048), emitted into the
generated compose and the ServerKit override for live re-apply, with a
Resource Limits panel (usage vs limit) in Service Settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:23:39 -04:00
Juan DenisandClaude Fable 5 e93faac226 feat(servers): store the agent's self-reported footprint dirs
The agent (serverkit-agent bbaf4e2) now sends install_dir and
config_dir in system_info. Panel side:

- servers.agent_install_dir + agent_config_dir columns (migration 047,
  idempotent guard like its siblings) with the same coalesce contract
  as the other system_info fields — a transient payload gap never
  wipes a stored value
- exposed in Server.to_dict and /servers/available; TargetPicker
  passes them on the agent target
- the File Manager rail prefers the reported config dir over the
  installer convention (backslash-normalized for Windows agents;
  agent-logs link derives <config_dir>/logs)
- proving test: full system_info -> DB -> available round trip +
  coalesce survival

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:36:34 -04:00
Juan DenisandClaude Fable 5 136f8b195a refactor(marketplace): retire the legacy Extension catalog (#51)
The DB-seeded Extension/ExtensionInstall catalog was dead weight:
nothing ever populated it on a real panel, so it fed Browse an empty
third lane redundant with the real sources (builtin folder scan, remote
registry, InstalledPlugin state), and its orphaned per-user config
plumbing connected to nothing a plugin could read (superseded by #49).

Removed: the two models, marketplace_service.py, all legacy /marketplace
CRUD/install/rate endpoints (the blueprint keeps only the registry
endpoints), the frontend published lane (catalog mappers, install/
uninstall handlers, rating stars, published stats) and its API methods.
Migration 046 drops the orphaned tables.

Pipeline + registry suites 29 green; lint 0 errors; build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:21:45 -04:00
Juan DenisandClaude Fable 5 4ca07fb71c feat(extensions): per-plugin config store + Configure form (#49)
Completes the deferred half of #30. InstalledPlugin gains config_json
(migration 045); admin-gated GET/PUT /api/v1/plugins/<id>/config
persists values; plugins read them via the new plugins_sdk.config(slug)
accessor. The Marketplace Installed tab shows a Configure button for
plugins declaring a config_schema and renders a real form
(string/number/integer/boolean/enum/secret fields, defaults, hints).
Values may hold secrets so they are excluded from to_dict() and only
served by the admin endpoint. Documented in docs/EXTENSIONS.md.

2 new tests (config round-trip + SDK accessor + secret non-leak;
non-object rejection); pipeline suite 18 green; lint 0 errors; build
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:09:39 -04:00
Juan DenisandClaude Opus 4.8 58e80b2e18 feat(databases): track managed databases as first-class resources
Persist the databases ServerKit provisions (and let you adopt existing ones) so
backups, connection strings, and the UI have durable state -- beside, not
replacing, the live-introspection explorer. Not a DBaaS (no pooling/replicas).

- ManagedDatabase model (engine/name/host/port/origin/owner; admin secret
  Fernet-encrypted, unique per engine+host+name) + migration 044.
- managed_database_service: record_provisioned (additive on the create routes),
  adopt (idempotent), build_connection_uri (real URI, masked by default),
  sync_state (drift detection), delete (optional drop), protect.
- Backup FK fix (the headline): a managed-DB BackupPolicy uses
  target_id = managed_databases.id plus a `managed` marker, and _resolve_target
  resolves the descriptor (incl. decrypted secret) from the row. Legacy
  JSON-descriptor policies keep resolving unchanged (backward compatible).
- API under /api/v1/databases/managed (list/create/get+sync/delete?drop=/adopt/
  connection-uri/protect); the connection-uri reveal is admin-only and audited.
- Frontend: a "Managed" panel on the Database Explorer (connection-string
  reveal/copy, one-click Protect, Untrack/Drop).
- Tests: provisioning persists a row, adopt idempotent, connection URI per
  engine + masking, sync_state drift, protect resolves via the real FK, legacy
  policy still resolves, API masking/admin-gating/audited reveal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:44:48 -04:00
Juan DenisandClaude Opus 4.8 cbbd410bc8 feat(services): first-class managed volumes for apps
Give a service tracked, named persistent storage that survives redeploys,
replacing fragile relative bind mounts (./mysql-data:/var/lib/mysql).

- AppVolume model (namespaced serverkit-app-{id}-{slug}, unique per mount path)
  + migration 043.
- volume_service: create (docker volume + row, dup-path refusal), list_for_app
  (rows + live inspect), delete (row-only by default; wipe only when the app is
  stopped and no running container uses it), measure, convert_bind_mount,
  run_args, compose_fragment, host_paths_for_app.
- Wired into the single-container deploy (_deploy_docker passes -v specs to
  docker run) and new compose stacks (create_docker_app gains named_volumes ->
  top-level volumes: block).
- API under /api/v1/apps/<id>/volumes (list/attach/detach?wipe=/convert), ACL
  via ResourceGrantService.
- Frontend: Storage panel in Service detail Settings (attach/detach with an
  off-by-default "also delete data" checkbox, disabled while running).
- Tests: create + namespaced volume, dup-path refusal, wipe safety, single-
  container -v proving test, compose top-level named-volume generation, convert,
  API CRUD.

Scoping: volumes are backup-addressable (mountpoints exposed; protect via the
files target) rather than force-folded into the app's single-parent incremental
tar chain, which would break restore. Live compose files (esp. the WP multidev
stack) are not rewritten -- convert_bind_mount is the opt-in migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:57:07 -04:00
Juan DenisandClaude Opus 4.8 298c556ec7 feat(connections): authenticate private container registry pulls
Store per-registry credentials (Fernet-encrypted) and run `docker login`
before pulling a private image — GHCR, Docker Hub, GitLab, ECR, or any
generic registry. Public/anonymous pulls are unchanged.

- ContainerRegistry model (secret encrypted at rest; to_dict never returns it)
  + nullable applications.registry_id; migration 042.
- container_registry_service: CRUD + login/logout/test_connection (secret piped
  via stdin, never on argv) + lazy ECR token exchange.
- DockerService.pull_image gains a registry= arg (login -> pull -> logout in a
  try/finally). Wired into the deploy path (_deploy_docker) and the compose
  path (start_app / apply_image_update): a docker login authenticates both
  `docker pull` and `docker compose pull`.
- API under /api/v1/connections/registries (list=auth, writes=admin); registries
  join the unified /connections list.
- Frontend: Connections hub "Container registry" card (add/test/remove with a
  provider selector) + a per-app registry picker in Container Ops.
- Tests: encrypt/decrypt, stdin-not-argv, login->pull->logout ordering incl.
  finally-on-failure, deploy + compose auth-before-pull, API CRUD/masking/admin.

Deviation from the plan's "single injection point": docker apps run via compose,
so login was wired into both the compose and run_container paths; remote-agent
compose is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:35:44 -04:00
Juan DenisandClaude Opus 4.8 6b35bb996d feat(wordpress): global plugin library for operator-owned plugins
Register WordPress plugins from GitHub repos or local paths in a global
library, then install or update them across any managed WP site from one
place. The per-site Plugins tab gains a "managed" badge and an
"update from library" action.

- models: WordPressCustomPlugin (library) + WordPressSitePlugin (installs)
- service: WordPressPluginLibraryService - sync, install, update, scan,
  with a per-slug cache dir under paths.SERVERKIT_CACHE_DIR
- api: /plugins/library CRUD + sync/install/bulk-update on wordpress_bp
- migration 041_wordpress_plugin_library
- frontend: WordPressPluginLibrary page + "Plugin Library" tab; managed
  badges and library-update action in PluginsTab
- tests: test_wp_plugin_library

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:08:42 -04:00
Juan DenisandClaude Opus 4.8 f76da49ab4 feat(sites): base-domain registry so sites can publish under multiple domains
Managed sites had exactly one base domain (the sites_base_domain setting), so
every site was <slug>.<that-one>. Add a registry (site_base_domains) letting an
operator register several — example.com, toto.com — each with its own DNS mode,
HTTPS state, and DNS provider connection, so a new site can be published under
any of them (a.example.com or a.toto.com), defaulting to the one marked default.

- New SiteBaseDomain model + migration 040 (own dns_provider_config_id + cert
  state per base; exactly one is_default).
- SiteBaseDomainService: add/remove/set_default/update, and ensure_seeded which
  materialises the legacy single sites_base_domain setting into the default row
  the first time a second domain is registered — so old and new never disagree.
- SiteDomainService is now base-aware and back-compatible: subdomain_for(name,
  base), https_enabled(base), dns_mode(base), wildcard_cert_paths(base) and
  give_subdomain(app, base) take an optional base; covering_base(host) picks the
  base (longest match) whose wildcard cert covers a host so vhosts get the right
  per-base cert; base_domain() returns the default and still falls back to the
  legacy setting / SITES_BASE_DOMAIN config for un-migrated & dev installs.

Existing single-base installs are untouched (zero rows -> setting fallback);
45 existing site-domain tests still green. New coverage in
test_site_base_domains.py: seeding, default promotion/demotion, per-base
https/dns-mode/cert, longest-match cover, and give_subdomain under a chosen base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:07:00 -04:00
Juan DenisandClaude Opus 4.8 afd042896b refactor(unify): §3 — canonical DeploymentJob links + unified /deployments surface
Additive foundation + one read namespace for the three parallel deploy records
(versioned build Deployment, webhook GitDeployment, DeploymentJob executions).

Backend:
- DeploymentJob gains deployment_id / git_deployment_id / webhook_id FKs +
  commit_hash / image_tag / container_id, so it can be the canonical execution
  record pointing at the release ledgers it produces (surfaced in to_dict).
  Migration 039 (idempotent, additive).
- New /api/v1/deployments surface:
    GET /apps/:id/history?source=build|webhook|all  → federated, source-tagged
    GET /:id                                         → build Deployment detail
  and the DeploymentJob surface re-mounted at /api/v1/deployments/jobs (alias of
  /deployment-jobs). Per-system blueprints (/builds, /deploy, /git) unchanged.

Deliberately NOT done (plan §3 Phase 1/2 deep merge, with rationale): folding
GitDeployment into Deployment+DeploymentJob and async-ifying the currently
synchronous build/webhook deploys. Recon confirmed those paths are synchronous
with distinct failure/timeout semantics and GitDeployment carries rollback state
(snapshot_data, rolled_back_to_id); flipping them to the queue can't be verified
without a live build/runner, so it's deferred behind the additive links above.

Verified: 6 new tests + previews/config-snapshot regression pass (35).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:52:04 -04:00
Juan DenisandClaude Opus 4.8 45e01f8731 refactor(unify): §6 Phase C6a — email provider usage flags
Foundation for folding the single-row email_relay_config into the multi-row
EmailProviderConnection so one table drives notifications and the Postfix relay.

- EmailProviderConnection gains uses_notifications (default true), uses_relay
  (default false), relay_priority (default 0); surfaced in to_dict.
- Notification selection (default_provider) now restricted to uses_notifications
  rows, so relay-only connections are never used to send notifications.
- New relay_provider() selects the active SMTP connection by relay_priority.
- add_provider() accepts the flags; uses_relay honored only for provider=smtp
  (API providers can't be a Postfix smarthost).
- Migration 038 (idempotent additive columns).

Additive — existing providers default to uses_notifications=true / no relay.
Verified: 4 new tests + notification-bus suite pass (24 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:32:41 -04:00
Juan DenisandClaude Opus 4.8 5d606a6030 refactor(unify): §8 Phase B1 — generalize BackupPolicy to all target types
First slice of the backups/snapshots unification. Makes the policy-driven
BackupPolicy/BackupRun system able to describe every data-protection target,
not just application/wordpress_site — the foundation for folding the legacy
BackupService (database/files) into one system.

- BackupPolicy gains target_subtype + target_meta_json (+ get/set helpers,
  surfaced in to_dict). VALID_TARGET_TYPES = application, wordpress_site,
  database, files, server.
- BackupPolicyService.validate_target_type(); get_or_create_policy() accepts
  and (idempotently) refreshes target_subtype/target_meta for non-resource
  targets like database/files.
- Migration 037 (idempotent, additive columns).

Additive only — no behavior change for existing app/WP policies.
Verified: 6 new tests + existing backup-protection suite pass (18 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:16:45 -04:00
Juan DenisandClaude Opus 4.8 807ca7b05e feat: per-service targeting for the compose env overlay
A variable can now land on one compose service instead of all of them. Both
EnvironmentVariable and SharedVariable gain an optional target_service
(NULL = all services), threaded through the shared resolver as an inclusion
filter (a var applies to a service when it targets all OR that service).

EnvService.get_effective_env_for_services(app, services) computes each service's
merged env (all-services vars + that-service vars, local overriding shared), and
ComposeEnvService builds the override's per-service environment from it — so a
var targeted at "db" only appears under the db service, untargeted vars appear
everywhere. The single-container docker-run path is unchanged (one container
gets the full effective env regardless of target).

API: env-var create/update and shared-variable create/update accept
target_service; GET /apps/<id>/compose-services lists a compose app's services.
UI: the Environment tab shows an "Applies to" selector (gated to compose apps via
the service list) with a per-row target chip; the shared variable editor gains a
target-service field.

Migration 036 adds the two columns; single Alembic head; chain applies from
empty. Proving tests cover per-service distribution + the resolver helper. Full
backend suite: 949 passed, 14 skipped; frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:19:41 -04:00
Juan DenisandClaude Opus 4.8 e2438db9d1 feat: UX hardening for the new organization/proxy/snapshot surfaces
Tightens the seams the recent features introduced:

- Secret precedence/conflict: the embedded shared-variables panel now resolves
  via the hierarchical endpoint, shows a per-variable source-scope badge +
  precedence legend, and flags keys that are also set locally with a
  "Set locally — local value applies" badge (shared groups are a facade and are
  not injected into the container; the app's own env var is authoritative).
- Split inventory: apps now expose project_name/environment_name; the Services
  list shows a project/environment badge (or "Unassigned") and a "Move to
  project" bulk action (POST /apps/move-to-project, workspace-validated,
  null-unassigns).
- Fleet proxy actionability: fleet_overview rows carry a {level,text}
  recommendation (mismatch warning / idle stack / empty host / aligned),
  rendered as a per-row pill.
- Onboarding wizard: "Step X of 5" + progress bar + rough ETA, and a
  collapsible step-history trail so returning users get a clean summary.
- Config diff: a plain-language summary ("3 environment variables and the image
  tag (app:1.2.1 -> app:1.1.9) changed") above the technical diff and echoed in
  the restore confirmation.
- Container status: an always-visible "N issues" / "healthy/total" badge so a
  degraded sub-container isn't hidden behind hover.
- Sidebar: Projects, Shared Variables and Workspaces collapse under a single
  "Organization" group.

No schema change (project/environment names are derived). Backend suite: 935
passed, 14 skipped; frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:15:16 -04:00
Juan DenisandClaude Opus 4.8 0e71ab346a feat: make the ingress-plane boundary explicit (host Nginx vs proxy stack)
Both reverse proxies bind 80/443, so pointing the same domain at host Nginx and
a Dockerized proxy stack is an operational footgun. This tags each app with the
plane it expects and surfaces disagreements instead of leaving them implicit.

- Each app carries an `ingress_plane` ('nginx' | 'proxy_stack'); NULL reads as
  the host-Nginx default. New pure helpers in app/utils/ingress.py: only
  container-based services are proxy-stack eligible, and a requested proxy_stack
  is forced back to nginx for PHP/WordPress/static/Python apps. to_dict() also
  exposes `ingress_proxy_eligible`.
- ProxyStackService.ingress_audit(server) reports which of a server's apps
  disagree with its active proxy mode; fleet_overview now includes per-server
  app_count + mismatch_count. New GET /servers/<id>/proxy/ingress-audit.
- Create wizard offers the proxy stack only for Docker/Compose services and
  keeps the default on Nginx; app detail shows an Ingress badge; the fleet proxy
  dashboard flags mismatch counts; the server proxy panel warns and lists the
  specific mismatched apps with reasons.

Migration 035 adds applications.ingress_plane. Backend suite green (incl. 8 new
ingress tests); frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:57:40 -04:00
Juan DenisandClaude Opus 4.8 478b24a3cb feat: Phase 3 team & scale — projects/environments, shared resources, PR previews, proxy stacks
- Project / Environment hierarchy (C2): Workspace -> Project -> Environment
  org model. New Project/Environment models + CRUD APIs, nullable
  applications.project_id/environment_id (opt-in, no destructive backfill),
  ProjectService.ensure_default, a Projects list + ProjectDetail (env tabs),
  a project/environment selector in the create wizard, and sidebar nav.

- Polymorphic shared resources facade (C9): ResourceTag + SharedVariableGroup
  /SharedVariable/attachment models keyed by (resource_type, resource_id),
  reusing the env-var Fernet encryption; SharedResourceService resolves merged
  variables (most-recent attachment wins) with secrets masked. /api/v1/shared
  endpoints + reusable TagsPanel/EnvironmentVariablesPanel + a Shared Variables
  management page. Existing per-resource tables untouched (facade first).

- PR preview environments (C3): ApplicationPreview + settings models, a
  PreviewService with pure domain-templating + reconcile (open PRs vs active
  previews) cores and best-effort provisioning (incl. guarded WordPress
  file+DB clone), a pull_request webhook endpoint, preview.* jobs, and a
  Previews tab on the app detail page.

- Per-server managed proxy stack (C6): opt-in Dockerized Traefik/Caddy as a
  Compose stack (host Nginx stays default and superior for PHP/WordPress).
  ProxyStack model + ProxyStackService with pure compose/config generators and
  versioned config backups, /api/v1/servers/<id>/proxy endpoints, and a Proxy
  tab on the server detail page.

Migrations 031-034 add the new tables/columns; the full chain applies cleanly
from an empty DB. Full backend suite: 871 passed, 14 skipped; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:33:44 -04:00
Juan DenisandClaude Opus 4.8 a550f16baf feat: Phase 2 developer experience — build packs, config snapshots, catalog certification
- Build packs / zero-Dockerfile deploys (C1): new BuildpackService detects the
  stack from a cloned repo (Node/Python/Go/PHP/Ruby/Rust/static), produces a
  transparent build plan and a generated Dockerfile, and persists the plan +
  overrides on the Application. POST /api/v1/buildpacks/{detect,generate}; a
  BuildpackPreview shows the detected plan + editable overrides + Dockerfile in
  the create wizard and the app Build tab. Builds on the existing build_method
  plumbing instead of duplicating it.

- Deployment config snapshots + diff engine (C4): ConfigurationService captures
  an immutable, hashed snapshot of resolved config (env keys + masked values,
  domains, image/tag, build method, volumes) at every deploy and rollback; new
  DeploymentSnapshot model + /api/v1/apps/<id>/snapshots[/diff|/restore]. A
  DeploymentTimeline + ConfigDiffModal surface "what changed" and one-click
  restore in the app Deploy tab. Secret values are never serialized.

- Template catalog certification (C5 cont.): a whole-catalog validation test
  proves all 105 declarative templates conform to the documented schema; fixed
  the stale index.json (was 63/105 entries) to list every bundled template.

Migration 030 adds the buildpack columns + deployment_snapshots table. Full
backend suite: 820 passed, 14 skipped; frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:49:04 -04:00
Juan DenisandClaude Opus 4.8 f47d0e1428 feat: Phase 1 foundation — status aggregator, API scopes, onboarding, template magic vars
Four foundational enhancements toward the deploy-code developer experience:

- Container status aggregator (C7): new ContainerStatusService applies a
  deterministic priority hierarchy (degraded > restarting > running:unhealthy >
  starting > running:healthy > exited > unknown) over an app's containers,
  cached with a short TTL. Exposed at /api/v1/status and pushed over a new
  container_status Socket.IO channel; Applications page now renders an
  aggregated ContainerStatusPill with sub-status tooltip.

- API token scopes + sensitive data masking (C10): require_scope() decorator
  enforces fine-grained scopes for API-key requests (pass-through for JWT),
  a canonical SCOPES catalog at /api/v1/api-keys/scopes, and a reusable
  sensitive_data_filter (mask_sensitive) honoring secrets:read. Frontend gains
  a grouped ApiKeyScopesModal picker and per-key scope pills.

- Server onboarding state machine (C8): new onboarding lifecycle
  (pending -> validating -> installing_prerequisites -> installing_docker ->
  pairing_agent -> ready/failed) with ServerOnboardingService, a
  server_onboarding_logs table, servers.onboarding_* columns, REST endpoints,
  and an OnboardingWizard surfaced on the server detail overview.

- Declarative template catalog (C5): documented catalog schema and
  magic-variable resolution (SERVICE_PASSWORD_/USER_/FQDN_/URL_/BASE64_*)
  layered onto the existing YAML template loader without changing behavior for
  templates that don't use them; GET /api/v1/templates/catalog/schema.

Migration 029 adds the onboarding table + columns. 60 new tests pass; frontend
builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:23:47 -04:00
Juan DenisandClaude Opus 4.8 d344302ac1 feat(domains): unify domains & DNS into one surface + admin-only Connections
- /domains is one table: app-linked domains + live zones from connected DNS
  providers (Cloudflare, ...) with provider badges. New GET /dns/portfolio
  (aggregates provider zones, marks which are adopted) and idempotent
  POST /dns/adopt (lazy-materialize a managed zone on demand).
- Every row opens one drawer = the unified DNS records surface
  (components/domains/DomainDnsPanel): live Cloudflare records via
  GET /dns/provider-records (no adopt needed), type badges, proxied=>HTTPS hint,
  inline Add record, deep-links to Cloudflare ops / full DNS page. Fixed-layout
  table (no horizontal scroll); actions moved into the drawer header.
- Registration expiry/registrar from Cloudflare Registrar + lazy RDAP fallback
  (GET /dns/registration), persisted/cached in the new domain_registrations
  table and merged into the portfolio so it survives a refresh. New
  frontend/src/utils/expiry.js formatExpiry() date util.
- Connections moved to admin-only (Settings Admin group + isAdmin gate;
  @admin_required on /api/v1/connections); CF connection card links to Domains.
- Cloudflare client: expose each zone's account_id; add list_registrar_domains.
- Tests: backend/tests/test_dns_portfolio.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:34:55 -04:00
Juan DenisandClaude Opus 4.8 2f1999018d feat(cloudflare): Cloudflare Tunnels (Phase 5)
Add a Tunnels tab for creating and managing Cloudflare Tunnels (cloudflared) —
exposing a local/private service through Cloudflare's edge with no public IP.
Clearly distinct from ServerKit's WireGuard remote-access tunnels (always
labelled "Cloudflare Tunnel" in the UI). Account-scoped; account resolved from
the zone.

- CloudflareTunnel model: records the connector token (encrypted at rest) and
  owning connection per (account, tunnel_id).
- CloudflareClient: cfd_tunnel create (config_src=cloudflare) / list
  (is_deleted=false) / delete / token / connections / configurations get+put.
- CloudflareService: create (returns + stores the one-time token, yields the
  cloudflared install command), list (managed-flagged), delete, fetch install,
  and public-hostname routing — sets the ingress config (always re-appends the
  required http_status:404 catch-all) and best-effort upserts the proxied CNAME
  hostname→<id>.cfargotunnel.com via the shared ownership-guarded DNS path.
- /api/v1/cloudflare/zones/:id/tunnels* routes (read any user; writes admin).
- TunnelsPanel (create form + one-time install box w/ copy, tunnel list with
  per-tunnel install reveal and public-hostname add/remove); api methods; SCSS.

Tests: list is_deleted param, create config_src, name required, token stored
encrypted + install string, managed-flagging, delete prunes local row, ingress
built with catch-all last (7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:34:32 -04:00
Juan DenisandClaude Opus 4.8 02774f741e feat(cloudflare): Workers edge hosting (Phase 4)
Add a Workers tab to the Cloudflare Zone Settings page for deploying edge
scripts and managing Worker routes. Workers are account-scoped, so the owning
account is resolved from the zone — the whole feature reuses the existing
Cloudflare DNS connection (no new credential).

- CloudflareWorker model: records the source ServerKit uploaded per (account,
  name) so a worker can be tracked and re-deployed (created via create_all).
- CloudflareClient: zone→account lookup, list/delete scripts, and a stable
  multipart PUT /scripts/{name} module upload (the still-beta resource-oriented
  Workers API is deliberately avoided per the roadmap's fallback note), plus
  zone Worker routes list/add/delete.
- CloudflareService: list (live scripts flagged when ServerKit-managed + routes),
  deploy (validated lowercase name, records source, optional route attach),
  delete, route add/delete.
- /api/v1/cloudflare/zones/:id/workers* routes (read any user; writes admin).
- WorkersPanel component (deploy form with a starter script, worker list, routes
  list); api methods; SCSS.

Tests: multipart upload shape, name validation, deploy records source, list
managed-flagging, delete prunes the local row (5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:36 -04:00
Juan DenisandClaude Opus 4.8 1c641b2a9d feat(backups): backup protection backend — policy, scheduler, smart backups, API
Add a real Protection layer for WordPress sites and applications: one BackupPolicy per target (cron mirrored into a ScheduledJob on the unified job bus), BackupRun history, cost estimation, and full/incremental smart backups with chain-aware retention and remote verify.

- models BackupPolicy + BackupRun (migration 028)
- backup_policy_service: CRUD, schedule wiring, backup.policy.run/restore.run handlers (distinct from legacy backup.run), chain-aware retention, restore chain
- backup_cost_service: storage-cost rates ($/GB/mo); local free by default
- backup_service: tar --listed-incremental + zstd/gzip compression tiers
- API: policy/runs/restore/verify/delete on /wordpress/sites and /apps; cost-rates + cost-summary on /backups
- restore.completed/failed notification events
- tests: cost, schedule sync, kind decision, chain-aware retention, restore wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:53:23 -04:00
Juan DenisandClaude Opus 4.8 38a258ee2d feat(dns): change log + admin notice on failure (Phase 2 backend)
#6 dns_changes audit log (migration 027) — every record write ServerKit sends to a
connected provider is recorded at the write choke point (guarded_upsert /
guarded_delete): create/update/delete, source, result (ok|error|conflict|skipped),
and the value set. Powers the "Changes to your Cloudflare" activity feed.
GET /api/v1/dns/changes lists it (filter by config_id / zone / result).

#8 A real write failure now surfaces an admin notice via the Notification Bus
(dns.sync_failed) instead of being swallowed to the log. Best-effort — surfacing
never breaks the write it describes.

Tests: test_dns_change_log.py. The frontend activity/mirror UI lands separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:34:17 -04:00
Juan DenisandClaude Opus 4.8 b1b5ed1161 feat(dns): ownership ledger + never-touch-foreign guard + zone mirror (Phase 1)
#3 managed_dns_records ledger (migration 026) — the single source of truth for
records ServerKit created in a connected provider zone, written by every write
path (zones page, DDNS, WordPress auto-DNS, email).

#4 DnsOwnershipService.guarded_upsert/guarded_delete: automatic paths refuse to
overwrite or delete a record ServerKit didn't create (the user's own records),
while the explicit Zones page adopts a matching record and takes ownership.
Wired into both CF paths (DNSZoneService._cloudflare_sync and
DNSProviderService._cloudflare_set_record/_delete_record). ensure_a_record now
surfaces a 'foreign_record' reason instead of clobbering.

#5 Live zone mirror: CloudflareClient.list_records +
DNSZoneService.list_provider_records classify every live record as serverkit vs
external; GET /api/v1/dns/<id>/mirror.

Tests: test_dns_ownership.py. Existing set_record mocks updated for the new
source kwarg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:24:53 -04:00
Juan DenisandClaude Opus 4.8 1127243d1d feat(dns): unify Cloudflare credentials behind one connection + shared client
Phase 0 of the DNS roadmap.

#2 Shared Cloudflare client (app/services/dns/): DnsCredential + DnsRecordSpec +
CloudflareClient. Both DNSProviderService and DNSZoneService delegate to it, so
auth (scoped token / global key), the CAA structured-data wire format, MX/SRV
priority parsing, and idempotent upsert live in one place. Fixes dup-on-recreate,
silent update/delete no-op, and missing proxied/priority on the provider path.

#1 Single credential source: new FK dns_zones.dns_provider_config_id (migration
025) + DNSZoneService._resolve_credential (linked -> auto-discover-by-domain ->
legacy inline-token). create_zone accepts a connection id; link_legacy_zones()
converges inline tokens onto encrypted connections at boot. The /dns create-zone
modal now picks a connection instead of pasting a token. DDNS rides it for free.

Tests: test_dns_cloudflare_client.py, test_dns_credential_resolution.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:12:31 -04:00
Juan Denis 652d903ae7 Normalize emails, add panel_origin & domain-aware URLs
Make email handling case-insensitive (queries + model normalization) to prevent duplicate accounts. Add SiteDomainService.panel_origin() helper and use it across services to build public URLs (Gitea, WordPress, Roundcube) and Django ALLOWED_HOSTS. Add optional custom-domain support when creating WordPress sites and propagate domain to Roundcube install. Improve environment domain fallback to prefer panel/base domains over .localhost. Small Dockerfile healthcheck tweak (use 127.0.0.1). Update install/update/build scripts to always (re)create virtualenv locally for release installs. Remove unused frontend CreateSiteModal and expose domain field in WordPress page.
2026-06-22 14:37:44 -04:00
Juan Denis 3d3975fa3a feat(telemetry): unified system event stream with correlation
Add SystemEvent model, TelemetryService, /api/v1 telemetry endpoints, and
a /telemetry UI page. Instrument audit, notification, webhook, backup,
deployment, and monitoring paths. Add correlation_id to Notification,
EventDelivery, and DeploymentJob so related events can be grouped.

- 18 backend tests added and passing
- Frontend production build clean
2026-06-21 20:19:57 -04:00
Juan DenisandClaude Opus 4.8 a661212d50 feat(notifications): queue-backed Notification Bus + email system
Reusable, non-blocking notification layer on top of the Queue Bus:
notify.send(event, to, data) records a Notification + per-channel
NotificationDelivery, queues them, and a daemon consumer delivers via
pluggable channel adapters (in-app, email, chat) inheriting retry,
backoff and dead-letter.

- Jinja2 email system: branded, dark-mode, Outlook-safe base layout +
  per-event templates (security/backup/monitoring/welcome/invitation).
  The legacy inline f-string email was removed and now renders here.
- In-app center API (inbox/unread-count/mark-read) + per-user Socket.IO push.
- Admin delivery log (stats/filter/retry).
- Email providers (SMTP/SendGrid/Postmark/SES/Mailgun) with encrypted
  credentials + test; the email adapter prefers the default provider and
  falls back to the legacy SMTP config; surfaced in ConnectionRegistry.
- NotificationService.send_all() is now a non-blocking shim that routes
  system alerts through the bus. Migrations 023 + 024.

Tests: backend/tests/test_notification_bus.py (20). Full suite: 391 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:57:02 -04:00
Juan Denis 9b4618b24b feat(queue-bus): introduce ServerKit Queue Bus under Operations
- Add pluggable queue bus with SQLAlchemy backend (Redis-ready interface)
- Models: QueueGroup, Queue, QueueMessage with retries, visibility timeout,
  delayed delivery, priority ordering, and dead-letter handling
- REST API at /api/v1/queue for groups, queues, messages, and stats
- Plugin SDK: from app.plugins_sdk import queue
- Operations UI page with group/queue/message management and send-message form
- Migrate webhook deliveries from raw threading.Thread to the queue bus
- Add webhook consumer with retry/dead-letter support
- Migration 022_queue_bus and backend tests
2026-06-20 22:25:06 -04:00
Juan DenisandClaude Opus 4.8 cb83d24f7c feat(secrets): scope vaults & webhooks to the active workspace
Vaults and webhook endpoints now carry a nullable workspace_id FK and are filtered by the active workspace context (X-Workspace-Id header or ?workspace_id), mirroring the servers/apps scoping (#33). New rows are stamped with the resolved workspace; with no context the view stays unscoped. Migration 021 adds the columns + indexes and backfills existing rows to the Default workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:47:37 -04:00
Juan DenisandClaude Opus 4.8 ada030553a feat(security): per-app WAF (ModSecurity v3 + OWASP CRS)
- WafPolicy model (per app: mode off/detect/block, paranoia level, anomaly
  threshold, disabled CRS rule ids).
- WafService: render_rules (SecRuleEngine + CRS include + paranoia/anomaly +
  SecRuleRemoveById), nginx_snippet, install/status (distro-aware,
  best-effort), apply() that writes per-app rules under
  /etc/nginx/serverkit-conf.d/waf and idempotently injects an include into
  the app vhost (graceful manual_include fallback), and audit-log event
  parsing.
- API /api/v1/waf: per-app policy GET/PUT, apply, events, install status.
- 29 tests: rule rendering per mode, rule-disable, nginx snippet, policy
  validation, audit-log parsing, API round-trip + auth.

Backend only; WAF UI + verified install on a real nginx+ModSecurity host are
follow-ups. Integration is additive — existing nginx/site management untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:02:24 -04:00
Juan DenisandClaude Opus 4.8 633c626d43 feat(docker): horizontal auto-scaling (CPU thresholds, min/max, cooldown)
- ContainerScalePolicy model (per-app: enabled, service_name, min/max
  replicas, cpu high/low %, cooldown, current_replicas).
- ContainerScaleService: evaluate() scales a compose service up/down by one
  replica when average CPU crosses the high/low thresholds, within bounds and
  outside the cooldown; scale_to() for manual; sweep() for cron. Uses
  `docker compose --scale`; local + scale-capable services only.
- apps.py: GET/PUT /<id>/scale-policy, POST /<id>/scale,
  POST /<id>/scale/evaluate, POST /scale-sweep (admin).
- 10 tests: bounds clamping, scale up/down, hold-at-max, cooldown, disabled,
  unknown-cpu, manual endpoint, auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:13:50 -04:00
Juan DenisandClaude Opus 4.8 6554e79821 feat(docker): container auto-sleep (policy, manual sleep/wake, idle sweep)
- ContainerSleepPolicy model (per-app: enabled, idle_timeout_minutes,
  last_activity_at, asleep) + a sleep badge in Application.to_dict.
- ContainerSleepService: sleep/wake an app (compose_down/up or
  stop/start_container, local), record_activity, and sweep_idle() which
  stops enabled+awake apps idle past their timeout — and never sleeps an app
  with no activity baseline.
- apps.py endpoints: GET/PUT /<id>/sleep-policy, POST /<id>/sleep,
  POST /<id>/wake, POST /sweep-idle (admin, cron-drivable).
- 9 tests: policy, sleep/wake, docker-failure, remote guard, sweep
  selection, no-baseline skip, endpoints, auth.

Follow-ups: traffic-based idle detection (feed record_activity from the nginx
log) and request-triggered wake-on-demand; remote-server sleep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:54:40 -04:00
Juan DenisandClaude Opus 4.8 7dc0bffb96 feat(docker): image-digest update detection
- ImageUpdateCheck model + ImageUpdateService: compare an app's local
  RepoDigest against the registry's current index digest (via
  `docker buildx imagetools inspect`) for the same tag, and flag
  update_available when they differ. Shells out to docker like the rest of
  the Docker layer; degrades gracefully when the image is local-only or the
  registry is unreachable.
- API: admin POST /api/v1/image-updates/applications/<id>/check and
  GET /api/v1/image-updates/applications/<id> for the latest result.
- Application.to_dict gains a lightweight image_update badge (mirrors the
  existing image_scan badge).
- 7 tests: digest diff/match/unknown, no-image, badge, endpoint, auth gate.

One-click apply (pull + recreate) to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:22:18 -04:00
Juan DenisandClaude Opus 4.8 5f28f3e700 feat(dns): dynamic DNS — token-authenticated A/AAAA updates
- DdnsHost model: a hostname bound to a DNS zone plus a secret token; the
  token is masked in API responses except when first minted.
- DdnsService.update_ip reuses DNSZoneService so a configured provider
  (e.g. Cloudflare) is synced automatically; chooses A vs AAAA by IP family;
  no-op when the IP is unchanged.
- API: admin CRUD for hosts + a public, token-authenticated /ddns/update
  endpoint that a router or cron calls when its IP changes (IP from ?ip= or
  the request source). Wrong token -> 401, bad IP -> 400.
- 8 tests covering the service flow and both endpoints.

UI panel to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 03:12:07 -04:00
Juan Denis bf9fee131e feat: add secrets manager and inbound webhook gateway
- Encrypted vault + secret models with Fernet encryption
- Webhook endpoint + delivery log models with HMAC verification
- SecretVaultService / WebhookGatewayService business logic
- /api/v1/vaults, /secrets, /webhooks REST endpoints (JWT + public receive)
- Migration 020_secrets_webhooks
- Frontend Secrets & Webhooks page, API methods, sidebar, styles
- Add SERVERKIT_ENCRYPTION_KEY to .env.example
2026-06-19 01:33:46 -04:00
Juan Denis 47c92e0571 feat(auth): WebAuthn / Passkey support
- Add webauthn dependency.
- Add PasskeyCredential model and Alembic migration 019_passkey_credentials.
- Add PasskeyService for registration/authentication challenge flow.
- Add auth API endpoints:
  - POST /auth/passkeys/options/register
  - POST /auth/passkeys/register
  - GET /auth/passkeys
  - DELETE /auth/passkeys/<id>
  - POST /auth/passkeys/options/authenticate
  - POST /auth/passkeys/authenticate
- Expose passkey_enabled flag in User.to_dict().
2026-06-19 01:33:45 -04:00
Juan Denis 57cbdfd5de feat(security): per-image CVE scanning (grype) and SBOM generation (syft)
- Add ImageVulnerabilityScan and SbomArtifact models.
- Add ImageScannerService to download/install grype and syft into
  /var/lib/serverkit/scanners, run CVE scans, generate SPDX SBOMs,
  and enforce a configurable deploy gate.
- Add security API endpoints for scanner install, image scans,
  scan history, deploy-gate checks, and SBOM create/download/list.
- Include lightweight image_scan badge in Application.to_dict() for UI.
- Add Alembic migration 018_image_scan_sbom.
2026-06-19 01:33:45 -04:00
Juan Denis 70a70642c1 feat(services): support manual/local apps and zip uploads
- Extend Application model with source, compose_file, systemd_unit,
  managed_by, version, and upload_path columns.
- Add migration 017_manual_apps for the new columns.
- Add POST /apps/manual to register existing server paths/systemd units.
- Add POST /apps/upload with versioning, rollback, and .env preservation.
- Update DockerService compose helpers to accept a custom compose_file.
- Update ProcessService to query systemd units and compose projects.
- Update apps API start/stop/restart/status/delete to honor compose_file
  and avoid deleting unmanaged manual paths.
- Add Manual/Local and Upload ZIP source modes to NewService.
- Show source badges in Services list and disable Git controls for
  manual/upload apps in ServiceDetail.
- Add version list, rollback, and new-version upload UI for upload apps.
2026-06-18 22:16:00 -04:00
Juan DenisandClaude Opus 4.8 7cf8d4e18a feat(remote-access): NAT-traversing WireGuard tunnels to expose private services
Self-hosted tunneled reverse proxy: a service on a NAT'd host (e.g. a home
Jellyfin box) gets a public HTTPS hostname with no port-forwarding, by pairing
two agents over WireGuard. Implements Phases 0-3 of docs/REMOTE_ACCESS_ROADMAP.md.

Agent (Go):
- new internal/wireguard package: Manager interface + kernel backend (wg/ip)
  and a userspace netstack backend (wireguard-go + gVisor) so Windows/macOS
  peers need no driver/admin; in-process keygen returns only the public key
  (private keys never leave the host)
- wireguard:keygen/interface/peer/status/forward/unforward + firewall
  :allow_port/:deny_port commands; wireguard + firewall capability probes
- TCP forwarder (tnet.ListenTCP / net.Listen -> dial localhost) carries
  traffic to the real service behind a netstack peer

Backend (Python):
- Tunnel + ExposedService models; tunnel_netutil (subnet allocator, health);
  tunnel_broker_service (keygen->up->peer->verify, firewall open, reconcile-on-
  reconnect); tunnel_publish_service (forward -> nginx -> DNS -> auth -> cert)
- nginx remote-upstream vhost (WebSocket + proxy_buffering off)
- /api/v1/tunnels CRUD + /<id>/services publish/unpublish
- panel-authoritative reconcile hooked into the agent gateway on reconnect

Frontend (React):
- Remote Access page (tunnels + exposed services, status, teardown) and an
  "Expose a Local Service" wizard; new services/api/tunnels.js

Verified: go build (win/linux) + vet + go tests (forwarder + no-leak parsers);
py_compile + pytest (netutil, reconcile, nginx template); create_app boot;
eslint + npm run build. Live multi-host run pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:20:00 -04:00
Juan DenisandClaude Opus 4.8 d157fc514d Encrypt provider secrets at rest + add Namecheap registrar (#16, #17)
Encryption-at-rest (#16/#17) for the two provider stores still in plaintext:
- crypto.py: add decrypt_secret_safe (decrypt with plaintext fallback) + is_encrypted.
- DNS providers: encrypt api_key/api_secret on write; decrypt at point-of-use via
  _api_key/_api_secret in the Cloudflare/Route53/DigitalOcean/GoDaddy builders.
- Storage: encrypt secrets in save_config; decrypt in get_config.
- Idempotent encrypt_legacy_secrets() migrations run on startup, encrypting any
  pre-existing plaintext in place. Legacy values keep working via the decrypt
  fallback, so there is no flag day.

Namecheap registrar:
- RegistrarConnection.config_json column for non-secret extras (username, client_ip).
- RegistrarService: Namecheap XML domains.getList (portfolio + expiry), test, and
  MM/DD/YYYY -> ISO date parsing.
- Frontend: namecheap tile is live; the registrar form is provider-aware (GoDaddy
  key+secret vs Namecheap key + username + whitelisted IP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:33:09 -04:00