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
+90 -1
View File
@@ -1,6 +1,20 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint
from .._shared import endpoint, field
DEVICE_SAMPLE = {
"uid": "DEVICE_UID",
"label": "Home laptop",
"kind": "native",
"platform": "linux/amd64",
"client_version": "0.1.0",
"status": "active",
"suspended_until": None,
"country": "NL",
"asn_class": "residential",
"last_seen_at": "2026-01-01T00:00:00+00:00",
"created_at": "2026-01-01T00:00:00+00:00",
}
GROUP = {
"slug": "nodes",
@@ -65,5 +79,80 @@ page. Every endpoint follows the shared [Conventions & Errors](/docs/conventions
"Clients and the server read the same numbers from here; a contract test fails when they drift.",
],
),
endpoint(
id="nodes-devices-list",
method="GET",
path="/nodes/devices",
title="List your devices",
summary="The devices you registered, with their status and when they last connected. Secrets are never listed.",
auth="user",
interactive=True,
sample_response={"devices": [DEVICE_SAMPLE], "max_devices": 5},
),
endpoint(
id="nodes-devices-create",
method="POST",
path="/nodes/devices",
title="Register a device",
summary="Register one client install. The device secret is returned once and cannot be read again.",
auth="user",
ajax=True,
encoding="form",
params=[
field("label", "form", "string", False, "Home laptop", "A name only you see, up to 60 characters."),
field("kind", "form", "string", False, "native", "`native`, `extension` or `container`.", options=["native", "extension", "container"]),
field("platform", "form", "string", False, "linux/amd64", "Operating system and architecture of the client."),
field("client_version", "form", "string", False, "0.1.0", "Version of the client."),
],
sample_response={"device": DEVICE_SAMPLE, "secret": "dpn_EXAMPLE_SECRET_SHOWN_ONCE"},
notes=[
"Refused with `403` and a `reason` (`kill_switch`, `no_consent`, `not_enabled`, `not_adult`, `deletion_pending`, `suspended`, `account_inactive`) unless donation is switched on, you are an adult, you have given the `bandwidth_sharing` consent and enabled donation for yourself.",
"Refused with `409` when you already have the maximum number of devices.",
"The response is `201` and is never cached.",
],
),
endpoint(
id="nodes-devices-rotate",
method="POST",
path="/nodes/devices/{device_uid}/rotate",
title="Rotate a device secret",
summary="Issue a new secret for one of your devices. The old secret stops working immediately.",
auth="user",
ajax=True,
params=[
field("device_uid", "path", "string", True, "DEVICE_UID", "Device uid."),
],
sample_response={"uid": "DEVICE_UID", "secret": "dpn_EXAMPLE_SECRET_SHOWN_ONCE"},
notes=["`404` for a device that is not yours."],
),
endpoint(
id="nodes-devices-delete",
method="POST",
path="/nodes/devices/{device_uid}/delete",
title="Delete a device",
summary="Remove a device. A connected node is disconnected on the next service tick. An administrator may remove any device.",
auth="user",
ajax=True,
destructive=True,
params=[
field("device_uid", "path", "string", True, "DEVICE_UID", "Device uid."),
],
sample_response={"ok": True},
),
endpoint(
id="nodes-ticket",
method="POST",
path="/nodes/ticket",
title="Exchange a device secret for a socket ticket",
summary="The node client calls this with its device secret to get a single-use ticket for `WS /nodes/ws?ticket=`.",
auth="user",
interactive=False,
sample_response={"ticket": "TICKET", "expires_in": 30, "ws_path": "/nodes/ws"},
notes=[
"Authenticated by `Authorization: Bearer dpn_...` with the device secret, not by a session. Anything else is `401`.",
"The ticket is valid for 30 seconds and can be redeemed once. The socket then speaks the frames published in the [protocol specification](/docs/nodes-protocol.html).",
"`403` with a `reason` when the device is suspended or its owner no longer passes the consent gate.",
],
),
],
}