API 

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

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)
  • 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)
  • 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)
  • 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)

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 

The main service for interacting with the vault.

interface VaultServiceInterface
Fully qualified name
\Netresearch\NrVault\Service\VaultServiceInterface

Main interface for vault operations.

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.

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.

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): 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 

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.

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 

Envelope encryption for a payload you keep in ONE column of your own table (ADR-032). 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.

ForeignEnvelopeRotator 

How a consuming extension joins master-key rotation (ADR-033). Tag your implementation:

Configuration/Services.yaml (in YOUR extension)
Vendor\Extension\Crypto\MyEnvelopeRotator:
  tags: ['nrvault.foreign_envelope_rotator']
Copied!
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 

The shared catalogue of recognisable secret shapes (ADR-031), 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 

Storing a secret 

Store a secret with VaultService
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
            ]
        );
    }
}
Copied!

Retrieving a secret 

Retrieve a secret value
public function getApiKey(): ?string
{
    return $this->vault->retrieve('my_extension_api_key');
}
Copied!

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) 

Inject VaultHttpClientInterface
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);
    }
}
Copied!

Via VaultService 

HTTP client via VaultService
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);
Copied!
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 

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.

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
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.
}
Copied!

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()).

How long a call may run follows the transport's total timeout. With one set — the platform value or withTimeout() — libcurl enforces it, and a defensive wall-clock bound of timeout + connect_timeout + 5 s sits above it. Without one (timeout = 0, the default on TYPO3 13.4 and 14.3), the call is not bounded in duration, as sendRequest() is not: it ends when the server has sent nothing — no final response head, no body byte after one — for 60 seconds ( SecureHttpClientFactory::STREAMING_IDLE_BUDGET_SECONDS ). Interim 1xx heads and the raw bytes after an unsolicited 101 Switching Protocols count as nothing. A server trickling a byte every few seconds keeps the call open, exactly as it keeps sendRequest() open; set a timeout or call withTimeout() for a hard ceiling. The OAuth token leg follows the same rule. Before this rule, a call without a total timeout was aborted after connect_timeout + 5 s however much the server was still sending; ADR-040: Without a total timeout, the cancellable send bounds silence records the change.

Every sendCancellable() writes exactly one audit row — and so does every sendRequest() and every sendStreaming() , which writes the same three actions (see below) — 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 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, the idle 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 and ADR-040 list 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 transfer received nothing within its idle limit and was aborted
No total timeout is set, and the server sent nothing — no final head, no body byte after one — for 60 seconds.
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 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 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.

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.

Reading a response while it arrives 

sendRequest() and sendCancellable() return once the whole body has arrived, so a caller that asked a provider for a streamed answer sees the first byte only at the end. StreamingHttpClientInterface adds a send that returns as soon as the response head and the first body bytes are in, or the transfer has ended, with a body that reads the rest from the wire.

The returned head is always the origin's. A 1xx interim head, and the 200 Connection established reply of a tunnelling proxy (which Guzzle 7 reports as a head of its own), are replaced by the head that follows them.

The transfer runs on the same curl-multi transport as sendCancellable() , with the CURLOPT_RESOLVE DNS pin, the SSRF middleware and the hardened options. Guzzle's stream option is never set: it would route the request to a handler that ignores the pin. The scheme allowlist, the allowed_hosts gate and the credential injection run through the same code as sendRequest() .

Like CancellableHttpClientInterface , the interface is separate and additive, so consumers feature-detect it:

Reading a server-sent event stream line by line
use Netresearch\NrVault\Http\StreamingHttpClientInterface;

$client = $this->vaultService->http()
    ->withAuthentication('openai_api_key')
    ->withReason('Chat completion stream')
    ->withTimeout(120);

if ($client instanceof StreamingHttpClientInterface && $client->supportsStreaming()) {
    $response = $client->sendStreaming($request, $signal);
} else {
    $response = $client->sendRequest($request);
}

$body = $response->getBody();
$buffer = '';
while (!$body->eof()) {
    $buffer .= $body->read(8192);
    while (($end = strpos($buffer, "\n")) !== false) {
        $this->handleLine(substr($buffer, 0, $end));
        $buffer = substr($buffer, $end + 1);
    }
}
Copied!

The loop has no try on purpose. When read() throws, stop reading: a failed body never reaches eof() , so a loop that catches the exception and goes on asking eof() never ends.

What the body does:

  • read() returns the bytes that have arrived. When none have, it drives the transport until some arrive or the transfer ends.
  • End of stream means the transfer completed and every byte was read. A transfer that fails after the headers throws from read() once the bytes that did arrive are handed out — a VaultException whose previous exception is the transport's. It never ends as a short body.
  • __toString() throws instead of returning part of a body — a deliberate deviation from PSR-7, whose __toString() must not throw. It returns the rest of the body — all of it if nothing was read yet — whenever the transfer completes, error statuses such as 401 included, and throws only when the transfer failed, was cancelled or passed the limit below. If you must not see an exception — say, while turning a 4xx body into your own error message — call getContents() in a try.
  • getContents() and __toString() return at most 16 MiB; past that they tear the transfer down and throw `Streaming response body is larger than getContents() returns; read it in chunks with read(). Use them for bodies known to be small and :php:read()` for anything else.
  • read(0) returns '' without driving the transfer; a negative length throws.
  • close() , detach() or dropping the body before the end removes the transfer from the transport and closes the connection. So does the signal, which is polled before the send and on every step; a signal that fires while the body is read throws RequestCancelledException.
  • A stalled stream ends. With a total timeout (the platform value or withTimeout() ), it ends at that timeout, and a long stream needs a long timeout, as a long blocking call does. Without one (timeout = 0, the default on TYPO3 13.4 and 14.3), a stream that keeps delivering lives, and one whose server sends nothing for 60 seconds — no final head, no body bytes after it — ends with `Streaming transfer received nothing within its idle limit and was aborted``. Interim ``1xx heads and the raw bytes after an unsolicited ``101 Switching Protocols`` count as nothing. Pausing between your own reads does not count either: what the server sent meanwhile is collected first. A server trickling a byte every few seconds after its head is not stopped in that case, exactly as on :php:sendRequest()`; set a timeout for a hard ceiling. Only reading drives the transfer.
  • Any exception while the body is read — a failed transfer, or one from a signal that breaks its "must not throw" rule — closes the body and releases the connection at once: isReadable() turns false and every further read() throws. After such a failure eof() stays false, so a loop on eof() cannot mistake a truncated body for a complete one; it becomes true only when you call close() or detach() . Stop at the first exception.
  • At most 16 MiB of unread body is buffered. A single transport step that delivers more — typically a small compressed body that decodes to a very large one — fails the transfer with the message `Streaming transfer aborted: one step delivered more body than the 16 MiB streaming buffer holds`, instead of filling memory. A step on a fast link delivers a few hundred kilobytes, and reading drains the buffer before the next step.
  • Redirects are not followed; a 3xx response is returned as it is.
  • The body is not seekable and not writable, and its metadata is empty.

One audit row per call, written when sendStreaming() returns or throws. The actions are the three of sendCancellable() above: http_call with the origin's status when the method returns, and with success = false when the call fails before that — the buffer-limit literal above included; http_call_cancelled when the signal stops the transfer before the method returns; http_call_cancelled_before_send when the signal was already set on entry. A failure, a cancellation or an abandon after the method returned writes no second row; the exception from read() is how you learn of it. The body is never logged. See ADR-039: A streaming send drives the pinned curl transfer from read(), which names the test for each property.

interface StreamingHttpClientInterface
Fully qualified name
\Netresearch\NrVault\Http\StreamingHttpClientInterface

An outbound send whose response body can be read while it arrives. Implemented by VaultHttpClient . A calling interface: a minor release may add methods to it.

sendStreaming(RequestInterface $request, ?CancellationSignalInterface $signal = null): ResponseInterface

Send an HTTP request and return once the origin's response head and the first body bytes have arrived, or the transfer has ended. The body advances the transfer as it is read. Runs the same guard sequence as sendRequest() : scheme allowlist, host allowlist, credential injection, one audit row.

Accepts no per-request transport options, for the reason given in ADR-037: A cancellable send is a method, not an exported handle.

When supportsStreaming() is false the call still completes, blocking, and the body is complete when it is returned.

param RequestInterface $request

PSR-7 request.

param CancellationSignalInterface|null $signal

Polled before the send and on every transport step, headers and body alike.

throws RequestCancelledException

If the signal aborted the call before it returned.

throws ClientExceptionInterface

If the transfer failed before it returned.

throws VaultException

If the scheme or host is rejected, secret retrieval fails, the transfer overran its time bound, or one step delivered more body than the 16 MiB buffer holds.

Returns

PSR-7 response whose body reads from the wire.

supportsStreaming(): bool

Whether sendStreaming() delivers the body while it arrives. The same answer as supportsCancellation() : false when the inner client was supplied by the caller, and false without curl_multi_*.

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 

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
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')
);
Copied!

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).
  • 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.