#11 Manual review/removal path for maintainers

Add an authenticated admin API to the ingest Worker so the single maintainer
can review the pending queue and pull a report before Friday's publish run.

Endpoints (on the existing handler):
  GET    /v1/admin/reports              list pending report ids
  POST   /v1/admin/reports/{id}/remove  mark a report removed
  DELETE /v1/admin/reports/{id}         remove alias

Remove calls lifecycle.MarkRemoved (#10), transitioning pending -> removed so
#13's ListPending excludes it from the next publish. Codes: 200 list/remove,
404 unknown id, 401 missing/bad/unset-secret token, 405 wrong method.

Auth: shared-secret Bearer token compared with crypto/subtle.ConstantTimeCompare,
fail-closed when the secret is unset. Injected via handler.New's new AdminBackend
arg: the dev server and tests wire a memory-backed lifecycle.Manager + ADMIN_TOKEN
env; the Worker reads ADMIN_TOKEN from Secrets Store and builds an R2-backed
Manager per request. Choice documented in docs/decisions/admin-auth.md.

Tests: Go httptest unit tests (list, remove+exclusion, 404, 401 incl. fail-closed,
405) and a Bruno api-tests flow (seed, authed list/remove, exclusion, no/bad
token 401). wrangler.jsonc gains only the ADMIN_TOKEN secret binding; worker
triggers untouched (owned by #13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:56:59 -05:00
committed by Jason Ross
co-authored by Claude Opus 4.8
parent 2b65dcfe6a
commit 047391d01c
18 changed files with 862 additions and 10 deletions
+30
View File
@@ -0,0 +1,30 @@
info:
name: "admin: authenticated list of pending reports"
# GET /v1/admin/reports with a valid Bearer token returns the pending ids.
# Captures the first id into the `removeId` run variable for the remove test.
type: http
seq: 7
http:
method: GET
url: "{{baseUrl}}/v1/admin/reports"
headers:
- name: Authorization
value: "Bearer {{adminToken}}"
runtime:
scripts:
- type: tests
code: |-
test("authenticated list returns 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("body has status ok and a reports array", function () {
expect(res.getBody().status).to.equal("ok");
expect(res.getBody().reports).to.be.an("array");
});
test("at least one pending report is listed (the seed)", function () {
expect(res.getBody().reports.length).to.be.at.least(1);
});
// Remember an id to remove in the next request.
bru.setVar("removeId", res.getBody().reports[0]);
+24
View File
@@ -0,0 +1,24 @@
info:
name: "admin: removed report is excluded from the pending list"
# Re-list after the remove: the removed id must no longer appear, proving it is
# excluded from the next weekly publish run (the ticket's acceptance criterion).
type: http
seq: 9
http:
method: GET
url: "{{baseUrl}}/v1/admin/reports"
headers:
- name: Authorization
value: "Bearer {{adminToken}}"
runtime:
scripts:
- type: tests
code: |-
test("list returns 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("the removed id is no longer pending", function () {
expect(res.getBody().reports).to.not.include(bru.getVar("removeId"));
});
+25
View File
@@ -0,0 +1,25 @@
info:
name: "admin: authenticated remove of a pending report"
# POST /v1/admin/reports/{id}/remove with a valid Bearer token transitions the
# report pending -> removed (#10), excluding it from the next weekly publish.
type: http
seq: 8
http:
method: POST
url: "{{baseUrl}}/v1/admin/reports/{{removeId}}/remove"
headers:
- name: Authorization
value: "Bearer {{adminToken}}"
runtime:
scripts:
- type: tests
code: |-
test("authenticated remove returns 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("body confirms the removed id", function () {
expect(res.getBody().status).to.equal("removed");
expect(res.getBody().id).to.equal(bru.getVar("removeId"));
});
+32
View File
@@ -0,0 +1,32 @@
info:
name: "admin: seed a pending report to review"
# Seeds one pending report so the admin list/remove flow below is self-contained
# (it does not depend on the earlier ingest contract tests having run first).
type: http
seq: 6
http:
method: POST
url: "{{baseUrl}}/v1/reports"
headers:
- name: Content-Type
value: application/json
body:
type: json
data: |-
{
"appVersion": "1.4.2 (142)",
"platform": "android",
"osVersion": "Android 14",
"device": "Pixel 7",
"clientTimestamp": "2026-07-02T12:34:56Z",
"report": "seed report for the admin review/removal flow (#11)"
}
runtime:
scripts:
- type: tests
code: |-
test("seed report is accepted (202)", function () {
expect(res.getStatus()).to.equal(202);
});
@@ -0,0 +1,20 @@
info:
name: "admin: wrong token is rejected (401)"
# A well-formed Bearer header carrying the wrong secret -> rejected.
type: http
seq: 11
http:
method: GET
url: "{{baseUrl}}/v1/admin/reports"
headers:
- name: Authorization
value: "Bearer not-the-admin-token"
runtime:
scripts:
- type: tests
code: |-
test("a request with the wrong bearer token returns 401", function () {
expect(res.getStatus()).to.equal(401);
});
+20
View File
@@ -0,0 +1,20 @@
info:
name: "admin: missing token is rejected (401)"
# No Authorization header -> the admin API rejects the request.
type: http
seq: 10
http:
method: GET
url: "{{baseUrl}}/v1/admin/reports"
runtime:
scripts:
- type: tests
code: |-
test("a request with no bearer token returns 401", function () {
expect(res.getStatus()).to.equal(401);
});
test("401 advertises the Bearer challenge", function () {
expect(res.getHeader("www-authenticate")).to.equal("Bearer");
});
+6
View File
@@ -3,3 +3,9 @@ name: local
variables:
- name: baseUrl
value: http://localhost:8787
# Shared secret for the maintainer admin API (#11). The dev server must be
# started with ADMIN_TOKEN set to this same value (see package.json
# "test:api"): `ADMIN_TOKEN=local-dev-admin-token go run ./cmd/devserver`.
# This is a throwaway local-only token, never a production secret.
- name: adminToken
value: local-dev-admin-token
+21 -2
View File
@@ -10,6 +10,13 @@
// by an in-memory object store and a throwaway per-run AES-256 key, so a POST
// /v1/reports exercises the full pipeline locally. Stored objects live only for
// the process lifetime.
//
// The maintainer admin API (#11) is wired over a lifecycle.Manager sharing that
// same in-memory store, so reports ingested via POST /v1/reports are immediately
// listable and removable under /v1/admin/reports. Its shared-secret bearer token
// comes from the ADMIN_TOKEN env var; if unset, the admin routes fail closed
// (every request 401s), matching production's fail-closed behaviour. Set
// ADMIN_TOKEN=... to exercise the admin API (the Bruno api-tests do this).
package main
import (
@@ -19,6 +26,7 @@ import (
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/handler"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
@@ -38,10 +46,21 @@ func main() {
if err != nil {
log.Fatalf("devserver: build keyring: %v", err)
}
sink := storage.NewSink(storage.NewMemoryStore(), keyring)
// One shared in-memory store backs both the ingest Sink and the admin
// lifecycle Manager, so an ingested report is visible to the admin API.
store := storage.NewMemoryStore()
sink := storage.NewSink(store, keyring)
adminToken := os.Getenv("ADMIN_TOKEN")
admin := handler.NewManagerBackend(lifecycle.New(store), adminToken)
if adminToken == "" {
log.Print("devserver: ADMIN_TOKEN is unset; /v1/admin routes fail closed (401). Set ADMIN_TOKEN to enable them.")
} else {
log.Print("devserver: admin API enabled at /v1/admin/reports (bearer token from ADMIN_TOKEN)")
}
log.Printf("devserver listening on %s (try GET / and GET /healthz)", addr)
if err := http.ListenAndServe(addr, handler.New(sink)); err != nil {
if err := http.ListenAndServe(addr, handler.New(sink, admin)); err != nil {
log.Fatalf("devserver: %v", err)
}
}
+76
View File
@@ -0,0 +1,76 @@
# ADR 0003: Authentication for the maintainer admin API
- **Status:** Accepted
- **Date:** 2026-07-02
- **Deciders:** Maintainer (single-maintainer project)
- **Ticket:** [#11](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/11) — Manual review/removal path for maintainers
- **Depends on:** [#10](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/10) (report lifecycle: `MarkRemoved`)
- **Related:** [#13](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/13) (weekly publish reads `ListPending`; a removed report is excluded)
---
## Context
The weekly job publishes every still-pending report as a GitHub issue. Before that
run the single maintainer needs an authenticated way to (a) list the pending
reports and (b) remove a specific one so it is never published. This adds two
admin endpoints to the existing Worker HTTP handler:
```
GET /v1/admin/reports -> 200 {"status":"ok","reports":[<id>...]}
POST /v1/admin/reports/{id}/remove -> 200 {"status":"removed","id":<id>}
DELETE /v1/admin/reports/{id} -> 200 (REST alias of the POST above)
```
`remove` calls `lifecycle.Manager.MarkRemoved` (#10), transitioning the report
`pending -> removed`; #13's `ListPending` then no longer returns it, so it is
excluded from the next publish run. These endpoints are destructive and expose
report ids, so they must be authenticated. The endpoints run in a Cloudflare
Worker (Go/TinyGo/Wasm) with secrets in Cloudflare Secrets Store.
## Decision
**Authenticate with a shared-secret Bearer token**, compared in constant time.
- Every admin request must send `Authorization: Bearer <token>`.
- The presented token is compared to the configured secret with
`crypto/subtle.ConstantTimeCompare`, so a wrong guess leaks no timing signal.
- The scheme (`Bearer`) is matched case-insensitively per RFC 7235; anything else
(missing header, wrong scheme, wrong token) returns **401** with a
`WWW-Authenticate: Bearer` challenge. A wrong method on an admin path returns
**405**; an unknown/never-pending id returns **404**.
- **Fail closed:** if the server has no secret configured (unset or empty), every
admin request is rejected with 401 regardless of what the client sends. A
missing secret binding can therefore never silently disable authentication.
- **Secret custody:** in production the secret is the Cloudflare Secrets Store
binding `ADMIN_TOKEN` (wrangler.jsonc `secrets_store_secrets`), read per request
(like the encryption keyring in ADR #5) and never logged or echoed. The dev
server and tests inject the token directly (env var `ADMIN_TOKEN` for the dev
server), so the exact same handler is exercised locally.
## Alternatives considered
- **Cloudflare Access (Zero Trust) in front of the route.** Strong (SSO, device
posture, short-lived JWTs, per-request audit) and requires no app-side secret.
**Rejected for v1:** it needs a Zero Trust org, an application, and an access
policy to be provisioned and maintained, and it complicates scripted/`curl`/CI
access — disproportionate for a single maintainer removing the occasional
report. It remains the natural upgrade if the maintainer set grows or richer
audit is wanted; it can be layered in front of the Bearer check later without
changing the handler.
- **mTLS / client certificates.** Operationally heavy (cert issuance, rotation,
client provisioning) for one operator. Rejected.
- **No dedicated auth, rely on an unguessable URL.** Rejected: not real
authentication, leaks via logs/history, and cannot be rotated cleanly.
## Consequences
- **Positive:** minimal moving parts; one secret to rotate (rotate the Secrets
Store value); trivially callable from `curl`, scripts, or CI; the auth is plain,
build-tag-free Go that is fully host-testable (`httptest`) and exercised end to
end by the Bruno API tests against the dev server.
- **Negative / limitations:** a single shared secret has no per-user identity or
built-in audit trail, and if leaked it grants full admin until rotated. Mitigated
by constant-time comparison, fail-closed behaviour, never logging the token, and
HTTPS-only transport (the Worker is HTTPS). Revisit with Cloudflare Access if the
maintainer set grows or per-actor audit becomes a requirement.
+224
View File
@@ -0,0 +1,224 @@
package handler
// Maintainer admin API (#11): an authenticated path for the single maintainer to
// review the pending queue and pull a report before the weekly publish run.
//
// GET /v1/admin/reports -> 200 {"status":"ok","reports":[<id>...]}
// POST /v1/admin/reports/{id}/remove -> 200 {"status":"removed","id":<id>}
// DELETE /v1/admin/reports/{id} -> 200 {"status":"removed","id":<id>} (alias)
//
// Removing a report transitions it pending -> removed via the lifecycle Manager
// (#10); #13's ListPending no longer returns it, so it is excluded from the next
// Friday publish. Remove is idempotent (removing an already-removed report still
// succeeds); an id that was never pending returns 404.
//
// # Authentication
//
// A shared-secret bearer token, chosen over Cloudflare Access for a
// single-maintainer, low-volume tool: it needs no Zero Trust org/policy setup,
// is trivially callable from curl/scripts/CI, and injects cleanly for tests and
// the dev server. See docs/decisions/admin-auth.md.
//
// Every admin request must carry `Authorization: Bearer <token>`. The presented
// token is compared to the configured secret with crypto/subtle.ConstantTimeCompare
// so a wrong token cannot be recovered by timing. The endpoint fails closed: if
// the server has no secret configured (unset/empty), every request is rejected
// with 401 regardless of what the client sends, so a missing binding can never
// silently disable auth. Missing/malformed/wrong credentials also return 401 with
// a `WWW-Authenticate: Bearer` challenge.
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
)
// AdminBackend supplies the admin API's dependencies. It is an interface, not a
// concrete *lifecycle.Manager, so the same routes serve both deployment targets:
// the host (dev server, tests) wires a static Manager + injected token via
// NewManagerBackend, while the Cloudflare Worker wires a lazy implementation that
// reads the token from Secrets Store and builds an R2-backed Manager per request
// (the token and the R2 binding are only available inside a request there).
type AdminBackend interface {
// AdminToken returns the configured shared secret, or "" if none is set (in
// which case auth fails closed). ctx-scoped so the Worker can read Secrets
// Store per request. A non-nil error (e.g. a failed secret load) yields 503.
AdminToken(ctx context.Context) (string, error)
// ListPending returns the ids of all pending reports (lifecycle.Manager.ListPending).
ListPending(ctx context.Context) ([]string, error)
// MarkRemoved transitions a pending report to removed (lifecycle.Manager.MarkRemoved),
// returning lifecycle.ErrUnknownReport if the id is not pending.
MarkRemoved(ctx context.Context, id string) error
}
// managerBackend adapts a ready *lifecycle.Manager and a fixed shared secret to
// AdminBackend. It is the host wiring (dev server, tests); the Worker supplies
// its own lazy backend.
type managerBackend struct {
mgr *lifecycle.Manager
token string
}
// NewManagerBackend wires an injected *lifecycle.Manager and shared secret into
// an AdminBackend for the dev server and tests. An empty token leaves the admin
// API authenticated-but-unopenable (every request 401s), which is the intended
// fail-closed behaviour when no secret is provisioned.
func NewManagerBackend(mgr *lifecycle.Manager, token string) AdminBackend {
return managerBackend{mgr: mgr, token: token}
}
func (b managerBackend) AdminToken(context.Context) (string, error) { return b.token, nil }
func (b managerBackend) ListPending(ctx context.Context) ([]string, error) {
return b.mgr.ListPending(ctx)
}
func (b managerBackend) MarkRemoved(ctx context.Context, id string) error {
return b.mgr.MarkRemoved(ctx, id)
}
// denyAllBackend is substituted when New is called with a nil AdminBackend. It
// reports an empty secret, so authentication always fails closed (401) and the
// list/remove methods are never reached.
type denyAllBackend struct{}
func (denyAllBackend) AdminToken(context.Context) (string, error) { return "", nil }
func (denyAllBackend) ListPending(context.Context) ([]string, error) { return nil, errNotConfigured }
func (denyAllBackend) MarkRemoved(context.Context, string) error { return errNotConfigured }
var errNotConfigured = errors.New("handler: admin backend not configured")
// adminAPI holds the admin route handlers over an AdminBackend.
type adminAPI struct {
backend AdminBackend
}
// register mounts the admin routes on mux. Patterns are method-agnostic and each
// handler dispatches on the method itself, matching the rest of this package
// (isGet, the ingest handler): a broad "/" catch-all is also registered on the
// same mux, and it would otherwise absorb a method-mismatched request before the
// mux's own 405 logic could fire, so the method check lives in-handler.
func (a *adminAPI) register(mux *http.ServeMux) {
mux.HandleFunc("/v1/admin/reports", a.reports)
mux.HandleFunc("/v1/admin/reports/{id}/remove", a.removeViaPost)
mux.HandleFunc("/v1/admin/reports/{id}", a.removeViaDelete)
}
// reports serves /v1/admin/reports: GET lists the pending report ids.
func (a *adminAPI) reports(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !a.authorize(w, r) {
return
}
ids, err := a.backend.ListPending(r.Context())
if err != nil {
writeAdmin(w, http.StatusServiceUnavailable, adminResponse{Status: "error", Error: "admin backend unavailable"})
return
}
if ids == nil {
ids = []string{} // marshal an empty JSON array, never null
}
writeAdmin(w, http.StatusOK, adminList{Status: "ok", Reports: ids})
}
// removeViaPost serves POST /v1/admin/reports/{id}/remove.
func (a *adminAPI) removeViaPost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
a.remove(w, r)
}
// removeViaDelete serves DELETE /v1/admin/reports/{id}, the REST-style alias for
// removeViaPost.
func (a *adminAPI) removeViaDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
methodNotAllowed(w, http.MethodDelete)
return
}
a.remove(w, r)
}
// remove transitions the {id} report pending -> removed, after authenticating.
func (a *adminAPI) remove(w http.ResponseWriter, r *http.Request) {
if !a.authorize(w, r) {
return
}
id := r.PathValue("id")
err := a.backend.MarkRemoved(r.Context(), id)
switch {
case err == nil:
writeAdmin(w, http.StatusOK, adminResponse{Status: "removed", ID: id})
case errors.Is(err, lifecycle.ErrUnknownReport):
writeAdmin(w, http.StatusNotFound, adminResponse{Status: "error", Error: "unknown report"})
default:
writeAdmin(w, http.StatusServiceUnavailable, adminResponse{Status: "error", Error: "admin backend unavailable"})
}
}
// methodNotAllowed writes a 405 with the Allow header advertising the one method
// the admin route accepts.
func methodNotAllowed(w http.ResponseWriter, allow string) {
w.Header().Set("Allow", allow)
writeAdmin(w, http.StatusMethodNotAllowed, adminResponse{Status: "error", Error: "method not allowed"})
}
// authorize enforces the shared-secret bearer token. It returns true when the
// request is authenticated; otherwise it writes a 401 (missing/bad token or unset
// server secret) or 503 (secret load failed) and returns false. The comparison is
// constant-time and the endpoint fails closed on an empty configured secret.
func (a *adminAPI) authorize(w http.ResponseWriter, r *http.Request) bool {
secret, err := a.backend.AdminToken(r.Context())
if err != nil {
writeAdmin(w, http.StatusServiceUnavailable, adminResponse{Status: "error", Error: "admin backend unavailable"})
return false
}
if secret == "" || !bearerMatches(r.Header.Get("Authorization"), secret) {
w.Header().Set("WWW-Authenticate", "Bearer")
writeAdmin(w, http.StatusUnauthorized, adminResponse{Status: "error", Error: "unauthorized"})
return false
}
return true
}
// bearerMatches reports whether the Authorization header carries a Bearer token
// equal to secret. The scheme is matched case-insensitively (per RFC 7235); the
// token is compared in constant time. secret is assumed non-empty (the caller
// fails closed on an empty secret before calling this).
func bearerMatches(header, secret string) bool {
scheme, token, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") {
return false
}
return subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1
}
// adminList is the GET /v1/admin/reports success body. Reports has no omitempty,
// so an empty pending set still marshals as {"reports":[]} (an array, never null),
// which clients and the Bruno tests rely on.
type adminList struct {
Status string `json:"status"`
Reports []string `json:"reports"`
}
// adminResponse is the JSON body shape for the remove and error responses.
type adminResponse struct {
Status string `json:"status"`
ID string `json:"id,omitempty"`
Error string `json:"error,omitempty"`
}
func writeAdmin(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+268
View File
@@ -0,0 +1,268 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
const testToken = "s3cret-admin-token"
// adminHarness builds a handler whose admin API is backed by an in-memory store
// seeded with the given pending report ids, authenticated by token. It returns
// the handler and the store so tests can assert post-conditions.
func adminHarness(t *testing.T, token string, pendingIDs ...string) (http.Handler, storage.ObjectStore) {
t.Helper()
store := storage.NewMemoryStore()
for _, id := range pendingIDs {
// The admin API only lists keys and moves opaque bytes, so a placeholder
// ciphertext is enough; no real crypto is needed to exercise it.
if err := store.Put(context.Background(), storage.ReportKey(storage.StatusPending, id), []byte("frame:"+id)); err != nil {
t.Fatalf("seed pending %q: %v", id, err)
}
}
h := New(nil, NewManagerBackend(lifecycle.New(store), token))
return h, store
}
// adminReq issues one admin request with an optional bearer token ("" sends no
// Authorization header) and returns the recorder.
func adminReq(t *testing.T, h http.Handler, method, target, bearer string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// adminBody is a decode target spanning every admin response field (list, remove,
// and error bodies), so one helper can parse any admin response in the tests.
type adminBody struct {
Status string `json:"status"`
Reports []string `json:"reports"`
ID string `json:"id"`
Error string `json:"error"`
}
// decodeAdmin parses an admin JSON response body.
func decodeAdmin(t *testing.T, rec *httptest.ResponseRecorder) adminBody {
t.Helper()
var got adminBody
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("admin response is not valid JSON: %v (body=%q)", err, rec.Body.String())
}
return got
}
// TestAdminListReturnsPendingIDs is the acceptance path: an authenticated
// maintainer lists exactly the pending report ids.
func TestAdminListReturnsPendingIDs(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a", "id-b", "id-c")
rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("GET list status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
}
got := decodeAdmin(t, rec)
if got.Status != "ok" {
t.Errorf("status field = %q, want ok", got.Status)
}
want := []string{"id-a", "id-b", "id-c"} // sorted, MemoryStore lists ascending
if !slices.Equal(got.Reports, want) {
t.Errorf("reports = %v, want %v", got.Reports, want)
}
}
// TestAdminListEmptyIsArray confirms an empty pending set marshals as [] (not null),
// which the API consumers (and the Bruno tests) rely on.
func TestAdminListEmptyIsArray(t *testing.T) {
h, _ := adminHarness(t, testToken)
rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, `"reports":[]`) {
t.Errorf("empty list body = %q, want it to contain \"reports\":[]", body)
}
}
// TestAdminRemoveExcludesFromPending is the ticket's core acceptance: a removed
// report transitions to removed and disappears from the pending list, so the next
// publish run will not see it.
func TestAdminRemoveExcludesFromPending(t *testing.T) {
h, store := adminHarness(t, testToken, "keep-1", "drop-me", "keep-2")
rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/drop-me/remove", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("remove status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
}
if got := decodeAdmin(t, rec); got.Status != "removed" || got.ID != "drop-me" {
t.Errorf("remove body = %+v, want {status:removed id:drop-me}", got)
}
// It is gone from the pending list...
list := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if got := decodeAdmin(t, list).Reports; slices.Contains(got, "drop-me") {
t.Errorf("pending list still contains drop-me: %v", got)
} else if !slices.Equal(got, []string{"keep-1", "keep-2"}) {
t.Errorf("pending list = %v, want [keep-1 keep-2]", got)
}
// ...and it now lives under the removed prefix (excluded from publishing).
removed, _ := store.List(context.Background(), storage.StatusPrefix(storage.StatusRemoved))
if !slices.Equal(removed, []string{storage.ReportKey(storage.StatusRemoved, "drop-me")}) {
t.Errorf("removed keys = %v, want the single removed report", removed)
}
}
// TestAdminRemoveViaDelete checks the DELETE alias behaves like POST .../remove.
func TestAdminRemoveViaDelete(t *testing.T) {
h, _ := adminHarness(t, testToken, "gone")
rec := adminReq(t, h, http.MethodDelete, "/v1/admin/reports/gone", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
}
list := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if got := decodeAdmin(t, list).Reports; len(got) != 0 {
t.Errorf("pending after DELETE = %v, want empty", got)
}
}
// TestAdminRemoveIdempotent proves removing the same report twice still succeeds
// (200), matching the lifecycle Manager's idempotent transition.
func TestAdminRemoveIdempotent(t *testing.T) {
h, _ := adminHarness(t, testToken, "twice")
if rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/twice/remove", testToken); rec.Code != http.StatusOK {
t.Fatalf("first remove status = %d, want 200", rec.Code)
}
if rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/twice/remove", testToken); rec.Code != http.StatusOK {
t.Errorf("second remove status = %d, want 200 (idempotent)", rec.Code)
}
}
// TestAdminRemoveUnknownIs404 covers an id that was never pending.
func TestAdminRemoveUnknownIs404(t *testing.T) {
h, _ := adminHarness(t, testToken, "real")
rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/ghost/remove", testToken)
if rec.Code != http.StatusNotFound {
t.Fatalf("remove unknown status = %d, want 404 (body=%q)", rec.Code, rec.Body.String())
}
if got := decodeAdmin(t, rec); got.Status != "error" {
t.Errorf("404 status field = %q, want error", got.Status)
}
}
// TestAdminMissingTokenIs401 covers a request with no Authorization header.
func TestAdminMissingTokenIs401(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
for _, tc := range []struct {
name, method, target string
}{
{"list", http.MethodGet, "/v1/admin/reports"},
{"remove", http.MethodPost, "/v1/admin/reports/id-a/remove"},
{"delete", http.MethodDelete, "/v1/admin/reports/id-a"},
} {
t.Run(tc.name, func(t *testing.T) {
rec := adminReq(t, h, tc.method, tc.target, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("no-token status = %d, want 401", rec.Code)
}
if wa := rec.Header().Get("WWW-Authenticate"); wa != "Bearer" {
t.Errorf("WWW-Authenticate = %q, want Bearer", wa)
}
})
}
// A no-op check that the guarded report is still pending (auth blocked the write).
list := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if got := decodeAdmin(t, list).Reports; !slices.Contains(got, "id-a") {
t.Errorf("report was mutated despite unauthorized requests: %v", got)
}
}
// TestAdminInvalidTokenIs401 covers a well-formed header carrying the wrong token.
func TestAdminInvalidTokenIs401(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", "wrong-token")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad-token status = %d, want 401", rec.Code)
}
}
// TestAdminMalformedAuthHeaderIs401 covers non-Bearer / malformed schemes.
func TestAdminMalformedAuthHeaderIs401(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
for _, hdr := range []string{"Basic abc123", "Bearer", "token " + testToken, testToken} {
req := httptest.NewRequest(http.MethodGet, "/v1/admin/reports", nil)
req.Header.Set("Authorization", hdr)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("Authorization %q: status = %d, want 401", hdr, rec.Code)
}
}
}
// TestAdminUnsetSecretFailsClosed is the fail-closed requirement: with no server
// secret configured, every request is rejected even when the client presents a
// (any) bearer token, so a missing binding can never leave the API open.
func TestAdminUnsetSecretFailsClosed(t *testing.T) {
h, _ := adminHarness(t, "" /* no server secret */, "id-a")
// Client presents a plausible token; the server has none, so 401.
if rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", "any-token"); rec.Code != http.StatusUnauthorized {
t.Errorf("list with unset server secret: status = %d, want 401", rec.Code)
}
if rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/id-a/remove", "any-token"); rec.Code != http.StatusUnauthorized {
t.Errorf("remove with unset server secret: status = %d, want 401", rec.Code)
}
// The empty string as a token must not authenticate against an unset secret.
if rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("list with no token and unset secret: status = %d, want 401", rec.Code)
}
}
// TestAdminNilBackendFailsClosed proves New(sink, nil) still fails closed (401),
// never panics, and never allows access.
func TestAdminNilBackendFailsClosed(t *testing.T) {
h := New(nil, nil)
if rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", "any-token"); rec.Code != http.StatusUnauthorized {
t.Errorf("nil backend list: status = %d, want 401", rec.Code)
}
}
// TestAdminWrongMethodIs405 checks the mux answers 405 for a wrong method on a
// known admin path, with an Allow header, before auth runs.
func TestAdminWrongMethodIs405(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
for _, tc := range []struct {
name, method, target, allow string
}{
{"put-list", http.MethodPut, "/v1/admin/reports", http.MethodGet},
{"delete-list", http.MethodDelete, "/v1/admin/reports", http.MethodGet},
{"get-remove", http.MethodGet, "/v1/admin/reports/id-a/remove", http.MethodPost},
{"put-report", http.MethodPut, "/v1/admin/reports/id-a", http.MethodDelete},
} {
t.Run(tc.name, func(t *testing.T) {
rec := adminReq(t, h, tc.method, tc.target, testToken)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
if allow := rec.Header().Get("Allow"); allow != tc.allow {
t.Errorf("Allow = %q, want %q", allow, tc.allow)
}
})
}
}
+18 -5
View File
@@ -20,21 +20,34 @@ const serviceName = "libremail-bug-report-ingest"
// New returns an http.Handler serving the ingest Worker's endpoints:
//
// GET / -> 200, JSON service/status/message
// GET /healthz -> 200, JSON {"status":"ok"}
// POST /v1/reports -> 202 on accept; 400/413/415/405/503 per the ingest contract
// GET / -> 200, JSON service/status/message
// GET /healthz -> 200, JSON {"status":"ok"}
// POST /v1/reports -> 202 on accept; 400/413/415/405/503 per the ingest contract
// GET /v1/admin/reports -> 200 list of pending report ids (authenticated, #11)
// POST /v1/admin/reports/{id}/remove -> 200 remove a pending report (authenticated, #11)
// DELETE /v1/admin/reports/{id} -> 200 remove a pending report (authenticated alias, #11)
//
// Any other path returns 404. On the health/hello endpoints any non-GET method
// returns 405; on /v1/reports any non-POST method returns 405 (Allow: POST).
// returns 405; on /v1/reports any non-POST method returns 405 (Allow: POST); on
// the admin routes a wrong method returns 405 (Allow header from the mux).
//
// sink is the storage backend for accepted reports (scrub + encrypt + R2, #9).
// It is injected so the deployed Worker supplies the real R2/Secrets-Store sink
// while cmd/devserver and tests supply an in-memory one. A nil sink defaults to
// ingest.NopSink, which enforces the full HTTP contract but discards bodies.
func New(sink ingest.Sink) http.Handler {
//
// admin is the maintainer admin API backend (#11): the lifecycle Manager plus the
// shared-secret token, injected the same way. A nil admin registers the admin
// routes but fails every request closed with 401, so the endpoints' shape is
// always present and can never be silently left unauthenticated.
func New(sink ingest.Sink, admin AdminBackend) http.Handler {
if admin == nil {
admin = denyAllBackend{}
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthz)
mux.Handle("/v1/reports", ingest.NewHandler(sink))
(&adminAPI{backend: admin}).register(mux)
mux.HandleFunc("/", root)
return mux
}
+2 -2
View File
@@ -15,7 +15,7 @@ func doRequest(t *testing.T, method, target string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, nil)
rec := httptest.NewRecorder()
New(nil).ServeHTTP(rec, req)
New(nil, nil).ServeHTTP(rec, req)
return rec
}
@@ -78,7 +78,7 @@ func TestUnknownPathReturns404(t *testing.T) {
// POST /v1/reports (the #9 storage seam), returning 202 and storing the report.
func TestReportsRoutedToInjectedSink(t *testing.T) {
sink := &ingest.MemorySink{}
h := New(sink)
h := New(sink, nil)
body := `{"appVersion":"1.0.0","platform":"android","report":"boom"}`
req := httptest.NewRequest(http.MethodPost, "/v1/reports", strings.NewReader(body))
+4
View File
@@ -31,6 +31,10 @@ const (
// KeyringBinding is the Cloudflare Secrets Store binding holding the JSON
// keyring secret, named per ADR #5.
KeyringBinding = "BUGREPORT_ENC_KEYRING"
// AdminTokenBinding is the Cloudflare Secrets Store binding holding the
// maintainer admin API shared secret (#11). Read per request via ReadSecret;
// never logged or echoed.
AdminTokenBinding = "ADMIN_TOKEN"
)
// ObjectStore is the seam for the opaque object backend. Implementations only
+8
View File
@@ -78,6 +78,14 @@ func (s *WorkerSink) loadKeyring() (*crypto.Keyring, error) {
return kr, nil
}
// ReadSecret reads a Cloudflare Secrets Store secret by binding name, for use
// outside this package (e.g. the Worker admin backend reading AdminTokenBinding,
// #11). Like getSecret it must be called within a request handler, and the value
// must never be logged or echoed. It errors if the binding is unbound or empty.
func ReadSecret(binding string) ([]byte, error) {
return getSecret(binding)
}
// getSecret reads a Cloudflare Secrets Store secret via `await binding.get()`.
// The returned value must never be logged or echoed (ADR #5, Key custody).
func getSecret(binding string) ([]byte, error) {
+66
View File
@@ -0,0 +1,66 @@
//go:build js && wasm
package main
// workerAdminBackend is the production handler.AdminBackend for the maintainer
// admin API (#11) in the Cloudflare Worker.
//
// The admin secret and the R2 bucket binding are only available inside a request
// on the Workers runtime (the Secrets Store get() is async and per-request; the
// R2 binding resolves from the request context), so — unlike the dev server,
// which injects a ready Manager + token at startup — this backend resolves both
// lazily on each call. The token is read from Secrets Store (AdminTokenBinding)
// and the lifecycle Manager is built over a fresh R2-backed store per operation.
// This mirrors how WorkerSink loads its keyring lazily.
//
// Compiled only into the js/wasm Worker; excluded from host builds and tests.
import (
"context"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
type workerAdminBackend struct{}
// AdminToken reads the shared admin secret from Cloudflare Secrets Store. A load
// failure (unbound/empty binding) returns an error, which the handler surfaces as
// 503; the handler independently fails closed (401) on an empty secret value.
func (workerAdminBackend) AdminToken(context.Context) (string, error) {
tok, err := storage.ReadSecret(storage.AdminTokenBinding)
if err != nil {
return "", err
}
return string(tok), nil
}
// ListPending builds an R2-backed lifecycle Manager for this request and returns
// the pending report ids.
func (workerAdminBackend) ListPending(ctx context.Context) ([]string, error) {
mgr, err := managerForRequest()
if err != nil {
return nil, err
}
return mgr.ListPending(ctx)
}
// MarkRemoved builds an R2-backed lifecycle Manager for this request and
// transitions the report pending -> removed.
func (workerAdminBackend) MarkRemoved(ctx context.Context, id string) error {
mgr, err := managerForRequest()
if err != nil {
return err
}
return mgr.MarkRemoved(ctx, id)
}
// managerForRequest resolves the R2 bucket binding (available within a request)
// and wraps it in a lifecycle Manager.
func managerForRequest() (*lifecycle.Manager, error) {
store, err := storage.NewR2Store(storage.BucketBinding)
if err != nil {
return nil, err
}
return lifecycle.New(store), nil
}
+5 -1
View File
@@ -21,5 +21,9 @@ func main() {
// The real storage Sink (#9): scrub -> AES-256-GCM encrypt -> R2 put, with the
// keyring loaded from Cloudflare Secrets Store on first request. Bindings
// (REPORTS_BUCKET, BUGREPORT_ENC_KEYRING) are declared in wrangler.jsonc.
workers.Serve(handler.New(storage.NewWorkerSink()))
//
// The maintainer admin API (#11) is wired with workerAdminBackend, which reads
// the admin shared secret (ADMIN_TOKEN) from Secrets Store and drives the
// lifecycle Manager over R2 per request (see admin.go).
workers.Serve(handler.New(storage.NewWorkerSink(), workerAdminBackend{}))
}
+13
View File
@@ -40,6 +40,19 @@
"binding": "BUGREPORT_ENC_KEYRING",
"store_id": "<store-id>",
"secret_name": "bugreport-enc-keyring"
},
// Maintainer admin API shared secret (issue #11). The Worker reads it per
// request via env.ADMIN_TOKEN.get() (internal/storage.AdminTokenBinding) to
// authenticate GET /v1/admin/reports and POST /v1/admin/reports/{id}/remove
// with a constant-time-compared Bearer token. Fail-closed: if this binding is
// absent or empty the admin routes reject every request (401/503). Replace
// "<store-id>" with the account's Secrets Store id at deploy time; not needed
// for `pnpm run build` (Wasm compile) or the devserver (which uses ADMIN_TOKEN
// from the environment instead).
{
"binding": "ADMIN_TOKEN",
"store_id": "<store-id>",
"secret_name": "bugreport-admin-token"
}
],