Add community node socket: devices, tickets, consent gate, kill switch

Phase 3 of plan.md. Device registration with one-time secrets, scoped single-use
socket tickets, the lock-owner WS /nodes/ws with handshake and heartbeat eviction,
a single batch ConsentGate, and NodesService which disconnects nodes within one tick
when consent, device or the nodes_enabled kill switch changes. A reconcile of 2000
sessions measured about 110 ms after replacing per-session queries with batches.
Devii may withdraw but never grant bandwidth_sharing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 09:14:53 +02:00
co-authored by Claude Sonnet 5.5
parent fad41d0fbe
commit 42b81271b8
41 changed files with 2166 additions and 22 deletions
+27 -3
View File
@@ -2,7 +2,7 @@ This file documents the community node network (`devplacepy/services/nodes/`, `d
## What exists today
Phase 1 (the specification) and Phase 2 (the data layer and usage ledger) are built. Everything else is planned and listed under "Gaps" next to the phase that closes it. Nothing here is enabled for any account.
Phase 1 (the specification), Phase 2 (the data layer and usage ledger) and Phase 3 (registry, session, consent gate, kill switch, device and ticket routes, the lock-owner socket) are built. No job traffic flows yet. Everything else is planned and listed under "Gaps" next to the phase that closes it. Nothing here is enabled for any account.
| Piece | File | State |
|-------|------|-------|
@@ -12,7 +12,14 @@ Phase 1 (the specification) and Phase 2 (the data layer and usage ledger) are bu
| API docs group | `docs_api/groups/nodes.py` | built, one endpoint |
| Tables and helpers | `database/nodes.py`, `NODES_COLUMNS` in `database/schema.py` | built, tested, no route uses them yet |
| Usage ledger | `ledger.py` (`UsageLedger`, `DonationSummary`) | built, tested |
| `bandwidth_sharing` consent kind | `CONSENT_KINDS` in `database/moderation.py` | registered, never granted by default, enforced nowhere yet |
| `bandwidth_sharing` consent kind | `CONSENT_KINDS` in `database/moderation.py` | registered, never granted by default, enforced by `ConsentGate` |
| Frame codec | `frames.py` (`decode`, `encode`, `FrameError`) | built, tested, validates every frame against `spec.py` |
| Consent choke point | `consent.py` (`ConsentGate`, `Verdict`) | built, tested, single and batch |
| Socket session | `session.py` (`NodeSession`) | built, tested with a fake socket and a real one |
| Registry and counters | `registry.py` (`NodeRegistry`, `NodeCounters`) | built, tested |
| Supervisor | `service.py` (`NodesService`) | built, registered in `main.py`, default enabled |
| Device, ticket and socket routes | `routers/nodes/devices.py`, `ticket.py`, `ws.py` | built, tested |
| nginx upgrade block for `/nodes/ws` | `nginx/nginx.conf.template` | added |
## The specification is the contract
@@ -49,6 +56,23 @@ Seven tables, all ensured with their full column set in `init_db` (`_ensure_node
- **Settings are clamped on write and on read** (`normalise_settings`): bandwidth to `1..max_donor_mb_per_day`, concurrency to `1..max_concurrent_per_node`, hours must be `HH:MM`, platforms are intersected with the spec's adapters. A member with no row is opted out.
- Site settings seeded in `init_db`: `nodes_enabled` (`0`), `nodes_max_total`, `nodes_daily_quota_user`, `nodes_daily_quota_guest`, `nodes_cache_ttl_seconds`, `nodes_social_via_nodes` (`0`). Like every seed, consumers pass the default to `get_setting` because the seed does not run on an empty database.
## Connecting a node (Phase 3)
1. A member consents to `bandwidth_sharing` and turns donation on for themselves (the profile UI for this is Phase 9; today it is `POST /profile/{username}/consent` and `save_node_settings`).
2. `POST /nodes/devices` registers a device and returns its `dpn_` secret once. `GET /nodes/devices`, `POST /nodes/devices/{uid}/rotate` and `/delete` manage it. All are `require_user_api` (401, never a redirect) and answer `Cache-Control: no-store`.
3. The node calls `POST /nodes/ticket` with `Authorization: Bearer dpn_...`. A session cookie or a member API key is not a device secret and gets 401. The ticket is a `ws_tickets` row with `scope="nodes"` and the device uid, valid `ticket_ttl_seconds`, redeemed by one conditional `UPDATE` (16 processes racing one ticket gave exactly one winner; a ticket of the wrong scope never redeems).
4. The node opens `WS /nodes/ws?ticket=...`. The route only runs on the service-lock owner (`4013` elsewhere, the client retries fast, the `/pubsub/ws` precedent). Order of refusal: service disabled `1013`, not the owner `4013`, bad ticket or device or status `1008`, consent gate `1008` with the reason, capacity `4029`, then the handshake.
5. `hello` must arrive within `hello_timeout_seconds`, with a supported `protocol`, a `kind` equal to the registered device kind and a non-empty list of known `modes`. The server answers `welcome` with the full limits and allowlist from the spec. A second connection of the same device kicks the first with `replaced`.
6. The session pings every `ping_seconds`; any frame from the client resets the missed counter and `missed_pings_before_evict` unanswered pings close it with `heartbeat`. `usage` frames are advisory and only summed on the session. Every other client frame is a protocol error until Phase 4 assigns jobs.
## The consent choke point
`ConsentGate` is the only place that decides whether a member's node may exist. `allows_many(user_uids)` decides a whole set with a fixed number of queries and `allows(uid)` is the one-element case of it, so the single and batch answers cannot diverge (a test compares them). Order of reasons: `kill_switch`, `no_account`, `deletion_pending`, `account_inactive`, `suspended`, `not_adult`, `no_consent`, `not_enabled`. It is called at device registration, ticket issue, socket accept and every supervisor tick. An account whose `age_band` is empty (created before age bands existed) is refused as not adult, loudly, rather than assumed adult.
`NodesService.reconcile` runs every `reconcile_seconds`, asks the gate once for all connected owners and loads all devices in one query, then kicks with a reason: `kill_switch`, `revoked`, `suspended`, or the gate's reason. This is why withdrawing the consent, switching donation off, deleting the device or flipping `nodes_enabled` takes effect within one tick even when the request landed on a different worker than the one holding the sockets. **Measured** (scratch SQLite, this machine, 2000 synthetic sessions): one tick costs about 110 ms; the per-session version of the same check cost about 1 ms per session, so 488 ms at 500 sessions and an extrapolated 2 s at 2000, which would have blocked the event loop for the whole tick. Do not reintroduce a per-session query in `reconcile`.
Withdrawal is member-only: `MEMBER_ONLY_CONSENTS` in `database/moderation.py` lists consents that Devii (header `X-Devii-Agent: 1`, resolved in `routers/profile/consent.py`) may withdraw but never grant, and the acceptance service lists `bandwidth_sharing` in `EXCLUDED_CONSENTS`.
## Two transports, two trust levels
`tunnel` (native client) relays raw TCP so TLS runs end to end between DevPlace and the destination: the donor cannot read or forge content. `fetch` (browser extension, which has no raw sockets) lets the donor read and forge the response, so every `fetch` result is verified and labelled. A plain web page or a service worker cannot do either (CORS, no sockets) and is out of scope for good.
@@ -56,7 +80,7 @@ Seven tables, all ensured with their full column set in `init_db` (`_ensure_node
## Gaps (what the next phases close)
- Phase 2 leftovers: the `bandwidth_sharing` consent can currently be granted through `POST /profile/{username}/consent` and by Devii's `set_consent` (Devii calls that route as the user). Nothing reads it yet, so it is inert, but Phase 3 must make Devii unable to grant it (Devii may read and withdraw) at the same time as `ConsentGate` lands. No XP or badge is awarded yet (`UsageLedger.claim_xp` only reserves the daily budget); the privacy page and app-store declarations are updated with the first phase that lets a device connect, because until then no personal data is collected.
- Phase 3: registry, session, the consent choke point, the kill switch, device and ticket routes, the lock-owner WebSocket, the nginx upgrade location.
- Phase 3 leftovers: `country`, `asn` and `asn_class` on a device are never filled (they stay `unknown`), so the datacenter exclusion (D6) is not enforced yet; it needs the offline ASN database from Phase 4. `last_seen_at` is written on connect only, not per heartbeat, so a device list cannot yet show who is online right now. The socket counters are per process and are shown in the service metrics only. The `devplace.net` front proxy idle timeout for a long-lived socket is unmeasured.
- Phase 4: broker, scheduler, dispatcher, destination policy, circuit breakers, result cache.
- Phase 5 onward: the reference client, adapters, the Go client, the extension, the profile UI, Devii tools, the CLI, the privacy and donor-terms pages, the legal gate.
- Not measured yet: Reddit and X success rates per address class, WebSocket idle timeouts through `pravda.education` and `devplace.net`, dispatcher tick cost against SQLite, socket capacity of one worker. The caps in `LimitSpec` are starting ceilings, not measurements.