# Domains API

Service domains. 10 operations under /api/v1. Errors are RFC 9457 problem+json with a stable `code` field. The complete schemas are in https://nebulactrl.com/api/v1/openapi.json.

## PATCH /domains/{domainId}

Update a domain's routing rule (staged until deployed)

Requires `If-Match: "<generation>"` of the owning service; a stale value returns 412 `generation_conflict`. Hostname changes need `projects:write`; every other field (path prefix, strip/add prefix, redirect, TLS mode, pinned certificate, options) also needs `proxy:write` and an agent with proxy v2 (422 `agent_too_old`). 422 codes: `invalid_path_prefix`, `invalid_redirect`, `redirect_loop`, `invalid_tls_mode`, `certificate_mismatch`, `invalid_options`, `basic_auth_needs_users`. 409 `domain_conflict` when host+path is taken.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `domainId` (path, string, required)
- `If-Match` (header, string): The owning service's ETag (generation) from its last GET. Required.

Request body (application/json, required): UpdateInputBody
  - `addPrefix` (string): Prepend this path before forwarding to the service
  - `certificateId` (string): An uploaded certificate of the service's server whose names cover the hostname; empty clears the pin
  - `dnsResolver` (string): DNS-01 provider (a lego provider code) that obtains the certificate; the server must have a resolver for it. Empty = HTTP-01 or a covering wildcard certificate
  - `hostname` (string)
  - `kind` (enum: serve | redirect): serve routes to the service; redirect answers with a redirect to redirectTo
  - `options` (RouteOptions)
  - `pathPrefix` (string): Serve only this path (and below), e.g. /api. Empty = the whole host
  - `preservePath` (boolean): A redirect keeps the request path and query (default true)
  - `redirectCode` (enum: 301 | 302 | 307 | 308)
  - `redirectTo` (string): Target hostname of a redirect (not a URL)
  - `stripPrefix` (boolean): Remove the path prefix before forwarding to the service
  - `tlsMode` (enum: auto | custom | none): auto = ACME or the default certificate, custom = an uploaded certificate, none = plain HTTP only
  - `wwwRedirect` (boolean): true creates (false removes) a whole-host 301 redirect from the www/apex counterpart of this hostname (www.<host>, or the host without www.) to this domain. Both hostnames are routed and get certificates; each needs its own DNS record. Needs a whole-host serve domain (422 invalid_www_redirect)

Response 200: OK (DomainView)
  - `addPrefix` (string, required)
  - `authUsers` (string[] | null, required)
  - `cert` (DomainCert, required)
  - `certificateId` (string, required)
  - `component` (string, required)
  - `dns` (DomainDNS, required)
  - `dnsCheckedAt` (string (date-time) | null, required)
  - `dnsOk` (boolean | null, required)
  - `dnsResolver` (string, required): The lego code of the DNS-01 provider that obtains the certificate; empty = HTTP-01 or a covering wildcard certificate
  - `generated` (boolean, required)
  - `hostname` (string, required)
  - `id` (string, required)
  - `kind` (enum: serve | redirect, required)
  - `options` (RouteOptions, required)
  - `pathPrefix` (string, required)
  - `preservePath` (boolean, required)
  - `redirectCode` (enum: 301 | 302 | 307 | 308, required)
  - `redirectTo` (string, required)
  - `status` (string, required)
  - `statusMessage` (string, required)
  - `stripPrefix` (boolean, required)
  - `targetPort` (integer (int64), required)
  - `tlsMode` (enum: auto | custom | none, required)
  - `wwwPartner` (string, required): The counterpart hostname a www redirect would use (www.<host>, or the host without www.); empty when the domain cannot have one
  - `wwwRedirect` (boolean, required): A whole-host redirect from wwwPartner to this domain exists (both hostnames are routed and get certificates)
  - `wwwRedirectFor` (string, required): For the redirect domain of a www redirect: the hostname it redirects to; empty otherwise
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`domain_conflict`); 412 Precondition Failed (`generation_conflict`); 422 Unprocessable Entity (`agent_too_old`); 428 Precondition Required; 500 Internal Server Error

## DELETE /domains/{domainId}

Remove a domain (staged until deployed)

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `domainId` (path, string, required)

Response 204: No Content
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /domains/{domainId}/auth-users

Replace a domain's basic-auth users

Replaces the whole user set (needs `proxy:write`). A user that already exists may omit the password to keep it. Passwords are bcrypt-hashed (cost 10) and stored sealed; only user names are ever returned. Enable the option with `PATCH /domains/{id}` `options.basicAuth`. Users apply live with the next route push.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `domainId` (path, string, required)

Request body (application/json, required): AuthUserBody[] | null
  - `password` (string): Omit to keep the password of an existing user
  - `username` (string, required)

Response 200: OK (AuthUsersOutputBody)
  - `usernames` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## DELETE /domains/{domainId}/auth-users/{username}

Remove one basic-auth user

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `domainId` (path, string, required)
- `username` (path, string, required)

Response 204: No Content
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## GET /domains/{domainId}/dns

Live Cloudflare DNS status of a domain

Reads the records of the hostname from Cloudflare and classifies them: `managed` (a record NebulaCtrl created points to the server or tunnel), `present` (a record NebulaCtrl did not create already points there), `wrong_target`, `missing` or `not_in_zone`. Changes nothing.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `domainId` (path, string, required)

Response 200: OK (DNSStatus)
  - `checkedAt` (string (date-time) | null, required)
  - `configured` (boolean, required): The team has a Cloudflare connection
  - `conflict` (string, required): Why adopting is impossible (for example a CNAME next to an A record)
  - `desired` (RecordView, required)
  - `effectiveProxied` (boolean, required)
  - `error` (string, required): The last failed sync, empty when the last sync worked
  - `manage` (boolean, required): NebulaCtrl keeps this domain's records in sync
  - `message` (string, required)
  - `needsAdopt` (boolean, required)
  - `originLock` (OriginLockView, required)
  - `proxied` (boolean, required): The proxy setting of the domain (orange cloud)
  - `records` (RecordView[] | null, required): A, AAAA and CNAME records of the hostname
  - `sslMode` (enum:  | off | flexible | full | strict, required): The zone's SSL/TLS mode; empty when unknown
  - `status` (enum:  | managed | present | wrong_target | missing | not_in_zone, required)
  - `tunnel` (boolean, required): The server is behind a Cloudflare Tunnel: the record is a proxied CNAME to it
  - `warnings` (string[] | null, required)
  - `zoneId` (string, required)
  - `zoneName` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error; 502 Bad Gateway

## PUT /domains/{domainId}/dns

Change a domain's Cloudflare settings

Sets the per-domain proxy toggle and whether NebulaCtrl manages the domain's records, then syncs. A failed sync is reported as `error` in the status and retried in the background.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `domainId` (path, string, required)

Request body (application/json, required): DnsSettingsInputBody
  - `manage` (boolean): Let NebulaCtrl keep the domain's DNS records in sync
  - `originLock` (boolean): Lock the origin to Cloudflare while the domain is proxied (default true). False opts out: anyone who finds the server's IP can bypass Cloudflare. Audited.
  - `proxied` (boolean): Proxy through Cloudflare (orange cloud). Tunnel records are always proxied.

Response 200: OK (DNSStatus)
  - `checkedAt` (string (date-time) | null, required)
  - `configured` (boolean, required): The team has a Cloudflare connection
  - `conflict` (string, required): Why adopting is impossible (for example a CNAME next to an A record)
  - `desired` (RecordView, required)
  - `effectiveProxied` (boolean, required)
  - `error` (string, required): The last failed sync, empty when the last sync worked
  - `manage` (boolean, required): NebulaCtrl keeps this domain's records in sync
  - `message` (string, required)
  - `needsAdopt` (boolean, required)
  - `originLock` (OriginLockView, required)
  - `proxied` (boolean, required): The proxy setting of the domain (orange cloud)
  - `records` (RecordView[] | null, required): A, AAAA and CNAME records of the hostname
  - `sslMode` (enum:  | off | flexible | full | strict, required): The zone's SSL/TLS mode; empty when unknown
  - `status` (enum:  | managed | present | wrong_target | missing | not_in_zone, required)
  - `tunnel` (boolean, required): The server is behind a Cloudflare Tunnel: the record is a proxied CNAME to it
  - `warnings` (string[] | null, required)
  - `zoneId` (string, required)
  - `zoneName` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /domains/{domainId}/dns/fix

Fix a domain's DNS record

Creates or corrects the record so the hostname points at the server (or tunnel). A record NebulaCtrl did not create is never modified unless `adopt` is true (409 `adoption_required`; 409 `dns_conflict` when adopting is impossible, for example a CNAME next to an A record). 422 `not_configured` / `not_in_zone`; 502 when Cloudflare fails.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `domainId` (path, string, required)

Request body (application/json, required): DnsFixInputBody
  - `adopt` (boolean): Take over the existing record NebulaCtrl did not create: it is marked as managed and pointed at the server

Response 200: OK (DNSStatus)
  - `checkedAt` (string (date-time) | null, required)
  - `configured` (boolean, required): The team has a Cloudflare connection
  - `conflict` (string, required): Why adopting is impossible (for example a CNAME next to an A record)
  - `desired` (RecordView, required)
  - `effectiveProxied` (boolean, required)
  - `error` (string, required): The last failed sync, empty when the last sync worked
  - `manage` (boolean, required): NebulaCtrl keeps this domain's records in sync
  - `message` (string, required)
  - `needsAdopt` (boolean, required)
  - `originLock` (OriginLockView, required)
  - `proxied` (boolean, required): The proxy setting of the domain (orange cloud)
  - `records` (RecordView[] | null, required): A, AAAA and CNAME records of the hostname
  - `sslMode` (enum:  | off | flexible | full | strict, required): The zone's SSL/TLS mode; empty when unknown
  - `status` (enum:  | managed | present | wrong_target | missing | not_in_zone, required)
  - `tunnel` (boolean, required): The server is behind a Cloudflare Tunnel: the record is a proxied CNAME to it
  - `warnings` (string[] | null, required)
  - `zoneId` (string, required)
  - `zoneName` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`adoption_required`, `dns_conflict`); 422 Unprocessable Entity (`not_configured`); 500 Internal Server Error; 502 Bad Gateway

## GET /services/{serviceId}/domains

List a service's domains

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (ListOutputBody1)
  - `items` (DomainView[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/domains

Add a domain (staged until deployed)

Hostnames are RFC 1123, lowercase and globally unique (409 `domain_conflict`). Fails with 422 `proxy_unavailable` while the server's proxy is not running. `statusMessage` carries DNS guidance and `dnsOk` the result of the periodic DNS check. Compose stacks need `component` (422 `component_required`, `unknown_component`) and, unless the component declares exactly one port, `targetPort` (422 `target_port_required`).

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): CreateInputBody1
  - `addPrefix` (string): Prepend this path before forwarding to the service
  - `certificateId` (string): An uploaded certificate of the service's server whose names cover the hostname; empty clears the pin
  - `component` (string): Compose stacks only (required for them): the component (compose service) the domain routes to
  - `dnsResolver` (string): DNS-01 provider (a lego provider code) that obtains the certificate; the server must have a resolver for it. Empty = HTTP-01 or a covering wildcard certificate
  - `hostname` (string, required)
  - `kind` (enum: serve | redirect): serve routes to the service; redirect answers with a redirect to redirectTo
  - `options` (RouteOptions)
  - `pathPrefix` (string): Serve only this path (and below), e.g. /api. Empty = the whole host
  - `preservePath` (boolean): A redirect keeps the request path and query (default true)
  - `redirectCode` (enum: 301 | 302 | 307 | 308)
  - `redirectTo` (string): Target hostname of a redirect (not a URL)
  - `stripPrefix` (boolean): Remove the path prefix before forwarding to the service
  - `targetPort` (integer (int64)): Defaults to, and must equal, the service port. Compose stacks: the container port of the component; optional when the component declares exactly one port
  - `tlsMode` (enum: auto | custom | none): auto = ACME or the default certificate, custom = an uploaded certificate, none = plain HTTP only
  - `wwwRedirect` (boolean): true creates (false removes) a whole-host 301 redirect from the www/apex counterpart of this hostname (www.<host>, or the host without www.) to this domain. Both hostnames are routed and get certificates; each needs its own DNS record. Needs a whole-host serve domain (422 invalid_www_redirect)

Response 201: Created (DomainView)
  - `addPrefix` (string, required)
  - `authUsers` (string[] | null, required)
  - `cert` (DomainCert, required)
  - `certificateId` (string, required)
  - `component` (string, required)
  - `dns` (DomainDNS, required)
  - `dnsCheckedAt` (string (date-time) | null, required)
  - `dnsOk` (boolean | null, required)
  - `dnsResolver` (string, required): The lego code of the DNS-01 provider that obtains the certificate; empty = HTTP-01 or a covering wildcard certificate
  - `generated` (boolean, required)
  - `hostname` (string, required)
  - `id` (string, required)
  - `kind` (enum: serve | redirect, required)
  - `options` (RouteOptions, required)
  - `pathPrefix` (string, required)
  - `preservePath` (boolean, required)
  - `redirectCode` (enum: 301 | 302 | 307 | 308, required)
  - `redirectTo` (string, required)
  - `status` (string, required)
  - `statusMessage` (string, required)
  - `stripPrefix` (boolean, required)
  - `targetPort` (integer (int64), required)
  - `tlsMode` (enum: auto | custom | none, required)
  - `wwwPartner` (string, required): The counterpart hostname a www redirect would use (www.<host>, or the host without www.); empty when the domain cannot have one
  - `wwwRedirect` (boolean, required): A whole-host redirect from wwwPartner to this domain exists (both hostnames are routed and get certificates)
  - `wwwRedirectFor` (string, required): For the redirect domain of a www redirect: the hostname it redirects to; empty otherwise
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`domain_conflict`); 422 Unprocessable Entity (`proxy_unavailable`, `component_required`, `target_port_required`); 500 Internal Server Error

## POST /services/{serviceId}/domains/generate

Generate a domain

Creates `<service>-<env>-<project>.<base>` where base is the server's base domain, else `<public-ip>.sslip.io`. 422 `no_base_domain` when neither exists. Compose stacks need `component`; the hostname is `<service>-<component>-<env>-<project>.<base>`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)
- `component` (query, string): Compose stacks only (required for them): the component the generated domain routes to

Response 201: Created (DomainView)
  - `addPrefix` (string, required)
  - `authUsers` (string[] | null, required)
  - `cert` (DomainCert, required)
  - `certificateId` (string, required)
  - `component` (string, required)
  - `dns` (DomainDNS, required)
  - `dnsCheckedAt` (string (date-time) | null, required)
  - `dnsOk` (boolean | null, required)
  - `dnsResolver` (string, required): The lego code of the DNS-01 provider that obtains the certificate; empty = HTTP-01 or a covering wildcard certificate
  - `generated` (boolean, required)
  - `hostname` (string, required)
  - `id` (string, required)
  - `kind` (enum: serve | redirect, required)
  - `options` (RouteOptions, required)
  - `pathPrefix` (string, required)
  - `preservePath` (boolean, required)
  - `redirectCode` (enum: 301 | 302 | 307 | 308, required)
  - `redirectTo` (string, required)
  - `status` (string, required)
  - `statusMessage` (string, required)
  - `stripPrefix` (boolean, required)
  - `targetPort` (integer (int64), required)
  - `tlsMode` (enum: auto | custom | none, required)
  - `wwwPartner` (string, required): The counterpart hostname a www redirect would use (www.<host>, or the host without www.); empty when the domain cannot have one
  - `wwwRedirect` (boolean, required): A whole-host redirect from wwwPartner to this domain exists (both hostnames are routed and get certificates)
  - `wwwRedirectFor` (string, required): For the redirect domain of a www redirect: the hostname it redirects to; empty otherwise
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity (`no_base_domain`); 500 Internal Server Error
