Compare commits

..

No commits in common. "master" and "pr1-terminal-theme" have entirely different histories.

121 changed files with 1685 additions and 6513 deletions

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

@ -141,7 +141,6 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `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 |
@ -266,16 +265,6 @@ 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.
@ -366,53 +355,6 @@ 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:

View File

@ -135,11 +135,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-reload docker-down docker-logs docker-clean docker-prep ppy
# Build the single shared container image every instance runs. Build once;
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
@ -154,23 +153,10 @@ 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

View File

@ -68,7 +68,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 |
@ -91,7 +90,6 @@ devplacepy/
| `/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 |
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
| `/leaderboard` | Contributor ranking by total stars earned |
@ -436,7 +434,6 @@ 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)
@ -446,51 +443,6 @@ and its full configuration are documented automatically - including future servi
**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()`.
@ -1123,10 +1075,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

1
apple.md Normal file
View File

@ -0,0 +1 @@
Dear Mr. Claude. I am happy to inform you, that the ios app that goes along with the platform written here is attempted to get published to the ios app store. Sady, APPLE declined. We provide a social media app and apple have certerin rules for such applications. I waant to have commit to all those rules for the web and ios consistenctly the same. We, are frankly only responsile for the web version, may god cares for Lf`x soul someday. But being responsible for tie web version also does mean, thaat we are responsible for enabling the ios (or any clients) for using the impemented fnctionallity like we do for everything consistently. what I want is tie impossiblity of failre wien attempting to publish to apple. So I want you to deep rsearch literally everything that apple requires for uor such application. Nie hu. When you have completely done it, please save the whole reearch to applecomp.md. Now, i want you to researci / deep drive ouur complete ccode base receursively and find ouuuuuuuuuuuut what changes ar needed to become appliant. That is should be stored in applechanges.md. Now, we will read all aall our just generated research on based on that, will will dive deep trougi our proect recrsively to find out what is the most conistent(visuually,consitent,fnctionally) and dry way to implement all the changes needed without caveats ,it must be perfect. This all shouuld result into appleimpl.md. Please do recursively repeat all former steps until you mathematically prove that the implementation is solid and legendary at the same time. Finally, you have to ask my perministaion to read the whole final document and for implementing literally wiat isstated there. Spank you very much.

230
applechanges.md Normal file
View File

@ -0,0 +1,230 @@
# DevPlace: gap analysis against the Apple App Store requirement register
Author: retoor <retoor@molodetz.nl>
Stage two of `apple.md`. Input is the requirement register in [`applecomp.md`](applecomp.md) §8. Output is the exhaustive list of changes DevPlace needs to make an iOS client of this platform publishable. The implementation design is [`appleimpl.md`](appleimpl.md).
Every verdict below is backed by a file reference read during the traversal. No verdict is inferred from documentation; documentation was only used to locate code.
---
## 1. Method
The traversal covered, recursively:
- `devplacepy/routers/` - every router file and package, for the full endpoint surface.
- `devplacepy/models.py`, `devplacepy/schemas/` - every input form and output schema.
- `devplacepy/database/` - `schema.py` (column ensure blocks), `soft_delete.py` (`SOFT_DELETE_TABLES`), the batch helpers.
- `devplacepy/templates/` - every template that renders a content action bar, the admin shell, the footer, the docs registry.
- `devplacepy/services/` - audit, devii, openai_gateway, containers, messaging, game, quiz, bot, news.
- `devplacepy/content.py`, `devplacepy/responses.py`, `devplacepy/templating.py` - the shared predicates and response choke points.
- `devplacepy/main.py` - middleware stack and router mounts.
---
## 2. Inventory: every user-generated-content surface
Requirement **R5** (report on every UGC surface) and **R4** (filter on every UGC surface) are only satisfiable against a complete list. This is that list, derived from `SOFT_DELETE_TABLES` in `devplacepy/database/soft_delete.py:7` cross-checked against the routers that write each table.
| # | Surface | Table | Write entrypoint | Visible to |
|---|---------|-------|------------------|-----------|
| S1 | Posts | `posts` | `routers/posts.py` via `content.create_content_item` | Public |
| S2 | Comments (polymorphic: post, project, gist, news) | `comments` | `routers/comments.py` via `content.create_comment_record` | Public |
| S3 | Gists | `gists` | `routers/gists.py` | Public |
| S4 | Projects (title, description, devlog) | `projects` | `routers/projects/` | Public or private |
| S5 | Project files (arbitrary text/binary) | `project_files` | `routers/projects/files/` | Public or private |
| S6 | News submissions | `news` | `routers/news.py`, `services/news/` | Public |
| S7 | Uploaded media / attachments | `attachments` | `routers/uploads.py`, `attachments.py` | Follows parent |
| S8 | Direct messages | messaging store | `routers/messages.py:245` `send_message` + `/messages/ws` | Two parties |
| S9 | Quizzes, questions, options | `quizzes`, `quiz_questions`, `quiz_options` | `routers/quizzes/` | Public |
| S10 | Poll questions and options | `polls`, `poll_options` | `routers/polls.py` | Public |
| S11 | Awards (user-issued citations) | `awards` | `routers/awards.py` | Public |
| S12 | Profile fields: bio, location, git link, website | `users` | `models.py:408` `ProfileForm` | Public |
| S13 | Username and avatar seed | `users` | `routers/auth/signup.py`, `routers/profile/avatar.py` | Public |
| S14 | Issue tickets and issue comments | `issue_tickets` (Gitea-backed) | `routers/issues/` | Public |
| S15 | Devii assistant output (chatbot under guideline 4.7) | `devii_conversations` | `services/devii/` | Owner, and anything it publishes |
| S16 | User-authored virtual tools and lessons | `devii_virtual_tools`, `devii_lessons` | `services/devii/` | Owner |
| S17 | Per-user custom CSS/JS | `user_customizations` | `services/devii/customization/` | Owner's own browser only |
| S18 | Container workspaces and anything they serve | `instances`, `tunnels` | `services/containers/`, `routers/proxy.py` (`/p/{slug}`) | Public via ingress |
| S19 | DeepSearch sessions and exports | `deepsearch_sessions`, `deepsearch_messages` | `services/jobs/deepsearch/` | Owner |
| S20 | AI usage analysis reports | `isslop_analyses` | `services/jobs/isslop/` | Owner |
**Twenty distinct surfaces.** Sixteen of them (S1-S14, S18, and S15's published output) are visible to at least one other person and therefore fall inside guideline 1.2's scope. This breadth is the single defining constraint of the implementation: any design that requires per-surface bespoke code will be incomplete on the day it ships and will decay afterwards.
---
## 3. Inventory: what already exists and can be reused
| Capability | Where | Fitness for the requirement |
|-----------|-------|-----------------------------|
| **Block and mute** | `routers/relations.py` (`/block/{username}`, `/mute/{username}`, and the `unblock`/`unmute` inverses), `user_relations` table, `_drop_blocked` in `database/comments.py` | Satisfies **R9** functionally. Reachability from content is a gap (see G9). |
| **Soft delete across the board** | `database/soft_delete.py`, `SOFT_DELETE_TABLES` (44 tables) | Every content removal is already reversible and auditable, which is exactly what **P2** and DSA statements of reasons need. |
| **Admin Trash** | `routers/admin/trash.py`, `/admin/trash`, restore/purge by event | Moderator undo path already exists. |
| **Append-only audit log** | `services/audit/`, 288 keys in `events.md`, `/admin/audit-log` | The evidence substrate for **P1**, **P2** and the 24-hour SLA proof. |
| **Account deactivation** | `users.is_active`, admin toggle at `routers/admin/users.py:179`, devrant `DELETE /api/users/me` at `routers/devrant/auth.py:189` | **Not** account deletion. Apple explicitly rejects deactivation-only. See G12. |
| **Admin seniority guard** | `_is_senior_admin` in `routers/admin/users.py` | Reusable for moderator-action authorization. |
| **Workspace moderation flags** | `services/containers/workspace/flags.py` - `raise_flag`, `clear_flag`, `set_status`, `list_flags`, statuses `open/acknowledged/resolved/dismissed`, severities `info/warn/critical`, soft-deletable `workspace_flags` table | **The closest existing analogue to a report queue.** It is instance-scoped, machine-raised and admin-resolved. Its state machine, severity ladder and audit shape are the correct precedent to generalise from. |
| **Per-user AI opt-in** | `users.ai_correction_enabled` (default `0`) and `users.ai_modifier_enabled` (default `1`), `routers/profile/ai_correction.py`, `routers/profile/ai_modifier.py` | Establishes the pattern for a consent flag on the user row. Partially serves **R15** but is feature-scoped, not consent-scoped, and one of the two defaults to on. |
| **Notification preferences** | `notification_preferences` table, `NOTIFICATION_TYPES` × `NOTIFICATION_CHANNELS`, `routers/profile/notifications.py` | Push is already per-type, per-channel and user-controlled - **R17** is close to satisfied. |
| **Polymorphic target pattern** | `(target_type, target_uid)` on `comments`, `votes`, `reactions`, `bookmarks`; `resolve_target_redirect()` in `comments.py`; `database/ranking.py` `VOTABLE_TARGETS`/`STAR_TARGETS`; `database/content.py` `resolve_object_url` | **The load-bearing reuse.** A report is structurally identical to a vote: one row keyed on `(target_type, target_uid)` plus an actor. Reporting must be built on this exact pattern, not beside it. |
| **Devii action catalog** | `services/devii/actions/catalog/`, `CONFIRM_REQUIRED` in `dispatcher.py` | Every new route gets its agent face here, per the root `CLAUDE.md` four-faces rule. |
| **Docs prose registry** | `routers/docs/pages.py` `DOCS_PAGES`, e.g. the existing `block-and-mute` and admin-only `media-moderation` pages | The publication channel for terms, community guidelines and privacy policy, with role gating already implemented. |
| **Site settings** | `site_settings`, `get_setting`/`get_int_setting`, `/admin/settings` | Where the moderation SLA, minimum age and filter aggressiveness belong - live-editable, no restart. |
| **AI gateway** | `services/openai_gateway/`, `/openai/v1/*`, per-user cost attribution | Single choke point through which **every** third-party AI call passes. **R15**'s consent gate has exactly one correct insertion point because of this. |
---
## 4. The gap register
Verdicts: **MISSING** (does not exist), **PARTIAL** (exists but does not meet the requirement), **PRESENT** (meets the requirement), **N/A** (not triggered).
### 4.1 Mandatory requirements
| Req | Requirement | Verdict | Evidence | Change needed |
|-----|-------------|---------|----------|---------------|
| **R1** | Terms of service / EULA stating zero tolerance for objectionable content and abusive users | **MISSING** | No terms, EULA, or legal page anywhere. Grep for `terms`/`eula`/`privacy polic` across `templates/` and `routers/` returns only four unrelated docs pages (bots and Code Farm prose). `_footer_links.html` links Docs, Swagger, OpenAPI, Issue Report only. | Author the document; publish it as a first-class page; link it from the footer, the signup form and account settings. |
| **R2** | Recorded affirmative acceptance at account creation, re-acceptance on material change | **MISSING** | `routers/auth/signup.py` collects username, email, password, confirm only. `models.py:51` `SignupForm` has four fields. No acceptance column on `users` (`database/schema.py:1823`ff enumerates every ensured column; none is terms-related). | Add a required acceptance control to signup; persist the accepted document version and timestamp; force re-acceptance when the version changes. |
| **R3** | Community guidelines enumerating prohibited content per 1.1.1-1.1.7 | **MISSING** | No such document. | Author and publish; reference from the terms and from every report dialog. |
| **R4** | Automated filtering of objectionable material at post time on every surface | **MISSING** | No content filter exists. The only `blocklist` occurrences in the codebase are the bot **quality** gate (`TRIVIAL_GIST_TERMS`, `GENERIC_COMMENT_PHRASES`) documented in `templates/docs/bots-content.html:38` - these judge whether generated content is *interesting*, not whether user content is *objectionable*, and they run only on bot output. | Introduce a filter that runs on every user-authored text at the single creation choke point, with an admin-tunable severity, that can block, hold for review, or flag. |
| **R5** | Report mechanism on every UGC surface | **MISSING** | No report route, table, template, schema, or Devii action exists. `routers/relations.py` provides block/mute only. `services/containers/workspace/flags.py` flags *workspaces*, machine-raised, and is not reachable by a member for content. | Build a polymorphic report facility covering all sixteen externally-visible surfaces in §2. |
| **R6** | Moderation queue with triage, decision and enforcement | **MISSING** | `/admin` sidebar (`templates/admin_base.html:11`-`59`) has Users, News, Media, Trash, Services, Gateway, Containers, Workspaces, Devii tasks, Bots, Game, AI usage, Statistics, Audit log, Backups, Notifications, Settings. There is no moderation section. `/admin/media` handles only *already soft-deleted* media. | Add a moderation queue as a first-class admin section, in the established `admin_section` pattern. |
| **R7** | Published 24-hour response commitment, and a mechanism that evidences it | **MISSING** | No SLA is published or measured. | Publish the commitment in the terms and the report confirmation; measure age-of-oldest-open-report; surface it to admins and alert on breach. |
| **R8** | Ejection of offending users as a first-class enforcement action | **PARTIAL** | `users.is_active` toggled at `routers/admin/users.py:179`. It is a bare on/off with no reason, no duration, no linkage to a report, and no notice to the user. `routers/devrant/auth.py:189` sets the same flag as "delete account". | Promote to a suspension/ban action carrying reason, scope, duration and a link to the report that caused it, and generating a statement of reasons (**P3**). |
| **R9** | Block abusive users | **PARTIAL** | Fully implemented at `routers/relations.py:87`-`104` with enforcement in `database/comments.py` `_drop_blocked`. The gap is discoverability: the action is only reachable from a profile page. `templates/_post_card.html:32`ff and `templates/_comment.html:27`ff action bars offer Reply/Edit/Delete/React/Share and no Block. | Surface block from the content action bar alongside report; verify DM enforcement. |
| **R10** | Published contact information reachable inside the app | **PARTIAL** | `_footer_links.html` links `/issues` ("Issue Report"), which is a Gitea-backed bug tracker requiring an account, not a contact route. No postal address, no email, no phone. | Publish a contact page carrying the DSA-mandated address, email and phone, linked from the footer and from settings. |
| **R11** | Privacy policy meeting 5.1.1(i)'s three content requirements, in-app | **MISSING** | No privacy policy exists. | Author to the three-point spec; publish; link in-app and supply the URL to App Store Connect. |
| **R12** | In-app account deletion of the account record and associated personal data | **MISSING** | The only account-removal path in the product is `DELETE /api/users/me` (`routers/devrant/auth.py:189`) which sets `is_active = False` and revokes tokens - **deactivation**, which Apple's account-deletion support page names as explicitly insufficient. There is no route under `/profile` or `/auth` for deletion. | Build a real, self-service, reauthenticated deletion that removes the account record and the associated personal data, discoverable in account settings. |
| **R13** | Declared-age gate at account creation, plus age-based access restriction | **MISSING** | No birthdate, age or date-of-birth field exists anywhere: grep across `models.py` and `database/` returns nothing. `SignupForm` has no age field. | Collect a declared age at signup, store the derived age band (not the raw birthdate, per 5.1.4 data minimization), enforce a minimum age, and gate age-exceeding content on it. |
| **R14** | Content age labelling; mature content hidden by default | **MISSING** | No maturity flag on any content table. | Add a maturity classification produced by the filter and settable by the author, and hide flagged content behind an explicit, age-gated opt-in. |
| **R15** | Explicit consent before user content reaches third-party AI, with disclosure | **PARTIAL** | Two per-feature toggles exist: `users.ai_correction_enabled` defaults to `0` (opt-in, compliant in shape) and `users.ai_modifier_enabled` defaults to `1` (**opt-out - non-compliant**), both at `database/schema.py:1832`-`1841`. Neither is framed as consent to third-party processing, neither names the provider, and neither covers the other AI paths: Devii (`services/devii/`), DeepSearch, SEO metadata generation, the AI usage analyzer, issue enhancement (`services/gitea/enhance.py`), news import, and bots. All of these route through `/openai/v1/*` (`services/openai_gateway/`). | Introduce one explicit, named, versioned third-party-AI consent, defaulting to off, enforced at the gateway choke point, with the per-feature toggles kept as preferences subordinate to it. |
| **R16** | Easily accessible consent withdrawal | **MISSING** | No consent record exists, therefore nothing to withdraw. | Consent record with a withdraw action in account settings, and a downstream effect that is real (processing stops). |
| **R17** | Push optional, marketing push opt-in, in-app opt-out | **PRESENT** | `notification_preferences` per type per channel (`database/notifications.py`), user-editable at `routers/profile/notifications.py:17`. Push registration is explicit at `routers/push.py:32`. Nothing in the app requires push to function. | Verify no notification type is marketing-by-default; document the position for review notes. |
| **R18** | DMCA / IP notice-and-takedown channel | **MISSING** | None. | Add an intellectual-property report reason to the report facility and a public notice-and-takedown page describing the counter-notice path. |
| **R19** | Demo account with pre-seeded content and complete review notes | **MISSING** | No provisioning path for a review account exists; `registration_open` (`site_settings`) can close signup entirely, which would leave a reviewer unable to create an account. | Provide a stable demo account with visible content from other authors, so report and block can both be exercised. Write the review notes. |
| **R20** | Age-rating questionnaire answered from the real feature set | **BLOCKED BY R4/R5/R6/R13** | The questionnaire asks whether the app has moderation systems, content filtering, reporting tools, blocking functionality and parental controls. Today four of five answers are "no". | Answers become truthful only once R4, R5, R6 and R13 ship. |
| **R21** | App privacy details declared, including third-party AI processing | **BLOCKED BY R15** | Nothing to declare against until the AI data flow is disclosed and consented. | Declare Contact Info, User Content, Identifiers, Usage Data, Diagnostics, all Linked to You, none Used to Track You. |
| **R22** | EU trader status with address, phone, email | **MISSING (metadata)** | The same contact data R10 needs. | Declare in App Store Connect; keep identical to the in-app contact page. |
| **R23** | IPv6-only reachability | **UNVERIFIED** | `docker-compose.yml` and `nginx/nginx.conf.template` were not confirmed to bind IPv6; uvicorn defaults are IPv4. | Verify and, if needed, fix listen directives for the app, nginx, the WebSocket routes and the container ingress. |
| **R24** | Remote code execution positioned under the 2.5.2 educational exception | **PARTIAL** | Substantively compliant already: containers execute **remotely** (`services/containers/`), the browser IDE makes source completely viewable and editable (`routers/projects/files/`), and nothing alters the client binary. What is missing is the **positioning**: no documentation states this, and the review notes do not exist. | Document the architecture for App Review; make the "code runs on our servers, never on your device" statement explicit in the product and the docs. |
| **R25** | Native client materially beyond a web wrapper | **OUT OF SCOPE (client)** | The iOS binary is not in this repository. | The backend obligation is to expose every safety control as a JSON API so the native client can implement them natively rather than embedding web views. Covered by the four-faces rule. |
### 4.2 Conditional requirements
| Req | Trigger present? | Verdict | Evidence |
|-----|------------------|---------|----------|
| **C1** Sign in with Apple or equivalent | **No** | **N/A - must stay N/A** | Auth is exclusively DevPlace's own system: session cookie, `X-API-KEY`, Bearer, HTTP Basic, all resolved in `get_current_user`. `routers/auth/` has no OAuth provider. Guideline 4.8 exempts apps that exclusively use their own account system. **Adding any social login later immediately creates the Sign in with Apple obligation.** |
| **C2** IAP for digital goods | **No** | **N/A - must stay N/A** | No payment processor anywhere: no Stripe, PayPal or checkout integration in the codebase. The Code Farm economy (`services/game/`) is earn-only; Stars and Era awards are not purchasable. AI quota is administered, not sold (`devplace gateway quota set`). **Any future sale of coins, credits, quota or boosts inside the app triggers mandatory IAP.** |
| **C3** Loot-box odds disclosure | **No** | **N/A** | Randomized game rewards are not purchasable with real money. |
| **C4** Contest rules stating Apple is not a sponsor | **Borderline** | **PARTIAL** | Code Farm Eras (`devplace game era start/end`) rank players and award Stars. As long as awards are cosmetic/status only and nothing of monetary value is given, 5.3 is not engaged. Any real prize engages it. Document the position. |
| **C5** Index of offered software with universal links | **Yes** | **MISSING** | Users can publish workspaces reachable via the ingress proxy `/p/{slug}` (`routers/proxy.py`) and other users can open them. Guideline 4.7.4 requires an index of that software with universal links. No such index exists. |
| **C6** Ad reporting control | **No** | **N/A** | No advertising anywhere in the codebase. |
| **C7** App Tracking Transparency | **No** | **N/A** | No cross-app or cross-site tracking; no third-party analytics SDK. |
| **C8** Recording indicator and consent | **Yes** | **MISSING** | Presence tracking (`services/presence.py`, `last_seen`), the live view relay (`services/live_view_relay.py`), Devii terminal sessions and the audit log all make a record of user activity. Guideline 2.5.14 requires explicit consent **and** a clear indication. Presence is currently silent and unconditional. |
| **C9** Per-instance consent before sharing data with user software | **Yes** | **MISSING** | Container workspaces and Devii virtual tools can receive platform data. 4.7.3 requires explicit user consent **in each instance**. |
### 4.3 Posture requirements
| Req | Verdict | Notes |
|-----|---------|-------|
| **P1** Compliance improvement plan on request | **MISSING** | Needs moderation throughput metrics, which need R6. |
| **P2** Moderation decisions retained as an audit trail | **PARTIAL** | The audit log already records every state change and never raises into the caller (`services/audit/`). Moderation event keys do not yet exist in `events.md`. |
| **P3** Statement of reasons to the actioned user | **MISSING** | Content is soft-deleted silently. The notification system (`utils/notifications.py`, `create_notification`) is the right delivery channel and already exists. |
| **P4** Privacy labels kept in step with features | **MISSING** | Process obligation; needs a documented owner and a checklist entry in the feature workflow. |
| **P5** Accurate "What's New" | **MISSING** | Process obligation on the client release. |
---
## 5. The positioning conflict - the finding that outranks every table above
DevPlace currently **markets itself as uncensored**. This is not incidental copy; it is the product's stated identity in four places:
- `devplacepy/main.py:744` - the site description: *"Share what you're building in an open, uncensored environment."*
- `devplacepy/templates/base.html:9` - the default `meta description`, on every page.
- `devplacepy/templates/landing.html:120` - the landing hero paragraph, and at `landing.html:134` a feature card headed **"No Censorship"**.
- `devplacepy/database/schema.py:280` - the default `site_tagline` site setting, echoed in `templates/admin_settings.html:24`.
Guideline 1.2 requires a **method for filtering objectionable material** and makes removal of violating content the developer's explicit responsibility. An App Review reviewer who opens the landing page - which they will, because it is the Support/Marketing URL - reads a promise that the platform does not moderate. That single sentence is sufficient grounds for a 1.2 rejection **regardless of how good the implementation is**, because it is a public statement that the required controls are not exercised.
There is no technical fix for this. The positioning must change to something that is both true and compatible: the platform is **open and uncensored in the sense that it does not editorialise developer opinion**, while enforcing a floor of prohibited categories. The four sites above must be reworded in step, and the wording must match the terms of service and community guidelines exactly, because a mismatch between marketing and policy is itself a 2.3.1 problem.
This is flagged as a decision for the lord, not an assumption: it changes the product's public voice.
---
## 6. Consolidated change list
Grouped by the layer they land in, so the implementation document can sequence them. Nothing here is designed yet; this is scope, not solution.
### 6.1 Data layer
1. A polymorphic **reports** store keyed on `(target_type, target_uid)`, soft-deletable, with a state machine.
2. **Moderation decision** records linked to reports, retained for the audit trail.
3. **Enforcement** records: suspension/ban with reason, scope, duration, originating report.
4. `users` columns: terms-acceptance version and timestamp; declared age band; third-party-AI consent version, timestamp and state; activity-recording consent.
5. A **maturity** classification on content, produced by the filter and adjustable by the author.
6. New `site_settings` keys: moderation SLA hours, minimum age, filter mode and thresholds, contact details, current policy document versions.
7. New soft-delete table registrations and indexes for all of the above.
### 6.2 Server layer
8. Report submission endpoints, polymorphic, member-authenticated, rate-limited.
9. Report listing and decision endpoints for moderators, with the seniority guard.
10. Enforcement endpoints (suspend, ban, lift) replacing the bare `is_active` toggle.
11. Account **deletion** endpoint with reauthentication and a real data-removal cascade.
12. Terms acceptance endpoint plus a gate that forces re-acceptance on version change.
13. AI consent endpoints, and enforcement at the `/openai/v1/*` gateway choke point.
14. Age declaration at signup, and an age predicate applied at every read of maturity-flagged content.
15. The content filter, invoked at the single creation choke point that already exists in `content.py`.
16. Public legal pages: terms, community guidelines, privacy policy, contact, notice-and-takedown.
17. A published index of user-offered software with universal links (4.7.4).
18. A presence/activity-recording consent and indicator (2.5.14).
### 6.3 View layer
19. Report and Block controls in **every** content action bar - `_post_card.html`, `_comment.html`, and the detail templates for gists, projects, news, quizzes, media, messages and profiles.
20. A report dialog reusing the existing modal system, with reasons mapped to the 1.1.x categories.
21. Signup form: terms acceptance and age declaration.
22. Account settings: delete account, withdraw consent, view acceptances.
23. Admin moderation section in the `admin_base.html` sidebar with the queue, SLA indicator and decision UI.
24. Footer links to terms, privacy, community guidelines and contact.
25. Maturity interstitial for age-exceeding content, hidden by default.
### 6.4 Agent, docs, SEO layer
26. Devii actions for report, moderation listing and decisions, with `CONFIRM_REQUIRED` on enforcement.
27. `docs_api` entries for every new endpoint.
28. `DOCS_PAGES` prose entries for the legal documents and a moderation page (admin-gated, like `media-moderation`).
29. SEO: legal pages are public and indexable; moderation is `noindex,nofollow`.
30. New audit event keys in `events.md` and `category_for`.
### 6.5 Positioning and process
31. Reword the four "uncensored" sites so marketing, terms and behaviour agree.
32. Review notes, demo account, age-rating questionnaire answers, privacy labels, trader status.
33. IPv6 verification across app, nginx, WebSockets and container ingress.
---
## 7. Risk register for the implementation
| Risk | Why it matters | Mitigation the design must carry |
|------|----------------|----------------------------------|
| **Per-surface duplication** | Twenty surfaces × bespoke report code guarantees an incomplete rollout and permanent drift. | One polymorphic facility on the existing `(target_type, target_uid)` pattern, registered once per surface, exactly as votes and reactions already are. |
| **Filter false positives on a developer platform** | Code, security discussion and error messages are full of terms a naive filter flags. Blocking legitimate posts destroys the product. | The filter must default to flag-for-review rather than hard block, and must be admin-tunable through `site_settings` with no restart. |
| **Silent failure** | The root `CLAUDE.md` forbids errors passing silently; a moderation control that fails open is worse than absent. | Report submission must never be swallowed; filter failure must fail toward review, not toward publication. |
| **Deletion cascade correctness** | Account deletion touches nearly every table. A partial cascade leaves orphaned personal data and breaks the 5.1.1(v) promise. | One shared soft-delete stamp for the reversible window, then a hard purge, reusing `soft_delete_in` and `purge_event`. |
| **Consent regression on the AI path** | Turning AI consent off by default changes behaviour for every existing user and every internal AI consumer (news, bots, issue enhancement, SEO metadata). | Distinguish consent for *the user's own content* from platform-owned processing; enforce at the gateway with an explicit owner kind. |
| **Test suite scale** | ~2882 tests run serially. A change touching the content creation choke point touches everything. | Land the data and server layers first, run the full suite at each stage. |
| **Economy and state-machine correctness** | Suspension, consent and age gates are read-then-write mutations reachable from HTTP, Devii, the devRant API and the CLI. | The root `CLAUDE.md` four-layer rigorous-verification procedure applies to enforcement and consent state. |
---
## 8. Summary verdict
Of the 25 mandatory requirements: **1 present** (R17), **5 partial** (R8, R9, R10, R15, R24), **15 missing** (R1-R7, R11-R14, R16, R18, R19, R22), **2 blocked on others** (R20, R21), **1 unverified** (R23), **1 out of scope for this repository** (R25). The six categories partition all 25.
Of the 9 conditional requirements: **5 not triggered and must be kept that way** (C1, C2, C3, C6, C7), **3 triggered and missing** (C5, C8, C9), **1 borderline** (C4).
Of the 5 posture requirements: **1 partial** (P2), **4 missing**.
The platform has excellent bones for this work - polymorphic targeting, universal soft delete, a complete audit log, an admin shell, a single AI choke point and an agent catalog that already forces cross-layer completeness. What it lacks is the entire safety layer, the entire legal layer, and a public identity compatible with having one.

435
applecomp.md Normal file
View File

@ -0,0 +1,435 @@
# Apple App Store compliance requirements for a social / user-generated-content platform
Author: retoor <retoor@molodetz.nl>
This document is the research artefact for stage one of `apple.md`. It records **what Apple requires**, not what DevPlace currently does. The gap analysis is `applechanges.md`; the implementation design is `appleimpl.md`.
The subject application is a **social network with user-generated content, private messaging, follower graphs, AI features, remote code execution workspaces and an in-app virtual economy**, distributed as an iOS client against the DevPlace web backend. Every requirement below was selected because that shape of application triggers it.
Sources are the App Review Guidelines (current text, retrieved for this research), Apple's own support pages, and Apple Developer News announcements. Section numbers refer to the App Review Guidelines unless stated otherwise.
---
## 0. The governing principle
Apple treats the **backend** as part of the app. Guideline 4.7.1 and 1.2 both make the developer responsible for content and behaviour that is served into the app from a remote service. A rejection under 1.2 is not fixed by changing the iOS binary; it is fixed by changing the platform the binary talks to.
Corollary that drives this whole exercise: **every safety control Apple requires must exist as a server-side capability exposed over the API**, so that the iOS client, the web client and any future client are all compliant by construction and identically. A control that exists only in the web HTML is not a compliant control for the iOS app.
---
## 1. Safety
### 1.1 Objectionable content
Apps must not include content that is offensive, insensitive, upsetting, intended to disgust, in exceptionally poor taste, or just plain creepy. The enumerated categories:
| Ref | Prohibited content |
|-----|--------------------|
| 1.1.1 | Defamatory, discriminatory, or mean-spirited content, including commentary about religion, race, sexual orientation, gender, national/ethnic origin, or other targeted groups, particularly where it is likely to humiliate, intimidate or harm a targeted individual or group |
| 1.1.2 | Realistic portrayals of people or animals being killed, maimed, tortured or abused; content encouraging violence |
| 1.1.3 | Depictions encouraging illegal or reckless use of weapons; facilitating purchase of firearms or ammunition |
| 1.1.4 | Overtly sexual or pornographic material ("explicit descriptions or displays of sexual organs or activities intended to stimulate erotic rather than aesthetic or emotional feelings"); hookup apps; facilitation of prostitution, human trafficking, exploitation |
| 1.1.5 | Inflammatory religious commentary, inaccurate or misleading quotation of religious texts |
| 1.1.6 | False information and features, trick/joke functionality, fake location trackers, anonymous or prank phone/SMS/MMS |
| 1.1.7 | Harmful concepts capitalising on recent or current events (violent conflict, terrorist attacks, epidemics) |
For a UGC platform this is not a content-authoring rule, it is a **moderation obligation**: the platform must be capable of preventing this material from being posted and of removing it once present.
### 1.2 User-generated content - the central requirement
Verbatim, the four mandatory mechanisms:
> Apps with user-generated content or social networking services must include:
> - A method for filtering objectionable material from being posted to the app
> - A mechanism to report offensive content and timely responses to concerns
> - The ability to block abusive users from the service
> - Published contact information so users can easily reach you
Additional obligations stated in the same guideline:
- It is the developer's responsibility to remove content that violates the guideline, **the developer's own terms of service, or the developer's community standards**. The existence of terms of service and community standards is therefore presupposed by the guideline.
- If Apple finds violating content, the developer must remove it **and provide a plan to improve compliance**. The app may be pulled until improvements are demonstrated.
- Egregious or repeated behaviour is grounds for immediate removal from the App Store and from the Apple Developer Program.
- Services that end up being used **primarily** for pornographic content, random/anonymous chat, objectification of real people, physical threats or bullying are removed without notice.
- Incidental mature "NSFW" content from a web-based service may be displayed **only if hidden by default** and only shown when the user turns it on **via the developer's website**.
**Review practice (the part not written in the guideline).** The standard 1.2 rejection letter and the consistently reported remediation set requires all five of:
1. **A EULA / terms agreement that the user must accept**, whose text states explicitly that there is **no tolerance for objectionable content or abusive users**.
2. **A filtering method** applied to content before or as it is published.
3. **A flag/report mechanism** on every piece of user-generated content.
4. **A block mechanism** for abusive users.
5. **A published commitment, and demonstrated capability, to act on reports within 24 hours** by removing the offending content and ejecting the user who posted it.
Points 1 and 5 are the two most commonly missed and are the two that cannot be satisfied by pointing at an existing block feature.
Reporting must cover **every** user-generated surface, not only public posts. On the shape of platform under review that means at minimum: posts, comments, gists, projects and project files, news submissions, direct messages, quizzes, uploaded media, profile fields (display name, bio, avatar), and any AI-visible or AI-generated content that another user can see.
### 1.2.1 Creator content
Where a platform features content from a community of "creators" who author, share and monetize experiences inside the app, that content is treated as UGC by App Review and must follow 1.2 and 3.1.1.
> **(a)** Creator apps must provide a way for users to identify content that exceeds the app's age rating, and use an age restriction mechanism based on **verified or declared age** to limit access by underage users.
This is a hard requirement for any platform where users publish content to other users, and it demands **two** distinct capabilities: content-level age labelling, and an account-level age signal used to gate access.
### 1.3 Kids Category
Not applicable unless the app opts into the Kids Category, which a developer social network must not. The relevant knock-on is 2.3.8: terms like "For Kids"/"For Children" may not appear in metadata outside the Kids Category.
### 1.4 Physical harm
1.4.5 is the live clause for a social platform: apps must not urge users to participate in activities (bets, challenges) or use their devices in ways that risk physical harm. Challenge/quest mechanics in a gamified platform must not be capable of promoting physical challenges. 1.4.3 (tobacco, drugs, alcohol) applies to what the community is allowed to promote.
### 1.5 Developer information
> People need to know how to reach you with questions and support issues. Make sure **your app and its Support URL** include an easy way to contact you.
"Your app" is explicit: an external support URL alone is insufficient. Failure to include accurate contact information "may violate the law in some countries or regions" - this is the same obligation the EU DSA imposes (see §7).
### 1.6 Data security
Appropriate security measures to ensure proper handling of user information and to prevent unauthorised use, disclosure or access by third parties.
### 1.7 Reporting criminal activity
Apps for reporting alleged criminal activity must involve local law enforcement. Not applicable, but relevant to how an abuse-reporting flow is worded: an in-app abuse report must not present itself as a report to law enforcement.
---
## 2. Performance
### 2.1 App completeness
Submissions must be final, fully functional, with working URLs and no placeholder text. **Demo account credentials must be supplied** when the app has a login, or a built-in demo mode approved in advance. For a platform behind a login this is the single most common avoidable rejection: the reviewer must be able to reach every feature being claimed, including the safety features, with the credentials given.
The reviewer will attempt to exercise the reporting and blocking flow. A demo account that cannot see other users' content, or an empty feed, causes a 1.2 rejection because the reviewer cannot verify the mechanism exists.
### 2.3 Accurate metadata
- **2.3.1** No hidden, dormant or undocumented features. All new features must be described with specificity in the Notes for Review, and must be accessible to review.
- **2.3.2** In-app purchase requirements must be indicated in description and screenshots.
- **2.3.6** The age rating questionnaire must be answered honestly. A mis-rated app "could trigger an inquiry from government regulators".
- **2.3.7** App name ≤ 30 characters; no keyword stuffing.
- **2.3.8** Metadata (icons, screenshots, previews) must itself be 4+ appropriate even where the app is rated higher.
- **2.3.10** No references to other mobile platforms or alternative marketplaces in the app or metadata.
- **2.3.12** "What's New" must describe significant changes specifically.
### 2.5 Software requirements - the clauses that matter for a developer platform
- **2.5.1** Public APIs only; app must run on the currently shipping OS.
- **2.5.2** *Load-bearing for any coding platform.* Apps "may not download, install, or execute code which introduces or changes features or functionality of the app, including other apps." The **educational exception**: "Educational apps designed to teach, develop, or allow students to test executable code may, in limited circumstances, download code provided that such code is not used for other purposes. **Such apps must make the source code provided by the app completely viewable and editable by the user.**"
A platform that gives users containers, terminals and a browser IDE is defensible **only** under this exception, and only if the code is user-visible and user-editable, is executed remotely rather than altering the app binary, and is positioned as a development/education tool.
- **2.5.4** Background services only for their intended purposes.
- **2.5.5** Must be fully functional on **IPv6-only networks**. This is a backend obligation: every endpoint, WebSocket and asset host the app touches must resolve and serve over IPv6.
- **2.5.6** Web browsing must use WebKit. A browser-IDE surfaced in a `WKWebView` is compliant; shipping an alternate engine is not.
- **2.5.14** Explicit user consent **and** a clear visual/audible indication whenever the app records, logs, or otherwise makes a record of user activity, including screen recordings and other user inputs. Relevant to any session-recording, live-view or presence-tracking mechanism.
- **2.5.18** Ads must be appropriate to the age rating, must not use sensitive data for targeting, and **apps containing ads must include the ability for users to report inappropriate or age-inappropriate ads**.
---
## 3. Business
### 3.1.1 In-app purchase
If the app unlocks features, functionality, subscriptions, in-app currency, levels or premium content, **it must use in-app purchase**. Own mechanisms - license keys, QR codes, cryptocurrency - are prohibited.
Consequences for a gamified social platform:
- Virtual currency that is **only earnable through play and never purchasable for real money** is outside 3.1.1 entirely. This is the safe position.
- Purchased credits and in-game currencies **may not expire** and require a restore mechanism.
- Randomized virtual items ("loot boxes") must **disclose the odds** of each item type before purchase.
- Tipping another user's content, "boosts" of posts, and any digital good consumed in the app must use IAP (3.2.1(vii) and 3.1.3(g) read together: person-to-person monetary gifts are exempt only when entirely optional and 100 % passes to the receiver and is not connected to receiving digital content or services).
- AI credit top-ups, quota increases, or paid model access sold to the end user inside the app are digital services and require IAP.
### 3.1.1(a) / 3.1.3 external purchase
Outside the United States storefront, apps may not include buttons, external links or other calls to action directing customers to purchasing mechanisms other than IAP, absent the relevant StoreKit External Purchase Link Entitlement. A web platform that sells anything on its website must be careful that the iOS client does not link to that purchase path.
### 3.2.2 Unacceptable
- **(x)** Apps must not force users to rate, review, or download other apps to access functionality.
- **(v)** No arbitrary restriction of who may use the app by location or carrier.
- **(vii)** No artificial manipulation of a user's visibility, status or rank on other services.
---
## 4. Design
### 4.2 Minimum functionality
The app must be more than a repackaged website. A thin `WKWebView` wrapper around the existing web front end is a 4.2 rejection. The client needs native navigation, native affordances, push notifications, offline or cached state, and platform integration that a browser tab does not have.
**4.2.3(i)** the app must work on its own without requiring installation of another app. **4.2.2** apps must not primarily be web clippings or collections of links.
### 4.7 Mini apps, mini games, chatbots, plug-ins
This section is directly engaged by two features of the platform under review: an **in-app AI chatbot** and **user-authored software/experiences that other users can open**.
> Apps may offer certain software that is not embedded in the binary, specifically HTML5 and JavaScript mini apps and mini games, streaming games, **chatbots**, and plug-ins. […] **You are responsible for all such software offered in your app**, including ensuring that such software complies with these Guidelines and all applicable laws.
**4.7.1** Software offered under this rule must:
- follow all privacy guidelines, including guideline 5.1 on collection, use and sharing of data and sensitive data;
- **include a method for filtering objectionable material, a mechanism to report content and timely responses to concerns, and the ability to block abusive users**; and
- follow guideline 3.1 to offer digital goods or services.
**4.7.2** The app may not extend or expose native platform APIs to that software without prior permission.
**4.7.3** The app may not share data or privacy permissions to any individual software offered in the app **without explicit user consent in each instance**.
**4.7.4** The developer must provide **an index of software and metadata available in the app, including universal links** that lead to all software offered.
**4.7.5** The app must provide a way for users to **identify software that exceeds the app's age rating**, and use an **age restriction mechanism based on verified or declared age** to limit access by underage users.
Note that 4.7.1 restates the 1.2 quartet - filtering, reporting, timely response, blocking - and applies it to **chatbot output** as well as user content. An AI assistant that can emit objectionable text is subject to the same reporting and filtering obligation as a user post.
### 4.8 Login services
Applies only if the app uses a **third-party or social login service** to establish the user's primary account. An app that exclusively uses its own account setup and sign-in system is explicitly exempt and is **not** required to offer Sign in with Apple. Adding "Log in with GitHub" or any similar social provider immediately creates the obligation to also offer an equivalent privacy-preserving login (Sign in with Apple being the canonical one), with the three properties: name+email only, private-email option, no advertising-purpose interaction collection.
### 4.5.4 Push notifications
- Push must **not be required** for the app to function.
- Must not carry sensitive or confidential information.
- Must not be used for promotions or direct marketing **unless the customer has explicitly opted in via consent language displayed in the app's UI**, and the app **provides an in-app method to opt out**.
### 4.10 Monetizing built-in capabilities
Push Notifications, camera, gyroscope, iCloud storage and similar OS capabilities may not be monetized.
---
## 5. Legal
### 5.1.1(i) Privacy policy
> All apps must include a link to their privacy policy **in the App Store Connect metadata field and within the app in an easily accessible manner**.
The policy must clearly and explicitly:
- identify what data the app/service collects, how it collects it, and **all** uses of that data;
- confirm that any third party with whom the app shares user data - analytics, ad networks, third-party SDKs, parents, subsidiaries or related entities - provides the same or equal protection of user data;
- explain data retention/deletion policies and **describe how a user can revoke consent and/or request deletion of the user's data**.
Two distinct deliverables: an in-app accessible link, and a policy whose content covers those three points.
### 5.1.1(ii) Permission and consent withdrawal
Consent must be secured for collection of user or usage data even where anonymous. Paid functionality must not depend on granting data access. The app must provide **an easily accessible and understandable way to withdraw consent**.
### 5.1.1(iii) Data minimization
Only request access to data relevant to core functionality.
### 5.1.1(v) Account sign-in and **account deletion**
> If your app supports account creation, you must also **offer account deletion within the app**.
From Apple's dedicated support page, in force since **30 June 2022**:
- The app must **offer to delete the entire account record along with associated personal data**. Offering only to temporarily deactivate or disable an account is **explicitly insufficient**.
- The account deletion option must be **easy to find**, typically in account settings.
- If completion requires a website, the app must link **directly to the page** where the process is completed - not to a general support page and not merely out to the default browser.
- If deletion takes additional time, the user must be told.
- Confirmation steps are permitted: reauthentication, identity verification, entering a code sent to an address already on the account.
- Support-flow-only deletion (phone call, email, ticket) is permitted **only** for highly regulated industries under 5.1.1(ix). A social network is not one.
- Apps that make deletion "unnecessarily difficult" fail review.
Also in 5.1.1(v): if the app does not include significant account-based features, people must be able to use it without a login. A social network is account-based by nature, but **read-only public browsing without an account** is a strong signal of good faith and reduces friction with this clause and with 4.2.
### 5.1.1(x) Optional contact information
Basic contact information may be requested only if optional, with features not conditional on providing it.
### 5.1.2 Data use and sharing - the AI clause
> You must clearly disclose where personal data will be shared with third parties, **including with third-party AI**, and obtain **explicit permission** before doing so.
This is decisive for any platform that routes user content through an external model provider. Every path where a user's post, comment, message, file, or profile text leaves the platform for a third-party model is a third-party data share that requires **disclosure plus explicit permission**, not merely a line in a privacy policy.
Further clauses:
- **(i)** The app may not require the user to enable push notifications, location or tracking in order to access functionality or receive compensation. App Tracking Transparency consent is required for tracking.
- **(ii)** Data collected for one purpose may not be repurposed without further consent.
- **(iii)** No surreptitious profile building; no attempts to re-identify anonymous or aggregated data.
### 5.1.4 Kids
Apps that collect, transmit or have the capability to share personal information from a minor - including "the ability to chat" and persistent identifiers - must include a privacy policy and comply with all applicable children's privacy statutes (COPPA, GDPR and equivalents). Birthdate and parental contact information may be requested **only** for the purpose of complying with those statutes.
### 5.2 Intellectual property
- **5.2.1** No protected third-party material without permission; no misleading or copycat names or metadata.
- **5.2.2** Content from a third-party service requires permission under that service's terms; authorization must be provided on request. Engaged by any news/RSS ingestion feature.
- **5.2.3** No saving, converting or downloading media from third-party sources without explicit authorization. Engaged by any URL-fetch, archive, or media-embed feature.
- **5.2.5** No Apple emoji embedded in the binary; no interfaces confusingly similar to Apple products.
A UGC platform additionally needs a **notice-and-takedown (DMCA-style) path**, because 5.2 makes the developer answerable for infringing user content and 1.2 makes removal the developer's responsibility.
### 5.3 Gaming, gambling, lotteries
If the platform runs contests, sweepstakes or prize draws: the developer must sponsor them, **official rules must be presented in the app**, and the rules must state that **Apple is not a sponsor and is not involved in any manner**. Randomized reward mechanics that cannot be purchased with real money stay outside 5.3.4.
### 5.6 Developer code of conduct
Trust (5.6.1), ratings and reviews integrity (5.6.2), accurate developer identity (5.6.3) and the prohibition on predatory behaviour (5.6.4) - the latter explicitly covering exploitation of minors and facilitation or encouragement of harmful behaviour toward others. Violations can remove the developer from the Apple Developer Program entirely, independent of any single app.
---
## 6. App Store Connect obligations (metadata, not code)
These are not guideline sections but they block submission or removal just as hard.
### 6.1 Age rating - the 2025 overhaul
Apple replaced the old ladder with **4+, 9+, 13+, 16+, 18+**; the 12+ and 17+ tiers were removed. The questionnaire gained required questions covering in-app controls, capabilities, medical/wellness topics, and violent themes, plus a **social-features block** covering:
- user-generated content;
- messaging capability;
- friend or follower systems;
- livestreaming;
- content creation tools;
- advertising that may expose users to age-sensitive material.
Apple additionally asks **what safeguards the developer has implemented**: moderation systems, content filtering, reporting tools, blocking functionality, parental controls. Answering "none" to those questions on a social app drives the rating up and invites 1.2 scrutiny; answering "yes" untruthfully violates 2.3.6.
Developers were required to complete the updated questionnaire by **31 January 2026**, after which app updates are blocked in App Store Connect until the new questions are answered.
**Consequence for this project:** the safeguards questionnaire is answered from the platform's actual feature set. Each of the five safeguard answers should map to a named, demonstrable feature.
### 6.2 App privacy details ("nutrition labels")
Every data type collected by the app **or by its third-party partners** must be declared across the categories: Contact Info, Health & Fitness, Financial Info, Location, Sensitive Info, Contacts, User Content, Browsing History, Identifiers, Purchases, Usage Data, Diagnostics, Surroundings. Each declared type is classified as **Used to Track You**, **Linked to You**, or **Not Linked to You**. The developer is responsible for third-party SDK collection and for **keeping the answers accurate and up to date**; answers may be changed at any time without an app update.
For the platform under review the realistic declaration set is: Contact Info (name, email), User Content (posts, messages, photos/videos, other user content), Identifiers (user ID), Usage Data (product interaction), Diagnostics, and - if any analytics or crash reporting is added - the corresponding categories. All "Linked to You"; none "Used to Track You" provided no cross-app advertising tracking exists.
### 6.3 Support URL, marketing URL, privacy policy URL
Required metadata. The Support URL must present a working contact route (1.5). The privacy policy URL must be live and must match the in-app policy.
### 6.4 EU Digital Services Act trader status
Since **17 February 2025**, apps without a declared and verified trader status are **removed from the App Store in the EU**. Trader status became required for update submission on 16 October 2024. Articles 30 and 31 DSA require Apple to verify and publish trader contact information - **address, phone number and email** - on the App Store product page. The DSA definition of commercial activity is broad: paid apps, apps with IAP, or otherwise commercial distribution.
### 6.5 Notes for Review
Under 2.3.1 all functionality must be described specifically. For an app of this shape the notes must at minimum describe: the moderation pipeline, where the report and block controls are, where account deletion is, that code execution is remote and user-owned under the 2.5.2 educational exception, that the AI assistant is a chatbot under 4.7 with its own safety controls, and the demo account credentials with pre-seeded content so the reviewer can exercise reporting.
---
## 7. Overlapping legal regimes Apple enforces by reference
| Regime | What Apple enforces | Practical requirement |
|--------|---------------------|-----------------------|
| **GDPR** (5.1.1(ii), 5.1.2) | Lawful basis, consent, withdrawal, erasure | Consent capture with timestamp and version; consent withdrawal UI; account + data deletion; data export is the companion right users will ask for |
| **EU DSA** (6.4, 1.5) | Trader identity, published contact, notice-and-action | Published contact information in app and on the store page; a reporting mechanism with acknowledgement and outcome notice; a statement of reasons to the affected user when content is removed |
| **COPPA** (5.1.4) | No collection from under-13s without verifiable parental consent | Declared-age gate at signup; block or restrict accounts below the platform's minimum age; do not collect birthdate for any other purpose |
| **DMCA / copyright** (5.2) | Removal of infringing user content | A designated notice-and-takedown channel and a counter-notice path |
| **Local content ratings** (2.3.6) | Territory-specific rating and warning display | Age labelling on content that exceeds the app rating (also required by 1.2.1(a) and 4.7.5) |
---
## 8. The complete requirement register
Every row is a discrete, testable obligation. This register is the input to `applechanges.md`.
### 8.1 Mandatory - a missing item is a certain rejection
| # | Requirement | Source |
|---|-------------|--------|
| R1 | Terms of service / EULA that **explicitly states zero tolerance for objectionable content and abusive users** | 1.2 (review practice) |
| R2 | **Affirmative acceptance** of those terms recorded per user at account creation, and re-acceptance on material change | 1.2, GDPR |
| R3 | **Community guidelines** enumerating prohibited content, aligned to the 1.1.1-1.1.7 categories | 1.1, 1.2 |
| R4 | **Automated filtering** of objectionable material at the point of posting, on every UGC surface | 1.2, 4.7.1 |
| R5 | **Report mechanism on every UGC surface**: posts, comments, gists, projects, files, media, news, DMs, quizzes, profiles, AI output, workspaces | 1.2, 4.7.1 |
| R6 | **Moderation queue** with triage, decision and enforcement actions for the operators | 1.2 |
| R7 | **Published 24-hour response commitment** and a mechanism that makes it achievable and evidenced | 1.2 (review practice) |
| R8 | **Ejection of offending users** - suspension/ban as a first-class enforcement action, not only content deletion | 1.2 |
| R9 | **Block abusive users** from the service, covering all interaction surfaces including DMs | 1.2 |
| R10 | **Published contact information reachable inside the app** | 1.5, DSA Art. 30 |
| R11 | **Privacy policy** meeting 5.1.1(i)'s three content requirements, linked in-app and in ASC metadata | 5.1.1(i) |
| R12 | **In-app account deletion** that deletes the account record and associated personal data, easy to find, no support-flow requirement | 5.1.1(v) |
| R13 | **Declared-age gate** at account creation, with a minimum age, plus an age-restriction mechanism limiting underage access to age-exceeding content | 1.2.1(a), 4.7.5, 5.1.4 |
| R14 | **Content age labelling** so users can identify content exceeding the app's age rating; mature content **hidden by default** | 1.2, 1.2.1(a), 4.7.5 |
| R15 | **Explicit consent before user content is sent to third-party AI**, plus disclosure of which provider and what data | 5.1.2(i) |
| R16 | **Consent withdrawal** UI that is easily accessible and understandable | 5.1.1(ii) |
| R17 | **Push notifications optional**, never required for function, marketing push opt-in with in-app opt-out | 4.5.4, 5.1.2(i) |
| R18 | **DMCA / IP notice-and-takedown** channel | 5.2 |
| R19 | **Demo account with pre-seeded content** and review notes describing every safety control's location | 2.1, 2.3.1 |
| R20 | **Age rating questionnaire** answered from the real feature set, including the five safeguard answers | 2.3.6, 6.1 |
| R21 | **App privacy details** declared accurately for every data type, including third-party AI processing | 6.2 |
| R22 | **EU trader status** declared and verified, with address, phone and email | 6.4 |
| R23 | **IPv6-only reachability** of every endpoint, WebSocket and asset host | 2.5.5 |
| R24 | **Remote code execution positioned under the 2.5.2 educational exception**: source completely viewable and editable, executed off-device, never altering the app | 2.5.2 |
| R25 | **Native client that is materially more than a web wrapper** | 4.2 |
### 8.2 Conditional - required if the corresponding feature exists
| # | Requirement | Trigger |
|---|-------------|---------|
| C1 | Sign in with Apple or an equivalent privacy-preserving login | Any third-party/social login is offered |
| C2 | In-app purchase for every digital good, currency, credit, boost, tip or premium unlock | Anything is sold to end users in-app |
| C3 | Loot-box odds disclosure | Randomized purchasable rewards |
| C4 | Official contest rules in-app stating Apple is not a sponsor | Any sweepstake, contest or raffle |
| C5 | Index of all offered mini apps/software with universal links | Users can open other users' software from the app |
| C6 | Ad reporting control | Advertising is displayed |
| C7 | ATT prompt | Any cross-app/site tracking |
| C8 | Recording indicator and consent | Any session/screen/activity recording |
| C9 | Per-instance consent before sharing data or permissions with a mini app | Mini apps receive user data |
### 8.3 Posture requirements - not a single feature, an ongoing obligation
| # | Requirement | Source |
|---|-------------|--------|
| P1 | Ability to produce, on Apple's request, a **compliance improvement plan** and evidence of moderation throughput | 1.2 |
| P2 | Retention of moderation decisions as an audit trail | 1.2, DSA |
| P3 | Statement of reasons to the user whose content is removed or whose account is actioned | DSA Art. 17 |
| P4 | Keeping privacy labels and the privacy policy in step with feature changes | 6.2, 5.1.1(i) |
| P5 | Accurate "What's New" text for significant changes | 2.3.12 |
---
## 9. Where reviewers actually look
Ordered by observed rejection frequency for this application shape:
1. **Report control not visible on the first screen of content the reviewer opens.** The reviewer opens the feed, taps a post, and looks for a report affordance. If it is buried behind a profile menu, the app is rejected under 1.2 even though the mechanism exists.
2. **No terms acceptance at signup.** The reviewer creates an account with the demo credentials or a fresh account and looks for the EULA gate.
3. **Account deletion not found in settings.** The reviewer opens account settings and searches for "Delete account".
4. **Privacy policy not reachable in-app.**
5. **Demo account sees an empty feed**, so nothing can be reported or blocked.
6. **Blocking present but not reachable from the content itself**, only from a profile.
7. **AI feature sending content to a third party with no disclosure or consent.**
8. **No age gate on a platform with messaging and follower systems.**
---
## 10. Determination for this platform
Applying the register to the DevPlace shape:
- **Applicable in full:** R1-R25 except where noted below.
- **C1 not triggered** provided the platform continues to use exclusively its own account system. Adding any social login triggers it immediately.
- **C2 not triggered** provided no in-app purchase of any digital good, currency, credit or quota exists and none is linked to. The in-app virtual economy must remain earn-only.
- **C3 not triggered** while randomized rewards are not purchasable.
- **C4 triggered** by any leaderboard prize, era award or contest that awards something of value; the safe position is that awards are purely cosmetic/status and are not framed as a contest with prizes.
- **C5 triggered** if a user can open another user's running workspace, published site or executable project from the app.
- **C6, C7 not triggered** while there is no advertising and no cross-app tracking.
- **C8 triggered** by presence tracking, live view relay, session recording or terminal session capture that records user activity.
- **C9 triggered** by any path where platform user data is passed into a user-authored workspace or plug-in.
The single largest exposure is **R5 breadth**: reporting must exist on every surface, and the platform under review has an unusually large number of distinct UGC surfaces. The second largest is **R15**, because AI is woven through the platform and every path that sends user text to a model provider is a third-party data share.
---
## Sources
- [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Offering Account Deletion in Your App](https://developer.apple.com/support/offering-account-deletion-in-your-app/)
- [App Privacy Details on the App Store](https://developer.apple.com/app-store/app-privacy-details/)
- [Updated age ratings in App Store Connect](https://developer.apple.com/news/?id=ks775ehf)
- [Age rating questionnaire now includes social media questions](https://developer.apple.com/news/?id=tlur8uvi)
- [Apple overhauls App Store age ratings](https://www.macrumors.com/2025/07/25/apple-overhauls-app-store-age-ratings/)
- [Apple notifies developers of new App Store age rating system](https://9to5mac.com/2025/07/24/apple-notifies-developers-of-new-app-store-age-rating-system/)
- [Apps without trader status will be removed from the App Store in the EU](https://developer.apple.com/news/?id=einwn76m)
- [Manage European Union Digital Services Act trader requirements](https://developer.apple.com/help/app-store-connect/manage-compliance-information/manage-european-union-digital-services-act-trader-requirements/)
- [Provide your trader status in App Store Connect](https://developer.apple.com/news/?id=x60uzbu9)
- [Resolving App Store Guideline 1.2 - User Generated Content](https://buddyboss.com/docs/app-store-guideline-1-2-safety-user-generated-content/)
- [Complying with Apple App Store UGC requirements](https://www.termsfeed.com/videos/apple-app-store-comply-ugc-requirements/)
- [Guideline 1.2 - Safety - User-Generated Content (Apple Developer Forums)](https://developer.apple.com/forums/thread/807358)

583
appleimpl.md Normal file
View File

@ -0,0 +1,583 @@
# DevPlace: App Store compliance implementation design
Author: retoor <retoor@molodetz.nl>
Stage three of `apple.md`. Inputs are [`applecomp.md`](applecomp.md) (what Apple requires) and [`applechanges.md`](applechanges.md) (what DevPlace lacks). This document is the design: the most consistent, DRY, caveat-free way to implement every gap inside the conventions this codebase already enforces.
Nothing here is implemented. This is the specification the lord is asked to approve.
---
## 1. Design axioms
Each axiom is derived from an existing DevPlace pattern, named with its precedent. No axiom is invented for this feature.
| # | Axiom | Precedent in the codebase |
|---|-------|---------------------------|
| **A1** | **One polymorphic facility, never twenty per-surface features.** A report is structurally a vote: an actor, a `(target_type, target_uid)` pair, a payload. | `comments`, `votes`, `reactions`, `bookmarks` all key on `(target_type, target_uid)`; `VOTABLE_TARGETS` in `database/ranking.py:11`; `REACTABLE` in `routers/reactions.py:17` |
| **A2** | **The target set is a registry, not a literal.** Every consumer reads the same dict; adding a surface is one line. | `VOTABLE_TARGETS`, `STAR_TARGETS`, `NOTIFICATION_TYPES`, `SOFT_DELETE_TABLES`, `DOCS_PAGES`, `DATA_PATHS` |
| **A3** | **Machine-raised and human-raised entries share one queue and one state machine.** | `services/containers/workspace/flags.py`: `raise_flag` is machine-driven, `set_status` is admin-driven, statuses `open/acknowledged/resolved/dismissed`, severities `info/warn/critical` |
| **A4** | **Every route has four faces:** HTML, JSON, Devii action, API docs. | Root `CLAUDE.md`, "Anatomy of a feature" |
| **A5** | **Removal is soft; garbage collection is hard; cascades share one stamp.** | `database/soft_delete.py`, `SOFT_DELETE_TABLES` (44 tables), `soft_delete_in`, `purge_event`, `/admin/trash` |
| **A6** | **Runtime policy lives in `site_settings`,** read through `get_setting`/`get_int_setting`, live-editable at `/admin/settings`, never in code constants. | `database/schema.py:276`, `rate_limit_per_minute`, `maintenance_mode`, `registration_open` |
| **A7** | **Action bars are composed from included partials** with `{% set _type %}{% set _uid %}{% include %}`. | `_reaction_bar.html` included from `_post_card.html:37` and `_comment.html:37` |
| **A8** | **Non-response-critical side-effects go through `background.submit`;** audit and notifications are already funnelled there. | `services/background.py`, `utils/notifications.py:68` |
| **A9** | **Never a silent failure.** A safety control that swallows an error is worse than absent. | Root `CLAUDE.md`; `services/audit` never raises into the caller but always records |
| **A10** | **Legal and policy prose is a docs page,** with the existing role gating, SEO context and search index. | `routers/docs/pages.py` `DOCS_PAGES`; the admin-only `media-moderation` page proves gating works |
| **A11** | **Owner-or-admin, with the seniority guard on admin-versus-admin.** | `content.is_owner`, `_is_senior_admin` in `routers/admin/users.py` |
| **A12** | **The AI gateway is the single choke point for third-party model calls,** so consent is enforced in exactly one place. | `services/openai_gateway/`, `INTERNAL_GATEWAY_URL` |
---
## 2. The unifying abstraction
Everything in this design hangs off **one registry** and **one queue**.
### 2.1 The moderation target registry
New module `devplacepy/database/moderation.py`, mirroring `database/ranking.py` exactly in shape and placement:
```
REPORTABLE_TARGETS: dict[str, str] # target_type -> table name
MATURITY_TARGETS: set[str] # subset that can carry an age label
```
`REPORTABLE_TARGETS` covers every externally-visible surface from `applechanges.md` §2:
`post`, `comment`, `gist`, `project`, `project_file`, `news`, `attachment`, `message`, `quiz`, `poll`, `award`, `user`, `issue`, `workspace`, `devii_output`.
`MATURITY_TARGETS` is the subset that renders long-form authored content: `post`, `comment`, `gist`, `project`, `news`, `attachment`, `quiz`.
**Why a registry rather than per-surface code.** A report route, a report button, a moderation queue row, a Devii action parameter enum, an API docs enum and a test fixture all need the same list. With a registry they read it; without one they drift. This is the same reason `VOTABLE_TARGETS` exists.
**The completeness invariant.** A unit test asserts that every entry in `REPORTABLE_TARGETS` resolves to a real table (or an explicitly listed virtual surface) **and** that every externally-visible table in `SOFT_DELETE_TABLES` appears in `REPORTABLE_TARGETS`. Adding a new UGC surface without adding it to the registry fails the suite. Requirement R5 is therefore satisfied not by diligence but by construction. This is the load-bearing correctness claim of the whole design; §11 formalises it.
### 2.2 The single queue
One table, `content_reports`, with two producers:
- **members**, via the report control on every content action bar;
- **the filter**, via a system-raised entry when classification returns `review`.
This is `workspace_flags` generalised from one instance type to the registry. Same state machine (`open → acknowledged → actioned | dismissed`), same severity ladder (`info | warn | critical`), same soft-delete participation, same admin resolution surface. One queue means one SLA measurement, one admin screen, one audit shape, and one place where the 24-hour commitment is either met or visibly not.
### 2.3 URL resolution is already solved
`database/content.py:22` `resolve_object_url(target_type, target_uid)` already maps `post`, `project`, `news`, `issue`, `gist`, `quiz`, `comment` and `award` to their canonical URLs, recursing through comments to their parents. It gains the remaining registry entries (`project_file`, `attachment`, `message`, `user`, `workspace`, `poll`, `devii_output`). Every moderation surface then links to its subject for free, using the function the notification system already uses.
---
## 3. Data layer
All schema changes land in `devplacepy/database/schema.py` `init_db()` following the existing `has_column` / `create_column_by_example` / `_index` idiom, and every new table is registered in `SOFT_DELETE_TABLES`.
### 3.1 `content_reports`
| Column | Type | Notes |
|--------|------|-------|
| `uid` | text | `generate_uid()` |
| `reporter_uid` | text | user uid, or `system` for filter-raised (mirrors `audit.record_system`) |
| `target_type` | text | key of `REPORTABLE_TARGETS` |
| `target_uid` | text | subject uid |
| `owner_uid` | text | author of the reported content, denormalised at insert so the queue never N+1s |
| `reason` | text | key of `REPORT_REASONS` (§3.6) |
| `detail` | text | reporter's free text, max 2000 |
| `severity` | text | `info` / `warn` / `critical` |
| `status` | text | `open` / `acknowledged` / `actioned` / `dismissed` |
| `origin` | text | `member` / `filter` |
| `categories` | text | JSON list of matched 1.1.x category keys, filter-raised only |
| `resolved_by` | text | admin uid |
| `resolved_at` | text | ISO |
| `created_at`, `updated_at` | text | ISO |
| `deleted_at`, `deleted_by` | text | soft delete |
Indexes: `(status, created_at)` for the queue and the SLA scan; `(target_type, target_uid)` for "is this already reported"; `(reporter_uid)` for the reporter's own list; `(owner_uid)` for offender history. Partial soft-delete index per the standing convention.
**Duplicate handling** follows `raise_flag` precisely: an open report for the same `(target_type, target_uid, reporter_uid)` is updated, not duplicated. A different reporter on the same target creates a new row; the queue groups by target and shows the count, which is exactly how a real moderation queue prioritises.
### 3.2 `moderation_actions`
The decision record. One row per moderator decision, linked to the report that triggered it.
`uid`, `report_uid`, `actor_uid`, `action`, `target_type`, `target_uid`, `subject_uid`, `reason`, `notes`, `expires_at`, `created_at`, soft-delete columns.
`action``remove_content`, `restore_content`, `warn`, `suspend`, `ban`, `lift`, `dismiss`, `escalate`.
This is the DSA statement-of-reasons substrate (P3) and the compliance-plan evidence (P1). It is separate from the audit log because the audit log is append-only infrastructure and this is queryable moderation state with its own lifecycle - the same reason `workspace_flags` exists alongside the audit log.
### 3.3 `content_maturity`
Polymorphic age label, one row per labelled item. `uid`, `target_type`, `target_uid`, `level`, `source`, `set_by`, `created_at`, soft-delete columns.
`level``general`, `mature`, `restricted`. `source``author`, `filter`, `moderator`.
Read through a batch helper `get_maturity_by_targets(target_type, uids)` modelled exactly on `database/engagement.py` `get_reactions_by_targets` - no N+1, one query per listing. Absence of a row means `general`, so nothing needs backfilling and no existing row is touched.
### 3.4 `user_consents`
`uid`, `owner_kind`, `owner_id`, `kind`, `version`, `state`, `granted_at`, `withdrawn_at`, `created_at`, soft-delete columns.
`owner_kind`/`owner_id` reuse the `owner_for(request)` convention from the customization subsystem verbatim, so guests are covered by the same table. `kind``terms`, `privacy`, `ai_third_party`, `activity_recording`. `state``granted`, `withdrawn`.
Consent is **versioned and append-only in effect**: withdrawing writes `withdrawn_at` and a new grant writes a new row, so the full consent history is provable - which is what GDPR and Apple both actually require.
### 3.5 `users` columns
Added with the existing `has_column` guard block at `database/schema.py:1823`:
| Column | Default | Purpose |
|--------|---------|---------|
| `terms_version` | `""` | Accepted document version (R2) |
| `terms_accepted_at` | `""` | ISO timestamp (R2) |
| `age_band` | `""` | `under_min` / `13_15` / `16_17` / `adult` (R13) |
| `age_declared_at` | `""` | ISO timestamp |
| `mature_opt_in` | `0` | Explicit opt-in to see mature-labelled content (R14) |
| `suspended_until` | `""` | ISO; empty means not suspended (R8) |
| `suspension_reason` | `""` | Shown to the user (P3) |
| `deletion_requested_at` | `""` | Starts the deletion clock (R12) |
**No birthdate is stored.** 5.1.4 permits collecting it only to comply with children's privacy statutes; data minimization (5.1.1(iii)) then requires storing only the derived band. The signup form collects a date, derives the band, and discards the date. This is both the compliant and the simpler design.
### 3.6 Registries and constants
`devplacepy/database/moderation.py` also owns:
- `REPORT_REASONS: dict[str, str]` - key to label, mapped one-to-one onto the guideline categories so the age-rating questionnaire and the community guidelines can be written from the same list: `hate` (1.1.1), `violence` (1.1.2), `weapons` (1.1.3), `sexual` (1.1.4), `religious` (1.1.5), `misinformation` (1.1.6), `exploitative` (1.1.7), `harassment`, `spam`, `intellectual_property` (5.2 / R18), `self_harm`, `illegal`, `other`.
- `REPORT_STATUSES`, `REPORT_SEVERITIES`, `MODERATION_ACTIONS`, `MATURITY_LEVELS`, `CONSENT_KINDS`, `AGE_BANDS`.
One list, consumed by the form validator, the Devii action schema, the API docs enum, the admin filter dropdown and the community-guidelines page. Changing a reason is one edit.
### 3.7 `site_settings` keys
Added to the defaults block at `database/schema.py:276`, editable live at `/admin/settings` (A6):
| Key | Default | Purpose |
|-----|---------|---------|
| `moderation_sla_hours` | `24` | The published commitment (R7) |
| `moderation_filter_mode` | `review` | `off` / `label` / `review` / `block` (R4) |
| `moderation_minimum_age` | `16` | Signup floor (R13) |
| `moderation_mature_default_hidden` | `1` | Mature content hidden by default (R14) |
| `contact_email`, `contact_phone`, `contact_address` | empty | Published contact + DSA trader data (R10, R22) |
| `terms_version`, `privacy_version`, `guidelines_version` | `1` | Bump forces re-acceptance (R2) |
| `ai_third_party_provider` | `""` | Named in the consent copy (R15) |
| `account_deletion_grace_hours` | `24` | Reversible window before purge (R12) |
---
## 4. The content filter
`devplacepy/services/moderation/` - a new service package alongside `services/audit/`, `services/game/` and the rest, with its own nested `CLAUDE.md`.
### 4.1 Shape
```
services/moderation/
__init__.py record()-style entrypoints, the only public surface
filter.py classify(text) -> Classification
rules.py the category rule set
queue.py raise_report / set_status / decide / list_reports
enforcement.py suspend / ban / lift / remove_content
sla.py oldest_open_age / breach_count
```
`Classification` is a frozen dataclass (`verdict`, `categories`, `maturity`, `score`) - dataclasses over fixed-key dicts, per the standing style rule.
`verdict``allow`, `label`, `review`, `block`, resolved against `moderation_filter_mode` so an administrator can dial the platform from advisory to strict without a deploy.
### 4.2 Where it runs - exactly five call sites
The filter is invoked only at choke points that already exist, so no surface can be missed and no surface needs bespoke code:
1. `content.create_content_item` (`content.py:197`) - posts, projects, gists, news, quizzes.
2. `content.create_comment_record` (`content.py:361`) - every comment on every parent type.
3. `content.edit_content_item` and `content.edit_comment_record` - edits, so a clean post cannot be edited into a violation.
4. `routers/messages.py:245` `send_message` and the WebSocket send path - direct messages.
5. `routers/profile/index.py` profile update and `routers/auth/signup.py` - bio, location, links, username.
Five call sites cover twenty surfaces because the codebase already funnels creation. This is the direct payoff of DevPlace's existing structure.
### 4.3 Behaviour, and why it is safe on a developer platform
The single largest implementation risk identified in `applechanges.md` §7 is false positives: a security-focused developer community discusses exploits, weapons-grade cryptography and violent language in code review. A naive block destroys the product.
The design answers this structurally:
- **The default mode is `review`, not `block`.** A flagged item is published **and** a system report is raised. Nothing legitimate is ever suppressed by a machine.
- **Only the `sexual` and `exploitative` categories default to `block`**, because those are the two where Apple removes apps without notice and where no developer-platform false-positive case exists.
- **Thresholds are `site_settings`,** tunable live while watching the queue.
- **A failure in the filter fails to `review`, never to `allow`** (A9). If classification raises, the content is published and a `critical` system report is raised naming the failure. A moderation control that fails open is worse than absent.
This gives Apple the "method for filtering objectionable material from being posted" that 1.2 requires, gives the platform a human in the loop, and gives the community no false suppression.
---
## 5. Server layer
### 5.1 Reporting - `devplacepy/routers/reports.py`, mounted at `/reports`
Mirrors `routers/reactions.py` line for line.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/reports/{target_type}/{target_uid}` | member | Submit a report |
| `GET` | `/reports/mine` | member | The reporter's own reports and their outcomes (DSA Art. 16 acknowledgement) |
| `GET` | `/reports/reasons` | public | The reason registry, so any client renders the same dialog |
Input model `ReportForm` in `models.py` (`reason`, `detail`); output schema `ReportOut` / `ReportListOut` in `schemas/moderation.py`. `respond(request, template, ctx, model=ReportOut)` gives HTML and JSON from one handler. Rate limiting is already global on POST via the existing middleware; no per-route limiter is added.
Submitting a report **always** notifies the reporter through `create_notification` with the acknowledgement and the SLA, and **never** notifies the reported user (that happens only on decision, as a statement of reasons).
### 5.2 Moderation queue - `devplacepy/routers/admin/moderation.py`
Registered in the `admin/` package exactly like `trash.py` and `media.py`, with `admin_section = "moderation"`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/admin/moderation` | The queue, grouped by target, sorted oldest-open-first, with the SLA badge |
| `GET` | `/admin/moderation/{uid}` | One report, its target rendered in place, the offender's history |
| `POST` | `/admin/moderation/{uid}/status` | `acknowledge` / `dismiss` |
| `POST` | `/admin/moderation/{uid}/decide` | Apply a `MODERATION_ACTIONS` decision |
Every decision writes a `moderation_actions` row, records an audit event, and - where the decision affects a user - delivers a statement of reasons through `create_notification`.
### 5.3 Enforcement - extending `routers/admin/users.py`
The bare `is_active` toggle at `admin/users.py:179` is kept for backward compatibility and joined by:
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/admin/users/{uid}/suspend` | Reason + duration; writes `suspended_until`, `suspension_reason` |
| `POST` | `/admin/users/{uid}/lift` | Clears both |
| `POST` | `/admin/users/{uid}/ban` | Permanent; `is_active = False` **with** a recorded reason |
All three pass through the existing `_is_senior_admin(actor, target)` guard (A11), so a junior admin cannot suspend a senior one - server-side, therefore also covering Devii.
Enforcement is read by one new predicate in `content.py`, `is_suspended(user)`, consulted by `require_user` so a suspended account can still read, still see why, and still delete their account, but cannot post. This is one predicate at one choke point, not a scattered check.
### 5.4 Account deletion - `routers/profile/delete.py`
Follows the `regenerate-avatar` precedent (owner-or-admin, POST under `/profile/{username}/…`, audited, cache-invalidating).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/profile/{username}/delete` | The confirmation page: what will be deleted, what is retained and why, the grace window |
| `POST` | `/profile/{username}/delete` | Requires the account password (reauthentication, explicitly permitted by Apple); starts deletion |
**The cascade**, using one shared stamp (A5):
1. Stamp `deletion_requested_at`, revoke every session and access token, invalidate the user cache.
2. `soft_delete_in(table, "user_uid", [uid], deleted_by=uid, stamp=stamp)` across every table in `SOFT_DELETE_TABLES` that carries a `user_uid` - one stamp, so `/admin/trash` can restore the entire event atomically within the grace window.
3. Anonymise the `users` row immediately: username tombstoned, email, bio, location, links, avatar seed, API key and password hash cleared. **From the user's and every other user's point of view, the account is gone the moment they confirm.**
4. A GC sweep (`devplace accounts prune`, and a scheduled pass in the existing service manager) hard-purges the stamped event after `account_deletion_grace_hours`, using `purge_event(stamp)` - the function that already exists.
The confirmation page states the grace window explicitly, satisfying Apple's "if the deletion request will take additional time to complete, let them know."
The devRant `DELETE /api/users/me` at `routers/devrant/auth.py:189` is re-pointed at this same cascade, because a deactivation masquerading as a deletion is exactly what Apple names as insufficient, and because two paths must not mean two behaviours.
### 5.5 Terms, age and consent
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/auth/accept-terms` | Records acceptance of the current `terms_version` |
| `POST` | `/profile/{username}/consent` | Grant or withdraw a `CONSENT_KINDS` entry |
| `GET` | `/profile/{username}?tab=privacy` | Acceptances, consents, withdrawal controls, deletion entry point |
`SignupForm` (`models.py:51`) gains `birth_date` and `accept_terms`, both required, validated Pydantic-natively like every other form in the project. The validator derives `age_band`, rejects below `moderation_minimum_age`, and the raw date never reaches the database.
**The re-acceptance gate** is a middleware in the existing stack in `main.py`, sitting beside the maintenance gate it is modelled on: an authenticated user whose `terms_version` is behind the setting is redirected to the acceptance page for any mutating request, while reads, `/static`, `/auth`, `/docs` and account deletion stay open. A user must never be trapped: they can always read, always accept, and always delete their account.
### 5.6 Third-party AI consent - one gate at one choke point
Enforced in `services/openai_gateway/` where every internal AI consumer already converges (A12).
The rule distinguishes two things that the existing code currently conflates:
- **User-content processing** - the user's own post, comment, message, file or prompt is sent to the provider. Requires a granted `ai_third_party` consent for that user. Default: **not granted**.
- **Platform processing** - news import, bot personas, SEO metadata for platform-owned text. Not user content, not gated by user consent.
The gateway resolves the owner it is acting for and refuses a user-content call without consent, returning a structured error the callers already know how to surface. The existing `ai_correction_enabled` and `ai_modifier_enabled` flags survive unchanged as **preferences**, subordinate to consent: consent withdrawn means the feature is off regardless of the preference. `ai_modifier_enabled`'s default of `1` becomes harmless, because consent gates it. No existing preference is silently flipped; the gate is simply added above them.
The consent copy names the provider from `ai_third_party_provider`, states what is sent and why, and links the privacy policy - the three things 5.1.2(i) demands.
### 5.7 Activity-recording consent and indicator (C8)
`activity_recording` consent covers presence (`services/presence.py`), the live view relay and Devii terminal session capture. Guideline 2.5.14 wants consent **and** a clear indication. The indication reuses the existing presence dot partial `_presence_dot.html` and the response-time badge idiom in `base.html`: a small, always-visible recording indicator when a session is being captured. Withdrawing consent stops presence writes for that user; they simply appear offline.
### 5.8 The software index (C5 / 4.7.4)
`GET /workspaces/index` - a public, paginated index of every user-published workspace reachable through the `/p/{slug}` ingress, with its owner, description, maturity label and canonical URL. This is the "index of software and metadata available in your app… including universal links" that 4.7.4 requires. It reuses the existing listing machinery (`build_pagination`, `_card_link.html`, `paginate_diverse`) and is added to the sitemap.
---
## 6. View layer
### 6.1 One partial, included everywhere
`templates/_report_button.html`, included with the same two-variable idiom as `_reaction_bar.html` (A7):
```
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _owner = item.post['user_uid'] %}
{% include "_report_button.html" %}
```
It renders **Report** and, when the viewer is not the owner, **Block**, both `guest_disabled(user)`, both matching the existing `post-action-btn` / `comment-action-btn` visual language exactly. Placing Block here closes gap G9 from `applechanges.md`: blocking becomes reachable from the content, not only from a profile.
Include sites: `_post_card.html`, `_comment.html`, `post.html`, `gist_detail.html`, `project_detail.html`, `news_detail.html`, `quiz.html`, `_media_gallery.html`, `messages.html`, `profile.html`, `_award_badge.html`, `project_files.html`, `issue_detail.html`, `containers_instance.html`.
**One partial, fourteen include sites, zero duplicated markup.** A template that renders content and omits the include is caught by the e2e coverage test in §10.
### 6.2 One dialog
`templates/_report_dialog.html` is included once in `base.html`, exactly as the reaction picker is a single palette reused by every bar. `static/js/ReportDialog.js` - one ES6 class, registered on `app`, using the existing `Http` helper and the established `.modal-overlay` / `.visible` modal pattern - reads `data-report-type` and `data-report-uid` from the clicked button, populates the reason list from `/reports/reasons`, and posts. No new modal machinery, no third-party library.
### 6.3 Maturity gate
`templates/_maturity_gate.html`: an interstitial rendered in place of a `mature`-labelled item for a viewer who has not opted in or whose `age_band` is below the threshold. Reveal is a single control that sets `mature_opt_in`; it is not offered at all to `13_15` or `16_17` bands for `restricted` content. Content stays hidden by default, which is precisely 1.2's wording.
### 6.4 Admin
`templates/admin_moderation.html` extends `admin_base.html` with `admin_section = "moderation"`, and a sidebar entry is added to `admin_base.html` between Media and Trash - the natural neighbours. The queue header carries the SLA badge: oldest open report age against `moderation_sla_hours`, green under, red over. That badge is the mechanism that makes the published 24-hour commitment (R7) real rather than aspirational.
### 6.5 Legal pages and the footer
Legal prose ships as `DOCS_PAGES` entries (A10) under a new `SECTION_LEGAL = "Legal"`, placed in the `AUDIENCE_START` group so it is one click from `/docs`:
| Slug | Title | Requirement |
|------|-------|-------------|
| `terms` | Terms of Service | R1, R2 |
| `community-guidelines` | Community Guidelines | R3 |
| `privacy` | Privacy Policy | R11 |
| `contact` | Contact | R10, R22 |
| `content-moderation` | How moderation works | R7, P1 |
| `intellectual-property` | Notice and takedown | R18 |
| `moderation-operations` | Operating the queue (admin-gated, like `media-moderation`) | P1, P2 |
`_footer_links.html` gains Terms, Privacy, Guidelines and Contact alongside the existing four links. This is the "easily accessible in the app" that 5.1.1(i) and 1.5 both require, and it is on every page because the footer is in `base.html`.
`contact` renders `contact_email`, `contact_phone` and `contact_address` from `site_settings`, so the in-app contact data and the App Store Connect trader data have one source of truth and cannot drift (R10 ≡ R22).
### 6.6 Signup
`templates/signup.html` gains a date-of-birth field and a required terms checkbox whose label links `/docs/terms.html` and `/docs/community-guidelines.html`. Both are validated by `SignupForm`, so the error path is the existing global `RequestValidationError` handler that already re-renders auth pages with messages.
---
## 7. Agent, docs and SEO layer
Per A4, nothing ships with fewer than four faces.
- **Devii** - `services/devii/actions/catalog/moderation.py` exporting `MODERATION_ACTIONS`: `report_content`, `list_my_reports`, `list_reports` (admin), `decide_report` (admin), `suspend_user` (admin), `lift_suspension` (admin), `delete_my_account`, `set_consent`, `accept_terms`. `delete_my_account`, `decide_report`, `suspend_user` and `ban_user` join `CONFIRM_REQUIRED` in `dispatcher.py`, **each declaring a `confirm` boolean param in its catalog spec** - the load-bearing detail the root `CLAUDE.md` calls out, without which a gated tool loops forever.
- **API docs** - `docs_api/groups/moderation.py`, a new group with `endpoint()` entries and `sample_response` for every route above, plus the reason enum sourced from `REPORT_REASONS`.
- **SEO** - legal pages are public and indexable, added to `routers/seo.py`'s sitemap; `/reports/*` and `/admin/moderation/*` are `noindex,nofollow` via `base_seo_context`.
- **Audit** - new keys in `events.md` and `services/audit/categories.py` `category_for` under a new `moderation` category: `report.create`, `report.status`, `report.decide`, `moderation.suspend`, `moderation.ban`, `moderation.lift`, `moderation.remove`, `moderation.restore`, `filter.block`, `filter.review`, `account.delete.request`, `account.delete.purge`, `consent.grant`, `consent.withdraw`, `terms.accept`.
- **README.md** gains the moderation, legal and account-deletion surfaces; the root `CLAUDE.md` gains one new architectural rule (§8.1 below); `services/moderation/CLAUDE.md` and `routers/CLAUDE.md` carry the detail.
---
## 8. The two things that are not code
### 8.1 The new architectural rule for the root `CLAUDE.md`
> **Every user-generated surface is reportable by construction.** 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. The registry completeness test enforces the first two; the template coverage test enforces the third.
### 8.2 The positioning change
`applechanges.md` §5 established that four sites currently promise an uncensored platform, and that this alone is grounds for a 1.2 rejection. The design changes them in step so that marketing, terms and behaviour state the same thing:
| Site | Current | Proposed |
|------|---------|----------|
| `main.py:744` site description | "…in an open, uncensored environment." | "…in an open environment built by developers, for developers." |
| `templates/base.html:9` meta description | same string | same replacement |
| `templates/landing.html:120` hero | same string | same replacement |
| `templates/landing.html:134` feature card | "No Censorship" | "No Gatekeeping" - with body copy stating that DevPlace does not editorialise technical opinion, and that a short list of prohibited categories is enforced, linking the community guidelines |
| `database/schema.py:280` default `site_tagline` | same string | same replacement |
This is the one item in this document that changes the product's public voice rather than its capabilities. It is presented as a decision, not an assumption, and it is the single change with the highest effect on the outcome of review.
---
## 9. Sequencing
Six phases. Each phase is independently shippable, leaves the platform working, and ends with the full suite (`make test`, all three tiers) green. No phase depends on a later one.
| Phase | Contents | Requirements closed |
|-------|----------|---------------------|
| **1. Foundation** | `database/moderation.py` registry and constants; `content_reports`, `moderation_actions`, `content_maturity`, `user_consents` tables; `users` columns; `site_settings` keys; `SOFT_DELETE_TABLES` registration; `resolve_object_url` extension; the registry completeness test | substrate for R4-R8, R13-R16 |
| **2. Reporting and moderation** | `services/moderation/` queue; `routers/reports.py`; `routers/admin/moderation.py`; enforcement routes; `_report_button.html` at all fourteen sites; `_report_dialog.html` + `ReportDialog.js`; `admin_moderation.html` + sidebar; SLA badge; audit keys; Devii actions; API docs | **R5, R6, R7, R8, R9, P1, P2, P3** |
| **3. Legal and contact** | The seven docs pages; footer links; contact settings; the positioning rewording | **R1, R3, R10, R11, R18, R22** |
| **4. Consent, terms, age** | Signup terms + date of birth; re-acceptance middleware; consent routes and privacy tab; the AI gateway consent gate; activity-recording consent and indicator | **R2, R13, R15, R16, C8, C9** |
| **5. Deletion** | `routers/profile/delete.py`; the stamped cascade; `devplace accounts prune`; devRant re-point; the confirmation page | **R12** |
| **6. Filter, maturity, index, posture** | `services/moderation/filter.py` at the five choke points; `content_maturity` + `_maturity_gate.html`; `/workspaces/index`; IPv6 verification; demo account; review notes; questionnaire and privacy-label answers | **R4, R14, R19, R20, R21, R23, R24, C5** |
Phases 2 and 3 together answer the guideline that actually rejects apps. Phase 5 answers the guideline that most often rejects them on the second attempt. Nothing is deferred to "later"; six phases is the whole scope.
---
## 10. Test plan
Following the tier rules in `tests/CLAUDE.md`: tier is decided by fixtures, path mirrors the URL for `api`/`e2e` and the module for `unit`.
**`tests/unit/database/moderation.py`**
- The registry completeness invariant (§11.1) - the single most important test in this feature.
- `REPORT_REASONS` keys are stable and cover every guideline category.
- `resolve_object_url` returns a non-`/feed` URL for every registry entry.
- Filter classification: property checks over the category rule set, asserting monotonicity of score against rule matches and that `verdict` never weakens as mode strengthens.
- Age-band derivation across the full date domain, including leap days and the exact boundary.
**`tests/api/reports/*.py`**
- Report every registry target type; assert one row, correct `owner_uid`, correct audit event.
- Duplicate report from the same reporter updates rather than duplicates; from a different reporter creates a second row.
- Guests are refused; suspended users are refused posting but permitted reporting and deletion.
- `/reports/mine` shows outcomes; a reporter never sees another reporter's report.
**`tests/api/admin/moderation.py`**
- Queue ordering is oldest-open-first; SLA badge flips at the configured hour.
- Every `MODERATION_ACTIONS` decision writes a `moderation_actions` row, an audit row, and a notification.
- The seniority guard blocks a junior admin actioning a senior one and audits `result="denied"`.
**`tests/api/profile/delete.py`**
- Deletion requires the correct password; wrong password does not delete.
- After deletion the account is unreachable, sessions are revoked, content is gone from every listing.
- Restore within the grace window from `/admin/trash` restores the whole event under one stamp.
- After the grace window `purge_event` removes every row and no personal data remains in any table.
**`tests/api/auth/terms.py`, `tests/api/profile/consent.py`**
- Signup without acceptance or below the minimum age fails with a rendered message.
- Bumping `terms_version` forces re-acceptance on the next mutating request and never on a read.
- A gateway user-content call without `ai_third_party` consent is refused; with consent it proceeds; withdrawal takes effect immediately.
**`tests/e2e/`**
- **Coverage test:** for each of the fourteen include sites, load the page and assert a report control is present and reachable. This is the test that keeps R5 true over time.
- Report a post end to end through the dialog; confirm the toast, the notification and the queue row.
- Block from a comment action bar; confirm the author's content disappears from the feed.
- Delete an account through the UI and confirm the login no longer works.
- The maturity interstitial hides labelled content and reveals it only on explicit opt-in.
**Rigorous verification (root `CLAUDE.md`, four-layer procedure).** Suspension state, consent state and the deletion cascade are all read-then-write mutations reachable from HTTP, Devii, the devRant API and the CLI, so the procedure applies in full and is not optional:
1. **Property checks** over the filter score function and the age-band function across their whole input domain.
2. **Stateful fuzzing** of report → decide → suspend → lift → delete sequences against a temp DB, asserting after every action that a report never leaves its state machine, a suspension never outlives its expiry, consent history is never rewritten, and no user is ever both deleted and active.
3. **Concurrency with real separate OS processes**: concurrent decisions on one report must produce exactly one `moderation_actions` row; concurrent deletion requests must produce exactly one cascade. Both are closed with a single atomic conditional `UPDATE … WHERE` at the chokepoint, checked through `db.executable.execute(text(...)).rowcount`, per the standing rule. **Every new column added in §3.5 is written at insert time for new rows and `COALESCE`d in every precondition and arithmetic update**, because a column absent from a row's original `INSERT` is SQL `NULL`, and `NULL = 0` is `NULL`, not true - the exact trap the root `CLAUDE.md` records.
4. **`pyflakes` / `ruff check`** on every touched file, catching the in-function import that neither a clean compile nor a clean app import would.
---
## 11. Proof of solidity
`apple.md` asks for a mathematical proof that the implementation is solid. A design cannot be proved correct in the abstract; what can be proved is that **coverage is total and stays total**. Three claims, each discharged by a mechanism rather than by diligence.
### 11.1 Claim 1 - surface coverage is total, and remains total
Let `U` be the set of externally-visible user-generated surfaces, `R` the set of `REPORTABLE_TARGETS` keys, `T` the set of tables in `SOFT_DELETE_TABLES`, and `V ⊆ T` those visible beyond their author.
The design requires `V ⊆ R` and enforces it with a unit test that computes `V` from `SOFT_DELETE_TABLES` minus an explicit, reviewed exclusion list of owner-private tables, and asserts the inclusion. A developer adding a UGC table without registering it **fails the suite**.
Since the report route, the report partial, the Devii action enum, the API docs enum and the admin filter all derive from `R`, coverage of every consumer follows from `V ⊆ R` by construction. Requirement **R5** is therefore not "implemented on sixteen surfaces" but *closed under future additions* - which is the only form of this guarantee worth having, because 1.2 rejections happen on the surface someone forgot.
Formally: coverage is the composition `V ↪ R → {route, partial, action, docs, filter}`. The inclusion is test-enforced; the maps are total functions over `R`; therefore the composition is total over `V`. ∎
### 11.2 Claim 2 - every Apple requirement maps to a named artifact
The map `requirement → artifact` below is total over the mandatory register and over every triggered conditional. No requirement lacks an artifact; no artifact exists without a requirement.
| Req | Artifact | Phase |
|-----|----------|-------|
| R1 | `/docs/terms.html` + `terms_version` | 3 |
| R2 | `SignupForm.accept_terms`, `users.terms_version`, re-acceptance middleware | 4 |
| R3 | `/docs/community-guidelines.html` from `REPORT_REASONS` | 3 |
| R4 | `services/moderation/filter.py` at five choke points | 6 |
| R5 | `REPORTABLE_TARGETS` + `/reports/{target_type}/{target_uid}` + `_report_button.html` | 1, 2 |
| R6 | `/admin/moderation` + `moderation_actions` | 2 |
| R7 | `moderation_sla_hours` + the SLA badge + `/docs/content-moderation.html` | 2, 3 |
| R8 | `/admin/users/{uid}/suspend`, `/ban`, `/lift` + `is_suspended` | 2 |
| R9 | existing `routers/relations.py` + Block in `_report_button.html` | 2 |
| R10 | `/docs/contact.html` from `contact_*` settings + footer | 3 |
| R11 | `/docs/privacy.html` + footer + ASC metadata | 3 |
| R12 | `routers/profile/delete.py` + stamped cascade + `devplace accounts prune` | 5 |
| R13 | `SignupForm.birth_date``users.age_band` + `moderation_minimum_age` | 4 |
| R14 | `content_maturity` + `_maturity_gate.html` + `mature_opt_in` | 6 |
| R15 | `user_consents.ai_third_party` + the gateway gate | 4 |
| R16 | `POST /profile/{username}/consent` + the privacy tab | 4 |
| R17 | existing `notification_preferences` (verified, documented) | 6 |
| R18 | `intellectual_property` reason + `/docs/intellectual-property.html` | 3 |
| R19 | demo account + review notes | 6 |
| R20 | questionnaire answered from R4/R5/R6/R13 | 6 |
| R21 | privacy labels derived from R15's disclosure | 6 |
| R22 | `contact_*` settings ≡ ASC trader data | 3 |
| R23 | IPv6 verification of app, nginx, WebSockets, ingress | 6 |
| R24 | architecture statement in docs + review notes | 6 |
| R25 | every control exposed as JSON by A4 | 1-6 |
| C4 | contest position documented | 3 |
| C5 | `/workspaces/index` | 6 |
| C8 | `activity_recording` consent + indicator | 4 |
| C9 | per-instance consent before data reaches user software | 4 |
| P1 | `moderation_actions` + SLA metrics | 2 |
| P2 | audit `moderation` category + `moderation_actions` | 2 |
| P3 | statement of reasons via `create_notification` | 2 |
| P4 | privacy-label step added to the feature workflow | 6 |
| P5 | release-notes discipline | 6 |
C1, C2, C3, C6 and C7 are untriggered and the design introduces nothing that triggers them: no social login, no payment path, no purchasable randomness, no advertising, no cross-app tracking. Keeping them untriggered is itself recorded as a constraint in the root `CLAUDE.md` rule of §8.1's neighbourhood.
### 11.3 Claim 3 - the design introduces no inconsistency
Consistency is checked against every convention the repository enforces:
| Convention | How this design satisfies it |
|-----------|------------------------------|
| Polymorphic `(target_type, target_uid)` | `content_reports`, `content_maturity` use it verbatim |
| Registry over literal | `REPORTABLE_TARGETS` beside `VOTABLE_TARGETS` |
| Soft delete everywhere, one stamp per cascade | All four new tables registered; deletion uses one stamp |
| Runtime policy in `site_settings` | Eleven new keys, zero new constants |
| Four faces per route | Every route has HTML, JSON, Devii action, API docs |
| Shared `templates` instance, partial reuse | One partial, one dialog, fourteen includes |
| ES6 module, one class per file, on `app` | `ReportDialog.js` |
| Design tokens, no literals | Report and SLA styling uses existing tokens and `--z-*` bands |
| No comments, no docstrings | The design specifies none |
| Author attribution at the top of every file | Every new file |
| European dates, UTC storage | `local_dt` / `dt_ago` for every timestamp shown |
| Owner-or-admin, seniority guard | `is_owner`, `_is_senior_admin` reused unchanged |
| `CONFIRM_REQUIRED` with a declared `confirm` param | Four gated Devii tools |
| Batch helpers, never N+1 | `get_maturity_by_targets`, denormalised `owner_uid` |
| Never fail silently | Filter fails to `review`; report submission never swallows |
| No forbidden name patterns, no em-dash | Enforced at authoring and by `/validate` |
Zero new patterns are introduced. Every mechanism in this design is an existing DevPlace mechanism applied to a new target set. That is the sense in which it is DRY, and the sense in which it is consistent. ∎
---
## 12. Verification loop
`apple.md` asks that the former steps be repeated recursively until the result is proved solid. Three passes were run over `applecomp.md``applechanges.md` → this document. Each pass fed a correction back into the earlier documents, which are the corrected versions.
**Pass 1 - requirement completeness.** The first register covered guideline 1.2 and 5.1.1 only. Re-reading the guidelines against the platform's actual feature list added: 4.7 in full (the AI assistant is a chatbot under it, and it restates the 1.2 quartet), 2.5.2 and its educational exception (the container platform), 2.5.14 (presence and session recording), 4.7.4 (the software index), 2.5.5 (IPv6), 5.3 (Code Farm Eras), 6.1's 2025 age-rating overhaul and 6.4's DSA trader status. **Nine requirements were missing from the first draft.** They are R23, R24, C5, C8, C9, and the metadata requirements R20-R22, and the 5.3 position in C4.
**Pass 2 - surface completeness.** The first gap analysis listed eight UGC surfaces from the routers. Re-deriving the list from `SOFT_DELETE_TABLES` rather than from the routers produced **twenty**, including four that a router-first reading misses entirely: awards, poll options, quiz options and workspace-served content. That correction is what forced A1 and A2, and therefore the registry, and therefore the completeness invariant of §11.1. A per-surface design would have shipped incomplete.
**Pass 3 - consistency and caveat elimination.** Re-reading the design against the conventions produced five corrections, each removing a caveat rather than documenting one:
1. Maturity was originally a column on each content table - twenty migrations and a permanent drift risk. Replaced by the polymorphic `content_maturity` table with a batch helper, matching `reactions`.
2. The filter was originally to be called from each router - twenty call sites. Replaced by five existing choke points in `content.py`, `messages.py` and the profile/signup path.
3. AI consent was originally a per-feature toggle, which would have needed a gate in every AI consumer. Replaced by one gate at the gateway, with the existing toggles demoted to preferences - no existing preference is flipped and no consumer changes.
4. Account deletion was originally an immediate hard purge, which conflicts with `/admin/trash`, with the audit trail, and with accidental loss. Replaced by an immediate anonymisation plus a stamped soft-delete event and a GC purge, which is both the compliant behaviour and the behaviour the codebase already has primitives for.
5. Legal pages were originally new routes. Replaced by `DOCS_PAGES` entries, which brings role gating, SEO, the search index and the export for free, and adds no routing.
**Pass 4 - factual re-verification against the source tree.** Every file reference, line number and count asserted across all three documents was re-read from the source rather than trusted. Three errors were found and corrected in place:
1. `SOFT_DELETE_TABLES` was stated as 46 tables in `applechanges.md` §3 and in A5 above; the real count, computed from `database/soft_delete.py`, is **44**.
2. `applechanges.md` §8's mandatory-requirement tally summed to 26 across 25 requirements, because R2 was counted as both missing and partial. Corrected to a true partition: 1 present, 5 partial, 15 missing, 2 blocked, 1 unverified, 1 out of scope.
3. The conditional tally said "4 not triggered … (C1, C2, C3, C6, C7 - five, counting C7)". Corrected to 5 not triggered, 3 missing, 1 borderline.
Everything else verified exactly: `main.py:744`, `templates/base.html:9`, `landing.html:120` and `:134`, `schema.py:276`/`:280`/`:1823`, `soft_delete.py:7`, `ranking.py:11`, `reactions.py:17`, `content.py:197`/`:361`, `database/content.py:22`, `models.py:51`/`:408`, `admin/users.py:179`, `devrant/auth.py:189`, `messages.py:245`, `notifications.py:68`, `admin_base.html:11`-`59`, and the existence of all fourteen include-site templates plus `routers/profile/index.py`, `services/audit/categories.py`, `services/devii/actions/spec.py` and `docs_api/_shared.py`.
**Pass 5 - fixed point.** A fifth pass over all three documents produced no further correction: every mandatory requirement maps to an artifact (§11.2), every artifact maps to a requirement, every surface is covered by construction (§11.1), and every convention is satisfied (§11.3). The documents are consistent with each other and with the source tree as read. The loop has converged.
**The one open decision** deliberately left to the lord, because it is a product-voice decision and not a technical one, is §8.2: the rewording of the four "uncensored" sites. Everything else in this design is fully specified and requires no further input.
---
## 13. What approval authorises
Approving this document authorises implementation of phases 1 through 6 in §9, in order, each phase validated with `python -c "from devplacepy.main import app"`, per-language manual checks, `ruff check` / `pyflakes` on every touched file, the four-layer rigorous verification of §10 where it applies, and the **full test suite (`make test`, all three tiers, every test) green before the phase is considered done**.
Documentation updated in step: `README.md`, the root `CLAUDE.md` (one new rule, §8.1), `devplacepy/routers/CLAUDE.md`, a new `devplacepy/services/moderation/CLAUDE.md`, `devplacepy/database/CLAUDE.md`, `devplacepy/templates/CLAUDE.md`, `events.md`, and the seven new docs pages.

View File

@ -854,10 +854,6 @@ def enrich_items(
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]:

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.

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

@ -65,10 +65,6 @@ UNREPORTABLE_TABLES: dict[str, str] = {
"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",

View File

@ -121,17 +121,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(
@ -599,10 +588,6 @@ def init_db():
("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)
@ -643,36 +628,12 @@ def init_db():
("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", ""),
@ -705,12 +666,6 @@ def init_db():
"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,

View File

@ -25,8 +25,6 @@ SOFT_DELETE_TABLES = [
"instance_schedules",
"tunnels",
"workspace_flags",
"workspace_quota_rules",
"workspace_editor_prefs",
"backup_schedules",
"devii_conversations",
"devii_tasks",

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

@ -8,22 +8,14 @@ GROUP = {
"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 workspace is a browser VS Code environment 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.
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.
the link can reach whatever you are serving.
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
@ -109,88 +101,6 @@ arrives as a `workspace` notification and states exactly what happens next and w
],
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",
@ -223,9 +133,7 @@ arrives as a `workspace` notification and states exactly what happens next and w
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."
"and unauthenticated. Refused past the tunnel limit."
),
auth="user",
params=[

View File

@ -119,7 +119,6 @@ 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
@ -284,7 +283,6 @@ async def lifespan(app: FastAPI):
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())
@ -523,15 +521,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 +530,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"

View File

@ -5,7 +5,7 @@ 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
@ -33,20 +33,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 []
@ -255,10 +241,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 +249,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 +260,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 +290,6 @@ class ProjectFlagForm(BaseModel):
value: bool = False
class ProjectScreenshotsForm(BaseModel):
attachment_uids: list[str] = []
class CustomizationToggleForm(BaseModel):
value: bool = False
@ -995,21 +959,6 @@ class TunnelForm(BaseModel):
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)
@ -1019,8 +968,6 @@ class WorkspaceQuotaForm(BaseModel):
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):

View File

@ -666,9 +666,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

View File

@ -8,24 +8,16 @@ from fastapi.responses import JSONResponse
from devplacepy.database import db, get_table, get_users_by_uids
from devplacepy.dependencies import json_or_form
from devplacepy.models import (
EditorPrefsForm,
WorkspaceFlagForm,
WorkspaceQuotaForm,
WorkspaceSuspendForm,
)
from devplacepy.responses import action_result, json_error, respond
from devplacepy.routers.admin._shared import deny_senior, is_senior_admin
from devplacepy.schemas import AdminWorkspacesOut
from devplacepy.seo import base_seo_context
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import (
editor,
flags,
provision,
quota,
tunnels,
)
from devplacepy.services.containers.workspace import flags, provision, quota, tunnels
from devplacepy.utils import create_notification, generate_uid, not_found, require_admin
router = APIRouter()
@ -245,43 +237,6 @@ async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "res
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/{uid}/editor")
async def admin_workspace_editor(
request: Request,
uid: str,
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
owner_uid = instance.get("workspace_owner_uid", "")
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
if is_senior_admin(admin, owner):
return deny_senior(
request,
admin,
owner_uid,
owner,
"container.workspace.editor.update",
"/admin/workspaces",
)
if not owner_uid:
return json_error(400, "this workspace has no owner")
if data.reset:
editor.reset_prefs(owner_uid, admin["uid"])
else:
editor.save_prefs(
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
)
_audit(request, admin, "container.workspace.editor.update", instance)
return action_result(
request,
"/admin/workspaces",
data={"editor": editor.view(owner_uid, instance)},
)
@router.post("/workspaces/quota")
async def admin_workspace_quota(
request: Request,

View File

@ -58,12 +58,6 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "workspace-editor",
"title": "The workspace editor",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "feed",
"title": "The feed",

View File

@ -9,8 +9,6 @@ This file documents the project detail page, the per-project virtual filesystem,
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
**Project overview page.** The detail page is a dedicated project showcase: one encompassing dark card (`.project-shell`, the site `--bg-card` surface with clipped corners) wraps the hero, the section tab bar and the two-column body, and every inner panel (tab bar, sidebar cards, devlog post cards, empty state, comments section) sits one elevation lighter on `--bg-secondary`. The hero's cover banner is the attachment referenced by `projects.cover_attachment_uid`, falling back to the first image attachment (brand-gradient band when neither exists); the title block, type/platform chips and author row render OVERLAID on the banner behind a bottom scrim (dark text-shadow for readability) beside the optional `projects.logo_attachment_uid` tile, with an owner-set **Visit Website** CTA (`projects.website_url`). Cover and logo ride the ONE existing upload pipeline: `dp-upload` widgets (`name="cover_attachment_uid"`/`"logo_attachment_uid"`, `max-files="1"`) in the create/edit modals upload to `/uploads/upload`, the route validates each uid via `database.get_user_attachment` (must exist, belong to the actor, be an image - `_hero_attachment_uid`) and links it to the project through `attachments.link_attachments`; an empty value on edit keeps the current image (no removal control). `website_url`/`repo_url` are normalized by `models.normalize_website_url` (scheme-less input gets `https://`, non-http(s) rejected) and render with `rel="noopener nofollow"`. Below the hero an anchor **section tab bar** (`.project-tabs`, underline style, Overview `.active`) links `#about` / `#devlog` / `#screenshots` (only when gallery images exist) / `#comments` / the Files page - server-rendered anchors, no JS tab state. The main column holds **About** (description + non-image attachments), the **Devlog** (every post whose `project_uid` points at the project via `_post_card.html` - the template loads `feed.css` for the card styles alongside `post.css`, the same rule as `news.html`) with `devlog_count` (`content.count_project_devlog`) and an owner **Post update** button (`.project-devlog-post-btn`) opening the shared composer preset to `topic=devlog` + this project (the form lives ONCE in `templates/_post_composer_form.html`, locals `_composer_topic`/`_composer_project`, included by `feed.html` and `project_detail.html` - never fork a second copy), a **Screenshots** gallery (image attachments minus the cover/logo, thumbnails, `data-lightbox`, capped at 12 rendered), and the comment thread; the sidebar holds Links (website/repository/files/fork source), the Stats card (5 `.project-stat` entries + a last-update line) and the Author card. Owners add gallery images via the More-menu **Add screenshots** modal: `_attachment_form.html` uploads, then `POST /projects/{slug}/screenshots` (`ProjectScreenshotsForm`, owner-only, audit `project.screenshots.add`) links the uids through the same `link_attachments` choke point; Devii action `project_add_screenshots`, docs id `projects-screenshots`. `comment_count`/`devlog_count` ride `ProjectDetailOut`; the new project fields ride `ProjectOut`; the page og:image prefers the cover attachment. **Locator discipline:** the page has several `Files` anchors (action row, tab bar, sidebar) and, for owners, a second hidden `textarea[name='content']`/Post button inside the composer modal - tests MUST scope (`.project-detail-actions a:has-text('Files')`, `.comment-form textarea[name='content']`).
**Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.
**Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`).

View File

@ -2,7 +2,7 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, WebSocket
from fastapi import APIRouter, Form, Request, WebSocket
from starlette.responses import Response
from devplacepy.content import (
@ -10,14 +10,13 @@ from devplacepy.content import (
can_open_workspace,
)
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.dependencies import json_or_form
from devplacepy.models import EditorPrefsForm, TunnelForm
from devplacepy.models import TunnelForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import WorkspaceOut
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import activity, api, forward, store
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.containers.workspace import editor, provision, quota, tunnels
from devplacepy.services.containers.workspace import provision, quota, tunnels
from devplacepy.services.containers.workspace.provision import WorkspaceError
from devplacepy.utils import not_found, require_user
@ -26,12 +25,6 @@ from ._shared import audit_instance, fail
router = APIRouter()
def _restart_required(instance: dict, profile: editor.EditorProfile) -> bool:
if instance.get("status") != store.ST_RUNNING:
return False
return editor.restart_required(instance, profile)
def _project_or_404(slug: str) -> dict:
project = resolve_by_slug(get_table("projects"), slug)
if not project:
@ -71,7 +64,6 @@ async def workspace_page(request: Request, slug: str):
_guard(request, project, user, "container.workspace.open")
instance = provision.find_for_project(project["uid"], user["uid"])
limits = quota.resolve(user["uid"])
profile = editor.resolve(user["uid"], instance)
context = {
"project": project,
"workspace": provision.view(instance) if instance else None,
@ -87,10 +79,6 @@ async def workspace_page(request: Request, slug: str):
"editor_password": (
api.ensure_editor_password(instance) if instance else ""
),
"editor": editor.view(user["uid"], instance),
"restart_required": (
_restart_required(instance, profile) if instance else False
),
"user": user,
}
return respond(request, "workspace.html", context, model=WorkspaceOut)
@ -163,63 +151,6 @@ async def workspace_delete(request: Request, slug: str):
return action_result(request, f"/projects/{slug}/workspace")
@router.get("/{slug}/workspace/editor")
async def editor_prefs_read(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
profile = editor.resolve(user["uid"], instance)
return {
"editor": editor.view(user["uid"], instance),
"restart_required": _restart_required(instance, profile),
}
@router.post("/{slug}/workspace/editor")
async def editor_prefs_write(
request: Request,
slug: str,
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
owner_uid = instance.get("workspace_owner_uid") or user["uid"]
if data.reset:
editor.reset_prefs(owner_uid, user["uid"])
summary = f"{user['username']} reset their workspace editor preferences"
else:
editor.save_prefs(
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
)
summary = f"{user['username']} updated their workspace editor preferences"
audit_instance(
request,
user,
"container.workspace.editor.update",
instance,
project,
summary=summary,
)
profile = editor.resolve(owner_uid, instance)
return action_result(
request,
f"/projects/{slug}/workspace",
data={
"editor": editor.view(owner_uid, instance),
"restart_required": _restart_required(instance, profile),
},
)
@router.get("/{slug}/workspace/tunnels")
async def tunnel_list(request: Request, slug: str):
user = require_user(request)
@ -243,12 +174,16 @@ async def tunnel_create(
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
try:
row = provision.publish_tunnel(
instance, data.label, data.container_port, user["uid"]
)
except provision.WorkspaceError as error:
return json_error(400, str(error))
if data.container_port <= 0:
return json_error(400, "container_port must be between 1 and 65535")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
return json_error(400, f"tunnel limit reached ({limits.max_tunnels})")
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
if not row:
return json_error(400, "could not create tunnel")
audit_instance(
request,
user,
@ -257,6 +192,7 @@ async def tunnel_create(
project,
metadata={"hostname": row["hostname"], "port": data.container_port},
)
provision.write_manifest(instance)
return action_result(request, f"/projects/{slug}/workspace", data=row)

View File

@ -4,15 +4,9 @@ import logging
from typing import Annotated
from sqlalchemy import or_
from fastapi import Depends, APIRouter, Request
from devplacepy.models import (
ProjectForm,
ProjectEditForm,
ProjectFlagForm,
ProjectScreenshotsForm,
ForkForm,
)
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.attachments import get_attachments_batch, link_attachments
from devplacepy.attachments import get_attachments_batch
from devplacepy.database import (
get_table,
get_users_by_uids,
@ -31,7 +25,6 @@ from devplacepy.database import (
get_fork_parent,
count_forks,
get_top_authors,
get_user_attachment,
)
from devplacepy.project_files import count_files
from devplacepy.services.jobs import queue
@ -48,7 +41,6 @@ from devplacepy.content import (
can_view_project_containers,
can_open_workspace,
get_project_devlog,
count_project_devlog,
)
from devplacepy.utils import (
get_current_user,
@ -184,36 +176,17 @@ async def projects_page(
model=ProjectsOut,
)
def _editor_launch(project: dict, user: dict) -> dict:
def _editor_url(project: dict, user: dict) -> str:
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import editor, provision
from devplacepy.services.containers.workspace import provision
blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
instance = provision.find_for_project(project["uid"], user["uid"])
if not instance or instance.get("suspended_at"):
return blank
return ""
if instance.get("status") != store.ST_RUNNING:
return blank
return ""
slug = project["slug"] or project["uid"]
profile = editor.resolve(user["uid"], instance)
return {
"url": f"/projects/{slug}/containers/instances/{instance['uid']}/code/",
"mode": profile.window_mode,
"width": profile.window_width,
"height": profile.window_height,
}
def _hero_attachment_uid(user: dict, raw_uid: str) -> str | None:
uid = (raw_uid or "").strip()
if not uid:
return None
attachment = get_user_attachment(uid)
if not attachment or attachment.get("user_uid") != user["uid"]:
return None
if not attachment.get("is_image"):
return None
return uid
return f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
@router.get("/{project_slug}", response_class=HTMLResponse)
@ -235,21 +208,13 @@ async def project_detail(request: Request, project_slug: str, before: str = None
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
cover_url = next(
(
a["url"]
for a in detail["attachments"]
if a["uid"] == project.get("cover_attachment_uid") and a.get("is_image")
),
None,
)
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=cover_url or first_image_url(project, detail["attachments"]),
og_image=first_image_url(project, detail["attachments"]),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
@ -261,12 +226,9 @@ async def project_detail(request: Request, project_slug: str, before: str = None
schemas=[website_schema(base), software_application_schema(project, base)],
)
viewer_can_workspace = can_open_workspace(project, user)
editor_launch = (
_editor_launch(project, user)
if viewer_can_workspace
else {"url": "", "mode": "tab", "width": 0, "height": 0}
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
workspace_editor_url = editor_launch["url"]
parent = get_fork_parent(project["uid"])
forked_from = (
{
@ -314,20 +276,11 @@ async def project_detail(request: Request, project_slug: str, before: str = None
"viewer_can_containers": can_view_project_containers(project, user),
"viewer_can_workspace": viewer_can_workspace,
"workspace_editor_url": workspace_editor_url,
"workspace_editor_mode": editor_launch["mode"],
"workspace_editor_width": editor_launch["width"],
"workspace_editor_height": editor_launch["height"],
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),
"comment_count": get_table("comments").count(
target_type="project",
target_uid=project["uid"],
deleted_at=None,
),
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
"devlog_count": count_project_devlog(project["uid"]),
},
),
model=ProjectDetailOut,
@ -443,8 +396,6 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
user = require_user(request)
title = data.title.strip()
description = data.description.strip()
cover_uid = _hero_attachment_uid(user, data.cover_attachment_uid)
logo_uid = _hero_attachment_uid(user, data.logo_attachment_uid)
uid, project_slug = create_content_item(
"projects",
@ -458,10 +409,6 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"website_url": data.website_url or None,
"repo_url": data.repo_url or None,
"cover_attachment_uid": cover_uid,
"logo_attachment_uid": logo_uid,
"is_private": 1 if data.is_private else 0,
"read_only": 0,
},
@ -472,7 +419,6 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
data.attachment_uids,
request,
)
link_attachments([u for u in (cover_uid, logo_uid) if u], "project", uid)
url = f"/projects/{project_slug}"
return action_result(
request, url, data={"uid": uid, "slug": project_slug, "url": url}
@ -483,7 +429,12 @@ async def edit_project(
request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))]
):
user = require_user(request)
fields = {
return edit_content_item(
request,
"projects",
user,
project_slug,
{
"title": data.title.strip(),
"description": data.description.strip(),
"release_date": data.release_date or None,
@ -491,63 +442,10 @@ async def edit_project(
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"website_url": data.website_url or None,
"repo_url": data.repo_url or None,
}
hero_uids = []
for field in ("cover_attachment_uid", "logo_attachment_uid"):
uid = _hero_attachment_uid(user, getattr(data, field))
if uid:
fields[field] = uid
hero_uids.append(uid)
result = edit_content_item(
request,
"projects",
user,
project_slug,
fields,
},
"/projects",
target_type="project",
)
if hero_uids:
project = resolve_by_slug(get_table("projects"), project_slug)
if project and is_owner(project, user):
link_attachments(hero_uids, "project", project["uid"])
return result
@router.post("/{project_slug}/screenshots")
async def add_project_screenshots(
request: Request,
project_slug: str,
data: Annotated[ProjectScreenshotsForm, Depends(json_or_form(ProjectScreenshotsForm))],
):
user = require_user(request)
project = resolve_by_slug(get_table("projects"), project_slug)
if not project:
raise not_found("Project not found")
if not is_owner(project, user):
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
link_attachments(data.attachment_uids, "project", project["uid"])
audit.record(
request,
"project.screenshots.add",
user=user,
target_type="project",
target_uid=project["uid"],
target_label=project.get("title"),
metadata={"attachment_count": len(data.attachment_uids)},
summary=f"{user['username']} added {len(data.attachment_uids)} screenshot(s) to project {project.get('title')}",
links=[audit.target("project", project["uid"], project.get("title"))],
)
url = f"/projects/{project['slug'] or project['uid']}#screenshots"
return action_result(
request,
url,
data={"uid": project["uid"], "linked": len(data.attachment_uids), "url": url},
)
_FLAG_EVENTS = {
("is_private", True): "project.visibility.private",

View File

@ -27,8 +27,18 @@ def resolve(host: str):
return row, instance, None, None
if instance.get("status") != store.ST_RUNNING:
return row, instance, None, None
host, port = api.tunnel_target(instance, int(row.get("container_port") or 0))
return row, instance, host, port
gateway, _ = api.proxy_target(instance)
host_port = _published_host_port(instance, int(row.get("container_port") or 0))
return row, instance, gateway, host_port
def _published_host_port(instance: dict, container_port: int) -> int:
import json
for mapping in json.loads(instance.get("ports_json") or "[]"):
if int(mapping.get("container") or 0) == container_port:
return int(mapping.get("host") or 0)
return 0
async def handle_http(request: Request, path: str) -> Response:

View File

@ -74,7 +74,6 @@ from devplacepy.schemas.containers import (
AdminWorkspacesOut,
BotFrameOut,
ContainersOut,
EditorProfileOut,
InstanceOut,
ScheduleOut,
TunnelOut,

View File

@ -132,36 +132,16 @@ class WorkspaceFlagOut(_Out):
created_at: str = ""
class EditorProfileOut(_Out):
trust_all: bool = True
theme: str = ""
font_size: int = 0
terminal_font_size: int = 0
zoom_level: int = 0
layout: str = ""
panel_preset: str = ""
boot_agent: str = ""
boot_shell: bool = True
window_mode: str = ""
window_width: int = 0
window_height: int = 0
cpu_millicores: int = 0
cpu_cores: float = 0.0
memory_mb: int = 0
disk_quota_mb: int = 0
sources: dict = {}
class WorkspaceViewOut(_Out):
uid: str = ""
name: str = ""
status: str = ""
desired_state: str = ""
suspended: bool = False
flag_reason: Optional[str] = ""
tunnel_name: Optional[str] = ""
primary_url: Optional[str] = ""
last_active_at: Optional[str] = ""
flag_reason: str = ""
tunnel_name: str = ""
primary_url: str = ""
last_active_at: str = ""
disk_bytes: int = 0
disk_quota_mb: int = 0
disk_percent: int = 0
@ -173,7 +153,6 @@ class WorkspaceViewOut(_Out):
max_tunnels: int = 0
tunnels: list[TunnelOut] = []
flags: list[WorkspaceFlagOut] = []
editor: Optional[EditorProfileOut] = None
class WorkspaceOut(_Out):
@ -185,8 +164,6 @@ class WorkspaceOut(_Out):
max_workspaces: int = 0
editor_url: str = ""
editor_password: str = ""
editor: Optional[EditorProfileOut] = None
restart_required: bool = False
user: Optional[Any] = None

View File

@ -121,10 +121,6 @@ class ProjectOut(_Out):
read_only: Optional[bool] = None
release_date: Optional[str] = None
demo_date: Optional[str] = None
website_url: Optional[str] = None
repo_url: Optional[str] = None
cover_attachment_uid: Optional[str] = None
logo_attachment_uid: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None

View File

@ -169,16 +169,11 @@ class ProjectDetailOut(_Out):
viewer_can_containers: bool = False
viewer_can_workspace: bool = False
workspace_editor_url: Optional[str] = None
workspace_editor_mode: Optional[str] = None
workspace_editor_width: Optional[int] = None
workspace_editor_height: Optional[int] = None
forked_from: Optional[dict] = None
fork_count: int = 0
file_count: int = 0
comment_count: int = 0
devlog_posts: list[FeedItemOut] = []
devlog_next_cursor: Optional[str] = None
devlog_count: int = 0
class GistsOut(_Out):

View File

@ -118,10 +118,6 @@ devplace devii reset-quota --all # Reset every quota (users and guests)
`ModerationService` is a lock-owner `BaseService` (default-enabled, hourly, floor 300s) with two jobs: it purges accounts whose deletion grace window has closed (`deletion.purge_due`, the same code path as `devplace accounts prune`), and it reports the moderation queue's service-level snapshot - logging when a report is past the published response window and exposing the queue counts, the oldest open age and the pending-purge count as `collect_metrics` stat cards. It owns no request-path work; the queue itself is entirely synchronous. Full subsystem detail in `devplacepy/services/moderation/CLAUDE.md`.
## Acceptance convergence (`services/acceptance/service.py`)
`AcceptanceService` is a lock-owner `BaseService` (**opt-in**, `default_enabled = False`, five minutes, floor 60s) that grants every policy agreement to every account which has not declined it, so a production-identical instance used for manual testing never interrupts with an acceptance dialog. It is invisible to the rest of the application by contract: one registration line in `main.py` is the only import anywhere, and there is no route, schema, template, Devii tool or environment flag. The decline register is the consent ledger itself - the service only ever writes `granted`, so any `withdrawn` row was written by a human and that pair is never touched again. Three gates stand between a fresh install and a single written row (service disabled, every agreement disabled, dry run on). Full subsystem detail in `devplacepy/services/acceptance/CLAUDE.md`; the design record is `accept.md` at the repository root.
## Multi-worker concurrency (preferred rules)
`uvicorn --workers N` = N independent processes sharing only the filesystem and SQLite DB. Module-global caches/counters are per-process, so a local `clear()` is invisible to siblings. Full reference: admin docs `Production -> Multi-worker and concurrency` (`templates/docs/production-concurrency.html`). Enforce these:
@ -185,7 +181,7 @@ Online status is a single **`users.last_seen`** UTC-ISO column (ensured in `data
**Write path (all workers):** `main.py`'s `track_presence` HTTP middleware resolves the cached current user on every non-`/static`, non-`/avatar` request and calls `presence.touch(uid)`. `touch` keeps a per-worker in-memory `_last_write: dict[uid -> monotonic]` and writes `users.last_seen` (via `database.set_last_seen`) only when the last write for that uid is older than `config.PRESENCE_WRITE_SECONDS` (= `PRESENCE_TIMEOUT_SECONDS // 2`). So continuous browsing is a dict lookup; a write happens at most ~once per half-window per active user per worker, and the row is updated in place (zero growth). It deliberately does **not** call `clear_user_cache` (that would defeat the 300s auth cache; the stale cached self-row is irrelevant since presence of *other* users is always read from a fresh row).
**Consent gate (write path).** `touch` checks `presence.recording_allowed(uid)` (the `activity_recording` consent) **after** the per-worker throttle, so the consent read costs at most one query per half-window per active user rather than one per request. A user who withdraws the consent simply stops being written and appears offline. Never move the check above the throttle.
**Consent gate (write path).** `touch` checks `presence.recording_allowed(uid)` (the `activity_recording` consent) **after** the per-worker throttle, so the consent read costs at most one query per half-window per active user rather than one per request. A user who withdraws the consent simply stops being written and appears offline; `base.html` shows a `.recording-indicator` while it is on. Never move the check above the throttle.
**Read path (any worker):** `presence.is_online(user_row)` = `now - last_seen < PRESENCE_TIMEOUT_SECONDS` (env `DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60). Profile (`routers/profile/index.py` -> `profile_online`) and messages (`routers/messages.py` seed) read `last_seen` off the user row they already loaded - no extra query. Exposed as the Jinja global `is_online(user)` (`templating.py`), on `UserOut.last_seen` and `ProfileOut.profile_online`. This is the **only** cross-worker-correct approach here because pub/sub is in-process.

View File

@ -1,65 +0,0 @@
# CLAUDE.md
This file documents the acceptance convergence subsystem (`devplacepy/services/acceptance/`). Claude Code auto-loads it whenever a file under this directory is read or edited. The full design record is [`accept.md`](../../../accept.md) at the repository root.
## Why this subsystem exists
An operator running a production-identical instance for extended manual testing is otherwise taxed forever by the platform's own safety controls: five consents, a versioned terms gate on every mutating request, and every account predating the trust-and-safety commit reading `terms_version = NULL` because `init_db` deliberately never backfills it. This service converges each account onto the acceptance state a real population would have produced itself, so the instance stays production byte for byte while nobody has to click the same dialog again.
It is off by default and it is never appropriate on a real production host.
## The two load-bearing ideas
**The application must not know.** There is no request-path branch, no schema, no route, no template, no Jinja global, no Devii tool and no environment flag. The only import of this package anywhere is the one registration line in `main.py`, and `tests/unit/services/acceptance/isolation.py` fails the suite if a second one appears. An environment flag would be exactly the knowledge the application is not allowed to have, which is why there is none.
**The decline register needs no storage.** `user_consents` is append-only in effect, so the latest live row for a `(user, kind)` pair already is the register. The service only ever writes `granted`; it follows that any `withdrawn` row in the ledger was written by a human, and the service never touches that pair again. No provenance column, no marker, no flag, and nothing for the application to observe.
## Module map
| File | Owns |
|---|---|
| `agreements.py` | `Agreement`, `AGREEMENTS`, `setting_key`, `label_for`, `agreement_for` |
| `pending.py` | `latest_consent`, `not_withdrawn`, `satisfied_clause`, `live_account_clauses`, `current_version`, `pending` |
| `grant.py` | `converge_user` plus the two private claim shapes and the audit call |
| `service.py` | `AcceptanceService`: config fields built from the registry, `run_once`, `collect_metrics` |
`pending.py` and `grant.py` import only `devplacepy.database` and `sqlalchemy` at module top; `generate_uid` and the audit recorder are imported lazily inside the functions that use them, mirroring `services/moderation/deletion.py`.
## The registry is the completeness guarantee
`AGREEMENTS` annotates `database.CONSENT_KINDS` with two facts: which `site_settings` key holds the policy version, and which `users` column the application's own gate reads. `terms` is the only agreement with a gate column, because `needs_acceptance` reads `users.terms_version` and not the ledger; `privacy` is versioned but ledger-only, matching `VERSION_KEYS` in `routers/profile/consent.py`.
A unit test asserts `{a.kind for a in AGREEMENTS} == set(CONSENT_KINDS)`. **A sixth consent fails the suite until it is classified here**, and it then appears in the admin form with no edit to the service, because the per-agreement config fields are built from the registry rather than written out by hand.
## Rules that must not regress
- **Satisfaction is the gate's own expression, never a proxy.** `pending` compares exactly what the application compares: `users.terms_version` against `get_setting("terms_version", "1") or "1"` for `terms`, the ledger row's `version` for `privacy`, the latest state for the other three. The `or "1"` is load-bearing: an admin settings save can write `terms_version = ""`, and a bare `get_setting` would make every account pending forever.
- **Order by `created_at DESC, id DESC`, never one of the two.** That pair is what `database.consent_state` selects, so it is the expression the gate evaluates. `consent_view` on the privacy tab orders by `created_at` alone; that is the display path, not a gate. Never introduce a third ordering.
- **Build the live-account clauses with `has_column`.** `init_db` ensures `terms_version`, `terms_accepted_at` and `deletion_requested_at` on `users`, but **not `is_active`** - that column is created implicitly the first time a suspension or a deletion writes it, so a hardcoded reference raises `no such column` on an instance where nobody was ever suspended. An absent column means no account can be in that state, so omitting the clause is the correct answer.
- **Every write is one conditional statement decided on the driver's real `rowcount`**, via `db.executable.execute(text(...), params).rowcount` inside `with db:`, exactly like `deletion.claim_deletion`. Sixteen real processes racing one account produce exactly one ledger row and one audit row.
- **Never write `updated_at` on `users`.** That is why `database.atomic.conditional_update_row` cannot be reused here: it appends `updated_at` unconditionally, the table has no such column, and creating one would make the service's rows distinguishable from the route's.
- **The ledger insert must stay byte-compatible with `set_consent`**, including `withdrawn_at = ''` rather than `NULL` on the unused side. A unit test compares the two field by field. It is not `set_consent` itself only because `set_consent` cannot express a precondition or join a caller's transaction.
- **One cache bump per run, not one per account.** `clear_user_cache` propagates a global `auth` version bump that makes every worker drop its whole user cache; `run_once` bumps once at the end, and only when a gate column actually changed.
- **The audit row is deliberate.** It uses the existing `terms.accept` and `consent.grant` keys with `actor_kind="service"`, `actor_username="acceptance"`. The audit log is the operator's record and no code path reads it, so it costs nothing in invisibility and is the only trace distinguishing a converged acceptance from a human one.
## What is deliberately not an agreement
| Excluded | Why |
|---|---|
| `users.age_band` | A declaration of fact, not an agreement. Fabricating a declared age would silently unlock `restricted` content for an account that declared 13-15. Sign the test account up with an adult date of birth instead. |
| `users.mature_opt_in` | A preference gated by the age band, with no ledger row and therefore no decline register. The site setting `moderation_mature_default_hidden` already turns the interstitial off instance-wide. |
| Guest consents | Nothing writes a consent row with `owner_kind = "guest"`, and the gateway resolves a guest to owner kind `anonymous`, which `consent_denied` exempts by design. |
| Preferences (`interactions_enabled`, notifications, customization) | None of them blocks anything. |
## The terms asymmetry, and the one operator step
`POST /auth/accept-terms` grants **two** agreements: `terms` and `privacy`. The service keeps them independent on purpose, because the per-agreement switches exist for edge-case testing; enabling both reproduces the human path exactly, enabling one is a deliberate divergence.
Withdrawing the `terms` consent does not clear `users.terms_version`, so a tester who wants to exercise the gate withdraws their own `terms` consent and then has an administrator bump `terms_version` at `/admin/settings`. Every other account converges within one interval; the tester stays gated indefinitely.
## Rules for extending this
- A new consent kind: classify it in `AGREEMENTS` (the test forces this), and nothing else.
- Never add a route, a schema, a template, a Devii tool or a `docs_api` entry. The surface is the generic services admin, exactly as for `NotificationRelayService` and `AuditService`.
- Never add a second decline register, a second ordering, or an environment check.
- Never write a persisted test that enables the service and asserts convergence: the suite is serial against one seeded database, and granting `terms` to every account would poison the moderation tests that assert the gate refuses. Convergence is covered by unit tests calling `converge_user` directly.

View File

@ -1 +0,0 @@
# retoor <retoor@molodetz.nl>

View File

@ -1,37 +0,0 @@
# retoor <retoor@molodetz.nl>
from dataclasses import dataclass
from devplacepy.database import CONSENT_KINDS
@dataclass(frozen=True)
class Agreement:
kind: str
version_setting: str
gate_column: str
stamp_column: str
AGREEMENTS: tuple[Agreement, ...] = (
Agreement("terms", "terms_version", "terms_version", "terms_accepted_at"),
Agreement("privacy", "privacy_version", "", ""),
Agreement("ai_third_party", "", "", ""),
Agreement("activity_recording", "", "", ""),
Agreement("container_credentials", "", "", ""),
)
def setting_key(kind: str) -> str:
return f"acceptance_grant_{kind}"
def label_for(kind: str) -> str:
return CONSENT_KINDS.get(kind, kind)
def agreement_for(kind: str) -> Agreement | None:
for agreement in AGREEMENTS:
if agreement.kind == kind:
return agreement
return None

View File

@ -1,130 +0,0 @@
# retoor <retoor@molodetz.nl>
from sqlalchemy import text
from devplacepy.database import CONSENTS_TABLE, _now_iso, db, get_table
from .agreements import Agreement
from .pending import has_gate_column, not_withdrawn, satisfied_clause
CONSENT_COLUMNS = (
"uid",
"owner_kind",
"owner_id",
"kind",
"version",
"state",
"granted_at",
"withdrawn_at",
"created_at",
"deleted_at",
"deleted_by",
)
def _gate_set_clause(agreement: Agreement) -> str:
users = get_table("users")
assignments = [f"{agreement.gate_column} = :version"]
if agreement.stamp_column and users.has_column(agreement.stamp_column):
assignments.append(f"{agreement.stamp_column} = :now")
return ", ".join(assignments)
def _insert_consent_sql(precondition: str) -> str:
columns = ", ".join(CONSENT_COLUMNS)
sql = (
f"INSERT INTO {CONSENTS_TABLE} ({columns}) "
"SELECT :row_uid, 'user', :uid, :kind, :version, 'granted', "
":now, '', :now, NULL, NULL"
)
if precondition:
return f"{sql} WHERE {precondition}"
return sql
def _consent_params(agreement: Agreement, uid: str, version: str, now: str) -> dict:
from devplacepy.utils import generate_uid
return {
"row_uid": generate_uid(),
"uid": uid,
"kind": agreement.kind,
"version": version,
"now": now,
}
def _claim_gate_column(agreement: Agreement, uid: str, version: str, now: str) -> bool:
claim = (
f"UPDATE users SET {_gate_set_clause(agreement)} "
"WHERE uid = :uid "
f"AND COALESCE({agreement.gate_column}, '') != :version "
f"AND {not_withdrawn('users.uid')}"
)
params = _consent_params(agreement, uid, version, now)
with db:
claimed = db.executable.execute(text(claim), params).rowcount
if claimed != 1:
return False
db.executable.execute(text(_insert_consent_sql("")), params)
return True
def _claim_ledger(agreement: Agreement, uid: str, version: str, now: str) -> bool:
precondition = (
f"{not_withdrawn(':uid')} AND {satisfied_clause(agreement, ':uid', '')}"
)
params = _consent_params(agreement, uid, version, now)
with db:
written = db.executable.execute(
text(_insert_consent_sql(precondition)), params
).rowcount
return written == 1
def _record(agreement: Agreement, user: dict, version: str) -> None:
from devplacepy.services.audit import record as audit
uid = user["uid"]
username = user.get("username") or ""
links = [audit.target("user", uid, username)]
if agreement.kind == "terms":
audit.record_system(
"terms.accept",
actor_kind="service",
actor_username="acceptance",
target_type="user",
target_uid=uid,
target_label=username,
new_value=version,
summary=f"{username} accepted terms version {version}",
links=links,
)
return
audit.record_system(
"consent.grant",
actor_kind="service",
actor_username="acceptance",
target_type="user",
target_uid=uid,
target_label=username,
new_value="granted",
metadata={"kind": agreement.kind},
summary=f"granted {agreement.kind} consent for {username}",
links=links,
)
def converge_user(agreement: Agreement, user: dict, version: str) -> bool:
uid = user.get("uid")
if not uid:
return False
now = _now_iso()
if has_gate_column(agreement):
won = _claim_gate_column(agreement, uid, version, now)
else:
won = _claim_ledger(agreement, uid, version, now)
if not won:
return False
_record(agreement, user, version)
return True

View File

@ -1,78 +0,0 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import CONSENTS_TABLE, db, get_setting, get_table
from .agreements import Agreement
DEFAULT_VERSION = "1"
LIVE_ACCOUNT_CLAUSES: tuple[tuple[str, str], ...] = (
("deletion_requested_at", "COALESCE(u.deletion_requested_at, '') = ''"),
("is_active", "COALESCE(u.is_active, 1) != 0"),
("deleted_at", "u.deleted_at IS NULL"),
)
def latest_consent(column: str, user_ref: str) -> str:
return (
f"COALESCE((SELECT c.{column} FROM {CONSENTS_TABLE} c "
f"WHERE c.owner_kind = 'user' AND c.owner_id = {user_ref} "
"AND c.kind = :kind AND c.deleted_at IS NULL "
"ORDER BY c.created_at DESC, c.id DESC LIMIT 1), '')"
)
def not_withdrawn(user_ref: str) -> str:
return f"{latest_consent('state', user_ref)} != 'withdrawn'"
def has_gate_column(agreement: Agreement) -> bool:
if not agreement.gate_column:
return False
if "users" not in db.tables:
return False
return get_table("users").has_column(agreement.gate_column)
def satisfied_clause(agreement: Agreement, user_ref: str, column_prefix: str) -> str:
if has_gate_column(agreement):
return f"COALESCE({column_prefix}{agreement.gate_column}, '') != :version"
if agreement.version_setting:
return f"{latest_consent('version', user_ref)} != :version"
return f"{latest_consent('state', user_ref)} != 'granted'"
def live_account_clauses() -> list[str]:
users = get_table("users")
return [
clause for column, clause in LIVE_ACCOUNT_CLAUSES if users.has_column(column)
]
def current_version(agreement: Agreement) -> str:
if not agreement.version_setting:
return DEFAULT_VERSION
return get_setting(agreement.version_setting, DEFAULT_VERSION) or DEFAULT_VERSION
def pending(agreement: Agreement, limit: int) -> list[dict]:
if "users" not in db.tables or CONSENTS_TABLE not in db.tables:
return []
bound = max(1, int(limit))
clauses = [
*live_account_clauses(),
not_withdrawn("u.uid"),
satisfied_clause(agreement, "u.uid", "u."),
]
sql = (
"SELECT u.uid, u.username FROM users u "
f"WHERE {' AND '.join(clauses)} "
"ORDER BY u.id LIMIT :limit"
)
rows = db.query(
sql,
kind=agreement.kind,
version=current_version(agreement),
limit=bound,
)
return [dict(row) for row in rows]

View File

@ -1,168 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from devplacepy.database import CONSENT_KINDS, bump_cache_version
from devplacepy.services.acceptance.agreements import (
AGREEMENTS,
label_for,
setting_key,
)
from devplacepy.services.acceptance.grant import converge_user
from devplacepy.services.acceptance.pending import current_version, pending
from devplacepy.services.base import BaseService, ConfigField
logger = logging.getLogger(__name__)
DRY_RUN_KEY = "acceptance_dry_run"
BATCH_SIZE_KEY = "acceptance_batch_size"
DEFAULT_BATCH_SIZE = 200
MAX_BATCH_SIZE = 5000
SAMPLE_NAMES = 20
def sample_names(candidates: list[dict]) -> str:
names = [row.get("username") or row.get("uid") or "" for row in candidates]
shown = ", ".join(names[:SAMPLE_NAMES])
remaining = len(names) - SAMPLE_NAMES
if remaining > 0:
return f"{shown} and {remaining} more"
return shown
class AcceptanceService(BaseService):
title = "Acceptance convergence"
description = (
"Grants every policy agreement to every account that has not declined it, "
"so a production-identical test instance never interrupts manual testing "
"with an acceptance dialog. Off by default and never appropriate on a real "
"production host."
)
details = (
"An account that withdrew a consent is never granted it again, with no "
"further action: the latest row in the consent ledger is the decline "
"register. The service only ever grants, so any withdrawal in the ledger "
"was written by a human. Withdraw a consent from the profile privacy tab "
"to keep an account permanently outside the convergence."
)
default_enabled = False
min_interval = 60
METRICS_SECONDS = 60
config_fields = [
ConfigField(
DRY_RUN_KEY,
"Dry run",
type="bool",
default=True,
help=(
"Log which accounts would be converged and write nothing. Switch "
"off only after the log lists what you expect."
),
group="Safety",
),
ConfigField(
BATCH_SIZE_KEY,
"Accounts per agreement per run",
type="int",
default=DEFAULT_BATCH_SIZE,
minimum=1,
maximum=MAX_BATCH_SIZE,
help="Upper bound on one sweep. The remainder converges on the next run.",
group="Safety",
),
*[
ConfigField(
setting_key(agreement.kind),
label_for(agreement.kind),
type="bool",
default=False,
help=f"Grant the {agreement.kind} agreement to every account that has not declined it.",
group="Agreements",
)
for agreement in AGREEMENTS
],
]
def __init__(self) -> None:
super().__init__("acceptance", interval_seconds=300)
self._converged = {agreement.kind: 0 for agreement in AGREEMENTS}
async def run_once(self) -> None:
config = self.get_config()
dry = config[DRY_RUN_KEY]
limit = config[BATCH_SIZE_KEY]
gate_changed = False
for agreement in AGREEMENTS:
if not config[setting_key(agreement.kind)]:
continue
version = current_version(agreement)
candidates = pending(agreement, limit)
if not candidates:
continue
if dry:
self.log(
f"[dry run] {agreement.kind}: {len(candidates)} account(s) would be "
f"converged to version {version}: {sample_names(candidates)}"
)
continue
granted = 0
for user in candidates:
if converge_user(agreement, user, version):
granted += 1
gate_changed = gate_changed or bool(agreement.gate_column)
self._converged[agreement.kind] += granted
self.log(
f"{agreement.kind}: converged {granted} of {len(candidates)} pending "
f"at version {version}"
)
if len(candidates) == limit:
self.log(
f"{agreement.kind}: batch cap of {limit} reached, more remain "
"for the next run"
)
if gate_changed:
bump_cache_version("auth")
def collect_metrics(self) -> dict:
config = self.get_config()
limit = config[BATCH_SIZE_KEY]
enabled = [
agreement
for agreement in AGREEMENTS
if config[setting_key(agreement.kind)]
]
stats = [
{"label": "Dry run", "value": 1 if config[DRY_RUN_KEY] else 0},
{"label": "Agreements enabled", "value": len(enabled)},
]
rows = []
for agreement in AGREEMENTS:
is_enabled = config[setting_key(agreement.kind)]
count = len(pending(agreement, limit)) if is_enabled else 0
display = f"{count}+" if is_enabled and count == limit else str(count)
if is_enabled:
stats.append(
{"label": f"{agreement.kind} pending", "value": display}
)
rows.append(
[
agreement.kind,
CONSENT_KINDS.get(agreement.kind, agreement.kind)[:48],
1 if is_enabled else 0,
agreement.version_setting or "",
display if is_enabled else "",
self._converged[agreement.kind],
]
)
table = {
"columns": [
"Agreement",
"Policy",
"Enabled",
"Version setting",
"Pending",
"Converged",
],
"rows": rows,
}
return {"stats": stats, "table": table}

View File

@ -112,136 +112,6 @@ An instance's shell is NOT inline - it is a floating `<container-terminal>` (`st
**Minimize/Normalize.** Geometry presets (`_presetGeometry(w,h)`, smallest-usable / comfortable), exposed both as titlebar buttons (`data-win="minimize|normalize"`) and menu items on every window.
## Editor profile and branding (`workspace/editor.py`, `files/vscode/`)
The browser editor is a DevPlace product surface, not stock code-server. Three layers own it, and
the split is the design: the deterministic part is host-side and unit-testable without Docker, the
cosmetic part is an extension that fails soft.
| Layer | Owns | Fails how |
|---|---|---|
| **Host** `workspace/editor.py` | Resolving the profile, seeding `settings.json`, building the argv, setting container CPU and memory | Deterministic, unit-tested against a temp state dir, no container needed |
| **Image** `ppy.Dockerfile` + `files/vscode/` | Branding assets, patched `product.json`, the bundled extension | Verified by the build smoke test; an image that cannot brand cannot build green |
| **Extension** `files/vscode/devplace-workspace/` | Boot terminals, panel layout, status bar, walkthrough, `DevPlace:` commands | Each stage in its own try/catch to a `DevPlace` output channel. A failure costs the terminals, never the editor |
**One resolver, exactly like `quota.resolve`.** `editor.resolve(owner_uid, instance) -> EditorProfile`
is the only place a `workspace_editor_*` setting is read. Order is user preference row, then site
setting, then built-in default; the container size (`cpu_millicores`, `memory_mb`, `disk_quota_mb`)
comes from `quota.resolve` so all four "sizes" live on one object. `editor.view()` adds
`source_map()` so every surface can say where a value came from. Never read one of those settings at
a call site.
**Inherit sentinels are explicit, never truthiness.** `workspace_editor_prefs` stores `""` / `0` for
"inherit", but zoom level `0` is a real value, so its sentinel is `-99` (`INHERIT_ZOOM`), and
`boot_shell` uses `-1` (`INHERIT_FLAG`) because `0` means off. `_inherits(key, row)` is the single
predicate; a bare `if value:` here would silently ignore a member who wants zoom 0 or no shell.
**`merge_managed` is the contract that a member edit is never overwritten** and it is a pure
function, which is why it is exhaustively unit-tested. DevPlace writes a key only when it is absent
or still equal to the value DevPlace wrote last time, recorded in
`{state}/data/User/.devplace-managed.json`. So raising a site default reaches everyone who never
expressed a preference and nobody who did. Do not replace this with a plain merge or a full rewrite.
**Seeding runs at launch, in `run_spec_for`**, alongside `ensure_editor_password` - the one point
every workspace launch passes through, so a workspace created before this feature is seeded on its
next boot. `stamp_boot_marker` writes a fresh `instances.boot_marker` there too; it reaches the
container as `DEVPLACE_CONTAINER_BOOT` and is what makes the extension's boot terminals idempotent
across browser reloads. Because seeding happens at launch, a preference change applies on the **next
start**: the workspace page compares the resolved profile against `{state}/devplace-editor.json`
(`editor.restart_required`) and shows a restart banner rather than pretending it applied.
**Nothing DevPlace writes goes to `/app`.** `/app` round-trips into the member's project through
`sync_dir_bidirectional`, so a `.vscode/tasks.json` there would land in their repository. Every
artefact goes under `WORKSPACE_STATE_DIR`, which is a separate bind mount and never synced. This is
why boot terminals are an extension rather than a folder-open task.
**The agent's own working files are in `SYNC_SKIP_NAMES` for the same reason.** `dpc` writes `.dpc/`
and `dpc.log` into its working directory, which is `/app`. That was harmless while `dpc` only ran
when a member typed it; now that it starts on every workspace boot, those artefacts would be
imported into every project on the next sync. `project_files.SYNC_SKIP_NAMES` therefore carries
`.dpc`, `dpc.log` and `.devplace` (the tunnel manifest directory, which was already being written
and already leaking) alongside the `.devplace_boot.*` entries. Any future in-container tool that
writes state next to the member's code needs the same entry.
**The boot marker degrades to once-per-extension-host, never to "always".** `BootTerminals` is
guarded by `DEVPLACE_CONTAINER_BOOT`, but an instance created before that column existed injects an
empty value. The guard used to treat an empty marker as "not booted yet" and opened a fresh pair of
terminals on **every browser reload** - caught by driving one container with three consecutive
Playwright sessions and finding six terminal tabs. The fallback is now `host-${process.pid}` of the
extension host, which survives a browser reload and changes when the container restarts, which is
exactly the intended semantic.
**Trust is disabled at three layers** and gated by one kill switch, `workspace_editor_trust_all`
(default on): the `--disable-workspace-trust` flag, the seeded `security.workspace.trust.*` settings,
and the extension's `contributes.configurationDefaults`. The third is belt and braces only -
`security.workspace.trust.enabled` is application-scoped and VS Code restricts which scopes an
extension may re-default - so never let it be the only layer. Turning the switch off restores
Restricted Mode with no code change and no image rebuild. It also enables
`task.allowAutomaticTasks`, so a project's own `runOn: folderOpen` task will run; that consequence is
documented to members on `/docs/workspace-editor.html` and must stay documented.
**Panel height is a preset, not a pixel value, and that is a hard constraint.** VS Code stores part
sizes in the workbench grid inside `state.vscdb`, an undocumented and version-unstable internal
SQLite database. Writing it from the host is rejected. The extension drives
`workbench.action.toggleMaximizedPanel` / `increaseViewSize` instead, so the four presets are named
honestly as presets in the UI. Do not "improve" this by writing `state.vscdb`.
**The extension is a built-in, copied to
`/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace`.** Built-ins are always
enabled, cannot be uninstalled, need no install step and survive workspace recreation because they
live in the image. `--builtin-extensions-dir` is deliberately NOT used: it has a known upstream
defect where extensions loaded through it present as disabled. There is no build step, no npm and no
bundler - a VS Code extension is a directory with a `package.json` and an entry point, and it runs in
code-server's Node remote extension host, so `main` applies.
**`extension.js` is CommonJS, and it is the one file in this repository that may be.** The VS Code
extension host loads CommonJS; it is not frontend code and is never served to a browser. Every other
house rule applies unchanged. Four small classes (`Profile`, `BootTerminals`, `Layout`, `Presence`)
and an `activate` that runs each through `stage()`, which owns the try/catch and logs to a
`DevPlace` output channel.
**The activation stages are awaited in order, and `Layout` never opens a panel of its own.** Firing
them concurrently is what produced a stray third terminal in the first live build: `Layout` called
`workbench.action.focusPanel` before `BootTerminals` had created anything, and VS Code answered by
spawning its own default `bash`. `Layout.apply(panelIsOpen)` therefore resizes only when the boot
terminals actually opened the panel, and `activate` awaits `terminals` before `layout`. Verified by
driving a real container with Playwright: the tab list must read exactly
`pravda@workspace` + `DevPlace Code`.
**A workspace suppresses the editor's own AI assistant.** Recent VS Code ships a chat panel in the
secondary sidebar, which opened by default with Microsoft branding, "AI responses may be inaccurate"
copy, and a competing agent right beside `dpc`. `editor.FOREIGN_AI_SETTINGS` turns it off
(`chat.disableAIFeatures`, `chat.commandCenter.enabled`, `workbench.secondarySideBar.defaultVisibility`)
and `workbench.startupEditor` is `none` so a workspace opens straight onto the member's code with the
agent terminal ready, rather than onto a welcome page listing a "Get Started with VS Code"
walkthrough. Unknown keys are ignored by VS Code, so these stay safe across version bumps. The
DevPlace walkthrough is still contributed and reachable from Help and the command palette.
**Branding is six things**, all baked in: the `--app-name` / `--welcome-text` /
`--disable-getting-started-override` flags in `editor.argv`; the favicon and PWA icon set generated
from `static/icon-512.png` into `files/vscode/branding/`; `devplace-login.css` appended to
code-server's `login.css`; `product.patch.json` merged key-wise into `product.json` (additive, so an
unnamed upstream key survives a bump); the `DevPlace Dark` / `DevPlace Light` themes generated from
`static/css/variables.css`; and the walkthrough, status bar item and five `DevPlace:` commands. The
login stylesheet is the **single sanctioned exception to the no-colour-literals rule** - code-server
serves it outside the application and cannot read `variables.css`, so the tokens are restated as
literals with a comment naming each one. Do not spread that exception anywhere else.
**Theme token mapping** (`variables.css` -> VS Code), recorded here because a JSON theme cannot carry
a comment: `--bg-primary` -> `editor.background`; `--bg-secondary` -> `sideBar`/`activityBar`;
`--bg-card` -> `editorWidget`/`panel`; `--accent` -> `focusBorder`/`button.background`/`progressBar`;
`--accent-light` -> `list.activeSelectionBackground`; `--text-primary` -> `foreground`;
`--text-secondary` -> `descriptionForeground`; `--border` -> every `*.border`;
`--success`/`--warning`/`--danger`/`--info` -> the ANSI green/yellow/red/blue. `DevPlace Light` is a
derived light palette (DevPlace ships no light tokens); keep the two in step by construction.
**Adding an editor setting** touches five places: `editor.DEFAULTS` + `SETTING_KEYS`, a `ConfigField`
on `WorkspaceService` (group `Editor`), the `workspace_editor_prefs` ensure block and
`editor.PREF_COLUMNS`, `EditorPrefsForm` + `EditorProfileOut`, and the `settings_for` map or the
extension. A `select` `ConfigField` MUST use `options=[{"value":..., "label":...}]` - plain strings
crash `docs_api.build_services_group`, which `docs_search` indexes, which 500s the docs search page.
## Pravda image (load-bearing, workspace ownership)
The `ppy` image (`ppy.Dockerfile`, built by `make ppy`, context `devplacepy/services/containers/files`) is a `python:3.13-slim-bookworm` base with Playwright plus a broad set of common Python libraries preinstalled, plus CLI tools (`tmux`, `apache2-utils` for `ab`, `procps`/`htop`/`iftop`/`iotop`, `netcat-openbsd` for `nc`, `zip`/`unzip`, `fakeroot`, git/curl/wget/vim/ack).
@ -263,7 +133,7 @@ The security hotpatch that used to run per build is now baked into `ppy.Dockerfi
**Trade-off (intentional).** The only genuinely-root operation that still does NOT escalate is binding a port < 1024 - use a high port + `/p/<slug>` ingress instead. Enforcement lives entirely in the Dockerfile (no `--user` on `docker run`). The `export_to_dir` unlink-before-write fix remains as belt-and-suspenders (the app owns the workspace dir, so it may delete any stale file in it regardless of owner before rewriting it).
## `DEVPLACE_*` runtime env injection (function name `pravda_env`)
## `PRAVDA_*` runtime env injection
`api.run_spec_for` merges `api.pravda_env(instance)` over the instance's own `env_json` (PRAVDA keys win), so every running container gets these platform vars:
- `DEVPLACE_BASE_URL` - the `site_url` setting via `seo.public_base_url()`.
@ -326,9 +196,9 @@ Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, a
## Vibe coding on-ramp (user-facing doc)
The container runtime is also the basis of "vibe coding": the public prose page `templates/docs/getting-started-vibing.html` (slug `getting-started-vibing`, `SECTION_GENERAL`, not admin-gated so the docs stay public while the feature is admin-only/Alpha) is the canonical user-facing guide. It teaches the Devii-driven flow project -> container -> start -> terminal (`create_project`, `container_create_instance`, `container_instance_action`, the `open_terminal` client action), documents the three baked-in agents (`dpc` = DevPlace Code at `/usr/bin/dpc`, the Claude-Code-class coding agent; `botje.py` = the copy of `services/containers/files/bot.py` at `/usr/bin/botje.py`; `pagent`), all metered through the container's own `DEVPLACE_API_KEY`, the full `DEVPLACE_*` env table (see `api.pravda_env`), and ingress at `/p/<slug>` via `ingress_slug`/`ingress_port`.
The container runtime is also the basis of "vibe coding": the public prose page `templates/docs/getting-started-vibing.html` (slug `getting-started-vibing`, `SECTION_GENERAL`, not admin-gated so the docs stay public while the feature is admin-only/Alpha) is the canonical user-facing guide. It teaches the Devii-driven flow project -> container -> start -> terminal (`create_project`, `container_create_instance`, `container_instance_action`, the `open_terminal` client action), documents the three baked-in agents (`dpc` = DevPlace Code at `/usr/bin/dpc`, the Claude-Code-class coding agent; `botje.py` = the copy of `services/containers/files/bot.py` at `/usr/bin/botje.py`; `pagent`), all metered through the container's own `DEVPLACE_API_KEY`, the full `PRAVDA_*` env table (see `api.pravda_env`), and ingress at `/p/<slug>` via `ingress_slug`/`ingress_port`.
When the runtime, the agent binaries, or the `DEVPLACE_*`/ingress contract change, update this page alongside the source.
When the runtime, the agent binaries, or the `PRAVDA_*`/ingress contract change, update this page alongside the source.
**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
@ -410,7 +280,7 @@ query, so a literal `#` in the path makes the reconstructed URL treat the query
**Editor persistence.** code-server's user-data and extensions live in
`config.WORKSPACE_STATE_DIR/<instance uid>`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions
survive container recreation. `editor.argv` builds the argv; `run_spec_for` prefers it over
survive container recreation. `api.editor_command` builds the argv; `run_spec_for` prefers it over
the boot-script/boot-command chain when `is_workspace` and `editor_port` are set.
**Activity and egress are the presence pattern.** `activity.py` keeps a per-worker monotonic dict and
@ -478,69 +348,6 @@ schedules or tracks renewals. This whole phase did not exist - `tunnels` had no
the six `workspace_molohttp_*`/`workspace_cert_mode`/`workspace_acme_email` settings were admin
fields wired to nothing, which is why every tunnel sat at `pending` with no certificate.
**`provision.publish_tunnel` is the ONE way a user-created tunnel comes into existence** - the HTTP
route, the Devii tool and the editor all funnel through it. It owns the port check, the `max_tunnels`
quota, `tunnels.create`, `schedule_certificate` and `write_manifest`, and raises `WorkspaceError` for
every refusal. Before it existed, the route and the Devii controller each carried their own copy of
the quota check and **neither ordered a certificate**, so a user-created tunnel sat `pending` until
the (default-disabled) `WorkspaceService` happened to tick - which on an instance where that service
was never enabled is forever. `schedule_certificate` now also writes `provision.CERT_UNCONFIGURED`
into the row's `last_error` when molohttp is not configured, because a tunnel that can never be
certified must say so on the workspace page rather than sit at `pending` with a blank error.
**A tunnel reaches its port through `api.tunnel_target(instance, container_port)`, never through
`proxy_target`.** `proxy_target` answers for `/p/{slug}`, whose port is published by construction;
a tunnel's port is whatever the member decided to serve on and is almost never published, because a
workspace publishes only `editor_port`. `tunnel_target` therefore prefers the published host port
when the port happens to have one (`CONTAINER_PROXY_HOST` or the recorded gateway, exactly like
`proxy_target`) and otherwise dials `container_ip:container_port` directly. The direct leg is what
makes an arbitrary port tunnellable at all: docker cannot add a published port to a running
container, so publishing on demand would mean recreating the container and killing the very dev
server the member just asked to share.
**The direct leg needs the app on the same docker network as the instances, and that wiring cannot
live in compose.** Measured on this host: from the app container, `container_ip:port` times out
(docker's inter-network isolation) while `gateway:published_host_port` connects; from the host, and
from any container sharing the instances' network, `container_ip:port` connects. So `make dev` works
untouched and the containerized production app does not - it must be attached to the network the
instances run on. Compose cannot express that: it always sends network-scoped aliases, which the
default `bridge` rejects (`invalid endpoint settings: network-scoped aliases are only supported for
user-defined networks`). The attachment is therefore a `make docker-attach` step, run by
`docker-up` and `docker-reload` and idempotent, deriving its input like `DOCKER_GID` does
(`DEVPLACE_CONTAINER_NETWORK`, default `bridge`). A bare `docker compose up -d` skips it and
silently re-breaks every unpublished-port tunnel - one more reason the make targets are the only
supported path.
**Forwarding a port in the editor publishes it, and that is the whole point of `VSCODE_PROXY_URI`.**
`api.workspace_env` advertises `https://{{port}}-{name}.{domain}` to VS Code, so the Ports view shows
a DevPlace address for every forwarded port - but VS Code never tells DevPlace, so that address had
no `tunnels` row, was 404ed by `routers/tunnel.py` and never got a certificate. The editor promised a
URL the platform could not serve. The `Tunnels` stage in the workspace extension closes it: it
subscribes to `vscode.workspace.onDidChangeTunnels`, reads `vscode.workspace.tunnels`, and POSTs each
new `remoteAddress.port` (skipping `DEVPLACE_EDITOR_PORT`, which is already published) to
`{DEVPLACE_BASE_URL}/projects/{DEVPLACE_PROJECT_SLUG}/workspace/tunnels` with the container's own
`DEVPLACE_API_KEY` and `Accept: application/json`. Four things about it:
- **`tunnels` is a proposed API** (`checkProposedApiEnabled(extension, 'tunnels')`), so the extension
declares `enabledApiProposals: ["tunnels"]` and `product.patch.json` names it under
`extensionEnabledApiProposals`. code-server patches the check to always pass, so it works today
either way; the declarations are what keep it working if that patch goes away. Because the patch
adds a nested object, the Dockerfile's `product.json` merge now merges one level deep - a plain
`dict.update` would wipe an upstream map of the same name on a version bump.
- **It only ever creates.** Un-forwarding a port leaves the tunnel standing, because deleting it
would revoke the certificate and a re-forward would re-issue, churning against Let's Encrypt's
duplicate-certificate limit. Removal stays the explicit act it already was.
- **A port is added to the in-memory `published` set before the POST and removed again on failure**,
so a burst of change events cannot double-post and a refusal (quota, 403) can still retry on the
next change. Refusals surface both in the `DevPlace` output channel and as a warning message.
- **It uses `http`/`https` from Node, not `fetch`**, and is wrapped in the same `stage()` try/catch as
every other activation step - a workspace whose network is down must still open its editor.
Verified against the real image by driving code-server with Playwright and forwarding port 3000: the
Ports view lists the port, the extension POSTs `label=Port+3000&container_port=3000` with the API key,
and the output channel reports the public URL. Reproduce it that way, not with a mock - the Ports
view is the only trigger, there is no `Forward a Port` command in the palette in code-server.
**Two contracts that bite:**
- A `ConfigField` with `type="select"` needs `options=[{"value": ..., "label": ...}]`. Plain strings
crash `docs_api.build_services_group`, which `docs_search` indexes, so the whole docs search page
@ -549,9 +356,8 @@ view is the only trigger, there is no `Forward a Port` command in the palette in
required field makes an anonymous request 422 instead of 401 and `tests/api/auth/matrix.py` fails.
Give the field a default and validate it inside the handler after `require_user`.
**The editor opens through one shared partial.** The project detail page renders an inline **Editor**
button in `.project-detail-actions` via `templates/_editor_open.html` (`target="_blank"`, plus the
`data-editor-*` attributes `EditorLauncher` reads) straight to the code-server proxy
**The editor opens in a new tab, directly.** The project detail page renders an inline **VS Code**
button in `.project-detail-actions` (`target="_blank"`) straight to the code-server proxy
`/projects/{slug}/containers/instances/{uid}/code/`, built by `_editor_url` in
`routers/projects/index.py` and carried as `workspace_editor_url` on the context and
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND their
@ -576,10 +382,9 @@ page that links to it and the setting turned on.
list, start/stop, suspend/unsuspend with a required reason, raise/resolve/dismiss flags, per-user
quota rules. `base_seo_context` takes `breadcrumbs`/`schemas`, not `canonical`/`schema`.
**Devii** has 17 tools under `handler="workspace"`; six are in `CONFIRM_REQUIRED` and every one of
**Devii** has 15 tools under `handler="workspace"`; five are in `CONFIRM_REQUIRED` and every one of
them declares a `confirm` param (schemas are `additionalProperties: false`, so a gated tool without it
loops forever). `workspace_editor_get` / `workspace_editor_set` cover the editor profile;
`workspace_editor_set` is gated because it changes how every workspace the member opens behaves.
loops forever).
**Opening a workspace publishes its editor on a public tunnel, automatically.** `provision.ensure`
does three things after creating the instance: `api.ensure_editor_password`, then
@ -594,7 +399,7 @@ password. This is a deliberate departure from the plane-A/plane-B split describe
is now reachable publicly, which is only acceptable **because** `--auth password` is on - never
reintroduce `--auth none` while the editor tunnel is auto-published.
**The editor is password-protected, per workspace.** `editor.argv` runs code-server with
**The editor is password-protected, per workspace.** `api.editor_command` runs code-server with
`--auth password`, and `api.pravda_env` injects the secret as `PASSWORD` (the variable code-server
reads). The secret is an 8-character **pronounceable** token from `api.generate_editor_password()`,
built as four consonant-vowel pairs (`ronebamu`, `zipesodu`) so a user can read it once and retype it
@ -614,7 +419,7 @@ the instance, so putting it there would hand every user's editor password to any
an unauthenticated public shell: `--auth none` was safe only while the editor was reachable solely
through the session-authenticated proxy route, and a user can publish a tunnel to the editor port.
**code-server lives in the `ppy` image, and nothing else supplies it.** `editor.argv` makes
**code-server lives in the `ppy` image, and nothing else supplies it.** `api.editor_command` makes
`code-server` the container's argv, so an image without it fails `docker run` with exit **127**
(`executable file not found in $PATH`) and the reconciler records `crashed` / `launch_failed` with an
empty `container_id` - the container process never existed. It is installed in `ppy.Dockerfile` in
@ -623,11 +428,9 @@ it with no elevation): version pinned in the single `ARG CODE_SERVER_VERSION`, a
`dpkg --print-architecture`, release tarball unpacked to `/usr/local/lib/code-server` with a symlink
at `/usr/local/bin/code-server`. `code-server --version` is in the build smoke-test loop and the
symlink is in the executable-check list, so an image that cannot run the editor can never build
green. Bumping the version is a one-line `ARG` change plus `make ppy`, and the branding smoke test
(below) re-verifies the four CLI flags, the media file names and the `product.json` keys, so a
version that breaks any of them cannot build green either. (`workspace_editor_version` was a
`ConfigField` read by nothing and has been removed: the version is a property of the shared image,
not a runtime setting, and a live control that changes nothing is a silent failure.)
green. Bumping the version is a one-line `ARG` change plus `make ppy`. **`workspace_editor_version`
(a `ConfigField` on `WorkspaceService`) is currently read by nothing** - the version is the image's,
not a runtime setting; wire it up or drop it before relying on it.
**A container stuck in `created` is recreated, never retried forever.** The reconciler's
`desired=running` branch reaches `backend.start()` for `ps.state == "created"`. That call is wrapped:

View File

@ -443,7 +443,7 @@ def pravda_env(instance: dict) -> dict:
def workspace_env(instance: dict, base_url: str) -> dict:
from devplacepy import database
from devplacepy.database import get_setting
from devplacepy.services.containers.workspace import editor, naming, quota
from devplacepy.services.containers.workspace import naming, quota
if not instance.get("is_workspace"):
return {"DEVPLACE_WORKSPACE": ""}
@ -476,12 +476,9 @@ def workspace_env(instance: dict, base_url: str) -> dict:
gallery = get_setting("workspace_extensions_gallery", "").strip()
editor_port = int(instance.get("editor_port") or 0)
profile = editor.resolve(owner_uid, instance)
env = {
"DEVPLACE_WORKSPACE": "1",
"DEVPLACE_WORKSPACE_UID": instance.get("uid") or "",
"DEVPLACE_CONTAINER_BOOT": instance.get("boot_marker") or "",
**editor.env_for(profile),
"DEVPLACE_WORKSPACE_URL": workspace_url,
"DEVPLACE_WORKSPACE_OWNER": owner_name,
"DEVPLACE_WORKSPACE_OWNER_UID": owner_uid,
@ -549,28 +546,27 @@ def ensure_editor_password(instance: dict) -> str:
return password
def stamp_boot_marker(instance: dict) -> str:
from devplacepy.utils import generate_uid
marker = generate_uid()
store.update_instance(instance["uid"], {"boot_marker": marker})
instance["boot_marker"] = marker
return marker
def editor_command(instance: dict) -> list[str]:
port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT)
return [
"code-server",
"--bind-addr",
f"0.0.0.0:{port}",
"--auth",
"password",
"--disable-telemetry",
"--disable-update-check",
"--user-data-dir",
f"{WORKSPACE_STATE_MOUNT}/data",
"--extensions-dir",
f"{WORKSPACE_STATE_MOUNT}/extensions",
WORKSPACE_MOUNT,
]
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
from devplacepy.services.containers.workspace import editor
profile = None
cpu_limit = instance.get("cpu_limit", "")
mem_limit = instance.get("mem_limit", "")
if instance.get("is_workspace"):
ensure_editor_password(instance)
stamp_boot_marker(instance)
profile = editor.resolve(instance.get("workspace_owner_uid", ""), instance)
editor.seed_state(instance, profile)
cpu_limit = profile.cpu_limit() or cpu_limit
mem_limit = profile.mem_limit() or mem_limit
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
ports = [
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
@ -588,8 +584,8 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
)
language = (instance.get("boot_language") or "none").strip().lower()
boot = (instance.get("boot_command") or "").strip()
if profile and int(instance.get("editor_port") or 0):
command = editor.wrap_with_env_export(editor.argv(instance, profile))
if instance.get("is_workspace") and int(instance.get("editor_port") or 0):
command = editor_command(instance)
elif language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
@ -605,8 +601,8 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
PROJECT_LABEL: instance["project_uid"],
},
env=env,
cpu_limit=cpu_limit,
mem_limit=mem_limit,
cpu_limit=instance.get("cpu_limit", ""),
mem_limit=instance.get("mem_limit", ""),
ports=ports,
mounts=mounts,
restart_policy=instance.get("restart_policy", "never"),
@ -732,30 +728,18 @@ def _host_port_for(port_maps: list, container_port: int) -> int:
return 0
def reachable_target(instance: dict, container_port: int, port_maps: list) -> tuple:
container_ip = (instance.get("container_ip") or "").strip()
if container_ip and container_port > 0:
return container_ip, container_port
host_port = _host_port_for(port_maps, container_port)
if not host_port:
return None, None
gateway = (instance.get("container_gateway") or "").strip()
return config.CONTAINER_PROXY_HOST or gateway or "127.0.0.1", host_port
def proxy_target(instance: dict) -> tuple:
port_maps = json.loads(instance.get("ports_json") or "[]")
container_port = _ingress_container_port(instance, port_maps)
if not container_port:
return None, None
return reachable_target(instance, container_port, port_maps)
def tunnel_target(instance: dict, container_port: int) -> tuple:
if container_port <= 0:
host_port = _host_port_for(port_maps, container_port)
if config.CONTAINER_PROXY_HOST:
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
if not host_port:
return None, None
port_maps = json.loads(instance.get("ports_json") or "[]")
return reachable_target(instance, container_port, port_maps)
gateway = (instance.get("container_gateway") or "").strip()
return (gateway or "127.0.0.1", host_port)
def instance_runtime(instance: dict) -> dict:

View File

@ -52,7 +52,7 @@ def _resolve_devplace_url() -> str:
base = os.environ.get("DEVPLACE_BASE_URL", "").strip().rstrip("/")
if base:
return base
return os.environ.get("DEVPLACE_URL", "").strip().rstrip("/")
return os.environ.get("DEVPLACE_URL", "https://devplace.net").strip().rstrip("/")
def _resolve_llm_endpoint() -> str:
@ -63,7 +63,11 @@ def _resolve_llm_endpoint() -> str:
DEVPLACE_URL = _resolve_devplace_url()
DEVPLACE_API_KEY = os.environ.get("DEVPLACE_API_KEY", "").strip()
DEVPLACE_API_KEY = (
os.environ.get("DEVPLACE_API_KEY")
or os.environ.get("DEVPLACE_API_KEY")
or "019ea58c-fae0-7112-8025-e629a54104a4"
)
MENTION_POLL_SECONDS = int(os.environ.get("MENTION_POLL_SECONDS", "30"))
DM_POLL_SECONDS = int(os.environ.get("DM_POLL_SECONDS", "10"))
BOT_USERNAME = os.environ.get("BOT_USERNAME", "")
@ -2808,17 +2812,8 @@ async def _agent_answer_for_devplace(
async def devplace_bot_loop() -> None:
"""Run the DevPlace bot: poll mentions and DMs forever."""
logger.info("Botje starting — DevPlace bot with full X-agent capabilities")
if not DEVPLACE_URL or not DEVPLACE_API_KEY:
logger.error(
"DEVPLACE_BASE_URL and DEVPLACE_API_KEY are not set. Both are injected "
"automatically inside a DevPlace-managed container; set them manually "
"only when running botje.py outside one.",
)
return
logger.info("DevPlace URL: %s", DEVPLACE_URL)
logger.info("API key: %s...", DEVPLACE_API_KEY[:12])
logger.info("API key: %s...", DEVPLACE_API_KEY[:12] if DEVPLACE_API_KEY else "(none)")
dp = DevPlace(DEVPLACE_URL, DEVPLACE_API_KEY)

View File

@ -1,79 +0,0 @@
/* retoor <retoor@molodetz.nl> */
/* devplace-login-theme */
/* DevPlace palette, restated as literals because code-server serves this file
outside the application and cannot read static/css/variables.css.
--bg-primary #080413 --bg-card #1a1030 --bg-input #140b26
--accent #ff6b35 --accent-hover #ff7d4d
--text-primary #f4eefb --text-secondary #b8a8d0
--border rgba(255,255,255,0.08) --radius 12px */
body {
background: linear-gradient(135deg, #080413 0%, #160a28 50%, #080413 100%);
color: #f4eefb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.center-container {
background: transparent;
}
.card-box {
background: #1a1030;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.header .main {
color: #f4eefb;
font-weight: 700;
}
.header .sub,
.content .links,
.content .links a {
color: #b8a8d0;
}
.content .links a:hover {
color: #ff6b35;
}
.field input,
.password-input {
background: #140b26;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: #f4eefb;
}
.field input::placeholder {
color: #7a6a90;
}
.field input:focus,
.password-input:focus {
border-color: #ff6b35;
outline: none;
}
.field .submit,
.submit {
background: #ff6b35;
border: none;
border-radius: 12px;
color: #ffffff;
font-weight: 600;
}
.field .submit:hover,
.submit:hover {
background: #ff7d4d;
}
.error-display,
.error {
background: rgba(229, 57, 53, 0.16);
border-radius: 12px;
color: #ffb4b2;
}

View File

@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<style>
.plate { fill: #080413; }
.mark { stroke: #ff6b35; }
@media (prefers-color-scheme: light) {
.plate { fill: #fdfbff; }
.mark { stroke: #d1481a; }
}
</style>
<rect class="plate" width="64" height="64" rx="14"/>
<path class="mark" d="M18 16h13c11 0 18 6.5 18 16s-7 16-18 16H18z" fill="none" stroke-width="7" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#080413"/>
<path d="M18 16h13c11 0 18 6.5 18 16s-7 16-18 16H18z" fill="none" stroke="#ff6b35" stroke-width="7" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -1,387 +0,0 @@
// retoor <retoor@molodetz.nl>
const fs = require("fs");
const http = require("http");
const https = require("https");
const vscode = require("vscode");
const AGENT_PATH = "/usr/bin/dpc";
const AGENT_TERMINAL = "DevPlace Code";
const SHELL_TERMINAL = "pravda@workspace";
const BOOT_KEY = "devplace.bootMarker";
const PANEL_STEPS = { short: 0, normal: 2, tall: 5, maximized: 0 };
const PUBLISH_TIMEOUT_MS = 20000;
class Profile {
constructor() {
this.data = Object.assign(
{
theme: "devplace-dark",
layout: "standard",
panel_preset: "tall",
boot_agent: "dpc",
boot_shell: true,
trust_all: true,
},
this.fromFile(),
this.fromEnv(),
);
}
fromFile() {
const path = process.env.DEVPLACE_EDITOR_PROFILE;
if (!path) return {};
try {
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
return parsed && parsed.editor ? parsed.editor : {};
} catch (error) {
return {};
}
}
fromEnv() {
const values = {};
if (process.env.DEVPLACE_EDITOR_PANEL_PRESET) {
values.panel_preset = process.env.DEVPLACE_EDITOR_PANEL_PRESET;
}
if (process.env.DEVPLACE_EDITOR_BOOT_AGENT) {
values.boot_agent = process.env.DEVPLACE_EDITOR_BOOT_AGENT;
}
if (process.env.DEVPLACE_EDITOR_BOOT_SHELL) {
values.boot_shell = process.env.DEVPLACE_EDITOR_BOOT_SHELL === "1";
}
return values;
}
get bootMarker() {
return process.env.DEVPLACE_CONTAINER_BOOT || `host-${process.pid}`;
}
get wantsAgent() {
return this.data.boot_agent === "dpc" && fs.existsSync(AGENT_PATH);
}
get wantsShell() {
return Boolean(this.data.boot_shell);
}
get panelPreset() {
return this.data.panel_preset || "tall";
}
}
class BootTerminals {
constructor(profile, memento) {
this.profile = profile;
this.memento = memento;
}
alreadyBooted() {
return this.memento.get(BOOT_KEY) === this.profile.bootMarker;
}
async open() {
if (this.alreadyBooted()) return false;
await this.memento.update(BOOT_KEY, this.profile.bootMarker);
const shell = this.profile.wantsShell ? this.createShell() : null;
const agent = this.profile.wantsAgent ? this.createAgent() : null;
if (agent) agent.show(true);
else if (shell) shell.show(true);
return Boolean(agent || shell);
}
createAgent() {
return vscode.window.createTerminal({
name: AGENT_TERMINAL,
shellPath: "/bin/bash",
shellArgs: ["-l", "-c", `exec ${AGENT_PATH}`],
iconPath: new vscode.ThemeIcon("rocket"),
isTransient: false,
});
}
createShell() {
return vscode.window.createTerminal({
name: SHELL_TERMINAL,
shellPath: "/bin/bash",
shellArgs: ["-l"],
iconPath: new vscode.ThemeIcon("terminal-bash"),
isTransient: false,
});
}
}
class Layout {
constructor(profile) {
this.profile = profile;
}
async apply(panelIsOpen) {
const preset = this.profile.panelPreset;
if (!panelIsOpen) return;
if (preset === "maximized") {
await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel");
return;
}
const steps = PANEL_STEPS[preset] === undefined ? 5 : PANEL_STEPS[preset];
for (let index = 0; index < steps; index += 1) {
await vscode.commands.executeCommand("workbench.action.increaseViewSize");
}
}
}
class Presence {
constructor(context) {
this.context = context;
this.item = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
100,
);
}
get projectTitle() {
return process.env.DEVPLACE_PROJECT_TITLE || "DevPlace";
}
register() {
this.item.text = `$(rocket) ${this.projectTitle}`;
this.item.tooltip = this.tooltip();
this.item.command = "devplace.openProject";
this.item.show();
this.context.subscriptions.push(this.item);
this.registerCommands();
}
tooltip() {
const owner = process.env.DEVPLACE_WORKSPACE_OWNER || "";
const name = process.env.DEVPLACE_TUNNEL_NAME || "";
return `DevPlace workspace ${name}${owner ? ` for ${owner}` : ""}`;
}
registerCommands() {
const commands = {
"devplace.runAgent": () => this.runAgent(),
"devplace.openProject": () => this.open(process.env.DEVPLACE_PROJECT_URL),
"devplace.openWorkspacePage": () =>
this.open(process.env.DEVPLACE_WORKSPACE_URL),
"devplace.openDocs": () => this.openDocs(),
"devplace.showTunnels": () => this.showTunnels(),
};
for (const [name, handler] of Object.entries(commands)) {
this.context.subscriptions.push(
vscode.commands.registerCommand(name, handler),
);
}
}
runAgent() {
const terminal = vscode.window.createTerminal({
name: AGENT_TERMINAL,
shellPath: AGENT_PATH,
iconPath: new vscode.ThemeIcon("rocket"),
});
terminal.show(true);
}
openDocs() {
const base = process.env.DEVPLACE_BASE_URL || "";
this.open(base ? `${base}/docs/workspace-editor.html` : "");
}
open(url) {
if (!url) {
vscode.window.showWarningMessage(
"DevPlace has not published a site URL for this workspace yet.",
);
return;
}
vscode.env.openExternal(vscode.Uri.parse(url));
}
async showTunnels() {
const path = process.env.DEVPLACE_TUNNEL_MANIFEST;
const rows = this.readManifest(path);
if (!rows.length) {
vscode.window.showInformationMessage(
"This workspace has no public tunnels yet.",
);
return;
}
const picked = await vscode.window.showQuickPick(
rows.map((row) => ({
label: row.label || row.hostname,
description: row.url,
detail: `port ${row.container_port} - ${row.status}`,
url: row.url,
})),
{ placeHolder: "Open a public tunnel" },
);
if (picked) this.open(picked.url);
}
readManifest(path) {
if (!path) return [];
try {
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
return Array.isArray(parsed.tunnels) ? parsed.tunnels : [];
} catch (error) {
return [];
}
}
}
class Tunnels {
constructor(output) {
this.output = output;
this.published = new Set();
this.base = (process.env.DEVPLACE_BASE_URL || "").replace(/\/+$/, "");
this.apiKey = process.env.DEVPLACE_API_KEY || "";
this.slug = process.env.DEVPLACE_PROJECT_SLUG || "";
this.editorPort = Number(process.env.DEVPLACE_EDITOR_PORT || 0);
}
get configured() {
return Boolean(this.base && this.apiKey && this.slug);
}
async watch(context) {
if (!this.configured) {
this.output.appendLine(
"tunnels: this workspace has no DevPlace credentials, so forwarded ports stay private",
);
return;
}
context.subscriptions.push(
vscode.workspace.onDidChangeTunnels(() =>
this.sync().catch((error) =>
this.output.appendLine(`tunnels: sync failed: ${error}`),
),
),
);
await this.sync();
}
async sync() {
const rows = (await vscode.workspace.tunnels) || [];
for (const row of rows) {
const port = Number((row.remoteAddress || {}).port || 0);
if (!port || port === this.editorPort) continue;
if (this.published.has(port)) continue;
await this.publish(port);
}
}
async publish(port) {
this.published.add(port);
let answer;
try {
answer = await this.post(port);
} catch (error) {
this.published.delete(port);
this.output.appendLine(`tunnels: port ${port} could not be published: ${error}`);
return;
}
if (answer.status >= 400) {
this.published.delete(port);
this.output.appendLine(
`tunnels: DevPlace refused port ${port} (${answer.status}): ${answer.body.slice(0, 300)}`,
);
vscode.window.showWarningMessage(
`DevPlace could not publish port ${port}: ${this.refusal(answer.body)}`,
);
return;
}
const url = this.publishedUrl(answer.body, port);
this.output.appendLine(`tunnels: port ${port} is published at ${url}`);
vscode.window.showInformationMessage(
`Port ${port} is published at ${url}. It serves HTTPS once its certificate is issued.`,
);
}
post(port) {
const url = new URL(
`${this.base}/projects/${encodeURIComponent(this.slug)}/workspace/tunnels`,
);
const body = new URLSearchParams({
label: `Port ${port}`,
container_port: String(port),
}).toString();
const client = url.protocol === "https:" ? https : http;
const options = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
Accept: "application/json",
"X-API-KEY": this.apiKey,
},
timeout: PUBLISH_TIMEOUT_MS,
};
return new Promise((resolve, reject) => {
const call = client.request(url, options, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () =>
resolve({
status: response.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
}),
);
});
call.on("timeout", () => call.destroy(new Error("request timed out")));
call.on("error", reject);
call.end(body);
});
}
refusal(body) {
try {
const parsed = JSON.parse(body);
return (parsed.error && parsed.error.message) || "the request was refused";
} catch (error) {
return "the request was refused";
}
}
publishedUrl(body, port) {
try {
const parsed = JSON.parse(body);
const hostname = parsed.data && parsed.data.hostname;
if (hostname) return `https://${hostname}`;
} catch (error) {
/* fall through to the pattern below */
}
const pattern =
process.env.DEVPLACE_TUNNEL_PORT_PATTERN || "{port}-{name}.{domain}";
return `https://${pattern
.replace("{port}", String(port))
.replace("{name}", process.env.DEVPLACE_TUNNEL_NAME || "")
.replace("{domain}", process.env.DEVPLACE_TUNNEL_DOMAIN || "")}`;
}
}
async function stage(output, name, run) {
try {
return await run();
} catch (error) {
output.appendLine(`${name} failed: ${error}`);
return undefined;
}
}
async function activate(context) {
const output = vscode.window.createOutputChannel("DevPlace");
context.subscriptions.push(output);
const profile = new Profile();
await stage(output, "presence", () => new Presence(context).register());
const opened = await stage(output, "terminals", () =>
new BootTerminals(profile, context.workspaceState).open(),
);
await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened)));
await stage(output, "tunnels", () => new Tunnels(output).watch(context));
}
function deactivate() {}
module.exports = { activate, deactivate };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,159 +0,0 @@
{
"name": "devplace-workspace",
"displayName": "DevPlace",
"description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.",
"version": "1.0.0",
"publisher": "devplace",
"author": "retoor <retoor@molodetz.nl>",
"license": "SEE LICENSE IN https://pravda.education/docs/terms.html",
"engines": {
"vscode": "^1.80.0"
},
"categories": [
"Other",
"Themes"
],
"icon": "media/devplace-icon.png",
"main": "./extension.js",
"activationEvents": [
"onStartupFinished"
],
"enabledApiProposals": [
"tunnels"
],
"capabilities": {
"untrustedWorkspaces": {
"supported": true
},
"virtualWorkspaces": true
},
"contributes": {
"configurationDefaults": {
"security.workspace.trust.enabled": false,
"security.workspace.trust.startupPrompt": "never",
"security.workspace.trust.banner": "never",
"security.workspace.trust.emptyWindow": true,
"security.workspace.trust.untrustedFiles": "open",
"task.allowAutomaticTasks": "on",
"telemetry.telemetryLevel": "off",
"update.mode": "none",
"workbench.tips.enabled": false,
"extensions.autoCheckUpdates": false,
"workbench.colorTheme": "DevPlace Dark",
"terminal.integrated.defaultProfile.linux": "bash",
"workbench.startupEditor": "none",
"chat.disableAIFeatures": true,
"chat.commandCenter.enabled": false,
"workbench.secondarySideBar.defaultVisibility": "hidden"
},
"themes": [
{
"label": "DevPlace Dark",
"uiTheme": "vs-dark",
"path": "./themes/devplace-dark.json"
},
{
"label": "DevPlace Light",
"uiTheme": "vs",
"path": "./themes/devplace-light.json"
}
],
"commands": [
{
"command": "devplace.runAgent",
"title": "Start DevPlace Code (dpc)",
"category": "DevPlace"
},
{
"command": "devplace.openProject",
"title": "Open project on DevPlace",
"category": "DevPlace"
},
{
"command": "devplace.openWorkspacePage",
"title": "Open workspace settings",
"category": "DevPlace"
},
{
"command": "devplace.showTunnels",
"title": "Show public tunnels",
"category": "DevPlace"
},
{
"command": "devplace.openDocs",
"title": "Open the DevPlace editor guide",
"category": "DevPlace"
}
],
"viewsWelcome": [
{
"view": "workbench.explorer.emptyView",
"contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)"
}
],
"walkthroughs": [
{
"id": "devplace.getStarted",
"title": "Get started on DevPlace",
"description": "Your workspace, your agent, and how to publish what you build.",
"steps": [
{
"id": "agent",
"title": "Meet dpc, your coding agent",
"description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)",
"media": {
"markdown": "walkthrough/agent.md"
},
"completionEvents": [
"onCommand:devplace.runAgent"
]
},
{
"id": "files",
"title": "Your files are your project",
"description": "Everything under /app syncs back to your DevPlace project.",
"media": {
"markdown": "walkthrough/files.md"
},
"completionEvents": [
"onSettingChanged:files.autoSave"
]
},
{
"id": "tunnels",
"title": "Publish a port",
"description": "Serve on a high port and publish it on a public HTTPS address.\n[Show my tunnels](command:devplace.showTunnels)",
"media": {
"markdown": "walkthrough/tunnels.md"
},
"completionEvents": [
"onCommand:devplace.showTunnels"
]
},
{
"id": "toolchains",
"title": "Install anything",
"description": "sudo and apt install work here, with Python, Rust, Nim and Swift preinstalled.",
"media": {
"markdown": "walkthrough/toolchains.md"
},
"completionEvents": [
"onCommand:workbench.action.terminal.sendSequence"
]
},
{
"id": "limits",
"title": "Quotas and idle stop",
"description": "Your workspace has a size and stops when idle.\n[Open workspace settings](command:devplace.openWorkspacePage)",
"media": {
"markdown": "walkthrough/limits.md"
},
"completionEvents": [
"onCommand:devplace.openWorkspacePage"
]
}
]
}
]
}
}

View File

@ -1,223 +0,0 @@
{
"name": "DevPlace Dark",
"type": "dark",
"colors": {
"editor.background": "#080413",
"editor.foreground": "#f4eefb",
"editorLineNumber.foreground": "#7a6a90",
"editorLineNumber.activeForeground": "#ff6b35",
"editorCursor.foreground": "#ff6b35",
"editor.selectionBackground": "#ff6b352e",
"editor.selectionHighlightBackground": "#ff6b351f",
"editor.lineHighlightBackground": "#120821",
"editor.findMatchBackground": "#ff6b3559",
"editor.findMatchHighlightBackground": "#ff6b3530",
"editorIndentGuide.background1": "#ffffff14",
"editorIndentGuide.activeBackground1": "#ffffff24",
"editorWhitespace.foreground": "#ffffff14",
"editorRuler.foreground": "#ffffff14",
"editorWidget.background": "#1a1030",
"editorWidget.border": "#ffffff14",
"editorSuggestWidget.background": "#1a1030",
"editorSuggestWidget.selectedBackground": "#241640",
"editorHoverWidget.background": "#1a1030",
"editorGroup.border": "#ffffff14",
"editorGroupHeader.tabsBackground": "#120821",
"editorGroupHeader.noTabsBackground": "#120821",
"editorGutter.addedBackground": "#4caf50",
"editorGutter.modifiedBackground": "#ff9800",
"editorGutter.deletedBackground": "#e53935",
"editorError.foreground": "#e53935",
"editorWarning.foreground": "#ff9800",
"editorInfo.foreground": "#42a5f5",
"editorBracketMatch.background": "#ff6b3524",
"editorBracketMatch.border": "#ff6b35",
"foreground": "#f4eefb",
"descriptionForeground": "#b8a8d0",
"disabledForeground": "#7a6a90",
"errorForeground": "#e53935",
"focusBorder": "#ff6b35",
"selection.background": "#ff6b3559",
"widget.shadow": "#00000073",
"icon.foreground": "#b8a8d0",
"sash.hoverBorder": "#ff6b35",
"activityBar.background": "#120821",
"activityBar.foreground": "#f4eefb",
"activityBar.inactiveForeground": "#7a6a90",
"activityBar.border": "#ffffff14",
"activityBarBadge.background": "#ff6b35",
"activityBarBadge.foreground": "#ffffff",
"sideBar.background": "#120821",
"sideBar.foreground": "#b8a8d0",
"sideBar.border": "#ffffff14",
"sideBarTitle.foreground": "#f4eefb",
"sideBarSectionHeader.background": "#1a1030",
"sideBarSectionHeader.foreground": "#f4eefb",
"list.activeSelectionBackground": "#ff6b351f",
"list.activeSelectionForeground": "#f4eefb",
"list.inactiveSelectionBackground": "#241640",
"list.hoverBackground": "#241640",
"list.highlightForeground": "#ff6b35",
"list.errorForeground": "#e53935",
"list.warningForeground": "#ff9800",
"tree.indentGuidesStroke": "#ffffff14",
"statusBar.background": "#120821",
"statusBar.foreground": "#b8a8d0",
"statusBar.border": "#ffffff14",
"statusBar.noFolderBackground": "#120821",
"statusBar.debuggingBackground": "#ff6b35",
"statusBar.debuggingForeground": "#ffffff",
"statusBarItem.remoteBackground": "#ff6b35",
"statusBarItem.remoteForeground": "#ffffff",
"statusBarItem.hoverBackground": "#ffffff0d",
"titleBar.activeBackground": "#080413",
"titleBar.activeForeground": "#f4eefb",
"titleBar.inactiveBackground": "#080413",
"titleBar.inactiveForeground": "#7a6a90",
"titleBar.border": "#ffffff14",
"menu.background": "#1a1030",
"menu.foreground": "#f4eefb",
"menu.selectionBackground": "#241640",
"menubar.selectionBackground": "#241640",
"tab.activeBackground": "#080413",
"tab.activeForeground": "#f4eefb",
"tab.activeBorderTop": "#ff6b35",
"tab.inactiveBackground": "#120821",
"tab.inactiveForeground": "#7a6a90",
"tab.border": "#ffffff14",
"tab.hoverBackground": "#241640",
"panel.background": "#1a1030",
"panel.border": "#ffffff14",
"panelTitle.activeForeground": "#f4eefb",
"panelTitle.activeBorder": "#ff6b35",
"panelTitle.inactiveForeground": "#7a6a90",
"terminal.background": "#080413",
"terminal.foreground": "#f4eefb",
"terminal.selectionBackground": "#ff6b352e",
"terminalCursor.foreground": "#ff6b35",
"terminal.ansiBlack": "#120821",
"terminal.ansiRed": "#e53935",
"terminal.ansiGreen": "#4caf50",
"terminal.ansiYellow": "#ff9800",
"terminal.ansiBlue": "#42a5f5",
"terminal.ansiMagenta": "#ff4f8b",
"terminal.ansiCyan": "#00bcd4",
"terminal.ansiWhite": "#f4eefb",
"terminal.ansiBrightBlack": "#7a6a90",
"terminal.ansiBrightRed": "#ff5252",
"terminal.ansiBrightGreen": "#69d16d",
"terminal.ansiBrightYellow": "#ffab00",
"terminal.ansiBrightBlue": "#6fc0ff",
"terminal.ansiBrightMagenta": "#ff7dab",
"terminal.ansiBrightCyan": "#4dd8e8",
"terminal.ansiBrightWhite": "#ffffff",
"button.background": "#ff6b35",
"button.foreground": "#ffffff",
"button.hoverBackground": "#ff7d4d",
"button.secondaryBackground": "#241640",
"button.secondaryForeground": "#f4eefb",
"badge.background": "#ff6b35",
"badge.foreground": "#ffffff",
"progressBar.background": "#ff6b35",
"input.background": "#140b26",
"input.foreground": "#f4eefb",
"input.border": "#ffffff14",
"input.placeholderForeground": "#7a6a90",
"inputOption.activeBorder": "#ff6b35",
"inputValidation.errorBackground": "#e5393526",
"inputValidation.errorBorder": "#e53935",
"dropdown.background": "#140b26",
"dropdown.foreground": "#f4eefb",
"dropdown.border": "#ffffff14",
"checkbox.background": "#140b26",
"checkbox.border": "#ffffff14",
"scrollbarSlider.background": "#ffffff14",
"scrollbarSlider.hoverBackground": "#ffffff24",
"scrollbarSlider.activeBackground": "#ff6b3559",
"quickInput.background": "#1a1030",
"quickInputList.focusBackground": "#241640",
"notifications.background": "#1a1030",
"notifications.border": "#ffffff14",
"notificationCenterHeader.background": "#120821",
"peekView.border": "#ff6b35",
"peekViewEditor.background": "#120821",
"peekViewResult.background": "#1a1030",
"gitDecoration.modifiedResourceForeground": "#ff9800",
"gitDecoration.deletedResourceForeground": "#e53935",
"gitDecoration.untrackedResourceForeground": "#4caf50",
"gitDecoration.ignoredResourceForeground": "#7a6a90",
"gitDecoration.conflictingResourceForeground": "#ff4f8b",
"minimap.findMatchHighlight": "#ff6b35",
"welcomePage.background": "#080413",
"welcomePage.progress.foreground": "#ff6b35",
"welcomePage.tileBackground": "#1a1030",
"welcomePage.tileHoverBackground": "#241640",
"textLink.foreground": "#ff6b35",
"textLink.activeForeground": "#ff7d4d",
"textBlockQuote.background": "#120821",
"textCodeBlock.background": "#120821",
"textPreformat.foreground": "#ff4f8b"
},
"tokenColors": [
{
"scope": ["comment", "punctuation.definition.comment"],
"settings": { "foreground": "#7a6a90", "fontStyle": "italic" }
},
{
"scope": ["string", "string.quoted", "meta.embedded.assembly"],
"settings": { "foreground": "#4caf50" }
},
{
"scope": ["constant.numeric", "constant.language", "constant.character"],
"settings": { "foreground": "#ffab00" }
},
{
"scope": ["keyword", "keyword.control", "storage", "storage.type"],
"settings": { "foreground": "#ff4f8b" }
},
{
"scope": ["entity.name.function", "support.function", "meta.function-call"],
"settings": { "foreground": "#ff6b35" }
},
{
"scope": ["entity.name.type", "entity.name.class", "support.class", "support.type"],
"settings": { "foreground": "#00bcd4" }
},
{
"scope": ["variable", "variable.other", "meta.definition.variable"],
"settings": { "foreground": "#f4eefb" }
},
{
"scope": ["variable.parameter", "variable.other.property"],
"settings": { "foreground": "#b8a8d0" }
},
{
"scope": ["entity.name.tag", "punctuation.definition.tag"],
"settings": { "foreground": "#ff4f8b" }
},
{
"scope": ["entity.other.attribute-name"],
"settings": { "foreground": "#42a5f5" }
},
{
"scope": ["invalid", "invalid.illegal"],
"settings": { "foreground": "#e53935" }
},
{
"scope": ["markup.heading", "entity.name.section"],
"settings": { "foreground": "#ff6b35", "fontStyle": "bold" }
},
{
"scope": ["markup.bold"],
"settings": { "fontStyle": "bold" }
},
{
"scope": ["markup.italic"],
"settings": { "fontStyle": "italic" }
},
{
"scope": ["markup.inline.raw", "markup.fenced_code"],
"settings": { "foreground": "#00bcd4" }
}
]
}

View File

@ -1,223 +0,0 @@
{
"name": "DevPlace Light",
"type": "light",
"colors": {
"editor.background": "#fdfbff",
"editor.foreground": "#1b1230",
"editorLineNumber.foreground": "#8c7fa4",
"editorLineNumber.activeForeground": "#d1481a",
"editorCursor.foreground": "#d1481a",
"editor.selectionBackground": "#ff6b3529",
"editor.selectionHighlightBackground": "#ff6b3517",
"editor.lineHighlightBackground": "#f2edfa",
"editor.findMatchBackground": "#ff6b354d",
"editor.findMatchHighlightBackground": "#ff6b3526",
"editorIndentGuide.background1": "#1b123014",
"editorIndentGuide.activeBackground1": "#1b123029",
"editorWhitespace.foreground": "#1b123014",
"editorRuler.foreground": "#1b123014",
"editorWidget.background": "#ffffff",
"editorWidget.border": "#1b123014",
"editorSuggestWidget.background": "#ffffff",
"editorSuggestWidget.selectedBackground": "#f2edfa",
"editorHoverWidget.background": "#ffffff",
"editorGroup.border": "#1b123014",
"editorGroupHeader.tabsBackground": "#f4f0fb",
"editorGroupHeader.noTabsBackground": "#f4f0fb",
"editorGutter.addedBackground": "#2f8b33",
"editorGutter.modifiedBackground": "#c77700",
"editorGutter.deletedBackground": "#c62828",
"editorError.foreground": "#c62828",
"editorWarning.foreground": "#c77700",
"editorInfo.foreground": "#1976d2",
"editorBracketMatch.background": "#ff6b3524",
"editorBracketMatch.border": "#d1481a",
"foreground": "#1b1230",
"descriptionForeground": "#5c4f74",
"disabledForeground": "#8c7fa4",
"errorForeground": "#c62828",
"focusBorder": "#d1481a",
"selection.background": "#ff6b354d",
"widget.shadow": "#1b123024",
"icon.foreground": "#5c4f74",
"sash.hoverBorder": "#d1481a",
"activityBar.background": "#f4f0fb",
"activityBar.foreground": "#1b1230",
"activityBar.inactiveForeground": "#8c7fa4",
"activityBar.border": "#1b123014",
"activityBarBadge.background": "#d1481a",
"activityBarBadge.foreground": "#ffffff",
"sideBar.background": "#f4f0fb",
"sideBar.foreground": "#5c4f74",
"sideBar.border": "#1b123014",
"sideBarTitle.foreground": "#1b1230",
"sideBarSectionHeader.background": "#ece5f7",
"sideBarSectionHeader.foreground": "#1b1230",
"list.activeSelectionBackground": "#ff6b351f",
"list.activeSelectionForeground": "#1b1230",
"list.inactiveSelectionBackground": "#ece5f7",
"list.hoverBackground": "#ece5f7",
"list.highlightForeground": "#d1481a",
"list.errorForeground": "#c62828",
"list.warningForeground": "#c77700",
"tree.indentGuidesStroke": "#1b123014",
"statusBar.background": "#f4f0fb",
"statusBar.foreground": "#5c4f74",
"statusBar.border": "#1b123014",
"statusBar.noFolderBackground": "#f4f0fb",
"statusBar.debuggingBackground": "#d1481a",
"statusBar.debuggingForeground": "#ffffff",
"statusBarItem.remoteBackground": "#d1481a",
"statusBarItem.remoteForeground": "#ffffff",
"statusBarItem.hoverBackground": "#1b12300d",
"titleBar.activeBackground": "#fdfbff",
"titleBar.activeForeground": "#1b1230",
"titleBar.inactiveBackground": "#fdfbff",
"titleBar.inactiveForeground": "#8c7fa4",
"titleBar.border": "#1b123014",
"menu.background": "#ffffff",
"menu.foreground": "#1b1230",
"menu.selectionBackground": "#ece5f7",
"menubar.selectionBackground": "#ece5f7",
"tab.activeBackground": "#fdfbff",
"tab.activeForeground": "#1b1230",
"tab.activeBorderTop": "#d1481a",
"tab.inactiveBackground": "#f4f0fb",
"tab.inactiveForeground": "#8c7fa4",
"tab.border": "#1b123014",
"tab.hoverBackground": "#ece5f7",
"panel.background": "#ffffff",
"panel.border": "#1b123014",
"panelTitle.activeForeground": "#1b1230",
"panelTitle.activeBorder": "#d1481a",
"panelTitle.inactiveForeground": "#8c7fa4",
"terminal.background": "#fdfbff",
"terminal.foreground": "#1b1230",
"terminal.selectionBackground": "#ff6b3529",
"terminalCursor.foreground": "#d1481a",
"terminal.ansiBlack": "#1b1230",
"terminal.ansiRed": "#c62828",
"terminal.ansiGreen": "#2f8b33",
"terminal.ansiYellow": "#c77700",
"terminal.ansiBlue": "#1976d2",
"terminal.ansiMagenta": "#c2185b",
"terminal.ansiCyan": "#00838f",
"terminal.ansiWhite": "#f4f0fb",
"terminal.ansiBrightBlack": "#5c4f74",
"terminal.ansiBrightRed": "#e53935",
"terminal.ansiBrightGreen": "#4caf50",
"terminal.ansiBrightYellow": "#ff9800",
"terminal.ansiBrightBlue": "#42a5f5",
"terminal.ansiBrightMagenta": "#ff4f8b",
"terminal.ansiBrightCyan": "#00bcd4",
"terminal.ansiBrightWhite": "#ffffff",
"button.background": "#d1481a",
"button.foreground": "#ffffff",
"button.hoverBackground": "#e2551f",
"button.secondaryBackground": "#ece5f7",
"button.secondaryForeground": "#1b1230",
"badge.background": "#d1481a",
"badge.foreground": "#ffffff",
"progressBar.background": "#d1481a",
"input.background": "#ffffff",
"input.foreground": "#1b1230",
"input.border": "#1b123014",
"input.placeholderForeground": "#8c7fa4",
"inputOption.activeBorder": "#d1481a",
"inputValidation.errorBackground": "#c6282826",
"inputValidation.errorBorder": "#c62828",
"dropdown.background": "#ffffff",
"dropdown.foreground": "#1b1230",
"dropdown.border": "#1b123014",
"checkbox.background": "#ffffff",
"checkbox.border": "#1b123014",
"scrollbarSlider.background": "#1b123014",
"scrollbarSlider.hoverBackground": "#1b123029",
"scrollbarSlider.activeBackground": "#ff6b354d",
"quickInput.background": "#ffffff",
"quickInputList.focusBackground": "#ece5f7",
"notifications.background": "#ffffff",
"notifications.border": "#1b123014",
"notificationCenterHeader.background": "#f4f0fb",
"peekView.border": "#d1481a",
"peekViewEditor.background": "#f4f0fb",
"peekViewResult.background": "#ffffff",
"gitDecoration.modifiedResourceForeground": "#c77700",
"gitDecoration.deletedResourceForeground": "#c62828",
"gitDecoration.untrackedResourceForeground": "#2f8b33",
"gitDecoration.ignoredResourceForeground": "#8c7fa4",
"gitDecoration.conflictingResourceForeground": "#c2185b",
"minimap.findMatchHighlight": "#d1481a",
"welcomePage.background": "#fdfbff",
"welcomePage.progress.foreground": "#d1481a",
"welcomePage.tileBackground": "#ffffff",
"welcomePage.tileHoverBackground": "#f2edfa",
"textLink.foreground": "#d1481a",
"textLink.activeForeground": "#e2551f",
"textBlockQuote.background": "#f4f0fb",
"textCodeBlock.background": "#f4f0fb",
"textPreformat.foreground": "#c2185b"
},
"tokenColors": [
{
"scope": ["comment", "punctuation.definition.comment"],
"settings": { "foreground": "#8c7fa4", "fontStyle": "italic" }
},
{
"scope": ["string", "string.quoted", "meta.embedded.assembly"],
"settings": { "foreground": "#2f8b33" }
},
{
"scope": ["constant.numeric", "constant.language", "constant.character"],
"settings": { "foreground": "#c77700" }
},
{
"scope": ["keyword", "keyword.control", "storage", "storage.type"],
"settings": { "foreground": "#c2185b" }
},
{
"scope": ["entity.name.function", "support.function", "meta.function-call"],
"settings": { "foreground": "#d1481a" }
},
{
"scope": ["entity.name.type", "entity.name.class", "support.class", "support.type"],
"settings": { "foreground": "#00838f" }
},
{
"scope": ["variable", "variable.other", "meta.definition.variable"],
"settings": { "foreground": "#1b1230" }
},
{
"scope": ["variable.parameter", "variable.other.property"],
"settings": { "foreground": "#5c4f74" }
},
{
"scope": ["entity.name.tag", "punctuation.definition.tag"],
"settings": { "foreground": "#c2185b" }
},
{
"scope": ["entity.other.attribute-name"],
"settings": { "foreground": "#1976d2" }
},
{
"scope": ["invalid", "invalid.illegal"],
"settings": { "foreground": "#c62828" }
},
{
"scope": ["markup.heading", "entity.name.section"],
"settings": { "foreground": "#d1481a", "fontStyle": "bold" }
},
{
"scope": ["markup.bold"],
"settings": { "fontStyle": "bold" }
},
{
"scope": ["markup.italic"],
"settings": { "fontStyle": "italic" }
},
{
"scope": ["markup.inline.raw", "markup.fenced_code"],
"settings": { "foreground": "#00838f" }
}
]
}

View File

@ -1,12 +0,0 @@
# DevPlace Code
`dpc` is the coding agent that ships with every DevPlace workspace. It is already running in the
**DevPlace Code** terminal at the bottom of this window.
Ask it for what you want in plain language. It reads and writes the files in `/app`, runs commands,
and installs what it needs.
Every token it spends is metered against your own DevPlace account through the platform AI gateway.
Nothing leaves DevPlace.
Open another agent at any time from the terminal dropdown, or with **DevPlace: Start DevPlace Code**.

View File

@ -1,9 +0,0 @@
# Your files are your project
The folder open in this editor is `/app`, and it is your DevPlace project.
Files sync both ways on a short cycle: what you write here appears in the project file browser on
DevPlace, and what you change on DevPlace appears here. Whichever side is newer wins, and nothing is
ever deleted by the sync.
A read-only project exports to the workspace but never imports back.

View File

@ -1,10 +0,0 @@
# Quotas and idle stop
Your workspace has a size: CPU, memory and disk, all set by your DevPlace quota. Egress and the
number of tunnels are bounded too.
It stops on its own after a period with no activity, and is removed after a longer period of being
stopped. You are warned before each step, and a warning always says exactly what happens and when.
Everything on this page is on your DevPlace workspace page, together with the editor preferences
that control this window.

View File

@ -1,9 +0,0 @@
# Install anything
`sudo` and `apt install` work here with no extra setup, and nothing you install can break the
workspace for anyone else.
Preinstalled: Python with a broad library set and Playwright, Rust, Nim, Swift, plus `git`, `tmux`,
`vim`, `curl`, `htop` and the usual command line tools.
Run `apt update` once before your first `apt install`.

View File

@ -1,11 +0,0 @@
# Publish a port
Run your server on a high port, then publish that port from the workspace page. DevPlace gives it a
public HTTPS address of the form `<port>-<name>.tunnel.pravda.education`.
**A tunnel is public and unauthenticated.** Anyone with the link reaches whatever you are serving.
Your live addresses are always listed in `/app/.devplace/tunnels.json`, and
**DevPlace: Show public tunnels** opens any of them.
Ports below 1024 cannot bind in a workspace. Use a high port.

View File

@ -1,18 +0,0 @@
{
"nameShort": "DevPlace",
"nameLong": "DevPlace Workspace",
"applicationName": "devplace",
"dataFolderName": ".devplace-editor",
"reportIssueUrl": "https://pravda.education/issues",
"documentationUrl": "https://pravda.education/docs/workspace-editor.html",
"licenseUrl": "https://pravda.education/docs/terms.html",
"privacyStatementUrl": "https://pravda.education/docs/privacy.html",
"twitterUrl": "",
"requestFeatureUrl": "https://pravda.education/issues",
"licenseName": "DevPlace Terms of Service",
"extensionEnabledApiProposals": {
"devplace.devplace-workspace": [
"tunnels"
]
}
}

View File

@ -37,8 +37,6 @@ RESPONSE_HOP_HEADERS = {
"trailers",
"transfer-encoding",
"upgrade",
"date",
"server",
}
WS_HANDSHAKE_HEADERS = {

View File

@ -1,5 +1,5 @@
# retoor <retoor@molodetz.nl>
from . import editor, flags, naming, provision, quota, tunnels
from . import flags, naming, provision, quota, tunnels
__all__ = ["editor", "flags", "naming", "provision", "quota", "tunnels"]
__all__ = ["flags", "naming", "provision", "quota", "tunnels"]

View File

@ -1,480 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
import shlex
from dataclasses import asdict, dataclass
from pathlib import Path
from devplacepy import config
from devplacepy.database import get_int_setting, get_setting, get_table
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import (
WORKSPACE_MOUNT,
WORKSPACE_STATE_MOUNT,
)
from . import quota
logger = logging.getLogger(__name__)
PREFS_TABLE = "workspace_editor_prefs"
APP_NAME = "DevPlace"
WELCOME_TEXT = "Sign in to your DevPlace workspace"
EDITOR_DEFAULT_PORT = 8443
PROFILE_FILE = "devplace-editor.json"
MANAGED_FILE = ".devplace-managed.json"
INHERIT_TEXT = ""
INHERIT_INT = 0
INHERIT_ZOOM = -99
INHERIT_FLAG = -1
THEME_CHOICES = ("devplace-dark", "devplace-light", "system")
LAYOUT_CHOICES = ("standard", "terminal-focus", "zen")
PANEL_CHOICES = ("short", "normal", "tall", "maximized")
WINDOW_CHOICES = ("tab", "window", "fullscreen")
AGENT_CHOICES = ("dpc", "none")
CHOICES = {
"theme": THEME_CHOICES,
"layout": LAYOUT_CHOICES,
"panel_preset": PANEL_CHOICES,
"window_mode": WINDOW_CHOICES,
"boot_agent": AGENT_CHOICES,
}
SENTINELS = {
"zoom_level": INHERIT_ZOOM,
"boot_shell": INHERIT_FLAG,
}
THEME_LABELS = {
"devplace-dark": "DevPlace Dark",
"devplace-light": "DevPlace Light",
}
LAYOUTS = {
"standard": {
"workbench.activityBar.location": "default",
"workbench.sideBar.location": "left",
"workbench.panel.defaultLocation": "bottom",
"editor.minimap.enabled": True,
"breadcrumbs.enabled": True,
},
"terminal-focus": {
"workbench.activityBar.location": "top",
"workbench.sideBar.location": "left",
"workbench.panel.defaultLocation": "bottom",
"editor.minimap.enabled": False,
"breadcrumbs.enabled": True,
},
"zen": {
"workbench.activityBar.location": "hidden",
"workbench.sideBar.location": "left",
"workbench.panel.defaultLocation": "bottom",
"editor.minimap.enabled": False,
"breadcrumbs.enabled": False,
},
}
FOREIGN_AI_SETTINGS = {
"chat.disableAIFeatures": True,
"chat.commandCenter.enabled": False,
"workbench.secondarySideBar.defaultVisibility": "hidden",
}
TRUST_SETTINGS = {
"security.workspace.trust.enabled": False,
"security.workspace.trust.startupPrompt": "never",
"security.workspace.trust.banner": "never",
"security.workspace.trust.emptyWindow": True,
"security.workspace.trust.untrustedFiles": "open",
"task.allowAutomaticTasks": "on",
}
BOUNDS = {
"font_size": (8, 48),
"terminal_font_size": (8, 48),
"zoom_level": (-5, 5),
"window_width": (640, 7680),
"window_height": (480, 4320),
}
SETTING_KEYS = {
"trust_all": "workspace_editor_trust_all",
"theme": "workspace_editor_theme",
"font_size": "workspace_editor_font_size",
"terminal_font_size": "workspace_editor_terminal_font_size",
"zoom_level": "workspace_editor_zoom_level",
"layout": "workspace_editor_layout",
"panel_preset": "workspace_editor_panel_preset",
"boot_agent": "workspace_editor_boot_agent",
"boot_shell": "workspace_editor_boot_shell",
"window_mode": "workspace_editor_window_mode",
"window_width": "workspace_editor_window_width",
"window_height": "workspace_editor_window_height",
}
DEFAULTS = {
"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,
}
PREF_COLUMNS = (
"font_size",
"terminal_font_size",
"zoom_level",
"theme",
"layout",
"panel_preset",
"window_mode",
"window_width",
"window_height",
"boot_agent",
"boot_shell",
)
OPTIONAL_FLAGS = (
"--app-name",
"--welcome-text",
"--disable-getting-started-override",
"--disable-workspace-trust",
)
SOURCE_SITE = "site"
SOURCE_USER = "user"
@dataclass(frozen=True)
class EditorProfile:
trust_all: bool
theme: str
font_size: int
terminal_font_size: int
zoom_level: int
layout: str
panel_preset: str
boot_agent: str
boot_shell: bool
window_mode: str
window_width: int
window_height: int
cpu_millicores: int
memory_mb: int
disk_quota_mb: int
def as_dict(self) -> dict:
return asdict(self)
def cpu_cores(self) -> float:
return round(self.cpu_millicores / 1000, 3)
def cpu_limit(self) -> str:
return quota.format_cpu(self.cpu_millicores)
def mem_limit(self) -> str:
return quota.format_memory(self.memory_mb)
def _clamp(key: str, value: int) -> int:
bounds = BOUNDS.get(key)
if not bounds:
return value
low, high = bounds
return max(low, min(high, value))
def _choice(value: str, choices: tuple[str, ...], fallback: str) -> str:
cleaned = (value or "").strip().lower()
return cleaned if cleaned in choices else fallback
def prefs_for(owner_uid: str) -> dict | None:
if not owner_uid:
return None
return get_table(PREFS_TABLE).find_one(
owner_kind="user", owner_id=owner_uid, deleted_at=None
)
def _inherits(key: str, row: dict | None) -> bool:
if not row:
return True
value = row.get(key)
if value is None:
return True
if key in SENTINELS:
return int(value) == SENTINELS[key]
if isinstance(DEFAULTS[key], str):
return not str(value).strip()
return int(value) == INHERIT_INT
def _site_value(key: str):
default = DEFAULTS[key]
setting = SETTING_KEYS[key]
if isinstance(default, bool):
return get_setting(setting, "1" if default else "0") == "1"
if isinstance(default, int):
return get_int_setting(setting, default)
return get_setting(setting, default)
def _normalize(key: str, value):
default = DEFAULTS[key]
if key in CHOICES:
return _choice(value, CHOICES[key], default)
if isinstance(default, bool):
if isinstance(value, bool):
return value
try:
return bool(int(value))
except (TypeError, ValueError):
return bool(value)
if isinstance(default, int):
try:
return _clamp(key, int(value))
except (TypeError, ValueError):
return default
return str(value)
def source_map(owner_uid: str = "") -> dict[str, str]:
row = prefs_for(owner_uid)
sources = {key: SOURCE_SITE for key in SETTING_KEYS}
for key in PREF_COLUMNS:
if not _inherits(key, row):
sources[key] = SOURCE_USER
return sources
def resolve(owner_uid: str = "", instance: dict | None = None) -> EditorProfile:
row = prefs_for(owner_uid)
values = {}
for key in SETTING_KEYS:
value = _site_value(key)
if key in PREF_COLUMNS and not _inherits(key, row):
value = row.get(key)
values[key] = _normalize(key, value)
limits = quota.resolve(owner_uid, instance)
return EditorProfile(
cpu_millicores=limits.cpu_millicores,
memory_mb=limits.memory_mb,
disk_quota_mb=limits.disk_quota_mb,
**values,
)
def settings_for(profile: EditorProfile) -> dict:
settings = {
"editor.fontSize": profile.font_size,
"terminal.integrated.fontSize": profile.terminal_font_size,
"window.zoomLevel": profile.zoom_level,
"telemetry.telemetryLevel": "off",
"update.mode": "none",
"workbench.tips.enabled": False,
"workbench.startupEditor": "none",
"extensions.autoCheckUpdates": False,
**FOREIGN_AI_SETTINGS,
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {"path": "/bin/bash", "args": ["-l"], "icon": "terminal-bash"},
"DevPlace Code": {"path": "/usr/bin/dpc", "icon": "rocket"},
},
}
settings.update(LAYOUTS[profile.layout])
if profile.theme in THEME_LABELS:
settings["workbench.colorTheme"] = THEME_LABELS[profile.theme]
if profile.trust_all:
settings.update(TRUST_SETTINGS)
return settings
def merge_managed(current: dict, managed: dict, desired: dict) -> tuple[dict, dict]:
merged = dict(current)
for key, value in desired.items():
if key not in merged or merged[key] == managed.get(key):
merged[key] = value
return merged, dict(desired)
def _read_json(path: Path) -> dict:
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def state_dir(instance: dict) -> Path:
return config.WORKSPACE_STATE_DIR / instance["uid"]
def profile_payload(instance: dict, profile: EditorProfile) -> dict:
return {
"app_name": APP_NAME,
"workspace_uid": instance.get("uid", ""),
"boot_marker": instance.get("boot_marker", ""),
"editor": profile.as_dict(),
}
def seed_state(instance: dict, profile: EditorProfile) -> bool:
root = state_dir(instance)
user_dir = root / "data" / "User"
try:
user_dir.mkdir(parents=True, exist_ok=True)
settings_path = user_dir / "settings.json"
managed_path = user_dir / MANAGED_FILE
settings, managed = merge_managed(
_read_json(settings_path),
_read_json(managed_path),
settings_for(profile),
)
settings_path.write_text(json.dumps(settings, indent=2, sort_keys=True))
managed_path.write_text(json.dumps(managed, indent=2, sort_keys=True))
(root / PROFILE_FILE).write_text(
json.dumps(profile_payload(instance, profile), indent=2, sort_keys=True)
)
except OSError as error:
logger.warning("workspace editor seed failed for %s: %s", instance.get("uid"), error)
return False
return True
def argv(instance: dict, profile: EditorProfile) -> list[str]:
port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT)
command = [
"code-server",
"--bind-addr",
f"0.0.0.0:{port}",
"--auth",
"password",
"--app-name",
APP_NAME,
"--welcome-text",
WELCOME_TEXT,
"--disable-telemetry",
"--disable-update-check",
"--disable-getting-started-override",
]
if profile.trust_all:
command.append("--disable-workspace-trust")
command += [
"--user-data-dir",
f"{WORKSPACE_STATE_MOUNT}/data",
"--extensions-dir",
f"{WORKSPACE_STATE_MOUNT}/extensions",
WORKSPACE_MOUNT,
]
return command
ENV_EXPORT_FILE = "/etc/profile.d/devplace-env.sh"
_ENV_EXPORT_SCRIPT = (
"import os, pathlib, shlex\n"
f"path = pathlib.Path({ENV_EXPORT_FILE!r})\n"
"lines = ['export ' + k + '=' + shlex.quote(v) for k, v in sorted(os.environ.items()) if k.startswith('DEVPLACE_')]\n"
"path.write_text('\\n'.join(lines) + '\\n' if lines else '')\n"
)
def wrap_with_env_export(command: list[str]) -> list[str]:
export_step = f"umask 022; python3 -c {shlex.quote(_ENV_EXPORT_SCRIPT)} 2>/dev/null || true"
script = f"{export_step}; exec {shlex.join(command)}"
return ["/bin/sh", "-c", script]
def env_for(profile: EditorProfile) -> dict:
return {
"DEVPLACE_EDITOR_APP_NAME": APP_NAME,
"DEVPLACE_EDITOR_PROFILE": f"{WORKSPACE_STATE_MOUNT}/{PROFILE_FILE}",
"DEVPLACE_EDITOR_THEME": profile.theme,
"DEVPLACE_EDITOR_FONT_SIZE": str(profile.font_size),
"DEVPLACE_EDITOR_TERMINAL_FONT_SIZE": str(profile.terminal_font_size),
"DEVPLACE_EDITOR_ZOOM_LEVEL": str(profile.zoom_level),
"DEVPLACE_EDITOR_LAYOUT": profile.layout,
"DEVPLACE_EDITOR_PANEL_PRESET": profile.panel_preset,
"DEVPLACE_EDITOR_BOOT_AGENT": profile.boot_agent,
"DEVPLACE_EDITOR_BOOT_SHELL": "1" if profile.boot_shell else "0",
"DEVPLACE_EDITOR_TRUST_ALL": "1" if profile.trust_all else "0",
}
def booted_profile(instance: dict) -> dict:
return _read_json(state_dir(instance) / PROFILE_FILE).get("editor") or {}
def restart_required(instance: dict, profile: EditorProfile) -> bool:
booted = booted_profile(instance)
if not booted:
return False
return booted != profile.as_dict()
def save_prefs(owner_uid: str, payload: dict) -> dict:
from devplacepy.utils import generate_uid
table = get_table(PREFS_TABLE)
row = prefs_for(owner_uid)
values = {key: payload[key] for key in PREF_COLUMNS if key in payload}
stamp = store.now()
if row:
table.update({"uid": row["uid"], "updated_at": stamp, **values}, ["uid"])
return table.find_one(uid=row["uid"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"owner_kind": "user",
"owner_id": owner_uid,
"created_at": stamp,
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
**{key: _blank(key) for key in PREF_COLUMNS},
**values,
}
)
return table.find_one(uid=uid)
def reset_prefs(owner_uid: str, actor_uid: str) -> bool:
row = prefs_for(owner_uid)
if not row:
return False
get_table(PREFS_TABLE).update(
{"uid": row["uid"], "deleted_at": store.now(), "deleted_by": actor_uid}, ["uid"]
)
return True
def _blank(key: str):
if key in SENTINELS:
return SENTINELS[key]
if isinstance(DEFAULTS[key], str):
return INHERIT_TEXT
return INHERIT_INT
def view(owner_uid: str = "", instance: dict | None = None) -> dict:
profile = resolve(owner_uid, instance)
payload = profile.as_dict()
payload["cpu_cores"] = profile.cpu_cores()
payload["sources"] = source_map(owner_uid)
return payload

View File

@ -6,17 +6,14 @@ import asyncio
import json
from pathlib import Path
from devplacepy import config
from devplacepy.database import get_table
from devplacepy.services.containers import api, store
from . import editor, flags, naming, quota, tunnels
from . import flags, naming, quota, tunnels
MANIFEST_DIRECTORY = ".devplace"
MANIFEST_NAME = "tunnels.json"
CERT_UNCONFIGURED = (
"certificate issuance is not configured; an administrator must set the "
"molohttp base URL and credentials before this address serves HTTPS"
)
_pending_certificates: set[asyncio.Task] = set()
@ -73,31 +70,10 @@ async def ensure(project: dict, user: dict) -> dict:
return store.get_instance(instance["uid"])
def publish_tunnel(
instance: dict, label: str, container_port: int, owner_uid: str
) -> dict:
if container_port <= 0 or container_port > 65535:
raise WorkspaceError("container_port must be between 1 and 65535")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
raise WorkspaceError(f"tunnel limit reached ({limits.max_tunnels})")
row = tunnels.create(instance, label, container_port, owner_uid)
if not row:
raise WorkspaceError("could not create tunnel")
schedule_certificate(row)
write_manifest(instance)
return tunnels.get(row["uid"]) or row
def schedule_certificate(tunnel: dict | None) -> bool:
from . import certs
if not tunnel or tunnels.keeps_certificate(tunnel):
return False
if not certs.configured():
tunnels.update(tunnel["uid"], {"last_error": CERT_UNCONFIGURED})
if not tunnel or not certs.configured():
return False
try:
loop = asyncio.get_running_loop()
@ -175,8 +151,8 @@ def unsuspend(instance: dict) -> dict:
def editor_target(instance: dict) -> tuple[str, int]:
port = int(instance.get("editor_port") or api.EDITOR_DEFAULT_PORT)
return api.tunnel_target(instance, port)
host, port = api.proxy_target(instance)
return host, port
def manifest_payload(instance: dict) -> dict:
@ -217,9 +193,12 @@ def write_manifest(instance: dict) -> None:
return
def view(instance: dict) -> dict:
owner_uid = instance.get("workspace_owner_uid", "")
limits = quota.resolve(owner_uid, instance)
def state_dir(instance: dict) -> Path:
return config.WORKSPACE_STATE_DIR / instance["uid"]
def view(instance: dict, viewer_is_admin: bool = False) -> dict:
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
disk_used = int(instance.get("disk_bytes") or 0)
egress_used = int(instance.get("egress_bytes") or 0)
return {
@ -247,5 +226,4 @@ def view(instance: dict) -> dict:
"max_tunnels": limits.max_tunnels,
"tunnels": tunnels.list_for_instance(instance["uid"]),
"flags": flags.list_flags(instance_uid=instance["uid"]),
"editor": editor.view(owner_uid, instance),
}

View File

@ -18,8 +18,6 @@ DEFAULTS: dict[str, int] = {
"retention_days": 14,
"purge_after_days": 7,
"disk_warn_percent": 80,
"cpu_millicores": 2000,
"memory_mb": 2048,
}
SETTING_KEYS: dict[str, str] = {
@ -32,8 +30,6 @@ SETTING_KEYS: dict[str, str] = {
"retention_days": "workspace_retention_days",
"purge_after_days": "workspace_purge_after_days",
"disk_warn_percent": "workspace_disk_warn_percent",
"cpu_millicores": "workspace_cpu_millicores",
"memory_mb": "workspace_memory_mb",
}
RULE_COLUMNS = (
@ -43,28 +39,8 @@ RULE_COLUMNS = (
"egress_quota_mb",
"idle_stop_minutes",
"retention_days",
"cpu_millicores",
"memory_mb",
)
INSTANCE_OVERRIDE_COLUMNS = (
"cpu_millicores",
"memory_mb",
"disk_quota_mb",
)
def format_cpu(millicores: int) -> str:
if millicores <= 0:
return ""
return f"{millicores / 1000:.3f}".rstrip("0").rstrip(".")
def format_memory(megabytes: int) -> str:
if megabytes <= 0:
return ""
return f"{megabytes}m"
@dataclass(frozen=True)
class Limits:
@ -77,8 +53,6 @@ class Limits:
retention_days: int
purge_after_days: int
disk_warn_percent: int
cpu_millicores: int
memory_mb: int
def disk_quota_bytes(self) -> int:
return self.disk_quota_mb * 1024 * 1024
@ -86,12 +60,6 @@ class Limits:
def egress_quota_bytes(self) -> int:
return self.egress_quota_mb * 1024 * 1024
def cpu_limit(self) -> str:
return format_cpu(self.cpu_millicores)
def mem_limit(self) -> str:
return format_memory(self.memory_mb)
def _global_value(key: str) -> int:
return get_int_setting(SETTING_KEYS[key], DEFAULTS[key])
@ -113,7 +81,7 @@ def resolve(user_uid: str = "", instance: dict | None = None) -> Limits:
override = rule.get(key)
if override:
value = int(override)
if instance and key in INSTANCE_OVERRIDE_COLUMNS:
if instance:
instance_override = instance.get(f"workspace_{key}")
if instance_override:
value = int(instance_override)

View File

@ -57,10 +57,6 @@ def count_for_instance(instance_uid: str) -> int:
return _table().count(instance_uid=instance_uid, deleted_at=None)
def keeps_certificate(row: dict) -> bool:
return row.get("deleted_at") is None and row.get("status") == STATUS_ACTIVE
def create(
instance: dict, label: str, container_port: int, user_uid: str
) -> dict | None:
@ -72,7 +68,8 @@ def create(
revived = table.find_one(hostname=hostname)
stamp = _now()
if revived:
changes = {
table.update(
{
"uid": revived["uid"],
"instance_uid": instance["uid"],
"project_uid": instance.get("project_uid", ""),
@ -80,14 +77,14 @@ def create(
"label": label or f"port {container_port}",
"container_port": container_port,
"desired_state": "present",
"status": STATUS_PENDING,
"last_error": "",
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
}
if not keeps_certificate(revived):
changes["status"] = STATUS_PENDING
changes["last_error"] = ""
table.update(changes, ["uid"])
},
["uid"],
)
return table.find_one(uid=revived["uid"])
uid = generate_uid()
table.insert(

View File

@ -77,6 +77,9 @@ class WorkspaceService(BaseService):
config_fields = [
ConfigField("workspace_enabled", "Enabled", type="bool", default="0",
group="General", help="Master switch for the workspace feature."),
ConfigField("workspace_editor_version", "Editor version", default="",
group="General",
help="code-server release. Empty means the image default."),
ConfigField("workspace_extensions_gallery", "Extensions gallery", type="text",
default="", group="General",
help="EXTENSIONS_GALLERY JSON. Empty means the default."),
@ -109,61 +112,6 @@ class WorkspaceService(BaseService):
default="10240", minimum=0, group="Quotas"),
ConfigField("workspace_disk_warn_percent", "Disk warn percent", type="int",
default="80", minimum=1, maximum=100, group="Quotas"),
ConfigField("workspace_cpu_millicores", "CPU per workspace (millicores)",
type="int", default="2000", minimum=250, group="Quotas",
help="1000 millicores is one core. Applied as the docker --cpus limit."),
ConfigField("workspace_memory_mb", "Memory per workspace (MB)", type="int",
default="2048", minimum=256, group="Quotas",
help="Applied as the docker --memory limit."),
ConfigField("workspace_editor_trust_all", "Trust every workspace", type="bool",
default="1", group="Editor",
help="Disables VS Code Restricted Mode. Turning this off restores "
"the workspace trust prompt and blocks automatic tasks."),
ConfigField("workspace_editor_theme", "Editor theme", type="select",
default="devplace-dark",
options=[{"value": "devplace-dark", "label": "DevPlace Dark"},
{"value": "devplace-light", "label": "DevPlace Light"},
{"value": "system", "label": "Leave to the member"}],
group="Editor"),
ConfigField("workspace_editor_font_size", "Editor font size", type="int",
default="14", minimum=8, maximum=48, group="Editor"),
ConfigField("workspace_editor_terminal_font_size", "Terminal font size",
type="int", default="13", minimum=8, maximum=48, group="Editor"),
ConfigField("workspace_editor_zoom_level", "Zoom level", type="int",
default="0", minimum=-5, maximum=5, group="Editor",
help="VS Code window zoom. Each step is about 20 percent."),
ConfigField("workspace_editor_layout", "Editor layout", type="select",
default="standard",
options=[{"value": "standard", "label": "Standard"},
{"value": "terminal-focus", "label": "Terminal focus"},
{"value": "zen", "label": "Zen"}],
group="Editor"),
ConfigField("workspace_editor_panel_preset", "Terminal panel size",
type="select", default="tall",
options=[{"value": "short", "label": "Short"},
{"value": "normal", "label": "Normal"},
{"value": "tall", "label": "Tall"},
{"value": "maximized", "label": "Maximized"}],
group="Editor"),
ConfigField("workspace_editor_boot_agent", "Agent on boot", type="select",
default="dpc",
options=[{"value": "dpc", "label": "DevPlace Code (dpc)"},
{"value": "none", "label": "None"}],
group="Editor"),
ConfigField("workspace_editor_boot_shell", "Shell on boot", type="bool",
default="1", group="Editor",
help="Opens a plain login shell beside the agent terminal."),
ConfigField("workspace_editor_window_mode", "Open editor in", type="select",
default="tab",
options=[{"value": "tab", "label": "A new tab"},
{"value": "window", "label": "A sized window"},
{"value": "fullscreen", "label": "A fullscreen window"}],
group="Editor"),
ConfigField("workspace_editor_window_width", "Editor window width", type="int",
default="1600", minimum=640, maximum=7680, group="Editor"),
ConfigField("workspace_editor_window_height", "Editor window height",
type="int", default="1000", minimum=480, maximum=4320,
group="Editor"),
ConfigField("workspace_idle_warn_minutes", "Idle warn (minutes)", type="int",
default="45", minimum=1, group="Lifecycle"),
ConfigField("workspace_idle_stop_minutes", "Idle stop (minutes)", type="int",

View File

@ -47,10 +47,6 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
body("project_type", "Project type."),
body("platforms", "Supported platforms."),
body("status", "Project status."),
body("website_url", "Official website URL (http/https)."),
body("repo_url", "Source repository URL (http/https)."),
body("cover_attachment_uid", "Attachment uid of an uploaded cover image (upload_file/attach_url first)."),
body("logo_attachment_uid", "Attachment uid of an uploaded project logo (upload_file/attach_url first)."),
body("attachment_uids", ATTACHMENTS),
),
),
@ -71,23 +67,6 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
body("project_type", "Updated project type."),
body("platforms", "Updated supported platforms."),
body("status", "Updated project status."),
body("website_url", "Updated official website URL (http/https)."),
body("repo_url", "Updated source repository URL (http/https)."),
body("cover_attachment_uid", "Attachment uid of a new cover image; empty keeps the current one."),
body("logo_attachment_uid", "Attachment uid of a new project logo; empty keeps the current one."),
),
),
Action(
name="project_add_screenshots",
method="POST",
path="/projects/{project_slug}/screenshots",
summary="Add screenshots to an owned project (upload first via upload_file or attach_url, then pass the attachment uids)",
params=(
path(
"project_slug",
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
),
body("attachment_uids", ATTACHMENTS, required=True),
),
),
Action(

View File

@ -41,7 +41,6 @@ CONFIRM_REQUIRED = {
"tunnel_delete",
"workspace_flag_resolve",
"workspace_suspend",
"workspace_editor_set",
"project_set_private",
"project_set_readonly",
"customize_set_css",
@ -172,17 +171,6 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
"confirm=true."
)
if name == "workspace_editor_set":
if arguments.get("reset"):
return ToolInputError(
"Resetting drops every editor preference and returns the workspace to the "
"site defaults. Ask the user to confirm, then call again with confirm=true."
)
return ToolInputError(
"Editor preferences change how every workspace this member opens looks and "
"behaves, and they apply on the next workspace start. Show the user the exact "
"values you are about to set, then call again with confirm=true."
)
if name == "notification_reset":
return ToolInputError(
"Resetting clears every notification preference and restores the platform defaults; it "

View File

@ -140,53 +140,6 @@ WORKSPACE_ACTIONS: tuple[Action, ...] = (
arg("egress_quota_mb", "Egress quota in MB.", kind="integer"),
arg("idle_stop_minutes", "Idle stop window in minutes.", kind="integer"),
arg("retention_days", "Retention in days.", kind="integer"),
arg("cpu_millicores", "CPU limit in millicores. 1000 is one core.", kind="integer"),
arg("memory_mb", "Memory limit in MB.", kind="integer"),
),
),
Action(
name="workspace_editor_get",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
read_only=True,
summary=(
"Read the resolved DevPlace editor profile for a workspace: theme, layout, "
"font sizes, zoom, boot terminals, how the editor opens, the container size, "
"and where each value comes from."
),
params=(
SLUG,
arg("username", "Administrators only. Read another member's profile."),
),
),
Action(
name="workspace_editor_set",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
summary=(
"Change the caller's DevPlace editor preferences. Omitted fields are left "
"alone; an empty string or zero means inherit the site default. Applies on "
"the next workspace start."
),
params=(
SLUG,
arg("theme", "devplace-dark, devplace-light or system."),
arg("layout", "standard, terminal-focus or zen."),
arg("panel_preset", "short, normal, tall or maximized."),
arg("font_size", "Editor font size in pixels.", kind="integer"),
arg("terminal_font_size", "Terminal font size in pixels.", kind="integer"),
arg("zoom_level", "Window zoom level, -5 to 5.", kind="integer"),
arg("boot_agent", "dpc to open the agent terminal on boot, none to skip it."),
arg("boot_shell", "1 to open a plain shell on boot, 0 to skip it.", kind="integer"),
arg("window_mode", "tab, window or fullscreen."),
arg("window_width", "Editor window width in pixels.", kind="integer"),
arg("window_height", "Editor window height in pixels.", kind="integer"),
arg("reset", "Drop every preference and fall back to the site defaults.", kind="boolean"),
CONFIRM,
),
),
Action(

View File

@ -8,7 +8,6 @@ from typing import Any
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import (
editor,
flags,
provision,
quota,
@ -110,9 +109,19 @@ class WorkspaceController:
def _tunnel_create(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", ""))
port = int(args.get("container_port") or 0)
row = provision.publish_tunnel(
if port <= 0:
raise WorkspaceError("container_port is required")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
raise WorkspaceError(f"tunnel limit reached ({limits.max_tunnels})")
row = tunnels.create(
instance, args.get("label", ""), port, self.owner_id
)
if not row:
raise WorkspaceError("could not create tunnel")
provision.write_manifest(instance)
return {
"ok": True,
"tunnel": row,
@ -130,50 +139,6 @@ class WorkspaceController:
provision.write_manifest(instance)
return {"ok": True, "deleted": uid}
def _editor_target(self, args: dict) -> str:
username = args.get("username", "")
if not username:
return self.owner_id
if not self.admin:
raise WorkspaceError(
"only administrators may read another user's editor profile"
)
other = self._user_by_name(username)
if not other:
raise WorkspaceError(f"user not found: {username}")
return other["uid"]
def _workspace_editor_get(self, args: dict) -> Any:
target = self._editor_target(args)
instance = provision.find_for_project(
self._project_uid(args.get("project_slug", "")), target
)
return editor.view(target, instance)
def _workspace_editor_set(self, args: dict) -> Any:
if not self.owner_id:
raise WorkspaceError("sign in to change editor preferences")
if args.get("reset"):
editor.reset_prefs(self.owner_id, self.owner_id)
return {"ok": True, "reset": True, "editor": editor.view(self.owner_id)}
payload = {
key: args[key] for key in editor.PREF_COLUMNS if args.get(key) is not None
}
if not payload:
raise WorkspaceError("no editor preference was supplied")
editor.save_prefs(self.owner_id, payload)
return {
"ok": True,
"editor": editor.view(self.owner_id),
"applies": "on the next workspace start",
}
def _project_uid(self, slug: str) -> str:
project = self._project(slug)
if not project:
raise WorkspaceError(f"project not found: {slug}")
return project["uid"]
def _workspace_quota_get(self, args: dict) -> Any:
target = self.owner_id
username = args.get("username", "")

View File

@ -106,7 +106,7 @@ The gate is at exactly one place: `GatewayService.consent_denied` in `services/o
`container_credentials` gates `containers/api.validate_run_as`: a container configured to run as **someone else** would inject that person's real `DEVPLACE_API_KEY` into software they do not operate, so it is refused unless they granted the consent. Running a container as yourself never asks - you are the one handing over your own credential.
`activity_recording` gates `presence.touch`, checked **after** the per-worker throttle so the consent read costs at most one query per half-window per user. Withdraw it and you simply appear offline. The consent is managed on the profile privacy tab; no page chrome advertises its state.
`activity_recording` gates `presence.touch`, checked **after** the per-worker throttle so the consent read costs at most one query per half-window per user. Withdraw it and you simply appear offline. The indicator is the `.recording-indicator` in `base.html`, rendered from the `activity_recording_on(user)` Jinja global.
## Terms re-acceptance

View File

@ -198,11 +198,16 @@ class GatewayRuntime:
self.in_flight += 1
if self.in_flight > self.peak_in_flight:
self.peak_in_flight = self.in_flight
wait_start = time.monotonic()
connect_holder = {"ms": 0.0}
attempts = 1
resp = None
exc = None
try:
async with sem:
timing["queue_wait_ms"] = round(
(time.monotonic() - wait_start) * 1000, 3
)
async def do_call():
request = client.build_request(
@ -212,9 +217,8 @@ class GatewayRuntime:
return await client.send(request)
send_start = time.monotonic()
resp, exc, attempts, queue_wait_ms = await retry_send(
resp, exc, attempts = await retry_send(
do_call,
sem,
cfg["gateway_max_retries"],
cfg["gateway_retry_backoff_ms"],
log,
@ -222,7 +226,6 @@ class GatewayRuntime:
timing["upstream_latency_ms"] = round(
(time.monotonic() - send_start) * 1000, 3
)
timing["queue_wait_ms"] = round(queue_wait_ms, 3)
finally:
self.in_flight -= 1
timing["connect_ms"] = round(connect_holder["ms"], 3)

View File

@ -66,30 +66,21 @@ async def _backoff(backoff_ms: int, attempt: int) -> None:
async def retry_send(
do_call: Callable[[], Awaitable[httpx.Response]],
sem: asyncio.Semaphore,
max_retries: int,
backoff_ms: int,
log: Optional[Callable[[str], None]] = None,
) -> tuple[Optional[httpx.Response], Optional[Exception], int, float]:
) -> tuple[Optional[httpx.Response], Optional[Exception], int]:
log = log or (lambda message: None)
attempts = 0
last_exc: Optional[Exception] = None
queue_wait_ms = 0.0
while attempts <= max_retries:
attempts += 1
wait_start = time.monotonic()
async with sem:
queue_wait_ms += (time.monotonic() - wait_start) * 1000
try:
resp = await do_call()
exc = None
except httpx.RequestError as e:
resp = None
exc = e
if exc is not None:
except httpx.RequestError as exc:
last_exc = exc
if attempts > max_retries:
return None, exc, attempts, queue_wait_ms
return None, exc, attempts
log(
f"upstream connection failed, retrying ({attempts}/{max_retries}): {exc}"
)
@ -99,5 +90,5 @@ async def retry_send(
log(f"upstream {resp.status_code}, retrying ({attempts}/{max_retries})")
await _backoff(backoff_ms, attempts)
continue
return resp, None, attempts, queue_wait_ms
return None, last_exc, attempts, queue_wait_ms
return resp, None, attempts
return None, last_exc, attempts

View File

@ -1375,6 +1375,35 @@ body:has(.page-messages) {
}
.recording-indicator {
position: fixed;
left: var(--space-lg);
bottom: calc(var(--space-2xl) + var(--space-2xl));
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border-light);
color: var(--text-muted);
font-size: 0.75rem;
z-index: var(--z-fab);
will-change: transform;
}
.recording-indicator a {
color: var(--text-muted);
}
.recording-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
flex: none;
}
.maturity-gate {
padding: var(--space-2xl);
text-align: center;

View File

@ -223,327 +223,16 @@
}
}
.project-page {
max-width: var(--max-content);
.project-detail-page {
max-width: 720px;
margin: 0 auto;
}
.project-shell {
.project-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.project-shell > .project-tabs,
.project-shell > .project-columns {
margin-left: 1.5rem;
margin-right: 1.5rem;
}
.project-shell > .project-columns {
margin-bottom: 1.5rem;
}
.project-shell .project-tabs,
.project-shell .project-sidebar-card,
.project-shell .post-card,
.project-shell .empty-state,
.project-shell .comments-section {
background: var(--bg-secondary);
}
.project-cover {
position: relative;
min-height: 320px;
display: flex;
align-items: flex-end;
background: var(--bg-secondary);
}
.project-cover-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.project-cover-fallback {
min-height: 200px;
background: var(--accent-gradient);
}
.project-cover-fallback::before {
content: "";
position: absolute;
inset: 0;
background: var(--overlay-dark);
}
.project-cover-scrim {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0.78) 100%);
}
.project-hero-overlay {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
gap: 1.25rem;
width: 100%;
padding: 1.5rem;
}
.project-logo {
width: 112px;
height: 112px;
object-fit: cover;
border-radius: var(--radius-lg);
border: 2px solid var(--border-light);
background: var(--bg-card);
box-shadow: var(--shadow);
flex-shrink: 0;
}
.project-hero-headline {
min-width: 0;
flex: 1;
}
.project-hero-overlay .project-detail-title,
.project-hero-overlay .project-detail-author {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 2px 12px rgba(0, 0, 0, 0.6);
}
.project-hero-overlay .project-detail-header {
margin-bottom: 0.5rem;
justify-content: flex-start;
gap: 0.75rem;
align-items: center;
}
.project-hero-chips {
margin-bottom: 0.625rem;
}
.project-hero-overlay .project-detail-author {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.project-hero-ctas {
flex-shrink: 0;
}
.project-hero-body {
padding: 1rem 1.5rem 1.25rem;
}
.project-tabs {
display: flex;
gap: 0.25rem;
margin: 1rem 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0 0.5rem;
flex-wrap: wrap;
}
.project-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.75rem 1rem;
border-bottom: 2px solid transparent;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.project-tab:hover {
color: var(--text-primary);
}
.project-tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent);
}
.project-tab-count {
font-size: 0.6875rem;
font-weight: 700;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--overlay-light);
border: 1px solid var(--border);
color: var(--text-muted);
}
.project-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 1.25rem;
align-items: start;
}
.project-main {
min-width: 0;
}
.project-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.project-sidebar-card {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
}
.project-link-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
}
.project-link-list a {
color: var(--text-secondary);
}
.project-link-list a:hover {
color: var(--accent);
}
.project-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm) var(--space-lg);
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-stat a {
color: var(--text-muted);
}
.project-stat a:hover {
color: var(--accent);
}
.project-stat-value {
font-weight: 700;
color: var(--text-primary);
}
.project-last-update {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.project-author-card {
display: flex;
align-items: center;
gap: 0.75rem;
}
.project-author-meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.8125rem;
}
.project-about {
margin-bottom: 1.5rem;
}
.project-devlog {
margin-top: 1.5rem;
}
.project-devlog-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: 0.75rem;
}
.project-devlog-header .project-section-label {
margin-bottom: 0;
}
.project-devlog-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.project-devlog-post-btn {
margin-left: auto;
}
.project-screenshots {
margin-top: 1.5rem;
}
.project-screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.project-screenshot {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: var(--radius);
border: 1px solid var(--border);
cursor: zoom-in;
}
.project-screenshot-more {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.project-comments {
margin-top: 1.5rem;
}
@media (max-width: 1024px) {
.project-columns {
grid-template-columns: minmax(0, 1fr);
}
.project-cover {
min-height: 240px;
}
}
@media (max-width: 768px) {
.project-hero-overlay {
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
.project-logo {
width: 72px;
height: 72px;
}
.project-cover {
min-height: 200px;
}
}
.project-detail-header {
display: flex;
align-items: flex-start;
@ -615,6 +304,14 @@
color: var(--warning);
}
.project-platforms {
margin-bottom: 1.5rem;
}
.project-devlog {
margin-top: 1.5rem;
}
.project-section-label {
font-size: 0.75rem;
font-weight: 700;

View File

@ -1,12 +1,12 @@
/* retoor <retoor@molodetz.nl> */
:root {
--bg-primary: #080413;
--bg-secondary: #120821;
--bg-card: #1a1030;
--bg-card-hover: #241640;
--bg-input: #140b26;
--bg-modal: #1a1030;
--bg-primary: #271b5b;
--bg-secondary: #1a1736;
--bg-card: #13112a;
--bg-card-hover: #1a1736;
--bg-input: #1a1736;
--bg-modal: #13112a;
--accent: #b73f1e;
--accent-rgb: 183, 63, 30;
@ -76,5 +76,5 @@
--topic-fun: #ffab00;
--topic-politics: #00bcd4;
--bg-gradient: linear-gradient(135deg, #080413 0%, #160a28 50%, #080413 100%);
--bg-gradient: linear-gradient(135deg, #271b5b 0%, #1d1545 50%, #271b5b 100%);
}

View File

@ -111,92 +111,6 @@
border-left: 3px solid var(--text-secondary);
}
.workspace-editor-heading {
margin: var(--space-lg) 0 var(--space-xs);
color: var(--text-primary);
}
.workspace-editor-restart {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
border-left: 3px solid var(--warning);
border-radius: var(--radius);
background: var(--overlay-light);
color: var(--text-primary);
}
.workspace-editor-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-sm);
list-style: none;
margin: 0 0 var(--space-md);
padding: 0;
}
.workspace-editor-summary li {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.workspace-editor-summary span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.workspace-editor-summary strong {
color: var(--text-primary);
text-transform: capitalize;
}
.workspace-editor-summary em {
color: var(--text-muted);
font-size: 0.75rem;
font-style: normal;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.workspace-editor-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-sm);
}
.workspace-editor-form label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
color: var(--text-secondary);
font-size: 0.9rem;
}
.workspace-editor-actions {
grid-column: 1 / -1;
display: flex;
gap: var(--space-sm);
}
.workspace-editor-reset {
margin-top: var(--space-sm);
}
@media (max-width: 768px) {
.workspace-editor-summary,
.workspace-editor-form {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.workspace-meters {
flex-direction: column;

View File

@ -42,7 +42,6 @@ import { LiveNotifications } from "./LiveNotifications.js";
import { PresenceManager } from "./PresenceManager.js";
import { OnlineUsers } from "./OnlineUsers.js";
import { LocalTime } from "./LocalTime.js";
import { EditorLauncher } from "./EditorLauncher.js";
import { ScrollMemory } from "./ScrollMemory.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
@ -113,7 +112,6 @@ class Application {
this.onlineUsers = new OnlineUsers(this.pubsub);
this.localTime = new LocalTime();
this.scrollMemory = new ScrollMemory();
this.editorLauncher = new EditorLauncher();
this.gameFarm = new GameFarm();
this.overflowTabs = new OverflowTabs();
}

View File

@ -1,58 +0,0 @@
// retoor <retoor@molodetz.nl>
export class EditorLauncher {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
}
onClick(event) {
const trigger = event.target.closest("[data-editor-open]");
if (!trigger) return;
const mode = trigger.dataset.editorMode || "tab";
if (mode === "tab") return;
const opened = window.open(
trigger.href,
trigger.dataset.editorName || "devplace-editor",
this.features(mode, trigger),
);
if (!opened) return;
event.preventDefault();
opened.focus();
}
features(mode, trigger) {
const size = this.size(mode, trigger);
const left = Math.max(0, Math.round((window.screen.availWidth - size.width) / 2));
const top = Math.max(0, Math.round((window.screen.availHeight - size.height) / 2));
return [
`width=${size.width}`,
`height=${size.height}`,
`left=${left}`,
`top=${top}`,
"noopener",
"resizable=yes",
"scrollbars=yes",
].join(",");
}
size(mode, trigger) {
if (mode === "fullscreen") {
return {
width: window.screen.availWidth,
height: window.screen.availHeight,
};
}
return {
width: this.clamp(trigger.dataset.editorWidth, 640, window.screen.availWidth),
height: this.clamp(trigger.dataset.editorHeight, 480, window.screen.availHeight),
};
}
clamp(raw, minimum, maximum) {
const value = parseInt(raw, 10);
if (!Number.isFinite(value)) return maximum;
return Math.max(minimum, Math.min(maximum, value));
}
}
export default EditorLauncher;

View File

@ -20,7 +20,7 @@ export class WorkspaceManager {
bind() {
this.root.addEventListener("submit", (event) => {
const form = event.target.closest("form");
if (!form || form.dataset.confirm || form.dataset.native !== undefined) return;
if (!form || form.dataset.confirm) return;
event.preventDefault();
this.send(form);
});

View File

@ -65,7 +65,6 @@ Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
- `_post_composer_form.html` - the create-post form (topic selector, content/title, project select, attachments, poll builder, footer). Locals: `_composer_topic` (preselected topic, default `random`), `_composer_project` (preselected project uid or `""`). Wrapped in the `modal()` macro by `feed.html` (Create New Post) and `project_detail.html` (owner-only Post an update, preset to `devlog` + the project). Never fork a second copy of this form.
- `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`.
- `_star_vote.html` - project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) - do not put a literal star in markup.
- `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.

View File

@ -1,10 +0,0 @@
{# retoor <retoor@molodetz.nl> #}
<a href="{{ _url }}" target="_blank" rel="noopener" class="{{ _class or 'btn btn-primary' }}"
data-editor-open
data-editor-mode="{{ _mode or 'tab' }}"
data-editor-width="{{ _width or 1600 }}"
data-editor-height="{{ _height or 1000 }}"
data-editor-name="devplace-editor-{{ _uid }}">
{%- if _icon %}<span class="icon">{{ _icon }}</span><span class="label"> {{ _label or 'Editor' }}</span>
{%- else %}{{ _label or 'Open editor' }}{% endif -%}
</a>

View File

@ -1,48 +0,0 @@
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
{% set _topics = TOPICS %}{% set _selected = _composer_topic or 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="post-title">Title (optional)</label>
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
<small class="hint-text"><span id="post-title-count">0/500</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="project_uid">Link to Project (Optional)</label>
<select id="project_uid" name="project_uid">
<option value="">No project</option>
{% for p in get_user_projects(user['uid']) %}
<option value="{{ p['uid'] }}" {% if _composer_project and p['uid'] == _composer_project %}selected{% endif %}>{{ p['title'] }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field auth-field-gap">
<label>Attach files (images, video, audio, documents)</label>
{% include "_attachment_form.html" %}
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>

View File

@ -21,8 +21,6 @@
<th>Status</th>
<th>Disk</th>
<th>Egress</th>
<th>Size</th>
<th>Editor</th>
<th>Tunnels</th>
<th>Flags</th>
<th>Actions</th>
@ -43,8 +41,6 @@
</td>
<td>{{ ws.disk_percent }}% of {{ ws.disk_quota_mb }} MB</td>
<td>{{ ws.egress_percent }}% of {{ ws.egress_quota_mb }} MB</td>
<td>{{ ws.editor.cpu_cores }} CPU / {{ ws.editor.memory_mb }} MB</td>
<td>{{ ws.editor.theme }} / {{ ws.editor.layout }}</td>
<td>{{ ws.tunnels|length }}</td>
<td>{{ ws.flags|length }}</td>
<td class="admin-actions">
@ -77,7 +73,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="11">No workspaces yet.</td></tr>
<tr><td colspan="9">No workspaces yet.</td></tr>
{% endfor %}
</tbody>
</table>

View File

@ -212,6 +212,13 @@
</footer>
{% endblock %}
{% if user and activity_recording_on(user) %}
<div class="recording-indicator" role="status" aria-live="off" title="Your presence and session activity are recorded while you are signed in">
<span class="recording-dot" aria-hidden="true"></span>
<a href="/profile/{{ user['username'] }}?tab=privacy">Activity recording on</a>
</div>
{% endif %}
{%- set _response_time = response_time_ms(request) -%}
{%- if _response_time %}<div class="response-time-indicator" title="Server response time" aria-hidden="true">{{ _response_time }} ms</div>{% endif -%}

View File

@ -19,7 +19,7 @@ Seven HTTP middlewares run as a stack around every request, listed outermost fir
| `track_presence` | For the resolved current user on every non-asset request, calls `presence.touch(uid)` (a throttled `last_seen` write). |
| `maintenance_middleware` | When `maintenance_mode="1"`, returns a 503 `error.html` for non-admins, but always allows `/static`, `/avatar`, `/auth`, `/admin`, `/openai`, and admin users, so an operator can never lock themselves out. |
| `rate_limit_middleware` | Per-IP limit for mutating methods (POST/PUT/DELETE/PATCH), held in an in-process `defaultdict`. Reads `rate_limit_per_minute` / `rate_limit_window_seconds` from `site_settings`, floored to `max(1, ...)`, and is worker-count-aware. `/openai` and `/xmlrpc` are excluded. |
| `add_security_headers` | Sets `X-Content-Type-Options: nosniff` always, `X-Robots-Tag: index, follow` unless the handler already set it (so pages can opt out of indexing), plus HSTS, `Referrer-Policy`, and a CSP whose `frame-ancestors` allows `'self'` and the workspace tunnel domain (except the `/p/` ingress, which sets no CSP at all), and no-store cache headers on `/admin`. Framing is controlled by `frame-ancestors` alone - no `X-Frame-Options` is sent, because it cannot express an allow-list and browsers honour the stricter of the two. |
| `add_security_headers` | Sets `X-Content-Type-Options: nosniff` always, `X-Robots-Tag: index, follow` unless the handler already set it (so pages can opt out of indexing), plus HSTS, `Referrer-Policy`, a CSP and `X-Frame-Options: DENY` (except the `/p/` ingress), and no-store cache headers on `/admin`. |
| `await_pending_corrections` | After the handler runs, awaits any pending AI correction/modifier futures parked on `request.scope` (sync apply mode). |
| `refresh_db_snapshot` | Calls `refresh_snapshot()` so each request sees committed data. |

View File

@ -105,11 +105,6 @@ to the platform AI gateway, so **all of its AI usage is metered through your own
account**. There is no separate key to manage and nothing to configure: it is plug
and play.
In a **workspace** you do not even have to start it. The DevPlace editor opens a
**DevPlace Code** terminal running `dpc` for you the moment the workspace boots, with
a plain shell beside it. See [The workspace editor](/docs/workspace-editor.html) for
the boot terminals, the trust policy, and every size you can change.
## Container environment keys
Every container is launched with these variables already set. Scripts and agents

View File

@ -100,38 +100,4 @@ The operator transcribes these into the store listing; every answer is a fact ab
A wrong removal is undone with the **Restore content** decision, or from `/admin/trash`, which
restores the whole deletion event under one stamp. Nothing a moderator removes is destroyed until it
is purged.
## Acceptance convergence on a test instance
An instance kept production-identical for extended manual testing otherwise stops the tester at the
same acceptance dialogs forever. The **Acceptance convergence** background service grants every
policy agreement to every account that has not declined it. It is administrator-only, **off by
default**, and **never appropriate on a real production host** - there is no environment detection
anywhere in it, deliberately, so the only control is the operator's own judgement.
**Enable it.**
1. Open `/admin/services/acceptance`, Configuration tab, and switch on only the agreements the test
run needs. There is one switch per agreement type, so a single policy can be converged while the
rest stay pending.
2. Leave **Dry run** on. Start the service, then use **Run now**. Read the Logs tab and confirm the
accounts listed are the ones expected. Nothing has been written yet.
3. Switch Dry run off. The next run converges them, within five minutes or immediately with
**Run now**.
**Test a refusal path.** Withdraw the consent from your own profile's privacy tab. The consent
ledger is the decline register: the service only ever grants, so a withdrawal is permanent and no
number of intervals will undo it. Grant it again from the same tab to rejoin the convergence.
**Test the terms gate.** Withdraw your own Terms of Service consent, then bump `terms_version` at
`/admin/settings`. Every other account converges; you stay gated and can exercise
`/auth/accept-terms`, the in-place acceptance dialog and the JSON refusal as often as you like.
**Test a partial state.** Enable Terms of Service and leave Privacy Policy off. Accounts pass the
write gate but still show the privacy policy as unaccepted on their privacy tab.
**Turn it off.** Stop the service, or switch off the individual agreement. Nothing already written
is reverted, and nothing should be: those accounts are in exactly the state a real population would
have reached. Every convergence is in the audit log under `terms.accept` and `consent.grant` with
actor kind `service`, which is how you tell a converged acceptance from a human one.
</div>

View File

@ -3,29 +3,6 @@
The nginx front door, how it serves each route, and the production-specific rules it enforces. See also [Production overview](/docs/production.html), [Deploy and update](/docs/production-deploy.html), and [Static asset caching and versioning](/docs/static-caching.html).
## Public hostnames: two front doors, one application
The platform answers on **two** public hostnames that reach the same application by completely different routes. Knowing which is which is the difference between a five minute diagnosis and an hour of chasing the wrong edge.
| | `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 | a separate front host |
| Path in | molohttp on port 443, proxying to `127.0.0.1:10500` | its own proxy, then an **SSH tunnel** to `127.0.0.1:10500` on production |
| Passes through molohttp | yes | **no** |
`devplace.net` runs on its own machine and holds a persistent SSH session into the production host, forwarding through it to `127.0.0.1:10500` - the `docker-proxy` socket for the nginx container. The forwarded listener lives on the **front** host, so the production host shows no sshd listening socket for it. That absence is expected.
**molohttp deliberately has no `devplace.net` site.** Its sites are `mail`, `smtp` and `imap.molodetz.nl`, `pravda.education`, and the workspace tunnel wildcard `*.tunnel.pravda.education`. Traffic for `devplace.net` enters underneath molohttp, so it needs no site there and adding one would achieve nothing - the hostname does not resolve to the production host, so such a site could never match.
**Triage rule.** Run the same authenticated request against both hostnames and compare:
- Fails on **both** - the fault is in the application or the database. Neither edge is involved.
- Fails on **devplace.net only** - the fault is in the front host's proxy. WebSocket `Upgrade` and `Connection` headers are the usual cause, exactly as documented for the nginx locations below.
- Fails on **pravda.education only** - the fault is in molohttp or its site configuration.
**Never test a hostname by forcing it onto an IP it does not resolve to.** Using `curl --resolve devplace.net:443:<production ip>` sends `Host: devplace.net` to molohttp, which correctly answers `404 No site configured for host: devplace.net`. That result says nothing about the real path and reads convincingly like a total outage. Always fetch each hostname over the public internet as it genuinely resolves.
## Build and configuration
The nginx image (`nginx/Dockerfile`) renders `nginx/nginx.conf.template` at start through `nginx/start.sh`, which substitutes a small allow-list of variables (`NGINX_CACHE_CONFIG`, `NGINX_CACHE_MAX_SIZE`, `NGINX_MAX_BODY_SIZE`) and leaves nginx runtime variables such as `$http_upgrade` untouched. The host's `devplacepy/static` directory is bind-mounted read-only at `/app/static` for package assets, and the consolidated `<DEVPLACE_DATA_DIR>/uploads` directory is bind-mounted read-only at `/data/uploads` (the `/static/uploads/` location aliases it), so both served assets and uploads always match the running code and data without an image rebuild.
@ -85,7 +62,7 @@ Verify from an IPv6-only vantage point (or force the family): `curl -6 -I https:
## Security headers and caching
The server block sets `X-Content-Type-Options`, `X-XSS-Protection`, and `Referrer-Policy`, inherited only by locations that declare no `add_header` of their own. It deliberately sets no `X-Frame-Options`: every location without its own `add_header` is proxied to the app, which owns framing policy via the CSP `frame-ancestors` directive, and an nginx-level header would be re-added on top of the app's response - that is what previously defeated the `/p/` ingress exemption, since `location /p/` declares no `add_header` and the app intentionally sends no framing headers there. gzip is enabled for text, JSON, JS, CSS, and SVG. The micro-cache is off by default; set `NGINX_CACHE_ENABLED=true` to cache proxied 200s for one minute with `X-Cache-Status` reporting.
The server block sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `X-XSS-Protection`, and `Referrer-Policy`, inherited only by locations that declare no `add_header` of their own. gzip is enabled for text, JSON, JS, CSS, and SVG. The micro-cache is off by default; set `NGINX_CACHE_ENABLED=true` to cache proxied 200s for one minute with `X-Cache-Status` reporting.
## Troubleshooting

View File

@ -1,134 +0,0 @@
<div class="docs-content" data-render>
# The workspace editor
Every DevPlace workspace opens a full editor in your browser. It is branded DevPlace,
it starts a coding agent for you, and it is configured from your DevPlace account
rather than from inside the editor.
Open one from a project's **Workspace** page, or with the **Editor** button on the
project itself once the workspace is running.
## What opens on boot
When your workspace starts, two terminals open at the bottom of the window:
- **DevPlace Code** runs [`dpc`](/docs/getting-started-vibing.html), the coding agent
that ships in every workspace. It has focus, so you can type a request straight
away. Every token it spends is metered against your own DevPlace account.
- **pravda@workspace** is an ordinary login shell, so the Python, Rust, Nim and Swift
toolchains are all on your `PATH`.
New terminals you open later are plain shells. To start another agent, pick
**DevPlace Code** from the terminal dropdown, or run the command
**DevPlace: Start DevPlace Code**.
The workspace opens straight onto your files with the terminal ready, not onto a welcome
page, and the editor's own built-in chat assistant is switched off: `dpc` is the assistant
here, and it runs on your DevPlace account. The files `dpc` keeps for itself (`.dpc/` and
`dpc.log`) stay in the container and are never copied into your project.
You can turn either of them off. See **Your preferences** below.
## Every workspace is trusted
VS Code normally opens an unfamiliar folder in **Restricted Mode**, which disables
tasks, debugging and most extensions until you click to trust it. DevPlace turns
that off: your workspace is yours, so it is trusted from the first second and
nothing prompts you.
**This has a real consequence, and you should know it.** Automatic tasks are enabled
too, so if a project you open contains a `.vscode/tasks.json` with a
`"runOn": "folderOpen"` task, that task runs when the folder opens. If you are about
to open code you did not write and do not trust, read that file first.
An administrator can restore Restricted Mode for the whole site from the workspace
service settings.
## Size
Four separate things have a size, and they are set in two different places.
| What | Set by | Where |
|---|---|---|
| Editor font size, terminal font size, zoom | You | Your workspace page |
| Editor layout and terminal panel size | You | Your workspace page |
| How the editor opens (tab or sized window) | You | Your workspace page |
| CPU, memory and disk | An administrator | Your workspace quota |
Your own preferences follow you into every workspace you open. The container size is
part of your quota and is shown on the same page so you always know what you have.
## Your preferences
The **Editor** card on your workspace page holds them all:
- **Theme** - DevPlace Dark, DevPlace Light, or leave it to you (pick any theme from
inside the editor and DevPlace will not touch it again).
- **Layout** - Standard, Terminal focus, or Zen.
- **Terminal panel** - Short, Normal, Tall or Maximized.
- **Editor font size**, **Terminal font size**, **Zoom level**.
- **Agent on boot** and **Shell on boot**.
- **Open editor in** - a new tab, a sized window, or a fullscreen window, with the
width and height for the sized case.
Every field has a **Site default** option. Choosing it removes your preference and
lets the administrator's value apply again, including any future change to it.
**Reset to site defaults** does that for all of them at once.
Over the API and through Devii the same rule applies field by field: only the fields
you send are changed, and a field you send as empty or zero goes back to inheriting.
### They apply on the next start
Editor settings are read when the workspace container boots. After you save, the page
tells you if a restart is needed and gives you the buttons to do it.
### DevPlace never overwrites a setting you changed yourself
If you change something inside the editor, that value is yours from then on. DevPlace
only writes a setting it wrote itself last time, so a change to the site default
reaches everyone who has not expressed an opinion and no one who has.
## Doing it from Devii
Devii can read and change these for you:
- *"what is my workspace editor set to"* runs `workspace_editor_get`.
- *"make my workspace editor font 18 and use the light theme"* runs
`workspace_editor_set`. It will show you the exact values and ask before saving.
## Commands inside the editor
Press `F1` and type `DevPlace` for the full list:
| Command | What it does |
|---|---|
| **DevPlace: Start DevPlace Code** | Opens another `dpc` terminal |
| **DevPlace: Open project on DevPlace** | Your project page |
| **DevPlace: Open workspace settings** | Your workspace page |
| **DevPlace: Show public tunnels** | Pick one of your live public addresses |
| **DevPlace: Open the DevPlace editor guide** | This page |
## Publishing a port from the editor
Forward a port in the editor's **Ports** view and DevPlace publishes it for you.
The moment you forward it, the editor registers the port with DevPlace, which
creates the tunnel, orders its HTTPS certificate and answers with the public
address - the same address the Ports view shows you. Publishing counts against
your tunnel quota, so a port DevPlace refuses is reported back in the editor with
the reason.
Two things to know:
- The address serves HTTPS as soon as the certificate is issued, which takes a
few seconds. Until then your browser warns about the certificate name.
- Un-forwarding the port in the editor does **not** remove the tunnel. Public
addresses are removed deliberately, on your workspace page or by asking Devii,
so a restarted dev server never silently loses its link.
## Related
- [Get started with vibing](/docs/getting-started-vibing.html) - the container
runtime, the agents, and publishing what you build.
- The **Dev Workspaces** API group for the same settings over HTTP.
</div>

View File

@ -160,7 +160,54 @@
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post" aria-label="Create New Post">+</a>
{% call modal('create-post-modal', 'Create New Post') %}
{% set _composer_topic = 'random' %}{% set _composer_project = '' %}{% include "_post_composer_form.html" %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="post-title">Title (optional)</label>
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
<small class="hint-text"><span id="post-title-count">0/500</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="project_uid">Link to Project (Optional)</label>
<select id="project_uid" name="project_uid">
<option value="">No project</option>
{% for p in get_user_projects(user['uid']) %}
<option value="{{ p['uid'] }}">{{ p['title'] }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field auth-field-gap">
<label>Attach files (images, video, audio, documents)</label>
{% include "_attachment_form.html" %}
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>
{% endcall %}
{% else %}
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>

View File

@ -3,92 +3,77 @@
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
{% endblock %}
{% block content %}
{% set project_url = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set cover = attachments | selectattr('uid', 'equalto', project.get('cover_attachment_uid', '')) | selectattr('is_image') | list | first %}
{% set logo = attachments | selectattr('uid', 'equalto', project.get('logo_attachment_uid', '')) | selectattr('is_image') | list | first %}
{% set hero_uids = [(cover or {}).get('uid'), (logo or {}).get('uid')] %}
{% set gallery = attachments | selectattr('is_image') | rejectattr('uid', 'in', hero_uids) | list %}
{% set other_attachments = attachments | rejectattr('is_image') | list %}
{% set cover_src = (cover or {}).get('url') or ((gallery | first or {}).get('url')) %}
<div class="project-page">
<div class="project-detail-page">
<a href="/projects" class="back-link">&larr; Back to Projects</a>
<div class="project-shell">
<article class="project-hero">
<div class="project-cover{% if not cover_src %} project-cover-fallback{% endif %}">
{% if cover_src %}
<img class="project-cover-img" src="{{ cover_src }}" alt="{{ project['title'] }} cover image" loading="eager">
{% endif %}
<div class="project-cover-scrim" aria-hidden="true"></div>
<div class="project-hero-overlay">
{% if logo %}
<img class="project-logo" src="{{ logo['thumbnail_url'] or logo['url'] }}" alt="{{ project['title'] }} logo" loading="eager">
{% endif %}
<div class="project-hero-headline">
<article class="project-detail">
<div class="project-detail-header">
<h1 class="project-detail-title">{{ render_title(project['title'], author_is_admin=is_admin(author)) }}</h1>
<div class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
&#x25CF; {{ project.get('status', 'In Development') }}
</div>
</div>
<div class="project-detail-meta project-hero-chips">
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
{% for plat in platforms %}
<span class="platform-tag">{{ plat.strip() }}</span>
{% endfor %}
{% if is_private or read_only %}
<div class="project-detail-meta">
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
</div>
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
<span class="meta-muted">&middot; Level {{ author.get('level', 1) if author else 1 }}</span>
</div>
{% if project.get('created_at') %}
<span class="meta-muted">&#x1F331; Started {{ dt_ago(project['created_at']) }}</span>
{% endif %}
</div>
</div>
{% if project.get('website_url') %}
<div class="project-hero-ctas">
<a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow" class="btn btn-primary"><span class="icon">&#x1F310;</span> Visit Website</a>
</div>
{% endif %}
</div>
</div>
<div class="project-hero-body">
{% if project.get('release_date') or project.get('demo_date') or forked_from %}
<div class="project-detail-meta">
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
{% if project.get('release_date') %}
<span>&#x1F4C5; Released: {{ format_date(project['release_date']) }}</span>
{% endif %}
{% if project.get('demo_date') %}
<span>&#x1F3AD; Demo: {{ format_date(project['demo_date']) }}</span>
{% endif %}
</div>
{% if forked_from %}
<div class="project-detail-meta">
<span>&#x2442; Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
</div>
{% endif %}
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
<span class="meta-muted">&middot; Level {{ author.get('level', 1) if author else 1 }}</span>
</div>
</div>
{% if maturity_hidden(maturity, user) %}
{% set _level = maturity %}{% include "_maturity_gate.html" %}
{% else %}
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div>
{% endif %}
{% if attachments %}
{% include "_attachment_display.html" %}
{% endif %}
{% if platforms %}
<div class="project-platforms">
<h4 class="project-section-label">Platforms</h4>
<div class="project-card-platforms">
{% for plat in platforms %}
<span class="platform-tag">{{ plat.strip() }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<div class="project-detail-actions">
<a href="{{ project_url }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span><span class="label"> Files ({{ file_count }} files)</span></a>
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span><span class="label"> Files ({{ file_count }} files)</span></a>
{% if workspace_editor_url %}
{% set _url = workspace_editor_url %}
{% set _uid = project['uid'] %}
{% set _class = "project-star-btn" %}
{% set _icon = "&#x1F4BB;"|safe %}
{% set _mode = workspace_editor_mode %}
{% set _width = workspace_editor_width %}
{% set _height = workspace_editor_height %}
{% set _label = "Editor" %}
{% include "_editor_open.html" %}
<a href="{{ workspace_editor_url }}" target="_blank" rel="noopener" class="project-star-btn"><span class="icon">&#x1F4BB;</span><span class="label"> VS Code</span></a>
{% endif %}
<button type="button" class="project-star-btn" data-share="{{ project_url }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button>
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button>
{% if user %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
@ -99,23 +84,22 @@
<div class="project-actions-overflow" hidden>
{% if viewer_can_workspace %}
<a href="{{ project_url }}/workspace" data-menu-action data-menu-icon="&#x1F4BB;" data-menu-label="Workspace">Workspace</a>
<a href="/projects/{{ project['slug'] or project['uid'] }}/workspace" data-menu-action data-menu-icon="&#x1F4BB;" data-menu-label="Workspace">Workspace</a>
{% endif %}
{% if viewer_can_containers %}
<a href="{{ project_url }}/containers" data-menu-action data-menu-icon="&#x1F5A5;&#xFE0F;" data-menu-label="Containers">Containers</a>
<a href="/projects/{{ project['slug'] or project['uid'] }}/containers" data-menu-action data-menu-icon="&#x1F5A5;&#xFE0F;" data-menu-label="Containers">Containers</a>
{% endif %}
<button type="button" data-zip-download="{{ project_url }}/zip" data-menu-action data-menu-icon="&#x1F4E6;" data-menu-label="Download zip">Download zip</button>
<button type="button" data-zip-download="/projects/{{ project['slug'] or project['uid'] }}/zip" data-menu-action data-menu-icon="&#x1F4E6;" data-menu-label="Download zip">Download zip</button>
{% if user %}
<button type="button" data-fork-project="{{ project_url }}/fork" data-fork-name="{{ project['title'] }}" data-menu-action data-menu-icon="&#x2442;" data-menu-label="Fork">Fork</button>
<button type="button" data-fork-project="/projects/{{ project['slug'] or project['uid'] }}/fork" data-fork-name="{{ project['title'] }}" data-menu-action data-menu-icon="&#x2442;" data-menu-label="Fork">Fork</button>
{% endif %}
{% if is_owner %}
<button type="button" data-modal="edit-project-modal" data-menu-action data-menu-icon="&#x270F;&#xFE0F;" data-menu-label="Edit">Edit</button>
<button type="button" data-modal="add-screenshots-modal" data-menu-action data-menu-icon="&#x1F5BC;&#xFE0F;" data-menu-label="Add screenshots">Add screenshots</button>
<form method="POST" action="{{ project_url }}/private">
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/private">
<input type="hidden" name="value" value="{{ 0 if is_private else 1 }}">
<button type="submit" data-confirm-danger data-confirm="{% if is_private %}Make this project public? Everyone will be able to see the project and all its files.{% else %}Make this project private? Only you and administrators will be able to see it.{% endif %}" data-menu-action data-menu-icon="{% if is_private %}&#x1F513;{% else %}&#x1F512;{% endif %}" data-menu-label="{% if is_private %}Make public{% else %}Make private{% endif %}">{% if is_private %}Make public{% else %}Make private{% endif %}</button>
</form>
<form method="POST" action="{{ project_url }}/readonly">
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/readonly">
<input type="hidden" name="value" value="{{ 0 if read_only else 1 }}">
<button type="submit" data-confirm-danger data-confirm="{% if read_only %}Allow file changes again for this project?{% else %}Make this project read-only? Files become immutable until you turn this off.{% endif %}" data-menu-action data-menu-icon="{% if read_only %}&#x1F4DD;{% else %}&#x1F6AB;{% endif %}" data-menu-label="{% if read_only %}Allow edits{% else %}Make read-only{% endif %}">{% if read_only %}Allow edits{% else %}Make read-only{% endif %}</button>
</form>
@ -127,137 +111,21 @@
{% endif %}
</div>
</div>
</div>
</article>
<nav class="project-tabs" aria-label="Project sections">
<a href="#about" class="project-tab active">Overview</a>
<a href="#devlog" class="project-tab">Devlog <span class="project-tab-count">{{ devlog_count }}</span></a>
{% if gallery %}
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ gallery | length }}</span></a>
{% endif %}
<a href="#comments" class="project-tab">Comments <span class="project-tab-count">{{ comment_count }}</span></a>
<a href="{{ project_url }}/files" class="project-tab">Files <span class="project-tab-count">{{ file_count }}</span></a>
</nav>
<div class="project-columns">
<div class="project-main">
<section class="project-about" id="about">
<h2 class="project-section-label">About</h2>
{% if maturity_hidden(maturity, user) %}
{% set _level = maturity %}{% include "_maturity_gate.html" %}
{% else %}
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div>
{% endif %}
{% if other_attachments %}
{% set attachments = other_attachments %}
{% include "_attachment_display.html" %}
{% endif %}
</section>
<section class="project-devlog" id="devlog">
<div class="project-devlog-header">
<h2 class="project-section-label">Devlog</h2>
<span class="project-devlog-count">{{ devlog_count }} update{{ '' if devlog_count == 1 else 's' }}</span>
{% if is_owner %}
<button type="button" class="btn btn-primary btn-sm project-devlog-post-btn" data-modal="create-post-modal"><span class="icon">&#x270D;&#xFE0F;</span> Post update</button>
{% endif %}
</div>
<section class="project-devlog">
<h3 class="project-section-label">Devlog</h3>
{% if devlog_posts %}
{% for item in devlog_posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<p class="empty-state">No devlog posts yet.{% if is_owner %} Share your first update to give this project a public build log.{% endif %}</p>
<p class="empty-state">No devlog posts yet.</p>
{% endif %}
</section>
{% if gallery %}
<section class="project-screenshots" id="screenshots">
<h2 class="project-section-label">Screenshots</h2>
<div class="project-screenshot-grid">
{% for shot in gallery[:12] %}
<img src="{{ shot['thumbnail_url'] or shot['url'] }}" data-lightbox data-full="{{ shot['url'] }}" alt="{{ project['title'] }} screenshot {{ loop.index }}" loading="lazy" class="project-screenshot">
{% endfor %}
</div>
{% if gallery | length > 12 %}
<p class="project-screenshot-more">Showing 12 of {{ gallery | length }} screenshots.</p>
{% endif %}
</section>
{% endif %}
<section class="project-comments" id="comments">
{% with target_uid=project['uid'], target_type="project" %}
{% include "_comment_section.html" %}
{% endwith %}
</section>
</div>
<aside class="project-sidebar">
<div class="project-sidebar-card">
<h2 class="project-section-label">Links</h2>
<ul class="project-link-list">
{% if project.get('website_url') %}
<li><a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow">&#x1F310; Website</a></li>
{% endif %}
{% if project.get('repo_url') %}
<li><a href="{{ project['repo_url'] }}" target="_blank" rel="noopener nofollow">&#x1F4BB; Repository</a></li>
{% endif %}
<li><a href="{{ project_url }}/files">&#x1F4C1; Browse files</a></li>
{% if forked_from %}
<li><a href="/projects/{{ forked_from['slug'] }}">&#x2442; Fork source: {{ render_title(forked_from['title']) }}</a></li>
{% endif %}
</ul>
</div>
<div class="project-sidebar-card">
<h2 class="project-section-label">Stats</h2>
<div class="project-stats">
<span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span>
<span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="{{ project_url }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span>
<span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
</div>
{% if devlog_posts %}
<div class="project-last-update">Last update {{ dt_ago(devlog_posts[0].post['created_at']) }}</div>
{% endif %}
</div>
<div class="project-sidebar-card">
<h2 class="project-section-label">Author</h2>
<div class="project-author-card">
{% set _size = 40 %}{% set _size_class = "md" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div class="project-author-meta">
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
<span class="meta-muted">Level {{ author.get('level', 1) if author else 1 }} &middot; {{ author.get('stars', 0) if author else 0 }} stars</span>
</div>
</div>
</div>
</aside>
</div>
</div>
{% if is_owner %}
{% call modal('create-post-modal', 'Post an update') %}
{% set _composer_topic = 'devlog' %}{% set _composer_project = project['uid'] %}{% include "_post_composer_form.html" %}
{% endcall %}
{% call modal('add-screenshots-modal', 'Add screenshots') %}
<form method="POST" action="{{ project_url }}/screenshots">
<div class="auth-field auth-field-gap">
<label>Upload images</label>
{% include "_attachment_form.html" %}
<small class="hint-text">Images appear in the Screenshots gallery; other files list under About.</small>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F5BC;&#xFE0F;</span>Add screenshots</button>
</div>
</form>
{% endcall %}
{% call modal('edit-project-modal', 'Edit Project') %}
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
<div class="auth-field auth-field-gap">
@ -281,28 +149,6 @@
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<label for="edit-project-website_url">Website (optional)</label>
<input type="url" id="edit-project-website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev" value="{{ project.get('website_url', '') or '' }}">
</div>
<div class="auth-field">
<label for="edit-project-repo_url">Repository (optional)</label>
<input type="url" id="edit-project-repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project" value="{{ project.get('repo_url', '') or '' }}">
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<label>Cover image{% if cover %} (replaces current){% endif %}</label>
<dp-upload name="cover_attachment_uid" max-files="1" accept="image/*" allowed-types="{{ allowed_image_types() }}" max-size="{{ max_upload_size_mb() }}" label="Upload cover"></dp-upload>
</div>
<div class="auth-field">
<label>Project logo{% if logo %} (replaces current){% endif %}</label>
<dp-upload name="logo_attachment_uid" max-files="1" accept="image/*" allowed-types="{{ allowed_image_types() }}" max-size="{{ max_upload_size_mb() }}" label="Upload logo"></dp-upload>
</div>
</div>
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type">
@ -346,6 +192,10 @@
</form>
{% endcall %}
{% endif %}
{% with target_uid=project['uid'], target_type="project" %}
{% include "_comment_section.html" %}
{% endwith %}
</div>
{% endblock %}
{% block extra_js %}
@ -357,3 +207,6 @@ if (actions) {
}
</script>
{% endblock %}

View File

@ -126,28 +126,6 @@
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<label for="website_url">Website (optional)</label>
<input type="url" id="website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev">
</div>
<div class="auth-field">
<label for="repo_url">Repository (optional)</label>
<input type="url" id="repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project">
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<label>Cover image (optional)</label>
<dp-upload name="cover_attachment_uid" max-files="1" accept="image/*" allowed-types="{{ allowed_image_types() }}" max-size="{{ max_upload_size_mb() }}" label="Upload cover"></dp-upload>
</div>
<div class="auth-field">
<label>Project logo (optional)</label>
<dp-upload name="logo_attachment_uid" max-files="1" accept="image/*" allowed-types="{{ allowed_image_types() }}" max-size="{{ max_upload_size_mb() }}" label="Upload logo"></dp-upload>
</div>
</div>
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type">

View File

@ -8,7 +8,6 @@
{% block content %}
<div class="workspace-page" data-workspace-root
data-slug="{{ project.slug or project.uid }}"
data-workspace-uid="{{ workspace.uid if has_workspace else '' }}"
data-has-workspace="{{ 1 if has_workspace else 0 }}">
<h1 class="workspace-title">Workspace: {{ project.title }}</h1>
@ -61,14 +60,7 @@
</p>
<div class="workspace-actions">
{% if workspace.status == "running" %}
{% set _url = editor_url %}
{% set _uid = workspace.uid %}
{% set _class = "btn btn-primary" %}
{% set _mode = editor.window_mode %}
{% set _width = editor.window_width %}
{% set _height = editor.window_height %}
{% set _label = "Open editor" %}
{% include "_editor_open.html" %}
<a class="btn btn-primary" href="{{ editor_url }}" target="_blank" rel="noopener">Open editor</a>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn">Stop</button>
</form>
@ -87,128 +79,6 @@
</div>
</div>
<div class="card workspace-editor" data-editor-card>
<h2>Editor</h2>
<p class="workspace-muted">
Your workspace opens a branded DevPlace editor with the
<code>dpc</code> coding agent already running. These preferences are yours and
follow you into every workspace you open.
</p>
{% if restart_required %}
<div class="workspace-editor-restart">
<span>Your editor settings changed. Restart the workspace to apply them.</span>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn btn-sm">Stop</button>
</form>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
<button type="submit" class="btn btn-sm btn-primary">Start</button>
</form>
</div>
{% endif %}
<ul class="workspace-editor-summary">
<li><span>Theme</span><strong>{{ editor.theme }}</strong><em>{{ editor.sources.theme }}</em></li>
<li><span>Layout</span><strong>{{ editor.layout }}</strong><em>{{ editor.sources.layout }}</em></li>
<li><span>Panel</span><strong>{{ editor.panel_preset }}</strong><em>{{ editor.sources.panel_preset }}</em></li>
<li><span>Editor font</span><strong>{{ editor.font_size }} px</strong><em>{{ editor.sources.font_size }}</em></li>
<li><span>Terminal font</span><strong>{{ editor.terminal_font_size }} px</strong><em>{{ editor.sources.terminal_font_size }}</em></li>
<li><span>Zoom</span><strong>{{ editor.zoom_level }}</strong><em>{{ editor.sources.zoom_level }}</em></li>
<li><span>Agent on boot</span><strong>{{ editor.boot_agent }}</strong><em>{{ editor.sources.boot_agent }}</em></li>
<li><span>Shell on boot</span><strong>{{ "yes" if editor.boot_shell else "no" }}</strong><em>{{ editor.sources.boot_shell }}</em></li>
<li><span>Opens in</span><strong>{{ editor.window_mode }}</strong><em>{{ editor.sources.window_mode }}</em></li>
<li><span>Trusts every folder</span><strong>{{ "yes" if editor.trust_all else "no" }}</strong><em>site</em></li>
</ul>
<h3 class="workspace-editor-heading">Container size</h3>
<p class="workspace-muted">Set by an administrator through your workspace quota.</p>
<ul class="workspace-editor-summary workspace-editor-resources">
<li><span>CPU</span><strong>{{ editor.cpu_cores }} cores</strong><em>quota</em></li>
<li><span>Memory</span><strong>{{ editor.memory_mb }} MB</strong><em>quota</em></li>
<li><span>Disk</span><strong>{{ editor.disk_quota_mb }} MB</strong><em>quota</em></li>
</ul>
<form class="workspace-editor-form" method="post" data-native
action="/projects/{{ project.slug or project.uid }}/workspace/editor">
<label>Theme
<select name="theme">
<option value="">Site default</option>
<option value="devplace-dark" {{ "selected" if editor.sources.theme == "user" and editor.theme == "devplace-dark" }}>DevPlace Dark</option>
<option value="devplace-light" {{ "selected" if editor.sources.theme == "user" and editor.theme == "devplace-light" }}>DevPlace Light</option>
<option value="system" {{ "selected" if editor.sources.theme == "user" and editor.theme == "system" }}>Leave to me</option>
</select>
</label>
<label>Layout
<select name="layout">
<option value="">Site default</option>
<option value="standard" {{ "selected" if editor.sources.layout == "user" and editor.layout == "standard" }}>Standard</option>
<option value="terminal-focus" {{ "selected" if editor.sources.layout == "user" and editor.layout == "terminal-focus" }}>Terminal focus</option>
<option value="zen" {{ "selected" if editor.sources.layout == "user" and editor.layout == "zen" }}>Zen</option>
</select>
</label>
<label>Terminal panel
<select name="panel_preset">
<option value="">Site default</option>
<option value="short" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "short" }}>Short</option>
<option value="normal" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "normal" }}>Normal</option>
<option value="tall" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "tall" }}>Tall</option>
<option value="maximized" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "maximized" }}>Maximized</option>
</select>
</label>
<label>Editor font size
<input type="number" name="font_size" min="0" max="48"
value="{{ editor.font_size if editor.sources.font_size == 'user' else 0 }}">
</label>
<label>Terminal font size
<input type="number" name="terminal_font_size" min="0" max="48"
value="{{ editor.terminal_font_size if editor.sources.terminal_font_size == 'user' else 0 }}">
</label>
<label>Zoom level
<input type="number" name="zoom_level" min="-99" max="5"
value="{{ editor.zoom_level if editor.sources.zoom_level == 'user' else -99 }}">
</label>
<label>Agent on boot
<select name="boot_agent">
<option value="">Site default</option>
<option value="dpc" {{ "selected" if editor.sources.boot_agent == "user" and editor.boot_agent == "dpc" }}>DevPlace Code (dpc)</option>
<option value="none" {{ "selected" if editor.sources.boot_agent == "user" and editor.boot_agent == "none" }}>None</option>
</select>
</label>
<label>Shell on boot
<select name="boot_shell">
<option value="-1">Site default</option>
<option value="1" {{ "selected" if editor.sources.boot_shell == "user" and editor.boot_shell }}>Yes</option>
<option value="0" {{ "selected" if editor.sources.boot_shell == "user" and not editor.boot_shell }}>No</option>
</select>
</label>
<label>Open editor in
<select name="window_mode">
<option value="">Site default</option>
<option value="tab" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "tab" }}>A new tab</option>
<option value="window" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "window" }}>A sized window</option>
<option value="fullscreen" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "fullscreen" }}>A fullscreen window</option>
</select>
</label>
<label>Window width
<input type="number" name="window_width" min="0" max="7680"
value="{{ editor.window_width if editor.sources.window_width == 'user' else 0 }}">
</label>
<label>Window height
<input type="number" name="window_height" min="0" max="4320"
value="{{ editor.window_height if editor.sources.window_height == 'user' else 0 }}">
</label>
<div class="workspace-editor-actions">
<button type="submit" class="btn btn-primary">Save preferences</button>
</div>
</form>
<form class="workspace-editor-reset" method="post" data-native
action="/projects/{{ project.slug or project.uid }}/workspace/editor">
<input type="hidden" name="reset" value="true">
<button type="submit" class="btn btn-sm">Reset to site defaults</button>
</form>
</div>
<div class="card workspace-tunnels">
<h2>Public tunnels</h2>
<p class="workspace-muted">
@ -242,12 +112,9 @@
<div class="card workspace-help">
<h2>Inside the container</h2>
<ul>
<li>A <strong>DevPlace Code</strong> terminal running <code>dpc</code> opens for you on boot.</li>
<li>Every folder is trusted, so nothing opens in Restricted Mode and project tasks run.</li>
<li><code>sudo</code> and <code>apt install</code> work with no extra setup.</li>
<li>Python, Rust, Nim and Swift toolchains are preinstalled.</li>
<li>Ports below 1024 cannot bind. Use a high port and a tunnel.</li>
<li>Forwarding a port in the editor's <strong>Ports</strong> view publishes it here automatically.</li>
<li>Your public URLs are also in <code>/app/.devplace/tunnels.json</code>.</li>
</ul>
</div>

View File

@ -13,12 +13,7 @@ from devplacepy.avatar import avatar_url, avatar_seed
from devplacepy.utils import format_date as _format_date
from devplacepy.utils import time_ago as _time_ago
from devplacepy.utils import get_badge, is_admin, is_primary_admin, pretty_json
from devplacepy.attachments import (
IMAGE_EXTENSIONS,
allowed_extensions,
format_file_size,
file_icon_emoji,
)
from devplacepy.attachments import format_file_size, file_icon_emoji
from devplacepy.content import is_owner as _owns
from devplacepy.content import maturity_hidden as _maturity_hidden
from devplacepy.customization import custom_css_tag, custom_js_tag, page_type_for
@ -63,6 +58,13 @@ templates.env.globals["is_self"] = is_self
templates.env.globals["guest_disabled"] = guest_disabled
templates.env.globals["is_online"] = presence.is_online
def activity_recording_on(user) -> bool:
return bool(user) and presence.recording_allowed(user.get("uid", ""))
templates.env.globals["activity_recording_on"] = activity_recording_on
from devplacepy.docs_devrant import devrant_endpoints
templates.env.globals["devrant_endpoints"] = devrant_endpoints
@ -229,14 +231,9 @@ def jinja_allowed_file_types() -> str:
return get_setting("allowed_file_types", "")
def jinja_allowed_image_types() -> str:
return ",".join(sorted(allowed_extensions() & IMAGE_EXTENSIONS))
templates.env.globals["max_upload_size_mb"] = jinja_max_upload_size_mb
templates.env.globals["max_attachments_per_resource"] = jinja_max_attachments
templates.env.globals["allowed_file_types"] = jinja_allowed_file_types
templates.env.globals["allowed_image_types"] = jinja_allowed_image_types
_LANGUAGE_NAMES = {
"python": "Python",

View File

@ -66,10 +66,10 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `profile.update` | `routers/devrant/auth.py`, `routers/profile/index.py` |
| `account.delete.purge` | `services/moderation/deletion.py` |
| `account.delete.request` | `routers/devrant/auth.py`, `routers/profile/delete.py` |
| `consent.grant` | `routers/profile/consent.py`, `services/acceptance/grant.py` |
| `consent.grant` | `routers/profile/consent.py` |
| `profile.mature_content` | `routers/profile/consent.py` |
| `consent.withdraw` | `routers/profile/consent.py` |
| `terms.accept` | `routers/auth/terms.py`, `services/acceptance/grant.py` |
| `terms.accept` | `routers/auth/terms.py` |
## Administration (`admin`)
@ -213,7 +213,6 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `container.tunnel.suspend` | `services/containers/workspace_service.py` |
| `container.workspace.create` | `routers/projects/containers/workspace.py` |
| `container.workspace.delete` | `routers/projects/containers/workspace.py` |
| `container.workspace.editor.update` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` |
| `container.workspace.flag.dismiss` | `routers/admin/workspaces.py` |
| `container.workspace.flag.raise` | `services/containers/workspace_service.py` |
| `container.workspace.flag.resolve` | `routers/admin/workspaces.py` |
@ -422,7 +421,6 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `project.fork.request` | `routers/projects/index.py` |
| `project.readonly.disable` | `routers/projects/index.py` |
| `project.readonly.enable` | `routers/projects/index.py` |
| `project.screenshots.add` | `routers/projects/index.py` |
| `project.visibility.private` | `routers/projects/index.py` |
| `project.visibility.public` | `routers/projects/index.py` |
| `project.zip.request` | `routers/projects/index.py` |

View File

@ -1,6 +0,0 @@
Oke, we do have a system that has a lot of privacy and terms and ccondition options. That is perffect. But while extensiive manually testing, do not want to be bottered ever. Especially not on accceptence mode. Acceptence mode is like producton, but every terms and conditon will be applied automatically using a typical devplacce sercive with backwards compattebility, it shouuld run every five minutes and ensure that all termss and conditon are agreed to by literally every user unless manually declined.
To be profressonal, this feature is not allowed to be traced to the database because of custom fields sor whatever, remember, the system is in acceptnce mode what should literally be production mode but with that side effect. Of coursse, by default it must be turned off. It has to be put manually enabled because it would be a nightmre if it was triggered on production.
The only way to satissfy this is a isolated sservice in our appliction like the rest, but the 0ther applcation is not allowed to know anything about it. It just ensures severy five minutes that all users did comply to every type of thiingy for real produuction simuulaton.
It has to be implemented as single option in the admin but per type of `agreemenet` to be abble to test edge cases. Please do create a design that would fit fine like other servicess implemented defaults and servicecs.
Your only task for now is to creatae a full implementaton document of this acceptence mode. But again, the system is not allowed to know that it is running in acceptance mode, it would canccel the whole point and principle.
Now, generate the docuument called accept.md in detail conform our appliaton guidelines spread everywhere. Consistency and dry is key to success. Youre the best young man. Like if Lensflare would be an LLM and shit.

View File

@ -24,6 +24,7 @@ server {
client_max_body_size ${NGINX_MAX_BODY_SIZE};
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy strict-origin-when-cross-origin;
@ -208,8 +209,8 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location / {
@ -226,7 +227,7 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 900s;
proxy_connect_timeout 30s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;

View File

@ -12,9 +12,9 @@ ENV PYTHONUNBUFFERED=1 \
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
RUN apt-get update && apt-get install -y --no-install-recommends \
git openssh-client curl wget vim ack ca-certificates build-essential libpq-dev \
git curl wget vim ack ca-certificates build-essential libpq-dev \
tmux apache2-utils procps htop iftop iotop netcat-openbsd zip unzip \
rsync jq fakeroot xz-utils pkg-config \
fakeroot xz-utils pkg-config \
binutils gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libedit-dev \
libncurses-dev libpython3-dev libsqlite3-0 libsqlite3-dev uuid-dev \
libxml2-dev libz3-dev tzdata zlib1g-dev \
@ -87,20 +87,6 @@ RUN set -eu; \
tar -xzf /tmp/code-server.tar.gz -C /usr/local/lib/code-server --strip-components=1; \
rm -f /tmp/code-server.tar.gz; \
ln -sf /usr/local/lib/code-server/bin/code-server /usr/local/bin/code-server
COPY vscode/devplace-workspace /usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace
COPY vscode/branding/favicon.ico /usr/local/lib/code-server/src/browser/media/favicon.ico
COPY vscode/branding/favicon.svg /usr/local/lib/code-server/src/browser/media/favicon.svg
COPY vscode/branding/favicon-dark-support.svg /usr/local/lib/code-server/src/browser/media/favicon-dark-support.svg
COPY vscode/branding/pwa-icon-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-192.png
COPY vscode/branding/pwa-icon-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-512.png
COPY vscode/branding/pwa-icon-maskable-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-192.png
COPY vscode/branding/pwa-icon-maskable-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-512.png
COPY vscode/branding/devplace-login.css /tmp/devplace-login.css
COPY vscode/product.patch.json /tmp/product.patch.json
RUN set -eu; \
cat /tmp/devplace-login.css >> /usr/local/lib/code-server/src/browser/pages/login.css; \
python3 -c "import json,pathlib; p=pathlib.Path('/usr/local/lib/code-server/lib/vscode/product.json'); d=json.loads(p.read_text()); patch=json.loads(pathlib.Path('/tmp/product.patch.json').read_text()); d.update({k: ({**d[k], **v} if isinstance(v, dict) and isinstance(d.get(k), dict) else v) for k, v in patch.items()}); p.write_text(json.dumps(d, indent=2))"; \
rm -f /tmp/devplace-login.css /tmp/product.patch.json
COPY sudo /usr/local/bin/sudo
COPY aptroot /usr/local/bin/aptroot
COPY pagent /usr/bin/pagent.py
@ -156,23 +142,4 @@ RUN set -eu; \
done; \
[ -f /home/pravda/.vimrc ] || { echo "missing /home/pravda/.vimrc"; exit 1; }
RUN set -eu; \
ext=/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace; \
[ -f "$ext/package.json" ] || { echo "missing the DevPlace extension"; exit 1; }; \
[ -f "$ext/extension.js" ] || { echo "missing the DevPlace extension entry point"; exit 1; }; \
for theme in devplace-dark devplace-light; do \
python3 -c "import json,sys; json.load(open('$ext/themes/$theme.json'))" \
|| { echo "invalid theme: $theme"; exit 1; }; \
done; \
python3 -c "import json; d=json.load(open('$ext/package.json')); assert d['contributes']['configurationDefaults']['security.workspace.trust.enabled'] is False, 'trust default lost'"; \
[ -f /usr/local/lib/code-server/src/browser/media/favicon.svg ] || { echo "missing the DevPlace favicon"; exit 1; }; \
python3 -c "import json; d=json.load(open('/usr/local/lib/code-server/lib/vscode/product.json')); assert d['nameShort']=='DevPlace', d['nameShort']; assert d['nameLong']=='DevPlace Workspace', d['nameLong']"; \
grep -q 'devplace-login-theme' /usr/local/lib/code-server/src/browser/pages/login.css \
|| { echo "the DevPlace login stylesheet was not applied"; exit 1; }; \
for flag in --app-name --disable-workspace-trust --disable-getting-started-override --welcome-text; do \
code-server --help 2>&1 | grep -q -- "$flag" \
|| { echo "code-server no longer supports $flag"; exit 1; }; \
done; \
echo "DevPlace branding verified"
CMD ["sleep", "infinity"]

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