Skip to main content

Architecture & Internals

Review update: bounded transport and contract gates​

The September 2026 review adds operation-level OpenAPI validation, typed custom-rule and gateway extension clients, and regression coverage for CLI-facing behavior. The shared request pipeline validates request bodies before OAuth, serializes JSON once, and creates a fresh URL/deadline for each retry. Authentication, body reads and backoff are bounded; caller cancellation stops waiting without cancelling a shared OAuth token refresh for other callers. The token manager separately owns one disposable 30-second deadline across token headers and JSON-body reads. All refresh waiters reject on expiry even if an instrumented fetch ignores cancellation; later callers can refresh again, and late token responses cannot update the cache. Pagination collectors stop at the requested cap without fetching an extra page and reject stalled non-final Spring pagination. Native fetch, crypto, and Zod remain the runtime foundation.

Stable gateway request fields reject typos; explicit extension maps accept only finite, prototype-safe JSON. Responses preserve additive fields. Public legacy AIRS request-builder types remain source-compatible with the CLI while their submission schemas enforce documented requirements.

This page explains how the Prisma AIRS TypeScript SDK works under the hood: the service domains it covers, the explicit authentication boundaries, the single request pipeline every client shares, and the validation and error models that hold it all together.

Looking for the symbol-level docs?

The TypeDoc-generated Full API reference documents every exported class, function, type, and Zod schema. This page covers the design behind those symbols.

The SDK has zero external HTTP dependencies — it is built on the runtime's native fetch and crypto, with zod as the only production dependency.

Service domains and auth methods​

The SDK separates AIRS/SCM authentication, gateway runtime keys and unauthenticated public catalog reads:

DomainEntry pointAuthBase URL constant
Scan APIinit() + ScannerAPI key HMAC and/or bearer tokenDEFAULT_ENDPOINT
Management APIManagementClientOAuth2 client_credentialsDEFAULT_MGMT_ENDPOINT (+ DLP)
Model SecurityModelSecurityClientOAuth2 client_credentialsDEFAULT_MODEL_SEC_*_ENDPOINT
Red TeamRedTeamClientOAuth2 client_credentialsDEFAULT_RED_TEAM_*_ENDPOINT
AI GatewayAIGatewayClientOAuth2 client_credentialsDEFAULT_AI_GW_*_ENDPOINT
Gateway runtimeAIGatewayInferenceClientx-portkey-api-keyExplicit runtime endpoint
Public model pricingAIGatewayModelPricingClientNoneExplicit public catalog endpoint

Only the scan service uses init() and the ApiKeyAuth adapter. It accepts an API key, a pre-obtained bearer token, or both; it does not fetch OAuth2 tokens. Management CRUD, DLP, model security, red teaming and SCM AI Gateway use OAuth2 client_credentials. Runtime inference uses a separate gateway key; public pricing accepts no credentials. Neither client inherits SCM authentication or a hostname. Public pricing has no implicit /v1 prefix and returns catalog data, not tenant cost telemetry; see public model pricing. The Management client additionally talks to a separate DLP base URL (DEFAULT_DLP_ENDPOINT) reusing the same OAuth credentials.

All endpoint paths, base URLs, content/batch limits, header names, and retry config live in one place: src/constants.ts. A few load-bearing values:

export const MAX_NUMBER_OF_RETRIES = 5;
export const HTTP_FORCE_RETRY_STATUS_CODES = [500, 502, 503, 504];
export const MAX_NUMBER_OF_BATCH_SCAN_OBJECTS = 20; // async submission cap
export const MAX_CONTENT_PROMPT_LENGTH = 2 * 1024 * 1024; // 2 MB

The unified request() pipeline​

Service-resource clients — regardless of auth method — use the internal request() helper in src/http/request.ts. Resource sub-clients build a declarative RequestSpec and hand it off. OAuthClient performs the token exchange directly to avoid recursive authentication; it reuses the same internal deadline/abortable-wait primitives and validates the token response separately:

// src/management/profiles.ts (representative)
async create(body: CreateSecurityProfileRequest): Promise<SecurityProfile> {
return request({
method: 'POST',
baseUrl: this.baseUrl,
path: MGMT_PROFILE_PATH,
body,
responseSchema: SecurityProfileSchema,
auth: this.auth, // ApiKeyAuth, OAuthAuth, or TsgHeaderAuth
numRetries: this.numRetries,
});
}

A RequestSpec (see src/http/types.ts) is a plain description of one call: method, baseUrl, path, optional params / body / formData / contentType, an optional requestSchema (validated before transport), an optional responseSchema, allowEmptyBody, an optional secretOperation (debug redaction context for AI Gateway calls), the numRetries budget, and an auth adapter. The pipeline does the rest.

The stages​

Walking it explicitly:

  1. Validate request. When a client declares requestSchema, the JSON body is parsed before URL construction, authentication, or transport. The parsed value—not the unchecked input—is later serialized. A failure throws USER_REQUEST_PAYLOAD_ERROR with the method, path, and the Zod issue for each failing field path. Secret string values are never echoed; enum, literal, and unknown-key issues report issue codes, not rejected values or unknown-key names.
  2. Build URL. The base URL is right-trimmed of trailing slashes, then joined with path. Query params are appended; array values append once per element (?id=a&id=b).
  3. Build headers + body. A User-Agent of PAN-AIRS/<version>-typescript-sdk is always set, along with service-name: api on every outgoing request — the header is optional in the AIRS spec but required by some tenants' downstream services (notably DLP GET /v2/api/data-patterns/{id} and /v2/api/data-profiles/{id}, which 400 without it). Sending it unconditionally avoids a per-endpoint workaround. If formData is present it is sent as-is (the runtime writes the multipart boundary). Otherwise a body is JSON-stringified with Content-Type: application/json — overridable via contentType (DLP endpoints use application/merge-patch+json).
  4. Auth. The auth.prepare() adapter mutates the prepared headers (see below).
  5. Fetch with retry. The whole attempt runs inside executeWithRetry (src/http-retry.ts).
  6. Validate response. On success, the body text is read once. If no responseSchema was declared, request() returns undefined. Otherwise the body is parsed and validated (next section).
note
Empty bodies are hydrated to {}

The AIRS API sometimes returns an empty 2xx body when an endpoint has zero results (e.g. /v1/mgmt/scanlogs with no logs in range). request() hydrates an empty body to {} before validation, so all-optional schemas parse cleanly and required-field failures surface on a specific path rather than a cryptic root error. Endpoints that legitimately return 200+body or 204+no-body (e.g. DLP dictionaries PUT) set allowEmptyBody, which resolves an empty body to undefined and skips validation entirely.

The AuthAdapter abstraction​

Authentication is a single plug-point. All three implementations — ApiKeyAuth, OAuthAuth, and TsgHeaderAuth (which wraps OAuthAuth for the AI Gateway and adds the x-tsg-id header) — implement the AuthAdapter interface from src/http/types.ts:

interface AuthAdapter {
prepare(req: PreparedRequest): Promise<PreparedRequest>;
onUnauthorized?(res: Response): Promise<boolean>;
}

prepare() augments outgoing requests — by convention it mutates headers, not the body. onUnauthorized() is optional and is called at most once per request after a retryable auth failure; returning true triggers a free retry that does not consume the retry budget.

ApiKeyAuth — scan service (HMAC-SHA256)​

src/http/auth/api-key.ts adds the x-pan-token API key header and, optionally, the bearer Authorization header. Its distinguishing behavior: when both an apiKey and a request body are present, it computes an HMAC-SHA256 payload hash over bodyText (keyed with the API key) and sets it as the x-payload-hash header. This is why the body is serialized to bodyText before the adapter runs. ApiKeyAuth has no onUnauthorized — API key auth has nothing to refresh.

OAuthAuth — everything else (bearer)​

src/http/auth/oauth.ts wraps an OAuthClient. Its prepare() calls oauthClient.getToken() and sets Authorization: Bearer <token>. Its onUnauthorized() clears the cached token on a 401/403 and returns true, so the free retry fetches a fresh token. This means an expired-token round trip self-heals in a single transparent retry.

OAuth2 token lifecycle​

OAuthClient (src/management/oauth-client.ts) is the OAuth2 client_credentials token manager. It is responsible for three things:

  • Caching. A token is reused while Date.now() < expiresAt - tokenBufferMs. The default buffer is 30s, so the client refreshes ~30 seconds before actual expiry rather than racing it.
  • Refresh. When the token is missing or within the buffer window, it fetches a new one by POSTing grant_type=client_credentials&scope=tsg_id:<tsgId> with HTTP Basic credentials (btoa(clientId:clientSecret)) to the token endpoint. The response is validated by OAuthTokenResponseSchema.
  • Deduplication. Concurrent getToken() calls share a single in-flight pendingFetch promise, so a burst of parallel requests triggers exactly one token fetch, not N.

It never exposes the raw token through its inspection API. getTokenInfo() returns a TokenInfo snapshot (hasToken, isValid, isExpired, isExpiringSoon, expiresInMs, expiresAt), and an optional onTokenRefresh callback fires after each successful refresh. Token-fetch failures throw AISecSDKException with ErrorType.OAUTH_ERROR.

Listing and pagination shapes​

The OAuth domains do not all expose the same pagination contract. The SDK keeps the wire shape close to each service while sharing helpers where the APIs match:

  • Management API resource lists use offset / limit and return fields such as next_offset where the endpoint supports it. ProfilesClient.list() defaults to offset: 0 and limit: 100.
  • Model Security and Red Team list endpoints use skip / limit / search, defined in src/listing.ts and serialized by the internal serializeListing() helper. Sub-clients extend that base shape with endpoint-specific filters such as Red Team status or target_type.
  • DLP list endpoints use Spring-style page / size options and return Page<T> envelopes from src/models/dlp-page.ts.

The shared Model Security / Red Team base options are:

interface ListingOptions {
skip?: number;
limit?: number;
search?: string;
}

paginate() adapts any of these wire contracts to an AsyncIterable, while collectAll() applies a default 10,000-record safety cap (max: 0 explicitly removes it). Resource clients expose listAll() where walking pages is part of a correct operation. In particular, profile and topic lookups walk all offset pages so revisions beyond the first 100 records are never missed. Profiles can pass the service's latest=true filter; topics implement latest-revision grouping client-side because their endpoint has no equivalent query parameter.

All-page helpers deliberately return a flat array rather than synthesizing pagination metadata. They preserve endpoint-specific filters, choose a page size appropriate to that service, and stop from the response's total/last/next metadata (falling back to a short page where necessary). The generic walker remembers previously visited cursors and throws on repetition, preventing a faulty upstream cursor from creating an infinite loop. Use list() when page boundaries or response metadata matter; use listAll() for a bounded inventory read.

const blockedScans = await modelSecurity.scans.listAll({
eval_outcomes: ['BLOCKED'],
limit: 100,
max: 2_000,
});

// Explicitly accept an unbounded walk only when the caller controls the risk.
const dictionaries = await management.dlp.dictionaries.listAll({ max: 0 });

Validation strategy: strict requests, forward-compatible responses​

Validation happens at three distinct boundaries:

  • Content / setters validate user-supplied values eagerly (e.g. content length, ID length).
  • Scanner / client methods validate arguments before building a request.
  • AI Gateway request schemas validate complete JSON write bodies in the shared pipeline before OAuth or fetch.
  • Zod response schemas validate every API response inside request().

Request and response policy intentionally differ. Stable AI Gateway request envelopes use .strict() and non-empty update refinements so misspelled or empty mutations fail locally. Known extension points use recursive finite-JSON schemas. Response schemas remain .passthrough() because the server may safely add fields that an older SDK has not modeled yet.

The models in src/models/*.ts are Zod schemas with inferred TypeScript types. Nearly every object schema ends in .passthrough():

export const ApiKeySchema = z
.object({
api_key_id: z.string(),
api_key_name: z.string().optional(),
// ...many more fields
})
.passthrough(); // unknown fields pass through instead of being stripped

.passthrough() is the SDK's forward-compatibility lever: when the API adds a new field, the response still validates and the new field is preserved on the returned object rather than being stripped or rejected. The SDK can model the fields it knows about strictly while tolerating server additions — so an API-side feature rollout does not break installed SDK versions. The trade-offs and the tooling that keeps these schemas honest (the preflight gate) are covered in API Design & Versioning.

Retry and backoff​

executeWithRetry (src/http-retry.ts) is the shared retry engine for the entire pipeline.

  • Retryable statuses: only 500, 502, 503, 504 (HTTP_FORCE_RETRY_STATUS_CODES). Network errors (thrown fetch) are also retried.
  • Backoff: full jitter — Math.floor(Math.random() * (2^attempt * 1000 + 1)) ms. This spreads retries from concurrent clients instead of synchronizing them into a thundering herd.
  • Budget: numRetries attempts, configurable 0–5, default 5.
  • Auth retries are free. When onRetryableFailure (wired to auth.onUnauthorized) handles a 401/403, the attempt counter is decremented so the token refresh does not consume the retry budget. The pipeline guards against loops by retrying auth at most once per request.

Exhausting the budget on a 5xx throws SERVER_SIDE_ERROR; a non-retryable 4xx throws CLIENT_SIDE_ERROR immediately, with a human-readable message extracted from the response body (error_message → message → data.message → error.message → msg → API error <status>, with (errorCode: X) appended when the body carries data.errorCode).

The error model​

All SDK errors are instances of AISecSDKException (src/errors.ts), carrying a typed errorType from the ErrorType enum:

ErrorTypeRaised when
SERVER_SIDE_ERROR5xx after retries are exhausted
CLIENT_SIDE_ERROR4xx response, or a network failure
USER_REQUEST_PAYLOAD_ERRORInvalid user input (bad UUID, oversized content, bad numRetries)
MISSING_VARIABLEA required config value (API key, client ID, …) is absent
AISEC_SDK_ERRORInternal SDK error
OAUTH_ERROROAuth2 token fetch failure
RESPONSE_VALIDATIONA 2xx body was invalid JSON or failed its Zod responseSchema

RESPONSE_VALIDATION is the signal that the live API diverged from the SDK's schema — exactly the class of drift the preflight gate is built to catch. See Error Handling for usage patterns.

The scan singleton​

The scan service is the one exception to the "construct a client" pattern. It uses a global singleton, globalConfiguration in src/configuration.ts, configured by the public init() function. init() resolves the API key / token / endpoint / retries from explicit options, falling back to PANW_AI_SEC_* environment variables, validates them, and stores them. Scanner then reads this singleton, so init() must run before any scan. This singleton is reset between tests; for the OAuth domains each client owns its own OAuthClient instance instead.