# @myelinbridge/cli

Deliver R&D data to your sponsor through [Myelin](https://myelinbridge.com), from a
terminal or a script: run the sponsor's quality checks on a folder before uploading a
byte, upload with resume, hand the delivery over for review, follow the outcome.

Node 20 or later. Nothing to install: `npx @myelinbridge/cli <command>` fetches it on
each run. Or `npm i -g @myelinbridge/cli` once, and the binary is `myelin`.
Full API reference: https://myelinbridge.com/developers.

## First delivery

Your own bridge owner — on your side, not your sponsor's — creates your key in
**Bridge → API**. It starts with `myl_live_` and is shown once.

```bash
export MYELIN_API_KEY=myl_live_…       # Windows: setx MYELIN_API_KEY myl_live_…  (then open a new terminal)

npx @myelinbridge/cli ping                                          # 1. the key works, and what it can reach
npx @myelinbridge/cli datasets                                      # 2. what you can deliver to
npx @myelinbridge/cli contract --dataset onco1-wes                  # 3. what the sponsor expects of a delivery
npx @myelinbridge/cli check ./run_042 --dataset onco1-wes           # 4. their checks on your folder — nothing is uploaded
npx @myelinbridge/cli push  ./run_042 --dataset onco1-wes --submit  # 5. upload, then hand over for review
npx @myelinbridge/cli status ONCO1-WES-001 --watch                  # 6. follow the review
```

That is the whole path. Four things worth knowing about it:

- **`check` uploads nothing.** It sends your file list, and the same engine that runs
  at submit answers. Exit code `2` means the delivery could not be validated as it
  stands: a blocking check failed, or a blocking check could not be evaluated at all.
  Exit `0` here means the checks that can run before upload pass; checks that read
  file contents run at submit, and `contract` tells you which ones. Above 10 000 files
  `check` cannot run (`too_many_files`, exit `1`): skip it, the same checks run at submit.
- **`push` sends every file under the folder** (dotfiles included, symlinks followed; a
  broken link or an unreadable file stops the command and is named), and it is safe to
  re-run: files already uploaded are skipped, and inside a large file, parts that already
  landed are skipped too. A crashed run resumes with the same command. Stage a clean
  folder if your pipeline leaves work files behind. `push` does not run `check` first.
  Without `--submit` the draft stays open, one per dataset, and you submit it later, from
  the CLI or from the portal; a later `push` of a different folder joins that same draft.
  At the end it prints the delivery's name (`ONCO1-WES-001`), which `status` and `recall`
  take.
- **One delivery at a time per dataset.** While a delivery is submitted or in review,
  `push` to that dataset exits `2` with `delivery_locked` until the reviewer validates it
  or sends it back. `recall` takes it back while it is still `submitted`. If you deliver
  more often than your sponsor reviews, agree a turnaround with them, or ask for one
  dataset per run. The same exit also comes, for a few seconds, from pushing into a
  delivery that another process is submitting or recalling at that moment: wait, check
  it with `status`, and run the `push` again.
- **After `--submit`, a person decides.** A reviewer on your sponsor's side opens the
  delivery in their portal and either validates it, after which Myelin copies the files
  into their bucket, or asks for changes; the turnaround is theirs, and Myelin notifies
  them. `status` tells you what to fix: on `changes_requested` it prints the files the
  reviewer flagged, their comments, and a hint per failed rule. Fix, then
  `push --submit --replace`: only the files you changed are sent, and files you did not
  touch keep their review votes. `--replace` matters here: the files already uploaded are
  compared with yours by MD5, so a correction that kept the file the same size is still
  sent. Without it, a file already uploaded at the same size is taken as unchanged.
- **`datasets` empty, or `dataset_not_active`?** The sponsor has not finished setup on
  their side. Three things must exist first, all created by them: an active bridge, a
  project your key is scoped to, and an activated dataset inside it. Ask your sponsor
  contact, naming the missing piece.

### What you will see

An example session, human mode. Your names, rules and counts will differ; the shape will not.

```
$ myelin ping
✓ key "pipeline" (myl_live_Yxwfeh…) · bridge Acme ↔ Genos CRO · 1 project(s)
Next: myelin datasets

$ myelin datasets
PROJECT  DATASET    STATUS  YOUR MOVE?
ONCO1    onco1-wes  active  —
Next: myelin contract --dataset onco1-wes

$ myelin contract --dataset onco1-wes
WES cohort — delivery contract v3
6 checks · 3 block validation · 5 checkable before upload · 1 reviewed by a person

COMPLETENESS — is everything here?
  ! a samplesheet is present at the delivery root
  ! every sample in the samplesheet has R1 and R2 reads
STRUCTURE — is it laid out as agreed?
  · file names follow {sample}_{lane}_{R1|R2}.fastq.gz
INTEGRITY — are the bytes what you say they are?
  ! every file matches your checksum manifest (checked at submission)
REVIEWER JUDGEMENT — decided by a person, not the engine
  · the consent scope covers this cohort

Run "myelin check <dir> --dataset onco1-wes" to test 5 of these locally.

$ myelin check ./run_042 --dataset onco1-wes
Evaluating 129 files (26.1 GB) against quality checks v3…
✓ samplesheet present          passed
✗ paired reads complete        failed — BLOCKING
    hint: S07 has R1 but no R2 — add S07_L001_R2.fastq.gz or drop S07 from the samplesheet
✓ file naming                  passed
… checksum manifest matches    reads file contents; runs at submit
○ consent scope                a reviewer decides this
1 blocking issue(s). Fix before pushing to avoid a review round-trip.
$ echo $?
2

$ myelin push ./run_042 --dataset onco1-wes --submit        # after the fix
Draft ONCO1-WES-004 created.
Uploading 130/130 files (26.1 GB) · resumable
  ✓ /samplesheet.csv (1/130)
  ✓ /S01/S01_L001_R1.fastq.gz (2/130)
  …
All files confirmed.
Submitting… auto-checks: 5 passed, 0 flagged, 0 failed of 6.
✓ Batch submitted for review. Track: myelin status ONCO1-WES-004 --watch

$ myelin status ONCO1-WES-004 --watch
ONCO1-WES-004: submitted — reviewer's move.
ONCO1-WES-004: in_review — reviewer's move.
ONCO1-WES-004: transferred — delivered to your sponsor’s bucket.
```

### Two optional declarations

- **`sample-depth`** is the folder depth a sample sits at: `1` means each top-level
  folder is one sample and is the default, `0` means the whole delivery is one sample,
  `2` one level deeper. Set it only if the default is wrong for your layout. It locks
  once your first delivery leaves draft, so per-sample verdicts stay comparable; if you
  are not sure which depth your sponsor expects, ask before the first submit.
- **`roles`** says which file plays which role: `samplesheet`, `checksum_manifest`,
  `qc_report`, `subject_roster`, `capture_bed`. Nothing is required to deliver; a role
  you declare turns on the checks that read that file, so more of the contract is
  verified before review. `roles none <role>` records that your dataset has no such
  file. It never locks. A declaration sticks across deliveries, but `checksum_manifest`
  and `qc_report` describe one delivery: when a later delivery carries its own under a
  new name, declare the new file (`roles set checksum_manifest <new path>`), or the
  checks that read them say *not checked* on it. A checksum manifest is read in the shapes tools write: plain
  `md5sum` output, or a delimited file with a filename column and an md5 column, paths
  relative to the delivery root.

## Commands

`<ds>` is a dataset slug (any case), an id, or an exact name, as `datasets` prints them.

| Command | Key | What it does |
|---|---|---|
| `ping` | partner | Checks the key and prints the bridge and the projects it can reach. |
| `datasets` | partner | Lists the datasets you can deliver to, and whose move it is. |
| `contract --dataset <ds>` | partner | Every check the dataset expects, one sentence each: `!` blocks validation, `~` is confirmed by the sponsor's reviewer rather than fixed by you. Says which checks `check` can run locally. |
| `check <dir> --dataset <ds>` | partner | Runs the dataset's checks on your local file list, server-side, without uploading. Exit `2` when a blocking check fails or could not be evaluated. |
| `push <dir> --dataset <ds> [--submit] [--replace]` | partner | Creates or resumes the dataset's draft and uploads what has not landed yet. `--submit` hands it to review. `--replace` sends the new version of a path already on the draft: a different size, or the same size with different content (files already uploaded are compared by MD5 and only the ones that changed are sent). Use it after `changes_requested`. Without it, a different size exits `2` and a file already uploaded at the same size is skipped as unchanged. |
| `status <batch> [--watch]` | partner | Where the delivery stands and whose move it is; on `changes_requested`, what to fix. Takes an id or a display name. `--watch` polls every 20 s and returns at the first final state: exit `0` at `transferred`, `2` at `changes_requested`, `1` at `rejected` or `transfer_failed`. A review can take days: for an unattended job, prefer a webhook. |
| `recall <batch>` | partner | Takes a submitted delivery back before the review starts; it becomes a draft again. Once a reviewer has opened it, `recall_failed`. |
| `projects` | partner | The projects this key is scoped to, with their status. |
| `sample-depth <0-5> --dataset <ds>` | partner | Declares the folder depth a sample sits at. Idempotent; locks once a delivery leaves draft. |
| `roles [set <role> <path> / none <role> / clear <role>] --dataset <ds>` | partner | Shows, declares or clears which file plays which role. Idempotent, never locks. |
| `sandbox <file>` | partner | Uploads one file under 4 MB as the partner-side test transfer your sponsor's bridge-activation checklist asks for. Not a test environment, and not a step before `push`. |
| `deliveries [--project <id>] [--dataset <id>] [--since <iso>] [--limit <n>] [--cursor <ts>]` | sponsor | What landed in your bucket, newest first. Page with `--cursor` from the printed `next_cursor`. |
| `resolve <path or prefix or id>` | sponsor | A `gs://` or `s3://` path, a bare prefix or an id, back to the project, dataset, delivery and file it belongs to. |
| `version` | | Prints the CLI version. |
| `help [<command>]` | | This list, or one command in detail. Works without a key. |

## From a script

Every command takes `--json`: one JSON object on stdout, no prose. Exit codes:
`0` ok · `1` error · `2` blocked, meaning a blocking check failed or could not be
evaluated, a declared path conflicts, or the API refused on the delivery's state or the
sponsor's setup (`delivery_locked`, `submit_blocked`, `dataset_not_active`,
`api_disabled`, `bridge_paused`, `sample_depth_locked`).

```bash
myelin check "$OUT" --dataset onco1-wes || exit $?
myelin push  "$OUT" --dataset onco1-wes --submit
# on changes_requested (status --watch exits 2): fix, then re-run the push with --replace —
# without it, a corrected file of the same size is taken as unchanged
```

Things a script should know:

- **An unknown option is an error** (`push --sumbit` exits `1` before a byte moves).
- **Pin the version you tested** in a pipeline: `npx @myelinbridge/cli@<version>`. Bare
  `npx @myelinbridge/cli` runs whatever is latest today. `--json` shapes and exit codes
  follow the API's rule: fields may appear, existing ones keep their meaning.
- **Rotating a key revokes the old one at once.** There is no overlap window, so rotate
  between runs, not during one; a run that starts with a revoked key fails `401` and
  resumes with the new key. A key may also carry an expiry the sponsor chose when creating
  it (`GET /v1/me` → `expires_at`).
- **No client-side timeout on API calls, by design**: a preflight of 10 000 files takes
  minutes. Bound the command with your runner's timeout if you need one. A connection
  refused, a DNS failure or a socket that never answers is reported at once (exit `1`) and
  not retried; a `429`, a `503 rate_limit_unavailable` and a failed part upload are (see below).
- **`status --watch` exits `0` at `transferred`, `2` at `changes_requested`, `1` at
  `rejected` or `transfer_failed`.** A network blip mid-watch is retried five times; if the
  runner dies, re-run it: the state is server-side and nothing is lost. On a draft there is
  nothing to wait for: it exits `2` at once and prints the push that sends it, with
  `--replace` when the draft was submitted before and recalled.
- **`--json` shapes.** `ping`, `contract`, `check`, `deliveries` and `resolve` print the API
  response as is. `datasets` prints `{ datasets: [{ project, dataset, status, quality_version,
  open_draft, id }] }`. `push` prints `{ batch_id, files, submitted: false }` for a draft and
  the submit response (`{ batch_id, status, already_submitted, auto_checks }`) with
  `--submit`. `status` prints `{ batch }`, plus `findings` on `changes_requested`. The delivery
  name that `status` takes is `batch.display_name`; the id works too.
- **Paths** are relative to the delivery root, `/`-separated; a leading `/` is optional.

`check --json` carries the numbers to branch on:

| Field | |
|---|---|
| `blocking_failures` | Blocking checks that were evaluated and failed. |
| `blocking_not_evaluated` | Blocking checks the engine could not evaluate. These stop the reviewer validating just as surely, so `check` exits `2` on them too. |
| `must_acknowledge_failures` | Checks the sponsor asked to be told about. Reported, never exited on. |
| `counts` | `passed`, `flagged`, `failed`, `not_evaluated`, `not_applicable`, plus `deferred` and `manual`. |
| `metadata.missing_required` | The sponsor's own required fields still empty on the dataset. Informational: their team fills them in Myelin, nothing to change in your delivery, and it never moves the exit code. |

A check that did not conclude says why in `details.abstained`: `rule` means the
sponsor's own rule could not be evaluated and there is nothing on your side to fix;
`data` means something the check needs is missing from your delivery; `nothing_to_evaluate`
means the rule matched none of your files.

An API error in `--json` mode is `{ "error", "code", "status", "request_id" }`. Branch on
`code`; quote `request_id` when you report a problem, it names the exact server-side log
line. The id is printed in human mode too.

## What push does when something goes wrong

- **A crash or a lost connection.** Re-run the same command. A file already uploaded at
  the same path and size is skipped (under `--replace`, at the same path with the same
  MD5); within a large file, parts that already landed are skipped.
- **The last step takes a long time, or does not answer.** Joining the uploaded parts normally
  takes seconds, even on a very large file; when storage is slow it can take longer. If the
  server answers `502` or `504`, or the connection drops, while it does, `push` sends the **same confirmation again** — same upload id, and
  the parts are not sent again — waiting 2 s, 4 s, 8 s … up to a minute between tries, for up
  to 10 minutes after the first failure. If it still does not answer, `push` stops with
  `confirm_unanswered` (exit `1`; a structured error under `--json`) and says that your file
  reached storage and only the last step is missing: run the same command again to retry it.
  A refusal (`409 size_mismatch`, `delivery_locked`, …) is reported at once and never retried.
- **A part that stops moving.** A part upload that makes **no progress for 120 seconds** — no
  data accepted while sending, no answer after the last byte — is aborted and sent again with a
  fresh address, and counts as a timeout: a part that stalls twice moves the file to smaller
  parts, like any other timeout. A slow link that keeps moving is never cut. Only the total
  silence is.
- **A part that arrives changed.** After every part, `push` compares the `ETag` storage
  returns (the MD5 of the bytes it received) with the MD5 of the bytes it sent. If they differ the
  part is sent once more; if they differ again `push` stops, names the part and says nothing was
  confirmed — look for a proxy or inspection device that alters uploads to the storage host. An
  ETag that is not an MD5 cannot be compared; `push` says so once and carries on.
- **A rate limit.** A `429` waits out `Retry-After` and retries, on every command.
- **The rate limiter is unavailable.** A `503 rate_limit_unavailable` means the server
  could not count the call and refused it before doing anything, so it is waited out and
  retried the same way. Any other `503` is reported, not retried.
- **A part that fails.** On a storage error, an edge timeout or an expired upload URL,
  the part is re-signed and retried with backoff, four attempts, before the error surfaces.
- **A slow uplink.** When the same part dies twice on a timeout early in a file — fewer than
  eight parts have landed — the file restarts with
  smaller parts, 64 → 16 → down to a floor that scales with the file's own size (5 MiB for
  anything at today's size limit — large enough that storage's 10,000-part ceiling could
  never be crossed, however far the file downshifts). A link of roughly 40 KB/s or more
  completes, slowly. On a slower one, a part whose last megabytes are still draining can look
  stalled to the 120-second no-progress timer below — raise `MYELIN_PART_IDLE_TIMEOUT_MS` for such
  a link.
- **A slowdown late in a big upload.** Restarting a file throws away what has landed, so it is only
  done while there is little to throw away. Once eight parts have landed at the current size, a part
  that fails twice is simply tried again (four attempts); if it still fails, `push` stops with
  "re-run to resume", keeps everything that landed, and the next run sends only what is missing.
- **A file that changes while it is sent.** `push` compares the file's size and modification time
  before and after sending it, and the checksum it records covers exactly the bytes that were sent. A
  file that was appended to, edited or replaced in the meantime is not confirmed: `push` stops, names
  the file, keeps the parts, and asks you to run the command again once it has stopped changing.
- **A retried `--submit`.** If the first attempt actually landed, the retry is answered
  with `already_submitted: true`, not a failure.
- **Checksums.** An MD5 of each file is computed while it uploads and recorded with the
  delivery, so a checksum-manifest check verifies against your own manifest. Under
  `--replace`, the files already uploaded at the same size are hashed first, before
  anything is sent — that is how an unchanged file is told from a corrected one.

Uploads go straight to storage over short-lived presigned URLs. No credential for the
sponsor's storage is ever on your machine, and revoking the key cuts off signing at once.

## Limits

| | |
|---|---|
| Files per delivery | 25 000. Submit refuses above that. |
| Files per `check` | 10 000 in one call. A larger delivery cannot be preflighted by `check`; its checks still run at submit. |
| Files per declare call | 500. `push` chunks automatically. |
| Per-file size | 20 GiB by default, set per deployment. A bridge whose destination is Google Cloud Storage may be raised past that independently — ask your sponsor. Above 20 GiB on such a bridge, a file needs an md5 recorded with it — `push` computes it while the file uploads and sends it when it confirms, so there is nothing to supply — and a CLI recent enough to downshift with a size-aware floor — this version qualifies; an older one is refused by name, with an upgrade command. |
| Checksum manifest | Inspected up to 16 MiB. |
| API rate | 120 requests per minute per key. Part uploads go direct to storage and do not count. |
| Throughput | On the order of 100 MB/s aggregate on a clean link (four parts in parallel); a 100 GB run is around twenty minutes. Your uplink is the limit. |

## Where your files are, and for how long

Uploads land in Myelin's holding area, Supabase Storage in the EU (Ireland), never in your
sponsor's bucket. There they can be read by you, by your sponsor's reviewers on that project,
and by the transfer worker's service identity; Myelin's own operator role has no
customer-data access. Once the reviewer validates, the files are copied into the sponsor's
bucket and the copy is verified. The holding copy is purged after that verified delivery, or
after a rejection, plus a grace window of seven days by default; each purge writes an audit
row. An empty draft with no activity for 24 hours is purged; a draft holding uploaded files
is kept, and its owner reminded. The full position is at https://myelinbridge.com/security.

There is no self-serve test environment. To rehearse without real data, ask your sponsor for
a dataset created for that purpose, or ask Myelin for a sandbox. Questions about the setup on
the sponsor's side go to your sponsor contact; questions about the API go to the address on
https://myelinbridge.com/developers.

## Environment

| Variable | |
|---|---|
| `MYELIN_API_KEY` | Required. Created by your bridge owner in **Bridge → API**. |
| `MYELIN_API_URL` | Optional. Defaults to `https://myelinbridge.com/api/v1`. |
| `MYELIN_API_HEADER` | Optional. Extra headers sent with every API call, one `Name: value` per line, for a Myelin deployment fronted by something that authenticates before Myelin does. It can never override `Authorization`. |
| `MYELIN_PART_IDLE_TIMEOUT_MS` | Optional. How long a part upload may make no progress before `push` aborts it and sends it again, in milliseconds. Defaults to `120000` (2 minutes). A slow link that keeps moving is never cut by it. |
| `MYELIN_CONFIRM_RETRY_BUDGET_MS` | Optional. How long `push` keeps sending the same confirmation again after the server failed to answer it, in milliseconds, counted from the first failure. Defaults to `600000` (10 minutes). `0` turns the retry off, for a pipeline that would rather fail at once and be re-run. |
| `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Optional. Honoured on every call; lowercase spellings read too. See below. |

## If you are the sponsor, not the partner

Everything above needs a **partner key**. A **sponsor key** is read-only and answers the
question your own systems ask once the data has landed: the bucket is full of ids, what
is this? Your organisation admin creates it in **Organisation → API keys**; one key covers
every partner. To narrow one to a single partner's data, scope it to that partner's projects.

```bash
export MYELIN_API_KEY=myl_live_…

myelin deliveries --dataset <dataset-id>        # what has landed, newest first
myelin resolve gs://acme-landing/inbox/acme-cro/4319…/de99…/7aa5…/data/SAMPLE_01/reads.fastq.gz
#   file · gs://…/reads.fastq.gz
#     project   ONCO1 — Oncology discovery
#     dataset   WES cohort (Genomics)
#     batch     ONCO1-WES-007 · #7
#     validated 2026-08-07T18:05:12Z · delivered 2026-08-07T18:06:20Z
#     manifest  gs://…/7aa5…/_myelin/manifest.json
```

Each delivery also carries the same record **as a file**, written next to the data at
`_myelin/manifest.json`, with `_myelin/files.csv` beside it as a flat table. Prefer the
file for anything auditable: it is frozen at delivery time, needs no credentials, and does
not depend on Myelin being reachable. A partner key calling these gets `403 wrong_key_side`,
and vice versa.

## For your IT department

Everything below is derived from what the code actually connects to. If you need to hand
one page to a network team, hand them this one.

### What the CLI needs to reach

Outbound **TCP 443 only**, to two hosts:

| Host | Why |
|---|---|
| `myelinbridge.com` | the API — auth, the quality contract, preflight, part signing, submit, status |
| `cdjlhyyrbogzkoaujzmj.storage.supabase.co` | the bytes — uploads go **straight to storage** over short-lived presigned URLs, never through the API host |

The second one surprises people, and it is the single commonest way a delivery
half-works: `myelin ping` and `myelin check` succeed, `myelin push` stalls on part 1.
That failure names the host it could not reach, so the allowlist gap is legible from
the error.

If your sponsor runs Myelin at their own address, `MYELIN_API_URL` names the first host
and the second is that deployment's storage host. You never have to guess it: every
Myelin deployment publishes the authoritative list in its own `content-security-policy`
header:

```bash
curl -sI https://myelinbridge.com | tr ';' '\n' | grep connect-src
#   connect-src 'self' https://<ref>.supabase.co wss://<ref>.supabase.co https://<ref>.storage.supabase.co
```

A wildcard rule works if your proxy prefers one: `*.supabase.co`.

### What a person using the web portal needs

Someone using the portal in a browser needs the two hosts above **plus** the
Supabase API host, which serves file previews and carries the reference documents
attached to a project or dataset, in both directions (sign-in itself goes through
`myelinbridge.com`):

| Host | Port | Why |
|---|---|---|
| `myelinbridge.com` | 443 | the application itself, sign-in included |
| `cdjlhyyrbogzkoaujzmj.supabase.co` | 443 | file previews, and the reference documents (protocols, sample sheets) read and uploaded in the portal — one request of up to 100 MiB per document |
| `cdjlhyyrbogzkoaujzmj.storage.supabase.co` | 443 | browser uploads, the same presigned PUTs the CLI makes |

Uploads travel in pieces of up to 64 MiB, so a proxy or upload scanner must accept
request bodies that large on the storage host, and bodies of up to 100 MiB on the Supabase
API host for reference documents. A one-page version of this section for a
network team — sizes, timeouts, TLS inspection, a five-minute test — is available from
your sponsor contact or hello@myelinbridge.com.

Plain HTTPS is enough. The policy also permits `wss:` to the Supabase host, but the
portal opens no WebSocket today; if your proxy distinguishes the two, you do not need to
allow WebSocket upgrades for the portal to work.

Nothing else is contacted: scripts and fonts are served by the application itself and no
analytics or tracker host is involved. That is not a promise in a README; it is the
`content-security-policy` header above, which the browser enforces on every response.

### What you do *not* need to open

- **No inbound anything.** Nothing connects to your network. The one exception is opt-in
  and yours to choose: if you register a webhook endpoint, Myelin makes an outbound HTTPS
  call to an address you publish.
- **No access to the destination bucket.** The sponsor's cloud bucket is written by
  Myelin, server-side, after review. A partner never touches it and never needs a
  credential for it.
- **No FTP, SSH, rsync or fixed source IPs.** One protocol, one port.

### Behind a proxy

Set the standard variables; the CLI honours them on every call, the API and the part
uploads alike:

```bash
export HTTPS_PROXY=http://proxy.corp.example:8080
export NO_PROXY=.corp.example,10.0.0.0/8
myelin ping        # prints "via proxy http://proxy.corp.example:8080" when one is in use
```

- `HTTPS_PROXY` for https targets, `HTTP_PROXY` for http, `NO_PROXY` to exclude.
  Lowercase spellings (`https_proxy`, …) are read too; both are in the wild.
- Credentials go in the URL: `http://user:pass@proxy:8080` (Basic). They are never
  printed, logged, or echoed in an error.
- `NO_PROXY` accepts a host, a suffix (`.corp.example`, `*.corp.example`), `host:port`,
  an IPv4 CIDR (`10.0.0.0/8`), or `*` for everything.
- **HTTP CONNECT proxies only.** A `socks5://` value is refused with a message rather
  than silently bypassed: a CLI that quietly dials direct while you believe it is proxied
  is worse than one that stops.
- **Basic proxy authentication only.** An NTLM or Kerberos proxy is not supported; put a
  local CONNECT proxy that speaks it (cntlm, px) in front and point `HTTPS_PROXY` at that.
- A typo in a proxy variable fails immediately, before any command runs.
- **TLS terminates at Myelin, not at your proxy.** Through a CONNECT tunnel the proxy sees
  the hostname and nothing else. If your proxy performs TLS interception, point Node at
  its CA: `export NODE_EXTRA_CA_CERTS=/etc/ssl/corp-root.pem`.

## Webhooks instead of polling

Register an HTTPS endpoint (portal Bridge → API, or `POST /v1/webhook-endpoints`) to
receive signed events (`batch.validated`, `batch.changes_requested`, `batch.transferred`,
…). Verification snippets: https://myelinbridge.com/developers. Polling fallback:
`GET /v1/events?cursor=…`.

An endpoint registered with a key receives the events of the projects that key can read,
and nothing else. A key with no project in its scope yet receives no events until it is
scoped. From the moment the key is revoked, rotated or expires, its endpoints stop receiving
and are listed `active: false`; register the same URLs again with the new key. While the
sponsor has paused the bridge, the key is refused (`bridge_paused`) and its endpoints receive
nothing: events that happen during the pause are not sent, and a delivery queued before it
goes out once the bridge is resumed.
