Merge branch 'master' into typosaurus/ticket-150
DevPlace CI / test (pull_request) Failing after 1h22m9s

This commit is contained in:
2026-08-02 00:25:01 +02:00
26 changed files with 1411 additions and 110 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ Prefixes are wired in `main.py`:
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
+23 -15
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push
from devplacepy.config import STATIC_DIR
from devplacepy.push import providers
from devplacepy.utils import require_user_api
from urllib.parse import urlparse
from devplacepy.services.audit import record as audit
@@ -22,7 +23,11 @@ WELCOME_PAYLOAD = {
@router.get("/push.json")
async def push_public_key() -> JSONResponse:
return JSONResponse({"publicKey": push.public_key_standard_b64()})
configs = providers.client_config()
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
return JSONResponse(
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
)
@router.post("/push.json")
@@ -33,21 +38,18 @@ async def push_register(request: Request) -> JSONResponse:
except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
keys = body.get("keys") if isinstance(body, dict) else None
if not (
isinstance(keys, dict)
and body.get("endpoint")
and keys.get("p256dh")
and keys.get("auth")
):
if not isinstance(body, dict):
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = await push.register(
user_uid=user["uid"],
endpoint=body["endpoint"],
key_auth=keys["auth"],
key_p256dh=keys["p256dh"],
)
provider = providers.get(body.get("provider"))
if provider is None or not providers.is_active(provider):
return JSONResponse({"error": "Unknown provider"}, status_code=400)
fields = provider.parse_registration(body)
if fields is None:
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = push.register(user["uid"], provider.name, fields)
if created:
try:
@@ -62,7 +64,13 @@ async def push_register(request: Request) -> JSONResponse:
target_type="user",
target_uid=user["uid"],
target_label=user.get("username"),
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created},
metadata={
"provider": provider.name,
"endpoint_host": urlparse(fields["endpoint"]).hostname
if fields.get("endpoint")
else None,
"created": created,
},
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
links=[audit.target("user", user["uid"], user.get("username"))],
)