From 407673bf57d600b277dcab5a73fb01fa60b59e66 Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 20 Oct 2026 16:58:04 +0200 Subject: [PATCH] Nodes --- devplacepy/docs_api/groups/nodes.py | 2 +- devplacepy/services/nodes/CLAUDE.md | 4 ++-- devplacepy/services/nodes/consent.py | 4 ---- nodes/client/build.sh | 4 ++-- pyproject.toml | 2 +- tests/api/nodes/devices.py | 2 +- tests/unit/services/nodes/consent.py | 9 ++++----- 7 files changed, 11 insertions(+), 16 deletions(-) diff --git a/devplacepy/docs_api/groups/nodes.py b/devplacepy/docs_api/groups/nodes.py index 4ec3ae32..824a6000 100644 --- a/devplacepy/docs_api/groups/nodes.py +++ b/devplacepy/docs_api/groups/nodes.py @@ -106,7 +106,7 @@ page. Every endpoint follows the shared [Conventions & Errors](/docs/conventions ], 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 `403` and a `reason` (`kill_switch`, `no_consent`, `not_enabled`, `deletion_pending`, `suspended`, `account_inactive`) unless donation is switched on, 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.", ], diff --git a/devplacepy/services/nodes/CLAUDE.md b/devplacepy/services/nodes/CLAUDE.md index ee3bd9d1..a3953ef2 100644 --- a/devplacepy/services/nodes/CLAUDE.md +++ b/devplacepy/services/nodes/CLAUDE.md @@ -58,7 +58,7 @@ The docs page `nodes-protocol.html` renders its tables from the `nodes_spec` Jin | D2 | the native client is Go, one static binary per OS and architecture | | D3 | the extension ships as a sideloadable zip first, store submission later | | D4 | rewards are XP, badges and a leaderboard only: no coins, no money, nothing purchasable | -| D5 | donors must be adults (`age_band`) | +| D5 | donation has no age gate: any active member with consent may donate | | D6 | a datacenter address is not a donor: residential and mobile only, admin override | ## Data layer (Phase 2) @@ -99,7 +99,7 @@ A job is a row in `node_requests`, so any worker can ask and only the lock owner ## 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. +`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`, `no_consent`, `not_enabled`. It is called at device registration, ticket issue, socket accept and every supervisor tick. `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`. diff --git a/devplacepy/services/nodes/consent.py b/devplacepy/services/nodes/consent.py index 6abf1310..3f613ffd 100644 --- a/devplacepy/services/nodes/consent.py +++ b/devplacepy/services/nodes/consent.py @@ -14,7 +14,6 @@ from devplacepy.database import ( from devplacepy.database.nodes import BATCH_SIZE, get_enabled_by_users CONSENT_KIND = "bandwidth_sharing" -ADULT_BAND = "adult" REASONS: dict[str, str] = { "kill_switch": "Community donation is switched off", @@ -22,7 +21,6 @@ REASONS: dict[str, str] = { "account_inactive": "Account is not active", "deletion_pending": "Account is being deleted", "suspended": "Account is suspended", - "not_adult": "Only adults can donate bandwidth", "no_consent": "Bandwidth sharing consent has not been given", "not_enabled": "Donation is turned off in the member's settings", } @@ -54,8 +52,6 @@ class ConsentGate: return Verdict(False, "account_inactive") if suspension_active(user): return Verdict(False, "suspended") - if (user.get("age_band") or "") != ADULT_BAND: - return Verdict(False, "not_adult") if not consented: return Verdict(False, "no_consent") if not enabled: diff --git a/nodes/client/build.sh b/nodes/client/build.sh index ffc5a3ce..caee046a 100755 --- a/nodes/client/build.sh +++ b/nodes/client/build.sh @@ -4,7 +4,7 @@ set -eu here=$(cd "$(dirname "$0")" && pwd) out=${1:-./dist} version=${VERSION:-$(git -C "$here" describe --tags --always --dirty 2>/dev/null || echo dev)} -targets="linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64" +targets="linux/amd64 linux/arm64 linux/386 linux/arm darwin/arm64 darwin/amd64 windows/amd64 windows/386" mkdir -p "$out" out=$(cd "$out" && pwd) @@ -32,7 +32,7 @@ for target in $targets; do if [ "$goos" = "windows" ]; then name="$name.exe" fi - (cd "$here" && CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "-s -w -X main.Version=$version" -o "$out/$name" .) + (cd "$here" && CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" GOARM=7 go build -trimpath -ldflags "-s -w -X main.Version=$version" -o "$out/$name" .) if [ "$first" -eq 0 ]; then printf ',\n' >>"$manifest" fi diff --git a/pyproject.toml b/pyproject.toml index c580726b..a598bf25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devplacepy" -version = "1.1.8" +version = "1.1.9" description = "DevPlace - The Developer Social Network" requires-python = ">=3.12" dependencies = [ diff --git a/tests/api/nodes/devices.py b/tests/api/nodes/devices.py index e5ac1b3b..a9f33b65 100644 --- a/tests/api/nodes/devices.py +++ b/tests/api/nodes/devices.py @@ -40,7 +40,7 @@ def test_registration_needs_consent_and_enablement(client): not_enabled = make_member(enabled=False) assert create(client, not_enabled).json()["error"]["reason"] == "not_enabled" minor = make_member(age_band="16_17") - assert create(client, minor).json()["error"]["reason"] == "not_adult" + assert create(client, minor).status_code == 201 def test_kill_switch_blocks_registration(client, member): diff --git a/tests/unit/services/nodes/consent.py b/tests/unit/services/nodes/consent.py index 52396b16..0493e10f 100644 --- a/tests/unit/services/nodes/consent.py +++ b/tests/unit/services/nodes/consent.py @@ -70,12 +70,12 @@ def test_each_missing_requirement_has_its_own_reason(switched_on): assert GATE.allows(withdrawn).reason == "no_consent" -@pytest.mark.parametrize("band", ["", "under_min", "13_15", "16_17"]) -def test_only_adults_pass(switched_on, band): +@pytest.mark.parametrize("band", ["", "under_min", "13_15", "16_17", "adult"]) +def test_age_band_is_not_checked(switched_on, band): uid = fresh(age_band=band) set_consent("user", uid, "bandwidth_sharing", True) save_node_settings(uid, {"enabled": True}) - assert GATE.allows(uid).reason == "not_adult" + assert GATE.allows(uid).allowed def test_account_states_block(switched_on): @@ -98,7 +98,6 @@ def test_every_reason_is_described(): "account_inactive", "deletion_pending", "suspended", - "not_adult", "no_consent", "not_enabled", ): @@ -120,7 +119,7 @@ def test_batch_decisions_equal_single_decisions(switched_on): for uid in uids: assert many[uid] == GATE.allows(uid) assert [many[uid].reason for uid in uids] == [ - "", "no_consent", "not_adult", "no_consent", "no_account", + "", "no_consent", "", "no_consent", "no_account", ]