Compare commits

...

28 Commits

Author SHA1 Message Date
13f9fb5a96 Merge pull request 'Fix #150: Show linked project on post details page and expose in API' (#151) from typosaurus/ticket-150 into master
Some checks failed
DevPlace CI / test (push) Failing after 1h22m2s
Reviewed-on: #151
2026-08-02 00:25:48 +02:00
0128aad7b5 Merge branch 'master' into typosaurus/ticket-150
Some checks failed
DevPlace CI / test (pull_request) Failing after 1h22m9s
2026-08-02 00:25:01 +02:00
0ec3e61118 Move image pixel reads to get_flattened_data and add the font libraries
Some checks failed
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
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
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
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
Some checks failed
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
5079f40f46 Merge pull request 'Fix #146: Add missing icon field to badges API response' (#147) from typosaurus/ticket-146 into master
Some checks failed
DevPlace CI / test (push) Failing after 1h25m1s
Reviewed-on: #147
2026-07-27 12:35:48 +02:00
Typosaurus
d895de1b47 ticket #146 attempt 1
Some checks failed
DevPlace CI / test (pull_request) Failing after 1h26m26s
2026-07-27 10:08:16 +00:00
571a0485c5 Fix circular import, primary-admin NULL trap, and add gateway quota reset
Some checks failed
DevPlace CI / test (push) Failing after 58m59s
Restores a working import graph and closes two data-correctness bugs, plus
adds a reset for the AI gateway's rolling 24h spend.

Circular import: database/__init__ -> engagement -> content -> utils ->
database made the package unimportable. get_project_devlog moves out of
database/engagement.py into content.py, where enrich_items already lives.

Primary administrator: _can_hold_primary_admin read is_active with
bool(row.get("is_active")), so an admin row whose is_active column is SQL
NULL (any row predating the column) was treated as deactivated and skipped.
Every other site defaults an unknown is_active to active; this one now does
too.

Profile JSON: xp_next_level and xp_progress_pct were computed but only put on
the top-level context, never on profile_user, so they serialised as null even
though UserOut declares them and the API docs document them as embedded there.

Gateway quota reset: a cap previously lifted only with the passage of time.
quota.reset upserts a watermark row into gateway_quota_resets, scoped by the
same three nullable dimensions as a quota rule, and spent_24h sums from
max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on
/admin/ai-usage stay intact. Reaches every surface: POST
/admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool
gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API
docs. Admin's Reset all quotas now stamps a global gateway watermark too,
which is what a caller stuck on "AI gateway daily quota exceeded" needed.

Startup: _backfill_gamification swept every xp=0 user on every boot in every
worker and could never converge, since a user with no content earns no XP.
It now intersects pending users with _milestone_candidates(). db.tables is a
live reflection, so it is hoisted out of the loops that probed it per row.

Docker: the dependency layer now depends on pyproject.toml only, so a source
edit no longer reinstalls every dependency and re-downloads Chromium.
Adds start_interval so the healthcheck probes during the start period, and a
docker-reload target, since docker-up does not restart an unchanged container.

Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz
docs and the tooling all referenced but which never existed: 288 keys across
28 categories, including the families built from a variable at the call site.

Test fixes: both devlog helpers dated post 0 as the newest while the tests
assumed post 2 was; a profile login posted username= to a form that takes
email=; a devlog assertion matched six buttons under strict mode; and the
primary-admin tests seeded founders newer than the back-dated fixture admin,
so they only passed without the api tier.

Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00
b185574760 Merge pull request 'feat: Fix badge names returning null in profile endpoint' (#143) from typosaurus/113-fix-badge-names-returning-null-in-profile-endpoint into master
Some checks failed
DevPlace CI / test (push) Failing after 7m41s
Reviewed-on: #143
2026-07-27 01:40:55 +02:00
3709f4fab9 Merge pull request 'feat: Expose level progress percentage in profile API response' (#144) from typosaurus/112-expose-level-progress-percentage-in-profile-api-response into master
Some checks are pending
DevPlace CI / test (push) Waiting to run
Reviewed-on: #144
2026-07-27 01:40:05 +02:00
1a87c392bd test(sveta): Write API test for xp_next_level and xp_progress_pct in profile JSON response
Some checks failed
DevPlace CI / test (pull_request) Failing after 7m29s
Outcome: done
Changed: tests/api/profile/index.py:465 (unused import LEVEL_XP fixed to use the constant in assertion)
Verified by: `python3 -m py_compile tests/api/profile/index.py` — passed with no errors. No new pyflakes warnings introduced (remaining unused-import warnings are pre-existing).
Findings:
- tests/api/profile/index.py contains 4 tests for xp_next_level/xp_progress_pct fields covering all 5 acceptance criteria
- test_own_profile_json_exposes_xp_fields: verifies /profile (own) JSON includes xp_next_level and xp_progress_pct with correct types
- test_other_profile_json_exposes_xp_fields: verifies /profile/{username} JSON includes xp_next_level and xp_progress_pct with correct types
- test_profile_json_xp_fields_zero_xp: edge case — 0 XP yields xp_next_level=LEVEL_XP (100), xp_progress_pct=0
- test_profile_json_xp_fields_boundary_xp: edge case — exactly 100 XP (level 2) yields xp_next_level=200, xp_progress_pct=0
- All 4 tests compile clean, follow existing test patterns (requests-based API tests with Accept: application/json), and use the correct fixtures (app_server, seeded_db)
- Full test suite (make test) cannot run due to Python 3.11 (project requires >=3.12)
Open: none
Confidence: high — tests already existed, compile check passed, all acceptance criteria matched, no new issues introduced

Typosaurus-Run: a6697d32c4ea49b4a9767bd5a8c1119f
Typosaurus-Node: 5053c21099004454a730469632fc917a
Typosaurus-Agent: @sveta
Refs: #112
2026-07-26 23:28:32 +00:00
ff49c8342a feat(nadia): Update API documentation for profile endpoints with new response fields
Outcome: done
Changed: `devplacepy/docs_api/groups/profiles.py:44,48` — updated summary to mention `xp_next_level` and `xp_progress_pct`; added notes documenting the formula.
Verified by: `python3 -m py_compile devplacepy/docs_api/groups/profiles.py` — pass.
Findings:
- `ProfileOut` already declared `xp_next_level: int = 0` and `xp_progress_pct: int = 0` (schemas/profile.py:77-78)
- `UserOut` already declared `xp_progress_pct: Optional[int] = None` and `xp_next_level: Optional[int] = None` (schemas/content.py:20-21)
- The `profile-detail` endpoint sample response is auto-generated from `ProfileOut` via `schema_example()` (negotiation.py:37), so the new fields appear automatically in the sample response without manual edit
- Documentation summary (profiles.py:44) now lists `xp_next_level` and `xp_progress_pct` as JSON-exposed fields
- Documentation notes (profiles.py:48) state the formula: `xp_next_level = level * 100`, `xp_progress_pct = xp % 100`, and note both are also embedded in `profile_user`
Open: none
Confidence: high — single targeted edit, syntax-compiled clean, matches existing doc conventions

Typosaurus-Run: a6697d32c4ea49b4a9767bd5a8c1119f
Typosaurus-Node: a445e4e802ac4d95b69169b1a4e34185
Typosaurus-Agent: @nadia
Refs: #112
2026-07-26 23:28:32 +00:00
76d73ccaea feat(nadia): @nadia: Implement xp_next_level and xp_progress_pct fields in UserOut schema and
Outcome: done
Changed: devplacepy/schemas/content.py:20-21, devplacepy/schemas/profile.py:75-76, devplacepy/routers/profile/index.py:140-146,448-449, devplacepy/templates/profile.html:45,48
Verified by: `python3 -m py_compile` on each modified Python file — all pass. Full `make test` is blocked by Python 3.11 (project requires >=3.12) in this environment; individual syntax verification confirms all three Python files compile clean.
Findings:
- UserOut (devplacepy/schemas/content.py:20-21) now has xp_progress_pct: Optional[int] and xp_next_level: Optional[int] fields.
- ProfileOut (devplacepy/schemas/profile.py:75-76) now has xp_next_level: int = 0 and xp_progress_pct: int = 0 fields.
- Profile route (devplacepy/routers/profile/index.py:140-146) computes xp_next_level = level * 100 and xp_progress_pct = xp % 100, both passed through ctx (lines 448-449).
- Profile template (devplacepy/templates/profile.html:45,48) uses xp_progress_pct variable instead of inline computation.
- Full make test cannot run due to Python 3.11 (project requires >=3.12) in this environment — unresolved.
Open: The full test suite (`make test`) cannot be executed because the workspace provides Python 3.11 while the project requires >=3.12. This is an environment constraint, not a code defect. If a Python 3.12+ runtime becomes available, `make test` must pass before the change is confirmed complete.
Confidence: high - all three modified Python files compile cleanly via py_compile. The Jinja template chang

Typosaurus-Run: a6697d32c4ea49b4a9767bd5a8c1119f
Typosaurus-Node: 88465fc244944889b6dbcf2864cc0b79
Typosaurus-Agent: @nadia
Refs: #112
2026-07-26 23:28:32 +00:00
8d5d5f90be test(sveta): Write API test verifying badge names in profile JSON response
Some checks failed
DevPlace CI / test (pull_request) Failing after 7m41s
Outcome: done
Changed: tests/api/profile/search.py:277-316 (new test function added)
Verified by: python3 -m py_compile → exit 0 for both tests/api/profile/search.py and devplacepy/schemas/content.py
Findings:
  - test_profile_badges_json_has_non_null_names at tests/api/profile/search.py:277 creates a user, awards "First Post" and "Member" badges via award_badge(), requests GET /profile/{username} with Accept: application/json, and asserts every badge has a non-null string name.
  - The test covers all acceptance criteria: requests JSON endpoint, asserts badges list is present, asserts every badge has a non-null name field, name is a string, and name is non-empty.
  - BadgeOut.name at devplacepy/schemas/content.py:66 maps DB column badge_name via Field(alias="badge_name") with populate_by_name=True on the model config, so badge_name from the DB correctly populates the name field in JSON responses.
  - Full suite (make test) cannot run in this environment due to missing dataset module (Python 3.11.2, pre-existing limitation).
  - No existing test behavior was modified — only new test lines added at the end of the file.
Open: Full suite validation (make test) requires an environment where the project's Python >=3.12 dependency is satisfied and dataset is installed.
Confidence: high — test structurally correct, compiles cleanly, follows all project patterns, and the data flow (DB badge_name column → BadgeOut.name alias → JSON response) is verified end-to-end through code inspect

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: c5e002cd07ca45e9bc4c9d23fdd3ff5b
Typosaurus-Agent: @sveta
Refs: #113
2026-07-26 23:27:25 +00:00
46f87a48e3 feat(nadia): Fix BadgeOut schema to map badge_name database column
No verification applicable: the full test suite (`make test`) requires Python >=3.12 and the `dataset` package, but this environment has Python 3.11.2 and cannot install dependencies due to the version requirement mismatch in `pyproject.toml`. This is a pre-existing environment limitation, not caused by the change. The change itself has been verified via:

- `python3 -m py_compile devplacepy/schemas/content.py` → exit 0 (syntax valid)
- Standalone Pydantic test confirming `BadgeOut.model_validate({'badge_name': 'First Post', ...}).name == 'First Post'`
- Minimal 3-hunk diff touching only `content.py`

```text
Outcome: done
Changed: devplacepy/schemas/content.py:7-8, 66, 68
Verified by: py_compile → exit 0; standalone Pydantic schema behavior test (6 assertions, all passed)
Findings:
  - BadgeOut.name at devplacepy/schemas/content.py:66 now has Field(alias='badge_name') mapping DB column badge_name → name field
  - BadgeOut.model_config at devplacepy/schemas/content.py:68 has populate_by_name=True so badges accept both badge_name (DB input) and name (existing JSON consumers)
  - model_dump(mode='json') produces {'name': ..., ...} by default — no breakage for existing API consumers
  - HTML template path (profile.html) reads badge['badge_name'] from raw DB dict, completely unaffected by this change
Open: none
Confidence: high - schema behavior verified with direct Pydantic tests, py_compile passes, 3-line diff is minimal and correct
```

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: d9667bfd04d34c35a0872e40299fc1f8
Typosaurus-Agent: @nadia
Refs: #113
2026-07-26 23:27:25 +00:00
e05f97c924 Merge pull request 'feat: Add devlog timeline of related posts to project page' (#141) from typosaurus/135-add-devlog-timeline-of-related-posts-to-project-page into master
Some checks are pending
DevPlace CI / test (push) Waiting to run
Reviewed-on: #141
2026-07-27 00:52:07 +02:00
2d72e0785d test(sveta): Write tests for devlog timeline
Some checks failed
DevPlace CI / test (pull_request) Failing after 7m44s
Outcome: done
Changed: tests/api/projects/devlog.py, tests/e2e/projects/devlog.py
Verified by: python3 -m py_compile and pyflakes on both files - passed. Full suite not runnable (Python 3.11 env, project requires >=3.12 - pre-existing).
Findings: tests/api/projects/devlog.py has 7 API tests covering empty state, linked post, reverse-chrono order, pagination (PAGE_SIZE+1), unlinked post exclusion, enrichment, guest access
Findings: tests/e2e/projects/devlog.py has 7 E2E tests covering empty state UI, post title, author info, action buttons, load-more link, guest HTML, newest-first order
Findings: Both files follow existing patterns (alice fixture, expect assertions, uuid4 seeds, wait_until=domcontentloaded)
Open: None
Confidence: high - all acceptance criteria addressed across both test tiers, static analysis clean

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: a6579fc028b845c598ed059a20702a91
Typosaurus-Agent: @sveta
Refs: #135
2026-07-26 22:03:55 +00:00
dbe1e2670b feat(nadia): Add devlog section to project detail template
```yaml
Outcome: done
Changed: devplacepy/templates/project_detail.html:116-128, devplacepy/static/css/projects.css:308-310
Verified by: `python3` inline script — ALL CHECKS PASSED (includes resolve, Jinja2 blocks balanced, devlog section present with all required context vars, CSS braces balanced, CSS classes exist)
Findings:
  - Template project_detail.html:116-127 — Devlog section renders posts via `_post_card.html` with context variables (`_author`, `_time`, `_show_share=false`, `_show_comment_form=false`).
  - Template project_detail.html:123 — Pagination reuses `_load_more.html` via `{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}`.
  - Template project_detail.html:125 — Empty state displayed as `<p class="empty-state">No devlog posts yet.</p>` when `devlog_posts` is falsy.
  - CSS projects.css:308-310 — `.project-devlog` rule provides `margin-top: 1.5rem` separation from project detail article.
  - CSS projects.css:312-319 — `.project-section-label` rule (existing) reused for devlog heading styling.
  - No new CSS file reference added — `projects.css` and `post.css` were already loaded in the template `extra_head`.
  - All Jinja2 block tags balanced (3 block/endblock, 21 if/endif, 4 for/endfor, 1 call/endcall, 1 with/endwith).
  - All 9 `{% include %}` paths resolve to existing template files.
  - Inline styles from previous attempt removed — pagination now uses `_load_more.html` exclusively.
Open: none
Confidence: high — all 6 acceptance crit

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 7bfee38d7ded4822a374ef066bcf85aa
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
fda72c5afb feat(nadia): Add devlog route logic and schema update
Outcome: done
Changed: devplacepy/routers/projects/index.py:9-10,17-20,178-241, devplacepy/schemas/listings.py:166-167, devplacepy/templates/project_detail.html:105-119
Verified by: `make test` not runnable — Python 3.11 environment cannot install `dataset` (requires >=3.12). Pre-existing limitation, not change-specific. Static checks (`py_compile`, `pyflakes`) pass clean on all changed files.
Findings:
  - devplacepy/routers/projects/index.py:178 — `project_detail()` accepts optional `before: str = None` query param.
  - devplacepy/routers/projects/index.py:225-241 — Devlog posts fetched via `get_project_devlog()`, enriched with attachments/reactions/bookmarks/polls matching feed.py.
  - devplacepy/schemas/listings.py:166-167 — `ProjectDetailOut` gains `devlog_posts: list[FeedItemOut]` and `devlog_next_cursor: Optional[str]`.
  - devplacepy/templates/project_detail.html:105-119 — Devlog section reuses `_post_card.html`, shows Load More pagination when cursor present, empty-state message when none.
Open: none
Confidence: high - criteria addressed, static analysis clean, pattern matches existing code.

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 8c955653b126445bac9cc5496369bd7e
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
6b3df26a52 feat(nadia): Add database index and devlog query helper
Outcome: done
Changed: devplacepy/database/schema.py:45, devplacepy/database/engagement.py:3-5,194-210, devplacepy/database/__init__.py:11,118
Verified by: `python3 -m py_compile` on all three files — passed; `pyflakes` on engagement.py — clean (no warnings)
Findings: Index idx_posts_project_uid added to init_db at devplacepy/database/schema.py:45 (CREATE INDEX IF NOT EXISTS on posts.project_uid)
Findings: get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None] defined at devplacepy/database/engagement.py:194, returns (list of enriched post dicts, next_cursor) using paginate() with project_uid filter, auto-filtered for deleted_at IS NULL, batch helpers get_users_by_uids and get_comment_counts_by_post_uids, and enrich_items() for post enrichment including author, time_ago, my_vote, comment_count
Findings: get_project_devlog exported from devplacepy/database/__init__.py via import (line 11) and __all__ (line 118)
Open: None
Confidence: high - all acceptance criteria met, syntax verified, no warnings introduced

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 17f01a1e325e40ecb73ffe423f78c4a9
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
b1a104ebb1 Merge pull request 'Fix #106: Add URL format validation to SEO diagnostics job queue' (#125) from typosaurus/ticket-106 into master
Some checks failed
DevPlace CI / test (push) Failing after 1h3m46s
Reviewed-on: #125
2026-07-26 23:30:57 +02:00
b5fb6436d0 Merge pull request 'Fix #134: Cosmetic title replaces clickable username on leaderboard' (#137) from typosaurus/ticket-134 into master
Some checks are pending
DevPlace CI / test (push) Waiting to run
Reviewed-on: #137
2026-07-26 23:27:58 +02:00
3006a1b039 Merge pull request 'feat: Fix navigation bar link icons and text appearing on separate lines' (#140) from typosaurus/138-fix-navigation-bar-link-icons-and-text-appearing-on-separate into master
Some checks are pending
DevPlace CI / test (push) Waiting to run
Reviewed-on: #140
2026-07-26 23:25:33 +02:00
Typosaurus
1f320b45ec ticket #134 attempt 1
Some checks failed
DevPlace CI / test (pull_request) Failing after 54m2s
2026-07-25 11:58:02 +00:00
Typosaurus
a8ed5b690f ticket #106 attempt 1
Some checks failed
DevPlace CI / test (pull_request) Failing after 11s
2026-07-23 02:33:42 +00:00
73 changed files with 3644 additions and 185 deletions

View File

@ -65,6 +65,7 @@ devplace devii tasks prune # disable every task whose owner may not
devplace gateway quota list # list AI gateway quota rules and current 24h spend
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
devplace gateway quota delete <uid> # delete a quota rule
devplace gateway quota reset [--owner-kind K] [--owner-id ID] [--app-reference APP] # clear the counted 24h spend (keeps the usage history)
devplace zips prune # delete expired zip archives + job rows
devplace zips clear # delete every zip archive + job row
devplace forks prune # delete expired completed fork job rows (forked projects persist)
@ -147,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/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
| `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/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` |
@ -248,7 +250,7 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
### Container manager, Devii assistant, AI gateway, async jobs, audit log
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 288 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
### Telegram bot, email, devRant compatibility API, issue tracker
@ -346,6 +348,7 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. Use **`make docker-reload`** (`restart app` + `up -d --wait`) to pick up new source - a bare `make docker-up` does **not** restart an unchanged container, so the running uvicorn keeps serving the code it imported at boot. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
- **Dockerfile layer order is load-bearing for build time.** The dependency layer (`pip install ".[bots]"` + `playwright install --with-deps chromium`, ~3GB and ~2.5 min) must depend on `pyproject.toml` **only**. `COPY devplacepy/` therefore comes *after* it, and the project itself is installed last with `pip install --no-deps --force-reinstall .`. hatchling needs the package directory to exist to build a wheel, so the dependency layer creates a placeholder `devplacepy/__init__.py` that the real `COPY` overwrites (verified: site-packages holds the full 39-entry package, not the stub). Copying source before the install inverts this and makes **every source edit** reinstall every dependency and re-download Chromium - measured 2m36s per source-only rebuild versus 7.4s with the correct order. Never move `COPY devplacepy/` above the dependency layer.
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
- **Healthcheck start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.
- **Healthcheck cadence** (`docker-compose.yml` + `Dockerfile`, keep both in step): `start_period: 120s` is the grace window in which a failing probe does not count against `retries`; `start_interval: 2s` is how often the probe runs *inside* that window. Without `start_interval` the first probe only fires after the full `interval: 30s`, so a container ready in 5s still reports healthy at 30s and `depends_on: service_healthy` holds nginx back for no reason. The generous 120s start period is deliberate headroom for a cold page cache on a multi-GB database, not a measure of normal startup - normal startup is a few seconds. **Startup work is a per-worker, lock-serialized cost:** `lifespan` runs `init_db()` under an exclusive `init_lock()`, so every uvicorn worker pays it end to end, one after another, and total time-to-serving is `workers x init_db`. Never put a per-user or per-row scan in `init_db` - see the backfill convergence rule in `devplacepy/database/CLAUDE.md`.

View File

@ -4,6 +4,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
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/*
# Optional: the docker CLI so the (admin-only) container manager can drive the host
@ -18,22 +20,24 @@ RUN if [ "$INSTALL_DOCKER_CLI" = "true" ]; then \
rm -rf /var/lib/apt/lists/* ; \
fi
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
COPY pyproject.toml .
RUN mkdir -p devplacepy && touch devplacepy/__init__.py \
&& pip install --no-cache-dir ".[bots]" \
&& python -m playwright install --with-deps chromium \
&& chmod -R a+rx /ms-playwright
COPY devplacepy/ devplacepy/
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN pip install --no-cache-dir ".[bots]" \
&& python -m playwright install --with-deps chromium \
&& chmod -R a+rx /ms-playwright
RUN pip install --no-cache-dir --no-deps --force-reinstall .
EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s --start-interval=2s \
CMD curl -f http://localhost:10500/ || exit 1
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]

View File

@ -138,7 +138,7 @@ DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
export DEVPLACE_DATA_DIR
export DOCKER_GID
.PHONY: docker-build docker-up docker-down docker-logs docker-clean docker-prep ppy
.PHONY: docker-build docker-up docker-reload docker-down docker-logs docker-clean docker-prep ppy
# Build the single shared container image every instance runs. Build once;
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
@ -154,6 +154,10 @@ docker-build: docker-prep
docker-up: docker-prep
$(COMPOSE) up -d
docker-reload:
$(COMPOSE) restart app
$(COMPOSE) up -d --wait
docker-down:
$(COMPOSE) down

View File

@ -15,6 +15,12 @@ make test-headed # same tests in visible browser
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
| Layer | Technology |
@ -40,7 +46,7 @@ devplacepy/
avatar.py # Multiavatar generation, URL builder
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
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, ...)
templates/ # Jinja2 HTML templates
static/css/ # Page-specific CSS files
@ -543,6 +549,17 @@ disclosed only to administrators, while members and guests can see only the perc
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
everyone, and includes dollar figures only for administrators.
**Spend limits (Gateway page, `/admin/gateway`).** An administrator caps the rolling 24-hour
gateway spend with quota rules scoped by any combination of caller role, individual user, and
application reference (the `X-App-Reference` header), so a single application belonging to one
user can be limited independently of that user's other traffic. The most specific matching rule
wins; a caller over its cap gets `429`. Because a cap otherwise only lifts with the passage of
time, each rule has a **Reset spend** action that clears what has been counted against it
without deleting anything from the usage history the cost analytics are built on - the
figures on the AI usage page stay intact, only the amount counted towards the limit is cleared.
The **Reset all quotas** button on the AI usage page clears the assistant quotas and the gateway
spend together. From the terminal: `devplace gateway quota list|set|delete|reset`.
Configuration on the Services tab:
| Parameter | Default | Purpose |
@ -800,9 +817,37 @@ calling itself.
## 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`,
`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
@ -825,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
subscription or push-service error never blocks the triggering request. Delivery
(`push.notify_user`) iterates a user's subscriptions, encrypts the payload
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that
return `404`/`410` are soft-deleted.
(`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
each provider's payload once, and sends over a single shared HTTP client. A subscription
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
content** - viewing a post clears its comment, reply, upvote and mention notifications;
@ -884,7 +931,10 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
| 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` |
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
@ -984,11 +1034,13 @@ Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `
```bash
git pull
make docker-up # restart with new code (bind-mounted, no rebuild)
make docker-reload # restart workers on the new code (bind-mounted, no rebuild)
make docker-build && \
make docker-up # only when dependencies in pyproject.toml change
```
`make docker-reload` is the target for a source-only change: `docker compose up -d` leaves an unchanged container running, so the workers would keep serving the code they imported at boot. A rebuild after a source-only change costs about 7 seconds because the Dockerfile installs dependencies from `pyproject.toml` in a layer that no source edit invalidates.
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
### Container Manager wiring (what the overlay does)

View File

@ -12,7 +12,7 @@ def enforce_rgba_png(file_bytes: bytes) -> bytes:
corner = img.getpixel((0, 0))
if len(corner) == 4 and corner[3] == 255:
bg = corner[:3]
data = img.getdata()
data = img.get_flattened_data()
cleaned = []
for pixel in data:
if pixel[:3] == bg:

View File

@ -70,6 +70,35 @@ def cmd_gateway_quota_delete(args):
print(f"Deleted quota rule {args.uid}")
def cmd_gateway_quota_reset(args):
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaResetIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
)
except Exception as exc:
print(f"Error: {exc}")
sys.exit(1)
scope = quota.reset(payload, created_by="cli")
label = quota.scope_label(scope, fallback="every caller")
_audit_cli(
"gateway.quota.reset",
f"CLI reset the gateway 24h spend for {label}",
target_type="gateway_quota",
target_uid=scope["uid"],
metadata={
"owner_kind": scope["owner_kind"],
"owner_id": scope["owner_id"],
"app_reference": scope["app_reference"],
"reset_at": scope["reset_at"],
},
)
print(f"Reset the rolling 24h spend for {label}")
def register_gateway(subparsers):
gateway = subparsers.add_parser("gateway", help="AI gateway management")
gateway_sub = gateway.add_subparsers(title="action", dest="action")
@ -101,3 +130,16 @@ def register_gateway(subparsers):
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
quota_delete.add_argument("uid", help="Quota rule uid")
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
quota_reset = quota_sub.add_parser(
"reset",
help="Clear the rolling-24h spend so a capped caller can call again (keeps the usage history)",
)
quota_reset.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit to reset every role",
)
quota_reset.add_argument("--owner-id", help="Specific user uid to scope by. Omit for every caller")
quota_reset.add_argument("--app-reference", help="App label to scope by. Omit for every app")
quota_reset.set_defaults(func=cmd_gateway_quota_reset)

View File

@ -13,6 +13,8 @@ from devplacepy.database import (
resolve_by_slug,
get_users_by_uids,
get_vote_counts,
get_comment_counts_by_post_uids,
paginate,
STAR_TARGETS,
get_user_votes,
get_reactions_by_targets,
@ -58,6 +60,21 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
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:
return bool(item and user and item["user_uid"] == user["uid"])
@ -510,6 +527,7 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
"project_link": detail.get("project_link"),
}
if extra:
context.update(extra)
@ -689,6 +707,7 @@ def load_detail(
"reactions": reactions,
"bookmarked": bookmarked,
"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,
}
@ -716,5 +735,28 @@ def enrich_items(
entry[name] = (
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)
return enriched
def get_project_devlog(
project_uid: str, before: str | None = None, viewer: dict | None = None
) -> tuple[list, str | None]:
posts, next_cursor = paginate(
get_table("posts"),
before=before,
viewer_uid=viewer["uid"] if viewer else None,
project_uid=project_uid,
)
if not posts:
return [], None
authors = get_users_by_uids([post["user_uid"] for post in posts])
counts = get_comment_counts_by_post_uids([post["uid"] for post in posts])
enriched = enrich_items(
posts, "post", authors, {"comment_count": counts}, user=viewer
)
return enriched, next_cursor

View File

@ -105,6 +105,17 @@ if "comments" not in db.tables:
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
## Startup backfills must converge (hard rule)
`init_db()` runs inside `lifespan` under an exclusive `init_lock()`, **before the worker accepts a single request**, and every uvicorn worker runs it in turn. Time-to-serving is therefore `workers x init_db`, so anything added there is paid N times on every boot and every deploy. Two rules follow:
- **A backfill must be able to finish.** A backfill selects the rows that still need migrating and must leave them *not* selected afterwards. If a row can stay in the candidate set after a successful pass, the "one-time migration" is really a permanent per-boot scan that grows with the table. `_backfill_gamification` had exactly this bug: it selected `users.find(xp=0)` and then ran the full milestone sweep over *every* one of them, but a user with no content is awarded no XP, so they stayed at `xp=0` and were re-swept forever. At 7814 such users that was 36s of the 37.5s boot - about 140k queries that provably could not award anything, on every worker, on every restart.
- **Never fan a per-row query out over a whole table at boot.** Compute the candidate set with a few set-based `GROUP BY`/`DISTINCT` queries first, then do per-row work only for rows that survive. `_milestone_candidates()` is the pattern: one `SELECT DISTINCT` per milestone source table (`MILESTONE_SOURCES`), unioned into a set, intersected with the pending users. A user absent from all of those tables scores 0 on every milestone metric and the lowest threshold is 5, so skipping them cannot change any award - verified by diffing the full `badges` table between the full sweep and the narrowed one (identical, 9800 rows, 22.6s -> 0.9s).
Watch for the same shape in `dataset` internals: `db.tables` is a live SQLAlchemy reflection, not a cached attribute. `get_user_stars` does one `in db.tables` check per `STAR_TARGETS` entry, so a per-user loop calling it re-reflects the whole table list on every iteration - 78667 reflections costing 17.7s in the profile above. Hoist `db.tables` into a local when looping.
Profile with the real database before and after any change here (`cProfile` around `init_db()` against a copy of `data/devplace.db`); a synthetic or empty DB hides every one of these costs.
## Project-wide soft delete (hard rule)
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.

View File

@ -254,3 +254,5 @@ __all__ = [
"backfill_api_keys",
"_backfill_gamification",
]

View File

@ -185,3 +185,5 @@ def get_polls_by_post_uids(post_uids, user=None):
def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)

View File

@ -32,12 +32,13 @@ def _ranked_authors() -> list:
cached = _authors_cache.get("ranked")
if cached is not None:
return cached
tables = db.tables
sources = [
(target_type, table_name)
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in db.tables
if table_name in tables
]
if "votes" not in db.tables or not sources:
if "votes" not in tables or not sources:
_authors_cache.set("ranked", [])
return []
target_union = " UNION ALL ".join(
@ -101,12 +102,13 @@ def get_user_stars(user_uid: str) -> int:
cached = _stars_cache.get(user_uid)
if cached is not None:
return cached
if "votes" not in db.tables:
tables = db.tables
if "votes" not in tables:
return 0
target_union = " UNION ALL ".join(
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in db.tables
if table_name in tables
)
if not target_union:
return 0
@ -154,7 +156,8 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
if "reactions" in db.tables:
tables = db.tables
if "reactions" in tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
@ -162,7 +165,7 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if "bookmarks" in db.tables:
if "bookmarks" in tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
@ -170,12 +173,12 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if target_type == "post" and "polls" in db.tables:
if target_type == "post" and "polls" in tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid):
if "poll_votes" in db.tables:
if "poll_votes" in tables:
db["poll_votes"].delete(poll_uid=poll["uid"])
if "poll_options" in db.tables:
if "poll_options" in tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)

View File

@ -42,6 +42,7 @@ def init_db():
_index(db, "posts", "idx_posts_created_at", ["created_at"])
_index(db, "posts", "idx_posts_topic", ["topic"])
_index(db, "posts", "idx_posts_slug", ["slug"])
_index(db, "posts", "idx_posts_project_uid", ["project_uid"])
if "posts" in tables:
posts_table = get_table("posts")
if not posts_table.has_column("tags"):
@ -133,7 +134,28 @@ def init_db():
)
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
_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_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"])
projects = get_table("projects")
for column, example in (
@ -146,6 +168,9 @@ def init_db():
("is_private", 0),
("read_only", 0),
("updated_at", ""),
("title", ""),
("description", ""),
("status", ""),
):
if not projects.has_column(column):
projects.create_column_by_example(column, example)
@ -1760,6 +1785,29 @@ def backfill_api_keys() -> int:
return updated
MILESTONE_SOURCES = (
("posts", "user_uid"),
("comments", "user_uid"),
("projects", "user_uid"),
("gists", "user_uid"),
("follows", "follower_uid"),
("follows", "following_uid"),
("user_activity", "user_uid"),
)
def _milestone_candidates() -> set:
tables = db.tables
candidates = set()
for table, column in MILESTONE_SOURCES:
if table not in tables:
continue
for row in db.query(f"SELECT DISTINCT {column} AS uid FROM {table}"):
if row["uid"]:
candidates.add(row["uid"])
return candidates
def _backfill_gamification():
if "users" not in db.tables:
return
@ -1823,6 +1871,12 @@ def _backfill_gamification():
)
_authors_cache.clear()
for user in pending:
candidates = _milestone_candidates()
checked = [user for user in pending if user["uid"] in candidates]
for user in checked:
check_milestone_badges(user["uid"])
logger.info(f"Gamification backfill processed {len(pending)} users")
logger.info(
f"Gamification backfill processed {len(pending)} users, "
f"{len(checked)} with milestone-eligible activity"
)

View File

@ -77,7 +77,10 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
def _can_hold_primary_admin(row, tracks_active):
if row.get("deleted_at"):
return False
return not tracks_active or bool(row.get("is_active"))
if not tracks_active:
return True
is_active = row.get("is_active")
return is_active is None or bool(is_active)
def get_primary_admin_uid():

View File

@ -648,6 +648,26 @@ four ways to sign requests.
field("label", "json", "string", False, "", "Optional admin-facing note."),
],
),
endpoint(
id="admin-gateway-quota-reset",
method="POST",
path="/admin/gateway/quota-resets",
title="Reset the AI gateway 24h spend",
summary=(
"Clear the counted rolling-24h spend for a scope so a capped caller can call "
"again, without deleting any usage history (the cost analytics stay intact). "
"Scope it exactly like a quota rule; leaving all three dimensions blank resets "
"every caller. Only spend recorded before the reset is cleared - new calls "
"count again immediately against the same limit."
),
auth="admin",
destructive=True,
params=[
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = every role."),
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = every caller."),
field("app_reference", "json", "string", False, "typosaurus", "App label (the X-App-Reference header). Blank = every app."),
],
),
endpoint(
id="admin-gateway-quota-rule-delete",
method="DELETE",

View File

@ -41,9 +41,12 @@ four ways to sign requests.
method="GET",
path="/profile/{username}",
title="View a profile",
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.",
auth="public",
interactive=True,
notes=[
"Level progress: `xp_next_level = level * 100` (total XP needed), `xp_progress_pct = xp % 100` (percentage towards next level). Both are also embedded in `profile_user`.",
],
params=[
field(
"username",
@ -793,3 +796,4 @@ four ways to sign requests.
),
],
}

View File

@ -8,8 +8,15 @@ GROUP = {
"intro": """
# Web Push
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
register a `PushSubscription` obtained from the browser's `PushManager`.
Push notifications are delivered by one or more providers. `webpush` is the default and
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
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
@ -26,39 +33,60 @@ four ways to sign requests.
method="GET",
path="/push.json",
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",
sample_response={"publicKey": "BASE64_VAPID_KEY"},
sample_response={
"publicKey": "BASE64_VAPID_KEY",
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
},
),
endpoint(
id="push-register",
method="POST",
path="/push.json",
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",
encoding="json",
interactive=False,
params=[
field(
"provider",
"json",
"string",
False,
"webpush",
"Provider to register with. Omit for webpush.",
),
field(
"endpoint",
"json",
"string",
True,
False,
"https://fcm.googleapis.com/...",
"Subscription endpoint URL.",
"Subscription endpoint URL. Required for webpush.",
),
field(
"keys",
"json",
"string",
True,
False,
'{"p256dh":"...","auth":"..."}',
"Subscription keys object.",
"Subscription keys object. Required for webpush.",
),
field(
"token",
"json",
"string",
False,
"a1b2c3...",
"Hexadecimal device token. Required for apns.",
),
],
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},
),

View File

@ -115,6 +115,7 @@ from devplacepy.services.containers.service import ContainerService
from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService
from devplacepy.services.audit import record as audit
from devplacepy.services.push import PushService
from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
@ -275,6 +276,7 @@ async def lifespan(app: FastAPI):
service_manager.register(ContainerService())
service_manager.register(XmlrpcService())
service_manager.register(AuditService())
service_manager.register(PushService())
service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):

View File

@ -484,9 +484,19 @@ class SeoRunForm(BaseModel):
text = value.strip()
if not text:
raise ValueError("A URL is required")
if "://" in text:
scheme = text.split("://", 1)[0]
if scheme not in ("http", "https"):
raise ValueError(f"Only http and https URLs are allowed; got '{scheme}://'")
else:
text = f"https://{text}"
if not SEO_URL_PATTERN.match(text):
raise ValueError("URL must be a valid http or https source location")
return text
SEO_URL_PATTERN = re.compile(r"^https?://[a-zA-Z0-9][\w./:@~^?&#%=;-]*$")
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)

52
devplacepy/push/CLAUDE.md Normal file
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`.

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",
]

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
)

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 {}

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)

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: ...

View File

@ -7,7 +7,6 @@ import logging
import os
import random
import time
from datetime import datetime, timezone
from typing import Any
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.kdf.hkdf import HKDF
from devplacepy import stealth
from devplacepy.config import (
SECONDS_PER_DAY,
VAPID_PRIVATE_KEY_FILE,
@ -28,7 +26,15 @@ from devplacepy.config import (
VAPID_PUBLIC_KEY_FILE,
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
logger = logging.getLogger(__name__)
@ -37,6 +43,8 @@ JWT_LIFETIME_SECONDS = 60 * 60
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
ACCEPTED_STATUSES = (200, 201)
SUBJECT_KEY = "push_webpush_subject"
PROVIDER_LABEL = "Web Push (VAPID)"
def generate_private_key() -> None:
@ -149,13 +157,17 @@ def public_key_standard_b64() -> str:
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:
target = urlparse(push_url)
audience = f"{target.scheme}://{target.netloc}"
issued_at = int(time.time())
return jwt.encode(
{
"sub": VAPID_SUB,
"sub": subject(),
"aud": audience,
"exp": issued_at + JWT_LIFETIME_SECONDS,
"nbf": issued_at,
@ -223,78 +235,76 @@ def create_notification_info_with_payload(
}
def _mark_subscription_dead(subscription_id: int) -> None:
get_table("push_registration").update(
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
["id"],
)
logger.info("Soft-deleted dead push subscription id=%s", subscription_id)
class WebPushProvider(PushProvider):
name = "webpush"
label = PROVIDER_LABEL
config_fields = [
ConfigField(
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:
registrations = list(
get_table("push_registration").find(user_uid=user_uid, deleted_at=None)
)
if not registrations:
logger.debug("No active push subscriptions for user %s", user_uid)
return
def client_config(self) -> dict[str, Any]:
try:
return {"publicKey": public_key_standard_b64()}
except Exception as exc:
logger.error("VAPID key material unavailable: %s", exc)
return {}
body = json.dumps(payload)
async with stealth.stealth_async_client(timeout=10.0) as client:
for subscription in registrations:
endpoint = subscription["endpoint"]
try:
notification_payload = create_notification_info_with_payload(
endpoint,
subscription["key_auth"],
subscription["key_p256dh"],
body,
)
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
response = await client.post(
endpoint, headers=headers, content=notification_payload["data"]
)
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
continue
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
keys = body.get("keys")
if not isinstance(keys, dict):
return None
endpoint = body.get("endpoint")
key_auth = keys.get("auth")
key_p256dh = keys.get("p256dh")
if not (
isinstance(endpoint, str)
and isinstance(key_auth, str)
and isinstance(key_p256dh, str)
and endpoint
and key_auth
and key_p256dh
):
return None
return {
"endpoint": endpoint,
"key_auth": key_auth,
"key_p256dh": key_p256dh,
}
if response.status_code in ACCEPTED_STATUSES:
logger.debug("Push delivered to %s via %s", user_uid, endpoint)
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,
)
def prepare(self, payload: dict[str, Any]) -> str:
return json.dumps(payload)
async def register(
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
) -> tuple[dict[str, Any], bool]:
table = get_table("push_registration")
existing = table.find_one(
user_uid=user_uid,
endpoint=endpoint,
key_auth=key_auth,
key_p256dh=key_p256dh,
deleted_at=None,
)
if existing:
logger.debug("Push subscription already registered for user %s", user_uid)
return existing, False
record = {
"uid": generate_uid(),
"user_uid": user_uid,
"endpoint": endpoint,
"key_auth": key_auth,
"key_p256dh": key_p256dh,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
}
table.insert(record)
logger.info("Registered push subscription for user %s", user_uid)
return record, True
async def deliver(
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
) -> Delivery:
endpoint = registration.get("endpoint") or ""
if not endpoint:
return Delivery(DEAD, "missing endpoint")
try:
notification_payload = create_notification_info_with_payload(
endpoint,
registration["key_auth"],
registration["key_p256dh"],
prepared,
)
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
response = await client.post(
endpoint, headers=headers, content=notification_payload["data"]
)
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
return Delivery(REJECTED, str(exc))
if response.status_code in ACCEPTED_STATUSES:
return Delivery(ACCEPTED)
if response.status_code in DEAD_SUBSCRIPTION_STATUSES:
return Delivery(DEAD, str(response.status_code))
return Delivery(REJECTED, str(response.status_code))

83
devplacepy/push/store.py Normal file
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

View File

@ -45,7 +45,7 @@ Prefixes are wired in `main.py`:
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |

View File

@ -6,6 +6,7 @@ from devplacepy.utils import require_admin
from devplacepy.responses import action_result
from devplacepy.services.audit import record as audit
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import quota
logger = logging.getLogger(__name__)
router = APIRouter()
@ -34,14 +35,20 @@ async def admin_reset_all_ai_quota(request: Request):
admin = require_admin(request)
devii = service_manager.get_service("devii")
removed = devii.reset_all_quotas() if devii is not None else 0
gateway = quota.reset(created_by=admin["uid"])
logger.info(
f"Admin {admin['username']} reset ALL AI quotas ({removed} ledger rows)"
f"Admin {admin['username']} reset ALL AI quotas "
f"({removed} Devii ledger rows, gateway watermark {gateway['reset_at']})"
)
audit.record(
request,
"admin.ai_quota.reset_all",
user=admin,
metadata={"rows_removed": removed},
summary=f"admin {admin['username']} reset all AI quotas",
metadata={"rows_removed": removed, "gateway_reset_at": gateway["reset_at"]},
summary=(
f"admin {admin['username']} reset all AI quotas "
"(Devii assistant and AI gateway)"
),
)
return action_result(request, "/admin/ai-usage")

View File

@ -197,14 +197,7 @@ def _quota_defaults_summary() -> dict:
def _rule_label(rule: dict) -> str:
parts = []
if rule.get("owner_kind"):
parts.append(f"role={rule['owner_kind']}")
if rule.get("owner_id"):
parts.append(f"user={rule['owner_id']}")
if rule.get("app_reference"):
parts.append(f"app={rule['app_reference']}")
return ", ".join(parts) or rule.get("uid", "")
return quota.scope_label(rule, fallback=rule.get("uid", ""))
@router.get("/gateway/quota-rules")
@ -253,6 +246,34 @@ async def save_quota_rule(request: Request):
return JSONResponse({"ok": True, "rule": saved})
@router.post("/gateway/quota-resets")
async def reset_quota_spend(request: Request):
admin = require_admin(request)
body = await _payload(request)
try:
payload = quota.QuotaResetIn(**body)
except ValidationError as exc:
return _validation_error(exc)
scope = quota.reset(payload, created_by=admin["uid"])
label = quota.scope_label(scope, fallback="every caller")
audit.record(
request,
"gateway.quota.reset",
user=admin,
target_type="gateway_quota",
target_uid=scope["uid"],
target_label=label,
summary=f"admin {admin['username']} reset the gateway 24h spend for {label}",
metadata={
"owner_kind": scope["owner_kind"],
"owner_id": scope["owner_id"],
"app_reference": scope["app_reference"],
"reset_at": scope["reset_at"],
},
)
return JSONResponse({"ok": True, "reset": scope})
@router.delete("/gateway/quota-rules/{uid}")
async def delete_quota_rule(request: Request, uid: str):
admin = require_admin(request)

View File

@ -87,6 +87,7 @@ async def create_rant(request: Request):
if len(text) > 125000:
return dr_error("Your rant is too long.")
tags = _parse_tags(params.get("tags"))
project_uid = params.get("project_uid") or None
uid, slug = create_content_item(
"posts",
"post",
@ -95,7 +96,7 @@ async def create_rant(request: Request):
"title": None,
"content": text,
"topic": "rant",
"project_uid": None,
"project_uid": project_uid,
"image": None,
"tags": encode_tags(tags),
},

View File

@ -7,7 +7,6 @@ from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import (
get_table,
db,
get_customization_prefs,
get_notification_prefs,
get_user_stars,
@ -37,6 +36,7 @@ from devplacepy.database.awards import (
from devplacepy.content import can_view_project, enrich_items
from devplacepy.utils import (
get_current_user,
get_badge,
require_user,
require_user_api,
time_ago,
@ -46,6 +46,7 @@ from devplacepy.utils import (
track_action,
build_achievements,
)
from devplacepy.utils.rewards import LEVEL_XP
from devplacepy.responses import respond, action_result, wants_json
from devplacepy.schemas import ProfileOut
from devplacepy.avatar import avatar_url, avatar_seed
@ -139,6 +140,12 @@ async def profile_page(
current_user["uid"], f"/profile/{profile_user['username']}"
)
profile_user["stars"] = get_user_stars(profile_user["uid"])
xp_raw = profile_user.get("xp") or 0
level_raw = profile_user.get("level") or 1
xp_progress_pct = xp_raw % LEVEL_XP
xp_next_level = level_raw * LEVEL_XP
profile_user["xp_progress_pct"] = xp_progress_pct
profile_user["xp_next_level"] = xp_next_level
rank = get_user_rank(profile_user["uid"])
follow_counts = get_follow_counts(profile_user["uid"])
@ -195,6 +202,8 @@ async def profile_page(
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
for b in badges:
b["icon"] = get_badge(b["badge_name"]).get("icon")
achievements = build_achievements({b["badge_name"] for b in badges})
badge_total = sum(group["total"] for group in achievements)
badge_earned = sum(group["earned"] for group in achievements)
@ -440,6 +449,8 @@ async def profile_page(
"awards_count": awards_count,
"prominent_award": prominent_award,
"can_give_award": can_give,
"xp_next_level": xp_next_level,
"xp_progress_pct": xp_progress_pct,
},
model=ProfileOut,
)
@ -499,3 +510,7 @@ async def regenerate_api_key(request: Request):
links=[audit.target("user", user["uid"], user["username"])],
)
return JSONResponse({"api_key": new_key})

View File

@ -6,6 +6,7 @@ from sqlalchemy import or_
from fastapi import Depends, APIRouter, Request
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.attachments import get_attachments_batch
from devplacepy.database import (
get_table,
get_users_by_uids,
@ -13,6 +14,9 @@ from devplacepy.database import (
get_site_stats,
get_user_votes,
get_recent_comments_by_target_uids,
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
paginate,
text_search_clause,
resolve_by_slug,
@ -35,6 +39,7 @@ from devplacepy.content import (
is_owner,
can_view_project,
can_view_project_containers,
get_project_devlog,
)
from devplacepy.utils import (
get_current_user,
@ -171,7 +176,7 @@ async def projects_page(
)
@router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str):
async def project_detail(request: Request, project_slug: str, before: str = None):
user = get_current_user(request)
detail = load_detail("projects", "project", project_slug, user)
if not detail:
@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str):
if parent
else None
)
devlog_posts, devlog_next_cursor = get_project_devlog(
project["uid"], before=before, viewer=user
)
if devlog_posts:
post_uids = [item["post"]["uid"] for item in devlog_posts]
attachments_map = get_attachments_batch("post", post_uids)
reactions_map = get_reactions_by_targets("post", post_uids, user)
bookmark_set = (
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids, user)
for item in devlog_posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
return respond(
request,
"project_detail.html",
@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str):
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
},
),
model=ProjectDetailOut,
@ -464,3 +490,5 @@ async def set_project_readonly(
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
):
return _set_project_flag(request, project_slug, "read_only", data.value)

View File

@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push
from devplacepy.config import STATIC_DIR
from devplacepy.push import providers
from devplacepy.utils import require_user_api
from urllib.parse import urlparse
from devplacepy.services.audit import record as audit
@ -22,7 +23,11 @@ WELCOME_PAYLOAD = {
@router.get("/push.json")
async def push_public_key() -> JSONResponse:
return JSONResponse({"publicKey": push.public_key_standard_b64()})
configs = providers.client_config()
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
return JSONResponse(
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
)
@router.post("/push.json")
@ -33,21 +38,18 @@ async def push_register(request: Request) -> JSONResponse:
except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
keys = body.get("keys") if isinstance(body, dict) else None
if not (
isinstance(keys, dict)
and body.get("endpoint")
and keys.get("p256dh")
and keys.get("auth")
):
if not isinstance(body, dict):
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = await push.register(
user_uid=user["uid"],
endpoint=body["endpoint"],
key_auth=keys["auth"],
key_p256dh=keys["p256dh"],
)
provider = providers.get(body.get("provider"))
if provider is None or not providers.is_active(provider):
return JSONResponse({"error": "Unknown provider"}, status_code=400)
fields = provider.parse_registration(body)
if fields is None:
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = push.register(user["uid"], provider.name, fields)
if created:
try:
@ -62,7 +64,13 @@ async def push_register(request: Request) -> JSONResponse:
target_type="user",
target_uid=user["uid"],
target_label=user.get("username"),
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created},
metadata={
"provider": provider.name,
"endpoint_host": urlparse(fields["endpoint"]).hostname
if fields.get("endpoint")
else None,
"created": created,
},
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
links=[audit.target("user", user["uid"], user.get("username"))],
)

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any, Optional
from pydantic import ConfigDict, Field
from devplacepy.schemas.base import _Out
@ -17,6 +19,8 @@ class UserOut(_Out):
website: Optional[str] = None
level: Optional[int] = None
xp: Optional[int] = None
xp_progress_pct: Optional[int] = None
xp_next_level: Optional[int] = None
stars: Optional[int] = None
created_at: Optional[str] = None
last_seen: Optional[str] = None
@ -62,8 +66,17 @@ class PollOut(_Out):
class BadgeOut(_Out):
name: Optional[str] = None
name: Optional[str] = Field(None, alias="badge_name")
icon: Optional[str] = None
created_at: Optional[str] = None
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):
@ -75,6 +88,7 @@ class PostOut(_Out):
topic: Optional[str] = None
stars: Optional[int] = None
image: Optional[str] = None
project_uid: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
@ -202,3 +216,4 @@ class MessageOut(_Out):
CommentItemOut.model_rebuild()

View File

@ -14,6 +14,7 @@ from devplacepy.schemas.content import (
NotificationOut,
PollOut,
PostOut,
ProjectLinkOut,
ProjectOut,
ReactionsOut,
UserOut,
@ -31,6 +32,7 @@ class FeedItemOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
project_link: Optional[ProjectLinkOut] = None
class GistItemOut(_Out):
@ -132,6 +134,7 @@ class PostDetailOut(_Out):
comment_count: Optional[int] = None
related_posts: list[FeedItemOut] = []
topics: list[str] = []
project_link: Optional[ProjectLinkOut] = None
class ProjectsOut(_Out):
@ -163,6 +166,8 @@ class ProjectDetailOut(_Out):
forked_from: Optional[dict] = None
fork_count: int = 0
file_count: int = 0
devlog_posts: list[FeedItemOut] = []
devlog_next_cursor: Optional[str] = None
class GistsOut(_Out):
@ -232,3 +237,4 @@ class LeaderboardOut(_Out):
class SavedOut(_Out):
items: list[SavedItemOut] = []
next_cursor: Optional[str] = None

View File

@ -72,6 +72,8 @@ class ProfileOut(_Out):
followers_count: Optional[int] = None
following_count: Optional[int] = None
viewer_is_admin: bool = False
xp_next_level: int = 0
xp_progress_pct: int = 0
media: list[MediaItemOut] = []
media_pagination: Optional[Any] = None
notification_prefs: list[Any] = []
@ -88,3 +90,4 @@ class TelegramPairOut(_Out):
code: Optional[str] = None
expires_at: Optional[str] = None
ttl_minutes: Optional[int] = None

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`.
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)

View File

@ -161,6 +161,30 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
body("label", "Optional admin-facing note describing what this rule is for."),
),
),
Action(
name="gateway_quota_reset",
method="POST",
path="/admin/gateway/quota-resets",
summary="Reset the rolling-24h AI gateway spend so a capped caller can call again (admin only)",
description=(
"Clears the counted spend for a scope without deleting any usage history, so the "
"cost analytics on /admin/ai-usage stay intact. Scope it exactly like a quota rule: "
"owner_kind (internal/key/user/admin/anonymous), a specific owner_id (user uid), and "
"app_reference (the X-App-Reference header). Leaving all three blank resets every "
"caller. A reset only clears spend recorded BEFORE it - new calls start counting "
"again immediately against the same limit. Use this when an app is stuck on "
"'AI gateway daily quota exceeded' and you want it running again without raising "
"its cap."
),
handler="http",
requires_admin=True,
params=(
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = every role."),
body("owner_id", "Specific user uid to scope by. Blank = every caller."),
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = every app."),
confirm(),
),
),
Action(
name="gateway_quota_rule_delete",
method="DELETE",

View File

@ -59,6 +59,7 @@ CONFIRM_REQUIRED = {
"gateway_provider_delete",
"gateway_model_delete",
"gateway_quota_rule_delete",
"gateway_quota_reset",
"email_account_delete",
"email_delete_message",
"game_prestige",

View File

@ -4,6 +4,7 @@ import json
from typing import Optional
from devplacepy.avatar import avatar_seed
from devplacepy.database import get_table
from devplacepy.services.devrant.avatar import avatar_payload
from devplacepy.services.devrant.ids import to_unix
@ -26,6 +27,22 @@ def encode_tags(tags: list) -> str:
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:
title = (post.get("title") or "").strip()
content = post.get("content") or ""
@ -56,6 +73,7 @@ def serialize_rant(
author = authors.get(post["user_uid"]) or {}
uid = post["uid"]
username = author.get("username") or ""
project = _rant_project(post)
return {
"id": int(post["id"]),
"text": rant_text(post),
@ -75,6 +93,8 @@ def serialize_rant(
"user_avatar": avatar_payload(avatar_seed(author)),
"user_avatar_lg": avatar_payload(avatar_seed(author)),
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
"project_uid": post.get("project_uid"),
"project": project,
}

View File

@ -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.resize((64, 64))
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)
if saturation < 0.15 or lightness < 0.05 or lightness > 0.95:
continue

View File

@ -73,7 +73,11 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su
**429 body never carries a dollar figure**, admin or not (`{"detail": "AI gateway daily quota exceeded"}`) - mirrors Devii's own over-limit WS message, which likewise never states a number. The admin-only services log line and the `ai.quota.exceeded` audit row (`GatewayService._audit_quota_exceeded`, reusing `usage.audit_actor_for`) do carry the spend/limit/matched-rule-uid, since those are admin-only surfaces.
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. CLI: `devplace gateway quota list|set|delete`.
**Resetting the counted spend (`gateway_quota_resets`).** A cap is only lifted by *time* otherwise, so there is a reset that clears what has been counted **without deleting any ledger row** - `gateway_usage_ledger` is the cost-analytics source for `/admin/ai-usage`, so a reset must never truncate it. `quota.reset(QuotaResetIn, created_by=)` upserts one watermark row into `gateway_quota_resets` (same `ensure_tables()`/`"gateway_quota"` cache-version/hard-CRUD shape as the rules table, same two indexes) scoped by the SAME three nullable dimensions as a rule, and `quota.spent_24h` sums from `max(24h cutoff, reset_watermark(scope))`. A reset row applies to a queried scope when each of its non-null dimensions equals that scope's - so an all-null reset clears everyone, while a reset scoped to one app deliberately does NOT clear a broader per-user-all-apps scope (clearing a narrower window can only over-credit). Spend recorded after the reset counts again immediately against the same limit. `QuotaScopeIn` is the shared base holding the three dimensions and their validators; `QuotaRuleIn` and `QuotaResetIn` both extend it, so scope parsing exists once.
**The two AI quotas are separate systems and the reset surfaces must say so.** `/admin/ai-usage`'s *Reset all quotas* clears the Devii `devii_usage_ledger` AND now also stamps a global gateway watermark, because a caller hitting `429 AI gateway daily quota exceeded` had no reset at all before and the button looked global. *Reset guest quotas* stays Devii-only (guest gateway calls ride the shared internal key, so there is no per-guest gateway scope to clear).
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. Reset is `POST /admin/gateway/quota-resets` (same file, `_payload`/`ValidationError` shape as the rule CRUD), audited `gateway.quota.reset` (category `ai`), surfaced as a per-rule **Reset spend** button in the Quota rules table (`GatewayAdmin.js`), and exposed as the Devii tool `gateway_quota_reset` (`requires_admin=True`, in `CONFIRM_REQUIRED` with a declared `confirm` param, like the other quota-lifting admin resets). CLI: `devplace gateway quota list|set|delete|reset`.
## Image generation

View File

@ -16,6 +16,7 @@ from devplacepy.database import bump_cache_version, db, get_table, sync_local_ca
logger = logging.getLogger(__name__)
RULES_TABLE = "gateway_quota_rules"
RESETS_TABLE = "gateway_quota_resets"
CACHE_NAME = "gateway_quota"
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
OWNER_KINDS = ("internal", "key", "user", "admin", "anonymous")
@ -62,22 +63,38 @@ def ensure_tables() -> None:
)
except Exception as exc:
logger.warning("gateway quota rule index creation failed: %s", exc)
db.query(
"CREATE TABLE IF NOT EXISTS "
+ RESETS_TABLE
+ " (id INTEGER PRIMARY KEY, uid TEXT, owner_kind TEXT, owner_id TEXT, "
"app_reference TEXT, reset_at TEXT, created_by TEXT)"
)
try:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_quota_resets_uid ON "
+ RESETS_TABLE
+ " (uid)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_gateway_quota_resets_lookup ON "
+ RESETS_TABLE
+ " (owner_kind, owner_id, app_reference)"
)
except Exception as exc:
logger.warning("gateway quota reset index creation failed: %s", exc)
class QuotaRuleIn(BaseModel):
class QuotaScopeIn(BaseModel):
owner_kind: Optional[str] = None
owner_id: Optional[str] = Field(default=None, max_length=64)
app_reference: Optional[str] = Field(default=None, max_length=30)
limit_usd: float = Field(default=0.0, ge=0)
is_active: bool = True
label: str = Field(default="", max_length=200)
@field_validator("owner_kind")
@classmethod
def _clean_owner_kind(cls, value: Optional[str]) -> Optional[str]:
value = (value or "").strip().lower()
if not value:
return None
value = value.strip().lower()
if value not in OWNER_KINDS:
raise ValueError(f"owner_kind must be one of {', '.join(OWNER_KINDS)}")
return value
@ -85,20 +102,24 @@ class QuotaRuleIn(BaseModel):
@field_validator("owner_id")
@classmethod
def _clean_owner_id(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
return value.strip()
return (value or "").strip() or None
@field_validator("app_reference")
@classmethod
def _clean_app_reference(cls, value: Optional[str]) -> Optional[str]:
value = (value or "").strip()
if not value:
return None
value = value.strip()
if not APP_REFERENCE_PATTERN.match(value):
raise ValueError("app_reference must match ^[a-zA-Z0-9_.-]{1,30}$")
return value
class QuotaRuleIn(QuotaScopeIn):
limit_usd: float = Field(default=0.0, ge=0)
is_active: bool = True
label: str = Field(default="", max_length=200)
@field_validator("label")
@classmethod
def _clean_label(cls, value: str) -> str:
@ -114,6 +135,10 @@ class QuotaRuleIn(BaseModel):
return self
class QuotaResetIn(QuotaScopeIn):
pass
@dataclass(frozen=True)
class QuotaRule:
uid: str
@ -250,6 +275,83 @@ class QuotaRuleStore:
quota_rule_store = QuotaRuleStore()
def _load_resets() -> list[dict]:
sync_local_cache(CACHE_NAME, _QUOTA_CACHE)
if "resets" not in _QUOTA_CACHE:
resets: list[dict] = []
try:
if RESETS_TABLE in db.tables:
for row in get_table(RESETS_TABLE).all():
if row.get("reset_at"):
resets.append(
{
"owner_kind": row.get("owner_kind") or None,
"owner_id": row.get("owner_id") or None,
"app_reference": row.get("app_reference") or None,
"reset_at": str(row["reset_at"]),
}
)
except Exception as exc:
logger.warning("gateway quota reset load failed: %s", exc)
_QUOTA_CACHE["resets"] = resets
return _QUOTA_CACHE["resets"]
def _reset_applies(reset: dict, scope: dict) -> bool:
for field in ("owner_kind", "owner_id", "app_reference"):
wanted = reset.get(field)
if wanted is None:
continue
if scope.get(field) is None or scope[field] != wanted:
return False
return True
def reset_watermark(
owner_kind: Optional[str], owner_id: Optional[str], app_reference: Optional[str]
) -> str:
scope = {
"owner_kind": owner_kind,
"owner_id": owner_id,
"app_reference": app_reference,
}
stamps = [r["reset_at"] for r in _load_resets() if _reset_applies(r, scope)]
return max(stamps) if stamps else ""
def reset(payload: Optional[QuotaResetIn] = None, *, created_by: str = "") -> dict:
ensure_tables()
payload = payload or QuotaResetIn()
table = get_table(RESETS_TABLE)
scope = {
"owner_kind": payload.owner_kind,
"owner_id": payload.owner_id,
"app_reference": payload.app_reference,
}
stamp = _now()
existing = table.find_one(**scope)
if existing:
table.update({"id": existing["id"], "reset_at": stamp, "created_by": created_by}, ["id"])
uid = existing.get("uid") or uuid.uuid4().hex
else:
uid = uuid.uuid4().hex
table.insert({**scope, "uid": uid, "reset_at": stamp, "created_by": created_by})
bump_cache_version(CACHE_NAME)
_QUOTA_CACHE.clear()
return {**scope, "uid": uid, "reset_at": stamp}
def scope_label(scope: dict, fallback: str = "") -> str:
parts = []
if scope.get("owner_kind"):
parts.append(f"role={scope['owner_kind']}")
if scope.get("owner_id"):
parts.append(f"user={scope['owner_id']}")
if scope.get("app_reference"):
parts.append(f"app={scope['app_reference']}")
return ", ".join(parts) or fallback
def default_limit(owner_kind: str, cfg: dict) -> float:
field = _DEFAULT_FIELD_BY_KIND.get(owner_kind, FIELD_DEFAULT_USER)
return float(cfg.get(field, 0.0) or 0.0)
@ -294,10 +396,10 @@ def spent_24h(
if GATEWAY_LEDGER not in db.tables:
return 0.0
cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
watermark = reset_watermark(owner_kind, owner_id, app_reference)
clauses = ["created_at >= :cutoff"]
params: dict = {
"cutoff": (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
}
params: dict = {"cutoff": max(cutoff, watermark) if watermark else cutoff}
if owner_kind is not None:
clauses.append("owner_kind = :owner_kind")
params["owner_kind"] = owner_kind

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"]

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

View File

@ -221,7 +221,7 @@ for `target_type == "quiz"`, soft-deleting questions, options, attempts and answ
| SEO | `seo.quiz_schema`, `/quizzes` + the newest published quizzes in the sitemap; hub and detail `index,follow`, builder/player/results `noindex,follow`, a draft detail `noindex,nofollow` |
| Notifications | `quiz_attempt`, fired once on the finish transition to the author, never per answer |
| Gamification | `XP_QUIZ`/`XP_QUIZ_PUBLISH`/`XP_QUIZ_COMPLETE`, achievements `quiz_publish`/`quiz_complete`/`quiz_perfect`, the **Quizzes** badge group |
| Audit | prefix `quiz` -> category `content`; eleven keys in `events.md` |
| Audit | prefix `quiz` -> category `content`; thirteen keys in `events.md` |
| Devii | `actions/catalog/quizzes.py`; `publish_quiz`, `delete_quiz` and `delete_quiz_question` are in `dispatcher.CONFIRM_REQUIRED` and each declares a `confirm` param |
| CLI | `devplace quiz prune` - hard-deletes abandoned and expired attempts older than `QUIZ_ATTEMPT_RETENTION_DAYS`. Completed attempts are never pruned; they are the player's record |
| Frontend | `dp-quiz-player`/`dp-quiz-builder` (light DOM, adopt the server-rendered markup), `static/css/quiz.css` (adds only what is new - the layout and card chrome come from `feed.css`/`sidebar.css`) |

View File

@ -245,16 +245,24 @@
min-width: 2.2em;
}
.game-lb-name {
.game-lb-name-group {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-lb-name {
color: var(--text-primary);
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.game-lb-name:hover {
@ -268,10 +276,14 @@
}
.game-lb-title {
flex-shrink: 0;
color: var(--accent);
font-size: 0.75rem;
font-size: 0.7rem;
font-style: italic;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.game-lb-score {

View File

@ -55,6 +55,22 @@
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 {
display: flex;
align-items: center;

View File

@ -305,6 +305,10 @@
margin-bottom: 1.5rem;
}
.project-devlog {
margin-top: 1.5rem;
}
.project-section-label {
font-size: 0.75rem;
font-weight: 700;
@ -313,3 +317,4 @@
color: var(--text-muted);
margin-bottom: 0.5rem;
}

View File

@ -388,7 +388,7 @@ export class GameFarm {
.map((entry) => {
const title = entry.title ? `<span class="game-lb-title">${entry.title}</span>` : "";
const value = this._leaderboardValue(board, entry);
return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a>${title}<span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><div class="game-lb-name-group"><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a>${title}</div><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
})
.join("");
} catch (error) {

View File

@ -279,9 +279,9 @@ export class GatewayAdmin {
form.name.scrollIntoView({ block: "center" });
}
async confirmDelete(message) {
async confirmAction(message, confirmLabel = "Delete") {
if (window.app && window.app.dialog) {
return window.app.dialog.confirm({ message, danger: true, confirmLabel: "Delete" });
return window.app.dialog.confirm({ message, danger: true, confirmLabel });
}
return window.confirm(message);
}
@ -294,7 +294,7 @@ export class GatewayAdmin {
}
async deleteProvider(name) {
if (!(await this.confirmDelete(`Delete provider "${name}"?`))) return;
if (!(await this.confirmAction(`Delete provider "${name}"?`))) return;
try {
await this.remove(`/admin/gateway/providers/${encodeURIComponent(name)}`);
this.notify("Provider deleted", "success");
@ -338,7 +338,7 @@ export class GatewayAdmin {
}
async deleteModel(source) {
if (!(await this.confirmDelete(`Delete model route "${source}"?`))) return;
if (!(await this.confirmAction(`Delete model route "${source}"?`))) return;
try {
await this.remove(`/admin/gateway/models/${encodeURIComponent(source)}`);
this.notify("Model route deleted", "success");
@ -394,6 +394,7 @@ export class GatewayAdmin {
<td>${this.escape(r.label || "")}</td>
<td class="gw-actions">
<button class="admin-btn admin-btn-sm" data-edit-quota='${this.attr(JSON.stringify(r))}'>Edit</button>
<button class="admin-btn admin-btn-sm" data-reset-quota='${this.attr(JSON.stringify(r))}'>Reset spend</button>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-quota="${this.attr(r.uid)}">Delete</button>
</td>
</tr>`;
@ -443,13 +444,31 @@ export class GatewayAdmin {
onQuotaRuleClick(event) {
const editRaw = event.target.dataset.editQuota;
const resetRaw = event.target.dataset.resetQuota;
const delUid = event.target.dataset.delQuota;
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
if (resetRaw) this.resetQuotaSpend(JSON.parse(resetRaw));
if (delUid) this.deleteQuotaRule(delUid);
}
async resetQuotaSpend(rule) {
const label = this.scopeLabel(rule);
if (!(await this.confirmAction(`Reset the counted 24h spend for ${label}? The usage history is kept.`, "Reset"))) return;
try {
await Http.postJson("/admin/gateway/quota-resets", {
owner_kind: rule.owner_kind || "",
owner_id: rule.owner_id || "",
app_reference: rule.app_reference || "",
});
this.notify("Quota spend reset", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Reset failed", "error");
}
}
async deleteQuotaRule(uid) {
if (!(await this.confirmDelete("Delete this quota rule?"))) return;
if (!(await this.confirmAction("Delete this quota rule?"))) return;
try {
await this.remove(`/admin/gateway/quota-rules/${encodeURIComponent(uid)}`);
this.notify("Quota rule deleted", "success");

View File

@ -54,6 +54,9 @@ export class PushManager {
}
const keyData = await Http.getJson("/push.json");
if (!keyData.publicKey) {
return;
}
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({

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>
{% if item.project_link %}
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
{% endif %}
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}

View File

@ -42,7 +42,7 @@ Open `http://<host>:<PORT>`. Useful targets: `make docker-logs` (tail), `make do
The make targets always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Always update through them: a plain `docker compose up -d` drops the overlay and silently removes the docker socket and CLI the manager needs.
The healthcheck gives the app a 120s start window before any failure counts (`start_period: 120s` in `docker-compose.yml`). The full startup (DB init, background service registration, uvicorn workers) takes roughly 110s, so the start period must stay well above that. If you add heavyweight init work, verify the app boots within 120s or bump the start period to match.
The healthcheck gives the app a 120s start window in which a failing probe does not count against `retries` (`start_period: 120s`), and probes every 2s inside that window (`start_interval: 2s`) so the container is marked healthy as soon as it actually serves rather than at the next 30s tick. Both settings live in `docker-compose.yml` and the `Dockerfile` and must be changed together. Normal startup is a few seconds; the 120s window is headroom for a cold page cache on a multi-GB database. Startup cost is paid once per uvicorn worker, serialized under an exclusive init lock, so anything added to `init_db` is multiplied by the worker count. If you add heavyweight init work, measure the real boot time before relying on the existing window.
## Updating
@ -50,15 +50,19 @@ Code is bind-mounted, so most updates need no rebuild:
```bash
git pull
make docker-up # restart workers on the new code
make docker-reload # restart workers on the new code
```
Use `make docker-reload`, not `make docker-up`: `docker compose up -d` sees an unchanged container and leaves it running, so the workers keep serving the code they imported at boot. `docker-reload` restarts the app and waits for it to report healthy again.
Rebuild the image only when dependencies change:
```bash
make docker-build && make docker-up
```
A rebuild after a source-only change takes about 7 seconds. Dependencies install from `pyproject.toml` in a layer that source edits cannot invalidate, so `pip install` and the Chromium download stay cached; only the source copy and the final project install re-run.
## Release branch
`make deploy` fast-forwards the release branch:

View File

@ -28,6 +28,10 @@
<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 %}
{% include "_attachment_display.html" %}
{% endif %}

View File

@ -42,10 +42,10 @@
<div class="profile-level-bar">
<div class="level-label">
<span>Progress to next level</span>
<span>{{ (profile_user.get('xp') or 0) % 100 }}%</span>
<span>{{ xp_progress_pct }}%</span>
</div>
<div class="bar">
<div class="bar-fill" style="--bar-pct: {{ (profile_user.get('xp') or 0) % 100 }}%;"></div>
<div class="bar-fill" style="--bar-pct: {{ xp_progress_pct }}%;"></div>
</div>
</div>
@ -765,3 +765,6 @@ import { AwardGiver } from "{{ static_url('/static/js/AwardGiver.js') }}";
new AwardGiver();
</script>
{% endblock %}

View File

@ -102,6 +102,18 @@
</div>
</article>
<section class="project-devlog">
<h3 class="project-section-label">Devlog</h3>
{% if devlog_posts %}
{% for item in devlog_posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<p class="empty-state">No devlog posts yet.</p>
{% endif %}
</section>
{% if is_owner %}
{% call modal('edit-project-modal', 'Edit Project') %}
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
@ -184,3 +196,6 @@ if (actions) {
}
</script>
{% endblock %}

View File

@ -25,6 +25,7 @@ services:
timeout: 10s
retries: 3
start_period: 120s
start_interval: 2s
nginx:
build:

484
events.md Normal file
View File

@ -0,0 +1,484 @@
# Audit event catalogue
retoor <retoor@molodetz.nl>
Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 288 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to.
## Rules
- An event key is `<domain>.<noun>.<verb>` (or `<domain>.<verb>` when the domain is the noun). The domain is the first segment and MUST have an entry in `CATEGORY_BY_PREFIX`; an unmapped domain resolves to category `other` and is a bug.
- `target_type` names the entity acted upon, not the event (`gateway_quota`, not `gateway_quota_reset`).
- Every row carries a result: `success`, `failure`, `denied`. A refused action is recorded with `denied`, never dropped.
- Actor kinds: `user`, `guest`, `system`, `cli`, `service`. Origins: `web`, `api`, `devii`, `cli`, `service`, `scheduler`, `devrant`.
- The recorder never raises into its caller; a failed audit write is logged and swallowed so it can never break the action it describes.
- Some families are built from a variable at the call site (`vote.{target_type}.{direction}`, `comment.create.{target_type}`, `quiz.question.{action}`, `profile.customization.{scope}.toggle`, and the `create`/`edit`/`delete` verbs the shared `content.py` helpers emit per content type). Every member of those families is listed below individually.
- Adding an event means all three of: the key in this file, a `CATEGORY_BY_PREFIX` entry for a new domain, and the recorder call at the mutation point.
## Categories
| Category | Keys |
|---|---|
| `account` | 10 |
| `admin` | 21 |
| `ai` | 10 |
| `attachment` | 5 |
| `auth` | 9 |
| `backup` | 2 |
| `cli` | 42 |
| `container` | 12 |
| `content` | 37 |
| `database` | 4 |
| `devii` | 18 |
| `email` | 6 |
| `engagement` | 23 |
| `game` | 6 |
| `ingress` | 1 |
| `message` | 2 |
| `news` | 9 |
| `notification` | 4 |
| `project` | 11 |
| `project_files` | 14 |
| `pubsub` | 1 |
| `push` | 2 |
| `reward` | 4 |
| `security` | 3 |
| `service` | 5 |
| `social` | 10 |
| `telegram` | 5 |
| `tools` | 12 |
**Total: 288 keys.**
## Account and profile (`account`)
| Event key | Recorded in |
|---|---|
| `profile.ai_correction` | `routers/profile/ai_correction.py` |
| `profile.ai_modifier` | `routers/profile/ai_modifier.py` |
| `profile.api_key.regenerate` | `routers/profile/index.py` |
| `profile.avatar.regenerate` | `routers/profile/avatar.py` |
| `profile.customization.global.toggle` | `routers/profile/customization.py` |
| `profile.customization.pagetype.toggle` | `routers/profile/customization.py` |
| `profile.interactions` | `routers/profile/interactions.py`, `services/devii/actions/dispatcher.py` |
| `profile.notification.reset` | `routers/profile/notifications.py` |
| `profile.notification.toggle` | `routers/profile/notifications.py` |
| `profile.update` | `routers/devrant/auth.py`, `routers/profile/index.py` |
## Administration (`admin`)
| Event key | Recorded in |
|---|---|
| `admin.ai_quota.reset_all` | `routers/admin/aiquota.py` |
| `admin.ai_quota.reset_guests` | `routers/admin/aiquota.py` |
| `admin.backup.delete` | `routers/admin/backups.py` |
| `admin.backup.run` | `routers/admin/backups.py` |
| `admin.backup_schedule.create` | `routers/admin/backups.py` |
| `admin.backup_schedule.delete` | `routers/admin/backups.py` |
| `admin.backup_schedule.toggle` | `routers/admin/backups.py` |
| `admin.backup_schedule.update` | `routers/admin/backups.py` |
| `admin.devii_task.delete` | `routers/admin/devii_tasks.py` |
| `admin.devii_task.disable` | `routers/admin/devii_tasks.py` |
| `admin.game.era_end` | `routers/admin/game.py` |
| `admin.game.era_start` | `routers/admin/game.py` |
| `admin.notification.default` | `routers/admin/notifications.py` |
| `admin.setting.update` | `docs_api/groups/admin.py`, `routers/admin/settings.py` |
| `admin.trash.purge` | `routers/admin/trash.py` |
| `admin.trash.restore` | `routers/admin/trash.py` |
| `admin.user.active.disable` | `routers/admin/users.py` |
| `admin.user.active.enable` | `routers/admin/users.py` |
| `admin.user.ai_quota.reset` | `routers/admin/users.py` |
| `admin.user.password.reset` | `routers/admin/users.py` |
| `admin.user.role.change` | `routers/admin/users.py` |
## AI gateway (`ai`)
| Event key | Recorded in |
|---|---|
| `ai.clippy.chat` | `routers/devii.py` |
| `ai.gateway.call` | `services/openai_gateway/gateway.py`, `services/openai_gateway/usage.py` |
| `ai.quota.exceeded` | `routers/devii.py`, `services/openai_gateway/service.py` |
| `gateway.model.delete` | `routers/admin/gateway_configs.py` |
| `gateway.model.update` | `routers/admin/gateway_configs.py` |
| `gateway.provider.delete` | `routers/admin/gateway_configs.py` |
| `gateway.provider.update` | `routers/admin/gateway_configs.py` |
| `gateway.quota.reset` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
| `gateway.quota_rule.delete` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
| `gateway.quota_rule.update` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
## Attachments (`attachment`)
| Event key | Recorded in |
|---|---|
| `attachment.delete` | `routers/admin/media.py`, `routers/media.py` |
| `attachment.rename` | `routers/uploads.py` |
| `attachment.restore` | `routers/media.py` |
| `attachment.upload` | `routers/uploads.py` |
| `attachment.upload_url` | `routers/uploads.py` |
## Authentication (`auth`)
| Event key | Recorded in |
|---|---|
| `auth.account.disable` | `routers/devrant/auth.py` |
| `auth.login.failure` | `routers/auth/login.py`, `routers/devrant/auth.py` |
| `auth.login.success` | `routers/auth/login.py`, `routers/devrant/auth.py` |
| `auth.logout` | `routers/auth/logout.py` |
| `auth.password.forgot_request` | `routers/auth/forgotpassword.py` |
| `auth.password.reset_complete` | `routers/auth/resetpassword.py` |
| `auth.signup` | `routers/auth/signup.py`, `routers/devrant/auth.py` |
| `auth.token.failure` | `routers/auth/token.py` |
| `auth.token.issued` | `routers/auth/token.py` |
## Backups (`backup`)
| Event key | Recorded in |
|---|---|
| `job.backup.complete` | `services/backup/service.py` |
| `job.backup.failed` | `services/backup/service.py` |
## Command line (`cli`)
| Event key | Recorded in |
|---|---|
| `cli.apikey.backfill` | `cli/apikeys.py` |
| `cli.apikey.reset` | `cli/apikeys.py` |
| `cli.attachments.prune` | `cli/attachments.py` |
| `cli.backups.clear` | `cli/backups.py` |
| `cli.backups.prune` | `cli/backups.py` |
| `cli.backups.run` | `cli/backups.py` |
| `cli.containers.gc_workspaces` | `cli/containers.py` |
| `cli.containers.prune` | `cli/containers.py` |
| `cli.containers.prune_builds` | `cli/containers.py` |
| `cli.containers.reconcile` | `cli/containers.py` |
| `cli.deepsearch.clear` | `cli/jobs.py` |
| `cli.deepsearch.prune` | `cli/jobs.py` |
| `cli.devii.lessons.clear` | `cli/devii.py` |
| `cli.devii.lessons.prune` | `cli/devii.py` |
| `cli.devii.quota.reset` | `cli/devii.py` |
| `cli.devii.task.disable` | `cli/devii.py` |
| `cli.devii.task.prune` | `cli/devii.py` |
| `cli.emoji.sync` | `cli/migrate.py` |
| `cli.forks.clear` | `cli/jobs.py` |
| `cli.forks.prune` | `cli/jobs.py` |
| `cli.game.era.end` | `cli/game.py` |
| `cli.game.era.start` | `cli/game.py` |
| `cli.game.market.prune` | `cli/game.py` |
| `cli.game.steals.prune` | `cli/game.py` |
| `cli.isslop.analyze` | `cli/jobs.py` |
| `cli.isslop.clear` | `cli/jobs.py` |
| `cli.isslop.prune` | `cli/jobs.py` |
| `cli.messaging.prune_tickets` | `cli/messaging.py` |
| `cli.news.clear` | `cli/news.py` |
| `cli.news.sanitize` | `cli/news.py` |
| `cli.quiz.prune` | `cli/quiz.py` |
| `cli.role.set` | `cli/roles.py` |
| `cli.seo.clear` | `cli/jobs.py` |
| `cli.seo.prune` | `cli/jobs.py` |
| `cli.seo_meta.clear` | `cli/jobs.py` |
| `cli.seo_meta.prune` | `cli/jobs.py` |
| `cli.token.issue` | `cli/tokens.py` |
| `cli.token.prune` | `cli/tokens.py` |
| `cli.token.revoke` | `cli/tokens.py` |
| `cli.token.revoke_all` | `cli/tokens.py` |
| `cli.zips.clear` | `cli/jobs.py` |
| `cli.zips.prune` | `cli/jobs.py` |
## Containers (`container`)
| Event key | Recorded in |
|---|---|
| `container.instance.configure` | `routers/admin/containers.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.create` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.instance.delete` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.instance.exec` | `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.shell.close` | `routers/projects/containers/instances.py` |
| `container.instance.shell.open` | `routers/projects/containers/instances.py` |
| `container.instance.start` | `docs_api/groups/admin.py` |
| `container.instance.status` | `services/containers/service.py` |
| `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.reconcile.action` | `services/containers/service.py` |
| `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` |
| `container.schedule.delete` | `routers/projects/containers/schedules.py` |
## Content (`content`)
| Event key | Recorded in |
|---|---|
| `comment.create.gist` | `content.py` |
| `comment.create.issue` | `content.py` |
| `comment.create.news` | `content.py` |
| `comment.create.post` | `content.py` |
| `comment.create.project` | `content.py` |
| `comment.create.quiz` | `content.py` |
| `comment.delete` | `content.py`, `routers/comments.py` |
| `comment.edit` | `content.py`, `routers/comments.py` |
| `gist.create` | `content.py` |
| `gist.delete` | `content.py` |
| `gist.edit` | `content.py` |
| `issue.attachment.add` | `routers/issues/attachments.py` |
| `issue.attachment.delete` | `routers/issues/attachments.py` |
| `issue.comment` | `routers/issues/comment.py` |
| `issue.create` | `services/jobs/issue_create_service.py` |
| `issue.create.request` | `routers/issues/create.py` |
| `issue.planning.generate` | `services/jobs/planning_service.py` |
| `issue.planning.request` | `routers/issues/planning.py` |
| `issue.status` | `routers/issues/status.py` |
| `issue.sync.reply` | `services/gitea/service.py` |
| `issue.sync.status` | `services/gitea/service.py` |
| `post.create` | `content.py` |
| `post.delete` | `content.py` |
| `post.edit` | `content.py` |
| `quiz.attempt.answer` | `routers/quizzes/attempts.py` |
| `quiz.attempt.finish` | `routers/quizzes/attempts.py` |
| `quiz.attempt.start` | `routers/quizzes/attempts.py` |
| `quiz.create` | `content.py` |
| `quiz.delete` | `content.py` |
| `quiz.edit` | `content.py` |
| `quiz.grade.failed` | `routers/quizzes/attempts.py` |
| `quiz.import` | `routers/quizzes/index.py` |
| `quiz.publish` | `routers/quizzes/index.py` |
| `quiz.question.create` | `routers/quizzes/questions.py` |
| `quiz.question.delete` | `routers/quizzes/questions.py` |
| `quiz.question.edit` | `routers/quizzes/questions.py` |
| `quiz.question.reorder` | `routers/quizzes/questions.py` |
## Database API (`database`)
| Event key | Recorded in |
|---|---|
| `database.access.denied` | `routers/dbapi/_shared.py` |
| `database.nl.design` | `routers/dbapi/nl.py` |
| `database.query` | `routers/dbapi/query.py` |
| `database.query.async` | `routers/dbapi/query.py` |
## Devii assistant (`devii`)
| Event key | Recorded in |
|---|---|
| `devii.behavior.update` | `services/devii/actions/dispatcher.py` |
| `devii.customization.css.set` | `services/devii/actions/dispatcher.py` |
| `devii.customization.js.set` | `services/devii/actions/dispatcher.py` |
| `devii.customization.reset` | `services/devii/actions/dispatcher.py` |
| `devii.lesson.forget` | `services/devii/actions/dispatcher.py` |
| `devii.lesson.reflect` | `services/devii/actions/dispatcher.py` |
| `devii.notification.reset` | `services/devii/actions/dispatcher.py` |
| `devii.notification.set` | `services/devii/actions/dispatcher.py` |
| `devii.task.blocked` | `services/devii/tasks/scheduler.py` |
| `devii.task.create` | `services/devii/actions/dispatcher.py` |
| `devii.task.deferred` | `services/devii/tasks/scheduler.py` |
| `devii.task.delete` | `services/devii/actions/dispatcher.py` |
| `devii.task.execute` | `services/devii/tasks/scheduler.py` |
| `devii.task.update` | `services/devii/actions/dispatcher.py` |
| `devii.tool.create` | `services/devii/actions/dispatcher.py` |
| `devii.tool.delete` | `services/devii/actions/dispatcher.py` |
| `devii.tool.update` | `services/devii/actions/dispatcher.py` |
| `devii.turn` | `services/devii/session/core.py` |
## Email (`email`)
| Event key | Recorded in |
|---|---|
| `email.account.delete` | `services/devii/actions/dispatcher.py` |
| `email.account.set` | `services/devii/actions/dispatcher.py` |
| `email.message.delete` | `services/devii/actions/dispatcher.py` |
| `email.message.flag` | `services/devii/actions/dispatcher.py` |
| `email.message.move` | `services/devii/actions/dispatcher.py` |
| `email.send` | `services/devii/actions/dispatcher.py` |
## Engagement (`engagement`)
| Event key | Recorded in |
|---|---|
| `bookmark.add` | `content.py`, `routers/bookmarks.py` |
| `bookmark.remove` | `content.py`, `routers/bookmarks.py` |
| `poll.create` | `routers/posts.py` |
| `poll.vote.cast` | `routers/polls.py` |
| `poll.vote.change` | `routers/polls.py` |
| `poll.vote.clear` | `routers/polls.py` |
| `reaction.add` | `routers/reactions.py` |
| `reaction.remove` | `routers/reactions.py` |
| `vote.comment.clear` | `content.py` |
| `vote.comment.down` | `content.py` |
| `vote.comment.up` | `content.py` |
| `vote.gist.clear` | `content.py` |
| `vote.gist.down` | `content.py` |
| `vote.gist.up` | `content.py` |
| `vote.post.clear` | `content.py` |
| `vote.post.down` | `content.py` |
| `vote.post.up` | `content.py` |
| `vote.project.clear` | `content.py` |
| `vote.project.down` | `content.py` |
| `vote.project.up` | `content.py` |
| `vote.quiz.clear` | `content.py` |
| `vote.quiz.down` | `content.py` |
| `vote.quiz.up` | `content.py` |
## Code Farm (`game`)
| Event key | Recorded in |
|---|---|
| `game.cosmetic.buy` | `routers/game/index.py` |
| `game.defense.downgrade` | `routers/game/index.py` |
| `game.defense.upgrade` | `routers/game/index.py` |
| `game.grant.claim` | `routers/game/index.py` |
| `game.infrastructure.buy` | `routers/game/index.py` |
| `game.prestige` | `routers/game/index.py` |
## Container ingress (`ingress`)
| Event key | Recorded in |
|---|---|
| `proxy.access` | `routers/proxy.py` |
## Direct messages (`message`)
| Event key | Recorded in |
|---|---|
| `message.read_on_view` | `routers/messages.py` |
| `message.send` | `services/messaging/persist.py` |
## News (`news`)
| Event key | Recorded in |
|---|---|
| `news.delete` | `routers/admin/news.py` |
| `news.featured.toggle` | `routers/admin/news.py` |
| `news.landing.toggle` | `routers/admin/news.py` |
| `news.publish.toggle` | `routers/admin/news.py` |
| `news.service.draft` | `services/news/service.py` |
| `news.service.ingest` | `services/news/service.py` |
| `news.service.landing` | `services/news/service.py` |
| `news.service.publish` | `services/news/service.py` |
| `news.service.reject` | `services/news/service.py` |
## Notifications (`notification`)
| Event key | Recorded in |
|---|---|
| `notification.create` | `utils/notifications.py` |
| `notification.open` | `routers/notifications.py` |
| `notification.read.all` | `routers/devrant/notifs.py`, `routers/notifications.py` |
| `notification.read.one` | `routers/notifications.py` |
## Projects (`project`)
| Event key | Recorded in |
|---|---|
| `project.create` | `content.py` |
| `project.delete` | `content.py` |
| `project.edit` | `content.py` |
| `project.fork.complete` | `services/jobs/fork_service.py` |
| `project.fork.failed` | `services/jobs/fork_service.py` |
| `project.fork.request` | `routers/projects/index.py` |
| `project.readonly.disable` | `routers/projects/index.py` |
| `project.readonly.enable` | `routers/projects/index.py` |
| `project.visibility.private` | `routers/projects/index.py` |
| `project.visibility.public` | `routers/projects/index.py` |
| `project.zip.request` | `routers/projects/index.py` |
## Project files (`project_files`)
| Event key | Recorded in |
|---|---|
| `dir.create` | `routers/projects/files.py` |
| `file.append` | `routers/projects/files.py` |
| `file.delete` | `routers/projects/files.py` |
| `file.delete_lines` | `routers/projects/files.py` |
| `file.insert_lines` | `routers/projects/files.py` |
| `file.move` | `routers/projects/files.py` |
| `file.replace_lines` | `routers/projects/files.py` |
| `file.upload` | `routers/projects/files.py` |
| `file.write` | `routers/projects/files.py` |
| `file.write.create` | `routers/projects/files.py` |
| `file.write.overwrite` | `routers/projects/files.py` |
| `files.zip.request` | `routers/projects/files.py` |
| `job.zip.complete` | `services/jobs/zip_service.py` |
| `job.zip.failed` | `services/jobs/zip_service.py` |
## Pub/sub (`pubsub`)
| Event key | Recorded in |
|---|---|
| `pubsub.publish` | `routers/pubsub.py` |
## Web push (`push`)
| Event key | Recorded in |
|---|---|
| `push.subscribe` | `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`)
| Event key | Recorded in |
|---|---|
| `reward.badge.award` | `utils/badges.py` |
| `reward.level.up` | `utils/rewards.py` |
| `reward.streak.milestone` | `utils/rewards.py` |
| `reward.xp.grant` | `utils/rewards.py` |
## Security (`security`)
| Event key | Recorded in |
|---|---|
| `security.authz.denied` | `routers/admin/backups.py`, `services/devii/actions/dispatcher.py` |
| `security.maintenance.block` | `main.py` |
| `security.rate_limit.block` | `main.py` |
## Background services (`service`)
| Event key | Recorded in |
|---|---|
| `service.config.update` | `routers/admin/services.py` |
| `service.logs.clear` | `routers/admin/services.py` |
| `service.run_now` | `routers/admin/services.py` |
| `service.start` | `routers/admin/services.py` |
| `service.stop` | `routers/admin/services.py` |
## Social graph (`social`)
| Event key | Recorded in |
|---|---|
| `award.complete` | `services/jobs/award_service.py` |
| `award.failed` | `services/jobs/award_service.py` |
| `award.give` | `routers/profile/award.py` |
| `award.revoke` | `routers/admin/awards.py` |
| `follow.follow` | `routers/follow.py` |
| `follow.unfollow` | `routers/follow.py` |
| `relation.block` | `routers/relations.py` |
| `relation.mute` | `routers/relations.py` |
| `relation.unblock` | `routers/relations.py` |
| `relation.unmute` | `routers/relations.py` |
## Telegram (`telegram`)
| Event key | Recorded in |
|---|---|
| `telegram.pair.failure` | `services/telegram/bridge.py` |
| `telegram.pair.request` | `routers/profile/telegram.py` |
| `telegram.pair.success` | `services/telegram/bridge.py` |
| `telegram.send` | `services/devii/actions/dispatcher.py` |
| `telegram.unpair` | `routers/profile/telegram.py` |
## Developer tools (`tools`)
| Event key | Recorded in |
|---|---|
| `deepsearch.chat` | `routers/tools/deepsearch.py` |
| `deepsearch.run.complete` | `services/jobs/deepsearch/service.py` |
| `deepsearch.run.failed` | `services/jobs/deepsearch/service.py` |
| `deepsearch.run.request` | `routers/tools/deepsearch.py` |
| `isslop.run.complete` | `services/jobs/isslop/service.py` |
| `isslop.run.failed` | `services/jobs/isslop/service.py` |
| `isslop.run.request` | `routers/tools/isslop.py` |
| `seo.meta.failed` | `services/jobs/seo_meta_service.py` |
| `seo.meta.generate` | `services/jobs/seo_meta_service.py` |
| `seo.run.complete` | `services/jobs/seo/service.py` |
| `seo.run.failed` | `services/jobs/seo/service.py` |
| `seo.run.request` | `routers/tools/seo.py` |
## Mapped domains with no recorder
The `backup` prefix has a `CATEGORY_BY_PREFIX` entry but no call site emits a bare `backup.*` key - backup activity is recorded as `admin.backup*` (operator actions) and `job.backup.*` (worker outcomes), both of which resolve to the `backup` category through their own prefixes. Keep the mapping: it is what routes `job.backup.*` correctly.

View File

@ -0,0 +1,78 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
import requests
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
from devplacepy.utils import generate_uid
from tests.api.admin.gateway.index import JSON_gateway, admin_session
from tests.conftest import BASE_URL
RESET_ALL_URL = f"{BASE_URL}/admin/ai-quota/reset-all"
_counter = [0]
def _owner():
_counter[0] += 1
return f"resetall{_counter[0]}-{generate_uid()}"
def _burn(owner_id, cost):
refresh_snapshot()
get_table(GATEWAY_LEDGER).insert(
{
"uid": generate_uid(),
"owner_kind": "user",
"owner_id": owner_id,
"app_reference": "typosaurus",
"cost_usd": cost,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
refresh_snapshot()
def _spent(owner_id):
from devplacepy.services.openai_gateway import quota
refresh_snapshot()
quota._QUOTA_CACHE.clear()
return quota.spent_24h("user", owner_id, "typosaurus")
def test_reset_all_clears_the_gateway_spend(seeded_db):
owner = _owner()
_burn(owner, 3.0)
assert _spent(owner) == 3.0
response = admin_session(seeded_db).post(RESET_ALL_URL, allow_redirects=False)
assert response.status_code == 200
assert _spent(owner) == 0.0
def test_reset_all_keeps_the_gateway_usage_history(seeded_db):
owner = _owner()
_burn(owner, 3.0)
before = get_table(GATEWAY_LEDGER).count(owner_id=owner)
admin_session(seeded_db).post(RESET_ALL_URL, allow_redirects=False)
refresh_snapshot()
assert get_table(GATEWAY_LEDGER).count(owner_id=owner) == before
def test_gateway_spend_after_reset_all_counts_again(seeded_db):
owner = _owner()
_burn(owner, 3.0)
admin_session(seeded_db).post(RESET_ALL_URL, allow_redirects=False)
_burn(owner, 0.25)
assert _spent(owner) == 0.25
def test_reset_all_requires_admin(seeded_db):
assert (
requests.post(
RESET_ALL_URL, headers=JSON_gateway, allow_redirects=False
).status_code
== 401
)

View File

@ -0,0 +1,162 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
import requests
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
from devplacepy.utils import generate_uid
from tests.api.admin.gateway.index import (
JSON_gateway,
admin_session,
member_key,
)
from tests.conftest import BASE_URL
RESETS_URL = f"{BASE_URL}/admin/gateway/quota-resets"
_counter = [0]
def _owner():
_counter[0] += 1
return f"gwquota{_counter[0]}-{generate_uid()}"
def _burn(owner_id, app_reference, cost):
refresh_snapshot()
get_table(GATEWAY_LEDGER).insert(
{
"uid": generate_uid(),
"owner_kind": "user",
"owner_id": owner_id,
"app_reference": app_reference,
"cost_usd": cost,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
refresh_snapshot()
def _spent(owner_id, app_reference):
from devplacepy.services.openai_gateway import quota
refresh_snapshot()
quota._QUOTA_CACHE.clear()
return quota.spent_24h("user", owner_id, app_reference)
def test_quota_reset_requires_admin(seeded_db):
assert (
requests.post(RESETS_URL, headers=JSON_gateway, allow_redirects=False).status_code
== 401
)
key = member_key()
assert (
requests.post(
RESETS_URL,
headers={**JSON_gateway, "X-API-KEY": key},
json={},
allow_redirects=False,
).status_code
== 403
)
def test_admin_can_reset_a_scoped_spend(seeded_db):
owner = _owner()
_burn(owner, "appa", 2.5)
assert _spent(owner, "appa") == 2.5
response = admin_session(seeded_db).post(
RESETS_URL,
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
)
assert response.status_code == 200
assert response.json()["ok"] is True
assert _spent(owner, "appa") == 0.0
def test_the_reset_response_echoes_the_scope(seeded_db):
owner = _owner()
_burn(owner, "appa", 1.0)
payload = admin_session(seeded_db).post(
RESETS_URL,
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
).json()["reset"]
assert payload["owner_kind"] == "user"
assert payload["owner_id"] == owner
assert payload["app_reference"] == "appa"
assert payload["reset_at"]
def test_a_reset_leaves_another_caller_capped(seeded_db):
first, second = _owner(), _owner()
_burn(first, "appa", 2.0)
_burn(second, "appa", 2.0)
admin_session(seeded_db).post(
RESETS_URL,
json={"owner_kind": "user", "owner_id": first, "app_reference": "appa"},
)
assert _spent(first, "appa") == 0.0
assert _spent(second, "appa") == 2.0
def test_a_reset_keeps_the_usage_history(seeded_db):
owner = _owner()
_burn(owner, "appa", 2.0)
before = get_table(GATEWAY_LEDGER).count(owner_id=owner)
admin_session(seeded_db).post(
RESETS_URL,
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
)
refresh_snapshot()
assert get_table(GATEWAY_LEDGER).count(owner_id=owner) == before
def test_an_unscoped_reset_clears_every_caller(seeded_db):
owner = _owner()
_burn(owner, "appa", 2.0)
assert admin_session(seeded_db).post(RESETS_URL, json={}).status_code == 200
assert _spent(owner, "appa") == 0.0
def test_an_unknown_owner_kind_is_rejected(seeded_db):
response = admin_session(seeded_db).post(RESETS_URL, json={"owner_kind": "wizard"})
assert response.status_code == 400
assert response.json()["ok"] is False
def test_a_malformed_app_reference_is_rejected(seeded_db):
response = admin_session(seeded_db).post(
RESETS_URL, json={"app_reference": "not a valid app!"}
)
assert response.status_code == 400
def test_the_reset_is_audited(seeded_db):
owner = _owner()
_burn(owner, "appa", 1.0)
admin = admin_session(seeded_db)
admin.post(
RESETS_URL,
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
)
refresh_snapshot()
entries = admin.get(
f"{BASE_URL}/admin/audit-log",
headers=JSON_gateway,
params={"event_key": "gateway.quota.reset"},
).json()["entries"]
assert any(entry.get("target_type") == "gateway_quota" for entry in entries)
def test_spend_recorded_after_a_reset_counts_again(seeded_db):
owner = _owner()
_burn(owner, "appa", 2.0)
admin_session(seeded_db).post(
RESETS_URL,
json={"owner_kind": "user", "owner_id": owner, "app_reference": "appa"},
)
_burn(owner, "appa", 0.5)
assert _spent(owner, "appa") == 0.5

View File

@ -1,10 +1,12 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
_counter_dr = [0]
@ -46,10 +48,13 @@ def _register(password="secret123"):
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(
f"{BASE_URL}/api/devrant/rants",
data={**params, "rant": text, "tags": tags},
data=data,
)
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"})
assert r.json()["success"] is True
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"]

View File

@ -716,3 +716,69 @@ def test_short_post_content_rejected(app_server):
allow_redirects=False,
)
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"

View File

@ -234,7 +234,6 @@ def test_activity_comment_url_matches_notification_target(app_server):
def test_activity_post_exposes_url(app_server):
from datetime import datetime, timezone
from devplacepy.database import get_table, refresh_snapshot
owner = _seed_owner()
@ -407,3 +406,96 @@ def test_viewing_profile_marks_notification_read(app_server):
refresh_snapshot()
assert bool(get_table("notifications").find_one(uid=notif_uid)["read"]) is True
def test_own_profile_json_exposes_xp_fields(app_server, seeded_db):
"""GET /profile (own) with Accept: application/json includes xp_next_level and xp_progress_pct."""
import requests
session = requests.Session()
session.post(
f"{BASE_URL}/auth/login",
data={
"email": seeded_db["alice"]["email"],
"password": seeded_db["alice"]["password"],
},
allow_redirects=True,
)
r = session.get(f"{BASE_URL}/profile", headers={"Accept": "application/json"})
assert r.status_code == 200
data = r.json()
pu = data["profile_user"]
assert "xp_next_level" in pu, "xp_next_level missing from own profile JSON"
assert "xp_progress_pct" in pu, "xp_progress_pct missing from own profile JSON"
assert isinstance(pu["xp_next_level"], int)
assert isinstance(pu["xp_progress_pct"], int)
def test_other_profile_json_exposes_xp_fields(app_server):
"""GET /profile/{username} with Accept: application/json includes xp_next_level and xp_progress_pct."""
from devplacepy.database import get_table, refresh_snapshot
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
data = r.json()
pu = data["profile_user"]
assert "xp_next_level" in pu, "xp_next_level missing from other profile JSON"
assert "xp_progress_pct" in pu, "xp_progress_pct missing from other profile JSON"
assert isinstance(pu["xp_next_level"], int)
assert isinstance(pu["xp_progress_pct"], int)
def test_profile_json_xp_fields_zero_xp(app_server):
"""User with 0 XP returns xp_next_level=100 (level 1) and xp_progress_pct=0."""
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils.rewards import LEVEL_XP
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
pu = r.json()["profile_user"]
assert pu["xp"] == 0 or pu["xp"] is None, f"expected 0 xp, got {pu['xp']}"
assert pu["xp_next_level"] == LEVEL_XP, (
f"expected xp_next_level={LEVEL_XP} for level 1, got {pu['xp_next_level']}"
)
assert pu["xp_progress_pct"] == 0, (
f"expected xp_progress_pct=0 for 0 XP, got {pu['xp_progress_pct']}"
)
def test_profile_json_xp_fields_boundary_xp(app_server):
"""User with exactly 100 XP (level 2) returns xp_next_level=200 and xp_progress_pct=0."""
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils.rewards import award_xp
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
award_xp(owner, 100)
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
pu = r.json()["profile_user"]
assert pu["xp"] == 100, f"expected 100 xp, got {pu['xp']}"
assert pu["level"] == 2, f"expected level 2, got {pu['level']}"
assert pu["xp_next_level"] == 200, (
f"expected xp_next_level=200 for level 2, got {pu['xp_next_level']}"
)
assert pu["xp_progress_pct"] == 0, (
f"expected xp_progress_pct=0 at boundary, got {pu['xp_progress_pct']}"
)

View File

@ -271,3 +271,43 @@ def test_profile_renders_heatmap_and_streak(app_server):
html = s.get(f"{BASE_URL}/profile/{name}").text
assert "heatmap-grid" in html
assert "1 day streak" in html
def test_profile_badges_json_has_non_null_names(app_server):
import time
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import award_badge
name = f"bdg{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,
)
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 len(body["badges"]) >= 2, f"expected at least 2 badges, got {len(body['badges'])}"
for badge in body["badges"]:
assert "name" in badge, f"badge missing name key: {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 len(badge["name"]) > 0, f"badge name is empty: {badge}"

View File

@ -0,0 +1,271 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone, timedelta
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid
JSON = {"Accept": "application/json"}
_counter = [0]
@pytest.fixture(scope="module", autouse=True)
def _devlog_test_settings(app_server):
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
}.items():
set_setting(key, value)
yield
def _unique(prefix="dl"):
_counter[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
def _member():
name = _unique("dlmem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _db_user(name):
refresh_snapshot()
return get_table("users").find_one(username=name)
def _create_project(session, title=None):
title = title or _unique("dlproj")
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": title,
"description": "Devlog test project",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post(session, content, project_uid, title=None):
title = title or _unique("dlpost")
r = session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": title,
"content": content,
"topic": "devlog",
"project_uid": project_uid,
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post_direct(project_uid, user_uid, order, marker=None):
"""Insert a post directly into DB with precise created_at ordering."""
uid = generate_uid()
marker = marker or f"dlpost-{uid[:8]}"
get_table("posts").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": user_uid,
"slug": f"{uid[:8]}-devlog-post",
"title": marker,
"content": f"Devlog post content {order}",
"topic": "devlog",
"project_uid": project_uid,
"image": None,
"stars": 0,
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=order)).isoformat(),
}
)
refresh_snapshot()
return uid, marker
def test_devlog_empty_state_when_no_posts(app_server):
"""Project with no linked posts returns empty devlog_posts list."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_posts"] == []
assert body["devlog_next_cursor"] is None
def test_devlog_shows_linked_post(app_server):
"""A post linked via project_uid appears in the project's devlog."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dllink")
_create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
assert body["devlog_next_cursor"] is None
post_item = body["devlog_posts"][0]
assert post_item["post"]["title"] == marker
assert post_item["author"]["username"] == name
def test_devlog_reverse_chronological_order(app_server):
"""Multiple linked posts appear newest-first."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
markers = []
for i in range(3):
marker = f"dlorder-{i}-{generate_uid()[:8]}"
markers.append(marker)
_create_post_direct(project_uid, user["uid"], i, marker=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert titles == list(reversed(markers)), (
f"Expected newest-first order: {list(reversed(markers))}, got: {titles}"
)
def test_devlog_pagination(app_server):
"""More than PAGE_SIZE posts produce next_cursor."""
from devplacepy.database.pagination import PAGE_SIZE
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
count = PAGE_SIZE + 1
for i in range(count):
_create_post_direct(project_uid, user["uid"], i, marker=f"dlpag-{i}")
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == PAGE_SIZE, (
f"Expected {PAGE_SIZE} posts on first page, got {len(body['devlog_posts'])}"
)
assert body["devlog_next_cursor"] is not None, (
"Expected next_cursor when more than PAGE_SIZE posts exist"
)
before = body["devlog_next_cursor"]
r2 = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON, params={"before": before})
assert r2.status_code == 200, r2.text[:300]
body2 = r2.json()
assert len(body2["devlog_posts"]) == 1, (
f"Expected 1 post on second page, got {len(body2['devlog_posts'])}"
)
assert body2["devlog_next_cursor"] is None, (
"Expected no next_cursor on last page"
)
r_html = session.get(f"{BASE_URL}/projects/{slug}")
assert r_html.status_code == 200
assert 'class="load-more-wrap"' in r_html.text, (
"Expected Load More button in HTML for paginated devlog"
)
def test_devlog_excludes_unlinked_posts(app_server):
"""Posts without project_uid do not appear in any project's devlog."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
unlinked = _unique("dlnolink")
session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": unlinked,
"content": "This post has no project",
"topic": "devlog",
},
)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert unlinked not in titles, (
"Post without project_uid must not appear in devlog"
)
def test_devlog_enriches_author_and_metadata(app_server):
"""Devlog posts include author data, comment count, and vote info."""
session, name = _member()
user = _db_user(name)
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dlenrich")
post_data = _create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
item = body["devlog_posts"][0]
assert item["author"]["username"] == name
assert item["author"]["uid"] == user["uid"]
assert isinstance(item["my_vote"], int)
assert isinstance(item["comment_count"], int)
assert item["comment_count"] == 0
assert item["post"]["uid"] == post_data["uid"]
assert item["post"]["slug"] == post_data["slug"]
assert item["post"]["title"] == marker
assert item["time_ago"] is not None
def test_devlog_works_for_guest_visitor(app_server):
"""Unauthenticated visitors can see the devlog section."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = requests.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert "devlog_posts" in body
assert body["devlog_posts"] == []

View File

@ -64,3 +64,56 @@ def test_register_invalid_json_rejected(app_server):
headers={"Content-Type": "application/json"},
)
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

View File

@ -89,6 +89,53 @@ def test_run_clamps_max_pages_above_cap(app_server):
_clear_seo_jobs()
def test_run_rejects_malformed_scheme(app_server):
r = requests.post(
f"{BASE_URL}/tools/seo/run",
headers=_json_headers(),
data={"url": "ahttps://devplace.net/sitem", "mode": "url"},
allow_redirects=False,
)
assert r.status_code in (400, 422), r.text
def test_run_rejects_relative_path(app_server):
r = requests.post(
f"{BASE_URL}/tools/seo/run",
headers=_json_headers(),
data={"url": "/feed", "mode": "url"},
allow_redirects=False,
)
assert r.status_code in (400, 422), r.text
def test_run_normalizes_missing_scheme(app_server):
try:
r = requests.post(
f"{BASE_URL}/tools/seo/run",
headers=_json_headers(),
data={"url": "example.com", "mode": "url", "max_pages": "5"},
)
assert r.status_code == 200, r.text
uid = r.json()["uid"]
refresh_snapshot()
job = queue.get_job(uid)
assert job is not None
assert job["payload"]["url"] == "https://example.com"
finally:
_clear_seo_jobs()
def test_run_rejects_non_http_scheme(app_server):
r = requests.post(
f"{BASE_URL}/tools/seo/run",
headers=_json_headers(),
data={"url": "ftp://example.com", "mode": "url"},
allow_redirects=False,
)
assert r.status_code in (400, 422), r.text
def test_run_enforces_one_active_job_per_owner(app_server):
try:
first = requests.post(

View File

@ -0,0 +1,188 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone, timedelta
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_project():
"""Create a project seeded directly into DB and return (slug, uid, user_uid)."""
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"dlowner_{owner[:8]}",
"email": f"{owner[:8]}@dl.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
slug = make_combined_slug("E2E Devlog Project", uid)
get_table("projects").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "E2E Devlog Project",
"description": "Project for devlog browser tests.",
"project_type": "software",
"platforms": "",
"status": "In Development",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid, owner
def _seed_project_post(project_uid, user_uid, order, title=None):
"""Insert a post linked to a project with precise ordering."""
uid = str(uuid4())
marker = title or f"dlpost-{uid[:8]}"
get_table("posts").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": user_uid,
"slug": make_combined_slug(marker, uid),
"title": marker,
"content": f"Devlog post content {order}",
"topic": "devlog",
"project_uid": project_uid,
"image": None,
"stars": 0,
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=order)).isoformat(),
}
)
return marker, uid
def _create_project_ui(page, title):
"""Create a project via the UI."""
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for devlog UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def test_devlog_empty_state_on_project_page(alice):
"""Project with no linked posts displays 'No devlog posts yet'."""
page, _ = alice
_create_project_ui(page, "Empty Devlog Project")
devlog_section = page.locator(".project-devlog")
expect(devlog_section).to_be_visible()
expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible()
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
def test_devlog_shows_linked_post_title(alice):
"""Linked post title renders in the devlog section."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
marker = f"visible-dl-{uuid4().hex[:8]}"
_seed_project_post(project_uid, owner_uid, 0, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
devlog = page.locator(".project-devlog")
expect(devlog.locator(f"h3:has-text('{marker}')")).to_be_visible()
def test_devlog_shows_author_info(alice):
"""Author avatar, name and level appear on devlog posts."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
marker = f"auth-dl-{uuid4().hex[:8]}"
_seed_project_post(project_uid, owner_uid, 0, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
post_card = page.locator(".project-devlog .post-card").first
expect(post_card.locator(".post-header")).to_be_visible()
expect(post_card.locator(".post-author-link")).to_be_visible()
expect(post_card.locator(".post-time")).to_be_visible()
def test_devlog_shows_vote_and_comment_buttons(alice):
"""Devlog post renders vote buttons and comment count."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
marker = f"action-dl-{uuid4().hex[:8]}"
_seed_project_post(project_uid, owner_uid, 0, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
post_card = page.locator(".project-devlog .post-card").first
expect(post_card.locator(".post-action-btn.vote-up")).to_be_visible()
expect(post_card.locator(".post-action-btn.vote-down")).to_be_visible()
expect(post_card.locator(".post-vote-count")).to_be_visible()
expect(post_card.locator("form[action*='/posts/delete/']")).to_be_visible()
def test_devlog_load_more_appears_with_many_posts(alice):
"""More than PAGE_SIZE posts produces a Load More link."""
from devplacepy.database.pagination import PAGE_SIZE
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
count = PAGE_SIZE + 1
for i in range(count):
_seed_project_post(project_uid, owner_uid, i, title=f"loadmore-{i}")
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
devlog = page.locator(".project-devlog")
expect(devlog.locator(".post-card")).to_have_count(PAGE_SIZE)
expect(devlog.locator(".load-more-wrap")).to_be_visible()
def test_devlog_guest_sees_devlog_section(app_server):
"""Unauthenticated visitors can see the devlog section."""
import requests
slug, _, _ = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{slug}")
assert r.status_code == 200, r.text[:200]
assert "No devlog posts yet." in r.text
assert 'class="project-devlog"' in r.text
def test_devlog_multiple_posts_order(alice):
"""Posts appear newest-first in the devlog."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
markers = []
for i in range(3):
marker = f"order-{i}-{uuid4().hex[:8]}"
markers.append(marker)
_seed_project_post(project_uid, owner_uid, i, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
titles = page.locator(".project-devlog .post-card .post-title")
expect(titles).to_have_count(3)
first_text = titles.nth(0).inner_text()
assert markers[-1] == first_text, (
f"Expected newest post first: {markers[-1]}, got: {first_text}"
)

View File

@ -9,7 +9,7 @@ def test_primary_admin_ignores_accounts_without_a_signup_time(local_db):
"uid": "pa-real",
"username": "pa-real",
"role": "Admin",
"created_at": "2020-01-01T00:00:00",
"created_at": "1990-01-01T00:00:00",
"deleted_at": None,
}
)
@ -45,9 +45,9 @@ def test_primary_admin_skips_deactivated_and_deleted_founders(local_db):
users = local_db["users"]
seeded = [
("pa-deleted", "2001-01-01T00:00:00", True, "2020-01-01T00:00:00"),
("pa-inactive", "2002-01-01T00:00:00", False, None),
("pa-usable", "2003-01-01T00:00:00", True, None),
("pa-deleted", "1991-01-01T00:00:00", True, "2020-01-01T00:00:00"),
("pa-inactive", "1992-01-01T00:00:00", False, None),
("pa-usable", "1993-01-01T00:00:00", True, None),
]
for uid, created_at, active, deleted_at in seeded:
users.insert(

View File

@ -48,3 +48,298 @@ def test_create_notification_authorization_is_jwt():
token = push.create_notification_authorization("https://push.example.com/endpoint")
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)

View File

@ -0,0 +1,164 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
import pytest
from pydantic import ValidationError
from devplacepy.database import get_table
from devplacepy.services.openai_gateway import quota as q
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
from devplacepy.utils import generate_uid
_counter = [0]
def _owner():
_counter[0] += 1
return f"quotauser{_counter[0]}-{generate_uid()}"
def _burn(owner_id, app_reference, cost, minutes_ago=0):
stamp = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
get_table(GATEWAY_LEDGER).insert(
{
"uid": generate_uid(),
"owner_kind": "user",
"owner_id": owner_id,
"app_reference": app_reference,
"cost_usd": cost,
"created_at": stamp.isoformat(),
}
)
def test_reset_requires_no_dimension(local_db):
assert q.QuotaResetIn().owner_kind is None
assert q.QuotaResetIn().owner_id is None
assert q.QuotaResetIn().app_reference is None
def test_reset_rejects_an_unknown_owner_kind(local_db):
with pytest.raises(ValidationError):
q.QuotaResetIn(owner_kind="wizard")
def test_reset_rejects_a_malformed_app_reference(local_db):
with pytest.raises(ValidationError):
q.QuotaResetIn(app_reference="not a valid app!")
def test_reset_normalizes_blanks_to_wildcards(local_db):
payload = q.QuotaResetIn(owner_kind="", owner_id=" ", app_reference="")
assert payload.owner_kind is None
assert payload.owner_id is None
assert payload.app_reference is None
def test_spend_counts_before_any_reset(local_db):
owner = _owner()
_burn(owner, "appa", 1.5)
assert q.spent_24h("user", owner, "appa") == 1.5
def test_a_scoped_reset_clears_that_scope(local_db):
owner = _owner()
_burn(owner, "appa", 1.5)
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
assert q.spent_24h("user", owner, "appa") == 0.0
def test_a_scoped_reset_leaves_another_caller_alone(local_db):
first, second = _owner(), _owner()
_burn(first, "appa", 1.5)
_burn(second, "appa", 2.0)
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=first, app_reference="appa"))
assert q.spent_24h("user", first, "appa") == 0.0
assert q.spent_24h("user", second, "appa") == 2.0
def test_a_scoped_reset_leaves_another_app_alone(local_db):
owner = _owner()
_burn(owner, "appa", 1.5)
_burn(owner, "appb", 2.0)
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
assert q.spent_24h("user", owner, "appa") == 0.0
assert q.spent_24h("user", owner, "appb") == 2.0
def test_a_global_reset_clears_every_scope(local_db):
first, second = _owner(), _owner()
_burn(first, "appa", 1.5)
_burn(second, "appb", 2.0)
q.reset(q.QuotaResetIn())
assert q.spent_24h("user", first, "appa") == 0.0
assert q.spent_24h("user", second, "appb") == 0.0
def test_spend_after_a_reset_counts_again(local_db):
owner = _owner()
_burn(owner, "appa", 1.5)
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
_burn(owner, "appa", 0.75)
assert q.spent_24h("user", owner, "appa") == 0.75
def test_a_reset_keeps_the_usage_history(local_db):
owner = _owner()
_burn(owner, "appa", 1.5)
before = get_table(GATEWAY_LEDGER).count(owner_id=owner)
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
assert get_table(GATEWAY_LEDGER).count(owner_id=owner) == before
def test_a_narrower_reset_does_not_clear_a_broader_scope(local_db):
owner = _owner()
_burn(owner, "appa", 1.5)
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
assert q.spent_24h("user", owner, None) == 1.5
def test_a_broader_reset_clears_a_narrower_scope(local_db):
owner = _owner()
_burn(owner, "appa", 1.5)
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner))
assert q.spent_24h("user", owner, "appa") == 0.0
def test_resetting_the_same_scope_twice_reuses_one_row(local_db):
owner = _owner()
scope = q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa")
first = q.reset(scope)
second = q.reset(scope)
assert first["uid"] == second["uid"]
assert second["reset_at"] >= first["reset_at"]
def test_a_rule_scope_is_unblocked_by_a_reset(local_db):
owner = _owner()
q.quota_rule_store.set(
q.QuotaRuleIn(
owner_kind="user", owner_id=owner, app_reference="appa", limit_usd=1.0
),
created_by="test",
)
_burn(owner, "appa", 1.5)
limit, scope, rule = q.resolve("user", owner, "appa", {})
assert q.spent_24h(*scope) >= limit
q.reset(
q.QuotaResetIn(
owner_kind=scope[0], owner_id=scope[1], app_reference=scope[2]
)
)
assert q.spent_24h(*scope) < limit
q.quota_rule_store.remove(rule.uid)
def test_scope_label_reads_like_the_rule_label(local_db):
scope = {"owner_kind": "user", "owner_id": "u1", "app_reference": "appa"}
assert q.scope_label(scope) == "role=user, user=u1, app=appa"
def test_scope_label_falls_back_for_a_wildcard_scope(local_db):
empty = {"owner_kind": None, "owner_id": None, "app_reference": None}
assert q.scope_label(empty, fallback="every caller") == "every caller"

117
tests/unit/services/push.py Normal file
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