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>
53 lines
2.5 KiB
Go
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
|
|
}
|