diff --git a/app/admin_api.py b/app/admin_api.py
index 12a4615..135d155 100644
--- a/app/admin_api.py
+++ b/app/admin_api.py
@@ -51,17 +51,23 @@ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
router = APIRouter(prefix="/api/admin", tags=["admin"])
-def _auth(request, token, capability):
+def _auth(request, token, capability, unit_id=None):
"""Authorise, and return a label naming who acted, for created_by.
Order matters: session first, so a normal signed-in leader never depends on
the shared token, and the token stays a fallback rather than the everyday
path.
+
+ unit_id scopes a membership capability to one unit: a pack leader holds
+ unit:write_own, but only within the pack. Global roles pass a scoped check
+ for every unit, and the break-glass token reaches everything - it exists
+ for when identity is broken, so it cannot depend on identity's scoping.
"""
person = auth.current_person(request)
if person:
- if not identity.can(person, capability):
- raise HTTPException(403, "your account does not have %s" % capability)
+ if not identity.can(person, capability, unit_id):
+ raise HTTPException(403, "your account does not have %s" % capability
+ + (" for this unit" if unit_id else ""))
return person["email"]
if not ADMIN_TOKEN:
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
@@ -179,3 +185,111 @@ def revoke_announcement(request: Request, announcement_id: str, x_admin_token: s
if not store.revoke_announcement(announcement_id):
raise HTTPException(409, "already revoked")
return store.get_announcement(announcement_id)
+
+
+# ----------------------------------------------------------------------------
+# Nearby units - the /find-a-unit directory, and the first writable directory
+# data. The verified_at and deactivate-not-delete rules live in store.py so
+# any future client inherits them.
+# ----------------------------------------------------------------------------
+
+@router.get("/nearby")
+def list_nearby(request: Request, include_inactive: bool = False,
+ x_admin_token: str = Header(None)):
+ """The editing view, so deactivated rows are reachable. nearby:write
+ rather than a read capability: the public page IS the read surface, and
+ this list exists only to be edited."""
+ _auth(request, x_admin_token, "nearby:write")
+ return {"nearby_units": store.list_nearby(include_inactive=include_inactive)}
+
+
+@router.post("/nearby", status_code=201)
+def create_nearby(request: Request, payload: dict = Body(...),
+ x_admin_token: str = Header(None)):
+ _auth(request, x_admin_token, "nearby:write")
+ try:
+ return store.create_nearby(payload)
+ except store.NearbyRejected as e:
+ raise _reject(e)
+
+
+@router.patch("/nearby/{nearby_id}")
+def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
+ x_admin_token: str = Header(None)):
+ """Partial update. Saving bumps verified_at to today unless the payload
+ carries an explicit date - see store.py for why saving is verifying."""
+ _auth(request, x_admin_token, "nearby:write")
+ try:
+ rec = store.update_nearby(nearby_id, payload)
+ except store.NearbyRejected as e:
+ raise _reject(e)
+ if not rec:
+ raise HTTPException(404, "no such nearby unit")
+ return rec
+
+
+@router.delete("/nearby/{nearby_id}")
+def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Header(None)):
+ """Take a unit off the page. Sets active=0; never deletes the row."""
+ _auth(request, x_admin_token, "nearby:write")
+ if not store.get_nearby(nearby_id):
+ raise HTTPException(404, "no such nearby unit")
+ if not store.deactivate_nearby(nearby_id):
+ raise HTTPException(409, "already inactive")
+ return store.get_nearby(nearby_id)
+
+
+# ----------------------------------------------------------------------------
+# Our own units - meeting time and place only. unit:write_own is checked
+# against the unit in the URL, so a pack leader edits the pack and not the
+# troop; admins hold it globally and reach both.
+# ----------------------------------------------------------------------------
+
+@router.get("/units")
+def list_units(request: Request, x_admin_token: str = Header(None)):
+ """The units the caller may edit, which is what the screen this feeds
+ shows. A pack leader gets the pack; a global role or the break-glass
+ token gets everything. The answer IS the scope - no second filter for
+ the console to get wrong."""
+ _auth(request, x_admin_token, "unit:write_own")
+ units = identity.list_units(include_inactive=True)
+ person = auth.current_person(request)
+ if person:
+ units = [u for u in units if identity.can(person, "unit:write_own", u["id"])]
+ return {"units": units}
+
+
+@router.patch("/units/{slug_or_id}")
+def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
+ x_admin_token: str = Header(None)):
+ unit = identity.get_unit(slug_or_id)
+ # Scope to the unit when it exists; an unknown slug still goes through
+ # _auth first, so probing paths answers 401/403 before it answers 404.
+ _auth(request, x_admin_token, "unit:write_own",
+ unit_id=unit["id"] if unit else None)
+ try:
+ return identity.update_unit_meets(slug_or_id, payload)
+ except identity.IdentityError as e:
+ raise HTTPException(e.status, e.detail)
+
+
+# ----------------------------------------------------------------------------
+# Site settings - the typed key registry lives in identity.SETTINGS_KEYS;
+# unknown keys are rejected there, not silently stored.
+# ----------------------------------------------------------------------------
+
+@router.get("/settings")
+def list_settings(request: Request, x_admin_token: str = Header(None)):
+ _auth(request, x_admin_token, "settings:write")
+ return {"settings": identity.all_settings()}
+
+
+@router.put("/settings/{key}")
+def put_setting(request: Request, key: str, payload: dict = Body(...),
+ x_admin_token: str = Header(None)):
+ """Set one value, or clear it back to the code default with value: null."""
+ actor = _auth(request, x_admin_token, "settings:write")
+ try:
+ return identity.set_setting(key, payload.get("value"), actor=actor)
+ except identity.IdentityError as e:
+ raise HTTPException(e.status, e.detail)
diff --git a/app/identity.py b/app/identity.py
index d87659c..1450878 100644
--- a/app/identity.py
+++ b/app/identity.py
@@ -333,6 +333,56 @@ def get_unit(slug_or_id):
con.close()
+# The only unit fields the admin API lets a leader change. Renaming a unit or
+# changing its type is a rechartering event, not a Tuesday edit, and stays a
+# code change.
+UNIT_MEETS_FIELDS = ("meets_weekday", "meets_time", "meets_at")
+
+
+def update_unit_meets(slug_or_id, fields):
+ """Change when and where a unit meets.
+
+ meets_weekday is ISO (Monday=1 .. Sunday=7) and meets_time is 24h "HH:MM",
+ matching how the seed rows store them; the site composes the display
+ sentence, so a display string is never accepted here. Any field may be set
+ to null - a unit between meeting places is a real state.
+ """
+ unit = get_unit(slug_or_id)
+ if not unit:
+ raise IdentityError(404, "no such unit")
+ if not fields:
+ raise IdentityError(422, "nothing to update")
+ unknown = sorted(set(fields) - set(UNIT_MEETS_FIELDS))
+ if unknown:
+ raise IdentityError(422, "only meeting fields are editable here: %s"
+ % (", ".join(UNIT_MEETS_FIELDS)))
+
+ out = dict(fields)
+ if "meets_weekday" in out and out["meets_weekday"] is not None:
+ v = out["meets_weekday"]
+ if isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 7:
+ raise IdentityError(422, "meets_weekday is ISO: 1 (Monday) to 7 (Sunday), or null")
+ if "meets_time" in out and out["meets_time"] is not None:
+ try:
+ datetime.datetime.strptime(str(out["meets_time"]), "%H:%M")
+ except ValueError:
+ raise IdentityError(422, 'meets_time must be 24h "HH:MM", or null')
+ out["meets_time"] = str(out["meets_time"])
+ if "meets_at" in out and out["meets_at"] is not None:
+ out["meets_at"] = str(out["meets_at"]).strip() or None
+
+ out["updated_at"] = _now()
+ con = connect()
+ try:
+ con.execute("UPDATE units SET %s WHERE id=?"
+ % ", ".join("%s=?" % k for k in out),
+ list(out.values()) + [unit["id"]])
+ con.commit()
+ finally:
+ con.close()
+ return get_unit(unit["id"])
+
+
def _person_row(con, r):
if not r:
return None
@@ -710,3 +760,101 @@ def bootstrap():
except Exception as e:
print("identity: bootstrap failed: %s" % e, flush=True)
return None, False
+
+
+# ---------------------------------------------------------------------------
+# Site settings
+# ---------------------------------------------------------------------------
+#
+# Typed key-value pairs for the handful of site-wide values that change more
+# often than the code does. SETTINGS_KEYS is the whole contract: a key not in
+# it cannot be written, and a key absent from the table - or holding a value
+# its checker no longer accepts - falls back to the code default. A mangled
+# row can make the site stale, never make it crash.
+#
+# Setting a value to null clears the row, which IS the fallback: there is no
+# stored-but-empty state to reason about.
+
+def _setting_text(v):
+ v = str(v).strip()
+ if not v:
+ raise IdentityError(422, "value must not be blank; send null to clear to the default")
+ return v
+
+
+def _setting_https_url(v):
+ v = _setting_text(v)
+ if not v.startswith("https://") or any(c in v for c in " \"'<>"):
+ raise IdentityError(422, "value must be a plain https:// URL")
+ return v
+
+
+# key -> (code default, checker)
+SETTINGS_KEYS = {
+ "nearby_source_name": ("Continental District unit list", _setting_text),
+ "nearby_source_url": ("https://tinyurl.com/ContinentalScouts", _setting_https_url),
+}
+
+
+def get_setting(key):
+ """The effective value: the stored one if present and still valid, else
+ the code default. Unknown keys are a programming error and raise."""
+ default, check = SETTINGS_KEYS[key]
+ con = connect()
+ try:
+ r = con.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
+ finally:
+ con.close()
+ if not r:
+ return default
+ try:
+ return check(r["value"])
+ except IdentityError:
+ return default
+
+
+def all_settings():
+ """Every known key with its effective value, for the settings screen.
+ Rows for keys the registry no longer knows are not shown - they are dead
+ weight, not settings."""
+ con = connect()
+ try:
+ stored = {r["key"]: dict(r) for r in con.execute("SELECT * FROM settings")}
+ finally:
+ con.close()
+ out = []
+ for key, (default, _check) in SETTINGS_KEYS.items():
+ row = stored.get(key)
+ out.append({
+ "key": key,
+ "value": get_setting(key),
+ "default": default,
+ "is_set": row is not None,
+ "updated_at": row["updated_at"] if row else None,
+ "updated_by": row["updated_by"] if row else None,
+ })
+ return out
+
+
+def set_setting(key, value, actor=None):
+ """Write one setting, or clear it back to the default with value=null."""
+ if key not in SETTINGS_KEYS:
+ raise IdentityError(422, "unknown setting %r. Known keys: %s"
+ % (key, ", ".join(sorted(SETTINGS_KEYS))))
+ default, check = SETTINGS_KEYS[key]
+ con = connect()
+ try:
+ if value is None:
+ con.execute("DELETE FROM settings WHERE key=?", (key,))
+ else:
+ value = check(value)
+ con.execute(
+ "INSERT INTO settings (key, value, updated_at, updated_by)"
+ " VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET"
+ " value=excluded.value, updated_at=excluded.updated_at,"
+ " updated_by=excluded.updated_by",
+ (key, value, _now(), actor))
+ con.commit()
+ finally:
+ con.close()
+ return [s for s in all_settings() if s["key"] == key][0]
diff --git a/app/nearby.py b/app/nearby.py
index 4b46dd5..b2fe4f0 100644
--- a/app/nearby.py
+++ b/app/nearby.py
@@ -17,6 +17,7 @@ and from this page. The rest of the site is our pitch; this one is a door out
for a family we are not the right fit for.
"""
+import identity
import store
# unit_type -> the badge a parent actually recognises.
@@ -39,12 +40,10 @@ SERVES_LABEL = {
BEASCOUT = "https://beascout.scouting.org"
-# The district's own short link to its unit list. This is the page the district
-# hands out, so it is the right thing to credit and the right thing to link.
-# One URL for the whole page rather than a per-row column: when the admin panel
-# exists this belongs in a settings row, not in seventeen copies.
-SOURCE_NAME = "Continental District unit list"
-SOURCE_URL = "https://tinyurl.com/ContinentalScouts"
+# The district's short link to its unit list is one URL for the whole page
+# rather than a per-row column, and it now lives in settings (defaults and
+# validation in identity.SETTINGS_KEYS) so the admin panel can change it when
+# the district moves the document.
MONTHS = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"]
@@ -103,8 +102,10 @@ def checked_on():
def _esc(s):
+ """Quotes included: several of these values are interpolated into
+ href="..." attributes, where a bare quote is a breakout."""
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")
- if s else "")
+ .replace('"', """) if s else "")
def unit_name(u):
@@ -176,8 +177,8 @@ def body_html(hero_html):
'has every unit in the area.')
checked = checked_on()
- src = (f'{SOURCE_NAME}')
+ src = (f'{_esc(identity.get_setting("nearby_source_name"))}')
stamp = (f'Pulled from the {src} on {pretty_date(checked)}.' if checked
else f'Pulled from the {src}.')
diff --git a/app/store.py b/app/store.py
index c3ac782..0a7d283 100644
--- a/app/store.py
+++ b/app/store.py
@@ -46,6 +46,14 @@ ANNOUNCEMENT_MAX_CHARS = 200
ANNOUNCEMENT_MAX_LIVE = 3
ANNOUNCEMENT_LEVELS = ("info", "urgent")
+# nearby_units guardrails. pack/troop/ship/club get a recognisable badge on
+# /find-a-unit; crew and post are real unit types the district may list and
+# render with the generic card. Anything else is a typo, and a typo'd type
+# would silently render a unit as generic "Scouting" rather than fail, so it
+# is rejected at write time instead.
+NEARBY_UNIT_TYPES = ("pack", "troop", "crew", "ship", "post", "club")
+NEARBY_SERVES = ("family", "boys", "girls", "coed")
+
SCHEMA = """
PRAGMA journal_mode=WAL;
@@ -403,9 +411,9 @@ def _backfill(con, path):
# record of what the site said, and when, survives.
-class AnnouncementRejected(Exception):
+class Rejected(Exception):
"""Raised when a write breaks a guardrail. Carries the HTTP status the
- admin API should return, so the caps live here rather than in the route."""
+ admin API should return, so the rules live here rather than in the route."""
def __init__(self, status, detail, extra=None):
super().__init__(detail)
@@ -414,6 +422,10 @@ class AnnouncementRejected(Exception):
self.extra = extra or {}
+class AnnouncementRejected(Rejected):
+ pass
+
+
def _live_at(con, when):
return con.execute(
"SELECT * FROM announcements"
@@ -539,3 +551,160 @@ def revoke_announcement(aid):
return cur.rowcount > 0
finally:
con.close()
+
+
+# ----------------------------------------------------------------------------
+# Nearby units - writes behind the /find-a-unit courtesy directory.
+#
+# This is somebody else's data, hand-copied from a district document, and two
+# rules follow from that.
+#
+# verified_at is bumped to today on every row write unless the caller passes
+# one explicitly. Saving a row IS the claim that a person just checked it
+# against the district list - verified_at is the date the honesty line on the
+# public page shows a family, not bookkeeping.
+#
+# Rows are deactivated, never deleted. A unit that folds or moves keeps its
+# row with active=0, so "why did that pack disappear from the page" stays
+# answerable. Reactivation is an update setting active back to 1.
+# ----------------------------------------------------------------------------
+
+
+class NearbyRejected(Rejected):
+ pass
+
+
+# Everything a caller may set. id and updated_at are the store's own.
+NEARBY_FIELDS = ("unit_type", "unit_number", "serves", "chartered_org", "street",
+ "town", "area", "meets", "notes", "link_url", "contact",
+ "sort_order", "active", "source", "verified_at")
+
+
+def _clean_nearby(fields, creating):
+ """Validate and normalise a payload. Unknown keys are rejected rather than
+ dropped - a silently ignored typo ("unit_typo": "pack") would read as a
+ successful save that changed nothing."""
+ unknown = sorted(set(fields) - set(NEARBY_FIELDS))
+ if unknown:
+ raise NearbyRejected(422, "unknown fields: %s. Editable fields are %s"
+ % (", ".join(unknown), ", ".join(NEARBY_FIELDS)))
+
+ out = {}
+ for k, v in fields.items():
+ if isinstance(v, str):
+ v = v.strip() or None
+ out[k] = v
+
+ if creating or "unit_type" in out:
+ if out.get("unit_type") not in NEARBY_UNIT_TYPES:
+ raise NearbyRejected(422, "unit_type must be one of %s" % (NEARBY_UNIT_TYPES,))
+ if creating or "unit_number" in out:
+ if not out.get("unit_number"):
+ raise NearbyRejected(422, "unit_number is required")
+ out["unit_number"] = str(out["unit_number"])
+ if out.get("serves") is not None and out["serves"] not in NEARBY_SERVES:
+ raise NearbyRejected(422, "serves must be one of %s, or null" % (NEARBY_SERVES,))
+ # Both of these land inside href="..." attributes on the public page, so a
+ # quote or bracket is an attribute breakout, not a formatting nit. Same
+ # character set identity._setting_https_url blocks, for the same reason.
+ if out.get("link_url") is not None:
+ v = str(out["link_url"])
+ if not v.startswith("https://") or any(c in v for c in " \"'<>"):
+ raise NearbyRejected(422, "link_url must be a plain https:// URL")
+ if out.get("contact") is not None:
+ v = str(out["contact"])
+ if "@" not in v or any(c in v for c in " \"'<>"):
+ raise NearbyRejected(422, "contact is rendered as a mailto: link and must be an email address")
+ if "sort_order" in out and out["sort_order"] is not None:
+ try:
+ out["sort_order"] = int(out["sort_order"])
+ except (TypeError, ValueError):
+ raise NearbyRejected(422, "sort_order must be an integer")
+ if "active" in out:
+ if out["active"] not in (0, 1, True, False):
+ raise NearbyRejected(422, "active must be 0 or 1")
+ out["active"] = int(out["active"])
+ if "verified_at" in out and out["verified_at"] is not None:
+ try:
+ datetime.date.fromisoformat(str(out["verified_at"]))
+ except ValueError:
+ raise NearbyRejected(422, "verified_at must be a plain YYYY-MM-DD date")
+ out["verified_at"] = str(out["verified_at"])
+ return out
+
+
+def _today():
+ return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
+
+
+def list_nearby(include_inactive=False):
+ con = connect()
+ try:
+ sql = "SELECT * FROM nearby_units"
+ if not include_inactive:
+ sql += " WHERE active = 1"
+ sql += " ORDER BY sort_order, unit_number"
+ return [dict(r) for r in con.execute(sql).fetchall()]
+ finally:
+ con.close()
+
+
+def get_nearby(nid):
+ con = connect()
+ try:
+ row = con.execute("SELECT * FROM nearby_units WHERE id=?", (nid,)).fetchone()
+ return dict(row) if row else None
+ finally:
+ con.close()
+
+
+def create_nearby(fields):
+ out = _clean_nearby(fields or {}, creating=True)
+ if not out.get("verified_at"):
+ out["verified_at"] = _today()
+ out.setdefault("active", 1)
+ out.setdefault("sort_order", 100)
+ nid = str(uuid.uuid4())
+ cols = ["id"] + list(out) + ["updated_at"]
+ vals = [nid] + [out[k] for k in out] + [_now()]
+ con = connect()
+ try:
+ con.execute("INSERT INTO nearby_units (%s) VALUES (%s)"
+ % (", ".join(cols), ", ".join("?" * len(cols))), vals)
+ con.commit()
+ finally:
+ con.close()
+ return get_nearby(nid)
+
+
+def update_nearby(nid, fields):
+ if not fields:
+ raise NearbyRejected(422, "nothing to update")
+ out = _clean_nearby(fields, creating=False)
+ if not out.get("verified_at"):
+ out["verified_at"] = _today()
+ out["updated_at"] = _now()
+ con = connect()
+ try:
+ cur = con.execute("UPDATE nearby_units SET %s WHERE id=?"
+ % ", ".join("%s=?" % k for k in out),
+ list(out.values()) + [nid])
+ con.commit()
+ if cur.rowcount == 0:
+ return None
+ finally:
+ con.close()
+ return get_nearby(nid)
+
+
+def deactivate_nearby(nid):
+ """Take a unit off the page. Sets active=0; the row and its history stay."""
+ con = connect()
+ try:
+ cur = con.execute(
+ "UPDATE nearby_units SET active=0, updated_at=? WHERE id=? AND active=1",
+ (_now(), nid))
+ con.commit()
+ return cur.rowcount > 0
+ finally:
+ con.close()
diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py
new file mode 100644
index 0000000..fbd3052
--- /dev/null
+++ b/tests/smoke_admin.py
@@ -0,0 +1,163 @@
+"""
+smoke_admin.py - the P2 admin write paths against a throwaway DB.
+
+Runs in-process with no container, no network and no dependencies beyond the
+stdlib, so it can be run before anything is committed.
+
+ STORE_DB=/tmp/x.db python3 tests/smoke_admin.py
+
+It covers the rules that are expensive to get wrong and invisible when they
+are: nearby rows bump verified_at on every save and deactivate rather than
+delete, unit edits are meeting fields only and unit-scoped, and settings
+accept only registered keys and fall back to the code default on anything
+absent or mangled.
+"""
+
+import os, sys, tempfile
+
+DB = os.environ.get("STORE_DB") or os.path.join(tempfile.mkdtemp(), "smoke.db")
+os.environ["STORE_DB"] = DB
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app"))
+
+import identity as I
+import nearby as N
+import store as S
+
+PASS = FAIL = 0
+
+
+def check(label, cond):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(" ok %s" % label)
+ else:
+ FAIL += 1
+ print(" FAIL %s" % label)
+
+
+def raises(label, status, exc, fn, *a, **kw):
+ try:
+ fn(*a, **kw)
+ except exc as e:
+ check("%s -> %d" % (label, status), e.status == status)
+ return
+ except Exception as e:
+ check("%s -> %d (got %r)" % (label, status, e), False)
+ return
+ check("%s -> %d (no error raised)" % (label, status), False)
+
+
+print("db: %s\n" % DB)
+S.init()
+I.init()
+
+print("nearby: writes are validated")
+raises("bad unit_type", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "trop", "unit_number": "1"})
+raises("missing unit_number", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack"})
+raises("bad serves", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1", "serves": "everyone"})
+raises("http link", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1", "link_url": "http://x.test"})
+raises("link with an attribute breakout", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1",
+ "link_url": 'https://x.test" onmouseover="alert(1)'})
+raises("contact without @", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1", "contact": "call Steve"})
+raises("contact with an attribute breakout", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1",
+ "contact": 'a@b.test" onfocus="alert(1)'})
+check("renderer escapes quotes as a second line", N._esc('a"b') == "a"b")
+raises("unknown field rejected, not dropped", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1", "unit_typo": "pack"})
+raises("verified_at must be a date", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1", "verified_at": "yesterday"})
+raises("sort_order must be an int", 422, S.NearbyRejected, S.create_nearby,
+ {"unit_type": "pack", "unit_number": "1", "sort_order": "soon"})
+
+print("\nnearby: create, update, verified_at")
+a = S.create_nearby({"unit_type": "pack", "unit_number": "244", "town": "Harleysville",
+ "area": "North Penn", "serves": "family"})
+check("row created", a and a["unit_number"] == "244")
+check("active and sort_order defaulted", a["active"] == 1 and a["sort_order"] == 100)
+check("verified_at defaults to today", a["verified_at"] == S._today())
+
+b = S.create_nearby({"unit_type": "troop", "unit_number": "27", "area": "North Penn",
+ "verified_at": "2026-08-01"})
+check("explicit verified_at kept", b["verified_at"] == "2026-08-01")
+
+b2 = S.update_nearby(b["id"], {"town": "Lansdale"})
+check("partial update lands", b2["town"] == "Lansdale" and b2["unit_number"] == "27")
+check("saving bumps verified_at", b2["verified_at"] == S._today())
+b3 = S.update_nearby(b["id"], {"notes": "meets in the annexe", "verified_at": "2026-08-15"})
+check("explicit verified_at wins on update", b3["verified_at"] == "2026-08-15")
+check("update of missing row is None", S.update_nearby("nope", {"town": "x"}) is None)
+raises("empty update", 422, S.NearbyRejected, S.update_nearby, b["id"], {})
+
+print("\nnearby: deactivate, never delete")
+check("deactivate", S.deactivate_nearby(a["id"]))
+check("second deactivate is a no-op", not S.deactivate_nearby(a["id"]))
+check("row survives", S.get_nearby(a["id"])["active"] == 0)
+check("default list hides it", all(r["id"] != a["id"] for r in S.list_nearby()))
+check("include_inactive shows it",
+ any(r["id"] == a["id"] for r in S.list_nearby(include_inactive=True)))
+back = S.update_nearby(a["id"], {"active": 1})
+check("reactivate via update", back["active"] == 1)
+groups = N.listing()
+check("public page groups the live rows",
+ len(groups) == 1 and groups[0][0] == "North Penn" and len(groups[0][1]) == 2)
+
+print("\nunits: meeting fields only, structured")
+pack = I.get_unit("pack73")
+check("seed row present", pack and pack["meets_weekday"] == 2)
+u = I.update_unit_meets("pack73", {"meets_time": "18:30"})
+check("time updated", u["meets_time"] == "18:30")
+u = I.update_unit_meets(pack["id"], {"meets_weekday": 4, "meets_at": None})
+check("update by id, null allowed", u["meets_weekday"] == 4 and u["meets_at"] is None)
+check("other fields untouched", u["display_name"] == pack["display_name"])
+raises("weekday 8", 422, I.IdentityError, I.update_unit_meets, "pack73", {"meets_weekday": 8})
+raises("weekday as bool", 422, I.IdentityError, I.update_unit_meets, "pack73", {"meets_weekday": True})
+raises("display time string", 422, I.IdentityError, I.update_unit_meets, "pack73", {"meets_time": "7pm"})
+raises("renaming is not a meeting edit", 422, I.IdentityError, I.update_unit_meets,
+ "pack73", {"display_name": "Pack 99"})
+raises("unknown unit", 404, I.IdentityError, I.update_unit_meets, "pack99", {"meets_time": "18:00"})
+raises("nothing to update", 422, I.IdentityError, I.update_unit_meets, "pack73", {})
+
+print("\nsettings: typed keys, default fallback")
+check("default when unset",
+ I.get_setting("nearby_source_name") == "Continental District unit list")
+s = I.set_setting("nearby_source_url", "https://example.test/units", actor="a@example.test")
+check("set and read back", I.get_setting("nearby_source_url") == "https://example.test/units")
+check("set_setting reports itself", s["is_set"] and s["updated_by"] == "a@example.test")
+raises("unknown key", 422, I.IdentityError, I.set_setting, "nearby_src_url", "https://x.test")
+raises("blank value", 422, I.IdentityError, I.set_setting, "nearby_source_name", " ")
+raises("http url", 422, I.IdentityError, I.set_setting, "nearby_source_url", "http://x.test")
+cleared = I.set_setting("nearby_source_url", None)
+check("null clears to default", not cleared["is_set"]
+ and I.get_setting("nearby_source_url") == "https://tinyurl.com/ContinentalScouts")
+
+con = I.connect()
+con.execute("INSERT INTO settings (key, value, updated_at) VALUES (?,?,?)",
+ ("nearby_source_url", "ftp://mangled", I._now()))
+con.commit(); con.close()
+check("mangled row falls back to default",
+ I.get_setting("nearby_source_url") == "https://tinyurl.com/ContinentalScouts")
+check("all_settings covers the registry",
+ {s["key"] for s in I.all_settings()} == set(I.SETTINGS_KEYS))
+
+print("\ncapabilities behind the new routes")
+leader = {"memberships": [{"unit_id": "u1", "role": "leader"}]}
+member = {"memberships": [{"unit_id": "u1", "role": "member"}]}
+admin = {"global_role": "admin", "memberships": []}
+check("leader can edit nearby", I.can(leader, "nearby:write"))
+check("member cannot", not I.can(member, "nearby:write"))
+check("leader edits own unit", I.can(leader, "unit:write_own", "u1"))
+check("but not the other one", not I.can(leader, "unit:write_own", "u2"))
+check("leader cannot touch settings", not I.can(leader, "settings:write"))
+check("admin spans units", I.can(admin, "unit:write_own", "u1") and I.can(admin, "unit:write_own", "u2"))
+check("admin holds settings:write", I.can(admin, "settings:write"))
+
+print("\n%d passed, %d failed" % (PASS, FAIL))
+sys.exit(1 if FAIL else 0)