Compare commits

..
Author SHA1 Message Date
retoor e02919a1db Merge pull request 'feat: Add badge earned info to user profile badges API response' (#159) from typosaurus/157-add-badge-earned-info-to-user-profile-badges-api-response into master
DevPlace CI / test (push) Failing after 1h7m13s
DevPlace CI / test (pull_request) Failing after 56m51s
Reviewed-on: #159
2026-08-05 02:21:36 +02:00
typosaurus 3a0f682052 test(sveta): Add profile badges description tests to the API tier
DevPlace CI / test (pull_request) Failing after 1h7m34s
Outcome: done
Changed: tests/api/profile/index.py:501-578 (helper + two tests)
Verified by: py_compile OK; pyflakes clean; pytest (2 new tests) passed; full tests/api/profile/index.py + tests/api/profile/search.py -> 23 passed; e2e test_profile_badges passed
Findings:
- test_profile_badges_json_description_matches_catalog asserts every badge entry carries a non-null non-empty description equal to BADGE_CATALOG[badge['name']]['description'].
- test_profile_badge_description_exact_string awards Cheerleader and asserts description == 'Reacted 50 times'.
Open: none
Confidence: high - new tests pass against committed implementation
2026-08-04 17:06:54 +00:00
typosaurus 68c403747c test(sveta): Extend the profile badges JSON test with description assertions
Outcome: done
Changed: tests/api/profile/search.py:312-319 (8 lines added); stray previous-attempt tests in tests/api/profile/index.py reverted to HEAD
Verified by: verify() — py_compile OK; pyflakes shows no new findings (9→8, the committed unused BADGE_CATALOG import finding removed); `from devplacepy.main import app` imports clean; pytest tests/api/profile/search.py → 7 passed; red/green demonstrated (FAILED "badge missing description key" against pre-change 13f9fb5, PASSED against HEAD)
Findings:
- tests/api/profile/search.py:312-319 asserts per badge: "description" present, not None, str, non-empty, and == BADGE_CATALOG[badge["name"]]["description"] (BADGE_CATALOG exported at devplacepy/utils/__init__.py:102).
- Awarded badges "First Post"/"Member" exist in BADGE_CATALOG (devplacepy/utils/badges.py:17-18); award_badge inserts only the named badge (badges.py:139-151), so the lookup cannot KeyError.
- Previous attempt's duplicate tests in tests/api/profile/index.py removed; the badge JSON test lives only in tests/api/profile/search.py:276.
- HEAD f72f2ed already carried the implementation (index.py:208, content.py:71) and the unused BADGE_CATALOG import; the addition makes it used.
Open: full `make test` (e2e tier) still requires Python >=3.12; workspace runs 3.11.2 (same limitation as sibling). API tier + import pass here.
Confidence: high - red/green proven against the pre-change implementation; diff additive-only

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: b827a89016b54505832d8529fbe887cd
Typosaurus-Agent: @sveta
Refs: #157
2026-08-04 16:55:58 +00:00
typosaurus f72f2edf6b feat(nadia): Add earned-by description to the profile badges API response
Outcome: done
Changed: devplacepy/routers/profile/index.py:205-208; devplacepy/schemas/content.py:71
Verified by: `python -c "from devplacepy.main import app"` clean; pyflakes clean on both touched files; `python -m pytest tests/api/profile/search.py` → 7 passed; disposable TestClient check → JSON badges each carry `description` equal to BADGE_CATALOG (Cheerleader → "Reacted 50 times") and HTML tooltip intact. Full `make test` not runnable here: only Python 3.11 installed, project requires >=3.12; CI runs the full suite.
Findings: index.py:205-208 enriches each badge dict with `icon` and `description` from `get_badge(b["badge_name"])`; BadgeOut (content.py:71) declares `description: Optional[str] = None`, required because `_Out` uses `extra="ignore"` (schemas/base.py:7). BadgeOut feeds only ProfileOut.badges (schemas/profile.py:32). profile.html:55 tooltips read only `badge_name` from the dict, so HTML is unchanged. tests/api/profile/awards_tab.py:81 fails on base state too (patch round-trip) — pre-existing, unrelated.
Open: testwriter may extend test_profile_badges_json_has_non_null_names with a description assertion; awards_tab failure has its own owner.
Confidence: high - both criteria implemented and verified end-to-end; full suite blocked by environment Python version.

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: 974d049e1b1e4a01923bdf9f583d07cd
Typosaurus-Agent: @nadia
Refs: #157
2026-08-04 16:40:19 +00:00
typosaurus 4bd420f38b feat(nadia): Add description to the badge dict and declare it on BadgeOut
Outcome: done
Changed: devplacepy/routers/profile/index.py:204-208, devplacepy/schemas/content.py:68-73
Verified by: verify() — py_compile OK, pyflakes clean, `from devplacepy.main import app` imports clean, pytest tests/api/profile/index.py tests/unit/utils.py → 42 passed
Findings:
- Badge loop (devplacepy/routers/profile/index.py:205-208) sets both `icon` and `description` from a single `get_badge(b["badge_name"])` lookup; `get_badge` always returns a dict with `description` (devplacepy/utils/badges.py:105-108).
- `BadgeOut` (devplacepy/schemas/content.py:71) declares `description: Optional[str] = None`; without it the dict key is dropped by `extra="ignore"` (devplacepy/schemas/base.py:10-11). BadgeOut is consumed only by ProfileOut (devplacepy/schemas/profile.py:32).
- Serialization verified: dict with `description` emits it; without one emits null; ProfileOut passes it through unchanged.
- Environment: workspace Python is 3.11.2, pyproject requires >=3.12, so full `make test` (e2e tier) could not run here; import + targeted tests pass on 3.11.
- 25 pre-existing tests/unit failures (e.g. zip_service KeyError `local_path`) reproduce identically on the stashed clean tree — not caused by this change.
- Direct pytest writes `__pycache__` (make exports PYTHONDONTWRITEBYTECODE=1); after a byte-level edit this caused a transient `cannot import name 'AttachmentOut'` in the uvicorn subprocess, gone after removing `__pycache__` — run tests via make targets.
Open: test extension asse

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: 527d1bad2be4464b886a71af0247378e
Typosaurus-Agent: @nadia
Refs: #157
2026-08-04 16:38:35 +00:00
retoor 13f9fb5a96 Merge pull request 'Fix #150: Show linked project on post details page and expose in API' (#151) from typosaurus/ticket-150 into master
DevPlace CI / test (push) Failing after 1h22m2s
Reviewed-on: #151
2026-08-02 00:25:48 +02:00
retoor 0128aad7b5 Merge branch 'master' into typosaurus/ticket-150
DevPlace CI / test (pull_request) Failing after 1h22m9s
2026-08-02 00:25:01 +02:00
retoorandClaude Opus 5 0ec3e61118 Move image pixel reads to get_flattened_data and add the font libraries
DevPlace CI / test (push) Failing after 1h5m59s
Pillow 12 renames Image.getdata to get_flattened_data; the award image
normaliser and the isslop hue histogram both read pixels that way. The image
stack also needs pango, harfbuzz, fontconfig and a base font in the container,
so text rendering has glyphs to work with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:50:54 +02:00
retoorandClaude Opus 5 a78b656ef9 Document the push providers in the README and the audit catalogue
README covers the provider model, the two providers and their transports, the
admin configuration surface at /admin/services/push, the delivery loop and the
updated file map. events.md records that push.subscribe and push.update now
carry the provider in their metadata, with endpoint_host set only for
endpoint-based providers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:50:54 +02:00
retoorandClaude Opus 5 7674dac628 Cover the push providers with tests and document the subsystem
Unit tests for the provider registry, both providers' registration parsing, the
APNs payload translation, provider token signing and caching, header and status
mapping against a mock transport, provider grouping and the delivery timeout
clamp, plus service tests for the configuration surface, the retention sweep and
the per-provider metrics. Api tests cover the provider listing on GET
/push.json, registration with and without an explicit provider, idempotency and
the rejection of an unknown or unconfigured provider.

Provider settings in unit tests are supplied by monkeypatching the provider's
setting reader rather than writing site_settings, because the unit tier shares
its database with the running api-tier server.

devplacepy/push/CLAUDE.md documents the protocol, how to add a provider, the
invariants and the APNs specifics; the root, routers and services files point at
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:47:20 +02:00
retoorandClaude Opus 5 53ddf4f233 Add push provider architecture with Apple Push Notification support
Split the push delivery library into a provider architecture. devplacepy/push
becomes a package: a PushProvider protocol with a registry, the existing Web
Push implementation moved unchanged behind it, a new APNs provider, a store
owning every push_registration access, and a delivery loop that groups a user's
subscriptions by provider, prepares each provider's payload once and sends over
a single shared client.

APNs delivers over HTTP/2 with an ES256 provider token cached per credential
fingerprint, so a worker signs at most one token per 45 minutes. Registrations
carry a hexadecimal device token; 410 and the Unregistered class of reasons soft
delete the subscription exactly like a gone Web Push endpoint.

All provider configuration is edited at /admin/services/push through the same
ConfigField surface every other subsystem uses, assembled from the registry so a
future provider needs no edit to the service. A provider that is disabled,
unconfigured or holding an unusable credential accepts no registrations and is
skipped during delivery, never failing the other providers.

POST /push.json accepts a registration for any active provider; a body without a
provider field is a Web Push body, so existing clients are unchanged. GET
/push.json keeps publicKey at the top level and adds the active providers.
push_registration gains provider and token columns, ensured in init_db with a
converging backfill; existing rows are never rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:43:10 +02:00
Typosaurus f44e3d1db8 ticket #150 attempt 2
DevPlace CI / test (pull_request) Failing after 55m28s
2026-07-28 13:33:07 +00:00
Typosaurus 9d14149f62 ticket #150 attempt 1 2026-07-28 13:18:20 +00:00
53 changed files with 1694 additions and 710 deletions
+1
View File
@@ -148,6 +148,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet | | `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet |
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API | | `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus | | `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
| `devplacepy/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) | | `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) | | `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` | | `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
+2
View File
@@ -4,6 +4,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates \ curl ca-certificates \
libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Optional: the docker CLI so the (admin-only) container manager can drive the host # Optional: the docker CLI so the (admin-only) container manager can drive the host
+46 -7
View File
@@ -15,6 +15,12 @@ make test-headed # same tests in visible browser
Open `http://localhost:10500`. Open `http://localhost:10500`.
PDF export (DeepSearch reports, via weasyprint) needs the Pango text stack installed at system level. The production image installs it; on a development host install it once:
```bash
sudo apt-get install -y libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 fonts-dejavu-core
```
## Stack ## Stack
| Layer | Technology | | Layer | Technology |
@@ -40,7 +46,7 @@ devplacepy/
avatar.py # Multiavatar generation, URL builder avatar.py # Multiavatar generation, URL builder
utils/ # Password hashing, session mgmt, time_ago, notification hook (package) utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
models.py # Pydantic schemas models.py # Pydantic schemas
push.py # Web push crypto, VAPID keys, encrypt/send/register push/ # Push delivery: provider protocol, Web Push, APNs, registrations
routers/ # One file per domain (auth, feed, posts, push, ...) routers/ # One file per domain (auth, feed, posts, push, ...)
templates/ # Jinja2 HTML templates templates/ # Jinja2 HTML templates
static/css/ # Page-specific CSS files static/css/ # Page-specific CSS files
@@ -811,9 +817,37 @@ calling itself.
## Push notifications & PWA ## Push notifications & PWA
Authenticated users can receive native web push notifications, and the site is an Authenticated users can receive native push notifications, and the site is an
installable Progressive Web App. Push uses only standard libraries (`cryptography`, installable Progressive Web App. Push uses only standard libraries (`cryptography`,
`PyJWT`, `httpx`) against the Web Push Protocol - no third-party push wrapper. `PyJWT`, `httpx`) against the Web Push Protocol and the Apple Push Notification service -
no third-party push wrapper.
### Providers
Delivery is split into providers behind one protocol (`devplacepy/push/providers/`). A user
receives a notification through every provider they hold a live subscription for.
| Provider | Registration | Transport |
|----------|--------------|-----------|
| `webpush` | `PushSubscription` from the browser `PushManager` (endpoint + `p256dh`/`auth` keys) | Web Push Protocol, VAPID signed, `aesgcm` encrypted payload |
| `apns` | Hexadecimal device token | `POST https://api.push.apple.com/3/device/{token}` over HTTP/2, ES256 provider token |
`POST /push.json` accepts a registration for any active provider; a body without a
`provider` field is a `webpush` body, so browsers need no change. `GET /push.json` returns
the VAPID public key plus the providers currently accepting registrations. A provider that
is disabled or not fully configured accepts no registrations and is skipped during
delivery, so an unconfigured provider is inert rather than an error.
Every provider setting is edited at **`/admin/services/push`**: per provider an `Enabled`
toggle, the VAPID subject for `webpush`, and team id, key id, `.p8` auth key (stored as a
masked secret), topic and environment (production or sandbox) for `apns`. The same page
holds the shared delivery timeout and the retention window after which dead subscriptions
are removed. Push delivery does not depend on that service running; stopping it only stops
the pruning sweep.
Adding a third provider is one file plus one registry entry: the registration route, the
delivery loop, the admin page, the audit record and the metrics are all written against the
provider protocol.
### Events ### Events
@@ -836,9 +870,11 @@ in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
`create_notification` schedules delivery as a fire-and-forget async task, so a dead `create_notification` schedules delivery as a fire-and-forget async task, so a dead
subscription or push-service error never blocks the triggering request. Delivery subscription or push-service error never blocks the triggering request. Delivery
(`push.notify_user`) iterates a user's subscriptions, encrypts the payload (`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that each provider's payload once, and sends over a single shared HTTP client. A subscription
return `404`/`410` are soft-deleted. the push service reports as gone (`404`/`410` for Web Push, `410` or an `Unregistered`
class reason for APNs) is soft-deleted; any other failure is logged and the subscription is
kept.
A notification is also **marked read automatically when you open the page that shows its A notification is also **marked read automatically when you open the page that shows its
content** - viewing a post clears its comment, reply, upvote and mention notifications; content** - viewing a post clears its comment, reply, upvote and mention notifications;
@@ -895,7 +931,10 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
| File | Role | | File | Role |
|------|------| |------|------|
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register | | `devplacepy/push/providers/` | Provider protocol, Web Push (VAPID keys, payload encryption), APNs |
| `devplacepy/push/store.py` | `push_registration` reads and writes |
| `devplacepy/push/delivery.py` | `notify_user` - group by provider, deliver, reap dead subscriptions |
| `devplacepy/services/push/service.py` | Provider configuration at `/admin/services/push`, retention sweep, metrics |
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` | | `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI | | `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
| `static/service-worker.js` | Receives push, shows notification, offline fallback | | `static/service-worker.js` | Receives push, shows notification, offline fallback |
+1 -1
View File
@@ -12,7 +12,7 @@ def enforce_rgba_png(file_bytes: bytes) -> bytes:
corner = img.getpixel((0, 0)) corner = img.getpixel((0, 0))
if len(corner) == 4 and corner[3] == 255: if len(corner) == 4 and corner[3] == 255:
bg = corner[:3] bg = corner[:3]
data = img.getdata() data = img.get_flattened_data()
cleaned = [] cleaned = []
for pixel in data: for pixel in data:
if pixel[:3] == bg: if pixel[:3] == bg:
-19
View File
@@ -27,18 +27,6 @@ def cmd_game_steals_prune(args):
print(f"Pruned {removed} raid record(s)") print(f"Pruned {removed} raid record(s)")
def cmd_game_failed_steals_prune(args):
from devplacepy.services.game import store
removed = store.prune_failed_steals()
_audit_cli(
"cli.game.failed_steals.prune",
f"CLI pruned {removed} old Code Farm failed raid record(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} failed raid record(s)")
def cmd_game_era_status(args): def cmd_game_era_status(args):
from devplacepy.services.game import store from devplacepy.services.game import store
@@ -101,13 +89,6 @@ def register_game(subparsers):
) )
steals_prune.set_defaults(func=cmd_game_steals_prune) steals_prune.set_defaults(func=cmd_game_steals_prune)
failed = game_sub.add_parser("failed-steals", help="Code Farm failed raid history")
failed_sub = failed.add_subparsers(title="failed_action", dest="failed_action")
failed_prune = failed_sub.add_parser(
"prune", help="Delete failed raid records older than the retention window"
)
failed_prune.set_defaults(func=cmd_game_failed_steals_prune)
era = game_sub.add_parser("era", help="Code Farm Era management") era = game_sub.add_parser("era", help="Code Farm Era management")
era_sub = era.add_subparsers(title="era_action", dest="era_action") era_sub = era.add_subparsers(title="era_action", dest="era_action")
era_status = era_sub.add_parser("status", help="Show the current Era status") era_status = era_sub.add_parser("status", help="Show the current Era status")
+19
View File
@@ -60,6 +60,21 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_project_by_uid(project_uid: str | None) -> dict | None:
if not project_uid:
return None
project = get_table("projects").find_one(uid=project_uid)
if not project:
return None
slug = project.get("slug") or project["uid"]
return {
"uid": project["uid"],
"name": project.get("title") or project.get("name", ""),
"slug": slug,
"url": f"/projects/{slug}",
}
def is_owner(item: dict | None, user: dict | None) -> bool: def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"]) return bool(item and user and item["user_uid"] == user["uid"])
@@ -512,6 +527,7 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}), "reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False), "bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"), "poll": detail.get("poll"),
"project_link": detail.get("project_link"),
} }
if extra: if extra:
context.update(extra) context.update(extra)
@@ -691,6 +707,7 @@ def load_detail(
"reactions": reactions, "reactions": reactions,
"bookmarked": bookmarked, "bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None, "poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
} }
@@ -718,6 +735,8 @@ def enrich_items(
entry[name] = ( entry[name] = (
source(item) if callable(source) else source.get(item["uid"], 0) source(item) if callable(source) else source.get(item["uid"], 0)
) )
if key == "post" and item.get("project_uid"):
entry["project_link"] = get_project_by_uid(item["project_uid"])
enriched.append(entry) enriched.append(entry)
return enriched return enriched
+24 -37
View File
@@ -134,7 +134,28 @@ def init_db():
) )
_index(db, "notifications", "idx_notifications_user", ["user_uid"]) _index(db, "notifications", "idx_notifications_user", ["user_uid"])
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"]) _index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
push_registration = get_table("push_registration")
for column, example in (
("uid", ""),
("user_uid", ""),
("provider", "webpush"),
("endpoint", ""),
("key_auth", ""),
("key_p256dh", ""),
("token", ""),
("created_at", ""),
("deleted_at", ""),
):
if not push_registration.has_column(column):
push_registration.create_column_by_example(column, example)
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"]) _index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
_index(db, "push_registration", "idx_push_registration_provider", ["provider"])
if "push_registration" in db.tables:
with db:
db.query(
"UPDATE push_registration SET provider = 'webpush' "
"WHERE provider IS NULL OR provider = ''"
)
_index(db, "sessions", "idx_sessions_token", ["session_token"]) _index(db, "sessions", "idx_sessions_token", ["session_token"])
projects = get_table("projects") projects = get_table("projects")
for column, example in ( for column, example in (
@@ -147,6 +168,9 @@ def init_db():
("is_private", 0), ("is_private", 0),
("read_only", 0), ("read_only", 0),
("updated_at", ""), ("updated_at", ""),
("title", ""),
("description", ""),
("status", ""),
): ):
if not projects.has_column(column): if not projects.has_column(column):
projects.create_column_by_example(column, example) projects.create_column_by_example(column, example)
@@ -1126,7 +1150,6 @@ def init_db():
("slot_index", 0), ("slot_index", 0),
("crop_key", ""), ("crop_key", ""),
("coins", 0), ("coins", 0),
("insurance_payout", 0),
("stolen_at", ""), ("stolen_at", ""),
("created_at", ""), ("created_at", ""),
): ):
@@ -1137,42 +1160,6 @@ def init_db():
) )
_index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"]) _index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"])
game_farm_defense_items = get_table("game_farm_defense_items")
for column, example in (
("uid", ""),
("farm_uid", ""),
("item_type", ""),
("purchased_at", ""),
):
if not game_farm_defense_items.has_column(column):
game_farm_defense_items.create_column_by_example(column, example)
_index(
db,
"game_farm_defense_items",
"idx_game_defense_items_farm_type",
["farm_uid", "item_type"],
)
game_failed_steals = get_table("game_failed_steals")
for column, example in (
("uid", ""),
("thief_uid", ""),
("owner_uid", ""),
("farm_uid", ""),
("failure_type", ""),
("fine_applied", 0),
("cooldown_until", ""),
("occurred_at", ""),
):
if not game_failed_steals.has_column(column):
game_failed_steals.create_column_by_example(column, example)
_index(
db,
"game_failed_steals",
"idx_game_failed_steals_thief",
["thief_uid", "occurred_at"],
)
game_quests = get_table("game_quests") game_quests = get_table("game_quests")
for column, example in ( for column, example in (
("uid", ""), ("uid", ""),
+38 -10
View File
@@ -8,8 +8,15 @@ GROUP = {
"intro": """ "intro": """
# Web Push # Web Push
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then Push notifications are delivered by one or more providers. `webpush` is the default and
register a `PushSubscription` obtained from the browser's `PushManager`. implements the Web Push protocol: fetch the public VAPID key, then register a
`PushSubscription` obtained from the browser's `PushManager`. `apns` delivers to an Apple
Push Notification service device token and is only offered when an administrator has
configured it.
`GET /push.json` lists the providers that currently accept registrations. A registration
body without a `provider` field is a `webpush` registration, so existing clients need no
change.
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
@@ -26,39 +33,60 @@ four ways to sign requests.
method="GET", method="GET",
path="/push.json", path="/push.json",
title="Get the public key", title="Get the public key",
summary="Return the VAPID public key for subscribing.", summary="Return the VAPID public key and the providers that accept registrations.",
auth="public", auth="public",
sample_response={"publicKey": "BASE64_VAPID_KEY"}, sample_response={
"publicKey": "BASE64_VAPID_KEY",
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
},
), ),
endpoint( endpoint(
id="push-register", id="push-register",
method="POST", method="POST",
path="/push.json", path="/push.json",
title="Register a subscription", title="Register a subscription",
summary="Register a browser push subscription. Sends a welcome notification.", summary="Register a push subscription. Sends a welcome notification.",
auth="user", auth="user",
encoding="json", encoding="json",
interactive=False, interactive=False,
params=[ params=[
field(
"provider",
"json",
"string",
False,
"webpush",
"Provider to register with. Omit for webpush.",
),
field( field(
"endpoint", "endpoint",
"json", "json",
"string", "string",
True, False,
"https://fcm.googleapis.com/...", "https://fcm.googleapis.com/...",
"Subscription endpoint URL.", "Subscription endpoint URL. Required for webpush.",
), ),
field( field(
"keys", "keys",
"json", "json",
"string", "string",
True, False,
'{"p256dh":"...","auth":"..."}', '{"p256dh":"...","auth":"..."}',
"Subscription keys object.", "Subscription keys object. Required for webpush.",
),
field(
"token",
"json",
"string",
False,
"a1b2c3...",
"Hexadecimal device token. Required for apns.",
), ),
], ],
notes=[ notes=[
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.' 'A webpush body is JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.',
'An APNs body is JSON: `{"provider": "apns", "token": "..."}`.',
"A provider that is unknown, disabled or unconfigured returns 400.",
], ],
sample_response={"registered": True}, sample_response={"registered": True},
), ),
+2
View File
@@ -115,6 +115,7 @@ from devplacepy.services.containers.service import ContainerService
from devplacepy.services.xmlrpc import XmlrpcService from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService from devplacepy.services.audit import AuditService
from devplacepy.services.audit import record as audit from devplacepy.services.audit import record as audit
from devplacepy.services.push import PushService
from devplacepy.services.telegram import TelegramService from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService from devplacepy.services.telegram.outbox_service import TelegramOutboxService
@@ -275,6 +276,7 @@ async def lifespan(app: FastAPI):
service_manager.register(ContainerService()) service_manager.register(ContainerService())
service_manager.register(XmlrpcService()) service_manager.register(XmlrpcService())
service_manager.register(AuditService()) service_manager.register(AuditService())
service_manager.register(PushService())
service_manager.register(TelegramService()) service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService()) service_manager.register(TelegramOutboxService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"): if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
-5
View File
@@ -635,11 +635,6 @@ class GameMasteryForm(BaseModel):
key: str = Field(min_length=1, max_length=40) key: str = Field(min_length=1, max_length=40)
class GameDefenseItemForm(BaseModel):
farm_uid: str = Field(min_length=1, max_length=64)
item_type: str = Field(min_length=3, max_length=20)
class GameEraStartForm(BaseModel): class GameEraStartForm(BaseModel):
name: str = Field(min_length=1, max_length=60) name: str = Field(min_length=1, max_length=60)
duration_days: int = Field(default=28, ge=1, le=180) duration_days: int = Field(default=28, ge=1, le=180)
+52
View File
@@ -0,0 +1,52 @@
This file documents `devplacepy/push/` - push notification delivery and its provider architecture. Claude Code loads it automatically whenever a file under this directory is read or edited.
## What this package is
One delivery library behind one public surface. `devplacepy.push` exports `notify_user`, `register`, `ensure_certificates`, `public_key_standard_b64` and the Web Push crypto helpers; every caller in the codebase (`main.py`, `utils/notifications.py`, `routers/push.py`) imports only those names. Everything else is internal to the package.
| Module | Role |
|---|---|
| `providers/base.py` | `PushProvider` protocol, the `Delivery` outcome and the three outcome constants |
| `providers/webpush.py` | VAPID key material, `aesgcm` payload encryption, the Web Push provider |
| `providers/apns.py` | Apple Push Notification service provider (token based, HTTP/2) |
| `providers/__init__.py` | `PROVIDERS` registry, `get`, `active`, `is_active`, `admin_fields`, `client_config` |
| `store.py` | Every `push_registration` read and write |
| `delivery.py` | `notify_user`: group by provider, one shared client, one prepared body per provider |
The admin configuration surface lives in `devplacepy/services/push/service.py` (`PushService`), not here.
## Adding a provider
1. Write `providers/<name>.py` with a `PushProvider` subclass: `name`, `label`, `config_fields`, `is_configured`, `parse_registration`, `prepare`, `deliver`, optionally `client_config`.
2. Add one entry to `PROVIDERS` in `providers/__init__.py`.
That is the whole change. The registration route, the delivery loop, the admin page, the audit record, the metrics and the docs are written against the protocol and need no edit. The `Enabled` toggle (`push_<name>_enabled`) comes from the base class, so a provider never declares its own.
## Invariants
- **Zero cost for the request.** Delivery is reached only through `utils/notifications.py` `_schedule_push`, a fire-and-forget task. Never make a route await `notify_user`, and never add a queue or a table to this path.
- **`deliver` never raises.** Return `Delivery(REJECTED, detail)` instead. `delivery.py` guards anyway, but a raising provider costs a log line per subscription.
- **A provider that is not configured is inert, never an error.** `is_configured()` is false, `is_active()` is false, the delivery loop skips it, and `POST /push.json` refuses a registration for it with 400. Nothing else in the platform notices.
- **`DEAD` is the only outcome that touches the database.** It soft-deletes the registration (`deleted_at`), exactly like a `404`/`410` Web Push endpoint always did. `REJECTED` keeps the row.
- **Every insert writes `deleted_at: None`,** and every read filters `deleted_at IS NULL`. `push_registration` deliberately stays out of `SOFT_DELETE_TABLES` (no `deleted_by`, not restorable from Trash) - a dead device token has no owner action to undo.
- **A row without a provider is a Web Push row.** `store.provider_of` resolves `None`/`""` to `DEFAULT_PROVIDER`, so a row written by an old worker during a deploy still delivers. `init_db` backfills the column once with a single converging `UPDATE`.
## Storage
`push_registration` columns are ensured in `init_db` (`database/schema.py`) because `dataset` only creates the columns of a table's first insert, and `find(provider=...)` against a missing column matches nothing.
| Column | webpush | apns |
|---|---|---|
| `provider` | `webpush` | `apns` |
| `endpoint`, `key_auth`, `key_p256dh` | set | `NULL` |
| `token` | `NULL` | device token |
Deduplication is generic: `store.register` looks up `user_uid` + `provider` + exactly the fields the provider's `parse_registration` returned, so a provider never writes its own identity rule.
## APNs specifics
- `POST https://{host}/3/device/{token}` over HTTP/2, host from `push_apns_environment` (`api.push.apple.com` or `api.sandbox.push.apple.com`; an unrecognised value falls back to production). HTTP/2 comes from `stealth_async_client` because the origin is `https` - the cleartext downgrade in `curl_transport` does not apply.
- Provider token: `ES256`, header `kid` = key id, claims `iss` = team id and `iat`. Cached per credential fingerprint for 45 minutes, so a worker signs at most one token per 45 minutes; Apple refuses tokens regenerated faster than every 20 minutes. Editing any credential changes the fingerprint and takes effect on the next delivery, with no restart.
- A `.p8` that does not parse is cached as a failure for the same window, so a misconfiguration costs one error log per window rather than one parse per notification.
- `410`, or any status carrying reason `BadDeviceToken`, `Unregistered`, `ExpiredToken`, `DeviceTokenNotForTopic` or `TopicDisallowed`, is `DEAD`. Everything else is `REJECTED`.
- The shared payload dict (`title`, `message`, `icon`, `url`) is translated once per batch into `aps.alert` plus the custom `url`/`icon` keys, mirroring what `service-worker.js` does for Web Push. `thread-id` mirrors the service worker's notification `tag`.
+29
View File
@@ -0,0 +1,29 @@
# retoor <retoor@molodetz.nl>
from devplacepy.push.delivery import notify_user
from devplacepy.push.providers.webpush import (
browser_base64,
create_notification_authorization,
create_notification_info_with_payload,
ensure_certificates,
generate_pkcs8_private_key,
generate_private_key,
generate_public_key,
hkdf,
public_key_standard_b64,
)
from devplacepy.push.store import register
__all__ = [
"browser_base64",
"create_notification_authorization",
"create_notification_info_with_payload",
"ensure_certificates",
"generate_pkcs8_private_key",
"generate_private_key",
"generate_public_key",
"hkdf",
"notify_user",
"public_key_standard_b64",
"register",
]
+83
View File
@@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Any
from devplacepy import stealth
from devplacepy.database import get_int_setting
from devplacepy.push import providers, store
logger = logging.getLogger(__name__)
TIMEOUT_KEY = "push_delivery_timeout_seconds"
DEFAULT_TIMEOUT_SECONDS = 10
MIN_TIMEOUT_SECONDS = 1
MAX_TIMEOUT_SECONDS = 120
def timeout_seconds() -> float:
seconds = get_int_setting(TIMEOUT_KEY, DEFAULT_TIMEOUT_SECONDS)
return float(min(max(seconds, MIN_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS))
def group_by_provider(
registrations: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
grouped: dict[str, list[dict[str, Any]]] = {}
for registration in registrations:
grouped.setdefault(store.provider_of(registration), []).append(registration)
return grouped
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
registrations = store.active_for_user(user_uid)
if not registrations:
logger.debug("No active push subscriptions for user %s", user_uid)
return
grouped = group_by_provider(registrations)
async with stealth.stealth_async_client(timeout=timeout_seconds()) as client:
for name, rows in grouped.items():
provider = providers.PROVIDERS.get(name)
if provider is None:
logger.warning(
"Unknown push provider %s on %s subscriptions of user %s",
name,
len(rows),
user_uid,
)
continue
if not providers.is_active(provider):
logger.debug(
"Push provider %s is not active; skipping %s subscriptions",
name,
len(rows),
)
continue
try:
prepared = provider.prepare(payload)
except Exception as exc:
logger.error("Push provider %s could not build a payload: %s", name, exc)
continue
for registration in rows:
await _deliver_one(provider, client, registration, prepared, user_uid)
async def _deliver_one(provider, client, registration, prepared, user_uid) -> None:
try:
outcome = await provider.deliver(client, registration, prepared)
except Exception as exc:
logger.error("Push provider %s raised for %s: %s", provider.name, user_uid, exc)
return
if outcome.status == providers.ACCEPTED:
logger.debug("Push delivered to %s via %s", user_uid, provider.name)
return
if outcome.status == providers.DEAD:
try:
store.mark_dead(registration["id"])
except Exception as exc:
logger.error("Could not soft-delete push subscription: %s", exc)
return
logger.warning(
"Push rejected by %s for %s: %s", provider.name, user_uid, outcome.detail
)
+76
View File
@@ -0,0 +1,76 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Any
from devplacepy.push.providers.apns import ApnsProvider
from devplacepy.push.providers.base import (
ACCEPTED,
DEAD,
REJECTED,
Delivery,
PushProvider,
)
from devplacepy.push.providers.webpush import WebPushProvider
logger = logging.getLogger(__name__)
DEFAULT_PROVIDER = WebPushProvider.name
PROVIDERS: dict[str, PushProvider] = {
provider.name: provider for provider in (WebPushProvider(), ApnsProvider())
}
__all__ = [
"ACCEPTED",
"DEAD",
"DEFAULT_PROVIDER",
"Delivery",
"PROVIDERS",
"PushProvider",
"REJECTED",
"active",
"admin_fields",
"client_config",
"get",
"is_active",
"names",
]
def get(name: str | None) -> PushProvider | None:
if not isinstance(name, str):
name = ""
return PROVIDERS.get(name.strip().lower() or DEFAULT_PROVIDER)
def names() -> list[str]:
return list(PROVIDERS)
def active() -> list[PushProvider]:
return [provider for provider in PROVIDERS.values() if is_active(provider)]
def admin_fields() -> list:
return [field for provider in PROVIDERS.values() for field in provider.all_fields()]
def client_config() -> dict[str, Any]:
return {provider.name: _client_config(provider) for provider in active()}
def is_active(provider: PushProvider) -> bool:
try:
return provider.is_active()
except Exception as exc:
logger.error("Push provider %s failed its readiness check: %s", provider.name, exc)
return False
def _client_config(provider: PushProvider) -> dict[str, Any]:
try:
return provider.client_config()
except Exception as exc:
logger.error("Push provider %s failed to describe itself: %s", provider.name, exc)
return {}
+243
View File
@@ -0,0 +1,243 @@
# retoor <retoor@molodetz.nl>
import hashlib
import json
import logging
import string
import time
from typing import Any
import httpx
import jwt
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_setting
from devplacepy.push.providers.base import (
ACCEPTED,
DEAD,
REJECTED,
Delivery,
PushProvider,
)
from devplacepy.services.base import ConfigField
from devplacepy.utils import DEFAULT_PUSH_URL, PUSH_ICON, generate_uid
logger = logging.getLogger(__name__)
TEAM_ID_KEY = "push_apns_team_id"
KEY_ID_KEY = "push_apns_key_id"
AUTH_KEY_KEY = "push_apns_auth_key"
TOPIC_KEY = "push_apns_topic"
ENVIRONMENT_KEY = "push_apns_environment"
PROVIDER_LABEL = "Apple Push (APNs)"
DEFAULT_ENVIRONMENT = "production"
HOSTS = {
"production": "api.push.apple.com",
"sandbox": "api.sandbox.push.apple.com",
}
ENVIRONMENT_OPTIONS = [
{"value": "production", "label": "Production"},
{"value": "sandbox", "label": "Sandbox"},
]
TOKEN_REFRESH_SECONDS = 45 * 60
TOKEN_MIN_LENGTH = 64
TOKEN_MAX_LENGTH = 200
THREAD_ID = "devplace-notification"
PUSH_TYPE = "alert"
PRIORITY = "10"
DEAD_REASONS = frozenset(
{
"BadDeviceToken",
"DeviceTokenNotForTopic",
"ExpiredToken",
"Unregistered",
"TopicDisallowed",
}
)
_token_state: dict[str, Any] = {}
def _setting(key: str) -> str:
return get_setting(key, "").strip()
def _environment() -> str:
value = _setting(ENVIRONMENT_KEY) or DEFAULT_ENVIRONMENT
return value if value in HOSTS else DEFAULT_ENVIRONMENT
def host() -> str:
return HOSTS[_environment()]
def _fingerprint(team_id: str, key_id: str, auth_key: str) -> str:
return hashlib.sha256(f"{team_id}:{key_id}:{auth_key}".encode("utf-8")).hexdigest()
def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
fingerprint = _fingerprint(team_id, key_id, auth_key)
issued_at = int(time.time())
state = _token_state.get("current")
if (
state
and state["fingerprint"] == fingerprint
and issued_at - state["issued_at"] < TOKEN_REFRESH_SECONDS
):
if state["token"] is None:
raise ValueError(state["error"])
return state["token"]
try:
token = jwt.encode(
{"iss": team_id, "iat": issued_at},
auth_key,
algorithm="ES256",
headers={"kid": key_id},
)
except Exception as exc:
message = f"APNs auth key is not usable: {exc}"
_token_state["current"] = {
"token": None,
"error": message,
"issued_at": issued_at,
"fingerprint": fingerprint,
}
logger.error(message)
raise ValueError(message) from exc
_token_state["current"] = {
"token": token,
"error": "",
"issued_at": issued_at,
"fingerprint": fingerprint,
}
return token
def _reason(response: httpx.Response) -> str:
try:
body = response.json()
except ValueError:
return ""
if isinstance(body, dict):
return str(body.get("reason", ""))
return ""
class ApnsProvider(PushProvider):
name = "apns"
label = PROVIDER_LABEL
config_fields = [
ConfigField(
TEAM_ID_KEY,
"Team ID",
type="str",
default="",
help="Ten character Apple Developer team identifier, used as the token iss claim.",
group=PROVIDER_LABEL,
),
ConfigField(
KEY_ID_KEY,
"Key ID",
type="str",
default="",
help="Ten character identifier of the APNs auth key, sent as the token kid header.",
group=PROVIDER_LABEL,
),
ConfigField(
AUTH_KEY_KEY,
"Auth key (.p8)",
type="text",
default="",
secret=True,
help="Contents of the APNs .p8 signing key, including the BEGIN and END lines. Leave blank to keep the stored key.",
group=PROVIDER_LABEL,
),
ConfigField(
TOPIC_KEY,
"Topic",
type="str",
default="",
help="Bundle identifier of the receiving app, sent as the apns-topic header.",
group=PROVIDER_LABEL,
),
ConfigField(
ENVIRONMENT_KEY,
"Environment",
type="select",
default=DEFAULT_ENVIRONMENT,
options=ENVIRONMENT_OPTIONS,
help="Production delivers to App Store builds, sandbox to development builds.",
group=PROVIDER_LABEL,
),
]
def is_configured(self) -> bool:
return bool(
_setting(TEAM_ID_KEY)
and _setting(KEY_ID_KEY)
and _setting(AUTH_KEY_KEY)
and _setting(TOPIC_KEY)
)
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
token = body.get("token")
if not isinstance(token, str):
return None
token = token.strip()
if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
return None
if any(character not in string.hexdigits for character in token):
return None
return {"token": token}
def prepare(self, payload: dict[str, Any]) -> str:
return json.dumps(
{
"aps": {
"alert": {
"title": payload.get("title") or "DevPlace",
"body": payload.get("message") or "",
},
"sound": "default",
"thread-id": THREAD_ID,
},
"url": payload.get("url") or DEFAULT_PUSH_URL,
"icon": payload.get("icon") or PUSH_ICON,
}
)
def headers(self) -> dict[str, str]:
return {
"authorization": f"bearer {provider_token(_setting(TEAM_ID_KEY), _setting(KEY_ID_KEY), _setting(AUTH_KEY_KEY))}",
"apns-topic": _setting(TOPIC_KEY),
"apns-push-type": PUSH_TYPE,
"apns-priority": PRIORITY,
"apns-expiration": str(int(time.time()) + SECONDS_PER_DAY),
"apns-id": generate_uid(),
"content-type": "application/json",
}
async def deliver(
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
) -> Delivery:
token = (registration.get("token") or "").strip()
if not token:
return Delivery(DEAD, "missing device token")
try:
headers = self.headers()
response = await client.post(
f"https://{host()}/3/device/{token}",
headers=headers,
content=prepared.encode("utf-8"),
)
except (httpx.HTTPError, ValueError) as exc:
return Delivery(REJECTED, str(exc))
if response.status_code == 200:
return Delivery(ACCEPTED)
reason = _reason(response)
detail = f"{response.status_code} {reason}".strip()
if response.status_code == 410 or reason in DEAD_REASONS:
return Delivery(DEAD, detail)
return Delivery(REJECTED, detail)
+66
View File
@@ -0,0 +1,66 @@
# retoor <retoor@molodetz.nl>
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import httpx
from devplacepy.database import get_setting
from devplacepy.services.base import ConfigField
ACCEPTED = "accepted"
DEAD = "dead"
REJECTED = "rejected"
@dataclass(frozen=True)
class Delivery:
status: str
detail: str = ""
class PushProvider(ABC):
name = ""
label = ""
config_fields: list[ConfigField] = []
@property
def enabled_key(self) -> str:
return f"push_{self.name}_enabled"
def enabled_field(self) -> ConfigField:
return ConfigField(
self.enabled_key,
"Enabled",
type="bool",
default=True,
help=f"Deliver notifications through {self.label}.",
group=self.label,
)
def all_fields(self) -> list[ConfigField]:
return [self.enabled_field(), *self.config_fields]
def is_enabled(self) -> bool:
return get_setting(self.enabled_key, "1") == "1"
def is_active(self) -> bool:
return self.is_enabled() and self.is_configured()
def client_config(self) -> dict[str, Any]:
return {}
@abstractmethod
def is_configured(self) -> bool: ...
@abstractmethod
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None: ...
@abstractmethod
def prepare(self, payload: dict[str, Any]) -> str: ...
@abstractmethod
async def deliver(
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
) -> Delivery: ...
@@ -7,7 +7,6 @@ import logging
import os import os
import random import random
import time import time
from datetime import datetime, timezone
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -20,7 +19,6 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from devplacepy import stealth
from devplacepy.config import ( from devplacepy.config import (
SECONDS_PER_DAY, SECONDS_PER_DAY,
VAPID_PRIVATE_KEY_FILE, VAPID_PRIVATE_KEY_FILE,
@@ -28,7 +26,15 @@ from devplacepy.config import (
VAPID_PUBLIC_KEY_FILE, VAPID_PUBLIC_KEY_FILE,
VAPID_SUB, VAPID_SUB,
) )
from devplacepy.database import get_table from devplacepy.database import get_setting
from devplacepy.push.providers.base import (
ACCEPTED,
DEAD,
REJECTED,
Delivery,
PushProvider,
)
from devplacepy.services.base import ConfigField
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,6 +43,8 @@ JWT_LIFETIME_SECONDS = 60 * 60
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY) PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
DEAD_SUBSCRIPTION_STATUSES = (404, 410) DEAD_SUBSCRIPTION_STATUSES = (404, 410)
ACCEPTED_STATUSES = (200, 201) ACCEPTED_STATUSES = (200, 201)
SUBJECT_KEY = "push_webpush_subject"
PROVIDER_LABEL = "Web Push (VAPID)"
def generate_private_key() -> None: def generate_private_key() -> None:
@@ -149,13 +157,17 @@ def public_key_standard_b64() -> str:
return base64.b64encode(point).decode("utf-8").rstrip("=") return base64.b64encode(point).decode("utf-8").rstrip("=")
def subject() -> str:
return get_setting(SUBJECT_KEY, "").strip() or VAPID_SUB
def create_notification_authorization(push_url: str) -> str: def create_notification_authorization(push_url: str) -> str:
target = urlparse(push_url) target = urlparse(push_url)
audience = f"{target.scheme}://{target.netloc}" audience = f"{target.scheme}://{target.netloc}"
issued_at = int(time.time()) issued_at = int(time.time())
return jwt.encode( return jwt.encode(
{ {
"sub": VAPID_SUB, "sub": subject(),
"aud": audience, "aud": audience,
"exp": issued_at + JWT_LIFETIME_SECONDS, "exp": issued_at + JWT_LIFETIME_SECONDS,
"nbf": issued_at, "nbf": issued_at,
@@ -223,78 +235,76 @@ def create_notification_info_with_payload(
} }
def _mark_subscription_dead(subscription_id: int) -> None: class WebPushProvider(PushProvider):
get_table("push_registration").update( name = "webpush"
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()}, label = PROVIDER_LABEL
["id"], config_fields = [
) ConfigField(
logger.info("Soft-deleted dead push subscription id=%s", subscription_id) SUBJECT_KEY,
"VAPID subject",
type="str",
default=VAPID_SUB,
help="Contact sent as the JWT sub claim, a mailto: or https: URL. Blank uses the built-in default.",
group=PROVIDER_LABEL,
)
]
def is_configured(self) -> bool:
return True
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None: def client_config(self) -> dict[str, Any]:
registrations = list( try:
get_table("push_registration").find(user_uid=user_uid, deleted_at=None) return {"publicKey": public_key_standard_b64()}
) except Exception as exc:
if not registrations: logger.error("VAPID key material unavailable: %s", exc)
logger.debug("No active push subscriptions for user %s", user_uid) return {}
return
body = json.dumps(payload) def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
async with stealth.stealth_async_client(timeout=10.0) as client: keys = body.get("keys")
for subscription in registrations: if not isinstance(keys, dict):
endpoint = subscription["endpoint"] return None
try: endpoint = body.get("endpoint")
notification_payload = create_notification_info_with_payload( key_auth = keys.get("auth")
endpoint, key_p256dh = keys.get("p256dh")
subscription["key_auth"], if not (
subscription["key_p256dh"], isinstance(endpoint, str)
body, and isinstance(key_auth, str)
) and isinstance(key_p256dh, str)
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS} and endpoint
response = await client.post( and key_auth
endpoint, headers=headers, content=notification_payload["data"] and key_p256dh
) ):
except (httpx.HTTPError, ValueError) as exc: return None
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc) return {
continue "endpoint": endpoint,
"key_auth": key_auth,
"key_p256dh": key_p256dh,
}
if response.status_code in ACCEPTED_STATUSES: def prepare(self, payload: dict[str, Any]) -> str:
logger.debug("Push delivered to %s via %s", user_uid, endpoint) return json.dumps(payload)
elif response.status_code in DEAD_SUBSCRIPTION_STATUSES:
_mark_subscription_dead(subscription["id"])
else:
logger.warning(
"Push rejected (%s) for %s via %s",
response.status_code,
user_uid,
endpoint,
)
async def deliver(
async def register( self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str ) -> Delivery:
) -> tuple[dict[str, Any], bool]: endpoint = registration.get("endpoint") or ""
table = get_table("push_registration") if not endpoint:
existing = table.find_one( return Delivery(DEAD, "missing endpoint")
user_uid=user_uid, try:
endpoint=endpoint, notification_payload = create_notification_info_with_payload(
key_auth=key_auth, endpoint,
key_p256dh=key_p256dh, registration["key_auth"],
deleted_at=None, registration["key_p256dh"],
) prepared,
if existing: )
logger.debug("Push subscription already registered for user %s", user_uid) headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
return existing, False response = await client.post(
endpoint, headers=headers, content=notification_payload["data"]
record = { )
"uid": generate_uid(), except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
"user_uid": user_uid, return Delivery(REJECTED, str(exc))
"endpoint": endpoint, if response.status_code in ACCEPTED_STATUSES:
"key_auth": key_auth, return Delivery(ACCEPTED)
"key_p256dh": key_p256dh, if response.status_code in DEAD_SUBSCRIPTION_STATUSES:
"created_at": datetime.now(timezone.utc).isoformat(), return Delivery(DEAD, str(response.status_code))
"deleted_at": None, return Delivery(REJECTED, str(response.status_code))
}
table.insert(record)
logger.info("Registered push subscription for user %s", user_uid)
return record, True
+83
View File
@@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from typing import Any
from devplacepy.database import db, get_table
from devplacepy.push.providers import DEFAULT_PROVIDER
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
TABLE = "push_registration"
def table():
return get_table(TABLE)
def provider_of(registration: dict[str, Any]) -> str:
return registration.get("provider") or DEFAULT_PROVIDER
def active_for_user(user_uid: str) -> list[dict[str, Any]]:
return list(table().find(user_uid=user_uid, deleted_at=None))
def register(
user_uid: str, provider: str, fields: dict[str, Any]
) -> tuple[dict[str, Any], bool]:
registrations = table()
existing = registrations.find_one(
user_uid=user_uid, provider=provider, deleted_at=None, **fields
)
if existing:
logger.debug("Push subscription already registered for user %s", user_uid)
return existing, False
record = {
"uid": generate_uid(),
"user_uid": user_uid,
"provider": provider,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
**fields,
}
registrations.insert(record)
logger.info("Registered %s push subscription for user %s", provider, user_uid)
return record, True
def mark_dead(registration_id: int) -> None:
table().update(
{"id": registration_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
["id"],
)
logger.info("Soft-deleted dead push subscription id=%s", registration_id)
def prune(cutoff: str) -> int:
if TABLE not in db.tables:
return 0
rows = list(table().find(deleted_at={"<": cutoff}))
if not rows:
return 0
table().delete(deleted_at={"<": cutoff})
return len(rows)
def counts() -> dict[str, int]:
if TABLE not in db.tables:
return {}
totals: dict[str, int] = {"dead": 0}
for row in db.query(
f"SELECT provider AS provider, deleted_at IS NULL AS live, COUNT(*) AS total "
f"FROM {TABLE} GROUP BY provider, deleted_at IS NULL"
):
provider = row["provider"] or DEFAULT_PROVIDER
if row["live"]:
totals[provider] = totals.get(provider, 0) + int(row["total"])
else:
totals["dead"] += int(row["total"])
return totals
+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` | | `/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` | | `/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` | | `/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) | | (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` | | `/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` | | (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
+2 -1
View File
@@ -87,6 +87,7 @@ async def create_rant(request: Request):
if len(text) > 125000: if len(text) > 125000:
return dr_error("Your rant is too long.") return dr_error("Your rant is too long.")
tags = _parse_tags(params.get("tags")) tags = _parse_tags(params.get("tags"))
project_uid = params.get("project_uid") or None
uid, slug = create_content_item( uid, slug = create_content_item(
"posts", "posts",
"post", "post",
@@ -95,7 +96,7 @@ async def create_rant(request: Request):
"title": None, "title": None,
"content": text, "content": text,
"topic": "rant", "topic": "rant",
"project_uid": None, "project_uid": project_uid,
"image": None, "image": None,
"tags": encode_tags(tags), "tags": encode_tags(tags),
}, },
+1 -24
View File
@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import GameDefenseItemForm, GameSlotForm from devplacepy.models import GameSlotForm
from devplacepy.responses import respond, wants_json from devplacepy.responses import respond, wants_json
from devplacepy.schemas import GameFarmViewOut from devplacepy.schemas import GameFarmViewOut
from devplacepy.services.game import GameError, store from devplacepy.services.game import GameError, store
@@ -110,26 +110,3 @@ async def steal_farm(
) )
) )
return RedirectResponse(url=f"/game/farm/{username}", status_code=302) return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
@router.post("/farm/{username}/defense-item")
async def purchase_defense_item(
request: Request, username: str, data: Annotated[GameDefenseItemForm, Form()]
):
viewer = require_user(request)
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
if viewer["uid"] != owner["uid"]:
raise HTTPException(status_code=403, detail="You can only buy defenses on your own farm.")
try:
result = store.purchase_defense_item(viewer, data.farm_uid, data.item_type)
except GameError as exc:
return action_error(request, str(exc), f"/game/farm/{username}")
track_action(viewer["uid"], "defense_item_purchase")
await notify_farm(owner["username"])
if wants_json(request):
return JSONResponse(
{"ok": True, "item_type": result["item_type"], "cost": result["cost"]}
)
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
+3 -1
View File
@@ -203,7 +203,9 @@ async def profile_page(
badges = list(get_table("badges").find(user_uid=profile_user["uid"])) badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
for b in badges: for b in badges:
b["icon"] = get_badge(b["badge_name"]).get("icon") badge_meta = get_badge(b["badge_name"])
b["icon"] = badge_meta.get("icon")
b["description"] = badge_meta.get("description")
achievements = build_achievements({b["badge_name"] for b in badges}) achievements = build_achievements({b["badge_name"] for b in badges})
badge_total = sum(group["total"] for group in achievements) badge_total = sum(group["total"] for group in achievements)
badge_earned = sum(group["earned"] for group in achievements) badge_earned = sum(group["earned"] for group in achievements)
+23 -15
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push from devplacepy import push
from devplacepy.config import STATIC_DIR from devplacepy.config import STATIC_DIR
from devplacepy.push import providers
from devplacepy.utils import require_user_api from devplacepy.utils import require_user_api
from urllib.parse import urlparse from urllib.parse import urlparse
from devplacepy.services.audit import record as audit from devplacepy.services.audit import record as audit
@@ -22,7 +23,11 @@ WELCOME_PAYLOAD = {
@router.get("/push.json") @router.get("/push.json")
async def push_public_key() -> JSONResponse: 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") @router.post("/push.json")
@@ -33,21 +38,18 @@ async def push_register(request: Request) -> JSONResponse:
except ValueError: except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400) return JSONResponse({"error": "Invalid JSON"}, status_code=400)
keys = body.get("keys") if isinstance(body, dict) else None if not isinstance(body, dict):
if not (
isinstance(keys, dict)
and body.get("endpoint")
and keys.get("p256dh")
and keys.get("auth")
):
return JSONResponse({"error": "Invalid request"}, status_code=400) return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = await push.register( provider = providers.get(body.get("provider"))
user_uid=user["uid"], if provider is None or not providers.is_active(provider):
endpoint=body["endpoint"], return JSONResponse({"error": "Unknown provider"}, status_code=400)
key_auth=keys["auth"],
key_p256dh=keys["p256dh"], 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: if created:
try: try:
@@ -62,7 +64,13 @@ async def push_register(request: Request) -> JSONResponse:
target_type="user", target_type="user",
target_uid=user["uid"], target_uid=user["uid"],
target_label=user.get("username"), 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", summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
links=[audit.target("user", user["uid"], user.get("username"))], links=[audit.target("user", user["uid"], user.get("username"))],
) )
+9
View File
@@ -68,10 +68,18 @@ class PollOut(_Out):
class BadgeOut(_Out): class BadgeOut(_Out):
name: Optional[str] = Field(None, alias="badge_name") name: Optional[str] = Field(None, alias="badge_name")
icon: Optional[str] = None icon: Optional[str] = None
description: Optional[str] = None
created_at: Optional[str] = None created_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
class ProjectLinkOut(_Out):
uid: str = ""
name: Optional[str] = None
slug: Optional[str] = None
url: Optional[str] = None
class PostOut(_Out): class PostOut(_Out):
uid: str = "" uid: str = ""
slug: Optional[str] = None slug: Optional[str] = None
@@ -81,6 +89,7 @@ class PostOut(_Out):
topic: Optional[str] = None topic: Optional[str] = None
stars: Optional[int] = None stars: Optional[int] = None
image: Optional[str] = None image: Optional[str] = None
project_uid: Optional[str] = None
created_at: Optional[str] = None created_at: Optional[str] = None
updated_at: Optional[str] = None updated_at: Optional[str] = None
-14
View File
@@ -178,8 +178,6 @@ class GameFarmOut(_Out):
era_name: str = "" era_name: str = ""
era_coins: int = 0 era_coins: int = 0
era_harvests: int = 0 era_harvests: int = 0
defense_items: list[GameDefenseItemOut] = []
raid_risk: GameRaidRiskOut | None = None
class GameStateOut(_Out): class GameStateOut(_Out):
@@ -194,18 +192,6 @@ class GameFarmViewOut(_Out):
stole_coins: int = 0 stole_coins: int = 0
class GameDefenseItemOut(_Out):
item_type: str = ""
purchased_at: str = ""
class GameRaidRiskOut(_Out):
failure_chance: float = 0.0
fine_min: int = 0
fine_max: int = 0
has_insurance: bool = False
class GameLeaderboardEntryOut(_Out): class GameLeaderboardEntryOut(_Out):
rank: int = 0 rank: int = 0
username: str = "" username: str = ""
+3
View File
@@ -14,6 +14,7 @@ from devplacepy.schemas.content import (
NotificationOut, NotificationOut,
PollOut, PollOut,
PostOut, PostOut,
ProjectLinkOut,
ProjectOut, ProjectOut,
ReactionsOut, ReactionsOut,
UserOut, UserOut,
@@ -31,6 +32,7 @@ class FeedItemOut(_Out):
reactions: ReactionsOut = ReactionsOut() reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False bookmarked: bool = False
poll: Optional[PollOut] = None poll: Optional[PollOut] = None
project_link: Optional[ProjectLinkOut] = None
class GistItemOut(_Out): class GistItemOut(_Out):
@@ -132,6 +134,7 @@ class PostDetailOut(_Out):
comment_count: Optional[int] = None comment_count: Optional[int] = None
related_posts: list[FeedItemOut] = [] related_posts: list[FeedItemOut] = []
topics: list[str] = [] topics: list[str] = []
project_link: Optional[ProjectLinkOut] = None
class ProjectsOut(_Out): class ProjectsOut(_Out):
+1 -1
View File
@@ -51,7 +51,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
`devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`. `devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`.
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`). This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`). `PushService` (`services/push/`) is the thinnest example of the pattern: it owns no loop work beyond pruning dead subscriptions, and exists mainly so every push provider's configuration is edited through the same `ConfigField` surface as every other subsystem - its `config_fields` are assembled from `devplacepy.push.providers.admin_fields()`, so a new provider appears at `/admin/services/push` with no edit to the service. Push delivery does NOT depend on that service running (see `devplacepy/push/CLAUDE.md`).
### DB-backed state (correct across workers) ### DB-backed state (correct across workers)
@@ -273,25 +273,4 @@ GAME_ACTIONS: tuple[Action, ...] = (
requires_auth=True, requires_auth=True,
params=(body("key", "An owned title cosmetic key.", required=True),), params=(body("key", "An owned title cosmetic key.", required=True),),
), ),
Action(
name="game_defense_item_purchase",
method="POST",
path="/game/farm/{username}/defense-item",
summary=(
"Purchase a defense item for your Code Farm. "
"Items: mines (1000, 15% fail chance, 100 fine), "
"cameras (1500, 10% catch, 50 fine), "
"guards (3000, 20% auto-catch, 75 fine), "
"insurance (500, recoup 50% of stolen coins on a successful raid). "
"Insurance can only be bought once. Confirmation required."
),
handler="http",
requires_auth=True,
params=(
path("username", "Your username."),
body("farm_uid", "Your farm's uid.", required=True),
body("item_type", "Item type: mines, cameras, guards, insurance.", required=True),
confirm(),
),
),
) )
@@ -4,6 +4,7 @@ import json
from typing import Optional from typing import Optional
from devplacepy.avatar import avatar_seed from devplacepy.avatar import avatar_seed
from devplacepy.database import get_table
from devplacepy.services.devrant.avatar import avatar_payload from devplacepy.services.devrant.avatar import avatar_payload
from devplacepy.services.devrant.ids import to_unix from devplacepy.services.devrant.ids import to_unix
@@ -26,6 +27,22 @@ def encode_tags(tags: list) -> str:
return json.dumps(cleaned) return json.dumps(cleaned)
def _rant_project(post: dict) -> dict | None:
project_uid = post.get("project_uid")
if not project_uid:
return None
project = get_table("projects").find_one(uid=project_uid)
if not project:
return None
slug = project.get("slug") or project["uid"]
return {
"uid": project["uid"],
"name": project.get("title") or project.get("name", ""),
"slug": slug,
"url": f"/projects/{slug}",
}
def rant_text(post: dict) -> str: def rant_text(post: dict) -> str:
title = (post.get("title") or "").strip() title = (post.get("title") or "").strip()
content = post.get("content") or "" content = post.get("content") or ""
@@ -56,6 +73,7 @@ def serialize_rant(
author = authors.get(post["user_uid"]) or {} author = authors.get(post["user_uid"]) or {}
uid = post["uid"] uid = post["uid"]
username = author.get("username") or "" username = author.get("username") or ""
project = _rant_project(post)
return { return {
"id": int(post["id"]), "id": int(post["id"]),
"text": rant_text(post), "text": rant_text(post),
@@ -75,6 +93,8 @@ def serialize_rant(
"user_avatar": avatar_payload(avatar_seed(author)), "user_avatar": avatar_payload(avatar_seed(author)),
"user_avatar_lg": avatar_payload(avatar_seed(author)), "user_avatar_lg": avatar_payload(avatar_seed(author)),
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]), "editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
"project_uid": post.get("project_uid"),
"project": project,
} }
-26
View File
@@ -723,32 +723,6 @@ CANARY_DOUBLE_CHANCE = 0.12
CANARY_FAIL_CHANCE = 0.06 CANARY_FAIL_CHANCE = 0.06
OBSERVABILITY_STEAL_CAP = 0.2 OBSERVABILITY_STEAL_CAP = 0.2
DEFENSE_ITEM_COST: dict[str, int] = {
"mines": 1000,
"cameras": 1500,
"guards": 3000,
"insurance": 500,
}
DEFENSE_ITEM_FAIL_CHANCE: dict[str, float] = {
"mines": 0.15,
"cameras": 0.10,
"guards": 0.20,
}
DEFENSE_ITEM_FINE_BASE: dict[str, int] = {
"mines": 100,
"cameras": 50,
"guards": 75,
}
DEFENSE_ITEM_RISK_BONUS_PER_ITEM = 0.05
DEFENSE_ITEM_MAX_FAILURE_CHANCE = 0.80
DEFENSE_ITEM_MAX_STEAL_FRACTION = 0.50
DEFENSE_ITEM_INSURANCE_PAYOUT_FRACTION = 0.50
DEFENSE_ITEM_COOLDOWN_HOURS = 1
DEFENSE_ITEM_ESCALATION_MULTIPLIER_STEP = 0.5
def infra_for(key: str) -> Infrastructure | None: def infra_for(key: str) -> Infrastructure | None:
return INFRA_BY_KEY.get(key) return INFRA_BY_KEY.get(key)
+2 -2
View File
@@ -49,7 +49,7 @@ from .common import (
steal_cooldown_remaining, steal_cooldown_remaining,
) )
from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys
from .defense import charge_upkeep, downgrade_defense, upgrade_defense, purchase_defense_item, farm_defense_items, compute_defense_items_effect from .defense import charge_upkeep, downgrade_defense, upgrade_defense
from .era import active_era, active_era_name, end_era, start_era from .era import active_era, active_era_name, end_era, start_era
from .farm import ( from .farm import (
_create_plot, _create_plot,
@@ -63,7 +63,7 @@ from .farm import (
upgrade_ci, upgrade_ci,
) )
from .infrastructure import buy_infrastructure, owns_infrastructure from .infrastructure import buy_infrastructure, owns_infrastructure
from .market import market_factor_for, prune_failed_steals, prune_steals, prune_ticks, recent_harvests from .market import market_factor_for, prune_steals, prune_ticks, recent_harvests
from .mastery import upgrade_mastery from .mastery import upgrade_mastery
from .quests import advance_quests, ensure_quests from .quests import advance_quests, ensure_quests
from .treasury import ( from .treasury import (
-115
View File
@@ -248,59 +248,6 @@ def water(visitor: dict, owner: dict, slot: int) -> dict:
} }
def _failed_raids_today(thief_uid: str, now: datetime) -> int:
from devplacepy.database import db
cutoff = _iso(now - timedelta(hours=24))
rows = list(
db.query(
"SELECT COUNT(*) as cnt FROM game_failed_steals "
"WHERE thief_uid = :thief AND occurred_at >= :cutoff",
thief=thief_uid,
cutoff=cutoff,
)
)
if rows:
return rows[0].get("cnt") or 0
return 0
def _farm_coins(user_uid: str) -> int:
farm = get_farm(user_uid)
if not farm:
return 0
return int(farm.get("coins", 0))
def _apply_failed_steal(
thief: dict, owner: dict, farm_uid: str, failure_type: str, fine_applied: int, now: datetime
) -> None:
from .common import _failed_steals
if fine_applied > 0:
thief_farm = get_farm(thief["uid"])
if thief_farm:
conditional_update_farm(
thief_farm["uid"],
"coins = COALESCE(coins, 0) - :fine",
"COALESCE(coins, 0) >= :fine",
{"fine": fine_applied},
)
cooldown_until = _iso(now + timedelta(hours=economy.DEFENSE_ITEM_COOLDOWN_HOURS))
_failed_steals().insert(
{
"uid": generate_uid(),
"thief_uid": thief["uid"],
"owner_uid": owner["uid"],
"farm_uid": farm_uid,
"failure_type": failure_type,
"fine_applied": fine_applied,
"cooldown_until": cooldown_until,
"occurred_at": _iso(now),
}
)
def steal(thief: dict, owner: dict, slot: int) -> dict: def steal(thief: dict, owner: dict, slot: int) -> dict:
if thief["uid"] == owner["uid"]: if thief["uid"] == owner["uid"]:
raise GameError("You cannot steal from your own farm.") raise GameError("You cannot steal from your own farm.")
@@ -345,57 +292,9 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
f"{owner.get('username', 'This farmer')} has already been raided " f"{owner.get('username', 'This farmer')} has already been raided "
f"{economy.STEAL_MAX_PER_VICTIM_PER_DAY} times today. Try again tomorrow." f"{economy.STEAL_MAX_PER_VICTIM_PER_DAY} times today. Try again tomorrow."
) )
# Defense items phase
from .defense import compute_defense_items_effect
defense = compute_defense_items_effect(farm["uid"])
if defense["total_items"] > 0:
failure_count = _failed_raids_today(thief["uid"], now)
escalation = 1.0 + failure_count * economy.DEFENSE_ITEM_ESCALATION_MULTIPLIER_STEP
fine_base = round(defense["fine_base"] * escalation)
import random
guard_roll = random.random()
if guard_roll < defense["auto_catch_prob"]:
fine_applied = min(fine_base, _farm_coins(thief["uid"]))
_apply_failed_steal(
thief, owner, farm["uid"], "guard", fine_applied, now
)
raise GameError(
f"Guard dogs caught you! You were fined {fine_applied} coins. "
f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h."
)
mine_roll = random.random()
mine_prob = defense["mines_count"] * economy.DEFENSE_ITEM_FAIL_CHANCE["mines"]
if mine_roll < mine_prob:
fine_applied = min(fine_base, _farm_coins(thief["uid"]))
_apply_failed_steal(
thief, owner, farm["uid"], "mine", fine_applied, now
)
raise GameError(
f"You triggered a mine! You lost {fine_applied} coins. "
f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h."
)
cam_roll = random.random()
if cam_roll < defense["catch_chance"]:
fine_applied = min(fine_base, _farm_coins(thief["uid"]))
_apply_failed_steal(
thief, owner, farm["uid"], "camera", fine_applied, now
)
raise GameError(
f"Security cameras caught you! You were fined {fine_applied} coins. "
f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h."
)
fraction = economy.effective_steal_fraction( fraction = economy.effective_steal_fraction(
defense_level, floor, building_tier.steal_reduction, cap defense_level, floor, building_tier.steal_reduction, cap
) )
if defense["total_items"] > 0:
risk_multiplier = 1.0 + defense["total_items"] * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM
fraction = min(fraction * risk_multiplier, economy.DEFENSE_ITEM_MAX_STEAL_FRACTION)
share = min(fraction, 1.0 - raided_fraction) share = min(fraction, 1.0 - raided_fraction)
taken = conditional_update_row( taken = conditional_update_row(
"game_plots", "game_plots",
@@ -437,12 +336,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
era_active=bool(active_era_name()), era_active=bool(active_era_name()),
extra=underdog_extra or None, extra=underdog_extra or None,
) )
insurance_payout = 0
if defense["has_insurance"] and coins_gain > 0:
insurance_payout = round(coins_gain * economy.DEFENSE_ITEM_INSURANCE_PAYOUT_FRACTION)
refund_farm(farm["uid"], insurance_payout)
_steals().insert( _steals().insert(
{ {
"uid": generate_uid(), "uid": generate_uid(),
@@ -451,7 +344,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
"slot_index": slot, "slot_index": slot,
"crop_key": crop.key, "crop_key": crop.key,
"coins": coins_gain, "coins": coins_gain,
"insurance_payout": insurance_payout,
"stolen_at": _iso(now), "stolen_at": _iso(now),
"created_at": _iso(now), "created_at": _iso(now),
} }
@@ -464,13 +356,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
"owner_uid": owner["uid"], "owner_uid": owner["uid"],
"share": round(share, 4), "share": round(share, 4),
"underdog_triggered": bool(underdog_extra), "underdog_triggered": bool(underdog_extra),
"insurance_payout": insurance_payout,
"risk_bonus_multiplier": round(
1.0 + defense["total_items"] * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM, 4
)
if defense["total_items"] > 0
else 1.0,
"defense_items_present": defense["total_items"],
} }
-8
View File
@@ -61,14 +61,6 @@ def _steals():
return get_table("game_steals") return get_table("game_steals")
def _defense_items():
return get_table("game_farm_defense_items")
def _failed_steals():
return get_table("game_failed_steals")
def last_steal_at(thief_uid: str, owner_uid: str) -> datetime | None: def last_steal_at(thief_uid: str, owner_uid: str) -> datetime | None:
from devplacepy.database import db from devplacepy.database import db
+1 -74
View File
@@ -4,10 +4,8 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from devplacepy.utils import generate_uid
from .. import economy from .. import economy
from .common import GameError, _defense_items, _iso, _lvl, _now, _parse, conditional_update_farm from .common import GameError, _iso, _lvl, _parse, conditional_update_farm
from .farm import ensure_farm, get_farm from .farm import ensure_farm, get_farm
@@ -140,74 +138,3 @@ def downgrade_defense(user: dict) -> dict:
if rows == 0: if rows == 0:
raise GameError("Defense already changed - refresh and try again.") raise GameError("Defense already changed - refresh and try again.")
return {"defense_level": level - 1} return {"defense_level": level - 1}
def purchase_defense_item(user: dict, farm_uid: str, item_type: str) -> dict:
if item_type not in economy.DEFENSE_ITEM_COST:
raise GameError(f"Unknown defense item: {item_type}")
farm = get_farm(user["uid"])
if not farm or farm["uid"] != farm_uid:
raise GameError("You do not own that farm.")
if item_type == "insurance":
existing = list(_defense_items().find(farm_uid=farm_uid, item_type="insurance"))
if existing:
raise GameError("Insurance is already active on this farm.")
cost = economy.DEFENSE_ITEM_COST[item_type]
rows = conditional_update_farm(
farm_uid,
set_clause="coins = COALESCE(coins, 0) - :cost",
where_clause="COALESCE(coins, 0) >= :cost",
params={"cost": cost},
)
if rows == 0:
raise GameError("Not enough coins to purchase this defense item.")
_defense_items().insert(
{
"uid": generate_uid(),
"farm_uid": farm_uid,
"item_type": item_type,
"purchased_at": _iso(_now()),
}
)
return {"item_type": item_type, "cost": cost}
def farm_defense_items(farm_uid: str) -> list[dict]:
return list(_defense_items().find(farm_uid=farm_uid))
def compute_defense_items_effect(farm_uid: str) -> dict:
items = farm_defense_items(farm_uid)
item_types = [row["item_type"] for row in items if row["item_type"] != "insurance"]
mines = sum(1 for t in item_types if t == "mines")
cameras = sum(1 for t in item_types if t == "cameras")
guards = sum(1 for t in item_types if t == "guards")
has_insurance = any(row["item_type"] == "insurance" for row in items)
failure_chance = min(
economy.DEFENSE_ITEM_MAX_FAILURE_CHANCE,
mines * economy.DEFENSE_ITEM_FAIL_CHANCE["mines"]
+ cameras * economy.DEFENSE_ITEM_FAIL_CHANCE["cameras"]
+ guards * economy.DEFENSE_ITEM_FAIL_CHANCE["guards"],
)
auto_catch_prob = guards * economy.DEFENSE_ITEM_FAIL_CHANCE["guards"]
catch_chance = cameras * economy.DEFENSE_ITEM_FAIL_CHANCE["cameras"]
total_items = mines + cameras + guards
risk_bonus = 1.0 + total_items * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM
fine_base = (
mines * economy.DEFENSE_ITEM_FINE_BASE["mines"]
+ cameras * economy.DEFENSE_ITEM_FINE_BASE["cameras"]
+ guards * economy.DEFENSE_ITEM_FINE_BASE["guards"]
)
return {
"items": [{"item_type": r["item_type"], "purchased_at": r["purchased_at"]} for r in items],
"failure_chance": round(failure_chance, 4),
"auto_catch_prob": round(auto_catch_prob, 4),
"catch_chance": round(catch_chance, 4),
"risk_bonus": round(risk_bonus, 4),
"fine_base": fine_base,
"has_insurance": has_insurance,
"total_items": total_items,
"mines_count": mines,
"cameras_count": cameras,
"guards_count": guards,
}
-16
View File
@@ -132,19 +132,3 @@ def prune_steals(older_than_days: int = 60) -> int:
text("DELETE FROM game_steals WHERE stolen_at < :cutoff"), {"cutoff": cutoff} text("DELETE FROM game_steals WHERE stolen_at < :cutoff"), {"cutoff": cutoff}
) )
return result.rowcount return result.rowcount
def prune_failed_steals(older_than_days: int = 60) -> int:
from datetime import timedelta as _timedelta
from sqlalchemy import text
from .common import _iso, _now
cutoff = _iso(_now() - _timedelta(days=older_than_days))
with db:
result = db.executable.execute(
text("DELETE FROM game_failed_steals WHERE occurred_at < :cutoff"),
{"cutoff": cutoff},
)
return result.rowcount
@@ -8,7 +8,6 @@ from datetime import datetime, timedelta
from .. import economy from .. import economy
from .common import ( from .common import (
PERK_COLUMN, PERK_COLUMN,
_defense_items,
_iso_week, _iso_week,
_lvl, _lvl,
_now, _now,
@@ -176,33 +175,6 @@ def serialize_plot(
} }
def _raid_risk(farm: dict) -> dict:
from .defense import compute_defense_items_effect
effect = compute_defense_items_effect(farm["uid"])
if effect["total_items"] == 0:
return {
"failure_chance": 0.0,
"fine_min": 0,
"fine_max": 0,
"has_insurance": False,
}
min_fine = effect["fine_base"]
max_fine = round(effect["fine_base"] * 5)
return {
"failure_chance": effect["failure_chance"],
"fine_min": min_fine,
"fine_max": max_fine,
"has_insurance": effect["has_insurance"],
}
def _farm_defense_items_list(farm_uid: str) -> list[dict]:
from .defense import farm_defense_items
return farm_defense_items(farm_uid)
def serialize_farm( def serialize_farm(
farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None
) -> dict: ) -> dict:
@@ -396,8 +368,6 @@ def serialize_farm(
"era_name": era_name or "", "era_name": era_name or "",
"era_coins": _lvl(farm, "era_coins"), "era_coins": _lvl(farm, "era_coins"),
"era_harvests": _lvl(farm, "era_harvests"), "era_harvests": _lvl(farm, "era_harvests"),
"defense_items": [] if not is_owner else _farm_defense_items_list(farm["uid"]),
"raid_risk": _raid_risk(farm) if not is_owner else {},
} }
@@ -188,7 +188,7 @@ def _screenshot_hue_buckets(screenshot_bytes: bytes) -> dict[int, int]:
image = Image.open(io.BytesIO(screenshot_bytes)).convert("RGB") image = Image.open(io.BytesIO(screenshot_bytes)).convert("RGB")
image = image.resize((64, 64)) image = image.resize((64, 64))
buckets: dict[int, int] = {} buckets: dict[int, int] = {}
for r, g, b in image.getdata(): for r, g, b in image.get_flattened_data():
hue, lightness, saturation = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0) hue, lightness, saturation = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
if saturation < 0.15 or lightness < 0.05 or lightness > 0.95: if saturation < 0.15 or lightness < 0.05 or lightness > 0.95:
continue continue
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.push.service import (
DEFAULT_RETENTION_DAYS,
RETENTION_KEY,
PushService,
)
__all__ = ["DEFAULT_RETENTION_DAYS", "PushService", "RETENTION_KEY"]
+79
View File
@@ -0,0 +1,79 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timedelta, timezone
from devplacepy.database import get_int_setting
from devplacepy.push import providers, store
from devplacepy.push.delivery import (
DEFAULT_TIMEOUT_SECONDS,
MAX_TIMEOUT_SECONDS,
MIN_TIMEOUT_SECONDS,
TIMEOUT_KEY,
)
from devplacepy.services.base import BaseService, ConfigField
logger = logging.getLogger(__name__)
RETENTION_KEY = "push_dead_retention_days"
DEFAULT_RETENTION_DAYS = 30
class PushService(BaseService):
title = "Push notifications"
description = (
"Configures the push providers and prunes dead subscriptions. Delivery is "
"independent of this service and continues while it is stopped."
)
details = (
"Notifications reach a user through every provider they have a live subscription "
"for. A provider delivers only while its own Enabled toggle is on and its "
"configuration is complete, so an unconfigured provider is inert."
)
default_enabled = True
min_interval = 3600
METRICS_SECONDS = 300
config_fields = [
ConfigField(
RETENTION_KEY,
"Dead subscription retention (days)",
type="int",
default=DEFAULT_RETENTION_DAYS,
minimum=0,
help="Subscriptions the push services rejected as gone are removed after this many days. 0 disables pruning.",
group="General",
),
ConfigField(
TIMEOUT_KEY,
"Delivery timeout (seconds)",
type="int",
default=DEFAULT_TIMEOUT_SECONDS,
minimum=MIN_TIMEOUT_SECONDS,
maximum=MAX_TIMEOUT_SECONDS,
help="Per request timeout used for every push provider.",
group="General",
),
*providers.admin_fields(),
]
def __init__(self) -> None:
super().__init__("push", interval_seconds=86400)
async def run_once(self) -> None:
days = get_int_setting(RETENTION_KEY, DEFAULT_RETENTION_DAYS)
if days <= 0:
self.log("Retention disabled (0 days); nothing pruned")
return
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
removed = store.prune(cutoff)
self.log(f"Pruned {removed} dead push subscriptions older than {days}d")
def collect_metrics(self) -> dict:
totals = store.counts()
metrics = {"dead": totals.get("dead", 0)}
for provider in providers.PROVIDERS.values():
metrics[f"{provider.name}_active"] = totals.get(provider.name, 0)
metrics[f"{provider.name}_ready"] = (
1 if providers.is_active(provider) else 0
)
return metrics
+16
View File
@@ -55,6 +55,22 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.project-link {
display: inline-block;
font-size: 0.875rem;
color: var(--accent);
font-weight: 600;
padding: 0.375rem 0.75rem;
margin-bottom: 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius);
text-decoration: none;
}
.project-link:hover {
text-decoration: underline;
}
.post-detail-actions { .post-detail-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+3
View File
@@ -54,6 +54,9 @@ export class PushManager {
} }
const keyData = await Http.getJson("/push.json"); const keyData = await Http.getJson("/push.json");
if (!keyData.publicKey) {
return;
}
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0)); const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({ const subscription = await registration.pushManager.subscribe({
+4
View File
@@ -17,6 +17,10 @@
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div> <div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
{% if item.project_link %}
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
{% endif %}
{% if item.attachments %} {% if item.attachments %}
{% include "_attachment_display.html" %} {% include "_attachment_display.html" %}
{% endif %} {% endif %}
+4
View File
@@ -28,6 +28,10 @@
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div> <div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
{% if project_link %}
<a href="{{ project_link.url }}" class="project-link">Project: {{ project_link.name }}</a>
{% endif %}
{% if attachments %} {% if attachments %}
{% include "_attachment_display.html" %} {% include "_attachment_display.html" %}
{% endif %} {% endif %}
+2
View File
@@ -408,6 +408,8 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `push.subscribe` | `routers/push.py` | | `push.subscribe` | `routers/push.py` |
| `push.update` | `routers/push.py` | | `push.update` | `routers/push.py` |
Both carry `metadata.provider` (`webpush`, `apns`, ...) plus `created`; `metadata.endpoint_host` is set for endpoint-based providers and `null` for token-based ones.
## Gamification (`reward`) ## Gamification (`reward`)
| Event key | Recorded in | | Event key | Recorded in |
+47 -2
View File
@@ -1,10 +1,12 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import time import time
from datetime import datetime, timezone
import pytest import pytest
import requests import requests
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
_counter_dr = [0] _counter_dr = [0]
@@ -46,10 +48,13 @@ def _register(password="secret123"):
raise AssertionError("registration did not open") raise AssertionError("registration did not open")
def _create_rant(params, text="this is a devrant rant body", tags="dev,test"): def _create_rant(params, text="this is a devrant rant body", tags="dev,test", project_uid=None):
data = {**params, "rant": text, "tags": tags}
if project_uid:
data["project_uid"] = project_uid
r = requests.post( r = requests.post(
f"{BASE_URL}/api/devrant/rants", f"{BASE_URL}/api/devrant/rants",
data={**params, "rant": text, "tags": tags}, data=data,
) )
return r return r
@@ -235,3 +240,43 @@ def test_search_returns_results_shape(app_server):
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"}) r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
assert r.json()["success"] is True assert r.json()["success"] is True
assert isinstance(r.json()["results"], list) assert isinstance(r.json()["results"], list)
def test_rant_serialization_includes_project(app_server):
name, params = _register()
refresh_snapshot()
user = get_table("users").find_one(username=name)
uid = user["uid"]
project_uid = generate_uid()
slug = make_combined_slug("project-for-rant", project_uid)
projects = get_table("projects")
projects.insert({
"uid": project_uid,
"user_uid": uid,
"slug": slug,
"title": "Project For Rant",
"description": "project linked to a rant",
"project_type": "software",
"status": "In Development",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
})
refresh_snapshot()
rant_id = _create_rant(
params,
text="rant with a project link here",
tags="dev",
project_uid=project_uid,
).json()["rant_id"]
refresh_snapshot()
rant = requests.get(
f"{BASE_URL}/api/devrant/rants/{rant_id}",
params=params,
).json()["rant"]
assert rant.get("project_uid") == project_uid
assert rant.get("project") is not None
assert rant["project"]["uid"] == project_uid
assert rant["project"]["name"] == "Project For Rant"
assert "/projects/" in rant["project"]["url"]
-62
View File
@@ -427,65 +427,3 @@ def test_grant_ineligible_when_rich_returns_400(app_server, seeded_db):
) )
r = session.post(f"{BASE_URL}/game/grant", headers=JSON) r = session.post(f"{BASE_URL}/game/grant", headers=JSON)
assert r.status_code == 400 assert r.status_code == 400
def test_defense_item_purchase_success(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, 10000)
refresh_snapshot()
user = get_table("users").find_one(username=name)
farm = store.get_farm(user["uid"])
r = session.post(
f"{BASE_URL}/game/farm/{name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "mines"},
headers=JSON,
)
assert r.status_code == 200
body = r.json()
assert body["item_type"] == "mines"
assert body["cost"] == economy.DEFENSE_ITEM_COST["mines"]
def test_defense_item_purchase_not_owner_returns_403(app_server, seeded_db):
session, name = _signup()
owner_session, owner_name = _signup()
_reset_farm(owner_name, 10000)
refresh_snapshot()
owner_user = get_table("users").find_one(username=owner_name)
farm = store.get_farm(owner_user["uid"])
r = session.post(
f"{BASE_URL}/game/farm/{owner_name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "mines"},
headers=JSON,
)
assert r.status_code in (400, 403)
assert r.status_code != 200
def test_defense_item_purchase_requires_auth(app_server, seeded_db):
r = requests.post(
f"{BASE_URL}/game/farm/test/defense-item",
data={"farm_uid": "x", "item_type": "mines"},
headers=JSON,
)
assert r.status_code in (401, 303)
def test_insurance_purchase_duplicate_rejected(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, 10000)
refresh_snapshot()
user = get_table("users").find_one(username=name)
farm = store.get_farm(user["uid"])
r1 = session.post(
f"{BASE_URL}/game/farm/{name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "insurance"},
headers=JSON,
)
assert r1.status_code == 200
r2 = session.post(
f"{BASE_URL}/game/farm/{name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "insurance"},
headers=JSON,
)
assert r2.status_code == 400
+66
View File
@@ -716,3 +716,69 @@ def test_short_post_content_rejected(app_server):
allow_redirects=False, allow_redirects=False,
) )
assert "/posts/" not in (r.headers.get("location") or "") assert "/posts/" not in (r.headers.get("location") or "")
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupj"),
"description": "project for linked post test",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def test_post_detail_includes_project_when_linked(app_server):
s, _ = _member()
project = _new_project(s)
project_uid = project["uid"]
created = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("aupjpost"),
"content": "this post is linked to a project",
"topic": "devlog",
"project_uid": project_uid,
},
).json()["data"]
slug = created["slug"]
detail = s.get(
f"{BASE_URL}/posts/{slug}",
headers=JSON_audit_log,
).json()
assert detail.get("project_link") is not None, "linked project must appear in post detail"
assert detail["project_link"]["uid"] == project_uid
assert detail["project_link"]["name"] is not None
assert "/projects/" in detail["project_link"]["url"]
def test_feed_includes_project_when_linked(app_server):
s, _ = _member()
project = _new_project(s)
project_uid = project["uid"]
s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("aupjfeed"),
"content": "this post appears in feed with a project link",
"topic": "devlog",
"project_uid": project_uid,
},
)
feed = s.get(
f"{BASE_URL}/feed",
headers=JSON_audit_log,
).json()
found = False
for item in feed["posts"]:
if item.get("project_link") is not None and item["project_link"]["uid"] == project_uid:
found = True
assert "/projects/" in item["project_link"]["url"]
break
assert found, "feed must include project info for linked posts"
+73
View File
@@ -499,3 +499,76 @@ def test_profile_json_xp_fields_boundary_xp(app_server):
) )
def _signup_badge_user():
import time
name = f"bdgi{int(time.time() * 1000)}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return session, name
def test_profile_badges_json_description_matches_catalog(app_server):
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import BADGE_CATALOG, award_badge
session, name = _signup_badge_user()
refresh_snapshot()
user = get_table("users").find_one(username=name)
assert user, f"user {name} not found after signup"
award_badge(user["uid"], "First Post")
award_badge(user["uid"], "Member")
refresh_snapshot()
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
body = r.json()
assert "badges" in body, "badges key missing from profile JSON"
assert isinstance(body["badges"], list), "badges is not a list"
assert body["badges"], "expected at least one badge"
for badge in body["badges"]:
assert "description" in badge, f"badge missing description key: {badge}"
assert badge["description"] is not None, f"badge description is null: {badge}"
assert isinstance(badge["description"], str), (
f"badge description is not a string: {badge}"
)
assert badge["description"] == BADGE_CATALOG[badge["name"]]["description"], (
f"badge description mismatch: {badge['name']}"
)
def test_profile_badge_description_exact_string(app_server):
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import award_badge
session, name = _signup_badge_user()
refresh_snapshot()
user = get_table("users").find_one(username=name)
assert user, f"user {name} not found after signup"
award_badge(user["uid"], "Cheerleader")
refresh_snapshot()
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
badges = r.json()["badges"]
cheerleader = [b for b in badges if b["name"] == "Cheerleader"]
assert cheerleader, f"Cheerleader badge missing from profile JSON: {badges}"
assert cheerleader[0]["description"] == "Reacted 50 times", (
f"expected 'Reacted 50 times', got {cheerleader[0]['description']!r}"
)
+10 -1
View File
@@ -276,7 +276,7 @@ def test_profile_renders_heatmap_and_streak(app_server):
def test_profile_badges_json_has_non_null_names(app_server): def test_profile_badges_json_has_non_null_names(app_server):
import time import time
from devplacepy.database import get_table, refresh_snapshot from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import award_badge from devplacepy.utils import BADGE_CATALOG, award_badge
name = f"bdg{int(time.time() * 1000)}" name = f"bdg{int(time.time() * 1000)}"
session = requests.Session() session = requests.Session()
@@ -309,5 +309,14 @@ def test_profile_badges_json_has_non_null_names(app_server):
assert badge["name"] is not None, f"badge name is null: {badge}" assert badge["name"] is not None, f"badge name is null: {badge}"
assert isinstance(badge["name"], str), f"badge name is not a string: {badge}" assert isinstance(badge["name"], str), f"badge name is not a string: {badge}"
assert len(badge["name"]) > 0, f"badge name is empty: {badge}" assert len(badge["name"]) > 0, f"badge name is empty: {badge}"
assert "description" in badge, f"badge missing description key: {badge}"
assert badge["description"] is not None, f"badge description is null: {badge}"
assert isinstance(badge["description"], str), f"badge description is not a string: {badge}"
assert len(badge["description"]) > 0, f"badge description is empty: {badge}"
assert badge["description"] == BADGE_CATALOG[badge["name"]]["description"], (
f"badge description mismatch: {badge['name']}"
)
+53
View File
@@ -64,3 +64,56 @@ def test_register_invalid_json_rejected(app_server):
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
assert r.status_code == 400 assert r.status_code == 400
def test_public_key_endpoint_lists_providers(app_server):
r = requests.get(f"{BASE_URL}/push.json")
assert r.status_code == 200
body = r.json()
assert body["publicKey"]
assert body["providers"]["webpush"]["publicKey"] == body["publicKey"]
def test_register_accepts_an_explicit_webpush_provider(app_server):
s = _session_push()
r = s.post(
f"{BASE_URL}/push.json",
json={
"provider": "webpush",
"endpoint": "https://push.example.com/explicit",
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
},
)
assert r.status_code == 200, r.text
assert r.json().get("registered") is True
def test_register_is_idempotent_for_the_same_subscription(app_server):
s = _session_push()
body = {
"endpoint": "https://push.example.com/idempotent",
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
}
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
def test_register_unknown_provider_rejected(app_server):
s = _session_push()
r = s.post(
f"{BASE_URL}/push.json",
json={"provider": "carrier-pigeon", "token": "a" * 64},
)
assert r.status_code == 400
def test_register_apns_rejected_while_unconfigured(app_server):
s = _session_push()
r = s.post(f"{BASE_URL}/push.json", json={"provider": "apns", "token": "a" * 64})
assert r.status_code == 400
def test_register_non_object_body_rejected(app_server):
s = _session_push()
r = s.post(f"{BASE_URL}/push.json", json=["nope"])
assert r.status_code == 400
+295
View File
@@ -48,3 +48,298 @@ def test_create_notification_authorization_is_jwt():
token = push.create_notification_authorization("https://push.example.com/endpoint") token = push.create_notification_authorization("https://push.example.com/endpoint")
assert token.count(".") == 2 assert token.count(".") == 2
def test_provider_registry_resolves_default_for_missing_name():
from devplacepy.push import providers
assert providers.get(None) is providers.PROVIDERS["webpush"]
assert providers.get("") is providers.PROVIDERS["webpush"]
assert providers.get(" APNS ") is providers.PROVIDERS["apns"]
assert providers.get("nope") is None
def test_webpush_parse_registration_accepts_subscription_shape():
from devplacepy.push import providers
webpush = providers.PROVIDERS["webpush"]
fields = webpush.parse_registration(
{
"endpoint": "https://push.example.com/sub",
"expirationTime": None,
"keys": {"p256dh": "p", "auth": "a"},
}
)
assert fields == {
"endpoint": "https://push.example.com/sub",
"key_auth": "a",
"key_p256dh": "p",
}
def test_webpush_parse_registration_rejects_incomplete_bodies():
from devplacepy.push import providers
webpush = providers.PROVIDERS["webpush"]
assert webpush.parse_registration({"endpoint": "https://push.example.com/x"}) is None
assert webpush.parse_registration({"keys": {"p256dh": "p", "auth": "a"}}) is None
assert (
webpush.parse_registration(
{"endpoint": "https://push.example.com/x", "keys": {"p256dh": "p"}}
)
is None
)
def test_apns_parse_registration_validates_device_token():
from devplacepy.push import providers
apns = providers.PROVIDERS["apns"]
token = "a1b2c3d4" * 8
assert apns.parse_registration({"token": f" {token} "}) == {"token": token}
assert apns.parse_registration({"token": "abc"}) is None
assert apns.parse_registration({"token": "z" * 64}) is None
assert apns.parse_registration({"token": "a" * 500}) is None
assert apns.parse_registration({"token": None}) is None
assert apns.parse_registration({}) is None
def test_apns_prepare_translates_the_shared_payload():
import json
from devplacepy.push import providers
body = json.loads(
providers.PROVIDERS["apns"].prepare(
{
"title": "DevPlace",
"message": "You have a new notification.",
"icon": "/static/apple-touch-icon.png",
"url": "/notifications",
}
)
)
assert body["aps"]["alert"] == {
"title": "DevPlace",
"body": "You have a new notification.",
}
assert body["aps"]["thread-id"] == "devplace-notification"
assert body["url"] == "/notifications"
assert body["icon"] == "/static/apple-touch-icon.png"
def test_apns_prepare_survives_an_empty_payload():
import json
from devplacepy.push import providers
body = json.loads(providers.PROVIDERS["apns"].prepare({}))
assert body["aps"]["alert"]["title"] == "DevPlace"
assert body["url"] == "/notifications"
def _apns_settings(monkeypatch, **values):
from devplacepy.push.providers import apns
defaults = {
apns.TEAM_ID_KEY: "",
apns.KEY_ID_KEY: "",
apns.AUTH_KEY_KEY: "",
apns.TOPIC_KEY: "",
apns.ENVIRONMENT_KEY: "",
}
defaults.update(values)
monkeypatch.setattr(apns, "_setting", lambda key: defaults.get(key, ""))
apns._token_state.clear()
return defaults
def _ec_private_key_pem():
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
key = ec.generate_private_key(ec.SECP256R1())
return key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
def test_apns_is_configured_requires_every_credential(monkeypatch):
from devplacepy.push import providers
from devplacepy.push.providers import apns
provider = providers.PROVIDERS["apns"]
_apns_settings(monkeypatch)
assert provider.is_configured() is False
assert providers.is_active(provider) is False
_apns_settings(
monkeypatch,
**{
apns.TEAM_ID_KEY: "TEAMID1234",
apns.KEY_ID_KEY: "KEYID12345",
apns.AUTH_KEY_KEY: "pem",
},
)
assert provider.is_configured() is False
_apns_settings(
monkeypatch,
**{
apns.TEAM_ID_KEY: "TEAMID1234",
apns.KEY_ID_KEY: "KEYID12345",
apns.AUTH_KEY_KEY: "pem",
apns.TOPIC_KEY: "nl.molodetz.devplace",
},
)
assert provider.is_configured() is True
def test_apns_host_falls_back_to_production(monkeypatch):
from devplacepy.push.providers import apns
_apns_settings(monkeypatch)
assert apns.host() == "api.push.apple.com"
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "sandbox"})
assert apns.host() == "api.sandbox.push.apple.com"
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "nonsense"})
assert apns.host() == "api.push.apple.com"
def test_apns_provider_token_is_signed_and_cached(monkeypatch):
import jwt
from devplacepy.push.providers import apns
_apns_settings(monkeypatch)
pem = _ec_private_key_pem()
token = apns.provider_token("TEAMID1234", "KEYID12345", pem)
assert apns.provider_token("TEAMID1234", "KEYID12345", pem) == token
header = jwt.get_unverified_header(token)
claims = jwt.decode(token, options={"verify_signature": False})
assert header["alg"] == "ES256"
assert header["kid"] == "KEYID12345"
assert claims["iss"] == "TEAMID1234"
assert isinstance(claims["iat"], int)
other = apns.provider_token("TEAMID1234", "KEYID12345", _ec_private_key_pem())
assert other != token
def test_apns_provider_token_rejects_a_broken_auth_key(monkeypatch):
import pytest
from devplacepy.push.providers import apns
_apns_settings(monkeypatch)
with pytest.raises(ValueError):
apns.provider_token("TEAMID1234", "KEYID12345", "not-a-pem")
with pytest.raises(ValueError):
apns.provider_token("TEAMID1234", "KEYID12345", "not-a-pem")
def _apns_response_status(monkeypatch, status, body):
import httpx
from tests.conftest import run_async
from devplacepy.push import providers
from devplacepy.push.providers import apns
_apns_settings(
monkeypatch,
**{
apns.TEAM_ID_KEY: "TEAMID1234",
apns.KEY_ID_KEY: "KEYID12345",
apns.AUTH_KEY_KEY: _ec_private_key_pem(),
apns.TOPIC_KEY: "nl.molodetz.devplace",
},
)
provider = providers.PROVIDERS["apns"]
seen = {}
def handler(request):
seen["url"] = str(request.url)
seen["headers"] = dict(request.headers)
return httpx.Response(status, json=body)
async def run():
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
return await provider.deliver(
client, {"token": "a" * 64}, provider.prepare({"message": "hi"})
)
return run_async(run()), seen
def test_apns_delivery_maps_statuses(monkeypatch):
from devplacepy.push import providers
accepted, seen = _apns_response_status(monkeypatch, 200, {})
assert accepted.status == providers.ACCEPTED
assert seen["url"] == f"https://api.push.apple.com/3/device/{'a' * 64}"
assert seen["headers"]["apns-topic"] == "nl.molodetz.devplace"
assert seen["headers"]["apns-push-type"] == "alert"
assert seen["headers"]["apns-priority"] == "10"
assert seen["headers"]["authorization"].startswith("bearer ")
assert int(seen["headers"]["apns-expiration"]) > 0
assert seen["headers"]["apns-id"]
gone, _ = _apns_response_status(monkeypatch, 410, {"reason": "Unregistered"})
assert gone.status == providers.DEAD
bad_token, _ = _apns_response_status(monkeypatch, 400, {"reason": "BadDeviceToken"})
assert bad_token.status == providers.DEAD
payload_error, _ = _apns_response_status(
monkeypatch, 400, {"reason": "PayloadTooLarge"}
)
assert payload_error.status == providers.REJECTED
throttled, _ = _apns_response_status(monkeypatch, 429, {"reason": "TooManyRequests"})
assert throttled.status == providers.REJECTED
def test_apns_delivery_without_configuration_never_raises(monkeypatch):
import httpx
from tests.conftest import run_async
from devplacepy.push import providers
_apns_settings(monkeypatch)
provider = providers.PROVIDERS["apns"]
async def run():
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
async with httpx.AsyncClient(transport=transport) as client:
return await provider.deliver(client, {"token": "a" * 64}, "{}")
assert run_async(run()).status == providers.REJECTED
def test_group_by_provider_treats_a_missing_provider_as_webpush():
from devplacepy.push.delivery import group_by_provider
grouped = group_by_provider(
[
{"id": 1, "provider": None},
{"id": 2, "provider": ""},
{"id": 3, "provider": "webpush"},
{"id": 4, "provider": "apns"},
]
)
assert sorted(grouped) == ["apns", "webpush"]
assert len(grouped["webpush"]) == 3
assert len(grouped["apns"]) == 1
def test_delivery_timeout_is_clamped(monkeypatch):
from devplacepy.push import delivery
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: default)
assert delivery.timeout_seconds() == float(delivery.DEFAULT_TIMEOUT_SECONDS)
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: 0)
assert delivery.timeout_seconds() == float(delivery.MIN_TIMEOUT_SECONDS)
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: 100000)
assert delivery.timeout_seconds() == float(delivery.MAX_TIMEOUT_SECONDS)
-142
View File
@@ -1177,145 +1177,3 @@ def test_claim_grant_partial_treasury(local_db):
result = store.claim_grant(user) result = store.claim_grant(user)
assert result["amount"] == 300 assert result["amount"] == 300
assert store.treasury_balance() == 0 assert store.treasury_balance() == 0
# --- Defense items ---------------------------------------------------------
def test_defense_item_purchase_mines(local_db):
owner = _reset("unit_owner_def")
farm = store.get_farm(owner["uid"])
result = store.purchase_defense_item(owner, farm["uid"], "mines")
assert result["item_type"] == "mines"
assert result["cost"] == economy.DEFENSE_ITEM_COST["mines"]
assert _coins(owner) == 100000 - economy.DEFENSE_ITEM_COST["mines"]
def test_defense_item_purchase_insurance_twice_rejected(local_db):
owner = _reset("unit_owner_ins")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "insurance")
with pytest.raises(GameError):
store.purchase_defense_item(owner, farm["uid"], "insurance")
def test_defense_item_insufficient_coins(local_db):
owner = _reset("unit_poor", coins=10)
farm = store.get_farm(owner["uid"])
with pytest.raises(GameError):
store.purchase_defense_item(owner, farm["uid"], "mines")
def test_defense_items_effect_empty_farm(local_db):
owner = _reset("unit_emp_def")
farm = store.get_farm(owner["uid"])
effect = store.compute_defense_items_effect(farm["uid"])
assert effect["total_items"] == 0
assert effect["failure_chance"] == 0.0
assert effect["has_insurance"] is False
def test_defense_items_effect_with_items(local_db):
owner = _reset("unit_items_eff")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
store.purchase_defense_item(owner, farm["uid"], "cameras")
store.purchase_defense_item(owner, farm["uid"], "guards")
effect = store.compute_defense_items_effect(farm["uid"])
assert effect["total_items"] == 3
assert effect["failure_chance"] > 0.0
assert effect["auto_catch_prob"] > 0.0
assert effect["catch_chance"] > 0.0
assert effect["risk_bonus"] > 1.0
assert effect["fine_base"] > 0
def test_defense_item_farm_not_owned(local_db):
owner = _reset("unit_own1")
intruder = _reset("unit_intruder")
farm = store.get_farm(owner["uid"])
with pytest.raises(GameError):
store.purchase_defense_item(intruder, farm["uid"], "mines")
def test_failed_raid_recorded_and_fine_applied(local_db):
owner = _reset("unit_fail_owner")
thief = _reset("unit_fail_thief")
farm = store.get_farm(owner["uid"])
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
store.purchase_defense_item(owner, farm["uid"], "guards")
store.purchase_defense_item(owner, farm["uid"], "mines")
store.purchase_defense_item(owner, farm["uid"], "cameras")
effect = store.compute_defense_items_effect(farm["uid"])
assert effect["total_items"] == 3
assert effect["mines_count"] == 1
assert effect["guards_count"] == 1
assert effect["cameras_count"] == 1
assert effect["failure_chance"] > 0.0
assert effect["auto_catch_prob"] > 0.0
assert effect["catch_chance"] > 0.0
def test_insurance_payout_on_successful_raid(local_db):
owner = _reset("unit_ins_owner")
thief = _reset("unit_ins_thief", coins=0)
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "insurance")
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
before_owner = _coins(owner)
try:
result = store.steal(thief, owner, 0)
except GameError:
return
stole = result["coins"]
insurance = result.get("insurance_payout", 0)
if stole > 0 and insurance > 0:
assert _coins(owner) == before_owner + insurance
def test_raid_risk_serialization(local_db):
owner = _reset("unit_risk_owner")
thief = _reset("unit_risk_thief")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
farm = store.get_farm(owner["uid"])
data = store.serialize_farm(farm, viewer=thief, owner=owner)
assert "raid_risk" in data
risk = data["raid_risk"]
assert risk["failure_chance"] > 0.0
assert risk["fine_min"] > 0
assert risk["fine_max"] >= risk["fine_min"]
assert risk["has_insurance"] is False
def test_raid_risk_serialization_owner_sees_no_risk(local_db):
owner = _reset("unit_risk_owner2")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
farm = store.get_farm(owner["uid"])
data = store.serialize_farm(farm, viewer=owner, owner=owner)
assert "raid_risk" in data
assert data["raid_risk"] == {}
def test_risk_bonus_multiplier_in_steal_result(local_db):
owner = _reset("unit_bonus_owner")
thief = _reset("unit_bonus_thief", coins=0)
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
try:
result = store.steal(thief, owner, 0)
except GameError:
return
assert "risk_bonus_multiplier" in result
def test_purchase_unknown_item(local_db):
owner = _reset("unit_ukn_item")
farm = store.get_farm(owner["uid"])
with pytest.raises(GameError):
store.purchase_defense_item(owner, farm["uid"], "nukes")
+117
View File
@@ -0,0 +1,117 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from devplacepy.database import get_table
from devplacepy.push import store
from devplacepy.services.push import PushService
from devplacepy.utils import generate_uid
def _registration(user_uid, deleted_at=None, provider="webpush"):
record = {
"uid": generate_uid(),
"user_uid": user_uid,
"provider": provider,
"endpoint": f"https://push.example.com/{generate_uid()}",
"key_auth": "a",
"key_p256dh": "p",
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": deleted_at,
}
get_table("push_registration").insert(record)
return record
def test_config_fields_cover_every_provider(local_db):
keys = [field.key for field in PushService().all_fields()]
for expected in (
"service_push_enabled",
"push_dead_retention_days",
"push_delivery_timeout_seconds",
"push_webpush_enabled",
"push_webpush_subject",
"push_apns_enabled",
"push_apns_team_id",
"push_apns_key_id",
"push_apns_auth_key",
"push_apns_topic",
"push_apns_environment",
):
assert expected in keys
def test_apns_auth_key_field_is_a_masked_secret(local_db):
fields = {field.key: field for field in PushService().all_fields()}
auth_key = fields["push_apns_auth_key"]
assert auth_key.secret is True
assert auth_key.type == "text"
assert auth_key.display_value() == ""
def test_run_once_prunes_only_stale_dead_rows(local_db, monkeypatch):
from devplacepy.services import push as push_service
user_uid = f"prune_{generate_uid()}"
live = _registration(user_uid)
fresh_dead = _registration(
user_uid,
deleted_at=(datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
)
stale_dead = _registration(
user_uid,
deleted_at=(datetime.now(timezone.utc) - timedelta(days=90)).isoformat(),
)
service = PushService()
monkeypatch.setattr(
push_service.service, "get_int_setting", lambda key, default: 30
)
from tests.conftest import run_async
run_async(service.run_once())
registrations = get_table("push_registration")
assert registrations.find_one(uid=live["uid"]) is not None
assert registrations.find_one(uid=fresh_dead["uid"]) is not None
assert registrations.find_one(uid=stale_dead["uid"]) is None
def test_run_once_with_retention_disabled_prunes_nothing(local_db, monkeypatch):
from devplacepy.services import push as push_service
from tests.conftest import run_async
user_uid = f"keep_{generate_uid()}"
stale_dead = _registration(
user_uid,
deleted_at=(datetime.now(timezone.utc) - timedelta(days=900)).isoformat(),
)
service = PushService()
monkeypatch.setattr(push_service.service, "get_int_setting", lambda key, default: 0)
run_async(service.run_once())
assert get_table("push_registration").find_one(uid=stale_dead["uid"]) is not None
def test_metrics_count_live_rows_per_provider(local_db):
user_uid = f"metrics_{generate_uid()}"
_registration(user_uid)
_registration(user_uid, provider="apns")
metrics = PushService().collect_metrics()
assert metrics["webpush_active"] >= 1
assert metrics["apns_active"] >= 1
assert metrics["webpush_ready"] == 1
assert "dead" in metrics
def test_store_counts_treats_a_missing_provider_as_webpush(local_db):
user_uid = f"legacy_{generate_uid()}"
record = _registration(user_uid)
get_table("push_registration").update(
{"id": get_table("push_registration").find_one(uid=record["uid"])["id"], "provider": None},
["id"],
)
assert store.counts().get("webpush", 0) >= 1