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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
@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>
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>
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>
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>
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>
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>
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>
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>
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>
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>