From 0776c768600b76bc30e803a407e7f2e41d97a84e Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 16 Jun 2026 15:35:48 +0200 Subject: [PATCH 1/7] feat: refactor all apis in new path Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/akrites/index.ts | 47 +++++++++++++++++++ backend/src/api/public/v1/index.ts | 4 ++ backend/src/api/public/v1/ossprey/index.ts | 1 + backend/src/api/public/v1/packages/index.ts | 1 + .../src/api/public/v1/stewardships/index.ts | 1 + .../data-access-layer/src/osspckgs/api.ts | 2 + 6 files changed, 56 insertions(+) create mode 100644 backend/src/api/public/v1/akrites/index.ts diff --git a/backend/src/api/public/v1/akrites/index.ts b/backend/src/api/public/v1/akrites/index.ts new file mode 100644 index 0000000000..5b41a0316f --- /dev/null +++ b/backend/src/api/public/v1/akrites/index.ts @@ -0,0 +1,47 @@ +import { Router } from 'express' + +import { createRateLimiter } from '@/api/apiRateLimiter' +import { safeWrap } from '@/middlewares/errorMiddleware' + +import { activityFeedHandler } from '../ossprey/activityFeed' +import { metricsHandler } from '../ossprey/metrics' +import { packageListHandler } from '../ossprey/packageList' +import { packageScatterHandler } from '../ossprey/packageScatter' +import { batchGetStewardship } from '../packages/batchGetStewardship' +import { getPackage } from '../packages/getPackage' +import { getPackagesMetrics } from '../packages/getPackagesMetrics' +import { assignStewardHandler } from '../stewardships/assignSteward' +import { escalateHandler } from '../stewardships/escalate' +import { openStewardship } from '../stewardships/openStewardship' +import { updateStatusHandler } from '../stewardships/updateStatus' + +const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) + +export function akritesRouter(): 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)) + + // --- packages --- + router.post(/^\/packages:batch-stewardship\/?$/, rateLimiter, safeWrap(batchGetStewardship)) + const packagesSubRouter = Router() + packagesSubRouter.use(rateLimiter) + packagesSubRouter.get('/metrics', safeWrap(getPackagesMetrics)) + packagesSubRouter.get('/detail', safeWrap(getPackage)) + router.use('/packages', packagesSubRouter) + + // --- stewardships --- + const stewardshipsSubRouter = Router() + stewardshipsSubRouter.use(rateLimiter) + stewardshipsSubRouter.post('/', safeWrap(openStewardship)) + stewardshipsSubRouter.put('/:id/steward', safeWrap(assignStewardHandler)) + stewardshipsSubRouter.put('/:id/escalate', safeWrap(escalateHandler)) + stewardshipsSubRouter.put('/:id/status', safeWrap(updateStatusHandler)) + router.use('/stewardships', stewardshipsSubRouter) + + return router +} diff --git a/backend/src/api/public/v1/index.ts b/backend/src/api/public/v1/index.ts index 7c33add17f..c83286506f 100644 --- a/backend/src/api/public/v1/index.ts +++ b/backend/src/api/public/v1/index.ts @@ -13,6 +13,7 @@ import { oauth2Middleware } from '../middlewares/oauth2Middleware' import { staticApiKeyMiddleware } from '../middlewares/staticApiKeyMiddleware' import { memberOrganizationAffiliationsRouter } from './affiliations' +import { akritesRouter } from './akrites' import { membersRouter } from './members' import { organizationsRouter } from './organizations' import { osspreyRouter } from './ossprey' @@ -29,6 +30,7 @@ export function v1Router(): Router { router.use('/organizations', oauth2Middleware(AUTH0_CONFIG), organizationsRouter()) router.use('/affiliations', staticApiKeyMiddleware(), memberOrganizationAffiliationsRouter()) + // TODO[deprecate]: /packages, /stewardships, /ossprey are superseded by /akrites — remove once consumers have migrated router.post( /^\/packages:batch-stewardship\/?$/, oauth2Middleware(AUTH0_CONFIG), @@ -41,6 +43,8 @@ export function v1Router(): Router { router.use('/stewardships', oauth2Middleware(AUTH0_CONFIG), stewardshipsRouter()) router.use('/ossprey', oauth2Middleware(AUTH0_CONFIG), osspreyRouter()) + router.use('/akrites', oauth2Middleware(AUTH0_CONFIG), akritesRouter()) + router.use(() => { throw new NotFoundError() }) diff --git a/backend/src/api/public/v1/ossprey/index.ts b/backend/src/api/public/v1/ossprey/index.ts index d5b3bc8fb6..990cdd8d78 100644 --- a/backend/src/api/public/v1/ossprey/index.ts +++ b/backend/src/api/public/v1/ossprey/index.ts @@ -7,6 +7,7 @@ import { metricsHandler } from './metrics' import { packageListHandler } from './packageList' import { packageScatterHandler } from './packageScatter' +// TODO[deprecate]: superseded by /v1/akrites/ossprey — remove once consumers have migrated export function osspreyRouter(): Router { const router = Router() diff --git a/backend/src/api/public/v1/packages/index.ts b/backend/src/api/public/v1/packages/index.ts index c082fcf498..60a9c3f9f0 100644 --- a/backend/src/api/public/v1/packages/index.ts +++ b/backend/src/api/public/v1/packages/index.ts @@ -12,6 +12,7 @@ import { listPackages } from './listPackages' const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) +// TODO[deprecate]: superseded by /v1/akrites/packages — remove once consumers have migrated export function packagesRouter(): Router { const router = Router() diff --git a/backend/src/api/public/v1/stewardships/index.ts b/backend/src/api/public/v1/stewardships/index.ts index 8e24ccef15..0646c58116 100644 --- a/backend/src/api/public/v1/stewardships/index.ts +++ b/backend/src/api/public/v1/stewardships/index.ts @@ -13,6 +13,7 @@ import { updateStatusHandler } from './updateStatus' const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) +// TODO[deprecate]: superseded by /v1/akrites/stewardships — remove once consumers have migrated export function stewardshipsRouter(): Router { const router = Router() diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index feb6c280f9..337d2a8cf5 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -49,6 +49,7 @@ export async function getPackagesByStewardshipPurls( ) } +// TODO[deprecate]: rename to AkritesMetrics once /v1/ossprey is removed export interface OsspreyMetrics { totalPackages: number criticalPackages: number @@ -60,6 +61,7 @@ export interface OsspreyMetrics { escalated: number } +// TODO[deprecate]: rename to getAkritesMetrics once /v1/ossprey is removed export async function getOsspreyMetrics(qx: QueryExecutor): Promise { const [counts, stewardRow]: [ { From 03f7b55c5412e4d010c4667df74c1c7097d2c32e Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 16 Jun 2026 16:27:58 +0200 Subject: [PATCH 2/7] feat: add comments Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/akrites/index.ts | 19 ++++++++++++++----- backend/src/api/public/v1/ossprey/index.ts | 3 ++- backend/src/api/public/v1/packages/index.ts | 6 +++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/backend/src/api/public/v1/akrites/index.ts b/backend/src/api/public/v1/akrites/index.ts index 5b41a0316f..0b053925df 100644 --- a/backend/src/api/public/v1/akrites/index.ts +++ b/backend/src/api/public/v1/akrites/index.ts @@ -15,28 +15,37 @@ import { escalateHandler } from '../stewardships/escalate' import { openStewardship } from '../stewardships/openStewardship' import { updateStatusHandler } from '../stewardships/updateStatus' -const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) +// Separate instances match the original per-router isolation: packages and stewardships each had +// their own createRateLimiter() call, giving independent 60 req/min buckets per IP. +const packagesRateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) +const stewardshipsRateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) export function akritesRouter(): 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 + // /packages/scatter registered before router.use('/packages', ...) so Express evaluates this + // explicit route first; without this ordering the sub-router would receive the request first + // and call next() on no match, adding unnecessary overhead. router.get('/packages/scatter', safeWrap(packageScatterHandler)) router.get('/packages', safeWrap(packageListHandler)) router.get('/activity', safeWrap(activityFeedHandler)) // --- packages --- - router.post(/^\/packages:batch-stewardship\/?$/, rateLimiter, safeWrap(batchGetStewardship)) + router.post( + /^\/packages:batch-stewardship\/?$/, + packagesRateLimiter, + safeWrap(batchGetStewardship), + ) const packagesSubRouter = Router() - packagesSubRouter.use(rateLimiter) + packagesSubRouter.use(packagesRateLimiter) packagesSubRouter.get('/metrics', safeWrap(getPackagesMetrics)) packagesSubRouter.get('/detail', safeWrap(getPackage)) router.use('/packages', packagesSubRouter) // --- stewardships --- const stewardshipsSubRouter = Router() - stewardshipsSubRouter.use(rateLimiter) + stewardshipsSubRouter.use(stewardshipsRateLimiter) stewardshipsSubRouter.post('/', safeWrap(openStewardship)) stewardshipsSubRouter.put('/:id/steward', safeWrap(assignStewardHandler)) stewardshipsSubRouter.put('/:id/escalate', safeWrap(escalateHandler)) diff --git a/backend/src/api/public/v1/ossprey/index.ts b/backend/src/api/public/v1/ossprey/index.ts index 990cdd8d78..54bc75bc1b 100644 --- a/backend/src/api/public/v1/ossprey/index.ts +++ b/backend/src/api/public/v1/ossprey/index.ts @@ -7,7 +7,8 @@ import { metricsHandler } from './metrics' import { packageListHandler } from './packageList' import { packageScatterHandler } from './packageScatter' -// TODO[deprecate]: superseded by /v1/akrites/ossprey — remove once consumers have migrated +// TODO[deprecate]: superseded by /v1/akrites — ossprey endpoints are now at /v1/akrites/metrics, +// /v1/akrites/packages, /v1/akrites/packages/scatter, /v1/akrites/activity — remove once consumers have migrated export function osspreyRouter(): Router { const router = Router() diff --git a/backend/src/api/public/v1/packages/index.ts b/backend/src/api/public/v1/packages/index.ts index 60a9c3f9f0..99806f6d10 100644 --- a/backend/src/api/public/v1/packages/index.ts +++ b/backend/src/api/public/v1/packages/index.ts @@ -12,7 +12,11 @@ import { listPackages } from './listPackages' const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) -// TODO[deprecate]: superseded by /v1/akrites/packages — remove once consumers have migrated +// TODO[deprecate]: /packages/metrics and /packages/detail are superseded by /v1/akrites/packages/metrics +// and /v1/akrites/packages/detail — remove once consumers have migrated. +// NOTE: GET /packages (listPackages) is intentionally NOT replicated in /v1/akrites because it has a +// different response shape from GET /v1/akrites/packages (ossprey packageListHandler). Before removing, +// verify no consumer calls GET /v1/packages — if unused, delete listPackages and this route entirely. export function packagesRouter(): Router { const router = Router() From 714355b267bc48852d22487df56330b58f3e473d Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 17 Jun 2026 07:57:21 +0200 Subject: [PATCH 3/7] fix: refactor yaml and adds comments Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/akrites/index.ts | 51 +- .../src/api/public/v1/akrites/openapi.yaml | 1189 +++++++++++++++++ 2 files changed, 1232 insertions(+), 8 deletions(-) create mode 100644 backend/src/api/public/v1/akrites/openapi.yaml diff --git a/backend/src/api/public/v1/akrites/index.ts b/backend/src/api/public/v1/akrites/index.ts index 0b053925df..e3d3c9ead7 100644 --- a/backend/src/api/public/v1/akrites/index.ts +++ b/backend/src/api/public/v1/akrites/index.ts @@ -2,6 +2,9 @@ import { Router } from 'express' import { createRateLimiter } from '@/api/apiRateLimiter' import { safeWrap } from '@/middlewares/errorMiddleware' +// TODO: restore once scopes are added to Auth0 staging tenant +// import { requireScopes } from '@/api/public/middlewares/requireScopes' +// import { SCOPES } from '@/security/scopes' import { activityFeedHandler } from '../ossprey/activityFeed' import { metricsHandler } from '../ossprey/metrics' @@ -27,29 +30,61 @@ export function akritesRouter(): Router { // /packages/scatter registered before router.use('/packages', ...) so Express evaluates this // explicit route first; without this ordering the sub-router would receive the request first // and call next() on no match, adding unnecessary overhead. - router.get('/packages/scatter', safeWrap(packageScatterHandler)) - router.get('/packages', safeWrap(packageListHandler)) + router.get('/packages/scatter', packagesRateLimiter, safeWrap(packageScatterHandler)) + router.get('/packages', packagesRateLimiter, safeWrap(packageListHandler)) router.get('/activity', safeWrap(activityFeedHandler)) // --- packages --- router.post( /^\/packages:batch-stewardship\/?$/, packagesRateLimiter, + // TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant + // requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'), safeWrap(batchGetStewardship), ) const packagesSubRouter = Router() packagesSubRouter.use(packagesRateLimiter) - packagesSubRouter.get('/metrics', safeWrap(getPackagesMetrics)) - packagesSubRouter.get('/detail', safeWrap(getPackage)) + packagesSubRouter.get( + '/metrics', + // TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant + // requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'), + safeWrap(getPackagesMetrics), + ) + packagesSubRouter.get( + '/detail', + // TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant + // requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'), + safeWrap(getPackage), + ) router.use('/packages', packagesSubRouter) // --- stewardships --- const stewardshipsSubRouter = Router() stewardshipsSubRouter.use(stewardshipsRateLimiter) - stewardshipsSubRouter.post('/', safeWrap(openStewardship)) - stewardshipsSubRouter.put('/:id/steward', safeWrap(assignStewardHandler)) - stewardshipsSubRouter.put('/:id/escalate', safeWrap(escalateHandler)) - stewardshipsSubRouter.put('/:id/status', safeWrap(updateStatusHandler)) + stewardshipsSubRouter.post( + '/', + // TODO: restore once write:stewardships is added to Auth0 staging tenant + // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), + safeWrap(openStewardship), + ) + stewardshipsSubRouter.put( + '/:id/steward', + // TODO: restore once write:stewardships is added to Auth0 staging tenant + // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), + safeWrap(assignStewardHandler), + ) + stewardshipsSubRouter.put( + '/:id/escalate', + // TODO: restore once write:stewardships is added to Auth0 staging tenant + // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), + safeWrap(escalateHandler), + ) + stewardshipsSubRouter.put( + '/:id/status', + // TODO: restore once write:stewardships is added to Auth0 staging tenant + // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), + safeWrap(updateStatusHandler), + ) router.use('/stewardships', stewardshipsSubRouter) return router diff --git a/backend/src/api/public/v1/akrites/openapi.yaml b/backend/src/api/public/v1/akrites/openapi.yaml new file mode 100644 index 0000000000..6ad810056d --- /dev/null +++ b/backend/src/api/public/v1/akrites/openapi.yaml @@ -0,0 +1,1189 @@ +openapi: 3.1.0 +info: + title: CDP Public API — Akrites + version: 1.0.0 + description: > + Unified namespace for OSSPREY dashboard read endpoints and stewardship write + actions. All routes require an OAuth 2.0 bearer token (Auth0 M2M or user session). + + + **Rate limits:** packages and stewardships sub-groups each have an independent + 60 requests/min per-IP bucket. + +servers: + - url: https://cm.lfx.dev/api/v1 + description: Production + - url: https://lf-staging.crowd.dev/api/v1 + description: Staging + +security: + - BearerAuth: [] + +tags: + - name: Dashboard + description: KPI bar metrics and activity feed. + - name: Packages + description: Package list, scatter plot, detail, and batch stewardship lookup. + - name: Stewardships + description: Open, assign, escalate, and update stewardship status. + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + # ── Shared primitives ────────────────────────────────────────────────────── + + 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: > + Derived from `scorecardScore`: + `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 + + EscalationResolutionPath: + type: string + enum: + - right_of_first_refusal + - replace_the_dependency + - find_vendor_for_lts + - consortium_adopts_maintainership + - compensating_controls_monitor + - namespace_takeover + + InactiveReason: + type: string + enum: + - quarterly_cadence_missed + - stepped_down + - no_longer_critical + + 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 + + StewardshipRecord: + type: object + required: [id, packageId, status, origin, version, createdAt, updatedAt] + properties: + id: + type: string + packageId: + type: string + status: + $ref: '#/components/schemas/StewardshipStatus' + origin: + type: string + version: + type: integer + openedAt: + type: string + format: date-time + nullable: true + lastStatusAt: + type: string + format: date-time + nullable: true + inactiveReason: + $ref: '#/components/schemas/InactiveReason' + nullable: true + resolutionPath: + $ref: '#/components/schemas/EscalationResolutionPath' + nullable: true + statusNote: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + StewardEntry: + type: object + required: [userId, role, assignedAt] + properties: + userId: + type: string + role: + type: string + assignedAt: + type: string + format: date-time + + # ── Dashboard schemas ────────────────────────────────────────────────────── + + OsspreyMetrics: + type: object + required: + - totalPackages + - criticalPackages + - coveragePercent + - coverageTrend + - activeStewards + - unassignedCritical + - needsAttention + - escalated + properties: + totalPackages: + type: integer + description: Total number of critical packages tracked. + criticalPackages: + type: integer + description: Critical packages with at least one critical vulnerability. + coveragePercent: + type: number + format: float + description: Percentage of critical packages with an active stewardship (assessing, active, or needs_attention). + coverageTrend: + type: number + format: float + nullable: true + description: Coverage delta vs. previous period. Currently always null (requires snapshot mechanism). + activeStewards: + type: integer + description: Distinct stewards assigned to a non-inactive stewardship. + unassignedCritical: + type: integer + description: Critical packages with no stewardship or status = unassigned. + needsAttention: + type: integer + description: Critical packages whose stewardship status is needs_attention. + escalated: + type: integer + description: Critical packages whose stewardship status is escalated. + + ActivityEntry: + type: object + required: + - id + - stewardshipId + - packagePurl + - packageName + - packageEcosystem + - actorUserId + - actorName + - actorType + - activityType + - stewardshipStatus + - createdAt + properties: + id: + type: string + stewardshipId: + type: string + packagePurl: + type: string + packageName: + type: string + packageEcosystem: + type: string + actorUserId: + type: string + nullable: true + actorName: + type: string + nullable: true + description: Display name (currently same as actorUserId; resolution from members table is pending). + actorType: + type: string + example: user + activityType: + type: string + example: status_change + content: + type: string + nullable: true + metadata: + type: object + nullable: true + additionalProperties: true + stewardshipStatus: + $ref: '#/components/schemas/StewardshipStatus' + createdAt: + type: string + format: date-time + + # ── Package schemas ──────────────────────────────────────────────────────── + + PackageListRow: + type: object + required: + - purl + - name + - ecosystem + - openVulns + - maintainerCount + - healthBand + - stewards + properties: + purl: + type: string + example: pkg:npm/%40angular/core@17.0.0 + name: + type: string + ecosystem: + type: string + criticalityScore: + type: number + format: float + nullable: true + stewardshipId: + type: string + nullable: true + stewardshipStatus: + $ref: '#/components/schemas/StewardshipStatus' + nullable: true + openVulns: + type: integer + maxVulnSeverity: + type: string + enum: [critical, high, medium, low] + nullable: true + maintainerCount: + type: integer + scorecardScore: + type: number + format: float + nullable: true + healthBand: + $ref: '#/components/schemas/HealthBand' + latestReleaseAt: + type: string + format: date-time + nullable: true + lastActivity: + type: object + nullable: true + required: [type, content, at] + properties: + type: + type: string + content: + type: string + nullable: true + at: + type: string + format: date-time + stewards: + type: array + items: + $ref: '#/components/schemas/StewardEntry' + + StatusCounts: + type: object + description: Count of packages per stewardship status (used to drive filter pill badges). + additionalProperties: + type: integer + example: + unassigned: 42 + open: 5 + assessing: 12 + active: 30 + needs_attention: 3 + escalated: 1 + blocked: 0 + inactive: 2 + + ScatterPoint: + type: object + required: + - purl + - name + - criticalityScore + - healthScore + - healthBand + - openVulns + - advisoryCount + properties: + purl: + type: string + name: + type: string + criticalityScore: + type: integer + description: Impact score scaled to 0–100. + healthScore: + type: integer + description: OpenSSF Scorecard score scaled to 0–100. + healthBand: + $ref: '#/components/schemas/HealthBand' + stewardshipStatus: + $ref: '#/components/schemas/StewardshipStatus' + nullable: true + stewardshipId: + type: string + nullable: true + openVulns: + type: integer + advisoryCount: + type: integer + + PackageMetrics: + type: object + required: [totalPackages, criticalPackages] + properties: + totalPackages: + type: integer + description: Total packages marked as critical. + criticalPackages: + type: integer + description: Critical packages with has_critical_vulnerability = true. + + Advisory: + type: object + required: [osvId, severity, resolution] + properties: + osvId: + type: string + example: GHSA-xxxx-xxxx-xxxx + severity: + type: string + enum: [critical, high, medium, low] + nullable: true + resolution: + type: string + nullable: true + + PackageDetail: + type: object + required: [purl, name, ecosystem, general, assessment, security, provenance, stewardship, history] + properties: + purl: + type: string + name: + type: string + ecosystem: + type: string + general: + type: object + properties: + healthScore: + type: number + nullable: true + impact: + type: object + properties: + impactScore: + type: integer + nullable: true + description: Criticality score scaled to 0–100. + downloadsLastMonth: + type: integer + nullable: true + dependentPackages: + type: integer + nullable: true + dependentRepos: + type: integer + nullable: true + transitiveReach: + type: integer + nullable: true + riskSignals: + type: object + properties: + lifecycle: + type: string + nullable: true + maintainerBusFactor: + type: integer + nullable: true + lastRelease: + type: string + format: date-time + nullable: true + hasSecurityFile: + type: boolean + nullable: true + openSSFScorecard: + type: number + format: float + nullable: true + assessment: + type: object + description: Reserved for future stewardship assessment fields. + security: + type: object + properties: + securityContacts: + type: array + nullable: true + items: + type: string + advisories: + type: array + items: + $ref: '#/components/schemas/Advisory' + cvd: + type: object + properties: + isPvrEnabled: + type: boolean + nullable: true + hasSecurityPolicyEnabled: + type: boolean + nullable: true + tier0Steward: + type: string + nullable: true + criticalVulnerabilityFlag: + type: boolean + nullable: true + provenance: + type: object + properties: + repositoryMapping: + type: object + properties: + declaredRepo: + type: string + nullable: true + mappingConfidence: + type: number + format: float + nullable: true + lastCommitAt: + type: string + format: date-time + nullable: true + supplyChainIntegrity: + type: object + properties: + buildProvenance: + type: string + nullable: true + description: Not yet ingested. + signedReleases: + type: string + nullable: true + description: Not yet ingested. + stewardship: + type: object + properties: + id: + type: string + nullable: true + status: + $ref: '#/components/schemas/StewardshipStatus' + stewards: + type: array + nullable: true + items: + $ref: '#/components/schemas/StewardEntry' + lastActivityAt: + type: string + format: date-time + nullable: true + resolutionPath: + $ref: '#/components/schemas/EscalationResolutionPath' + nullable: true + statusNote: + type: string + nullable: true + history: + type: object + description: Reserved for future activity history fields. + + PackageStewardshipSummary: + type: object + description: Slim stewardship summary returned per-purl by the batch endpoint. + nullable: true + required: [name, ecosystem] + properties: + name: + type: string + ecosystem: + type: string + lifecycle: + type: string + enum: [active, stable, declining, abandoned] + nullable: true + health: + type: number + format: float + nullable: true + impact: + type: integer + nullable: true + description: Criticality score scaled to 0–100. + openVulns: + type: object + nullable: true + properties: + low: + type: integer + medium: + type: integer + high: + type: integer + critical: + type: integer + stewardship: + $ref: '#/components/schemas/StewardshipStatus' + nullable: true + stewards: + type: array + nullable: true + items: + $ref: '#/components/schemas/Steward' + lastActivityAt: + type: string + format: date-time + nullable: true + lastActivityDescription: + type: string + nullable: true + + # ── Pagination wrapper ───────────────────────────────────────────────────── + + PaginationMeta: + type: object + required: [total, page, pageSize] + properties: + total: + type: integer + page: + type: integer + pageSize: + type: integer + +paths: + # ── Dashboard ────────────────────────────────────────────────────────────── + + /akrites/metrics: + get: + operationId: getAkritesMetrics + summary: Get KPI bar metrics + description: > + Returns aggregate counts that power the global KPI bar on the OSSPREY + dashboard (total packages, coverage %, active stewards, unassigned + critical, needs-attention, escalated). + tags: + - Dashboard + responses: + '200': + description: Metrics snapshot. + content: + application/json: + schema: + $ref: '#/components/schemas/OsspreyMetrics' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/activity: + get: + operationId: getAkritesActivity + summary: Get stewardship activity feed + description: > + Returns a paginated, reverse-chronological list of stewardship activity + events across all packages (status changes, assignments, escalations, etc.). + tags: + - Dashboard + 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: + allOf: + - $ref: '#/components/schemas/PaginationMeta' + - type: object + required: [rows] + properties: + rows: + type: array + items: + $ref: '#/components/schemas/ActivityEntry' + '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' + + # ── Packages (ossprey-derived read endpoints) ────────────────────────────── + + /akrites/packages: + get: + operationId: listAkritesPackages + summary: List packages with stewardship data + description: > + Paginated list of critical packages enriched with stewardship status, + vulnerability counts, health band, and the latest stewardship activity. + Supports rich filtering and sorting. + tags: + - Packages + 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: ecosystem + in: query + schema: + type: string + - name: lifecycle + in: query + schema: + type: string + enum: [active, stable, declining, abandoned] + - name: name + in: query + description: Substring match on package name. + schema: + type: string + - name: status + in: query + schema: + $ref: '#/components/schemas/StewardshipStatus' + - name: healthBand + in: query + schema: + $ref: '#/components/schemas/HealthBand' + - name: vulnSeverity + in: query + description: > + Filter by highest open vulnerability severity present on the package. + `none` returns packages with zero vulnerabilities; `any` disables the filter. + schema: + type: string + enum: [any, high, critical, none] + - name: staleOnly + in: query + schema: + type: boolean + default: false + - name: unstewardedOnly + in: query + schema: + type: boolean + default: false + - name: busFactor1Only + in: query + description: Return only packages whose maintainer count is 1. + schema: + type: boolean + default: false + - name: sortBy + in: query + schema: + type: string + enum: [name, risk, 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: + allOf: + - $ref: '#/components/schemas/PaginationMeta' + - type: object + required: [rows, statusCounts] + properties: + rows: + type: array + items: + $ref: '#/components/schemas/PackageListRow' + statusCounts: + $ref: '#/components/schemas/StatusCounts' + '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' + + /akrites/packages/scatter: + get: + operationId: getAkritesPackagesScatter + summary: Get risk matrix scatter data + description: > + Returns all critical packages as scatter-plot points with impact score + (x-axis) and health score (y-axis). No pagination — the full dataset is + returned. + tags: + - Packages + responses: + '200': + description: Scatter plot dataset. + content: + application/json: + schema: + type: object + required: [points, total] + properties: + points: + type: array + items: + $ref: '#/components/schemas/ScatterPoint' + total: + type: integer + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/packages/metrics: + get: + operationId: getAkritesPackagesMetrics + summary: Get package count metrics + description: > + Returns total and critical package counts. Lighter alternative to + `/akrites/metrics` when only package-level counts are needed. + tags: + - Packages + responses: + '200': + description: Package count metrics. + content: + application/json: + schema: + $ref: '#/components/schemas/PackageMetrics' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/packages/detail: + get: + operationId: getAkritesPackageDetail + summary: Get package detail + description: > + Returns the full detail view for a single package identified by its + PURL, including risk signals, security advisories, repository provenance, + and current stewardship state. + tags: + - Packages + parameters: + - name: purl + in: query + required: true + description: > + Package URL (PURL) — must start with `pkg:`. + Version qualifiers are normalised server-side. + schema: + type: string + example: pkg:npm/%40angular/core@17.0.0 + responses: + '200': + description: Package detail. + content: + application/json: + schema: + $ref: '#/components/schemas/PackageDetail' + '400': + description: Validation error (malformed purl). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Package not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/packages:batch-stewardship: + post: + operationId: batchGetStewardship + summary: Batch stewardship lookup by PURL + description: > + Given up to 100 PURLs, returns a map of `purl → stewardship summary` + for each. Missing packages are returned as `null`. Useful for enriching + external package listings with CDP stewardship state. + tags: + - Packages + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [purls] + properties: + purls: + type: array + minItems: 1 + maxItems: 100 + items: + type: string + description: Must start with `pkg:`. + example: pkg:npm/%40angular/core@17.0.0 + responses: + '200': + description: Per-purl stewardship map. + content: + application/json: + schema: + type: object + required: [packages] + properties: + packages: + type: object + description: > + Keys are the original PURLs from the request. + Values are null when the package is not found in CDP. + additionalProperties: + oneOf: + - $ref: '#/components/schemas/PackageStewardshipSummary' + - type: 'null' + '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' + + # ── Stewardships ─────────────────────────────────────────────────────────── + + /akrites/stewardships: + post: + operationId: openStewardship + summary: Open a stewardship for a package + description: > + Creates a new stewardship record for the package identified by the given + PURL, setting its status to `open`. The authenticated user is recorded + as the actor who opened it. + tags: + - Stewardships + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [purl] + properties: + purl: + type: string + description: Must start with `pkg:`. + example: pkg:npm/%40angular/core@17.0.0 + responses: + '200': + description: Stewardship opened. + content: + application/json: + schema: + type: object + required: [stewardship] + properties: + stewardship: + $ref: '#/components/schemas/StewardshipRecord' + '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' + '404': + description: Package not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/stewardships/{id}/steward: + put: + operationId: assignSteward + summary: Assign a steward to a stewardship + description: > + Assigns a user as a steward (lead or co-steward) for the given + stewardship. Optionally transitions the stewardship status to + `assessing` in the same operation. + tags: + - Stewardships + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [userId, role] + properties: + userId: + type: string + description: Auth0 sub of the user to assign. + example: auth0|abc123 + role: + type: string + enum: [lead, co_steward] + moveToAssessing: + type: boolean + default: false + description: > + When true, automatically transitions stewardship status to + `assessing` after assignment. + responses: + '200': + description: Steward assigned. + content: + application/json: + schema: + type: object + required: [stewardship, stewards] + properties: + stewardship: + $ref: '#/components/schemas/StewardshipRecord' + stewards: + type: array + items: + $ref: '#/components/schemas/StewardEntry' + '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' + '404': + description: Stewardship not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/stewardships/{id}/escalate: + put: + operationId: escalateStewardship + summary: Escalate a stewardship + description: > + Transitions the stewardship to `escalated` status and records the chosen + resolution path. Optionally attaches a free-text note. + tags: + - Stewardships + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [resolutionPath] + properties: + resolutionPath: + $ref: '#/components/schemas/EscalationResolutionPath' + notes: + type: string + minLength: 1 + responses: + '200': + description: Stewardship escalated. + content: + application/json: + schema: + type: object + required: [stewardship] + properties: + stewardship: + $ref: '#/components/schemas/StewardshipRecord' + '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' + '404': + description: Stewardship not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/stewardships/{id}/status: + put: + operationId: updateStewardshipStatus + summary: Update stewardship status + description: > + Updates the stewardship status. Valid target statuses are: + `assessing`, `active`, `needs_attention`, `blocked`, `inactive`. + When setting `inactive`, `inactiveReason` is required. + tags: + - Stewardships + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [assessing, active, needs_attention, blocked, inactive] + inactiveReason: + $ref: '#/components/schemas/InactiveReason' + description: Required when status is `inactive`. + notes: + type: string + minLength: 1 + responses: + '200': + description: Stewardship status updated. + content: + application/json: + schema: + type: object + required: [stewardship] + properties: + stewardship: + $ref: '#/components/schemas/StewardshipRecord' + '400': + description: > + Validation error. Also returned when `status` is `inactive` but + `inactiveReason` is missing. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Stewardship not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' From fd39e04f4fb0ecfca131a5cb3465fcd4547d81c5 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 17 Jun 2026 08:02:25 +0200 Subject: [PATCH 4/7] fix: lint Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/akrites/index.ts | 2 +- backend/src/api/public/v1/akrites/openapi.yaml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/api/public/v1/akrites/index.ts b/backend/src/api/public/v1/akrites/index.ts index e3d3c9ead7..cbaf038f8c 100644 --- a/backend/src/api/public/v1/akrites/index.ts +++ b/backend/src/api/public/v1/akrites/index.ts @@ -2,10 +2,10 @@ import { Router } from 'express' import { createRateLimiter } from '@/api/apiRateLimiter' import { safeWrap } from '@/middlewares/errorMiddleware' + // TODO: restore once scopes are added to Auth0 staging tenant // import { requireScopes } from '@/api/public/middlewares/requireScopes' // import { SCOPES } from '@/security/scopes' - import { activityFeedHandler } from '../ossprey/activityFeed' import { metricsHandler } from '../ossprey/metrics' import { packageListHandler } from '../ossprey/packageList' diff --git a/backend/src/api/public/v1/akrites/openapi.yaml b/backend/src/api/public/v1/akrites/openapi.yaml index 6ad810056d..ac07471f2e 100644 --- a/backend/src/api/public/v1/akrites/openapi.yaml +++ b/backend/src/api/public/v1/akrites/openapi.yaml @@ -389,7 +389,8 @@ components: PackageDetail: type: object - required: [purl, name, ecosystem, general, assessment, security, provenance, stewardship, history] + required: + [purl, name, ecosystem, general, assessment, security, provenance, stewardship, history] properties: purl: type: string From 616cb1c3bb62def1404243f6f8e8cc85a6873abd Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 17 Jun 2026 09:04:44 +0200 Subject: [PATCH 5/7] fix: allign specs, add endpoins Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/akrites/index.ts | 24 +- .../src/api/public/v1/akrites/openapi.yaml | 214 +++++++++++++++--- .../src/api/public/v1/packages/getPackage.ts | 52 +++-- .../v1/packages/getPackageAdvisories.ts | 32 +++ .../public/v1/packages/getPackageHistory.ts | 30 +++ backend/src/api/public/v1/packages/purl.ts | 11 + .../public/v1/stewardships/assignSteward.ts | 4 +- .../data-access-layer/src/osspckgs/api.ts | 17 +- .../src/osspckgs/stewardships.ts | 45 +++- 9 files changed, 363 insertions(+), 66 deletions(-) create mode 100644 backend/src/api/public/v1/packages/getPackageAdvisories.ts create mode 100644 backend/src/api/public/v1/packages/getPackageHistory.ts diff --git a/backend/src/api/public/v1/akrites/index.ts b/backend/src/api/public/v1/akrites/index.ts index cbaf038f8c..70492455c0 100644 --- a/backend/src/api/public/v1/akrites/index.ts +++ b/backend/src/api/public/v1/akrites/index.ts @@ -12,6 +12,8 @@ import { packageListHandler } from '../ossprey/packageList' import { packageScatterHandler } from '../ossprey/packageScatter' import { batchGetStewardship } from '../packages/batchGetStewardship' import { getPackage } from '../packages/getPackage' +import { getPackageAdvisories } from '../packages/getPackageAdvisories' +import { getPackageHistory } from '../packages/getPackageHistory' import { getPackagesMetrics } from '../packages/getPackagesMetrics' import { assignStewardHandler } from '../stewardships/assignSteward' import { escalateHandler } from '../stewardships/escalate' @@ -56,30 +58,42 @@ export function akritesRouter(): Router { // requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'), safeWrap(getPackage), ) + packagesSubRouter.get( + '/advisories', + // TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant + // requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'), + safeWrap(getPackageAdvisories), + ) + packagesSubRouter.get( + '/history', + // TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant + // requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'), + safeWrap(getPackageHistory), + ) router.use('/packages', packagesSubRouter) // --- stewardships --- const stewardshipsSubRouter = Router() stewardshipsSubRouter.use(stewardshipsRateLimiter) stewardshipsSubRouter.post( - '/', + '/open', // TODO: restore once write:stewardships is added to Auth0 staging tenant // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), safeWrap(openStewardship), ) - stewardshipsSubRouter.put( - '/:id/steward', + stewardshipsSubRouter.post( + '/:id/assign', // TODO: restore once write:stewardships is added to Auth0 staging tenant // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), safeWrap(assignStewardHandler), ) - stewardshipsSubRouter.put( + stewardshipsSubRouter.post( '/:id/escalate', // TODO: restore once write:stewardships is added to Auth0 staging tenant // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), safeWrap(escalateHandler), ) - stewardshipsSubRouter.put( + stewardshipsSubRouter.patch( '/:id/status', // TODO: restore once write:stewardships is added to Auth0 staging tenant // requireScopes([SCOPES.WRITE_STEWARDSHIPS]), diff --git a/backend/src/api/public/v1/akrites/openapi.yaml b/backend/src/api/public/v1/akrites/openapi.yaml index ac07471f2e..48ca282a11 100644 --- a/backend/src/api/public/v1/akrites/openapi.yaml +++ b/backend/src/api/public/v1/akrites/openapi.yaml @@ -158,7 +158,6 @@ components: OsspreyMetrics: type: object required: - - totalPackages - criticalPackages - coveragePercent - coverageTrend @@ -167,12 +166,9 @@ components: - needsAttention - escalated properties: - totalPackages: - type: integer - description: Total number of critical packages tracked. criticalPackages: type: integer - description: Critical packages with at least one critical vulnerability. + description: Total number of packages marked as critical (is_critical = true). coveragePercent: type: number format: float @@ -363,14 +359,11 @@ components: PackageMetrics: type: object - required: [totalPackages, criticalPackages] + required: [criticalPackages] properties: - totalPackages: - type: integer - description: Total packages marked as critical. criticalPackages: type: integer - description: Critical packages with has_critical_vulnerability = true. + description: Total packages marked as critical (is_critical = true). Advisory: type: object @@ -387,6 +380,32 @@ components: type: string nullable: true + PackageHistoryEvent: + type: object + required: [id, actorType, activityType, createdAt] + properties: + id: + type: string + actorUserId: + type: string + nullable: true + actorType: + type: string + example: user + activityType: + type: string + example: state_changed + content: + type: string + nullable: true + metadata: + type: object + nullable: true + additionalProperties: true + createdAt: + type: string + format: date-time + PackageDetail: type: object required: @@ -398,12 +417,18 @@ components: type: string ecosystem: type: string + latestVersion: + type: string + nullable: true general: type: object properties: healthScore: - type: number + type: integer nullable: true + description: OpenSSF Scorecard score scaled to 0–100 (scorecardScore × 10, rounded). + healthBand: + $ref: '#/components/schemas/HealthBand' impact: type: object properties: @@ -412,8 +437,9 @@ components: nullable: true description: Criticality score scaled to 0–100. downloadsLastMonth: - type: integer + type: string nullable: true + description: Raw download count string from the registry. dependentPackages: type: integer nullable: true @@ -439,13 +465,21 @@ components: hasSecurityFile: type: boolean nullable: true + hasSecurityPolicy: + type: boolean + nullable: true + description: repos.security_policy_enabled + branchProtectionEnabled: + type: boolean + nullable: true + description: repos.branch_protection_enabled openSSFScorecard: type: number format: float nullable: true assessment: - type: object - description: Reserved for future stewardship assessment fields. + nullable: true + description: Reserved for future stewardship assessment fields (G1). security: type: object properties: @@ -464,9 +498,6 @@ components: isPvrEnabled: type: boolean nullable: true - hasSecurityPolicyEnabled: - type: boolean - nullable: true tier0Steward: type: string nullable: true @@ -486,6 +517,10 @@ components: type: number format: float nullable: true + mappingLabel: + type: string + enum: [High, Medium, Low] + nullable: true lastCommitAt: type: string format: date-time @@ -509,12 +544,17 @@ components: nullable: true status: $ref: '#/components/schemas/StewardshipStatus' - stewards: - type: array + origin: + type: string nullable: true - items: - $ref: '#/components/schemas/StewardEntry' - lastActivityAt: + version: + type: integer + nullable: true + openedAt: + type: string + format: date-time + nullable: true + lastStatusAt: type: string format: date-time nullable: true @@ -524,9 +564,18 @@ components: statusNote: type: string nullable: true + stewards: + type: array + nullable: true + items: + $ref: '#/components/schemas/StewardEntry' + lastActivityAt: + type: string + format: date-time + nullable: true history: - type: object - description: Reserved for future activity history fields. + nullable: true + description: Always null in /detail — full history available at GET /packages/history. PackageStewardshipSummary: type: object @@ -884,6 +933,109 @@ paths: schema: $ref: '#/components/schemas/Error' + /akrites/packages/advisories: + get: + operationId: getAkritesPackageAdvisories + summary: Get advisories for a package + description: > + Returns all open security advisories for a single package identified by + PURL. Intended for lazy-loading the Security tab in the package detail drawer. + tags: + - Packages + parameters: + - name: purl + in: query + required: true + description: Package URL (PURL) — must start with `pkg:`. + schema: + type: string + example: pkg:npm/%40angular/core@17.0.0 + responses: + '200': + description: Advisory list. + content: + application/json: + schema: + type: object + required: [advisories, total] + properties: + advisories: + type: array + items: + $ref: '#/components/schemas/Advisory' + total: + type: integer + '400': + description: Validation error (malformed purl). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Package not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites/packages/history: + get: + operationId: getAkritesPackageHistory + summary: Get stewardship history for a package + description: > + Returns the full activity log for the stewardship associated with the + given PURL, ordered newest-first. Returns an empty list if no stewardship + exists. Intended for lazy-loading the History tab in the package detail drawer. + tags: + - Packages + parameters: + - name: purl + in: query + required: true + description: Package URL (PURL) — must start with `pkg:`. + schema: + type: string + example: pkg:npm/%40angular/core@17.0.0 + responses: + '200': + description: Stewardship activity history. + content: + application/json: + schema: + type: object + required: [events, total] + properties: + events: + type: array + items: + $ref: '#/components/schemas/PackageHistoryEvent' + total: + type: integer + '400': + description: Validation error (malformed purl). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Package not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /akrites/packages:batch-stewardship: post: operationId: batchGetStewardship @@ -943,7 +1095,7 @@ paths: # ── Stewardships ─────────────────────────────────────────────────────────── - /akrites/stewardships: + /akrites/stewardships/open: post: operationId: openStewardship summary: Open a stewardship for a package @@ -995,8 +1147,8 @@ paths: schema: $ref: '#/components/schemas/Error' - /akrites/stewardships/{id}/steward: - put: + /akrites/stewardships/{id}/assign: + post: operationId: assignSteward summary: Assign a steward to a stewardship description: > @@ -1026,6 +1178,10 @@ paths: role: type: string enum: [lead, co_steward] + note: + type: string + minLength: 1 + description: Optional note stored in the steward_added activity metadata. moveToAssessing: type: boolean default: false @@ -1067,7 +1223,7 @@ paths: $ref: '#/components/schemas/Error' /akrites/stewardships/{id}/escalate: - put: + post: operationId: escalateStewardship summary: Escalate a stewardship description: > @@ -1125,7 +1281,7 @@ paths: $ref: '#/components/schemas/Error' /akrites/stewardships/{id}/status: - put: + patch: operationId: updateStewardshipStatus summary: Update stewardship status description: > diff --git a/backend/src/api/public/v1/packages/getPackage.ts b/backend/src/api/public/v1/packages/getPackage.ts index 318505b3d0..75f6f50fab 100644 --- a/backend/src/api/public/v1/packages/getPackage.ts +++ b/backend/src/api/public/v1/packages/getPackage.ts @@ -1,8 +1,8 @@ import type { Request, Response } from 'express' -import { z } from 'zod' import { NotFoundError } from '@crowd/common' import { + computeHealthBand, getAdvisoriesByPackageId, getPackageDetailByPurl, getStewardshipSummary, @@ -12,20 +12,18 @@ import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' -import { normalizePurl } from './purl' +import { purlQuerySchema } from './purl' import type { StewardshipStatus } from './types' -const querySchema = z.object({ - purl: z - .string() - .trim() - .min(1) - .refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' }) - .transform(normalizePurl), -}) +function repoMappingLabel(confidence: number | null): 'High' | 'Medium' | 'Low' | null { + if (confidence === null) return null + if (confidence >= 0.8) return 'High' + if (confidence >= 0.5) return 'Medium' + return 'Low' +} export async function getPackage(req: Request, res: Response): Promise { - const { purl } = validateOrThrow(querySchema, req.query) + const { purl } = validateOrThrow(purlQuerySchema, req.query) const qx = await getPackagesQx() const pkg = await getPackageDetailByPurl(qx, purl) @@ -39,17 +37,22 @@ export async function getPackage(req: Request, res: Response): Promise { pkg.stewardshipId ? getStewardshipSummary(qx, Number(pkg.stewardshipId)) : null, ]) + const scorecardScore = pkg.scorecardScore != null ? Number(pkg.scorecardScore) : null + const mappingConfidence = + pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null + ok(res, { purl: pkg.purl, name: pkg.name, ecosystem: pkg.ecosystem, + latestVersion: pkg.latestVersion ?? null, general: { - healthScore: null, + healthScore: scorecardScore !== null ? Math.round(scorecardScore * 10) : null, + healthBand: computeHealthBand(scorecardScore), impact: { impactScore: pkg.criticalityScore != null ? Math.round(Number(pkg.criticalityScore) * 100) : null, - downloadsLastMonth: - pkg.downloadsLast30d != null ? parseInt(pkg.downloadsLast30d, 10) : null, + downloadsLastMonth: pkg.downloadsLast30d ?? null, dependentPackages: pkg.dependentPackagesCount ?? null, dependentRepos: pkg.dependentReposCount ?? null, transitiveReach: pkg.transitiveReach, @@ -59,10 +62,12 @@ export async function getPackage(req: Request, res: Response): Promise { maintainerBusFactor: pkg.maintainerCount, lastRelease: pkg.latestReleaseAt ? pkg.latestReleaseAt.toISOString() : null, hasSecurityFile: pkg.hasSecurityFile, - openSSFScorecard: pkg.scorecardScore != null ? Number(pkg.scorecardScore) : null, + hasSecurityPolicy: pkg.hasSecurityPolicy, + branchProtectionEnabled: pkg.branchProtectionEnabled, + openSSFScorecard: scorecardScore, }, }, - assessment: {}, + assessment: null, security: { securityContacts: null, advisories: advisories.map((a) => ({ @@ -72,7 +77,6 @@ export async function getPackage(req: Request, res: Response): Promise { })), cvd: { isPvrEnabled: null, - hasSecurityPolicyEnabled: pkg.branchProtectionEnabled, tier0Steward: null, criticalVulnerabilityFlag: pkg.hasCriticalVulnerability, }, @@ -80,8 +84,8 @@ export async function getPackage(req: Request, res: Response): Promise { provenance: { repositoryMapping: { declaredRepo: pkg.repoUrl ?? pkg.repositoryUrl ?? pkg.declaredRepositoryUrl ?? null, - mappingConfidence: - pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null, + mappingConfidence, + mappingLabel: repoMappingLabel(mappingConfidence), lastCommitAt: pkg.repoLastCommitAt ? pkg.repoLastCommitAt.toISOString() : null, }, supplyChainIntegrity: { @@ -92,11 +96,15 @@ export async function getPackage(req: Request, res: Response): Promise { stewardship: { id: pkg.stewardshipId ?? null, status: (pkg.stewardshipStatus ?? 'unassigned') as StewardshipStatus, - stewards: stewardshipSummary?.stewards ?? null, - lastActivityAt: stewardshipSummary?.lastActivityAt ?? null, + origin: pkg.stewardshipOrigin ?? null, + version: pkg.stewardshipVersion ?? null, + openedAt: pkg.stewardshipOpenedAt ? pkg.stewardshipOpenedAt.toISOString() : null, + lastStatusAt: pkg.stewardshipLastStatusAt ? pkg.stewardshipLastStatusAt.toISOString() : null, resolutionPath: pkg.stewardshipResolutionPath ?? null, statusNote: pkg.stewardshipStatusNote ?? null, + stewards: stewardshipSummary?.stewards ?? null, + lastActivityAt: stewardshipSummary?.lastActivityAt ?? null, }, - history: {}, + history: null, }) } diff --git a/backend/src/api/public/v1/packages/getPackageAdvisories.ts b/backend/src/api/public/v1/packages/getPackageAdvisories.ts new file mode 100644 index 0000000000..f5a8a55965 --- /dev/null +++ b/backend/src/api/public/v1/packages/getPackageAdvisories.ts @@ -0,0 +1,32 @@ +import type { Request, Response } from 'express' + +import { NotFoundError } from '@crowd/common' +import { getAdvisoriesByPackageId, getPackageDetailByPurl } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +import { purlQuerySchema } from './purl' + +export async function getPackageAdvisories(req: Request, res: Response): Promise { + const { purl } = validateOrThrow(purlQuerySchema, req.query) + + const qx = await getPackagesQx() + const pkg = await getPackageDetailByPurl(qx, purl) + + if (!pkg) { + throw new NotFoundError() + } + + const advisories = await getAdvisoriesByPackageId(qx, pkg.id) + + ok(res, { + advisories: advisories.map((a) => ({ + osvId: a.osvId, + severity: a.severity, + resolution: a.resolution, + })), + total: advisories.length, + }) +} diff --git a/backend/src/api/public/v1/packages/getPackageHistory.ts b/backend/src/api/public/v1/packages/getPackageHistory.ts new file mode 100644 index 0000000000..cb3322d921 --- /dev/null +++ b/backend/src/api/public/v1/packages/getPackageHistory.ts @@ -0,0 +1,30 @@ +import type { Request, Response } from 'express' + +import { NotFoundError } from '@crowd/common' +import { getPackageDetailByPurl, listPackageHistory } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +import { purlQuerySchema } from './purl' + +export async function getPackageHistory(req: Request, res: Response): Promise { + const { purl } = validateOrThrow(purlQuerySchema, req.query) + + const qx = await getPackagesQx() + const pkg = await getPackageDetailByPurl(qx, purl) + + if (!pkg) { + throw new NotFoundError() + } + + if (!pkg.stewardshipId) { + ok(res, { events: [], total: 0 }) + return + } + + const events = await listPackageHistory(qx, pkg.stewardshipId) + + ok(res, { events, total: events.length }) +} diff --git a/backend/src/api/public/v1/packages/purl.ts b/backend/src/api/public/v1/packages/purl.ts index 1159659be4..0a80e8866f 100644 --- a/backend/src/api/public/v1/packages/purl.ts +++ b/backend/src/api/public/v1/packages/purl.ts @@ -13,6 +13,17 @@ * This is always the version separator, not an npm scope (pkg:npm/@babel/core * has @babel followed by /core, so it never matches the end-of-string pattern). */ +import { z } from 'zod' + +export const purlQuerySchema = z.object({ + purl: z + .string() + .trim() + .min(1) + .refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' }) + .transform(normalizePurl), +}) + export function normalizePurl(purl: string): string { const withoutQualifiers = purl.replace(/[?#].*$/, '') const withoutVersion = withoutQualifiers.replace(/@[^/@]+$/, '') diff --git a/backend/src/api/public/v1/stewardships/assignSteward.ts b/backend/src/api/public/v1/stewardships/assignSteward.ts index 0359e4bbf1..e48ada8a39 100644 --- a/backend/src/api/public/v1/stewardships/assignSteward.ts +++ b/backend/src/api/public/v1/stewardships/assignSteward.ts @@ -15,17 +15,19 @@ const paramsSchema = z.object({ const bodySchema = z.object({ userId: z.string().trim().min(1), role: z.enum(['lead', 'co_steward']), + note: z.string().trim().min(1).optional(), moveToAssessing: z.boolean().optional().default(false), }) export async function assignStewardHandler(req: Request, res: Response): Promise { const { id } = validateOrThrow(paramsSchema, req.params) - const { userId, role, moveToAssessing } = validateOrThrow(bodySchema, req.body) + const { userId, role, note, moveToAssessing } = validateOrThrow(bodySchema, req.body) const qx = await getPackagesQx() const result = await assignSteward(qx, id, { userId, role, + note, assignedBy: req.actor.id, moveToAssessing, }) diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 337d2a8cf5..45b5f4c388 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -51,7 +51,6 @@ export async function getPackagesByStewardshipPurls( // TODO[deprecate]: rename to AkritesMetrics once /v1/ossprey is removed export interface OsspreyMetrics { - totalPackages: number criticalPackages: number coveragePercent: number coverageTrend: number | null @@ -65,7 +64,6 @@ export interface OsspreyMetrics { export async function getOsspreyMetrics(qx: QueryExecutor): Promise { const [counts, stewardRow]: [ { - totalPackages: string criticalPackages: string covered: string needsAttention: string @@ -76,8 +74,7 @@ export async function getOsspreyMetrics(qx: QueryExecutor): Promise 0 ? Math.round((covered / total) * 1000) / 10 : 0, coverageTrend: null, // TODO: requires snapshot mechanism or stewardship_activity timestamp analysis activeStewards: parseInt(stewardRow.count, 10), @@ -557,6 +553,9 @@ export interface PackageDetailRow { stewardshipLastStatusAt: Date | null stewardshipResolutionPath: string | null stewardshipStatusNote: string | null + stewardshipOrigin: string | null + stewardshipVersion: number | null + stewardshipOpenedAt: Date | null // from package_repos + repos repoUrl: string | null repoMappingConfidence: number | null @@ -602,6 +601,9 @@ export async function getPackageDetailByPurl( s.last_status_at AS "stewardshipLastStatusAt", s.resolution_path AS "stewardshipResolutionPath", s.status_note AS "stewardshipStatusNote", + s.origin AS "stewardshipOrigin", + s.version AS "stewardshipVersion", + s.opened_at AS "stewardshipOpenedAt", -- best repo link (highest confidence, prefer declared) r.url AS "repoUrl", pr.confidence AS "repoMappingConfidence", @@ -692,7 +694,6 @@ export async function listPackagesForScatter(qx: QueryExecutor): Promise { @@ -222,7 +223,11 @@ export async function assignSteward( stewardshipId, actorUserId: data.assignedBy, content: `Assigned steward ${data.userId} as ${data.role}`, - metadata: JSON.stringify({ userId: data.userId, role: data.role }), + metadata: JSON.stringify({ + userId: data.userId, + role: data.role, + ...(data.note ? { note: data.note } : {}), + }), }, ) @@ -387,6 +392,44 @@ export async function listStewardshipActivity( } } +export interface PackageHistoryEvent { + id: string + actorUserId: string | null + actorType: string + activityType: string + content: string | null + metadata: Record | null + createdAt: string +} + +export async function listPackageHistory( + qx: QueryExecutor, + stewardshipId: string, +): Promise { + const rows: Array> = await qx.select( + `SELECT id::text AS id, + actor_user_id AS "actorUserId", + actor_type AS "actorType", + activity_type AS "activityType", + content, + metadata, + created_at AS "createdAt" + FROM stewardship_activity + WHERE stewardship_id = $(stewardshipId)::bigint + ORDER BY created_at DESC`, + { stewardshipId }, + ) + return rows.map((r) => ({ + id: r.id as string, + actorUserId: r.actorUserId ? String(r.actorUserId) : null, + actorType: String(r.actorType), + activityType: String(r.activityType), + content: r.content ? String(r.content) : null, + metadata: r.metadata as Record | null, + createdAt: toIso(r.createdAt), + })) +} + export const ESCALATION_RESOLUTION_PATHS = [ 'right_of_first_refusal', 'replace_the_dependency', From 0a42480a5a5669819689f82fa20d164a3ad27fdd Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 17 Jun 2026 09:39:11 +0200 Subject: [PATCH 6/7] fix: scatter limit Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/akrites/index.ts | 15 +++----- .../src/api/public/v1/akrites/openapi.yaml | 37 +++++++++++++++++-- backend/src/api/public/v1/packages/purl.ts | 16 ++++---- .../public/v1/stewardships/openStewardship.ts | 9 +---- .../data-access-layer/src/osspckgs/api.ts | 1 + 5 files changed, 51 insertions(+), 27 deletions(-) diff --git a/backend/src/api/public/v1/akrites/index.ts b/backend/src/api/public/v1/akrites/index.ts index 70492455c0..455626b95d 100644 --- a/backend/src/api/public/v1/akrites/index.ts +++ b/backend/src/api/public/v1/akrites/index.ts @@ -20,10 +20,7 @@ import { escalateHandler } from '../stewardships/escalate' import { openStewardship } from '../stewardships/openStewardship' import { updateStatusHandler } from '../stewardships/updateStatus' -// Separate instances match the original per-router isolation: packages and stewardships each had -// their own createRateLimiter() call, giving independent 60 req/min buckets per IP. -const packagesRateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) -const stewardshipsRateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) +const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 }) export function akritesRouter(): Router { const router = Router() @@ -32,20 +29,20 @@ export function akritesRouter(): Router { // /packages/scatter registered before router.use('/packages', ...) so Express evaluates this // explicit route first; without this ordering the sub-router would receive the request first // and call next() on no match, adding unnecessary overhead. - router.get('/packages/scatter', packagesRateLimiter, safeWrap(packageScatterHandler)) - router.get('/packages', packagesRateLimiter, safeWrap(packageListHandler)) + router.get('/packages/scatter', rateLimiter, safeWrap(packageScatterHandler)) + router.get('/packages', rateLimiter, safeWrap(packageListHandler)) router.get('/activity', safeWrap(activityFeedHandler)) // --- packages --- router.post( /^\/packages:batch-stewardship\/?$/, - packagesRateLimiter, + rateLimiter, // TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant // requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'), safeWrap(batchGetStewardship), ) const packagesSubRouter = Router() - packagesSubRouter.use(packagesRateLimiter) + packagesSubRouter.use(rateLimiter) packagesSubRouter.get( '/metrics', // TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant @@ -74,7 +71,7 @@ export function akritesRouter(): Router { // --- stewardships --- const stewardshipsSubRouter = Router() - stewardshipsSubRouter.use(stewardshipsRateLimiter) + stewardshipsSubRouter.use(rateLimiter) stewardshipsSubRouter.post( '/open', // TODO: restore once write:stewardships is added to Auth0 staging tenant diff --git a/backend/src/api/public/v1/akrites/openapi.yaml b/backend/src/api/public/v1/akrites/openapi.yaml index 48ca282a11..f0a7f1313e 100644 --- a/backend/src/api/public/v1/akrites/openapi.yaml +++ b/backend/src/api/public/v1/akrites/openapi.yaml @@ -143,15 +143,26 @@ components: StewardEntry: type: object - required: [userId, role, assignedAt] + required: [id, stewardshipId, userId, role, assignedAt] properties: + id: + type: string + stewardshipId: + type: string userId: type: string + name: + type: string + nullable: true role: type: string + enum: [lead, co_steward] assignedAt: type: string format: date-time + assignedBy: + type: string + nullable: true # ── Dashboard schemas ────────────────────────────────────────────────────── @@ -311,9 +322,29 @@ components: StatusCounts: type: object description: Count of packages per stewardship status (used to drive filter pill badges). - additionalProperties: - type: integer + required: [all, unassigned, open, assessing, active, needs_attention, escalated, blocked, inactive] + properties: + all: + type: integer + description: Total count across all statuses (matches total from the package list without status filter). + 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 example: + all: 95 unassigned: 42 open: 5 assessing: 12 diff --git a/backend/src/api/public/v1/packages/purl.ts b/backend/src/api/public/v1/packages/purl.ts index 0a80e8866f..7e15b852b5 100644 --- a/backend/src/api/public/v1/packages/purl.ts +++ b/backend/src/api/public/v1/packages/purl.ts @@ -15,14 +15,14 @@ */ import { z } from 'zod' -export const purlQuerySchema = z.object({ - purl: z - .string() - .trim() - .min(1) - .refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' }) - .transform(normalizePurl), -}) +export const purlFieldSchema = z + .string() + .trim() + .min(1) + .refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' }) + .transform(normalizePurl) + +export const purlQuerySchema = z.object({ purl: purlFieldSchema }) export function normalizePurl(purl: string): string { const withoutQualifiers = purl.replace(/[?#].*$/, '') diff --git a/backend/src/api/public/v1/stewardships/openStewardship.ts b/backend/src/api/public/v1/stewardships/openStewardship.ts index 77643f7ba3..18a9828df2 100644 --- a/backend/src/api/public/v1/stewardships/openStewardship.ts +++ b/backend/src/api/public/v1/stewardships/openStewardship.ts @@ -8,15 +8,10 @@ import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' -import { normalizePurl } from '../packages/purl' +import { purlFieldSchema } from '../packages/purl' const bodySchema = z.object({ - purl: z - .string() - .trim() - .min(1) - .refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' }) - .transform(normalizePurl), + purl: purlFieldSchema, }) export async function openStewardship(req: Request, res: Response): Promise { diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 45b5f4c388..04c29ed1b6 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -695,6 +695,7 @@ export async function listPackagesForScatter(qx: QueryExecutor): Promise ({ From f85e45b748ffd4bc6f5a60d65a623046b69756ee Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 17 Jun 2026 09:41:33 +0200 Subject: [PATCH 7/7] fix: lint Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/akrites/openapi.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/api/public/v1/akrites/openapi.yaml b/backend/src/api/public/v1/akrites/openapi.yaml index f0a7f1313e..d609a532d8 100644 --- a/backend/src/api/public/v1/akrites/openapi.yaml +++ b/backend/src/api/public/v1/akrites/openapi.yaml @@ -322,7 +322,8 @@ components: StatusCounts: type: object description: Count of packages per stewardship status (used to drive filter pill badges). - required: [all, unassigned, open, assessing, active, needs_attention, escalated, blocked, inactive] + required: + [all, unassigned, open, assessing, active, needs_attention, escalated, blocked, inactive] properties: all: type: integer