Vela holds one ServerKit API key as a host-side secret and polls the
fleet list, so this read route moves from bare @jwt_required() to
auth_required() + require_scope('servers:read') — a deliberate,
per-route jwt_only_census exception, not a sweep. JWT callers are
unchanged; a restricted key without servers:read gets 403 from the
scope middleware, no credentials get 401.
Scope choice: the existing servers:read catalog entry already covers
'list servers, agents, and metrics', and the serverkit-gui read routes
(capabilities/frame/surface, sibling repo) take the same scope, so one
key scope serves the whole Vela polling surface.
The census ceiling ratchets down to 600 and the migration inventory is
regenerated, as both ratchets require. tests/test_servers_api_key_scope.py
pins the four cases: scoped key 200, wrong-scope key 403, JWT 200,
anonymous 401.
A fresh panel no longer lets whoever reaches it first become admin:
registering the first account needs a one-time code the installer
prints (`serverkit setup-code` repeats it). SERVERKIT_ADMIN_EMAIL and
SERVERKIT_ADMIN_PASSWORD create the admin unattended instead.
Git apps now deploy what users expect: a repository with a compose
file deploys every service it declares, "Deploy latest" builds the
branch's newest commit, and push webhooks rebuild the containers.
Also fixes branch switching on single-branch clones, uploads of a
lone file, compose v1 port preflight, and CLI commands that ignored
their flags.
Flask refuses register_blueprint once the app has handled its first
request, so on any running panel — where login and dashboard requests
long precede the first extension install — the hot-load in
_register_plugin_blueprint always raised, the installer only logged a
'will load on restart' warning, and the extension's routes stayed
missing. Its pages rendered (frontend is static) while every API call
failed: GETs were swallowed by the tabs' console.error handlers, and the
first POST fell through to the static-file route, surfacing as the 405
reported for the Malware Scanner custom-path scan.
_register_blueprint_live waives Flask's setup guard for the duration of
the registration call. That is safe in this deployment shape: the panel
runs a single gunicorn worker with threads, so the process installing
the plugin is the process serving its routes, and werkzeug rebuilds its
URL matcher lazily on the next bind while in-flight requests keep the
matcher they already bound.
PR #145 closed the prefix-sibling hole in LogService.is_path_allowed but
left the gap its author named: unlike FileService, the log viewer had no
PROTECTED_ROOTS exclusion. On the install.sh layout the install dir sits
under the allowed root /opt, so the Log Files tab could read — and,
through clear_log, truncate — the backend .env, the SQLite instance dir
and the generated deploy config, the same hole the file manager closed
for GHSA-rm3m-9mvw-68fh.
Share FileService.PROTECTED_ROOTS rather than copying the list, so the
two surfaces cannot drift apart, and reject protected paths before the
allowed-root check. Panel logs live under /var/log and SERVERKIT_DIR,
which are untouched, so legitimate viewing is unaffected.
LogService.is_path_allowed resolved the requested path with realpath and
then asked whether the result merely started with one of
ALLOWED_LOG_DIRECTORIES. That is a text comparison rather than a
containment test, so a directory whose name only begins with an allowed
root was accepted as if it sat inside it. With the roots shipped at
backend/app/services/log_service.py:30-36, both /opt-private and
/homestead pass on the strength of /opt and /home, which is not what the
comment above the list at line 29 says the list does.
FileService guards the same roots and gets this right at
backend/app/services/file_service.py:96-100, comparing real_path == root
or real_path.startswith(root + os.sep). The same form appears at
backend/app/api/deploy.py:197, backend/app/services/upload_service.py:66
and backend/app/services/linked_panel_agent.py:251. LogService does not,
and its guard also gates truncation through clear_log at
backend/app/services/log_service.py:367.
The loop now compares against the separator-terminated root, so a
sibling sharing a name prefix is rejected while everything genuinely
inside a root still resolves, including paths reached through .. because
realpath has already collapsed them. A regression test covering the
shipped /opt constant, a sandboxed root and the clear_log door ships
with it.
The catalog certification test now reads SERVERKIT_TEMPLATES_DIR and
SERVERKIT_TEMPLATES_INDEX, so the official template registry's CI can
run the installer's own rules against its tree before a merge publishes
it to every panel. The build_repo_index() check stays bundled-only and
skips under the override.
The changelog had stopped at "1.11.1 - Unreleased" while 1.11.2 through
1.11.4 shipped: the Connect revocation, JWKS refetch, metrics-warning and
sidebar fixes are now recorded under the published 1.11.4 release.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtgcyJ75YEozymcrWDyedu
Keep connection persistence behind the service boundary, use typed errors and shared JSON parsing, and refresh reviewed inventories. The new GET connection catalog accepts API keys so API clients managing connections can discover IDs without exposing credentials; existing JWT-only routes stay unchanged. Add regression coverage and release notes.
Stock nginx (1.18-1.24 on Debian/Ubuntu) has no `.mjs` entry in mime.types,
so the runtime-extension vendor shims the import map points at came back as
application/octet-stream. Browsers refuse to execute an ES module with that
type, so every runtime-loaded extension (WordPress, Git, Mail, Fail2ban, ...)
failed on fresh installs with "Failed to fetch dynamically imported module:
blob:..." (Chrome) or "'application/octet-stream' is not a valid JavaScript
MIME type" (Safari).
Add a location block to the shipped vhosts (and the container frontend
config) that forces text/javascript for /serverkit-vendor/*.mjs. The regex is
anchored to that folder: a bare `\.mjs$` would also capture the
/api/v1/plugins/<slug>/assets/dist/index.mjs route and 404 every bundle.
update.sh already re-copies these vhosts, so existing installs pick it up on
their next update.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWc84WDsWR7M55Mn7qtpie
Reworked `CHANGELOG.md` into a Keep a Changelog-style release history backfilled from published GitHub releases (starting at 1.6.25), added a structured `Unreleased` section, and linked each version to release/comparison URLs. Updated contributor guidance and the PR template to require concise user-facing changelog notes (or justification when not needed), and added a direct changelog link in the README navigation to make release history easier to find.
Three defects, found together because they are the same failure wearing
three hats: monitoring that looks configured and is not.
Fleet alert thresholds were never evaluated. check_fleet_thresholds was
written, was correct, and had no caller anywhere in the backend, so a
threshold set on the Fleet page was checked against nothing and a server
could sit at 99% CPU for a week in silence. It now runs every minute as a
built-in scheduled job alongside every other recurring task, and each
alert it opens or resolves goes to admins through the Notification Bus.
An evaluation nobody is told about is most of the way to no monitoring.
The agent heartbeat was throwing metrics away. It built its own
ServerMetrics row and filled five of the model's thirteen columns,
dropping memory_used, disk_used, the network totals, both network rates
and extra. The two rates are exactly what the threshold checker reads, so
a network threshold could never fire for an agent-reported server: the
data needed to evaluate it was discarded on the way in. Heartbeats now go
through ServerMetricsService, the writer everything else already used.
Metric history outlived its own retention. The history and aggregate
endpoints offered 7-day and 30-day windows while cleanup deleted every
sample after seven days, and two constants described hourly and daily
aggregate retention that nothing produced. A 30-day chart came back
holding at most seven days and said nothing about it. Both endpoints now
return a coverage block — the retention in force, the earliest point they
can answer for, and whether the window asked for was truncated — and the
two constants that promised retention nobody implemented are gone. Real
rolled-up aggregates are the other way to fix this and would need their
own tables and writer; until they exist, saying what we have is the
honest answer.
Two more things surfaced while writing the tests.
get_aggregated_metrics grouped by date_trunc, which is PostgreSQL's alone,
so the endpoint raised "no such function: date_trunc" on every SQLite
deployment — one of the two supported databases. Buckets are now built
with the right function for the database in use.
And an escalation is not a recovery. warning -> critical closes the
warning row, so both a genuine recovery and a worsening breach end up as
status='resolved'; announcing the second as "recovered" would tell an
operator the problem went away at the moment it got worse. Supersession is
tracked separately and is not notified twice, because the new alert
already says what happened.
The two built-in-job counts in test_jobs.py move from 16 to 17 and 24 to
25, and BASELINE_COUNT is raised for the new tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ARCHITECTURE gains the security-extension seam note; CORE_SLIM Tier B rows
3/7 marked done; CHANGELOG Unreleased entry for the lean-by-default
Security page. (Plan docs 47/55 updated too — they live in the git-ignored
docs/plans/.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-facing half of the registry-mirror work: an offline panel (or one with the
registry disabled) was missing LocalKit Bridge from the Marketplace because the
bundled fallback had fallen behind the published index.
The sync script and the test guard are tooling, so they stay in git log --
except for the one line about the new check, which is the part a reader of this
file would care about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also corrects the scope note, which said the agent is tagged agent-vX.Y.Z and
linked to agent/README.md -- a path that no longer exists. The agent ships from
jhd3197/serverkit-agent and is tagged vX.Y.Z there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recaptured against the ported design, plus new shots for the templates
catalog, its filter and deploy drawers, and the Services card view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wire the server half of runtime-frontend delivery: get_active_contributions()
now emits panel sdk_version and a 'frontends' descriptor map {slug: {entry,
hashes, sdk_version}} for every active extension whose frontend_entry is a .mjs
ESM bundle. The bundle sha256 is pinned at install time into the plugin config
under the reserved _frontend_hashes key (no migration — reuses config_json);
update_plugin_config() and the config API preserve/hide reserved keys. Add the
extensions.runtime_frontend kill switch (default on). The client loader already
consumed this shape, so an installed extension's UI now renders on a pre-built
panel with no Vite rebuild. Un-skips the restored plan-25 recovery suites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add docs/DEPLOY_CONSOLE.md (the feature + architecture), an architecture section
in docs/ARCHITECTURE.md, a CHANGELOG (Unreleased) entry, and move the Deploy
Console into the ROADMAP's Recently Completed list with its Active Development
checkboxes ticked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deprecate the Tailwind `ui/skeleton` shim (deleted; its two call sites moved to
the SCSS `Skeleton` primitive with no raw Tailwind sizing classes) and add
`SkeletonBoundary`, which keeps real content in flow and paints the skeleton over
its exact box while loading — inheriting the real dimensions so there is no
cumulative layout shift when data arrives. Falls back to intrinsic size when the
data-gated children are still absent (first load), overlays once content is
present (refresh).
Migrated the worst hand-guessed loaders through the boundary: SSL certificates,
WordPress list + detail page, the WordPress activity feed, and all 11 WordPress
detail tabs. Skeleton shimmer now honors prefers-reduced-motion across the legacy
wp-* skeleton families too (specificity-proof override), and loading regions set
aria-busy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/ANALYTICS.md: privacy model, the two data sources, dashboard tour,
snippet + WordPress mu-plugin + nginx sub_filter install paths, config table,
geolocation, scale/PostgreSQL guidance, collector protections, and the
single-worker note. Clarifies the panel-extension vs WP-plugin naming and that
the Umami/Plausible/PostHog templates remain available.
- Recommend serverkit-analytics for the wordpress + web-apps setup-wizard use
cases (RECOMMENDED_EXTENSIONS_BY_USE_CASE). Onboarding guard test green.
- CHANGELOG (Unreleased): Web Analytics feature entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an in-memory (single-worker) per-IP throttle that complements the per-user
account lockout: it stops password-spraying across many accounts from one IP
and prevents a single attacker draining the shared login rate-limit bucket.
- new app/services/auth_throttle_service.py — lock-guarded TTL map with
is_blocked / register_failure / reset (+ opportunistic memory sweep)
- config: AUTH_IP_MAX_ATTEMPTS (10) / AUTH_IP_WINDOW_MINUTES (15) /
AUTH_IP_BLOCK_MINUTES (15)
- wire into /login (block up front -> 429 + Retry-After BEFORE the password
check; register on bad user/password; reset on correct password),
/login-links/redeem, and the 2FA /verify endpoint
- per-user lockout and the flask-limiter 5/min decorator kept (defense in depth)
- tests: test_login_bruteforce.py (6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rate-limit buckets, login lockout and audit-log source IPs hand-parsed the
leftmost X-Forwarded-For token — a client-controlled value — letting an
attacker rotate it to dodge per-IP limits or forge audit trails.
- config: add TRUST_PROXY_HEADERS / TRUSTED_PROXY_HOPS (+ _env_bool/_env_int)
- create_app: wrap in gated Werkzeug ProxyFix (rightmost trusted hops) before
the limiter/handlers so every remote_addr consumer sees the real client
- new app/utils/client_ip.py::get_client_ip() — the single trusted seam
- replace all six leftmost-XFF parsers (rate_limit, audit middleware,
api_key_auth, api_analytics, audit_service, ddns) with get_client_ip()
- deploy: root .env.example ships TRUST_PROXY_HEADERS=true (behind nginx);
backend/.env.example documents it off for dev
- tests: test_trusted_client_ip.py (7) incl. real flask-limiter bucket isolation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make `update.sh` fetch the CI-built release tarball by default instead of
rebuilding the SPA on the server, so a normal update needs no Node/npm on the
box. This keeps updates working on old distros, ARM/Pi and tiny hosts where the
vite 8 toolchain (Node 20.19+/22.12+) can't or shouldn't run — the Node
requirement stays a build-time/CI concern, not a runtime one.
On-box source rebuilds remain available and explicit: --source / --main (main
branch), --branch <name>, or BUILD_FROM_SOURCE=1. --branch now implies source
mode (clears the release default). npm is only required for those paths.
Test: test_update.sh +1 (mode mapping — default=release, --source/--branch/
BUILD_FROM_SOURCE=source). 49 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 6 (for shipped work). Sync the pre-bundled frontend plugin.json
copies for the four extracted builtins (entry_point/url_prefix + status
description) so builtin-extensions/ and frontend/src/plugins/ agree
(sync-builtin-frontends --check + test_extension_manifests green). Raise the
test-count ratchet 2588 -> 2623. Document the second-speed backend-extraction
recipe in EXTENSIONS.md, add CHANGELOG entries (wizard installs + opt-in
FTP/cloud/remote-access/status), and a README "lean by default" bullet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan 47 Phase 1. The onboarding "Recommended for you" step now installs real
extensions matched to the selected use cases instead of rendering decorative
strings:
- Backend use-case -> slug map + resolver (RECOMMENDED_EXTENSIONS_BY_USE_CASE,
recommend_extensions_for_use_cases) single-sourced and test-guarded against
dead slugs; exposed via GET /api/v1/plugins/recommendations.
- SetupStepSummary renders default-checked install checkboxes with each
extension's real display_name/description and installs source-aware on Finish
(per-item progress, fail-soft: an install failure never blocks setup).
- complete-onboarding persists onboarding_installed_extensions.
- WordPress flagship seeding is now gated on needs_setup(): fresh installs skip
it (the wizard installs it when the WordPress use case is picked); completed
-setup/upgraded installs keep it (zero regression). TESTING bypass keeps the
WordPress suite green; the real gate is covered by dedicated proving tests.
Proving tests: tests/test_onboarding_recommendations.py (12).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/v1/search?q= (JWT, 2-char minimum) fans a term out across the core
entity types — services, servers, domains, databases, WordPress sites, cron
jobs (admin-only), installed extensions, and vaults (names only, never secret
values) — reusing each domain's existing access helpers so a member only sees
what their grants allow. Per-type cap of 5; each entity block is isolated so one
failing type degrades gracefully instead of 500-ing the search.
Wires the frontend api.search() async provider (200ms debounce) into the
palette's entity group, replacing the fetch-everything-on-open prefetch. Adds
tests/test_search.py covering shape, min-length, cap, member-vs-admin filtering,
cron admin-only, and the vaults names-only rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connecting GitHub required hand-registering an OAuth app and pasting a client
id + secret — friction, and an open-source panel can't ship a secret anyway.
Now an admin sets it up in one click.
- backend: build_github_app_manifest / complete_github_app_manifest — hand
GitHub an app manifest, convert the returned code into credentials stored
locally (client id/secret in settings; PEM + webhook secret encrypted). No
shared secret in the repo or build.
- connect flow: when an app slug exists, generate_github_authorize_url points at
the app's install screen (grants repos + authorizes in one hop); repositories
are listed per-installation since GitHub Apps don't use /user/repos.
- get_github_config surfaces provider_kind/app_slug/app_name/install_url.
- routes: GET/POST /source-connections/admin/github/app-manifest[/complete].
- frontend: Settings -> Connections leads with "Set up in one click" (auto-POSTs
the manifest form to github.com); a new /connections/github-app/callback page
finishes setup and chains into connect. Manual OAuth app moves under
"Advanced". 7 proving tests (manifest shape, conversion+storage, install-url
routing, app-mode repo listing); ratchet 2498->2505.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/services/new rendered everything at once regardless of state — a "Ready to
Import" rail of placeholder rows, a decorative Connect->Detect->Deploy strip, a
numbered guide, and two boilerplate note boxes. It is now a slim stepper over
one centered column.
- three explicit steps: Source (radio-cards) -> Connect (source-specific input
+ API-driven template picker) -> Review (manifest detection, buildpack, env
list, core fields; nothing renders without real data)
- decomposed the 1,327-line monolith into pages/new-service/: a
useNewServiceForm hook (step state + submit) with presentational SourceStep /
ConnectStep / ReviewStep components
- ?template=/?source= deep links land preloaded on the Connect step; deploy
templates load from the backend catalog (kind: repo) instead of a JS constant
- submit payloads are byte-identical to before (flow re-skin, not a backend
change); _new-service.scss rewritten for the stepper layout
- deletes the hardcoded frontend catalog (data/deployTemplates.js) now that its
last consumer is gone, and the old single-file NewService.jsx
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A v1 manifest service gains a third image source next to buildpacks and
BYO image: — `dockerfilePath: services/api/Dockerfile`. This is the
monorepo path: one repository, several services, each built from its own
Dockerfile with the repo root as the shared build context.
- spec: new service key (camel + snake alias), validated relative-only
(no `..`/absolute), mutually exclusive with `image` and `containers`,
rejected on db services; schema docs copy kept in sync.
- resolve/plan: the path rides the create_app payload (with autoDeploy)
and shows in the step description; a changed path plans as an app
update; drift_pair tracks it against the stored build config.
- apply: create clones the project's repository into the managed apps
dir (idempotent — an existing checkout is reused) and writes the same
git-deployment + build config the import wizard does, so the existing
deploy pipeline (push webhook, per-service autoDeploy) takes over
unchanged. Source resolves from the stored manifest's provenance, else
a sibling app's git deployment; neither is a plan-time blocker
(dockerfile_no_source). Remote targets refuse like the other appliance
features ('a Dockerfile build' joins _service_features).
- scaffold: a dockerfile-built app round-trips its build config instead
of leaking the built image as a BYO `image:` declaration.
- import wizard: a v1 manifest with dockerfilePath seeds
build_method=dockerfile + the path.
Tests: 10 new (spec exclusivity/traversal + plan/apply/blocker wiring);
manifest suite 96/96, full backend suite green except 3 pre-existing
test_server_time_timezone failures also present on a clean tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Install the guard the data loss taught us to want: code and its proving tests
died together, so the suite stayed green (2572->2209 passing) while whole
features silently vanished — nobody diffs a green number.
- backend/tests/BASELINE_COUNT (2475) + check_test_count.py: compares pytest's
collected count to the floor; CI (backend-ci.yml new step) fails if it drops.
Lowering the floor requires editing BASELINE_COUNT in the same commit, so a
silent test loss surfaces as a red X instead of a still-green suite. Verified:
passes at baseline, exits 1 on a simulated drop.
- CHANGELOG: document the recovery-parity restore (Cloudflare R2 engine,
email-bounce, DNS-cutover staging, survey routes, cron CSS, 18 test suites)
and the ratchet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a fromServer env binder ({property: publicIp|hostname}) resolved against the
target server or panel host, with a fromserver_no_ip plan-time blocker. Add the
${SERVER_PUBLIC_IP} template magic variable. Attach manifest-generated app
projects to a shared external `serverkit` docker network (DockerService.
ensure_network + networks param on create_docker_app/buildpack generate_compose)
so a fromService host reference resolves cross-app by container name at runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add first-class `image:`/`registry:` (apply stamps Application.docker_image +
registry_id, resolved by registry name; registry_credential blocker on
unknown/uncredentialed registries, ECR accepted) and `hostRequirements`
(privileged/capAdd/sysctls/devices/kernelModules). create_docker_app emits the
host-requirement fields; every requirement is listed in plain words as a plan
issue and audited via manifest.host_requirements on apply. kernelModules get an
advisory /proc/modules check that warns but never silently passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `containers:` map that turns one service into one Application rendered as
one compose project. Normalizer validates mutual-exclusion with buildpack keys,
dependsOn references, and rejects cycles (DFS). New UnitComposeService renders
container_name prefixing, healthcheck blocks (cmd->CMD-SHELL, httpPath->wget),
depends_on.condition mapping, raw port publishes, and per-container synthetic
named volumes. resolve_service flattens container ports/bootstraps/host-reqs/env
to the unit (unified `bootstraps` list under one bootstrap_done flag); unit disk
backups route at the docker-volume name. Seams: unit_compose/unit_container_names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist manifest disk `size` onto AppVolume.declared_size; route disk backups to
the docker volume's live host mountpoint (VolumeService.host_path_for_mount +
volume_mount meta in BackupPolicyService._resolve_target). Add `bootstrap`
schema + BootstrapService.run_once (docker compose run --rm, injectable runner
seam), a bootstrap apply step that stamps applications.bootstrap_done once, and
POST /manifests/bootstrap/reset to re-arm it. Scaffold round-trips disk size.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuild of plan 35 Phase 1 against the surviving migrations (ports as a JSON
column on applications, not a separate table). Adds typed `ports[]` to the
serverkit.yaml schema/normalizer, AppPortService (persist/compose-emit/firewall),
open_port apply step + firewall open on apply / close on app delete, and the
blockers engine (port_conflict/remote_target/observed_server/firewall_undetected)
that refuses apply instead of half-deploying.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Per-source explainer strip (SOURCE_GUIDES): the selected source card renders
what it does, what to have ready, and the 2-3 next steps.
- Extract the hardcoded SERVICE_TEMPLATES into frontend/src/data/deployTemplates.js;
the Templates tab gains a 'Deploy templates' section (badged 'Example' for the
demo) with a 'Use template' deep-link (/services/new?template=<id>) that
preselects it in the wizard. One list, two entry points; Template Library
button now targets #deploy-templates.
- New docsLinks.js map + <DocsLink> (book icon, opens serverkit.ai docs in a new
tab, hidden under White Label — the single place that rule lives). Wired into
the New Service source pane (deploy-sources / manifest) and the Marketplace
manual-install modal (extensions/installing).
Lint 0 errors; build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop stripping the v2 fields in registry_service normalization: add repo,
logo, bundled to _FIELDS. Repo-relative logos (assets/<slug>/<file>) are
resolved to absolute URLs against the index URL they came from, so one
relative path works for both the raw-GitHub index and the serverkit.ai
/ext endpoint. Bundled entries are excluded from the Browse catalog by
default (they duplicate builtin cards) and reachable via
?include_bundled=true. Detail modal gains a Source repo link.
Tests: v2 normalization, relative-logo resolution, v1 unaffected, bundled
exclusion + include_bundled endpoint flag. Full test_registry.py green (19).
Docs: EXTENSIONS_REGISTRY.md documents schema_version 2 + v2 fields.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>