Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions internal/server/handle_p2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package server

import (
"net/http"
"strings"
)

func handleP2(store AdvisoriesMarshaler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vendor := r.PathValue("vendor")
vendor = strings.ToLower(vendor)

file := r.PathValue("file")
file = strings.ToLower(file)
if !strings.HasSuffix(file, ".json") {
http.NotFound(w, r)
return
}
if strings.HasSuffix(file, "~dev.json") {
http.NotFound(w, r)
return
}

slug := strings.TrimSuffix(file, ".json")

advisories, err := store.MarshalAdvisoriesFor(vendor, slug)
if err != nil {
http.NotFound(w, r)
return
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

w.Write([]byte(`{"packages":[],"security-advisories":`))
//gosec:disable G705 -- Advisories bytes originate from trusted embedded asset files
w.Write(advisories)
w.Write([]byte(`}`))
}
}
89 changes: 64 additions & 25 deletions internal/server/handle_p2_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package server

import (
_ "embed"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -10,38 +9,78 @@ import (
func TestP2(t *testing.T) {
t.Parallel()

methods := []string{http.MethodGet, http.MethodPost}

tests := []struct {
name string
path string
name string
data map[string][]byte
path string
wantCode int
wantCT string
wantBody string
}{
{"stable", "/p2/foo/bar.json"},
{"dev", "/p2/foo/bar~dev.json"},
{
name: "known/stable",
data: map[string][]byte{
"foo/bar": []byte(`[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]`),
},
path: "/p2/foo/bar.json",
wantCode: http.StatusOK,
wantCT: "application/json",
wantBody: `{"packages":[],"security-advisories":[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]}`,
},
{
name: "known/dev",
data: map[string][]byte{
"foo/bar": []byte(`[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]`),
},
path: "/p2/foo/bar~dev.json",
wantCode: http.StatusNotFound,
},
{
name: "unknown/stable",
data: map[string][]byte{
"foo/bar": []byte(`[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]`),
},
path: "/p2/baz/qux.json",
wantCode: http.StatusNotFound,
},
{
name: "unknown/dev",
path: "/p2/foo/bar~dev.json",
wantCode: http.StatusNotFound,
},
{name: "no_vendor/stable", path: "/p2/bar.json", wantCode: http.StatusNotFound},
{name: "no_vendor/dev", path: "/p2/bar~dev.json", wantCode: http.StatusNotFound},
{name: "no_file", path: "/p2/foo/", wantCode: http.StatusNotFound},
{name: "no_slug/stable", path: "/p2/foo/.json", wantCode: http.StatusNotFound},
{name: "no_slug/dev", path: "/p2/foo/~dev.json", wantCode: http.StatusNotFound},
{name: "no_extension/stable", path: "/p2/foo/bar", wantCode: http.StatusNotFound},
{name: "no_extension/dev", path: "/p2/foo/bar~dev", wantCode: http.StatusNotFound},
{name: "not_json/stable", path: "/p2/foo/bar.txt", wantCode: http.StatusNotFound},
{name: "not_json/dev", path: "/p2/foo/bar~dev.txt", wantCode: http.StatusNotFound},
}

for _, method := range methods {
t.Run(method, func(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

srv := newTestServerWithData(tt.data)
req := httptest.NewRequest(http.MethodGet, tt.path, http.NoBody)
rec := httptest.NewRecorder()

srv := newTestServer()
req := httptest.NewRequest(method, tt.path, http.NoBody)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)

srv.ServeHTTP(rec, req)
if rec.Code != tt.wantCode {
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
}

Comment on lines +65 to 75
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The p2 tests no longer assert the Cache-Control: max-age=86400 header, even though the spec requires it for both stable and dev paths and other server tests verify Cache-Control behavior. Consider re-adding the Cache-Control assertion here to prevent regressions in caching semantics.

Copilot uses AI. Check for mistakes.
if rec.Code != http.StatusNotFound {
t.Errorf("status code = %d, want %d", rec.Code, http.StatusNotFound)
}
gotCT := rec.Header().Get("Content-Type")
if tt.wantCT != "" && gotCT != tt.wantCT {
t.Errorf("Content-Type = %q, want %q", gotCT, tt.wantCT)
}
Comment on lines +70 to +79
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_p2_test.go no longer asserts the Cache-Control: max-age=86400 header for the new /p2/ endpoints, even though the spec explicitly requires it for both stable and ~dev paths. Please add an assertion (at least for one 200 case and one 404 case) so a future middleware/route change can’t silently drop the required caching header.

Copilot uses AI. Check for mistakes.

Comment on lines +70 to 80
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 routes are required (per spec) to include Cache-Control: max-age=86400 for both stable and ~dev paths, but this test no longer asserts that header. Add an assertion for Cache-Control (ideally for all cases, including 404) so regressions in middleware/route wrapping get caught.

Copilot uses AI. Check for mistakes.
gotCC := rec.Header().Get("Cache-Control")
wantCC := "max-age=86400"
if gotCC != wantCC {
t.Errorf("Cache-Control header = %q, want %q", gotCC, wantCC)
}
})
gotBody := rec.Body.String()
if tt.wantBody != "" && gotBody != tt.wantBody {
t.Errorf("body = %q, want %q", gotBody, tt.wantBody)
}
Comment on lines +70 to 84
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The p2 tests no longer assert the Cache-Control: max-age=86400 header even though the spec/requirements in this PR call it out and other route tests (e.g. static handlers) verify Cache-Control explicitly. Adding an assertion here would prevent regressions if middleware/route wiring changes.

Copilot uses AI. Check for mistakes.
})
}
Expand Down
2 changes: 2 additions & 0 deletions internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ func addRoutes(mux *http.ServeMux, store AdvisoriesMarshaler, modTime time.Time)
mux.HandleFunc("GET /api/security-advisories/{$}", withCacheControl("max-age=3600")(m(hAdvs)))
mux.HandleFunc("POST /api/security-advisories/{$}", withCacheControl("max-age=3600")(hAdvs))

mux.HandleFunc("GET /p2/{vendor}/{file}", handleP2(store))
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addRoutes sets up m := withConditionalGet(modTime) and applies it to other GET endpoints (/api/security-advisories/ and static files), but the new /p2/ GET route isn't wrapped. This means no Last-Modified header and no If-Modified-Since short-circuit for p2 responses, which can increase bandwidth and is inconsistent with other cacheable GET routes. Consider registering this route as m(handleP2(store)) (and/or through the same wrapper pattern used elsewhere).

Suggested change
mux.HandleFunc("GET /p2/{vendor}/{file}", handleP2(store))
mux.HandleFunc("GET /p2/{vendor}/{file}", m(handleP2(store)))

Copilot uses AI. Check for mistakes.

// Health check.
hUp := withCacheControl("no-store")(http.HandlerFunc(handleUp))
mux.HandleFunc("GET /up", hUp)
Expand Down
2 changes: 1 addition & 1 deletion internal/server/static/packages.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"metadata-url": "/p2/%package%.json",
"security-advisories": {
"metadata": false,
"metadata": true,
"api-url": "/api/security-advisories/"
},
"infos": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-31
81 changes: 81 additions & 0 deletions openspec/changes/archive/2026-04-02-add-p2-route/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
## Context

The server uses stdlib `net/http.ServeMux` with Go 1.22+ method+path patterns.
All response JSON is produced as pre-serialized `[]byte` — handlers write raw
bytes directly rather than marshalling structs at request time. Advisory data
is embedded in the binary at compile time as per-slug JSON files.

The `AdvisoriesMarshaler` interface is the seam between the HTTP layer and the
data layer. The `data.Store` already knows how to map `(vendor, slug)` to a
JSON array of full `Advisory` objects.

Currently both `/p2/{vendor}/{slug}.json` and `/p2/{vendor}/{slug}~dev.json`
fall through to the default 404, which is wrapped by the global
`withCacheControl("max-age=86400")` middleware.

## Goals / Non-Goals

**Goals:**
- Serve `{ "packages": [], "security-advisories": [...] }` at the stable p2 path
- Return 404 for the dev p2 path
- Enable `"metadata": true` in `packages.json`

**Non-Goals:**
- Serving actual package version metadata (`packages` stays always-empty)
- Supporting `~dev.json` advisory lookups
- Changing how the existing `/api/security-advisories/` endpoint works

## Decisions

### Route registration: single handler, not two routes

Go 1.22 ServeMux wildcards match whole path segments. The last segment of
`/p2/foo/bar.json` is `bar.json` and of `/p2/foo/bar~dev.json` is
`bar~dev.json` — there is no way to write two patterns with a per-segment
wildcard that distinguish these at the routing layer alone.

**Decision:** Register one route — `GET /p2/{vendor}/{file}` — and dispatch
inside the handler on whether `{file}` ends in `~dev.json` (→ 404), ends in
`.json` (→ serve advisories, slug = file without `.json` suffix), or anything
else (→ 404).

Considered: registering `GET /p2/{vendor}/{slug}~dev.json` as a literal-suffix
pattern. Rejected because Go ServeMux does not support literal suffixes within
a wildcard segment.

### Reuse `AdvisoriesMarshaler`

The p2 handler accepts the existing `AdvisoriesMarshaler` interface.
`MarshalAdvisoriesFor(vendor, slug)` returns a JSON array of full `Advisory`
objects; that array is used verbatim as the `security-advisories` value in the
p2 response — no transformation needed.

**Decision:** No new interface. The p2 handler takes `AdvisoriesMarshaler`
directly and writes the returned bytes straight into the response body.

Considered: a separate `P2AdvisoriesMarshaler` interface returning only
`advisoryId` + `affectedVersions`. Rejected: unnecessary complexity; Composer
ignores unknown fields, so serving the full advisory objects is harmless and
keeps the data layer unchanged.

### Response assembly: inline byte writes

Consistent with `handleAdvisories`, the p2 handler assembles the response with
direct `w.Write` calls using pre-built byte literals and the `[]byte` from
`MarshalAdvisoriesFor`, avoiding any per-request struct marshalling.

```
{"packages":[],"security-advisories": <bytes from store> }
```

### Cache-Control

The global `withCacheControl("max-age=86400")` wrapper already covers all
responses including 404s, matching the existing test expectation. No per-route
override needed.

## Risks / Trade-offs

- **Test breakage** → `handle_p2_test.go` currently asserts `StatusNotFound`
for the stable path. That assertion must change to `StatusOK`. Mitigation:
the test file is small and the change is mechanical.
35 changes: 35 additions & 0 deletions openspec/changes/archive/2026-04-02-add-p2-route/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

Composer clients can discover security advisories directly from Packagist v2
`/p2/{vendor}/{slug}.json` endpoints when a repository sets `"metadata": true`.
This change implements those endpoints and enables the flag, giving Composer a
standards-compliant path to per-package advisory data.

## What Changes

- Add `GET /p2/{vendor}/{slug}.json` — responds with Packagist v2 metadata:
`packages: []` (always empty; no version metadata served) and a
`security-advisories` array of `{ advisoryId, affectedVersions }` objects
drawn from `AdvisoriesMarshaler.MarshalAdvisoriesFor(vendor, slug)`
- Add `GET /p2/{vendor}/{slug}~dev.json` — always returns 404 (dev/branch
aliases carry no advisory metadata)
- Update `internal/server/static/packages.json` — flip `"metadata": false`
to `"metadata": true` so Composer clients use the new endpoints

## Capabilities

### New Capabilities
- `p2-package-metadata`: Packagist v2 per-package endpoint that surfaces
security advisories in the format Composer expects at `/p2/{vendor}/{slug}.json`

### Modified Capabilities

(none)

## Impact

- `internal/server/routes.go` — new route registrations for `/p2/` paths
- `internal/server/handle_p2.go` — new handler file
- `internal/server/static/packages.json` — `metadata` flag update
- `internal/server/handle_p2_test.go` — existing tests assert 404 for both
paths; stable path assertion changes to 200
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## ADDED Requirements

### Requirement: Stable p2 path returns security advisories
The server SHALL respond to `GET /p2/{vendor}/{slug}.json` with HTTP 200 and a
JSON body containing `"packages": []` and a `"security-advisories"` array
populated from `AdvisoriesMarshaler.MarshalAdvisoriesFor(vendor, slug)`.

#### Scenario: Known package with advisories
- **WHEN** a GET request is made to `/p2/{vendor}/{slug}.json` for a vendor/slug that has advisories
- **THEN** the response status SHALL be 200
- **THEN** the response body SHALL be `{"packages":[],"security-advisories":[...]}` where the array contains the advisory objects for that package

#### Scenario: Unknown package
- **WHEN** a GET request is made to `/p2/{vendor}/{slug}.json` for a vendor/slug with no known advisories
- **THEN** the response status SHALL be 404

#### Scenario: Cache-Control header on stable path
- **WHEN** a GET request is made to `/p2/{vendor}/{slug}.json`
- **THEN** the response SHALL include a `Cache-Control: max-age=86400` header

### Requirement: Dev p2 path always returns 404
The server SHALL respond to `GET /p2/{vendor}/{slug}~dev.json` with HTTP 404
regardless of vendor or slug.

#### Scenario: Dev path returns 404
- **WHEN** a GET request is made to `/p2/{vendor}/{slug}~dev.json`
- **THEN** the response status SHALL be 404

#### Scenario: Cache-Control header on dev path
- **WHEN** a GET request is made to `/p2/{vendor}/{slug}~dev.json`
- **THEN** the response SHALL include a `Cache-Control: max-age=86400` header

### Requirement: Composer repository metadata enables p2 advisory discovery
The `packages.json` repository metadata SHALL set `"metadata": true` in the
`security-advisories` block so that Composer clients look up advisories via
the `/p2/` endpoints.

#### Scenario: packages.json reports metadata enabled
- **WHEN** a client fetches `/packages.json`
- **THEN** the response body SHALL contain `"metadata": true` inside the `security-advisories` object
26 changes: 26 additions & 0 deletions openspec/changes/archive/2026-04-02-add-p2-route/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## 1. Configuration

- [x] 1.1 Update `internal/server/static/packages.json`: change `"metadata": false` to `"metadata": true`

## 2. Handler

- [x] 2.1 Create `internal/server/handle_p2.go` with a `handleP2(store AdvisoriesMarshaler) http.HandlerFunc`
- [x] 2.2 Dispatch on the `{file}` path value: suffix `~dev.json` → 404; suffix `.json` → extract slug and continue; anything else → 404
- [x] 2.3 Call `store.MarshalAdvisoriesFor(vendor, slug)`; on error return 404
- [x] 2.4 On success write `{"packages":[],"security-advisories":<bytes>}` with status 200 and `Content-Type: application/json`

## 3. Routing

- [x] 3.1 Register `GET /p2/{vendor}/{file}` in `internal/server/routes.go` wired to `handleP2(store)`

## 4. Tests

- [x] 4.1 Update `handle_p2_test.go`: known-package stable path now expects 200 with correct JSON body
- [x] 4.2 Add test case: unknown-package stable path expects 404
- [x] 4.3 Confirm dev path case still expects 404 (no change needed, verify only)
- [x] 4.4 Update `stubStore` in `server_test.go` if needed to support p2 test data

## 5. Verification

- [x] 5.1 Run `mise run test:unit` — all tests pass
- [x] 5.2 Run `mise run lint` — no lint errors
20 changes: 20 additions & 0 deletions openspec/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
schema: spec-driven

# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform

# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
Loading
Loading