Phoenix Internal API (PAI)

PAI is the privileged internal API plane. It is not publicly reachable and is protected by:

Restricted: Access is limited to Phoenix Global Admins and Phoenix-owned services only.

Base URL

/internal/v1

Swagger

Internal Swagger UI (requires a PAI key, or a session JWT when PAI_SESSION_TOKENS_ENABLED is on; IP allowlist enforced only when configured):

Authentication

Provide a PAI key via one of:

Key Generation

POST /api/v1/admin/pai/keys

Generated Global Admin only, no email step. Returns the api key (phx_pai_...) and companion token (phx_pait_...) once — both shown on screen; the companion token is also emailed best-effort. Manage with GET /api/v1/admin/pai/keys (list, no secrets) and POST /api/v1/admin/pai/keys/{key_id}/revoke (revoke). To rotate, generate a new pair and revoke the old key. POST /api/v1/admin/pai/keys accepts optional per-key restrictions:

{
  "name": "Internal Analytics",
  "ip_allowlist": ["10.0.0.0/8", "192.168.1.10"],
  "domain_allowlist": ["internal.example.com", "*.svc.example.com"]
}

For backwards compatibility, mixed entries submitted in ip_allowlist are split into IP/CIDR and domain restrictions by the backend. Domain restrictions are matched against X-Forwarded-Host, Host, Origin, and Referer; use them as an additional guard with the global IP allowlist, not as a replacement for network controls.

Email delivery is best-effort and is not required before PAI key issuance (GA-only generation succeeds even when SMTP is unavailable; the companion token is shown on screen and, when SMTP is configured, also emailed). MFA verification is planned but not yet available.

Paired companion token (x-pai-token, added 2026-07-21)

Database-mode PAI keys (pai_key_mode=db) can carry an optional companion token — an independent secret (phx_pait_...) minted alongside the api key by POST /api/v1/admin/pai/keys (returned inline as pai_token in that response, and also emailed to the requesting admin). Send it as a second header alongside the key:

The companion is required — a missing or mismatched value returns 401 — when either:

This is a backward-compatible, per-key rollout: existing db-mode keys issued before this feature (no stored companion hash) keep working with the api key alone unless pai_require_paired_token is turned on globally. Env-mode PAI keys (pai_key_mode=env, the single shared PAI_KEY) never require a companion token — there is no per-key record to hang a hash off of.

Session tokens (Authorization: Bearer <jwt>, added 2026-07-21)

Once minted via POST /internal/v1/pai/session (below), a short-lived session JWT can be presented as Authorization: Bearer <token> in place of the api-key + companion-token pair on subsequent internal API calls, until it expires or the key's revocation epoch advances.

Session Tokens (added 2026-07-21)

POST /internal/v1/pai/session

Exchanges a PAI api-key + companion-token pair for a short-lived session JWT, so subsequent calls can send Authorization: Bearer <token> instead of resending the raw key pair on every request.

Flag: pai_session_tokens_enabled (default false, env PAI_SESSION_TOKENS_ENABLED). Returns 404 when off.

Auth: Same as any PAI call — the router-level require_pai_access dependency runs first, so the IP allowlist, api key, and (if required for this key) companion token are already validated before this handler runs. If that validation fails, the usual 401/403 responses apply and the request never reaches the session-minting logic below.

Request

No request body. Send the pair as headers:

POST /internal/v1/pai/session
X-API-Key: phx_pai_...
x-pai-token: phx_pait_...

(Omit x-pai-token if the key doesn't require a companion — see Paired companion token above.)

Response 200

{
  "access_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 900
}

expires_in is seconds until expiry, from pai_session_ttl_seconds (default 900, i.e. 15 minutes; env PAI_SESSION_TTL_SECONDS).

Using the session token

Present it on subsequent internal API calls in place of the key pair:

Authorization: Bearer <access_token>

The minted JWT embeds the same per-key IP/domain allowlist as the underlying key, so the session path is never broader than what the raw api-key + companion pair could already reach. It is also invalidated early if the key's revocation epoch advances (e.g. the key is revoked/rotated) — verify_session_token checks the JWT's embedded epoch against the key's current epoch on every call and rejects a stale one.

Notes

Core Endpoints

Endpoint Purpose
POST /internal/v1/pai/sessionMint a short-lived session JWT (see Session Tokens above)
GET /internal/v1/cve/{cve_id}Full CVE details + raw NVD record
GET /internal/v1/cve/{cve_id}/github-pocsFull (uncapped) GitHub PoC list + PS-HP-aligned popularity summary
GET /internal/v1/phoenix-score/{cve_id}Full PS-HP output with components
GET /internal/v1/high-profileFull high-profile list (no redaction)
GET /internal/v1/enterprise-watchlistFull watchlist entries
POST /internal/v1/packages/intelInternal package-version CVE + MPI malware intelligence
POST /internal/v1/calculate-scoreCustom PS-HP calculation
POST /internal/v1/calculate-pss-scoreCustom PS-OSS (PSS) calculation
POST /internal/v1/calculate-phs-scoreCustom PS-PHS calculation
POST /internal/v1/calculate-pvs-scoreCustom PS-PVS calculation
POST /internal/v1/calculate-adqe-scoreCustom ADQE calculation
POST /internal/v1/calculate-eol-riskCustom EOL SLR risk calculation
GET /internal/v1/scoring-weightsPS-HP/PS-OSS weights
GET /internal/v1/threat-actorsThreat actor intelligence
GET /internal/v1/eol-intelligenceFull EOL intelligence
GET /internal/v1/analysis/cweFull CWE analysis (mirror of /api/v1/analysis/cwe)
GET /internal/v1/analysis/cwe-top25Full CWE Top 25 analysis
GET /internal/v1/analysis/kev-cweFull KEV CWE analysis
GET /internal/v1/analysis/owasp-top10Full OWASP Top 10 analysis
GET /internal/v1/analysis/reference-threat-frequenciesFull, live reference threat-frequency distributions
GET /internal/v1/cvesUnrestricted CVE search — public filters plus sort, published_after/published_before, has_analysis; limit up to 1000; unredacted
GET /internal/v1/cves/by-productUnrestricted vendor/product CVE enumeration (limit up to 2000)
GET /internal/v1/cves/trendingTrending CVEs (news/social mention volume)
GET /internal/v1/cves/kevCISA KEV catalog, no tier field filtering (limit up to 10000)
GET /internal/v1/cves/homepage-intelHomepage intelligence bundle

The /internal/v1/cves* group (added 2026-07-08) mirrors the public /api/v1/cves read surface, unredacted and with higher limits, gated only by PAI auth (require_pai_access + pai_enabled).

Package Intelligence

POST /internal/v1/packages/intel

Returns the internal package-version intelligence payload for a package coordinate. This is the PAI twin of the premium public package intelligence endpoint.

Gated by enable_package_intel_api (default false). When the flag is off the endpoint returns 404.

Request

{
  "ecosystem": "Maven",
  "name": "com.h2database:h2",
  "version": "1.3.176"
}

Response fields

FieldNotes
ecosystem, name, version, purlNormalised package identity.
cvesVersion-filtered OSV CVE/advisory records.
vulnerabilitiesAlias of cves.
malwareMPI and compromised-package intelligence.
malware.scan_id, malware.signals, malware.iocs, malware.rawInternal-only MPI evidence for Phoenix-owned services.
ps_oss_scoreDerived package risk score.
source_attributionData sources used in the response.

Advisory Endpoints

Gated by ENABLE_ADV_FETCH (default false). All return 404 when the flag is off.

Endpoint Purpose
GET /internal/v1/cves/{cve_id}/advisory-patchesAdvisory patch suggestions for a CVE
GET /internal/v1/advisories/{advisory_id}CVEs + full advisory records for an advisory ID (422 on malformed ID)
GET /internal/v1/advisories/patch-month/{patch_month}Advisory entries for a patch month (YYYY-MM; 422 on bad format, 400 when > 500 records)

CWE / OWASP Top 10 / Threat-Frequency Analysis Endpoints

Added 2026-07-10. PAI mirrors of the five public /api/v1/analysis/* endpoints (see PUBLIC_API.html). These routes are not themselves gated by ENABLE_CWE_OWASP_THREAT_TIERED_API — that flag only governs whether the public routes shape their payload. The PAI mirrors always return the full, unshaped payload (AccessMode.FULL), regardless of the flag's state, because PAI access is itself the Enterprise gate (require_pai_access) and there is no end-user tier context on a PAI call.

Endpoint Purpose
GET /internal/v1/analysis/cweFull CWE analysis (mirror of /api/v1/analysis/cwe)
GET /internal/v1/analysis/cwe-top25Full CWE Top 25 analysis
GET /internal/v1/analysis/kev-cweFull KEV CWE analysis
GET /internal/v1/analysis/owasp-top10Full OWASP Top 10 analysis
GET /internal/v1/analysis/reference-threat-frequenciesFull, live reference threat-frequency distributions — note: this route always reads the true, current reference_threat_frequencies.json, unlike the public route, whose underlying data source is itself flag-gated (legacy snapshot when the flag is off).

No additional tier parameter or header is required beyond the PAI key itself. Missing/unavailable underlying data returns 404 (this router's existing convention — see GET /internal/v1/eol-intelligence), not the public routes' 503.

Org/Team Admin Tenancy

Flag: enable_org_team_admin_scoping (default false). All endpoints in this section return 404 when the flag is off.
Auth: Global admin or org manager (is_org_manager Cognito group) for the target org — enforced by resolve_admin_scope + ensure_target_org_in_scope.
Related: docs/Individual_Feature/2026-06-29-org-team-tenancy-plan-3-tenancy-router.md

Organizations

EndpointAuthPurpose
GET /internal/v1/admin/organizationsglobal-admin or org-managerList organizations. Non-global admins see only their own org.
POST /internal/v1/admin/organizationsglobal-adminCreate org. Body: { slug, display_name, plan_tier, seat_limit }. 201.
GET /internal/v1/admin/organizations/{org_id}global-admin or org-managerGet org. 404 when not found or out of scope.
PATCH /internal/v1/admin/organizations/{org_id}global-adminUpdate org metadata (display_name, plan_tier, seat_limit, status).
POST /internal/v1/admin/organizations/{org_id}/licenseglobal-adminSet org plan tier + seat limit. Audited. Body: { tier, seat_limit, product }.

Teams

EndpointAuthPurpose
GET /internal/v1/admin/organizations/{org_id}/teamsglobal-admin or org-managerList teams. Non-global admins see only teams in their scope.
POST /internal/v1/admin/organizations/{org_id}/teamsglobal-adminCreate team. Body: { slug, display_name, seat_cap? }. 201. 409 SLUG_TAKEN on duplicate.
GET /internal/v1/admin/organizations/{org_id}/teams/{team_id}global-admin or org-managerGet team. Caller must have the team in scope.
POST /internal/v1/admin/organizations/{org_id}/teams/{team_id}/licenseglobal-adminSet team tier override + seat cap. Tier clamped to org tier.
DELETE /internal/v1/admin/organizations/{org_id}/teams/{team_id}global-adminDelete team. 204.

Members

GET /internal/v1/admin/organizations/{org_id}/members

Auth: Global admin or tenant_admin of that org. team_admin callers are auto-scoped to their own teams.

Query param: team_id (optional uuid) — filter by team. Overridden by auto-scoping for team_admin callers.

Response 200: { "members": [...] }

POST /internal/v1/admin/organizations/{org_id}/members

Auth: Global admin or tenant_admin of that org.

Add an existing user (by user_sub) to an org.

FieldTypeRequiredNotes
user_substryesCognito user sub
user_idintnoInternal DB user ID
rolestrnoDefault member. Values: member, viewer, team_admin, tenant_admin
team_iduuidnoAssign to a specific team

Errors: 400 invalid role; 403 TENANT_ADMIN_ESCALATION (tenant_admin caller cannot assign tenant_admin role); 409 seat limit.

Response 201: { "added": bool, "already_member": bool }

PATCH /internal/v1/admin/organizations/{org_id}/members/{user_sub}

Auth: Global admin or tenant_admin of that org.

Update a member's role. Request body: { "role": "member|viewer|team_admin|tenant_admin" }

Errors: 400 invalid role; 403 TENANT_ADMIN_ESCALATION; 404 member not found; 409 LAST_ADMIN (cannot demote the last tenant_admin).

DELETE /internal/v1/admin/organizations/{org_id}/members/{user_sub}

Auth: Global admin or tenant_admin of that org.

Remove a member. 409 LAST_ADMIN when removing the last tenant_admin. 204 on success.

PUT /internal/v1/admin/organizations/{org_id}/members/{user_sub}/team

Reassign a member to a team. Enterprise orgs only (403 ENTERPRISE_REQUIRED for non-enterprise).

Request body: { "team_id": uuid }

Invitations

EndpointAuthPurpose
GET /internal/v1/admin/organizations/{org_id}/invitationsglobal-admin or org-managerList invitations. team_admin auto-scoped. Query param: team_id.
POST /internal/v1/admin/organizations/{org_id}/invitationsglobal-admin or tenant_adminCreate invite. Org admins cannot create tenant_admin invites.
DELETE /internal/v1/admin/organizations/{org_id}/invitations/{invite_id}global-admin or tenant_adminRevoke invite. 204. 404 when not found.

POST /internal/v1/admin/organizations/{org_id}/users — Org-scoped user creation

Create a brand-new user and enroll them in the org. Provisions the user in Cognito (sends invite email) and inserts a users DB row.

Auth: Global admin or tenant_admin of that org. team_admin callers always receive 403 TEAM_ADMIN_DIRECT_CREATE_FORBIDDEN.

Request body

FieldTypeRequiredNotes
emailstr (email)yesNew user's email address
full_namestryesDisplay name
rolestryesmember, viewer, or team_admin. tenant_admin not permitted here.
team_iduuidnoAssign to a team immediately
industrystrnoDefault generic
passwordstrnoTemporary password; Cognito email invite sent regardless

Response 201

{ "user_sub": "cognito-sub-uuid", "role": "member", "org_id": "...", "team_id": null }

Errors

StatusCodeMeaning
400INVALID_ROLErole is not member, viewer, or team_admin
400Password does not meet Cognito policy
403TEAM_ADMIN_DIRECT_CREATE_FORBIDDENCaller is a team_admin
403TENANT_ADMIN_ESCALATIONCaller is tenant_admin and attempted to assign tenant_admin role
404org_id not found or out of caller scope
409SEAT_LIMITOrganization has no available seats
409USER_ALREADY_EXISTSEmail already registered in Cognito

Caller Scope Helpers

EndpointPurpose
GET /internal/v1/admin/org-scopeReturn caller's org-admin scope. Response: { is_global_admin, is_org_manager, tenant_id, member_role, plan_tier }. Used by UI to gate the Organization-Team tab.
GET /internal/v1/admin/member-teamsMap user_sub -> {org, team} across all tenants. Global admin only.

Admin Registrations (org-scoped)

Auth: require_admin (admin or global-admin Cognito group).

These are /api/v1/admin/ endpoints (not PAI). Documented here because their scoping behaviour changed as part of the org/team tenancy feature.

GET /api/v1/admin/registrations

List user registrations.

Scope: Global admin sees all rows. Non-global admins see only rows belonging to their primary org (get_user_primary_org). Non-global admins with no org membership receive an empty list.

Query parameters: status (optional), limit (default 100, max 1000), offset (default 0).

Response 200: Array of UserRegistrationListItem.

GET /api/v1/admin/registrations/pending

Convenience alias — equivalent to GET /registrations?status=pending. Same org-scoping rules.

Response 200: Array of UserRegistrationListItem with status=pending.

Neutral Defaults

PAI calculation endpoints apply neutral defaults when parameters are omitted: