Files
JMR-devandClaude Opus 4.8 047391d01c #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>
2026-07-02 15:56:59 -05:00

57 lines
2.7 KiB
Go

// Package storage persists accepted LibreMail bug-reports as encrypted-at-rest
// objects, implementing the storage half of the ingest pipeline: for each
// accepted report it scrubs PII (internal/scrub, #8), encrypts the scrubbed
// bytes with AES-256-GCM (internal/crypto, ADR #5), and writes only the opaque
// ciphertext frame to the object store. The store never sees plaintext or the
// key.
//
// The package follows the repo's build-tag pattern so it is host-testable
// without TinyGo or the Workers runtime:
//
// - The ObjectStore interface, MemoryStore, and the Sink (scrub+encrypt+put)
// carry no build constraints and are unit-tested with `go test`.
// - The real R2-backed store (R2Store) and the Worker sink that loads the
// keyring from Cloudflare Secrets Store live behind //go:build js && wasm and
// are compiled by the Wasm Worker build in CI.
package storage
import (
"context"
"errors"
)
// ErrNotFound is returned by ObjectStore.Get when no object exists at the key.
var ErrNotFound = errors.New("storage: object not found")
// Binding names wired in wrangler.jsonc and provisioned by infra (#2).
const (
// BucketBinding is the R2 bucket binding name (the JS var the Worker reads).
// The bound bucket is "libremail-bug-reports" (infra defaultR2BucketName).
BucketBinding = "REPORTS_BUCKET"
// 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
// ever handle already-encrypted frames.
//
// - Put writes data (a sealed frame) at key, overwriting any existing object.
// - Get reads the bytes back, or returns ErrNotFound. Get exists for the publish
// job (#15) and for tests; the ingest path is write-only.
// - List returns the keys whose names start with prefix, in sorted (ascending)
// order. It backs the lifecycle "list pending" query (#10/#13); implementations
// must page through the whole result set, not just the first batch.
// - Delete removes the object at key. Deleting a key that does not exist is not
// an error (idempotent), which is what makes a status transition safe to retry.
type ObjectStore interface {
Put(ctx context.Context, key string, data []byte) error
Get(ctx context.Context, key string) ([]byte, error)
List(ctx context.Context, prefix string) ([]string, error)
Delete(ctx context.Context, key string) error
}