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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The public portal is a Vue 3 and TypeScript application. A small Go service owns
- A versioned multipart API shared by the web portal and native applications.
- Explicit 4 MiB diagnostic upload bounds and per-product ZIP entry allowlists.
- Idempotent submission and receipt reconciliation after an uncertain response.
- Atomic cancellation tombstones that prevent a delayed upload from recreating a cancelled private report.
- Human-readable support codes and unguessable private status/deletion links.
- Application-level AES-256-GCM encryption for private report fields, capabilities, and diagnostic objects.
- Automatic private-data expiration and immediate deletion through the private capability.
Expand Down Expand Up @@ -85,6 +86,7 @@ npm run build
- New reports are private. Nothing is published automatically.
- Diagnostic attachments are optional and must match the selected product's registered schema.
- The service does not store raw idempotency keys or raw status capabilities in database lookup columns.
- Cancellation retains only the one-way idempotency hash needed to reject a delayed submission; it does not retain report content.
- Private report text, contact details, receipt capabilities, and diagnostic objects are encrypted before storage.
- Support codes are identifiers, not authentication secrets.
- Private status URLs are bearer capabilities. Applications must never put them in diagnostics, telemetry, or public issues.
Expand Down
25 changes: 25 additions & 0 deletions internal/intake/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var (
ErrInvalid = errors.New("invalid report")
ErrNotFound = errors.New("report not found")
ErrKeyReused = errors.New("idempotency key was already used for another report")
ErrCancelled = errors.New("report submission was cancelled")
idempotencyKey = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`)
productID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`)
archiveFileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$`)
Expand Down Expand Up @@ -73,6 +74,8 @@ func (service *Service) Submit(ctx context.Context, submission Submission) (doma
return domain.Receipt{}, ErrKeyReused
}
return service.receipt(existing)
} else if errors.Is(err, store.ErrCancelled) {
return domain.Receipt{}, ErrCancelled
} else if !errors.Is(err, store.ErrNotFound) {
return domain.Receipt{}, err
}
Expand Down Expand Up @@ -130,6 +133,9 @@ func (service *Service) Submit(ctx context.Context, submission Submission) (doma
if report.DiagnosticObjectKey != nil {
_ = service.objects.Delete(*report.DiagnosticObjectKey)
}
if errors.Is(err, store.ErrCancelled) {
return domain.Receipt{}, ErrCancelled
}
if errors.Is(err, store.ErrConflict) {
if existing, lookupErr := service.reports.ByIdempotencyHash(ctx, idempotencyHash); lookupErr == nil {
if !bytes.Equal(existing.RequestHash, requestHash) {
Expand All @@ -151,6 +157,9 @@ func (service *Service) Reconcile(ctx context.Context, key string) (domain.Recei
return domain.Receipt{}, ErrInvalid
}
report, err := service.reports.ByIdempotencyHash(ctx, hash([]byte(key)))
if errors.Is(err, store.ErrCancelled) {
return domain.Receipt{}, ErrCancelled
}
if errors.Is(err, store.ErrNotFound) {
return domain.Receipt{}, ErrNotFound
}
Expand All @@ -160,6 +169,22 @@ func (service *Service) Reconcile(ctx context.Context, key string) (domain.Recei
return service.receipt(report)
}

func (service *Service) Cancel(ctx context.Context, key string) error {
if !idempotencyKey.MatchString(key) {
return ErrInvalid
}
report, err := service.reports.CancelByIdempotencyHash(ctx, hash([]byte(key)), service.now())
if err != nil {
return err
}
if report != nil && report.DiagnosticObjectKey != nil {
if err := service.objects.Delete(*report.DiagnosticObjectKey); err != nil {
return fmt.Errorf("delete cancelled private diagnostic object: %w", err)
}
}
return nil
}

func (service *Service) Status(ctx context.Context, capability string) (domain.PrivateStatus, error) {
if !validCapability(capability) {
return domain.PrivateStatus{}, ErrNotFound
Expand Down
46 changes: 46 additions & 0 deletions internal/intake/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,52 @@ func TestSubmitRejectsIdempotencyKeyReuseWithDifferentContent(t *testing.T) {
}
}

func TestCancelBeforeSubmitPreventsLatePrivateReport(t *testing.T) {
service, reports, objects := testService(t)
submission := validSubmission(t)
if err := service.Cancel(context.Background(), submission.IdempotencyKey); err != nil {
t.Fatal(err)
}
if err := service.Cancel(context.Background(), submission.IdempotencyKey); err != nil {
t.Fatalf("repeated cancel: %v", err)
}
if _, err := service.Submit(context.Background(), submission); !errors.Is(err, ErrCancelled) {
t.Fatalf("late submit error = %v, want ErrCancelled", err)
}
if _, err := service.Reconcile(context.Background(), submission.IdempotencyKey); !errors.Is(err, ErrCancelled) {
t.Fatalf("reconcile error = %v, want ErrCancelled", err)
}
if _, err := reports.ByIdempotencyHash(context.Background(), hash([]byte(submission.IdempotencyKey))); !errors.Is(err, store.ErrCancelled) {
t.Fatalf("stored cancellation error = %v, want ErrCancelled", err)
}
if len(objects.Values) != 0 {
t.Fatalf("private object count = %d, want 0", len(objects.Values))
}
}

func TestCancelExistingReportDeletesPrivateDataAndPreventsRecreation(t *testing.T) {
service, reports, objects := testService(t)
submission := validSubmission(t)
if _, err := service.Submit(context.Background(), submission); err != nil {
t.Fatal(err)
}
if err := service.Cancel(context.Background(), submission.IdempotencyKey); err != nil {
t.Fatal(err)
}
if err := service.Cancel(context.Background(), submission.IdempotencyKey); err != nil {
t.Fatalf("repeated cancel: %v", err)
}
if len(objects.Values) != 0 {
t.Fatalf("private object count after cancellation = %d, want 0", len(objects.Values))
}
if _, err := reports.ByIdempotencyHash(context.Background(), hash([]byte(submission.IdempotencyKey))); !errors.Is(err, store.ErrCancelled) {
t.Fatalf("stored cancellation error = %v, want ErrCancelled", err)
}
if _, err := service.Submit(context.Background(), submission); !errors.Is(err, ErrCancelled) {
t.Fatalf("recreated submit error = %v, want ErrCancelled", err)
}
}

func TestSubmitRejectsUnregisteredAndExpandingArchiveEntries(t *testing.T) {
service, _, _ := testService(t)
submission := validSubmission(t)
Expand Down
32 changes: 28 additions & 4 deletions internal/store/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ import (
)

type Memory struct {
mu sync.Mutex
reports []domain.Report
sessions []domain.AdminSession
mu sync.Mutex
reports []domain.Report
cancellations map[string]time.Time
sessions []domain.AdminSession
}

func NewMemory() *Memory { return &Memory{} }
func NewMemory() *Memory { return &Memory{cancellations: make(map[string]time.Time)} }

func (memory *Memory) Create(_ context.Context, report domain.Report) error {
memory.mu.Lock()
defer memory.mu.Unlock()
if _, cancelled := memory.cancellations[string(report.IdempotencyHash)]; cancelled {
return ErrCancelled
}
for _, existing := range memory.reports {
if existing.SupportCode == report.SupportCode || bytes.Equal(existing.IdempotencyHash, report.IdempotencyHash) {
return ErrConflict
Expand All @@ -37,9 +41,29 @@ func (memory *Memory) ByIdempotencyHash(_ context.Context, hash []byte) (domain.
return report, nil
}
}
if _, cancelled := memory.cancellations[string(hash)]; cancelled {
return domain.Report{}, ErrCancelled
}
return domain.Report{}, ErrNotFound
}

func (memory *Memory) CancelByIdempotencyHash(_ context.Context, hash []byte, now time.Time) (*domain.Report, error) {
memory.mu.Lock()
defer memory.mu.Unlock()
memory.cancellations[string(hash)] = now
for index, report := range memory.reports {
if bytes.Equal(report.IdempotencyHash, hash) {
if report.DeletedAt == nil {
memory.reports[index].DeletedAt = &now
memory.reports[index].UpdatedAt = now
}
cancelled := memory.reports[index]
return &cancelled, nil
}
}
return nil, nil
}

func (memory *Memory) ByCapabilityHash(_ context.Context, hash []byte) (domain.Report, error) {
memory.mu.Lock()
defer memory.mu.Unlock()
Expand Down
8 changes: 8 additions & 0 deletions internal/store/migrations/003_submission_cancellations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS support_submission_states (
idempotency_hash BYTEA PRIMARY KEY CHECK (octet_length(idempotency_hash) = 32),
cancelled_at TIMESTAMPTZ
);

INSERT INTO support_submission_states (idempotency_hash)
SELECT idempotency_hash FROM support_reports
ON CONFLICT (idempotency_hash) DO NOTHING;
70 changes: 67 additions & 3 deletions internal/store/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,24 @@ func OpenPostgres(ctx context.Context, databaseURL string) (*Postgres, error) {
}

func (postgres *Postgres) Create(ctx context.Context, report domain.Report) error {
_, err := postgres.pool.Exec(ctx, `
transaction, err := postgres.pool.Begin(ctx)
if err != nil {
return err
}
defer transaction.Rollback(ctx)
if _, err := transaction.Exec(ctx, `INSERT INTO support_submission_states (idempotency_hash)
VALUES ($1) ON CONFLICT (idempotency_hash) DO NOTHING`, report.IdempotencyHash); err != nil {
return err
}
var cancelledAt *time.Time
if err := transaction.QueryRow(ctx, `SELECT cancelled_at FROM support_submission_states
WHERE idempotency_hash = $1 FOR UPDATE`, report.IdempotencyHash).Scan(&cancelledAt); err != nil {
return err
}
if cancelledAt != nil {
return ErrCancelled
}
_, err = transaction.Exec(ctx, `
INSERT INTO support_reports (
id, support_code, product_id, request_type, status, private_payload,
capability_ciphertext, diagnostic_object_key, idempotency_hash, request_hash,
Expand All @@ -43,11 +60,58 @@ func (postgres *Postgres) Create(ctx context.Context, report domain.Report) erro
if errors.As(err, &postgresError) && postgresError.Code == "23505" {
return ErrConflict
}
return err
if err != nil {
return err
}
return transaction.Commit(ctx)
}

func (postgres *Postgres) ByIdempotencyHash(ctx context.Context, hash []byte) (domain.Report, error) {
return scanReport(postgres.pool.QueryRow(ctx, reportSelect+` WHERE idempotency_hash = $1 AND deleted_at IS NULL`, hash))
report, err := scanReport(postgres.pool.QueryRow(ctx, reportSelect+` WHERE idempotency_hash = $1 AND deleted_at IS NULL`, hash))
if !errors.Is(err, ErrNotFound) {
return report, err
}
var cancelled bool
if lookupErr := postgres.pool.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM support_submission_states WHERE idempotency_hash = $1 AND cancelled_at IS NOT NULL
)`, hash).Scan(&cancelled); lookupErr != nil {
return domain.Report{}, lookupErr
}
if cancelled {
return domain.Report{}, ErrCancelled
}
return domain.Report{}, ErrNotFound
}

func (postgres *Postgres) CancelByIdempotencyHash(ctx context.Context, hash []byte, now time.Time) (*domain.Report, error) {
transaction, err := postgres.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer transaction.Rollback(ctx)
if _, err := transaction.Exec(ctx, `INSERT INTO support_submission_states (idempotency_hash, cancelled_at)
VALUES ($1, $2)
ON CONFLICT (idempotency_hash) DO UPDATE
SET cancelled_at = COALESCE(support_submission_states.cancelled_at, EXCLUDED.cancelled_at)`, hash, now); err != nil {
return nil, err
}
report, updateErr := scanReport(transaction.QueryRow(ctx, `UPDATE support_reports
SET deleted_at = COALESCE(deleted_at, $1),
updated_at = CASE WHEN deleted_at IS NULL THEN $1 ELSE updated_at END
WHERE idempotency_hash = $2
RETURNING id, support_code, product_id, request_type, status, private_payload,
capability_ciphertext, diagnostic_object_key, idempotency_hash, request_hash,
capability_hash, created_at, updated_at, retention_until, deleted_at`, now, hash))
if updateErr != nil && !errors.Is(updateErr, ErrNotFound) {
return nil, updateErr
}
if err := transaction.Commit(ctx); err != nil {
return nil, err
}
if errors.Is(updateErr, ErrNotFound) {
return nil, nil
}
return &report, nil
}

func (postgres *Postgres) ByCapabilityHash(ctx context.Context, hash []byte) (domain.Report, error) {
Expand Down
6 changes: 4 additions & 2 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import (
)

var (
ErrNotFound = errors.New("report not found")
ErrConflict = errors.New("report already exists")
ErrNotFound = errors.New("report not found")
ErrConflict = errors.New("report already exists")
ErrCancelled = errors.New("report submission was cancelled")
)

type Reports interface {
Create(context.Context, domain.Report) error
ByIdempotencyHash(context.Context, []byte) (domain.Report, error)
CancelByIdempotencyHash(context.Context, []byte, time.Time) (*domain.Report, error)
ByCapabilityHash(context.Context, []byte) (domain.Report, error)
DeleteByCapabilityHash(context.Context, []byte) (domain.Report, error)
Expired(context.Context, time.Time, int) ([]domain.Report, error)
Expand Down
11 changes: 11 additions & 0 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (server *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/v1/products", server.listProducts)
mux.HandleFunc("POST /api/v1/reports", server.createReport)
mux.HandleFunc("GET /api/v1/receipts", server.reconcileReceipt)
mux.HandleFunc("DELETE /api/v1/receipts", server.cancelSubmission)
mux.HandleFunc("GET /api/v1/reports/{capability}", server.reportStatus)
mux.HandleFunc("DELETE /api/v1/reports/{capability}", server.deleteReport)
mux.HandleFunc("POST /api/v1/admin/login", server.adminLogin)
Expand Down Expand Up @@ -168,6 +169,14 @@ func (server *Server) reconcileReceipt(response http.ResponseWriter, request *ht
writeJSON(response, http.StatusOK, receipt)
}

func (server *Server) cancelSubmission(response http.ResponseWriter, request *http.Request) {
if err := server.intake.Cancel(request.Context(), request.Header.Get("Idempotency-Key")); err != nil {
server.writeIntakeError(response, err)
return
}
response.WriteHeader(http.StatusNoContent)
}

func (server *Server) reportStatus(response http.ResponseWriter, request *http.Request) {
status, err := server.intake.Status(request.Context(), request.PathValue("capability"))
if err != nil {
Expand All @@ -192,6 +201,8 @@ func (server *Server) writeIntakeError(response http.ResponseWriter, err error)
writeProblem(response, http.StatusBadRequest, "invalid_report", "Check the report details and try again.")
case errors.Is(err, intake.ErrKeyReused):
writeProblem(response, http.StatusConflict, "idempotency_conflict", "This retry identifier belongs to different report content.")
case errors.Is(err, intake.ErrCancelled):
writeProblem(response, http.StatusGone, "submission_cancelled", "This private report submission was cancelled.")
case errors.Is(err, intake.ErrNotFound):
writeProblem(response, http.StatusNotFound, "not_found", "This private report link is not available.")
default:
Expand Down
22 changes: 22 additions & 0 deletions internal/web/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

Expand All @@ -29,3 +30,24 @@ func TestSecurityHeadersKeepPrivateRoutesOutOfIndexes(t *testing.T) {
t.Fatalf("public X-Robots-Tag = %q", got)
}
}

func TestCancellationEndpointReturnsTerminalResult(t *testing.T) {
handler, _ := testAdminHandler(t)
idempotencyKey := strings.Repeat("A", 43)

cancelRequest := httptest.NewRequest(http.MethodDelete, "/api/v1/receipts", nil)
cancelRequest.Header.Set("Idempotency-Key", idempotencyKey)
cancelled := httptest.NewRecorder()
handler.ServeHTTP(cancelled, cancelRequest)
if cancelled.Code != http.StatusNoContent || cancelled.Body.Len() != 0 {
t.Fatalf("cancellation = %d, body = %q", cancelled.Code, cancelled.Body.String())
}

reconcileRequest := httptest.NewRequest(http.MethodGet, "/api/v1/receipts", nil)
reconcileRequest.Header.Set("Idempotency-Key", idempotencyKey)
reconciled := httptest.NewRecorder()
handler.ServeHTTP(reconciled, reconcileRequest)
if reconciled.Code != http.StatusGone || !strings.Contains(reconciled.Body.String(), "submission_cancelled") {
t.Fatalf("reconciliation = %d, body = %q", reconciled.Code, reconciled.Body.String())
}
}
12 changes: 12 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ paths:
$ref: "#/components/schemas/Receipt"
"400": { $ref: "#/components/responses/Problem" }
"409": { $ref: "#/components/responses/Problem" }
"410": { $ref: "#/components/responses/Problem" }
/api/v1/receipts:
get:
summary: Reconcile an uncertain report submission
Expand All @@ -65,6 +66,17 @@ paths:
schema:
$ref: "#/components/schemas/Receipt"
"404": { $ref: "#/components/responses/Problem" }
"410": { $ref: "#/components/responses/Problem" }
delete:
summary: Cancel a private report submission
operationId: cancelReportSubmission
description: Atomically prevent a pending submission from creating a report and delete any report already created with the same idempotency key.
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"204":
description: The submission is terminally cancelled and no private report remains available.
"400": { $ref: "#/components/responses/Problem" }
/api/v1/reports/{capability}:
parameters:
- name: capability
Expand Down
Loading