diff --git a/backend/src/api/public/v1/akrites-external/index.ts b/backend/src/api/public/v1/akrites-external/index.ts index e60f35fc3b..8e0b5a63a7 100644 --- a/backend/src/api/public/v1/akrites-external/index.ts +++ b/backend/src/api/public/v1/akrites-external/index.ts @@ -7,6 +7,8 @@ import { SCOPES } from '@/security/scopes' import { getAkritesExternalAdvisoryDetail } from '../packages/getAkritesExternalAdvisoryDetail' import { getAkritesExternalAdvisoryDetailBatch } from '../packages/getAkritesExternalAdvisoryDetailBatch' +import { getAkritesExternalBlastRadiusJob } from '../packages/getAkritesExternalBlastRadiusPoll' +import { submitAkritesExternalBlastRadiusJob } from '../packages/getAkritesExternalBlastRadiusSubmit' import { getAkritesExternalContactDetail } from '../packages/getAkritesExternalContactDetail' import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExternalContactDetailBatch' import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail' @@ -38,6 +40,16 @@ export function akritesExternalRouter(): Router { advisoriesSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalAdvisoryDetailBatch)) router.use('/advisories', advisoriesSubRouter) + // Same read:advisories scope as Advisories above (see the contract's Blast Radius + // tag). The reachability pipeline isn't built yet, so every request 501s — see + // getAkritesExternalBlastRadiusSubmit/Poll — but the route/scope wiring is real. + const blastRadiusSubRouter = Router() + blastRadiusSubRouter.use(rateLimiter) + blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES])) + blastRadiusSubRouter.post('/jobs', safeWrap(submitAkritesExternalBlastRadiusJob)) + blastRadiusSubRouter.get('/jobs/:analysisId', safeWrap(getAkritesExternalBlastRadiusJob)) + router.use('/blast-radius', blastRadiusSubRouter) + // Security contacts expose contact PII (e.g. reporter emails), so the contract gates // them behind a dedicated cdp:maintainers:read scope and explicitly forbids reaching // them via the packages scope. That scope isn't issued by Auth0 yet, so reuse the diff --git a/backend/src/api/public/v1/akrites-external/openapi.yaml b/backend/src/api/public/v1/akrites-external/openapi.yaml index 8bb8f78366..d788bdaea0 100644 --- a/backend/src/api/public/v1/akrites-external/openapi.yaml +++ b/backend/src/api/public/v1/akrites-external/openapi.yaml @@ -10,7 +10,9 @@ info: Packages, Advisories and Contacts endpoints are implemented. Blast Radius - is specced separately and not yet built. + routes exist and are wired up (auth, scopes, request validation), but the + reachability pipeline itself is not built yet — both endpoints always + respond 501 NOT_IMPLEMENTED. TODO: scopes below (read:packages, read:stewardships) are the existing @@ -45,6 +47,12 @@ tags: it, the implementation requires read:maintainer-roles (NOT read:packages). The response shape is still under discussion upstream (reportingMethods / reportingGuidelines / integrationHints are reserved and always null today). + - name: Blast Radius + description: > + Advisory reachability analysis jobs (submit + poll). Requires + read:packages, reused for now (see the Advisories tag TODO). The + reachability pipeline is not built yet, so both endpoints always + respond 501 NOT_IMPLEMENTED once past auth/validation. components: securitySchemes: @@ -574,6 +582,29 @@ components: allOf: - $ref: '#/components/schemas/ContactDetail' + BlastRadiusJobRequest: + type: object + required: [advisoryId] + description: > + Not yet actionable — see the Blast Radius tag. Shape mirrors the draft + contract so the route can validate input ahead of the pipeline landing. + properties: + advisoryId: + type: string + description: GHSA or CVE identifier. + example: GHSA-jf85-cpcp-j695 + ecosystem: + type: string + nullable: true + example: npm + package: + type: string + nullable: true + description: Full purl (e.g. pkg:npm/lodash) or bare package name. Omit for an advisory-wide analysis. + force: + type: boolean + default: false + paths: /akrites-external/packages/detail: get: @@ -882,3 +913,83 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + + /akrites-external/blast-radius/jobs: + post: + operationId: submitBlastRadiusJob + summary: Submit a blast-radius analysis job + description: > + Not implemented — always responds 501 once the request passes + auth/validation. See the Blast Radius tag. + tags: [Blast Radius] + security: + - M2MBearer: + - read:packages + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BlastRadiusJobRequest' + responses: + '501': + description: Not implemented yet. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '400': + description: Validation error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Token missing read:packages scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites-external/blast-radius/jobs/{analysisId}: + get: + operationId: getBlastRadiusJob + summary: Poll a blast-radius analysis job + description: > + Not implemented — always responds 501, regardless of analysisId. See + the Blast Radius tag. + tags: [Blast Radius] + security: + - M2MBearer: + - read:packages + parameters: + - name: analysisId + in: path + required: true + schema: + type: string + responses: + '501': + description: Not implemented yet. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Token missing read:packages scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' diff --git a/backend/src/api/public/v1/packages/blastRadius.ts b/backend/src/api/public/v1/packages/blastRadius.ts new file mode 100644 index 0000000000..c375c81f89 --- /dev/null +++ b/backend/src/api/public/v1/packages/blastRadius.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +// Advisory-wide when package is omitted; narrowed to one package otherwise. +// Mirrors BlastRadiusJobRequest in the akrites-external draft contract. +export const blastRadiusJobRequestSchema = z.object({ + advisoryId: z.string().trim().min(1), + ecosystem: z.string().trim().min(1).nullable().optional(), + package: z.string().trim().min(1).nullable().optional(), + force: z.boolean().optional().default(false), +}) + +export const analysisIdParamSchema = z.object({ + analysisId: z.string().trim().min(1), +}) diff --git a/backend/src/api/public/v1/packages/getAkritesExternalBlastRadiusPoll.ts b/backend/src/api/public/v1/packages/getAkritesExternalBlastRadiusPoll.ts new file mode 100644 index 0000000000..6617d5f4b0 --- /dev/null +++ b/backend/src/api/public/v1/packages/getAkritesExternalBlastRadiusPoll.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from 'express' + +import { NotImplementedError } from '@crowd/common' + +import { validateOrThrow } from '@/utils/validation' + +import { analysisIdParamSchema } from './blastRadius' + +// No jobs are ever created (see submitAkritesExternalBlastRadiusJob), so there is +// nothing to poll yet — always 501, regardless of analysisId. +export async function getAkritesExternalBlastRadiusJob( + req: Request, + _res: Response, +): Promise { + validateOrThrow(analysisIdParamSchema, req.params) + + throw new NotImplementedError('Blast-radius analysis is not implemented yet') +} diff --git a/backend/src/api/public/v1/packages/getAkritesExternalBlastRadiusSubmit.ts b/backend/src/api/public/v1/packages/getAkritesExternalBlastRadiusSubmit.ts new file mode 100644 index 0000000000..359a981ff4 --- /dev/null +++ b/backend/src/api/public/v1/packages/getAkritesExternalBlastRadiusSubmit.ts @@ -0,0 +1,19 @@ +import type { Request, Response } from 'express' + +import { NotImplementedError } from '@crowd/common' + +import { validateOrThrow } from '@/utils/validation' + +import { blastRadiusJobRequestSchema } from './blastRadius' + +// The reachability pipeline isn't built yet — see the Blast Radius section of +// the akrites-external draft contract. Still validate the request shape so +// callers get a real 400 for malformed input rather than a blanket 501. +export async function submitAkritesExternalBlastRadiusJob( + req: Request, + _res: Response, +): Promise { + validateOrThrow(blastRadiusJobRequestSchema, req.body) + + throw new NotImplementedError('Blast-radius analysis is not implemented yet') +} diff --git a/services/libs/common/src/errors/http.ts b/services/libs/common/src/errors/http.ts index 2a3f51e29f..61821d5ccf 100644 --- a/services/libs/common/src/errors/http.ts +++ b/services/libs/common/src/errors/http.ts @@ -78,6 +78,15 @@ export class ConflictError extends HttpError { } } +export class NotImplementedError extends HttpError { + readonly code = 'NOT_IMPLEMENTED' + readonly status = 501 + + constructor(message = 'Not implemented') { + super(message) + } +} + export class RateLimitError extends HttpError { readonly code = 'RATE_LIMITED' readonly status = 429 diff --git a/services/libs/common/src/index.ts b/services/libs/common/src/index.ts index 078bbaadfb..4bf8557ed0 100644 --- a/services/libs/common/src/index.ts +++ b/services/libs/common/src/index.ts @@ -15,6 +15,7 @@ import { InsufficientScopeError, InternalError, NotFoundError, + NotImplementedError, RateLimitError, UnauthorizedError, } from './errors/http' @@ -65,6 +66,7 @@ export { ConflictError, RateLimitError, InternalError, + NotImplementedError, // Application errors ApplicationError,