From 041c7758c187be41a6b45c548c9e66d284aaa097 Mon Sep 17 00:00:00 2001 From: Jason Ross <51939451+JMR-dev@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:13:39 -0500 Subject: [PATCH 1/3] #6 Decision: GitHub labels + abuse/rate-limit policy ADR (#18) Add docs/decisions/labels-and-abuse.md fixing concrete values that unblock #14 and inform #7: - Labels on auto-published issues (JMR-dev/LibreMail): bug-report, automated, needs-triage. #14 must create any missing. - Ingest policy: 256 KiB payload cap (413); per-IP 15/60s + 100/1h rate limits (429 + Retry-After) via Cloudflare Rate Limiting rules in Pulumi; full response-code contract (202/400/413/415/405/429/5xx). - Weekly publish job: 50 issues/run cap; serial creation, 1s spacing, Retry-After honoured, exponential backoff (base 1s, cap 60s, jitter, max 5 attempts), mark-published-on-confirm de-dup. Co-authored-by: Claude Opus 4.8 --- docs/decisions/labels-and-abuse.md | 229 +++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/decisions/labels-and-abuse.md diff --git a/docs/decisions/labels-and-abuse.md b/docs/decisions/labels-and-abuse.md new file mode 100644 index 0000000..400177d --- /dev/null +++ b/docs/decisions/labels-and-abuse.md @@ -0,0 +1,229 @@ +# ADR: GitHub labels + ingest abuse / rate-limit policy + +- **Status:** Proposed (becomes Accepted on merge) +- **Date:** 2026-07-02 +- **Ticket:** [#6](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/6) +- **Unblocks:** [#14](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/14) (publish job) - **Informs:** [#7](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/7) (ingest endpoint) +- **Parent:** [JMR-dev/LibreMail#11](https://github.com/JMR-dev/LibreMail/issues/11) open question: "Target GitHub label(s); abuse / rate limiting." + +## Context + +A Cloudflare Worker (Go) exposes an HTTPS ingest endpoint that accepts user-initiated, +opt-in debug bug-report POSTs from the LibreMail Android app. Accepted reports are +best-effort scrubbed of PII, stored encrypted in Cloudflare R2, and every Friday at 17:00 +Central a scheduled job publishes any not-manually-removed report as a GitHub issue on the +app repo `JMR-dev/LibreMail`. + +Constraints that shape this decision: + +- **Single maintainer, low expected volume.** Reports are user-initiated debug submissions + only; realistic legitimate volume is single digits per week. +- **Mobile clients behind CGNAT.** Many distinct users share a small pool of public IPs via + carrier-grade NAT, so per-IP limits must be generous enough not to lock out a whole carrier. +- **IaC is Pulumi (Go).** Infrastructure (including edge rate-limit rules) is provisioned + declaratively, which favours a config-driven rate-limit mechanism over hand-rolled state. +- **Existing labels on `JMR-dev/LibreMail`** (checked 2026-07-02): `bug`, `documentation`, + `duplicate`, `enhancement`, `good first issue`, `help wanted`, `invalid`, `question`, + `wontfix`, `Infrastructure`, `Compliance`, `Release`, `donotmerge`. There is **no** + `bug-report`, `needs-triage`, or `automated` label yet, and the generic `bug` label is + reserved for maintainer-confirmed defects, so a distinct marker is needed. + +This ADR fixes concrete values so #7 and #14 can be implemented without further decisions. +It **decides** the labels; it does **not** create them (that is #14's implementation job). + +--- + +## Decision 1 - GitHub labels on auto-published issues + +Every issue created by the weekly publish job on `JMR-dev/LibreMail` is tagged with **all +three** labels below. Names are lowercase kebab-case (consistent with GitHub's built-in +`bug` / `good first issue` style and trivially queryable). + +| Label | Suggested colour | Applied | Meaning | Removed by | +| -------------- | ---------------- | ------------ | ------------------------------------------------------------------------------------------------ | ---------- | +| `bug-report` | `#e99695` | at creation | This issue originated from the app's opt-in debug bug-report ingest pipeline. Distinct from the maintainer-triaged `bug` label. Permanent origin marker. | never (permanent) | +| `automated` | `#c5def5` | at creation | Provenance: the issue was created by the weekly publish job, not written by a human. Lets the maintainer filter/automate bot-created issues. | never (permanent) | +| `needs-triage` | `#fbca04` | at creation | Workflow state: the report has not yet been reviewed/confirmed by the maintainer. | maintainer, once triaged | + +**Rationale for three labels:** `bug-report` + `automated` are permanent provenance markers +(origin and authorship); `needs-triage` is a mutable workflow state the maintainer clears. +Together they give a clean triage inbox query: + +``` +repo:JMR-dev/LibreMail is:issue is:open label:bug-report label:needs-triage +``` + +**Requirement for #14:** before (or while) creating issues, the publish job **must ensure +each label exists on `JMR-dev/LibreMail`, creating any that are missing** (idempotent +create-or-ignore via the GitHub Labels API). None of the three exist today. Suggested +colours above are non-binding; the maintainer may recolour without changing this ADR. +Label **names** are binding (they are the contract between #14's creation step and its +application step, and any maintainer saved queries). + +--- + +## Decision 2 - Ingest endpoint abuse / rate-limit policy (for #7) + +### 2.1 Maximum request / payload size + +- **Hard limit: 256 KiB (262,144 bytes) for the request body.** Requests larger than this + are rejected with **413**. +- **Enforce in two places** (do not trust a single signal): + 1. Fast path: if `Content-Length` is present and exceeds the limit, reject with 413 + **before** reading the body. + 2. Hard cap while reading: wrap the body reader so it stops and returns 413 once + 262,144 bytes have been read (covers missing/chunked/lying `Content-Length` and + protects against memory exhaustion). + +**Justification:** A debug report is JSON metadata (app/OS version, device model) plus logs +and stack traces. Realistic reports are a few KiB to low tens of KiB. 256 KiB leaves +generous headroom (~250k characters, thousands of log lines) while being far too small to be +useful as an abuse/exfiltration vector or to bloat R2. **Note for #14:** GitHub issue bodies +are capped at **65,536 characters**, so #14 must truncate or attach when formatting a report +whose rendered body would exceed that; the 256 KiB ingest cap is intentionally larger than +the GitHub cap and is not the same limit. + +### 2.2 Rate limit per client IP + +| Rule | Threshold | Action | Purpose | +| --------------- | -------------------------------------- | ------------------------------------- | ------------------------------------ | +| Burst | > 15 requests / 60 s per IP | 429; block that IP for 10 minutes | Stop a script hammering the endpoint | +| Sustained | > 100 requests / 1 h per IP | 429; block that IP for 1 hour | Catch slower, sustained abuse | +| Global (optional, defense-in-depth) | > 1,000 accepted reports / 1 h across all IPs | 503 + alert the maintainer | Distributed abuse bypassing per-IP limits | + +**Justification for the (generous) per-IP numbers:** a legitimate user submits once, maybe +retrying a few times. 15/min and 100/hr are far above that, deliberately, because CGNAT means +many real users can share one public IP; tighter limits risk blocking a whole mobile carrier. +A malicious client still does thousands/min, so 15/min blocks abuse decisively. Numbers are +starting points; the maintainer should tighten if abuse appears (see Consequences). + +### 2.3 Mechanism + +**Recommended (primary): Cloudflare Rate Limiting rules, provisioned via Pulumi.** + +- Runs at the edge **before** the Worker executes: rejected requests cost no Worker + invocation/CPU and never touch application code - strong volumetric protection, cheaply. +- Declarative, so it lives in the existing Pulumi (Go) IaC alongside the rest of the infra; + no custom counter state to write or maintain. +- **Trade-offs:** requires the Rate Limiting rules feature on the account's Cloudflare plan; + counting is IP-based and therefore coarse under CGNAT (accepted, per 2.2). + +**Alternatives (documented, not chosen for v1):** + +- *Workers Rate Limiting binding* (`env.RATE_LIMITER.limit({ key })`) - in-Worker, use if + edge Rate Limiting rules are unavailable on the plan. Runs inside the Worker (costs an + invocation) but needs no external store. +- *Durable Object token bucket* - strongly consistent, supports custom keys (e.g. per + app-supplied token rather than IP). Most accurate but adds a DO round-trip, DO cost, and + code complexity. **Not** justified for this low-volume, single-maintainer project up front; + revisit only if per-IP rules prove inadequate. +- *Workers KV counter* - **rejected**: KV is eventually consistent, so counts undercount + across edge locations, making it unreliable for enforcing a rate limit. + +Regardless of the rate-limit mechanism, the **Worker always enforces the size cap (2.1) and +schema validation (2.4)** itself - those cannot be expressed as an edge rate rule. + +### 2.4 Response-code contract + +| Status | When | Notes / headers | +| ----------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| **202 Accepted** | Well-formed report accepted, scrubbed, and stored for the weekly publish run | Body `{"status":"accepted"}`. Async pipeline, so 202 (not 200). | +| **400 Bad Request** | Not valid JSON, missing/invalid required fields, or fails schema validation | Body `{"error":""}`. Never echo request contents. | +| **413 Payload Too Large** | Body exceeds 256 KiB (per 2.1) | - | +| **415 Unsupported Media Type**| `Content-Type` is not `application/json` (may be folded into 400 if #7 prefers) | - | +| **405 Method Not Allowed** | Any method other than POST | Send `Allow: POST`. | +| **429 Too Many Requests** | Rate limit exceeded (per 2.2) | **Must** send `Retry-After: `. | +| **500 Internal Server Error** | Unexpected server error | Generic body; log internally. | +| **503 Service Unavailable** | Storage (R2) unavailable, or global shed (2.2) | May send `Retry-After`. | + +**Client retry contract** (the app honours this): + +- **429** - wait for `Retry-After`, then retry. +- **5xx** - retry with exponential backoff + jitter, bounded attempts. +- **400 / 413 / 415** - permanent; do **not** retry (the request is malformed/too large). + +Error responses must be small and must not reflect request contents or reveal whether a +specific IP is individually blocked (avoid info leak / log injection). + +--- + +## Decision 3 - Weekly publish job policy (for #14) + +### 3.1 Per-run issue cap + +- **Maximum 50 issues created per weekly run.** If more than 50 reports are pending, publish + the **oldest 50**, leave the remainder pending for the next run, and **alert the + maintainer** that the cap was hit. + +**Justification:** 50 is far above realistic legitimate weekly volume, so it never throttles +normal flow, but it caps blast radius if abuse slips through or a bug produces a surge - +better to throttle and alert than to flood the app repo with hundreds of auto-issues. Draining +oldest-first over subsequent runs is the safer failure mode; the maintainer can raise the cap +or purge abusive reports. + +### 3.2 GitHub API pacing, backoff, and retry + +GitHub's authenticated **primary** limit is 5,000 requests/hour; **secondary** limits cap +content creation (guidance: serial requests, ~1 s between mutations, and roughly 80 +content-creating requests/min / 500/hour). The policy below sits comfortably under all of +these (50 issues at 1 s spacing is ~50 s of wall-clock; awaiting network I/O does not consume +Worker CPU time, so it fits within the scheduled-event budget). + +- **Serialize** issue creation - one at a time, **no concurrency**. +- **Space successful creates by at least 1 second.** +- **Honour `Retry-After` exactly** whenever a 403/429 response includes it (overrides the + computed backoff below). +- **On 403/429 with `x-ratelimit-remaining: 0`** - wait until the `x-ratelimit-reset` epoch, + then retry. +- **On a secondary-rate-limit 403 without `Retry-After`** - wait a **minimum of 60 s**, then + apply the exponential schedule below. +- **On 5xx / network errors** - exponential backoff `min(60s, 1s * 2^attempt)` with **full + jitter**, **max 5 attempts per issue**. Schedule: ~1s, 2s, 4s, 8s, 16s (capped at 60s), + each jittered. +- **On attempt exhaustion** - leave that report **pending** (do not mark published), log + + alert, and continue with the next report. Never abort the whole run for one failure. +- **Idempotency / de-dup** - a report is marked published **only after a confirmed 201 + Created**. On an ambiguous failure (e.g. timeout after POST) the report stays pending and + #14's own de-dup mechanism (a stored per-report published marker / issue URL) must prevent a + duplicate on the next run. (Detailed de-dup design is #14's scope; this ADR only fixes the + "mark published only on confirmed success" rule.) + +--- + +## Consequences + +**Positive** + +- #7 and #14 have concrete, implementable numbers with no open decisions. +- Edge rate limiting (Cloudflare rules via Pulumi) protects the Worker cheaply and fits the + existing IaC; the Worker still owns size + schema validation. +- Three provenance/state labels give the maintainer a clean, queryable triage inbox and keep + auto-reports distinct from the human-triaged `bug` label. +- The per-run cap + backoff make the publish job safe against surges and GitHub secondary + limits, and safe to re-run (de-dup, mark-on-confirm). + +**Negative / limitations** + +- IP-based rate limiting is coarse under mobile CGNAT: shared IPs mean the per-IP limits are + deliberately loose, and a burst from one carrier could still occasionally throttle a real + user. Mitigated by generous thresholds + short block durations; revisit with per-app-token + limits (Durable Object) if needed. +- The 256 KiB ingest cap (and the downstream 65,536-char GitHub issue-body cap) may truncate + extremely verbose reports. Acceptable for a debug pipeline; #14 truncates/attaches. +- The 50/run cap means a large legitimate backlog would drain over several weeks. Acceptable + and safer than flooding; the maintainer is alerted when the cap is hit. + +**Follow-ups** + +- Revisit all thresholds after observing real traffic. +- Consider an app-supplied shared secret / attestation on the ingest request to reduce + anonymous abuse (feeds #7), and a Durable Object token bucket if IP limits prove inadequate. + +## Values the maintainer should confirm at review + +- **Three labels** (`bug-report`, `automated`, `needs-triage`) vs. a leaner set - is + `needs-triage` wanted at creation, or should it be applied manually? +- **256 KiB** payload cap. +- **Per-IP limits** (15/min, 100/hr) and whether the optional global 1,000/hr shed is wanted + in v1. +- **50 issues/run** cap and where the "cap hit" alert should go. -- 2.47.3 From e93b6a2266140d88ebe3971a2e4398a587767f45 Mon Sep 17 00:00:00 2001 From: Jason Ross <51939451+JMR-dev@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:17:15 -0500 Subject: [PATCH 2/3] Add encryption ADR: Worker-side AES-256-GCM + Secrets Store key custody (#19) Documents the decision for #5: Worker-side authenticated encryption (AES-256-GCM) applied in the Worker before writing to R2, so R2 never receives plaintext or the key. Key material is held as a versioned keyring in Cloudflare Secrets Store (shared by the ingest and weekly-publish Workers). Rotation is data-loss-free via a key-id/version in each object header plus retained old key versions. Unblocks #9. Co-authored-by: Claude Opus 4.8 --- docs/decisions/encryption.md | 392 +++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 docs/decisions/encryption.md diff --git a/docs/decisions/encryption.md b/docs/decisions/encryption.md new file mode 100644 index 0000000..1aad882 --- /dev/null +++ b/docs/decisions/encryption.md @@ -0,0 +1,392 @@ +# ADR 0001: Encryption scheme and key custody for R2 bug-report objects + +- **Status:** Accepted +- **Date:** 2026-07-02 +- **Deciders:** Maintainer (single-maintainer project) +- **Ticket:** [#5](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/5) — Decision: encryption scheme + key custody (Cloudflare Secret Manager) +- **Unblocks:** [#9](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/9) — encrypted-at-rest R2 storage +- **Related:** [JMR-dev/LibreMail#11](https://github.com/JMR-dev/LibreMail/issues/11) (open question: "Encryption scheme + key custody for R2 objects") + +--- + +## Context + +A Cloudflare Worker (Go, compiled with TinyGo to Wasm) ingests LibreMail debug bug-reports +over HTTPS, best-effort scrubs PII, and stores each scrubbed report in a Cloudflare R2 +bucket. A scheduled job later reads the stored reports, decrypts them, and publishes each as +a GitHub issue on the LibreMail repo. Secrets live in Cloudflare Secrets Store (a.k.a. +Cloudflare Secret Manager). There is a single maintainer and the volume is low (individual +opt-in bug reports, published weekly). + +**Why encryption at rest is not enough by default:** PII scrubbing is explicitly +*best-effort*. Reports can contain residual PII (log fragments, stack traces, free-text +descriptions, device identifiers, e-mail-shaped strings the scrubber missed). We therefore +need the stored objects to be **unreadable to anyone who obtains read access to the bucket +but who does not hold the maintainer's key**. + +### Threat model + +In scope — what we defend against: + +1. **Bucket-read compromise.** A leaked or over-scoped R2 API token, an S3-credential leak, + a bucket misconfiguration, or an accidentally public bucket. An attacker can `GET`/`LIST` + objects but does not have the encryption key. +2. **Storage-layer / at-rest disclosure.** Access to the raw stored bytes (backups, disks, + the R2 storage service) without the key. +3. **Object tampering / substitution.** An attacker who can write to the bucket swaps or + mutates an object to corrupt or spoof a published issue. + +Out of scope — explicitly *not* defended against (documented so we do not overclaim): + +- **A fully compromised or malicious Cloudflare platform.** The Worker's compute runs on + Cloudflare and the key is held in Cloudflare Secrets Store, so Cloudflare-the-platform is + necessarily trusted with plaintext at processing time. Defending against the compute + provider itself would require the app to never possess the key on Cloudflare (e.g. + decrypt only on the maintainer's own hardware) and is not a goal here. +- **A compromised Worker isolate at runtime** (it legitimately holds plaintext and the key). +- **The published GitHub issues themselves** (post-publication confidentiality is a separate + concern governed by scrubbing quality and repo visibility). + +The meaningful, realistic win is category 1/2/3: the object bytes at rest are opaque and +tamper-evident to anyone without the key. + +--- + +## Options evaluated + +### Option A — R2 default encryption at rest (Cloudflare-managed keys) + +R2 encrypts all objects at rest with AES-256 automatically; keys are managed by Cloudflare. +No code, nothing to configure. + +- **Pros:** Zero effort, transparent, always on, no key to lose. +- **Cons:** Cloudflare holds the keys, and *any principal with bucket read access reads + plaintext* (this is exactly threat #1). It protects only against raw-disk theft, which is + not our primary threat. **Does not meet the requirement.** + +### Option B — R2 SSE-C (server-side encryption, customer-provided key) + +R2 supports SSE-C: the client supplies a 256-bit key on each `PUT`/`GET`; R2 encrypts with +it and **removes the key from memory after the operation** (Cloudflare cannot recover objects +without the key). Available via the Workers and S3 APIs. + +- **Pros:** Objects at rest are encrypted under a key Cloudflare does not retain; protects + against threat #1/#2. Very little code. +- **Cons:** + - **Plaintext and the key are sent to the R2 service on every request** — R2 performs the + crypto server-side. The plaintext-exposure surface at the storage boundary is larger than + Option C, and you are trusting R2 to actually drop the key each time. + - We do not control the ciphertext format, the integrity binding, or key-versioning; we + inherit R2's behavior. Rotation means re-`PUT`ting objects under a new key. + - The key must still be held by the caller (the Worker) anyway — so custody is no simpler + than Option C, but we get less control. + - Coupled to R2/Cloudflare; not portable. + +### Option C — Worker-side authenticated encryption before write (chosen) + +The Worker encrypts each scrubbed report with **AES-256-GCM** *before* writing to R2, using +key material from Cloudflare Secrets Store. Only ciphertext is ever handed to R2. + +- **Pros:** + - **R2 never receives plaintext or the key** — it only ever stores opaque bytes. Smallest + storage-boundary exposure of the three options. Fully covers threats #1/#2. + - **Authenticated encryption**: GCM's tag gives integrity + tamper detection (threat #3); + additional authenticated data (AAD) binds the format and key version so objects cannot be + silently downgraded or transplanted. + - We own the format, key-versioning, and rotation → clean rotation without data loss. + - Portable, standard primitive; not locked to any R2/Cloudflare crypto feature. + - Defense-in-depth layered on top of best-effort scrubbing. +- **Cons:** + - We must manage a key and its rotation (mitigations below). + - **Key loss = permanent data loss** for not-yet-published reports (mitigated by an offline + keyring backup). + - Must implement AES-GCM in the TinyGo/Wasm environment (see the crypto-provider note). + +> **Terminology.** The ticket calls this "envelope encryption." Strictly, envelope +> encryption means generating a per-object *data key* (DEK) and wrapping it with a *key +> encryption key* (KEK). At this scale we adopt the simpler equivalent: a single active +> AES-256 data key selected from a versioned keyring, applied directly with AEAD. The true +> per-object-DEK variant is discussed under [Alternatives considered](#alternatives-considered); +> it is not needed here and adds moving parts. + +--- + +## Decision + +**Adopt Option C: Worker-side AES-256-GCM authenticated encryption, applied in the Worker +before the object is written to R2, with the key held in Cloudflare Secrets Store as a +versioned keyring.** + +Rationale: it is the only option where the R2 service never sees plaintext or the key, it +gives us cryptographic integrity we control, and it makes key rotation a first-class, +data-loss-free operation. Given the residual-PII sensitivity, that control is worth the +modest key-management burden. Option A fails the requirement outright; Option B meets +confidentiality but with a larger plaintext-exposure surface, no integrity guarantees we +own, and worse rotation ergonomics, for no custody saving. + +--- + +## Concrete scheme (what #9 implements) + +### Primitive + +- **Algorithm:** AES-256-GCM (AEAD — confidentiality + integrity in one primitive). +- **Key size:** 256-bit (32 bytes). +- **Nonce/IV:** 96-bit (12 bytes), generated fresh per object from a CSPRNG. Never reused + under the same key. (At this volume we are astronomically far from the ~2^32 random-nonce + birthday bound for a single key; rotation further caps messages per key.) +- **Auth tag:** 128-bit (16 bytes), the standard GCM tag. This is what provides integrity. +- **Additional authenticated data (AAD):** the object *header* bytes (magic + format version + + key id). AAD is authenticated but not encrypted; binding it means an attacker cannot flip + the key id, downgrade the format, or splice a body under a different header without failing + authentication. + +### Crypto provider (TinyGo/Wasm constraint — important) + +The Worker is Go compiled with **TinyGo → Wasm**. As of this writing, TinyGo's `crypto/aes` +and `crypto/cipher` are importable but **fail their test suites** (unimplemented reflection +paths), so the pure-Go AES-GCM implementation is not safe to rely on. `crypto/rand` *is* +supported. + +**Recommendation:** perform AES-256-GCM via the Workers runtime's **Web Crypto API** +(`crypto.subtle.encrypt` / `crypto.subtle.decrypt` with `{ name: "AES-GCM", iv, additionalData, +tagLength: 128 }`) and draw random bytes from `crypto.getRandomValues`, called through JS +interop from the Go/Wasm Worker. This is the Cloudflare-idiomatic path and avoids shipping a +questionable pure-Go cipher into Wasm. + +Crucially, **the on-disk wire format below is identical whether encryption is done via +SubtleCrypto or a Go AEAD** — both produce/consume `ciphertext || 16-byte-tag`. So #9 may +choose either provider without changing the object format; SubtleCrypto is the recommended +default. (Confirm current TinyGo status at implementation time; if pure-Go GCM has become +reliable, it is a drop-in alternative.) + +### Stored R2 object layout + +Each R2 object body is a single self-describing binary frame: + +``` + offset size field + ------ ---- ------------------------------------------------------------- + 0 4 magic = ASCII "LMB1" (0x4C 0x4D 0x42 0x31) + 4 1 format_version = 0x01 + 5 2 key_id = uint16, big-endian (keyring version used) + 7 12 nonce = 96-bit random IV + 19 N ciphertext = AES-256-GCM(plaintext) + 19+N 16 auth_tag = 128-bit GCM tag (appended by Seal/encrypt) +``` + +- **Header** = bytes `[0, 7)` = `magic || format_version || key_id`. The header is passed as + the **AAD** to GCM (it is stored in the clear but is authenticated). +- The `nonce` is stored in the frame so the reader can supply it to GCM; GCM authenticates + the nonce implicitly (a modified nonce produces a wrong tag). +- With Go's `crypto/cipher` AEAD and with SubtleCrypto alike, the tag is appended to the + ciphertext, so `ciphertext || auth_tag` is one contiguous blob (`N + 16` bytes). + +Encrypt (pseudocode; illustrative, not committed code): + +```go +header := []byte{'L','M','B','1', 0x01} +header = append(header, byte(keyID>>8), byte(keyID)) // magic|ver|key_id (AAD) + +nonce := random(12) // CSPRNG, unique per object +ct := aesGCM(activeKey).Seal(nil, nonce, plaintext, header) // ct = ciphertext||tag + +object := append(append(header, nonce...), ct...) // full R2 body +// PUT object to R2 at key derived from a random report id +``` + +Decrypt (pseudocode): + +```go +magic, ver, keyID := object[0:4], object[4], be16(object[5:7]) +require(magic == "LMB1" && ver == 0x01) +header := object[0:7] // AAD +nonce := object[7:19] +ctTag := object[19:] // ciphertext||tag + +key := keyring[keyID] // hard-fail if version unknown +plaintext, err := aesGCM(key).Open(nil, nonce, ctTag, header) +require(err == nil) // auth failure => reject (tamper/corruption) +``` + +### Integrity + +Provided entirely by AES-GCM: any modification to the ciphertext, the nonce, or the +authenticated header causes `Open`/`decrypt` to fail. No separate HMAC is needed. Callers +**must** treat a decryption/authentication error as a hard failure (skip + alert), never as +"publish what we have." + +### R2 custom metadata (optional, operational) + +For at-a-glance ops (e.g. dashboards) it is fine to mirror `key_id` and a report id into R2 +custom metadata, but note that **R2 custom metadata is neither encrypted nor authenticated**. +Therefore: never put anything sensitive there, and treat it as untrusted — the *authoritative* +`key_id` is the one inside the AAD-authenticated header, not the metadata copy. + +--- + +## Key custody + +### Where the key lives + +The key material is a **versioned keyring** stored as a single account-level secret in +**Cloudflare Secrets Store**, bound to *both* the ingest Worker and the weekly publish job. +An account-level secret can be bound to multiple Workers, so both share one custody point. + +Keyring secret value (JSON): + +```json +{ + "active": 2, + "keys": { + "1": "", + "2": "" + } +} +``` + +- Each `keys[]` is 32 random bytes (AES-256), base64-encoded. +- `active` is the version new objects are encrypted under. +- Version numbers are the `key_id` written into the object header (uint16 range; ample). + +Suggested binding name: `BUGREPORT_ENC_KEYRING`. + +`wrangler` binding (illustrative): + +```toml +secrets_store_secrets = [ + { binding = "BUGREPORT_ENC_KEYRING", store_id = "", secret_name = "bugreport-enc-keyring" } +] +``` + +### How the Worker retrieves it at runtime + +Secrets Store secrets are read asynchronously through the `env` binding: + +``` +raw := await env.BUGREPORT_ENC_KEYRING.get() // returns the secret string +keyring := parseJSON(raw) // {active, keys{ver: base64}} +// decode base64 -> map[uint16][32]byte; select keyring.keys[keyring.active] to encrypt +``` + +Handling rules: + +- Decode once and hold the parsed keyring in memory only for the isolate/request lifetime. +- **Never** log it, include it in a response body, put it in an error message, or write it to + R2. Redact it from any structured logging. +- Fetching happens inside the request/scheduled handler (the `get()` is async), not at module + top-level. + +### How the weekly publish job decrypts + +**Recommended:** implement the weekly publisher as a **Cloudflare Worker on a Cron Trigger** +that shares the same `BUGREPORT_ENC_KEYRING` binding. It lists R2 objects, decrypts each +in-Worker (selecting the key by the header's `key_id`), creates the GitHub issue, and +optionally deletes/tombstones the published object. **The key never leaves Cloudflare.** The +GitHub credential it needs to open issues is a *separate* Secrets Store secret. + +**Not recommended:** running decryption in a GitHub Actions job. That requires copying the +keyring into GitHub Actions secrets, widening key custody to a second provider and enlarging +the attack surface, for no benefit. (Note: using GitHub Actions to *deploy* the Workers is +fine and unrelated — the concern is only about where *decryption* runs.) + +### Access control and backup + +- Restrict Secrets Store access to the maintainer via Cloudflare RBAC; enable audit logging. +- **Critical:** losing the keyring means permanent, unrecoverable loss of all + not-yet-published reports. Keep an **encrypted offline backup** of the keyring (e.g. a + password manager or hardware-backed store), updated whenever a new version is added. + +--- + +## Key rotation + +Rotation must not lose access to already-stored objects. This is handled by the `key_id` in +each object header plus a keyring that retains old versions. + +**To rotate (routine):** + +1. Generate a new 32-byte key from a CSPRNG (offline). +2. Add it to the keyring under the next version number and set `active` to it, e.g. + add `"3": ""` and set `"active": 3`. +3. Update the single Secrets Store secret (one atomic write via dashboard/API). +4. Allow propagation. Workers may cache a secret for the isolate's lifetime, so a new version + fully takes over after isolates recycle (or after a redeploy, which forces it). + +After rotation, new objects are written with `key_id = 3`; existing objects keep their +original `key_id` (1 or 2) and **still decrypt**, because those versions remain in the +keyring. + +**Rules:** + +- **Never remove a key version while any stored object still references it.** Removing a + version that an object was encrypted under makes that object permanently undecryptable. +- Because reports are short-lived (stored, then published weekly and removed), a superseded + key can be safely retired roughly a week or two after it stops being `active` — once no + object references it. This bounds the rotation/retirement burden. +- **Optional explicit retirement / re-key:** run a one-off pass — `GET` each object, `Open` + with its old key, re-`Seal` under the current `active` key (new nonce, updated `key_id`), + `PUT` back — then drop the retired version from the keyring. + +**Compromise response:** if a key version is suspected compromised, add-and-activate a fresh +version immediately so new writes are protected, then run the re-key pass above over existing +objects and remove the compromised version. The short object lifetime keeps this window small. + +--- + +## Consequences + +**Positive** + +- Objects in R2 are opaque ciphertext; R2 never receives plaintext or the key. Anyone with + only bucket read access (leaked/over-scoped R2 token, misconfig, accidental public bucket) + gets nothing usable. +- GCM gives integrity and tamper-evidence; AAD binds format + key version, preventing + downgrade/transplant. +- Defense-in-depth over best-effort PII scrubbing. +- Rotation is data-loss-free via the versioned keyring + `key_id` header. +- One custody system (Cloudflare Secrets Store) shared by both Workers; portable primitive. + +**Negative / costs** + +- Key loss = permanent loss of not-yet-published reports (mitigated by offline keyring backup). +- Must implement AES-GCM in TinyGo/Wasm; recommended path is host SubtleCrypto, which adds a + little JS interop. +- Slight extra Worker CPU per report (negligible at this volume). +- Does **not** defend against a malicious Cloudflare platform (out of scope, by design). +- Unauthenticated R2 custom metadata (if used) must be treated as non-sensitive and untrusted. +- Rotation is a manual maintainer responsibility. + +**Open questions to confirm at review** + +1. **Where does the weekly publisher run?** Cron-Trigger Worker (recommended — key stays in + Cloudflare) vs GitHub Action (would widen custody). This ADR assumes the Cron Worker. +2. **Crypto provider:** host SubtleCrypto (recommended) vs pure-Go AES-GCM once TinyGo + support is verified reliable. +3. **Keyring shape/binding:** single JSON keyring secret (this ADR) vs one binding per + version; confirm the binding name (`BUGREPORT_ENC_KEYRING`). +4. **Object lifecycle:** are R2 objects deleted after publication? Confirming this (and + possibly an R2 lifecycle rule) bounds rotation/retirement work. Likely its own ticket. + +--- + +## Alternatives considered + +- **True per-object envelope encryption (DEK + KEK).** Generate a random 256-bit DEK per + report, encrypt the payload with the DEK, and wrap the DEK with a KEK from Secrets Store; + store the wrapped DEK in the frame. Benefits: each object has an isolated key, and a + single-use DEK makes payload nonce handling trivial. Costs: more bytes and an extra + wrap/unwrap per object. Not worth it here — the volume is tiny and single-key AEAD with a + versioned keyring is simpler and equally safe at this scale. The frame's `format_version` + leaves room to adopt this later if requirements change. +- **AES-KW / RFC 3394 for key wrapping** (if the DEK variant were adopted): rejected in + favor of AES-GCM wrapping to stay on primitives available via SubtleCrypto. + +## References + +- Cloudflare R2 — Data security (default at-rest encryption): https://developers.cloudflare.com/r2/reference/data-security/ +- Cloudflare R2 — Use SSE-C: https://developers.cloudflare.com/r2/examples/ssec/ +- Cloudflare Secrets Store — Workers integration (`secrets_store_secrets`, `await env.BINDING.get()`): https://developers.cloudflare.com/secrets-store/integrations/workers/ +- Cloudflare Workers — Secrets: https://developers.cloudflare.com/workers/configuration/secrets/ +- TinyGo — Packages supported by TinyGo (stdlib status incl. `crypto/aes`, `crypto/cipher`, `crypto/rand`): https://tinygo.org/docs/reference/lang-support/stdlib/ +- NIST SP 800-38D — AES-GCM (nonce/usage guidance): https://csrc.nist.gov/pubs/sp/800/38/d/final -- 2.47.3 From e07048faad0f99d16a48457249a6e6299340e53c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 13:18:34 -0500 Subject: [PATCH 3/3] Bootstrap Go module + Cloudflare Worker build tooling 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 --- .gitignore | 6 + README.md | 71 ++- cmd/devserver/main.go | 28 + go.mod | 5 + go.sum | 2 + internal/handler/handler.go | 82 +++ internal/handler/handler_test.go | 85 +++ package.json | 15 + pnpm-lock.yaml | 869 +++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 7 + worker/main.go | 21 + wrangler.jsonc | 19 + 12 files changed, 1209 insertions(+), 1 deletion(-) create mode 100644 cmd/devserver/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/handler/handler.go create mode 100644 internal/handler/handler_test.go create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 worker/main.go create mode 100644 wrangler.jsonc diff --git a/.gitignore b/.gitignore index aaadf73..88ea06c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ go.work.sum # env file .env +# Node dependencies (pnpm) +node_modules/ + +# Worker Wasm build output (generated by `pnpm run build` via TinyGo) +/build/ + # Editor/IDE # .idea/ # .vscode/ diff --git a/README.md b/README.md index 9b61b50..e953552 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,81 @@ Per [JMR-dev/LibreMail#11](https://github.com/JMR-dev/LibreMail/issues/11): ## Stack -- Worker: Go +- Worker: Go, compiled to WebAssembly with [TinyGo](https://tinygo.org) and served through the [`syumai/workers`](https://github.com/syumai/workers) runtime adapter - Infrastructure as code: Pulumi (Go) - Deployment: GitHub Actions - Secrets/key custody: Cloudflare Secret Manager - DNS: Google Cloud DNS +## Build & run locally + +The request-handling logic lives in `internal/handler` as plain, build-tag-free +Go, so it is unit-tested and run locally with the standard Go toolchain — no +TinyGo needed. Only the actual Wasm Worker build requires TinyGo. + +Layout: + +- `internal/handler/` — the core `http.Handler` (health/hello endpoints). No build tags; all request logic and its tests live here. +- `cmd/devserver/` — a plain `net/http` server that mounts the core handler for local dev without TinyGo. +- `worker/` — the Cloudflare Workers (Wasm) entrypoint, guarded by `//go:build js && wasm`, wiring the same core handler into the Workers runtime. Excluded from host builds and tests. + +### Test + +```console +go vet ./... +go test ./... +``` + +### Run locally (no TinyGo) + +```console +go run ./cmd/devserver # listens on :8787; override with ADDR, e.g. ADDR=:9000 +``` + +Then, from another shell: + +```console +$ curl -s localhost:8787/ +{"service":"libremail-bug-report-ingest","status":"ok","message":"hello from the LibreMail bug-report ingest Worker"} +$ curl -s localhost:8787/healthz +{"status":"ok"} +``` + +This runs the exact handler the deployed Worker uses, minus the Workers runtime. + +### Build & run the real Worker (requires TinyGo) + +Node tooling is managed with **pnpm**; wrangler is a dev dependency. The Wasm +build uses [TinyGo](https://tinygo.org) 0.35.0+. + +```console +pnpm install # install wrangler +pnpm run build # workers-assets-gen + TinyGo -> ./build/app.wasm + ./build/worker.mjs +pnpm exec wrangler dev # serve the Wasm Worker locally on :8787 +pnpm exec wrangler deploy # deploy (CI only) +``` + +`pnpm run build` runs, verbatim: + +```console +go run github.com/syumai/workers/cmd/workers-assets-gen && tinygo build -o ./build/app.wasm -target wasm -no-debug ./worker +``` + +TinyGo is **not** required for tests or the dev server; it is needed only for the +Wasm build above and is installed in CI. The generated `./build/` output is +git-ignored. + +### Why TinyGo + `syumai/workers` + +Cloudflare Workers execute WebAssembly, not native binaries, so Go must be +compiled to Wasm. Of the two options — the standard compiler's +`GOOS=js GOARCH=wasm` output or TinyGo — TinyGo emits far smaller modules that +sit comfortably inside the Worker size limit, which is why it is the standard +path for Go on Workers. The [`syumai/workers`](https://github.com/syumai/workers) +package adapts Go's `net/http` handler model to the Workers `fetch` event, so a +single `http.Handler` runs unchanged on the dev server and in the deployed +Worker. + ## Status Early bootstrap. See the [project board](../../projects) and diff --git a/cmd/devserver/main.go b/cmd/devserver/main.go new file mode 100644 index 0000000..fed9eb7 --- /dev/null +++ b/cmd/devserver/main.go @@ -0,0 +1,28 @@ +// Command devserver runs the core bug-report ingest handler on a plain +// net/http server for local development. +// +// It deliberately does not depend on TinyGo or the Cloudflare Workers runtime, +// so `go run ./cmd/devserver` works with the standard Go toolchain and exercises +// the exact same handler that the deployed Wasm Worker serves. It listens on +// :8787 by default (matching wrangler dev's default port); override with ADDR. +package main + +import ( + "log" + "net/http" + "os" + + "github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/handler" +) + +func main() { + addr := os.Getenv("ADDR") + if addr == "" { + addr = ":8787" + } + + log.Printf("devserver listening on %s (try GET / and GET /healthz)", addr) + if err := http.ListenAndServe(addr, handler.New()); err != nil { + log.Fatalf("devserver: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51c962f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/JMR-dev/LibreMail-Bug-Report-Ingest + +go 1.26.2 + +require github.com/syumai/workers v0.33.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5274c7 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/syumai/workers v0.33.0 h1:ku24TZT8m32leS5wYId1F+pGPVXhl+TPnxaDwbr66y4= +github.com/syumai/workers v0.33.0/go.mod h1:ZnqmdiHNBrbxOLrZ/HJ5jzHy6af9cmiNZk10R9NrIEA= diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..ec3ab5f --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,82 @@ +// Package handler holds the core, transport-agnostic HTTP handlers for the +// LibreMail bug-report ingest Worker. +// +// It intentionally carries no build constraints, so it compiles and is +// unit-tested with the standard Go toolchain on the host, and is reused +// verbatim by both the local dev server (cmd/devserver) and the Cloudflare +// Worker Wasm entrypoint (worker). Keeping the request logic here means the same +// http.Handler runs unchanged on the dev server and in the deployed Worker. +package handler + +import ( + "encoding/json" + "net/http" +) + +// serviceName identifies this service in responses. +const serviceName = "libremail-bug-report-ingest" + +// New returns an http.Handler serving the bootstrap health/hello endpoints: +// +// GET / -> 200, JSON service/status/message +// GET /healthz -> 200, JSON {"status":"ok"} +// +// Any other path returns 404 and any non-GET method returns 405. +func New() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", healthz) + mux.HandleFunc("/", root) + return mux +} + +// root handles the service root. Because "/" is the catch-all pattern in the +// mux, it also rejects unknown paths with 404. +func root(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + writeJSON(w, http.StatusNotFound, response{Status: "error", Error: "not found"}) + return + } + if !isGet(w, r) { + return + } + writeJSON(w, http.StatusOK, response{ + Service: serviceName, + Status: "ok", + Message: "hello from the LibreMail bug-report ingest Worker", + }) +} + +// healthz is a liveness/readiness probe endpoint. +func healthz(w http.ResponseWriter, r *http.Request) { + if !isGet(w, r) { + return + } + writeJSON(w, http.StatusOK, response{Status: "ok"}) +} + +// isGet reports whether the request method is GET. If not, it writes a 405 with +// an Allow header and returns false. +func isGet(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + writeJSON(w, http.StatusMethodNotAllowed, response{Status: "error", Error: "method not allowed"}) + return false + } + return true +} + +// response is the JSON body shape returned by every endpoint. Empty fields are +// omitted so success and error bodies stay minimal. +type response struct { + Service string `json:"service,omitempty"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +// writeJSON writes body as JSON with the given status code. +func writeJSON(w http.ResponseWriter, status int, body response) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 0000000..8e915f8 --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,85 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// doRequest runs a single request through the handler and returns the recorder. +func doRequest(t *testing.T, method, target string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, nil) + rec := httptest.NewRecorder() + New().ServeHTTP(rec, req) + return rec +} + +// decode parses the JSON response body or fails the test. +func decode(t *testing.T, rec *httptest.ResponseRecorder) response { + t.Helper() + var got response + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("response body is not valid JSON: %v (body=%q)", err, rec.Body.String()) + } + return got +} + +func TestRoot(t *testing.T) { + rec := doRequest(t, http.MethodGet, "/") + + if rec.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Errorf("GET / Content-Type = %q, want application/json; charset=utf-8", ct) + } + got := decode(t, rec) + if got.Status != "ok" { + t.Errorf("GET / status field = %q, want ok", got.Status) + } + if got.Service != serviceName { + t.Errorf("GET / service field = %q, want %q", got.Service, serviceName) + } + if got.Message == "" { + t.Error("GET / message field is empty, want a hello message") + } +} + +func TestHealthz(t *testing.T) { + rec := doRequest(t, http.MethodGet, "/healthz") + + if rec.Code != http.StatusOK { + t.Fatalf("GET /healthz status = %d, want %d", rec.Code, http.StatusOK) + } + got := decode(t, rec) + if got.Status != "ok" { + t.Errorf("GET /healthz status field = %q, want ok", got.Status) + } +} + +func TestUnknownPathReturns404(t *testing.T) { + rec := doRequest(t, http.MethodGet, "/does-not-exist") + + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /does-not-exist status = %d, want %d", rec.Code, http.StatusNotFound) + } + got := decode(t, rec) + if got.Status != "error" { + t.Errorf("404 status field = %q, want error", got.Status) + } +} + +func TestNonGetReturns405(t *testing.T) { + for _, path := range []string{"/", "/healthz"} { + rec := doRequest(t, http.MethodPost, path) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST %s status = %d, want %d", path, rec.Code, http.StatusMethodNotAllowed) + } + if allow := rec.Header().Get("Allow"); allow != http.MethodGet { + t.Errorf("POST %s Allow header = %q, want GET", path, allow) + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab67f32 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "libremail-bug-report-ingest", + "version": "0.0.0", + "private": true, + "description": "Cloudflare Worker (Go/Wasm) that ingests LibreMail debug bug-reports.", + "scripts": { + "build": "go run github.com/syumai/workers/cmd/workers-assets-gen && tinygo build -o ./build/app.wasm -target wasm -no-debug ./worker", + "dev": "wrangler dev", + "start": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "wrangler": "^4.106.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..46ee82d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,869 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + wrangler: + specifier: ^4.106.0 + version: 4.106.0 + +packages: + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260630.1': + resolution: {integrity: sha512-oEVsD2NZtPAMaEvFeH2Y6N63yiFuOnPDKeAM+l8AkRbLAbFk462uWOq6/ZLn8ouY4P4coMkgsOPqcT1mkuzvzg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260630.1': + resolution: {integrity: sha512-tar1vcQSzM+27Agrlv28BhtN1tIFKw2YHrzldEMyQJOJB/885TU8Z3oO1c/a9YOmsKABhD6I4dGFhsmXyrbK1g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260630.1': + resolution: {integrity: sha512-mhjIg91+ikWw5v9tY4BYO7N9vLOZBhn7EnVFvxCdxcpuUUFBKATxUYHUy1kkgYxnmiI6s93PRNbzBz1NpYQ3IQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260630.1': + resolution: {integrity: sha512-7g0iGvMCwGct+vE3FOKXtFWMAIGHzK2Ei9oALp44gXuL4lBcs3PPJISeTp5itquW2JwS1fw4Hnq7zrT7N/dgPw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260630.1': + resolution: {integrity: sha512-J5KF9VF8yRpRBib/cPSuEp6iR9q3/cKgeDVhg1ZtuwpkzwnmCb+rxMF5WFLxAN8bI2x2FMG1v6o4vVFOGZ0fOQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + miniflare@4.20260630.0: + resolution: {integrity: sha512-lyRplDrSJJWVpzSSQPBSQtNmUuxScCZyOOkXFs37uSbdTfWRDDmw6DyFKVS2s1eYtA/i4u2xR/0FyPIsTl/HJw==} + engines: {node: '>=22.0.0'} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260630.1: + resolution: {integrity: sha512-7M0AA4l14hmPGtzQ5YPHyXosIKI/uz3TdcPHeiFDbgb7/0c8ECVMzIaodSV5bZIVhDHL0OlzqITAdPiwAr+dTg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.106.0: + resolution: {integrity: sha512-b6EVbsvbmAUY4bUQXT3+f8oFP8x+J5rEa5z3Akeh+6vyKiN4x8+PyZ53DPpnqdxhIihhq/a00Yq5chGJ19QXBQ==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260630.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260630.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260630.1 + + '@cloudflare/workerd-darwin-64@1.20260630.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260630.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260630.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260630.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260630.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + blake3-wasm@2.1.5: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + kleur@4.1.5: {} + + miniflare@4.20260630.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260630.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + semver@7.8.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260630.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260630.1 + '@cloudflare/workerd-darwin-arm64': 1.20260630.1 + '@cloudflare/workerd-linux-64': 1.20260630.1 + '@cloudflare/workerd-linux-arm64': 1.20260630.1 + '@cloudflare/workerd-windows-64': 1.20260630.1 + + wrangler@4.106.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260630.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260630.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260630.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c14b867 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# Approve build (postinstall) scripts for wrangler's toolchain deps. +# These are first-party Cloudflare / well-known packages required by +# `wrangler dev`/`deploy`; they are not needed for `go test` or the dev server. +allowBuilds: + esbuild: true + sharp: true + workerd: true diff --git a/worker/main.go b/worker/main.go new file mode 100644 index 0000000..152eef4 --- /dev/null +++ b/worker/main.go @@ -0,0 +1,21 @@ +//go:build js && wasm + +// Command worker is the Cloudflare Workers (Wasm) entrypoint. +// +// It is compiled only for the js/wasm target by TinyGo (see the "Build & run +// locally" section of the README). The build constraint above keeps this file +// out of host builds and `go test ./...`, so the standard Go toolchain never +// needs the Workers runtime. It wires the shared core handler into the +// syumai/workers adapter, which bridges Go's net/http model to the Workers +// fetch event. +package main + +import ( + "github.com/syumai/workers" + + "github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/handler" +) + +func main() { + workers.Serve(handler.New()) +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..65f8b7a --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,19 @@ +{ + // Cloudflare Worker config for local dev and deploy. + // Docs: https://developers.cloudflare.com/workers/wrangler/configuration/ + "name": "libremail-bug-report-ingest", + + // Entry module. The TinyGo build (see package.json "build") produces the Wasm + // binary at ./build/app.wasm and workers-assets-gen produces this shim, which + // instantiates the Wasm module. The ./build/ artifacts are git-ignored and are + // created by `pnpm run build` (which requires TinyGo). + "main": "./build/worker.mjs", + + "compatibility_date": "2025-06-01", + + // Wrangler runs this before dev/deploy to (re)build the Wasm + shim. + // Requires TinyGo locally; TinyGo is installed in CI. + "build": { + "command": "pnpm run build" + } +} -- 2.47.3