@uluops/sdk-core
v0.16.0
Published
Shared infrastructure for UluOps SDKs, including the canonical content-addressing (hash parity) implementation shared with the registry API and @uluops/core
Maintainers
Readme
UluOps · Operating Intelligence as Infrastructure
@uluops/sdk-core
Shared infrastructure for UluOps SDKs. Provides HTTP client, authentication strategies, error hierarchy, configuration loaders, and utility functions used by @uluops/ops-sdk and @uluops/registry-sdk.
Current version: 0.15.0
Quick Start
Building an SDK on top of sdk-core
import { HttpClient, type HttpClientConfig } from '@uluops/sdk-core/http';
// Extend HttpClient with your SDK's defaults
class MyHttpClient extends HttpClient {
constructor(config: Partial<HttpClientConfig> = {}) {
super({
baseUrl: config.baseUrl ?? 'https://api.example.com/v1',
sdkName: '@my-org/my-sdk',
sdkVersion: '1.0.0',
loggerPrefix: '[my-sdk:http]',
...config,
});
}
}
const http = new MyHttpClient({ apiKey: 'ulr_your-api-key-here' });
// Type-safe API calls with automatic retry, auth, and error mapping
const data = await http.get<{ items: string[] }>('/items');
const created = await http.post<{ id: string }>('/items', { name: 'new item' });See Error handling with typed errors below for
try/catchpatterns.
Using the HTTP client directly
import { HttpClient } from '@uluops/sdk-core';
const client = new HttpClient({
baseUrl: 'https://api.example.com/v1',
sdkName: 'my-app',
sdkVersion: '1.0.0',
loggerPrefix: '[my-app]',
apiKey: 'ulr_your-api-key-here',
timeout: 30000,
retries: 3,
// Optional: route security-relevant events (rejected credentials, blocked
// redirects, failed token refresh, credential swaps) to your telemetry sink.
// See "Security Events" below.
onSecurityEvent: (event) => myTelemetry.record(event),
});
const result = await client.get<MyType>('/endpoint');Error handling with typed errors
import { NotFoundError, RateLimitError, isSdkApiError } from '@uluops/sdk-core/errors';
try {
await client.get('/missing-resource');
} catch (error) {
if (error instanceof NotFoundError) {
console.log('Not found:', error.message);
} else if (error instanceof RateLimitError) {
console.log(`Rate limited. Retry after ${error.retryAfter}s`);
} else if (isSdkApiError(error)) {
console.log(`API error [${error.code}]: ${error.message}`);
}
}Table of Contents
Overview
This package extracts the shared infrastructure that was duplicated across @uluops/ops-sdk and @uluops/registry-sdk. It provides:
- HTTP Client: Native
fetch-based client with timeout, retry, auth, rate limiting,{ data: T }envelope parsing, and a structured security-event channel - Authentication: API key and JWT session strategies with automatic token refresh
- Error Hierarchy: 11 typed error classes mapped to HTTP status codes, plus type guard functions
- Configuration: Credential chain loader (constructor > env vars > .env files > stored credentials)
- Utilities: Logger with sensitive data redaction (object-level and string-level), retry with exponential backoff, rate limit header parsing
Prerequisites
- Node.js 20.3.0 or higher (uses native
fetch; streaming usesAbortSignal.any, added in 20.3.0) - TypeScript 5.0+ (for TypeScript users)
- ESM project (
"type": "module"in package.json) — this package is ESM-only
Installation
# npm
npm install @uluops/sdk-core
# yarn
yarn add @uluops/sdk-core
# pnpm
pnpm add @uluops/sdk-coreAPI Reference
HttpClient
The core HTTP client using native fetch with automatic retry, auth, and error mapping.
Configuration
import { HttpClient, type HttpClientConfig } from '@uluops/sdk-core/http';
const client = new HttpClient({
// Required
baseUrl: 'https://api.example.com/v1', // API base URL
sdkName: '@my-org/my-sdk', // For User-Agent header
sdkVersion: '1.0.0', // For User-Agent header
loggerPrefix: '[my-sdk:http]', // Log message prefix
// Authentication (choose one)
apiKey: 'ulr_...', // API key (preferred)
sessionToken: 'jwt-token', // Existing session token
email: '[email protected]', // Email for login
password: 'password', // Password for login
// Optional
authBaseUrl: 'https://auth.example.com', // Separate auth endpoint URL
timeout: 30000, // Request timeout in ms (default: 30000)
retries: 3, // Max retry attempts (default: 3)
debug: false, // Enable debug logging
defaultHeaders: { 'X-Custom': 'value' }, // Extra default headers
onTokenRefresh: (token) => { /* ... */ },// Token refresh callback
onRateLimitApproaching: (info) => { // Fires when remaining < threshold
console.warn(`${info.remaining}/${info.limit} remaining`);
},
rateLimitThreshold: 0.1, // Threshold ratio (default: 0.1)
onRetry: ({ attempt, maxAttempts, error, delayMs }) => {
console.warn(`Retry ${attempt}/${maxAttempts} in ${delayMs}ms`);
},
onSecurityEvent: (event) => { // Structured security telemetry
myTelemetry.record(event); // See "Security Events" below
},
});Request Methods
// GET — always retried on transient errors
const data = await client.get<MyType>('/endpoint', { page: 1 });
// POST
const created = await client.post<MyType>('/endpoint', { name: 'value' });
// PUT
const updated = await client.put<MyType>('/endpoint/123', { name: 'updated' });
// PATCH (supports options: params, skipAuth)
const patched = await client.patch<MyType>('/endpoint/123', { name: 'patched' }, { skipAuth: true });
// DELETE
await client.delete('/endpoint/123');Advanced Request Options
// Retry mutations (POST/PUT/DELETE are NOT retried by default)
const result = await client.request<MyType>('POST', '/idempotent-endpoint', {
body: { key: 'value' },
retryMutations: true,
});
// Runtime validation: the generic T is asserted at the JSON boundary, NOT
// validated at runtime (same convention as axios/ky/got). For runtime
// guarantees, parse the result yourself with a Zod schema. The SDK deliberately
// does NOT accept a caller-supplied schema — an earlier `options.schema` was
// removed in 0.11.0 because any object with a `.parse` method was accepted,
// creating a code-execution primitive from untrusted input. See CHANGELOG 0.11.0.
import { z } from 'zod';
const schema = z.object({ id: z.string(), name: z.string() });
const validated = schema.parse(await client.get<unknown>('/endpoint'));
// ⚠️ Raw response — bypasses retry, token refresh, and rate limit tracking
const raw = await client.requestRaw<MyRawType>('GET', '/endpoint');
console.log(raw); // parsed JSON without envelope unwrapping
// ⚠️ Binary response — same bypass as requestRaw
const binary = await client.requestBinary('GET', '/files/report.pdf');
console.log(binary.data, binary.contentType);Streaming Responses (0.15.0+)
Unlike requestRaw/requestBinary, requestStream/getStream keep full
resilience through headers — transient-error retry with backoff/Retry-After,
deduplicated 401 token refresh, redirect rejection, rate-limit tracking, and
security-event emission — then hand off the redirect-checked, 2xx-guaranteed
Response with its body unread. Non-2xx responses buffer the JSON error
body and throw the same typed SdkApiError hierarchy as request().
After handoff the transport steps back:
- No retry ever. A stream that dies after 2xx headers is a 200-then-die the transport cannot heal — integrity verification (row counts, checksums) is the consumer's job.
- Timeout covers time-to-headers only. The internal timer is released at handoff; a slow or infinite body is not bounded by the transport.
- Body cancellation belongs to the caller via
options.signal, which participates in the fetch for its full lifecycle — thread anAbortSignaland abort it from an idle watchdog if the stream stalls.
// Stream a dataset export (NDJSON) through a BFF passthrough
const watchdog = new AbortController();
const res = await client.getStream(
'/export/projects/p1/issues',
{ format: 'ndjson' },
{ signal: watchdog.signal }
);
console.log(res.headers.get('x-export-total-rows')); // headers readable at handoff
return new Response(res.body, { headers: filtered(res.headers) }); // caller owns the bodyRate Limit Info
// After any request, check rate limit headers
const info = client.getRateLimitInfo();
if (info) {
console.log(`${info.remaining}/${info.limit} requests remaining`);
console.log(`Resets at: ${info.reset}`);
}Automatic Retries
The client automatically retries on transient errors (502, 503, 504, 429) and network failures (DNS, connection reset, ECONNREFUSED) with exponential backoff and jitter:
- GET requests: Always retried (up to
retriesattempts) - Mutations (POST/PUT/DELETE): Only retried when
retryMutations: true - Network errors: Always retried (transient by nature)
- Backoff: Exponential with jitter (base: 1s, max: 30s)
retries: 0: Makes exactly one attempt (no retries) and surfaces the real typed error (e.g.NetworkError) — it does not skip the request- 401 handling: Automatic token refresh with deduplication (one refresh at a time). A 401 with no configured credentials throws a
UnauthorizedErrorexplaining how to set them; a 401 with credentials present preserves the server's reason and appends guidance that the credential may be expired, revoked, or invalid - Redirects are NOT retried: an upstream 3xx from the configured origin throws a non-retryable
RedirectError(see Security Events) — it is rejected before the request body is replayed, not treated as a transient fault - Visibility: Use
onRetrycallback to observe retry attempts in real time
Security Events
Beyond the operational onRetry / onRateLimitApproaching callbacks, the client exposes a single structured security-event channel. It delivers the security-relevant events the client already observes as a discriminated union, so you can route them to your telemetry sink instead of scraping free-text logs or classifying thrown errors.
import { HttpClient, type SecurityEvent } from '@uluops/sdk-core';
const client = new HttpClient({
baseUrl: 'https://api.example.com/v1',
sdkName: 'my-app',
sdkVersion: '1.0.0',
loggerPrefix: '[my-app]',
apiKey: 'ulr_your-api-key-here',
onSecurityEvent: (event: SecurityEvent) => {
switch (event.type) {
case 'auth_failure': // a sent credential was rejected (401)
siem.alert('auth_rejected', { authType: event.authType, requestId: event.requestId });
break;
case 'redirect_rejected': // upstream 3xx blocked before the body was replayed
siem.alert('redirect_blocked', { origin: event.baseUrl });
break;
case 'token_refresh_failed': // session re-login was rejected
siem.alert('session_refresh_failed', {});
break;
case 'auth_strategy_replaced': // live credential swap via setAuthStrategy
siem.alert('credential_swapped', { from: event.previousType, to: event.newType });
break;
}
},
});| Event type | Fires when | Payload fields (beyond type, timestamp, message) |
|--------------|-----------|--------------------------------------------------------|
| auth_failure | A sent credential is rejected with 401 | authType, statusCode, requestId? |
| redirect_rejected | The configured origin returns a 3xx the SDK refuses to follow | baseUrl |
| token_refresh_failed | A session token refresh (re-login) is rejected | authType ('session') |
| auth_strategy_replaced | The live credential is swapped via setAuthStrategy | previousType, newType |
The channel is reporting, not enforcement — the SDK sets no policy and takes no action; you decide what constitutes an incident. Delivery is best-effort and fire-and-forget: a handler that throws is caught and logged, never propagated into request flow. Every event field is credential-safe by construction, so events may be logged verbatim. All event types (SecurityEvent, SecurityEventHandler, the four event interfaces, AuthType) are exported from the root and /http.
Authentication
Three authentication strategies, resolved via priority chain.
API Key (Recommended)
Keys must start with ulr_, be at least 20 characters, and contain only alphanumeric characters, underscores, and hyphens.
import { ApiKeyAuth } from '@uluops/sdk-core/http';
const auth = new ApiKeyAuth('ulr_your-api-key-here');
auth.getAuthorizationHeader(); // 'Bearer ulr_your-api-key-here'
auth.getType(); // 'api_key'
auth.isAuthenticated(); // trueJWT Session
import { JwtSessionAuth } from '@uluops/sdk-core/http';
import type { FetchClient } from '@uluops/sdk-core/http';
const auth = new JwtSessionAuth({
email: '[email protected]',
password: 'password',
httpClient: myFetchClient, // For login/refresh requests
onTokenRefresh: (token) => { /* save token */ },
// clearCredentialsAfterLogin: true (default) — zeroes password after first
// login for CWE-316 mitigation, but disables automatic token refresh.
// Set to false for long-lived sessions (MCP servers, daemons).
});
// Login happens automatically on first request
await auth.refresh();
auth.getSessionToken(); // 'jwt-token-here'
auth.getExpiresAt(); // DateStrategy Factory
import { createAuthStrategy } from '@uluops/sdk-core/http';
// Priority: apiKey > sessionToken > email/password
const strategy = createAuthStrategy({
apiKey: 'ulr_...',
});
strategy.getType(); // 'api_key'Errors
All API errors extend SdkApiError and include statusCode, code, message, details, and requestId.
Error Classes
| Error | Status | When It Happens |
|-------|--------|-----------------|
| ValidationError | 400 | Invalid request data |
| UnauthorizedError | 401 | Missing or invalid credentials |
| ForbiddenError | 403 | Valid credentials but insufficient permissions |
| NotFoundError | 404 | Resource doesn't exist |
| ConflictError | 409 | Name collision or state conflict |
| PayloadTooLargeError | 413 | Request body exceeds size limit |
| UnprocessableError | 422 | Valid syntax but invalid semantics |
| RateLimitError | 429 | Too many requests (retryAfter property) |
| ServiceUnavailableError | 503 | Server temporarily down (retryAfter property) |
| NetworkError | 0 | DNS failure, connection refused (auto-retried) |
| RedirectError | 0 | Configured origin returned a 3xx the SDK refuses to follow (not retried) |
| TimeoutError | 0 | Request exceeded timeout |
Error Factory
import { createErrorFromStatus, NotFoundError } from '@uluops/sdk-core/errors';
// Signature: createErrorFromStatus(statusCode, message, code?, details?, requestId?)
const error = createErrorFromStatus(404, 'Project not found', 'NOT_FOUND', { id: '123' });
error instanceof NotFoundError; // true
error.isRetryable(); // falseType Guards
import {
isSdkApiError,
isValidationError,
isNotFoundError,
isConflictError,
isUnprocessableError,
isRateLimitError,
isNetworkError,
isRedirectError,
} from '@uluops/sdk-core/errors';
if (isSdkApiError(error)) {
console.log(error.code, error.statusCode);
}Error Serialization
Errors safely serialize to JSON with sensitive details redacted:
const error = new NotFoundError('Item not found', { id: '123', apiKey: 'ulr_secret' });
console.log(JSON.stringify(error));
// { "name": "SdkApiError", "code": "NOT_FOUND", "statusCode": 404,
// "message": "Item not found", "details": { "id": "123", "apiKey": "[REDACTED]" } }
// Note: keys matching sensitive patterns (apiKey, token, secret, jwt, passwd, etc.) are automatically redactedConfiguration
Credential Loading
Loads credentials from multiple sources in priority order:
- Explicit arguments:
apiKey,sessionToken,email/password - Environment variables: via SDK-specific
EnvVarConfig - Local
.envfile: viadotenvin the current working directory - Global credentials:
~/.uluops/credentials.json
import {
loadCredentials,
loadConfig,
type EnvVarConfig,
type Credentials,
type SdkConfig,
} from '@uluops/sdk-core/config';
// Each SDK passes its own env var names
const MY_ENV_VARS: EnvVarConfig = {
apiKey: 'MY_API_KEY',
email: 'MY_EMAIL',
password: 'MY_PASSWORD',
baseUrl: 'MY_BASE_URL',
debug: 'MY_DEBUG',
};
// Load credentials with priority chain
const creds: Credentials = loadCredentials({ envVars: MY_ENV_VARS });
// Load full config (credentials + baseUrl + debug)
const config: SdkConfig = loadConfig({
envVars: MY_ENV_VARS,
defaults: { baseUrl: 'https://api.example.com/v1' },
});Credential Validation
import { validateCredentials, isApiKey, API_KEY_PREFIX } from '@uluops/sdk-core/config';
// Throws ValidationError if no credentials found
validateCredentials({ apiKey: process.env.MY_API_KEY });
// Check API key format. Enforces the ulr_ prefix AND the minimum length
// (MIN_API_KEY_LENGTH, 20 chars) so a value that passes isApiKey also passes
// the ApiKeyAuth constructor.
isApiKey('ulr_abc123def456ghij'); // true (20 chars)
isApiKey('invalid'); // false (no prefix)
isApiKey('ulr_short'); // false (prefix but too short)Stored Credentials
import { loadStoredCredentials, getCredentialsPath } from '@uluops/sdk-core/config';
// Path: ~/.uluops/credentials.json
console.log(getCredentialsPath());
// Load from stored file (returns null if not found or invalid)
// Warns if file permissions are world-readable on Unix systems
// Validates field formats (apiKey prefix, non-empty sessionToken/email)
const stored = loadStoredCredentials();Environment & Paths
import { loadEnvFiles, getGlobalConfigDir } from '@uluops/sdk-core/config';
// Load .env from cwd and ~/.uluops/.env
loadEnvFiles();
// Get the global config directory path (~/.uluops)
const configDir = getGlobalConfigDir();Constants
import {
SDK_CORE_VERSION, // package version string
DEFAULT_TIMEOUT, // 30000 ms
DEFAULT_RETRY_COUNT, // 3
BACKOFF_BASE_MS, // 1000 ms
MAX_BACKOFF_MS, // 30000 ms
JITTER_MIN, // 0.1 (10% of delay)
JITTER_MAX, // 0.2 (20% of delay)
API_KEY_PREFIX, // 'ulr_'
HTTP_STATUS, // { OK: 200, CREATED: 201, ... }
ERROR_CODES, // { NOT_FOUND: 'NOT_FOUND', ... }
RETRYABLE_STATUS_CODES,// Set(502, 503, 504, 429)
CONFIG_PATHS, // { configDir, envFile, credentialsFile }
} from '@uluops/sdk-core/config';Utilities
Logger
import { createLogger, type Logger } from '@uluops/sdk-core/utils';
const logger: Logger = createLogger('[my-sdk]', true);
logger.debug('Fetching', '/api/items');
logger.warn('Retry attempt', 2);
logger.error('Request failed', error);
// Disabled logger (no-ops)
const silent = createLogger('[my-sdk]', false);Sensitive Data Redaction
import { redactSensitive, sanitizeForLog, sanitizeForDisplay, sanitizeString } from '@uluops/sdk-core/utils';
// Redact showing last 4 chars
redactSensitive('ulr_secret_key_12345678', 4); // '***5678'
// Sanitize an object for logging (redacts sensitive keys in structured data)
sanitizeForLog({ apiKey: 'ulr_...', name: 'safe' });
// { apiKey: '[REDACTED]', name: 'safe' }
// Sanitize an object for display (deep, strips sensitive keys)
sanitizeForDisplay({ apiKey: 'ulr_...', name: 'safe' });
// { apiKey: '[REDACTED]', name: 'safe' }
// Sanitize a string (redacts credential values in free-form text)
sanitizeString('Login failed with apiKey=ulr_abc123def456');
// 'Login failed with [REDACTED]'Retry with Backoff
import { retry, sleep } from '@uluops/sdk-core/utils';
// Retry an async operation with exponential backoff
const result = await retry(
() => fetchData(),
{ maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000 },
);
// Simple delay
await sleep(1000);Helpers
import { isPlainObject, isUuid, truncate, toQuery } from '@uluops/sdk-core/utils';
isPlainObject({}); // true
isPlainObject([]); // false
isUuid('550e8400-...'); // true
truncate('long string', 5); // 'long ...'
// Build query params from an object (accepts string, number, boolean, null)
const params = toQuery({ page: 1, active: true, empty: undefined });
// { page: 1, active: true } — undefined values strippedRate Limit Parsing
import { parseRateLimitHeaders, type RateLimitInfo } from '@uluops/sdk-core/utils';
const headers = new Headers({
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '42',
'x-ratelimit-reset': '1700000000',
});
const info: RateLimitInfo | undefined = parseRateLimitHeaders(headers);
// { limit: 100, remaining: 42, reset: Date }Content Hashing
The canonical content-addressing implementation shared by the registry API (which
computes and stores hashes at publish time) and @uluops/core (which verifies
caller-pinned hashes at resolve time). Both sides import from here so they hash
identically by construction.
Two artifacts are hashed with two different rules and are not interchangeable:
- YAML source →
computeHash— normalizes first (yaml.parse→yaml.stringifywith sorted keys), so semantically identical documents hash identically. Falls back to raw bytes for non-object / unparseable content. - Rendered prompt →
computePromptHash— hashes the markdown byte-for-byte with no normalization.
import {
computeHash,
computePromptHash,
verifyHash,
verifyPromptHash,
} from '@uluops/sdk-core/utils';
const yamlHash = computeHash(definitionYaml); // 'sha256:...' (normalized)
const promptHash = computePromptHash(renderedPrompt); // 'sha256:...' (raw bytes)
// Verification is timing-safe and never throws on a malformed/wrong-length pin —
// it returns false (a clean refusal), so caller-supplied pins can't crash you.
verifyHash(definitionYaml, expectedHash); // boolean
verifyPromptHash(renderedPrompt, expectedPromptHash); // booleanThe
yamldependency is pinned exact (2.9.0) to match the version that produced the registry's stored hashes. Bumping it can change normalization and is guarded by golden fixtures captured from real published definitions.
Package Exports
| Export Path | Contents |
|------------|----------|
| @uluops/sdk-core | Everything (HttpClient, errors, config, utils) |
| @uluops/sdk-core/http | HttpClient (incl. requestStream/getStream), RequestStreamOptions, ApiKeyAuth, JwtSessionAuth, createAuthStrategy, SecurityEvent types |
| @uluops/sdk-core/errors | SdkApiError + all error subclasses, createErrorFromStatus, type guards |
| @uluops/sdk-core/config | loadCredentials, loadConfig, constants, EnvVarConfig |
| @uluops/sdk-core/utils | createLogger, redactSensitive, sanitizeString, sleep, retry, toQuery, computeHash, computePromptHash, verifyHash, verifyPromptHash |
Extending for Your SDK
This package is designed to be extended, not used directly by end users. Here's the pattern used by @uluops/ops-sdk and @uluops/registry-sdk:
1. Extend HttpClient with SDK defaults
// my-sdk/src/http/http-client.ts
import { HttpClient, type HttpClientConfig } from '@uluops/sdk-core/http';
import { SDK_VERSION, DEFAULT_BASE_URL } from '../config/constants.js';
export class MyHttpClient extends HttpClient {
constructor(config: Partial<HttpClientConfig> = {}) {
super({
...config,
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
sdkName: '@my-org/my-sdk',
sdkVersion: SDK_VERSION,
loggerPrefix: '[my-sdk:http]',
});
}
}2. Alias the base error class
// my-sdk/src/errors/errors.ts
export {
SdkApiError as MyApiError,
isSdkApiError as isMyApiError,
ValidationError,
NotFoundError,
// ... all other errors
} from '@uluops/sdk-core/errors';3. Wrap config loaders with SDK-specific env vars
// my-sdk/src/config/loaders.ts
import { loadConfig as coreLoadConfig, type EnvVarConfig } from '@uluops/sdk-core/config';
const MY_ENV_VARS: EnvVarConfig = {
apiKey: 'MY_SDK_API_KEY',
email: 'MY_SDK_EMAIL',
password: 'MY_SDK_PASSWORD',
baseUrl: 'MY_SDK_BASE_URL',
debug: 'MY_SDK_DEBUG',
};
export function loadConfig(options = {}) {
return coreLoadConfig({
...options,
envVars: MY_ENV_VARS,
defaults: { baseUrl: 'https://api.my-service.com/v1' },
});
}4. Re-export shared utilities
// my-sdk/src/utils/helpers.ts
export { sleep, retry, truncate, isPlainObject, isUuid } from '@uluops/sdk-core/utils';
// Add SDK-specific helpers
export function myCustomHelper() { /* ... */ }5. Auth strategy access (for login flows)
HttpClient exposes its auth strategy so an SDK subclass can implement a login()
method that swaps the credential in place after authenticating:
import { JwtSessionAuth } from '@uluops/sdk-core/http';
class MyHttpClient extends HttpClient {
async login(email: string, password: string): Promise<void> {
const auth = new JwtSessionAuth(this.createFetchClient(), { email, password });
await auth.refresh(); // performs the login POST against authBaseUrl
this.setAuthStrategy(auth); // subsequent requests use the session token
}
}
client.getAuthStrategy(); // AuthStrategy | null — current credential strategy
client.getAuthBaseUrl(); // string — the auth endpoint origin (for temp clients)setAuthStrategy performs no authorization check of its own — replacing the
credential is an intentional trusted-caller capability (the caller already holds
the client reference). Because the swap changes which credential every subsequent
request carries, it emits an auth_strategy_replaced security
event so embedders can observe and correlate credential changes.
License
MIT License - see LICENSE for details.
