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.
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:
| Domain | Entry point | Auth | Base URL constant |
|---|---|---|---|
| Scan API | init() + Scanner | API key HMAC and/or bearer token | DEFAULT_ENDPOINT |
| Management API | ManagementClient | OAuth2 client_credentials | DEFAULT_MGMT_ENDPOINT (+ DLP) |
| Model Security | ModelSecurityClient | OAuth2 client_credentials | DEFAULT_MODEL_SEC_*_ENDPOINT |
| Red Team | RedTeamClient | OAuth2 client_credentials | DEFAULT_RED_TEAM_*_ENDPOINT |
| AI Gateway | AIGatewayClient | OAuth2 client_credentials | DEFAULT_AI_GW_*_ENDPOINT |
| Gateway runtime | AIGatewayInferenceClient | x-portkey-api-key | Explicit runtime endpoint |
| Public model pricing | AIGatewayModelPricingClient | None | Explicit 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:
- 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 throwsUSER_REQUEST_PAYLOAD_ERRORwith 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. - Build URL. The base URL is right-trimmed of trailing slashes, then joined with
path. Queryparamsare appended; array values append once per element (?id=a&id=b). - Build headers + body. A
User-AgentofPAN-AIRS/<version>-typescript-sdkis always set, along withservice-name: apion every outgoing request — the header is optional in the AIRS spec but required by some tenants' downstream services (notably DLPGET /v2/api/data-patterns/{id}and/v2/api/data-profiles/{id}, which 400 without it). Sending it unconditionally avoids a per-endpoint workaround. IfformDatais present it is sent as-is (the runtime writes the multipart boundary). Otherwise abodyis JSON-stringified withContent-Type: application/json— overridable viacontentType(DLP endpoints useapplication/merge-patch+json). - Auth. The
auth.prepare()adapter mutates the prepared headers (see below). - Fetch with retry. The whole attempt runs inside
executeWithRetry(src/http-retry.ts). - Validate response. On success, the body text is read once. If no
responseSchemawas declared,request()returnsundefined. Otherwise the body is parsed and validated (next section).
{}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 byOAuthTokenResponseSchema. - Deduplication. Concurrent
getToken()calls share a single in-flightpendingFetchpromise, 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/limitand return fields such asnext_offsetwhere the endpoint supports it.ProfilesClient.list()defaults tooffset: 0andlimit: 100. - Model Security and Red Team list endpoints use
skip/limit/search, defined insrc/listing.tsand serialized by the internalserializeListing()helper. Sub-clients extend that base shape with endpoint-specific filters such as Red Teamstatusortarget_type. - DLP list endpoints use Spring-style
page/sizeoptions and returnPage<T>envelopes fromsrc/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 (thrownfetch) 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:
numRetriesattempts, configurable0–5, default5. - Auth retries are free. When
onRetryableFailure(wired toauth.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:
ErrorType | Raised when |
|---|---|
SERVER_SIDE_ERROR | 5xx after retries are exhausted |
CLIENT_SIDE_ERROR | 4xx response, or a network failure |
USER_REQUEST_PAYLOAD_ERROR | Invalid user input (bad UUID, oversized content, bad numRetries) |
MISSING_VARIABLE | A required config value (API key, client ID, …) is absent |
AISEC_SDK_ERROR | Internal SDK error |
OAUTH_ERROR | OAuth2 token fetch failure |
RESPONSE_VALIDATION | A 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.