Compare commits

..

2 Commits

Author SHA1 Message Date
Typosaurus
85cf634e60 ticket #88 attempt 2 2026-07-19 21:21:46 +00:00
Typosaurus
aa178a8c62 ticket #88 attempt 1 2026-07-19 21:05:14 +00:00
781 changed files with 14998 additions and 52132 deletions

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -40,10 +40,10 @@ The already-established **choke points** (the public function is a thin wrapper
- **C. Cross-reference before every change.** A funnel is called from many sites; deferring inside it changes ALL of them. Find every caller and confirm none depends on the side-effect's result synchronously. If even one does, do not defer the funnel.
- **D. Zero degradation.** A fix must never reduce functionality, weaken a check, or change observable behavior beyond moving WHEN a side-effect runs. Deferral is best-effort and must NEVER raise into the caller. If the only fix would degrade or risk stale reads, record it unfixed with the safe path forward.
- **E. Dig deep.** Pursue the root cause; gather more evidence rather than guessing or bailing.
- **F. Verify your own work.** After each edit, re-read the changed region, re-check the callers, confirm `python -c "from devplacepy.main import app"` still imports clean, and re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates).
- **F. Verify your own work.** After each edit, re-read the changed region, re-check the callers, confirm `python -c "from devplacepy.main import app"` still imports clean, and run `hawk .`.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, confirm the app imports clean, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm they pass. **HARD GUARDRAIL: never run the test suite (no `make test`, no `pytest`); never perform any git write operation.** Validate by clean import + the per-language checks + an em-dash scan only.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, confirm the app imports clean, then run `hawk .` and confirm it passes. **HARD GUARDRAIL: never run the test suite (no `make test`, no `pytest`); never perform any git write operation.** Validate by clean import + hawk + an em-dash scan only.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); full typing on functions you add; keep `retoor <retoor@molodetz.nl>` as the first line of any source file you create.
@ -78,4 +78,4 @@ FIX: move the side-effect into a thin public wrapper that `background.submit(_wo
- **wrong-tool**: grep `background.submit(` for any argument that is an `async def`/coroutine function, and any external-client call (gitea/push/AI) deferred via the sync queue.
## Output
Return a markdown report: a one-line summary, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name (e.g. `missing-deferral`, `double-wrap`, `unsafe-deferral`, `bypass-funnel`, `wrong-tool`, `captured-request`, `broken-wiring`), the message, and (in fix mode) whether it was fixed. End with the verification you ran (clean import, the per-language checks, em-dash scan) and its result. Never claim the test suite was run.
Return a markdown report: a one-line summary, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name (e.g. `missing-deferral`, `double-wrap`, `unsafe-deferral`, `bypass-funnel`, `wrong-tool`, `captured-request`, `broken-wiring`), the message, and (in fix mode) whether it was fixed. End with the verification you ran (clean import, `hawk .`, em-dash scan) and its result. Never claim the test suite was run.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -56,11 +56,11 @@ When a layer is intentionally absent (an internal route with no public docs, a r
No comments or docstrings in source you author; full typing on every signature and variable; `pathlib` over `os`; dataclasses over fixed-key dicts; no magic numbers; no version pinning; no em-dashes anywhere (use a hyphen) in any file you touch. Keep `retoor <retoor@molodetz.nl>` as the first line (correct comment style for the language) of any NEW source file you create - never of the existing files you edit, and never inside a `.md` with YAML frontmatter.
## Validation (after implementing; never skip)
There is NO validator binary in this environment - validate each touched file directly, using the Python interpreter where `import devplacepy` resolves its dependencies (verify that first; the repo `.venv` may be incomplete). Then: confirm `python -c "from devplacepy.main import app"` imports clean; compile or parse every touched language (`python -m py_compile <files>` for Python, `node --check <file>` for JS, brace balance for CSS, tag and `{% %}`/`{{ }}` balance for templates); and grep every touched file for em-dashes - the character AND the entity forms `&mdash;`/`&#8212;`/`&#x2014;` - confirming none. For any new `*Out` schema, `model_validate` it against a representative context dict so a key mismatch surfaces now, not at request time. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
There is NO `hawk` or validator binary in this environment - validate each touched file directly, using the Python interpreter where `import devplacepy` resolves its dependencies (verify that first; the repo `.venv` may be incomplete). Then: confirm `python -c "from devplacepy.main import app"` imports clean; compile or parse every touched language (`python -m py_compile <files>` for Python, `node --check <file>` for JS, brace balance for CSS, tag and `{% %}`/`{{ }}` balance for templates); and grep every touched file for em-dashes - the character AND the entity forms `&mdash;`/`&#8212;`/`&#x2014;` - confirming none. For any new `*Out` schema, `model_validate` it against a representative context dict so a key mismatch surfaces now, not at request time. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
## Live verification of UI/API changes (mandatory for visual work)
A structurally valid template can still render broken - the static checks and the import check never open a browser. Per CLAUDE.md this project treats live verification as non-negotiable for any layout, styling, component, responsive, or backend change:
- Do not assume any verification CLI is installed (`mole`, `falcon`, `hound` are NOT present here); check with `command -v` first and fall back to the steps below or the project's `screenshot`/`serve`/`validate` skills when they exist.
A structurally valid template can still render broken - `hawk` and the import check never open a browser. Per CLAUDE.md this project treats live verification as non-negotiable for any layout, styling, component, responsive, or backend change:
- Do not assume any verification CLI is installed (`hawk`, `mole`, `falcon`, `hound` are NOT present here); check with `command -v` first and fall back to the steps below or the project's `screenshot`/`serve`/`validate` skills when they exist.
- When your change touches `templates/` or `static/`, the rendered result MUST be visually verified: start the dev server (`make dev` in the background; confirm it is healthy on `http://localhost:10500`), capture each new/changed route with headless Playwright (`wait_until="domcontentloaded"`), and inspect the screenshot against the intended UI and the surrounding design system (tokens, spacing, responsiveness). Tear down any server you started.
- When your change touches `routers/`, verify the endpoints over HTTP against the live server (an api-spec runner if available, otherwise `curl`/`httpx` asserting the status and a body fragment).
- The `/feature` workflow performs this live `Verify` phase for you automatically; when you are invoked standalone for UI/API work, perform it yourself before declaring the work complete, or explicitly state it is the caller's responsibility and name the routes to check.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Visual judgement is out of scope for auto-fix and is recorded as a finding. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Visual judgement is out of scope for auto-fix and is recorded as a finding. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. A rename is auto-applied ONLY for a confirmed local/private name that passed the decision algorithm AND only after you grep and update every reference in the same run. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. A rename is auto-applied ONLY for a confirmed local/private name that passed the decision algorithm AND only after you grep and update every reference in the same run. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After writing a test module, validate it ONLY by a clean import (`python -c "import tests..."` or `python -m py_compile`).
## Mode
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER launch the suite yourself, not the full suite and not a single file - the serial single-process suite cannot run concurrently with other agents, so the orchestrating session runs `make test` (all tests) after your work.** Validate only by a clean import of the new test module. Never perform any git write operation.
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER run the suite, not the full suite and not a single file.** Validate only by a clean import of the new test module. Never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -12,4 +12,4 @@ Follow the audit-log design (`devplacepy/services/audit/`); confirm against the
3. Call the recorder on the mutation's success path: `audit.record(request, event_key, ...)` in HTTP or WebSocket handlers, or `audit.record_system(event_key, ...)` in request-less contexts (services, jobs, CLI). On a guard or denial branch pass `result="denied"`; on a failure branch pass `result="failure"`.
4. Route through the existing DRY choke point when one applies (`content.py`, the `project_files.py` helpers, `routers/containers.py` `_audit_instance`, the Devii dispatcher `_audit_mechanic`) instead of scattering call sites. The HTTP path and the Devii path for one mutation must stay disjoint (no double counting).
5. Recording is best-effort: wrap nothing the caller depends on, and NEVER gate the audited action on the record succeeding.
6. Validate the touched files with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and `python -c "from devplacepy.main import app"`.
6. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.

View File

@ -10,4 +10,4 @@ Follow the docs convention exactly (confirm against `devplacepy/routers/docs/pag
1. Create `devplacepy/templates/docs/<slug>.html` as a prose page: one `<div class="docs-content" data-render> ... </div>` containing GitHub-flavored markdown. The page is rendered server-side. Any example component markup INSIDE the data-render block must be HTML-escaped (`&lt;dp-...&gt;`); a live demo, if any, goes in a SEPARATE block OUTSIDE the data-render div with its own `<script type="module">`.
2. Register it in `DOCS_PAGES` in `devplacepy/routers/docs/pages.py`: `{"slug": "<slug>", "title": "<title>", "kind": "prose", "section": SECTION_*}`. Add `"admin": True` for an admin-only page. If a new section is needed, add a `SECTION_*` constant and place it in the correct `AUDIENCES` group.
3. Write accurate, professional content - confirm every factual claim against the source. No em-dashes, no AI disclaimers, dates as DD/MM/YYYY.
4. Validate: check the new template for tag and `{% %}` balance and `pages.py` with `python -m py_compile` + `pyflakes`, run `python -c "from devplacepy.main import app"`, and confirm the slug is registered with no duplicate.
4. Validate: run `hawk` on the new template and on `pages.py`, run `python -c "from devplacepy.main import app"`, and confirm the slug is registered with no duplicate.

View File

@ -33,7 +33,7 @@ Arguments: `$ARGUMENTS`
## Execute
1. Resolve the dimension list and mode from the arguments above.
2. **CHECK mode**: launch every selected subagent concurrently (one `Agent` call per dimension in a single message). Each subagent runs read-only and returns its findings report. Tell each subagent explicitly: "Operate in REPORT mode. Do not modify any file." If `changed`, append the file list and: "Restrict findings to these files."
3. **FIX mode**: launch the selected subagents **one at a time in canonical order** (never in parallel - parallel edits to the same file would conflict). Tell each: "Operate in FIX mode: apply minimal root-cause fixes per your doctrine, then re-validate every file you touched with the per-language checks and confirm `python -c \"from devplacepy.main import app\"` still imports clean." Wait for each to finish before starting the next. If `changed`, append the file list and: "Restrict fixes to these files."
3. **FIX mode**: launch the selected subagents **one at a time in canonical order** (never in parallel - parallel edits to the same file would conflict). Tell each: "Operate in FIX mode: apply minimal root-cause fixes per your doctrine, then run `hawk .` and confirm it passes." Wait for each to finish before starting the next. If `changed`, append the file list and: "Restrict fixes to these files."
4. Each subagent's final message is its report; it is not shown to the user directly, so collect them.
## Report

View File

@ -13,4 +13,4 @@ Mirror an existing service - read `devplacepy/services/base.py` (BaseService) an
4. If it calls an LLM, default its endpoint to `config.INTERNAL_GATEWAY_URL` and authenticate with the internal gateway key, like the other AI consumers.
5. Emit audit events via `record_system` for any state change it makes.
6. Document it in `devplacepy/services/CLAUDE.md` (Background services base machinery section, or the service's own nested `CLAUDE.md` if it has one) and in `README.md` if user-visible.
7. Validate the touched files with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and `python -c "from devplacepy.main import app"`.
7. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.

View File

@ -1,5 +1,5 @@
---
description: Run DevPlace tests. Run a tier, a file, or a single test with the correct flags. Subagents never run tests themselves (the serial suite cannot run concurrently); the orchestrating session always runs the full suite as the final validation of every change.
description: Run DevPlace tests - the sanctioned explicit-ask path. Run a tier, a file, or a single test with the correct flags. The agents never run tests themselves; this command is how you ask.
argument-hint: [unit|api|e2e|all|<path::test_name>]
allowed-tools: Bash(make test*), Bash(python -m pytest *), Read
---
@ -12,6 +12,6 @@ Mapping:
- `all` or empty -> `make test`
- a path like `tests/api/posts/create.py::test_x` -> `python -m pytest <that> -v --tb=line -x`
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. Subagents and workflows never launch tests (the serial single-process suite cannot run concurrently); the orchestrating session runs the full suite (`make test`) as the mandatory final validation of every change.
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. This command is the one sanctioned way to run them (the subagents and workflows never do).
Report results clearly. On a failure, show the relevant output, and if a browser (e2e) test failed, point me at the screenshot under `/tmp/devplace_test_screenshots/`. Never weaken a test to make it pass; if a test reveals a real bug, report it - do not edit the test.

View File

@ -1,13 +1,13 @@
---
description: Run the mandatory DevPlace pre-completion verification on changed files - the per-language checks, the app import, and an em-dash scan. Zero errors required. Never runs the test suite.
allowed-tools: Bash(python *), Bash(node *), Bash(git status:*), Bash(git diff:*), Read, Grep
description: Run the mandatory DevPlace pre-completion verification on changed files - the validator, the app import, and an em-dash scan. Zero errors required. Never runs the test suite.
allowed-tools: Bash(python *), Bash(hawk *), Bash(git status:*), Bash(git diff:*), Read, Grep
---
Changed files in the working tree:
!`git status --porcelain`
Verify the work is complete and correct, following the DevPlace verification rule (zero tolerance):
1. For each changed or new file under `devplacepy/` or `tests/`, run the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates). Every file must come back clean.
1. For each changed or new file under `devplacepy/` or `tests/`, run `hawk <file>` (it covers Python, JavaScript, CSS, and HTML/Jinja). Every file must report clean.
2. Run `python -c "from devplacepy.main import app"` - it must import with no error.
3. Grep the changed files for em-dash characters (U+2014 and U+2013) that are authored prose, and report any. Leave em-dashes that are data (replace/maketrans/regex targets, fixtures) untouched.
4. Report a PASS or FAIL summary with the exact failures.

View File

@ -1,154 +0,0 @@
#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>
import json
import re
import sys
from pathlib import Path
CONFIRMATION_TOKEN = "I-HAVE-CONFIRMED-PRODUCTION-DB-ACCESS"
PRODUCTION_PATHS = re.compile(
r"data/(devplace\.db|devii_tasks\.db|devii_lessons\.db|keys|uploads"
r"|attachments|project_files|backups)\b"
)
PRODUCTION_DB_FILE = re.compile(r"\bdevplace\.db\b")
MANAGEMENT_CLI = re.compile(r"(?:^|[;&|(]\s*|\s)(?:[\w./-]*/)?devplace\s+(?!-)")
PYTHON_INVOCATION = re.compile(r"(?:^|[;&|(\s])(?:[\w./-]*/)?python[0-9.]*(?:\s|$)")
DATABASE_OVERRIDE = re.compile(r"DEVPLACE_DATABASE_URL\s*=\s*[\"']?(\S+?)[\"']?(?:\s|$)")
DATABASE_ASSIGNMENT = re.compile(r"DEVPLACE_DATABASE_URL[\"'\]\s]*[=,]")
MODULE_INVOCATION = re.compile(r"-m\s+devplacepy")
INLINE_CODE = re.compile(r"-c\s+(?P<quote>[\"'])(?P<code>.*?)(?P=quote)", re.DOTALL)
SCRIPT_PATH = re.compile(r"(?:^|\s)(?P<path>[\w./~-]+\.py)(?:\s|$)")
IMPORT_GATE = re.compile(
r"^from devplacepy\.main import app\s*;?\s*(?:print\([^)]*\)\s*;?\s*)?$"
)
TEST_RUNNER = re.compile(r"\bpytest\b|\bmake\s+(test|test-[\w-]+)\b")
SERVER_TARGET = re.compile(r"\bmake\s+(dev|prod|docker-[\w-]+|ppy)\b")
APPLICATION_IMPORT = re.compile(
r"(?:^|[\s;])(?:from|import)\s+devplacepy\b"
r"|import_module\s*\(\s*[\"']devplacepy",
re.MULTILINE,
)
def reaches_application(text: str) -> bool:
return bool(APPLICATION_IMPORT.search(text))
def overrides_the_database(text: str) -> bool:
match = DATABASE_OVERRIDE.search(text)
if not match:
return False
return "data/devplace.db" not in match.group(1)
def source_targets_a_scratch_database(source: str) -> bool:
if PRODUCTION_DB_FILE.search(source):
return False
return bool(DATABASE_ASSIGNMENT.search(source))
def script_is_safe(command: str) -> bool | None:
match = SCRIPT_PATH.search(command)
if not match:
return None
path = Path(match.group("path")).expanduser()
try:
body = path.read_text(errors="replace")
except OSError:
return None
if not reaches_application(body):
return True
return source_targets_a_scratch_database(body)
def hazard_in(command: str) -> str:
if CONFIRMATION_TOKEN in command:
return ""
if TEST_RUNNER.search(command) or SERVER_TARGET.search(command):
return ""
if PRODUCTION_DB_FILE.search(command) or PRODUCTION_PATHS.search(command):
return "it names the production database or a production data directory"
if MANAGEMENT_CLI.search(command):
return "the devplace management CLI operates on the production database"
if not PYTHON_INVOCATION.search(command):
return ""
if overrides_the_database(command):
return ""
if MODULE_INVOCATION.search(command):
return "it runs a devplacepy module with no DEVPLACE_DATABASE_URL override"
inline = INLINE_CODE.search(command)
if inline:
code = inline.group("code").strip()
if not reaches_application(code):
return ""
if IMPORT_GATE.match(code):
return ""
return "it imports devplacepy inline with no DEVPLACE_DATABASE_URL override"
safe = script_is_safe(command)
if safe is None:
return ""
if safe:
return ""
return "the script imports devplacepy with no DEVPLACE_DATABASE_URL override"
def refuse(reason: str) -> dict:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Blocked: this command reaches the production database because {reason}. "
"The production database is never touched without the user's explicit, "
"stated confirmation. Stop, tell the user exactly what the command would "
"read or write, and ask them to confirm in their own words. Only after "
f"they have done so may the command carry the literal token "
f"{CONFIRMATION_TOKEN}, which still raises a permission prompt they must "
"approve. Never add that token on your own initiative. Alternatives that "
"need no confirmation: set DEVPLACE_DATABASE_URL to a scratch database, "
"or run the test suite."
),
}
}
def confirm(reason: str) -> dict:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": (
"This command carries the production-database confirmation token and "
f"reaches the production database because {reason}. Approve only if you "
"asked for this."
),
}
}
def main() -> None:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
sys.exit(0)
command = (payload.get("tool_input") or {}).get("command") or ""
if not command:
sys.exit(0)
if CONFIRMATION_TOKEN in command:
stripped = command.replace(CONFIRMATION_TOKEN, "")
reason = hazard_in(stripped)
if reason:
print(json.dumps(confirm(reason)))
sys.exit(0)
reason = hazard_in(command)
if reason:
print(json.dumps(refuse(reason)))
sys.exit(0)
if __name__ == "__main__":
main()

View File

@ -1,25 +0,0 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"deny": [
"Bash(devplace *)",
"Write(data/**)",
"Edit(data/**)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_production_db.py\"",
"timeout": 10,
"statusMessage": "Checking for production database access"
}
]
}
]
}
}

View File

@ -18,7 +18,7 @@ const RULES = [
'- A Devii Action requires_auth/requires_admin MUST exactly match the underlying route guard. Never grant a member an admin capability. A non-admin must not even see an admin tool schema.',
'- If the action is irreversible or destructive, add it to dispatcher CONFIRM_REQUIRED and declare a confirm boolean param in its spec (schemas set additionalProperties:false, so a gated tool without a declared confirm param can never receive confirm=true and loops forever).',
'- Prefer handler="http" reusing an existing REST route; only add a local controller handler when there is no route. Reuse the arg()/body()/query()/confirm() helpers for params.',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const TESTS = [
@ -100,7 +100,7 @@ const map = await agent(
)
const build = await agent(
`Add this Devii tool, editing files directly in the repo. Add the Action to the catalog mirroring the similar action, set requires_auth/requires_admin to exactly match the underlying route guard, wire the dispatcher handler if a new local handler is needed, and add a docs_api.py entry if it wraps an HTTP endpoint. If destructive, add it to CONFIRM_REQUIRED and declare a confirm param. Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nTool request: ${ask}\n\nContext:\n${JSON.stringify(map, null, 2)}\n\n${RULES}\n\nReturn the action name, files changed, and whether the checks and the import passed.`,
`Add this Devii tool, editing files directly in the repo. Add the Action to the catalog mirroring the similar action, set requires_auth/requires_admin to exactly match the underlying route guard, wire the dispatcher handler if a new local handler is needed, and add a docs_api.py entry if it wraps an HTTP endpoint. If destructive, add it to CONFIRM_REQUIRED and declare a confirm param. Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nTool request: ${ask}\n\nContext:\n${JSON.stringify(map, null, 2)}\n\n${RULES}\n\nReturn the action name, files changed, and whether validator and import passed.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
@ -127,7 +127,7 @@ const gaps = audits
let gapFix = 'no actionable gaps'
if (gaps.length) {
gapFix = await agent(
`Close these Devii tool gaps with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
`Close these Devii tool gaps with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
)
}

View File

@ -17,7 +17,7 @@ const RULES = [
'- No em-dash characters; use a hyphen. Full Python type hints; pathlib over os; Pydantic Form input with explicit max lengths; sanitize and bound user input.',
'- Reuse templating.templates, database.py batch helpers, respond(), the shared partials and frontend utilities. Never per-router Jinja2Templates.',
'- Guards: get_current_user (public read), require_user (member write), require_admin (admin). Every POST/PUT/DELETE is guarded. Declare specific routes before catch-alls. Never pass a respond() context key that collides with a Jinja global (use viewer_is_admin).',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const TOUCHPOINTS = [
@ -108,7 +108,7 @@ const map = await agent(
)
const build = await agent(
`Implement this single DevPlace route across every applicable touchpoint, editing files directly in the repo, mirroring the closest existing route. Keep the layers in agreement (Out schema carries every returned JSON key; Devii action auth flags match the guard). Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nEndpoint: ${ask}\n\nClosest route to mirror:\n${JSON.stringify(map, null, 2)}\n\n${TOUCHPOINTS}\n\n${RULES}\n\nReturn the files changed and whether the checks and the import passed.`,
`Implement this single DevPlace route across every applicable touchpoint, editing files directly in the repo, mirroring the closest existing route. Keep the layers in agreement (Out schema carries every returned JSON key; Devii action auth flags match the guard). Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nEndpoint: ${ask}\n\nClosest route to mirror:\n${JSON.stringify(map, null, 2)}\n\n${TOUCHPOINTS}\n\n${RULES}\n\nReturn the files changed and whether validator and import passed.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
@ -135,7 +135,7 @@ const gaps = audits
let gapFix = 'no actionable gaps'
if (gaps.length) {
gapFix = await agent(
`Close these gaps on the new route with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
`Close these gaps on the new route with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
)
}

View File

@ -22,7 +22,7 @@ const RULES = [
'- Reuse shared helpers: templating.templates (never a per-router Jinja2Templates), database.py batch helpers (no inline N+1), the respond() negotiator, _avatar_link.html / _user_link.html, and on the frontend Http / Poller / JobPoller / OptimisticAction / FloatingWindow and the dp-* components.',
'- Auth guards: get_current_user (public read), require_user (member write), require_admin (admin). Every POST/PUT/DELETE is guarded; deletes are soft and owner-or-admin.',
'- Never pass a respond() context key that collides with a Jinja global (use viewer_is_admin, not is_admin). Dates are DD/MM/YYYY via format_date.',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the pytest suite. Never perform any git write.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the pytest suite. Never perform any git write.',
].join('\n')
const FANOUT = [
@ -185,7 +185,7 @@ const plan = await agent(
)
const build = await agent(
`Implement directly - no plan, no approval needed, this is implement mode. Build this DevPlace feature coherently and completely, editing files in the repo, following the plan. Keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard; a respond() context key must never shadow a Jinja global). Do NOT write pytest tests in this step (a later phase owns that). When done, run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"" and report whether each passed, and list the user-facing routes the feature exposes.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the checks and the import passed, the routes, and a short summary.`,
`Implement directly - no plan, no approval needed, this is implement mode. Build this DevPlace feature coherently and completely, editing files in the repo, following the plan. Keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard; a respond() context key must never shadow a Jinja global). Do NOT write pytest tests in this step (a later phase owns that). When done, run "hawk ." and "python -c \\"from devplacepy.main import app\\"" and report whether each passed, and list the user-facing routes the feature exposes.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the validator and the import passed, the routes, and a short summary.`,
{ agentType: 'feature-builder', label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
@ -279,7 +279,7 @@ for (const i of (live && live.issues) || []) {
let gapFix = 'no actionable gaps from the audit or live verification'
if (gaps.length) {
gapFix = await agent(
`Close these confirmed completeness, security, style, frontend, and live-rendering gaps found in the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement and the styling consistent with the design system. Re-run the per-language checks afterward. Do not run the pytest suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
`Close these confirmed completeness, security, style, frontend, and live-rendering gaps found in the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement and the styling consistent with the design system. Re-run "hawk ." afterward. Do not run the pytest suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ agentType: 'feature-builder', label: 'fix-gaps', phase: 'Fix' }
)
}

View File

@ -19,7 +19,7 @@ const RULES = [
'- Runtime artifacts live in config.DATA_DIR (the var/ dir), OUTSIDE the devplacepy package and NOT under /static. Heavy compression or blocking work runs in a subprocess. SQLite stays synchronous.',
'- Enqueue endpoints own authz (require_user plus any resource guard); status and download are capability URLs scoped only by the unguessable uuid7. Soft-delete the job tracking rows; permanent artifacts are not deleted by cleanup().',
'- Record audit events with record_system in the service. Frontend status polling uses JobPoller, never a bespoke loop.',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const CHECKLIST = [
@ -133,7 +133,7 @@ const plan = await agent(
)
const build = await agent(
`Implement this new async job kind coherently, editing files directly in the repo, mirroring ZipService/ForkService and following the plan. Keep the *JobOut schema, routes, Devii tools, and docs in agreement. Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write tests here. Do not run the suite. Do not commit.\n\nJob request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${CHECKLIST}\n\n${RULES}\n\nReturn the job kind, files changed, and whether the checks and the import passed.`,
`Implement this new async job kind coherently, editing files directly in the repo, mirroring ZipService/ForkService and following the plan. Keep the *JobOut schema, routes, Devii tools, and docs in agreement. Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write tests here. Do not run the suite. Do not commit.\n\nJob request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${CHECKLIST}\n\n${RULES}\n\nReturn the job kind, files changed, and whether validator and import passed.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
@ -162,7 +162,7 @@ const gaps = audits
let gapFix = 'no actionable gaps'
if (gaps.length) {
gapFix = await agent(
`Close these job-service gaps with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
`Close these job-service gaps with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
)
}

1
.dpc/verify_cache.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

3
.gitignore vendored
View File

@ -14,10 +14,7 @@ notification-private.pem
notification-private.pkcs8.pem
notification-public.pem
.pytest_cache/
.ruff_cache/
.opencode
.dpc/
.claude/settings.local.json
devii_*.db
devii_*.db-shm
devii_*.db-wal

View File

@ -1,636 +0,0 @@
# DevPlace architecture atlas
Author: retoor <retoor@molodetz.nl>
Every diagram below was derived from the source tree, the live SQLite schema and the imported FastAPI app object, not from the documentation. Where the repository documentation and the running code disagree, the disagreement is recorded in the last section.
| Measure | Value |
|---|---|
| python files | 644 |
| routers | 38 |
| http paths | 348 |
| websockets | 12 |
| services | 27 |
| db tables | 102 |
| agent tools | 318 |
| test functions | 3073 |
## 01 Deployment topology
Source: `docker-compose.yml / Dockerfile / nginx/nginx.conf.template`
Two containers on one bridge network, on a single host. nginx is the only published listener and binds to loopback only. The app container bind-mounts the repository root, so the production process reads the same SQLite file and the same `data/` tree as `make dev`.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
client["Browser / API client / Telegram / devRant client"]
subgraph host["Single host"]
subgraph appnet["docker network: appnet"]
nginx["nginx container<br/>127.0.0.1:PORT to :80<br/>8 websocket upgrade locations"]
app["app container<br/>uvicorn devplacepy.main:app<br/>--workers 2 --backlog 8192"]
end
repo["repository root<br/>bind mount .:/app"]
static["devplacepy/static<br/>read only mount"]
data["DEVPLACE_DATA_DIR = data/<br/>23 registered paths"]
sqlite[("data/devplace.db<br/>SQLite, WAL, 256MB mmap")]
lock["data/locks/devplace-services.lock<br/>flock, elects one service owner"]
docker["host Docker daemon<br/>ppy:latest instances"]
end
client --> nginx
nginx -->|"proxy_pass, X-Real-IP"| app
nginx -->|"/static/, /static/uploads/"| static
app --> repo
app --> data
data --> sqlite
app --> lock
app -->|"docker CLI backend"| docker
```
nginx depends_on app with condition service_healthy. App healthcheck: interval 30s, timeout 10s, retries 3, start_period 120s, start_interval 2s.
## 02 Worker boot and service election
Source: `devplacepy/main.py lifespan`
Every uvicorn worker runs the full lifespan. Schema work is serialized behind an exclusive lock so workers pay it one after another; background services are elected, so exactly one worker in the host owns them.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
s1["ensure_data_dirs<br/>creates all 23 DATA_PATHS"]
s2["init_lock<br/>exclusive flock on INIT_LOCK_FILE"]
s3["init_db<br/>idempotent columns and indexes"]
s4["ensure_certificates<br/>VAPID keys in data/keys"]
s5["service_manager.register x27"]
gate{"DEVPLACE_DISABLE_SERVICES set?"}
skip["services registered but never started<br/>test mode"]
bg["background.start<br/>per worker asyncio queue"]
elect{"acquire_service_lock"}
owner["set_lock_owner True<br/>supervise all 27 services"]
decline["declined, another worker owns them"]
vis["start_visit_flusher"]
serve(["serving on port 10500"])
s1 --> s2 --> s3 --> s4 --> s5 --> gate
gate -->|yes| skip --> vis
gate -->|no| bg --> elect
elect -->|"lock acquired"| owner --> vis
elect -->|"lock held elsewhere"| decline --> vis
vis --> serve
```
Shutdown reverses it: flush_visits, service_manager.shutdown_all, background.stop.
## 03 Request pipeline
Source: `devplacepy/main.py, verified against app.user_middleware`
Ten middlewares wrap every request. The order below is the real execution order read off the built middleware stack, outermost first. Two of them are plain ASGI classes; the other eight are `BaseHTTPMiddleware` dispatch functions.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
req(["incoming ASGI scope"])
m0["0 GZipMiddleware<br/>minimum_size 512, level 5"]
m1["1 TunnelDispatchMiddleware<br/>host matches a workspace tunnel?"]
tun["routers/tunnel.py<br/>handle_http / handle_ws"]
m2["2 response_timing<br/>request.state.request_start, X-Response-Time"]
m3["3 visit_statistics"]
m4["4 track_presence<br/>throttled last_seen write"]
m5["5 maintenance_middleware<br/>503 unless static, avatar, auth, admin"]
m6["6 rate_limit_middleware<br/>mutating methods only, per IP bucket"]
m7["7 add_security_headers"]
m8["8 await_pending_corrections<br/>drains sync AI correction futures"]
m9["9 refresh_db_snapshot"]
mounts["mounts: /static/uploads, /static/v{ts}, /static"]
routes["38 included routers"]
handlers["exception handlers<br/>404, 500, RequestValidationError"]
req --> m0 --> m1
m1 -->|"tunnel host"| tun
m1 -->|"normal host"| m2 --> m3 --> m4 --> m5 --> m6 --> m7 --> m8 --> m9
m9 --> mounts
m9 --> routes
routes --> handlers
```
Rate limiting reads rate_limit_per_minute and rate_limit_window_seconds from site_settings and exempts reads and the /openai gateway. Bucket key is X-Real-IP falling back to request.client.host.
## 04 URL surface
Source: `devplacepy/main.py include_router calls`
38 routers, each mounted under one prefix, grouped here by the concern they serve. Directory shape mirrors the URL: a domain with one resource is a flat module, a domain with several sub-resources is a package that aggregates its leaves in `__init__.py`.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
app(["FastAPI app"])
subgraph identity["Identity and profile"]
r1["/auth - auth/"]
r2["/profile - profile/"]
r3["/avatar - avatar.py"]
r4["/follow - follow.py"]
r5["(none) - relations.py block, mute"]
end
subgraph social["Content and engagement"]
r6["/feed - feed.py"]
r7["/posts - posts.py"]
r8["/comments - comments.py"]
r9["/gists - gists.py"]
r10["/news - news.py"]
r11["/votes - votes.py"]
r12["/reactions - reactions.py"]
r13["/bookmarks - bookmarks.py"]
r14["/polls - polls.py"]
r15["/awards - awards.py"]
r16["/leaderboard - leaderboard.py"]
r17["/notifications - notifications.py"]
r18["/messages - messages.py"]
r19["/uploads - uploads.py"]
r20["/media - media.py"]
end
subgraph work["Projects and compute"]
r21["/projects - projects/ incl files, containers"]
r22["/p - proxy.py container ingress"]
r23["/zips - zips.py"]
r24["/forks - forks.py"]
r25["/issues - issues/"]
end
subgraph aiml["AI and tools"]
r26["/openai - openai_gateway.py"]
r27["/devii - devii.py"]
r28["/tools - tools/ seo, deepsearch, isslop"]
end
subgraph play["Play"]
r29["/game - game/"]
r30["/quizzes - quizzes/"]
end
subgraph platform["Platform"]
r31["/admin - admin/ 23 modules"]
r32["/docs - docs/"]
r33["(none) - push.py, seo.py"]
end
subgraph machine["Machine interfaces"]
r34["/api - devrant/"]
r35["/dbapi - dbapi/ read only"]
r36["/xmlrpc - xmlrpc.py"]
r37["/pubsub - pubsub.py"]
end
app --> identity
app --> social
app --> work
app --> aiml
app --> play
app --> platform
app --> machine
```
| Prefix | Paths | Prefix | Paths | Prefix | Paths |
|---|---|---|---|---|---|
| /admin | 92 | /profile | 17 | /gists | 5 |
| /projects | 46 | /issues | 13 | /messages | 5 |
| /tools | 27 | /dbapi | 9 | /notifications | 5 |
| /game | 23 | /auth | 6 | /uploads | 5 |
| /quizzes | 21 | /devii | 5 | /docs | 4 |
| /api | 20 | /posts | 4 | everything else | 36 |
## 05 Route fan-out
Source: `devplacepy/responses.py, schemas/, docs_api/, services/devii/actions/`
One handler serves four consumers. `respond()` feeds the same context dict to a Pydantic model and to a Jinja template, so a key missing from the `*Out` schema is silently absent from JSON while still rendering in HTML. The agent catalog and the docs registry are separate declarations that must be kept in step by hand.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
form["models.py<br/>Pydantic form model via Annotated Form"]
guard["guard: get_current_user<br/>require_user / require_admin"]
handler["router handler"]
helpers["database/ batch helpers<br/>get_users_by_uids, build_pagination"]
ctx["context dict"]
neg{"wants_json(request)"}
json["model.model_validate<br/>JSONResponse"]
html["templates.TemplateResponse<br/>extends base.html"]
action["services/devii/actions catalog<br/>Action, requires_auth mirrors guard"]
docs["docs_api endpoint()<br/>params and sample_response"]
seo["seo.py base_seo_context<br/>JSON-LD, sitemap entry"]
form --> handler
guard --> handler
handler --> helpers --> ctx --> neg
neg -->|"json accepted"| json
neg -->|"otherwise"| html
handler -.->|"same capability"| action
handler -.->|"same contract"| docs
html -.-> seo
```
wants_json is true when the request content-type starts with application/json, or Accept contains application/json and does not contain text/html. Registry sizes: 318 agent actions, 259 documented endpoint entries across 20 API groups, 122 prose docs pages.
## 06 Identity resolution
Source: `devplacepy/utils/auth.py`
A single resolver serves browsers, API clients, the devRant compatibility layer and issued access tokens. The result is memoised on `request.state` for the request and in a process TTL cache keyed by credential.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
start(["get_current_user"])
cached{"request.state._auth_user set?"}
sess{"session cookie<br/>64 hex chars"}
xapi{"X-API-KEY header"}
bearer{"Authorization: Bearer"}
basic{"Authorization: Basic"}
triple["try in order:<br/>users.api_key,<br/>devrant token 40 hex,<br/>access token 64 chars"]
user(["user dict"])
guest(["None, guest"])
gu["require_user: 303 to /"]
ga["require_admin: redirect to /feed"]
start --> cached
cached -->|yes| user
cached -->|no| sess
sess -->|match| user
sess -->|no| xapi
xapi -->|present| triple
triple -->|match| user
triple -->|no match| bearer
xapi -->|absent| bearer
bearer -->|present| triple
bearer -->|absent| basic
basic -->|"username-or-email:password, pbkdf2_sha256"| user
basic -->|no| guest
guest --> gu
user --> ga
```
Roles are stored capitalized as Admin or Member and tested through the is_admin global. Admin seniority is enforced per user mutation in routers/admin/users.py.
## 07 Background service fleet
Source: `devplacepy/services/manager.py, base.py, jobs/base.py`
27 singletons registered at boot, supervised only by the worker that won the service lock. Sixteen are long-lived loops on `BaseService`; eleven are queue consumers on `JobService`, which adds retention, concurrency and timeout settings on top and drains rows from the shared `jobs` table.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
mgr["ServiceManager<br/>supervise, shutdown_all"]
settings[("site_settings<br/>enabled flag and interval per service")]
jobs[("jobs table")]
subgraph base["BaseService loops - 16"]
b1["NewsService"]
b2["BotsService"]
b3["GatewayService"]
b4["DeviiService"]
b5["PubSubService"]
b6["NotificationRelayService"]
b7["LiveViewRelayService"]
b8["PresenceRelayService"]
b9["IssueTrackerService"]
b10["ContainerService"]
b11["WorkspaceService"]
b12["XmlrpcService"]
b13["AuditService"]
b14["PushService"]
b15["TelegramService"]
b16["TelegramOutboxService"]
end
subgraph job["JobService consumers - 11"]
j1["ZipService"]
j2["ForkService"]
j3["SeoService"]
j4["SeoMetaService"]
j5["AwardService"]
j6["BackupService"]
j7["DbApiJobService"]
j8["DeepsearchService"]
j9["IsslopService"]
j10["IssueCreateService"]
j11["PlanningReportService"]
end
mgr --> base
mgr --> job
settings --> mgr
job --> jobs
```
Separate from the fleet, services/background.py offers a fire and forget queue per worker for audit writes, XP awards and notifications; when no consumer runs, as in tests, the callable executes inline so ordering stays deterministic.
## 08 Data layer
Source: `devplacepy/database/, devplacepy/config.py`
One SQLite file reached through `dataset`, called synchronously from async handlers by design. 102 tables exist in the live schema; 52 of them carry the soft delete pair and are restorable as one event from the admin trash.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
db[("data/devplace.db")]
subgraph g1["Identity and access - 14"]
t1["users, sessions, password_resets<br/>access_tokens, devrant_tokens<br/>user_relations, follows<br/>notification_preferences, user_customizations<br/>push_registration, email_accounts<br/>telegram_links, telegram_pairings, telegram_outbox"]
end
subgraph g2["Content and engagement - 16"]
t2["posts, comments, gists<br/>projects, project_files, project_forks<br/>attachments, news, news_images, news_sync<br/>polls, poll_options, poll_votes<br/>votes, reactions, bookmarks"]
end
subgraph g3["Reputation - 5"]
t3["awards, badges, award_usage<br/>user_activity, user_activity_seen"]
end
subgraph g4["Code Farm - 9"]
t4["game_farms, game_plots, game_quests<br/>game_cosmetics, game_market_ticks<br/>game_steals, game_treasury<br/>game_eras, game_era_results"]
end
subgraph g5["Quizzes - 5"]
t5["quizzes, quiz_questions, quiz_options<br/>quiz_attempts, quiz_answers"]
end
subgraph g6["Live delivery - 3"]
t6["messages, notifications, ws_tickets"]
end
subgraph g7["Devii - 8"]
t7["devii_conversations, devii_turns<br/>devii_tasks, devii_task_runs<br/>devii_lessons, devii_virtual_tools<br/>devii_behavior, devii_usage_ledger"]
end
subgraph g8["AI gateway - 6"]
t8["gateway_providers, gateway_models<br/>gateway_usage_ledger<br/>gateway_quota_rules, gateway_quota_resets<br/>gateway_concurrency_samples"]
end
subgraph g9["Jobs, tools and usage - 20"]
t9["jobs, backups, backup_schedules<br/>deepsearch_sessions, deepsearch_messages, deepsearch_url_cache<br/>isslop_analyses, isslop_reports, isslop_events<br/>isslop_dom_results, isslop_file_results, isslop_image_results<br/>seo_metadata, seo_usage<br/>issue_tickets, issue_comment_authors, issue_usage<br/>correction_usage, modifier_usage, news_usage"]
end
subgraph g10["Containers - 9"]
t10["instances, instance_events, instance_metrics<br/>tunnels, workspace_flags, workspace_quota_rules<br/>builds, dockerfiles, dockerfile_versions"]
end
subgraph g11["Platform state - 7"]
t11["site_settings, cache_state, service_state<br/>audit_log, audit_log_links<br/>visit_stats_hourly, visit_unique_slots"]
end
db --> g1
db --> g2
db --> g3
db --> g4
db --> g5
db --> g6
db --> g7
db --> g8
db --> g9
db --> g10
db --> g11
```
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
root["DEVPLACE_DATA_DIR<br/>default repo/data"]
blobs["uploads, attachments, project_files<br/>sharded xx/yy on the uuid7 random tail"]
jobsdir["zips, zip_staging, fork_staging<br/>backups, backup_staging"]
reports["seo_reports, planning_reports<br/>dbapi, deepsearch, deepsearch_chroma"]
isslop["isslop, isslop_workspaces<br/>isslop_runs, isslop_media"]
ws["container_workspaces, workspace_state"]
keys["keys VAPID, bot, locks"]
dbs["devplace.db, devii_tasks.db, devii_lessons.db"]
root --> blobs
root --> jobsdir
root --> reports
root --> isslop
root --> ws
root --> keys
root --> dbs
```
config.DATA_PATHS registers 23 directories and ensure_data_dirs creates the whole tree before any write. Uploads live under data/uploads but are served at the unchanged /static/uploads URL.
## 09 Content rendering
Source: `devplacepy/rendering.py, static/js/ContentRenderer.js`
Two pipelines with a deliberate split: anything that exists at request time is rendered on the server for SEO, and the client pipeline is reserved for content that does not exist yet. Each has its own XSS control at a different point.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
subgraph server["Server, rendering.py, lru_cache"]
a1["raw text"]
a2["normalize dashes to hyphen"]
a3["emoji shortcodes, 4869 names"]
a4["mistune GFM, escape=True"]
a5["media pass: bare URLs to embeds, mentions to links"]
a6["mask emails on rendered text nodes"]
a7["render_content / render_title in template"]
a8["ContentEnhancer adds highlighting and copy buttons"]
a1 --> a2 --> a3 --> a4 --> a5 --> a6 --> a7 --> a8
end
subgraph client["Client, ContentRenderer.js"]
b1["live text: comments, DM bubbles, Devii, DeepSearch"]
b2["marked"]
b3["DOMPurify.sanitize, fail closed"]
b4["highlight.js"]
b5["media and autolink pass"]
b1 --> b2 --> b3 --> b4 --> b5
end
```
The server escapes at the markdown step, the client sanitizes after parsing. Server rendered content must not carry data-render, or both pipelines run over it.
## 10 AI plane
Source: `devplacepy/routers/openai_gateway.py, services/openai_gateway/, services/devii/`
Every model call in the product, internal or external, leaves through one gateway, which is where routing, quota and cost attribution live. Internal consumers call it over loopback HTTP rather than importing a client, so their spend lands in the same ledger.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
ext["external client<br/>/openai/v1/*"]
subgraph consumers["Internal consumers via INTERNAL_GATEWAY_URL"]
c1["services/devii"]
c2["services/correction<br/>AI correction and modifier"]
c3["services/news"]
c4["services/bot"]
c5["services/deepsearch + jobs/deepsearch"]
c6["services/dbapi nl2sql"]
c7["services/gitea enhance + planning"]
c8["jobs/isslop"]
end
gw["gateway.py<br/>routing.py, quota.py, usage.py<br/>reliability.py, vision.py"]
ledger[("gateway_usage_ledger<br/>gateway_quota_rules")]
stealth["stealth_async_client<br/>curl_cffi Chrome 146 fingerprint"]
up["upstream provider"]
subgraph devii["Devii assistant"]
d1["registry.py CATALOG<br/>318 tools"]
d2["role gating<br/>guest 108, member 246<br/>admin 312, primary 318"]
d3["45 tools behind CONFIRM_REQUIRED"]
d4["session, tasks, lessons<br/>virtual tools, behavior"]
d5["surfaces: /devii ws, Telegram, CLI"]
end
ext --> gw
consumers --> gw
gw --> ledger
gw --> stealth --> up
d1 --> d2 --> d3
devii --> c1
d4 --> d1
d5 --> d4
```
Cleartext loopback calls are forced to HTTP/1.1 in curl_transport.http_version_for, because the Chrome impersonation profile would otherwise negotiate HTTP/2 against an HTTP/1.1 only uvicorn.
## 11 Containers, workspaces and ingress
Source: `devplacepy/services/containers/, routers/projects/containers/, routers/proxy.py, routers/tunnel.py`
One shared image serves every instance. Reconciliation is a loop that compares desired state in the database against what the Docker daemon actually reports, and there are two independent ways in from the outside: a path prefix and a hostname.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
ui["/projects/{slug}/containers<br/>instances, schedules, workspace"]
api["services/containers/api.py"]
store[("instances, instance_events<br/>instance_metrics, tunnels<br/>workspace_flags, workspace_quota_rules")]
svc["ContainerService<br/>reconcile desired vs docker ps"]
wsvc["WorkspaceService<br/>provision, certs, quota, tunnels"]
backend["backend/docker_cli.py<br/>backend/fake.py for tests"]
image["single shared image ppy:latest"]
inst["running instance<br/>rootless workflow, aptroot"]
wsdir["data/container_workspaces/{uid}"]
proxy["/p/{slug}<br/>routers/proxy.py, relays headers verbatim"]
tunnel["host based tunnel<br/>TunnelDispatchMiddleware to routers/tunnel.py"]
code["workspace editor over websocket<br/>/projects/{slug}/containers/instances/{uid}/code"]
exec["terminal over websocket<br/>.../exec/ws"]
ui --> api --> store
svc --> store
wsvc --> store
api --> backend
svc --> backend
wsvc --> wsdir
backend --> image --> inst
proxy --> inst
tunnel --> inst
code --> inst
exec --> inst
```
Access uses stricter predicates than the rest of the product: owns_instance, can_view_project_containers, can_view_instance, can_manage_instance. The primary administrator sees and manages every container; any other admin can only view others on public projects and manage instances they own.
## 12 Real time plane
Source: `routers/*.py websocket handlers, services relays, nginx.conf.template`
Twelve WebSocket endpoints, each of which needs its own nginx upgrade location because the catch-all location strips upgrade headers. Fan-out to connected sockets goes through in-process hubs driven by relay services on the lock-owning worker.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
subgraph sockets["WebSocket endpoints"]
s1["/devii/ws"]
s2["/messages/ws"]
s3["/pubsub/ws"]
s4["/tools/seo/{uid}/ws"]
s5["/tools/deepsearch/{uid}/ws"]
s6["/tools/deepsearch/{uid}/chat"]
s7["/dbapi/query/{uid}/ws"]
s8[".../containers/instances/{uid}/exec/ws"]
s9[".../containers/instances/{uid}/code"]
s10[".../code/{path}"]
s11["/p/{slug}"]
s12["/p/{slug}/{path}"]
end
subgraph relays["Relay services"]
r1["NotificationRelayService"]
r2["LiveViewRelayService"]
r3["PresenceRelayService<br/>track limit 500, online limit 30"]
r4["PubSubService"]
end
hubs["messaging/hub.py, pubsub/hub.py<br/>devii/hub.py"]
tick[("ws_tickets<br/>expiring auth tickets")]
s2 --> hubs
s3 --> hubs
s1 --> hubs
relays --> hubs
tick --> s2
```
Presence is authoritative from the relay: PRESENCE_TRACK_LIMIT sets the tracked online set, PRESENCE_ONLINE_LIMIT only caps how many the feed panel displays, and PRESENCE_ONLINE_MARGIN_SECONDS provides hysteresis at the boundary.
## 13 Frontend
Source: `devplacepy/static/, devplacepy/templates/`
No framework and no package manager. 131 ES6 modules, one class per file, hung off a single global `app`; 33 of them are custom elements. 217 Jinja templates, of which 107 are the docs site.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
base["templates/base.html<br/>extra_head for CSS, extra_js for JS"]
partials["shared partials<br/>_avatar_link, _user_link, _comment_section<br/>_post_card, _pagination, _sidebar_search"]
appjs["static/js/Application.js<br/>instantiated once as app"]
subgraph modules["131 ES6 modules"]
u1["utilities<br/>Http, Poller, JobPoller<br/>OptimisticAction, FloatingWindow, ScrollMemory"]
u2["33 custom elements<br/>components/ with dp- prefix"]
u3["feature modules<br/>chat/, devii/, autoload/"]
end
css["49 stylesheets<br/>variables.css tokens, per page files"]
vers["static_url in Jinja, assetUrl in JS<br/>/static/v{boot ts}/, immutable for a year"]
cust["per user CSS and JS injection<br/>custom_css_tag, custom_js_tag"]
base --> partials
base --> appjs --> modules
base --> css
base --> cust
vers --> css
vers --> modules
```
Per user customizations are configured only through Devii, stored in user_customizations, scoped globally or per matched route template, and run solely in that owner's own browser sessions.
## 14 Test and delivery topology
Source: `tests/, pyproject.toml, .gitea/workflows/test.yaml`
Three tiers separated by what they exercise, decided by fixtures, run serially in a single process against one uvicorn subprocess on port 10501 with a temporary database.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
unit["tests/unit - 140 modules<br/>mirrors source module path<br/>local_db or no fixture"]
api["tests/api - 221 modules<br/>mirrors URL path<br/>app_server, seeded_db"]
e2e["tests/e2e - 131 modules<br/>mirrors URL path<br/>page, alice, bob"]
srv["uvicorn subprocess :10501<br/>temp SQLite, own DATA_DIR<br/>DEVPLACE_DISABLE_SERVICES=1"]
pw["Playwright Chromium<br/>session scoped context"]
ci["Gitea Actions on push and PR to master<br/>full suite under coverage"]
prod["promotion to production<br/>docker compose"]
unit --> ci
api --> srv --> ci
e2e --> srv
e2e --> pw --> ci
ci --> prod
```
pytest-xdist is not a dependency and -n is rejected centrally, so no tier can be parallelised by accident.
## 15 Findings
Source: `measured against the repository documentation`
The structure holds up: the router tree mirrors the URL tree, the test tree mirrors both, every runtime path is registered in one place, and every model call has a single exit. The items below are the places where the running code and the written record have drifted apart, or where a structure exists that nothing currently uses. None of them is a functional defect.
### Middleware order in the documentation is stale
The root CLAUDE.md states that response_timing is the outermost middleware. Measured from app.user_middleware it is third, behind GZipMiddleware and TunnelDispatchMiddleware, both added after it. The consequence is minor but real: X-Response-Time excludes compression time and excludes tunnel-host dispatch entirely.
### Two catalogue counts have drifted
CLAUDE.md cites around 2882 tests and an events.md catalogue of 288 keys. Measured now: 3073 functions named test_ across 492 modules, and 312 distinct dotted event keys in events.md. Both are undercounts in the docs, not missing implementation.
### instance_schedules is declared soft-deletable but has no table
It appears in database.SOFT_DELETE_TABLES and in the routers under /projects/{slug}/containers/schedules, but no such table exists in the live schema. dataset creates it lazily on first insert, so this is correct only for as long as every read path tolerates the table being absent.
### Three legacy container tables still exist
builds, dockerfiles and dockerfile_versions are present in the live database. The project replaced per-project images with the single shared ppy:latest image and ships devplace containers prune-builds as the one-time cleanup for exactly these rows. The tables are still carried.
### Five duplicate OpenAPI operation IDs
Generating the schema warns on editor_proxy twice in routers/projects/containers/workspace.py, passthrough in routers/openai_gateway.py, and proxy_http twice in routers/proxy.py. These are catch-all routes registered for several methods, so a generated client would collide on those names.
### The one deliberate asymmetry is documented and intentional
routers/__init__.py holds nothing but the attribution line; unlike every nested router package, the top level does not aggregate. main.py performs all 38 include_router calls directly, which keeps prefix ownership in a single readable block.
---
Sources: devplacepy/main.py, the imported FastAPI app object, data/devplace.db sqlite_master, and the working tree at HEAD 192df12b with uncommitted local modifications present. Counts exclude __pycache__ and the virtual environment.

313
CHANGELOG.md Normal file
View File

@ -0,0 +1,313 @@
## 2026-06-19 🟢
- Block and mute user relations with API endpoints, CLI emoji-sync command, and content filtering
## 2026-06-18 🟢
- News service with image dedup, AI grading, featured/landing auto-rotation and admin lock
- Server-rendered content pipeline with Telegram pairing, response timing, and admin user index
- AI Markdown reformatting for news articles with usage metering and sidebar cleanup
## 2026-06-17 🟢
- Backup download restricted to primary admin, admin-hidden projects invisible to other admins
- Access token system with CLI management and wildcard file type support
- Access token issuance with JSON/form login endpoint and token lifecycle management
## 2026-06-16 🔥 Big day!
- Gateway admin UI with provider and model routing for OpenAI gateway
- Backup management CLI commands and service layer with configurable data directories
- Audit logging for admin trash restore/purge and notification clear, plus SEO noindex for private projects and sitemap docs page refactor
- Instance lookup by name in addition to uid and slug, new terminal session service
- Keyboard-aware input visibility with ResizeObserver fallback for mobile message layout
- E2E comment hierarchy seed helpers for gists, news and projects
- Optimistic message insertion disabled to prevent duplicate bubbles
- Router tree documented in AGENTS.md with 14 new route entries
- Add dpc binary to container image and set executable permissions
- Remove .html and .svg from allowed upload types and MIME mappings
## 2026-06-15 🟢
- ASGI lifespan handler with background service orchestration and lock-based worker coordination
- Message chunking with sentence-aware splitting and configurable character limits
- Enforce hard test-coverage standard across DevPlace workflows and agents
- Audio file support with inline player and expanded allowed upload types
- Gist comment form integration with card-scoped comment targeting
- Bot account API key adoption for per-user gateway spend attribution
## 2026-06-14 🔥 Massive day!
- Admin/internal database API with CRUD, natural-language query, and read-only SQL execution
- DeepSearch research job queue with CLI prune/clear, Chroma vector store, and date-aware system message composition
- DeepSearch multi-agent researcher with grounded RAG chat and per-session vector store
- SEO Diagnostics tool with CLI management, live WebSocket progress, and static asset cache-busting
- OpenAI-compatible embeddings endpoint with model mapping and usage tracking
- Stealth HTTP client with curl_cffi transport adapter replacing raw httpx for outbound requests
- Bot monitor with live age badges and zoomable screenshots
- Author-interleaved feed ordering across all feed views and tabs
- Author diversity via interleaving (no per-author cap) for home and feed
- devRant API client library and example scripts in Python and JavaScript
- TTLCache-backed cache version reads with invalidation on bump
- Random client IP spoofing for load-testing traffic
- Deepsearch chat component attribute naming from data-* to direct properties
- Replace `python -m agents.validator` with `hawk` across all agent markdown files
## 2026-06-13 🔥 Massive day!
- Three-tier test suite with unit, API, and E2E directories mirroring source and endpoint paths
- Soft-delete audit for bookmarks, comments, follows, polls, project files, reactions, and bug create request event
- Notification preferences with per-user per-channel toggles and admin defaults
- Author diversity enforcement across home page and feed with personalized landing for authenticated users
- Shared free-text search across feed, gists, and projects listings
- Docs search with agent-powered Docii chat and admin-configurable search mode
- Devii agent audit log query action with filterable paginated endpoint
- Router directory-tree convention with admin audit log, AI quota, and container management endpoints
- Initial maintenance agent fleet with per-dimension code quality enforcers
- Unified blob sharding on uuid7 random tail across attachments, project files, and zip service
- Consolidated runtime data directory layout with migration CLI command
- Context-aware window control button visibility with font size boundary detection and minimize/normalize size presets
- Overflow-managed profile tabs with a "more" dropdown for narrow screens
- Startup jitter, randomized browser fingerprinting, and short comment styles for bot realism
- Sidebar search form with hidden field support and configurable placeholder
- Prevent titlebar double-click maximize when clicking buttons in FloatingWindow and DeviiTerminal
- Pin test server to single worker and use upsert for rate-limit settings to prevent spurious 429s
- DEVPLACE_DISABLE_RATE_LIMIT env var to bypass rate limiter in tests and middleware
- Fallback to location.origin when DEVPLACE_DOCS.base is missing
- Add claude-manual task-oriented guide page with cross-reference from claude.html
- Remove PWA install button and associated installer module
- Removed stale test files and fixed Gitea env teardown and ingress proxy test cleanup
- Locustfile seed data expansion and route exclusion documentation
## 2026-06-12 🔥 Massive day!
- Agent report system with codenames, timestamped output streams, and write-budget enforcement
- Tool-scoped payload filtering for worker agents with orchestration tool isolation
- Agent isolation and result caching in Maestro review sweep
- Concurrent read-only fleet check mode with per-agent cost tracking and contextvar-isolated findings
- Admin analytics and AI usage API response keys renamed, password change toggle added
- Gitea-backed bug tracker with list/detail/comment/status and AI-enhanced filing
- Bug detail page with admin/member role rendering and viewer_is_admin context flag
- Changed-files fast mode for maintenance agents with write-allowlist guard
- Partial config save with error reporting and password manager suppression
- Admin route cache-disabling headers via Cache-Control, Pragma and Expires
- Dirty-field tracking and server-side value sync for service config forms
- Rename `is_admin` to `viewer_is_admin` in bug detail schema, router, and template
- Bug tracker unavailable page with JSON and HTML 503 error responses
- Bot comments avoid repeating sibling opinions via thread-aware distinctness prompt
- Default Gitea repository changed from pydevplace to devplacepy
- Remove pytest-xdist parallel test execution, switch to serial single-process test runner
## 2026-06-11 🔥 Massive day!
- Platform-wide soft delete with deleted_at/deleted_by columns and admin trash management
- Owner-or-admin soft-delete enforcement on all content endpoints
- Unified image lightbox with attribute-wired opening and per-user media tab with soft delete
- Autonomous maintenance agent fleet with CLI entry point, Makefile targets, and dependency-free validator
- Seed-finding guided fix mode for maintenance agents with incomplete report tracking
- Audit log tables with CLI recording hooks
- Resolve merge conflict in pagination template and add admin-audit-log endpoint to docs API
- Bots documentation pages and session stop/reset commands
- Reduced nested comment indentation from 1.5rem to 0.25rem per depth level
- Reduce comment indentation multiplier and padding for nested replies
- Switch to dynamic viewport height and remove autofocus from message input
- Inline message layout with responsive height and auto-scroll
- Optional label attribute with hidden empty state for dp-upload component
- Mandatory retoor header added to all devplacepy source files
## 2026-06-10 🟢
- Port conflict detection and test isolation hardening across admin, avatar, bugs, landing, messages, and customization tests
- Container proxy routing via container IP instead of host port, with fake backend network simulation
- XDG-compliant devii tasks database path with DEVII_HOME override
- Project editing endpoint with 125k char body limit and remote URL attachment guard
## 2026-06-09 🔥 Big day!
- Container manager with Dockerfile CRUD, image builds, instance lifecycle, ingress proxy, and CLI commands
- Async project fork service with job queue, CLI management, and shared container image build
- Parallel test execution with per-worker isolated databases, data dirs, and uvicorn subprocesses via pytest-xdist
- Per-user customization suppression toggles with profile UI and Devii tool
- Customization toggle UI with enable/disable state management
- Unified shared Http and Poller utilities across all frontend modules, replacing inline fetch and setInterval patterns
- Responsive refinements for sub-360px screens, touch targets, safe-area insets, and mobile window controls
- Click-to-open profile dropdown with keyboard and outside-click dismissal
- Migrate hardcoded spacing values to CSS custom properties across multiple stylesheets
- Unicode escape normalization for emoji constants across codebase
- Consolidated upload ignore rules into a single directory-level gitignore entry
- Removed unused imports across routers, database, and services
- Remove project_set_private from confirmation-required actions and fix async test helpers
## 2026-06-08 🟢
- Admin AI quota management with CLI and admin panel reset controls
- API key management CLI with backfill command and auth support across session, API key, and HTTP Basic
- Per-project filesystem with directory and file CRUD, upload, and inline editing
- Async zip job framework with CLI management and zip archive download endpoints
- Add mistune dependency to project
## 2026-06-06 🟢
- Reactions, bookmarks, polls, extended sessions, and operational settings
## 2026-06-05 🔥 Massive day!
- Batch attachment linking, deduplicated mention notifications, and idempotent badge milestone checks
- Cursor-based load-more pagination across feed, gists, news and projects
- Canonical slug redirects, cursor-based next-page links, and OG image extraction across feed, gists, news, posts, projects, and profile
- TTLCache with LRU eviction, CLI role management, content unit helpers, database query functions, follow API with XP rewards, and news service with AI grading
- Unified comment form component with mobile touch optimizations across all CSS
- Inline comment previews on post cards with per-comment reply forms
- Comment template with threaded voting, author display, and attachment support
- Post-login redirect with `next` parameter and unauthenticated comment redirect to login
- Login redirect for unauthenticated admin, next parameter support with external URL rejection, and inline comment reply forms
- Seed comments created for all posts instead of only the first
- Replace uuid4 with uuid7 via uuid_utils for push notification JWT jti claims
- Coverage instrumentation for CI and local test runs with HTML report artifact
- Coverage configuration with subprocess measurement support
- Sitemap TTL configurable via environment variable and news_images schema migration
- Kill stale server process and add startup failure detection for Locust targets
## 2026-06-02 🟢
- Multi-worker service lock with cascading vote/comment cleanup on content deletion
## 2026-05-30 🟢
- Leaderboard route with gamification system (XP, levels, badges, stars) and content creation refactor
## 2026-05-28 🟢
- Cursor-based pagination for feed, notifications, and votes with thumbnail extension fallback
- Push registration returns creation flag and only sends welcome notification on first registration
## 2026-05-27 🟢
- AJAX vote buttons with live count updates across posts, gists, projects, and comments
- CSS-only card-link overlay replacing JS-driven data-href navigation
## 2026-05-25 🟢
- Unified notification click-to-navigate with comment anchor highlighting and dismiss refactor
## 2026-05-23 🔥 Massive day!
- Web push notifications with PWA manifest and service worker registration
- Web push notifications with PWA offline shell and install prompt
- Unified badge, notification, and content enrichment system with star tracking helpers
- Aggregate star counts across posts, projects and gists for profile and top-author ranking
- Content editing and deletion with cascading cleanup, avatar image helper, HTTP form POST, text input cursor management, and toast flash utility
- Share button with clipboard copy across detail pages, structured data schemas for gists and news articles, configurable site URL and rate limit, and production proxy headers support
- Production deployment workflow via git merge master into production
- Automatic production deployment on successful master push
- Removed automatic production deployment from CI pipeline
- Admin settings form with Pydantic validation and model-driven save
- Pydantic form models with validation for signup, login, password reset, comments, bugs, admin actions, and posts
- Type-safe integer settings with empty-value skip on admin save
- Input validation tests for votes, posts, profile, and signup endpoints
- Rate-limit environment variable and expanded Locust seed data for gists, notifications, and uploads
- TTLCache with ETag-based HTTP caching for avatar endpoint
- Dynamic language sidebar filtering based on existing gist language codes
- Vendor static assets for CodeMirror, highlight.js, marked, and emoji picker
- Test server log capture via tempfile with reduced log verbosity
- DOMPurify XSS sanitization for client-side rendered markdown content
- Add mobile-web-app-capable meta tag for PWA support
- Topnav notification bell selector scoped to /notifications href
- Fix notification bell icon locator to use explicit href selector instead of first match
- Remove stale import of get_users_by_uids from project_detail endpoint
## 2026-05-22 🟢
- News article HTML sanitization CLI command and database migration
## 2026-05-19 🟢
- Avatar generation exception logging with full traceback
- Fix multiavatar import path and add required arguments to function call
## 2026-05-16 🟢
- Clickable post titles and content with downvote support on feed and detail pages
- Interactive vote buttons and clickable post titles on profile page
- Handle @-mention with preceding text in content rendering
- Unread notification cache invalidation across comments, follows, messages, votes, and mentions
- Compact send button, attachment upload container, and auto-scroll on message thread load
- GistEditor lazy init with modal observer, CodeMirror Rust mode removed, emoji picker module type, source textarea required removed, projects tab spacing and settings button removed
- Add space between icon and label in feed navigation tabs
## 2026-05-15 🟢
- Python 3.13 base image, default port 10500, and nginx template to conf.d migration
- Responsive mobile navigation and messages layout with hamburger menu and back button
- Responsive breakpoint widened from 768px to 1024px for topnav, breadcrumb and page layouts
## 2026-05-14 🟢
- Migrate from deprecated `datetime.utcnow()` to timezone-aware `datetime.now(timezone.utc)` across the entire codebase
- Wait-for-url stabilization in noindex tests for messages and notifications pages
- Disable parallel test execution in CI pipeline
## 2026-05-13 🟢
- Migrate all TemplateResponse calls to pass request as first positional argument
- Attachment linking and deletion refactored into dedicated module with batch support
- Parallelised integration test suite with xdist worker port isolation
- Remove deprecated imghdr dependency and fix icon spacing in bug report buttons
- Replace hardcoded pytest.BASE_URL with conftest BASE_URL in attachment tests
- CI trigger branch from main to master
## 2026-05-12 🟢
- News service with admin curation, landing page articles, and comment support
- Mention notification system across bugs, comments, messages, posts, and projects with user search API
- Gists page with code snippet sharing, voting, and comment integration
## 2026-05-11 🔥 Big day!
- Unified threaded comment system with polymorphic target support across posts, projects, and bugs
- News management system with admin panel, pagination, and SEO sitemap integration
- News background service framework with CLI management, bug reports router, and admin services monitoring
- Admin panel with user management CLI, SEO metadata, and production deployment config
- Multiavatar local SVG generation with WAL mode SQLite and Locust load testing
- CI branch target renamed from main to master and test fixtures refactored for explicit login and seeded database
- Test fixture improvements with debug logging, stderr capture, and extended startup timeout
- Remove hawk static analysis step from CI test workflow
## 2026-05-10 🚀 First commit!
- Initial project scaffold with FastAPI SSR app, auth, feed, posts, comments, projects, profile, messages, notifications, and voting
- DiceBear avatar proxy with style picker on signup and profile, threaded comments
- Image upload support for posts with daily topic display on landing and feed
────────────────────────────────────────────────────────────
Summary: 194 commits over 29 active days. The project launched on May 10 with the initial FastAPI scaffold, auth, feed, and core content features. The biggest pushes came on June 13 (23 commits) delivering the three-tier test suite, soft-delete audit system, notification preferences, and author diversity enforcement; June 23 (23 commits) adding web push notifications, PWA support, content editing/deletion, and production deployment workflows; and June 14 (14 commits) introducing the admin database API, DeepSearch research system, SEO diagnostics, and the stealth HTTP client.

133
CLAUDE.md
View File

@ -23,11 +23,7 @@ make install # pip install -e . + playwright install chromium
make ppy # build the single shared container image (ppy:latest); run once before launching instances
make dev # uvicorn --reload on port 10500, backlog 4096
make prod # uvicorn --workers $(WEB_WORKERS) (defaults to nproc), port 10500 (backlog 8192)
make test # full suite (unit + api + e2e), headless, serial; one pass reports EVERY failure
make test-fast # unit + api only, no browser - the quickest triage pass (~3 min)
make test-failed # re-run only the tests that failed in the previous run
make test-first-failure # full suite with -x, stops at the first failure
make test-slowest # full suite plus the 40 slowest tests, to find what costs wall-clock
make test # Playwright + unit tests, headless, serial (one at a time), -x fail-fast
make test-headed # same tests in a visible Chromium window (single process)
make locust # Locust load test, interactive web UI
make locust-headless # Locust CLI mode for CI
@ -35,12 +31,10 @@ make locust-headless # Locust CLI mode for CI
The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make.
Preliminary validation: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). These checks are gates on the way to the real validation, never a substitute for it: **every change ends with the full test suite (`make test` - all three tiers, every test) and it must pass.**
Validate code without running the suite: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance).
Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x`
**Finding failures fast (the triage order).** The suite no longer stops at the first failure - `-rf` is in `pyproject.toml` `addopts`, so every run (make target or bare `pytest`) prints one `FAILED <nodeid>` line per failure at the end, giving the complete list from a single pass instead of one pass per bug. Triage cheapest-first: `make test-fast` (unit + api, no browser, ~3 min) covers most regressions; only then pay for the browser tier with `make test` or `make test-e2e`. After a run, `make test-failed` re-runs just the failures from pytest's cache (`--last-failed`), which is the loop to iterate in until it is empty. `make test-first-failure` keeps the old `-x` behaviour for the rare case where a single early failure poisons everything after it.
CLI (installed as `devplace`):
```bash
devplace role get <username>
@ -59,18 +53,10 @@ devplace attachments prune # remove orphan attachment records/files
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
devplace devii reset-quota --guests # reset every guest quota
devplace devii reset-quota --all # reset every quota (users and guests)
devplace devii tasks list [--all] # list scheduled Devii tasks and their owners
devplace devii tasks disable <uid> # disable one scheduled task
devplace devii tasks prune # disable every task whose owner may not schedule
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)
devplace forks clear # delete every fork job row (forked projects persist)
devplace messaging prune-tickets # delete expired WebSocket auth tickets (ws_tickets)
devplace seo prune # delete expired SEO audit reports + job rows
devplace seo clear # delete every SEO audit report + job row
devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists)
@ -80,14 +66,6 @@ devplace deepsearch clear # delete every DeepSearch session + job row + collec
devplace isslop analyze <url> # run a AI usage analysis from the terminal (report persists)
devplace isslop prune # delete expired AI usage analysis job rows (analyses + reports persist)
devplace isslop clear # delete every AI usage analysis, its report and job rows
devplace quiz prune # delete abandoned/expired quiz attempts older than the retention window
devplace game market prune # delete Code Farm market tick buckets older than the tracking window
devplace game steals prune # delete Code Farm raid records older than the raid-efficiency window
devplace game era status # show the current Code Farm Era
devplace game era start <name> [--days N] # start a Code Farm Era (default 28 days)
devplace game era end # end the running Code Farm Era (ranks, awards Stars, records results)
devplace accounts pending # list deleted accounts awaiting their purge
devplace accounts prune # permanently purge accounts past the deletion grace window (--dry-run to preview)
devplace backups list # list recorded backups
devplace backups run <database|uploads|keys|full> # enqueue a backup (processed by the running server)
devplace backups prune # remove backup records whose archive file is missing
@ -121,7 +99,6 @@ Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA
| `DEVPLACE_WEB_WORKERS` / `--workers` | `nproc` (prod) | Uvicorn worker count; `make prod WEB_WORKERS=N` to override. |
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | How long after a user's last activity they still count as online. `config.PRESENCE_WRITE_SECONDS` (half of it) throttles `last_seen` writes per worker. |
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Max avatars in the feed's live "Online now" panel. |
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes as the authority for every avatar dot. `PRESENCE_ONLINE_LIMIT` only caps how many of them the feed panel *displays*. |
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin (hysteresis) before an online user is dropped, kills dot/roster flicker at the boundary. |
| `DEVPLACE_DATA_DIR` | `<repo>/data` | Single root for ALL runtime/user-generated data OUTSIDE the package and OUTSIDE `/static`. Point at a volume in prod. |
| `DEVPLACE_OUTBOUND_PROXY_URL` | unset | Fallback for the `outbound_proxy_url` site setting (below) when the DB/settings row is unavailable (early CLI contexts). Prefer configuring the setting via `/admin/settings` - it applies live with no restart. |
@ -140,8 +117,6 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `devplacepy/services/devii/CLAUDE.md` | Devii assistant: sessions/channels, scheduler, virtual tools, self-configured behavior, client browser tools |
| `devplacepy/services/openai_gateway/CLAUDE.md` | AI gateway: `/openai/v1/*`, usage ledger, provider/model routing |
| `devplacepy/services/jobs/CLAUDE.md` | Async job services: zip, fork, SEO diagnostics, SEO metadata, DeepSearch, AI Usage Analyzer |
| `devplacepy/services/moderation/CLAUDE.md` | Trust and safety: the reportable-target registry, the content filter and its five choke points, the report queue and its atomic resolution, enforcement, consent, maturity, account deletion |
| `devplacepy/services/acceptance/CLAUDE.md` | Acceptance convergence: the opt-in service that grants every policy agreement to every account that has not declined it, its invisibility contract and the ledger-as-decline-register rule |
| `devplacepy/services/audit/CLAUDE.md` | Audit log: recorders, categories, retention |
| `devplacepy/services/backup/CLAUDE.md` | Backup service: targets, worker, schedules, primary-admin-only download |
| `devplacepy/services/telegram/CLAUDE.md` | Telegram bot bridge |
@ -153,18 +128,15 @@ 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/game/CLAUDE.md` | Code Farm idle game |
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
| `devplacepy/utils/CLAUDE.md` | Notifications and gamification (XP/levels/badges/leaderboard) |
| `devplacepy/static/js/CLAUDE.md` | Custom web components, shared frontend utilities (Http/Poller/FloatingWindow/ScrollMemory) |
| `devplacepy/static/css/CLAUDE.md` | CSS system: design tokens (no literals, no `var()` fallbacks), file-scoped palettes, `--z-*` stacking bands, the closed breakpoint set, reduced motion |
| `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials |
| `tests/CLAUDE.md` | Detailed testing patterns and pitfalls |
The AI usage analyzer (`devplace isslop analyze`, the `/tools/isslop` job service) lives entirely inside the package at `devplacepy/services/jobs/isslop/` and is documented in `devplacepy/services/jobs/CLAUDE.md`. There is no repo-root `isslop/` project.
`isslop/` at the repo root is a separate, standalone sibling project (own `pyproject.toml`, `Makefile`, port 18732) with its own `isslop/CLAUDE.md` - it is not a nested subsystem of the `devplacepy` package. The integrated engine that DevPlace actually runs (`devplace isslop analyze`, the `/tools/isslop` job service) is a distinct implementation documented in `devplacepy/services/jobs/CLAUDE.md`.
## Architecture
@ -199,8 +171,6 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
| `/api` | devrant/ package - see `routers/devrant/CLAUDE.md` |
| `/dbapi` | dbapi/ package, **primary-administrator-only, strictly READ-ONLY** - see `services/dbapi/CLAUDE.md` |
| `/game` | game/ package - see `services/game/CLAUDE.md` |
| `/reports`, `/admin/moderation`, `/workspaces` | reports.py, admin/moderation.py, workspaces.py - see `services/moderation/CLAUDE.md` |
| `/quizzes` | quizzes/ package - see `services/quiz/CLAUDE.md` |
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
@ -256,7 +226,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 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.
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.
### Telegram bot, email, devRant compatibility API, issue tracker
@ -266,38 +236,22 @@ Devii is also reachable over **Telegram** (one supervised long-poller subprocess
`devplacepy/seo.py` generates JSON-LD schemas (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication). Every router builds context via `base_seo_context(request, ...)`. Auth/messages/notifications are `noindex,nofollow`; profiles with fewer than 2 posts are `noindex,follow`. `/robots.txt` and `/sitemap.xml` are served by `routers/seo.py`. Full implementation map (template layer, heading hierarchy, slugs, related posts, performance, default OG image, SEO tests) is in `devplacepy/routers/CLAUDE.md`.
## The production database is never touched without explicit confirmation (hard rule)
`data/devplace.db` is the live production database, and `make dev`, `make prod` and the Docker stack all share it (see "Production deployment"). No agent-initiated command may read or write it, or anything else under `data/`, without the user's explicit, stated confirmation - not a one-click approval, a confirmation they wrote themselves after being told exactly what the command would do.
This is enforced, not remembered. `.claude/hooks/guard_production_db.py` runs as a `PreToolUse` hook on every Bash call and **denies** the command outright when it reaches production, naming the reason. The interesting case is the one that motivated the rule: a script that never mentions a path at all but imports `devplacepy` and therefore resolves `config.DATA_DIR` to the real database. The hook reads the script and decides on its content, so a scratch-database script passes and an unguarded one does not.
What the guard blocks: any command naming `data/devplace.db` or a production data directory, the `devplace` management CLI, `python -m devplacepy...`, and any inline `-c` or script file that imports `devplacepy` without a `DEVPLACE_DATABASE_URL` override. What stays free: `make test` and `pytest` (the suite runs on its own temp database), `make dev`/`make prod`/`make docker-*`, the mandated import gate `python -c "from devplacepy.main import app"`, and anything that sets `DEVPLACE_DATABASE_URL` to a scratch file. `permissions.deny` in `.claude/settings.json` additionally refuses `Write`/`Edit` anywhere under `data/`, which the Bash hook cannot see.
The escape hatch is deliberately two-factor and must never be self-served: after the user has confirmed in their own words, the command may carry the literal token `I-HAVE-CONFIRMED-PRODUCTION-DB-ACCESS`, which downgrades the denial to a permission prompt the user still has to approve. **Never add that token on your own initiative.** Write disposable scripts against a temp database via `DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` instead, exactly as "Rigorous correctness verification" already requires.
## Conventions (project-specific)
- **No comments, no docstrings in source.** Code is self-documenting.
- **Forbidden variable name patterns:** `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_`.
- **Outbound HTTP goes through the stealth client only.** Never instantiate a bare `httpx.AsyncClient(...)`/`httpx.Client(...)` for outbound traffic - build it via `devplacepy.stealth.stealth_async_client(...)`/`stealth_sync_client(...)`. The async factory routes bytes through **`curl_cffi` (curl-impersonate, BoringSSL)** behind an `httpx` transport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport if `curl_cffi` is missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: the reverse-proxy forwarding core `services/containers/forward.py` (and the three routers that call it - `routers/proxy.py`, `routers/tunnel.py`, the workspace editor route), which relays the user's own headers verbatim and therefore keeps a plain `httpx.AsyncClient`; bolting the Chrome identity onto it would overwrite the very headers it exists to forward. **Load-bearing gotcha:** cleartext `http://` is forced to HTTP/1.1 (`curl_transport.http_version_for`) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns `400 "Invalid HTTP request received."` once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer calls `INTERNAL_GATEWAY_URL` = `http://localhost:<port>/openai/v1/...`, so without this override a large Devii turn (big system prompt + tool list) fails on every request. `https://` keeps the HTTP/2 default. `stealth_sync_client` stays pure-httpx Chrome-aligned (no curl_cffi sync adapter). **Never bolt a hand-written browser-header dict (UA/`sec-ch-ua`/`Sec-Fetch-*`) onto a `stealth_async_client`/`stealth_sync_client` call** - curl_cffi's impersonation already emits an internally-consistent Chrome 146 identity across TLS, UA, and Client Hints; a caller-supplied header set claiming a different Chrome version or platform creates a UA-vs-TLS-vs-Client-Hints mismatch that is a textbook anti-bot tripwire (this was a real bug in Devii's `fetch_url`/`http_request` tool, fixed by deleting its hardcoded Chrome-131 header block). `stealth.detect_consent_gate(html)` recognizes the common JS-redirect cookie-consent wall pattern (DPG Media's `nu.nl` and sibling sites, and generically any page gated behind a `decodeURIComponent(...)` callback referencing a "privacy gate"/`consent.js`) - reuse it wherever a fetched page's real content sits behind that gate rather than special-casing a domain. `stealth_transport`/`stealth_async_client`/`stealth_sync_client` all accept a `proxy=` override, defaulting to `stealth.configured_proxy_url()` (the admin-editable `outbound_proxy_url` site setting, falling back to `DEVPLACE_OUTBOUND_PROXY_URL` when the setting is unset) - this is the only real fix for a destination that blocks by IP/ASN reputation (e.g. reddit.com returns an identical "blocked by network security" page across every TLS/header fingerprint tried, confirmed by testing curl_cffi's full impersonation list - it is blocking the datacenter ASN outright, not fingerprinting the client).
- **Outbound HTTP goes through the stealth client only.** Never instantiate a bare `httpx.AsyncClient(...)`/`httpx.Client(...)` for outbound traffic - build it via `devplacepy.stealth.stealth_async_client(...)`/`stealth_sync_client(...)`. The async factory routes bytes through **`curl_cffi` (curl-impersonate, BoringSSL)** behind an `httpx` transport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport if `curl_cffi` is missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: `routers/proxy.py` relays the user's own headers verbatim. **Load-bearing gotcha:** cleartext `http://` is forced to HTTP/1.1 (`curl_transport.http_version_for`) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns `400 "Invalid HTTP request received."` once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer calls `INTERNAL_GATEWAY_URL` = `http://localhost:<port>/openai/v1/...`, so without this override a large Devii turn (big system prompt + tool list) fails on every request. `https://` keeps the HTTP/2 default. `stealth_sync_client` stays pure-httpx Chrome-aligned (no curl_cffi sync adapter). **Never bolt a hand-written browser-header dict (UA/`sec-ch-ua`/`Sec-Fetch-*`) onto a `stealth_async_client`/`stealth_sync_client` call** - curl_cffi's impersonation already emits an internally-consistent Chrome 146 identity across TLS, UA, and Client Hints; a caller-supplied header set claiming a different Chrome version or platform creates a UA-vs-TLS-vs-Client-Hints mismatch that is a textbook anti-bot tripwire (this was a real bug in Devii's `fetch_url`/`http_request` tool, fixed by deleting its hardcoded Chrome-131 header block). `stealth.detect_consent_gate(html)` recognizes the common JS-redirect cookie-consent wall pattern (DPG Media's `nu.nl` and sibling sites, and generically any page gated behind a `decodeURIComponent(...)` callback referencing a "privacy gate"/`consent.js`) - reuse it wherever a fetched page's real content sits behind that gate rather than special-casing a domain. `stealth_transport`/`stealth_async_client`/`stealth_sync_client` all accept a `proxy=` override, defaulting to `stealth.configured_proxy_url()` (the admin-editable `outbound_proxy_url` site setting, falling back to `DEVPLACE_OUTBOUND_PROXY_URL` when the setting is unset) - this is the only real fix for a destination that blocks by IP/ASN reputation (e.g. reddit.com returns an identical "blocked by network security" page across every TLS/header fingerprint tried, confirmed by testing curl_cffi's full impersonation list - it is blocking the datacenter ASN outright, not fingerprinting the client).
- **Form validation is Pydantic-native.** Routers declare `data: Annotated[SomeForm, Form()]` (models in `models.py`); the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes. Read raw `await request.form()` only when also reading an uploaded file alongside a model.
- **`RedirectResponse(url=..., status_code=302)`** - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
- **Ownership checks:** compare `resource["user_uid"] == user["uid"]` before edit/delete (use `content.is_owner`). **Deletes additionally allow any admin:** `is_owner(...) or is_admin(user)` on every content delete endpoint, so an administrator may delete any member's content while members are limited to their own. Edits stay owner-only. Deletes are **soft** and cascade under one shared stamp - see `devplacepy/database/CLAUDE.md`.
- **All dates shown to users are DD/MM/YYYY** (European), rendered in the viewer's own timezone client-side. Timestamps are stored/emitted as UTC ISO. Use the `local_dt(iso, mode)`/`dt_ago(iso)` Jinja globals for any user-facing instant - they emit `<time data-dt>` and `static/js/LocalTime.js` reformats to local timezone with a `MutationObserver` for dynamic content. `format_date()`/`time_ago()` stay as plain-text helpers for JSON responses, no-JS fallbacks, and non-timestamp date fields (e.g. project `release_date`) - do NOT wrap those in `local_dt`.
- **Slug + UUID lookup:** resources with slugs accept either the slug or the bare UUID via `resolve_by_slug()`. Slugs are `make_combined_slug(title, uid)`, prefixed with the **random tail** of the UUID (never the leading bytes - same timestamp-collision reasoning as blob sharding).
- **Roles are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"`. Always test admin-ness through the `is_admin(user)` global (case-sensitive `== "Admin"`) - never hand-roll a lowercase compare. **Any write to `users.role` MUST call `database.invalidate_admins_cache()`.**
- **Admin seniority: a junior admin cannot manage a more senior admin.** Every per-user mutation in `routers/admin/users.py` and `routers/admin/moderation.py` is gated by `is_senior_admin(actor, target)` (`routers/admin/_shared.py`) - blocks (audits `result="denied"`) when the target is an Admin who registered earlier. Server-side, so it also covers Devii's admin tools.
- **Admin seniority: a junior admin cannot manage a more senior admin.** Every per-user mutation in `routers/admin/users.py` is gated by `_is_senior_admin(actor, target)` - blocks (audits `result="denied"`) when the target is an Admin who registered earlier. Server-side, so it also covers Devii's admin tools.
- **Never pass a `respond()` context key that collides with a Jinja global.** `respond(request, template, ctx, model=XOut)` feeds the same `ctx` to both the Pydantic model (JSON) and the template. A key like `is_admin`/`avatar_url`/`is_self` holding a non-callable value shadows the global across the whole inheritance chain, turning `{% if is_admin(user) %}` into `False(user)` -> `TypeError`, a 500 that fires only for the branch that calls the global. Name viewer/permission flags distinctly (`viewer_is_admin`, not `is_admin`) in both schema and context.
- **Project visibility (`is_private`) and read-only (`read_only`)** are owner-controlled flags on `projects`. Read access is gated by the single `content.can_view_project(project, user)` predicate at EVERY read surface - never re-implement the check inline. Predicate: `not is_private OR is_owner OR (is_admin AND owner is not an admin)` - a project hidden by a member stays visible to any admin, but one hidden by an admin is visible only to that owner admin. **Containers have their own, stricter isolation predicates** (`owns_instance`, `can_view_project_containers`, `can_view_instance`, `can_manage_instance`) - the primary administrator sees/manages every container; any other admin can VIEW others' containers only on public projects and can MANAGE only instances they own. Read-only is enforced as a single data-layer guard `project_files._guard_writable(project_uid)` at the top of every mutation entrypoint - add it to any NEW file-mutating function. Devii may flip read-only/visibility only after explicit confirmation (`CONFIRM_REQUIRED`). Full UI-level detail in `devplacepy/routers/projects/CLAUDE.md`.
- **Deletions ALWAYS require confirmation:** `confirmation_error` gates EVERY content delete tool via `CONFIRM_REQUIRED` (`delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news`, container delete, and any `container_exec` matching `dispatcher.DESTRUCTIVE_COMMAND`). The first call is refused; the agent must show the exact target then pass `confirm=true`. **Load-bearing: every confirmation-gated tool MUST also declare a `confirm` boolean param in its catalog spec** - schemas set `additionalProperties: false`, so a gated tool without a declared `confirm` param can never receive it and loops forever.
## Every user-generated surface is reportable by construction (hard rule)
A new content table added to `SOFT_DELETE_TABLES` that is visible to anyone other than its author MUST be registered in `database/moderation.py` `REPORTABLE_TARGETS`, MUST resolve in `resolve_object_url`, and MUST include `_report_button.html` in its action bar. A table that is genuinely private to its owner goes in `UNREPORTABLE_TABLES` **with its reason** instead. `tests/unit/database/moderation.py` computes the difference and fails the suite on anything unclassified, so report coverage is closed under future additions rather than remembered; the e2e coverage test enforces the third requirement.
The same rule keeps the untriggered app-store conditionals untriggered: **no social login, no payment path, no purchasable randomness, no advertising, and no cross-app tracking** may be introduced without also implementing the obligations each of them creates (Sign in with Apple, in-app purchase, odds disclosure, ad reporting, App Tracking Transparency). Full detail in `devplacepy/services/moderation/CLAUDE.md`.
## Modal pattern
`Application.js` `initModals()` toggles a `.visible` CSS class on `.modal-overlay`; the CSS rule `.modal-overlay.visible { display: flex; }` handles visibility. Triggers usually have `href="#"`, so call `e.preventDefault()`. `.modal-close` is wired generically - no inline JS needed. Full modal/partial/CDN detail in `devplacepy/templates/CLAUDE.md`.
@ -321,24 +275,13 @@ Admin **Trash** at `/admin/trash` restores/purges by event. Full table list, dat
## Testing
Playwright (NOT pytest-playwright). Around 2882 tests in `tests/`, split into three category directories by *what they exercise*: `tests/api/` (HTTP integration, no browser), `tests/e2e/` (Playwright browser), `tests/unit/` (pure in-process). **The directory tree mirrors the path** - one segment per directory, last segment is the file. `api`/`e2e` mirror the URL path (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`); `unit` mirrors the source module path (`devplacepy/utils.py` -> `tests/unit/utils.py`). A test's tier is decided by its fixtures: `page`/`alice`/`bob` = e2e; `app_server`/`seeded_db` or any HTTP call = api; `local_db`-only or no fixture = unit. Run a tier with `make test-unit`/`make test-api`/`make test-e2e`; `make test` runs all three.
Playwright (NOT pytest-playwright). Around 1959 tests in `tests/`, split into three category directories by *what they exercise*: `tests/api/` (HTTP integration, no browser), `tests/e2e/` (Playwright browser), `tests/unit/` (pure in-process). **The directory tree mirrors the path** - one segment per directory, last segment is the file. `api`/`e2e` mirror the URL path (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`); `unit` mirrors the source module path (`devplacepy/utils.py` -> `tests/unit/utils.py`). A test's tier is decided by its fixtures: `page`/`alice`/`bob` = e2e; `app_server`/`seeded_db` or any HTTP call = api; `local_db`-only or no fixture = unit. Run a tier with `make test-unit`/`make test-api`/`make test-e2e`; `make test` runs all three.
Fixture stack: `app_server` (session-scoped uvicorn subprocess on port 10501), `browser_context` (session-scoped Playwright context), `page` (function-scoped, fresh cookies), `alice`/`bob` (seeded logged-in users, `bob` gets its own context for multi-user tests).
Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wait_until="domcontentloaded"`; prefer `page.locator(...).wait_for(state="visible")` over `wait_for_selector`; scope ambiguous selectors (e.g. comment Delete is `.comment-action-btn:has-text('Delete')`). A test that flips a global `site_settings` value MUST restore it in `try/finally`. Full pitfalls/patterns catalogue is in `tests/CLAUDE.md`.
**Always run the full test suite (`make test` - unit, api, and e2e, every test) as the final validation of every change.** No tier may be skipped and no subset substituted for the whole. The clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks are preliminary gates before the suite, not replacements for it. Any failure is a real signal and blocks completion until fixed.
## Rigorous correctness verification (money, state machines, concurrency)
The persisted test suite (`tests/unit`/`api`/`e2e`) is example-based: it proves the specific inputs someone wrote down behave correctly. It is structurally blind to three classes of bug - a formula wrong at an input nobody tried, an invariant violated only after a long sequence of actions, and a race that only two nearly-simultaneous requests trigger. **Any feature that touches a spendable resource (coins, credits, quotas), a bounded state machine (levels, tiers, counters with a floor/ceiling), or a read-then-write mutation reachable from more than one request path applies this procedure in addition to, not instead of, the normal persisted tests.** A simple CRUD toggle doesn't need it; an economy, inventory, ledger, or scoring feature does. This was built out fully for the Code Farm economy rebalance (see `devplacepy/services/game/CLAUDE.md`, "Every purchase/upgrade is atomic..." - the worked example, including every bug it actually caught) and is the standing procedure for anything shaped like it going forward.
Four escalating layers, run in order, as disposable Python scripts that call the real functions directly against a temp DB (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` pointed at a scratch file) - not pytest files, unless the user separately asks for persisted tests too:
1. **Property/invariant checks on every pure function, across the full input domain.** For each formula (a reward, a cost curve, a score, a discount), assert the mathematical property it must have - monotonicity, bounds, non-negativity, idempotency, additivity over a partition of its domain - by iterating the real range (hundreds to thousands of values), not a handful of hand-picked spot checks. A monotonicity bug at input 347 is invisible if only 0, 1, and 10 are tried.
2. **Stateful fuzzing.** Build N independent entities, fire a long randomized sequence of every mutating action across all of them against a live temp DB, and re-check invariants after every single action: balances never negative, counters that should only increase never decrease, levels/tiers never exceed their declared bounds. Catch the domain's expected exception type and continue - the goal is catching invariant violations and *unexpected* exceptions, not asserting every action succeeds. This proves safety (nothing bad happens) but not liveness (legitimate actions actually succeed) - a bug that wrongly blocks a valid action just raises a well-formed expected exception and sails through a pure fuzz test undetected. Layer 3 exists because of exactly this gap.
3. **Concurrency: prove races are closed, never infer it from luck.** Any read-then-write mutation reachable from more than one worker process (`uvicorn --workers N`) is a TOCTOU race until proven otherwise. Test with **real separate OS processes**, not threads in one process - `dataset` gives each thread its own pooled connection, and enough threads exhausts that pool and produces `database is locked` noise that is a test-harness artifact, not a finding about the application. Set up genuinely fresh, production-representative state before racing - **never pre-seed or zero a column that the real code path leaves unset/NULL.** This was the single most expensive mistake made building this procedure: a first race-safety pass "passed" only because its own setup script had artificially pre-zeroed columns that a real fresh row leaves as SQL `NULL` - silently hiding the exact bug the fix was supposed to prevent (`NULL = 0` evaluates to `NULL`, not true, in a SQL `WHERE` clause; any column not written in the row's original `INSERT` needs `COALESCE(column, 0)` in every later precondition and every arithmetic `SET`, not a bare comparison). Fire many concurrent attempts at the same resource, assert the exact right number succeed, and verify the final state matches the hand-computed expected total exactly (currency spent, levels advanced) - not just "the others were blocked." Fix a real race with a single atomic conditional SQL statement at the exact chokepoint (`UPDATE ... SET ... WHERE <precondition>`, checked via the driver's real `rowcount` - `dataset`'s wrapped `db.query()` does not expose it, use `db.executable.execute(sqlalchemy.text(...), params).rowcount` inside `with db:`), not a client-side lock or an optimistic-locking library this codebase doesn't otherwise use.
4. **Static analysis beyond `py_compile`.** A clean `python -m py_compile` and a clean `from devplacepy.main import app` only prove syntax and module-load order - neither catches a missing import inside a function body, which is a runtime `NameError` invisible until that exact line executes. Run `pyflakes`/`ruff check` on every touched file before calling a change done; it is nearly free and catches an entire class of bug that layers 1-3 can each individually miss if they don't happen to exercise the broken line.
**Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead.
## Feature workflow
@ -355,10 +298,9 @@ A new public read almost always needs all four. The cardinal failure mode is cha
2. **Data layer.** `database/` for query/batch helpers (never inline N+1 loops - use `get_users_by_uids`, `build_pagination`, `_in_clause`; guard raw SQL with `if "table" in db.tables`). `models.py` for the Pydantic `Form` input model. `schemas/` for the `*Out` JSON response model - every context key a JSON route exposes via `respond(..., model=XOut)` MUST exist on `XOut` or it is silently dropped. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
3. **Server layer.** Handler with the right auth guard (`get_current_user` public read, `require_user` member POST, `require_admin` admin). Specific paths (`/{username}/followers`) declared before catch-alls (`/{username}`). Return HTML+JSON via `respond(request, template, ctx, model=XOut)` or pure JSON via `JSONResponse`. Register any NEW router in `main.py`.
4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`.
5. **Safety layer (skip only when the feature stores nothing and shows nothing).** If the feature adds a surface that carries user-generated content, register it in `database/moderation.py` `REPORTABLE_TARGETS` (or in `UNREPORTABLE_TABLES` with a reason), resolve it in `resolve_object_url`, and include `_report_button.html` in its action bar - the registry test and the e2e coverage test both fail otherwise. If it collects or transmits a new *category* of personal data, or sends anything new to a third party, update `/docs/privacy.html` and the app-store privacy declarations in the same change; that disclosure is part of the feature, not a follow-up.
6. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
7. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
8. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above.
5. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
6. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen). **Never run the test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above.
Failures at any implementation step block the workflow - never skip a failed step.
@ -366,59 +308,10 @@ Failures at any implementation step block the workflow - never skip a failed ste
Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to `master`: installs dependencies + Playwright Chromium, runs the full suite serially under coverage, publishes coverage HTML as an artifact, uploads failure screenshots. CI must be green before merging. Changes move through DTAP: Development (`make dev`) -> Test (CI suite + coverage on `master`) -> Acceptance (`master` to `production` promotion via `make deploy`) -> Production (Docker Compose stack). Only CI-green `master` commits are promoted to `production`.
## Diagnosing a production failure (the order that finds it fastest)
This procedure exists because a single "the editor is down" report turned out to be **three unrelated faults stacked on each other** (a stale URL, a firewalled network leg, and a corrupt database), and the investigation wasted hours by guessing before measuring. Work the layers outward from the browser; each step is cheap and each one eliminates a whole class of cause. **Never skip to a hypothesis, and never repair anything before the layer above it is proven healthy.**
**Layer 0 - is the request even arriving here?** Fetch the hostname over the public internet exactly as it resolves (`curl -sS -o /dev/null -w '%{http_code} %{remote_ip}' https://host/`). Compare the answering IP against this machine's own addresses (`ip -6 addr`, `curl https://api.ipify.org`). Two hostnames serve this platform by different routes - see the topology section above. **Never use `curl --resolve` to force a hostname onto an IP it does not resolve to**; that fabricates a path that no real traffic takes and produces confident, wrong conclusions.
**Layer 1 - which edge answered?** The error body identifies it. `application/problem+json` with `No site configured for host` is molohttp. A DevPlace HTML error page is the application. An nginx error page is the nginx container. A browser `ERR_*` with no body means nothing well-formed was returned at all.
**Layer 2 - same failure on both hostnames?** Run the identical authenticated request against `pravda.education` and `devplace.net`. Failing on **both** means the application or the database; failing on **one** means that host's edge. This single comparison is the highest-value measurement available and costs one command.
**Layer 3 - the application log, before any theory.** `docker logs --since 5m devplace-app-1`. Count error classes rather than reading prose (`grep -c malformed`). A recurring service-loop error is a systemic fault even when it looks unrelated to the symptom.
**Layer 4 - reproduce the failing hop in isolation.** Point the real code at the real upstream from a scratch harness rather than reasoning about it. Running `forward.proxy_http` against a live code-server is what exposed the duplicate `Date` header; reading the function had not. Use a scratch database (`DEVPLACE_DATABASE_URL`) so the harness never reaches production.
**Layer 5 - test from where the code actually runs.** The app runs **inside a container**; `127.0.0.1` there is not the host. `docker exec devplace-app-1 curl ...` is the only honest reachability test for a container-to-container hop. A hang with zero bytes means a packet was **DROPped** (firewall), a refusal means nothing is listening, and a slow error means the upstream answered badly - three different causes with three different fixes.
**Layer 6 - confirm the object exists before blaming the plumbing.** A 404 from a guard is not a proxy failure. Resolve the identifier through the application's own read surface (the workspace page, an admin JSON endpoint) with the affected account's session. A stale instance uid in a bookmarked URL looks exactly like an outage.
### Rules learned the hard way
- **State what a command will read or write before running it against production, and keep production access read-only until the diagnosis is complete.** The one write in a repair is the final swap, and it comes after verification, not before.
- **Copy before repairing, and copy the whole set.** A WAL-mode SQLite database is `.db` **plus** `-wal` **plus** `-shm`; a `.db`-only copy silently discards every transaction still in the WAL. Stop writes first, or the snapshot is inconsistent. Never leave a stale `-wal` beside a recovered file - SQLite will replay it and re-corrupt the result.
- **Repair on a copy, verify on the copy, and prove what was preserved.** `PRAGMA integrity_check` names the damaged objects; index damage is derived data and costs nothing (`REINDEX`, or `.recover`), while a table b-tree fault is the only kind that can lose rows. Diff row counts table by table between the original and the recovered file and report the delta - "it says ok" is not evidence that data survived.
- **Verify the fix through the user's own path, with their account, in a real browser.** A green unit test and a 200 from `curl` did not prove the editor worked; driving Playwright through login, the code-server password prompt and a `.monaco-workbench` selector did.
- **A measurement recorded in these files can go stale.** `services/containers/CLAUDE.md` recorded that `container_ip:port` times out from the app container while `gateway:published_host_port` connects. A later change (`make docker-attach`) inverted it, and a host firewall closed the documented leg entirely. Re-measure before trusting a recorded measurement, and update the record when it turns out to be false.
- **Report each fault separately and correct yourself explicitly.** Three stacked faults produce a symptom that no single explanation covers, and an early wrong theory is worse than no theory once it is repeated as fact.
## Production hostnames and the devplace.net SSH tunnel (verified topology, do not re-derive)
**The platform answers on two public hostnames, and they reach the same application by two completely different paths.** This has already cost one debugging session; the failure mode is that a `curl --resolve devplace.net:443:<production ip>` "test" reports `No site configured for host: devplace.net` and looks like a total outage, when in fact devplace.net never touches the production edge at all.
| | `pravda.education` | `devplace.net` |
|---|---|---|
| DNS | `95.216.15.238`, `2a01:4f9:2a:100e::2` | `88.198.21.243`, `2a01:4f8:222:2c45::2` |
| Machine | the production host itself | a separate front host (Hetzner, PTR `static.88-198-21-243.clients.your-server.de`) |
| Path in | molohttp on `:443` -> `127.0.0.1:10500` | its own proxy -> **SSH tunnel** -> `127.0.0.1:10500` on production |
| Reaches molohttp | yes | **no, never** |
**`devplace.net` is a front host that forwards over SSH.** It holds a persistent SSH session into the production host (visible there as an established inbound connection from `88.198.21.243` to port 22) and forwards through it to `127.0.0.1:10500`, which is the `docker-proxy` for the `devplace-nginx` container. The listening socket lives on the **front** host (an `ssh -L` style local forward), so the production host shows **no** sshd-owned listener - that absence is expected and is not evidence against the tunnel.
Two consequences that must not be forgotten:
- **molohttp has no `devplace.net` site, and that is correct.** Its site list is `mail`/`smtp`/`imap.molodetz.nl`, `pravda.education` and `*.tunnel.pravda.education`. devplace.net traffic enters below molohttp, straight into `127.0.0.1:10500`, so it needs no site. **Never "fix" this by adding a devplace.net site to molohttp** - devplace.net does not resolve to the production host, so such a site could never match, and its absence is not a bug.
- **Both hostnames land on the same nginx and the same app**, so a request that fails on both is failing in the application, not in either edge. That comparison is the fastest triage available here: run the same authenticated request against both hostnames. Same failure on both means look at the app or the database; a failure only on devplace.net means look at the front host's proxy (WebSocket `Upgrade` headers are the usual culprit, exactly as for the production nginx locations below).
**Testing rule.** Never point a hostname at an IP it does not resolve to in order to "test" it. Fetch each hostname over the public internet as it really resolves (`curl https://devplace.net/...` and `curl https://pravda.education/...`), because forcing devplace.net onto the production IP tests molohttp with a `Host` it deliberately does not serve and proves nothing about the real path.
## Production deployment
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. 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.
- **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`.
- **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 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,8 +4,6 @@ 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
@ -20,24 +18,22 @@ 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/
RUN pip install --no-cache-dir --no-deps --force-reinstall .
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
EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s --start-interval=2s \
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
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

@ -12,7 +12,7 @@ DEVPLACE_RATE_LIMIT ?= 1000000
PYTHONDONTWRITEBYTECODE := 1
export PYTHONDONTWRITEBYTECODE
.PHONY: install dev clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless
.PHONY: install dev clean tree tree-loc zip test test-headed coverage coverage-headed coverage-html locust locust-headless
install:
pip install -e .
@ -43,31 +43,19 @@ zip:
@printf 'Wrote %s (%s files)\n' "$(notdir $(CURDIR)).zip" "$$(git ls-files | wc -l)"
test:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/
test-headed:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/
test-unit:
python -m pytest tests/unit
test-api:
python -m pytest tests/api
test-e2e:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e
test-fast:
python -m pytest tests/unit tests/api
test-failed:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --last-failed --last-failed-no-failures none
test-first-failure:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
test-slowest:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --durations=40
test-headed:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -x
test-unit:
python -m pytest tests/unit -x
test-api:
python -m pytest tests/api -x
test-e2e:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e -x
coverage:
rm -f .coverage .coverage.*
@ -121,12 +109,8 @@ clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf devplacepy.egg-info
rm -rf .pytest_cache
rm -rf .venv
test-cache-clean:
rm -rf .pytest_cache
# Container Manager works out of the box: the overlay installs the docker CLI in
# the image and mounts the host socket. DOCKER_GID is read straight from the
# socket so the UID-1000 app can use it; the data dir is the project's own data/
@ -135,11 +119,10 @@ test-cache-clean:
COMPOSE := docker compose -f docker-compose.yml -f docker-compose.containers.yml
DEVPLACE_DATA_DIR ?= $(CURDIR)/data
DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
DEVPLACE_CONTAINER_NETWORK ?= bridge
export DEVPLACE_DATA_DIR
export DOCKER_GID
.PHONY: docker-build docker-up docker-attach docker-reload docker-down docker-logs docker-clean docker-prep ppy
.PHONY: docker-build docker-up 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,23 +137,6 @@ docker-build: docker-prep
docker-up: docker-prep
$(COMPOSE) up -d
$(MAKE) docker-attach
# Workspace tunnels reach a container port that was never published on the host,
# so the app must sit on the same docker network as the instances it runs. The
# default bridge rejects the network-scoped aliases compose always sends, so
# this cannot live in docker-compose.containers.yml and is wired here instead.
docker-attach:
@app=$$($(COMPOSE) ps -q app); \
test -n "$$app" || { echo "app container is not running"; exit 1; }; \
docker network connect $(DEVPLACE_CONTAINER_NETWORK) $$app 2>/dev/null \
&& echo "attached app to the $(DEVPLACE_CONTAINER_NETWORK) network" \
|| echo "app is already on the $(DEVPLACE_CONTAINER_NETWORK) network"
docker-reload:
$(COMPOSE) restart app
$(COMPOSE) up -d --wait
$(MAKE) docker-attach
docker-down:
$(COMPOSE) down

247
README.md
View File

@ -15,14 +15,6 @@ make test-headed # same tests in visible browser
Open `http://localhost:10500`.
A measured structural overview of the whole application, fifteen Mermaid diagrams covering deployment, request pipeline, URL surface, data layer, AI plane, containers and the real-time plane, is in [ARCHITECTURE.md](ARCHITECTURE.md).
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 |
@ -48,7 +40,7 @@ devplacepy/
avatar.py # Multiavatar generation, URL builder
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
models.py # Pydantic schemas
push/ # Push delivery: provider protocol, Web Push, APNs, registrations
push.py # Web push crypto, VAPID keys, encrypt/send/register
routers/ # One file per domain (auth, feed, posts, push, ...)
templates/ # Jinja2 HTML templates
static/css/ # Page-specific CSS files
@ -68,7 +60,6 @@ devplacepy/
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
| `/projects/{slug}` | Dedicated project page: one encompassing card with a cover banner and project logo (owner-uploaded through the standard attachment uploader), the title overlaid on the banner, status/type/platform chips, owner-set Website and Repository links, section tabs (Overview, Devlog, Screenshots, Comments, Files), an About section, the Devlog timeline of every post linked to the project (owners post updates straight from the page via the shared composer preset to the `devlog` topic), a Screenshots gallery built from image attachments (owners add more from the More menu), and a sidebar with links, stats and the author card |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
@ -78,25 +69,19 @@ devplacepy/
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
| `/uploads` | Attachment management (full lifecycle for the signed-in user, same files that appear on posts and other content): `POST /uploads/upload` (multipart) and `POST /uploads/upload-url` (from URL) create; `GET /uploads` lists your own attachments (paginated, newest first, optional `linked` filter); `GET /uploads/{uid}` returns one; `PATCH /uploads/{uid}` renames its display filename (the file extension is always preserved); `DELETE /uploads/delete/{uid}` removes one. Reading and modifying another user's attachment is owner-or-admin; files are served at `/static/uploads/` |
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/admin/devii-tasks` | Admin **Devii tasks**: every scheduled task across all owners with its schedule, run count, expiry and failure streak, plus per-task disable and delete |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. An opened conversation loads its 500 most recent messages; older history is retained in the database. The `POST /messages/send` form remains as a no-JavaScript fallback |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
| `/polls` | Vote on post-attached polls |
| `/follow` | Follow/unfollow users |
| `/reports` | Content reporting: `POST /reports/{target_type}/{target_uid}` files a report against any user-generated surface, `GET /reports/mine` lists the reports you filed and their outcome, `GET /reports/reasons` serves the reason registry so every client renders the same dialog |
| `/admin/moderation` | Admin **Moderation** queue: reported content oldest-open-first with the response-window badge, one report per detail page with the offender's history, triage (`/status`) and decisions (`/decide`) |
| `/workspaces/index` | Public index of every workspace published to the ingress proxy, with owner, project, maturity label and direct link |
| `/projects/{slug}/workspace` | A member's dev workspace for a project: open/start/stop/delete, quota and idle status, public tunnels, and the **Editor** card holding their editor preferences (`GET`/`POST /projects/{slug}/workspace/editor`) |
| `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you. Also reachable directly from every content action bar |
| `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you |
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
| `/leaderboard` | Contributor ranking by total stars earned |
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
@ -128,47 +113,6 @@ Member progression is driven by activity and peer recognition.
Every AI gateway response (`/openai/v1/*`) also returns per-call `X-Gateway-*` headers with the full token breakdown and the dollar cost of that call, so any client can read its own usage.
## Quizzes
**Quizzes** (`/quizzes`) let any signed-in member write a quiz and every member play it. The hub is
one page with three columns: filters and search on the left, the quiz list in the middle showing
what you still have to do and what you already completed with your score, and the cross-quiz
scoreboard on the right. Guests read published quizzes and see the board; they cannot play.
- **Eight question kinds.** Single choice, multiple choice, true/false, free text, fill in the
blanks, numeric, ordering, and matching. Seven are graded deterministically, several with partial
credit. Ordering and matching use plain selectors and keyboard controls, never a drag-only
interaction, so they work with a keyboard and a screen reader.
- **AI-graded free text.** A free-text answer is reviewed by the platform's own AI against the
author's reference answer and grading criteria, billed to the answering member's own API key. The
score is re-clamped on the server and the correct/incorrect verdict is derived from the clamped
score, so a reviewer can never mark an answer correct while scoring it zero. When the reviewer is
unavailable the answer is still graded, by a deterministic keyword comparison, and is visibly
stamped as such - grading never silently becomes a zero.
- **Publishing is permanent.** A draft is fully editable; publishing freezes the quiz, its
questions and its options forever. There is no unpublish and no post-publish edit, which is what
makes two members' scores on the same quiz comparable. The builder shows a live pre-publish
checklist and keeps the Publish button disabled until it is empty, and the action is confirmation
gated on both the web UI and in Devii.
- **One attempt at a time.** Starting a quiz creates an attempt that lives on the server, so a
refresh, a second tab and a different device all resume the same one. Each question can be
answered exactly once. A time limit is a deadline stored on the attempt, evaluated when someone
looks at it - nothing runs in the background.
- **Settings.** Shuffle the questions, shuffle the options, reveal the correct answer after each
question, allow reviewing every answer on the results screen, set a time limit, set a pass mark.
- **An honest scoreboard.** Your **best** attempt per quiz counts, never the sum of your attempts,
so replaying a quiz can raise your contribution up to your personal best and never beyond it.
Quizzes you wrote yourself count like any other. Each quiz also has its own leaderboard.
- **Full automation.** Devii creates a complete quiz from one JSON document, publishes it, plays it
end to end and reads the result, all through the same public API - and the hub's *Create quiz
with Devii* button opens the assistant with that request already typed in (it never sends it for
you). The whole flow works without JavaScript too: every question is a real form.
- **Engagement.** Quizzes carry comments, stars, bookmarks and reactions like any other content,
and appear in the sitemap.
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
`devplace quiz prune`.
## Code Farm
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.
@ -181,25 +125,18 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
- **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days).
- **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete.
- **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 60%) carries over into the new run.
- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a grant from it once per week - the balance is divided between everyone currently eligible rather than paid first-come-first-served, capped at 2,500 coins and suppressed below 250. A direct wealth transfer from the farms refactoring at the top to the farms building at the bottom.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), and **Branch Protection** (longer steal grace and a smaller steal cut). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping - scaled by your own prestige and Tech Debt Payoff multiplier, so the cooperative loop stays worth doing at every stage - and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful raid pays the thief a share of the build's coin value and the **owner keeps and can still harvest the remainder** - a raid redistributes value rather than destroying it. The thief earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a notification naming the raider, the crop, and the exact amount taken. You can raid any given neighbour only **once per hour**, and any farm can absorb at most **3 raids per day**, so an inactive player can never be stripped by an unlimited queue of raiders. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked and converted into grow-time-normalized supply, so fast and slow crops saturate on the same real-terms scale; supply is measured per active farm so a busy server is not permanently floored by a few heavy players; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops pay a boost (up to +15%) whenever the high-tier market is saturated and they are not - a crop is either penalized or boosted, never both. Printing one crop nonstop is throttled, planting what the market is short on is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (caps what any raider can take from you at 20% of a build's value).
- **Defense.** An upgradeable building that multiplicatively reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth). If you cannot pay, only what you can afford is taken and the tier decays by one level - your balance is never emptied - and you are notified. You can also step down a tier deliberately to leave the commitment.
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 5 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, a capped coin contribution, CI tier, plots, perks, and streak - the cap keeps it a measure of what you built rather than what you hoard), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
- **Eras (admin-managed seasons).** Administrators can start an Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection) to harvest it first. A successful steal pays the thief half the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**, so no one has to babysit their farm against constant theft. Stealing pays coins only, so the leaderboard stays earned by real farming. This is the competitive counterpart to watering.
- **Leaderboard.** Top farmers are ranked by a composite achievement score that weighs every factor the game tracks - refactor (prestige) count, XP, lifetime harvests, current coins, CI tier, plots bought, perk levels, and login streak - so total accomplishment decides position rather than just the current post-refactor cycle. The score is shown alongside your own farm next to each player's level.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_fertilize`, `game_daily`, `game_claim_quest`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`, `game_downgrade_defense`). See the API reference group **Code Farm** and the full player guide at `/docs/code-farm.html`.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`). See the API reference group **Code Farm**.
## Engagement
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
- **Emoji reactions** - a fixed palette of reactions on posts, comments, gists, and projects, separate from voting and carrying no ranking weight.
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
@ -208,20 +145,6 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils/`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
## Trust and safety
DevPlace is open in the sense that it does not editorialise technical opinion. It enforces a short, fixed list of prohibited categories, published as the [Community Guidelines](/docs/community-guidelines.html), and it has the machinery to make that enforcement real.
- **Automated filtering at post time.** Every user-authored text passes a classifier at the five places content is created or changed (content creation, content editing, comments, direct messages, profile and signup fields), covering every surface with no per-route code. The default mode is `review`, not `block`: a match is published **and** raises a report for a human, because a developer platform discusses exploits, malware analysis and violent subject matter as its work and a machine that suppressed that would destroy the product. Only sexual and exploitative content is refused outright. Classification failure falls back to review, never to publication. Tunable live at `/admin/settings` (`moderation_filter_mode`: `off`, `label`, `review`, `block`).
- **A report control on every surface.** Posts, comments, gists, projects, project files, news, uploads, direct messages, quizzes, polls, awards, profiles, issues, workspaces and assistant output are all reportable through one polymorphic endpoint keyed on `(target_type, target_uid)`, from one dialog, with one reason list. Reports are private to the reporter; the reported person is never told who filed.
- **One queue with a published response window.** `/admin/moderation` is worked oldest-open-first and carries a badge showing the age of the oldest unresolved report against `moderation_sla_hours` (default 24), so a breach is visible rather than assumed. Resolution is a single atomic conditional update: two administrators deciding at once produce exactly one decision, the second gets a 409.
- **Real enforcement, with a statement of reasons.** Remove or restore content, warn, suspend for a stated period, ban, lift, dismiss, or escalate. Every decision writes a permanent `moderation_actions` row plus an audit event, and tells the affected user what was decided and why. A suspended account can still read, still see why, still report, and still delete itself; it cannot create. A junior administrator can never action a senior one.
- **Blocking**, unchanged and independent of all of the above, is now reachable from the content itself as well as from a profile.
- **Age and maturity.** Signup collects a date of birth, derives an age band, and **discards the date**; accounts below `moderation_minimum_age` (default 16) are refused. Content labelled mature is hidden behind an interstitial until the viewer explicitly opts in, and the reveal is never offered to a minor age band for restricted content.
- **Consent.** Five versioned, independently withdrawable consents (`terms`, `privacy`, `ai_third_party`, `activity_recording`, `container_credentials`) with full history, managed at `/profile/{username}?tab=privacy` and changeable **only by the account holder** - an administrator reads the record but never grants or withdraws on someone else's behalf. **No content is sent to a third-party AI provider without `ai_third_party` consent**, enforced once at the gateway; the per-feature AI toggles remain as preferences subordinate to it. Withdrawing `activity_recording` stops presence recording. While recording is on, an indicator says so on every page. `container_credentials` is what lets software another member runs in a container receive your API key; running your own container never asks.
- **Account deletion.** Self-service at `/profile/{username}/delete`, reauthenticated with the account password. Sessions and tokens are revoked, the profile is anonymised immediately, and all content is removed under one deletion event that stays restorable for `account_deletion_grace_hours` (default 24) before `devplace accounts prune` (and the Moderation housekeeping service) purges it permanently. The devRant `DELETE /api/users/me` routes into the same cascade.
- **Legal pages**: [Terms of Service](/docs/terms.html), [Community Guidelines](/docs/community-guidelines.html), [Privacy Policy](/docs/privacy.html), [How moderation works](/docs/content-moderation.html), [Notice and takedown](/docs/intellectual-property.html) and [Contact](/docs/contact.html). All six are indexed in the sitemap and reachable from the docs index; the footer of every page links Terms, Privacy, Community Guidelines and Contact. Contact details come from the `contact_email`, `contact_phone` and `contact_address` settings, so the in-product page and any app-store trader declaration have one source of truth.
## Vibe coding (Alpha, admin only)
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`, and `DEVPLACE_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
@ -248,7 +171,6 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes on `public.presence.roster`. That one set is the single source of truth behind every avatar presence dot on every page; `DEVPLACE_PRESENCE_ONLINE_LIMIT` only caps how many of them the feed's Online now panel displays |
### Runtime settings
@ -436,61 +358,15 @@ and its full configuration are documented automatically - including future servi
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call. **Provider and model routing** (admin **Gateway** page, `/admin/gateway`) maps any number of requested model names onto named upstream providers and target models, each with its own pricing economy (input, output and cache-hit/cache-miss rates per million tokens) and an optional vision model that describes image content before forwarding, so text and vision models are merged transparently. Unmapped requests fall through to the default upstream unchanged, so existing clients are unaffected
- **`JobService` / `ZipService` / `ForkService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` builds project zip archives in a subprocess, `ForkService` copies a project into a new project owned by the forking user
- **`ContainerService`** - the admin container manager (`services/containers/`): a reconciling supervisor for container instances, all running one shared prebuilt image
- **`AcceptanceService`** - grants every policy agreement (Terms of Service, Privacy Policy, third-party AI processing, activity recording, container credentials) to every account that has not declined it, so an instance kept production-identical for extended manual testing never interrupts with an acceptance dialog. Administrator-only, **off by default**, with a separate switch per agreement type and a dry run that reports what it would do without writing. An account that withdrew a consent is never granted it again, with no further action: the consent ledger itself is the decline register. It is not appropriate on a real production host
### Container manager (admin only)
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, `pagent` at `/usr/bin/pagent.py`, and the `code-server` browser IDE that backs dev workspaces all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, and `pagent` at `/usr/bin/pagent.py` all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.
### Dev Workspaces and the browser editor
A **workspace** is a member-facing container running the DevPlace browser editor, layered on the
container runtime above. It is opened from a project's **Workspace** page and reached at
`/projects/{slug}/workspace`; the editor itself is proxied at
`/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project
page whenever the workspace is running.
The editor is `code-server`, rebranded as DevPlace end to end: the application name, the browser tab
icon and PWA icons, the login page styling, and `product.json` all carry DevPlace, and a bundled
built-in extension ships the **DevPlace Dark** and **DevPlace Light** themes (generated from the
site's own design tokens), a **Get started on DevPlace** walkthrough, a project status bar item and
five `DevPlace:` commands. Nothing in the interface identifies as code-server.
**On boot** two terminals open: a focused **DevPlace Code** terminal already running `dpc`, the
coding agent baked into the image, and a plain login shell beside it with the Python, Rust, Nim and
Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for
terminals the member opens later.
The workspace opens straight onto the member's files rather than a welcome page, and the editor's
own built-in chat assistant is suppressed so `dpc` is the only agent on offer and every token it
spends is ledgered against the member's DevPlace account. `dpc`'s own working files (`.dpc/`,
`dpc.log`) are in `SYNC_SKIP_NAMES`, so running an agent on every boot never pollutes the project.
**Every workspace is trusted.** VS Code Restricted Mode is disabled at the command line and in the
seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a
real consequence (a project's own `.vscode/tasks.json` will run on folder open), it is documented to
members on `/docs/workspace-editor.html`, and an administrator can restore Restricted Mode site-wide
with the `workspace_editor_trust_all` setting.
**Four sizes are configurable through one resolver.** Editor and terminal font size plus zoom, the
editor layout and terminal panel preset, whether the editor opens in a tab or a sized window, and the
container's CPU, memory and disk. The first three are the member's own preferences on their workspace
page (and over the API, and through Devii's `workspace_editor_get` / `workspace_editor_set`); the
container size is part of the administrator-set workspace quota. Each preference resolves instance
override, then the member's row, then the site setting, then the built-in default, and the page shows
which of those each value came from.
**A member edit is never overwritten.** DevPlace seeds the editor's `settings.json` from the host
before each launch and records exactly what it wrote; on the next launch it updates only the keys
whose current value is still the one it wrote. A setting the member changed inside the editor is
theirs permanently, while a change to the site default still reaches everyone who has expressed no
preference. Preferences apply on the next workspace start, and the page says so and offers the
restart.
### Async job framework and zip downloads
`services/jobs/` is the standard way to run blocking work asynchronously and hand the caller a result URL. A shared `jobs` table is the queue (discriminated by `kind`); `queue.enqueue()` inserts a `pending` row from any worker, the lock-owning worker processes jobs in `JobService.run_once` (reap, recover orphans, refill up to a concurrency limit, prune expired), and status is polled from the database. Retention is built in: each job service deletes its own expired artifacts via a `cleanup` hook (default 7 days, admin-configurable). To add a kind, subclass `JobService`, set `kind`, and implement `process()` and `cleanup()`.
@ -617,17 +493,6 @@ 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 |
@ -717,31 +582,7 @@ are run by the background service, so a queued reminder survives a server restar
even if you have closed the Devii terminal. When a reminder fires you receive an in-app
notification and a live toast carrying its message (the **Reminders** notification type, which
you can toggle like any other on your profile), in addition to the result appearing in the
terminal.
**Every account may schedule, within two rolling 24-hour quotas.** A member may create 5 tasks
and execute 10 task runs per 24 hours; an administrator may create 5 and execute 100. Deleting a
task does not give a creation slot back, and a run that would exceed the quota is **postponed
until a slot frees, never dropped or disabled** - the task simply runs later, and the exact time
its next slot opens is reported. All four numbers are adjustable on the Devii service page, where
0 means unlimited. Guests cannot schedule at all.
**A task knows when it is running as a task, and a member's task cannot spawn more tasks.** While
a scheduled run is executing, creating a task, re-enabling one, or triggering one immediately is
refused for members - so a member's automation can never fan out into more automation. An
administrator's task may schedule follow-up work, and every new task and run still counts against
the same quotas. The assistant is told which environment it is in, and the restriction itself is
enforced by the server rather than by the instruction, so no prompt can talk its way around it.
Every scheduled task is also bounded in time: a repeating task must leave at least fifteen minutes
between runs, carries a maximum number of executions, and expires at most thirty days after its
first run. A task that fails several times in a row, whose owner has been inactive for a month, or
that passes its automation spend limit is disabled automatically with the reason recorded in the
audit log. Across the whole platform only a few scheduled tasks run at the same time, handed out
one at a time per owner, so a single account can never monopolise the scheduler. Administrators
see every task, its owner, its 24-hour usage, and its bounds at **Admin -> Devii tasks**, where any
task can be disabled or deleted, and the same is available from the command line with
`devplace devii tasks`.
terminal. Manage your reminders conversationally (list, change, run now, or delete them).
Configuration on the Services tab:
@ -765,15 +606,6 @@ Configuration on the Services tab:
| `devii_rsearch_timeout` | `300` | Read timeout (seconds) for `rsearch_*` calls; web-grounded answers can take minutes; minimum five minutes |
| `devii_email_enabled` | on | Enable the email tools (`email_*`) for signed-in users |
| `devii_email_timeout` | `30` | Connection/read timeout (seconds) for IMAP and SMTP calls |
| `devii_task_member_create_24h` | `5` | Tasks a member may create per rolling 24 hours (`0` = unlimited) |
| `devii_task_member_runs_24h` | `10` | Task runs a member may execute per rolling 24 hours; excess runs are postponed |
| `devii_task_admin_create_24h` | `5` | Tasks an administrator may create per rolling 24 hours |
| `devii_task_admin_runs_24h` | `100` | Task runs an administrator may execute per rolling 24 hours |
| `devii_task_max_concurrent` | `4` | Scheduled tasks running at once across all owners, handed out round-robin, one at a time per owner |
| `devii_task_max_per_owner` | `10` | Active scheduled tasks one administrator may hold (`0` = no cap) |
| `devii_task_daily_usd` | `0.5` | Rolling 24h spend cap for scheduled runs, separate from the interactive quota (`0` = unlimited) |
| `devii_task_max_failures` | `3` | Consecutive failures after which a task disables itself (`0` = never) |
| `devii_task_owner_idle_days` | `30` | Disable an owner's tasks after this many days without activity (`0` = never) |
Beyond the platform tools, Devii has external **web** tools. `fetch_url` reads a web page;
`http_request` makes an arbitrary HTTP call (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) to any
@ -885,37 +717,9 @@ calling itself.
## Push notifications & PWA
Authenticated users can receive native push notifications, and the site is an
Authenticated users can receive native web push notifications, and the site is an
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
`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.
`PyJWT`, `httpx`) against the Web Push Protocol - no third-party push wrapper.
### Events
@ -938,11 +742,9 @@ 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`) 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.
(`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.
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;
@ -999,10 +801,7 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
| File | Role |
|------|------|
| `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/push.py` | VAPID keys, payload encryption, send, register |
| `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 |
@ -1102,13 +901,11 @@ Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `
```bash
git pull
make docker-reload # restart workers on the new code (bind-mounted, no rebuild)
make docker-up # restart with 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)
@ -1123,10 +920,6 @@ What the overlay (`docker-compose.containers.yml`) changes:
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
One piece of wiring cannot live in the overlay:
- **Workspace tunnel reach.** A workspace tunnel serves a port the member chose, which is almost never published on the host, so the app has to dial the container directly - and it can only do that from the container's own docker network. Compose cannot attach a service to the default `bridge` network (it always sends network-scoped aliases, which that network rejects), so `make docker-up` and `make docker-reload` run `make docker-attach`, an idempotent `docker network connect` of the app container to `DEVPLACE_CONTAINER_NETWORK` (default `bridge`). A bare `docker compose up -d` skips it and every tunnel to an unpublished port answers `502`. On a bare-metal `make prod` deploy the app is already on the host and reaches container IPs with no wiring at all.
Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.
### nginx specifics

View File

@ -546,21 +546,6 @@ def delete_attachment(uid):
_delete_attachment_row(row)
def rename_attachment(uid, filename):
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
return None
ext = Path(row.get("stored_name", "")).suffix.lower()
stem = Path(str(filename)).name.strip()
if ext:
stem = Path(stem).stem
if not stem:
return None
clean = f"{stem}{ext}"
get_table("attachments").update({"uid": uid, "original_filename": clean}, ["uid"])
return clean
def soft_delete_attachment(uid, deleted_by="system"):
row = get_table("attachments").find_one(uid=uid)
if not row or row.get("deleted_at"):

View File

@ -28,6 +28,6 @@ def generate_avatar_svg(seed: str) -> str:
initial = seed[:1].upper() if seed else "?"
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<rect width="100" height="100" rx="50" fill="#b73f1e"/>'
f'<rect width="100" height="100" rx="50" fill="#ff6b35"/>'
f'<text x="50" y="65" text-anchor="middle" fill="white" font-size="40" font-weight="700" font-family="sans-serif">{initial}</text></svg>'
)

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.get_flattened_data()
data = img.getdata()
cleaned = []
for pixel in data:
if pixel[:3] == bg:

View File

@ -2,7 +2,6 @@
from devplacepy.cli.main import main, build_parser
from devplacepy.cli._shared import _audit_cli
from devplacepy.cli.accounts import cmd_accounts_pending, cmd_accounts_prune
from devplacepy.cli.roles import cmd_role_get, cmd_role_set
from devplacepy.cli.apikeys import cmd_apikey_get, cmd_apikey_reset, cmd_apikey_backfill
from devplacepy.cli.tokens import (
@ -43,15 +42,12 @@ from devplacepy.cli.containers import (
cmd_containers_prune_builds,
cmd_containers_gc_workspaces,
)
from devplacepy.cli.quiz import cmd_quiz_prune
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
__all__ = [
"main",
"build_parser",
"_audit_cli",
"cmd_accounts_pending",
"cmd_accounts_prune",
"cmd_role_get",
"cmd_role_set",
"cmd_apikey_get",
@ -88,7 +84,6 @@ __all__ = [
"cmd_containers_prune",
"cmd_containers_prune_builds",
"cmd_containers_gc_workspaces",
"cmd_quiz_prune",
"cmd_emoji_sync",
"cmd_migrate_data",
]

View File

@ -1,46 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_accounts_prune(args):
from devplacepy.services.moderation import deletion
pending = deletion.due_purges()
if args.dry_run:
for row in pending:
print(f"{row['uid']} deleted at {row['deletion_requested_at']}")
print(f"{len(pending)} account(s) due for purge")
return
purged = deletion.purge_due()
_audit_cli(
"cli.accounts.prune",
f"CLI purged {purged} deleted account(s) past the grace window",
metadata={"count": purged},
)
print(f"Purged {purged} deleted account(s)")
def cmd_accounts_pending(args):
from devplacepy.services.moderation import deletion
pending = deletion.due_purges()
for row in pending:
print(f"{row['uid']}\t{row['deletion_requested_at']}")
print(f"{len(pending)} account(s) past the {deletion.grace_hours()}h grace window")
def register_accounts(subparsers):
accounts = subparsers.add_parser("accounts", help="Deleted account management")
accounts_sub = accounts.add_subparsers(title="action", dest="action")
prune = accounts_sub.add_parser(
"prune", help="Permanently purge accounts past the deletion grace window"
)
prune.add_argument(
"--dry-run", action="store_true", help="List what would be purged and exit"
)
prune.set_defaults(func=cmd_accounts_prune)
pending = accounts_sub.add_parser(
"pending", help="List deleted accounts awaiting their purge"
)
pending.set_defaults(func=cmd_accounts_pending)

View File

@ -107,106 +107,6 @@ def cmd_devii_lessons_prune(args):
print(f"Pruned {pruned} lesson(s) (active before: {active_before}, now: {_active_count()})")
def _task_rows(enabled_only: bool) -> list:
from devplacepy.services.devii.tasks.store import TABLE
if TABLE not in db.tables:
return []
criteria = {"deleted_at": None}
if enabled_only:
criteria["enabled"] = True
rows = list(db[TABLE].find(**criteria))
rows.sort(key=lambda row: int(row.get("run_count") or 0), reverse=True)
return rows
def _owner_name(owner_id: str) -> str:
user = get_table("users").find_one(uid=owner_id)
return user["username"] if user else owner_id
def cmd_devii_tasks_list(args):
rows = _task_rows(not args.all)
if not rows:
print("No tasks")
return
for row in rows:
schedule = (
f"every {row.get('every_seconds')}s"
if row.get("kind") == "interval"
else (row.get("cron") or row.get("run_at") or "")
)
print(
f"{row.get('uid')} {_owner_name(str(row.get('owner_id') or '')):16} "
f"{'on ' if row.get('enabled') else 'off'} {str(row.get('status')):9} "
f"runs={row.get('run_count')}/{row.get('max_runs') or '-'} "
f"{schedule:24} {row.get('label') or ''}"
)
def cmd_devii_tasks_disable(args):
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
if TABLE not in db.tables:
print("No devii_tasks table exists")
return
row = db[TABLE].find_one(uid=args.uid, deleted_at=None)
if not row:
print(f"Task '{args.uid}' not found")
sys.exit(1)
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
store.update(
args.uid,
{
"enabled": False,
"status": "disabled",
"next_run_at": None,
"last_error": "disabled from the command line",
},
)
_audit_cli(
"cli.devii.task.disable",
f"CLI disabled Devii task {args.uid}",
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
target_type="task",
target_uid=args.uid,
target_label=row.get("label"),
)
print(f"Disabled task '{args.uid}'")
def cmd_devii_tasks_prune(args):
from devplacepy.services.devii.tasks.guards import automation_allowed
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
if TABLE not in db.tables:
print("No devii_tasks table exists")
return
pruned = 0
for row in list(db[TABLE].find(enabled=True, deleted_at=None)):
owner_kind = str(row.get("owner_kind") or "")
owner_id = str(row.get("owner_id") or "")
if automation_allowed(owner_kind, owner_id):
continue
store = TaskStore(db, owner_kind, owner_id)
store.update(
row["uid"],
{
"enabled": False,
"status": "disabled",
"next_run_at": None,
"last_error": "owner is not an administrator",
},
)
pruned += 1
_audit_cli(
"cli.devii.task.prune",
"CLI disabled tasks whose owner may not schedule",
metadata={"disabled": pruned},
)
print(f"Disabled {pruned} task(s) whose owner is not an administrator")
def register_devii(subparsers):
devii = subparsers.add_parser("devii", help="Devii assistant management")
devii_sub = devii.add_subparsers(title="action", dest="action")
@ -238,19 +138,3 @@ def register_devii(subparsers):
lessons_clear = lessons_sub.add_parser("clear", help="Hard-delete every devii_lessons row")
lessons_clear.add_argument("--force", action="store_true", help="Required to confirm hard deletion")
lessons_clear.set_defaults(func=cmd_devii_lessons_clear)
devii_tasks = devii_sub.add_parser("tasks", help="Inspect and stop scheduled Devii tasks")
tasks_sub = devii_tasks.add_subparsers(title="sub-action", dest="sub_action")
tasks_list = tasks_sub.add_parser("list", help="List scheduled tasks and their owners")
tasks_list.add_argument("--all", action="store_true", help="Include disabled tasks")
tasks_list.set_defaults(func=cmd_devii_tasks_list)
tasks_disable = tasks_sub.add_parser("disable", help="Disable one task by uid")
tasks_disable.add_argument("uid", help="Uid of the task")
tasks_disable.set_defaults(func=cmd_devii_tasks_disable)
tasks_prune = tasks_sub.add_parser(
"prune", help="Disable every task whose owner is not an administrator"
)
tasks_prune.set_defaults(func=cmd_devii_tasks_prune)

View File

@ -1,103 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_game_market_prune(args):
from devplacepy.services.game import store
removed = store.prune_ticks()
_audit_cli(
"cli.game.market.prune",
f"CLI pruned {removed} stale Code Farm market tick(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} stale market tick bucket(s)")
def cmd_game_steals_prune(args):
from devplacepy.services.game import store
removed = store.prune_steals()
_audit_cli(
"cli.game.steals.prune",
f"CLI pruned {removed} old Code Farm raid record(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} raid record(s)")
def cmd_game_era_status(args):
from devplacepy.services.game import store
era = store.active_era()
if not era:
print("No Era is currently running.")
return
print(f"Era {era['era_number']}: {era['name']}")
print(f"Started: {era['started_at']}")
print(f"Scheduled end: {era['ends_at']}")
def cmd_game_era_start(args):
from devplacepy.services.game import GameError, store
try:
era = store.start_era(args.name, args.duration_days)
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.start",
f"CLI started Code Farm Era {era['era_number']}: {era['name']}",
metadata={"era_number": era["era_number"], "name": era["name"]},
)
print(f"Started Era {era['era_number']}: {era['name']}")
def cmd_game_era_end(args):
from devplacepy.services.game import GameError, store
try:
result = store.end_era()
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.end",
f"CLI ended Code Farm Era {result['era_number']}",
metadata=result,
)
print(f"Ended Era {result['era_number']} ({result['participants']} participant(s) ranked)")
def register_game(subparsers):
game = subparsers.add_parser("game", help="Code Farm management")
game_sub = game.add_subparsers(title="action", dest="action")
market = game_sub.add_parser("market", help="Code Farm market saturation data")
market_sub = market.add_subparsers(title="market_action", dest="market_action")
market_prune = market_sub.add_parser(
"prune", help="Delete market tick buckets older than the tracking window"
)
market_prune.set_defaults(func=cmd_game_market_prune)
steals = game_sub.add_parser("steals", help="Code Farm raid history")
steals_sub = steals.add_subparsers(title="steals_action", dest="steals_action")
steals_prune = steals_sub.add_parser(
"prune", help="Delete raid records older than the raid-efficiency window"
)
steals_prune.set_defaults(func=cmd_game_steals_prune)
era = game_sub.add_parser("era", help="Code Farm Era management")
era_sub = era.add_subparsers(title="era_action", dest="era_action")
era_status = era_sub.add_parser("status", help="Show the current Era status")
era_status.set_defaults(func=cmd_game_era_status)
era_start = era_sub.add_parser("start", help="Start a new Era")
era_start.add_argument("name", help="Era name")
era_start.add_argument(
"--days", dest="duration_days", type=int, default=28, help="Planned Era length in days"
)
era_start.set_defaults(func=cmd_game_era_start)
era_end = era_sub.add_parser("end", help="End the currently running Era")
era_end.set_defaults(func=cmd_game_era_end)

View File

@ -1,145 +0,0 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_gateway_quota_list(args):
from devplacepy.services.openai_gateway import quota
rules = quota.quota_rule_store.list()
if not rules:
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
return
for rule in rules:
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
scope = ", ".join(
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
) or "(no dimensions - invalid)"
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
active = "active" if rule["is_active"] else "inactive"
label = f" - {rule['label']}" if rule["label"] else ""
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
def cmd_gateway_quota_set(args):
from pydantic import ValidationError
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaRuleIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
limit_usd=args.limit_usd,
is_active=not args.inactive,
label=args.label or "",
)
except ValidationError as exc:
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
sys.exit(1)
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
_audit_cli(
"gateway.quota_rule.update",
f"CLI saved gateway quota rule {saved['uid']}",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
},
target_type="gateway_quota_rule",
target_uid=saved["uid"],
)
print(f"Saved quota rule {saved['uid']}")
def cmd_gateway_quota_delete(args):
from devplacepy.services.openai_gateway import quota
if not quota.quota_rule_store.remove(args.uid):
print(f"Quota rule '{args.uid}' not found")
sys.exit(1)
_audit_cli(
"gateway.quota_rule.delete",
f"CLI deleted gateway quota rule {args.uid}",
target_type="gateway_quota_rule",
target_uid=args.uid,
)
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")
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
quota_list.set_defaults(func=cmd_gateway_quota_list)
quota_set = quota_sub.add_parser(
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
)
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
quota_set.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit for any role",
)
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
quota_set.add_argument(
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
)
quota_set.add_argument("--label", help="Optional admin-facing note")
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
quota_set.set_defaults(func=cmd_gateway_quota_set)
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

@ -7,12 +7,12 @@ from devplacepy.cli._shared import _audit_cli
def _remove_zip_artifacts(job):
import shutil
from pathlib import Path
from devplacepy.config import ZIP_STAGING_DIR
from devplacepy.services.jobs.zip_service import STAGING_DIR
local_path = (job.get("result") or {}).get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
shutil.rmtree(ZIP_STAGING_DIR / job["uid"], ignore_errors=True)
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
def cmd_zips_prune(args):
@ -251,6 +251,7 @@ def cmd_isslop_clear(args):
def cmd_isslop_analyze(args):
import asyncio
import json
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key

View File

@ -2,7 +2,6 @@
import argparse
import sys
from devplacepy.cli.accounts import register_accounts
from devplacepy.cli.roles import register_roles
from devplacepy.cli.apikeys import register_apikeys
from devplacepy.cli.tokens import register_tokens
@ -13,10 +12,6 @@ from devplacepy.cli.jobs import register_jobs
from devplacepy.cli.backups import register_backups
from devplacepy.cli.containers import register_containers
from devplacepy.cli.migrate import register_migrate
from devplacepy.cli.game import register_game
from devplacepy.cli.quiz import register_quiz
from devplacepy.cli.gateway import register_gateway
from devplacepy.cli.messaging import register_messaging
def build_parser():
@ -33,11 +28,6 @@ def build_parser():
register_backups(sub)
register_containers(sub)
register_migrate(sub)
register_game(sub)
register_quiz(sub)
register_gateway(sub)
register_messaging(sub)
register_accounts(sub)
return parser

View File

@ -1,29 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_messaging_prune_tickets(args):
from datetime import datetime, timezone
from devplacepy.database import get_table
now = datetime.now(timezone.utc).isoformat()
tickets = get_table("ws_tickets")
expired = list(tickets.find(expires_at={"<": now}))
for ticket in expired:
tickets.delete(uid=ticket["uid"])
_audit_cli(
"cli.messaging.prune_tickets",
f"CLI pruned {len(expired)} expired WS tickets",
metadata={"count": len(expired)},
)
print(f"Pruned {len(expired)} expired WS ticket(s)")
def register_messaging(subparsers):
messaging = subparsers.add_parser("messaging", help="Messaging WS ticket management")
messaging_sub = messaging.add_subparsers(title="action", dest="action")
messaging_prune_tickets = messaging_sub.add_parser(
"prune-tickets", help="Delete expired WebSocket auth tickets"
)
messaging_prune_tickets.set_defaults(func=cmd_messaging_prune_tickets)

View File

@ -1,31 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_quiz_prune(args):
from datetime import datetime, timedelta, timezone
from devplacepy.config import QUIZ_ATTEMPT_RETENTION_DAYS
from devplacepy.services.quiz import store
cutoff = (
datetime.now(timezone.utc) - timedelta(days=QUIZ_ATTEMPT_RETENTION_DAYS)
).isoformat()
removed = store.prune_attempts(cutoff)
_audit_cli(
"cli.quiz.prune",
f"CLI pruned {removed} abandoned quiz attempt(s)",
metadata={"count": removed, "retention_days": QUIZ_ATTEMPT_RETENTION_DAYS},
)
print(f"Pruned {removed} abandoned or expired quiz attempt(s)")
def register_quiz(subparsers):
quiz = subparsers.add_parser("quiz", help="Quiz management")
quiz_sub = quiz.add_subparsers(title="action", dest="action")
prune = quiz_sub.add_parser(
"prune",
help="Delete abandoned and expired attempts older than the retention window",
)
prune.set_defaults(func=cmd_quiz_prune)

View File

@ -16,7 +16,6 @@ UPLOADS_DIR = DATA_DIR / "uploads"
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
PROJECT_FILES_DIR = UPLOADS_DIR / "project_files"
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
WORKSPACE_STATE_DIR = DATA_DIR / "workspace_state"
ZIPS_DIR = DATA_DIR / "zips"
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
@ -51,7 +50,6 @@ SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
PRESENCE_WRITE_SECONDS = max(1, PRESENCE_TIMEOUT_SECONDS // 2)
PRESENCE_ONLINE_LIMIT = int(environ.get("DEVPLACE_PRESENCE_ONLINE_LIMIT", "30"))
PRESENCE_TRACK_LIMIT = int(environ.get("DEVPLACE_PRESENCE_TRACK_LIMIT", "500"))
PRESENCE_ONLINE_MARGIN_SECONDS = int(
environ.get("DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS", "20")
)
@ -85,18 +83,6 @@ AWARD_IMAGE_PROMPT_DEFAULT = (
"plate, no text labels rendered in the image. Center one stylized trophy/medal "
"icon that visually matches this message:"
)
QUIZ_ANSWER_MAX_CHARS = 2000
QUIZ_FEEDBACK_MAX_CHARS = 400
QUIZ_MAX_QUESTIONS = 100
QUIZ_MAX_OPTIONS = 12
QUIZ_MAX_TIME_LIMIT_SECONDS = 86400
QUIZ_AI_CORRECT_THRESHOLD = 0.5
QUIZ_GRADING_TIMEOUT_SECONDS = 45.0
QUIZ_ATTEMPT_RETENTION_DAYS = 90
QUIZ_SCOREBOARD_LIMIT = 20
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
QUIZ_LIST_PER_PAGE = 20
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
DEFAULT_MODIFIER_PROMPT = (
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
@ -108,12 +94,6 @@ INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
WORKSPACE_TUNNEL_DOMAIN = environ.get(
"DEVPLACE_WORKSPACE_TUNNEL_DOMAIN", "tunnel.pravda.education"
).strip()
WORKSPACE_ACTIVITY_WRITE_SECONDS = 30
WORKSPACE_METRICS_RING = 720
VAPID_PRIVATE_KEY_FILE = KEYS_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
@ -125,7 +105,6 @@ DATA_PATHS: dict[str, Path] = {
"attachments": ATTACHMENTS_DIR,
"project_files": PROJECT_FILES_DIR,
"container_workspaces": CONTAINER_WORKSPACES_DIR,
"workspace_state": WORKSPACE_STATE_DIR,
"zips": ZIPS_DIR,
"zip_staging": ZIP_STAGING_DIR,
"fork_staging": FORK_STAGING_DIR,

View File

@ -13,8 +13,6 @@ 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,
@ -31,11 +29,6 @@ from devplacepy.database import (
soft_delete_engagement,
soft_delete_fork_relations,
load_comments,
band_allows_mature,
band_allows_restricted,
get_maturity,
get_maturity_by_targets,
get_int_setting,
_now_iso,
db,
)
@ -56,63 +49,19 @@ from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
from devplacepy.services.moderation.screening import (
record as record_screening,
refuse_if_blocked,
screen_fields,
)
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news", "quiz"}
REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
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"])
def mature_hidden_by_default() -> bool:
return get_int_setting("moderation_mature_default_hidden", 1) != 0
def maturity_hidden(level: str | None, user: dict | None) -> bool:
if not level or level == "general":
return False
if not mature_hidden_by_default():
return False
if not user:
return True
band = user.get("age_band") or "adult"
allowed = (
band_allows_restricted(band) if level == "restricted" else band_allows_mature(band)
)
return not (allowed and bool(user.get("mature_opt_in")))
def is_suspended(user: dict | None) -> bool:
from devplacepy.database import suspension_active
return suspension_active(user)
def _owner_is_admin(project: dict) -> bool:
owner_uid = project.get("user_uid")
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
@ -170,45 +119,6 @@ def can_manage_instance(
return is_primary_admin(user) or owns_instance(instance, project, user)
def workspaces_enabled() -> bool:
from devplacepy.database import get_setting
return get_setting("workspace_enabled", "0") == "1"
def can_open_workspace(project: dict | None, user: dict | None) -> bool:
if not project or not user or not user.get("uid"):
return False
if not workspaces_enabled():
return False
return is_owner(project, user) or is_admin(user)
def owns_workspace(instance: dict | None, user: dict | None) -> bool:
if not instance or not user:
return False
uid = user.get("uid")
if not uid:
return False
return instance.get("workspace_owner_uid") == uid
def can_manage_workspace(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not user:
return False
if owns_workspace(instance, user):
return True
return can_manage_instance(instance, project, user)
def can_manage_tunnel(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
return can_manage_workspace(instance, project, user)
def canonical_redirect(
area: str, item: dict, requested: str
) -> RedirectResponse | None:
@ -240,8 +150,6 @@ def create_content_item(
attachment_uids: list | None,
request=None,
) -> tuple[str, str]:
screening = screen_fields(table_name, fields)
refuse_if_blocked(screening)
uid = generate_uid()
slug = make_combined_slug(slug_source, uid)
get_table(table_name).insert(
@ -287,20 +195,13 @@ def create_content_item(
metadata=metadata or None,
links=links,
)
record_screening(
screening,
target_type=target_type,
target_uid=uid,
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, table_name, uid, request)
schedule_modification(user, table_name, uid, request)
schedule_seo_meta_for_table(table_name, uid)
return uid, slug
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project", "quiz"}
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
@ -410,8 +311,6 @@ def create_comment_record(
parent_uid: str | None = None,
attachment_uids: list | None = None,
) -> tuple[str, str]:
screening = screen_fields("comments", {"content": content})
refuse_if_blocked(screening)
comment_uid = generate_uid()
redirect_url = resolve_object_url(target_type, target_uid)
insert = {
@ -462,13 +361,6 @@ def create_comment_record(
)
create_mention_notifications(content, user["uid"], comment_url)
record_screening(
screening,
target_type="comment",
target_uid=comment_uid,
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, "comments", comment_uid, request)
schedule_modification(user, "comments", comment_uid, request)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
@ -493,19 +385,10 @@ def create_comment_record(
def edit_comment_record(request, user: dict, comment: dict, content: str) -> str:
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
screening = screen_fields("comments", {"content": content})
refuse_if_blocked(screening)
updated_at = datetime.now(timezone.utc).isoformat()
get_table("comments").update(
{"uid": comment["uid"], "content": content, "updated_at": updated_at}, ["uid"]
)
record_screening(
screening,
target_type="comment",
target_uid=comment["uid"],
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, "comments", comment["uid"], request)
schedule_modification(user, "comments", comment["uid"], request)
logger.info(f"Comment {comment['uid']} edited by {user['username']}")
@ -627,8 +510,6 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
"project_link": detail.get("project_link"),
"maturity": detail.get("maturity", "general"),
}
if extra:
context.update(extra)
@ -663,20 +544,11 @@ def edit_content_item(
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(url=redirect_fail, status_code=302)
screening = screen_fields(table_name, update_fields)
refuse_if_blocked(screening)
update_fields = {
**update_fields,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
table.update({"uid": item["uid"], **update_fields}, ["uid"])
record_screening(
screening,
target_type=kind,
target_uid=item["uid"],
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, table_name, item["uid"], request)
schedule_modification(user, table_name, item["uid"], request)
schedule_seo_meta_for_table(table_name, item["uid"], regenerate=True)
@ -749,11 +621,6 @@ def delete_content_item(
soft_delete_engagement("comment", comment_uids, actor)
if target_type == "post":
clear_user_post_count(item["user_uid"])
if target_type == "quiz":
from devplacepy.services.quiz.store import cascade_questions, clear_cache
cascade_questions(item["uid"], actor, stamp)
clear_cache()
if target_type == "project":
from devplacepy.project_files import soft_delete_all_project_files
from devplacepy.templating import clear_user_projects_cache
@ -817,8 +684,6 @@ 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,
"maturity": get_maturity(target_type, item["uid"])["level"],
}
@ -834,7 +699,6 @@ def enrich_items(
user_votes = (
get_user_votes(user["uid"], [item["uid"] for item in items]) if user else {}
)
maturity = get_maturity_by_targets(key, [item["uid"] for item in items])
enriched = []
for item in items:
entry = {
@ -842,38 +706,10 @@ def enrich_items(
"author": authors.get(item["user_uid"]),
"time_ago": time_ago(item[ts_field]),
"my_vote": user_votes.get(item["uid"], 0),
"maturity": maturity.get(item["uid"], {}).get("level", "general"),
}
for name, source in extra_maps.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 count_project_devlog(project_uid: str) -> int:
return get_table("posts").count(project_uid=project_uid, deleted_at=None)
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

@ -12,9 +12,7 @@ PRAGMA cache_size=-8000; -- 8MB page cache
PRAGMA temp_store=MEMORY; -- temp tables in memory
```
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}, "poolclass": NullPool}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap.
**`poolclass=NullPool` is load-bearing - never revert it to SQLAlchemy's default `QueuePool` (caused a production outage).** `dataset.Database.executable` caches ONE DBAPI connection per OS thread ID **forever** and never returns it to the pool except via `db.close()`, which nothing in this codebase calls (`dataset/database.py`: `self.connections[tid] = self.engine.connect()`). That is fine as long as the same handful of threads ever touch the DB - but FastAPI runs every sync route dependency (`get_setting` and friends, hit on nearly every request) through `anyio.to_thread.run_sync`, whose worker pool scales up and recycles threads elastically under load, and container sync (`asyncio.to_thread`) adds more. Each new thread's first query permanently claims one pool slot. With the default bounded `QueuePool` (`pool_size=5, max_overflow=10` = 15 total), a burst of concurrent load creates enough new threads that the pool fills for good within minutes, and every request thereafter - including the Docker healthcheck's own probe - blocks the full 30s pool timeout and then raises `sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached`, wedging the whole app (nginx waits on an app that is waiting on itself; every external caller sees a bare connection timeout, not an HTTP error). `NullPool` removes the artificial ceiling: each `engine.connect()` opens a real, unpooled SQLite connection, so the existing "one connection cached per thread forever" behavior just works, exactly as WAL mode is designed to support. Never pass `pool_size`/`max_overflow` alongside `NullPool` (SQLAlchemy rejects them). Do not "fix" the underlying thread churn instead - that means touching the sync-dependency/threadpool model, which the hard rule below forbids.
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap.
`init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` (through the `_index()` helper, wrapped in try/except so it is safe to run on every startup regardless of table state), and seed defaults for `site_settings` only insert when the key doesn't already exist.
@ -30,8 +28,6 @@ _index(db, "news", "idx_news_status", ["status"]) # now safe - column exists
Do this with `get_table(name)` (NOT `if name in db.tables`): `get_table` + `create_column_by_example` creates the table if it is absent, so the schema is guaranteed regardless of which process or which insert touches the table first. Symptom to watch for: a page renders fine in isolation but shows empty data or 500s only after a CLI/maintenance test runs earlier in the suite (it created the table partially).
**This applies to `users` too, and an `if "users" not in db.tables: return` guard silently defeats it.** `backfill_api_keys()` is the `users` ensure-block (every non-signup column: `api_key`, `last_seen`, the AI correction/modifier settings, `avatar_seed`, `award_count`/`last_award_at`/`last_award_slug`/`last_award_uid`, ...). It used to early-return when `users` was absent, which is exactly the state on a **brand-new database**: `init_db()` runs before the first signup, so the ensure-block was skipped entirely, and the first `/auth/signup` then created `users` with only the columns of that INSERT. Every ensured-but-unwritten column was therefore missing from the long-running server's reflected metadata, so `users.find_one(...)` returned rows without them **for the whole process lifetime** - the feature reading them looked simply switched off (this is what made the awards tab, the prominent-award banner, and the avatar award badge invisible on a fresh DB, and it is invisible in production only because the columns happen to exist from an older boot). It now calls `get_table("users")` unconditionally, so the table is born with the full ensured column set. Never reintroduce a `db.tables` guard in front of a column-ensure block.
The `_index(...)` helper supports `where=` (partial) and `unique=` indexes; every table with a `uid` column gets a UNIQUE `idx_<table>_uid`, soft-delete tables get a PARTIAL `idx_<table>_trash` (`WHERE deleted_at IS NOT NULL`) and NEVER a bare `deleted_at` index (it mis-steers the planner on live reads), and "live newest-first" listings need a composite/live-partial index that carries the sort column (see "Indexing conventions" below). `init_db()` finishes with `ANALYZE`/`PRAGMA optimize`. Verify any index change with `EXPLAIN QUERY PLAN`.
**SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting.
@ -109,17 +105,6 @@ 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`.
@ -148,23 +133,6 @@ The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginat
- **Devii:** `list_media` (public) and `delete_media` (auth, in `CONFIRM_REQUIRED`) in the catalog.
- **Docs visibility (deliberate):** members and guests must never be told this is a *soft* delete. The public prose page `docs/media-gallery.html` (General) and the member-facing `media-delete` API endpoint (Profiles group) describe deletion as a plain "remove" - no soft-delete, restore, trash, or purge language. All moderation mechanics live on the admin-only `docs/media-moderation` prose page (`admin: True`) and in the admin API group (`media-restore`, `admin-media`, `admin-media-purge`, all `auth="admin"`), which `docs_search` excludes from member results and `routers/docs/` package 404s for non-admins. Because `docs_search._strip` keeps the text *inside* `{% if %}` blocks, admin content must live on a separate `admin: True` page, never inline-gated on a public page (a public page may only carry an admin-gated *link*). The member `MediaItemOut` schema omits `deleted_at`; the admin-only `AdminMediaItemOut` adds it.
## Moderation, consent and maturity tables (`database/moderation.py`)
Four soft-deletable tables carry the trust-and-safety layer; the full subsystem is documented in `devplacepy/services/moderation/CLAUDE.md`.
| Table | Shape | Notes |
|---|---|---|
| `content_reports` | `(target_type, target_uid)` + `reporter_uid` + `owner_uid` | The one queue. `owner_uid` is denormalised at insert so the admin list never N+1s. Indexes `(status, created_at)` for the queue and SLA scan, `(target_type, target_uid)` for duplicate detection, `(reporter_uid)`, `(owner_uid)` |
| `moderation_actions` | one row per moderator decision, linked to its report | Queryable moderation state with its own lifecycle - deliberately separate from the append-only audit log, the same way `workspace_flags` is |
| `content_maturity` | `(target_type, target_uid)` -> `level` | Polymorphic age label. Read through the batch helper `get_maturity_by_targets`, never per row. **Absence of a row means `general`**, so nothing needed backfilling |
| `user_consents` | `(owner_kind, owner_id, kind)` | Append-only in effect: withdrawing stamps `withdrawn_at` on the current row and inserts a new one, so the history is provable |
`REPORTABLE_TARGETS` (target type -> table) is the registry every consumer reads, exactly like `VOTABLE_TARGETS`. `UNREPORTABLE_TABLES` is its explicit counterpart: each entry names a soft-deletable table and **why** it carries no reportable content. `tests/unit/database/moderation.py` asserts the two partition `SOFT_DELETE_TABLES`, so a new user-generated table cannot be added without classifying it.
The `users` columns added alongside are ensured in `backfill_api_keys()` like every other non-signup column: `terms_version`, `terms_accepted_at`, `age_band`, `age_declared_at`, `mature_opt_in`, `suspended_until`, `suspension_reason`, `deletion_requested_at`. `mature_opt_in` is normalised from NULL to `0` in the same `with db:` block that fixes the AI-modifier defaults, because it is read as a flag. **No date of birth is ever stored** - only the derived `age_band`.
Two atomic conditional updates protect this data and must never become read-then-write: `queue.claim_open` (report resolution) and `deletion.claim_deletion` (the account-deletion cascade). The latter's precondition is `COALESCE(deletion_requested_at, '') = ''` because the column is SQL `NULL` on rows that predate it - the exact `NULL = 0` trap recorded above.
## Role-based visibility (generic + DRY)
- **One source of truth for role/visibility checks**, registered as Jinja globals in `templating.py` - never hand-roll `user.get('role') == 'Admin'` or `user['uid'] == x['user_uid']` in a template again:
@ -172,7 +140,6 @@ Two atomic conditional updates protect this data and must never become read-then
- `owns(item, user)` (= `content.is_owner`) - per-item ownership (e.g. each comment). Page-level detail templates keep using the `is_owner` **bool** passed in their context (post/gist/project/profile); do not call `is_owner(...)` as a function - that name is a context bool and shadows globals.
- `is_self(user, uid)` - "is this me" (profile follow vs edit, leaderboard highlight).
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
- **Account enabled/disabled is `database.is_account_active(row)` - never read `is_active` inline.** `is_active` is a nullable column, so a row written before it existed (or by any insert that omits it) holds SQL `NULL`, and the obvious `bool(row.get("is_active"))` reads that as *disabled*. `.get("is_active", True)` is no better: the default only applies when the key is **absent**, and a `SELECT *` row always has the key with value `None`. The predicate is `is_active is None or bool(is_active)` - unknown means active, only an explicit `0`/`False` disables. This is the same NULL-vs-0 trap as the `COALESCE` rule for atomic updates. It gates session auth, API-key auth, Basic auth, the login and access-token routes, the devRant token/auth paths, the admin enable/disable toggle, and `_can_hold_primary_admin`; every one of them goes through this single function. A NULL row previously could not log in at all, and the admin toggle could not disable it. (The `is_active` on a `gateway_models` row is a different table with its own semantics and is deliberately not routed through this.)
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
- **Policy enforced everywhere:** guests see all non-admin content read-only with action controls **shown but disabled** (`guest_disabled` on vote/star/react/poll/bookmark/follow/comment submit; create FABs become `/auth/login` links via `.feed-fab.login-required`); members get full member actions; **role badges render only to admin viewers** (`{% if is_admin(user) %}` around every `*.role` label). Backend stays the real gate (`require_user`/`require_admin`).
@ -211,15 +178,6 @@ Site settings are seeded on startup (`site_settings` table):
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
| `customization_enabled` | `"1"` | When `"0"`, `custom_css_tag`/`custom_js_tag` inject nothing (feature off) |
| `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed |
| `moderation_sla_hours` | `"24"` | The published moderation response window; the admin queue badge turns red past it |
| `moderation_filter_mode` | `"review"` | `off`/`label`/`review`/`block` - how the content filter acts on a match |
| `moderation_filter_review_score` | `"2"` | Rule score at which a match becomes a report rather than a label |
| `moderation_minimum_age` | `"16"` | Signup floor; only the derived age band is stored |
| `moderation_mature_default_hidden` | `"1"` | Hide mature-labelled content behind an interstitial by default |
| `account_deletion_grace_hours` | `"24"` | Reversible window before a deleted account is purged |
| `contact_email` / `contact_phone` / `contact_address` | `""` | Published contact details, rendered on `/docs/contact.html` |
| `terms_version` / `privacy_version` / `guidelines_version` | `"1"` | Bumping `terms_version` forces re-acceptance before the next write. **Every reader uses `get_setting(key, "1") or "1"`** - an empty stored value must read as the default or the gate 403s every write |
| `ai_third_party_provider` | `""` | Named in the consent copy and the privacy policy |
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).

View File

@ -2,9 +2,8 @@
from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta, timezone, TTLCache, DATABASE_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, ensure_data_dirs, logger, db
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
from .atomic import conditional_update_row
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, is_account_active, search_users_by_username
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, search_users_by_username
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
@ -36,46 +35,9 @@ from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_r
from .follows import get_follow_counts, get_follow_list, get_following_among
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
from .moderation import (
ACTIONS_TABLE,
ADULT_AGE,
AGE_BANDS,
CONSENTS_TABLE,
CONSENT_KINDS,
CONSENT_STATES,
MATURITY_LEVELS,
MATURITY_SOURCES,
MATURITY_TABLE,
MATURITY_TARGETS,
MODERATION_ACTIONS,
MODERATION_TABLES,
REPORTABLE_TARGETS,
REPORTS_TABLE,
REPORT_OPEN_STATUSES,
REPORT_ORIGINS,
REPORT_REASONS,
REPORT_SEVERITIES,
REPORT_STATUSES,
SYSTEM_ACTOR,
UNREPORTABLE_TABLES,
age_band_for,
band_allows_mature,
band_allows_restricted,
consent_granted,
consent_state,
get_maturity,
get_maturity_by_targets,
list_consents,
minimum_age,
report_reason_options,
set_consent,
set_maturity,
suspension_active,
years_between,
)
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_deleted_media
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
@ -110,7 +72,6 @@ __all__ = [
"get_table",
"_in_clause",
"_now_iso",
"conditional_update_row",
"_settings_cache",
"get_setting",
"get_int_setting",
@ -125,7 +86,6 @@ __all__ = [
"set_last_seen",
"get_online_users",
"get_primary_admin_uid",
"is_account_active",
"search_users_by_username",
"_relations_cache",
"get_user_relations",
@ -254,40 +214,6 @@ __all__ = [
"soft_delete_engagement",
"delete_engagement",
"get_target_owner_uid",
"ACTIONS_TABLE",
"ADULT_AGE",
"AGE_BANDS",
"CONSENTS_TABLE",
"CONSENT_KINDS",
"CONSENT_STATES",
"MATURITY_LEVELS",
"MATURITY_SOURCES",
"MATURITY_TABLE",
"MATURITY_TARGETS",
"MODERATION_ACTIONS",
"MODERATION_TABLES",
"REPORTABLE_TARGETS",
"REPORTS_TABLE",
"REPORT_OPEN_STATUSES",
"REPORT_ORIGINS",
"REPORT_REASONS",
"REPORT_SEVERITIES",
"REPORT_STATUSES",
"SYSTEM_ACTOR",
"UNREPORTABLE_TABLES",
"age_band_for",
"band_allows_mature",
"band_allows_restricted",
"consent_granted",
"consent_state",
"get_maturity",
"get_maturity_by_targets",
"list_consents",
"report_reason_options",
"set_consent",
"set_maturity",
"suspension_active",
"years_between",
"_drop_blocked",
"_build_comment_items",
"load_comments",
@ -308,8 +234,6 @@ __all__ = [
"delete_attachments",
"_delete_attachment_file",
"get_user_media",
"get_user_attachments",
"get_user_attachment",
"get_deleted_media",
"_stats_cache",
"get_site_stats",
@ -326,5 +250,3 @@ __all__ = [
"backfill_api_keys",
"_backfill_gamification",
]

View File

@ -1,26 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import text
from .core import db
def conditional_update_row(
table_name: str, row_uid: str, set_clause: str, where_clause: str, params: dict
) -> int:
sql = (
f"UPDATE {table_name} SET {set_clause}, updated_at = :updated_at "
f"WHERE uid = :row_uid AND ({where_clause})"
)
bind = {
**params,
"updated_at": datetime.now(timezone.utc).isoformat(),
"row_uid": row_uid,
}
with db:
result = db.executable.execute(text(sql), bind)
return result.rowcount

View File

@ -124,53 +124,6 @@ def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
return items, pagination
def _decorate_attachment(row: dict) -> dict:
from devplacepy.attachments import _row_to_attachment
item = _row_to_attachment(row)
item["linked"] = bool(item.get("target_type"))
item["target_url"] = (
resolve_object_url(item["target_type"], item["target_uid"])
if item["linked"]
else None
)
return item
def get_user_attachments(
user_uid: str, page: int = 1, per_page: int = 24, linked=None
) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
clause = "user_uid=:u AND deleted_at IS NULL"
if linked is True:
clause += " AND target_type != ''"
elif linked is False:
clause += " AND target_type = ''"
total = list(
db.query(f"SELECT COUNT(*) AS n FROM attachments WHERE {clause}", u=user_uid)
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
f"SELECT * FROM attachments WHERE {clause} "
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
u=user_uid,
limit=pagination["per_page"],
offset=offset,
)
return [_decorate_attachment(row) for row in rows], pagination
def get_user_attachment(uid: str) -> dict | None:
if "attachments" not in db.tables:
return None
row = db["attachments"].find_one(uid=uid, deleted_at=None)
if not row:
return None
return _decorate_attachment(row)
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)

View File

@ -38,9 +38,6 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
if target_type == "gist":
gist = resolve_by_slug(get_table("gists"), target_uid)
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
if target_type == "quiz":
quiz = resolve_by_slug(get_table("quizzes"), target_uid)
return f"/quizzes/{quiz['slug'] or quiz['uid']}" if quiz else "/quizzes"
if target_type == "comment":
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
if not comment:
@ -56,44 +53,6 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
if receiver:
return f"/profile/{receiver['username']}?tab=awards#award-{award.get('slug', '')}"
return "/feed"
if target_type == "user":
person = get_table("users").find_one(uid=target_uid)
return f"/profile/{person['username']}" if person else "/feed"
if target_type == "project_file":
node = get_table("project_files").find_one(uid=target_uid)
if not node:
return "/projects"
project = get_table("projects").find_one(uid=node.get("project_uid", ""))
if not project:
return "/projects"
slug = project.get("slug") or project["uid"]
return f"/projects/{slug}/files?path={node.get('path', '')}"
if target_type == "attachment":
attachment = get_table("attachments").find_one(uid=target_uid)
if not attachment:
return "/feed"
parent_type = attachment.get("target_type") or ""
parent_uid = attachment.get("target_uid") or ""
if parent_type and parent_uid:
return resolve_object_url(parent_type, parent_uid)
owner = get_table("users").find_one(uid=attachment.get("user_uid", ""))
return f"/profile/{owner['username']}?tab=media" if owner else "/feed"
if target_type == "message":
message = get_table("messages").find_one(uid=target_uid)
if not message:
return "/messages"
return f"/messages?with_uid={message.get('sender_uid', '')}"
if target_type == "poll":
poll = get_table("polls").find_one(uid=target_uid)
if not poll:
return "/feed"
return resolve_object_url("post", poll.get("post_uid", ""))
if target_type == "workspace":
instance = get_table("instances").find_one(uid=target_uid)
return f"/admin/containers/{instance['uid']}" if instance else "/admin/containers"
if target_type == "devii_output":
return "/devii"
return "/feed"

View File

@ -4,7 +4,6 @@ import dataset
import logging
from pathlib import Path
from sqlalchemy import or_
from sqlalchemy.pool import NullPool
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
@ -31,7 +30,6 @@ db = dataset.connect(
"timeout": 30,
"check_same_thread": False,
},
"poolclass": NullPool,
},
on_connect_statements=[
"PRAGMA journal_mode=WAL",

View File

@ -185,5 +185,3 @@ 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

@ -1,332 +0,0 @@
# retoor <retoor@molodetz.nl>
from datetime import date, datetime, timezone
from .core import _in_clause, _now_iso, db, get_table
from .settings import get_int_setting
REPORTABLE_TARGETS: dict[str, str] = {
"post": "posts",
"comment": "comments",
"gist": "gists",
"project": "projects",
"project_file": "project_files",
"news": "news",
"attachment": "attachments",
"message": "messages",
"quiz": "quizzes",
"poll": "polls",
"award": "awards",
"user": "users",
"issue": "issue_tickets",
"workspace": "instances",
"devii_output": "devii_conversations",
}
MATURITY_TARGETS: set[str] = {
"post",
"comment",
"gist",
"project",
"news",
"attachment",
"quiz",
}
UNREPORTABLE_TABLES: dict[str, str] = {
"news_images": "child rows of a reportable news article",
"poll_options": "child rows of a reportable poll",
"quiz_questions": "child rows of a reportable quiz",
"quiz_options": "child rows of a reportable quiz",
"tunnels": "child rows of a reportable workspace instance",
"issue_comment_authors": "authorship index for reportable issue comments",
"votes": "engagement counters, carry no authored content",
"reactions": "engagement counters, carry no authored content",
"bookmarks": "private to the owner",
"follows": "relationship rows, carry no authored content",
"poll_votes": "private ballots",
"quiz_attempts": "private to the participant",
"quiz_answers": "private to the participant",
"sessions": "authentication state",
"access_tokens": "authentication state",
"devrant_tokens": "authentication state",
"user_relations": "private block and mute lists",
"notification_preferences": "private to the owner",
"user_customizations": "runs only in the owner's own browser",
"devii_tasks": "private to the owner",
"devii_lessons": "private to the owner",
"devii_virtual_tools": "private to the owner",
"deepsearch_sessions": "private to the owner",
"deepsearch_messages": "private to the owner",
"isslop_analyses": "generated from a public URL, not authored content",
"email_accounts": "private mailbox credentials",
"instance_schedules": "child rows of a reportable workspace instance",
"workspace_flags": "moderation records, not authored content",
"workspace_quota_rules": "administrator-set limits, not authored content",
"workspace_editor_prefs": (
"private per-user editor configuration, never shown to another member"
),
"content_reports": "moderation records, readable only by the reporter and moderators",
"moderation_actions": "moderation records, not authored content",
"content_maturity": "moderation labels, not authored content",
"user_consents": "private consent history of the account holder",
"backup_schedules": "operator configuration",
"project_forks": "lineage index for reportable projects",
"seo_metadata": "generated metadata for reportable content",
}
REPORT_REASONS: dict[str, str] = {
"hate": "Hate speech or discriminatory content",
"violence": "Realistic violence or threats",
"weapons": "Weapons or dangerous instructions",
"sexual": "Sexual or pornographic content",
"religious": "Content targeting religion or belief",
"misinformation": "False or misleading information",
"exploitative": "Content exploiting a person",
"harassment": "Harassment or bullying",
"spam": "Spam or unwanted promotion",
"intellectual_property": "Copyright or trademark infringement",
"self_harm": "Self-harm or suicide",
"illegal": "Illegal activity",
"other": "Something else",
}
def report_reason_options() -> list[dict[str, str]]:
return [{"key": key, "label": label} for key, label in REPORT_REASONS.items()]
REPORT_STATUSES: tuple[str, ...] = ("open", "acknowledged", "actioned", "dismissed")
REPORT_OPEN_STATUSES: tuple[str, ...] = ("open", "acknowledged")
REPORT_SEVERITIES: tuple[str, ...] = ("info", "warn", "critical")
REPORT_ORIGINS: tuple[str, ...] = ("member", "filter")
MODERATION_ACTIONS: tuple[str, ...] = (
"remove_content",
"restore_content",
"warn",
"suspend",
"ban",
"lift",
"dismiss",
"escalate",
)
MATURITY_LEVELS: tuple[str, ...] = ("general", "mature", "restricted")
MATURITY_SOURCES: tuple[str, ...] = ("author", "filter", "moderator")
CONSENT_KINDS: dict[str, str] = {
"terms": "Terms of Service and Community Guidelines",
"privacy": "Privacy Policy",
"ai_third_party": "Processing of your content by a third-party AI provider",
"activity_recording": "Recording of your presence and session activity",
"container_credentials": (
"Sharing your DevPlace credentials with software another member runs "
"in a container"
),
}
CONSENT_STATES: tuple[str, ...] = ("granted", "withdrawn")
AGE_BANDS: tuple[str, ...] = ("under_min", "13_15", "16_17", "adult")
ADULT_AGE = 18
TEEN_AGE = 16
YOUNG_TEEN_AGE = 13
MINIMUM_AGE_FLOOR = YOUNG_TEEN_AGE
DEFAULT_MINIMUM_AGE = TEEN_AGE
SYSTEM_ACTOR = "system"
REPORTS_TABLE = "content_reports"
ACTIONS_TABLE = "moderation_actions"
MATURITY_TABLE = "content_maturity"
CONSENTS_TABLE = "user_consents"
MODERATION_TABLES: tuple[str, ...] = (
REPORTS_TABLE,
ACTIONS_TABLE,
MATURITY_TABLE,
CONSENTS_TABLE,
)
def years_between(born: date, today: date) -> int:
years = today.year - born.year
if (today.month, today.day) < (born.month, born.day):
years -= 1
return years
def minimum_age() -> int:
return max(
MINIMUM_AGE_FLOOR,
get_int_setting("moderation_minimum_age", DEFAULT_MINIMUM_AGE),
)
def age_band_for(age: int) -> str:
if age >= ADULT_AGE:
return "adult"
if age >= TEEN_AGE:
return "16_17"
if age >= YOUNG_TEEN_AGE:
return "13_15"
return "under_min"
def band_allows_mature(band: str) -> bool:
return band == "adult"
def band_allows_restricted(band: str) -> bool:
return band == "adult"
def get_maturity_by_targets(target_type: str, uids: list[str]) -> dict[str, dict]:
uids = [uid for uid in (uids or []) if uid]
if not uids or MATURITY_TABLE not in db.tables:
return {}
placeholders, params = _in_clause(uids)
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid, level, source FROM {MATURITY_TABLE} "
f"WHERE target_type = :tt AND target_uid IN ({placeholders}) "
f"AND deleted_at IS NULL",
**params,
)
return {
row["target_uid"]: {"level": row["level"], "source": row["source"]}
for row in rows
}
def get_maturity(target_type: str, target_uid: str) -> dict:
found = get_maturity_by_targets(target_type, [target_uid])
return found.get(target_uid, {"level": "general", "source": ""})
def set_maturity(
target_type: str, target_uid: str, level: str, source: str, set_by: str
) -> dict | None:
if target_type not in MATURITY_TARGETS or level not in MATURITY_LEVELS:
return None
from devplacepy.utils import generate_uid
table = get_table(MATURITY_TABLE)
existing = table.find_one(target_type=target_type, target_uid=target_uid)
now = _now_iso()
if existing:
table.update(
{
"id": existing["id"],
"level": level,
"source": source,
"set_by": set_by,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
},
["id"],
)
return table.find_one(id=existing["id"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"target_type": target_type,
"target_uid": target_uid,
"level": level,
"source": source,
"set_by": set_by,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def list_consents(owner_kind: str, owner_id: str) -> list[dict]:
if not owner_id or CONSENTS_TABLE not in db.tables:
return []
return list(
get_table(CONSENTS_TABLE).find(
owner_kind=owner_kind,
owner_id=owner_id,
deleted_at=None,
order_by=["-created_at"],
)
)
def consent_state(owner_kind: str, owner_id: str, kind: str) -> dict | None:
if not owner_id or CONSENTS_TABLE not in db.tables:
return None
rows = list(
get_table(CONSENTS_TABLE).find(
owner_kind=owner_kind,
owner_id=owner_id,
kind=kind,
deleted_at=None,
order_by=["-created_at", "-id"],
_limit=1,
)
)
return rows[0] if rows else None
def consent_granted(owner_kind: str, owner_id: str, kind: str) -> bool:
row = consent_state(owner_kind, owner_id, kind)
return bool(row and row.get("state") == "granted")
def set_consent(
owner_kind: str, owner_id: str, kind: str, granted: bool, version: str = "1"
) -> dict | None:
if kind not in CONSENT_KINDS or not owner_id:
return None
from devplacepy.utils import generate_uid
table = get_table(CONSENTS_TABLE)
now = _now_iso()
current = consent_state(owner_kind, owner_id, kind)
if current and not granted and current.get("state") == "granted":
table.update({"id": current["id"], "withdrawn_at": now}, ["id"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"owner_kind": owner_kind,
"owner_id": owner_id,
"kind": kind,
"version": version,
"state": "granted" if granted else "withdrawn",
"granted_at": now if granted else "",
"withdrawn_at": "" if granted else now,
"created_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def suspension_active(user: dict | None) -> bool:
if not user:
return False
until = (user.get("suspended_until") or "").strip()
if not until:
return False
try:
expiry = datetime.fromisoformat(until)
except ValueError:
return False
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
return expiry > datetime.now(timezone.utc)

View File

@ -18,10 +18,6 @@ NOTIFICATION_TYPES = [
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
{"key": "workspace", "label": "Dev workspaces", "description": "Idle, quota, retention and moderation notices for your dev workspaces"},
{"key": "moderation", "label": "Moderation", "description": "Acknowledgements for reports you file and decisions taken on your content"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
]

View File

@ -1,7 +1,5 @@
# retoor <retoor@molodetz.nl>
import os
from .core import TTLCache, _in_clause, _now_iso, db, get_table
from .users import get_users_by_uids
from .soft_delete import soft_delete, soft_delete_in
@ -12,17 +10,13 @@ VOTABLE_TARGETS: dict[str, str] = {
"project": "projects",
"gist": "gists",
"comment": "comments",
"quiz": "quizzes",
}
STAR_TARGETS: set[str] = {"post", "project", "gist", "quiz"}
STAR_TARGETS: set[str] = {"post", "project", "gist"}
RANKING_TTL = int(os.environ.get("DEVPLACE_RANKING_TTL", "60"))
_authors_cache = TTLCache(ttl=RANKING_TTL, max_size=200)
_authors_cache = TTLCache(ttl=60, max_size=200)
_stars_cache = TTLCache(ttl=15, max_size=2000)
@ -32,13 +26,12 @@ 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 tables
if table_name in db.tables
]
if "votes" not in tables or not sources:
if "votes" not in db.tables or not sources:
_authors_cache.set("ranked", [])
return []
target_union = " UNION ALL ".join(
@ -102,13 +95,12 @@ def get_user_stars(user_uid: str) -> int:
cached = _stars_cache.get(user_uid)
if cached is not None:
return cached
tables = db.tables
if "votes" not in tables:
if "votes" not in db.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 tables
if table_name in db.tables
)
if not target_union:
return 0
@ -156,8 +148,7 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
tables = db.tables
if "reactions" in tables:
if "reactions" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
@ -165,7 +156,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 tables:
if "bookmarks" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
@ -173,12 +164,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 tables:
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid):
if "poll_votes" in tables:
if "poll_votes" in db.tables:
db["poll_votes"].delete(poll_uid=poll["uid"])
if "poll_options" in tables:
if "poll_options" in db.tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)

View File

@ -0,0 +1,348 @@
# retoor <retoor@molodetz.nl>
import inspect
import os
import httpx
from devplacepy.cache import TTLCache
from devplacepy_services.base.db_codec import (
decode_value,
encode_args,
is_write,
is_write_sql,
)
_SERVICE_URL = os.environ.get("DEVPLACE_DB_SERVICE_URL", "http://127.0.0.1:10601").rstrip("/")
_INTERNAL_KEY = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", "").strip()
_CLIENT: httpx.Client | None = None
# Section 7.3: settings reads tolerate up to 5s staleness. patch_module()
# generically RPCs every devplacepy.database call, bypassing the local
# TTL cache get_setting/get_int_setting had in-process - without this,
# every settings read (rate limiting, maintenance mode, admin dashboards)
# pays a full HTTP round trip to the database broker.
_SETTINGS_CACHE_TTL_SECONDS = 5
_SETTINGS_CACHE = TTLCache(ttl=_SETTINGS_CACHE_TTL_SECONDS, max_size=512)
_CACHED_SETTINGS_FNS = frozenset({"get_setting", "get_int_setting"})
def _service_url() -> str:
return os.environ.get("DEVPLACE_DB_SERVICE_URL", _SERVICE_URL).rstrip("/")
def _headers() -> dict[str, str]:
headers: dict[str, str] = {}
key = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", _INTERNAL_KEY).strip()
if key:
headers["X-Internal-Key"] = key
return headers
def _client() -> httpx.Client:
global _CLIENT
if _CLIENT is None:
_CLIENT = httpx.Client(timeout=30.0)
return _CLIENT
def _post(path: str, body: dict) -> object:
response = _client().post(
f"{_service_url()}/{path.lstrip('/')}",
json=body,
headers=_headers(),
)
if response.status_code >= 400:
payload = response.json() if response.content else {}
message = payload.get("error", "Database service request failed")
raise RuntimeError(message)
if not response.content:
return None
return decode_value(response.json())
def _invoke_cached(fn_name: str, args, kwargs):
cache_key = f"{fn_name}:{args!r}:{sorted(kwargs.items())!r}"
cached = _SETTINGS_CACHE.get(cache_key)
if cached is not None:
return cached
value = _invoke(fn_name, args, kwargs, write=False)
_SETTINGS_CACHE.set(cache_key, value)
return value
def _invoke(fn_name: str, args, kwargs, *, write: bool = False):
encoded_args, encoded_kwargs = encode_args(args, kwargs)
payload = {
"fn": fn_name,
"args": encoded_args,
"kwargs": encoded_kwargs,
"write": write,
}
result = _post("internal/invoke", payload)
if isinstance(result, dict) and "result" in result:
return result["result"]
return result
class RemoteSearchClause:
def __init__(self, term, fields, author_field=None):
self.term = term.strip()
self.fields = tuple(fields)
self.author_field = author_field
class RemoteUidInClause:
def __init__(self, field, uids):
self.field = field
self.uids = frozenset(uids)
class RemoteTable:
def __init__(self, db: "RemoteDb", name: str) -> None:
self._db = db
self._name = name
self._column_cache = None
def __getattr__(self, name: str):
def caller(*args, **kwargs):
return self._db._table_op(self._name, name, args, kwargs)
return caller
def has_column(self, name: str) -> bool:
cache = self._column_cache
if cache is None:
sample = self.find(_limit=1)
row = next(iter(sample), None)
cache = set(row.keys()) if row else set()
self._column_cache = cache
return name in cache
def count(self, **kwargs):
return self._db._table_op(self._name, "count", [], kwargs)
@property
def table(self):
return self
@property
def exists(self) -> bool:
return self._name in self._db.tables
class RemoteDb:
def __init__(self) -> None:
self._tables_cache: list[str] | None = None
@property
def tables(self) -> list[str]:
if self._tables_cache is None:
result = _post("internal/db-op", {"op": "tables"})
self._tables_cache = list(result or [])
return self._tables_cache
def __getitem__(self, name: str) -> RemoteTable:
return RemoteTable(self, name)
def query(self, sql: str, **params):
encoded_args, encoded_kwargs = encode_args((sql,), params)
result = _post(
"internal/db-op",
{
"op": "query",
"args": encoded_args,
"kwargs": encoded_kwargs,
"write": is_write_sql(sql),
},
)
return result or []
def _table_op(self, table: str, method: str, args, kwargs, *, write: bool = False):
encoded_args, encoded_kwargs = encode_args(args, kwargs)
result = _post(
"internal/db-op",
{
"op": "table_op",
"table": table,
"method": method,
"args": encoded_args,
"kwargs": encoded_kwargs,
"write": write,
},
)
if method in {"insert", "update", "delete"}:
self._tables_cache = None
return result
@property
def executable(self):
return self
@property
def in_transaction(self) -> bool:
return False
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
_LOCAL_REMOTE = frozenset(
{
"get_table",
"refresh_snapshot",
"_in_clause",
"_now_iso",
"text_search_clause",
}
)
def _remote_text_search_clause(
table, search, fields=("title", "description"), author_field=None
):
term = (search or "").strip()
if not term:
return None
if type(table).__name__ == "RemoteTable":
return RemoteSearchClause(term, fields, author_field)
from devplacepy.database.content import text_search_clause as local_clause
return local_clause(table, search, fields, author_field=author_field)
def _remote_get_table(name: str):
import devplacepy.database.core as core
return core.db[name]
def _remote_refresh_snapshot() -> None:
return None
def patch_module(module) -> None:
import devplacepy.database as db_module
for name in db_module.__all__:
if name in _LOCAL_REMOTE:
continue
target = getattr(module, name, None)
if target is None or not callable(target):
continue
if inspect.isclass(target):
continue
def make_wrapper(fn_name: str, fn_write: bool):
if fn_name in _CACHED_SETTINGS_FNS:
def wrapper(*args, **kwargs):
return _invoke_cached(fn_name, args, kwargs)
wrapper.__name__ = fn_name
return wrapper
def wrapper(*args, **kwargs):
return _invoke(fn_name, args, kwargs, write=fn_write)
wrapper.__name__ = fn_name
return wrapper
setattr(module, name, make_wrapper(name, is_write(name)))
def activate() -> None:
import devplacepy.database.core as core
core.db = RemoteDb()
import devplacepy.database as db_module
patch_module(db_module)
for submodule_name in (
"settings",
"users",
"relations",
"pagination",
"soft_delete",
"engagement",
"usage",
"awards",
"seo_meta",
"activity",
"customization",
"email",
"notifications",
"forks",
"follows",
"deepsearch",
"ranking",
"comments",
"content",
"attachments_data",
"stats",
"schema",
):
try:
submodule = __import__(
f"devplacepy.database.{submodule_name}",
fromlist=[submodule_name],
)
except ImportError:
continue
patch_module(submodule)
for external_name in (
"devplacepy.services.statistics.tracking",
"devplacepy.services.base",
"devplacepy.attachments",
"devplacepy.project_files",
):
try:
external = __import__(external_name, fromlist=[external_name.split(".")[-1]])
except ImportError:
continue
if hasattr(external, "db"):
external.db = RemoteDb()
db_module.db = core.db
db_module.get_table = _remote_get_table
core.get_table = _remote_get_table
db_module.refresh_snapshot = _remote_refresh_snapshot
core.refresh_snapshot = _remote_refresh_snapshot
db_module.text_search_clause = _remote_text_search_clause
import devplacepy.database.content as content_module
content_module.text_search_clause = _remote_text_search_clause
for submodule_name in (
"settings",
"users",
"relations",
"pagination",
"soft_delete",
"engagement",
"usage",
"awards",
"seo_meta",
"activity",
"customization",
"email",
"notifications",
"forks",
"follows",
"deepsearch",
"ranking",
"comments",
"content",
"attachments_data",
"stats",
"schema",
):
try:
submodule = __import__(
f"devplacepy.database.{submodule_name}",
fromlist=[submodule_name],
)
except ImportError:
continue
if hasattr(submodule, "db"):
submodule.db = core.db

View File

@ -42,7 +42,6 @@ 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"):
@ -121,17 +120,6 @@ def init_db():
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
_index(db, "comments", "idx_comments_created_at", ["created_at"])
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
messages = get_table("messages")
for column, example in (
("uid", ""),
("sender_uid", ""),
("receiver_uid", ""),
("content", ""),
("read", False),
("created_at", ""),
):
if not messages.has_column(column):
messages.create_column_by_example(column, example)
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
_index(
@ -145,28 +133,7 @@ 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 (
@ -179,9 +146,6 @@ 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)
@ -288,7 +252,7 @@ def init_db():
defaults = {
"site_name": "DevPlace",
"site_description": "The Developer Social Network",
"site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open environment built by developers, for developers.",
"site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment.",
}
for key, value in defaults.items():
existing = db["site_settings"].find_one(key=key)
@ -394,21 +358,6 @@ def init_db():
_index(
db, "issue_comment_authors", "idx_issue_comment_authors_number", ["gitea_number"]
)
ws_tickets = get_table("ws_tickets")
for column, example in (
("uid", ""),
("token", ""),
("user_uid", ""),
("created_at", ""),
("expires_at", ""),
("used_at", ""),
):
if not ws_tickets.has_column(column):
ws_tickets.create_column_by_example(column, example)
_index(db, "ws_tickets", "idx_ws_tickets_token", ["token"], unique=True)
_index(db, "ws_tickets", "idx_ws_tickets_expires", ["expires_at"])
migrate_bug_tables_to_issue_tables()
_index(db, "service_state", "idx_service_state_name", ["name"])
if "devii_conversations" in db.tables:
@ -440,46 +389,6 @@ def init_db():
"idx_devii_turns_owner_time",
["owner_kind", "owner_id", "started_at"],
)
if "devii_tasks" in db.tables:
tasks = get_table("devii_tasks")
for column, example in (
("expires_at", ""),
("failure_count", 0),
("notify", 0),
("tz", ""),
):
if not tasks.has_column(column):
tasks.create_column_by_example(column, example)
try:
with db:
db.query(
"UPDATE devii_tasks SET failure_count = 0 WHERE failure_count IS NULL"
)
except Exception as e: # noqa: BLE001
logger.warning(f"Could not backfill devii_tasks.failure_count: {e}")
task_runs = get_table("devii_task_runs")
for column, example in (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("task_uid", ""),
("created_at", ""),
):
if not task_runs.has_column(column):
task_runs.create_column_by_example(column, example)
_index(
db,
"devii_task_runs",
"idx_devii_task_runs_owner_time",
["owner_kind", "owner_id", "created_at"],
)
_index(db, "devii_task_runs", "idx_devii_task_runs_time", ["created_at"])
_index(
db,
"devii_tasks",
"idx_devii_tasks_owner_created",
["owner_kind", "owner_id", "created_at"],
)
_index(db, "devii_tasks", "idx_devii_tasks_owner", ["owner_kind", "owner_id"])
_index(
db, "devii_tasks", "idx_devii_tasks_due", ["enabled", "status", "next_run_at"]
@ -562,163 +471,16 @@ def init_db():
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
instances = get_table("instances")
for column, example in (
("uid", ""),
("project_uid", ""),
("slug", ""),
("name", ""),
("status", ""),
("created_at", ""),
("owner_uid", ""),
("created_by", ""),
("desired_state", ""),
("container_id", ""),
("ingress_slug", ""),
("ingress_port", 0),
("ports_json", ""),
("container_gateway", ""),
("run_as_uid", ""),
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
("is_workspace", 0),
("workspace_owner_uid", ""),
("editor_port", 0),
("editor_host_port", 0),
("editor_password", ""),
("tunnel_name", ""),
("last_active_at", ""),
("idle_warned_at", ""),
("delete_warned_at", ""),
("disk_bytes", 0),
("disk_sampled_at", ""),
("egress_bytes", 0),
("request_count", 0),
("flagged_at", ""),
("flag_reason", ""),
("suspended_at", ""),
("suspended_by", ""),
("boot_marker", ""),
("workspace_cpu_millicores", 0),
("workspace_memory_mb", 0),
("workspace_disk_quota_mb", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
tunnels = get_table("tunnels")
for column, example in (
("uid", ""),
("instance_uid", ""),
("project_uid", ""),
("user_uid", ""),
("hostname", ""),
("label", ""),
("container_port", 0),
("desired_state", "present"),
("status", "pending"),
("cert_status", ""),
("cert_checked_at", ""),
("request_count", 0),
("bytes_out", 0),
("last_request_at", ""),
("last_error", ""),
("last_synced_at", ""),
("created_at", ""),
("updated_at", ""),
):
if not tunnels.has_column(column):
tunnels.create_column_by_example(column, example)
quota_rules = get_table("workspace_quota_rules")
for column, example in (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("label", ""),
("max_workspaces", 0),
("max_tunnels", 0),
("disk_quota_mb", 0),
("egress_quota_mb", 0),
("idle_stop_minutes", 0),
("retention_days", 0),
("cpu_millicores", 0),
("memory_mb", 0),
("created_at", ""),
("updated_at", ""),
):
if not quota_rules.has_column(column):
quota_rules.create_column_by_example(column, example)
editor_prefs = get_table("workspace_editor_prefs")
for column, example in (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("font_size", 0),
("terminal_font_size", 0),
("zoom_level", -99),
("theme", ""),
("layout", ""),
("panel_preset", ""),
("window_mode", ""),
("window_width", 0),
("window_height", 0),
("boot_agent", ""),
("boot_shell", -1),
("created_at", ""),
("updated_at", ""),
):
if not editor_prefs.has_column(column):
editor_prefs.create_column_by_example(column, example)
flags = get_table("workspace_flags")
for column, example in (
("uid", ""),
("instance_uid", ""),
("user_uid", ""),
("kind", ""),
("severity", "warn"),
("detail", ""),
("metric_value", 0.0),
("threshold", 0.0),
("status", "open"),
("resolved_by", ""),
("resolved_at", ""),
("created_at", ""),
("updated_at", ""),
):
if not flags.has_column(column):
flags.create_column_by_example(column, example)
_index(db, "instances", "idx_instances_workspace", ["is_workspace", "status"])
_index(db, "instances", "idx_instances_workspace_owner", ["workspace_owner_uid"])
_index(db, "instances", "idx_instances_tunnel_name", ["tunnel_name"])
_index(db, "tunnels", "idx_tunnels_hostname", ["hostname"])
_index(db, "tunnels", "idx_tunnels_instance", ["instance_uid"])
_index(db, "tunnels", "idx_tunnels_user", ["user_uid"])
_index(db, "tunnels", "idx_tunnels_state", ["desired_state", "status"])
_index(
db,
"workspace_quota_rules",
"idx_workspace_quota_owner",
["owner_kind", "owner_id"],
)
_index(
db,
"workspace_editor_prefs",
"idx_workspace_editor_prefs_owner",
["owner_kind", "owner_id"],
)
_index(db, "workspace_flags", "idx_workspace_flags_open", ["status", "created_at"])
_index(
db,
"workspace_flags",
"idx_workspace_flags_instance",
["instance_uid", "kind"],
)
_index(db, "workspace_flags", "idx_workspace_flags_user", ["user_uid"])
if "instances" in db.tables:
instances = get_table("instances")
for column, example in (
("run_as_uid", ""),
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
_index(db, "instances", "idx_instances_project", ["project_uid"])
_index(db, "instances", "idx_instances_slug", ["slug"])
@ -756,10 +518,6 @@ def init_db():
from devplacepy.services.openai_gateway import routing as gateway_routing
gateway_routing.ensure_tables()
from devplacepy.services.openai_gateway import quota as gateway_quota
gateway_quota.ensure_tables()
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
_index(db, "audit_log", "idx_audit_category", ["category"])
@ -1256,32 +1014,6 @@ def init_db():
("legacy_speed", 0),
("legacy_plots", 0),
("legacy_defense", 0),
("legacy_carryover", 0),
("last_grant_week", ""),
("prestiged_at", ""),
("mastery_points", 0),
("mastery_points_earned_total", 0),
("mastery_autoreplant", 0),
("mastery_analytics", 0),
("mastery_contracts", 0),
("lifetime_coins_earned", 0),
("lifetime_harvests", 0),
("infra_registry", 0),
("infra_canary", 0),
("infra_observability", 0),
("defense_level", 0),
("defense_last_upkeep_at", ""),
("upkeep_amnesty", 0),
("active_title", ""),
("underdog_boost_until", ""),
("contract_boost_until", ""),
("harvests_week", 0),
("harvests_week_start", ""),
("last_kernel_harvest_prestige", 0),
("time_to_kernel_seconds", 0),
("era_coins", 0),
("era_harvests", 0),
("era_joined_at", ""),
("created_at", ""),
("updated_at", ""),
):
@ -1306,7 +1038,6 @@ def init_db():
_index(
db, "game_steals", "idx_game_steals_pair", ["thief_uid", "owner_uid", "stolen_at"]
)
_index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"])
game_quests = get_table("game_quests")
for column, example in (
@ -1314,7 +1045,6 @@ def init_db():
("farm_uid", ""),
("user_uid", ""),
("day", ""),
("scope", "daily"),
("slot_index", 0),
("kind", ""),
("label", ""),
@ -1322,17 +1052,13 @@ def init_db():
("progress", 0),
("reward_coins", 0),
("reward_xp", 0),
("reward_stars", 0),
("claimed", 0),
("created_at", ""),
("updated_at", ""),
):
if not game_quests.has_column(column):
game_quests.create_column_by_example(column, example)
with db:
db.query("UPDATE game_quests SET scope = 'daily' WHERE scope IS NULL OR scope = ''")
_index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"])
_index(db, "game_quests", "idx_game_quests_farm_day_scope", ["farm_uid", "day", "scope"])
game_plots = get_table("game_plots")
for column, example in (
@ -1344,7 +1070,7 @@ def init_db():
("planted_at", ""),
("ready_at", ""),
("watered_by", "[]"),
("raided_fraction", 0.0),
("cooldown_until", ""),
("created_at", ""),
("updated_at", ""),
):
@ -1352,226 +1078,17 @@ def init_db():
game_plots.create_column_by_example(column, example)
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
game_market_ticks = get_table("game_market_ticks")
for column, example in (
("uid", ""),
("crop_key", ""),
("hour_bucket", ""),
("harvests", 0),
("updated_at", ""),
):
if not game_market_ticks.has_column(column):
game_market_ticks.create_column_by_example(column, example)
_index(
db,
"game_market_ticks",
"idx_game_market_ticks_bucket",
["crop_key", "hour_bucket"],
unique=True,
)
game_cosmetics = get_table("game_cosmetics")
_drop_index(db, "idx_rate_limit_log_user_ts")
rate_limit_log = get_table("rate_limit_log")
for column, example in (
("uid", ""),
("user_uid", ""),
("cosmetic_key", ""),
("purchased_at", ""),
("created_at", ""),
("action", ""),
("timestamp", ""),
):
if not game_cosmetics.has_column(column):
game_cosmetics.create_column_by_example(column, example)
_index(
db,
"game_cosmetics",
"idx_game_cosmetics_owner",
["user_uid", "cosmetic_key"],
unique=True,
)
game_treasury = get_table("game_treasury")
for column, example in (
("uid", ""),
("balance", 0),
("collected_total", 0),
("granted_total", 0),
("updated_at", ""),
):
if not game_treasury.has_column(column):
game_treasury.create_column_by_example(column, example)
game_eras = get_table("game_eras")
for column, example in (
("uid", ""),
("era_number", 0),
("name", ""),
("started_at", ""),
("ends_at", ""),
("active", 0),
("created_at", ""),
):
if not game_eras.has_column(column):
game_eras.create_column_by_example(column, example)
_index(db, "game_eras", "idx_game_eras_active", ["active"])
game_era_results = get_table("game_era_results")
for column, example in (
("uid", ""),
("era_number", 0),
("user_uid", ""),
("rank", 0),
("era_score", 0),
("era_coins_final", 0),
("joined_at", ""),
("reward_stars", 0),
("reward_cosmetic_key", ""),
("created_at", ""),
):
if not game_era_results.has_column(column):
game_era_results.create_column_by_example(column, example)
_index(db, "game_era_results", "idx_game_era_results_era", ["era_number", "rank"])
quizzes = get_table("quizzes")
for column, example in (
("uid", ""),
("user_uid", ""),
("slug", ""),
("title", ""),
("description", ""),
("status", "draft"),
("published_at", ""),
("shuffle_questions", 0),
("shuffle_options", 0),
("reveal_answers", 0),
("allow_review", 0),
("time_limit_seconds", 0),
("pass_percent", 0),
("question_count", 0),
("total_points", 0),
("attempt_count", 0),
("stars", 0),
("content_version", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quizzes.has_column(column):
quizzes.create_column_by_example(column, example)
_index(db, "quizzes", "idx_quizzes_slug", ["slug"], unique=True)
_index(db, "quizzes", "idx_quizzes_user_created", ["user_uid", "created_at"])
_index(db, "quizzes", "idx_quizzes_status_created", ["status", "created_at"])
_index(
db,
"quizzes",
"idx_quizzes_live_created",
["created_at"],
where="deleted_at IS NULL",
)
quiz_questions = get_table("quiz_questions")
for column, example in (
("uid", ""),
("quiz_uid", ""),
("position", 0),
("kind", ""),
("prompt", ""),
("explanation", ""),
("points", 1),
("media_attachment_uid", ""),
("correct_boolean", 0),
("expected_answer", ""),
("grading_criteria", ""),
("numeric_value", 0.0),
("numeric_tolerance", 0.0),
("case_sensitive", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_questions.has_column(column):
quiz_questions.create_column_by_example(column, example)
_index(
db, "quiz_questions", "idx_quiz_questions_quiz_position", ["quiz_uid", "position"]
)
quiz_options = get_table("quiz_options")
for column, example in (
("uid", ""),
("question_uid", ""),
("quiz_uid", ""),
("position", 0),
("label", ""),
("match_value", ""),
("is_correct", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_options.has_column(column):
quiz_options.create_column_by_example(column, example)
_index(
db,
"quiz_options",
"idx_quiz_options_question_position",
["question_uid", "position"],
)
_index(db, "quiz_options", "idx_quiz_options_quiz", ["quiz_uid"])
quiz_attempts = get_table("quiz_attempts")
for column, example in (
("uid", ""),
("quiz_uid", ""),
("user_uid", ""),
("status", "in_progress"),
("question_order", "[]"),
("started_at", ""),
("expires_at", ""),
("completed_at", ""),
("answered_count", 0),
("score_points", 0.0),
("max_points", 0),
("score_percent", 0.0),
("passed", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_attempts.has_column(column):
quiz_attempts.create_column_by_example(column, example)
_index(db, "quiz_attempts", "idx_quiz_attempts_user_created", ["user_uid", "created_at"])
_index(db, "quiz_attempts", "idx_quiz_attempts_quiz_status", ["quiz_uid", "status"])
_index(db, "quiz_attempts", "idx_quiz_attempts_user_quiz", ["user_uid", "quiz_uid"])
_index(db, "quiz_attempts", "idx_quiz_attempts_status_user", ["status", "user_uid"])
quiz_answers = get_table("quiz_answers")
for column, example in (
("uid", ""),
("attempt_uid", ""),
("question_uid", ""),
("quiz_uid", ""),
("position", 0),
("answer_text", ""),
("option_uids", "[]"),
("answered_at", ""),
("is_correct", 0),
("awarded_points", 0.0),
("feedback", ""),
("graded_by", ""),
("confidence", 0.0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not quiz_answers.has_column(column):
quiz_answers.create_column_by_example(column, example)
_index(
db, "quiz_answers", "idx_quiz_answers_attempt_position", ["attempt_uid", "position"]
)
_index(db, "quiz_answers", "idx_quiz_answers_quiz", ["quiz_uid"])
if not rate_limit_log.has_column(column):
rate_limit_log.create_column_by_example(column, example)
_index(db, "rate_limit_log", "idx_rate_limit_log_user_ts", ["user_uid", "timestamp"])
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
@ -1598,8 +1115,6 @@ def init_db():
["user_uid", "created_at"],
)
_ensure_moderation_tables()
for table in db.tables:
_uid_index(db, table)
@ -1738,19 +1253,6 @@ def init_db():
"outbound_proxy_url": "",
"devii_lessons_max_per_owner": "500",
"devii_lessons_max_age_days": "90",
"moderation_sla_hours": "24",
"moderation_filter_mode": "review",
"moderation_filter_review_score": "2",
"moderation_minimum_age": "16",
"moderation_mature_default_hidden": "1",
"contact_email": "",
"contact_phone": "",
"contact_address": "",
"terms_version": "1",
"privacy_version": "1",
"guidelines_version": "1",
"ai_third_party_provider": "",
"account_deletion_grace_hours": "24",
}
for key, value in operational_defaults.items():
existing = db["site_settings"].find_one(key=key)
@ -1831,83 +1333,6 @@ def init_db():
_refresh_query_planner_stats()
MODERATION_COLUMNS: dict[str, tuple[tuple[str, object], ...]] = {
"content_reports": (
("uid", ""),
("reporter_uid", ""),
("target_type", ""),
("target_uid", ""),
("owner_uid", ""),
("reason", ""),
("detail", ""),
("severity", "warn"),
("status", "open"),
("origin", "member"),
("categories", ""),
("resolved_by", ""),
("resolved_at", ""),
("created_at", ""),
("updated_at", ""),
),
"moderation_actions": (
("uid", ""),
("report_uid", ""),
("actor_uid", ""),
("action", ""),
("target_type", ""),
("target_uid", ""),
("subject_uid", ""),
("reason", ""),
("notes", ""),
("expires_at", ""),
("created_at", ""),
),
"content_maturity": (
("uid", ""),
("target_type", ""),
("target_uid", ""),
("level", "general"),
("source", ""),
("set_by", ""),
("created_at", ""),
("updated_at", ""),
),
"user_consents": (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("kind", ""),
("version", "1"),
("state", ""),
("granted_at", ""),
("withdrawn_at", ""),
("created_at", ""),
),
}
MODERATION_INDEXES: tuple[tuple[str, str, list[str]], ...] = (
("content_reports", "idx_content_reports_queue", ["status", "created_at"]),
("content_reports", "idx_content_reports_target", ["target_type", "target_uid"]),
("content_reports", "idx_content_reports_reporter", ["reporter_uid"]),
("content_reports", "idx_content_reports_owner", ["owner_uid"]),
("moderation_actions", "idx_moderation_actions_report", ["report_uid"]),
("moderation_actions", "idx_moderation_actions_subject", ["subject_uid"]),
("moderation_actions", "idx_moderation_actions_created", ["created_at"]),
("content_maturity", "idx_content_maturity_target", ["target_type", "target_uid"]),
("user_consents", "idx_user_consents_owner", ["owner_kind", "owner_id", "kind"]),
)
def _ensure_moderation_tables() -> None:
for table_name, columns in MODERATION_COLUMNS.items():
table = get_table(table_name)
for column, example in columns:
if not table.has_column(column):
table.create_column_by_example(column, example)
for table_name, index_name, columns in MODERATION_INDEXES:
_index(db, table_name, index_name, columns)
def _refresh_query_planner_stats() -> None:
try:
has_stats = bool(
@ -1960,11 +1385,11 @@ def migrate_ai_gateway_settings() -> None:
def backfill_api_keys() -> int:
users = get_table("users")
if "users" not in db.tables:
return 0
users = db["users"]
if not users.has_column("api_key"):
users.create_column_by_example("api_key", "")
if not users.has_column("created_at"):
users.create_column_by_example("created_at", "")
if not users.has_column("cust_disable_global"):
users.create_column_by_example("cust_disable_global", 0)
if not users.has_column("cust_disable_pagetype"):
@ -1997,22 +1422,6 @@ def backfill_api_keys() -> int:
users.create_column_by_example("last_award_slug", "")
if not users.has_column("last_award_uid"):
users.create_column_by_example("last_award_uid", "")
if not users.has_column("terms_version"):
users.create_column_by_example("terms_version", "")
if not users.has_column("terms_accepted_at"):
users.create_column_by_example("terms_accepted_at", "")
if not users.has_column("age_band"):
users.create_column_by_example("age_band", "")
if not users.has_column("age_declared_at"):
users.create_column_by_example("age_declared_at", "")
if not users.has_column("mature_opt_in"):
users.create_column_by_example("mature_opt_in", 0)
if not users.has_column("suspended_until"):
users.create_column_by_example("suspended_until", "")
if not users.has_column("suspension_reason"):
users.create_column_by_example("suspension_reason", "")
if not users.has_column("deletion_requested_at"):
users.create_column_by_example("deletion_requested_at", "")
with db:
db.query(
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
@ -2027,7 +1436,6 @@ def backfill_api_keys() -> int:
"UPDATE users SET interactions_enabled = -1 "
"WHERE interactions_enabled IS NULL"
)
db.query("UPDATE users SET mature_opt_in = 0 WHERE mature_opt_in IS NULL")
import uuid_utils
updated = 0
@ -2040,29 +1448,6 @@ 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
@ -2126,12 +1511,6 @@ def _backfill_gamification():
)
_authors_cache.clear()
candidates = _milestone_candidates()
checked = [user for user in pending if user["uid"] in candidates]
for user in checked:
for user in pending:
check_milestone_badges(user["uid"])
logger.info(
f"Gamification backfill processed {len(pending)} users, "
f"{len(checked)} with milestone-eligible activity"
)
logger.info(f"Gamification backfill processed {len(pending)} users")

View File

@ -3,7 +3,7 @@
from .core import _in_clause, _now_iso, db, get_table
SEO_META_TYPES = ("post", "project", "gist", "news", "issue", "quiz")
SEO_META_TYPES = ("post", "project", "gist", "news", "issue")
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:

View File

@ -23,10 +23,6 @@ SOFT_DELETE_TABLES = [
"sessions",
"instances",
"instance_schedules",
"tunnels",
"workspace_flags",
"workspace_quota_rules",
"workspace_editor_prefs",
"backup_schedules",
"devii_conversations",
"devii_tasks",
@ -46,15 +42,6 @@ SOFT_DELETE_TABLES = [
"user_relations",
"seo_metadata",
"awards",
"quizzes",
"quiz_questions",
"quiz_options",
"quiz_attempts",
"quiz_answers",
"content_reports",
"moderation_actions",
"content_maturity",
"user_consents",
]

View File

@ -15,9 +15,6 @@ def get_users_by_uids(uids):
_admins_cache = TTLCache(ttl=300, max_size=4)
# The primary administrator must be an account that can actually authenticate, so scan a
# few of the earliest admins and skip any that are soft-deleted or deactivated.
PRIMARY_ADMIN_CANDIDATES = 50
def invalidate_admins_cache() -> None:
@ -74,17 +71,6 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
)
def is_account_active(row) -> bool:
is_active = (row or {}).get("is_active")
return is_active is None or bool(is_active)
def _can_hold_primary_admin(row, tracks_active):
if row.get("deleted_at"):
return False
return not tracks_active or is_account_active(row)
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
@ -94,17 +80,11 @@ def get_primary_admin_uid():
return None
rows = list(
db.query(
"SELECT * FROM users WHERE role = 'Admin' "
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
"LIMIT :cap",
cap=PRIMARY_ADMIN_CANDIDATES,
"SELECT uid FROM users WHERE role = 'Admin' "
"ORDER BY created_at ASC, id ASC LIMIT 1"
)
)
tracks_active = "is_active" in db["users"].columns
primary = next(
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
None,
)
primary = rows[0]["uid"] if rows else None
_admins_cache.set("primary", primary or "")
return primary

29
devplacepy/db_client.py Normal file
View File

@ -0,0 +1,29 @@
# retoor <retoor@molodetz.nl>
import os
def _activate() -> None:
if os.environ.get("DEVPLACE_DB_SERVICE") == "1":
return
if os.environ.get("DEVPLACE_REMOTE_DB") == "1":
from devplacepy.database.remote import activate
activate()
_activate()
import devplacepy.database as _database
def _remote_table(table) -> bool:
return type(table).__name__ == "RemoteTable"
def __getattr__(name: str):
return getattr(_database, name)
def __dir__():
return sorted(name for name in dir(_database) if not name.startswith("_"))

View File

@ -1,9 +1,9 @@
# retoor <retoor@molodetz.nl>
VOTE_TARGETS = ["post", "comment", "gist", "project", "quiz"]
REACTION_TARGETS = ["post", "comment", "gist", "project", "quiz"]
BOOKMARK_TARGETS = ["post", "gist", "project", "news", "quiz"]
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist", "quiz"]
VOTE_TARGETS = ["post", "comment", "gist", "project"]
REACTION_TARGETS = ["post", "comment", "gist", "project"]
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = [
"python",

View File

@ -8,12 +8,10 @@ from . import (
content,
profiles,
messaging,
moderation,
notifications,
uploads,
project_files,
containers,
workspaces,
tools,
push,
issues,
@ -21,7 +19,6 @@ from . import (
services,
admin,
game,
quizzes,
)
ORDERED_GROUPS = [
@ -32,12 +29,10 @@ ORDERED_GROUPS = [
content.GROUP,
profiles.GROUP,
messaging.GROUP,
moderation.GROUP,
notifications.GROUP,
uploads.GROUP,
project_files.GROUP,
containers.GROUP,
workspaces.GROUP,
tools.GROUP,
push.GROUP,
issues.GROUP,
@ -45,5 +40,4 @@ ORDERED_GROUPS = [
services.GROUP,
admin.GROUP,
game.GROUP,
quizzes.GROUP,
]

View File

@ -613,73 +613,6 @@ four ways to sign requests.
auth="admin",
destructive=True,
),
endpoint(
id="admin-gateway-quota-rules",
method="GET",
path="/admin/gateway/quota-rules",
title="List AI gateway quota rules",
summary=(
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
"combination of role, specific user uid, and app_reference label, plus the "
"global per-role default caps that apply when no rule matches."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-gateway-quota-rule-set",
method="POST",
path="/admin/gateway/quota-rules",
title="Create or update an AI gateway quota rule",
summary=(
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
"wildcard, and the most specific active match wins over other rules and over "
"the global default. Pass uid to update an existing rule."
),
auth="admin",
params=[
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
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",
path="/admin/gateway/quota-rules/{uid}",
title="Delete an AI gateway quota rule",
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
],
),
endpoint(
id="admin-bots-monitor",
method="GET",
@ -737,52 +670,6 @@ four ways to sign requests.
)
],
),
endpoint(
id="admin-devii-tasks",
method="GET",
path="/admin/devii-tasks",
title="Scheduled Devii tasks",
summary=(
"Every scheduled Devii task across all owners with its schedule, run count, "
"expiry, failure streak, and whether its owner may still schedule, plus the "
"configured automation bounds. Returns HTML (or JSON with "
"Accept: application/json)."
),
auth="admin",
interactive=True,
params=[
field(
"state",
"query",
"string",
False,
"active",
"One of active, inactive, all.",
)
],
),
endpoint(
id="admin-devii-task-disable",
method="POST",
path="/admin/devii-tasks/{uid}/disable",
title="Disable a scheduled task",
summary="Stop one scheduled task. The row is kept and stays auditable.",
auth="admin",
params=[
field("uid", "path", "string", True, "", "Uid of the task."),
],
),
endpoint(
id="admin-devii-task-delete",
method="POST",
path="/admin/devii-tasks/{uid}/delete",
title="Delete a scheduled task",
summary="Soft-delete one scheduled task; it moves to the admin trash.",
auth="admin",
params=[
field("uid", "path", "string", True, "", "Uid of the task."),
],
),
endpoint(
id="admin-backups",
method="GET",
@ -943,37 +830,5 @@ four ways to sign requests.
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-game",
method="GET",
path="/admin/game",
title="Code Farm Era management",
summary="View the current Code Farm Era status.",
auth="admin",
sample_response={"era_active": False, "era_name": ""},
),
endpoint(
id="admin-game-era-start",
method="POST",
path="/admin/game/era/start",
title="Start an Era",
summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.",
auth="admin",
params=[
field("name", "form", "string", True, "Genesis", "Era name."),
field("duration_days", "form", "int", False, "28", "Planned Era length in days."),
],
sample_response={"ok": True, "redirect": "/admin/game"},
),
endpoint(
id="admin-game-era-end",
method="POST",
path="/admin/game/era/end",
title="End the running Era",
summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.",
auth="admin",
destructive=True,
sample_response={"ok": True, "redirect": "/admin/game"},
),
],
}

View File

@ -50,13 +50,6 @@ as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
field("email", "form", "string", True, "alice@example.com", "Email address; must be unique and contain an @."),
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
field("birth_date", "form", "string", True, "01/01/1990", "Date of birth, DD/MM/YYYY or YYYY-MM-DD. Only the derived age band is stored; the date is discarded."),
field("accept_terms", "form", "enum", True, "1", "Acceptance of the Terms of Service and Community Guidelines.", ["1"]),
],
notes=[
"Signup is refused below the platform minimum age (`moderation_minimum_age`).",
"Accepting records the terms, privacy and activity-recording consents; "
"third-party AI processing stays off until it is granted separately.",
],
),
endpoint(

View File

@ -0,0 +1,580 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "containers",
"title": "Container Manager",
"admin": True,
"intro": """
# Container Manager
Run supervised container instances for a project. There is no in-app image building: every instance
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
state; a single reconciler converges containers to it.
""",
"endpoints": [
endpoint(
id="containers-page",
method="GET",
path="/projects/{project_slug}/containers",
title="Container manager page",
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
auth="admin",
interactive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
)
],
),
endpoint(
id="containers-admin-index",
method="GET",
path="/admin/containers",
title="Admin containers list",
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
auth="admin",
interactive=True,
),
endpoint(
id="containers-admin-data",
method="GET",
path="/admin/containers/data",
title="Admin containers list data",
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
auth="admin",
sample_response={
"instances": [
{
"uid": "INSTANCE_UID",
"name": "staging",
"status": "running",
"project_slug": "PROJECT_SLUG",
"project_title": "My Project",
"ingress_slug": "my-service",
"restart_policy": "always",
}
]
},
),
endpoint(
id="containers-admin-instance",
method="GET",
path="/admin/containers/{uid}",
title="Instance detail page",
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-admin-edit-page",
method="GET",
path="/admin/containers/{uid}/edit",
title="Edit instance page",
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-create-instance",
method="POST",
path="/projects/{project_slug}/containers/instances",
title="Create an instance",
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field("name", "form", "string", True, "staging", "Instance name."),
field(
"boot_command",
"form",
"string",
False,
"python app.py",
"Optional boot command.",
),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field(
"env",
"form",
"textarea",
False,
"KEY=VALUE",
"Env vars, one KEY=VALUE per line.",
),
field(
"ports",
"form",
"string",
False,
"80",
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field(
"mem_limit", "form", "string", False, "512m", "Memory limit."
),
field(
"restart_policy",
"form",
"enum",
False,
"never",
"Restart policy.",
["never", "always", "on-failure", "unless-stopped"],
),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field(
"ingress_slug",
"form",
"string",
False,
"my-service",
"Publish at /p/<slug> (optional).",
),
field(
"ingress_port",
"form",
"integer",
False,
"8899",
"Container port to publish (must be a mapped port).",
),
],
),
endpoint(
id="containers-ingress",
method="GET",
path="/p/{slug}",
title="Container ingress proxy",
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
auth="public",
interactive=True,
params=[
field(
"slug",
"path",
"string",
True,
"my-service",
"The instance's ingress_slug.",
)
],
),
endpoint(
id="containers-instance-action",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
title="Instance lifecycle",
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"path",
"enum",
True,
"start",
"Lifecycle action.",
["start", "stop", "restart", "pause", "resume"],
),
],
),
endpoint(
id="containers-instance-logs",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/logs",
title="Instance logs",
summary="Recent docker logs of a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field("tail", "query", "integer", False, "200", "Number of lines."),
],
sample_response={"logs": "..."},
),
endpoint(
id="containers-instance-sync",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/sync",
title="Sync workspace",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-instance-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/delete",
title="Delete instance",
summary="Remove a container instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
),
endpoint(
id="containers-instance-exec",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/exec",
title="Exec a command",
summary="Run a one-shot command inside a running instance and return its output.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"command",
"form",
"string",
True,
"ls -la /app",
"Shell command to run (via /bin/sh -c).",
),
],
),
endpoint(
id="containers-instance-data",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}",
title="Instance detail data",
summary="Return the full instance row plus runtime info as JSON.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
),
endpoint(
id="containers-instance-metrics",
method="GET",
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
title="Instance metrics",
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
auth="admin",
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"metrics": [], "stats": {}},
),
endpoint(
id="containers-instance-schedules",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
title="Create a schedule",
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
auth="admin",
encoding="form",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"action",
"form",
"string",
True,
"start",
"Lifecycle action to run on schedule (start, stop, restart).",
),
field(
"kind",
"form",
"string",
True,
"cron",
"Schedule kind: cron, once, interval, or delay.",
),
field(
"cron",
"form",
"string",
False,
"0 * * * *",
"Cron expression (when kind is cron).",
),
field(
"run_at",
"form",
"string",
False,
"2026-01-01T00:00:00",
"ISO timestamp for a one-time run (when kind is once).",
),
field(
"delay_seconds",
"form",
"integer",
False,
"60",
"Seconds to wait before a single run (when kind is delay).",
),
field(
"every_seconds",
"form",
"integer",
False,
"300",
"Interval in seconds between runs (when kind is interval).",
),
field(
"max_runs",
"form",
"integer",
False,
"10",
"Optional cap on the number of runs.",
),
],
),
endpoint(
id="containers-instance-schedule-delete",
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
title="Delete a schedule",
summary="Remove a schedule from an instance.",
auth="admin",
destructive=True,
params=[
field(
"project_slug",
"path",
"string",
True,
"PROJECT_SLUG",
"Project slug or uid.",
),
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
field(
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
),
],
),
endpoint(
id="containers-admin-create",
method="POST",
path="/admin/containers/create",
title="Admin create instance",
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
field("name", "form", "string", True, "staging", "Instance name."),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
],
),
endpoint(
id="containers-admin-edit",
method="POST",
path="/admin/containers/{uid}/edit",
title="Admin edit instance",
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
],
),
endpoint(
id="containers-admin-action",
method="POST",
path="/admin/containers/{uid}/{action}",
title="Admin instance lifecycle",
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
],
),
endpoint(
id="containers-admin-sync",
method="POST",
path="/admin/containers/{uid}/sync",
title="Admin bidirectional sync",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-admin-delete",
method="POST",
path="/admin/containers/{uid}/delete",
title="Admin delete instance",
summary="Soft-delete an instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
),
endpoint(
id="containers-admin-project-search",
method="GET",
path="/admin/containers/projects/search",
title="Admin project search",
summary="Search projects by title for the admin create form (returns uid, slug, title).",
auth="admin",
params=[
field("q", "query", "string", False, "api", "Title fragment."),
],
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
),
endpoint(
id="containers-admin-user-search",
method="GET",
path="/admin/containers/users/search",
title="Admin run-as user search",
summary="Search users by username for the run-as-user select (returns uid, username).",
auth="admin",
params=[
field("q", "query", "string", False, "alice", "Username fragment."),
],
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
),
],
}

View File

@ -362,7 +362,7 @@ four ways to sign requests.
method="GET",
path="/projects/{project_slug}",
title="View a project",
summary="Render the project overview with its devlog, screenshots and comments. Returns an HTML page.",
summary="Render a project with comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
@ -373,42 +373,7 @@ four ways to sign requests.
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
),
field(
"before",
"query",
"string",
False,
"",
"Devlog pagination cursor (devlog_next_cursor from the previous page).",
),
],
),
endpoint(
id="projects-screenshots",
method="POST",
path="/projects/{project_slug}/screenshots",
title="Add screenshots to a project",
summary="Link uploaded image attachments to an owned project's Screenshots gallery. Redirects to the gallery.",
auth="user",
encoding="form",
params=[
field(
"project_slug",
"path",
"string",
True,
"my-project-1a2b3c4d",
"Project slug or uid.",
),
field(
"attachment_uids",
"form",
"string",
True,
"ATTACHMENT_UID",
"Comma-separated attachment uids from POST /uploads/upload or /uploads/upload-url.",
),
)
],
),
endpoint(
@ -478,38 +443,6 @@ four ways to sign requests.
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
field(
"website_url",
"form",
"string",
False,
"https://myproject.dev",
"Optional official website URL (http/https).",
),
field(
"repo_url",
"form",
"string",
False,
"https://github.com/me/project",
"Optional source repository URL (http/https).",
),
field(
"cover_attachment_uid",
"form",
"string",
False,
"ATTACHMENT_UID",
"Optional attachment uid of an uploaded cover image.",
),
field(
"logo_attachment_uid",
"form",
"string",
False,
"ATTACHMENT_UID",
"Optional attachment uid of an uploaded project logo.",
),
],
),
endpoint(
@ -587,38 +520,6 @@ four ways to sign requests.
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
field(
"website_url",
"form",
"string",
False,
"https://myproject.dev",
"Optional official website URL (http/https).",
),
field(
"repo_url",
"form",
"string",
False,
"https://github.com/me/project",
"Optional source repository URL (http/https).",
),
field(
"cover_attachment_uid",
"form",
"string",
False,
"ATTACHMENT_UID",
"Optional attachment uid of an uploaded cover image.",
),
field(
"logo_attachment_uid",
"form",
"string",
False,
"ATTACHMENT_UID",
"Optional attachment uid of an uploaded project logo.",
),
],
),
endpoint(

View File

@ -2,27 +2,6 @@
from .._shared import endpoint, field
CROP_KEYS = [
"shell",
"python",
"webapp",
"api",
"rust",
"haskell",
"kernel",
"distsys",
"mlpipe",
"secfort",
]
PERK_KEYS = ["yield", "growth", "discount", "xp"]
QUEST_KINDS = ["plant", "harvest", "water", "earn"]
QUEST_SCOPES = ["daily", "weekly"]
LEGACY_KEYS = ["autoharvest", "multiplier", "speed", "plots", "defense", "carryover"]
MASTERY_KEYS = ["autoreplant", "analytics", "contracts"]
INFRA_KEYS = ["registry", "canary", "observability"]
COSMETIC_KEYS = ["title_architect", "title_refactorer", "title_kernel_hacker", "skin_neon"]
BOARD_KEYS = ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play", "era"]
GROUP = {
"slug": "game",
"title": "Code Farm",
@ -33,19 +12,8 @@ The Code Farm is a cooperative idle game. Each member owns a farm of plots, plan
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
faster builds, and waters other members' growing builds to speed them up and earn coins.
Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth;
the fees fill a community treasury from which active low-balance farms can claim a weekly grant.
All endpoints negotiate HTML or JSON. POST bodies are form encoded
(`application/x-www-form-urlencoded`). Every own-farm action returns `{"ok": true, "farm": {...}}`
- the full updated farm state - so a client can refresh without a second request; the two
neighbour actions (water, steal) return the neighbour's farm as `{"farm": {...}}`, and a
successful steal adds `stole_coins`. An invalid action (not enough coins, wrong plot state, a
protected harvest, an active cooldown) returns HTTP 400 as
`{"error": {"status": 400, "message": "..."}}`; an unknown farm username is 404. Reading your
own farm state also runs lazy owner effects: the CI Bot legacy upgrade auto-harvests ready
builds, and any due Defense upkeep is charged. The complete rules, formulas, and an automated
client are on the [Code Farm guide](/docs/code-farm.html).
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
client can refresh without a second request.
""",
"endpoints": [
endpoint(
@ -63,7 +31,7 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="GET",
path="/game/state",
title="Farm state",
summary="The signed-in player's full farm state as JSON. Reading it auto-collects ready builds (with the CI Bot legacy upgrade, reported as auto_harvested/auto_harvest_coins/auto_harvest_xp) and charges any due Defense upkeep.",
summary="The signed-in player's full farm state as JSON.",
auth="user",
sample_response={
"ok": True,
@ -72,26 +40,8 @@ client are on the [Code Farm guide](/docs/code-farm.html).
"level": 1,
"ci_tier": 1,
"plot_count": 4,
"prestige": 0,
"stars": 0,
"refactor_cost": 20000,
"plots": [{"slot": 0, "state": "empty", "raided_fraction": 0.0}],
"daily_streak_reset": False,
"contract_boost_seconds_remaining": 0,
"auto_harvested": 0,
"steal_max_per_victim_per_day": 3,
"defense_downgrade_available": False,
"crops": [
{
"key": "python",
"name": "Python Script",
"cost": 15,
"reward_coins": 36,
"grow_seconds": 120,
"locked": False,
"market_state": "normal",
}
],
"plots": [{"slot": 0, "state": "empty"}],
"crops": [{"key": "python", "name": "Python Script", "cost": 15}],
},
},
),
@ -100,41 +50,16 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="GET",
path="/game/leaderboard",
title="Farm leaderboard",
summary="Top 25 farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid over 30 days, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running). Cached about 15 seconds.",
summary="Top farmers ranked by level, XP, and harvests.",
auth="public",
params=[
field(
"board",
"query",
"string",
False,
"score",
"Leaderboard board key.",
options=BOARD_KEYS,
)
],
sample_response={
"entries": [
{
"rank": 1,
"username": "alice",
"level": 4,
"xp": 600,
"coins": 240,
"total_harvests": 52,
"prestige": 1,
"score": 6120,
"title": "The Architect",
}
]
},
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
),
endpoint(
id="game-view-farm",
method="GET",
path="/game/farm/{username}",
title="View a farm",
summary="Another player's farm, with per-plot can_water/can_steal flags computed for the viewer.",
summary="Another player's farm, with water controls on growing builds.",
auth="public",
negotiation=True,
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
@ -145,11 +70,11 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/plant",
title="Plant a crop",
summary="Plant a crop in an empty plot. Costs the crop's live coin price (the cost field in the farm state's crops list).",
summary="Plant a crop in an empty plot. Costs the crop's coin price.",
auth="user",
params=[
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
field("crop", "form", "string", True, "python", "Crop key.", options=CROP_KEYS),
field("slot", "form", "integer", True, "0", "Plot slot index."),
field("crop", "form", "string", True, "python", "Crop key."),
],
sample_response={"ok": True, "farm": {"coins": 35}},
),
@ -158,9 +83,9 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/harvest",
title="Harvest a build",
summary="Harvest a finished (state ready) build for coins and XP.",
summary="Harvest a finished build for coins and XP.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 86}},
),
endpoint(
@ -168,7 +93,7 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/buy-plot",
title="Buy a plot",
summary="Unlock a new plot (up to 12). Cost starts at 100 coins and doubles per extra plot; the exact price is the farm state's next_plot_cost.",
summary="Unlock a new plot. Cost doubles per extra plot.",
auth="user",
sample_response={"ok": True, "farm": {"plot_count": 5}},
),
@ -177,7 +102,7 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/upgrade",
title="Upgrade CI",
summary="Upgrade the farm CI tier for faster builds (up to tier 5); the exact price is the farm state's ci_next_cost.",
summary="Upgrade the farm CI tier for faster builds.",
auth="user",
sample_response={"ok": True, "farm": {"ci_tier": 2}},
),
@ -186,11 +111,11 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/farm/{username}/water",
title="Water a build",
summary="Water another player's growing build to cut 8% off its build time; pays the visitor 6 coins scaled by their own prestige and Tech Debt Payoff multiplier, plus 3 XP. Once per visitor per build, 3 waterings per build total.",
summary="Water another player's growing build to speed it up and earn coins.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}},
),
@ -199,11 +124,11 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/farm/{username}/steal",
title="Steal a build",
summary="Raid another player's ready build once its protection window has passed. You take a share of the build's realized coin value (half by default, less against defended owners - the plot's steal_coins field is the exact payout) and the owner keeps and can still harvest the remainder; the plot records the share taken as raided_fraction. Limited to once per hour per neighbour and 3 raids per victim per day; a fully stripped build reports steal_reason stripped, and Security Fortress builds are immune.",
summary="Steal another player's ready build once its protection window has passed; you receive half the build's coin value. Limited to once per hour per neighbour.",
auth="user",
params=[
field("username", "path", "string", True, "alice", "Farm owner's username."),
field("slot", "form", "integer", True, "0", "Plot slot index, 0-based."),
field("slot", "form", "integer", True, "0", "Plot slot index."),
],
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
),
@ -212,9 +137,9 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/fertilize",
title="Fertilize a build",
summary="Spend coins to halve a growing build's remaining time (the plot's fertilize_cost field is the exact price). The price is computed from the exact value this build will pay out - including the golden multiplier and any active contract, Underdog, or Canary upside - so fertilizing is a pure time-skip and never a profit, on any build, at any prestige, with any combination of boosts.",
summary="Spend coins to halve a growing build's remaining time. The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.",
auth="user",
params=[field("slot", "form", "integer", True, "0", "Plot slot index, 0-based.")],
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
sample_response={"ok": True, "farm": {"coins": 12}},
),
endpoint(
@ -222,7 +147,7 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/daily",
title="Claim daily bonus",
summary="Claim the once-per-UTC-day coin bonus; consecutive days grow a streak (20 coins on day one up to 92 from day seven on), scaled by your own prestige and Tech Debt Payoff multiplier. A lapsed streak resets to day one - the farm state's daily_streak_reset flag and daily_reward already reflect that.",
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.",
auth="user",
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
),
@ -231,9 +156,9 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/perk",
title="Upgrade a perk",
summary="Upgrade a permanent perk with coins: yield (+5% harvest coins), growth (+4% build speed), discount (-3% planting cost), or xp (+5% harvest XP) per level. Perks reset on refactor.",
summary="Upgrade a permanent perk: yield, growth, discount, or xp.",
auth="user",
params=[field("perk", "form", "string", True, "growth", "Perk key.", options=PERK_KEYS)],
params=[field("perk", "form", "string", True, "growth", "Perk key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
@ -241,20 +166,9 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/quests/claim",
title="Claim a quest",
summary="Claim a completed daily quest by its kind, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a 48-hour +20% coin boost instead of coins.",
summary="Claim a completed daily quest reward by its kind.",
auth="user",
params=[
field("quest", "form", "string", True, "harvest", "Quest kind.", options=QUEST_KINDS),
field(
"scope",
"form",
"string",
False,
"daily",
"daily (default) or weekly.",
options=QUEST_SCOPES,
),
],
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
sample_response={"ok": True, "farm": {"coins": 130}},
),
endpoint(
@ -262,137 +176,20 @@ client are on the [Code Farm guide](/docs/code-farm.html).
method="POST",
path="/game/prestige",
title="Refactor (prestige)",
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, up to 35% with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.",
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.",
auth="user",
destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}},
),
endpoint(
id="game-grant",
method="POST",
path="/game/grant",
title="Claim the community grant",
summary="Claim the weekly community grant, paid from the treasury filled by refactor fees and divided between everyone currently eligible (capped at 2500 coins, suppressed below 250). Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.",
auth="user",
sample_response={"ok": True, "farm": {"coins": 2550}},
sample_response={"ok": True, "farm": {"prestige": 1}},
),
endpoint(
id="game-legacy",
method="POST",
path="/game/legacy",
title="Buy a Legacy upgrade",
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest (CI Bot), multiplier (+10% coins/level), speed (+5% build speed/level), plots (+1 starting plot/level), defense (+30s grace, -5% steal loss/level), or carryover (Golden Parachute, +5% refactor carry-over/level).",
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.",
auth="user",
params=[
field(
"key",
"form",
"string",
True,
"multiplier",
"Legacy upgrade key.",
options=LEGACY_KEYS,
)
],
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
sample_response={"ok": True, "farm": {"stars": 1}},
),
endpoint(
id="game-mastery",
method="POST",
path="/game/mastery",
title="Buy a Mastery upgrade",
summary="Spend Mastery points (earned at prestige 50 and every 5 prestige after) on a permanent Mastery upgrade: autoreplant (Continuous Delivery, 3 points), analytics (Farm Analytics, 2 points), or contracts (Legacy Contracts, 4 points).",
auth="user",
params=[
field(
"key",
"form",
"string",
True,
"autoreplant",
"Mastery upgrade key.",
options=MASTERY_KEYS,
)
],
sample_response={"ok": True, "farm": {"mastery_points": 0}},
),
endpoint(
id="game-infrastructure-buy",
method="POST",
path="/game/infrastructure/buy",
title="Buy Infrastructure",
summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (Rust/Compiler/Kernel build 15% faster; 3M coins, prestige 3), canary (12% chance to double a harvest, 6% to only refund its planting cost; 6M, prestige 8), or observability (caps what any raider can take from you at 20% of a build's value; 15M, prestige 15).",
auth="user",
params=[
field(
"key",
"form",
"string",
True,
"registry",
"Infrastructure key.",
options=INFRA_KEYS,
)
],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-defense-upgrade",
method="POST",
path="/game/defense/upgrade",
title="Upgrade Defense",
summary="Buy the next Defense tier (Firewall through Zero Trust Mesh; the farm state's defense_next_cost is the exact price). Each tier multiplicatively reduces what a raider takes from you and adds steal grace, but adds an ongoing daily coin upkeep of max(tier minimum, 0.2% of your balance). If you cannot pay, the tier decays by one level and only what you can afford is taken - your balance is never emptied.",
auth="user",
sample_response={"ok": True, "farm": {"defense_level": 1}},
),
endpoint(
id="game-defense-downgrade",
method="POST",
path="/game/defense/downgrade",
title="Downgrade Defense",
summary="Drop your Defense down one tier to escape its daily upkeep. There is no refund. Available whenever defense_downgrade_available is true in the farm state.",
auth="user",
sample_response={"ok": True, "farm": {"defense_level": 0}},
),
endpoint(
id="game-cosmetics-buy",
method="POST",
path="/game/cosmetics/buy",
title="Buy a cosmetic",
summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect. The farm state's cosmetics list carries each key, cost, and an owned flag.",
auth="user",
params=[
field(
"key",
"form",
"string",
True,
"title_architect",
"Cosmetic key.",
options=COSMETIC_KEYS,
)
],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-cosmetics-equip",
method="POST",
path="/game/cosmetics/equip",
title="Equip a title",
summary="Equip an owned title cosmetic so its display name shows next to your name on the leaderboard.",
auth="user",
params=[
field(
"key",
"form",
"string",
True,
"title_architect",
"An owned title cosmetic key.",
options=COSMETIC_KEYS,
)
],
sample_response={"ok": True, "farm": {"active_title": "title_architect"}},
),
],
}

View File

@ -134,7 +134,7 @@ for signing DevPlace's own requests.
path="/openai/v1/chat/completions",
title="Chat completions",
summary="OpenAI-compatible chat completion. Supports streaming.",
auth="user",
auth="public",
encoding="json",
params=[
field(
@ -174,7 +174,7 @@ for signing DevPlace's own requests.
path="/openai/v1/embeddings",
title="Embeddings",
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
auth="user",
auth="public",
encoding="json",
params=[
field(
@ -214,7 +214,7 @@ for signing DevPlace's own requests.
path="/openai/v1/images/generations",
title="Image generation",
summary="OpenAI-compatible image generation. Request model molodetz-img-small.",
auth="user",
auth="public",
encoding="json",
params=[
field(
@ -262,7 +262,7 @@ for signing DevPlace's own requests.
path="/openai/v1/{path}",
title="Passthrough",
summary="Any other /v1 path is forwarded to the upstream as-is.",
auth="user",
auth="public",
interactive=False,
params=[
field(

View File

@ -57,9 +57,9 @@ four ways to sign requests.
"content",
"form",
"textarea",
False,
True,
"Hello there.",
"Body, 0-2000 characters. May be empty when at least one attachment is provided.",
"Body, 1-2000 characters.",
),
field(
"receiver_uid",
@ -71,38 +71,5 @@ four ways to sign requests.
),
],
),
endpoint(
id="messages-conversations",
method="GET",
path="/messages/conversations",
title="List conversations",
summary="Return the signed-in user's conversation list as JSON, for live refresh without a full page reload.",
auth="user",
interactive=False,
sample_response={
"conversations": [
{
"other_user": {"uid": "8f14e45f-...", "username": "alice_test"},
"last_message": "Hello there.",
"last_message_at": "2026-07-21T10:00:00+00:00",
"unread": True,
}
]
},
),
endpoint(
id="messages-ws-ticket",
method="POST",
path="/messages/ws-ticket",
title="Issue a WebSocket ticket",
summary="Exchange the caller's session/API-key auth for a short-lived, single-use ticket that a browser WebSocket handshake can carry as a query parameter (a native WebSocket cannot set custom auth headers).",
auth="user",
encoding="none",
interactive=False,
notes=[
"The ticket is valid for 30 seconds and can be redeemed exactly once, as `wss://.../messages/ws?ticket=<ticket>`.",
],
sample_response={"ticket": "3f9c2a...", "expires_in": 30},
),
],
}

View File

@ -1,489 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
from devplacepy.database.moderation import (
CONSENT_KINDS,
MODERATION_ACTIONS,
REPORTABLE_TARGETS,
REPORT_REASONS,
REPORT_STATUSES,
)
REPORT_TARGETS = list(REPORTABLE_TARGETS)
REASON_KEYS = list(REPORT_REASONS)
CONSENT_KEYS = list(CONSENT_KINDS)
SAMPLE_REPORT = {
"uid": "REPORT_UID",
"target_type": "post",
"target_uid": "POST_UID",
"target_url": "/posts/a-post",
"reason": "harassment",
"reason_label": "Harassment or bullying",
"detail": "Repeated personal attacks in the thread.",
"severity": "warn",
"status": "open",
"origin": "member",
"categories": [],
"created_at": "2026-01-05T10:00:00+00:00",
"resolved_at": "",
"reporter_name": "alice",
"owner_name": "bob",
"report_count": 2,
}
GROUP = {
"slug": "moderation",
"title": "Reporting & Moderation",
"intro": """
# Reporting & Moderation
Every externally visible surface on DevPlace is reportable through one polymorphic
endpoint, and every report lands in one queue with one state machine. The reason
list is served by `GET /reports/reasons`, so a native client renders the same
dialog the web UI does.
DevPlace commits to reviewing every report within the window published on the
[content moderation](/docs/content-moderation.html) page. Filing a report always
returns an acknowledgement carrying that window.
The moderation endpoints under `/admin/moderation` are administrator-only and are
subject to the admin seniority rule: a junior administrator cannot action a more
senior one.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
"endpoints": [
endpoint(
id="report-reasons",
method="GET",
path="/reports/reasons",
title="List report reasons",
summary="The reason keys a report may be filed under, with their labels.",
auth="public",
sample_response={
"reasons": [{"key": "harassment", "label": "Harassment or bullying"}],
"severities": ["info", "warn", "critical"],
},
),
endpoint(
id="report-create",
method="POST",
path="/reports/{target_type}/{target_uid}",
title="Report content",
summary="File a report against any user-generated surface.",
auth="user",
encoding="form",
destructive=False,
params=[
field(
"target_type",
"path",
"enum",
True,
"post",
"The kind of content being reported.",
REPORT_TARGETS,
),
field(
"target_uid",
"path",
"string",
True,
"POST_UID",
"UID of the reported item.",
),
field(
"reason",
"form",
"enum",
True,
"harassment",
"Why the content breaks the guidelines.",
REASON_KEYS,
),
field(
"detail",
"form",
"string",
False,
"",
"Free text for the moderator, up to 2000 characters.",
),
],
notes=[
"A second report on the same target by the same reporter updates the "
"open report instead of creating a duplicate.",
"You cannot report your own content.",
],
sample_response={
"ok": True,
"redirect": "/reports/mine",
"data": {
"uid": "REPORT_UID",
"status": "open",
"severity": "warn",
"sla_hours": 24,
},
},
),
endpoint(
id="reports-mine",
method="GET",
path="/reports/mine",
title="List your reports",
summary="The reports you filed and the outcome of each.",
auth="user",
params=[
field(
"status",
"query",
"enum",
False,
"open",
"Filter by report status.",
list(REPORT_STATUSES),
),
field("page", "query", "integer", False, "1", "Page number."),
],
sample_response={
"reports": [SAMPLE_REPORT],
"pagination": {"page": 1, "total": 1, "total_pages": 1},
"status": "",
},
),
endpoint(
id="admin-moderation",
method="GET",
path="/admin/moderation",
title="The moderation queue",
summary="Reported content awaiting a decision, oldest open first.",
auth="admin",
params=[
field(
"status",
"query",
"enum",
False,
"open",
"Filter by report status.",
list(REPORT_STATUSES),
),
field("page", "query", "integer", False, "1", "Page number."),
],
sample_response={
"reports": [SAMPLE_REPORT],
"counts": {"open": 1, "acknowledged": 0, "actioned": 0, "dismissed": 0},
"sla": {
"sla_hours": 24,
"oldest_open_hours": 1.5,
"breached": 0,
"within_sla": True,
},
},
),
endpoint(
id="admin-moderation-report",
method="GET",
path="/admin/moderation/{uid}",
title="Read one report",
summary="One report with its decisions and the author's history.",
auth="admin",
params=[
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
],
sample_response={
"report": SAMPLE_REPORT,
"actions": [],
"history": [],
"available_actions": list(MODERATION_ACTIONS),
"can_remove": True,
},
),
endpoint(
id="admin-moderation-status",
method="POST",
path="/admin/moderation/{uid}/status",
title="Set a report status",
summary="Move a report through the triage state machine.",
auth="admin",
encoding="form",
params=[
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
field(
"status",
"form",
"enum",
True,
"acknowledged",
"New status.",
list(REPORT_STATUSES),
),
],
sample_response={"ok": True, "redirect": "/admin/moderation/REPORT_UID"},
),
endpoint(
id="admin-moderation-decide",
method="POST",
path="/admin/moderation/{uid}/decide",
title="Decide a report",
summary="Apply a moderation decision and notify the affected user.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
field(
"action",
"form",
"enum",
True,
"dismiss",
"The decision to apply.",
list(MODERATION_ACTIONS),
),
field(
"reason",
"form",
"string",
False,
"",
"Reason shown to the affected user.",
),
field("notes", "form", "string", False, "", "Internal notes."),
field(
"duration_hours",
"form",
"integer",
False,
"24",
"Suspension length in hours.",
),
],
notes=[
"A report already resolved by another moderator answers 409.",
"Content removal is unavailable for targets that have no removal "
"path (direct messages, accounts, workspaces, polls, assistant "
"output); act on the account instead.",
],
sample_response={"ok": True, "redirect": "/admin/moderation/REPORT_UID"},
),
endpoint(
id="admin-user-suspend",
method="POST",
path="/admin/users/{uid}/suspend",
title="Suspend an account",
summary="Suspend an account for a fixed period with a stated reason.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "USER_UID", "User UID."),
field("reason", "form", "string", False, "", "Reason shown to the user."),
field(
"duration_hours",
"form",
"integer",
False,
"24",
"Suspension length in hours.",
),
],
notes=[
"A suspended account can still read, still see why, and still delete "
"itself, but cannot create content.",
],
sample_response={"ok": True, "redirect": "/admin/users"},
),
endpoint(
id="admin-user-lift",
method="POST",
path="/admin/users/{uid}/lift",
title="Lift a restriction",
summary="Clear a suspension or ban and restore the account.",
auth="admin",
encoding="form",
params=[field("uid", "path", "string", True, "USER_UID", "User UID.")],
sample_response={"ok": True, "redirect": "/admin/users"},
),
endpoint(
id="admin-user-ban",
method="POST",
path="/admin/users/{uid}/ban",
title="Ban an account",
summary="Permanently close an account and revoke every credential.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "USER_UID", "User UID."),
field("reason", "form", "string", False, "", "Reason shown to the user."),
],
sample_response={"ok": True, "redirect": "/admin/users"},
),
endpoint(
id="auth-accept-terms-page",
method="GET",
path="/auth/accept-terms",
title="The terms acceptance page",
summary="The Terms of Service version in force and the version this account accepted.",
auth="user",
sample_response={"terms_version": "1", "accepted_version": ""},
),
endpoint(
id="auth-accept-terms",
method="POST",
path="/auth/accept-terms",
title="Accept the terms",
summary="Record acceptance of the Terms of Service version in force.",
auth="user",
encoding="form",
notes=[
"A member whose accepted version is behind the version in force is "
"redirected here on any mutating request. Reading, the docs, the "
"safety controls and account deletion are never blocked.",
],
sample_response={"ok": True, "redirect": "/feed", "data": {"terms_version": "1"}},
),
endpoint(
id="profile-consent",
method="POST",
path="/profile/{username}/consent",
title="Grant or withdraw a consent",
summary="Change one consent on your own account. Withdrawal takes effect at once.",
auth="user",
encoding="form",
params=[
field("username", "path", "string", True, "USERNAME", "Your own username."),
field(
"kind",
"form",
"enum",
True,
"ai_third_party",
"Consent to change.",
CONSENT_KEYS,
),
field("granted", "form", "enum", True, "1", "1 grants, 0 withdraws.", ["1", "0"]),
],
notes=[
"Withdrawing `ai_third_party` makes the AI gateway refuse every call "
"that would send your own content to the provider, whatever the "
"per-feature preference says.",
"Withdrawing `activity_recording` stops presence writes; you simply "
"appear offline.",
"Only the account holder can change a consent. An administrator "
"reads the record but never grants or withdraws it for someone else.",
],
sample_response={
"ok": True,
"redirect": "/profile/USERNAME?tab=privacy",
"data": {"kind": "ai_third_party", "state": "granted"},
},
),
endpoint(
id="profile-mature-content",
method="POST",
path="/profile/{username}/mature-content",
title="Set the mature-content preference",
summary="Show or hide content labelled mature for your own account.",
auth="user",
encoding="form",
params=[
field("username", "path", "string", True, "USERNAME", "Your own username."),
field(
"mature_opt_in",
"form",
"enum",
True,
"1",
"1 shows mature content, 0 hides it.",
["1", "0"],
),
],
notes=[
"Only the account holder can change this preference. An administrator "
"reads the privacy tab but never sets it for someone else.",
],
sample_response={
"ok": True,
"redirect": "/profile/USERNAME?tab=privacy",
"data": {"mature_opt_in": True},
},
),
endpoint(
id="profile-delete",
method="GET",
path="/profile/{username}/delete",
title="Account deletion page",
summary="What deletion removes, what is retained, and the grace window.",
auth="user",
params=[
field("username", "path", "string", True, "USERNAME", "Your own username."),
],
sample_response={
"username": "USERNAME",
"grace_hours": 24,
"removed": ["Your account record, username, email address and password"],
"retained": ["Append-only audit and moderation records"],
},
),
endpoint(
id="profile-delete-confirm",
method="POST",
path="/profile/{username}/delete",
title="Delete your account",
summary="Permanently delete your account and personal data.",
auth="user",
encoding="form",
destructive=True,
params=[
field("username", "path", "string", True, "USERNAME", "Your own username."),
field("password", "form", "string", True, "PASSWORD", "Your account password."),
],
notes=[
"Only the account holder can delete an account; an administrator uses "
"a ban instead.",
"Sessions and tokens are revoked and the profile is anonymised "
"immediately; the deletion event is purged after the grace window.",
],
sample_response={
"ok": True,
"redirect": "/",
"data": {"stamp": "2026-01-05T10:00:00+00:00", "rows": 42, "grace_hours": 24},
},
),
endpoint(
id="workspaces-index",
method="GET",
path="/workspaces/index",
title="Published workspace index",
summary="Every workspace published to the public ingress, with its link.",
auth="public",
params=[field("page", "query", "integer", False, "1", "Page number.")],
notes=[
"The project-derived `description` and `project_url` come back empty "
"unless you may view the workspace's project, so a private project "
"never leaks its title or description through this public listing.",
],
sample_response={
"workspaces": [
{
"uid": "INSTANCE_UID",
"name": "demo",
"slug": "demo",
"owner_uid": "USER_UID",
"url": "{{ base }}/p/demo",
"description": "A demo workspace.",
"owner": "alice",
"maturity": "general",
"project_url": "/projects/demo",
}
],
"total": 1,
},
),
],
}

View File

@ -41,12 +41,9 @@ 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, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.",
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). 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",
@ -323,7 +320,7 @@ four ways to sign requests.
),
field(
"description",
"json",
"body",
"string",
True,
"Great work on the release!",
@ -796,4 +793,3 @@ four ways to sign requests.
),
],
}

View File

@ -8,15 +8,8 @@ GROUP = {
"intro": """
# Web Push
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.
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
register a `PushSubscription` obtained from the browser's `PushManager`.
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
@ -33,60 +26,39 @@ four ways to sign requests.
method="GET",
path="/push.json",
title="Get the public key",
summary="Return the VAPID public key and the providers that accept registrations.",
summary="Return the VAPID public key for subscribing.",
auth="public",
sample_response={
"publicKey": "BASE64_VAPID_KEY",
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
},
sample_response={"publicKey": "BASE64_VAPID_KEY"},
),
endpoint(
id="push-register",
method="POST",
path="/push.json",
title="Register a subscription",
summary="Register a push subscription. Sends a welcome notification.",
summary="Register a browser 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",
False,
True,
"https://fcm.googleapis.com/...",
"Subscription endpoint URL. Required for webpush.",
"Subscription endpoint URL.",
),
field(
"keys",
"json",
"string",
False,
True,
'{"p256dh":"...","auth":"..."}',
"Subscription keys object. Required for webpush.",
),
field(
"token",
"json",
"string",
False,
"a1b2c3...",
"Hexadecimal device token. Required for apns.",
"Subscription keys object.",
),
],
notes=[
'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.",
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
],
sample_response={"registered": True},
),

View File

@ -1,556 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.quiz import scoring
from .._shared import endpoint, field
KIND_KEYS = list(scoring.KIND_KEYS)
FILTER_KEYS = ["all", "todo", "done", "mine", "drafts"]
STATUS_KEYS = ["draft", "published"]
GRADED_BY_KEYS = ["auto", "ai", "fallback"]
VIEWER_STATES = ["todo", "in_progress", "done"]
SAMPLE_QUIZ = {
"uid": "0198f2c0-1111-7aaa-8bbb-000000000001",
"slug": "8bbb000000000001-sqlite-fundamentals",
"url": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"title": "SQLite fundamentals",
"status": "published",
"question_count": 10,
"total_points": 14,
"attempt_count": 23,
"time_limit_seconds": 900,
"pass_percent": 70,
"viewer_owns": False,
"viewer_can_edit": False,
"viewer_can_play": True,
"viewer_state": "todo",
"validation_errors": [],
}
GROUP = {
"slug": "quizzes",
"title": "Quizzes",
"intro": """
# Quizzes
A quiz is user-generated content like a gist or a project: it has an owner, a slug, comments,
votes, bookmarks and reactions. Any signed-in member authors quizzes, every member plays them,
and guests read published ones.
**Publishing is terminal.** A draft is fully editable; the moment its owner publishes it, the
quiz, its questions and its options are frozen forever. There is no unpublish and no
post-publish edit, which is what makes two members' scores on the same quiz comparable. Every
write endpoint on a published quiz returns `400`; only delete still works. Publish validates
the whole quiz first and refuses with the exact list of problems.
Playing a quiz creates an **attempt**. There is at most one in-progress attempt per member per
quiz - starting again returns the existing one. Each question can be answered exactly once; a
second submit returns `400` and credits nothing. A time limit is stored on the attempt and
evaluated lazily on read, so an expired attempt reads as `expired` with no background process
involved.
Seven question kinds are graded deterministically. The eighth, `free_text`, is graded by the
internal AI gateway against the author's criteria and billed to the answering member's own API
key. When the gateway is unavailable the answer is still graded, by a deterministic
token-overlap fallback, and the answer carries `graded_by: "fallback"` so the degradation is
visible rather than silent. `graded_by` is one of `auto`, `ai`, `fallback`.
**Correct answers are never served to a player mid-attempt.** `is_correct` on the options and
`correct_boolean` / `expected_answer` / `numeric_value` / `match_value` on the question are
omitted unless the viewer owns the quiz, or the question has already been answered in this
attempt and the quiz has `reveal_answers` on. A public export of a published quiz omits them
too; the owner's export includes them.
The **scoreboard** at `/quizzes/scoreboard` sums each member's **best** completed attempt per
quiz, never the sum of all attempts, so replaying a quiz can raise a member's contribution to
their personal best and never beyond it. Quizzes a member wrote themselves count like any
other.
All endpoints negotiate HTML or JSON. POST bodies are form encoded
(`application/x-www-form-urlencoded`). Action POSTs answer `{"ok": true, "redirect": "...",
"data": {...}}`; an invalid domain operation answers `400` as
`{"error": {"status": 400, "message": "..."}}`.
""",
"endpoints": [
endpoint(
id="quizzes-list",
method="GET",
path="/quizzes",
title="Quiz hub",
summary=(
"Published quizzes with the viewer's per-quiz state, the filter counts and "
"the cross-quiz scoreboard."
),
auth="public",
negotiation=True,
params=[
field("search", "query", "string", False, "sqlite", "Match the title, description or author username."),
field("filter", "query", "enum", False, "all", "Which quizzes to list.", options=FILTER_KEYS),
field("page", "query", "integer", False, "1", "1-based page number."),
],
sample_response={
"quizzes": [
{
**SAMPLE_QUIZ,
"viewer_best_percent": 0.0,
"comment_count": 3,
"stars": 5,
}
],
"filter": "all",
"counts": {"all": 12, "todo": 9, "done": 3, "mine": 2, "drafts": 1},
"pagination": {"page": 1, "total": 12, "total_pages": 1},
"scoreboard": [
{"rank": 1, "user": {"username": "alice"}, "total_points": 84.0,
"quizzes_completed": 7, "avg_percent": 88.4, "perfect_count": 2}
],
"viewer_can_create": True,
},
),
endpoint(
id="quizzes-scoreboard",
method="GET",
path="/quizzes/scoreboard",
title="Quiz scoreboard",
summary=(
"Score per user across every published quiz, counting each member's best "
"attempt per quiz. Cached about 15 seconds."
),
auth="public",
params=[
field("limit", "query", "integer", False, "20", "How many entries to return, up to 100."),
],
sample_response={
"scoreboard": [
{"rank": 1, "user": {"username": "alice"}, "total_points": 84.0,
"quizzes_completed": 7, "avg_percent": 88.4, "perfect_count": 2}
],
"viewer_standing": None,
"limit": 20,
},
),
endpoint(
id="quizzes-new",
method="GET",
path="/quizzes/new",
title="New quiz form",
summary="The create form behind the New quiz button.",
auth="user",
negotiation=True,
sample_response={"viewer_can_create": True},
),
endpoint(
id="quizzes-create",
method="POST",
path="/quizzes/create",
title="Create a quiz",
summary="Create a draft quiz. Add its questions afterwards, then publish it.",
auth="user",
encoding="form",
params=[
field("title", "form", "string", True, "SQLite fundamentals", "3 to 200 characters."),
field("description", "form", "string", False, "Ten questions on WAL.", "Markdown, up to 5000 characters."),
field("shuffle_questions", "form", "boolean", False, "1", "Shuffle the question order per attempt."),
field("shuffle_options", "form", "boolean", False, "1", "Shuffle the answer options."),
field("reveal_answers", "form", "boolean", False, "1", "Reveal the correct answer after each question."),
field("allow_review", "form", "boolean", False, "1", "Allow reviewing every answer on the results screen."),
field("time_limit_seconds", "form", "integer", False, "900", "0 for no limit, up to 86400."),
field("pass_percent", "form", "integer", False, "70", "0 to 100, 0 for no pass or fail verdict."),
],
sample_response={
"ok": True,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
"data": {"uid": SAMPLE_QUIZ["uid"], "slug": SAMPLE_QUIZ["slug"]},
},
),
endpoint(
id="quizzes-import",
method="POST",
path="/quizzes/import",
title="Import a quiz document",
summary=(
"Create a complete quiz - metadata, settings, every question and every option - "
"from one JSON document. Capped at 100 questions and 12 options per question."
),
auth="user",
encoding="form",
params=[
field(
"document",
"form",
"string",
True,
'{"title": "SQLite fundamentals", "questions": [{"kind": "single_choice", "prompt": "Which journal mode allows concurrent readers?", "options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": true}]}]}',
"The complete quiz as a JSON string. See the export endpoint for the exact shape.",
),
],
sample_response={
"ok": True,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
"data": {"uid": SAMPLE_QUIZ["uid"], "slug": SAMPLE_QUIZ["slug"], "question_count": 10},
},
),
endpoint(
id="quizzes-detail",
method="GET",
path="/quizzes/{slug}",
title="Quiz detail",
summary=(
"One quiz with its stats, its leaderboard, its comments and the viewer's own "
"state. A draft is visible only to its owner and to administrators."
),
auth="public",
negotiation=True,
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
sample_response={
"quiz": SAMPLE_QUIZ,
"leaderboard": [],
"comments": [],
"viewer_state": "todo",
"star_count": 5,
},
),
endpoint(
id="quizzes-export",
method="GET",
path="/quizzes/{slug}/export",
title="Export a quiz",
summary=(
"The full quiz document, the exact inverse of the import endpoint. The owner "
"gets every correct answer; everyone else gets the questions without the key."
),
auth="public",
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
sample_response={
"title": "SQLite fundamentals",
"description": "Ten questions on WAL, indexing and transactions.",
"settings": {"shuffle_questions": True, "reveal_answers": True,
"pass_percent": 70, "time_limit_seconds": 900},
"questions": [
{
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers and one writer?",
"points": 1,
"options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
}
],
},
),
endpoint(
id="quizzes-leaderboard",
method="GET",
path="/quizzes/{slug}/leaderboard",
title="Quiz leaderboard",
summary="Top completed attempts on one quiz, best percentage first.",
auth="public",
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("limit", "query", "integer", False, "25", "How many entries to return, up to 100."),
],
sample_response={
"quiz_uid": SAMPLE_QUIZ["uid"],
"entries": [
{"rank": 1, "user": {"username": "bob"}, "score_points": 13.0,
"score_percent": 92.86, "passed": True, "completed_at": "2026-07-25T10:00:00+00:00"}
],
},
),
endpoint(
id="quizzes-builder",
method="GET",
path="/quizzes/{slug}/edit",
title="Quiz builder",
summary=(
"The owner's builder page: the quiz, every question with its answer key, the "
"question-kind catalogue and the live pre-publish checklist."
),
auth="user",
negotiation=True,
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
sample_response={
"quiz": SAMPLE_QUIZ,
"questions": [],
"kinds": [{"key": "single_choice", "label": "Single choice", "has_options": True}],
"validation_errors": ["Add at least one question before publishing."],
},
),
endpoint(
id="quizzes-edit",
method="POST",
path="/quizzes/edit/{slug}",
title="Edit a quiz",
summary="Change the title, description and settings of a DRAFT quiz. 400 once published.",
auth="user",
encoding="form",
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("title", "form", "string", True, "SQLite fundamentals", "3 to 200 characters."),
field("description", "form", "string", False, "Updated description.", "Markdown, up to 5000 characters."),
field("shuffle_questions", "form", "boolean", False, "1", "Shuffle the question order per attempt."),
field("shuffle_options", "form", "boolean", False, "1", "Shuffle the answer options."),
field("reveal_answers", "form", "boolean", False, "1", "Reveal the correct answer after each question."),
field("allow_review", "form", "boolean", False, "1", "Allow reviewing every answer on the results screen."),
field("time_limit_seconds", "form", "integer", False, "900", "0 for no limit, up to 86400."),
field("pass_percent", "form", "integer", False, "70", "0 to 100, 0 for no pass or fail verdict."),
],
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals"},
),
endpoint(
id="quizzes-publish",
method="POST",
path="/quizzes/{slug}/publish",
title="Publish a quiz",
summary=(
"IRREVERSIBLE. Freezes the quiz, its questions and its options forever. "
"Refuses with the validation problems when the quiz is incomplete."
),
auth="user",
encoding="form",
destructive=True,
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
sample_response={
"ok": True,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals",
"data": {"uid": SAMPLE_QUIZ["uid"], "status": "published"},
},
),
endpoint(
id="quizzes-delete",
method="POST",
path="/quizzes/delete/{slug}",
title="Delete a quiz",
summary=(
"Owner or administrator. Removes the quiz with its questions, options, "
"attempts and answers. The only operation left on a published quiz."
),
auth="user",
encoding="form",
destructive=True,
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
sample_response={"ok": True, "redirect": "/quizzes"},
),
endpoint(
id="quizzes-question-add",
method="POST",
path="/quizzes/{slug}/questions",
title="Add a question",
summary="Append one question with its options to a DRAFT quiz. 400 once published.",
auth="user",
encoding="form",
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("kind", "form", "enum", True, "single_choice", "The question kind.", options=KIND_KEYS),
field("prompt", "form", "string", True, "Which journal mode allows concurrent readers?", "Markdown, up to 2000 characters."),
field("points", "form", "integer", False, "1", "1 to 100."),
field("explanation", "form", "string", False, "WAL keeps readers off the writer's lock.", "Shown after answering."),
field("options", "form", "string", False, "DELETE\nWAL\nMEMORY", "Option labels, one per line or comma separated."),
field("match_values", "form", "string", False, "", "Accepted answers aligned with the options, for fill_blank and matching."),
field("correct_indexes", "form", "string", True, "1", "0-based indexes of the correct options, comma separated. Required for choice questions."),
field("correct_boolean", "form", "boolean", False, "1", "true_false only: the statement is true."),
field("expected_answer", "form", "string", False, "", "free_text only: the reference answer."),
field("grading_criteria", "form", "string", False, "", "free_text only: criteria for the AI reviewer."),
field("numeric_value", "form", "number", False, "0", "numeric only: the correct value."),
field("numeric_tolerance", "form", "number", False, "0", "numeric only: accepted absolute tolerance."),
field("case_sensitive", "form", "boolean", False, "0", "fill_blank only: compare case sensitively."),
],
sample_response={
"ok": True,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit",
"data": {"uid": "0198f2c0-2222-7aaa-8bbb-000000000002", "position": 0},
},
),
endpoint(
id="quizzes-question-edit",
method="POST",
path="/quizzes/{slug}/questions/{question_uid}",
title="Edit a question",
summary="Replace one question and its options on a DRAFT quiz. 400 once published.",
auth="user",
encoding="form",
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("question_uid", "path", "string", True, "0198f2c0-2222-7aaa-8bbb-000000000002", "The question uid."),
field("kind", "form", "enum", True, "single_choice", "The question kind.", options=KIND_KEYS),
field("prompt", "form", "string", True, "Which journal mode allows concurrent readers?", "Markdown, up to 2000 characters."),
field("points", "form", "integer", False, "1", "1 to 100."),
field("explanation", "form", "string", False, "", "Shown after answering."),
field("options", "form", "string", False, "DELETE\nWAL\nMEMORY", "Option labels, one per line or comma separated."),
field("match_values", "form", "string", False, "", "Accepted answers aligned with the options."),
field("correct_indexes", "form", "string", True, "1", "0-based indexes of the correct options."),
field("correct_boolean", "form", "boolean", False, "1", "true_false only."),
field("expected_answer", "form", "string", False, "", "free_text only."),
field("grading_criteria", "form", "string", False, "", "free_text only."),
field("numeric_value", "form", "number", False, "0", "numeric only."),
field("numeric_tolerance", "form", "number", False, "0", "numeric only."),
field("case_sensitive", "form", "boolean", False, "0", "fill_blank only."),
],
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"},
),
endpoint(
id="quizzes-question-delete",
method="POST",
path="/quizzes/{slug}/questions/{question_uid}/delete",
title="Delete a question",
summary="Remove one question and its options from a DRAFT quiz, then renumber.",
auth="user",
encoding="form",
destructive=True,
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("question_uid", "path", "string", True, "0198f2c0-2222-7aaa-8bbb-000000000002", "The question uid."),
],
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"},
),
endpoint(
id="quizzes-question-reorder",
method="POST",
path="/quizzes/{slug}/questions/reorder",
title="Reorder the questions",
summary="Set a new question order on a DRAFT quiz. Every uid must be listed exactly once.",
auth="user",
encoding="form",
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("order", "form", "string", True, "uid-b,uid-a,uid-c", "Every question uid in the wanted order, comma separated."),
],
sample_response={"ok": True, "redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/edit"},
),
endpoint(
id="quizzes-attempt-start",
method="POST",
path="/quizzes/{slug}/attempts",
title="Start or resume an attempt",
summary=(
"Returns the member's single in-progress attempt, creating it when there is "
"none. The question order and one blank answer row per question are "
"materialized at start."
),
auth="user",
encoding="form",
params=[field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid.")],
sample_response={
"ok": True,
"redirect": "/quizzes/8bbb000000000001-sqlite-fundamentals/attempts/0198f2c0-3333-7aaa-8bbb-000000000003",
"data": {"uid": "0198f2c0-3333-7aaa-8bbb-000000000003", "status": "in_progress"},
},
),
endpoint(
id="quizzes-attempt-get",
method="GET",
path="/quizzes/{slug}/attempts/{attempt_uid}",
title="Read an attempt",
summary=(
"The attempt with its questions in play order. Correct answers are withheld "
"until a question is answered and the quiz reveals answers."
),
auth="user",
negotiation=True,
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
],
sample_response={
"quiz": SAMPLE_QUIZ,
"attempt": {
"uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
"status": "in_progress",
"remaining_seconds": 812,
"answered_count": 2,
"question_count": 10,
"score_points": 2.0,
"max_points": 14,
"score_percent": 14.29,
"questions": [
{
"uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 1,
"options": [{"uid": "opt-a", "label": "DELETE"}, {"uid": "opt-b", "label": "WAL"}],
}
],
},
},
),
endpoint(
id="quizzes-attempt-answer",
method="POST",
path="/quizzes/{slug}/attempts/{attempt_uid}/answer",
title="Answer a question",
summary=(
"Grade and record one answer. Each question can be answered exactly once; a "
"second submit answers 400 and credits nothing."
),
auth="user",
encoding="form",
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
field("question_uid", "form", "string", True, "0198f2c0-2222-7aaa-8bbb-000000000002", "The question being answered."),
field("answer_text", "form", "string", False, "true", "Free text, the numeric value, or true/false."),
field("option_uids", "form", "string", False, "opt-b", "Chosen option uids, comma separated and in order for ordering."),
field("blanks", "form", "string", False, "WAL,NORMAL", "fill_blank only: one answer per blank, comma separated."),
field("matches", "form", "string", False, "one,two", "matching only: the chosen right-hand value per option_uid, in order."),
],
sample_response={
"ok": True,
"answer": {
"question_uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
"answered": True,
"is_correct": True,
"awarded_points": 1.0,
"feedback": "Correct.",
"graded_by": "auto",
"confidence": 1.0,
},
"attempt": {"answered_count": 3, "score_points": 3.0, "max_points": 14},
},
),
endpoint(
id="quizzes-attempt-finish",
method="POST",
path="/quizzes/{slug}/attempts/{attempt_uid}/finish",
title="Finish an attempt",
summary=(
"Close the attempt and compute the final score from its answer rows. A second "
"finish returns the same result and awards nothing again."
),
auth="user",
encoding="form",
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
],
sample_response={
"quiz": SAMPLE_QUIZ,
"attempt": {"status": "completed", "score_points": 13.0, "max_points": 14,
"score_percent": 92.86, "passed": True},
"review": [],
"fallback_count": 0,
},
),
endpoint(
id="quizzes-attempt-results",
method="GET",
path="/quizzes/{slug}/attempts/{attempt_uid}/results",
title="Attempt results",
summary=(
"The result of one attempt: score, percentage, pass verdict, and the "
"per-question review when the author allowed it. Attempt owner or admin."
),
auth="user",
negotiation=True,
params=[
field("slug", "path", "string", True, SAMPLE_QUIZ["slug"], "Quiz slug or uid."),
field("attempt_uid", "path", "string", True, "0198f2c0-3333-7aaa-8bbb-000000000003", "The attempt uid."),
],
sample_response={
"quiz": SAMPLE_QUIZ,
"attempt": {"status": "completed", "score_percent": 92.86, "passed": True},
"review": [],
"fallback_count": 0,
},
),
],
}

View File

@ -88,10 +88,11 @@ four ways to sign requests.
field(
"emoji",
"form",
"string",
"enum",
True,
REACTION_EMOJI[0],
"Any single emoji character. Re-sending the same one removes it.",
"One of the allowed reaction emoji.",
REACTION_EMOJI,
),
],
sample_response={

View File

@ -15,11 +15,6 @@ comment, project, gist, message, or issue - see
play inline once posted; other types render as download links. The record's `is_image` and
`is_video` flags indicate how the file is displayed.
You manage your own attachments over the full lifecycle: **list** every file you uploaded, **get**
one by uid, **rename** its display filename, and **delete** it. The list is the same set of
attachments that appear on your posts and other content - listing, renaming, or deleting one is
reflected everywhere it is used.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
@ -87,139 +82,12 @@ four ways to sign requests.
"mime_type": "image/png",
},
),
endpoint(
id="uploads-list",
method="GET",
path="/uploads",
title="List your attachments",
summary="List every attachment you uploaded, newest first, paginated (24 per page).",
auth="user",
params=[
field(
"page",
"query",
"integer",
False,
"1",
"1-based page number.",
),
field(
"linked",
"query",
"string",
False,
"",
"Filter: `true` returns only attachments already used on a post/comment/project/gist/issue, `false` returns only orphaned uploads. Omit for all.",
),
],
notes=[
"Each item carries `uid`, `original_filename`, `mime_type`, `url`, `file_size`, its `target_type`/`target_uid`/`target_url` when linked, and a `linked` flag.",
],
sample_response={
"attachments": [
{
"uid": "ATTACHMENT_UID",
"original_filename": "photo.png",
"file_size": 20480,
"mime_type": "image/png",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
"is_image": True,
"is_video": False,
"is_audio": False,
"linked": True,
"target_type": "post",
"target_uid": "POST_UID",
"target_url": "/posts/POST_SLUG",
"created_at": "2026-01-01T12:00:00+00:00",
}
],
"pagination": {"page": 1, "per_page": 24, "total": 1, "total_pages": 1},
"total": 1,
},
),
endpoint(
id="uploads-get",
method="GET",
path="/uploads/{attachment_uid}",
title="Get one attachment",
summary="Fetch the metadata of a single attachment you own; administrators may fetch any user's attachment.",
auth="user",
params=[
field(
"attachment_uid",
"path",
"string",
True,
"ATTACHMENT_UID",
"UID of the attachment.",
)
],
notes=["Returns `404` if the attachment does not exist, `403` if it is not yours."],
sample_response={
"uid": "ATTACHMENT_UID",
"original_filename": "photo.png",
"file_size": 20480,
"mime_type": "image/png",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
"is_image": True,
"is_video": False,
"is_audio": False,
"linked": True,
"target_type": "post",
"target_uid": "POST_UID",
"target_url": "/posts/POST_SLUG",
"created_at": "2026-01-01T12:00:00+00:00",
},
),
endpoint(
id="uploads-rename",
method="PATCH",
path="/uploads/{attachment_uid}",
title="Rename an attachment",
summary="Change the display filename of an attachment you own; administrators may rename any user's attachment.",
auth="user",
params=[
field(
"attachment_uid",
"path",
"string",
True,
"ATTACHMENT_UID",
"UID of the attachment.",
),
field(
"filename",
"form",
"string",
True,
"renamed.png",
"New display filename.",
),
],
notes=[
"Only the display filename changes; the stored file and its extension are untouched. The original extension is always preserved, so the file type cannot be altered.",
"Returns the updated attachment record. `404` if it does not exist, `403` if it is not yours, `400` for an empty filename.",
],
sample_response={
"uid": "ATTACHMENT_UID",
"original_filename": "renamed.png",
"file_size": 20480,
"mime_type": "image/png",
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
"is_image": True,
"linked": True,
"target_type": "post",
"target_uid": "POST_UID",
"target_url": "/posts/POST_SLUG",
"created_at": "2026-01-01T12:00:00+00:00",
},
),
endpoint(
id="uploads-delete",
method="DELETE",
path="/uploads/delete/{attachment_uid}",
title="Delete an attachment",
summary="Remove an attachment you previously uploaded; administrators may remove any user's attachment.",
summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).",
auth="user",
destructive=True,
params=[
@ -229,15 +97,9 @@ four ways to sign requests.
"string",
True,
"ATTACHMENT_UID",
"UID of the attachment (the `uid` returned by Upload a file, Attach a file from a URL, or List your attachments).",
"UID of the attachment.",
)
],
notes=[
"Only the owner may delete their own attachment; an administrator may delete any user's. Deleting one you do not own returns `403`.",
"The attachment is removed everywhere at once: it leaves your attachment list (List your attachments) and disappears from every post, comment, project, gist, message, or issue it was attached to, and its file stops being served under `/static/uploads/`.",
"Idempotent from the caller's view: an already-removed or unknown uid returns `404`. A successful delete returns `200` with `{\"status\": \"deleted\"}`.",
"To detach a file from a single post/comment without removing the upload itself, edit that object's attachment list instead - deleting here removes the attachment from every place it is used.",
],
sample_response={"status": "deleted"},
),
],

View File

@ -1,259 +0,0 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "workspaces",
"title": "Dev Workspaces",
"intro": """
# Dev Workspaces
A workspace is a browser editor attached to one of your projects. It runs your project files, a
terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and `apt install` work
with no extra setup; ports below 1024 cannot bind, so use a high port and publish it through a
tunnel.
The editor opens with a **DevPlace Code** terminal already running the `dpc` coding agent and a
plain shell beside it, and it trusts every folder, so nothing opens in Restricted Mode. Its
appearance and boot behaviour are your own preferences, readable and writable through the two
`/workspace/editor` endpoints below and explained on
[the workspace editor page](/docs/workspace-editor.html). Editor preferences apply on the next
workspace start.
A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
the link can reach whatever you are serving. Forwarding a port in the editor's **Ports** view creates
the tunnel for you through the same endpoint; un-forwarding it does not remove the tunnel.
Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
arrives as a `workspace` notification and states exactly what happens next and when.
""",
"endpoints": [
endpoint(
id="workspace-get",
method="GET",
path="/projects/{slug}/workspace",
title="Read workspace",
summary=(
"State, quota usage, idle countdown, tunnels and open moderation flags "
"for your workspace on this project."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={
"has_workspace": True,
"viewer_can_workspace": True,
"workspace_count": 1,
"max_workspaces": 2,
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
"workspace": {
"uid": "INSTANCE_UID",
"status": "running",
"suspended": False,
"tunnel_name": "brave-otter",
"primary_url": "https://brave-otter.tunnel.pravda.education",
"disk_bytes": 5242880,
"disk_quota_mb": 2048,
"disk_percent": 1,
"egress_bytes": 10240,
"egress_quota_mb": 10240,
"egress_percent": 0,
"idle_stop_minutes": 60,
"retention_days": 14,
"max_tunnels": 5,
"tunnels": [],
"flags": [],
},
},
),
endpoint(
id="workspace-open",
method="POST",
path="/projects/{slug}/workspace",
title="Open or resume workspace",
summary=(
"Create the workspace if you have none for this project, otherwise resume "
"it. Idempotent. Refused when you are at your workspace limit, over disk "
"quota, or suspended."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
endpoint(
id="workspace-stop",
method="POST",
path="/projects/{slug}/workspace/stop",
title="Stop workspace",
summary="Stop the container. Files and tunnels are kept.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
endpoint(
id="workspace-delete",
method="POST",
path="/projects/{slug}/workspace/delete",
title="Delete workspace",
summary="Remove the workspace and its tunnels. An administrator can restore it.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
endpoint(
id="workspace-editor-get",
method="GET",
path="/projects/{slug}/workspace/editor",
title="Read editor profile",
summary=(
"The resolved DevPlace editor profile for this workspace: theme, layout, "
"panel preset, font sizes, zoom, boot terminals, how the editor opens, the "
"container size, and where each value comes from."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={
"editor": {
"trust_all": True,
"theme": "devplace-dark",
"font_size": 14,
"terminal_font_size": 13,
"zoom_level": 0,
"layout": "standard",
"panel_preset": "tall",
"boot_agent": "dpc",
"boot_shell": True,
"window_mode": "tab",
"window_width": 1600,
"window_height": 1000,
"cpu_millicores": 2000,
"cpu_cores": 2.0,
"memory_mb": 2048,
"disk_quota_mb": 2048,
"sources": {"theme": "user", "font_size": "site"},
},
"restart_required": False,
},
),
endpoint(
id="workspace-editor-set",
method="POST",
path="/projects/{slug}/workspace/editor",
title="Set editor preferences",
summary=(
"Change your own editor preferences. Only the fields you send are "
"changed; within those, an empty string or zero means inherit the site "
"default, and `reset` drops every preference. Applies on the next "
"workspace start, and the response says whether a restart is needed."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
field("theme", "body", "string", False, "devplace-dark",
"devplace-dark, devplace-light or system."),
field("layout", "body", "string", False, "standard",
"standard, terminal-focus or zen."),
field("panel_preset", "body", "string", False, "tall",
"short, normal, tall or maximized."),
field("font_size", "body", "integer", False, "14",
"Editor font size in pixels. Zero inherits."),
field("terminal_font_size", "body", "integer", False, "13",
"Terminal font size in pixels. Zero inherits."),
field("zoom_level", "body", "integer", False, "0",
"Window zoom, -5 to 5. Send -99 to inherit."),
field("boot_agent", "body", "string", False, "dpc",
"dpc or none."),
field("boot_shell", "body", "integer", False, "1",
"1 opens a shell on boot, 0 skips it, -1 inherits."),
field("window_mode", "body", "string", False, "tab",
"tab, window or fullscreen."),
field("window_width", "body", "integer", False, "1600",
"Editor window width in pixels. Zero inherits."),
field("window_height", "body", "integer", False, "1000",
"Editor window height in pixels. Zero inherits."),
field("reset", "body", "boolean", False, "false",
"Drop every preference and fall back to the site defaults."),
],
sample_response={
"ok": True,
"redirect": "/projects/my-project/workspace",
"data": {"restart_required": True},
},
),
endpoint(
id="workspace-tunnels-list",
method="GET",
path="/projects/{slug}/workspace/tunnels",
title="List tunnels",
summary="Every public tunnel published by this workspace.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={
"tunnels": [
{
"uid": "TUNNEL_UID",
"hostname": "8080-brave-otter.tunnel.pravda.education",
"label": "web",
"container_port": 8080,
"status": "active",
"cert_status": "valid",
"request_count": 12,
"bytes_out": 40960,
}
]
},
),
endpoint(
id="workspace-tunnel-create",
method="POST",
path="/projects/{slug}/workspace/tunnels",
title="Create tunnel",
summary=(
"Publish a container port on a public HTTPS hostname. The URL is public "
"and unauthenticated. Refused past the tunnel limit. The certificate is "
"ordered right away, so the hostname answers plain HTTP for a few seconds "
"before it serves HTTPS. Forwarding a port in the editor calls this for you."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
field("container_port", "body", "integer", True, 8080, "Port inside the container."),
field("label", "body", "string", False, "web", "Human label."),
],
sample_response={
"ok": True,
"data": {
"uid": "TUNNEL_UID",
"hostname": "8080-brave-otter.tunnel.pravda.education",
"status": "pending",
},
},
),
endpoint(
id="workspace-tunnel-delete",
method="POST",
path="/projects/{slug}/workspace/tunnels/{uid}/delete",
title="Delete tunnel",
summary="Remove a tunnel. The public URL stops serving immediately.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
field("uid", "path", "string", True, "TUNNEL_UID", "Tunnel uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
],
}

View File

@ -8,7 +8,7 @@ import time
from collections import defaultdict
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from fastapi import FastAPI, Request, WebSocket
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
@ -66,13 +66,11 @@ from devplacepy.routers import (
issues,
news,
gists,
quizzes,
uploads,
media,
push,
leaderboard,
reactions,
reports,
bookmarks,
polls,
docs,
@ -87,7 +85,6 @@ from devplacepy.routers import (
dbapi,
pubsub,
game,
workspaces,
)
from devplacepy.services.manager import service_manager
from devplacepy.services.background import background
@ -114,14 +111,9 @@ from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.jobs.isslop.service import IsslopService
from devplacepy.services.gitea.service import IssueTrackerService
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.workspace_service import WorkspaceService
from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService
from devplacepy.services.moderation.service import ModerationService
from devplacepy.services.moderation.screening import ContentRefused
from devplacepy.services.acceptance.service import AcceptanceService
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
@ -280,12 +272,8 @@ async def lifespan(app: FastAPI):
service_manager.register(PlanningReportService())
service_manager.register(IssueTrackerService())
service_manager.register(ContainerService())
service_manager.register(WorkspaceService())
service_manager.register(XmlrpcService())
service_manager.register(AuditService())
service_manager.register(ModerationService())
service_manager.register(AcceptanceService())
service_manager.register(PushService())
service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
@ -309,9 +297,6 @@ async def lifespan(app: FastAPI):
flush_visits()
await service_manager.shutdown_all()
await background.stop()
from devplacepy.services.containers import forward
await forward.close_client()
app = FastAPI(
@ -381,30 +366,6 @@ async def server_error(request: Request, exc):
)
@app.exception_handler(ContentRefused)
async def content_refused(request: Request, exc: ContentRefused):
logger.info("content refused on %s %s: %s", request.method, request.url.path, exc.message)
if wants_json(request):
return json_error(400, exc.message, categories=list(exc.categories))
seo_ctx = base_seo_context(
request,
title="Content not published - DevPlace",
description=exc.message,
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 400,
"error_message": exc.message,
},
status_code=400,
)
_AUTH_FORM_PAGES = {
"/auth/signup": ("signup.html", "Join DevPlace"),
"/auth/login": ("login.html", "Sign In"),
@ -475,7 +436,6 @@ app.include_router(messages.router, prefix="/messages")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(reactions.router, prefix="/reactions")
app.include_router(reports.router, prefix="/reports")
app.include_router(bookmarks.router, prefix="/bookmarks")
app.include_router(polls.router, prefix="/polls")
app.include_router(avatar.router, prefix="/avatar")
@ -503,8 +463,6 @@ app.include_router(devrant.router, prefix="/api")
app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
app.include_router(quizzes.router, prefix="/quizzes")
app.include_router(workspaces.router, prefix="/workspaces")
@app.middleware("http")
@ -523,15 +481,6 @@ async def await_pending_corrections(request: Request, call_next):
return response
def _frame_ancestors() -> str:
from devplacepy.services.containers.workspace import naming
tunnel_domain = naming.domain()
if not tunnel_domain:
return "'self'"
return f"'self' https://*.{tunnel_domain}"
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
@ -541,9 +490,10 @@ async def add_security_headers(request: Request, call_next):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if not request.url.path.startswith("/p/"):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = (
"object-src 'none'; base-uri 'self'; "
f"frame-ancestors {_frame_ancestors()}; form-action 'self'"
"frame-ancestors 'none'; form-action 'self'"
)
if request.url.path.startswith("/admin"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
@ -633,53 +583,6 @@ async def maintenance_middleware(request: Request, call_next):
)
_TERMS_ALLOWED_PREFIXES = (
"/static",
"/avatar",
"/auth",
"/docs",
"/reports",
"/block",
"/mute",
"/openai",
)
_TERMS_GATED_METHODS = ("POST", "PUT", "DELETE", "PATCH")
def _terms_exempt(path: str) -> bool:
if path.startswith(_TERMS_ALLOWED_PREFIXES):
return True
return path.startswith("/profile/") and (
path.endswith("/delete") or path.endswith("/consent")
)
@app.middleware("http")
async def terms_acceptance_gate(request: Request, call_next):
if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
return await call_next(request)
from devplacepy.routers.auth.terms import (
TERMS_ACCEPTANCE_CODE,
current_terms_version,
needs_acceptance,
)
user = get_current_user(request)
if not needs_acceptance(user):
return await call_next(request)
message = "Accept the updated Terms of Service to continue."
if wants_json(request):
return json_error(
403,
message,
code=TERMS_ACCEPTANCE_CODE,
redirect="/auth/accept-terms",
terms_version=current_terms_version(),
)
return RedirectResponse(url="/auth/accept-terms", status_code=303)
@app.middleware("http")
async def track_presence(request: Request, call_next):
path = request.url.path
@ -708,42 +611,6 @@ async def response_timing(request: Request, call_next):
return response
class TunnelDispatchMiddleware:
def __init__(self, app):
self.app = app
@staticmethod
def _host(scope) -> str:
for key, value in scope.get("headers") or []:
if key == b"host":
return value.decode("latin-1")
return ""
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
from devplacepy.routers import tunnel as tunnel_router
from devplacepy.services.containers.workspace import naming
try:
if not naming.is_tunnel_host(self._host(scope)):
await self.app(scope, receive, send)
return
except Exception:
await self.app(scope, receive, send)
return
path = (scope.get("path") or "/").lstrip("/")
if scope["type"] == "websocket":
websocket = WebSocket(scope, receive, send)
await tunnel_router.handle_ws(websocket, path)
return
request = Request(scope, receive)
response = await tunnel_router.handle_http(request, path)
await response(scope, receive, send)
app.add_middleware(TunnelDispatchMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
@ -832,7 +699,7 @@ async def landing(request: Request):
seo_ctx = base_seo_context(
request,
title="DevPlace - The Developer Social Network",
description="Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.",
description="Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.",
breadcrumbs=[],
schemas=[website_schema(base)],
)

View File

@ -1,22 +1,13 @@
# retoor <retoor@molodetz.nl>
import json
import re
from datetime import datetime
from typing import Literal, Optional
from urllib.parse import urlsplit, urlparse
from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS
from devplacepy.rendering import is_single_emoji
from devplacepy.config import (
DEFAULT_CORRECTION_PROMPT,
DEFAULT_MODIFIER_PROMPT,
QUIZ_ANSWER_MAX_CHARS,
QUIZ_MAX_OPTIONS,
QUIZ_MAX_QUESTIONS,
QUIZ_MAX_TIME_LIMIT_SECONDS,
)
from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.config import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT
def normalize_european_date(value):
@ -33,20 +24,6 @@ def normalize_european_date(value):
raise ValueError("Date must be in DD/MM/YYYY format")
def normalize_website_url(value):
if not value:
return ""
text = str(value).strip()
if not text:
return ""
if not text.lower().startswith(("http://", "https://")):
text = f"https://{text}"
parsed = urlparse(text)
if parsed.scheme not in ("http", "https") or not parsed.hostname or "." not in parsed.hostname:
raise ValueError("Website must be a valid http(s) URL")
return text
def normalize_poll_options(value):
if value is None:
return []
@ -62,53 +39,11 @@ def normalize_poll_options(value):
return value
def _declared_age(birth_date: str) -> int:
from datetime import date
from devplacepy.database.moderation import years_between
normalized = normalize_european_date(birth_date)
if not normalized:
raise ValueError("Date of birth is required")
born = date.fromisoformat(normalized)
today = date.today()
if born > today:
raise ValueError("Date of birth cannot be in the future")
return years_between(born, today)
class SignupForm(BaseModel):
username: str = Field(min_length=3, max_length=32)
email: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=6, max_length=128)
confirm_password: str = Field(min_length=1, max_length=128)
birth_date: str = Field(min_length=1, max_length=20)
accept_terms: str = Field(default="")
@property
def age_band(self) -> str:
from devplacepy.database.moderation import age_band_for
return age_band_for(_declared_age(self.birth_date))
@field_validator("birth_date")
@classmethod
def old_enough(cls, value):
from devplacepy.database import minimum_age
minimum = minimum_age()
if _declared_age(value) < minimum:
raise ValueError(f"You must be at least {minimum} years old to join")
return value
@field_validator("accept_terms")
@classmethod
def terms_accepted(cls, value):
if value.strip().lower() not in ("1", "on", "true", "yes"):
raise ValueError(
"You must accept the Terms of Service and Community Guidelines"
)
return value
@field_validator("username")
@classmethod
@ -119,10 +54,6 @@ class SignupForm(BaseModel):
raise ValueError(
"Username can only contain letters, numbers, hyphens, and underscores"
)
from devplacepy.services.moderation.filter import classify
if classify(value).verdict == "block":
raise ValueError("That username breaks the community guidelines")
return value
@field_validator("email")
@ -230,7 +161,7 @@ class CommentForm(BaseModel):
content: str = Field(min_length=3, max_length=125000)
target_uid: str = Field(default="", max_length=36)
post_uid: str = Field(default="", max_length=36)
target_type: Literal["post", "project", "news", "issue", "gist", "quiz"] = "post"
target_type: Literal["post", "project", "news", "issue", "gist"] = "post"
parent_uid: str = Field(default="", max_length=36)
attachment_uids: list[str] = []
@ -255,10 +186,6 @@ class ProjectForm(BaseModel):
)
platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100)
website_url: str = Field(default="", max_length=500)
repo_url: str = Field(default="", max_length=500)
cover_attachment_uid: str = Field(default="", max_length=64)
logo_attachment_uid: str = Field(default="", max_length=64)
is_private: bool = False
attachment_uids: list[str] = []
@ -267,11 +194,6 @@ class ProjectForm(BaseModel):
def normalize_dates(cls, value):
return normalize_european_date(value)
@field_validator("website_url", "repo_url")
@classmethod
def valid_link_url(cls, value):
return normalize_website_url(value)
class ProjectEditForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
@ -283,21 +205,12 @@ class ProjectEditForm(BaseModel):
)
platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100)
website_url: str = Field(default="", max_length=500)
repo_url: str = Field(default="", max_length=500)
cover_attachment_uid: str = Field(default="", max_length=64)
logo_attachment_uid: str = Field(default="", max_length=64)
@field_validator("release_date", "demo_date", mode="before")
@classmethod
def normalize_dates(cls, value):
return normalize_european_date(value)
@field_validator("website_url", "repo_url")
@classmethod
def valid_link_url(cls, value):
return normalize_website_url(value)
class BackupRunForm(BaseModel):
target: Literal["database", "uploads", "keys", "full"] = "full"
@ -322,10 +235,6 @@ class ProjectFlagForm(BaseModel):
value: bool = False
class ProjectScreenshotsForm(BaseModel):
attachment_uids: list[str] = []
class CustomizationToggleForm(BaseModel):
value: bool = False
@ -380,10 +289,6 @@ class UploadUrlForm(BaseModel):
filename: Optional[str] = Field(default=None, max_length=255)
class AttachmentRenameForm(BaseModel):
filename: str = Field(min_length=1, max_length=255)
class ProjectFileWriteForm(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(default="", max_length=400000)
@ -481,10 +386,9 @@ class ContainerScheduleForm(BaseModel):
class MessageForm(BaseModel):
content: str = Field(min_length=0, max_length=2000)
content: str = Field(min_length=1, max_length=2000)
receiver_uid: str = Field(min_length=1, max_length=36)
attachment_uids: list[str] = []
client_id: Optional[str] = Field(default=None, max_length=64)
class ProfileForm(BaseModel):
@ -545,10 +449,9 @@ class ReactionForm(BaseModel):
@field_validator("emoji")
@classmethod
def valid_emoji(cls, value):
reaction = (value or "").strip()
if not is_single_emoji(reaction):
raise ValueError("Reaction must be a single emoji")
return reaction
if value not in REACTION_EMOJI:
raise ValueError("Invalid reaction")
return value
class PollVoteForm(BaseModel):
@ -566,19 +469,9 @@ 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)
@ -669,29 +562,8 @@ class AdminSettingsForm(BaseModel):
maintenance_message: str = Field(default="", max_length=300)
docs_search_mode: str = Field(default="", max_length=20)
outbound_proxy_url: str = Field(default="", max_length=500)
moderation_sla_hours: str = Field(default="", max_length=10)
moderation_filter_mode: str = Field(default="", max_length=10)
moderation_minimum_age: str = Field(default="", max_length=3)
moderation_mature_default_hidden: str = Field(default="", max_length=1)
account_deletion_grace_hours: str = Field(default="", max_length=10)
contact_email: str = Field(default="", max_length=200)
contact_phone: str = Field(default="", max_length=60)
contact_address: str = Field(default="", max_length=500)
terms_version: str = Field(default="", max_length=20)
privacy_version: str = Field(default="", max_length=20)
guidelines_version: str = Field(default="", max_length=20)
ai_third_party_provider: str = Field(default="", max_length=120)
extra_head: str = Field(default="", max_length=50000)
@field_validator("moderation_filter_mode")
@classmethod
def validate_filter_mode(cls, value):
from devplacepy.services.moderation.rules import FILTER_MODES
if value and value not in FILTER_MODES:
raise ValueError(f"Filter mode must be one of {', '.join(FILTER_MODES)}")
return value
@field_validator("outbound_proxy_url")
@classmethod
def validate_outbound_proxy_url(cls, value):
@ -719,403 +591,7 @@ class GamePerkForm(BaseModel):
class GameQuestForm(BaseModel):
quest: str = Field(min_length=1, max_length=40)
scope: str = Field(default="daily", min_length=1, max_length=10)
class GameLegacyForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameInfraForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameCosmeticForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameMasteryForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameEraStartForm(BaseModel):
name: str = Field(min_length=1, max_length=60)
duration_days: int = Field(default=28, ge=1, le=180)
QUIZ_KINDS = (
"single_choice",
"multiple_choice",
"true_false",
"free_text",
"fill_blank",
"numeric",
"ordering",
"matching",
)
QUIZ_OPTION_KINDS = frozenset(
{"single_choice", "multiple_choice", "fill_blank", "ordering", "matching"}
)
def normalize_index_list(value):
parts = normalize_poll_options(value)
if not isinstance(parts, list):
return []
indexes = []
for part in parts:
try:
indexes.append(int(str(part).strip()))
except (TypeError, ValueError):
continue
return indexes
class QuizForm(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="", max_length=5000)
shuffle_questions: bool = False
shuffle_options: bool = False
reveal_answers: bool = False
allow_review: bool = True
time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
pass_percent: int = Field(default=0, ge=0, le=100)
class QuizQuestionForm(BaseModel):
kind: Literal[QUIZ_KINDS]
prompt: str = Field(min_length=1, max_length=2000)
explanation: str = Field(default="", max_length=2000)
points: int = Field(default=1, ge=1, le=100)
media_attachment_uid: str = Field(default="", max_length=36)
correct_boolean: bool = False
expected_answer: str = Field(default="", max_length=2000)
grading_criteria: str = Field(default="", max_length=2000)
numeric_value: float = 0.0
numeric_tolerance: float = Field(default=0.0, ge=0.0)
case_sensitive: bool = False
options: list[str] = []
match_values: list[str] = []
correct_indexes: list[int] = []
@field_validator("options", "match_values", mode="before")
@classmethod
def split_lists(cls, value):
return normalize_poll_options(value)
@field_validator("correct_indexes", mode="before")
@classmethod
def split_indexes(cls, value):
return normalize_index_list(value)
@field_validator("options", "match_values")
@classmethod
def bounded_options(cls, value):
if len(value) > QUIZ_MAX_OPTIONS:
raise ValueError(f"A question takes at most {QUIZ_MAX_OPTIONS} options")
for entry in value:
if len(entry) > 500:
raise ValueError("Each option must be 500 characters or fewer")
return value
@model_validator(mode="after")
def kind_requirements(self):
options = [option for option in self.options if option.strip()]
if self.kind in QUIZ_OPTION_KINDS and not options:
raise ValueError("This question type needs at least one option")
if self.kind in ("single_choice", "multiple_choice") and len(options) < 2:
raise ValueError("Choice questions need at least two options")
if self.kind == "single_choice" and len(self.correct_indexes) != 1:
raise ValueError("A single choice question needs exactly one correct option")
if self.kind == "multiple_choice" and not self.correct_indexes:
raise ValueError("A multiple choice question needs at least one correct option")
if self.kind in ("fill_blank", "matching") and len(self.match_values) < len(options):
raise ValueError("Every option needs an accepted answer")
if self.kind == "matching" and len(options) < 2:
raise ValueError("A matching question needs at least two pairs")
if self.kind == "ordering" and len(options) < 2:
raise ValueError("An ordering question needs at least two items")
if self.kind == "free_text" and not (
self.expected_answer.strip() or self.grading_criteria.strip()
):
raise ValueError("A free text question needs a reference answer or grading criteria")
return self
def option_rows(self) -> list[dict]:
rows = []
for index, label in enumerate(self.options):
if not label.strip():
continue
match_value = (
self.match_values[index] if index < len(self.match_values) else ""
)
rows.append(
{
"label": label.strip(),
"match_value": match_value.strip(),
"is_correct": index in set(self.correct_indexes),
}
)
return rows
class QuizReorderForm(BaseModel):
order: list[str] = []
@field_validator("order", mode="before")
@classmethod
def split_order(cls, value):
return normalize_poll_options(value)
@model_validator(mode="after")
def require_order(self):
if not self.order:
raise ValueError("The new question order is required")
return self
class QuizAnswerForm(BaseModel):
question_uid: str = Field(min_length=1, max_length=36)
answer_text: str = Field(default="", max_length=QUIZ_ANSWER_MAX_CHARS)
option_uids: list[str] = []
blanks: list[str] = []
matches: list[str] = []
@field_validator("option_uids", "blanks", "matches", mode="before")
@classmethod
def split_lists(cls, value):
return normalize_poll_options(value)
def submission(self) -> dict:
if self.blanks:
return {
"answer_text": json.dumps(self.blanks, ensure_ascii=False),
"option_uids": self.option_uids,
}
if self.matches:
pairs = dict(zip(self.option_uids, self.matches))
return {
"answer_text": json.dumps(pairs, ensure_ascii=False),
"option_uids": self.option_uids,
}
return {"answer_text": self.answer_text, "option_uids": self.option_uids}
class QuizDocumentOption(BaseModel):
label: str = Field(default="", max_length=500)
match_value: str = Field(default="", max_length=500)
is_correct: bool = False
class QuizDocumentQuestion(BaseModel):
kind: Literal[QUIZ_KINDS]
prompt: str = Field(min_length=1, max_length=2000)
explanation: str = Field(default="", max_length=2000)
points: int = Field(default=1, ge=1, le=100)
media_attachment_uid: str = Field(default="", max_length=36)
correct_boolean: bool = False
expected_answer: str = Field(default="", max_length=2000)
grading_criteria: str = Field(default="", max_length=2000)
numeric_value: float = 0.0
numeric_tolerance: float = Field(default=0.0, ge=0.0)
case_sensitive: bool = False
options: list[QuizDocumentOption] = Field(default_factory=list, max_length=QUIZ_MAX_OPTIONS)
@model_validator(mode="after")
def kind_requirements(self):
labelled = [option for option in self.options if option.label.strip()]
if self.kind in ("single_choice", "multiple_choice") and len(labelled) < 2:
raise ValueError("Choice questions need at least two options")
if self.kind == "single_choice" and sum(
1 for option in labelled if option.is_correct
) != 1:
raise ValueError("A single choice question needs exactly one correct option")
if self.kind == "multiple_choice" and not any(
option.is_correct for option in labelled
):
raise ValueError("A multiple choice question needs at least one correct option")
if self.kind in ("ordering", "matching") and len(labelled) < 2:
raise ValueError("This question type needs at least two entries")
if self.kind == "matching" and any(
not option.match_value.strip() for option in labelled
):
raise ValueError("Every matching pair needs a right-hand value")
if self.kind == "fill_blank" and any(
not option.match_value.strip() for option in labelled
):
raise ValueError("Every blank needs an accepted answer")
if self.kind == "free_text" and not (
self.expected_answer.strip() or self.grading_criteria.strip()
):
raise ValueError("A free text question needs a reference answer or grading criteria")
return self
class QuizDocumentSettings(BaseModel):
shuffle_questions: bool = False
shuffle_options: bool = False
reveal_answers: bool = False
allow_review: bool = True
time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
pass_percent: int = Field(default=0, ge=0, le=100)
class QuizDocument(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="", max_length=5000)
settings: QuizDocumentSettings = Field(default_factory=QuizDocumentSettings)
questions: list[QuizDocumentQuestion] = Field(
default_factory=list, max_length=QUIZ_MAX_QUESTIONS
)
@model_validator(mode="after")
def require_questions(self):
if not self.questions:
raise ValueError("A quiz document needs at least one question")
return self
class QuizImportForm(BaseModel):
document: QuizDocument
@field_validator("document", mode="before")
@classmethod
def parse_document(cls, value):
if isinstance(value, str):
try:
return json.loads(value)
except ValueError as exc:
raise ValueError("document must be valid JSON") from exc
return value
class TunnelForm(BaseModel):
label: str = Field(default="", max_length=64)
container_port: int = Field(default=0, ge=0, le=65535)
class EditorPrefsForm(BaseModel):
font_size: int = Field(default=0, ge=0, le=48)
terminal_font_size: int = Field(default=0, ge=0, le=48)
zoom_level: int = Field(default=-99, ge=-99, le=5)
theme: str = Field(default="", max_length=32)
layout: str = Field(default="", max_length=32)
panel_preset: str = Field(default="", max_length=32)
window_mode: str = Field(default="", max_length=32)
window_width: int = Field(default=0, ge=0, le=7680)
window_height: int = Field(default=0, ge=0, le=4320)
boot_agent: str = Field(default="", max_length=32)
boot_shell: int = Field(default=-1, ge=-1, le=1)
reset: bool = False
class WorkspaceQuotaForm(BaseModel):
owner_id: str = Field(default="", max_length=36)
label: str = Field(default="", max_length=64)
max_workspaces: int = Field(default=0, ge=0, le=100)
max_tunnels: int = Field(default=0, ge=0, le=100)
disk_quota_mb: int = Field(default=0, ge=0)
egress_quota_mb: int = Field(default=0, ge=0)
idle_stop_minutes: int = Field(default=0, ge=0)
retention_days: int = Field(default=0, ge=0)
cpu_millicores: int = Field(default=0, ge=0, le=64000)
memory_mb: int = Field(default=0, ge=0, le=1048576)
class WorkspaceFlagForm(BaseModel):
kind: str = Field(default="manual", max_length=40)
severity: str = Field(default="warn", max_length=16)
detail: str = Field(default="", max_length=500)
class WorkspaceSuspendForm(BaseModel):
reason: str = Field(default="", max_length=500)
class ReportForm(BaseModel):
reason: str = Field(min_length=1, max_length=40)
detail: str = Field(default="", max_length=2000)
@field_validator("reason")
@classmethod
def known_reason(cls, value):
from devplacepy.database.moderation import REPORT_REASONS
if value not in REPORT_REASONS:
raise ValueError("Unknown report reason")
return value
class ReportStatusForm(BaseModel):
status: str = Field(default="acknowledged", max_length=20)
@field_validator("status")
@classmethod
def known_status(cls, value):
from devplacepy.database.moderation import REPORT_STATUSES
if value not in REPORT_STATUSES:
raise ValueError("Unknown report status")
return value
class ModerationDecisionForm(BaseModel):
action: str = Field(min_length=1, max_length=30)
reason: str = Field(default="", max_length=200)
notes: str = Field(default="", max_length=2000)
duration_hours: int = Field(default=24, ge=1, le=8760)
@field_validator("action")
@classmethod
def known_action(cls, value):
from devplacepy.database.moderation import MODERATION_ACTIONS
if value not in MODERATION_ACTIONS:
raise ValueError("Unknown moderation action")
return value
class SuspensionForm(BaseModel):
reason: str = Field(default="", max_length=200)
duration_hours: int = Field(default=24, ge=1, le=8760)
class BanForm(BaseModel):
reason: str = Field(default="", max_length=200)
class ConsentForm(BaseModel):
kind: str = Field(min_length=1, max_length=40)
granted: str = Field(default="0", max_length=5)
@field_validator("kind")
@classmethod
def known_kind(cls, value):
from devplacepy.database.moderation import CONSENT_KINDS
if value not in CONSENT_KINDS:
raise ValueError("Unknown consent kind")
return value
class MaturityForm(BaseModel):
level: str = Field(default="general", max_length=20)
@field_validator("level")
@classmethod
def known_level(cls, value):
from devplacepy.database.moderation import MATURITY_LEVELS
if value not in MATURITY_LEVELS:
raise ValueError("Unknown maturity level")
return value
class MaturePreferenceForm(BaseModel):
mature_opt_in: str = Field(default="0", max_length=5)
class AccountDeleteForm(BaseModel):
password: str = Field(min_length=1, max_length=128)
confirm_text: str = Field(default="", max_length=40)

View File

@ -463,15 +463,11 @@ def delete_node(project_uid: str, raw_path: str, deleted_by: str = "system") ->
if node is None:
raise ProjectFileError(f"'{path}' does not exist")
stamp = _now()
rows = _descendants(project_uid, path)
for row in rows:
for row in _descendants(project_uid, path):
_table().update(
{"uid": row["uid"], "deleted_at": stamp, "deleted_by": deleted_by},
["uid"],
)
for row in rows:
if row.get("is_binary"):
_unlink_blob(row)
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
@ -582,11 +578,9 @@ def _export_node(row: dict, dest: Path) -> None:
if target.is_symlink():
target.unlink()
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
src = PROJECT_FILES_DIR / row["directory"] / row["stored_name"]
try:
shutil.copyfile(src, target)
except (FileNotFoundError, OSError):
logger.warning("Blob file missing during export: %s", src)
shutil.copyfile(
PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target
)
else:
target.write_text(row.get("content") or "", encoding="utf-8")
@ -666,9 +660,6 @@ IMPORT_SKIP_NAMES = {
SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | {
".devplace_boot.py",
".devplace_boot.sh",
".devplace",
".dpc",
"dpc.log",
}
IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
@ -694,6 +685,7 @@ def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -
def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
_guard_writable(project_uid)
dest = Path(dest_dir).resolve()
dest.mkdir(parents=True, exist_ok=True)
if subpath:
@ -716,12 +708,9 @@ def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
if target.is_symlink() or target.is_file():
target.unlink()
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
src = PROJECT_FILES_DIR / row["directory"] / row["stored_name"]
try:
shutil.copyfile(src, target)
except (FileNotFoundError, OSError):
logger.warning("Blob file missing: %s", src)
continue
shutil.copyfile(
PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target
)
else:
target.write_text(row.get("content") or "", encoding="utf-8")
written += 1

View File

@ -7,6 +7,7 @@ import logging
import os
import random
import time
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
@ -19,6 +20,7 @@ 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,
@ -26,15 +28,7 @@ from devplacepy.config import (
VAPID_PUBLIC_KEY_FILE,
VAPID_SUB,
)
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.database import get_table
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
@ -43,8 +37,6 @@ 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:
@ -157,17 +149,13 @@ 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": subject(),
"sub": VAPID_SUB,
"aud": audience,
"exp": issued_at + JWT_LIFETIME_SECONDS,
"nbf": issued_at,
@ -235,76 +223,78 @@ def create_notification_info_with_payload(
}
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 _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)
def is_configured(self) -> bool:
return True
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 {}
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 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,
}
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 prepare(self, payload: dict[str, Any]) -> str:
return json.dumps(payload)
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,
)
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))
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

View File

@ -1,52 +0,0 @@
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

@ -1,29 +0,0 @@
# 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

@ -1,83 +0,0 @@
# 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

@ -1,76 +0,0 @@
# 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

@ -1,243 +0,0 @@
# 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

@ -1,66 +0,0 @@
# 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

@ -1,83 +0,0 @@
# 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,12 +45,6 @@ def write_emoji_module(path: Path = EMOJI_JS_PATH) -> int:
EMOJI_MAP = build_emoji_shortcodes()
def is_single_emoji(value: str) -> bool:
text = (value or "").strip()
return emoji.emoji_count(text) == 1 and emoji.purely_emoji(text)
_WIDGET_RE = re.compile(r"<dp-widget>(.*?)</dp-widget>", re.DOTALL | re.IGNORECASE)
_WIDGET_PH = "\x00WIDGET_{}\x00"

View File

@ -15,7 +15,7 @@ Prefixes are wired in `main.py`:
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live), broadcasts the FINAL corrected/modified content with an additive `ai_processed` frame flag (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live) and broadcasts the FINAL corrected/modified content (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/notifications` | notifications.py |
| `/votes` | votes.py |
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |
@ -23,16 +23,14 @@ Prefixes are wired in `main.py`:
| `/polls` | polls.py - poll voting: `POST /polls/{poll_uid}/vote` |
| `/avatar` | avatar.py |
| `/follow` | follow.py |
| `/reports` | reports.py - polymorphic content reporting: `POST /reports/{target_type}/{target_uid}` (member), `GET /reports/mine` (member), `GET /reports/reasons` (public). See `devplacepy/services/moderation/CLAUDE.md` |
| `/workspaces` | workspaces.py - `GET /workspaces/index`, the public index of every workspace published to the `/p/{slug}` ingress, with owner, project, maturity label and absolute link. Indexed in the sitemap. Publishing an ingress slug is the deliberate public act, so the workspace itself is always listed, but the project-derived fields (`description` and `project_url`, whose slug carries the project title) are withheld unless `content.can_view_project(project, viewer)` passes - a private project must not leak its title or description through this public listing |
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `moderation` leaf (`/admin/moderation`) is the report queue: the list (oldest-open-first, status tabs, the SLA badge), the per-report detail with the offender's history, `POST /{uid}/status` for triage and `POST /{uid}/decide` for decisions; the per-user enforcement routes `POST /admin/users/{uid}/{suspend,lift,ban}` live in the `users` leaf alongside the legacy `toggle`. Both share `is_senior_admin`/`deny_senior` from `admin/_shared.py`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md` |
| `/admin/services` | admin/services.py |
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
| `/gists` | gists.py |
| `/news` | news.py |
| `/uploads` | uploads.py - attachment management CRUD for the signed-in user over the ONE `attachments` table (the same rows that appear on posts/comments/projects/gists/issues). Create: `POST /upload` (multipart), `POST /upload-url` (server-side fetch). Read: `GET ""` (own attachments, paginated 24/page newest-first, `?page=`, `?linked=true|false` via `database.get_user_attachments`), `GET /{attachment_uid}` (one, via `database.get_user_attachment`). Update: `PATCH /{attachment_uid}` (rename display filename via `attachments.rename_attachment`; the original file extension is ALWAYS preserved - renaming can never change the file type, the upload-time security control - audit `attachment.rename`). Delete: `DELETE /delete/{attachment_uid}` (soft delete). All `require_user_api` (401 for guests); read/rename/delete of another user's row is owner-or-admin. JSON-only router (no HTML/`respond`); list uses `UploadsListOut`, single/rename return `UploadItemOut`. Devii tools mirror every face: `upload_file`/`attach_url`/`list_attachments`/`get_attachment`/`rename_attachment`/`delete_attachment` |
| `/uploads` | uploads.py |
| `/media` | media.py - profile media gallery item soft delete/restore: `POST /media/{uid}/delete` and `POST /media/{uid}/restore` (owner or admin) |
| `/openai` | openai_gateway.py |
| `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` |
@ -45,9 +43,8 @@ Prefixes are wired in `main.py`:
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/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 - 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` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (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` |
@ -62,7 +59,6 @@ Every page/redirect endpoint also returns JSON when the client asks. Core in `de
- **Page GETs:** `return respond(request, "x.html", context, model=XOut)` - HTML renders the template; JSON does `XOut.model_validate(context).model_dump()`. One context, two renderings.
- **Action POSTs:** `return action_result(request, url, data=<resource|None>)` - HTML 302 redirects; JSON returns `{ok, redirect, data}`. (Set cookies on the returned response after calling it, as `auth.py` login/signup do.)
- **Refusals:** `return json_error(status_code, message)` - **status first, message second**. Swapping them is not a lint-level mistake, it is a guaranteed 500: `JSONResponse(..., status_code="some message")` raises `TypeError` inside `Response.init_headers` before any byte is written, so the branch that was supposed to explain a limit to the user crashes instead. It was a real production bug - every refusal in the two workspace routers was written `json_error(message, status)`, so a member already at the default 2-workspace quota got a 500 from the workspace page's **Open workspace** button rather than the quota message. `tests/unit/responses.py` now AST-scans the whole package for both argument orders, so the swap cannot come back anywhere.
Response models live in `devplacepy/schemas.py` (Pydantic v2, `extra="ignore"`, all-Optional so they validate the existing context dicts directly). **Always project users through `UserOut`** (and `AdminUserOut`) - the raw user rows contain `email`/`api_key`/`password_hash`, and the models drop them; never serialize a raw user row. List item shapes vary (feed/gists/news/admin-news are wrapped `{post|gist|article: ...}`; projects are flat rows with `author_name`/`my_vote`) - match the context exactly. Errors negotiate centrally: `main.py` 404/500/validation handlers and the rate-limit/maintenance middleware, plus `utils.require_user`/`require_admin` (401/403 for JSON, 303 redirect for browsers). The four legacy AJAX endpoints (votes/reactions/bookmarks/polls) keep their original flat JSON shapes and are left untouched. Documented in `docs_api.py`'s Conventions group.
@ -192,7 +188,7 @@ News articles have an internal detail page at `/news/{slug}` with full comment s
The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
- **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid).
- **Signed-in users** get a personalized dashboard hero (`.dashboard-welcome`): avatar, "Welcome back, {username}", quicklink buttons (`.dashboard-btn`, with `New Post` -> `/feed` as `.dashboard-btn-primary`, plus Code Farm/Projects/Gists), and a Posts/Stars/Level stat strip (`.dashboard-stats`, `user_post_count` + the user dict's `stars`/`level`). Styles live in the `.dashboard-*` classes in `static/css/landing.css`.
- **Signed-in users** get a personalized hero (`.landing-hero-user`): avatar, "Welcome back, {username}", a `Go to your feed` CTA, a Posts/Stars/Level stat strip (`user_post_count` + the user dict's `stars`/`level`), and quick links. Styles live in `.landing-hero-user`/`.landing-welcome`/`.landing-stats`/`.landing-quicklinks` in `static/css/landing.css`.
- Both states share the Latest Posts + Developer News + "Build With Us" sections. The **Build With Us** section is static HTML/CSS (`.landing-help-*` in `landing.css`): four cards linking to `/docs/index.html` (Documentation), `/swagger` + `/openapi.json` (API Reference), `/issues` (Contribute & Report), and Devii. The Devii card's `Launch Devii` button is a plain `<button data-devii-open>` that opens the globally mounted `DeviiTerminal` (`app.devii`) in place - no extra JS, route, schema, or Devii action; a secondary link points to `/devii/` for the full terminal page.
- Context adds `user`, `is_authenticated`, `user_post_count`; `LandingOut` carries `is_authenticated`/`user_post_count` for the JSON form. `GET /` is documented in `docs_api.py` (id `home`, mapped to `LandingOut`).
@ -290,8 +286,7 @@ All SEO features are implemented across the following locations:
## Engagement: reactions, bookmarks, polls, contribution heatmap
### Emoji reactions
- **Any single emoji is allowed.** `ReactionForm` validates with `rendering.is_single_emoji(value)` (`emoji.emoji_count(text) == 1 and emoji.purely_emoji(text)`, whitespace stripped) - so every emoji the picker can emit (all 3953 fully-qualified sequences, skin tones and ZWJ families included) is accepted, while text, mixed text+emoji, and multi-emoji strings are rejected. `REACTION_EMOJI` in `constants.py` (a template global) is now only the **quick-pick palette** shown by default, not an allowlist; the full set comes from the vendored `emoji-picker-element` opened by the palette's `+` button.
- The rendered chips are `reaction_emojis(_reactions)` (a `templating.py` global): the quick-pick palette plus any other emoji already used on that target (from `counts`/`mine`), so an off-palette reaction renders server-side too. `ReactionBar.js` creates a chip on the fly for any emoji returned by the JSON response that has none yet.
- Curated palette only: `REACTION_EMOJI` in `constants.py` (registered as a template global). `ReactionForm` rejects anything outside it; free-text emoji are not allowed.
- Endpoint `POST /reactions/{target_type}/{target_uid}` (`routers/reactions.py`) toggles one `(user, target, emoji)` row in the `reactions` table. Target types: `post`, `comment`, `gist`, `project`. AJAX (`x-requested-with: fetch`) returns `{counts, mine}`.
- Reactions are **non-ranking** - they never touch `stars` or XP and intentionally send **no notifications** (votes already notify; reactions would be notification spam).
- Batch reads via `get_reactions_by_targets(target_type, uids, user)` in `database.py` (used by feed, profile, comment loader, `load_detail`) - never per-row. The `_reaction_bar.html` partial takes `_type`, `_uid`, `_reactions` ({counts, mine}) and renders the full palette as toggle chips; `ReactionBar.js` uses document-level click delegation. All four engagement controllers (`ReactionBar`, `VoteManager`, `BookmarkManager`, `PollManager`) extend the shared `OptimisticAction` base (the `Http.sendForm -> render -> error` core); each keeps only its own event wiring and `_render`.
@ -317,14 +312,6 @@ All SEO features are implemented across the following locations:
- `database.py` helpers: `get_follow_counts(uid)` (`{followers, following}`), `get_follow_list(uid, mode, page)` (paginated people + `build_pagination`, ordered newest-first), and `get_following_among(follower_uid, target_uids)` (single IN-clause set used to set `is_following` per row, avoiding N+1). `mode` is `"followers"` (people who follow `uid`) or `"following"` (people `uid` follows).
- Devii catalog tools `list_followers` / `list_following` (`requires_auth=False`) map to the JSON endpoints; documented in `docs_api.py` under the `profiles` group.
### Reporting and moderation
`_report_button.html` is the single report control, included with the same two-variable idiom as `_reaction_bar.html` at **fifteen** sites (`_post_card`, `_comment`, `post`, `gist_detail`, `project_detail`, `news_detail`, `quiz`, `_media_gallery`, `_awards_gallery`, `messages`, `profile`, `project_files`, `issue_detail`, `containers_instance`, `workspace_index`). Locals: `_type`, `_uid`, `_owner` (owner uid, so the control hides on your own content), `_owner_name` (optional; when present the partial also renders the **Block** form, which is what makes blocking reachable from the content rather than only from a profile) and `_class` (the surrounding button class so it inherits each surface's visual language).
`_report_dialog.html` is included once in `base.html` for signed-in users and driven by `static/js/ReportDialog.js` (`app.reportDialog`) through the standard `.modal-overlay`/`.visible` pattern and `Http.sendForm`. The reason list is the `REPORT_REASONS` Jinja global, sourced from `database/moderation.py`, so the dialog, the API enum, the docs enum and the guidelines page can never drift.
An e2e coverage test asserts the control is reachable on every include site; the registry test asserts every reportable target resolves. See `devplacepy/services/moderation/CLAUDE.md`.
### Block and mute (`routers/relations.py`)
A logged-in user can **block** or **mute** another user; both are one-directional and reversible. **Block** hides every piece of the blocked user's content from the blocker - posts, comments (any category), feed, listings, issue list, detail pages, and DMs - everywhere EXCEPT the blocked user's own profile page (kept fully visible so the blocker can review and unblock), and it also suppresses any notification that user would generate. **Mute** is the lighter option: it only suppresses the muted user's notifications while their content stays visible. The blocked/muted user is unaffected and is not told.

View File

@ -8,13 +8,9 @@ from devplacepy.routers.admin import (
backups,
bots,
containers,
workspaces,
devii_tasks,
game,
gateway_configs,
issues,
media,
moderation,
news,
notifications,
services,
@ -31,7 +27,6 @@ router.include_router(aiusage.router)
router.include_router(statistics.router)
router.include_router(aiquota.router)
router.include_router(media.router)
router.include_router(moderation.router)
router.include_router(trash.router)
router.include_router(settings.router)
router.include_router(notifications.router)
@ -41,8 +36,5 @@ router.include_router(auditlog.router)
router.include_router(backups.router)
router.include_router(bots.router)
router.include_router(gateway_configs.router)
router.include_router(devii_tasks.router)
router.include_router(game.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")
router.include_router(workspaces.router)

View File

@ -2,45 +2,6 @@
import json
from fastapi import Request
from devplacepy.responses import action_result
from devplacepy.services.audit import record as audit
def seniority_key(user: dict) -> tuple[str, int]:
return (user.get("created_at") or "", user.get("id") or 0)
def is_senior_admin(actor: dict, target: dict | None) -> bool:
if not target or target.get("role") != "Admin":
return False
if target.get("uid") == actor.get("uid"):
return False
return seniority_key(target) < seniority_key(actor)
def deny_senior(
request: Request,
admin: dict,
uid: str,
target: dict,
event_key: str,
redirect_url: str = "/admin/users",
):
audit.record(
request,
event_key,
user=admin,
result="denied",
target_type="user",
target_uid=uid,
target_label=target.get("username"),
summary=f"admin {admin['username']} cannot manage senior admin {target.get('username')}",
links=[audit.target("user", uid, target.get("username"))],
)
return action_result(request, redirect_url)
def parse_metadata(raw: str | dict | None) -> dict | None:
if not raw:

View File

@ -6,7 +6,6 @@ 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()
@ -35,20 +34,14 @@ 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 "
f"({removed} Devii ledger rows, gateway watermark {gateway['reset_at']})"
f"Admin {admin['username']} reset ALL AI quotas ({removed} ledger rows)"
)
audit.record(
request,
"admin.ai_quota.reset_all",
user=admin,
metadata={"rows_removed": removed, "gateway_reset_at": gateway["reset_at"]},
summary=(
f"admin {admin['username']} reset all AI quotas "
"(Devii assistant and AI gateway)"
),
metadata={"rows_removed": removed},
summary=f"admin {admin['username']} reset all AI quotas",
)
return action_result(request, "/admin/ai-usage")

View File

@ -1,205 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import db, get_admin_uids, get_int_setting, get_users_by_uids
from devplacepy.responses import action_result, respond
from devplacepy.schemas import AdminDeviiTasksOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.devii import config as devii_config
from devplacepy.services.devii.tasks import limits
from devplacepy.services.devii.tasks.guards import DEFAULT_MAX_PER_OWNER
from devplacepy.services.devii.tasks.schedule import now_utc
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
from devplacepy.utils import not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
STATES = ("active", "inactive", "all")
def _schedule_text(row: dict) -> str:
kind = row.get("kind") or ""
if kind == "interval":
return f"every {row.get('every_seconds')}s"
if kind == "cron":
return f"cron {row.get('cron')}"
return f"once {row.get('run_at') or ''}".strip()
def _rows(state: str) -> list[dict]:
if TABLE not in db.tables:
return []
criteria: dict = {"deleted_at": None}
if state == "active":
criteria["enabled"] = True
elif state == "inactive":
criteria["enabled"] = False
rows = list(db[TABLE].find(**criteria))
rows.sort(key=lambda row: int(row.get("run_count") or 0), reverse=True)
return rows
def _quotas(owner_uids: set[str]) -> dict[str, dict]:
reference = now_utc()
quotas = {}
for owner_uid in owner_uids:
runs = limits.run_quota(db, "user", owner_uid, reference)
creations = limits.create_quota(db, "user", owner_uid, reference)
quotas[owner_uid] = {
"runs_used": runs.used,
"runs_limit": runs.limit,
"creates_used": creations.used,
"creates_limit": creations.limit,
}
return quotas
def _items(rows: list[dict]) -> list[dict]:
owners = get_users_by_uids([row.get("owner_id") for row in rows if row.get("owner_id")])
admins = get_admin_uids()
quotas = _quotas({str(row.get("owner_id") or "") for row in rows if row.get("owner_id")})
items = []
for row in rows:
owner_uid = row.get("owner_id") or ""
owner = owners.get(owner_uid)
items.append(
{
"uid": row.get("uid"),
"label": row.get("label") or row.get("uid"),
"owner_uid": owner_uid,
"owner": owner["username"] if owner else owner_uid,
"owner_is_admin": owner_uid in admins,
"quota": quotas.get(owner_uid, {}),
"schedule": _schedule_text(row),
"status": row.get("status") or "",
"enabled": bool(row.get("enabled")),
"run_count": int(row.get("run_count") or 0),
"max_runs": row.get("max_runs"),
"failure_count": int(row.get("failure_count") or 0),
"next_run_at": row.get("next_run_at"),
"expires_at": row.get("expires_at"),
"last_error": row.get("last_error"),
}
)
return items
def _require_task(uid: str) -> dict:
row = db[TABLE].find_one(uid=uid, deleted_at=None) if TABLE in db.tables else None
if row is None:
raise not_found("Unknown task")
return row
@router.get("/devii-tasks", response_class=HTMLResponse)
async def admin_devii_tasks(request: Request, state: str = "active"):
admin = require_admin(request)
if state not in STATES:
state = "active"
rows = _rows(state)
items = _items(rows)
bounds = {
"max_concurrent": get_int_setting(
devii_config.FIELD_TASK_MAX_CONCURRENT,
devii_config.DEFAULT_TASK_MAX_CONCURRENT,
),
"max_per_owner": get_int_setting(
devii_config.FIELD_TASK_MAX_PER_OWNER, DEFAULT_MAX_PER_OWNER
),
"max_failures": get_int_setting(
devii_config.FIELD_TASK_MAX_FAILURES,
devii_config.DEFAULT_TASK_MAX_FAILURES,
),
"idle_days": get_int_setting(
devii_config.FIELD_TASK_IDLE_DAYS, devii_config.DEFAULT_TASK_IDLE_DAYS
),
"member_create_24h": limits.create_limit(False),
"member_runs_24h": limits.run_limit(False),
"admin_create_24h": limits.create_limit(True),
"admin_runs_24h": limits.run_limit(True),
}
tabs = [
{"key": key, "label": key.capitalize(), "active": key == state}
for key in STATES
]
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Devii tasks - Admin",
description="Every scheduled Devii task, its owner, and its bounds.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Devii tasks", "url": "/admin/devii-tasks"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_devii_tasks.html",
{
**seo_ctx,
"request": request,
"user": admin,
"items": items,
"state": state,
"tabs": tabs,
"limits": bounds,
"admin_section": "devii-tasks",
},
model=AdminDeviiTasksOut,
)
@router.post("/devii-tasks/{uid}/disable")
async def admin_devii_task_disable(request: Request, uid: str):
admin = require_admin(request)
row = _require_task(uid)
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
store.update(
uid,
{
"enabled": False,
"status": "disabled",
"next_run_at": None,
"last_error": f"disabled by {admin['username']}",
},
)
logger.info(f"Admin {admin['username']} disabled Devii task {uid}")
audit.record(
request,
"admin.devii_task.disable",
user=admin,
target_type="task",
target_uid=uid,
target_label=row.get("label"),
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
summary=f"{admin['username']} disabled Devii task {uid}",
)
return action_result(request, "/admin/devii-tasks")
@router.post("/devii-tasks/{uid}/delete")
async def admin_devii_task_delete(request: Request, uid: str):
admin = require_admin(request)
row = _require_task(uid)
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
store.delete(uid)
logger.info(f"Admin {admin['username']} deleted Devii task {uid}")
audit.record(
request,
"admin.devii_task.delete",
user=admin,
target_type="task",
target_uid=uid,
target_label=row.get("label"),
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
summary=f"{admin['username']} deleted Devii task {uid}",
)
return action_result(request, "/admin/devii-tasks")

View File

@ -1,97 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from devplacepy.models import GameEraStartForm
from devplacepy.responses import respond, action_result, json_error, wants_json
from devplacepy.schemas import AdminGameOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.game import GameError, store
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
def _era_context() -> dict:
era = store.active_era()
return {
"era_active": bool(era),
"era_name": era["name"] if era else "",
"era_number": int(era["era_number"]) if era else 0,
"era_started_at": era["started_at"] if era else "",
"era_ends_at": era["ends_at"] if era else "",
}
@router.get("/game", response_class=HTMLResponse)
async def admin_game(request: Request):
admin = require_admin(request)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Code Farm - Admin",
description="Manage Code Farm Eras.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Code Farm", "url": "/admin/game"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_game.html",
{
**seo_ctx,
"request": request,
"user": admin,
"admin_section": "game",
**_era_context(),
},
model=AdminGameOut,
)
@router.post("/game/era/start")
async def admin_game_era_start(request: Request, data: Annotated[GameEraStartForm, Form()]):
admin = require_admin(request)
try:
era = store.start_era(data.name, data.duration_days)
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to start Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_start",
user=admin,
metadata={"era_number": era["era_number"], "name": era["name"]},
summary=f"admin {admin['username']} started Era {era['name']}",
)
return action_result(request, "/admin/game")
@router.post("/game/era/end")
async def admin_game_era_end(request: Request):
admin = require_admin(request)
try:
result = store.end_era()
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to end Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_end",
user=admin,
metadata=result,
summary=f"admin {admin['username']} ended Era {result['era_number']}",
)
return action_result(request, "/admin/game")

View File

@ -9,7 +9,7 @@ from pydantic import ValidationError
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import quota, routing
from devplacepy.services.openai_gateway import routing
from devplacepy.templating import templates
from devplacepy.utils import require_admin
@ -182,113 +182,3 @@ async def delete_model(request: Request, source_model: str):
summary=f"admin {admin['username']} deleted gateway model route {source_model}",
)
return JSONResponse({"ok": True})
def _quota_defaults_summary() -> dict:
svc = service_manager.get_service("openai")
cfg = svc.get_config() if svc is not None else {}
return {
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
}
def _rule_label(rule: dict) -> str:
return quota.scope_label(rule, fallback=rule.get("uid", ""))
@router.get("/gateway/quota-rules")
async def list_quota_rules(request: Request):
require_admin(request)
rules = quota.quota_rule_store.list()
for rule in rules:
rule["spent_24h_usd"] = round(
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
)
return JSONResponse(
{
"rules": rules,
"count": len(rules),
"defaults": _quota_defaults_summary(),
}
)
@router.post("/gateway/quota-rules")
async def save_quota_rule(request: Request):
admin = require_admin(request)
body = await _payload(request)
uid = str(body.pop("uid", "") or "").strip() or None
try:
payload = quota.QuotaRuleIn(**body)
except ValidationError as exc:
return _validation_error(exc)
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
audit.record(
request,
"gateway.quota_rule.update",
user=admin,
target_type="gateway_quota_rule",
target_uid=saved["uid"],
target_label=_rule_label(saved),
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
"is_active": saved["is_active"],
},
)
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)
existing = quota.quota_rule_store.get(uid)
label = _rule_label(existing.as_dict()) if existing else uid
existed = quota.quota_rule_store.remove(uid)
if not existed:
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
audit.record(
request,
"gateway.quota_rule.delete",
user=admin,
target_type="gateway_quota_rule",
target_uid=uid,
target_label=label,
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
)
return JSONResponse({"ok": True})

View File

@ -1,323 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import (
REPORT_STATUSES,
SYSTEM_ACTOR,
get_table,
get_users_by_uids,
)
from devplacepy.dependencies import json_or_form
from devplacepy.models import ModerationDecisionForm, ReportStatusForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.routers.admin._shared import deny_senior, is_senior_admin
from devplacepy.schemas import AdminModerationOut, AdminReportOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.moderation import enforcement, queue, sla
from devplacepy.utils import create_notification, not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
QUEUE_URL = "/admin/moderation"
STATUS_TABS = [
{"key": "open", "label": "Open"},
{"key": "acknowledged", "label": "Acknowledged"},
{"key": "actioned", "label": "Actioned"},
{"key": "dismissed", "label": "Dismissed"},
]
SUBJECT_ACTIONS = ("warn", "suspend", "ban", "lift")
ENFORCEMENT_EVENTS = {
"remove_content": "moderation.remove",
"restore_content": "moderation.restore",
"warn": "moderation.warn",
"suspend": "moderation.suspend",
"ban": "moderation.ban",
"lift": "moderation.lift",
}
DECISION_MESSAGES = {
"remove_content": "Your {target} was removed after a moderation review.",
"restore_content": "Your {target} was restored after a moderation review.",
"warn": "A moderator issued a warning about your {target}.",
"suspend": "Your account is suspended following a moderation review.",
"ban": "Your account has been closed following a moderation review.",
"lift": "Your account restriction has been lifted.",
}
def _breadcrumbs(extra: list[dict] | None = None) -> list[dict]:
trail = [
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Moderation", "url": QUEUE_URL},
]
return trail + (extra or [])
def _available_actions(target_type: str) -> list[str]:
actions = ["dismiss", "escalate"]
if enforcement.can_remove(target_type):
actions = ["remove_content", "restore_content"] + actions
return actions + list(SUBJECT_ACTIONS)
def _subject(report: dict) -> dict | None:
owner_uid = report.get("owner_uid") or ""
if not owner_uid:
return None
return get_users_by_uids([owner_uid]).get(owner_uid)
def _action_view(rows: list[dict]) -> list[dict]:
actors = get_users_by_uids([row.get("actor_uid") for row in rows if row.get("actor_uid")])
view = []
for row in rows:
actor = actors.get(row.get("actor_uid"))
view.append(
{
"uid": row["uid"],
"report_uid": row.get("report_uid", ""),
"action": row.get("action", ""),
"actor_name": actor["username"] if actor else row.get("actor_uid", ""),
"reason": row.get("reason", ""),
"notes": row.get("notes", ""),
"expires_at": row.get("expires_at", ""),
"created_at": row.get("created_at", ""),
}
)
return view
@router.get("/moderation", response_class=HTMLResponse)
async def admin_moderation(request: Request, status: str = "open", page: int = 1):
admin = require_admin(request)
if status not in REPORT_STATUSES:
status = "open"
reports, pagination = queue.list_reports(status=status, page=page)
counts = queue.status_counts()
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Moderation - Admin",
description="Triage reported content and apply moderation decisions.",
robots="noindex,nofollow",
breadcrumbs=_breadcrumbs(),
schemas=[website_schema(base)],
)
return respond(
request,
"admin_moderation.html",
{
**seo_ctx,
"request": request,
"user": admin,
"reports": reports,
"pagination": pagination,
"status": status,
"statuses": [
{**tab, "count": counts.get(tab["key"], 0), "active": tab["key"] == status}
for tab in STATUS_TABS
],
"counts": counts,
"sla": sla.snapshot(),
"admin_section": "moderation",
},
model=AdminModerationOut,
)
@router.get("/moderation/{uid}", response_class=HTMLResponse)
async def admin_report_detail(request: Request, uid: str):
admin = require_admin(request)
report = queue.get_report(uid)
if not report:
raise not_found("Report not found")
view = queue.enrich_reports([report])[0]
subject = _subject(report)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title=f"Report {uid} - Admin",
description="One reported item and the decisions taken on it.",
robots="noindex,nofollow",
breadcrumbs=_breadcrumbs([{"name": "Report", "url": f"{QUEUE_URL}/{uid}"}]),
schemas=[website_schema(base)],
)
return respond(
request,
"admin_report.html",
{
**seo_ctx,
"request": request,
"user": admin,
"report": view,
"actions": _action_view(queue.actions_for_report(uid)),
"history": _action_view(
queue.actions_for_subject(report.get("owner_uid", ""))
),
"available_actions": _available_actions(report["target_type"]),
"can_remove": enforcement.can_remove(report["target_type"]),
"subject": subject,
"sla": sla.snapshot(),
"admin_section": "moderation",
},
model=AdminReportOut,
)
@router.post("/moderation/{uid}/status")
async def admin_report_status(
request: Request,
uid: str,
data: Annotated[ReportStatusForm, Depends(json_or_form(ReportStatusForm))],
):
admin = require_admin(request)
report = queue.get_report(uid)
if not report:
raise not_found("Report not found")
updated = queue.set_status(uid, data.status, admin["uid"])
if not updated:
return json_error(400, "Report status could not be changed")
logger.info(f"Admin {admin['username']} set report {uid} to {data.status}")
audit.record(
request,
"report.status",
user=admin,
target_type=report["target_type"],
target_uid=report["target_uid"],
old_value=report.get("status"),
new_value=data.status,
metadata={"report_uid": uid},
summary=f"{admin['username']} set report {uid} to {data.status}",
links=[audit.target(report["target_type"], report["target_uid"])],
)
return action_result(request, f"{QUEUE_URL}/{uid}")
@router.post("/moderation/{uid}/decide")
async def admin_report_decide(
request: Request,
uid: str,
data: Annotated[ModerationDecisionForm, Depends(json_or_form(ModerationDecisionForm))],
):
admin = require_admin(request)
report = queue.get_report(uid)
if not report:
raise not_found("Report not found")
action = data.action
if action not in _available_actions(report["target_type"]):
return json_error(400, "That action does not apply to this target")
subject = _subject(report)
if action in SUBJECT_ACTIONS:
if not subject:
return json_error(400, "This report has no account to act on")
if is_senior_admin(admin, subject):
return deny_senior(
request,
admin,
subject["uid"],
subject,
f"moderation.{action}",
redirect_url=f"{QUEUE_URL}/{uid}",
)
if action == "escalate":
queue.escalate(uid)
else:
outcome = "dismissed" if action == "dismiss" else "actioned"
if not queue.claim_open(uid, outcome, admin["uid"]):
return json_error(409, "This report was already resolved")
expires_at = ""
if action == "remove_content":
enforcement.remove_content(
request, admin, report["target_type"], report["target_uid"]
)
elif action == "restore_content":
enforcement.restore_content(report["target_type"], report["target_uid"])
elif action == "suspend":
expires_at = enforcement.suspend_user(subject, data.duration_hours, data.reason)
elif action == "ban":
enforcement.ban_user(subject, data.reason)
elif action == "lift":
enforcement.lift_suspension(subject)
enforcement.unban_user(subject)
queue.record_action(
report_uid=uid,
actor_uid=admin["uid"],
action=action,
target_type=report["target_type"],
target_uid=report["target_uid"],
subject_uid=subject["uid"] if subject else "",
reason=data.reason,
notes=data.notes,
expires_at=expires_at,
)
logger.info(f"Admin {admin['username']} applied {action} to report {uid}")
metadata = {
"report_uid": uid,
"action": action,
"reason": data.reason,
"subject_uid": subject["uid"] if subject else "",
}
links = [audit.target(report["target_type"], report["target_uid"])]
audit.record(
request,
"report.decide",
user=admin,
target_type=report["target_type"],
target_uid=report["target_uid"],
metadata=metadata,
summary=f"{admin['username']} applied {action} on report {uid}",
links=links,
)
enforcement_key = ENFORCEMENT_EVENTS.get(action)
if enforcement_key:
audit.record(
request,
enforcement_key,
user=admin,
target_type=report["target_type"],
target_uid=report["target_uid"],
metadata=metadata,
summary=f"{admin['username']} applied {action} from report {uid}",
links=links,
)
_notify_subject(subject, action, report, data.reason)
if action != "escalate":
_notify_reporter(report, action)
return action_result(request, f"{QUEUE_URL}/{uid}")
def _notify_subject(subject: dict | None, action: str, report: dict, reason: str) -> None:
template = DECISION_MESSAGES.get(action)
if not subject or not template:
return
message = template.format(target=report["target_type"])
if reason:
message = f"{message} Reason: {reason}."
enforcement.notify_subject(subject["uid"], message)
def _notify_reporter(report: dict, action: str) -> None:
reporter_uid = report.get("reporter_uid") or ""
if not reporter_uid or reporter_uid == SYSTEM_ACTOR:
return
if not get_table("users").find_one(uid=reporter_uid):
return
verb = "dismissed" if action == "dismiss" else "actioned"
create_notification(
reporter_uid,
"moderation",
f"Your report on a {report['target_type']} was {verb}.",
reporter_uid,
"/reports/mine",
)

View File

@ -30,7 +30,6 @@ TRASH_TABLES = [
{"key": "projects", "label": "Projects", "icon": "\U0001f680", "type": "project"},
{"key": "news", "label": "News", "icon": "\U0001f4f0", "type": "news"},
{"key": "awards", "label": "Awards", "icon": "\U0001f3c6", "type": "award"},
{"key": "quizzes", "label": "Quizzes", "icon": "\U0001f9e9", "type": "quiz"},
{"key": "project_files", "label": "Project files", "icon": "\U0001f4c1", "type": None},
{"key": "attachments", "label": "Attachments", "icon": "\U0001f4ce", "type": None},
]

Some files were not shown because too many files have changed in this diff Show More