PAI is the privileged internal API plane. It is not publicly reachable and is protected by:
X-PAI-Key)X-PAI-Token) / session-JWT second factor (PAI_REQUIRE_PAIRED_TOKEN, PAI_SESSION_TOKENS_ENABLED)PAI_IP_ALLOWLIST) — skipped when empty; restrict at the network/system layer (WAF / load balancer / security group) instead/internal/v1
Internal Swagger UI (requires a PAI key, or a session JWT when PAI_SESSION_TOKENS_ENABLED is on; IP allowlist enforced only when configured):
GET /internal/docsGET /internal/openapi.jsonProvide a PAI key via one of:
X-PAI-Key: phx_pai_...X-API-Key: phx_pai_...Authorization: PAIKey phx_pai_...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.
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:
x-pai-token: phx_pait_...The companion is required — a missing or mismatched value returns 401 — when either:
pai_require_paired_token is true (env PAI_REQUIRE_PAIRED_TOKEN, default false), or
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.
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.
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.
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.)
{
"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).
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.
Bearer session token to this endpoint returns 401 ("Cannot mint a
session from an existing session token..."). You must always present the api-key + companion pair to
mint a session; this prevents a leaked short-lived token from self-refreshing indefinitely.401 Missing PAI key, 401 Invalid PAI key,
401 Missing PAI companion token, 401 Invalid PAI companion token,
403 Client IP not allowed) apply as usual if the pair fails validation before reaching
this endpoint.| Endpoint | Purpose |
|---|---|
POST /internal/v1/pai/session | Mint 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-pocs | Full (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-profile | Full high-profile list (no redaction) |
GET /internal/v1/enterprise-watchlist | Full watchlist entries |
POST /internal/v1/packages/intel | Internal package-version CVE + MPI malware intelligence |
POST /internal/v1/calculate-score | Custom PS-HP calculation |
POST /internal/v1/calculate-pss-score | Custom PS-OSS (PSS) calculation |
POST /internal/v1/calculate-phs-score | Custom PS-PHS calculation |
POST /internal/v1/calculate-pvs-score | Custom PS-PVS calculation |
POST /internal/v1/calculate-adqe-score | Custom ADQE calculation |
POST /internal/v1/calculate-eol-risk | Custom EOL SLR risk calculation |
GET /internal/v1/scoring-weights | PS-HP/PS-OSS weights |
GET /internal/v1/threat-actors | Threat actor intelligence |
GET /internal/v1/eol-intelligence | Full EOL intelligence |
GET /internal/v1/analysis/cwe | Full CWE analysis (mirror of /api/v1/analysis/cwe) |
GET /internal/v1/analysis/cwe-top25 | Full CWE Top 25 analysis |
GET /internal/v1/analysis/kev-cwe | Full KEV CWE analysis |
GET /internal/v1/analysis/owasp-top10 | Full OWASP Top 10 analysis |
GET /internal/v1/analysis/reference-threat-frequencies | Full, live reference threat-frequency distributions |
GET /internal/v1/cves | Unrestricted CVE search — public filters plus sort, published_after/published_before, has_analysis; limit up to 1000; unredacted |
GET /internal/v1/cves/by-product | Unrestricted vendor/product CVE enumeration (limit up to 2000) |
GET /internal/v1/cves/trending | Trending CVEs (news/social mention volume) |
GET /internal/v1/cves/kev | CISA KEV catalog, no tier field filtering (limit up to 10000) |
GET /internal/v1/cves/homepage-intel | Homepage 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).
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.
{
"ecosystem": "Maven",
"name": "com.h2database:h2",
"version": "1.3.176"
}
| Field | Notes |
|---|---|
ecosystem, name, version, purl | Normalised package identity. |
cves | Version-filtered OSV CVE/advisory records. |
vulnerabilities | Alias of cves. |
malware | MPI and compromised-package intelligence. |
malware.scan_id, malware.signals, malware.iocs, malware.raw | Internal-only MPI evidence for Phoenix-owned services. |
ps_oss_score | Derived package risk score. |
source_attribution | Data sources used in the response. |
Gated by ENABLE_ADV_FETCH (default false). All return 404 when the flag is off.
| Endpoint | Purpose |
|---|---|
GET /internal/v1/cves/{cve_id}/advisory-patches | Advisory 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) |
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/cwe | Full CWE analysis (mirror of /api/v1/analysis/cwe) |
GET /internal/v1/analysis/cwe-top25 | Full CWE Top 25 analysis |
GET /internal/v1/analysis/kev-cwe | Full KEV CWE analysis |
GET /internal/v1/analysis/owasp-top10 | Full OWASP Top 10 analysis |
GET /internal/v1/analysis/reference-threat-frequencies | Full, 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.
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
| Endpoint | Auth | Purpose |
|---|---|---|
GET /internal/v1/admin/organizations | global-admin or org-manager | List organizations. Non-global admins see only their own org. |
POST /internal/v1/admin/organizations | global-admin | Create org. Body: { slug, display_name, plan_tier, seat_limit }. 201. |
GET /internal/v1/admin/organizations/{org_id} | global-admin or org-manager | Get org. 404 when not found or out of scope. |
PATCH /internal/v1/admin/organizations/{org_id} | global-admin | Update org metadata (display_name, plan_tier, seat_limit, status). |
POST /internal/v1/admin/organizations/{org_id}/license | global-admin | Set org plan tier + seat limit. Audited. Body: { tier, seat_limit, product }. |
| Endpoint | Auth | Purpose |
|---|---|---|
GET /internal/v1/admin/organizations/{org_id}/teams | global-admin or org-manager | List teams. Non-global admins see only teams in their scope. |
POST /internal/v1/admin/organizations/{org_id}/teams | global-admin | Create 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-manager | Get team. Caller must have the team in scope. |
POST /internal/v1/admin/organizations/{org_id}/teams/{team_id}/license | global-admin | Set team tier override + seat cap. Tier clamped to org tier. |
DELETE /internal/v1/admin/organizations/{org_id}/teams/{team_id} | global-admin | Delete team. 204. |
GET /internal/v1/admin/organizations/{org_id}/membersAuth: 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}/membersAuth: Global admin or tenant_admin of that org.
Add an existing user (by user_sub) to an org.
| Field | Type | Required | Notes |
|---|---|---|---|
user_sub | str | yes | Cognito user sub |
user_id | int | no | Internal DB user ID |
role | str | no | Default member. Values: member, viewer, team_admin, tenant_admin |
team_id | uuid | no | Assign 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}/teamReassign a member to a team. Enterprise orgs only (403 ENTERPRISE_REQUIRED for non-enterprise).
Request body: { "team_id": uuid }
| Endpoint | Auth | Purpose |
|---|---|---|
GET /internal/v1/admin/organizations/{org_id}/invitations | global-admin or org-manager | List invitations. team_admin auto-scoped. Query param: team_id. |
POST /internal/v1/admin/organizations/{org_id}/invitations | global-admin or tenant_admin | Create invite. Org admins cannot create tenant_admin invites. |
DELETE /internal/v1/admin/organizations/{org_id}/invitations/{invite_id} | global-admin or tenant_admin | Revoke invite. 204. 404 when not found. |
POST /internal/v1/admin/organizations/{org_id}/users — Org-scoped user creationCreate 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.
| Field | Type | Required | Notes |
|---|---|---|---|
email | str (email) | yes | New user's email address |
full_name | str | yes | Display name |
role | str | yes | member, viewer, or team_admin. tenant_admin not permitted here. |
team_id | uuid | no | Assign to a team immediately |
industry | str | no | Default generic |
password | str | no | Temporary password; Cognito email invite sent regardless |
{ "user_sub": "cognito-sub-uuid", "role": "member", "org_id": "...", "team_id": null }
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_ROLE | role is not member, viewer, or team_admin |
| 400 | — | Password does not meet Cognito policy |
| 403 | TEAM_ADMIN_DIRECT_CREATE_FORBIDDEN | Caller is a team_admin |
| 403 | TENANT_ADMIN_ESCALATION | Caller is tenant_admin and attempted to assign tenant_admin role |
| 404 | — | org_id not found or out of caller scope |
| 409 | SEAT_LIMIT | Organization has no available seats |
| 409 | USER_ALREADY_EXISTS | Email already registered in Cognito |
| Endpoint | Purpose |
|---|---|
GET /internal/v1/admin/org-scope | Return 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-teams | Map user_sub -> {org, team} across all tenants. Global admin only. |
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/registrationsList 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/pendingConvenience alias — equivalent to GET /registrations?status=pending. Same org-scoping rules.
Response 200: Array of UserRegistrationListItem with status=pending.
PAI calculation endpoints apply neutral defaults when parameters are omitted:
null and excluded via renormalization.unknown status.