.. This file is part of PEPSI. Copyright (C) 2026 GNUnet e.V. PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. PEPSI is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. .. _admin-api: The administrative API ====================== ``pepsi-httpd`` serves an HTTP API under ``/api/v1`` covering everything the operator command-line tools do: the queue, the health summary, the configuration, the key store and the logs. The browser console and an unattended script are clients of the *same* documented surface. .. warning:: **The API is not yet stable.** The path carries ``v1`` so the versioning mechanism exists and clients are written against it, but while Pepsi's version starts with ``0.`` a release may still change the shape of a request or a response; such changes are listed in ``NEWS``. Unlike the database, which each release migrates forward (see :ref:`upgrading`), a client may have to be adapted. Freezing the contract before the console, its first real client, has exercised it would lock in decisions taken blind. Where it is served ------------------ **Only on a listener flagged** ``ADMIN = yes``. On every other listener the ``/api/v1`` routes answer a plain ``404`` that is byte-for-byte what any unknown path gets — so publishing the public HTTPS listener does not publish administration, and a public listener cannot even be probed for whether administration is enabled on this deployment. The shipped configuration flags exactly one listener, a UNIX socket: .. code-block:: ini [pepsi-httpd-listener-admin] SERVE = unix UNIXPATH = /run/pepsi/admin.sock UNIXPATH_MODE = 660 UNIXPATH_GROUP = pepsi-admin MODE = plain ADMIN = yes ``pepsi-httpd`` **refuses** to serve the administrative routes on a flagged listener that would carry them in cleartext off this host: plaintext TCP is accepted only on a loopback address, TLS and UNIX sockets always. A socket-activated listener (``SERVE = systemd``) qualifies only with TLS, because the address of the inherited descriptor is not visible to the server. A flagged listener that fails the test logs a warning at start-up and serves the public endpoints only; ``pepsi-setup`` reports the same thing when it validates the configuration, so the mistake is caught before it matters. .. note:: **One binary serves both audiences.** There is no separate administrative daemon: one thing to build, package, supervise and keep in sync — and, in exchange, a bug in the shared server process is a bug in both surfaces. The isolation is therefore *the operator's to configure*. Bind the administrative listener to a UNIX socket or to loopback, and put a reverse proxy in front of it if it must be reachable from elsewhere. .. note:: **What a front server is believed about.** In that topology this process terminates no TLS and sees no client address, so it reads two headers from the front server: ``X-Forwarded-Proto`` decides whether the session cookie goes out ``Secure`` (and under its ``__Host-`` name), and the rightmost ``X-Forwarded-For`` element is what the rate limits key on and what the secure-link portal records as the peer of an access attempt. Both are read **only** on a connection that cannot have come from off this host — a UNIX socket, or a loopback TCP peer — so a client talking to a public listener directly can never assert either. The sites ``pepsi-setup`` writes set both. Without them a proxied deployment has cookies with no ``Secure`` attribute and one single rate-limit bucket shared by the whole internet. Authentication -------------- Three mechanisms, one identity model. An explicitly presented credential always wins: the ``Authorization`` header is tried first, then the session cookie, and ``SO_PEERCRED`` only when neither is present. That order matters — if peercred won, an administrator who deliberately presented a narrowly-scoped token over the local socket would silently get full authority back. Local administrators (``SO_PEERCRED``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A process connecting over a UNIX-socket listener is identified by the credentials the kernel recorded at ``connect(2)``, which the peer cannot forge. ``root``, or a member of ``[pepsi-admin] ADMIN_GROUP`` (default ``pepsi-admin``), is an administrator with **no credential to configure, store or lose**: .. code-block:: console # curl --unix-socket /run/pepsi/admin.sock http://localhost/api/v1/status This is how first-run bootstrap works on a system that has no accounts yet, and it is why the socket may safely be mode ``0660`` with a group: the identity comes from the kernel, not from the file mode. Bearer tokens ~~~~~~~~~~~~~ For automation. A token is minted through the API and shown **exactly once**: .. code-block:: console # curl --unix-socket /run/pepsi/admin.sock -X POST \ -H 'Content-Type: application/json' \ -d '{"label":"monitoring","scopes":["queue:read","logs:read"]}' \ http://localhost/api/v1/tokens The presented form is ``pepsi_.``. Only the SHA-256 of the secret half is stored, so a token cannot be re-displayed or recovered from a database backup; the id half is a public selector that indexes the table, which is what lets the comparison be a constant-time check against one row rather than a scan. Present it as ``Authorization: Bearer pepsi_.`` — the ``Bearer`` scheme of RFC 6750, carried in the ``Authorization`` field RFC 9110 §11.6.2 defines. Scheme names are case-insensitive (RFC 7235 §2.1), so ``bearer`` and ``BEARER`` are accepted too. Tokens may carry an expiry (``expires_in_days``) and are revoked by deleting them. The token is *not* an OAuth 2.0 (RFC 6749) access token and there is no authorization server: it is a locally minted credential that happens to use the same wire form. Pepsi does speak OAuth 2.0 elsewhere — outbound to a smarthost, see :doc:`programs/pepsi-helper-token-refresh` — and the two should not be confused. Passwords and sessions ~~~~~~~~~~~~~~~~~~~~~~ For a browser. Accounts live in ``pepsi.admin_account`` with Argon2id (RFC 9106) password hashes; ``POST /api/v1/auth/login`` exchanges a name and password for a session cookie (RFC 6265) plus a CSRF token, and every mutating request from a session must repeat that token in the ``X-Pepsi-Csrf`` header. A bearer token and a local peer need no CSRF token: neither is ever sent by a browser on somebody else's behalf. The cookie carries ``HttpOnly``, ``SameSite=Strict`` and the ``__Host-`` name prefix. That prefix is the strongest of the three: the browser refuses the cookie unless it was set over HTTPS with no ``Domain`` attribute and ``Path=/``, so a sibling host on the same registrable domain cannot plant a session cookie for this one. The prefix and ``Secure`` are one decision, and it follows the **client's** scheme rather than this process's: a listener that terminates TLS itself, or a plaintext one behind a front server asserting ``X-Forwarded-Proto: https`` as described above. On the plaintext local bootstrap path — a loopback or UNIX listener with no front server — a browser would refuse a ``Secure`` cookie outright, so neither is used and the cookie is named ``pepsi_session``. Both spellings are accepted on the way in, so adding or removing a TLS front server does not sign every administrator out. Those requirements are also why the secure-link portal, sharing an origin, cannot use the same prefix: ``__Host-`` mandates ``Path=/``, which would send the portal's session cookie to ``/metrics``, ``/resume`` and the Web Key Directory as well. It keeps its path scoping, uses ``__Secure-`` instead, and buys back what the prefix would have given by sealing the cookie's value under the server pepper — see :doc:`secure-link`. Sessions are rows, not process memory, so a restart does not log everyone out and two server processes agree about who is logged in. They expire twice over: * an **idle timeout** (``SESSION_IDLE``, default 30 minutes) pushed forward on every request, and * a **hard lifetime** (``SESSION_LIFETIME``, default 12 hours) that is never extended. There is no "remember me". This console can revoke a key, read who corresponds with whom and rewrite the pipeline; a forgotten browser tab should not still hold that tomorrow, and a stolen laptop should not be a standing administrative session. The policy is affordable because the people who administer a deployment most often — local operators — never type a password at all. .. note:: **PAM is not supported.** It would put a privileged authentication path and system-account semantics inside a mail server's web tier, and make the API's security depend on host configuration Pepsi does not control. A local administrator does not need it; a remote one gets an account in the database with an explicit scope list. Authorisation: scopes --------------------- Every endpoint declares the one scope it requires, in the same table the dispatch table and the OpenAPI document are generated from — so what this manual promises and what the server enforces cannot disagree. .. list-table:: :header-rows: 1 :widths: 20 80 * - Scope - Grants * - ``config:read`` - Read the effective configuration and its provenance (secrets masked). * - ``config:write`` - Change the configuration overlay. * - ``keys:read`` - Read local identities, correspondent keys and trust anchors. * - ``keys:write`` - Change local identities and remove trust anchors. * - ``peers:write`` - Import a correspondent's key, queue a discovery lookup, or forget a cached key. * - ``queue:read`` - Read the health summary and the message queue. * - ``queue:write`` - Requeue, reroute or delete a queued message. * - ``logs:read`` - Read the audit log, the mail log, TLS outcomes and the DNS cache. * - ``setup:write`` - Manage accounts and tokens, and drive the online setup. * - ``secure:read`` - Read secure-link metadata: who a stored message is for, when it was read, how often the PIN was got wrong. Never its content. * - ``secure:write`` - Revoke a secure-link message, destroying the only copy of it. * - ``own:
`` - Authority over exactly one e-mail address. A local administrator holds every scope except ``own:``. A principal can never mint a credential more powerful than itself: creating an account or a token with a scope the caller does not hold is refused with ``scope_escalation``. The ``own:
`` scope ~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``own:
`` *narrows* authority to a single address. No interface issues such a principal on its own; an administrator creates one as a token or an account carrying that scope. The scope is nevertheless **defined and enforced** on every endpoint that takes an address, because retrofitting an authorisation model onto endpoints written without one is the expensive half of the work. A principal holding only ``own:`` scopes may read its own identities and correspondent keys (an unfiltered listing is narrowed to its address rather than refused) and its own rows of the mail log (it must pass ``?address=`` naming its own address there). It may not read anybody else's, may not touch the deployment-wide objects — the correspondent-key cache, the trust anchors — and may not read or write the configuration. An administrator may delegate one address by issuing such a token; a token that can only create credentials cannot conjure address authority it does not itself hold. It may also manage **its own keys** (the pEp-style self-service; see :doc:`key-management`): ask for a server-managed key (``POST /api/v1/identities``), register the user's own public key (``POST /api/v1/identities/client``), and change its own identity's flags, including a key-server upload request and its revocation, with ``PATCH /api/v1/identities/{id}``. Each of these re-checks the address, and the two ``POST`` routes and a revoking ``PATCH`` only enqueue a task that the applier re-checks again against the principal's ``own:`` scope. Deleting an identity's row stays with ``keys:write``. It may follow **the tasks it queued** to their end: ``GET /api/v1/setup/tasks/{id}`` answers for a task this principal queued itself, and ``GET /api/v1/setup/tasks`` lists exactly those (see `Following a task`_). Everything else on the setup surface stays ``setup:write``. Conventions ----------- * **JSON only.** Requests and responses are ``application/json``. * **One error shape**, on every failure:: {"code": "scope_required", "hint": "this endpoint requires the 'queue:write' scope", "detail": {"scope": "queue:write"}} ``code`` is a stable token to switch on; ``hint`` is prose for a human and may change; ``detail`` carries structured extra information when there is any. * **Uniform authentication failures.** A wrong password, an unknown account and a disabled account all produce the same ``invalid_credentials``, and an unknown account still pays for one password verification, so neither the message nor the response time enumerates accounts. * **Internal failures never carry a cause.** The full error goes to the process log; the client gets ``internal_error``. * **Listings** answer ``{"items": [...], "total": N, "limit": L, "offset": O}``, and every one of them means the same thing by it. See `Pagination`_. * **ISO-8601 timestamps**, with an offset. * **No trailing slashes.** Pagination ---------- Eleven endpoints answer the listing envelope, and all eleven behave identically: .. list-table:: :header-rows: 1 :widths: 40 60 * - Endpoint - Filters that also apply to ``total`` * - ``GET /api/v1/queue`` - ``?stage=`` ``?status=`` * - ``GET /api/v1/events`` - ``?kind=`` (prefix) ``?actor=`` * - ``GET /api/v1/mail-log`` - ``?address=`` * - ``GET /api/v1/secure-messages`` - ``?include_expired=`` * - ``GET /api/v1/identities`` - ``?address=`` ``?protocol=`` * - ``GET /api/v1/peers`` - ``?address=`` * - ``GET /api/v1/peers/unclaimed`` - ``?address=`` * - ``GET /api/v1/ca-trust`` - — * - ``GET /api/v1/accounts`` - — * - ``GET /api/v1/tokens`` - — * - ``GET /api/v1/setup/tasks`` - — (narrowed to the caller's own tasks without ``setup:write``) ``limit`` defaults to ``[pepsi-admin] PAGE_LIMIT`` (100) and is clamped to 1–1000; ``offset`` is clamped to be non-negative; a value that is not a number is a ``400``. The envelope echoes the window that was **applied**, so a request for ``limit=100000`` comes back saying ``"limit": 1000``. Both go into the SQL, and ``total`` is a ``COUNT(*)`` over the same ``WHERE`` clause as the page — never the length of ``items``, and never the size of an over-fetched window. Every listing orders by something unique last (its row id), so an ``OFFSET`` can neither skip a row nor show one twice. ``GET /api/v1/mail-log`` adds one member to the envelope, ``mode``, because an empty page means something different depending on whether ``[pepsi] MAIL_LOG`` is recording anything at all. ``total`` and the page are two queries ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The count runs first, then the page, and **nothing holds a snapshot across the two**. A read-only transaction per listing would make them exactly consistent, at the cost of tying up a pooled database connection on a web tier for a guarantee no client needs. So, against a table being written concurrently: * rows deleted in between make ``total`` an over-estimate — and the queue is drained continuously, so this is the normal case there, not a curiosity. The visible effect is a last page that comes back empty. * rows inserted in between make it an under-estimate. Page until ``items`` is shorter than ``limit``; treat ``total`` as a good number to *show* an operator, not as a loop bound to trust to the row. The count is a real ``COUNT(*)``, not an estimate, on every one of these tables. That is affordable because of what they are: ``pepsi.workqueue`` is the **live backlog** — a delivered message's row is deleted by the stage that delivered it, so the table holds mail still in flight rather than a history of everything ever sent — and the key store, the accounts and the tokens are deployment-scale objects. ``event_log`` and ``mail_log`` are the two that do grow with traffic; both are bounded by the retention sweeps described under `The audit log`_ and `The mail log (off by default)`_. Not this shape ~~~~~~~~~~~~~~ ``GET /api/v1/status`` takes a ``limit`` — how many stuck messages the report lists — but no ``offset``, and answers ``pepsi-status``'s report rather than a listing. ``GET /api/v1/tls-sessions`` answers ``{"days": D, "items": [...]}`` — it echoes the window it applied, the way the listing envelope echoes ``limit``/``offset`` — bounded by the ``days`` parameter (1–365, default from ``pepsi-status``). ``GET /api/v1/dns-cache`` answers a bare ``{"items": [...]}``, bounded only by the size of the DNS failure cache. Endpoints --------- The authoritative list for a given build is ``GET /api/v1/openapi.json``, which is generated from the server's own route table — including the ``limit`` and ``offset`` parameters, which it declares on exactly the endpoints listed under `Pagination`_. The Notes column below gives each listing's *other* parameters; every listing additionally takes ``?limit=`` and ``?offset=``. .. list-table:: :header-rows: 1 :widths: 8 42 20 30 * - Method - Path - Scope - Notes * - ``POST`` - ``/api/v1/auth/login`` - — - Returns a session cookie and a CSRF token. * - ``POST`` - ``/api/v1/auth/logout`` - — - Idempotent. * - ``GET`` - ``/api/v1/auth/whoami`` - — - The first thing to check when something answers ``403``. * - ``GET`` ``POST`` - ``/api/v1/accounts`` - ``setup:write`` - Password hashes are never returned. * - ``PATCH`` ``DELETE`` - ``/api/v1/accounts/{id}`` - ``setup:write`` - Deleting an account ends its sessions. * - ``GET`` ``POST`` - ``/api/v1/tokens`` - ``setup:write`` - The secret is in the ``POST`` response and nowhere else. * - ``DELETE`` - ``/api/v1/tokens/{id}`` - ``setup:write`` - Revocation. * - ``GET`` - ``/api/v1/status`` - ``queue:read`` - Identical to ``pepsi-status --json``. ``?limit=`` caps the stuck-message list; not a listing, so no ``?offset=``. * - ``GET`` - ``/api/v1/queue`` - ``queue:read`` - ``?stage=`` ``?status=`` * - ``GET`` - ``/api/v1/queue/{id}`` - ``queue:read`` - Envelope, stage, status and ``state``. **Never the message.** * - ``POST`` - ``/api/v1/queue/{id}/requeue`` - ``queue:write`` - Pending again at its current stage. * - ``POST`` - ``/api/v1/queue/{id}/bounce`` - ``queue:write`` - Body ``{"stage": ""}``. * - ``POST`` - ``/api/v1/queue/{id}/cancel`` - ``queue:write`` - Deletes the message. * - ``GET`` - ``/api/v1/events`` - ``logs:read`` - The audit log. ``?kind=`` (prefix) ``?actor=`` * - ``GET`` - ``/api/v1/mail-log`` - ``logs:read`` - ``?address=`` Empty unless ``[pepsi] MAIL_LOG`` is on; reports the mode. * - ``GET`` - ``/api/v1/tls-sessions`` - ``logs:read`` - ``?days=`` * - ``GET`` - ``/api/v1/dns-cache`` - ``logs:read`` - MX addresses currently failing. * - ``GET`` - ``/api/v1/config`` - ``config:read`` - ``?scope=`` Effective values with provenance; secrets masked. * - ``GET`` - ``/api/v1/config/{section}`` - ``config:read`` - One section. * - ``PUT`` ``DELETE`` - ``/api/v1/config/{section}/{option}`` - ``config:write`` - Validated before it is stored. See below. * - ``POST`` - ``/api/v1/config/validate`` - ``config:read`` - Dry run; answers ``200`` with a verdict either way. * - ``GET`` - ``/api/v1/identities`` - ``keys:read`` - ``?address=`` ``?protocol=`` * - ``GET`` ``PATCH`` ``DELETE`` - ``/api/v1/identities/{id}`` - ``keys:read`` / ``keys:write`` - ``PATCH`` sets ``is_primary`` (``true``), ``published``, ``vks_wanted``; or, on its own, ``status`` (only ``revoked``, with an optional ``revocation_reason``), which **queues** a ``revoke-identity`` task. See below. ``GET`` and ``PATCH`` are open to ``own:
`` for that address; ``DELETE`` is not. * - ``GET`` - ``/api/v1/identities/{id}/public`` - ``keys:read`` - The public half, base64. * - ``POST`` - ``/api/v1/identities`` - ``keys:write`` or ``own:
`` - **Queue** a ``generate-identity`` task: ``{"address": ..., "vks": true|false}``. See below. * - ``POST`` - ``/api/v1/identities/client`` - ``keys:write`` or ``own:
`` - **Queue** a ``register-client-key`` task: ``{"address": ..., "key": ""}``. See below. * - ``DELETE`` - ``/api/v1/otp/{address}`` - ``keys:write`` - **Queue** a ``reset-otp`` task removing (and so unlocking) the address's second factor. See below. * - ``GET`` - ``/api/v1/peers`` - ``keys:read`` - ``?address=`` * - ``POST`` - ``/api/v1/peers`` - ``peers:write`` - Import a correspondent's key by hand. See below. * - ``POST`` - ``/api/v1/peers/discover`` - ``peers:write`` - **Queue** a discovery lookup; it does not fetch. See below. * - ``DELETE`` - ``/api/v1/peers/{id}`` - ``peers:write`` - Forget a cached key. * - ``GET`` - ``/api/v1/peers/unclaimed`` - ``keys:read`` - ``?address=`` — served addresses we hold no identity for but have cached a key for anyway. A paginated listing. * - ``GET`` - ``/api/v1/ca-trust`` - ``keys:read`` - The S/MIME trust anchors. * - ``DELETE`` - ``/api/v1/ca-trust/{id}`` - ``keys:write`` - Changes which inbound signatures validate. * - ``GET`` - ``/api/v1/secure-messages`` - ``secure:read`` - Outstanding secure-link messages, metadata only. ``?include_expired=1``. * - ``GET`` - ``/api/v1/secure-messages/{token}`` - ``secure:read`` - One message with its access history. Never its content. * - ``DELETE`` - ``/api/v1/secure-messages/{token}`` - ``secure:write`` - Revoke; this destroys the only copy of the message. * - ``GET`` - ``/api/v1/dns-records`` - ``config:read`` - The records to publish and the last live verdict. See below. * - ``GET`` - ``/api/v1/setup/questions`` - ``setup:write`` - The setup interview as data. * - ``GET`` - ``/api/v1/setup/telemetry`` - ``setup:write`` - Whether switching feature telemetry on could take effect, and if not, what to do on the server. See below. * - ``GET`` - ``/api/v1/setup/answers`` - ``setup:write`` - The staged answers; credentials masked. * - ``PUT`` - ``/api/v1/setup/answers`` - ``setup:write`` - Merge answers into the staged (draft) set. * - ``DELETE`` - ``/api/v1/setup/answers`` - ``setup:write`` - Abandon the staged interview. * - ``GET`` - ``/api/v1/setup/tasks`` - ``setup:write``, or any principal for its own tasks - Privileged setup tasks, newest first; without ``setup:write``, only the caller's own. * - ``POST`` - ``/api/v1/setup/tasks`` - ``setup:write`` - Ask the applier for one of its closed set of actions. * - ``GET`` - ``/api/v1/setup/tasks/{id}`` - ``setup:write``, or any principal for its own tasks - One task, with ``?since=`` streaming its progress and ``?wait=`` holding the answer until there is news. * - ``POST`` - ``/api/v1/setup/preflight`` - ``setup:write`` - Check the environment (ports, resolver DNSSEC). * - ``POST`` - ``/api/v1/setup/dns-check`` - ``setup:write`` - Compare live DNS against what setup would publish. * - ``POST`` - ``/api/v1/setup/certificates`` - ``setup:write`` - Obtain the certificates the configuration needs. * - ``GET`` - ``/api/v1/openapi.json`` - — - Generated from the route table. The monitoring endpoints call ``pepsi-status``'s and ``pepsi-queue``'s own functions and serialise the same structs, so ``pepsi-status --json`` and ``GET /api/v1/status`` are the same bytes by construction. The configuration endpoints reuse ``pepsi-config``'s provenance walk and its validation. What this server cannot do -------------------------- Generate or revoke key material -- it asks the applier instead ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Private key material lives in ``crypto_identity.private_wrapped``, which ``pepsi-setup`` grants to the ``pepsi-crypto`` role **alone** (the schema owner aside) and revokes from every service account, ``pepsi-httpd`` included: a web tier that can mint signing keys mints them for whoever compromises it. ``POST /api/v1/identities`` therefore does not generate anything. It enqueues a ``generate-identity`` task (``{"address": ..., "vks": true|false}``; ``vks`` is optional and defaults to ``[pepsi-keys] VKS_PUBLISH``) and answers with the task (``{"task": ..., "kind": "generate-identity", "applier_notified": ...}``). The root applier re-validates it and generates an OpenPGP key as the ``pepsi-crypto`` role. Generation on request is always allowed, even next to the user's own key. ``POST /api/v1/identities/client`` enqueues a ``register-client-key`` task in the same way (``{"address": ..., "key": ...}``, the key an armoured OpenPGP public key of at most 64 KiB). It goes through the applier although no private material is involved, because a key registered as the user's own is trusted by the MTA -- it becomes the address's public face, and signatures made with it verify as the user's -- so a compromised web tier must not be able to plant one. The applier requires one OpenPGP certificate whose User IDs name the address, and does not register a fingerprint the address already has, retired or not. ``PATCH /api/v1/identities/{id}`` with ``{"status": "revoked"}`` (and an optional ``"revocation_reason"``, one line of at most 1024 bytes) is **asynchronous** for the same reason: revoking a signing-only key destroys its private half, and that ``UPDATE`` names ``private_wrapped``, which ``pepsi-httpd`` may not. It enqueues a ``revoke-identity`` task (``{"address": ..., "identity_id": ..., "reason": ...}``) and answers with the task, not the changed row: ``{"identity_id": ..., "task": ..., "kind": "revoke-identity", "applier_notified": ...}``. The identity's ``status`` reads ``revoked`` once the applier has run; ``GET /api/v1/setup/tasks/{task}`` shows the task itself, to ``setup:write`` and to the principal that asked for it alike, whose ``result`` names the fingerprint and says whether the identity was revoked by this task or already had been. The applier checks that the identity belongs to the task's address and then applies the same rule as ``pepsi-keys identity revoke``: a signing-only key's private half is destroyed, an encryption key's is kept so mail already encrypted to it stays readable. Because the answer is a task, a revoking ``PATCH`` carries ``status`` alone; combined with ``is_primary``, ``published`` or ``vks_wanted`` it is refused with ``400``, as is a ``revocation_reason`` without ``status``. For all three, the applier's authorisation gate is ``keys:write``, or ``own:
`` for exactly the address in the task's parameters -- ``setup:write`` alone is not enough, since managing one user's key is not configuring the server -- and the address must be at a ``[pepsi-ingress] ACCEPTED_DOMAINS`` domain. A task can end ``done``, ``failed`` (the applier tried and could not, the reason in ``error``) or ``refused`` (the applier would not accept the row at all), and the principal that asked can read which: a revocation request for a compromised key is followed with ``GET /api/v1/setup/tasks/{task}?wait=60`` until ``status`` is one of the three, as `Following a task`_ describes. Without ``pepsi-httpd-admin`` all three answer ``503 setup_applier_unavailable``, like every applier path, and without a ``[pepsi-admin] CONFIG_DB`` connection ``503 setup_write_unavailable`` (see `Online setup`_: only the ``pepsi-config`` role may enqueue). Producing a CSR and importing an issued certificate are not offered here; use ``pepsi-keys``. **The owner's second factor.** An address whose owner enrolled a second factor (:doc:`key-management`, "A second factor for key changes") needs its current code on each of the three when the task is asked for under ``own:
``: add ``"otp": "123456"`` to the body (the console's forms have a field for it). ``pepsi-httpd`` cannot check the code -- ``pepsi.otp_key`` is granted to ``pepsi-crypto`` alone -- so it is carried in the task's parameters and the applier checks it before acting; a missing, wrong, replayed or locked code fails the task with the reason in ``error``. A task asked for with ``keys:write`` is not second-factor checked: the operator can reset the second factor anyway. ``DELETE /api/v1/otp/{address}`` (``keys:write`` only, never ``own:``) queues a ``reset-otp`` task that removes the address's second factor, unlocking it; its owner then enrols again. The flag changes a ``PATCH`` performs directly (``is_primary``, ``published``, ``vks_wanted``) are not gated, since this process performs them itself. Write the configuration, unless you ask for it ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Writing ``pepsi.config_override`` belongs to the ``pepsi-config`` database role, which ``pepsi-setup`` grants and explicitly revokes from every service account — a component that processes mail must not be able to rewrite the pipeline it runs in. ``pepsi-httpd`` is such an account, and peer authentication keys off the effective uid, so its own connection cannot be that role. ``PUT`` and ``DELETE`` on ``/api/v1/config`` therefore answer ``503 config_write_unavailable`` until ``[pepsi-admin] CONFIG_DB`` names a connection that authenticates as ``pepsi-config`` — in practice a password in a ``secrets.d`` fragment readable only by ``pepsi-httpd``, or a ``pg_ident`` map. The server checks with ``SELECT current_user`` at start-up and refuses anything else. ``PUT`` takes ``{"value": "...", "scope": "..."}``, where ``scope`` is ``global`` (the default), ``domain:`` or ``address:
``; ``DELETE`` takes the same as ``?scope=``, and ``GET`` reads the effective configuration *as seen from* that scope. Both writes answer with a ``reload`` object saying how the change takes effect (``hot``, ``restart`` with the unit to restart, or ``ini-only``). Sections that are read from the configuration file only (``[pepsi]``, ``[pepsi-postgres]``, ``[paths]``, ``[pepsi-admin]``, ``[pepsi-httpd]``, ``[pepsi-crypto]``, ``[pepsi-srs]``, ``[pepsi-origin]``, ``[pepsi-secure-link]``, ``[pepsi-wizard]``, and every ``[pepsi-httpd-listener-*]``, ``[pepsi-httpd-cert-*]`` and ``[pepsi-ingress-listener-*]`` section) are refused with ``403 forbidden`` regardless. So is every credential-bearing option in any other section (one whose name contains ``PASSWORD``, ``PASSPHRASE``, ``SECRET``, ``TOKEN``, ``CREDENTIAL``, ``CLIENT_ID`` or ``PEPPER`` -- the same rule that masks it in ``GET``, which also covers the file a credential is read from and the endpoint it is sent to): secrets are never stored in the database. A ``domain:`` or ``address:`` scope is accepted for ``[stage-*]`` sections only, the only ones read per correspondent; any other section there is refused with ``403`` too. ``POST /api/v1/config/validate`` reports the same refusals as ``"valid": false``. The overlay's reader applies the same rule, so a row that reached the table some other way is ignored with a warning. Do anything privileged ~~~~~~~~~~~~~~~~~~~~~~ Setup writes ``/etc/pepsi/pepsi.conf``, hands each ``secrets.d`` fragment to the one account that reads it, runs certbot, creates database roles and generates key material — all as root. ``pepsi-httpd`` drops privileges before it accepts a connection and must never be able to regain them. So the setup endpoints do not act. They write an *intent* row into ``pepsi.setup_task``, and a separate root program, ``pepsi-setup apply``, drains it. Root is reached through a database table, never through a socket that speaks a protocol. The full trust model — the closed task list, the two admission gates, what is deliberately absent, and what the design cannot promise — is in **pepsi-setup**\ (1), "The applier's trust model", and is required reading before deploying the browser setup. Correspondent keys ------------------ ``POST /api/v1/peers`` stores a correspondent's public key by hand — the sibling of ``pepsi-keys peer import``. (The browser console has **no** import: its correspondent page can only forget a key, and points at ``pepsi-keys peer import`` for the rest.) The body names the ``address``, the ``protocol`` (``openpgp`` or ``smime``), the ``material`` (base64, or armored/PEM text verbatim) and an optional ``pin``. Two refusals matter: * **Material that is not a key** is a ``422 invalid_value``. OpenPGP input goes through the hardened parser the discovery methods use, which walks the top level only and rejects a stream carrying a compressed or encrypted packet — a transferable public key never contains one, and a compression bomb stapled to a keyring is not a key worth having. * **A key that names a different address** is a ``422 invalid_value`` naming whose key it really is. Filing one correspondent's key under another's address silently misdirects every future message to them. A key that names *no* address is stored — silence cannot contradict anything — and the response says so in its ``binding`` field (``matched`` / ``no-identity``). The row is stored with ``source = api``, which the conflict rule ranks beside a hand-entered key: it displaces a discovered one and does not displace a **pinned** one. A ``*@domain`` entry is refused here; the schema admits one only for ``source = manual``, so widening a whole domain stays a decision taken at a terminal. ``POST /api/v1/peers/discover`` **queues** a lookup and returns at once. It does not fetch: discovery speaks HTTPS, LDAP and DNS to hosts the correspondent's domain chooses, and doing that inside a request handler would let a stranger hold this server's connection open — the same reason the encrypt and decrypt stages park a message rather than looking a key up themselves. The endpoint writes the same ``pepsi.key_request`` row a stage's park writes, through the same SQL function, and a ``pepsi-keydisc`` service does the work. The answer appears in ``GET /api/v1/peers`` when one has stored it. ``{"force": true}`` re-asks an address whose *fresh* negative cache entry says there is no key. It answers ``501 no_discovery_method`` when ``[pepsi-keydiscovery] SOURCES`` is empty, since no service would ever answer, and ``403`` for an address that ``ALLOW_DOMAINS``/``DENY_DOMAINS`` exclude. Secure links ------------ ``GET /api/v1/secure-messages`` lists the messages the secure-link portal is holding: who they are from and for, when they were created and when they expire, how many times the PIN was entered correctly and wrongly, whether a lockout is in force, and how many bytes are stored. ``GET /api/v1/secure-messages/{token}`` adds the access log — the timestamp, the peer address and the outcome of each attempt. There is deliberately no geolocation and no user agent. **No endpoint returns the message.** ``pepsi-httpd`` *is* the portal, so it is the one database role granted ``secure_message.ciphertext`` — which is exactly why nothing on the operator surface reads it. The queries behind these endpoints name every column except that one and report only ``length(ciphertext)``, so the property is enforced by the SQL rather than by a field somebody remembered to leave out. Nor would reading it help: the key is Argon2id over a PIN the server never sees, salted per row and peppered from a file only the portal reads. ``DELETE /api/v1/secure-messages/{token}`` revokes. It deletes the row, and with it the only copy of the message — there is no escrow key and the portal keeps no second copy — so it needs ``secure:write`` rather than ``secure:read``. The sender re-sends if it was a mistake. DNS records ----------- ``GET /api/v1/dns-records`` answers with the records this deployment should publish, the last live-DNS verdict for each (``ok`` / ``missing`` / ``mismatch`` / ``lookup-failed``), the remedy for each that is wrong, and the zone text ``pepsi-setup run`` would print. It **serves a stored answer** rather than computing one. Deriving what should be published means reading the validated configuration and the DKIM key directory, and comparing it means querying DNS — work that belongs to ``pepsi-setup``, which this server cannot call (the dependency already runs the other way) and whose inputs a web tier should not be reading. So the privileged applier computes it as part of a ``run-preflight`` task and writes it back, and this endpoint reports it **with the time it was computed**, always: a verdict with no age on it invites trust in a stale one. ``POST /api/v1/setup/dns-check`` asks for a fresh one, and needs ``setup:write`` accordingly — looking at what was found is a read, asking a root process to go and look is not. Before any check has run, the response is empty and names the endpoint that runs one, rather than an empty list that reads like "everything is fine". Online setup ------------ The browser path through ``pepsi-setup``. Two mechanisms: **Answers are staged, not applied.** ``PUT /api/v1/setup/answers`` merges into a set of *draft* rows in ``pepsi.config_override``, which every configuration reader filters out structurally (``WHERE NOT draft``). Nothing takes effect while the interview is in progress, so a session that times out half way through has not half-configured a mail server, and resuming is reading the drafts back. The identifiers are the ones ``GET /api/v1/setup/questions`` publishes, which are also the keys ``pepsi-setup --answers`` reads and the ``[pepsi-wizard]`` section round-trips — so an interview can be started in a browser and finished at a terminal. **The telemetry switch is offered only where it can take effect.** ``[pepsi] SHARE_TELEMETRY`` is in the configuration file, so it is set like any other answer and written by the applier's ``write-config`` task, which then sends the ``telemetry_changed`` notification (in both directions) so that a running ``pepsi-telemetry-client`` re-reads the file. That daemon may run while telemetry is off — dormant, submitting nothing — and it keeps a liveness row in ``pepsi.telemetry_client`` saying whether it runs and whether a ``SYSTEM_ID`` is configured (a boolean; the console never sees the identifier). The console never generates an identifier, since that is what opt-in rules out, so unless the daemon is running **and** has one, *yes* cannot take effect: ``GET /api/v1/setup/questions`` then marks the question ``"disabled": true`` with a ``disabled_reason`` telling the operator what to do on the server (``systemctl enable --now pepsi-telemetry-client.service``; add ``SYSTEM_ID =`` and 64 hexadecimal characters to ``[pepsi]``), the console renders it disabled, and a *yes* sent anyway through ``PUT /api/v1/setup/answers`` or a ``write-config`` task is refused with ``409 telemetry_client_not_ready``. Switching *off*, or keeping an answer that is already *yes*, is never refused. ``GET /api/v1/setup/telemetry`` answers with the whole picture: .. code-block:: json {"question": "SHARE_TELEMETRY", "sharing": false, "can_enable": true, "reason": null, "client": {"running": true, "enabled": false, "submitting": false, "system_id_configured": true, "detail": null, "version": "0.1.0", "last_seen": "2026-09-27T10:00:00+00"}} **Actions are requested, not performed.** ``POST /api/v1/setup/tasks`` enqueues one of eleven kinds (``write-config``, ``write-secret``, ``obtain-certificate``, ``install-schema``, ``provision-roles``, ``generate-keys``, ``run-preflight``, ``generate-identity``, ``register-client-key``, ``revoke-identity``, ``reset-otp``) with strictly validated parameters. The last four act on one address's keys and are normally reached through ``POST /api/v1/identities``, ``/identities/client``, a revoking ``PATCH /api/v1/identities/{id}`` and ``DELETE /api/v1/otp/{address}``; the applier authorises them by ``keys:write`` or -- all but ``reset-otp`` -- the address's own ``own:`` scope rather than ``setup:write``. There is no arbitrary-command kind, and there is **no restart, reload or shutdown kind** — a change that needs a component restarted ends with the operator restarting it, which the console says rather than hiding. The applier refuses anything else, and every refusal, success and failure is an audit record. Enqueuing goes through the ``[pepsi-admin] CONFIG_DB`` connection, exactly as configuration writes do and for the same reason: only the ``pepsi-config`` role may ``INSERT`` into ``setup_task``, and the applier refuses any row whose ``written_by`` says otherwise. Without that connection the setup endpoints answer ``503 setup_write_unavailable``; the server's own account may only ``SELECT`` the queue, so the console can *watch* privileged work without being able to ask for it. ``GET /api/v1/setup/tasks/{id}?since=`` returns the progress lines the applier has written so far, so a certbot run or a schema install can be watched line by line without the applier holding an HTTP connection open. Following a task ~~~~~~~~~~~~~~~~ A task answers ``{"id", "kind", "status", "requested_by", "requested_at", "result", "error", "log"}``; ``status`` is ``pending`` or ``running`` until it is one of ``done``, ``failed`` or ``refused``, after which the applier writes nothing more to it. ``params`` is never returned, since one kind's parameter is a credential. **Who may read it.** A principal holding ``setup:write`` reads every task. Any other authenticated principal reads the tasks it queued itself -- in practice the ``generate-identity``, ``register-client-key`` and ``revoke-identity`` tasks it queued through the key endpoints, the only tasks it can queue. ``GET /api/v1/setup/tasks`` is narrowed to those rather than refused, and ``GET /api/v1/setup/tasks/{id}`` answers ``404 not_found`` for a task somebody else queued, exactly as for one that does not exist. What such a task shows is the requester's own business: the kind, the address and identity it named, the fingerprint of the key, and why it failed or was refused. The parameters -- a registered public key, a revocation reason -- are not returned, and the key endpoints hand over nothing private in the first place. "Queued itself" is decided by a key recorded with the task, ``requester_key``, not by ``requested_by``. ``requested_by`` (``token: