Files
LibreMail-Bug-Report-Ingest/internal/storage/store.go
T
JMR-devandClaude Opus 4.8 cb7d9c67a0 #10 Report lifecycle/status metadata (pending/removed/published)
Add a status layer over the ObjectStore so each stored report has a
lifecycle state, encoded in its object key as reports/<status>/<id>:
pending (new reports), removed (#11), published (#15). Encoding status in
the key prefix means "list pending" is a single prefix listing with no
secondary index to drift, so it returns exactly the pending reports by
construction.

Storage:
- Extend ObjectStore with List(ctx, prefix) and Delete(ctx, key); implement
  in MemoryStore (host) and the js/wasm R2Store. R2Store.List drives the R2
  binding's list() directly to page a prefix (the syumai helper takes no
  options), so a status with >1000 objects is still enumerated exactly.
- The ingest Sink now writes new reports under reports/pending/<id>, so
  accepted reports enter the lifecycle as pending. The <id> is stable across
  transitions.

lifecycle package:
- Manager over an ObjectStore: ListPending, GetPending(id), MarkRemoved(id),
  MarkPublished(id). A transition copies the opaque ciphertext frame to the
  destination status key and deletes the source key — bytes are never
  decrypted or re-encrypted; no key is needed to change status.
- Copy-then-delete is idempotent and retry-safe: Put(dest) before Delete(src)
  never loses a report, a retry converges (re-Put identical bytes, Delete the
  leftover source), and a transition of an id not in the source status returns
  ErrUnknownReport (unless it is already at the destination -> idempotent nil).

Tests (host, MemoryStore, no TinyGo):
- List-pending exactness across a mix of pending/removed/published.
- pending->removed and pending->published leave the pending set, appear under
  the target, and move byte-identical ciphertext that still decrypts.
- Idempotent retry and convergence from an interrupted (both-keys) state.
- Unknown/terminal-state ids error sensibly; new Sink reports list as pending.

Closes #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:25:11 -05:00

53 lines
2.5 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"
)
// 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
}