Commit Graph
53 Commits
Author SHA1 Message Date
JMR-devandClaude Opus 4.8 985fb684c5 #4 GitHub Actions CD: deploy via workflow_dispatch
Add a manual, workflow_dispatch-only CD workflow and close the infra
binding-wiring gap (#9) so the deployed Worker is actually functional.

Deliverable 1 - .github/workflows/deploy.yml:
- workflow_dispatch only, with a `stack` choice input (default prod).
- Gated to the `production` GitHub Actions environment and to the main
  branch (guard step fails otherwise); no push/PR trigger.
- Reuses ci.yml's Go 1.26 + TinyGo + pnpm setup and the TinyGo net/http
  patch, builds the Wasm Worker (pnpm run build), then runs pulumi up over
  infra/ via pulumi/actions, injecting the built ../build/worker.mjs as the
  workerScriptPath config. Provider/backend creds come from environment
  secrets (nothing committed). All actions pinned by commit SHA; actionlint
  clean.

Deliverable 2 - infra/deploy.go binding wiring (#9):
- WorkersScript now carries the R2 bucket binding (REPORTS_BUCKET), the four
  Secrets Store bindings (BUGREPORT_ENC_KEYRING, ADMIN_TOKEN, GITHUB_TOKEN,
  OTEL_EXPORTER_OTLP_HEADERS), and the plain vars (GITHUB_REPO, OTEL_*),
  matching wrangler.jsonc and the Worker runtime contract.
- New WorkersCronTrigger resource registers the two Friday UTC crons (#13),
  bound to the Worker.
- Real built artifact wired via ContentFile + computed ContentSha256 when
  the workerScriptPath config is set; documented placeholder otherwise
  (keeps the program testable without the artifact, as #2 did).
- secretsStoreId is a new required config; git rate-limit ruleset insertion
  point (#6/#7) kept reserved.
- Extended the WithMocks tests to assert the R2 + Secrets Store + var
  bindings, the crons, and the artifact ContentFile/ContentSha256 path.
- Updated Pulumi.<stack>.yaml and infra/README.md with the full
  secret/config list and how a maintainer triggers the deploy.

Verified in infra/: go build, go vet, go test all green; actionlint clean
on deploy.yml. No deploy/preview/provision was run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:40:08 -05:00
Jason Ross 5cf9c767dc Merge pull request #45 from JMR-dev/ticket-17-otel-observability
#17 Observability: OpenTelemetry logging + tracing + alerting
2026-07-02 17:16:49 -05:00
JMR-devandClaude Opus 4.8 45ac7236ee #17 Observability: OpenTelemetry logging + tracing + alerting
Instrument the Worker with OpenTelemetry traces + structured logs over OTLP,
plus alertable signals, via a minimal hand-rolled OTLP/HTTP exporter that fits
the TinyGo/Wasm Worker build.

New internal/telemetry package (build-tag-free, host-tested):
- Span/log shim: Telemetry provider, Span (attrs/status/events/end), Log
  (Info/Warn/Error), trace/span-id correlation, W3C-style ids from crypto/rand.
- Exporter seam: MemoryExporter (in-memory, for tests) and OTLPHTTPExporter
  (OTLP/HTTP JSON over net/http). No go.opentelemetry.io/otel/sdk dependency:
  the full OTEL-Go SDK + OTLP exporters pull in a large, reflection-heavy tree
  (protobuf, grpc) that bloats the Wasm binary and is unreliable under TinyGo.
  The shim uses only stdlib already proven under this project's js/wasm target
  (net/http per #26, encoding/json, crypto/rand). OTLP is the wire format, so
  any OTLP backend can ingest it.
- Behaviour-preserving by construction: instrumentation is threaded through
  context. Instrumented code pulls an optional *Telemetry from ctx; absent (or
  nil exporter) => every method is a no-op. No public signatures change
  (NewHandler, handler.New, publish.New/Publish, schedule.Run are untouched), so
  parallel work built on the current APIs keeps compiling.

Instrumentation:
- ingest: an "ingest.request" server span + correlated log per request,
  classifying accepted / rejected / rate_limited / error. Observe-only (wraps the
  response writer to read the status); the HTTP contract is unchanged. A 5xx
  (e.g. 503 storage-unavailable) sets the span to Error and emits the alertable
  ingest.error signal; 4xx client rejections are INFO, not alerts.
- publish: a "publish.run" span with per-report "publish.report" child spans and
  a log per report (published/failed). A failed report/run sets Error and emits
  alert.type=publish.run_failed. The per-run cap-hit (folding in the #14
  follow-up) is now emitted as a structured, alertable OTEL signal
  (alert.type=publish.cap_hit + counts), not merely a log line.
- schedule: a "schedule.run" span parenting the publish run; a list/publish
  failure emits alert.type=schedule.run_failed.

Config (OTLP endpoint TBD, issue #17):
- OTEL_EXPORTER_OTLP_ENDPOINT (plain var) - base OTLP/HTTP URL; empty => telemetry
  disabled (Worker behaves as before). /v1/traces and /v1/logs are appended.
- OTEL_EXPORTER_OTLP_HEADERS (Secrets Store secret) - auth header(s), never
  committed. OTEL_SERVICE_NAME (plain var) - service.name override.
- worker/telemetry_wasm.go builds the exporter lazily per run and injects the
  provider into the request/scheduled context; wrangler.jsonc gains only these
  OTEL keys.

Alerting: run-failure, cap-hit, and elevated-ingest-error are emitted as span
status=Error and structured log records carrying alert=true + a specific
alert.type, so a backend alert rule can key on them once the OTLP endpoint is
chosen.

Tests: host unit tests with the in-memory exporter assert the ingest spans+logs
for accepted/rejected/error, the publish run span + per-report spans + the
cap-hit and run-failed signals, the schedule run span + list-error alert, and
the OTLP/JSON encoding + HTTP round trip (httptest, no real backend). No-op
default verified. go vet ./... and go test ./... green; GOOS=js GOARCH=wasm
go build ./... compiles.

Closes #17

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:14:21 -05:00
Jason Ross 850a1ebc73 Merge pull request #44 from JMR-dev/ticket-16-test-suite
#16 Test suite: anonymization, lifecycle, de-dup, endpoint integration
2026-07-02 17:04:24 -05:00
JMR-devandClaude Opus 4.8 0d66969a97 #16 Test suite: end-to-end pipeline integration tests
Add internal/integration: a build-tag-free, host-only test package that
wires the Worker's REAL components together and exercises the full flow,
catching regressions in how the stages compose that per-ticket unit tests
miss. CI (#3) runs it automatically via `go test ./...` — no ci.yml change.

Scenarios (real http.Handler over loopback, real scrub->encrypt->store
Sink + host AES-256-GCM keyring, real lifecycle.Manager over a shared
MemoryStore, real publish.Publisher driving the real GitHub client against
a mock GitHub REST server, real schedule.Run gate):

- ingest->scrub->encrypt->store: POST /v1/reports with PII is 202; the
  object at rest is AES-256-GCM ciphertext leaking neither the PII nor the
  placeholder text, and decrypts to the fully scrubbed body.
- pending listed then removed via the authed admin API (#11); fail-closed
  401 without a token; removed report drops from pending.
- Friday-17:00-Central cron publishes one labeled issue per pending report
  with PII scrubbed from the issue body, marks each published, and a second
  run creates no new issues (cross-run de-dup); a later ingest publishes
  exactly once.
- a removed report is never published (admin removal x publish, #11 x #14).
- endpoint status-code contract on the assembled handler (ingest + admin).

Deterministic and fast: virtual clock + zero GitHub request spacing, no
real sleeps or network. No production code, wrangler.jsonc, or ci.yml
changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:00:42 -05:00
Jason Ross d50f67ed8d Merge pull request #43 from JMR-dev/ticket-15-mark-processed
#15 Mark reports processed after publishing
2026-07-02 16:47:19 -05:00
JMR-devandClaude Opus 4.8 288f7e7fea #15 Mark reports processed after publishing
Wire the publisher's onPublished hook to lifecycle.MarkPublished so each
confirmed-201 publish immediately transitions that report pending->published,
completing cross-run de-duplication.

- internal/publish: add the narrow Marker seam (write half of lifecycle.Manager)
  and WithMarkPublished(m) option. It sets onPublished to call m.MarkPublished on
  each confirmed create; on a mark failure it logs loudly (naming the report and
  the duplicate-next-run risk) and surfaces the error so the run is recorded
  failed. Mirrors the existing PendingGetter read-half seam, so publish stays
  host-testable and free of the Wasm-only storage backends.
- worker/scheduled_wasm.go: buildPublish now passes WithMarkPublished(manager);
  the one Manager instance is both pending getter and marker. worker/main.go
  untouched.

Partial-failure guarantee falls out of #14's seam: the hook runs only on a 201
and per-report failures are isolated, so successes leave the pending set and a
failed report stays pending and is retried next run without duplicating the
already-published ones.

Tests (host, real lifecycle.Manager over MemoryStore + mock issue creator):
all-succeed run marks all published and a second run creates no new issues;
partial failure retries only the failed report next run without duplicating the
rest; MarkPublished is idempotent on an already-published report; and the
mark-failure edge case is surfaced, logged, and (honestly) re-publishes once.

Closes #15

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:44:54 -05:00
Jason Ross 697e3b7f1d Merge pull request #42 from JMR-dev/ticket-41-flaky-crypto-test
#41 Fix flaky TestSealOpenRoundtrip (deterministic)
2026-07-02 16:37:37 -05:00
JMR-devandClaude Opus 4.8 49c2df1611 #41 Fix flaky TestSealOpenRoundtrip (deterministic)
TestSealOpenRoundtrip scanned the whole ~36-byte sealed frame for the
plaintext with bytes.Contains(sealed, pt). For the 1-byte case ("x"),
any of the ~29 random bytes (nonce+ciphertext+tag) equalling that byte
tripped the assertion, so it failed ~11% of runs (1-(255/256)^29).
Living on main, that spuriously failed ~11% of CI runs.

Replace the probabilistic per-byte scan with a deterministic check that
the sealed frame is never the bare plaintext (it always carries the
header+nonce+tag, so it differs in both length and content). The
round-trip Open(Seal(x)) == x assertion is unchanged. Verbatim-leak
coverage already lives deterministically in TestNoPlaintextLeak, which
uses a multi-byte marker where a chance match is negligible.

Test-only change; no production code touched.
Verified: go test ./internal/crypto/... -count=100 passes; the
previously-flaky test passes 500 consecutive runs; go vet ./... clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:35:26 -05:00
Jason Ross 45c2c57156 Merge pull request #40 from JMR-dev/ticket-14-publish-issues
#14 Decrypt/format reports and publish as GitHub issues (de-duped)
2026-07-02 16:34:54 -05:00
JMR-devandClaude Opus 4.8 f9ec7978b8 Add internal/publish: the real schedule.Publisher that turns pending
encrypted reports into labeled GitHub issues.

- GitHub REST client on net/http (host-testable via httptest; works under
  TinyGo js/wasm per #26). Encodes ADR #6 §3.2: serial mutations spaced
  >=1s, honour Retry-After, wait until x-ratelimit-reset, >=60s floor for
  secondary-limit 403s, full-jitter exponential backoff (base 1s, cap 60s,
  <=5 attempts). Ensures the three ADR #6 labels (create-or-ignore).
- Publisher: GetPending -> crypto.Open -> format -> CreateIssue per id,
  with the ADR #6 per-run cap (50) and 65,536-char body cap (truncate).
  Per-report failures are isolated and surfaced, never abort the batch.
- onPublished(ctx, id) seam, called only after a confirmed 201, default
  no-op: #15 wires it to lifecycle.MarkPublished to complete cross-run
  de-dup. #14 does not implement the mark-published transition.
- Issue body wraps report free-text in a length-adaptive code fence and
  metadata in inline code, neutralising Markdown/@mention injection.
- Worker: swap schedule.LogPublisher for the real publisher in
  scheduled_wasm.go; read GITHUB_TOKEN (Secrets Store) + GITHUB_REPO (var);
  pre-gate so the sibling cron fire does no secret I/O. worker/main.go
  untouched. Export storage.GetSecret for the token read.
- wrangler.jsonc: add GITHUB_TOKEN secret + GITHUB_REPO var (my keys only).

Tests (host, httptest mock, virtual clock): N reports -> N labeled issues
+ onPublished per success; >65,536-char body truncated; transient 5xx and
Retry-After retried per policy; persistent failure isolated (no
onPublished); permission 403 not retried; per-run cap; decrypt failure
isolated. go vet + go test ./... green; GOOS=js GOARCH=wasm build compiles.

Closes #14

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:30:33 -05:00
Jason Ross 9810b676c6 Merge pull request #39 from JMR-dev/ticket-11-manual-removal
#11 Manual review/removal path for maintainers
2026-07-02 15:58:55 -05:00
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
Jason Ross 2b65dcfe6a Merge pull request #38 from JMR-dev/ticket-13-cron-trigger
#13 Cloudflare Cron Trigger: Friday 17:00 America/Chicago, DST-correct
2026-07-02 15:54:53 -05:00
JMR-devandClaude Opus 4.8 e650e5f161 #13 Cloudflare Cron Trigger: Friday 17:00 America/Chicago, DST-correct
Add a weekly Cron Trigger that fires at 17:00 America/Chicago (Central) every
Friday year-round, correct across the CST/CDT DST transition, and on fire lists
the pending reports (#10 Manager.ListPending) and hands their ids to the publish
step.

Cloudflare crons are UTC-only, and 17:00 Central is 22:00 UTC under CDT (summer)
and 23:00 UTC under CST (winter), so no single UTC cron expresses it. Register
BOTH Friday UTC hours in wrangler.jsonc (`0 22 * * 5` and `0 23 * * 5`) and gate
each fire: only the fire that is actually 17:00 Central does the work, so
publishing runs exactly once per Friday.

TinyGo/Wasm may lack the IANA tz database, so the gate does not call
time.LoadLocation. Instead internal/schedule computes the US Central DST rule
from first principles (CDT from the 2nd Sunday of March 02:00 to the 1st Sunday
of November 02:00, else CST) behind a pure func IsFriday1700Central(time.Time),
host-testable without TinyGo and cross-checked against the real America/Chicago
zone (via a test-only time/tzdata import) over a 20-year sweep.

- internal/schedule: pure DST gate + Run orchestrator; Publisher/PendingLister
  seams; LogPublisher no-op default (the seam #14 replaces).
- worker/scheduled_wasm.go: js/wasm-only adapter registering the scheduled task
  via init()+cron.ScheduleTaskNonBlock, wiring the R2-backed lifecycle Manager to
  schedule.Run. worker/main.go is untouched.
- wrangler.jsonc: add triggers.crons (only the triggers section changed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:52:39 -05:00
Jason Ross c9f8489350 Merge pull request #37 from JMR-dev/ticket-36-deps-security
#36 Security: patch form-data + uuid (Dependabot)
2026-07-02 15:44:59 -05:00
JMR-devandClaude Opus 4.8 52ce6e9aca #36 Security: patch vulnerable transitive deps form-data + uuid
Force patched versions of two vulnerable transitive dev-tooling deps
flagged by Dependabot. Both are dev-only (pulled in transitively by
wrangler / @usebruno/cli) and are not part of the Go/Wasm Worker:

- form-data 4.0.4 -> 4.0.6  (HIGH, CRLF injection; vuln >=4.0.0 <4.0.6)
- uuid      10.0.0 -> 14.0.1 (MEDIUM, buffer bounds; vuln <11.1.1)

The pnpm overrides live in pnpm-workspace.yaml (the `overrides:` key)
rather than package.json's `pnpm.overrides` because pnpm 11 no longer
reads the "pnpm" field in package.json (it warns and ignores it). This
sits alongside the existing allowBuilds config in the same file. The
lockfile was regenerated so form-data resolves to a single 4.0.6 and
uuid to 14.0.1 (the >=11.1.1 override resolves to the latest published
uuid, which is well above the vulnerable <11.1.1 range).

Verified locally:
- pnpm install and pnpm install --frozen-lockfile exit 0
- pnpm run test:api (Bruno suite) passes 8/8 against go devserver
- pnpm exec wrangler --version -> 4.106.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:42:41 -05:00
Jason Ross 9557075bdb Merge pull request #35 from JMR-dev/ticket-10-lifecycle-metadata
#10 Report lifecycle/status metadata (pending/removed/published)
2026-07-02 15:30:04 -05:00
JMR-devandClaude Opus 4.8 a03da6ede9 storage(r2): build R2 list options via Object/Set for TinyGo parity
Construct the list() options with js.Global().Get("Object").New()+Set instead
of js.ValueOf(map[string]any), keeping the JS-interop surface identical to the
rest of the wasm build. No behavior change; wasm-only file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:27:31 -05:00
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
Jason Ross 786eb225b7 Merge pull request #34 from JMR-dev/ticket-9-encrypted-r2-storage
#9 Encrypted-at-rest R2 storage for scrubbed reports
2026-07-02 15:10:42 -05:00
JMR-devandClaude Opus 4.8 bd7fe21d97 #9 Encrypted-at-rest R2 storage for scrubbed reports
Implement the storage path: for each accepted report, scrub PII (#8),
encrypt with AES-256-GCM (ADR #5), and write only ciphertext to R2, wired
in as the real ingest Sink replacing NopSink.

- internal/crypto: AES-256-GCM in the exact ADR #5 wire format
  (magic "LMB1" || version || key_id BE16 || nonce(12) || ct || tag(16);
  the 7-byte header is the GCM AAD). Provider-independent framing shared by
  a host crypto/aes+crypto/cipher impl (tests, devserver) and a Wasm
  SubtleCrypto impl (syscall/js, //go:build js && wasm) per the TinyGo
  constraint; both produce byte-identical frames. Versioned keyring with
  key_id rotation; ParseKeyring reads the Secrets Store JSON secret.
- internal/storage: ObjectStore interface with an in-memory fake (tests,
  devserver) and a Wasm R2Store (syumai/workers R2 binding). Sink ties
  scrub -> Seal -> Put under a unique reports/<ts>-<rand> key. WorkerSink
  loads the keyring from Secrets Store (BUGREPORT_ENC_KEYRING), cached for
  the isolate lifetime.
- handler.New now takes an injectable ingest.Sink; the Worker uses the real
  R2/Secrets-Store sink, the devserver a memory + throwaway-key sink.
- wrangler.jsonc: add REPORTS_BUCKET (R2) and BUGREPORT_ENC_KEYRING
  (Secrets Store) bindings.

Tests (host, no TinyGo): encrypt/decrypt roundtrip; ciphertext != plaintext;
wrong key + tamper (ct/tag/nonce/header-AAD) fail; exact wire layout plus a
known-answer vector; key_id rotation with retained keys; full sink path (PII
scrubbed then encrypted, readback requires the key and yields the scrubbed
content). Existing ingest/handler behavior preserved (202 on valid POST).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:08:21 -05:00
Jason Ross 3d320a8cea Merge pull request #33 from JMR-dev/ticket-32-ci-cache-infra
#32 CI: cache infra/ Go module deps
2026-07-02 15:02:37 -05:00
JMR-devandClaude Opus 4.8 98ee51b21a CI: cache infra/ Go module deps (Pulumi SDKs) to speed up runs
setup-go's built-in module cache defaults to keying only on the root
go.sum, so the infra/ module's heavy Pulumi SDK dependencies
(pulumi/sdk, pulumi-cloudflare, pulumi-gcp) re-downloaded on every run.

Set cache-dependency-path to hash both go.sum and infra/go.sum so
infra/'s deps are restored from cache. The cache warms on the first
(cold) run; the speedup lands on subsequent (warm) runs.

Closes #32

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:00:50 -05:00
Jason Ross da31bc4b24 Merge pull request #31 from JMR-dev/ticket-12-privacy-doc
#12 Document data flow & privacy posture
2026-07-02 14:52:56 -05:00
JMR-devandClaude Opus 4.8 80db59d9f1 Correct privacy doc status after #7/#8/#2 merged to main
Bring docs/privacy.md's implementation status current with main, which now
includes the ingest endpoint (#7), the scrub library (#8), and the Pulumi
infra (#2).

- Stop claiming the ingest endpoint is unimplemented: POST /v1/reports (size
  cap, v1 schema validation, and the 202/400/413/415/405/503 contract) and the
  PII-scrub library are now implemented in the repo.
- Replace the granular per-stage status table with one concise
  "Current implementation status" note that is less prone to going stale.
- Keep the honest nuance: the endpoint is wired to a no-op sink, so accepted
  reports are not yet retained, scrubbed in-line, encrypted, or published;
  scrub is not yet invoked on the live path. Encrypted storage (#9), lifecycle
  (#10), manual removal (#11), cron (#13), and publish (#14/#15) remain not yet
  built, and the edge rate-limit ruleset is reserved but not yet provisioned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:47:43 -05:00
JMR-devandClaude Opus 4.8 cb1b6536f2 Document data flow & privacy posture (#12)
Add docs/privacy.md describing the end-to-end bug-report pipeline and its
privacy posture, so it can be linked from LibreMail's README / F-Droid
metadata.

Covers: opt-in / user-initiated-only submission; HTTPS ingest (POST
/v1/reports, size-limited, validated); best-effort PII scrub and its
documented limits; encrypted-at-rest R2 storage (AES-256-GCM, key in
Cloudflare Secrets Store); manual review/removal window; and the weekly
publish to GitHub. States plainly that scrubbing is best-effort (not a
guarantee) and marks stages that are designed but not yet implemented.

Links ADR #5 (encryption) and ADR #6 (labels/abuse).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:47:43 -05:00
Jason Ross dd443bbe37 Merge pull request #27 from JMR-dev/ticket-2-pulumi-scaffold
#2 Pulumi IaC scaffolding: Worker, R2 bucket, Google Cloud DNS
2026-07-02 14:36:23 -05:00
Jason Ross 1a605fdda6 Merge branch 'main' into ticket-2-pulumi-scaffold 2026-07-02 14:32:03 -05:00
Jason Ross ab81b3a36e Merge pull request #29 from JMR-dev/ticket-7-ingest-endpoint
#7 Ingest HTTPS endpoint: accept report POST, size limit
2026-07-02 14:30:41 -05:00
Jason Ross 9a2d9df944 Merge branch 'main' into ticket-2-pulumi-scaffold 2026-07-02 14:29:02 -05:00
Jason Ross cd59c79521 Merge branch 'main' into ticket-7-ingest-endpoint 2026-07-02 14:29:00 -05:00
Jason Ross 16941e1c9d Merge pull request #28 from JMR-dev/ticket-8-pii-redaction
#8 PII anonymization/redaction pass before storage
2026-07-02 14:27:30 -05:00
Jason Ross 653b981146 Merge branch 'main' into ticket-7-ingest-endpoint 2026-07-02 14:25:41 -05:00
Jason Ross 1c1119cd30 Merge branch 'main' into ticket-8-pii-redaction 2026-07-02 14:25:37 -05:00
Jason Ross 62ad4ce907 Merge branch 'main' into ticket-2-pulumi-scaffold 2026-07-02 14:25:34 -05:00
Jason Ross 92655c58cb Merge pull request #25 from JMR-dev/ticket-3-ci
#3 CI: build, lint, test + working TinyGo/Wasm build
2026-07-02 14:24:32 -05:00
Jason Ross c0c2925a5a Merge branch 'main' into ticket-3-ci 2026-07-02 14:22:41 -05:00
JMR-devandClaude Opus 4.8 47f9babe35 #26 Fix TinyGo net/http wasm build via pinned upstream patch (Go 1.26)
TinyGo 0.41.1 and earlier vendor tinygo-org/net@e54965e, whose net/http
js/wasm overlay (roundtrip_js.go) calls the private t.roundTrip fallback
removed from Go 1.25+/1.26 net/http, so `pnpm run build` fails to compile
on Go 1.26 (tinygo-org/tinygo#5467). No released TinyGo carries the fix
yet: it landed in tinygo-org/net@1026408a on 2026-04-27, after 0.41.1
shipped 2026-04-22, and is already on TinyGo's dev branch.

Keep Go 1.26 and apply the exact upstream fix in CI before the build:
- .ci/tinygo-net-roundtrip.patch: byte-exact tinygo-org/net@1026408a diff
  (its parent e54965e is the commit 0.41.1 ships), targeting
  src/net/http/roundtrip_js.go.
- ci.yml: new "Patch TinyGo net/http (temporary)" step applies it to
  $(tinygo env TINYGOROOT) via `git apply`, failing loudly on drift.
- .gitattributes: force LF on *.patch so `git apply` works on the Linux
  runner regardless of the committer's platform.
- README: document the temporary patch and its removal condition.

Temporary: remove the patch and the CI step once a TinyGo release later
than 0.41.1 ships the net fix. Tracking #26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:06:08 -05:00
JMR-devandClaude Opus 4.8 b31455f1c3 #7 Approve protobufjs build script so pnpm install exits 0
@usebruno/cli (the API-test runner added in this PR) pulls in protobufjs,
whose postinstall build script pnpm 11 leaves un-approved by default. That
makes `pnpm install` exit non-zero (ERR_PNPM_IGNORED_BUILDS), which also
aborts `pnpm exec` / `pnpm run` via their verify-deps-before-run precheck
and would break CI's pnpm install once this lands on main.

Add protobufjs to the existing allowBuilds allowlist in pnpm-workspace.yaml
(same mechanism already used for esbuild/sharp/workerd). With it, pnpm
install exits 0 and the Bruno OpenCollection YAML suite runs green through
the pnpm wrapper (pnpm exec bru run / pnpm run test:api), 8/8 tests passing
against `go run ./cmd/devserver`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:02:04 -05:00
JMR-devandClaude Opus 4.8 bd3637d8a6 Revert Go downgrade; keep Go 1.26 per maintainer mandate
Undoes the go.mod/setup-go pin to 1.25 from the previous commit. The
maintainer requires Go 1.26. The TinyGo net/http wasm build failure is
an upstream toolchain bug (tinygo-org/tinygo#5467) and is being resolved
separately without changing the Go version. Not pushed pending the
toolchain-fix decision (issue #26).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:53:46 -05:00
JMR-devandClaude Opus 4.8 4c22056e66 #7 Ingest HTTPS endpoint: accept report POST, size limit
Add internal/ingest implementing POST /v1/reports, wired into the core
build-tag-free handler so the same route serves on the dev server and the
Cloudflare Worker.

Response contract (ADR #6 §2.4):
- 202 Accepted for valid JSON within the 256 KiB cap ({"status":"accepted"})
- 413 for oversized bodies (Content-Length fast path AND a MaxBytesReader
  hard cap, so a missing/lying Content-Length cannot bypass the limit)
- 415 when Content-Type is not application/json
- 400 for malformed JSON or failed schema validation (generic error body,
  never echoes request content)
- 405 with Allow: POST for any non-POST method
- 503 when the storage Sink fails

Storage is decoupled behind a small Sink interface (Store(ctx, raw)) with a
NopSink default and a MemorySink for tests, so PII scrubbing (#8) and
encrypted R2 storage (#9) can slot in without touching the HTTP contract.
Rate limiting (429) and volumetric shedding stay a Cloudflare-edge/Pulumi
concern per #2 and are intentionally not implemented in the Worker.

Tests:
- Go unit tests (net/http/httptest) for every response code, including 413
  via both Content-Length and an oversized streamed body, plus boundary,
  storage-failure, and no-content-echo cases.
- Bruno API tests in OpenCollection YAML format under api-tests/, asserting
  the full contract against the local dev server via @usebruno/cli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:52:04 -05:00
JMR-devandClaude Opus 4.8 6508679d86 Add best-effort PII redaction pass (internal/scrub) (#8)
Introduce package internal/scrub, a schema-agnostic, regex/heuristic
based redaction pass to run over raw bug-report payloads before storage
(#9). It masks (never deletes) matches with bracketed placeholders so
payload structure is preserved for triage.

Categories:
- Emails: robust address regex; ignores @handles and "meet @ 3pm".
- Auth tokens/secrets: Authorization/Proxy-Authorization header values,
  standalone Bearer tokens, eyJ-anchored JWTs, well-known provider key
  formats (GitHub, GitLab, Slack, Stripe, OpenAI, Google, AWS), and
  values under secret-named keys (password, api_key, token, ...).
- IP addresses: octet-validated IPv4 and comprehensive IPv6 (full,
  compressed, loopback, IPv4-mapped), ordered for correct extraction.
- Names: deliberately weak, key-directed heuristic (name/user/...),
  \b-anchored to avoid filename/hostname collisions. Documented in code
  as best-effort and NOT to be relied upon.

API: Scrub([]byte) []byte, ScrubString(string) string, plus composable
per-category RedactEmails/RedactTokens/RedactIPs/RedactNames and exported
Placeholder* constants. Non-mutating and idempotent. Build-tag-free so it
compiles for host and the Wasm target.

Tests cover each category with positive and over-redaction-guard cases
(89 passing checks); go test ./... is green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:46:45 -05:00
JMR-devandClaude Opus 4.8 21665b172d #2 Pulumi IaC scaffolding: Worker, R2 bucket, Google Cloud DNS
Add an infra/ Pulumi (Go) program in its own module
(github.com/JMR-dev/LibreMail-Bug-Report-Ingest/infra) that declares the
three pieces of edge/DNS infrastructure for the bug-report ingest pipeline:

- Cloudflare Worker script (libremail-bug-report-ingest, built in #1)
- Cloudflare R2 bucket (libremail-bug-reports) for encrypted reports (ADR 0001)
- Google Cloud DNS record (CNAME) pointing the ingest hostname at the Worker,
  referencing an existing managed zone by name

Per-environment stacks (dev/prod) via Pulumi.<stack>.yaml + pulumi.Config;
account id, zone, domain, etc. are parameterized through config and secrets
are kept out of git (documented in infra/README.md). Worker content is a
documented placeholder because the real TinyGo->Wasm artifact is produced by
the build pipeline.

Mock-based unit tests (pulumi.RunErr + pulumi.WithMocks) assert the registered
resources and their inputs; go build + go vet + go test all pass without the
Pulumi CLI. Structured so the #7 Cloudflare Rate Limiting ruleset can be added
later (reserved cloudflareZoneId config + insertion point in deploy.go).

Providers: pulumi-cloudflare v6.17.0, pulumi-gcp v8.41.1, pulumi/sdk v3.250.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:45:07 -05:00
JMR-devandClaude Opus 4.8 f27ad42c24 #26 Pin Go 1.25 + TinyGo 0.41.1 so the Wasm build compiles
TinyGo 0.41.1's bundled net/http override (roundtrip_js.go) fails to
compile against the Go 1.26 stdlib:

  net/http/roundtrip_js.go:73:12: t.roundTrip undefined (type *Transport
  has no field or method roundTrip, but does have method RoundTrip)

This is tinygo-org/tinygo#5467 (closed 2026-06-20, but not in any tagged
TinyGo release as of 0.41.1, released 2026-04-22). Go 1.25.x is the
newest line TinyGo 0.41.1 fully supports; syumai/workers v0.33.0 needs
only go 1.21.3 and the handler uses only net/http + encoding/json, so
downgrading is safe:

- go.mod: go 1.26.2 -> go 1.25.0 (so GOTOOLCHAIN won't auto-upgrade past
  what TinyGo supports)
- ci.yml: setup-go go-version 1.26 -> 1.25 (TinyGo pin stays 0.41.1)
- README: document the pinned TinyGo/Go matrix and the #5467 rationale

go vet ./..., go test ./..., and actionlint stay green locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:17 -05:00
Jason Ross 27d707d704 Merge pull request #24 from JMR-dev/ticket-23-autoupdate-pat
#23 autoupdate: use STATUS_CHECKS_RETRIGGER_TOKEN so branch updates re-trigger checks
2026-07-02 13:34:02 -05:00
JMR-devandClaude Opus 4.8 4c2d0ad43f autoupdate: use STATUS_CHECKS_RETRIGGER_TOKEN so branch updates re-trigger checks
The autoupdate workflow authenticated its branch-update pushes with the
default GITHUB_TOKEN. Pushes made with GITHUB_TOKEN do not re-trigger
downstream workflow runs, so status checks were not re-run on updated PR
branches.

Source the token from the STATUS_CHECKS_RETRIGGER_TOKEN PAT (scoped to the
"production" environment) instead. The action still reads GITHUB_TOKEN from
env, so only the value changes. Add `environment: production` to the job so
the environment-scoped secret is accessible, and update the explanatory
comment accordingly.

Closes #23

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:30:53 -05:00
JMR-devandClaude Opus 4.8 610b02ba83 #3 GitHub Actions CI: build, lint, test
Add .github/workflows/ci.yml running on pull_request (targeting main) and
push to main. A single ubuntu-latest job "ci":
- checks out the repo, sets up Go 1.26, pnpm 10 + Node 22 (pnpm store
  cache), and TinyGo 0.41.1 (Binaryen/wasm-opt included);
- runs pnpm install --frozen-lockfile, go vet ./..., go test ./...;
- conditionally vets/tests an infra/ Go module if infra/go.mod exists
  (no-op until ticket #2 adds it);
- runs pnpm run build to confirm the TinyGo/Wasm Worker builds end to end.

Every action is pinned by full commit SHA with a "# vX.Y.Z" comment,
matching the supply-chain style of .github/workflows/autoupdate.yml.
Validated with actionlint (clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:30:50 -05:00
Jason RossandClaude Opus 4.8 8e1dc66c54 CI: auto-update open PR branches via autoupdate Action (#20) (#22)
Add .github/workflows/autoupdate.yml. On every push to main, the
chinthakagodawita/autoupdate action merges main into all open PRs that
target it (PR_FILTER: "all"), keeping branches current as PRs merge.

The action is pinned to commit 0707656 (v1.7.0) for supply-chain safety.
Uses the default GITHUB_TOKEN with minimal contents:write and
pull-requests:write permissions.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:22:52 -05:00
Jason RossandClaude Opus 4.8 499bf7f655 Bootstrap Go module + Cloudflare Worker build tooling (#21)
Initialize the Go module and the Go -> Cloudflare Workers (TinyGo/Wasm) build
path, structured so `go test` and a local dev server run on plain Go without
TinyGo, while the real Wasm entrypoint is isolated behind build tags.

- go.mod/go.sum: module github.com/JMR-dev/LibreMail-Bug-Report-Ingest (Go 1.26),
  requiring github.com/syumai/workers.
- internal/handler: build-tag-free core http.Handler (GET / and GET /healthz,
  JSON responses, 404/405 handling) with net/http/httptest unit tests.
- cmd/devserver: plain net/http server mounting the core handler for local dev
  without TinyGo (listens on :8787, override with ADDR).
- worker/main.go: Cloudflare Workers (Wasm) entrypoint behind
  //go:build js && wasm, wiring the same handler via github.com/syumai/workers;
  excluded from host builds/tests.
- package.json + pnpm-lock.yaml + pnpm-workspace.yaml: wrangler dev dependency
  managed with pnpm, with toolchain build scripts approved.
- wrangler.jsonc: name=libremail-bug-report-ingest, main=./build/worker.mjs,
  build via `pnpm run build` (TinyGo).
- README: "Build & run locally" section with exact commands and the rationale
  for the TinyGo + syumai/workers path.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:22:48 -05:00