openapi: 3.1.0
info:
  title: FENO Public API
  version: "1.0.0"
  summary: Customer-facing API for administering .no domains held at FENO.
  description: |
    Administer the `.no` domains you already hold at FENO: DNS records, domain lifecycle
    settings (auto-renew, transfer lock, nameservers, DNSSEC), contacts, and automated TLS
    through ACME DNS-01.

    Registration, renewal, transfer and any other operation that spends money are deliberately
    **not** on this surface.

    ## Authentication

    A bearer token created from the FENO dashboard:

    ```
    Authorization: Bearer feno_live_<43 base64url chars>
    ```

    The key is accepted only in that header — never a query string, cookie or body field. It is
    shown once at creation and stored only as an HMAC hash.

    ## Scopes

    | Scope | Permits |
    |---|---|
    | `domains:read` | Read domains, nameservers, DNSSEC state |
    | `domains:write` | Auto-renew, transfer lock, nameservers, DS records |
    | `dns:read` | Read DNS records |
    | `dns:write` | Create/replace/delete any supported record type at any name |
    | `contacts:read` | Read contacts |
    | `contacts:write` | Update the customer-editable contact fields |
    | `acme:write` | Write TXT records at `_acme-challenge*` only — nothing else, plus a minimal `GET /domains/{domain}` and a `GET /domains/{domain}/dns` that lists only `_acme-challenge*` TXT records unless the key also holds `dns:read` |
    | `ddns:write` | Update A/AAAA records through the dyndns2 endpoint only (`dns:write` also satisfies those routes) |

    A `:write` scope does **not** imply the matching `:read`. `GET /me` needs no scope.

    That independence is why `GET /domains/{domain}/dns` filters on `dns:read` rather than on
    which scope let the call in: a key holding `acme:write` **and** `dns:write` may write the
    whole zone and still lists only `_acme-challenge*` TXT records. The response carries
    `filtered: true` whenever that has happened, so a partial listing is never mistaken for an
    empty zone.

    Use `acme:write` for CI runners and certificate renewal: it cannot touch MX, A or any other
    record, so a leaked build-agent credential cannot redirect your mail.

    ## Response envelope

    Success: `{"success": true, "data": ...}`.
    Error: `{"success": false, "error": "...", "code": "ERROR_CODE", "data": null}`.
    Branch on `code`, not on `error`.

    `POST /acme/register` and `POST /acme/update` are the two exceptions: they implement the
    acme-dns wire contract byte for byte and return acme-dns's bare JSON shapes, because that is
    what off-the-shelf ACME clients parse.

    ## Pagination

    `GET /domains` is cursor-paginated (`limit`, `cursor` → `nextCursor`, `hasMore`).
    `GET /contacts` is offset-paginated (`limit`, `offset` → `total`).

    ## Rate limits

    Per API key: 120 requests/minute and 5000 requests/hour, both sliding windows. Every
    authenticated response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
    `X-RateLimit-Reset`; a `429` carries `Retry-After`. `POST /acme/update` is limited
    separately, per delegation: 20/minute and 120/hour.

    ## Propagation

    FENO is not the authoritative nameserver — a `200` means the DNS write was accepted, not
    that every resolver can see it. ACME clients must be given an explicit propagation wait
    (60 seconds is the sane default).
  contact:
    name: FENO support
    url: https://feno.no
  license:
    name: Proprietary

servers:
  - url: https://api.feno.no/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Identity
    description: Who this key belongs to and what it can reach.
  - name: Domains
    description: Read domains and change their lifecycle settings.
  - name: DNS
    description: DNS records of zones on FENO nameservers.
  - name: Contacts
    description: Read and update contacts.
  - name: ACME
    description: DNS-01 challenge delegations, including the acme-dns compatible endpoints.
  - name: Dynamic DNS
    description: dyndns2-compatible dynamic DNS for routers and NAS boxes. Plain-text responses.

paths:
  /me:
    get:
      tags: [Identity]
      summary: Identity of the calling key
      description: |
        Returns the account the key belongs to and the scopes it carries. Requires no particular
        scope — it is an authentication check, not an authorization one, which makes it the
        fastest way to diagnose an `INSUFFICIENT_SCOPE` elsewhere.

        The raw key is never echoed back; `keyPrefix` is the display fragment.
      operationId: getMe
      responses:
        "200":
          description: The account and the calling key.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/Me"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /domains:
    get:
      tags: [Domains]
      summary: List your domains
      description: |
        Cursor-paginated, newest first. Includes domains owned outright and domains reachable
        through membership of their billing contact.

        Loop while `hasMore` is true, passing `nextCursor` back verbatim. The cursor is opaque —
        constructing or editing one returns `INVALID_CURSOR`.
      operationId: listDomains
      security:
        - bearerAuth: [domains:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of domains.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    type: object
                    required: [items, limit, hasMore, nextCursor]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/Domain"
                      limit:
                        type: integer
                        examples: [50]
                      hasMore:
                        type: boolean
                      nextCursor:
                        type: [string, "null"]
                        description: Pass back as `cursor` for the next page. Null on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /domains/{domain}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Domains]
      summary: Get one domain
      description: |
        Accepts a key with `domains:read` **or** `acme:write`.

        A key holding only `acme:write` gets a MINIMAL shape —
        `{"domain": "kunde.no", "status": "active", "fenoNameservers": true}` — because the only
        reason a DNS-01 client calls this endpoint is to find out which candidate name is a zone
        it can write the challenge into. Expiry, contacts, lock state and the nameserver list
        stay behind `domains:read`.
      operationId: getDomain
      security:
        - bearerAuth: [domains:read]
        - bearerAuth: [acme:write]
      responses:
        "200":
          description: The domain.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/Domain"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
    patch:
      tags: [Domains]
      summary: Change auto-renew and/or the transfer lock
      description: |
        Send `willRenew`, `transferLock`, or both. `autoRenew` is accepted as an alias for
        `willRenew`.

        **Unlocking returns the Norid `authInfo`** in the response — that is the code another
        registrar needs, and an unlock without it is useless.

        The two changes are applied independently and are **not atomic**. If the second one
        fails, the error carries `data.applied` listing which fields did land, so a retry can
        cover only what is missing.
      operationId: updateDomain
      security:
        - bearerAuth: [domains:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                willRenew:
                  type: boolean
                  description: Auto-renewal on or off.
                autoRenew:
                  type: boolean
                  description: Alias for `willRenew`.
                transferLock:
                  type: boolean
                  description: Norid transfer lock on or off.
            examples:
              unlock:
                summary: Unlock for transfer away
                value: { transferLock: false }
              stopRenewal:
                summary: Stop auto-renewing
                value: { willRenew: false }
      responses:
        "200":
          description: The updated domain. `authInfo` is present only after an unlock.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    allOf:
                      - $ref: "#/components/schemas/Domain"
                      - type: object
                        properties:
                          authInfo:
                            type: string
                            description: Norid authInfo, returned only when the transfer lock was released.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            Rejected by the domain lifecycle layer — e.g. `ACCOUNT_NOT_LINKED`, `DOMAIN_EXPIRED`,
            `AUTO_RENEW_REQUIRED_WHILE_LISTED`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /domains/{domain}/nameservers:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    put:
      tags: [Domains]
      summary: Replace the nameserver set
      description: |
        Replaces the delegation wholesale. Norid requires at least two nameservers and pre-checks
        that they answer authoritatively for the zone, so a set that is not yet live is rejected
        with `DNS_NOT_PROPAGATED` rather than producing a broken delegation.

        Switching **away** from FENO nameservers removes FENO-managed DNSSEC; switching **to**
        them enables it.
      operationId: updateNameservers
      security:
        - bearerAuth: [domains:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nameservers]
              properties:
                nameservers:
                  type: array
                  # The delegated internal handler enforces 2..6 (INSUFFICIENT_NAMESERVERS /
                  # TOO_MANY_NAMESERVERS); a 1-element set was never accepted.
                  minItems: 2
                  maxItems: 6
                  items:
                    type: string
                  examples:
                    - ["ns1.feno.no", "ns2.feno.no"]
      responses:
        "200":
          description: The delegation as it now stands.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    type: object
                    properties:
                      domainName:
                        type: string
                      nameservers:
                        type: array
                        items:
                          type: string
                      message:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /domains/{domain}/dnssec:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Domains]
      summary: Read the DS records published at Norid
      operationId: getDnssec
      security:
        - bearerAuth: [domains:read]
      responses:
        "200":
          description: Current DNSSEC state.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/DnssecState"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
    put:
      tags: [Domains]
      summary: Add or remove DS records
      description: |
        Manual DS management, for domains you sign yourself. Domains on FENO nameservers are
        signed automatically and need none of this.

        Norid validates a DS live when it is submitted — it queries the zone's nameservers for a
        matching DNSKEY and valid signatures — so a DS pushed before the zone is serving it is
        rejected.
      operationId: updateDnssec
      security:
        - bearerAuth: [domains:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                addDsRecords:
                  type: array
                  items:
                    $ref: "#/components/schemas/DsRecord"
                remDsRecords:
                  type: array
                  items:
                    $ref: "#/components/schemas/DsRecord"
                removeAll:
                  type: boolean
                  description: Remove every DS record at Norid.
      responses:
        "200":
          description: DNSSEC state after the change.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/DnssecState"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /domains/{domain}/dns:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [DNS]
      summary: List DNS records
      description: |
        Requires the domain to use FENO nameservers, else `400 NOT_FENO_NS`.

        Accepts a key with `dns:read` **or** `acme:write`.

        **A key that does not hold `dns:read` gets a FILTERED list** — TXT records at
        `_acme-challenge` / `_acme-challenge.<label>` only, the records an `acme:write` credential
        could have written itself. That is what lets a certificate client find the id of the
        challenge record it must delete after validation without being able to read the rest of
        the zone.

        The filter is keyed on `dns:read`, not on which scope admitted the call, so it applies to
        a key holding `acme:write` **and** `dns:write` as well: such a key may write the whole
        zone and still sees only challenge records. Read `filtered` in the response rather than
        inferring it from your own scope list.
      operationId: listDnsRecords
      security:
        - bearerAuth: [dns:read]
        - bearerAuth: [acme:write]
      responses:
        "200":
          description: |
            The zone's records — every one of them when `filtered` is `false`, and only the
            `_acme-challenge*` TXT records when it is `true`.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    type: object
                    required: [domainName, records, filtered, filterReason]
                    properties:
                      domainName:
                        type: string
                      records:
                        type: array
                        items:
                          $ref: "#/components/schemas/DnsRecord"
                      filtered:
                        type: boolean
                        description: |
                          `true` when records were withheld because the key does not hold
                          `dns:read`. Always present, never omitted when `false`, so an
                          incomplete listing can never be mistaken for a complete one.
                      filterReason:
                        type: [string, "null"]
                        description: Why the listing was narrowed, or `null` when it was not.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
        "500":
          $ref: "#/components/responses/ServerError"
    post:
      tags: [DNS]
      summary: Create a DNS record
      description: |
        Accepts a key with `dns:write` **or** `acme:write`. A key holding only `acme:write` may
        create TXT records at `_acme-challenge` or `_acme-challenge.<label>` and nothing else;
        anything wider returns `403 ACME_SCOPE_VIOLATION`, checked before the zone is touched.

        `name` is **relative to the zone**: `""` is the apex, `_acme-challenge` is the challenge
        record of the zone itself. Sending an absolute FQDN creates
        `_acme-challenge.kunde.no.kunde.no`, which is accepted and resolves to nothing.
      operationId: createDnsRecord
      security:
        - bearerAuth: [dns:write]
        - bearerAuth: [acme:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DnsRecordInput"
            examples:
              acmeChallenge:
                summary: ACME DNS-01 challenge record
                value:
                  type: TXT
                  name: _acme-challenge
                  value: Xf3k9Lm2pQr7sT1uV4wY6zA8bC0dE5fG7hI9jK1lM3n
                  ttl: 60
              webApex:
                summary: A record at the apex
                value:
                  type: A
                  name: ""
                  value: 203.0.113.5
                  ttl: 3600
              mail:
                summary: MX record
                value:
                  type: MX
                  name: ""
                  value: mail.kunde.no
                  priority: 10
                  ttl: 3600
              caa:
                summary: CAA record (split form — value is the CA domain, tag/flags are fields)
                value:
                  type: CAA
                  name: ""
                  value: letsencrypt.org
                  flags: 0
                  tag: issue
                  ttl: 3600
      responses:
        "201":
          description: The created record.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/DnsRecord"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
        "500":
          $ref: "#/components/responses/ServerError"

  /domains/{domain}/dns/{recordId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RecordId"
    put:
      tags: [DNS]
      summary: Replace a DNS record
      description: |
        Under `acme:write`, **both** the record as it stands and the record as it would become
        must be within reach — otherwise a key could overwrite an MX record it happens to know
        the id of, or convert a challenge TXT into an A record pointing anywhere.

        `ttl` and the per-type numeric fields are **kept as they are when omitted** — an omitted
        `ttl` used to silently reset the record to 3600, which turned a 60-second challenge
        record into an hour-long one with nothing in the response to show it. `type` and `value`
        are always required.
      operationId: updateDnsRecord
      security:
        - bearerAuth: [dns:write]
        - bearerAuth: [acme:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DnsRecordInput"
      responses:
        "200":
          description: The record as it now stands — the full DnsRecord shape, as POST and GET return.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/DnsRecord"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
        "500":
          $ref: "#/components/responses/ServerError"
    delete:
      tags: [DNS]
      summary: Delete a DNS record
      description: |
        Under `acme:write`, the record being deleted must itself be a TXT at `_acme-challenge*`.

        During a wildcard issuance two TXT values live at the same `_acme-challenge` name — one
        per authorization. Delete only the value you published, or you will fail the other
        authorization.
      operationId: deleteDnsRecord
      security:
        - bearerAuth: [dns:write]
        - bearerAuth: [acme:write]
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      deleted:
                        const: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/BadGateway"
        "500":
          $ref: "#/components/responses/ServerError"

  /contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      description: Contacts this account owns, plus organisation contacts it is a member of. Offset-paginated.
      operationId: listContacts
      security:
        - bearerAuth: [contacts:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of contacts.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    type: object
                    required: [items, total, limit, offset]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/Contact"
                      total:
                        type: integer
                      limit:
                        type: integer
                      offset:
                        type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /contacts/{id}:
    parameters:
      - $ref: "#/components/parameters/ContactId"
    get:
      tags: [Contacts]
      summary: Get one contact
      operationId: getContact
      security:
        - bearerAuth: [contacts:read]
      responses:
        "200":
          description: The contact.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/Contact"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
    patch:
      tags: [Contacts]
      summary: Update the customer-editable fields
      description: |
        Only the fields listed in the request schema may be changed. Anything else is rejected
        loudly (`UNKNOWN_FIELD`, or `FIELD_NOT_EDITABLE` with the reason) rather than silently
        dropped — a caller must never believe a change landed when it did not.

        Read-only on purpose: `orgForm`, `vatRegistered` and `orgName` come from Brreg;
        `primaryContactName` and the person identifier re-validate against Norid and must be
        changed from the dashboard; `payoutBankAccount` has its own dashboard endpoint;
        `invoiceEnabled` and `verificationStatus` are set by FENO.

        Switching `preferredPaymentMethod` to `EMAIL` or `EHF` requires invoicing approval
        (`403 INVOICE_NOT_ENABLED`) — it is a credit decision, not a preference.
      operationId: updateContact
      security:
        - bearerAuth: [contacts:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              properties:
                address:
                  type: [string, "null"]
                city:
                  type: [string, "null"]
                state:
                  type: [string, "null"]
                zip:
                  type: [string, "null"]
                country:
                  type: string
                  description: ISO 3166-1 alpha-2.
                  examples: ["NO"]
                preferredPaymentMethod:
                  type: string
                  enum: [CARD, EMAIL, EHF]
                paymentEmail:
                  type: [string, "null"]
                paymentReference:
                  type: [string, "null"]
                primaryContactEmail:
                  type: [string, "null"]
                primaryContactPhone:
                  type: [string, "null"]
                color:
                  type: [string, "null"]
      responses:
        "200":
          description: The updated contact.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /acme/delegations:
    post:
      tags: [ACME]
      summary: Create an ACME challenge delegation
      description: |
        Creates an acme-dns credential whose challenge TXT records live in a FENO-controlled
        zone (`acme.feno.no`), never in your own. A stolen delegation credential therefore cannot
        touch a single real record of yours.

        When the domain is already on FENO nameservers, the
        `_acme-challenge.<domain> CNAME <subdomain>.acme.feno.no` record is created for you —
        `cnameCreated` and `cnameReason` report what happened.

        Requires `dns:write`. `acme:write` deliberately does not reach credential-creating
        endpoints.

        **`password` is returned once and is not recoverable.**
      operationId: createAcmeDelegation
      security:
        - bearerAuth: [dns:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain:
                  type: string
                  examples: ["kunde.no"]
                allowfrom:
                  type: array
                  maxItems: 256
                  description: Source IPs/CIDRs allowed to use the credential. Empty or omitted means any. At most 256 entries.
                  items:
                    type: string
                  examples:
                    - ["203.0.113.10", "198.51.100.0/24"]
      responses:
        "201":
          description: The delegation, including the one-time password.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    $ref: "#/components/schemas/AcmeDelegationCreated"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The account already holds 100 delegations (`ACME_DELEGATION_LIMIT_REACHED`). Revoke unused ones first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          description: The FENO delegation zone could not be resolved (`ACME_DELEGATION_ZONE_MISSING`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [ACME]
      summary: List your ACME delegations
      description: Never returns the password or its hash. `publishedTxt` shows the values currently live.
      operationId: listAcmeDelegations
      security:
        - bearerAuth: [dns:write]
      responses:
        "200":
          description: The account's delegations.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    type: object
                    properties:
                      delegations:
                        type: array
                        items:
                          $ref: "#/components/schemas/AcmeDelegation"
                      delegationZone:
                        type: string
                        examples: ["acme.feno.no"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /acme/delegations/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Delegation id.
        schema:
          type: string
    delete:
      tags: [ACME]
      summary: Revoke a delegation
      description: |
        Kills the credential and deletes the TXT records it published in the delegation zone.

        The CNAME lives in **your** zone and may point at a delegation you intend to recreate, so
        it is reported as `cnameLeftInPlace` rather than deleted behind your back.
      operationId: deleteAcmeDelegation
      security:
        - bearerAuth: [dns:write]
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    const: true
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      subdomain:
                        type: string
                      revoked:
                        const: true
                      txtRecordsRemoved:
                        type: integer
                      cnameLeftInPlace:
                        $ref: "#/components/schemas/CnameInstruction"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /acme/register:
    post:
      tags: [ACME]
      summary: acme-dns register (acme-dns response shape)
      description: |
        The [acme-dns](https://github.com/joohoi/acme-dns) `POST /register` endpoint. Returns the
        bare five-field credential block clients expect — **not** FENO's `{success, data}`
        envelope.

        **Divergence from upstream:** upstream leaves this endpoint unauthenticated. On a
        multi-tenant registrar that would be an open write endpoint against FENO's own DNS zone,
        so FENO requires a bearer key with `dns:write`; an anonymous call gets
        `403 {"error": "forbidden"}`.

        This has no effect on renewals — no ACME client calls `/register` during renewal. It is a
        one-time setup step, and `POST /acme/update` stays bit-compatible with upstream.

        `domain` is a FENO extension: naming a domain you own lets FENO create the delegation
        CNAME for you.

        **Error shapes on this endpoint are mixed, deliberately.** The bearer check runs in the
        shared `/v1` auth layer, which answers in FENO's `{success, error, code}` envelope —
        so `401` (`AUTH_REQUIRED`, `INVALID_API_KEY`, `API_KEY_INACTIVE`, `API_KEY_EXPIRED`),
        `403` (`INSUFFICIENT_SCOPE`, `IP_NOT_ALLOWED`, `ACCOUNT_SUSPENDED`) and `429`
        (`RATE_LIMITED`, with `Retry-After`) all use the envelope. The **one** exception is a
        completely anonymous call, refused before that layer with the acme-dns-shaped
        `403 {"error": "forbidden", "detail": "..."}` an acme-dns client can read.

        Errors raised inside the handler use the acme-dns `{error, detail}` shape, but the
        `error` token is FENO's machine code rather than an upstream token — `bad_domain` (400,
        `domain` was not a string), `INVALID_ALLOWFROM` (400), `DOMAIN_NOT_MANAGED` (404),
        `ACME_DELEGATION_ZONE_MISSING` (503) — with `server_error` for anything 5xx. Branch on the
        HTTP status first; `detail` is the human-readable half.

        A `domain` that is not yours answers the same `404 DOMAIN_NOT_MANAGED` as a name FENO has
        never held; the two are not distinguishable, on purpose (see the shared `404` response).

        `POST /acme/update`, the endpoint renewals actually use, emits only upstream tokens.
      operationId: acmeDnsRegister
      security:
        - bearerAuth: [dns:write]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                domain:
                  type: string
                  description: FENO extension. A domain you hold at FENO; enables automatic CNAME creation.
                  examples: ["kunde.no"]
                allowfrom:
                  type: array
                  items:
                    type: string
      responses:
        "201":
          description: acme-dns credentials. Store this JSON verbatim — clients consume it as-is.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcmeDnsCredentials"
        "400":
          $ref: "#/components/responses/AcmeDnsError"
        "401":
          $ref: "#/components/responses/AcmeDnsError"
        "403":
          $ref: "#/components/responses/AcmeDnsError"
        "404":
          $ref: "#/components/responses/AcmeDnsError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/AcmeDnsError"
        "503":
          $ref: "#/components/responses/AcmeDnsError"

  /acme/update:
    post:
      tags: [ACME]
      summary: acme-dns update — publish a challenge value
      description: |
        The endpoint every renewal actually hits. Authenticates with the **delegation's own**
        credentials in `X-Api-User` / `X-Api-Key` headers — not with a FENO API key — and returns
        acme-dns's bare shapes.

        **Two TXT slots.** A certificate covering both `kunde.no` and the wildcard produces two
        distinct challenge values at the same `_acme-challenge` name, and the CA needs both live
        simultaneously. Each update shifts the previous value into slot 2 and publishes both.
        Re-submitting the value already in slot 1 does not shift, so a client retry cannot evict
        the other live challenge.

        Rate limited per delegation: 20/minute, 120/hour.
      operationId: acmeDnsUpdate
      security:
        - acmeDnsUser: []
          acmeDnsKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subdomain, txt]
              properties:
                subdomain:
                  type: string
                  description: Must be this credential's own subdomain.
                txt:
                  type: string
                  maxLength: 255
                  description: The challenge value. Non-empty, printable, at most 255 characters.
      responses:
        "200":
          description: Published. Echoes the value just set.
          content:
            application/json:
              schema:
                type: object
                required: [txt]
                properties:
                  txt:
                    type: string
        "400":
          $ref: "#/components/responses/AcmeDnsError"
        "401":
          $ref: "#/components/responses/AcmeDnsError"
        "429":
          $ref: "#/components/responses/AcmeDnsError"
        "500":
          $ref: "#/components/responses/AcmeDnsError"

  /nic/update:
    get:
      tags: [Dynamic DNS]
      summary: dyndns2 update — point A/AAAA records at a changing address
      description: |
        The dyndns2 protocol, as spoken by ddclient, inadyn, routers and NAS firmware.
        Responses are **plain text**, never the JSON envelope: one line per hostname, using the
        dyndns2 tokens (`good <ip>`, `nochg <ip>`, `nohost`, `notfqdn`, `numhost`, `badagent`,
        `abuse`, `dnserr`, `911`; auth failures are HTTP 401 with body `badauth`).

        Auth: `Authorization: Bearer feno_live_...` **or** HTTP Basic with the API key as the
        password (username ignored) — the Basic form exists only on the Dynamic DNS routes.
        Scope: `ddns:write` (or `dns:write`).

        Also served at the **API root** (`https://api.feno.no/nic/update`, outside `/v1`) so
        that ddclient's `server=api.feno.no` works unmodified, and aliased at `GET /dyndns`.

        Records are created with TTL 60; an existing record keeps its TTL up to 300, above
        which it is lowered to 60. On top of the normal per-key limits, this endpoint allows 10
        updates/min per key (over → `abuse`). See DDNS.md for client recipes.
      operationId: dyndns2Update
      security:
        - bearerAuth: [ddns:write]
        - bearerAuth: [dns:write]
        - basicDdnsAuth: []
      parameters:
        - name: hostname
          in: query
          required: true
          description: FQDN to update, or a comma-separated list of up to 20.
          schema:
            type: string
          example: home.kunde.no
        - name: myip
          in: query
          required: false
          description: |
            IPv4 to publish. Absent → the request's source address. An IPv6 value here is
            treated as `myipv6`.
          schema:
            type: string
          example: 203.0.113.7
        - name: myipv6
          in: query
          required: false
          description: IPv6 to publish (AAAA record).
          schema:
            type: string
          example: 2001:db8::1
        - name: offline
          in: query
          required: false
          description: Accepted and ignored — FENO never parks a name.
          schema:
            type: string
      responses:
        "200":
          description: |
            One dyndns2 token line per hostname, in request order (request-level failures are a
            single line).
          content:
            text/plain:
              schema:
                type: string
              examples:
                updated:
                  value: "good 203.0.113.7"
                unchanged:
                  value: "nochg 203.0.113.7"
                unknownHost:
                  value: "nohost"
        "401":
          description: The credential did not resolve to a usable key.
          content:
            text/plain:
              schema:
                type: string
              example: badauth

  /dyndns:
    get:
      tags: [Dynamic DNS]
      summary: Alias of /nic/update
      description: |
        Identical handler and contract as `GET /nic/update`, for clients that take a full URL
        (ddns-updater `custom`, Synology DSM, QNAP, curl).
      operationId: dyndns2UpdateAlias
      security:
        - bearerAuth: [ddns:write]
        - bearerAuth: [dns:write]
        - basicDdnsAuth: []
      parameters:
        - name: hostname
          in: query
          required: true
          schema:
            type: string
        - name: myip
          in: query
          required: false
          schema:
            type: string
        - name: myipv6
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: dyndns2 token line(s), as on `/nic/update`.
          content:
            text/plain:
              schema:
                type: string
        "401":
          description: The credential did not resolve to a usable key.
          content:
            text/plain:
              schema:
                type: string
              example: badauth

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        A FENO public API key: `feno_live_` + 43 base64url characters. Created from the dashboard
        and shown once. Sent as `Authorization: Bearer feno_live_...`.

        The scopes listed on each operation are enforced; a `:write` scope does not imply the
        matching `:read`.
    basicDdnsAuth:
      type: http
      scheme: basic
      description: |
        Accepted ONLY on the Dynamic DNS routes: HTTP Basic where the PASSWORD is the FENO API
        key and the username is ignored (use `feno`). Exists because dyndns2 clients cannot
        send a Bearer header. Runs the identical key checks as bearerAuth.
    acmeDnsUser:
      type: apiKey
      in: header
      name: X-Api-User
      description: acme-dns delegation username (a UUID). Used only by `POST /acme/update`.
    acmeDnsKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: acme-dns delegation password. Used only by `POST /acme/update`.

  parameters:
    DomainName:
      name: domain
      in: path
      required: true
      description: |
        The domain name, e.g. `kunde.no`. Case-insensitive, surrounding whitespace is trimmed, and
        the fully-qualified form with a trailing root dot (`kunde.no.`) is accepted as the same
        name — DNS tooling hands you that spelling.
      schema:
        type: string
      example: kunde.no
    RecordId:
      name: recordId
      in: path
      required: true
      description: Numeric DNS record id, as returned by `GET /domains/{domain}/dns`.
      schema:
        type: integer
      example: 481522
    ContactId:
      name: id
      in: path
      required: true
      description: Contact id.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      required: false
      description: Items per page. Values above 200 are clamped to 200.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor from the previous page's `nextCursor`. Do not construct one.
      schema:
        type: string
    Offset:
      name: offset
      in: query
      required: false
      description: Items to skip.
      schema:
        type: integer
        minimum: 0
        default: 0

  headers:
    XRateLimitLimit:
      description: Requests permitted per minute for this key.
      schema:
        type: integer
        examples: [60]
    XRateLimitRemaining:
      description: Requests left in the current minute.
      schema:
        type: integer
    XRateLimitReset:
      description: Length of the rate-limit window in seconds.
      schema:
        type: integer
        examples: [60]
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer

  responses:
    BadRequest:
      description: |
        Invalid input. Common codes: `MISSING_DNS_FIELDS`, `INVALID_RECORD_TYPE`,
        `INVALID_DNS_RECORD`, `INVALID_RECORD_NAME`, `INVALID_RECORD_ID`, `INVALID_CURSOR`,
        `NO_DOMAIN_FIELDS`, `INVALID_NAMESERVERS`, `MISSING_DNSSEC_PARAMS`, `UNKNOWN_FIELD`,
        `FIELD_NOT_EDITABLE`, `INVALID_COUNTRY`, and `NOT_FENO_NS` when the domain is not on
        FENO nameservers.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: |
        `AUTH_REQUIRED`, `INVALID_API_KEY`, `API_KEY_INACTIVE`, `API_KEY_EXPIRED` or
        `ACCOUNT_NOT_FOUND`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: |
        `INSUFFICIENT_SCOPE`, `ACME_SCOPE_VIOLATION`, `IP_NOT_ALLOWED`, `ACCOUNT_SUSPENDED` or
        `INVOICE_NOT_ENABLED`.

        A 403 on this API is always about **the key** — its scopes, its IP allowlist, the state of
        the account behind it. It is never the answer to "that resource belongs to someone else";
        see `404` below.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: |
        `DOMAIN_NOT_MANAGED`, `DNS_RECORD_NOT_FOUND`, `CONTACT_NOT_FOUND` or
        `ACME_DELEGATION_NOT_FOUND`.

        For domains and contacts this one answer covers **two** cases deliberately: the name or id
        does not exist at FENO at all, and it exists but belongs to another account. They are
        indistinguishable on purpose — telling them apart would let any key be used to ask whether
        an arbitrary `.no` name is in FENO's portfolio. `POST /account/dns-update-keys` has always
        collapsed them for the same reason.

        Practical consequence for a DNS-01 zone walk: keep peeling labels on `404`, and stop on
        `401`/`403`, which mean the key itself is the problem.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: "`RATE_LIMITED` — the per-key window, or the failed-authentication throttle."
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ServerError:
      description: Unexpected failure. The message is generic; quote `X-Request-Id` to support.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BadGateway:
      description: |
        `DNS_PROVIDER_ERROR` — the DNS provider behind FENO failed for a reason that is not
        about your request. Its own status code and vendor error code are never forwarded (they
        describe FENO's account, not yours). Retry with backoff.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AcmeDnsError:
      description: |
        acme-dns error shape — **not** FENO's envelope. Tokens: `unauthorized`, `forbidden`,
        `bad_txt`, `bad_domain`, `bad_request`, `rate_limited`, `server_error`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AcmeDnsErrorBody"

  schemas:
    Error:
      type: object
      required: [success, error, code, data]
      properties:
        success:
          const: false
        error:
          type: string
          description: Human-readable. Do not branch on it.
        code:
          type: string
          description: Machine-readable. Branch on this.
          examples: ["INSUFFICIENT_SCOPE"]
        data:
          description: |
            Null on almost every error. `AUTO_RENEW_ERROR` carries `{"applied": [...]}` naming the
            fields that did land, because a two-field PATCH is not atomic.
          type: [object, "null"]

    Me:
      type: object
      properties:
        account:
          type: object
          properties:
            id:
              type: string
            email:
              type: string
            firstName:
              type: [string, "null"]
            lastName:
              type: [string, "null"]
        apiKey:
          type: [object, "null"]
          properties:
            id:
              type: string
            label:
              type: string
            keyPrefix:
              type: string
              description: First 15 characters, for display. The raw key is never returned.
              examples: ["feno_live_a1b2c"]
            scopes:
              type: array
              items:
                type: string
            expiresAt:
              type: [string, "null"]
              format: date-time
            lastUsedAt:
              type: [string, "null"]
              format: date-time
            createdAt:
              type: [string, "null"]
              format: date-time

    Domain:
      type: object
      description: |
        An explicit whitelist of fields. Internal flags and the Norid authInfo are never included.
      properties:
        id:
          type: string
        domainName:
          type: string
          examples: ["kunde.no"]
        status:
          type: string
          description: Domain lifecycle status.
        provider:
          type: string
          examples: ["NORID"]
        registeredAt:
          type: [string, "null"]
          format: date-time
        expiresAt:
          type: [string, "null"]
          format: date-time
        isExpired:
          type: boolean
        willRenew:
          type: boolean
          description: Auto-renewal.
        transferLock:
          type: boolean
        nameservers:
          type: array
          items:
            type: string
          examples:
            - ["ns1.feno.no", "ns2.feno.no"]
        dnssecEnabled:
          type: boolean
          description: Presence only. `GET /domains/{domain}/dnssec` serves the DS records.

    DnssecState:
      type: object
      properties:
        domainName:
          type: string
        enabled:
          type: boolean
        dsRecords:
          type: array
          items:
            $ref: "#/components/schemas/DsRecord"

    DsRecord:
      type: object
      properties:
        keyTag:
          type: integer
        alg:
          type: integer
          description: DNSSEC algorithm number.
        digestType:
          type: integer
        digest:
          type: string

    DnsRecord:
      type: object
      properties:
        id:
          type: integer
        type:
          type: string
          enum: [A, AAAA, CNAME, TXT, MX, SRV, CAA, NS]
        name:
          type: string
          description: Relative to the zone. `""` is the apex.
        value:
          type: string
        ttl:
          type: [integer, "null"]
        priority:
          type: [integer, "null"]
          description: MX and SRV.
        weight:
          type: [integer, "null"]
          description: SRV.
        port:
          type: [integer, "null"]
          description: SRV.
        flags:
          type: [integer, "null"]
          description: CAA. `value` is the CA domain / iodef URL on its own — never the `<flags> <tag> <value>` triple.
        tag:
          type: [string, "null"]
          description: CAA. `issue`, `issuewild` or `iodef`.

    DnsRecordInput:
      type: object
      required: [type, value]
      description: |
        Validated server-side per type before anything is written: an `A` value must be an IPv4
        address, `AAAA` an IPv6 address, `CNAME`/`MX` a hostname. A failure is
        `400 INVALID_DNS_RECORD` naming the field. This is the same validation the FENO
        dashboard applies — the two write to the same zones.

        **CAA** is written in the SPLIT form, the same shape `GET` returns: `value` is the CA
        domain (`issue`/`issuewild`) or the report URL (`iodef`, `mailto:` or `http(s)://`),
        `tag` is required (`issue` | `issuewild` | `iodef`, case-insensitive, stored
        lowercase) and `flags` is 0–255 (default 0). `;` is accepted as the `issue`/`issuewild`
        value meaning "no CA may issue" (RFC 8659). For compatibility, the zone-file triple
        `"<flags> <tag> <value>"` in `value` (e.g. `"0 issue letsencrypt.org"`) is split
        server-side when `flags`/`tag` are omitted; if they are sent too and disagree with the
        triple, the request is `400 INVALID_DNS_RECORD`. On `PUT`, omitted `flags`/`tag` keep the
        record's current values.
      examples:
        # CAA, canonical split form
        - { type: CAA, name: "", value: letsencrypt.org, flags: 0, tag: issue, ttl: 3600 }
        # CAA, zone-file triple — split server-side into the row above
        - { type: CAA, name: "", value: "0 issue letsencrypt.org" }
      properties:
        type:
          type: string
          enum: [A, AAAA, CNAME, TXT, MX, SRV, CAA, NS]
        name:
          type: string
          default: ""
          description: |
            Relative to the zone. `""` is the apex. Never send an absolute FQDN: a name equal to
            the zone, or ending in `.<zone>`, is rejected with `400 INVALID_RECORD_NAME` rather
            than stored as `name.zone.zone`. Lowercased, trimmed, and one trailing dot is
            tolerated and removed.
          maxLength: 253
        value:
          type: string
          maxLength: 4096
        ttl:
          type: integer
          minimum: 15
          maximum: 604800
          description: Seconds. Defaults to 3600 on create; on update, omitting it KEEPS the current value.
        priority:
          type: integer
          minimum: 0
          maximum: 65535
        weight:
          type: integer
          minimum: 0
          maximum: 65535
        port:
          type: integer
          minimum: 0
          maximum: 65535
        flags:
          type: integer
          minimum: 0
          maximum: 255
          default: 0
          description: CAA only.
        tag:
          type: string
          enum: [issue, issuewild, iodef]
          description: CAA only. Required for a CAA record unless `value` is the `<flags> <tag> <value>` triple. Case-insensitive.

    Contact:
      type: object
      description: |
        An explicit whitelist. Withheld on purpose: the encrypted person identifier, the payout
        bank account (presence only, as `payoutBankAccountSet`), the student/Feide columns and
        verification document metadata.
      properties:
        id:
          type: string
        contactType:
          type: string
          examples: ["person", "organization"]
        setupComplete:
          type: boolean
        orgName:
          type: [string, "null"]
        orgNumber:
          type: [string, "null"]
          description: Brreg organisation number.
        orgForm:
          type: [string, "null"]
        vatRegistered:
          type: [boolean, "null"]
        firstName:
          type: [string, "null"]
        lastName:
          type: [string, "null"]
        email:
          type: [string, "null"]
        phone:
          type: [string, "null"]
        address:
          type: [string, "null"]
        city:
          type: [string, "null"]
        state:
          type: [string, "null"]
        zip:
          type: [string, "null"]
        country:
          type: [string, "null"]
        isDefault:
          type: boolean
        preferredPaymentMethod:
          type: [string, "null"]
          enum: [CARD, EMAIL, EHF, null]
        paymentEmail:
          type: [string, "null"]
        paymentReference:
          type: [string, "null"]
        primaryContactName:
          type: [string, "null"]
        primaryContactEmail:
          type: [string, "null"]
        primaryContactPhone:
          type: [string, "null"]
        color:
          type: [string, "null"]
        invoiceEnabled:
          type: boolean
        verificationStatus:
          type: [string, "null"]
        verifiedAt:
          type: [string, "null"]
          format: date-time
        payoutBankAccountSet:
          type: boolean
          description: Presence only — the account number itself is never served here.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CnameInstruction:
      type: object
      description: The CNAME that delegates the challenge name to FENO's delegation zone.
      properties:
        name:
          type: string
          examples: ["_acme-challenge.kunde.no"]
        type:
          const: CNAME
        value:
          type: string
          examples: ["8e5bd1c4-2f9a-4a3d-9c11-77b2e0d5a6f8.acme.feno.no"]

    AcmeDelegation:
      type: object
      properties:
        id:
          type: string
        domain:
          type: [string, "null"]
        username:
          type: string
        subdomain:
          type: string
        fulldomain:
          type: string
        allowfrom:
          type: array
          items:
            type: string
        publishedTxt:
          type: array
          description: The challenge values currently live at the delegation name (up to two).
          items:
            type: string
        cname:
          $ref: "#/components/schemas/CnameInstruction"
        lastUsedAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time

    AcmeDelegationCreated:
      type: object
      required:
        [id, domain, username, password, fulldomain, subdomain, allowfrom, cname, apiBaseUrl, examples]
      properties:
        id:
          type: string
          description: The delegation's FENO id — pass it to `DELETE /acme/delegations/{id}`.
        domain:
          type: string
        username:
          type: string
        password:
          type: string
          description: Shown once. Only a hash is stored; there is no recovery path.
        fulldomain:
          type: string
        subdomain:
          type: string
        allowfrom:
          type: array
          items:
            type: string
        cnameCreated:
          type: boolean
          description: Whether FENO created the delegation CNAME in your zone for you.
        cnameReason:
          type: string
          enum:
            - created
            - repointed
            - already_present
            - not_feno_nameservers
            - no_bunny_zone
            - conflicting_record
            - domain_not_found
            - no_domain
            - error
        cname:
          $ref: "#/components/schemas/CnameInstruction"
        updateEndpoint:
          type: string
          examples: ["/v1/acme/update"]
        delegationZone:
          type: string
          examples: ["acme.feno.no"]
        apiBaseUrl:
          type: string
          description: |
            Absolute base of the acme-dns surface, which is what every client wants configured
            (`ACME_DNS_API_BASE`, `ACMEDNS_BASE_URL`, cert-manager's `host`, Caddy's
            `server_url`). Built server-side so the dashboard, this API and the docs cannot
            drift apart on it.
          examples: ["https://api.feno.no/v1/acme"]
        examples:
          $ref: "#/components/schemas/AcmeClientExamples"

    AcmeClientExamples:
      type: object
      description: |
        Ready-to-paste client configuration, generated with the plaintext password — this
        response is the only place it exists. The dashboard and this API render the same examples
        from a single source.
      properties:
        credentialsJson:
          type: string
          description: The acme-dns account JSON, keyed by domain. Feed it to clients verbatim.
        lego:
          type: string
        acmesh:
          type: string
        caddy:
          type: string

    AcmeDnsCredentials:
      type: object
      description: The upstream acme-dns register response — exactly five fields, no envelope.
      required: [username, password, fulldomain, subdomain, allowfrom]
      properties:
        username:
          type: string
        password:
          type: string
        fulldomain:
          type: string
        subdomain:
          type: string
        allowfrom:
          type: array
          items:
            type: string

    AcmeDnsErrorBody:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum:
            - unauthorized
            - forbidden
            - bad_txt
            - bad_domain
            - bad_request
            - rate_limited
            - server_error
        detail:
          type: string
          description: FENO addition. Ignored by clients that read only `error`.
