---
title: "API"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:developer-api@1.0"
source: "Developer/Api.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# API {#api}

This chapter documents the public API of the nr-vault extension.

## Extension points and the compatibility promise {#extension-points-and-the-compatibility-promise}

Most interfaces in this chapter are for *calling*.
Six are for *implementing*: an extension plugs its own code into nr-vault
through them.
They carry the `#[\Netresearch\NrVault\Attribute\ExtensionPoint]`
attribute, and the test suite checks this list against that attribute:

-   `Netresearch\NrVault\Adapter\VaultAdapterInterface` — a storage backend
    ([Custom storage adapters](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-custom-adapters@1.0))
-   `Netresearch\NrVault\Crypto\MasterKeyProviderInterface` — a master-key
    source, selected by the identifier it declares and registered with the
    `nr_vault.master_key_provider` tag
    ([Custom master key providers](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-custom-key-providers@1.0))
-   `Netresearch\NrVault\Audit\Sink\AuditSinkInterface` — an external
    destination for audit evidence
-   `Netresearch\NrVault\Crypto\ForeignEnvelopeRotatorInterface` — re-wraps
    the envelopes a consuming extension stores itself
    ([ForeignEnvelopeRotator](https://docs.typo3.org/permalink/netresearch/nr-vault:api-foreign-envelope-rotator@1.0))
-   `Netresearch\NrVault\Service\Doctor\ReadinessCheckInterface` — an
    additional `vault:doctor` control
-   `Netresearch\NrVault\Http\CancellationSignalInterface` — a caller-owned
    cancellation signal for a secure outbound send
    ([Cancelling an outbound request](https://docs.typo3.org/permalink/netresearch/nr-vault:api-http-cancellable@1.0))

A new method means different things to a caller and to an implementation, so
the two kinds of interface carry different promises:

-   **Extension points**

    No method is added, and no method signature changes, outside a major
    release — not even by an optional parameter.
    PHP refuses to load a class whose methods no longer match its interface,
    so either change would break every existing implementation.
    A new capability arrives as a separate interface that an implementation
    may additionally implement and a caller detects with `instanceof`, the
    way `CancellableHttpClientInterface` joined `VaultHttpClientInterface`.

-   **All other interfaces**

    Are for calling.
    A minor release may add methods to them; existing calls keep working.
    Implementing one of them outside this package is not supported, and such
    an implementation may stop loading after any update.

Removing or changing anything a caller uses — a method, a parameter, an enum
backing value, an exception class — is a breaking change for either kind and
waits for a major release.

## VaultService {#vaultservice}

The main service for interacting with the vault.

-   **interface VaultServiceInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Service\VaultServiceInterface`

    Main interface for vault operations.

    > [!NOTE]
    > The plaintext parameters `$secret` / `$newSecret` carry the
    > PHP `#[\SensitiveParameter]` attribute on the interface, so they
    > are redacted from stack traces and `var_dump()` output. Mirror
    > the attribute on any custom implementation.

    -   **store(string $identifier, string $secret, array $options = \[\]) : void**

        Store a secret in the vault. `$secret` is a
        `#[\SensitiveParameter]`.

        -   *param string $identifier:* Unique identifier for the secret.
        -   *param string $secret:* The secret value to store (`#[\SensitiveParameter]`).
        -   *param array $options:* Optional configuration: `owner` (int BE-user UID), `groups` (int\[\] BE-group UIDs), `context` (string), `expiresAt` (int|DateTimeInterface|null), `metadata` (array), `description` (string), `scopePid` (int).
        -   *throws ValidationException:* If the identifier is invalid.
        -   *throws AccessDeniedException:* If the per-secret ACL or the operation permission refuses the write — `secret.create` on a new secret, `secret.rotate` on an existing one, and additionally `secret.manage_policy` when the options change the owner or the group tiers.
        -   *throws EncryptionException:* If encryption fails.

        Operation permissions and the per-secret ACL are two independent gates.
        Holding one never implies the other, and both are asserted before the
        value is written.

    -   **retrieve(string $identifier)**

        Retrieve a secret from the vault.

        -   *param string $identifier:* The secret identifier.
        -   *returntype:* string|null
        -   *throws AccessDeniedException:* If user lacks read permission.
        -   *throws SecretExpiredException:* If the secret has expired.

        *Returns:* The decrypted secret value or null if not found.

    -   **retrieveForFrontend(string $identifier)**

        Frontend-scoped counterpart of `retrieve()`. Only secrets flagged
        `frontend_accessible` resolve here, and that requirement holds for
        every caller — including a request that happens to carry a backend
        session, whose ambient privileges would otherwise widen
        `retrieve()`'s access decision. Expiry, decryption, audit logging
        and read statistics behave as in `retrieve()`. See
        [ADR-035: Per-request allow-set of frontend-resolvable identifiers](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-035-frontend-placeholder-allow-set@1.0).

        -   *param string $identifier:* The secret identifier.
        -   *returntype:* string|null
        -   *throws AccessDeniedException:* If the secret is not frontend-accessible, or the actor lacks read permission.
        -   *throws SecretExpiredException:* If the secret has expired.

        *Returns:* The decrypted secret value or null if not found.

    -   **exists(string $identifier) : bool**

        Check if a secret exists.

        -   *param string $identifier:* The secret identifier.

        *Returns:* True if the secret exists.

    -   **delete(string $identifier, string $reason = '') : void**

        Delete a secret from the vault.

        The secret is gone for every vault operation, and there is no restore
        operation. The row is a **soft delete**, though: it is marked
        `deleted` and keeps its ciphertext and wrapped DEK, in the database
        and in every backup, until it is removed at the database level.
        `vault:rotate-master-key` does not re-wrap deleted rows, so destroying
        the master key they were wrapped under makes them unreadable. See
        [Secret disposal](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-decommissioning-secrets@1.0).

        -   *param string $identifier:* The secret identifier.
        -   *param string $reason:* Optional reason for deletion (logged).
        -   *throws SecretNotFoundException:* If secret doesn't exist.
        -   *throws AccessDeniedException:* If user lacks delete permission.

    -   **assertDeletable(string $identifier) : void**

        Assert that `delete()` is permitted for this identifier — without
        deleting. Exists for callers that delete several secrets as one
        logical unit, such as a record delete spanning multiple vault fields:
        a vault delete cannot be undone through the vault, so a partially
        applied batch cannot be compensated, and the only way to keep it
        all-or-nothing is to run every permission gate up front and abort
        before the first deletion.

        A secret that does not exist returns without throwing — the goal state
        is already reached. Ask `exists()` to distinguish absent from
        present. Passing does **not** guarantee the subsequent delete
        succeeds: an audit-write failure, or a permission revoked in between,
        can still abort it.

        -   *param string $identifier:* The secret identifier.
        -   *throws AccessDeniedException:* If the current actor lacks delete permission.

    -   **rotate(string $identifier, string $newSecret, string $reason = '') : void**

        Rotate a secret with a new value. `$newSecret` is a
        `#[\SensitiveParameter]`.

        -   *param string $identifier:* The secret identifier.
        -   *param string $newSecret:* The new secret value (`#[\SensitiveParameter]`).
        -   *param string $reason:* Optional reason for rotation (logged).
        -   *throws SecretNotFoundException:* If the secret does not exist.
        -   *throws AccessDeniedException:* If the per-secret ACL or the `secret.rotate` operation permission refuses it.
        -   *throws EncryptionException:* If encryption fails.

    -   **setEnabled(string $identifier, bool $enabled, string $reason = '') : void**

        Enable or disable a secret — the single write path for its availability.

        Disabling withdraws the secret from every read path at once: the record
        carries TCA's `disabled` enable column, so a disabled secret resolves
        to nothing in `retrieve()`, `retrieveForFrontend()` and every
        placeholder that goes through them. It stays administrable — it can be
        re-enabled, rotated, deleted, and its metadata read — and reports its
        state in `SecretDetails::$enabled`.

        The state is absolute rather than a toggle: setting the state a secret
        already has is a no-op and writes no audit entry. A change is audited as
        `metadata_update`, the same action the FormEngine path writes for the
        same column, and is all-or-nothing with that entry
        ([ADR-036: Mutation and audit are all-or-nothing](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-036-mutation-audit-atomicity@1.0)): if the audit write fails the
        previous availability is restored and the failure surfaces.

        -   *param string $identifier:* The secret identifier.
        -   *param bool $enabled:* The availability the secret should have afterwards.
        -   *param string $reason:* Optional justification, recorded in the audit entry alongside the direction of the change.
        -   *throws SecretNotFoundException:* If the secret does not exist.
        -   *throws AccessDeniedException:* If the per-secret ACL or the `secret.manage_policy` operation permission refuses it.

    -   **list(?string $pattern = null, bool $includeDisabled = false) : array**

        List accessible secrets.

        -   *param string|null $pattern:* Optional pattern to filter identifiers (supports the `*` wildcard).
        -   *param bool $includeDisabled:* Also return disabled secrets. Off by default, so a consumer asking which secrets are available keeps the answer it had; the management surfaces pass `true`, because a disabled secret that never appears in a listing cannot be re-enabled.

        *Returns:* A `list<SecretMetadata>` of secret metadata DTOs (`Netresearch\NrVault\Domain\Dto\SecretMetadata`); each entry reports its availability in `$enabled`.

    -   **getMetadata(string $identifier) : SecretDetails**

        Get metadata for a secret without retrieving its value. Resolves a
        disabled secret too — metadata is not the value, and withholding it
        would hide the record from the form that re-enables it.

        -   *param string $identifier:* The secret identifier.
        -   *throws SecretNotFoundException:* If secret doesn't exist.
        -   *throws AccessDeniedException:* If user lacks permission.

        *Returns:* A `SecretDetails` DTO (`Netresearch\NrVault\Domain\Dto\SecretDetails`) with identifier, description, owner, groups, version, availability (`$enabled`), etc.

    -   **http() : VaultHttpClientInterface**

        Get an HTTP client that can inject secrets into requests.

        *Returns:* A PSR-18 compatible vault-aware HTTP client.

## EncryptionService {#encryptionservice}

The crypto boundary: libsodium envelope encryption (per-secret DEK
wrapped by the master key).

-   **interface EncryptionServiceInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Crypto\EncryptionServiceInterface`

    Low-level encryption operations. Most callers use
    `VaultServiceInterface` instead.

    > [!NOTE]
    > Plaintext and key parameters (`$plaintext`, `$encryptedValue`,
    > `$encryptedDek`, `$oldMasterKey`, `$newMasterKey`) carry the
    > `#[\SensitiveParameter]` attribute on the interface.

    -   **encrypt(string $plaintext, string $identifier) : EncryptedData**

        Encrypt a plaintext value with a unique DEK. `$plaintext` is a
        `#[\SensitiveParameter]`.

        -   *param string $plaintext:* The value to encrypt (`#[\SensitiveParameter]`).
        -   *param string $identifier:* Secret identifier (used as AAD).
        -   *throws EncryptionException:* If encryption fails.

        *Returns:* An `EncryptedData` value object (`Netresearch\NrVault\Crypto\EncryptedData`) holding the ciphertext, encrypted DEK, and nonces.

    -   **decrypt(string $encryptedValue, string $encryptedDek, string $dekNonce, string $valueNonce, string $identifier, int $encryptionVersion = 1, string $encryptionAlgorithm = '') : string**

        Decrypt a previously encrypted value. `$encryptedValue` and
        `$encryptedDek` are `#[\SensitiveParameter]`.

        -   *param string $encryptedValue:* Base64-encoded ciphertext (`#[\SensitiveParameter]`).
        -   *param string $encryptedDek:* Base64-encoded encrypted DEK (`#[\SensitiveParameter]`).
        -   *param string $dekNonce:* Base64-encoded DEK nonce.
        -   *param string $valueNonce:* Base64-encoded value nonce.
        -   *param string $identifier:* Secret identifier (used as AAD).
        -   *param int $encryptionVersion:* Stored per-secret encryption version. Defaults to `ENCRYPTION_VERSION_LEGACY` (1), where the algorithm is derived from host capabilities.
        -   *param string $encryptionAlgorithm:* Stored per-secret algorithm marker. Required for version 2+, must be `''` for version 1.
        -   *throws EncryptionException:* If decryption fails or the marker is unknown on this host.

        *Returns:* The decrypted plaintext.

    -   **generateDek() : string**

        Generate a new Data Encryption Key.

        *Returns:* A 32-byte random key.

    -   **calculateChecksum(string $plaintext) : string**

        Calculate a value checksum for change detection. `$plaintext` is
        a `#[\SensitiveParameter]`.

        -   *param string $plaintext:* The secret value (`#[\SensitiveParameter]`).

        *Returns:* SHA-256 hash (64 hex characters).

    -   **reEncryptDek(string $encryptedDek, string $dekNonce, string $identifier, string $oldMasterKey, string $newMasterKey, int $encryptionVersion = 1, string $encryptionAlgorithm = '') : ReEncryptedDek**

        Re-encrypt a DEK with a new master key (used during master-key
        rotation). `$encryptedDek`, `$oldMasterKey` and
        `$newMasterKey` are `#[\SensitiveParameter]`.

        -   *param string $encryptedDek:* Current encrypted DEK (`#[\SensitiveParameter]`).
        -   *param string $dekNonce:* Current DEK nonce.
        -   *param string $identifier:* Secret identifier.
        -   *param string $oldMasterKey:* Previous master key (`#[\SensitiveParameter]`).
        -   *param string $newMasterKey:* New master key (`#[\SensitiveParameter]`).
        -   *param int $encryptionVersion:* Stored per-secret encryption version.
        -   *param string $encryptionAlgorithm:* Stored per-secret algorithm marker (required for version 2+).

        The DEK is re-wrapped with the SAME algorithm the secret was encrypted
        with; the version and algorithm markers are unchanged by the operation.

        *Returns:* A `ReEncryptedDek` value object (`Netresearch\NrVault\Crypto\ReEncryptedDek`).

## EnvelopeCodec {#envelopecodec}

Envelope encryption for a payload you keep in ONE column of your own table
([ADR-032](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-032-portable-envelope-codec@1.0)). Use this instead of
`EncryptionServiceInterface` when you have a blob rather than the vault's
seven-column layout.

-   **interface EnvelopeCodecInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Crypto\EnvelopeCodecInterface`

    -   **const MARKER**

        `'nrv1:'` — the version marker of the envelopes `seal()` produces.
        A stored value is self-identifying, so a column can hold sealed and
        unsealed values during a migration.

    -   **seal(string $plaintext, string $identifier) : string**

        Encrypt a payload into a single string. `$plaintext` is a
        `#[\SensitiveParameter]`.

        -   *param string $plaintext:* The payload to protect (`#[\SensitiveParameter]`).
        -   *param string $identifier:* Context label bound to the ciphertext as additional authenticated data. Use a stable, per-purpose value (a column or use-case name), never a per-row one.
        -   *throws EncryptionException:* If encryption fails or the master key is unavailable.

        *Returns:* `MARKER` \+ base64-encoded JSON envelope.

    -   **open(string $sealed, string $identifier) : string**

        Decrypt a sealed string. The stored change-detection checksum is not
        verified — integrity comes from the AEAD tag, which is always checked.

        -   *param string $sealed:* A string produced by `seal()`.
        -   *param string $identifier:* The SAME identifier the payload was sealed with.
        -   *throws EnvelopeFormatException:* If the string is not a well-formed envelope.
        -   *throws EncryptionException:* If authentication fails, the algorithm marker is unknown on this host, or the master key is unavailable.

        *Returns:* The decrypted payload.

    -   **isSealed(string $value) : bool**

        Whether a stored value is an envelope, as opposed to a plain value written
        before sealing was introduced.

    -   **rewrap(string $sealed, string $identifier, string $oldMasterKey, string $newMasterKey) : string**

        Re-wrap the envelope's DEK from one master key to another, leaving the
        payload ciphertext untouched — nothing is decrypted. This is the primitive
        behind `ForeignEnvelopeRotatorInterface`; you normally reach it through
        `EnvelopeRotationContext::rewrap()` rather than calling it directly.

> [!WARNING]
> `seal()` wraps the payload's DEK with the CURRENT master key, and that
> wrapped DEK lives in YOUR table where `vault:rotate-master-key` cannot reach
> it. If you seal payloads you MUST also register a
> `ForeignEnvelopeRotatorInterface` (below), or your data becomes
> permanently undecryptable the first time an operator rotates the master key.

## ForeignEnvelopeRotator {#foreignenveloperotator}

How a consuming extension joins master-key rotation
([ADR-033](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-foreign-envelope-rotation@1.0)). Tag your implementation:

**Configuration/Services.yaml (in YOUR extension)**

```yaml
Vendor\Extension\Crypto\MyEnvelopeRotator:
  tags: ['nrvault.foreign_envelope_rotator']
```

-   **interface ForeignEnvelopeRotatorInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Crypto\ForeignEnvelopeRotatorInterface`

    -   **getIdentifier() : string**

        Short label naming your extension and the data it owns, for the
        operator-facing rotation report (e.g. `nr-llm: agent run state`).

    -   **getTables() : array**

        Every table `rewrapAll()` writes to. The command refuses to rotate when
        one of them is mapped to a different database connection than
        `tx_nrvault_secret`, because atomicity across two connections is a
        fiction.

    -   **countEnvelopes() : int**

        How many sealed envelopes you hold. Called outside the transaction, for the
        dry-run report and the operator summary. Throwing aborts the rotation
        before anything is touched.

    -   **rewrapAll(EnvelopeRotationContext $context) : int**

        Re-wrap every envelope you own; return how many. Runs INSIDE the vault's
        rotation transaction, after the vault's own secrets and before the commit.

        Do not open, commit or roll back a transaction, and do not swallow
        failures: throwing rolls the ENTIRE rotation back, which is deliberate —
        a partial rotation leaves data wrapped under a key the operator has been
        told to destroy. Work in batches; the whole pass is one transaction.

-   **class EnvelopeRotationContext**

    -   *Fully qualified name:* `\Netresearch\NrVault\Crypto\EnvelopeRotationContext`

    Handed to `rewrapAll()`. It closes over the old and new master keys and
    exposes only the operation, so you move envelopes between keys without ever
    holding key material.

    -   **rewrap(string $sealed, string $identifier) : string**

        Re-wrap one envelope's DEK. The payload is not decrypted.

    -   **isSealed(string $value) : bool**

        For skipping rows written before you started sealing.

## SecretRedactor {#secretredactor}

The shared catalogue of recognisable secret shapes
([ADR-031](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-031-shared-secret-pattern-catalogue@1.0)), used by this
extension's plaintext scanner and available to any consumer that needs to mask
secrets in log lines, error messages or outbound payloads.

This is a best-effort net for secrets that have already escaped their proper
home. It recognises the catalogued shapes and nothing else, and is not a
substitute for keeping secrets in the vault.

-   **interface SecretRedactorInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Secret\SecretRedactorInterface`

    -   **redact(string $text, bool $includeEmails = false) : string**

        Replace every recognised secret occurrence in free text with a mask.

        -   *param bool $includeEmails:* Also mask e-mail addresses. Off by default: an address is personal data rather than a secret, and masking one inside, say, a model prompt changes what the text says.

        *Returns:* The masked text. If the regex engine gives up on a pathological input the text is returned as-is rather than emptied.

    -   **isSecretIdentifier(string $identifier, SecretIdentifierKind $kind) : bool**

        Whether a name reads as secret-bearing within its namespace. The kind
        matters: database columns and configuration keys are suffix-anchored, while
        environment variables use a broad substring rule. A name check alone is not
        enough — `GITHUB_PAT` says nothing about being secret — so pair it with
        `identifyValue()` or `redact()` on the value.

    -   **identifyValue(string $value)**

        The shape name when the WHOLE value is a known secret format, else null.
        Leading and trailing whitespace is ignored.

        *Returns:* `string|null` — the matched shape name, or null.

-   **enum SecretIdentifierKind**

    -   *Fully qualified name:* `\Netresearch\NrVault\Secret\SecretIdentifierKind`

    `DatabaseColumn`, `ConfigurationKey`, `EnvironmentVariable` — the three
    identifier namespaces, deliberately not merged into one rule set.

### Usage examples {#usage-examples}

#### Storing a secret {#storing-a-secret}

**Store a secret with VaultService**

```php
use Netresearch\NrVault\Service\VaultServiceInterface;

class MyService
{
    public function __construct(
        private readonly VaultServiceInterface $vault,
    ) {}

    public function storeApiKey(string $apiKey): void
    {
        $this->vault->store(
            'my_extension_api_key',
            $apiKey,
            [
                'description' => 'API key for external service',
                'groups' => [1, 2], // Admin, Editor groups
                'context' => 'payment',
                'expiresAt' => time() + 86400 * 90, // 90 days
            ]
        );
    }
}
```

#### Retrieving a secret {#retrieving-a-secret}

**Retrieve a secret value**

```php
public function getApiKey(): ?string
{
    return $this->vault->retrieve('my_extension_api_key');
}
```

### Vault HTTP client {#vault-http-client}

The vault provides a PSR-18 compatible HTTP client that can inject secrets
into requests without exposing them to your code. Configure authentication
with `withAuthentication()`, then use standard `sendRequest()`.

#### Direct injection (recommended) {#direct-injection-recommended}

**Inject VaultHttpClientInterface**

```php
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Http\VaultHttpClientInterface;

final class ExternalApiService
{
    public function __construct(
        private readonly VaultHttpClientInterface $httpClient,
    ) {}

    public function fetchData(): array
    {
        // Configure authentication, then use standard PSR-18
        $client = $this->httpClient->withAuthentication(
            'api_token',
            SecretPlacement::Bearer,
        );

        $request = new Request('GET', 'https://api.example.com/data');
        $response = $client->sendRequest($request);

        return json_decode($response->getBody()->getContents(), true);
    }
}
```

#### Via VaultService {#via-vaultservice}

**HTTP client via VaultService**

```php
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

$client = $this->vaultService->http()
    ->withAuthentication('stripe_api_key', SecretPlacement::Bearer);

$request = new Request(
    'POST',
    'https://api.stripe.com/v1/charges',
    ['Content-Type' => 'application/json'],
    json_encode($payload),
);

$response = $client->sendRequest($request);
```

-   **interface VaultHttpClientInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Http\VaultHttpClientInterface`

    PSR-18 compatible HTTP client with vault-based authentication.
    Extends `\Psr\Http\Client\ClientInterface`.

    -   **withAuthentication(string $secretIdentifier, SecretPlacement $placement = SecretPlacement::Bearer, array $options = \[\]) : static**

        Create a new client instance configured with authentication.
        Returns an immutable instance - the original is unchanged.

        -   *param string $secretIdentifier:* Vault identifier for the secret.
        -   *param SecretPlacement $placement:* How to inject the secret.
        -   *param array $options:* Additional options (headerName, prefix, queryParam, bodyField, usernameSecret, reason).

        *Returns:* New client instance with authentication configured.

    -   **withOAuth(OAuthConfig $config, string $reason = 'OAuth2 API call') : static**

        Create a new client instance configured with OAuth 2.0 authentication.

        -   *param OAuthConfig $config:* OAuth configuration.
        -   *param string $reason:* Audit log reason.

        *Returns:* New client instance with OAuth configured.

    -   **withReason(string $reason) : static**

        Create a new client instance with a custom audit reason.

        -   *param string $reason:* Audit log reason for requests.

        *Returns:* New client instance with reason configured.

    -   **withTimeout(int $seconds) : static**

        Create a new client instance with a request timeout override.
        Applies Guzzle's `timeout` option (total request duration) to every
        request sent through the returned instance, authenticated or not — use
        it for long-running API calls that exceed the instance-wide
        `$GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout']`. Connection
        establishment (`connect_timeout`) stays platform-managed.

        -   *param int $seconds:* Timeout in seconds; non-positive values mean "no override" and fall back to the platform default.

        *Returns:* New client instance with the timeout configured.

    -   **sendRequest(RequestInterface $request) : ResponseInterface**

        Send an HTTP request (PSR-18 method).

        -   *param RequestInterface $request:* PSR-7 request.
        -   *throws ClientExceptionInterface:* If request fails.

        *Returns:* PSR-7 response.

#### Cancelling an outbound request {#cancelling-an-outbound-request}

PSR-18 returns a response, never a handle, so `sendRequest()` cannot be
aborted once it is running — a caller whose own work was cancelled still waits
out the timeout. `CancellableHttpClientInterface` adds a second send that
polls a caller-supplied signal and tears the socket down when it turns true.

It is a *send*, not an exported handle, and that is a security property rather
than a matter of taste. Four of this client's protections — the scheme
allowlist, the `allowed_hosts` gate, the credential injection and the audit
write — are statements inside the sending method, not middleware on the handler
stack, so they do not travel with a transport. The rule the package holds to is
therefore narrow and sharp: **nothing hands you a client that already carries a
vault secret**, and `VaultHttpClient` is the only place nr-vault attaches a
secret to *your* request. There is no send you can drive that puts a vault
secret on the wire without those four: every public method of that class returns
a configured clone, a PSR-7 response or a bool, and
`sendCancellable()` takes no options parameter — asserted by
`VaultHttpClientCancellableTest::theCredentialBearingClientExportsNoTransportAndNoPromise()`.

nr-vault sends two credentials of its own on paths that are not your request and
do not carry all four: the `X-Vault-Token` header of the transit master-key
provider, on a plain Guzzle client, and the `client_secret` of the OAuth token
leg, which applies the `allowed_hosts` gate, writes one `oauth_token_request`
audit row per attempted round trip, and rides `sendCancellable()`'s
cancellation signal (issue #303). They are listed in
[ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-cancellable-outbound-send@1.0).

Building a hardened transport *without* vault credentials remains a supported,
public case: `SecureHttpClientFactory::create()` and
`createCancellable()` return one, carrying the SSRF reject middleware and
the `CURLOPT_RESOLVE` DNS pin — and no secret, no allowlist gate, no audit
write.

The interface is separate from `VaultHttpClientInterface` and purely
additive, so consumers feature-detect instead of raising a version floor:

**Aborting a call when the surrounding operation is cancelled**

```php
use Netresearch\NrVault\Http\CancellableHttpClientInterface;
use Netresearch\NrVault\Http\CancellationSignalInterface;
use Netresearch\NrVault\Exception\RequestCancelledException;

final class RunCancellationSignal implements CancellationSignalInterface
{
    public function __construct(private readonly MyRunState $run) {}

    public function isCancelled(): bool
    {
        return $this->run->wasCancelled();
    }
}

$client = $this->vaultService->http()
    ->withAuthentication('tool_api_key')
    ->withReason('MCP tool call')
    ->withTimeout(15);

try {
    $response = $client instanceof CancellableHttpClientInterface && $client->supportsCancellation()
        ? $client->sendCancellable($request, new RunCancellationSignal($run))
        : $client->sendRequest($request);
} catch (RequestCancelledException $e) {
    // The call was abandoned on purpose; the audit log already recorded it.
}
```

The signal is polled once before the request is sent and then between ticks of
the transport event loop. Implementations must be cheap and must not throw:
the method is called several times a second per in-flight request, and it runs
after the credential has been injected. A signal that throws anyway still leaves
an audit row — the write is in a `finally` — but the exception reaches the
caller unchanged
(`VaultHttpClientCancellableTest::aSignalThatThrowsMidFlightStillLeavesAnAuditRow()`).

> [!NOTE]
> Where a guarantee in this section names a test, that test is in
> `Tests/Unit/Http/VaultHttpClientCancellableTest.php` or
> `Tests/Unit/Http/VaultHttpClientTest.php` and enforces the sentence it is
> named in; each fixed literal listed below is asserted by a test as well,
> through the outcome-to-test table in
> [ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-cancellable-outbound-send@1.0), which names one per row.
>
> Two statements here name none and are descriptions rather than pinned
> guarantees: what `SecureHttpClientFactory::create()` and
> `createCancellable()` hand out, and the invariant sentence introducing
> this section — whose operative half, that no send you can drive skips the
> four protections, does name one.

> [!NOTE]
> When this client builds the transport itself, it comes from
> `SecureHttpClientFactory` and carries the same hardened options, the
> same SSRF/DNS-pin middleware
> (`ssrfDnsPinIsInstalledOnTheCancellableTransport()`) and the same timeout
> as the blocking client
> (`theTransportTheClientResolvesForItselfCarriesTheRememberedTimeout()`) —
> cancellation is an early exit, never an extension.
>
> A PSR-18 client you injected into `VaultHttpClient`'s constructor is
> never replaced by a transport
> (`anInjectedGuzzleClientIsNeverSwappedForACancellableTransport()`):
> `supportsCancellation()` reports false for such an instance and
> `sendCancellable()` completes the call blocking, on your client. The one
> exception is `withTimeout()`, which has to bake the override into a
> client and therefore rebuilds one from the factory — the clone it returns
> reports `supportsCancellation()` **true**
> (`withTimeoutRebuildsACallerSuppliedClientAndTurnsCancellationOn()`).
>
> A client obtained from `VaultServiceInterface::http()` supports
> cancellation on any platform with `curl_multi_*`, through the withers
> included (`cancellationSurvivesTheWithersAndTheProductionFactory()`);
> without that extension it degrades. Ask
> `supportsCancellation()` rather than assuming either.

Every `sendCancellable()` writes exactly one audit row — and so does every
`sendRequest()` — so the log is complete with respect to calls and not
merely to egress. The outcome-to-test table in
[ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-cancellable-outbound-send@1.0) is the enumeration that backs this
sentence: one row per way a call can end, one named test per row. Three actions
can result, and each means one thing — the
action is what you filter and count on, so none of them needs the error message
to be understood:

-   **`http_call_cancelled` (badge: warning)**

    The signal stopped an **in-flight** request. The credential was retrieved,
    injected and handed to the transport: treat it as exposed. Nothing else is
    filed under this action, so "which calls were abandoned after their
    credential went out?" is a query on this one value
    (`theTwoCancellationOutcomesAreToldApartByTheirAction()`).

-   **`http_call_cancelled_before_send` (badge: info)**

    The signal was already set when the send began. No secret was retrieved and
    nothing was handed to the transport. Its own action so you can exclude these
    rows by query rather than by reading messages
    (`cancellingBeforeSendReadsNoSecretAndStillLeavesADistinguishableRow()`).

-   **`http_call` with `success = false`**

    Everything that failed rather than was cancelled — a refused scheme or host,
    a transport that could not be built, a credential that could not be obtained,
    a transport error, the defensive wall-clock bound, a settlement that is not a
    response, or a throw from your signal or the ticker. Nobody asked for those,
    so they sit with the other failures. ADR-037 lists the test for each.

Within an action, the row's error message is a fixed literal shown under the
badge:

-   **`Request cancelled before send: nothing egressed and no secret was retrieved`**

    The pre-flight refusal.

-   **`Request cancelled after send began: credential injected and transfer handed to the transport`**

    In the usual case the bytes are already out; if the signal turns true before
    the first tick they may not be, which is why the literal does not claim more
    than it can.

-   **`Cancellable transfer exceeded its wall-clock budget and was aborted`**

    The defensive bound above the curl timeouts tripped, i.e. the transport
    stopped settling its promise.

-   **`Cancellable transport settled with a value that is not an HTTP response`**

    The transfer settled with something unusable.

-   **`Cancellable transfer aborted by an unexpected error after the credential was injected: …`**

    Guzzle's option handling, your signal or the ticker threw.

-   **`Blocking send aborted by an unexpected error after the credential was injected: …`**

    The degraded blocking branch threw something that is not a PSR-18
    `ClientExceptionInterface`. The same literal can appear for a plain
    `sendRequest()`, which runs the same send-and-audit helper.

-   **`Cancellable transport could not be built; nothing was sent: …`**

    Building the transport for `sendCancellable()` threw. It is built after
    the two guards and before the credential is read, so nothing egressed and no
    secret was retrieved. This one is specific to `sendCancellable()`.

-   **`Cancellable transfer was rejected`**

    The promise rejected with a reason that is not a `Throwable`, so there is
    no foreign message to append. Specific to `sendCancellable()`.

-   **`Request refused before any secret was read: unsupported URI scheme "…"`**

    The URI was neither `http` nor `https`. The scheme is in the message
    because the audit context records method, host, path and status only.

-   **`Request refused before any secret was read: host is not in the allowed hosts list`**

    `$GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']` refused the
    destination. The host is on the row, in the context. The gate also refuses a
    hostname it cannot resolve to a checked address, so this message covers a
    destination that is not in DNS at all — a host served by `/etc/hosts`, by
    an NSS module or by a container runtime's resolver needs a literal
    `allowed_hosts` entry, and
    [ADR-038: A host we cannot resolve is refused, not handed to curl](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-038-unresolvable-host-is-refused@1.0) says why.

-   **`Credential injection failed; nothing was sent: …`**

    The vault read, or the OAuth token leg, threw. Nothing egressed.

The last three appear for `sendRequest()` as well: they are refusals of a
call that was asked for, and a call that was asked for shows up in the log.
The exception you receive is unchanged by that row — pinned by three
characterization tests in `VaultHttpClientTest`, written before the rows
existed.

See [ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-cancellable-outbound-send@1.0) for the transport details and the
residual gaps — in particular that the OAuth token round trip preceding an
OAuth-authenticated call is not cancellable.

-   **interface CancellableHttpClientInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Http\CancellableHttpClientInterface`

    An outbound send that can be aborted while it is still on the wire.
    Implemented by `VaultHttpClient` alongside
    `VaultHttpClientInterface`.

    -   **sendCancellable(RequestInterface $request, CancellationSignalInterface $signal) : ResponseInterface**

        Send an HTTP request, aborting the transfer as soon as `$signal` says
        so. Runs the same guard sequence as `sendRequest()`: scheme
        allowlist, host allowlist, credential injection, audit write.

        Accepts no per-request transport options, deliberately — see
        [ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-cancellable-outbound-send@1.0).

        When `supportsCancellation()` is false the call still completes,
        blocking, with an ordinary `http_call` audit row. It degrades; it does
        not fail
        (`aNonGuzzleInnerClientDegradesToABlockingSendWithAnOrdinaryAuditRow()`).

        -   *param RequestInterface $request:* PSR-7 request.
        -   *param CancellationSignalInterface $signal:* Polled before the send and between transport ticks.
        -   *throws RequestCancelledException:* If the signal aborted the call.
        -   *throws ClientExceptionInterface:* If the transfer itself failed.
        -   *throws VaultException:* If the scheme or host is rejected, or secret retrieval fails.

        *Returns:* PSR-7 response.

    -   **supportsCancellation() : bool**

        Whether this instance can abort a transfer in flight. False when the
        platform has no `curl_multi_*` support, and false when the inner client
        was supplied by the caller instead of built by
        `SecureHttpClientFactory` — a supplied client may carry your own
        middleware or proxy, so it stays the one that sends. A *pre-flight* signal
        is still honoured in either case, because nothing has egressed yet, and
        the call is still audited
        (`aPreFlightSignalIsHonouredEvenWhenCancellationIsUnsupported()`).

-   **interface CancellationSignalInterface**

    -   *Fully qualified name:* `\Netresearch\NrVault\Http\CancellationSignalInterface`

    -   **isCancelled() : bool**

        Return true to abort the in-flight request. Must not throw, and must be
        cheap.

#### Authentication options {#authentication-options}

The `withAuthentication()` method accepts these options:

-   **headerName**

    Custom header name (for `SecretPlacement::Header`, default: `X-API-Key`).

-   **prefix**

    Auth scheme/prefix prepended to the secret (for `SecretPlacement::Header`).
    Use for non-Bearer `Authorization: <scheme> <secret>` schemes — e.g. `'Key '`
    for the TYPO3 FAL providers or `'DeepL-Auth-Key '` for DeepL.

-   **queryParam**

    Query parameter name (for `SecretPlacement::QueryParam`, default: `api_key`).

-   **bodyField**

    Body field name (for `SecretPlacement::BodyField`, default: `api_key`).

-   **usernameSecret**

    Separate username secret identifier (for `SecretPlacement::BasicAuth`).

-   **reason**

    Reason for access (logged in audit).

#### SecretPlacement enum {#secretplacement-enum}

-   **placement**

    Authentication placement using `SecretPlacement` enum:

    -   `SecretPlacement::Bearer` \- Bearer token in Authorization header.
    -   `SecretPlacement::BasicAuth` \- HTTP Basic Authentication.
    -   `SecretPlacement::Header` \- Custom header value.
    -   `SecretPlacement::QueryParam` \- Query parameter.
    -   `SecretPlacement::BodyField` \- Field in request body.
    -   `SecretPlacement::OAuth2` \- OAuth 2.0 with automatic token refresh.
    -   `SecretPlacement::ApiKey` \- X-API-Key header (shorthand).

**Authentication examples**

```php
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

// Bearer authentication
$client = $this->vault->http()
    ->withAuthentication('stripe_api_key', SecretPlacement::Bearer);
$response = $client->sendRequest(
    new Request('POST', 'https://api.stripe.com/v1/charges', [], $body)
);

// Custom header
$client = $this->vault->http()
    ->withAuthentication('api_token', SecretPlacement::Header, [
        'headerName' => 'X-API-Key',
    ]);
$response = $client->sendRequest(
    new Request('GET', 'https://api.example.com/data')
);

// Custom Authorization scheme (e.g. DeepL "Authorization: DeepL-Auth-Key <key>")
$client = $this->vault->http()
    ->withAuthentication('deepl_api_key', SecretPlacement::Header, [
        'headerName' => 'Authorization',
        'prefix' => 'DeepL-Auth-Key ',
    ]);
$response = $client->sendRequest(
    new Request('POST', 'https://api-free.deepl.com/v2/translate', [], $body)
);

// Basic authentication with separate credentials
$client = $this->vault->http()
    ->withAuthentication('service_password', SecretPlacement::BasicAuth, [
        'usernameSecret' => 'service_username',
        'reason' => 'Fetching secure data',
    ]);
$response = $client->sendRequest(
    new Request('GET', 'https://api.example.com/secure')
);

// Query parameter
$client = $this->vault->http()
    ->withAuthentication('api_key', SecretPlacement::QueryParam, [
        'queryParam' => 'key',
    ]);
$response = $client->sendRequest(
    new Request('GET', 'https://maps.example.com/geocode')
);
```

### PSR-14 events {#psr-14-events}

The vault dispatches events during secret operations.

-   **class SecretCreatedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\SecretCreatedEvent`

    Dispatched when a new secret is created.

    -   `getIdentifier()`: The secret identifier.
    -   `getSecret()`: The Secret entity.
    -   `getActorUid()`: User ID who created it.

-   **class SecretAccessedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\SecretAccessedEvent`

    Dispatched when a secret is read.

    -   `getIdentifier()`: The secret identifier.
    -   `getActorUid()`: User ID who accessed it.
    -   `getContext()`: The secret's context.

-   **class SecretRotatedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\SecretRotatedEvent`

    Dispatched when a secret is rotated.

    -   `getIdentifier()`: The secret identifier.
    -   `getNewVersion()`: The new version number.
    -   `getActorUid()`: User ID who rotated it.
    -   `getReason()`: The rotation reason.

-   **class SecretDeletedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\SecretDeletedEvent`

    Dispatched when a secret is deleted.

    -   `getIdentifier()`: The secret identifier.
    -   `getActorUid()`: User ID who deleted it.
    -   `getReason()`: The deletion reason.

-   **class SecretUpdatedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\SecretUpdatedEvent`

    Dispatched when a secret value is updated (without rotation).

    -   `getIdentifier()`: The secret identifier.
    -   `getNewVersion()`: The new version number.
    -   `getActorUid()`: User ID who updated it.

-   **class MasterKeyRotatedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\MasterKeyRotatedEvent`

    Dispatched by `vault:rotate-master-key` after the rotation transaction has
    COMMITTED, so a listener never observes a rotation that was rolled back.

    -   `getSecretsReEncrypted()`: Number of this extension's secrets re-encrypted.
    -   `getForeignEnvelopesReEncrypted()`: Number of consumer-owned envelopes
        re-wrapped ([ADR-033](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-foreign-envelope-rotation@1.0)).
    -   `getActorUid()`: The acting backend user, or 0 in a CLI context.
    -   `getRotatedAt()`: When the rotation completed.

    This is a notification, not a participation hook: a listener cannot re-wrap
    anything, because both master keys are gone by the time it runs. To have your
    own envelopes rotated, implement `ForeignEnvelopeRotatorInterface`.

-   **class AuditIntegrityAlertEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\AuditIntegrityAlertEvent`

    Dispatched when audit verification produces a finding.

    -   `getAlert()`: The alert value object.
    -   `getReason()`: The stable reason code (`TABLE_RESET`,
        `EPOCH_DOWNGRADE`, `SINK_FAILURE`, `NO_EXTERNAL_SINK`, …).
    -   `isTamperEvidence()`: Whether this finding is evidence of tampering
        rather than an availability problem — the discriminator a pager rule
        should key on.

-   **class BreakGlassActivatedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\BreakGlassActivatedEvent`

    Dispatched when a break-glass window opens.

    -   `getActorUid()` / `getActorUsername()`: Who opened it.
    -   `getReason()`: The mandatory justification.
    -   `getExpiresAt()`: When the window lapses on its own.

-   **class BreakGlassDeactivatedEvent**

    -   *Fully qualified name:* `\Netresearch\NrVault\Event\BreakGlassDeactivatedEvent`

    Dispatched when a window is closed deliberately. A window that merely
    *expires* dispatches nothing — nothing runs at the moment it lapses, so
    reconstruct the closed interval from the activation event's expiry.

    -   `getActorUid()` / `getActorUsername()`: Who closed it.
    -   `getReason()`: The closing note.
