From da01a5756847e47862f39baa3fd1db19d28fa9dc Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 16 Jun 2026 11:32:27 +0200 Subject: [PATCH 1/5] feat: add new 4 endpoints Signed-off-by: Umberto Sgueglia --- backend/src/api/ossprey/activityFeed.ts | 39 ++ backend/src/api/ossprey/index.ts | 9 + backend/src/api/ossprey/metrics.ts | 9 + backend/src/api/ossprey/openapi.yaml | 645 ++++++++++++++++++ backend/src/api/ossprey/packageList.ts | 77 +++ backend/src/api/ossprey/packageScatter.ts | 13 + backend/src/api/public/v1/index.ts | 2 + .../src/api/public/v1/ossprey/activityFeed.ts | 41 ++ backend/src/api/public/v1/ossprey/index.ts | 20 + backend/src/api/public/v1/ossprey/metrics.ts | 12 + .../src/api/public/v1/ossprey/packageList.ts | 93 +++ .../api/public/v1/ossprey/packageScatter.ts | 12 + .../api/public/v1/packages/listPackages.ts | 2 +- .../data-access-layer/src/osspckgs/api.ts | 188 ++++- .../src/osspckgs/stewardships.ts | 78 +++ 15 files changed, 1236 insertions(+), 4 deletions(-) create mode 100644 backend/src/api/ossprey/activityFeed.ts create mode 100644 backend/src/api/ossprey/index.ts create mode 100644 backend/src/api/ossprey/metrics.ts create mode 100644 backend/src/api/ossprey/openapi.yaml create mode 100644 backend/src/api/ossprey/packageList.ts create mode 100644 backend/src/api/ossprey/packageScatter.ts create mode 100644 backend/src/api/public/v1/ossprey/activityFeed.ts create mode 100644 backend/src/api/public/v1/ossprey/index.ts create mode 100644 backend/src/api/public/v1/ossprey/metrics.ts create mode 100644 backend/src/api/public/v1/ossprey/packageList.ts create mode 100644 backend/src/api/public/v1/ossprey/packageScatter.ts diff --git a/backend/src/api/ossprey/activityFeed.ts b/backend/src/api/ossprey/activityFeed.ts new file mode 100644 index 0000000000..e4556113d2 --- /dev/null +++ b/backend/src/api/ossprey/activityFeed.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +import { listStewardshipActivity } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { validateOrThrow } from '@/utils/validation' + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(25), +}) + +export default async (req, res) => { + const { page, pageSize } = validateOrThrow(querySchema, req.query) + + const qx = await getPackagesQx() + const { rows, total } = await listStewardshipActivity(qx, { page, pageSize }) + + await req.responseHandler.success(req, res, { + rows: rows.map((r) => ({ + id: r.id, + stewardshipId: r.stewardshipId, + packagePurl: r.packagePurl, + packageName: r.packageName, + packageEcosystem: r.packageEcosystem, + actorUserId: r.actorUserId, + actorName: null, // TODO: resolve display name from crowd.dev users/members table by actorUserId + actorType: r.actorType, + activityType: r.activityType, + content: r.content, + metadata: r.metadata, + stewardshipStatus: r.stewardshipStatus, + createdAt: r.createdAt, + })), + total, + page, + pageSize, + }) +} diff --git a/backend/src/api/ossprey/index.ts b/backend/src/api/ossprey/index.ts new file mode 100644 index 0000000000..fabadf9cbe --- /dev/null +++ b/backend/src/api/ossprey/index.ts @@ -0,0 +1,9 @@ +import { safeWrap } from '../../middlewares/errorMiddleware' + +export default (app) => { + app.get('/ossprey/metrics', safeWrap(require('./metrics').default)) + // /packages/scatter must be registered before /packages to avoid Express treating 'scatter' as a path param + app.get('/ossprey/packages/scatter', safeWrap(require('./packageScatter').default)) + app.get('/ossprey/packages', safeWrap(require('./packageList').default)) + app.get('/ossprey/activity', safeWrap(require('./activityFeed').default)) +} diff --git a/backend/src/api/ossprey/metrics.ts b/backend/src/api/ossprey/metrics.ts new file mode 100644 index 0000000000..2b0bbfac9f --- /dev/null +++ b/backend/src/api/ossprey/metrics.ts @@ -0,0 +1,9 @@ +import { getOsspreyMetrics } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' + +export default async (req, res) => { + const qx = await getPackagesQx() + const metrics = await getOsspreyMetrics(qx) + await req.responseHandler.success(req, res, metrics) +} diff --git a/backend/src/api/ossprey/openapi.yaml b/backend/src/api/ossprey/openapi.yaml new file mode 100644 index 0000000000..4f90fc7451 --- /dev/null +++ b/backend/src/api/ossprey/openapi.yaml @@ -0,0 +1,645 @@ +openapi: 3.1.0 +info: + title: OSSPREY Admin API + version: 1.0.0 + description: > + Internal endpoints for the OSSPREY V2 Admin Dashboard. + + + **Authentication:** Bearer token (Auth0 JWT) in the `Authorization` header. + All endpoints require a valid session token from the crowd.dev backend. + + + **Base path:** All routes are prefixed with `/ossprey/`. + +servers: + - url: https://cm.lfx.dev + description: Production + - url: https://lf-staging.crowd.dev + description: Staging + - url: http://localhost:8080 + description: Local + +tags: + - name: Metrics + description: Aggregate KPI metrics for the dashboard header. + - name: Packages + description: Package list, scatter plot data. + - name: Activity + description: Stewardship activity feed. + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + example: BAD_REQUEST + message: + type: string + example: Invalid query parameter. + + StewardshipStatus: + type: string + enum: + - unassigned + - open + - assessing + - active + - needs_attention + - escalated + - blocked + - inactive + + HealthBand: + type: string + enum: [healthy, fair, concerning, critical] + + VulnSeverity: + type: string + enum: [critical, high, medium, low] + + Steward: + type: object + required: [userId, role, assignedAt] + properties: + userId: + type: string + description: Auth0 sub of the assigned steward. + example: auth0|abc123 + role: + type: string + enum: [lead, co_steward] + assignedAt: + type: string + format: date-time + + LastActivity: + type: object + required: [type, at] + properties: + type: + type: string + description: activity_type enum value. + example: escalation + content: + type: + - string + - 'null' + example: 'Escalated with resolution path: right_of_first_refusal' + at: + type: string + format: date-time + + PackageRow: + type: object + required: + - purl + - name + - ecosystem + - openVulns + - maintainerCount + - stewards + properties: + purl: + type: string + example: pkg:npm/lodash@4.17.21 + name: + type: string + example: lodash + ecosystem: + type: string + enum: [npm, maven] + criticalityScore: + type: + - number + - 'null' + description: packages.impact (0–1 float). Multiply × 100 for display. + example: 0.94 + stewardshipId: + type: + - string + - 'null' + example: '4501' + stewardshipStatus: + oneOf: + - $ref: '#/components/schemas/StewardshipStatus' + - type: 'null' + openVulns: + type: integer + example: 2 + maxVulnSeverity: + oneOf: + - $ref: '#/components/schemas/VulnSeverity' + - type: 'null' + description: Worst advisory severity for this package. Null if no advisories. + maintainerCount: + type: integer + example: 1 + scorecardScore: + type: + - number + - 'null' + description: OpenSSF Scorecard score (0–10). Multiply × 10 for display. + example: 5.2 + healthBand: + oneOf: + - $ref: '#/components/schemas/HealthBand' + - type: 'null' + latestReleaseAt: + type: + - string + - 'null' + format: date-time + lastActivity: + oneOf: + - $ref: '#/components/schemas/LastActivity' + - type: 'null' + stewards: + type: array + items: + $ref: '#/components/schemas/Steward' + + ScatterPoint: + type: object + required: + - purl + - name + - criticalityScore + - healthScore + - healthBand + - openVulns + - advisoryCount + properties: + purl: + type: string + example: pkg:npm/lodash@4.17.21 + name: + type: string + example: lodash + criticalityScore: + type: integer + description: 0–100 integer (impact × 100, rounded). Y-axis position. + example: 94 + healthScore: + type: integer + description: 0–100 integer (scorecardScore × 10, rounded). X-axis position. + example: 52 + healthBand: + $ref: '#/components/schemas/HealthBand' + stewardshipStatus: + oneOf: + - $ref: '#/components/schemas/StewardshipStatus' + - type: 'null' + stewardshipId: + type: + - string + - 'null' + example: '4501' + openVulns: + type: integer + example: 0 + advisoryCount: + type: integer + description: Same as openVulns — explicit alias for tooltip display. + example: 0 + + ActivityEntry: + type: object + required: + - id + - stewardshipId + - packagePurl + - packageName + - packageEcosystem + - actorType + - activityType + - stewardshipStatus + - createdAt + properties: + id: + type: string + example: '9182736' + stewardshipId: + type: string + example: '4501' + packagePurl: + type: string + example: pkg:npm/minimist@1.2.6 + packageName: + type: string + example: minimist + packageEcosystem: + type: string + enum: [npm, maven] + actorUserId: + type: + - string + - 'null' + description: Auth0 sub of the actor. Null for system events. + example: auth0|abc123 + actorName: + type: + - string + - 'null' + description: Display name of the actor. Null — resolution from users table pending. + example: null + actorType: + type: string + enum: [user, system] + activityType: + type: string + enum: + - state_changed + - assessment_completed + - assessment_flagged + - remediation_logged + - status_update + - escalation + - escalation_resolved + - blocker_added + - blocker_resolved + - steward_added + - steward_removed + content: + type: + - string + - 'null' + example: 'Escalated with resolution path: right_of_first_refusal' + metadata: + type: + - object + - 'null' + additionalProperties: true + stewardshipStatus: + $ref: '#/components/schemas/StewardshipStatus' + createdAt: + type: string + format: date-time + +# ────────────────────────────────────────────────────────────────────────────── +# Paths +# ────────────────────────────────────────────────────────────────────────────── +paths: + /ossprey/metrics: + get: + operationId: getOsspreyMetrics + summary: KPI bar + Overview summary panel metrics + tags: [Metrics] + security: + - BearerAuth: [] + responses: + '200': + description: Aggregate metrics for the dashboard. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: object + required: + - totalPackages + - criticalPackages + - coveragePercent + - activeStewards + - unassignedCritical + - needsAttention + - escalated + properties: + totalPackages: + type: integer + example: 24381 + criticalPackages: + type: integer + example: 1842 + coveragePercent: + type: number + format: float + description: Percentage of critical packages with active stewardship (1 decimal). + example: 33.2 + coverageTrend: + type: + - number + - 'null' + description: Percentage-point delta vs 30 days ago. Null until snapshot mechanism is in place. + example: null + activeStewards: + type: integer + example: 612 + unassignedCritical: + type: integer + example: 1230 + needsAttention: + type: integer + example: 47 + escalated: + type: integer + example: 8 + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /ossprey/packages: + get: + operationId: listOsspreyPackages + summary: Paginated package list — Queue tab and Triage Board columns + description: > + Returns a filtered, sorted, paginated list of critical packages with their + stewardship state. Used by the Queue tab and by the Triage Board (one request + per status column, fired in parallel). + tags: [Packages] + security: + - BearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 250 + default: 25 + - name: name + in: query + description: Case-insensitive substring search on package name. + schema: + type: string + example: lodash + - name: ecosystem + in: query + schema: + type: string + enum: [npm, maven] + - name: lifecycle + in: query + schema: + type: string + enum: [active, stable, declining, abandoned] + - name: status + in: query + description: > + Filter by stewardship status. `unassigned` includes packages with no + stewardship row. + schema: + $ref: '#/components/schemas/StewardshipStatus' + - name: healthBand + in: query + schema: + $ref: '#/components/schemas/HealthBand' + - name: vulnSeverity + in: query + description: > + `any` = at least one advisory; `high` = worst severity ≥ HIGH; + `critical` = worst severity = CRITICAL; `none` = zero advisories. + schema: + type: string + enum: [any, high, critical, none] + - name: staleOnly + in: query + schema: + type: boolean + default: false + description: Return only packages with no release in ≥ 18 months. + - name: unstewardedOnly + in: query + schema: + type: boolean + default: false + - name: busFactor1Only + in: query + schema: + type: boolean + default: false + - name: sortBy + in: query + schema: + type: string + enum: [risk, impact, openVulns, health, name] + default: risk + - name: sortDir + in: query + schema: + type: string + enum: [asc, desc] + default: desc + responses: + '200': + description: Paginated package list. + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: object + required: [rows, total, page, pageSize] + properties: + rows: + type: array + items: + $ref: '#/components/schemas/PackageRow' + total: + type: integer + example: 1842 + page: + type: integer + example: 1 + pageSize: + type: integer + example: 25 + example: + data: + rows: + - purl: pkg:npm/lodash@4.17.21 + name: lodash + ecosystem: npm + criticalityScore: 0.94 + stewardshipId: '4501' + stewardshipStatus: needs_attention + openVulns: 2 + maxVulnSeverity: high + maintainerCount: 1 + scorecardScore: 5.2 + healthBand: fair + latestReleaseAt: '2022-01-14T00:00:00Z' + lastActivity: + type: escalation + content: 'Escalated with resolution path: right_of_first_refusal' + at: '2024-06-15T10:23:00Z' + stewards: + - userId: auth0|abc123 + role: lead + assignedAt: '2024-05-01T09:00:00Z' + total: 1842 + page: 1 + pageSize: 25 + '400': + description: Invalid query parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /ossprey/packages/scatter: + get: + operationId: getOsspreyScatter + summary: Scatter plot data — Risk Matrix tab + description: > + Returns all critical packages as lightweight data points for the health-vs-impact + scatter plot. No pagination — returns everything in one response. + tags: [Packages] + security: + - BearerAuth: [] + responses: + '200': + description: All scatter plot points. + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: object + required: [points, total] + properties: + points: + type: array + items: + $ref: '#/components/schemas/ScatterPoint' + total: + type: integer + example: 1842 + example: + data: + points: + - purl: pkg:npm/lodash@4.17.21 + name: lodash + criticalityScore: 94 + healthScore: 52 + healthBand: fair + stewardshipStatus: needs_attention + stewardshipId: '4501' + openVulns: 2 + advisoryCount: 2 + total: 1842 + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /ossprey/activity: + get: + operationId: getOsspreyActivity + summary: Paginated activity feed — Overview tab + description: > + Returns recent stewardship events across all packages, sorted by date descending. + The frontend groups items by day using `createdAt`. + tags: [Activity] + security: + - BearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + responses: + '200': + description: Paginated activity feed. + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: object + required: [rows, total, page, pageSize] + properties: + rows: + type: array + items: + $ref: '#/components/schemas/ActivityEntry' + total: + type: integer + example: 142 + page: + type: integer + example: 1 + pageSize: + type: integer + example: 25 + example: + data: + rows: + - id: '9182736' + stewardshipId: '4501' + packagePurl: pkg:npm/minimist@1.2.6 + packageName: minimist + packageEcosystem: npm + actorUserId: auth0|abc123 + actorName: null + actorType: user + activityType: escalation + content: 'Escalated with resolution path: right_of_first_refusal' + metadata: + resolutionPath: right_of_first_refusal + notes: Reached out to maintainer, awaiting response + stewardshipStatus: escalated + createdAt: '2024-06-15T10:23:00Z' + total: 142 + page: 1 + pageSize: 25 + '400': + description: Invalid query parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' diff --git a/backend/src/api/ossprey/packageList.ts b/backend/src/api/ossprey/packageList.ts new file mode 100644 index 0000000000..79c7451195 --- /dev/null +++ b/backend/src/api/ossprey/packageList.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' + +import { computeHealthBand, listPackagesForApi } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { validateOrThrow } from '@/utils/validation' + +const MAX_PAGE_SIZE = 250 + +const boolParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(25), + ecosystem: z.string().trim().optional(), + lifecycle: z.enum(['active', 'stable', 'declining', 'abandoned']).optional(), + name: z.string().trim().optional(), + status: z + .enum([ + 'unassigned', + 'open', + 'assessing', + 'active', + 'needs_attention', + 'escalated', + 'blocked', + 'inactive', + ]) + .optional(), + healthBand: z.enum(['healthy', 'fair', 'concerning', 'critical']).optional(), + vulnSeverity: z.enum(['any', 'high', 'critical', 'none']).optional(), + staleOnly: boolParam, + unstewardedOnly: boolParam, + busFactor1Only: boolParam, + sortBy: z.enum(['name', 'risk', 'impact', 'openVulns', 'health']).default('risk'), + sortDir: z.enum(['asc', 'desc']).default('desc'), +}) + +export default async (req, res) => { + const params = validateOrThrow(querySchema, req.query) + + const qx = await getPackagesQx() + const { rows, total } = await listPackagesForApi(qx, { + ...params, + includeStewards: true, + }) + + const mappedRows = rows.map((r) => ({ + purl: r.purl, + name: r.name, + ecosystem: r.ecosystem, + criticalityScore: r.criticalityScore, + stewardshipId: r.stewardshipId ?? null, + stewardshipStatus: r.stewardshipStatus ?? null, + openVulns: r.openVulns, + maxVulnSeverity: r.maxVulnSeverity ?? null, + maintainerCount: r.maintainerCount, + scorecardScore: r.scorecardScore, + healthBand: computeHealthBand(r.scorecardScore != null ? Number(r.scorecardScore) : null), + latestReleaseAt: r.latestReleaseAt ? r.latestReleaseAt.toISOString() : null, + lastActivity: r.lastActivityAt + ? { + type: r.lastActivityType, + content: r.lastActivityContent, + at: r.lastActivityAt.toISOString(), + } + : null, + stewards: r.stewards ?? [], + })) + + await req.responseHandler.success(req, res, { + rows: mappedRows, + total, + page: params.page, + pageSize: params.pageSize, + }) +} diff --git a/backend/src/api/ossprey/packageScatter.ts b/backend/src/api/ossprey/packageScatter.ts new file mode 100644 index 0000000000..204ea0f5b6 --- /dev/null +++ b/backend/src/api/ossprey/packageScatter.ts @@ -0,0 +1,13 @@ +import { listPackagesForScatter } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' + +export default async (req, res) => { + const qx = await getPackagesQx() + const points = await listPackagesForScatter(qx) + + await req.responseHandler.success(req, res, { + points, + total: points.length, + }) +} diff --git a/backend/src/api/public/v1/index.ts b/backend/src/api/public/v1/index.ts index 982d7d4bc0..7c33add17f 100644 --- a/backend/src/api/public/v1/index.ts +++ b/backend/src/api/public/v1/index.ts @@ -15,6 +15,7 @@ import { staticApiKeyMiddleware } from '../middlewares/staticApiKeyMiddleware' import { memberOrganizationAffiliationsRouter } from './affiliations' import { membersRouter } from './members' import { organizationsRouter } from './organizations' +import { osspreyRouter } from './ossprey' import { packagesRouter } from './packages' import { batchGetStewardship } from './packages/batchGetStewardship' import { stewardshipsRouter } from './stewardships' @@ -38,6 +39,7 @@ export function v1Router(): Router { ) router.use('/packages', oauth2Middleware(AUTH0_CONFIG), packagesRouter()) router.use('/stewardships', oauth2Middleware(AUTH0_CONFIG), stewardshipsRouter()) + router.use('/ossprey', oauth2Middleware(AUTH0_CONFIG), osspreyRouter()) router.use(() => { throw new NotFoundError() diff --git a/backend/src/api/public/v1/ossprey/activityFeed.ts b/backend/src/api/public/v1/ossprey/activityFeed.ts new file mode 100644 index 0000000000..d87185227e --- /dev/null +++ b/backend/src/api/public/v1/ossprey/activityFeed.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { listStewardshipActivity } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(25), +}) + +export async function activityFeedHandler(req: Request, res: Response): Promise { + const { page, pageSize } = validateOrThrow(querySchema, req.query) + + const qx = await getPackagesQx() + const { rows, total } = await listStewardshipActivity(qx, { page, pageSize }) + + ok(res, { + rows: rows.map((r) => ({ + id: r.id, + stewardshipId: r.stewardshipId, + packagePurl: r.packagePurl, + packageName: r.packageName, + packageEcosystem: r.packageEcosystem, + actorUserId: r.actorUserId, + actorName: r.actorUserId, // TODO: resolve display name from crowd.dev users/members table by actorUserId + actorType: r.actorType, + activityType: r.activityType, + content: r.content, + metadata: r.metadata, + stewardshipStatus: r.stewardshipStatus, + createdAt: r.createdAt, + })), + total, + page, + pageSize, + }) +} diff --git a/backend/src/api/public/v1/ossprey/index.ts b/backend/src/api/public/v1/ossprey/index.ts new file mode 100644 index 0000000000..d5b3bc8fb6 --- /dev/null +++ b/backend/src/api/public/v1/ossprey/index.ts @@ -0,0 +1,20 @@ +import { Router } from 'express' + +import { safeWrap } from '@/middlewares/errorMiddleware' + +import { activityFeedHandler } from './activityFeed' +import { metricsHandler } from './metrics' +import { packageListHandler } from './packageList' +import { packageScatterHandler } from './packageScatter' + +export function osspreyRouter(): Router { + const router = Router() + + router.get('/metrics', safeWrap(metricsHandler)) + // /packages/scatter must be registered before /packages to avoid Express treating 'scatter' as a path param + router.get('/packages/scatter', safeWrap(packageScatterHandler)) + router.get('/packages', safeWrap(packageListHandler)) + router.get('/activity', safeWrap(activityFeedHandler)) + + return router +} diff --git a/backend/src/api/public/v1/ossprey/metrics.ts b/backend/src/api/public/v1/ossprey/metrics.ts new file mode 100644 index 0000000000..6b0aa4a429 --- /dev/null +++ b/backend/src/api/public/v1/ossprey/metrics.ts @@ -0,0 +1,12 @@ +import type { Request, Response } from 'express' + +import { getOsspreyMetrics } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' + +export async function metricsHandler(req: Request, res: Response): Promise { + const qx = await getPackagesQx() + const metrics = await getOsspreyMetrics(qx) + ok(res, metrics) +} diff --git a/backend/src/api/public/v1/ossprey/packageList.ts b/backend/src/api/public/v1/ossprey/packageList.ts new file mode 100644 index 0000000000..6247a758d1 --- /dev/null +++ b/backend/src/api/public/v1/ossprey/packageList.ts @@ -0,0 +1,93 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { + computeHealthBand, + getPackageStatusCounts, + listPackagesForApi, +} from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const MAX_PAGE_SIZE = 250 + +const boolParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(25), + ecosystem: z.string().trim().optional(), + lifecycle: z.enum(['active', 'stable', 'declining', 'abandoned']).optional(), + name: z.string().trim().optional(), + status: z + .enum([ + 'unassigned', + 'open', + 'assessing', + 'active', + 'needs_attention', + 'escalated', + 'blocked', + 'inactive', + ]) + .optional(), + healthBand: z.enum(['healthy', 'fair', 'concerning', 'critical']).optional(), + vulnSeverity: z.enum(['any', 'high', 'critical', 'none']).optional(), + staleOnly: boolParam, + unstewardedOnly: boolParam, + busFactor1Only: boolParam, + sortBy: z.enum(['name', 'risk', 'impact', 'openVulns', 'health']).default('risk'), + sortDir: z.enum(['asc', 'desc']).default('desc'), +}) + +export async function packageListHandler(req: Request, res: Response): Promise { + const params = validateOrThrow(querySchema, req.query) + + const filterOpts = { + ecosystem: params.ecosystem, + lifecycle: params.lifecycle, + name: params.name, + healthBand: params.healthBand, + vulnSeverity: params.vulnSeverity, + staleOnly: params.staleOnly, + unstewardedOnly: params.unstewardedOnly, + busFactor1Only: params.busFactor1Only, + } + + const qx = await getPackagesQx() + const [{ rows, total }, statusCounts] = await Promise.all([ + listPackagesForApi(qx, { ...params, includeStewards: true, includeLastActivity: true }), + getPackageStatusCounts(qx, filterOpts), + ]) + + ok(res, { + rows: rows.map((r) => ({ + purl: r.purl, + name: r.name, + ecosystem: r.ecosystem, + criticalityScore: r.criticalityScore, + stewardshipId: r.stewardshipId ?? null, + stewardshipStatus: r.stewardshipStatus ?? null, + openVulns: r.openVulns, + maxVulnSeverity: r.maxVulnSeverity ?? null, + maintainerCount: r.maintainerCount, + scorecardScore: r.scorecardScore, + healthBand: computeHealthBand(r.scorecardScore != null ? Number(r.scorecardScore) : null), + latestReleaseAt: r.latestReleaseAt ? r.latestReleaseAt.toISOString() : null, + lastActivity: r.lastActivityAt + ? { + type: r.lastActivityType, + content: r.lastActivityContent, + at: r.lastActivityAt.toISOString(), + } + : null, + stewards: r.stewards ?? [], + })), + total, + page: params.page, + pageSize: params.pageSize, + statusCounts, + }) +} diff --git a/backend/src/api/public/v1/ossprey/packageScatter.ts b/backend/src/api/public/v1/ossprey/packageScatter.ts new file mode 100644 index 0000000000..89963d79c1 --- /dev/null +++ b/backend/src/api/public/v1/ossprey/packageScatter.ts @@ -0,0 +1,12 @@ +import type { Request, Response } from 'express' + +import { listPackagesForScatter } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' + +export async function packageScatterHandler(req: Request, res: Response): Promise { + const qx = await getPackagesQx() + const points = await listPackagesForScatter(qx) + ok(res, { points, total: points.length }) +} diff --git a/backend/src/api/public/v1/packages/listPackages.ts b/backend/src/api/public/v1/packages/listPackages.ts index 8cc47e1380..08bf82361c 100644 --- a/backend/src/api/public/v1/packages/listPackages.ts +++ b/backend/src/api/public/v1/packages/listPackages.ts @@ -26,7 +26,7 @@ const stewardshipStatusValues = [ 'inactive', ] as const const healthBandValues = ['healthy', 'fair', 'concerning', 'critical'] as const -const vulnSeverityValues = ['any', 'high', 'critical'] as const +const vulnSeverityValues = ['any', 'high', 'critical', 'none'] as const const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index f3e3a0b093..63ff4d9a3f 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -49,6 +49,70 @@ export async function getPackagesByStewardshipPurls( ) } +export interface OsspreyMetrics { + totalPackages: number + criticalPackages: number + coveragePercent: number + coverageTrend: number | null + activeStewards: number + unassignedCritical: number + needsAttention: number + escalated: number +} + +export async function getOsspreyMetrics(qx: QueryExecutor): Promise { + const [counts, stewardRow]: [ + { + totalPackages: string + criticalPackages: string + covered: string + needsAttention: string + escalated: string + unassignedCritical: string + }, + { count: string }, + ] = await Promise.all([ + qx.selectOne(` + SELECT + COUNT(*)::text AS "totalPackages", + COUNT(*) FILTER (WHERE p.is_critical = true)::text AS "criticalPackages", + COUNT(*) FILTER (WHERE p.is_critical = true AND s.status IN ('assessing','active','needs_attention'))::text AS covered, + COUNT(*) FILTER (WHERE p.is_critical = true AND s.status = 'needs_attention')::text AS "needsAttention", + COUNT(*) FILTER (WHERE p.is_critical = true AND s.status = 'escalated')::text AS escalated, + COUNT(*) FILTER (WHERE p.is_critical = true AND (s.status = 'unassigned' OR s.id IS NULL))::text AS "unassignedCritical" + FROM packages p + LEFT JOIN stewardships s ON s.package_id = p.id + `), + qx.selectOne(` + SELECT COUNT(DISTINCT ss.user_id)::text AS count + FROM stewardship_stewards ss + JOIN stewardships s ON s.id = ss.stewardship_id + WHERE ss.deleted_at IS NULL + AND s.status != 'inactive' + `), + ]) + + const critical = parseInt(counts.criticalPackages, 10) + const covered = parseInt(counts.covered, 10) + + return { + totalPackages: parseInt(counts.totalPackages, 10), + criticalPackages: critical, + coveragePercent: critical > 0 ? Math.round((covered / critical) * 1000) / 10 : 0, + coverageTrend: null, // TODO: requires snapshot mechanism or stewardship_activity timestamp analysis + activeStewards: parseInt(stewardRow.count, 10), + unassignedCritical: parseInt(counts.unassignedCritical, 10), + needsAttention: parseInt(counts.needsAttention, 10), + escalated: parseInt(counts.escalated, 10), + } +} + +export interface StewardEntry { + userId: string + role: string + assignedAt: string +} + export interface PackageListRow { purl: string name: string @@ -57,13 +121,26 @@ export interface PackageListRow { stewardshipId: string | null stewardshipStatus: string | null openVulns: number + maxVulnSeverity: 'critical' | 'high' | 'medium' | 'low' | null maintainerCount: number scorecardScore: number | null + latestReleaseAt: Date | null + lastActivityType: string | null + lastActivityContent: string | null + lastActivityAt: Date | null + stewards?: StewardEntry[] total: string } export type HealthBand = 'healthy' | 'fair' | 'concerning' | 'critical' -export type VulnSeverityFilter = 'any' | 'high' | 'critical' +export type VulnSeverityFilter = 'any' | 'high' | 'critical' | 'none' + +export function computeHealthBand(scorecardScore: number | null): HealthBand { + if (scorecardScore === null || scorecardScore < 3.0) return 'critical' + if (scorecardScore < 5.0) return 'concerning' + if (scorecardScore < 7.0) return 'fair' + return 'healthy' +} export interface ListPackagesOptions { page: number @@ -77,6 +154,8 @@ export interface ListPackagesOptions { staleOnly: boolean unstewardedOnly: boolean busFactor1Only: boolean + includeStewards?: boolean + includeLastActivity?: boolean sortBy: 'name' | 'impact' | 'openVulns' | 'health' | 'risk' sortDir: 'asc' | 'desc' } @@ -160,6 +239,8 @@ export async function getPackageStatusCounts( if (opts.vulnSeverity) { if (opts.vulnSeverity === 'any') { conditions.push('ap_counts.cnt > 0') + } else if (opts.vulnSeverity === 'none') { + conditions.push('ap_counts.cnt = 0') } else if (opts.vulnSeverity === 'high') { conditions.push('ap_severity.max_rank >= 3') } else { @@ -292,8 +373,9 @@ export async function listPackagesForApi( if (opts.vulnSeverity) { if (opts.vulnSeverity === 'any') { conditions.push('ap_counts.cnt > 0') + } else if (opts.vulnSeverity === 'none') { + conditions.push('ap_counts.cnt = 0') } else if (opts.vulnSeverity === 'high') { - // high includes packages where worst severity is HIGH or CRITICAL conditions.push('ap_severity.max_rank >= 3') } else { // critical: worst severity is CRITICAL only @@ -345,6 +427,23 @@ export async function listPackagesForApi( // Shared LATERAL clauses — included in both the main query and the count fallback // so that WHERE conditions referencing them work in both paths. + const stewardsLateral = + opts.includeStewards === true + ? ` + LEFT JOIN LATERAL ( + SELECT COALESCE( + json_agg( + json_build_object('userId', ss.user_id, 'role', ss.role, 'assignedAt', ss.assigned_at) + ORDER BY ss.assigned_at ASC + ) FILTER (WHERE ss.id IS NOT NULL), + '[]'::json + ) AS stewards + FROM stewardship_stewards ss + WHERE ss.stewardship_id = s.id + AND ss.deleted_at IS NULL + ) ss_agg ON true` + : '' + const laterals = ` LEFT JOIN stewardships s ON s.package_id = p.id LEFT JOIN LATERAL ( @@ -366,7 +465,20 @@ export async function listPackagesForApi( WHERE pr.package_id = p.id ORDER BY pr.confidence DESC LIMIT 1 - ) r_sc ON true` + ) r_sc ON true + ${stewardsLateral} + ${ + opts.includeLastActivity === true + ? ` + LEFT JOIN LATERAL ( + SELECT sa.activity_type, sa.content, sa.created_at + FROM stewardship_activity sa + WHERE sa.stewardship_id = s.id + ORDER BY sa.created_at DESC + LIMIT 1 + ) last_act ON true` + : '' + }` const rows: PackageListRow[] = await qx.select( ` @@ -378,8 +490,18 @@ export async function listPackagesForApi( s.id::text AS "stewardshipId", s.status AS "stewardshipStatus", COALESCE(ap_counts.cnt, 0) AS "openVulns", + CASE ap_severity.max_rank + WHEN 4 THEN 'critical' + WHEN 3 THEN 'high' + WHEN 2 THEN 'medium' + WHEN 1 THEN 'low' + ELSE NULL + END AS "maxVulnSeverity", pm_counts.cnt AS "maintainerCount", r_sc.scorecard_score AS "scorecardScore", + p.latest_release_at AS "latestReleaseAt", + ${opts.includeLastActivity === true ? `last_act.activity_type AS "lastActivityType", last_act.content AS "lastActivityContent", last_act.created_at AS "lastActivityAt",` : ''} + ${opts.includeStewards === true ? "COALESCE(ss_agg.stewards, '[]'::json) AS stewards," : ''} COUNT(*) OVER() AS total FROM packages p ${laterals} @@ -517,6 +639,66 @@ export async function getPackageDetailByPurl( ) } +export interface ScatterPoint { + purl: string + name: string + criticalityScore: number + healthScore: number + healthBand: HealthBand + stewardshipStatus: string | null + stewardshipId: string | null + openVulns: number +} + +export async function listPackagesForScatter(qx: QueryExecutor): Promise { + const rows: Array<{ + purl: string + name: string + criticalityScore: number + healthScore: number + scorecardScoreRaw: number | null + stewardshipId: string | null + stewardshipStatus: string | null + openVulns: number + }> = await qx.select(` + SELECT + p.purl, + p.name, + ROUND(COALESCE(p.impact, 0) * 100)::int AS "criticalityScore", + ROUND(COALESCE(r_sc.scorecard_score, 0) * 10)::int AS "healthScore", + r_sc.scorecard_score AS "scorecardScoreRaw", + s.id::text AS "stewardshipId", + s.status AS "stewardshipStatus", + COALESCE(ap_counts.cnt, 0) AS "openVulns" + FROM packages p + LEFT JOIN stewardships s ON s.package_id = p.id + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id + ) ap_counts ON true + LEFT JOIN LATERAL ( + SELECT r.scorecard_score + FROM package_repos pr + JOIN repos r ON r.id = pr.repo_id + WHERE pr.package_id = p.id + ORDER BY pr.confidence DESC + LIMIT 1 + ) r_sc ON true + WHERE p.is_critical = true + ORDER BY p.purl ASC + `) + + return rows.map((r) => ({ + purl: r.purl, + name: r.name, + criticalityScore: r.criticalityScore, + healthScore: r.healthScore, + healthBand: computeHealthBand(r.scorecardScoreRaw), + stewardshipStatus: r.stewardshipStatus ?? null, + stewardshipId: r.stewardshipId ?? null, + openVulns: r.openVulns, + })) +} + export async function getAdvisoriesByPackageId( qx: QueryExecutor, packageId: string, diff --git a/services/libs/data-access-layer/src/osspckgs/stewardships.ts b/services/libs/data-access-layer/src/osspckgs/stewardships.ts index 5b9760e10d..56b82366f8 100644 --- a/services/libs/data-access-layer/src/osspckgs/stewardships.ts +++ b/services/libs/data-access-layer/src/osspckgs/stewardships.ts @@ -309,6 +309,84 @@ export async function getStewardshipSummary( } } +export interface ActivityFeedRow { + id: string + stewardshipId: string + packagePurl: string + packageName: string + packageEcosystem: string + actorUserId: string | null + // TODO: join actor display name from crowd.dev users/members table (actor_user_id is an Auth0 ID stored in packages DB) + actorType: string + activityType: string + content: string | null + metadata: Record | null + stewardshipStatus: string + createdAt: string + total: string +} + +export async function listStewardshipActivity( + qx: QueryExecutor, + opts: { page: number; pageSize: number }, +): Promise<{ rows: Omit[]; total: number }> { + const rows: ActivityFeedRow[] = await qx.select( + ` + SELECT + sa.id::text AS id, + sa.stewardship_id::text AS "stewardshipId", + p.purl AS "packagePurl", + p.name AS "packageName", + p.ecosystem AS "packageEcosystem", + sa.actor_user_id AS "actorUserId", + sa.actor_type AS "actorType", + sa.activity_type AS "activityType", + sa.content AS content, + sa.metadata AS metadata, + s.status AS "stewardshipStatus", + sa.created_at AS "createdAt", + COUNT(*) OVER()::text AS total + FROM stewardship_activity sa + JOIN stewardships s ON s.id = sa.stewardship_id + JOIN packages p ON p.id = s.package_id + ORDER BY sa.created_at DESC + LIMIT $(limit) OFFSET $(offset) + `, + { limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize }, + ) + + let total: number + if (rows.length > 0) { + total = parseInt(rows[0].total, 10) + } else { + const countRow: { count: string } = await qx.selectOne( + `SELECT COUNT(*)::text AS count + FROM stewardship_activity sa + JOIN stewardships s ON s.id = sa.stewardship_id + JOIN packages p ON p.id = s.package_id`, + ) + total = parseInt(countRow.count, 10) + } + + return { + rows: rows.map((row) => ({ + id: row.id, + stewardshipId: row.stewardshipId, + packagePurl: row.packagePurl, + packageName: row.packageName, + packageEcosystem: row.packageEcosystem, + actorUserId: row.actorUserId, + actorType: row.actorType, + activityType: row.activityType, + content: row.content, + metadata: row.metadata as Record | null, + stewardshipStatus: row.stewardshipStatus, + createdAt: toIso(row.createdAt), + })), + total, + } +} + export const ESCALATION_RESOLUTION_PATHS = [ 'right_of_first_refusal', 'replace_the_dependency', From 4bbcdee3c150cf46d2bea03981f2fbc9cef8bfe1 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 16 Jun 2026 11:57:00 +0200 Subject: [PATCH 2/5] fix: code review Signed-off-by: Umberto Sgueglia --- backend/src/api/ossprey/activityFeed.ts | 39 -- backend/src/api/ossprey/index.ts | 9 - backend/src/api/ossprey/metrics.ts | 9 - backend/src/api/ossprey/openapi.yaml | 645 ------------------ backend/src/api/ossprey/packageList.ts | 77 --- backend/src/api/ossprey/packageScatter.ts | 13 - .../data-access-layer/src/osspckgs/api.ts | 8 +- 7 files changed, 5 insertions(+), 795 deletions(-) delete mode 100644 backend/src/api/ossprey/activityFeed.ts delete mode 100644 backend/src/api/ossprey/index.ts delete mode 100644 backend/src/api/ossprey/metrics.ts delete mode 100644 backend/src/api/ossprey/openapi.yaml delete mode 100644 backend/src/api/ossprey/packageList.ts delete mode 100644 backend/src/api/ossprey/packageScatter.ts diff --git a/backend/src/api/ossprey/activityFeed.ts b/backend/src/api/ossprey/activityFeed.ts deleted file mode 100644 index e4556113d2..0000000000 --- a/backend/src/api/ossprey/activityFeed.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod' - -import { listStewardshipActivity } from '@crowd/data-access-layer' - -import { getPackagesQx } from '@/db/packagesDb' -import { validateOrThrow } from '@/utils/validation' - -const querySchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - pageSize: z.coerce.number().int().min(1).max(100).default(25), -}) - -export default async (req, res) => { - const { page, pageSize } = validateOrThrow(querySchema, req.query) - - const qx = await getPackagesQx() - const { rows, total } = await listStewardshipActivity(qx, { page, pageSize }) - - await req.responseHandler.success(req, res, { - rows: rows.map((r) => ({ - id: r.id, - stewardshipId: r.stewardshipId, - packagePurl: r.packagePurl, - packageName: r.packageName, - packageEcosystem: r.packageEcosystem, - actorUserId: r.actorUserId, - actorName: null, // TODO: resolve display name from crowd.dev users/members table by actorUserId - actorType: r.actorType, - activityType: r.activityType, - content: r.content, - metadata: r.metadata, - stewardshipStatus: r.stewardshipStatus, - createdAt: r.createdAt, - })), - total, - page, - pageSize, - }) -} diff --git a/backend/src/api/ossprey/index.ts b/backend/src/api/ossprey/index.ts deleted file mode 100644 index fabadf9cbe..0000000000 --- a/backend/src/api/ossprey/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { safeWrap } from '../../middlewares/errorMiddleware' - -export default (app) => { - app.get('/ossprey/metrics', safeWrap(require('./metrics').default)) - // /packages/scatter must be registered before /packages to avoid Express treating 'scatter' as a path param - app.get('/ossprey/packages/scatter', safeWrap(require('./packageScatter').default)) - app.get('/ossprey/packages', safeWrap(require('./packageList').default)) - app.get('/ossprey/activity', safeWrap(require('./activityFeed').default)) -} diff --git a/backend/src/api/ossprey/metrics.ts b/backend/src/api/ossprey/metrics.ts deleted file mode 100644 index 2b0bbfac9f..0000000000 --- a/backend/src/api/ossprey/metrics.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getOsspreyMetrics } from '@crowd/data-access-layer' - -import { getPackagesQx } from '@/db/packagesDb' - -export default async (req, res) => { - const qx = await getPackagesQx() - const metrics = await getOsspreyMetrics(qx) - await req.responseHandler.success(req, res, metrics) -} diff --git a/backend/src/api/ossprey/openapi.yaml b/backend/src/api/ossprey/openapi.yaml deleted file mode 100644 index 4f90fc7451..0000000000 --- a/backend/src/api/ossprey/openapi.yaml +++ /dev/null @@ -1,645 +0,0 @@ -openapi: 3.1.0 -info: - title: OSSPREY Admin API - version: 1.0.0 - description: > - Internal endpoints for the OSSPREY V2 Admin Dashboard. - - - **Authentication:** Bearer token (Auth0 JWT) in the `Authorization` header. - All endpoints require a valid session token from the crowd.dev backend. - - - **Base path:** All routes are prefixed with `/ossprey/`. - -servers: - - url: https://cm.lfx.dev - description: Production - - url: https://lf-staging.crowd.dev - description: Staging - - url: http://localhost:8080 - description: Local - -tags: - - name: Metrics - description: Aggregate KPI metrics for the dashboard header. - - name: Packages - description: Package list, scatter plot data. - - name: Activity - description: Stewardship activity feed. - -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - - schemas: - Error: - type: object - required: [error] - properties: - error: - type: object - required: [code, message] - properties: - code: - type: string - example: BAD_REQUEST - message: - type: string - example: Invalid query parameter. - - StewardshipStatus: - type: string - enum: - - unassigned - - open - - assessing - - active - - needs_attention - - escalated - - blocked - - inactive - - HealthBand: - type: string - enum: [healthy, fair, concerning, critical] - - VulnSeverity: - type: string - enum: [critical, high, medium, low] - - Steward: - type: object - required: [userId, role, assignedAt] - properties: - userId: - type: string - description: Auth0 sub of the assigned steward. - example: auth0|abc123 - role: - type: string - enum: [lead, co_steward] - assignedAt: - type: string - format: date-time - - LastActivity: - type: object - required: [type, at] - properties: - type: - type: string - description: activity_type enum value. - example: escalation - content: - type: - - string - - 'null' - example: 'Escalated with resolution path: right_of_first_refusal' - at: - type: string - format: date-time - - PackageRow: - type: object - required: - - purl - - name - - ecosystem - - openVulns - - maintainerCount - - stewards - properties: - purl: - type: string - example: pkg:npm/lodash@4.17.21 - name: - type: string - example: lodash - ecosystem: - type: string - enum: [npm, maven] - criticalityScore: - type: - - number - - 'null' - description: packages.impact (0–1 float). Multiply × 100 for display. - example: 0.94 - stewardshipId: - type: - - string - - 'null' - example: '4501' - stewardshipStatus: - oneOf: - - $ref: '#/components/schemas/StewardshipStatus' - - type: 'null' - openVulns: - type: integer - example: 2 - maxVulnSeverity: - oneOf: - - $ref: '#/components/schemas/VulnSeverity' - - type: 'null' - description: Worst advisory severity for this package. Null if no advisories. - maintainerCount: - type: integer - example: 1 - scorecardScore: - type: - - number - - 'null' - description: OpenSSF Scorecard score (0–10). Multiply × 10 for display. - example: 5.2 - healthBand: - oneOf: - - $ref: '#/components/schemas/HealthBand' - - type: 'null' - latestReleaseAt: - type: - - string - - 'null' - format: date-time - lastActivity: - oneOf: - - $ref: '#/components/schemas/LastActivity' - - type: 'null' - stewards: - type: array - items: - $ref: '#/components/schemas/Steward' - - ScatterPoint: - type: object - required: - - purl - - name - - criticalityScore - - healthScore - - healthBand - - openVulns - - advisoryCount - properties: - purl: - type: string - example: pkg:npm/lodash@4.17.21 - name: - type: string - example: lodash - criticalityScore: - type: integer - description: 0–100 integer (impact × 100, rounded). Y-axis position. - example: 94 - healthScore: - type: integer - description: 0–100 integer (scorecardScore × 10, rounded). X-axis position. - example: 52 - healthBand: - $ref: '#/components/schemas/HealthBand' - stewardshipStatus: - oneOf: - - $ref: '#/components/schemas/StewardshipStatus' - - type: 'null' - stewardshipId: - type: - - string - - 'null' - example: '4501' - openVulns: - type: integer - example: 0 - advisoryCount: - type: integer - description: Same as openVulns — explicit alias for tooltip display. - example: 0 - - ActivityEntry: - type: object - required: - - id - - stewardshipId - - packagePurl - - packageName - - packageEcosystem - - actorType - - activityType - - stewardshipStatus - - createdAt - properties: - id: - type: string - example: '9182736' - stewardshipId: - type: string - example: '4501' - packagePurl: - type: string - example: pkg:npm/minimist@1.2.6 - packageName: - type: string - example: minimist - packageEcosystem: - type: string - enum: [npm, maven] - actorUserId: - type: - - string - - 'null' - description: Auth0 sub of the actor. Null for system events. - example: auth0|abc123 - actorName: - type: - - string - - 'null' - description: Display name of the actor. Null — resolution from users table pending. - example: null - actorType: - type: string - enum: [user, system] - activityType: - type: string - enum: - - state_changed - - assessment_completed - - assessment_flagged - - remediation_logged - - status_update - - escalation - - escalation_resolved - - blocker_added - - blocker_resolved - - steward_added - - steward_removed - content: - type: - - string - - 'null' - example: 'Escalated with resolution path: right_of_first_refusal' - metadata: - type: - - object - - 'null' - additionalProperties: true - stewardshipStatus: - $ref: '#/components/schemas/StewardshipStatus' - createdAt: - type: string - format: date-time - -# ────────────────────────────────────────────────────────────────────────────── -# Paths -# ────────────────────────────────────────────────────────────────────────────── -paths: - /ossprey/metrics: - get: - operationId: getOsspreyMetrics - summary: KPI bar + Overview summary panel metrics - tags: [Metrics] - security: - - BearerAuth: [] - responses: - '200': - description: Aggregate metrics for the dashboard. - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - type: object - required: - - totalPackages - - criticalPackages - - coveragePercent - - activeStewards - - unassignedCritical - - needsAttention - - escalated - properties: - totalPackages: - type: integer - example: 24381 - criticalPackages: - type: integer - example: 1842 - coveragePercent: - type: number - format: float - description: Percentage of critical packages with active stewardship (1 decimal). - example: 33.2 - coverageTrend: - type: - - number - - 'null' - description: Percentage-point delta vs 30 days ago. Null until snapshot mechanism is in place. - example: null - activeStewards: - type: integer - example: 612 - unassignedCritical: - type: integer - example: 1230 - needsAttention: - type: integer - example: 47 - escalated: - type: integer - example: 8 - '401': - description: Missing or invalid bearer token. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - /ossprey/packages: - get: - operationId: listOsspreyPackages - summary: Paginated package list — Queue tab and Triage Board columns - description: > - Returns a filtered, sorted, paginated list of critical packages with their - stewardship state. Used by the Queue tab and by the Triage Board (one request - per status column, fired in parallel). - tags: [Packages] - security: - - BearerAuth: [] - parameters: - - name: page - in: query - schema: - type: integer - minimum: 1 - default: 1 - - name: pageSize - in: query - schema: - type: integer - minimum: 1 - maximum: 250 - default: 25 - - name: name - in: query - description: Case-insensitive substring search on package name. - schema: - type: string - example: lodash - - name: ecosystem - in: query - schema: - type: string - enum: [npm, maven] - - name: lifecycle - in: query - schema: - type: string - enum: [active, stable, declining, abandoned] - - name: status - in: query - description: > - Filter by stewardship status. `unassigned` includes packages with no - stewardship row. - schema: - $ref: '#/components/schemas/StewardshipStatus' - - name: healthBand - in: query - schema: - $ref: '#/components/schemas/HealthBand' - - name: vulnSeverity - in: query - description: > - `any` = at least one advisory; `high` = worst severity ≥ HIGH; - `critical` = worst severity = CRITICAL; `none` = zero advisories. - schema: - type: string - enum: [any, high, critical, none] - - name: staleOnly - in: query - schema: - type: boolean - default: false - description: Return only packages with no release in ≥ 18 months. - - name: unstewardedOnly - in: query - schema: - type: boolean - default: false - - name: busFactor1Only - in: query - schema: - type: boolean - default: false - - name: sortBy - in: query - schema: - type: string - enum: [risk, impact, openVulns, health, name] - default: risk - - name: sortDir - in: query - schema: - type: string - enum: [asc, desc] - default: desc - responses: - '200': - description: Paginated package list. - content: - application/json: - schema: - type: object - required: [data] - properties: - data: - type: object - required: [rows, total, page, pageSize] - properties: - rows: - type: array - items: - $ref: '#/components/schemas/PackageRow' - total: - type: integer - example: 1842 - page: - type: integer - example: 1 - pageSize: - type: integer - example: 25 - example: - data: - rows: - - purl: pkg:npm/lodash@4.17.21 - name: lodash - ecosystem: npm - criticalityScore: 0.94 - stewardshipId: '4501' - stewardshipStatus: needs_attention - openVulns: 2 - maxVulnSeverity: high - maintainerCount: 1 - scorecardScore: 5.2 - healthBand: fair - latestReleaseAt: '2022-01-14T00:00:00Z' - lastActivity: - type: escalation - content: 'Escalated with resolution path: right_of_first_refusal' - at: '2024-06-15T10:23:00Z' - stewards: - - userId: auth0|abc123 - role: lead - assignedAt: '2024-05-01T09:00:00Z' - total: 1842 - page: 1 - pageSize: 25 - '400': - description: Invalid query parameters. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '401': - description: Missing or invalid bearer token. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - /ossprey/packages/scatter: - get: - operationId: getOsspreyScatter - summary: Scatter plot data — Risk Matrix tab - description: > - Returns all critical packages as lightweight data points for the health-vs-impact - scatter plot. No pagination — returns everything in one response. - tags: [Packages] - security: - - BearerAuth: [] - responses: - '200': - description: All scatter plot points. - content: - application/json: - schema: - type: object - required: [data] - properties: - data: - type: object - required: [points, total] - properties: - points: - type: array - items: - $ref: '#/components/schemas/ScatterPoint' - total: - type: integer - example: 1842 - example: - data: - points: - - purl: pkg:npm/lodash@4.17.21 - name: lodash - criticalityScore: 94 - healthScore: 52 - healthBand: fair - stewardshipStatus: needs_attention - stewardshipId: '4501' - openVulns: 2 - advisoryCount: 2 - total: 1842 - '401': - description: Missing or invalid bearer token. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - /ossprey/activity: - get: - operationId: getOsspreyActivity - summary: Paginated activity feed — Overview tab - description: > - Returns recent stewardship events across all packages, sorted by date descending. - The frontend groups items by day using `createdAt`. - tags: [Activity] - security: - - BearerAuth: [] - parameters: - - name: page - in: query - schema: - type: integer - minimum: 1 - default: 1 - - name: pageSize - in: query - schema: - type: integer - minimum: 1 - maximum: 100 - default: 25 - responses: - '200': - description: Paginated activity feed. - content: - application/json: - schema: - type: object - required: [data] - properties: - data: - type: object - required: [rows, total, page, pageSize] - properties: - rows: - type: array - items: - $ref: '#/components/schemas/ActivityEntry' - total: - type: integer - example: 142 - page: - type: integer - example: 1 - pageSize: - type: integer - example: 25 - example: - data: - rows: - - id: '9182736' - stewardshipId: '4501' - packagePurl: pkg:npm/minimist@1.2.6 - packageName: minimist - packageEcosystem: npm - actorUserId: auth0|abc123 - actorName: null - actorType: user - activityType: escalation - content: 'Escalated with resolution path: right_of_first_refusal' - metadata: - resolutionPath: right_of_first_refusal - notes: Reached out to maintainer, awaiting response - stewardshipStatus: escalated - createdAt: '2024-06-15T10:23:00Z' - total: 142 - page: 1 - pageSize: 25 - '400': - description: Invalid query parameters. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '401': - description: Missing or invalid bearer token. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' diff --git a/backend/src/api/ossprey/packageList.ts b/backend/src/api/ossprey/packageList.ts deleted file mode 100644 index 79c7451195..0000000000 --- a/backend/src/api/ossprey/packageList.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from 'zod' - -import { computeHealthBand, listPackagesForApi } from '@crowd/data-access-layer' - -import { getPackagesQx } from '@/db/packagesDb' -import { validateOrThrow } from '@/utils/validation' - -const MAX_PAGE_SIZE = 250 - -const boolParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) - -const querySchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(25), - ecosystem: z.string().trim().optional(), - lifecycle: z.enum(['active', 'stable', 'declining', 'abandoned']).optional(), - name: z.string().trim().optional(), - status: z - .enum([ - 'unassigned', - 'open', - 'assessing', - 'active', - 'needs_attention', - 'escalated', - 'blocked', - 'inactive', - ]) - .optional(), - healthBand: z.enum(['healthy', 'fair', 'concerning', 'critical']).optional(), - vulnSeverity: z.enum(['any', 'high', 'critical', 'none']).optional(), - staleOnly: boolParam, - unstewardedOnly: boolParam, - busFactor1Only: boolParam, - sortBy: z.enum(['name', 'risk', 'impact', 'openVulns', 'health']).default('risk'), - sortDir: z.enum(['asc', 'desc']).default('desc'), -}) - -export default async (req, res) => { - const params = validateOrThrow(querySchema, req.query) - - const qx = await getPackagesQx() - const { rows, total } = await listPackagesForApi(qx, { - ...params, - includeStewards: true, - }) - - const mappedRows = rows.map((r) => ({ - purl: r.purl, - name: r.name, - ecosystem: r.ecosystem, - criticalityScore: r.criticalityScore, - stewardshipId: r.stewardshipId ?? null, - stewardshipStatus: r.stewardshipStatus ?? null, - openVulns: r.openVulns, - maxVulnSeverity: r.maxVulnSeverity ?? null, - maintainerCount: r.maintainerCount, - scorecardScore: r.scorecardScore, - healthBand: computeHealthBand(r.scorecardScore != null ? Number(r.scorecardScore) : null), - latestReleaseAt: r.latestReleaseAt ? r.latestReleaseAt.toISOString() : null, - lastActivity: r.lastActivityAt - ? { - type: r.lastActivityType, - content: r.lastActivityContent, - at: r.lastActivityAt.toISOString(), - } - : null, - stewards: r.stewards ?? [], - })) - - await req.responseHandler.success(req, res, { - rows: mappedRows, - total, - page: params.page, - pageSize: params.pageSize, - }) -} diff --git a/backend/src/api/ossprey/packageScatter.ts b/backend/src/api/ossprey/packageScatter.ts deleted file mode 100644 index 204ea0f5b6..0000000000 --- a/backend/src/api/ossprey/packageScatter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { listPackagesForScatter } from '@crowd/data-access-layer' - -import { getPackagesQx } from '@/db/packagesDb' - -export default async (req, res) => { - const qx = await getPackagesQx() - const points = await listPackagesForScatter(qx) - - await req.responseHandler.success(req, res, { - points, - total: points.length, - }) -} diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 63ff4d9a3f..24e29b9f97 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -125,9 +125,9 @@ export interface PackageListRow { maintainerCount: number scorecardScore: number | null latestReleaseAt: Date | null - lastActivityType: string | null - lastActivityContent: string | null - lastActivityAt: Date | null + lastActivityType?: string | null + lastActivityContent?: string | null + lastActivityAt?: Date | null stewards?: StewardEntry[] total: string } @@ -648,6 +648,7 @@ export interface ScatterPoint { stewardshipStatus: string | null stewardshipId: string | null openVulns: number + advisoryCount: number } export async function listPackagesForScatter(qx: QueryExecutor): Promise { @@ -696,6 +697,7 @@ export async function listPackagesForScatter(qx: QueryExecutor): Promise Date: Tue, 16 Jun 2026 12:45:16 +0200 Subject: [PATCH 3/5] fix: add limits Signed-off-by: Umberto Sgueglia --- .../src/api/public/v1/ossprey/openapi.yaml | 733 ++++++++++++++++++ .../data-access-layer/src/osspckgs/api.ts | 3 +- 2 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 backend/src/api/public/v1/ossprey/openapi.yaml diff --git a/backend/src/api/public/v1/ossprey/openapi.yaml b/backend/src/api/public/v1/ossprey/openapi.yaml new file mode 100644 index 0000000000..7f479fd77c --- /dev/null +++ b/backend/src/api/public/v1/ossprey/openapi.yaml @@ -0,0 +1,733 @@ +openapi: 3.1.0 +info: + title: CDP Public API — OSSPREY Admin Dashboard V2 + version: 1.0.0 + description: > + Read endpoints for the OSSPREY Admin Dashboard V2. + + + **Authentication:** OAuth 2.0 bearer token (Auth0 M2M or user session). + + + **V2 scope:** These endpoints cover the four dashboard tabs (Overview, Queue, + Triage Board, Risk Matrix) and the global KPI bar. + Package Detail Drawer and write actions are deferred to the next milestone. + +servers: + - url: https://cm.lfx.dev/api/v1 + description: Production + - url: https://lf-staging.crowd.dev/api/v1 + description: Staging + +tags: + - name: Dashboard + description: KPI metrics and activity feed (Overview tab). + - name: Packages + description: Package list and scatter plot (Queue, Triage Board, Risk Matrix tabs). + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + example: VALIDATION_ERROR + message: + type: string + example: Invalid query parameter. + + HealthBand: + type: string + enum: [healthy, fair, concerning, critical] + description: > + Server-derived from `scorecardScore` thresholds: + `null or < 3.0` → critical · `< 5.0` → concerning · `< 7.0` → fair · `≥ 7.0` → healthy + + StewardshipStatus: + type: string + enum: + - unassigned + - open + - assessing + - active + - needs_attention + - escalated + - blocked + - inactive + + Steward: + type: object + required: [userId, role, assignedAt] + properties: + userId: + type: string + description: Auth0 sub of the assigned steward. + example: auth0|abc123 + role: + type: string + enum: [lead, co_steward] + assignedAt: + type: string + format: date-time + + PackageRow: + type: object + required: + - purl + - name + - ecosystem + - openVulns + - maintainerCount + - healthBand + - stewards + properties: + purl: + type: string + example: pkg:npm/lodash@4.17.21 + name: + type: string + example: lodash + ecosystem: + type: string + example: npm + criticalityScore: + type: + - number + - 'null' + description: packages.impact (0–1 float). Multiply × 100 for display score. + example: 0.94 + stewardshipId: + type: + - string + - 'null' + example: '4501' + stewardshipStatus: + oneOf: + - $ref: '#/components/schemas/StewardshipStatus' + - type: 'null' + openVulns: + type: integer + description: Count of advisory_packages rows for this package. + example: 3 + maxVulnSeverity: + type: + - string + - 'null' + enum: [critical, high, medium, low] + description: Worst advisory severity. Null if no advisories. + maintainerCount: + type: integer + description: Count of package_maintainers rows. Bus factor proxy. + example: 2 + scorecardScore: + type: + - number + - 'null' + description: OpenSSF Scorecard score (0–10). Null if no repo mapped. + example: 5.2 + healthBand: + $ref: '#/components/schemas/HealthBand' + latestReleaseAt: + type: + - string + - 'null' + format: date-time + description: Used by the frontend to derive the stale flag (≥ 18 months). + lastActivity: + description: Most recent stewardship activity for this package. Null if none. + oneOf: + - type: object + required: [type, at] + properties: + type: + type: + - string + - 'null' + example: state_changed + content: + type: + - string + - 'null' + example: Moved to active stewardship + at: + type: string + format: date-time + - type: 'null' + stewards: + type: array + description: Active stewards (deleted_at IS NULL). Empty array if none. + items: + $ref: '#/components/schemas/Steward' + + StatusCounts: + type: object + description: Per-status package counts for the tab bar. Computed without the active status filter. + required: + - all + - unassigned + - open + - assessing + - active + - needs_attention + - escalated + - blocked + - inactive + properties: + all: + type: integer + unassigned: + type: integer + open: + type: integer + assessing: + type: integer + active: + type: integer + needs_attention: + type: integer + escalated: + type: integer + blocked: + type: integer + inactive: + type: integer + + ScatterPoint: + type: object + required: + - purl + - name + - criticalityScore + - healthScore + - healthBand + - openVulns + - advisoryCount + properties: + purl: + type: string + example: pkg:npm/lodash@4.17.21 + name: + type: string + example: lodash + criticalityScore: + type: integer + description: ROUND(p.impact × 100). Y-axis position (0–100). + example: 94 + healthScore: + type: integer + description: ROUND(scorecard_score × 10). X-axis position (0–100). 0 if no repo. + example: 52 + healthBand: + $ref: '#/components/schemas/HealthBand' + stewardshipStatus: + oneOf: + - $ref: '#/components/schemas/StewardshipStatus' + - type: 'null' + stewardshipId: + type: + - string + - 'null' + example: '4501' + openVulns: + type: integer + example: 3 + advisoryCount: + type: integer + description: Alias of openVulns — explicit field for tooltip display. + example: 3 + + ActivityFeedItem: + type: object + required: + - id + - stewardshipId + - packagePurl + - packageName + - packageEcosystem + - actorType + - activityType + - stewardshipStatus + - createdAt + properties: + id: + type: string + example: '9182736' + stewardshipId: + type: string + example: '4501' + packagePurl: + type: string + example: pkg:npm/minimist@1.2.6 + packageName: + type: string + example: minimist + packageEcosystem: + type: string + example: npm + actorUserId: + type: + - string + - 'null' + description: Auth0 sub. Null for system events. + example: auth0|abc123 + actorName: + type: + - string + - 'null' + description: > + Display name of the actor. Currently returns actorUserId as a + placeholder — will be resolved to a display name once the + cross-DB users join is implemented. + example: auth0|abc123 + actorType: + type: string + enum: [user, system] + activityType: + type: string + enum: + - state_changed + - steward_added + - steward_removed + - escalation + - escalation_resolved + - assessment_started + - assessment_completed + - assessment_flagged + - spot_check + - note_added + example: escalation + content: + type: + - string + - 'null' + example: "Escalated with resolution path: right_of_first_refusal" + metadata: + type: + - object + - 'null' + additionalProperties: true + stewardshipStatus: + $ref: '#/components/schemas/StewardshipStatus' + createdAt: + type: string + format: date-time + +# ────────────────────────────────────────────────────────────────────────────── +# Paths +# ────────────────────────────────────────────────────────────────────────────── +paths: + /ossprey/metrics: + get: + operationId: getOsspreyMetrics + summary: Global KPI bar metrics + description: > + Returns the seven aggregate metrics shown in the sticky KPI bar across all + four dashboard tabs. Fetch once on page load; refresh after any write action. + tags: + - Dashboard + security: + - BearerAuth: [] + responses: + '200': + description: KPI metrics. + content: + application/json: + schema: + type: object + required: + - totalPackages + - criticalPackages + - coveragePercent + - activeStewards + - unassignedCritical + - needsAttention + - escalated + properties: + totalPackages: + type: integer + description: Total critical packages (is_critical = true). + example: 1842 + criticalPackages: + type: integer + description: Critical packages with at least one critical-severity advisory. + example: 312 + coveragePercent: + type: number + format: float + description: > + Percentage of critical packages with status in + (assessing, active, needs_attention). One decimal place. + example: 33.2 + coverageTrend: + type: + - number + - 'null' + description: > + Percentage-point change vs. previous month. Null until + snapshot mechanism is implemented. + example: null + activeStewards: + type: integer + description: > + COUNT(DISTINCT user_id) from stewardship_stewards where + deleted_at IS NULL and stewardship status != inactive. + example: 42 + unassignedCritical: + type: integer + description: Critical packages with no stewardship row or status = unassigned. + example: 1230 + needsAttention: + type: integer + description: Critical packages with status = needs_attention. + example: 47 + escalated: + type: integer + description: Critical packages with status = escalated. + example: 8 + example: + totalPackages: 617024 + criticalPackages: 312 + coveragePercent: 0.1 + coverageTrend: null + activeStewards: 3 + unassignedCritical: 616021 + needsAttention: 1 + escalated: 1 + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /ossprey/activity: + get: + operationId: listStewardshipActivity + summary: Paginated stewardship activity feed + description: > + Returns recent stewardship events across all packages, ordered by most + recent first. Used to populate the activity feed on the Overview tab. + The frontend groups items by day using `createdAt`. + tags: + - Dashboard + security: + - BearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + responses: + '200': + description: Paginated activity feed. + content: + application/json: + schema: + type: object + required: [rows, total, page, pageSize] + properties: + rows: + type: array + items: + $ref: '#/components/schemas/ActivityFeedItem' + total: + type: integer + example: 142 + page: + type: integer + example: 1 + pageSize: + type: integer + example: 25 + example: + rows: + - id: '9182736' + stewardshipId: '4501' + packagePurl: pkg:maven/org.slf4j/slf4j-api + packageName: slf4j-api + packageEcosystem: maven + actorUserId: auth0|mock-user-alice + actorType: user + activityType: state_changed + content: Assessment complete, moving to active + metadata: + from: assessing + to: active + stewardshipStatus: active + createdAt: '2026-06-14T10:23:00Z' + total: 11 + page: 1 + pageSize: 25 + '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' + + /ossprey/packages: + get: + operationId: listOsspreyPackages + summary: Filtered paginated package list + description: > + Returns a paginated, filtered, sorted list of critical packages with their + stewardship state and risk signals. + + + Used by three tabs: + + - **Queue tab** — full table with all filters + + - **Triage Board** — one request per status column, fired in parallel + (`?status=X&pageSize=50`) + + - **Summary panel** — click-through navigates to Queue with pre-filled filter + + + The response always includes `statusCounts` — per-status counts computed + without the active `status` filter, used to drive the tab bar badge numbers. + tags: + - Packages + security: + - BearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 250 + default: 25 + - name: name + in: query + description: Case-insensitive substring search on package name. + schema: + type: string + - name: ecosystem + in: query + schema: + type: string + enum: [npm, maven, cargo, pypi] + - name: lifecycle + in: query + schema: + type: string + enum: [active, stable, declining, abandoned] + - name: status + in: query + description: > + Filter by stewardship status. `unassigned` includes packages with + no stewardship row (s.id IS NULL). + schema: + $ref: '#/components/schemas/StewardshipStatus' + - name: healthBand + in: query + schema: + $ref: '#/components/schemas/HealthBand' + - name: vulnSeverity + in: query + description: > + `any` = at least one open advisory · `high` = worst rank ≥ HIGH · + `critical` = worst rank = CRITICAL · `none` = zero advisories. + schema: + type: string + enum: [any, high, critical, none] + - name: staleOnly + in: query + description: Return only packages with no release in ≥ 18 months. + schema: + type: boolean + default: false + - name: unstewardedOnly + in: query + description: Return only packages with status = unassigned or no stewardship row. + schema: + type: boolean + default: false + - name: busFactor1Only + in: query + description: Return only packages with exactly one maintainer. + schema: + type: boolean + default: false + - name: sortBy + in: query + schema: + type: string + enum: [risk, name, impact, openVulns, health] + default: risk + - name: sortDir + in: query + schema: + type: string + enum: [asc, desc] + default: desc + responses: + '200': + description: Paginated package list. + content: + application/json: + schema: + type: object + required: [rows, total, page, pageSize, statusCounts] + properties: + rows: + type: array + items: + $ref: '#/components/schemas/PackageRow' + total: + type: integer + example: 1842 + page: + type: integer + example: 1 + pageSize: + type: integer + example: 25 + statusCounts: + $ref: '#/components/schemas/StatusCounts' + example: + rows: + - purl: pkg:maven/org.slf4j/slf4j-api + name: slf4j-api + ecosystem: maven + criticalityScore: 0.998 + stewardshipId: '101' + stewardshipStatus: active + openVulns: 0 + maxVulnSeverity: null + maintainerCount: 2 + scorecardScore: 7.5 + healthBand: healthy + latestReleaseAt: '2026-04-10T00:00:00Z' + lastActivity: + type: state_changed + content: Assessment complete, moving to active + at: '2026-06-01T10:00:00Z' + stewards: + - userId: auth0|mock-user-alice + role: lead + assignedAt: '2026-01-15T09:00:00Z' + total: 9 + page: 1 + pageSize: 25 + statusCounts: + all: 9 + unassigned: 1 + open: 1 + assessing: 1 + active: 2 + needs_attention: 1 + escalated: 1 + blocked: 1 + inactive: 1 + '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' + + /ossprey/packages/scatter: + get: + operationId: getOsspreyPackagesScatter + summary: Scatter plot data for the Risk Matrix tab + description: > + Returns all packages where `is_critical = true AND has_critical_vulnerability = true` + as lightweight data points for the health-vs-impact scatter plot. + No pagination — this filter set is expected to stay around 2 000 packages. + Ordered by `impact DESC`. + + + Dot color is determined by `stewardshipStatus`. Legend checkboxes toggle + visibility client-side — no additional API calls needed. + tags: + - Packages + security: + - BearerAuth: [] + responses: + '200': + description: Scatter plot data points. + content: + application/json: + schema: + type: object + required: [points, total] + properties: + points: + type: array + items: + $ref: '#/components/schemas/ScatterPoint' + total: + type: integer + description: > + Count of packages matching the filter + (is_critical = true AND has_critical_vulnerability = true). + Equals points.length — no separate count query. + example: 2000 + example: + points: + - purl: pkg:maven/org.slf4j/slf4j-api + name: slf4j-api + criticalityScore: 100 + healthScore: 75 + healthBand: healthy + stewardshipStatus: active + stewardshipId: '101' + openVulns: 0 + advisoryCount: 0 + - purl: pkg:maven/com.fasterxml.jackson.core/jackson-databind + name: jackson-databind + criticalityScore: 99 + healthScore: 0 + healthBand: critical + stewardshipStatus: needs_attention + stewardshipId: '102' + openVulns: 2 + advisoryCount: 2 + total: 2000 + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 24e29b9f97..0a08895ae2 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -685,7 +685,8 @@ export async function listPackagesForScatter(qx: QueryExecutor): Promise ({ From 91016a0ecfbed56a355a10ef347cae4b9272cb04 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 16 Jun 2026 12:47:36 +0200 Subject: [PATCH 4/5] fix: lint Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/ossprey/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/api/public/v1/ossprey/openapi.yaml b/backend/src/api/public/v1/ossprey/openapi.yaml index 7f479fd77c..b09e53bc5e 100644 --- a/backend/src/api/public/v1/ossprey/openapi.yaml +++ b/backend/src/api/public/v1/ossprey/openapi.yaml @@ -312,7 +312,7 @@ components: type: - string - 'null' - example: "Escalated with resolution path: right_of_first_refusal" + example: 'Escalated with resolution path: right_of_first_refusal' metadata: type: - object From 06fd0a8d2d77d0f7005330b13c38170202380ab0 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 16 Jun 2026 14:37:13 +0200 Subject: [PATCH 5/5] fix: comments Signed-off-by: Umberto Sgueglia --- .../src/api/public/v1/ossprey/openapi.yaml | 14 ++-- .../data-access-layer/src/osspckgs/api.ts | 81 ++++++++++--------- .../src/osspckgs/stewardships.ts | 2 +- 3 files changed, 51 insertions(+), 46 deletions(-) diff --git a/backend/src/api/public/v1/ossprey/openapi.yaml b/backend/src/api/public/v1/ossprey/openapi.yaml index b09e53bc5e..808532bfb2 100644 --- a/backend/src/api/public/v1/ossprey/openapi.yaml +++ b/backend/src/api/public/v1/ossprey/openapi.yaml @@ -397,14 +397,14 @@ paths: description: Critical packages with status = escalated. example: 8 example: - totalPackages: 617024 + totalPackages: 1842 criticalPackages: 312 - coveragePercent: 0.1 + coveragePercent: 33.2 coverageTrend: null - activeStewards: 3 - unassignedCritical: 616021 - needsAttention: 1 - escalated: 1 + activeStewards: 42 + unassignedCritical: 1230 + needsAttention: 47 + escalated: 8 '401': description: Missing or invalid bearer token. content: @@ -538,9 +538,9 @@ paths: type: string - name: ecosystem in: query + description: Filter by package ecosystem. Not validated server-side — any ecosystem stored in the DB is accepted. schema: type: string - enum: [npm, maven, cargo, pypi] - name: lifecycle in: query schema: diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 0a08895ae2..feb6c280f9 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -74,14 +74,15 @@ export async function getOsspreyMetrics(qx: QueryExecutor): Promise 0 ? Math.round((covered / critical) * 1000) / 10 : 0, + totalPackages: total, + criticalPackages: parseInt(counts.criticalPackages, 10), + coveragePercent: total > 0 ? Math.round((covered / total) * 1000) / 10 : 0, coverageTrend: null, // TODO: requires snapshot mechanism or stewardship_activity timestamp analysis activeStewards: parseInt(stewardRow.count, 10), unassignedCritical: parseInt(counts.unassignedCritical, 10), @@ -425,26 +426,8 @@ export async function listPackagesForApi( // Separate paginated params from filter-only params used by the fallback COUNT query const queryParams = { ...params, limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize } - // Shared LATERAL clauses — included in both the main query and the count fallback - // so that WHERE conditions referencing them work in both paths. - const stewardsLateral = - opts.includeStewards === true - ? ` - LEFT JOIN LATERAL ( - SELECT COALESCE( - json_agg( - json_build_object('userId', ss.user_id, 'role', ss.role, 'assignedAt', ss.assigned_at) - ORDER BY ss.assigned_at ASC - ) FILTER (WHERE ss.id IS NOT NULL), - '[]'::json - ) AS stewards - FROM stewardship_stewards ss - WHERE ss.stewardship_id = s.id - AND ss.deleted_at IS NULL - ) ss_agg ON true` - : '' - - const laterals = ` + // Laterals needed for WHERE filter conditions — included in both the main query and the COUNT fallback. + const filterLaterals = ` LEFT JOIN stewardships s ON s.package_id = p.id LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id @@ -465,11 +448,29 @@ export async function listPackagesForApi( WHERE pr.package_id = p.id ORDER BY pr.confidence DESC LIMIT 1 - ) r_sc ON true - ${stewardsLateral} - ${ - opts.includeLastActivity === true - ? ` + ) r_sc ON true` + + // Additional laterals for SELECT output only — not needed in the COUNT fallback. + const stewardsLateral = + opts.includeStewards === true + ? ` + LEFT JOIN LATERAL ( + SELECT COALESCE( + json_agg( + json_build_object('userId', ss.user_id, 'role', ss.role, 'assignedAt', ss.assigned_at) + ORDER BY ss.assigned_at ASC + ) FILTER (WHERE ss.id IS NOT NULL), + '[]'::json + ) AS stewards + FROM stewardship_stewards ss + WHERE ss.stewardship_id = s.id + AND ss.deleted_at IS NULL + ) ss_agg ON true` + : '' + + const lastActLateral = + opts.includeLastActivity === true + ? ` LEFT JOIN LATERAL ( SELECT sa.activity_type, sa.content, sa.created_at FROM stewardship_activity sa @@ -477,8 +478,11 @@ export async function listPackagesForApi( ORDER BY sa.created_at DESC LIMIT 1 ) last_act ON true` - : '' - }` + : '' + + const laterals = `${filterLaterals} + ${stewardsLateral} + ${lastActLateral}` const rows: PackageListRow[] = await qx.select( ` @@ -518,10 +522,11 @@ export async function listPackagesForApi( } else { // Window function returns no rows when the page is beyond the result set. // Fall back to a separate COUNT so the caller always gets the real total. + // Use filterLaterals (not laterals) — stewards/last_act laterals are SELECT-only and not needed here. const countRow: { count: string } = await qx.selectOne( `SELECT COUNT(*)::text AS count FROM packages p - ${laterals} + ${filterLaterals} ${where}`, params, ) diff --git a/services/libs/data-access-layer/src/osspckgs/stewardships.ts b/services/libs/data-access-layer/src/osspckgs/stewardships.ts index 56b82366f8..22a29b8205 100644 --- a/services/libs/data-access-layer/src/osspckgs/stewardships.ts +++ b/services/libs/data-access-layer/src/osspckgs/stewardships.ts @@ -349,7 +349,7 @@ export async function listStewardshipActivity( FROM stewardship_activity sa JOIN stewardships s ON s.id = sa.stewardship_id JOIN packages p ON p.id = s.package_id - ORDER BY sa.created_at DESC + ORDER BY sa.created_at DESC, sa.id DESC LIMIT $(limit) OFFSET $(offset) `, { limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize },