@@ -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.",
|
||||
],
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user