f97a4102dd
* fix(relay): accept a provision response that issues no secret Lockstep with gateway-gateway #223 (finding F-004). The connector used to return the stored per-gateway secret from POST /relay/provision on EVERY replay, to anyone reaching the endpoint with the right gatewayId. It now returns credential material only to a caller that proves recorded ownership or possession, and answers secretIssued:false otherwise with secret/deliveryKey ABSENT. This gateway treats that as a hard failure, so #223 cannot merge until this ships. 1. A secret-less response is a VALUE, not an error. _post_provision() raised on any response without a secret. Two legitimate responses hit that raise once #223 lands: - every platform after the first in a multi-platform boot. All platforms share ONE gatewayId, so the first POST mints the secret and the rest are replays of an unchanged binding. self_provision_relay() catches RuntimeError per platform and continues, so a Telegram+Discord gateway would front Telegram and leave Discord unfronted with a warning. - a re-provision by a caller that legitimately no longer holds the secret. Refusing to hand it back IS F-004; /relay/rotate is the way back. Only a malformed body raises now. No secret AND no secretIssued key is still the old unexpected-response case (a pre-F-004 connector), so that keeps failing loudly -- which is what makes this deployable in either order. 2. Only a response that CARRIES credentials may write them. A separate bug that change 1 exposes rather than causes. The guard tested the same env var it wrote: once a withheld response reaches it, it writes an empty secret -- which then reads as "still unset" on the next pass, masking itself, while GATEWAY_RELAY_ID and _DELIVERY_KEY have ALREADY been stamped from a response carrying no credentials. Guard on the secret being present. Tests: 5 invariants in tests/gateway/relay/test_provision_secret_optional.py. Proven red on base -- reverting gateway/relay/__init__.py to origin/main turns 2 of the 5 red, one per fix, then green with the fix restored. My first draft of the multi-platform test passed on base because it had the FIRST platform issue the secret, which satisfies the old guard so it never re-enters and the bug never fires; it proved nothing until the ordering was fixed and a direct write-rule test added. tests/gateway/relay: 303 passed, 0 failed. The 6 failures in the wider tests/gateway run (systemd_notify, wecom_callback, shutdown_forensics, scale_to_zero) reproduce identically on clean origin/main -- pre-existing. * fix(relay): fail closed on the F-004 discriminator; a fully-withheld boot is not a provision Two review findings on the parent commit, each with a red-on-parent test. 1. `_post_provision()` accepted ANY present `secretIssued` value when `secret` was absent (`true`, `"false"`, `0`, …) and settled them as a successful provision. The only credential-less shape the connector emits is literal JSON `false`, so match the discriminator by value: `is not False` raises. 2. `self_provision_relay()` returned True and logged the INFO "self-provisioned" line when every platform answered `secretIssued:false` — no credential in hand, WS upgrade about to be refused, nothing in the boot log to say why. Now: WARNING naming `/relay/rotate` / pin `GATEWAY_RELAY_SECRET`, return False. tests/gateway/relay/: 308 passed, 0 failed.