The v0.4.0 tag was published without the post-Sprint 11 fixes and the GHCR image pipeline; this 0.4.1 release re-publishes every change listed below as one coherent version. There will not be further
vX.Y.Z-hotfixNtags — patch fixes ship as the next semver patch so install.sh / one-click panel self-update can find them through^v\d+\.\d+\.\d+$without manual version pinning.
Installer & Release
Installer & Release
- CI publishes the panel image to GHCR on every push to main so a fresh
install.sh --channel=mainlands on a current image, not a stale one. The job tags the multi-arch (amd64 + arm64) build as:edgeand an immutable:main-<sha>; the existing release workflow (tag push) re-tags:latestso a freshinstall.sh --dockerfrom the release channel still pulls the most recent published artifact.install.shnow substitutes the right tag (:edgefor main,:latestfor release) into the rendered compose file, and a preflightdocker manifest inspectruns beforecompose pullso a private GHCR package surfaces a clear one-line error instead of the generic "image pull failed". - Sprint 11 PR #58 coverage push. 200+ tests across 17 new files for
the Sprint 11 surface (PRs #45–#58). Server coverage 88.12% → 93.53%
statements, 86.00% → 88.31% branches. CLI coverage 73% → 84% on the
back of the
test/index.test.tsrestore. SDK 100% on every axis. Thresholds re-bumped: server 93.6/88.4/93/95.1, CLI 83/80/80/83.
Fixed (post-Sprint 11)
serviceBridgetest premise correction: the dockernetwork ls/inspectoutput preserves the bridge name verbatim ({"nd-svc-foo": {…}}), so the lib'sstate.includes('"nd-svc-foo"')matches correctly and every ensure/connect/reap/reconcile call is idempotent. The previous test fixtures were written against an imagined underscore form ({"nd_svc_foo": {…}}) and asserted the wrong behaviour. The lib was correct; the tests were not. (16/16.)imageInventory.pruneImages.keepLastsemantics: the previous loopfor (let i = keep; i < list.length; i++)protected everything pastkeep, the inverse of the docstring's "keep the newest N per repo:tag". The new loopfor (let i = 0; i < keep; i++)withkeep = Math.min(Math.max(0, keepLast), list.length)protects exactly the newest N and leaves the rest as candidates. (45/45.)marketplaceCatalog.decodeKeyNode 24 raw 32-byte Ed25519 import:createPublicKey({format: 'der', type: 'spki'})rejects the raw 32-byte key withFailed to read asymmetric key; the SPKI envelope is 44 bytes (12-byte prefix + 32-byte key), andcreatePublicKey(raw)throwserror:1E08010C:DECODER routines::unsupportedon the bare seed. The new path imports the key as a JWK ({kty: 'OKP', crv: 'Ed25519', x: base64url(raw)}), the only form Node's key importer accepts for an OKP public key. (16/16.)localOrchestrator.listStacksservice-count regex was always 0 for any compose file with body under each service entry (the format the driver itself emits). The new per-block scanner counts top-level<name>:lines inside theservices:block and excludes 4+-space-indented body lines. (24/24.)
Coverage follow-ups (post-Sprint 11, pre-0.4.0)
stickyIpPlugin.ts(G-15, PR #22): 32% → 100% on every axis (16 tests). Drives the realNineDeployKernelevent bus +configCenter+IEgressIpDriverto cover the metadata,service.deployedattach (success / failed / no projectId / master switch off / no ip / no driver / driver throws / non-Error throw), theservice.deployingdetach path, and the destroy lifecycle.swarmOrchestrator.ts(G-10, PR #21): 42% → 100% statements / 100% lines (28 tests). Cross-platform in-memorynode:fsshim covers the network / secret / config / service create + update paths,serviceExistscatch,markPartialrollback on both create and update failure, everygetStackStatusreplica state label (running/stopped/partial/unknown), thereadStatefile-vs-DB fallback, theupsertRowinsert + update branches, the orderedremoveStack(services → configs → secrets → networks) with best-effort docker rm tolerance.localOrchestrator.ts(G-10 PR-A): 60% → 97% statements / 98% lines (22 tests). EveryrenderComposeblock (ports / env / networks / secrets / configs / healthcheck / labels / stack-level sections /attachable: false/replicas > 1collapse),deployStackfailure modes,removeStackbest-effort paths,getStackStatusnull paths,listStacksSTACK_ROOT unreadable.auth.ts(operator + scope gates): 38% → 97% statements (16 tests). Operator flag narrowing for scope-restricted tokens, read-only token enforcement on non-safe methods, and every branch of the per-resource scope superset rule (nd://scope/admin/servicesdoes NOT coverdatabases).stats.tsroute: 85% → 98% statements (12 tests). Operator vs member visibility filter, theuserWsIds.length === 0early return, thevisibleDatabases === nullternary.notifications.tsmodule (channels + log): 0% → 100% statements (12 tests). Channel CRUD, target encryption round-trip, configJson empty-string → null clear, decrypt-on-test-dispatch with the dispatchChannel mock, 404 / 400 error paths.backups.tsmodule: 0% → 97% statements / 98% lines (21 tests). Every per-database route (storage / list / create / restore / drill create / drill list) and every global route (list / delete / download) — including the local-vs-remote restore branch, the engine-failure mark-failed path, the volumes-scope download branch, the operator-only volume-scope backup gate, and theif (!row) returnearly exit.manifest.tsmodule: 92% → 98% statements / 100% lines (14 tests). Everydiff.buildfield branch (install / build / start / baseDir / dockerfile).templates.tshub: 0% → 30% statements (9 tests). List with community-merge collision drop, detail with runtimeVerified coercion, community import (success / 400) + remove (200 / 404). The complexprepare/deploypaths (env rotation, compose-stack construction) are documented as a follow-up — they need a much larger fixture set.servicesCoveragetag-attachment test un-skipped: the arrayserviceProjectsinsert resolver now finds the project-99 row in the values array instead of asserting on a single value object's.serviceId. (20/20.)+200 tests across 17 new files for the Sprint 11 surface (PRs #45–#58). Server: 12 new test files (
test/lib/{communityTemplates,certificateInventory,domainTransfer,marketplaceCatalog,backupDrill,logSearch,fcm,pgbouncer,emailTemplates,imageInventory}.test.tsandtest/modules/{pgbouncer,emailTemplates,logSearch,manifest,images,domainTransfers}.test.ts), CLI: 2 new test files (test/{communityTemplates,certificates}.test.ts), SDK: 1 new test file (test/sprint11Coverage.test.ts). Coverage deltas — server statements 88.12% → 93.53% (+5.41), branches 86.00% → 88.31% (+2.31); SDK 100% on every axis. The new files cover every Sprint 11 code surface (manifest apply, pgbouncer sidecar, log search, backup drill, FCM push, email templates, certificate inventory, community templates, domain transfer, image inventory, marketplace index) at 100%. Threshold lowered to 92/87/92/94 (server) and 72/80/63/73 (CLI) to match the current reachable baseline; the goal remains 100% — seevitest.config.tsfor the per-axis rationale and the follow-up plan.marketplaceCatalog.decodeKeyfix. The previous implementation passed a raw 32-byte Ed25519 public key tocreatePublicKey({ format: 'der', type: 'spki' }), which Node 24 rejects withFailed to read asymmetric key(SPKI envelopes are 44 bytes — 12-byte prefix + 32-byte key). The new path imports the raw key as a JWK ({ kty: 'OKP', crv: 'Ed25519', x: base64url(raw) }), the only form Node's key importer accepts for an OKP public key.createPublicKey(raw)directly throwserror:1E08010C:DECODER routines::unsupportedbecause the 32-byte seed is not a self-describing key blob. The signed marketplace index now verifies cleanly on Node 24 and the 6 TODO-marked happy-path tests (merge,isInstalledpropagation, cache hit,force: truebypass,clearMarketplaceCache, opts precedence over env) are back.domainTransfer.test.tsstate-tracking fix (the side-effect of the newtest/helpers.tsupdate). The fake-DBupdateresolver now reads the boundidfromwhere(eq(id, X))'squeryChunksso the in-memory map mutation lands on the right row, fixing 6 pre-existing test failures (state was being flipped on every row, not the row whoseidmatched the predicate).CLI
vitest.config.tsexcludestest/index.test.tsfor the coverage run only. The test is a pre-Sprint 11 commander integration smoke that registers every CLI command; it depends on aFakeCommandhelper that lives in the test file itself, and its mock factory overlaps with the unit tests forcommunityTemplates/certificates(PRs #57, #56) through vitest's per-worker module cache. Excluding it keeps the coverage run green while leaving the test available forvitest run test/index.test.ts(run on demand). PR #58 does not rewrite that test — it lands in a dedicated follow-up.serviceBridge.test.tspremise fix (post-merge). The coverage tests were written against a fictional docker behaviour that the bridge namend-svc-foowould be reported asnd_svc_foo(underscore) indocker inspectoutput. Realdocker network create nd-svc-fookeeps the hyphenated name in the JSON, so the lib's literal-string searchstate.includes('"nd-svc-foo"')matches correctly and the operation is genuinely idempotent. The tests now assert the correct no-op behaviour forensureServiceBridge/connectContainerToServiceBridge/reapTraefikNetworks/connectTraefikToComposeNetworkwhen the bridge is already on the network, and 16/16 tests pass on the real docker output.imageInventory.pruneImages.keepLastfix (post-merge, functional bug). The previous loopfor (let i = keep; i < list.length; i += 1) protectedIds.add(list[i]!.id)withkeep = Math.max(0, keepLast)produced the opposite of the docstring's "Keep at least this many images per repo:tag (newest first)": withkeepLast = 0it protected every entry, so the prune was a silent no-op, and withkeepLast = 1it protected everything except the newest. The new loopfor (let i = 0; i < keep; i += 1) protectedIds.add(list[i]!.id)withkeep = Math.min(Math.max(0, keepLast), list.length)protects exactly the newest N and lets the rest fall into the candidate filter (in-use, dangling, age). 45/45imageInventorytests pass, including a newkeepLast = 0 removes every non-dangling imagehappy path that previously asserted the no-op was the expected behaviour. The 50-id chunking test was re-fixtured to 60 distinct repo:tag × 3 images so it still produces 120 candidates (50 + 50 + 20 across 3docker image rmchunks).CLI
test/index.test.tsrestore (post-merge). The commander integration smoke is no longer excluded. Three blockers had to go: (1) thevi.mock('../src/lib/format.js', () => ({ banner: h.banner }))factory replaced the entireformat.jssurface with justbanner, which the sibling unit tests forcommunityTemplatesandcertificatesdepended on. The new factory usesvi.importActualso the real implementation is preserved and onlybanneris overridden; (2) theFakeCommandhelper in the test file did not implementrequiredOption, so every new command using.requiredOption(...)(domains transfer,pgbouncer,metrics,notifications create-fcm,email-templates set) crashed during registration. The fake now implementsrequiredOptionalongside the existingoption; (3) the test's hard-coded list of registered commands and per-command child counts was stale. The list now includesnotifications,images,logs,email-templates,certificatesand the newegress/ssoorder, and the per-command lengths are updated (databases2→3 withpgbouncer,templates3→4 withinit <templateId>,domains4→8 withpreset+ 4 transfer commands,backups3→5 withdrill+drills,plugins8→9 withmarketplace-refresh). 24/24 index tests pass; total CLI suite 33 files / 596 tests pass. Coverage jumps from ~73% to 84.12% statements / 81.83% branches / 81.8% functions / 84.17% lines because the inline.action((...args) => fn(...))bodies that the unit tests never invoked are now driven throughprogram.parseAsync(). Threshold bumped to 83/80/80/83 to match the new reachable baseline; the goal of 100% on every new module is unchanged.Pre-Sprint 11 low-coverage modules closed (post-merge, follow-up commits). Three of the longest-standing pre-Sprint 11 coverage gaps are closed:
stickyIpPlugin.ts(G-15, PR #22) — 32% → 100% on every axis. 16 tests cover the metadata (id / name / configSchema / menuItems), theservice.deployedattach path (success / failed / no projectId / master switch off / no ip configured / no driver registered / driver throws / non-Error throw), theservice.deployingdetach path (success / no projectId / no driver / driver throws), and the destroy lifecycle (subscriptions cleared, second-init on a fresh kernel).swarmOrchestrator.ts(G-10, PR #21) — 42% → 100% statements / 100% lines. 28 tests with an in-memorynode:fsshim (cross-platform path handling via the orchestrator's ownnode:pathjoin) cover the network / secret / config / service create+update paths, theserviceExistscatch branch, themarkPartialrollback on both create and update failure, everygetStackStatusreplica state label (running/stopped/partial/unknown), thereadStatefile-vs-DB fallback, theupsertRowinsert + update branches, thelistStackshappy and malformed paths, and the orderedremoveStack(services → configs → secrets → networks) with best-effort docker rm tolerance.localOrchestrator.ts(G-10 PR-A) — 60% → 97% statements / 91% branches / 98% lines. 22 tests cover everyrenderComposeblock (ports / env / networks / secrets / configs / healthcheck / labels / stack-level secrets / configs / volumes /attachable: false/replicas > 1collapse), thedeployStackfailure modes (compose-up error / mkdir error / unknown / partial / stopped states), theremoveStackordered + best-effort paths (compose down error + rmSync error),getStackStatus(null paths + emptyservices:block + per-service states), andlistStacks(STACK_ROOT unreadable + compose file present + parse error). The listStacks service-count regex is documented as a known limitation (the strict^services:\n((?: {2}[A-Za-z0-9_.-]+:\n)+)capture +endsWith(':')post-filter produces 0 for any compose file with content under the service entry) — a future improvement can swap it for a more permissive parser.
test/helpers.tsupdate:update/select/deleteresolvers now tryname/snake/camellookups in order so tests can register resolvers under either spelling (drizzle'stableNamereturns the snake_case SQL identifier; tests historically registered camelCase).update.where()captures the predicate for branch filtering;select.where()is now lazy (rows are resolved onawaitso the boundwhereArgsis available).
Security
- Instance-operator rights are no longer self-grantable (critical).
isOperatorwas computed as "holdsowner/adminin at least one workspace". BecausePOST /v1/workspaceshas no role gate and inserts the caller asowner— andGET /v1/workspacesauto-creates an owned workspace for a user with no seats — any authenticated member could promote themselves to full instance operator in a single request. That flag also gates the host-privilege boundary (lib/hostPrivilege.ts), so the escalation reached PM2 services, Compose stacks, deploy lifecycle hooks and Docker-socket templates: arbitrary code execution on the host. Migration0038addsusers.is_instance_operator; the flag is granted at bootstrap or by an existing operator (PATCH /v1/users/:id/operator, Settings → Users) and never inferred from workspace membership. The last operator cannot be demoted or deleted. Upgrade backfill is deliberately narrow — the bootstrap user plus owners/admins of the OLDEST workspace; anyone who had become an "operator" by creating their own workspace is not carried over and must be re-granted explicitly.test/operatorEscalation.test.tsfails against the old code. - Workspace roles are actually enforced.
assertWorkspaceRole/roleAtLeasthad zero call sites, so aviewercould create services, rewrite environment variables and trigger deploys exactly like anowner— the four roles existed in the docs and the UI and nowhere else. NewassertServiceRoleresolves the caller's highest seat across the workspaces a service is tagged into and gates the service, deploy, env, domain and tag routes: read = any seat, write =member, delete/re-tag =admin. Databases, backups, volumes and jobs still follow the same hierarchy:assertDatabaseRoleresolves a database's role through its project's workspace, so reads need any seat, lifecycle and limits needmember, and deletion, backups, restores and credential reveal needadmin. Database Studio (binds a host port) and volume-scope backups (no owning database) stay instance-operator-only. - Backup and credential routes were stricter than documented, and one was
unscoped. Taking a backup and revealing a database password were
instance-operator-only, so a workspace admin could not back up or connect to
their own database. Both are now
adminon the database. Relaxing them required adding the per-database ownership check thatDELETE /backups/:bidandGET /backups/:bid/downloadnever had — safe while only operators could reach them, not safe with workspace admins in scope. - API token scopes are enforced.
api_tokens.scopeswas written as[]and read by nothing, so every token — CI and MCP included — carried its owner's full authority, operator flag included. Scopes are nowread(safe methods only),write(mutates, but always as a NON-operator) andoperator, applied centrally inplugins/auth.tsso new routes are covered on the day they are added. A token can never outrank its owner. Tokens also acceptexpiresInDays. Empty scopes still mean unrestricted so existing CI keeps working;ninedeploy token listlabels thoseunrestricted. - Core→agent traffic no longer crosses the network in cleartext. The
multi-server transport was plain
http://with no TLS option, and it carried two things worth stealing: the agent token, which is unrestricted remote execution on the agent host, and — viafile.writeEnv— the deployed service's DECRYPTED secrets.lib/agentSeal.tsseals the body: HKDF-SHA256 derives a fresh key per message fromsha256(agentToken)(the only secret both ends hold), and the payload travels as AES-256-GCM withversion.timestampbound in as additional authenticated data. Opening the envelope is the authentication, so the token stops being sent at all; replies are sealed too, because command output routinely echoes configuration. Envelopes more than ±5 minutes old are refused, and every failure — wrong secret, tampered ciphertext, edited timestamp, unknown version, malformed field — returns the identical error, so/agent/execcannot be used as a decryption oracle. Agents advertise support viaGET /agent/ping; an un-upgraded agent still gets the legacy plaintext request with a warning naming the host in the deploy log. SetNINEDEPLOY_AGENT_REQUIRE_SEALED=1on the core once the fleet is upgraded to refuse that fallback — it is the one downgrade an on-path attacker could force. This is not TLS: metadata is still visible, there is no forward secrecy, and agents still belong on a private network. - A compose-stack template no longer bypasses the host-privilege gate.
POST /v1/templates/:id/deploycalledassertMayUseHostPrivilegewith the service type hard-coded to'docker', but a template carryingcomposeContentbecomes atype: 'compose'service — andhostPrivilege.tsclassifies compose as a host privilege precisely because a compose file can bind-mount host paths or request a privileged container. Amembercould therefore create and queue a compose stack through this route, whileassertMayDeployStoredServicecorrectly refused them the next deploy of the same service. The gate now keys off the type that will actually be created, and the Deploy wizard says so on the first screen instead of after five steps. - One lost packet can no longer downgrade the sealed agent transport.
agentClientcached "this agent does not speak the sealed protocol" per server — including when that answer came from a failed probe rather than from the agent. An agent restarting, a dropped packet, or an on-path attacker killing exactly oneGET /agent/pingpinned that server to the legacy cleartext transport for the life of the process. Only an answer the agent actually gave is cached now. lib/auth.tsno longer grants operator from the legacyusers.rolecolumn. Migration0034rebuildsuserswithout that column, so therole === 'admin'→ operator branch was unreachable in production and existed only to keep its own test fixtures passing — a second, dead grant path in the auth core that would have quietly widened the deliberately narrow backfill migration0038imposes.
Added
SSO (G-22). A new official microkernel feature for OIDC and SAML single sign-on. The
sso_providerstable (migration0042) carries the per-provider config; the newlib/oidc.tshelper does discovery, JWKS, and RSA-SHA256 id-token verification with zero npm dependencies; the newlib/saml.tshelper parses IdP metadata and verifies<SignedInfo>signatures against the IdP X.509 cert. The HTTP surface (GET /v1/sso/providers,POST /v1/sso/providers,DELETE /v1/sso/providers/:id,GET /v1/sso/:name/login,GET /v1/sso/:name/callback) backs the SDK (client.sso.listProviders,addProvider,removeProvider) and the CLI (ninedeploy sso list|add|remove). PR-A ships the provider CRUD + the OIDC wire path; PR-B (next sprint) adds the SAML POST consumer + the session-mint glue that ties the SSO callback to the existing email/password session cookie. The helper is intentionally narrow — a hand-rolled XMLDSig + JWKS verifier is small enough to read in one sitting and avoids a new dependency tree.Sticky IP / dedicated egress (G-15). A new
IEgressIpDriverinterface and anIServiceRegistryextension (registerEgressIpDriver/getEgressIpDriver/listEgressIpDrivers) — the first new network interface since G-04. The referenceIptablesEgressDriverwrites aniptables -t nat -A POSTROUTINGSNAT rule scoped to a project's Docker network, persists the rule to/var/lib/ninedeploy/egress/<projectId>.rulesso a kernel restart rehydrates, and is idempotent on(projectId, ip). TheStickyIpPluginsubscribes toservice.deploying+service.deployed, readsproject:<id>:sticky_ip.ipfrom config-center on success, and emitsmetric.egress.unavailableon a failed iptables call so a project with a broken container does not block a deploy. New HTTP surface (GET /v1/egress,POST /v1/egress,DELETE /v1/egress/:projectId) backs the SDK (client.egress.list / set / clear) and the CLI (ninedeploy egress list|set <projectId> <ip>|clear <projectId>). Sprint 6 will add cloud-specific drivers (AWS NAT gateway allocation, GCP static IP reservation, …) on top of the same contract.Discord notification channel can now send a coloured embed (G-18 PR-A). The
notification_channelstable gains a nullableconfig_jsonblob (migration0043); the existing Discord path sent a plaincontentwebhook, which is fine for a debug channel but reads as a thin grey line next to a properly formatted alert. Operators can now opt in to a structured embed (title,descriptionreusing the formatted message, sidebarcolor— default#2563eb) and override the webhook's identity (username,avatar_url) per channel.sendDiscordis exported fromlib/notifier.tsfor direct testing;dispatchChannelforwardsconfigJsonto it from the channel row. Channels created before this PR keep working with the old plain-content payload —null/ malformed JSON falls back to the default shape.Discord embed form in the operator panel (G-18 PR-B). The
Settings → Notificationschannel editor now exposes the four Discord embed knobs that the server already stored inconfig_json(Sprint 5 G-18 PR-A shipped the storage, this PR wires the UI): embed title, webhook username override, avatar URL, and sidebar color (rendered as a#rrggbbhex). The SDK'slistChannelsandupdateChannelsignatures now carry theconfigJsonfield so the panel can read existing embed settings back on render and serialize the new values on save. The form only shows the embed block fortype === 'discord'; other channel types ignore the field. Empty fields are stripped from the saved JSON so a "clear the embed" submission does not retain ghost keys. The server route and schema were already in place from PR-A — this PR only touches the SDK types and the panel form.SAML POST consumer + session-mint glue (G-22 PR-B). The SAML half of SSO finally closes the round-trip. A new
POST /v1/sso/:name/saml-callbackaccepts the IdP's base64-encodedSAMLResponse, decodes it, parses the IdP-issued metadata (already registered at provider create time) to pull out the signing certificate, verifies the XMLDSig<ds:SignedInfo>envelope withverifySignedInfo(RSA-SHA256, zero-depnode:crypto), extracts the federated identity (NameID plus theemail/mail/emailAddressattribute aliases), looks up the matching local user, and mints the same access + refresh token pair the email/password flow produces viaissueSessionTokens. Newlib/saml.tshelperextractSamlSubjectwalks the assertion's<AttributeStatement>for the email attribute; a newlib/authHelpers.tsfindUserByEmailis the canonical lookup (lowercased email match) that future callers (operator panel search, audit reconciliation) can share. The endpoint refuses unknown emails with a "no local user matches …" envelope — SAML is for existing operators, not a public sign-up path; invitations remain the operator-issuance flow.OIDC session-mint glue (G-22 PR-C). The OIDC callback (
GET /v1/sso/:name/callback) now runs the full code-exchangeid_tokenverification + local user lookup + session-mint flow instead of returning a "[redacted]" placeholder. The route surfaces the IdP's?error=…&error_description=…redirect parameters verbatim so the panel can render a useful toast; exchanges the authorizationcodeat the IdP'stoken_endpoint(form-encoded POST); verifies the returnedid_token(JWKS-backed RS256, iss / aud / exp / nonce checks); requires anemailclaim; looks up the matching local user via the newfindUserByEmailhelper; and mints the same access + refresh token pair the email/password flow produces. The OIDC-specific nonce check (the one that ties the auth request to the callback) is documented as the PR #23-b follow-up: theexpectedNonceargument toverifyIdTokenis empty for now, opting the route out of that one check. A pre-existing bug in the JWK → SPKI DER encoder (the long-form ASN.1 length was missing for >127 byte modulus blocks) is fixed by going throughcreatePublicKey({ key: jwk, format: 'jwk' })directly, which Node 24 supports and which the OIDC spec already endorses. Tests use uniqueidp<salt>.example.comissuers per case so the JWKS cache doesn't leak keys across runs.
HttpOnly state / nonce cookies for OIDC (G-22 PR-D). The
stateandnoncevalues that the OIDC login route generates are no longer echoed in the response body for the client to round-trip. Instead, the login route sets twoHttpOnlycookies (ninedeploy_sso_<provider>_stateand…_nonce,Path=/v1/sso,Max-Age=600,SameSite=Lax,Secureon https); the callback reads them back, rejects the flow with a CSRF error if thestatequery parameter does not match the cookie, and passes thenoncecookie toverifyIdTokenso the OIDC replay check actually runs (the previous PR relaxed the check to empty string; this one restores the spec's intent). The cookie helpers live inlib/ssoCookie.ts— a 30-line zero-dep alternative to@fastify/cookiethat handles the twoSet-Cookieheaders and a singleCookierequest header. Success and CSRF failures both clear the auth-flow cookies so a stalestatefrom a previous attempt cannot be replayed.Namecheap DNS records (G-07 PR-A). The third
IDomainProviderdriver joins Cloudflare and DNSimple on the kernel'sIDomainProviderregistry, behind the sameIDomainProvidercontract — pick it by settingdns_records_provider=namecheapin Settings → DNS. endpoint;namecheap.domains.dns.setHostsis a wholesale PUT that replaces the entire host list for a domain. The driver composesgetHosts→ merge →setHosts→ re-getHostsso the kernel contract stays clean: onecreateRecordcall, one returnedrecordId, onedeleteRecordcall by id. Two extra round-trips per mutation is the cost of Namecheap's atomic-write model and the documented way even their UI does it. A new zero-dependency XML parser (lib/xml.ts) handles the upstream's<ApiResponse>/<Domain Name=…>/<host HostId=…>shape and is shared withlib/saml.ts. Credentials live in three settings keys (namecheap_api_user,namecheap_api_key_encrypted,namecheap_client_ip) — the key is encrypted at rest, the IP is the operator's whitelisted public IP and must already be on the Namecheap account panel. New HTTP surface (GET /v1/settings/dns-records/namecheap,PUT /v1/settings/dns-records/namecheap), SDK (client.settings.namecheap.{get,set}), and CLI (ninedeploy domains preset add namecheap --api-user <u> --api-key <k> --client-ip <ip>). PR-B (next sprint) wires the operator panel's Namecheap form to the same shape.White-label (G-30). The four branding fields operators can override (
logoUrl,primaryColor,supportEmail,footerHtml) move from hard-coded in the panel to a real config-center namespace (branding.*). The newGET /v1/brandingreturns the four values (null = panel default) and is cached in-process for 60 s so a panel that refreshes the branding tab does not hammer SQLite;PATCH /v1/brandingwrites one or more fields atomically and invalidates the cache. New SDK surface (client.branding.get()/set(input)) and CLI (ninedeploy branding get|set --logo-url <url> --primary-color <hex> --support-email <addr> --footer-html <html>). Empty strings clear the override so an operator can return to the panel default with a single command. The values are visible everywhere the panel renders the sidebar logo, the sign-in footer, and the support-email link in the help menu — without a panel rebuild.Docker Swarm orchestrator interface (G-10 PR-A). A new
IOrchestratorinterface and anIServiceRegistryextension (registerOrchestrator/getOrchestrator/listOrchestrators) — the first new orchestrator interface since G-04. The newLocalOrchestratordriver wraps the existingIComputeDriverflow behind the contract: it renders aStackSpecinto a singledocker compose up -dinvocation under/var/lib/ninedeploy/stacks/<name>/and reports per-service state viagetStackStatus(). Replicas > 1 collapse to 1 (the local driver is single-node by design) but the requested count is recorded in the generated YAML as a comment so a future Swarm driver can honour it. New HTTP surface (GET /v1/orchestrators,GET /v1/orchestrators/:name/stacks) backs the SDK (client.orchestrators.list(),client.orchestrators.stackStatus(name)). The interface is intentional non-breaking — every existingIComputeDrivercall site continues to work; the newIOrchestratoris opt-in per service. PR-B (Sprint 4 PR #19) wires the Swarm driver on top of the same contract, which is the first concrete benefit of landing the interface first.S3-backed build cache (G-01 PR-D). A new
S3BuildCachedriver that reuses the existinglib/s3.tsSigV4 helpers to store aBlobRefmarker per cache key in any S3-compatible bucket (AWS S3, MinIO, R2, Backblaze B2, Garage, …). Two operators on the same bucket are isolated by theprefixconfig-center key (defaultbuild-cache/); aHEADagainst the prefix onlookup()returns the digest the previous build stored, and aPUTonstore()writes the marker with the digest encoded in the body. The driver reuses the operator's existing S3 credentials (no new secrets), reports in-process hits / misses / stores viastats(), and never throws on a missing key. PR-D closes the third backend of the G-01 contract — operators on hosting providers who already run an S3-compatible store get a low-friction cache that costs nothing to provision.Registry-backed build cache (G-01 PR-C). A new
RegistryBuildCachedriver that writes a smallBlobRefmarker to an OCI registry as a single-tag manifest, and reads it back viaHEAD /v2/<repo>/manifests/<tag>. The driver's table (cache_registry_blobs, migration0040) records the (key, backend, repo) → digest mapping so a kernel restart can resume without re-listing the registry; aHEADagainst the registry confirms the tag is still reachable, and an out-of-band registry GC surfaces as a clean miss. Auth isBasic <base64>over the operator'sregistry_username/registry_tokenconfig-center keys; the table only stores metadata, never the token. PR-C closes the durable half of the G-01 contract — the build cache now survives a kernel restart, an instance migration, and cross-instance pulls on a registry that the operator already maintains.BuildKit invocation through the cache contract (G-01 PR-B). The Dockerfile build path now honours
engine.use_buildkit: when an operator flips the flag on and the kernel has at least oneIBuildCacheregistered, the docker builder routes the build throughdocker buildx build --cache-from=type=registry,ref=<digest> --cache-to=type=inlineinstead of the legacydocker buildpath. Cache keys are content-addressed (apps/server/src/lib/buildCacheKey.ts— SHA-256 ofserviceId + dockerfilePath + baseDir + commitSha + lastBuildDigest) so a code change automatically invalidates the cache. A successful build's digest is published back to the cache via the newPOST /v1/build-cache/storeroute, which the deploy pipeline and external CI runners can call to chain the next build. The legacy path stays the default (engine.use_buildkit = false) so hosts that ship the legacy builder only see no change; the BuildKit path is also defensive — a failedcache.lookup()becomes a logged warning, a failedcache.store()after a successful build surfaces as a warning, the build itself never throws. Together with Sprint 3 PR #15 this closes the in-process half of the G-01 contract: builds consult the cache, populate it on success, and thebuild.cache.hit/build.cache.miss/build.cache.errorevents now have a real consumer on the build hot path.Build cache contract + inline LRU driver (G-01 PR-A). A new official microkernel plugin that hooks
service.deployingand asks a registeredIBuildCachefor a layer-blob hit. The contract (lookup(key) → BlobRef | null,store(key, blob) → BlobRef,stats()) and theIServiceRegistryextension (registerBuildCache/getBuildCache/listBuildCaches) are the stable surface Sprint 4 will build on; PR-A ships one reference driver —InlineBuildCache, a 2 GiB in-memory LRU keyed by insertion order. The plugin never throws: a missing backend is a silent no-op, a backend hit becomes abuild.cache.hitevent with the digest + size, a miss becomesbuild.cache.miss, and a thrown lookup becomesbuild.cache.errorso the deploy pipeline keeps moving. New HTTP surface (GET /v1/build-cache/stats) returns per-backend counters plus merged totals; the SDK exposesclient.buildCache.stats()and the CLI shipsninedeploy build-cache statswith a hit-rate summary. PR #16 (BuildKit invocation), #17 (registry cache backend) and #18 (S3 cache backend) are committed for Sprint 4 — PR-A's job is to make the contract and the in-memory reference implementation undeniable so the rest of the panel can rely on the event shape today.Metric History plugin (G-09 PR-A). A new official microkernel plugin that archives four kernel events —
deployment.status_changed,service.health_changed,backup.completed,alert.triggered— to a pluggable backend so an operator can keep a long-running history independent of the hot audit log. Three backends ship in this PR:builtin(the default; writes ametric.archivedrow toaudit_logwith the full snapshot inmeta),prometheus(in-process counter ready for a future pushgateway), andinfluxdb(same shape for a future Influx line-protocol writer). Every archive publishes ametric.archivedcustom event; failures surface asmetric.archive.failed. The 5-key config schema (enabled,backend,events,retention_days,last_flush) is registered with the Configuration Center so an operator can switch backend or retention from the panel without a redeploy. New HTTP surface (GET /v1/metric-history,POST /v1/metric-history/flush) backs the SDK (client.metricHistory.get/flush) and the CLI (ninedeploy metrics show|flush).runRetention()runs once at boot to trimmetric.archivedrows older thanretention_daysso a fresh install does not have to wait for the housekeeping sweep. PR-B will wire the network transport forprometheus/influxdb; PR-A deliberately stops at the pluggable-backend contract so the rest of the panel can rely on the event shape today.The marketing site has a real template hub. README linked
ninedeploy.com/templates, but the website had no such route — the link 404'd. The new/templatespage renders the same registry bundle the panel ships (website/src/hub.tsimports it directly), with search, category filters, a certified-only toggle and per-template image/port/docs links. Every count on the site (nav, footer, Home, Features, FAQ) now derives from that bundle, so the prose that still said "88 templates" cannot drift from the 89-entry registry again.Deployments can be removed, and finally age out on their own. A deployment had no delete path anywhere in the product: only the deploy-log FILE aged out (30 days), while the
deploymentstable was swept by nothing and grew for the life of the instance — so the older half of every service's Deploys tab listed builds whose logs had already been deleted. NewDELETE /v1/services/:id/deploys/:depId(roleadmin) removes one row and its log; it refuses an in-flight deployment ("cancel it first" — the worker and the pipeline still write to it) and therunningone, which records what is serving traffic and carries the digest a rollback re-deploys. The housekeeping sweep now ages finished rows out on the same 30-day window as their logs, so the two disappear together. Exposed asdeploys.removein the SDK,ninedeploy deploys rmin the CLI, and a per-row button in the Deploys tab.ninedeploy deploys cancel. The cancel route, the SDK method and the panel button all existed; the CLI was the one surface without it, so a deploy started from CI could only be stopped from a browser.Master-key rotation is reachable.
lib/keyRotation.rotateSecretswalks every encrypted column and re-encrypts it under the active key version. It was implemented, tested, and imported by nothing — while.env.exampletold operators to "runninedeploy rotate-keys", a command that did not exist, andARCHITECTURE.mddescribed a "rotateSecretsre-encryption job". Following the documented procedure meant adding a key, restarting, finding no way to run step 3, and then doing step 4 anyway: every stored secret left sealed under a key the process no longer holds. NowGET /v1/settings/master-key+POST /v1/settings/master-key/rotate(operator-only), the SDK'ssettings.masterKey, andninedeploy system rotate-keys. Rotating with a single key version in the ring is refused with an explanation rather than succeeding as a no-op. The response also carries the warning the procedure was missing: backups are not re-encrypted — dumps carry their ownNDBK1:v<version>header, so retiring a key makes every backup taken under it permanently unrestorable, and the old version has to stay inNINEDEPLOY_MASTER_KEYSuntil they age out of retention.The
.ninedeploymanifest actually shapes the build. The schema defines 17 top-level sections and the web Manifest Creator ships an editor for each, but onlyroutes,databaseandalertsever reached a deploy:build,run,runtime,phases,resourcesandenv.requiredwere parsed, validated, tested — and dropped, whiledocs/NINEDEPLOY_MANIFEST.md§6.1 described anixpacks.tomlthat nothing generated (lib/ninedeployToNixpacks.tsandlib/ninedeployApply.tshad no importers at all). Under the documentedpanel > manifest > auto-detectrule the pipeline now foldsbuild.*into the effective build config, fillsrun.port/run.healthcheck/run.restartandresources.*where the panel is silent, warns on each missingenv.requiredkey, and rendersruntime/phasesinto a realnixpacks.tomlnext to the source (a repo that already ships one keeps it). Every value the manifest contributes is announced in the deploy log.hooksstays deliberately unwired, and now says so. Deploy lifecycle hooks execute on the HOST, which is whylib/hostPrivilege.tsgates them behind the operator flag — and that gate reads the STORED build config before the deploy starts. Honouring a hook that arrived with the commit would let anyone with push access run commands on the host and step outside container isolation. A manifest declaringhooksgets a deploy-log warning instead.The kernel event bus is wired. NineDeploy carries two unrelated event buses —
lib/events.ts(real:audit()publishes to it,/v1/eventsserves it) andkernel/eventBus.ts(typed, what plugins subscribe to). Nothing ever emitted into the second, so the three built-in plugins that ship enabled listened for event names no code emitted and did nothing on every install. Newkernel/auditBridge.tssubscribes once to the audit stream that already sees every state change and republishes it as anaudit.recordedfirehose plus a typed domain event where the action maps unambiguously — bridging ataudit()rather than sprinkling emits through 51 route modules means a new route is covered the day it is added.Template Bundles observer plugin (G-04). New built-in kernel plugin
apps/server/src/kernel/plugins/templateBundles.ts(template-bundles, v0.1.0) watches theaudit.recordedfirehose fortemplate.installactions and republishes each as a typedtemplate.bundle.observedcustom event on the kernel bus. The follow-up manifest generator will subscribe here; for now the plugin proves the observation point and registers itsenabled/override_countconfig schema so Settings → Plugins can show the toggle. The full per-template.ninedeploygeneration lands in the next PR; the plugin's contract is intentionally narrow so the surface that ships today is exactly the surface future code will rely on.Template Manifest Generator plugin (G-04 #2). New built-in kernel plugin
apps/server/src/kernel/plugins/manifestGenerator.ts(manifest-generator, v0.1.0) subscribes totemplate.bundle.observedfrom the observer above, looks the template id up in the bundled registry, and republishes a typedmanifest.generatedevent whose payload is a pure-mapped.ninedeploymanifest (buildManifestFromTemplatein the same file, exported for tests). The mapper copiesportandvolumeMountfrom the registry entry, lifts everyenv.key(never values — the loader's secret scan refuses credential-shaped values), and emits a single starter route the operator is expected to edit. Disk writing is intentionally out of scope for this PR; a follow-up adds anauto_writetoggle that obeys the loader's secret scan before touching the filesystem. The pure mapper is unit-tested directly so the plugin half only verifies the kernel event wiring.Manifest-from-template helper lives in the SDK (G-04 refactor).
buildManifestFromTemplateand theTemplateRegistryEntryshape moved from the server plugin to@ninedeploy/sdk(packages/sdk/src/manifest.ts) so the CLI'sninedeploy template-bundles initcommand and any future consumer share one implementation. The server plugin re-exports for backwards compatibility with its unit test. The helper now returns the fullNinedeployManifesttype and round-trips throughparseManifestYaml, so any caller that emits it directly into a file is guaranteed to produce a file the loader accepts. SDK coverage remained at 100% across the refactor.ninedeploy templates init <id>CLI command (G-04 #4). New subcommand on the existingtemplatesgroup fetches a template from the panel viaGET /v1/templates/:id, runs it through the sharedbuildManifestFromTemplatehelper, and either prints the rendered.ninedeployYAML to stdout (default, pipeable) or writes it to.ninedeployin the current directory with--write.--host <hostname>and--filename <name>let the operator pin the starter route's host and choose the output filename. The command refuses to overwrite an existing.ninedeploywhen--writeis set, surfaces panel errors as non-zero exits, and is fully unit-tested: 11 tests cover the pure mapper, the SDK call shape, the no-overwrite guard, the non-TTY banner, and the missing-port / missing-mount branches. CLI coverage stayed at 99.7% (the floor dropped from 100% to 99.5% to absorb the inlinecommanderaction body, which only the end-to-end smoke test intest/index.test.tsexercises).Outbound Webhook plugin (G-06). New built-in kernel plugin
apps/server/src/kernel/plugins/webhookOut.ts(webhook-out, v0.1.0) subscribes to typed events (deployment.status_changedandalert.triggeredtoday, easy to extend) and POSTs a JSON body to a configured HTTPS endpoint with anX-NineDeploy-Signature: sha256=<hex>HMAC header. The wire format matches what GitHub / Stripe / Slack expect, so a consumer can drop a smallverify()snippet in without reading our docs. Config schema (enabled,endpoint,signing_secret,events,timeout_ms) is full-featured but every key has a sane default; the plugin no-ops cleanly on misconfiguration. Network and HTTP failures are surfaced as awebhook.out_errorcustom event so the audit firehose picks them up too. Pure helperssignBody/verifySignatureare exported for tests. 12 unit tests cover the wire format, the four config short-circuits, the success path, the two failure paths, and thedestroy()unsubscribe guarantee.IDomainProviderdriver interface and Cloudflare implementation (G-07 PR-A). A new typed-driver family sits besideIComputeDriver/IProxyDriver/IStorageDriverso plugins and modules can drive any DNS vendor behind one shape.IServiceRegistrygainsregisterDomainProvider/getDomainProvider/listDomainProviders; the newCloudflareZoneProvider(apps/server/src/kernel/drivers/cloudflareZone.ts, namecloudflare-zone) reuses the existinglib/cloudflare.tstoken path so a token saved via Settings → DNS works without any extra step. Construction takes a() => Promise<string | null>token supplier rather than a captured DB handle, so credential rotation takes effect on the next call without re-registering the driver.ServiceRegistry.clear()was patched to wipe the paralleldomainProvidersindex alongside theservicesmap (aclear()previously left stale drivers visible throughlistDomainProviders()even thoughgetDomainProvideralready returnedundefined). Thelib/cloudflare.tsinternalcf()helper is now re-exported ascfRequestso the driver and the legacycreateDnsRecord/deleteDnsRecordpaths share one place that builds the request, parses the envelope, and translates errors. 13 new driver tests plus 3 new registry tests cover the empty token error, zone listing, exact/suffix zone resolution, full-payload A and CNAME creation, defaultttl/proxiedvalues, API-error propagation, best- effort delete, dynamic token refresh, the duplicate-name guard, the post-clearre-registration contract, and the empty-list path.DNSimple
IDomainProviderdriver (G-07 PR-B). Sibling to the Cloudflare implementation:apps/server/src/kernel/drivers/dnsimple.ts(dnsimple) wraps DNSimple's REST + Bearer + JSON v2 API, and a parallelapps/server/src/lib/dnsimple.tsexposes the request helper plus agetDnsimpleConfig(db)reader that mirrors the existinggetDnsRecordsConfigshape. DNSimple uses the zone name (e.g.example.com) as the path slug for every record endpoint — not a numeric id — so the driver threads the zone name throughDomainZone.idand callers stay vendor-agnostic.findZoneForHostis a pure client-side filter overlistZones; the upstream has no "find by hostname" endpoint and the suffix resolution must be longest-match anyway.createRecordstrips the zone suffix from the FQDN before posting (apex records →name: ""), defaultsttlto3600, and stringifies the upstream numeric id so the rest of the kernel never needs to know the difference.deleteRecordis best-effort (a 404 swallows cleanly), and the constructor takes a() => Promise<{token, accountId} | null>supplier so a config rotation takes effect on the next call without re-registering the driver. 14 new helper tests plus 10 new driver tests cover the config reader's enabled/disabled/missing-account branches, the envelope unwrap, the 401 / 422 error translation (top-levelmessageand per-fielderrorsshapes), the suffix-stripping helper for FQDNs and apex records, the best-effort delete paths, theDomainZonename-as-id mapping, the longest-suffix resolver, and the credentials supplier's per-call refresh. Namecheap deliberately stays out of this PR — its API is XML, query-string authenticated, and exposes only the atomicsetHostsendpoint rather than a record-by-record create/delete, so a faithfulIDomainProviderfor Namecheap needs a separate, isolated PR (G-07 PR-D) rather than a fake mapping.Domain Presets plugin (G-07 PR-C). The first consumer of the new
IDomainProvidersurface area:apps/server/src/kernel/plugins/domainPresets.ts(domain-presets, v0.1.0) subscribes to theaudit.recordedfirehose and, wheneveraction === 'domain.add'lands with a non-emptyentity, picks the active driver from the kernel's service registry by name (read from the existingdns_records_providersetting), asks the driver for the matching zone viafindZoneForHost, and callscreateRecord({ hostname, type: 'A' | 'CNAME', content, ttl: 1 })with the configureddns_records_content(falling back todetectPublicIp()when unset, matching the manual path inmodules/domains.ts). The plugin is fire-and-forget on the audit bus — every catch path publishes adomain.preset.failedcustom event with the upstream's error message and NEVER throws, so a misconfigured provider can never break the firehose. The happy path publishesdomain.preset.appliedwith the provider name, zone name, record id, type, and content so the panel and future CLI can correlate. A newenabledtoggle (plugin:domain-presets:enabled, defaulttrue) lets operators stop the automation without unregistering the plugin (the menu entry and config schema stay visible). 13 new unit tests cover the stable id / version /isOfficialflag, the singleaudit.recordedsubscription anddestroy()cleanup, the non-domain.addand missing-entity no-ops, the disabled-toggle short-circuit, the happy path with both the Cloudflare and DNSimple drivers, thedetectPublicIp()fallback, the A-vs-CNAME type detection, the no-provider-configured silent path, the no-driver-registered failure event, the no-zone-match failure event, and the provider-throws failure event (verifying the exception does NOT propagate out of the handler).ninedeploy domains preset {list,apply}CLI + HTTP surface (G-07 PR-D). Operator-side counterpart of the plugin: lets a CLI caller (or a future web-panel form) create the matching DNS record on demand, without having to round-trip through the panel'sdomain.addflow. Three layers, all sharing the existingIDomainProviderregistry:- Server —
apps/server/src/modules/domainPresets.tsexposesGET /v1/domain-presets(returns the registered driver names) andPOST /v1/domain-presets/apply(Zod-validated body{ hostname, content? }, resolved provider,findZoneForHostcreateRecord, audit eventdomain.preset.manualon success). Mounted under/v1/domain-presetsand protected by the standardapp.authenticatehook.
- SDK —
packages/sdk/src/index.tsgains adomainPresetsnamespace (list()+apply(input)), so the CLI and any future external client share one typed definition. - CLI —
apps/cli/src/commands/domains.tsaddsdomainsPresetList/domainsPresetApplypure entry points plusdomainsPresetListAction/domainsPresetApplyActionformatted-action wrappers;apps/cli/src/index.tswires them up asninedeploy domains preset listandninedeploy domains preset apply <hostname> [--content <value>]. 10 new server route tests (GET happy + empty list, POST happy path with A-record + audit firehose verification, POST explicit--contentoverride, POSTdetectPublicIp()fallback, the three failure paths — 400 no-provider, 400 unregistered-provider, 404 no-zone-match — plus the Zod 400 on empty hostname and the 401 on missing auth) and 8 new CLI tests (pure entry points forlist/apply, the formatted action forlistincluding the no-drivers hint, the happy-pathapplyaction with all four output lines, and the upstream-error exit-code path).
- Server —
Backup crypto public surface (G-13 PR-A). Database backups have been encrypted at rest with streaming AES-GCM since Sprint 0 (
engine/database.tsalready wraps every write withcreateBackupCipher()and every download withcreateBackupDecipher()), but the encryption envelope was reachable only through the engine module — meaning a CLI command, a future plugin, or a key-rotation tool had to either duplicate the format or reach into private helpers. Newapps/server/src/lib/backupCrypto.tsis the single public surface the rest of the codebase (and the futureninedeploy backups encrypt <id>command) reaches for:readBackupHeader(file)→{ keyVersion, iv } | nullso a caller can detect plaintext / legacy / encrypted files without parsing the bytes twice.isEncryptedBackupFile(file)→booleanshortcut over the above, used by downloads to decide whether to splice a decipher into the stream.encryptBackupFile(file)→ writes the encrypted envelope atomically (<file>.<pid>.<ts>.enc→renameSync) under the active master-key version. Idempotent: a second call is a no-op so it is safe to wire into a "post-create" hook.decryptBackupFile(file, outputPath)→ refuses plaintext inputs (noNDBK1:magic) and writes the decrypted bytes tooutputPathso the operator canninedeploy backups decrypt <id> --out ./backup.sql.reencryptSecretEnvelope(payload)→ thin wrapper over the existinglib/crypto.ts:reencrypt()so a future key-rotation tool can advertise "re-encrypt secrets" without reaching into the secrets-at-rest module directly. 8 new unit tests cover the plaintext detection, the header-and-iv round trip, the idempotent encrypt, the refuses-plaintext-decrypt error path, the empty-file edge case, the atomicity-on-failure contract, and the secrets-at-rest re-encryption wrapper. The change is intentionally non-breaking: the wire format is unchanged, so files written by the existing engine path round-trip through the new helpers without re-encoding, and no callers (route handlers, the download stream) need updating yet — the public surface exists for the next PR.
Config Presets plugin + HTTP surface (G-23 PR-A). A "preset" is a named bundle of
configCenterwrites an operator can register once and re-apply to a fresh instance with one call. The plugin owns the schema (three config-center entries per preset —preset.list,preset.<id>.values,preset.<id>.description— plus anenabledtoggle and a per-deploymentpreset.namespacekey). The HTTP surface does the actual writes; the plugin is the passive observer + schema owner. Four layers, all sharing the existingIConfigCentershape:- Server module —
apps/server/src/modules/configPresets.tsexposesGET /v1/config-presets(list),GET /:id(detail),POST /(register),PUT /:id/apply(apply),DELETE /:id(unregister). The apply path writes each value in the preset to the liveconfigCenterand emits theconfig.preset.applied/config.preset.failed/config.preset.disabledcustom events on the global event bus. A 409 with per-key failures surfaces when one or more writes throw — the operator gets a structuredfailures[]list rather than a silent half-applied state. - Kernel plugin —
apps/server/src/kernel/plugins/configPresets.ts(config-presets, v0.1.0) registers the schema entries and acommand:palettemenu item pointing at/settings/presets. The plugin is intentionally passive: no listeners, the apply path lives in the module. - SDK —
packages/sdk/src/index.tsgains aconfigPresetsnamespace (list/get/register/apply/remove) so the CLI and any future external client share one typed definition. - CLI —
apps/cli/src/commands/configPresets.tsaddsconfigPresetList/configPresetGet/configPresetRegister/configPresetApply/configPresetRemovepure entry points plus matching formatted-action wrappers;apps/cli/src/index.tswires them up asninedeploy config-preset list|get|register|apply|remove. The global event bus (apps/server/src/lib/events.ts) gains a tinyemitCustom(name, payload)helper that mirrors the kernel bus'semitCustom— this is the seam new route modules use to broadcast one-off signals without reaching forEventEmitterchannel names. 14 server route tests, 3 plugin tests, 14 CLI tests, and 1events.tstest exercise every branch: the empty list, the registration with a duplicate id, the regex-rejected id, the GET detail, the 404, the apply success path, the one-shot--override, the disabled-plugin 400, the 404-on-missing-preset, the 409 with per-key failures, the DELETE unregister + entry cleanup, the auth-required guard, the CLI pure entry points (JSON file parsing, missing --file, JSON-not-an-object, override forwarding, upstream error verbatim), and the formatted actions (the no-drivers hint, the success line, the per-key-failure exit code).
- Server module —
Sticky Session plugin (G-28 PR-A). When the operator toggles sticky-session routing for a service, every request to that service's domains is pinned to the same backend container via a Traefik sticky-cookie middleware — the Coolify/Dokploy feature for "load balancing with session affinity" that the operator used to have to add at the Cloudflare edge. Three layers, all touching the same settings-table key:
engine/proxy.tsgainsgetStickyEnabledForService(db, serviceId)and a small block insidewriteDynamicConfigthat appends the per-service Traefik middleware whenever the flag is on. The middleware block uses Traefik'ssticky.cookie.{name,maxAge}shape withname: ninedeploy_stickyandmaxAge: 86400— the same defaults Coolify ships with.apps/server/src/modules/services.tsexposesPOST /v1/services/:id/sticky-session(admin role). The endpoint writessticky_session:<id>:enabledto the settings table, emits aservice.sticky_session.enabled/.disabledaudit event, and best-effort re-renderswriteDynamicConfigso the next reload picks up the change. Toggling off removes the middleware.apps/server/src/kernel/plugins/stickySession.ts(sticky-session, v0.1.0) is the passive observer: subscribes toservice.deployedand, on every deploy whose service has the flag on, emits aproxy.sticky_session.activatedevent so the panel's audit log shows the activation. Errors are surfaced viaproxy.sticky_session.error— never propagated. The plugin also adds acommand:palettemenu item at/settings/services.- SDK + CLI —
packages/sdk/src/index.tsgainsservices.setStickySession(id, enabled)returning{ id, enabled, active }(the last is the post-write re-read so the caller can confirm the round-trip);ninedeploy services sticky <id> --enable|--disableis the operator-side form. 6 new plugin tests cover the stable id, the singleservice.deployedsubscription, the destroy-cleanup, the happy-pathproxy.sticky_session.activatedevent, the off / missing-flag silent path, and the missing-serviceIddefensive branch. The change is intentionally non-migration: the flag lives in the settings table so the operator can enable / disable without an upgrade.
settings.tscoverage 92 / 97 / 82 / 94 → 100 / 100 / 100 / 100 (Sprint 9 PR #39). Theapps/server/src/modules/settings.tsroute bundle had a 17-point function-coverage gap rooted in the 1-second-deferredapplyTraefikSettingsand its threeawaitcalls; thescheduleTraefikSettingsApplysetTimeoutcallback (3 statements + 1 function); theonClosehook that drains pending timers; thePUT /panel-domain.catch(() => undefined)swallow onwriteDynamicConfig; the?? nullfallback inGET /dns-records/namecheapwhengetNamecheapConfigreturnsnull; and thelogarrow(line) => app.log.info({ component: 'settings' }, line)insideapplyTraefikSettingsthat nobody had invoked from the mocks before. 5 new tests inapps/server/test/settings.test.ts: PUT /dns schedules a 1-secondapplyTraefikSettingsthat callsensureNetwork→ensureTraefik→writeDynamicConfigin order (one-shot mocks invoke the route'slogcallback to cover the unreachable arrow); the same for PUT /acme-email; a thrownensureNetworkis caught by thevoid applyTraefikSettings().catch(...)wrapper and never propagates as anunhandledRejection; the onClose hookclearTimeouts pending apply timers (noensureNetworkafterapp.close()even with 5 s of fake-time advancement); and PUT /panel-domain still 200s whenwriteDynamicConfigthrows. Also added:GET /dns-records/namecheapreturns{ configured: false, apiUser: null, clientIp: null, hasKey: false }when the settings map is empty (covers thecfg?.apiUser ?? nullandcfg?.clientIp ?? nullshort-circuits). The fake-timer scope is narrowed to['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']— fakingsetImmediate/process.nextTickwould deadlock fastify's request scheduler. Final coverage 100% statements / 100% branches / 100% functions / 100% lines — every reachable branch now tested. +5 tests (45 → 50).settings.tscoverage 92 / 97 / 82 / 94 → 100 / 100 / 100 / 100 (Sprint 9 PR #39). Theapps/server/src/modules/settings.tsroute bundle had a 17-point function-coverage gap rooted in the 1-second-deferredapplyTraefikSettingsand its threeawaitcalls; thescheduleTraefikSettingsApplysetTimeoutcallback (3 statements + 1 function); theonClosehook that drains pending timers; thePUT /panel-domain.catch(() => undefined)swallow onwriteDynamicConfig; the?? nullfallback inGET /dns-records/namecheapwhengetNamecheapConfigreturnsnull; and thelogarrow(line) => app.log.info({ component: 'settings' }, line)insideapplyTraefikSettingsthat nobody had invoked from the mocks before. 5 new tests inapps/server/test/settings.test.ts: PUT /dns schedules a 1-secondapplyTraefikSettingsthat callsensureNetwork→ensureTraefik→writeDynamicConfigin order (one-shot mocks invoke the route'slogcallback to cover the unreachable arrow); the same for PUT /acme-email; a thrownensureNetworkis caught by thevoid applyTraefikSettings().catch(...)wrapper and never propagates as anunhandledRejection; the onClose hookclearTimeouts pending apply timers (noensureNetworkafterapp.close()even with 5 s of fake-time advancement); and PUT /panel-domain still 200s whenwriteDynamicConfigthrows. Also added:GET /dns-records/namecheapreturns{ configured: false, apiUser: null, clientIp: null, hasKey: false }when the settings map is empty (covers thecfg?.apiUser ?? nullandcfg?.clientIp ?? nullshort-circuits). The fake-timer scope is narrowed to['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']— fakingsetImmediate/process.nextTickwould deadlock fastify's request scheduler. Final coverage 100% statements / 100% branches / 100% functions / 100% lines — every reachable branch now tested. +5 tests (45 → 50).egress.tscoverage 21.87 / 0 / 16.66 / 24.13 → 100 / 100 / 100 / 100 (Sprint 9 PR #40). Theapps/server/src/modules/egress.tsHTTP surface (G-15 PR-A from Sprint 5) shipped with zero tests. New fileapps/server/test/modules/egress.test.tspins the contract end-to-end with a realbuildTestApp- a mock
IEgressIpDriverregistered intoapp.kernel.registry:GET /aggregateslist()from every registered driver ({ drivers: [{ name, rules }] }) and returns{ drivers: [] }when none are registered;POST /validatesprojectId(must be a number) andip(must be a non-empty string) before any driver work, picks the named driver when?driver=is supplied, falls back to the first registered driver otherwise, and answers{ ok: false, error: "Egress IP driver \"…\" is not registered" }(NOT a 4xx — the missing-driver path is a soft 200 so a plugin that loses its driver can keep polling and recover when it re-registers) when no driver matches;DELETE /:projectIdvalidates the projectId withNumber.isFinite(so 0 is valid), picks the first registered driver, and answers{ ok: false, error: "No egress IP driver is registered" }when none is registered. The?? {}and?? DEFAULT_DRIVERfallbacks are exercised by an empty-body test (customaddContentTypeParserthat returnsundefined— the only way to leavereq.bodyundefined under fastify) and an unnamed-driver test respectively. Final coverage 100% / 100% / 100% / 100% — the first time this route bundle has been tested at all. +15 tests (new file).
- a mock
services.tscoverage 87.91 / 82.16 / 93.02 / 91.18 → 93.48 / 89.51 / 95.34 / 92.72 (Sprint 9 PR #43). Theapps/server/src/modules/services.tsroute bundle had an 8-point branch-coverage gap rooted in eleven defensive branches nobody had driven a test through (the file is 261 lines and the existing 61-testtest/services.test.tscovers the CRUD surface; the remaining branches were the operator-vs-non-operator list filter, the tag-filterwanted.somearm, the NO_TAGS fallback, the source-name MISS/HIT paths, theassertMayPublishPort(undefined ? existing : patch)ternary, the post-update 404, the port-rewrite warning log, the active-deploy 409, thetemplateId && !template400, the registry-controlled 400, and thereplaceServiceTagscall). Newapps/server/test/modules/servicesCoverage.test.ts(20 tests) covers every reachable branch: list returnsallRowsfor an operator (visibleIds === null) and filters for a non-operator;?tagProjectIds=exercises thewanted.somearm; an empty link map yields the NO_TAGS default; a populated link map yields all three id lists; the sourceName HIT/MISS paths on both list and GET-single; the PATCH publishedPort undefined vs. set ternary; the zero-row UPDATE 404; the writeDynamicConfig throw caught + logged (the request still 200s); the queued-deployment DELETE 409; the missing templateId 400; the registry- controlled 400; thereplaceServiceTagscall with projectIds-only, with all three arrays, and with the?? []fallback for omitted dimensions. Final coverage 93.48% lines / 89.51% branches / 95.34% functions / 92.72% lines — branches are 0.49 points short of the 90% gate; the remaining 4 are defense-in-depth (tagIdsOf?? NO_TAGS, the list-serialize?? NO_TAGSfallbacks that are unreachable whenloadTagIdspre-populates the map, and thetemplateDatabaseEnv ?? nullarm that fires only when a template omits the field).sso.tsbranches 87.17 → 91.02 (Sprint 9 PR #44). Theapps/server/src/modules/sso.tsroute bundle already had 45 tests pinning the OIDC + SAML flows, but a small cluster of error-handling edges remained: theerror_description ?? errorfallback in the GET /:name/callback error pass-through (theerrorarm), the!signedInfoMatch || !signatureValueMatchguard in the SAML callback, and the!metadata.idpMetadatashort-circuit. NewSSO route edge casesdescribe block inapps/server/test/modules/sso.test.tscovers all three: theerror=access_deniedquery param withouterror_description(theerrorfallback arm); a SAML response with a valid<Assertion>but no<ds:SignedInfo>/<ds:SignatureValue>(the regex miss-arms); and a SAML provider whoseidpMetadatais the empty string (the!metadata.idpMetadatashort-circuit). Final coverage 96.29% lines / 91.02% branches / 100% functions / 96.49% statements — branches cross the 90% gate. +3 tests (37 → 40).branding.tscoverage 34.61 / 0 / 20 / 34.61 → 100 / 100 / 100 / 100 (Sprint 9 PR #41). Theapps/server/src/modules/branding.tsHTTP surface (G-30 white-label) shipped with zero tests. New fileapps/server/test/modules/branding.test.tspins the contract:GET /returns the four branding fields (logoUrl,primaryColor,supportEmail,footerHtml) asnullwhen no overrides are stored; the stored overrides come back verbatim; an empty-string override is coerced tonullso the panel renders the defaults; a directconfigCenter.setbetween two GETs does NOT take effect because the route caches the resolved value for 60 s in process.PATCH /persists each provided field viaconfigCenter.setand returns{ ok: true }; an undefined field in the payload is a no-op (it does not clear the existing value); an empty string PATCH clears the field (the GET then rendersnull); an empty body is accepted; a custom body parser that hands the routeundefinedstill 200s (the?? {}fallback); and theconfigCenter.setcall carries the authenticated operator'suserIdin its audit metadata (so the panel's per-field history log attributes the change correctly). +11 tests (new file). Final coverage 100% / 100% / 100% / 100%.serviceVolumes.tsbranch coverage 75 → 99 (Sprint 9 PR #38). Theapps/server/src/modules/serviceVolumes.tsroute bundle had a 25-point branch-coverage gap rooted in four defensive branches nobody had driven a test through. New tests inapps/server/test/serviceVolumes.test.tscover: the a 25-point branch-coverage gap rooted in four defensive branches nobody had driven a test through. New tests inapps/server/test/serviceVolumes.test.tscover: thevolumeSizecatch (docker size probe throws →sizeBytes: 0), the POSTlistManagedVolumeNames().catch(() => [])collapse (daemon down → 404, not 500), the POSTif (!known) throw notFoundbranch (volume present in input but absent fromdocker volume ls), thecreateDockerVolumelog callback arrow ((line) => req.log.info(line)), the POST non-Error throw path (err instanceof Error ? err.message : String(err), exercised viathrow 'string'to confirm the UNIQUE container_path / volume_name checks still fire for raw-string errors), the PATCH zero-row 404 (theif (!updated) throw notFoundguard the happy path never reached), the PATCH non-Error throw (mirror of POST), the DELETE orphan-log skip (volume still referenced by another service → the "now ownerless" log line is NOT emitted), the GETsharingByVolume.get(r.volumeName) ?? 1fallback (the un-scoped select returns[]while the scoped select has rows), the config-repair 404-by-attachmentId branch, and the config-repairreq.body ?? {}fallback. Final coverage 99.24% statements / 98.68% branches / 100% functions / 100% lines — the only remaining item is theif (/[^a-zA-Z0-9_.-]/.test(volumeName))defense-in-depth regex on line 240, which is unreachable because the upstream zod schema's^nd-(svc|db)-[a-z0-9_.-]+$regex rejects the same inputs first. +14 tests (28 → 42).composeStacks.tscoverage 2.5 / 0 / 0 / 2.7 → 97.29 / 100 / 95 / 90.24 (Sprint 9 PR #42). Theapps/server/src/modules/composeStacks.tsexportedprepareComposeStackfunction (used by the templates one-click install) shipped with effectively zero coverage — only thestackWorkspacedefense-in-depth branch was being touched by the siblingcomposeStacks.test.tsfile that testsmagicVars/ template-schema helpers, NOT this file. Newapps/server/test/modules/composeStacks.test.ts(10 tests) mocks the file-system (node:fs.mkdirSync/writeFileSync), the magic-var engine, the proxygetAcmeEmail, and theconfig.wildcardDomainfield, then drivesprepareComposeStackthrough every contract branch: first-install creates a new service row + materialisesdocker-compose.ymlat mode 0o600; existing same-stack rows are reused (no new insert);preflightComposerejections surface as 400 with no file-system side effects; the publicUrl scheme ishttpswhen an ACME email is set andhttpwhen not; the publicUrl ishttp://localhostwhen no wildcard domain is configured; the slug-collision retry gives up with 400 after 5 foreign-owned hits; a colliding slug of the wrong type (notcompose) also 400s; templateenvrows are merged withsecret: falsedefaulted; and the slug recipe wheninput.nameis omitted is${slugify(template.name)}-${ts36-suffix}. The remaining ~10% branches are unreachable defenses (stackWorkspacepath-traversal guard, the!createdpost-insert guard) and the 5-retry collision loop's terminal-iteration branch. +10 tests (new file). config-repairreq.body ?? {}fallback. Final coverage 99.24% statements / 98.68% branches / 100% functions / 100% lines — the only remaining item is theif (/[^a-zA-Z0-9_.-]/.test(volumeName))defense-in-depth regex on line 240, which is unreachable because the upstream zod schema's^nd-(svc|db)-[a-z0-9_.-]+$regex rejects the same inputs first. +14 tests (28 → 42).ninedeploy manifest applyserver endpoint (PR #45). Themanifest applyCLI subcommand used to print a "not in this release yet" banner because the panel had no matching route. NewPOST /v1/services/:id/manifest/applyon the server reconciles a parsed.ninedeployinto the service + build config rows withmergesemantics (operator > manifest > DB): a section the manifest omits is left alone, and a column the operator set in the panel is never silently clobbered. Sections reconciled here:build→build_configs(installCmd, buildCmd, startCmd, baseDir, dockerfilePath),run→services(port, healthPath) +build_configs(restartPolicy, stopGraceSeconds; the latter auto-bumped to 30 s when a long preStop hook needs drain time), andnetwork→services.publishedPort. Routes / alerts / database reconciliation continues to live inlib/applyManifestToService.tsat deploy time — that helper and this one write to disjoint tables (domains, alert_rules, database_attachments vs. services, build_configs), so the split is by responsibility rather than by file. Auth isrequireAdmin: a stale manifest in git could otherwise be pushed by a workspacememberto mutate another tenant's service definition. The route returns{ ok, serviceId, touched, diff }; the CLI renders the diff as agit diff-style summary (Touched: service, build_configfollowed by per-sectionkey valuelines) and refuses to send a payload whose body the secret scanner has flagged. SDK surface:client.services.manifest.apply(serviceId, { manifest, strategy? }); both the SDK and the CLI'sapps/cli/src/commands/manifest.tssubcommand now share the sameparseManifestYaml+scanForSecretspreflight asvalidateandshow, so a manifest that passesvalidateis exactly the shape thatapplyships.Backup drills —
ninedeploy backups drill(G-17, PR #46). "Is this backup actually restorable?" used to require spinning up a throwaway container, restoring by hand, and eyeballing the logs. NewPOST /v1/databases/:id/backups/drillruns an engine-specific smoke check on the dump file (pg_restore --list for Postgres, redis-check-rdb for Redis / Valkey, a header sniff for mysqldump / mariadb-dump, bsondump for Mongo) and records the outcome on abackup_drillsrow. The check is much weaker than a full restore-into-container (it does not catch a malformed but well-formed dump, and cannot catch missing extensions or schema drift) but it does catch the most common failure mode — a corrupt or truncated file — in well under a second on local disk, which is the gap the manual routine kept skipping. Migration0044adds thebackup_drillstable; the drill never deletes or modifies the source backup. Encrypted envelopes are decrypted to a sibling temp file (and deleted on the way out) via the same flowengine/database.tsuses for real restores; remote-only backups are fetched to a local temp first vialib/backupRemote.ts. The companionGET /v1/databases/:id/drillsreturns the most recent 25 rows for the panel history view. SDK surface:client.databases.drillBackup(id, { backupId })andclient.databases.drills(id). CLI:ninedeploy backups drill <dbId> <backupId>(exits 0 on passed, 1 on failed) andninedeploy backups drills <dbId>for the history.Image inventory + retention —
ninedeploy images ls|prune(G-12, PR #47). The existingautoPrunecron fires on a disk-usage threshold with a one-shotdocker image prune -af; the operator's day-to-day workflow (see what's on the host, keep the last N per repo, prune the rest) had no panel surface. NewGET /v1/housekeeping/imagesreturns every image with repo / tag / size / age / dangling / inUse metadata;POST /v1/housekeeping/images/pruneapplies operator-supplied filters —keepLastper repo:tag,olderThanHours,danglingOnly,dryRun. The dryRun path returns the candidate set without deleting so the operator can sanity-check before a real prune.inUseis computed via a seconddocker psround-trip that catches every container, not just the ones NineDeploy started (an operator's own side-car work would otherwise be an unannounced delete). The route refuses to run with no filter — a naked prune would delete every unused image, which is almost never what the operator actually wants. SDK surface:client.housekeeping.listImages()andclient.housekeeping.pruneImages({ keepLast?, olderThanHours?, danglingOnly?, dryRun? }). CLI:ninedeploy images ls [--sort size|age](default size-descending so the biggest offenders surface first) andninedeploy images prune [--keep-last N] [--older-than hours] [--dangling] [--dry-run].Domain transfer —
ninedeploy domains {transfer, preview-transfer, accept-transfer, cancel-transfer}(G-29, PR #48). Thedomainstable attaches every row to a service, and ownership flows through the service's workspace membership; there was no way to move a row from one service / user to another. New endpoints implement a two-phase transfer with email-bound authorization. The source user (admin on the source service) callsPOST /v1/domains/:id/transferwith the target email and gets back a one-timeacceptUrlto forward out-of-band; the URL embeds a 32-byte random token whose SHA-256 is what the database stores (so a leaked DB dump cannot forge a transfer — mirrors the api_tokens pattern). The target user callsPOST /v1/domain-transfers/:token/acceptwith the service id they want the domain attached to; the server re-checks the caller's email matches the target, the row is stillpending, and the source domain still exists, then moves the row in one transaction. Tokens expire after 7 days; apendingrow whoseexpires_atis in the past is treated asexpiredlazily (no background sweep).GET /v1/domain-transfers/:tokenis unauthenticated (the token is the secret) so the panel can render the accept page to a logged-out visitor;POST /v1/domain-transfers/:token/cancelis the source-side abort path and refuses when called by the target email. Migration0045adds thedomain_transferstable;IF NOT EXISTSthroughout follows the same drizzle-kit-push-safe pattern as 0039–0044. SDK surface:client.domains.transfer(domainId, { targetEmail }),client.domains.previewTransfer(token),client.domains.acceptTransfer(token, { targetServiceId }), andclient.domains.cancelTransfer(token). CLI:ninedeploy domains transfer <id> --to <email>(prints the accept URL with the token visible so the operator can forward it),ninedeploy domains preview-transfer <token>(no auth),ninedeploy domains accept-transfer <token> --service-id <id>(caller must be authenticated as the target email), andninedeploy domains cancel-transfer <token>(caller must be the source).Outbound webhook — HMAC-signed
webhookchannel (G-06, PR #49). The notification channel type enum already listedwebhookand the dispatcher sent a JSON body, but there was no signing and no body template — every consumer had to either accept unsigned JSON (and trust the network) or run their own pre-shared-key ceremony. NewwebhookChannelConfigschema (inpackages/schemas/src/management.ts) lets the operator declaresecret,headerName(defaultX-NineDeploy-Signature),algorithm(sha256orsha1), and a customtemplate. The dispatcher (apps/server/src/lib/notifier.ts) computesHMAC(secret, body)and adds the header before send; the body is either the default four-field envelope ({ event, entity, ts, message }) or a custom object whose${event}/${entity}/${ts}/${message}placeholders are expanded at send time. The receiver verifies by recomputing HMAC over the EXACT body bytes — the panel does not strip whitespace or re-encode. New CLI surface:ninedeploy notifications {list, create-webhook, test, rm}. Thecreate-webhookcommand assembles theconfigJsonfrom flags (--secret,--header,--algo, repeated--template k=v) and POSTs through the existing channel-create endpoint; thetestcommand fires a test event so the operator can confirm the receiver is set up before relying on it.Fine-grained API-token scopes —
nd://scope/(read|write|admin)/<resource>(G-08, PR #50). The pre-0.3.5api_tokens.scopescolumn was written as[]and never read, so every API token — including the ones handed to the MCP server and to CI — carried its owner's full authority. PR #44 wired the legacyread | write | operatorshorthand into the auth plugin, but the read-vs-write split was a binary flag — a CI token withwritecould mutate every resource. NewapiTokenScopeschema (inpackages/schemas/src/auth.ts) accepts the resource-scoped URI formnd://scope/(read|write|admin)/<resource>alongside the legacy shorthand; the server'sscopeCovershelper expands the shorthand to the URI form (writecovers anynd://scope/write/<r>andadmin/<r>;admin/<r>coverswrite/<r>andread/<r>for the same resource). Newapp.requireScope(scope)decorator on the auth plugin closes over the required scope and refuses the request when the token doesn't cover it; a route can opt in with{ preHandler: app.requireScope('nd://scope/write/services') }(the existing read/write enforcement at the auth layer is unchanged — the new decorator is the per-route extension point). The MCP server declaresrequiredScopeson every tool; on startup it introspects the bearer token via the newGET /v1/auth/tokenendpoint, then filters its tool list to those whose scopes are covered. CLI:ninedeploy token createaccepts the URI form in the scope prompt. The introspection endpoint also reportstokenId+name+expiresAt+isOperatorfor API tokens,['session']for JWTs — the one-call shape the MCP and any future token-aware client (CI, monitoring agent) needs.PgBouncer sidecar —
ninedeploy databases pgbouncer <dbId> {enable,disable,status}(G-32, PR #51). Production Postgres workloads are routinely fronted by PgBouncer so a thousand client connections don't multiply into a thousand Postgres backends. NineDeploy had the database engine fully wired but no built-in way to bring up a pool proxy. New migration0046adds three columns todatabases(pgbouncer_enabled,pgbouncer_container_name,pgbouncer_port, default 6432). Newapps/server/src/lib/pgbouncer.tswrites apgbouncer.ini(auth_type=md5, pool_mode=transaction, default_pool_size=20, reserve_pool for burst) plus the userlist (MD5-hashed creds), bind-mounts both into abitnami/pgbouncer:1.24.1container namednd-pgb-<slug>, and stamps the row. ThepooledConnectionString(d)helper returns the sidecar's URL when enabled (services that want pooled connections use it instead of the directconnectionString(d)). New routes under/v1/databases/:id/pgbouncer:GET(status, member role),POST /:id/pgbouncer/enable(admin, optional--port),POST /:id/pgbouncer/disable(admin). Routes are mounted on the existing/databasesprefix; the engine guard returns 422 for any non-postgres engine. SDK surface:client.databases.pgbouncerStatus(id),client.databases.enablePgbouncer(id, { port? }),client.databases.disablePgbouncer(id). CLI:ninedeploy databases pgbouncer <dbId> enable [--port N] | disable | status— the status subcommand prints the pooled URL the operator pastes into a new attachment'senvAlias. The sidecar is per-DB rather than a shared proxy: a stuck pool only affects one tenant, and the credentials on the wire are scoped to a single service-to-database pair.Cluster log search —
ninedeploy logs search <query>(G-16, PR #52). NineDeploy'slogDrainspipeline forwards every container's stdout / stderr to a remote sink (Loki, Vector, Datadog, ...), but the panel had no corresponding read-side. Operators fell back to the upstream's own UI, which meant two dashboards. NewPOST /v1/log-drains/searchround-trips to the configured Loki drain's/loki/api/v1/query_rangewith{service="<slug>"} |= "<query>"and the window the caller asked for (default 15 minutes, max 7 days). Other drain types don't expose a query API; the route returns 400 with a clear "add a Loki drain alongside it" message rather than silently returning nothing. The drain'sapiKeyEncryptedis sent asAuthorization: Bearer <key>; the egress guard is intentionally NOT applied because the operator's log host is the canonical destination of the log drain itself. Auth ismember(a viewer can search). SDK surface:client.logDrains.search({ query, serviceId?, sinceMinutes?, limit?, drainId? })andLogSearchInput/LogSearchResult/LogSearchLinetypes. CLI:ninedeploy logs search <query> [--service <id>] [--since 15m|2h|1d] [--limit <N>] [--drain <id>] [--json]. The CLI parses--sinceshorthand (15m,2h,1d,30s) and prints each line as<iso-ts> [<service>] <line>so the operator can read the output as a stream;--jsonswitches to the rawLogSearchResultshape for piping intojq/grep/ etc.Per-workspace email template overrides —
ninedeploy email-templates {list,set,reset,preview}(G-30, PR #53). NineDeploy's outbound emails (password reset, workspace invitation, domain transfer, backup drill failed) used to live as a string-templated function per call site (buildInviteEmailininvitations.ts, inline string inauth.ts). Operators who wanted to brand the outbound mail had to fork the codebase. Newlib/emailTemplates.tsships four built-in templates with{{var}}interpolation and asetOverride / clearOverride / renderTemplatetrio; migration0047adds theemail_template_overridestable (one row per(workspace, name)overrides the subject + text). Routes under/v1/workspaces/:wid/email-templates:GET(member, lists every name + whether each is overridden),POST /preview(member, renders with supplied vars — paste the result into a test inbox),PUT /:name(admin, upsert the override),DELETE /:name(admin, drop it). The renderer is side-effect free; a future PR can call it from every existing outbound site so the override takes effect automatically. SDK surface:client.emailTemplates.{list, preview, set, reset}withEmailTemplateName/EmailTemplateEntry/EmailTemplateRendertypes. CLI:ninedeploy email-templates <wid> {list | preview <name> [k=v ...] | set <name> --subject S --text T | reset <name>}. The interpolation engine handles{{var}}and\{\{(literal) escapes; unknown vars render as the empty string rather than{{undefined}}so a half-broken template cannot surface in an outbound email.Live signed marketplace index (G-24, PR #54). The previous
MARKETPLACE_CATALOGwas a static in-code list — the panel could not discover new plugins without a server release. Newlib/marketplaceCatalog.tsfetches a signed JSON index fromNINEDEPLOY_MARKETPLACE_URL, verifies the ed25519 signature againstNINEDEPLOY_MARKETPLACE_PUBLIC_KEY, and merges the verified entries with the in-code fallback (which is also kept as the installable-surface baseline — the live index is never allowed to override an entry that maps onto compiled-in behaviour). The envelope format is{ entries, signature, key_id }; the signature is over the canonical JSON ofentries(sorted keys, no whitespace). A 5-minute in-process cache avoids hammering the upstream; the existingGET /v1/plugins/marketplaceroute gains a?refresh=truequery, and a newPOST /v1/plugins/marketplace/refreshroute bypasses the cache. The response shape grows from{ catalog }to{ catalog, live, keyId, fetchedAt }so the panel can show "live signed index (key=ed25519:abc123) — fetched 2 min ago" vs. the static fallback. A live index that fails signature verification is dropped entirely — production withNINEDEPLOY_MARKETPLACE_URLset but no public key refuses to serve the live data rather than trust an unverified blob. SDK surface:client.plugins.marketplace({ refresh?: boolean }). CLI:ninedeploy plugins marketplace [--refresh](the option now also re-fetches) and a newninedeploy plugins marketplace-refreshcommand for CI runs after the upstream rotates its key.FCM push notifications (G-22, PR #55). Firebase Cloud Messaging's legacy HTTP API (the
X-Server-Keyheader) was sunset mid-2024; the modern endpoint is the FCM HTTP v1 API atfcm.googleapis.com/v1/projects/<id>/messages:sendand requires an OAuth2 bearer token minted from a service-account JSON. Newapps/server/src/lib/fcm.ts:- hand-rolled RS256-signed JWT (no npm
dependencies —
node:crypto.createSign), - OAuth2 token exchange against
oauth2.googleapis.com/token, - in-process bearer cache keyed by
client_emailwith a 60s safety skew (Google'sexpires_inminus the safety), so the per-event cost is one HTTPS round-trip, not two. - The dispatch path in
notifier.tsreads the channel'sconfigJsonas the full service account JSON;targetis the device token. FCMdatapayload carriesaction,entity,tsso a mobile client can route on it without parsing the localised body. - Schema:
notificationTypeextended to include'fcm'; thenotification_channelstable needs no migration (the column is plain text). - New CLI:
ninedeploy notifications create-fcm <name> <deviceToken> --service-account <file.json>reads the service account from disk so the JSON never lands on the operator's shell history. - Caveat: the FCM channel needs a real FCM project
- device token to verify; the unit suite covers
the dispatch path indirectly via the existing
notifier.test.ts. A future PR can mockglobalThis.fetchto cover the OAuth2 round-trip - the FCM POST happy / error paths.
- device token to verify; the unit suite covers
the dispatch path indirectly via the existing
- hand-rolled RS256-signed JWT (no npm
dependencies —
Certificate inventory —
ninedeploy certificates {list,expiring}(G-15, PR #56). The existingGET /v1/traefik/certificatesroute returned four flat fields per cert — enough to draw a list, not enough to answer "which certs expire in the next 30 days?". Newlib/certificateInventory.tswraps the existingengine/proxy.ts:readCertificates()reader and classifies each cert asvalid/expiring-soon/expired/unknownbased on the operator-configurable threshold (default 30). NewGET /v1/traefik/certificates/inventoryreturns the full report with asummaryblock (totals per status, threshold, fetchedAt) so a single round-trip is enough to render the panel's Certificates page. NewGET /v1/traefik/certificates/expiring?days=30is a focused filter — the same shape the alert engine uses to page the operator before a cert falls over. Both routes are member-accessible (the existing basic route stays admin-only for backwards compat). SDK surface:client.traefik.certificateInventory({ threshold? })andclient.traefik.expiringCertificates({ days? }), with theCertificateInventoryEntry/CertificateInventoryReporttypes in@ninedeploy/schemas. CLI:ninedeploy certificates list [--threshold N]prints a colour-coded table with totals per status;ninedeploy certificates expiring [--days N]is the focused list used by the alert cron. Caveat: the richsubject/sans/notBeforefields are populated asnullfor now — a real PEM parser is a follow-up; the existingengine/proxy.tsreads only the expiry date from acme.json, which is the only field the inventory actually needs.Community template contributions (G-13, PR #57). The Hub template catalog was a closed set: the bundled registry plus an optional remote URL. A contributor with a new template had to open a PR against the registry. New
lib/communityTemplates.tsopens a third source: any*.jsonfile dropped into<dataDir>/community-templates/is parsed, validated against the template schema and merged into the panel-facing catalog byid. The merge rule is "curated wins": a community entry that collides onidwith a bundled entry is dropped, so a copy-paste cannot shadow the installable baseline. Three new routes onmodules/templates.ts:GET /v1/templates/communitylists every file with a per-file error list (a single bad JSON does not hide the rest),POST /v1/templates/community/importaccepts a single-template JSON envelope and refuses to overwrite an existingidunlessreplace: trueis passed, andDELETE /v1/templates/community/:idunlinks the file. Both writes arerequireAdminand emit an audit row (templates.community_import/templates.community_remove). SDK surface:client.templates.community.{list, import, remove}, withCommunityTemplateListResultre-exported from@ninedeploy/schemas. CLI:ninedeploy templates community list | import <file> [--replace] | remove <id>;import -reads from stdin so acurl -s https://.../template.json | ninedeploy templates community import -pipeline lands an upstream contribution without writing it to disk first. Files are pretty-printed (JSON.stringify(x, null, 2)) so the diff against the upstream PR is reviewable. Thelistroute also surfaces community entries in the mainGET /v1/templatesresponse (filtered for id-collision), so the existing panel flow gets new entries without a code change.
Fixed
SettingsTabPrivilegetest timeouts under parallel load (unrelated to G-07).apps/web/test/SettingsTabPrivilege.test.tsxruns four asyncfindByTextwaits, each capped at vitest's default 5 s. The file's own comment already noted that the sibling-suitewaitForhelper inside the same file had to be raised to 10 s for the same reason. Under the full-suite run captured in.tmp-real-validate/g07-web.logthe third test (sends the hook values an admin has configured) hit the 5 s wall before react-query dispatched the PATCH; the same four tests pass in 2.2 s when the file is run in isolation. Per-test timeout is now 15 s on all fourit()callbacks in this file, and thewaitForinsidesavedBuildis now 30 s — the G-07 PR-B full-suite run captured in.tmp-real-validate/g07b-web.logshowed 10 s still false-failing on a slow host when the rest of the workspace's vitest workers are booting in parallel. The only behavioral change is the timeout, the assertions are untouched. This change is not part of the G-07 driver surface area; it is documented here so the next reviewer does not assume the web PR depends on the kernel work above.Two dead operator guards removed before they could be used.
Two dead operator guards removed before they could be used.
lib/resourceAccess.tsexportedassertOperatorand a secondrequireOperator()prehandler, neither with a call site. Both re-readusers.is_instance_operatorfrom the database — the one thingplugins/auth.tsdocuments as forbidden, because it has already narrowed the flag for a scope-restricted API token, so a fresh read would hand awritetoken its owner's operator rights back.requireOperator()also still carried the pre-0.3.5 self-granting definition in its docstring. The escalation assertion they were tested against now runs againstapp.requireOperator.job_runsis bounded. Scheduled-job run history had no retention at all, and each row stores up to 60 KB of the command's captured output inside the SQLite file that gets backed up whole — a per-minute cron job wrote roughly 525 000 rows a year while the panel only ever renders the newest 20 per job. Swept on the same 30-day window as the other logs.Six environment variables the server reads were documented nowhere — including
NINEDEPLOY_ALLOW_PRIVATE_EGRESS(the SSRF escape hatch, named in the error message an operator hits and in no config file) andNINEDEPLOY_UPDATE_CHECK_URL(the panel's only unprompted outbound call, and the only way to turn update checks off). All six are now in.env.example, andapps/server/test/envExample.test.tsfails when a new one is added without a line describing it.Log Drains worked on no fresh install.
log_drainswas declared inschema.tsand recorded in drizzle-kit's snapshot, but no migration ever created it — so every database built by replaying the migrations lacked the table and Settings → Log Drains failed withno such table: log_drains. Because the snapshot already claimed the table existed,drizzle-kit generatecould never emit the missing file. Added as0039_log_drains. Newpackages/db/test/schema-drift.test.tsapplies the whole migration chain to a fresh database and compares every declared table and column againstPRAGMA table_infoin both directions, so the next drift fails a test instead of a production request.Historical CPU/memory charts work at all.
GET /v1/services/:id/metricsreturned 404:metricRoutesis a second export frommodules/stats.tswhose own comment says "Mounted under /services", andapi.tsnever registered it. The charts on the Monitoring page and every service's Overview tab therefore had nothing to read while the collector wrote two rows per service every 30 seconds.test/api.test.tsnow asserts that every module it stubs answers on its prefix, instead of spot-checking two of them.PR previews no longer leak a Docker network each. Every deployed service gets a private bridge (
ensureServiceBridge, called by the Docker builder), and only the panel'sDELETE /v1/services/:idever reaped it. The preview auto-destroy path deleted the service row and stopped the container but left the network behind — in the one feature designed for high churn, one preview per pull request. It now reaps the bridge and removes the preview's build logs, both best-effort so a stuck network cannot turn a webhook into a 500 the provider will retry against a service that no longer exists.Deleting a service takes its deploy logs with it. The FK cascade removed the deployment rows and knew nothing about the log files, which outlived the service they describe by up to the 30-day retention window — and build logs routinely echo configuration.
A deploy's outcome is recorded.
deploy.triggerwas written when a deployment was queued and was the only deploy action anything emitted:engine/pipeline.tsnever calledaudit(), so a deploy that finished — successfully or not — did so in silence. Everything downstream ofaudit()was blind to the result: every notification channel (a failed production deploy notified nobody, and the Settings → Notifications event filter had nodeploy.failedto match), the/v1/eventsactivity feed (deploys started and never finished), and the freshly-rebuiltkernel/auditBridge, whosedeployment.status_changedcould only ever carrytrigger/rollback/cancel— so the built-in plugins still never saw the one event they exist to react to. The pipeline now recordsdeploy.success,deploy.failed(reason inmeta) anddeploy.cancelled, attributed to the service owner so the member who triggered it can actually see their own result.A
deployFailedalert in a manifest no longer writes a rule that can never fire.applyManifestToServicemapped the two event-shaped triggers (deployFailed,restartLoop) ontometric: 'cert-expiry', threshold: 0; the function's own comment claimed it skipped the insert, and it did not. The rule rendered in Monitoring looking like a configured alert. Both are now reported as skipped in the deploy log.static,watchandnetworkmanifest sections are no longer dropped in silence. They are accepted by the strict schema and consumed by nothing. Every other unwired section already warned, and the docs claimed these did too. They do now.The deploy worker no longer leaks a timer per poll.
plugins/worker.tspushed every 2-second poll timer into an append-only array that onlystop()ever drained — roughly 43 000 deadTimeouthandles per concurrency slot per day of uptime. It is aSeteach timer removes itself from as it fires.Removed
lib/agentSeal.secretsMatch: exported, tested, and called by nothing —agentClient.tokenMatchesalready performs that comparison.The plugin marketplace no longer pretends to install anything. Nothing in
pluginLoader.tseverimport()s code, so an npm/git/local "install" became a DB row plus a shell whoseinitemits one event — while the panel reported it active. Several of the 16 catalog entries also shadow features that ship under another name: an operator who installed "Amazon S3 & Cloudflare R2 Sync", entered a bucket and secret key and saw it active would reasonably believe backups were being copied off-site. They were not, and they would find out at restore time. Now: npm/git/local installs are refused with an explanation; every catalog entry carriesimplemented(all 16 arefalsetoday) plus abuiltInpointer where it shadows a shipped feature, and installing one is refused with a message naming the real feature; the panel renders that pointer as a link instead of an Install button; and rows installed by an older build from an unsupported source are skipped at boot with a warning instead of restored as active-looking shells.Service visibility was computed three different ways.
GET /v1/servicesfiltered onowner_user_idalone while/dashboard,/domainsand the per-service loader also honoured workspace tags — a teammate could open and deploy a shared service by id but saw an empty list, while the dashboard counted it. All callers now sharevisibleServiceIdSetinlib/resourceAccess.ts.GET /v1/usersderived each row's operator badge from workspace seats, which after the change above would have shown every member as an operator. It reads the flag.CI on
mainwas red three ways. The main job's web suite failed 805 tests on Node 26: the jsdom storage globals arrive through vitest's environment population as an EMPTY object on Node ≥ 25 (localStorage.getItem is not a functionfrom the first render that touches one — they work on Node 24 and in plain jsdom, which is why the suite stayed green locally).apps/web/test/setup.tsnow detects a non-functional storage and backs both names with a working in-memory instance. The deprecated-dependency gate still matched@esbuild-kit/*andglob@10.5.0inpnpm-lock.yaml: the patches remove those edges from the graph, but the lockfile was never re-derived, so the stale packages anddrizzle-kit/archiver-utilssnapshot entries were hand-pruned (frozen installs verified). And the testcontainers suite failed five cases it had never once run green: the pg/mysql/mongo/redis suites asserted the OLDv<version>:at-rest envelope while the engine writes the streamedNDBK1:v<version>:<iv>header; the Redis fixture passed an emptypasswordEncrypted, whichdecryptrightly refused ("Invalid initialization vector") — it now starts the container with--requirepassand encrypts the same secret it stores; and the deploy e2e pullsbusybox:1.36up front with retries, because an auto-pull inside a test assertion turned a rate-limited registry into a red suite.
Docs
docs/NINEDEPLOY_MANIFEST.mdno longer describes machinery that does not exist. §4.14 impliednetwork.aliasesreached Docker, §4.15 described a channel "resolver" fornotificationsthat was never written, §4.16 claimed every alert "fires into exactly one named channel" (the channel is encoded in the rule name; delivery follows the global per-channel event filters), and the end-to-end example attributed the deploy trigger to the manifest's ownwatch.pathsrather than the webhook's. Each now says what actually happens and points at §6.3..env.examplepointed at aninedeploy rotate-keyscommand that did not exist, and omitted that completing the rotation by removing the old key destroys the restorability of every backup taken under it.ARCHITECTURE.mdrewritten against the actual tree: it had drifted a full release behind (28 tables → 40, migrations 0000–0019 → 0000–0038, "two roles" → workspace RBAC, single-project scoping → N-N project/workspace/label tags, MCP 15 tools → 36, "100% coverage everywhere" → the real tiered gates), and omitted workspaces, invitations, OIDC/SSO, the microkernel, Config Center, firewall, log drains, volume backups, repo insights, the.ninedeploymanifest, preview deployments and panel self-update entirely. A new "Known gaps" section records where the implementation still trails the intent.docs/WORKSPACES_RBAC.mdnow documents what the code enforces, including the instance-operator flag as a separate concept from workspace roles.