Gate blocked actions behind an in-place terms acceptance dialog

A member whose account has not accepted the terms in force now gets one
dialog on the action they attempted instead of a dead-end refusal. The
client handler is the single TermsGate, wired into every Http POST helper
so the four optimistic controllers cannot swallow the gate into an error
flash, and the original request is replayed once the acceptance is
recorded. Reading the site and deleting an account stay unblocked.

apple.md is the source brief the compliance research documents reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:25:57 +02:00
co-authored by Claude Opus 5
parent 8e9d3fad98
commit 91fac7fd67
38 changed files with 393 additions and 58 deletions
+2
View File
@@ -15,6 +15,8 @@ from devplacepy.utils import clear_user_cache, require_user
logger = logging.getLogger(__name__)
router = APIRouter()
TERMS_ACCEPTANCE_CODE = "terms_acceptance_required"
def current_terms_version() -> str:
return get_setting("terms_version", "1") or "1"
+4 -4
View File
@@ -2000,9 +2000,9 @@ async def reflect(observation: str, conclusion: str, next_action: str):
@tool
async def verify(command: str = "hawk .", timeout: int = 600):
async def verify(command: str, timeout: int = 600):
"""Run a verification command (linter, tests, validator). Marks the task verified on success.
command: Shell command, default 'hawk .'.
command: Shell command that verifies the change, for example the project's test or lint command.
timeout: Timeout in seconds.
"""
try:
@@ -2526,7 +2526,7 @@ async def react_loop(
"role": "user",
"content": (
"[verification-gate] You produced a final answer after modifying files "
"without a successful verify(). Call verify() now (default 'hawk .') and "
"without a successful verify(). Call verify() with the command that verifies this project now and "
"report the result. If verification truly does not apply, reply starting "
"with: 'No verification applicable: <reason>'."
),
@@ -2559,7 +2559,7 @@ OPERATING PROTOCOL
3. INVESTIGATE BEFORE EDITING. Use grep, glob_files, list_dir, find_symbol, and retrieve to navigate. You MUST read_file (or read_lines) an existing file before edit_file, patch_file, or write_file touches it — the harness enforces this. Prefer edit_file for surgical replacements, patch_file for multi-hunk diffs, create_file for new files, write_file for full rewrites of files you have read.
4. VERIFY BEFORE FINISHING. Whenever you modify files, call verify() (default 'hawk .') before your final answer. The harness rejects a final answer that changed files without a successful verify().
4. VERIFY BEFORE FINISHING. Whenever you modify files, call verify() with the command that verifies this project before your final answer. The harness rejects a final answer that changed files without a successful verify().
5. REFLECT ON FAILURE. After any tool returns status=error, the harness injects a reflection trigger. Respond with reflect() (observation, conclusion, next_action), then proceed. Never blindly retry the same call.
+4 -4
View File
@@ -1757,9 +1757,9 @@ async def reflect(observation: str, conclusion: str, next_action: str):
@tool
async def verify(command: str = "hawk .", timeout: int = 600):
async def verify(command: str, timeout: int = 600):
"""Run a verification command (linter, tests, validator). Marks the task verified on success.
command: Shell command, default 'hawk .'.
command: Shell command that verifies the change, for example the project's test or lint command.
timeout: Timeout in seconds.
"""
try:
@@ -2196,7 +2196,7 @@ async def react_loop(
"role": "user",
"content": (
"[verification-gate] You produced a final answer after modifying files without a successful verify(). "
"Call verify() now (default 'hawk .') and report the result. If verification truly does not apply, reply "
"Call verify() with the command that verifies this project now and report the result. If verification truly does not apply, reply "
"starting with: 'No verification applicable: <reason>'."
),
})
@@ -2224,7 +2224,7 @@ OPERATING PROTOCOL
3. INVESTIGATE BEFORE EDITING. Use grep, glob_files, list_dir, find_symbol, and retrieve to navigate. You MUST read_file (or read_lines) an existing file before edit_file, patch_file, or write_file touches it — the harness enforces this. Prefer edit_file for surgical replacements, patch_file for multi-hunk diffs, create_file for new files, write_file for full rewrites of files you have read.
4. VERIFY BEFORE FINISHING. Whenever you modify files, call verify() (default 'hawk .') before your final answer. The harness rejects a final answer that changed files without a successful verify().
4. VERIFY BEFORE FINISHING. Whenever you modify files, call verify() with the command that verifies this project before your final answer. The harness rejects a final answer that changed files without a successful verify().
5. REFLECT ON FAILURE. After any tool returns status=error, the harness injects a reflection trigger. Respond with reflect() (observation, conclusion, next_action), then proceed. Never blindly retry the same call.
+4 -4
View File
@@ -1712,9 +1712,9 @@ async def fetch_url(url: str, max_bytes: int = 1048576):
@tool
async def verify(command: str = "hawk .", timeout: int = 600):
async def verify(command: str, timeout: int = 600):
"""Run a verification command (tests, linter, validator) and return whether it passed. Marks the task verified on success.
command: Shell command, default 'hawk .' per project conventions.
command: Shell command that verifies the change, for example the project's test or lint command.
timeout: Timeout in seconds.
"""
try:
@@ -2221,7 +2221,7 @@ async def react_loop(
"role": "user",
"content": (
"[verification-gate] You produced a final answer after modifying files "
"without calling verify(). Call verify() now (default 'hawk .') and "
"without calling verify(). Call verify() with the command that verifies this project now and "
"report the result. If verification truly does not apply, reply explicitly "
"starting with: 'No verification applicable: <reason>'."
),
@@ -2267,7 +2267,7 @@ OPERATING PROTOCOL
3. INVESTIGATE BEFORE EDITING. Use grep, glob_files, list_dir, find_symbol, and retrieve to navigate the codebase. Use read_file or read_lines before modifying. Prefer edit_file for surgical text replacements; create_file for new files; reserve write_file for full rewrites of files you have already read.
4. VERIFY BEFORE FINISHING. Whenever you modify files, call verify() before producing a final answer (default command 'hawk .'). The harness will reject a final answer that involved file changes without a successful verify().
4. VERIFY BEFORE FINISHING. Whenever you modify files, call verify() with the command that verifies this project before producing a final answer. The harness will reject a final answer that involved file changes without a successful verify().
5. REFLECT ON FAILURE. After any tool returns status=error, the harness injects a reflection trigger. Respond by calling reflect() with observation/conclusion/next_action, then proceed. Never blindly retry the same call.
+15
View File
@@ -114,6 +114,21 @@ The gate is at exactly one place: `GatewayService.consent_denied` in `services/o
Every reader of a policy version uses `get_setting(key, "1") or "1"`. **This is not cosmetic**: on a fresh database `init_db` skips the settings seed (its `tables` snapshot predates `site_settings`), so an admin settings save can insert `terms_version = ""`, and a bare `get_setting` would then compare every user's `"1"` against `""` and 403 every write on the platform. That was a real failure; keep the `or "1"`.
### The refusal is self-describing, and the client acts on it
A browser form POST is a real navigation, so the `303` to `/auth/accept-terms` already works with no JS. A **fetch** caller cannot follow that, so the JSON branch carries everything the client needs to resolve the block itself:
```json
{"error": {"status": 403, "message": "Accept the updated Terms of Service to continue.",
"code": "terms_acceptance_required", "redirect": "/auth/accept-terms", "terms_version": "1"}}
```
`code` is the contract - the client matches on it, never on the message text. It is `TERMS_ACCEPTANCE_CODE` in `routers/auth/terms.py`, the single definition, imported by `main.py` and asserted by the api test. `terms_version` lets the dialog name the version without a second request.
The client side is `static/js/TermsGate.js` (`app.termsGate`); see `devplacepy/static/js/CLAUDE.md`. **Do not add a second terms check, a second refusal shape, or a per-caller handler** - `Http` routes every fetch refusal through the one gate, so a new fetch caller inherits the behaviour with no work.
**Note on `users.terms_version`:** `init_db` ensures the column but deliberately does **not** backfill it, so every account predating the trust-and-safety commit reads `NULL` and must accept. That is correct - a backfill would fabricate consent nobody gave - but it means the gate is the normal state for legacy accounts, not a rare edge, so the accept path must stay one click.
## Maturity
`content_maturity` is polymorphic (`target_type`, `target_uid`), read through the batch helper `get_maturity_by_targets` - never per row. Absence of a row means `general`, so nothing needed backfilling. `content.maturity_hidden(level, user)` is the single predicate (also the `maturity_hidden` Jinja global) and `_maturity_gate.html` is the single interstitial; `enrich_items` and `load_detail` attach `maturity` so listings and detail pages both have it with one query.
+14
View File
@@ -27,6 +27,20 @@
word-break: break-word;
}
.dialog-links {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--space-sm) var(--space-md);
margin: 0 0 1rem;
padding: 0;
font-size: 0.875rem;
}
.dialog-links a {
color: var(--accent);
}
.dialog-field {
margin-bottom: 1rem;
}
+2
View File
@@ -34,6 +34,7 @@ import { IssueAttachments } from "./IssueAttachments.js";
import { PlanningGenerator } from "./PlanningGenerator.js";
import { MediaGallery } from "./MediaGallery.js";
import { ReportDialog } from "./ReportDialog.js";
import { TermsGate } from "./TermsGate.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
@@ -69,6 +70,7 @@ class Application {
this.toast = document.createElement("dp-toast");
this.lightbox = document.createElement("dp-lightbox");
document.body.append(this.dialog, this.contextMenu, this.toast, this.lightbox);
this.termsGate = new TermsGate(this.dialog);
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
+3
View File
@@ -43,6 +43,9 @@ A small set of plain ES6 modules under `static/js/` own the cross-cutting patter
Detail on each utility:
- **`Http` (`static/js/Http.js`, global `window.Http`).** The single HTTP helper. `getJson(url)` (GET -> JSON, throws on non-2xx); `sendForm(url, params)` (POST form-encoded, follows the `/auth/login` redirect via `Http.toLogin()`, throws a bare status on failure, returns JSON); `send(url, params)` (POST form-encoded that throws `data.error.message` on `!ok` **or** a 200 body with `ok:false` - the manager-style error the container/admin UIs surface in a toast); `postJson`/`postForm`/`toLogin`. Container files (`ContainerManager`, `ContainerList`, `ContainerInstance`, `ContainerTerminal`), `ServiceMonitor`, and `ProjectFiles` all route through it - none re-implement `fetch`.
`Http.suspend()` is the named "we are resolving this elsewhere, do not let the caller render an error" idiom (a promise that never settles). It replaced three inline `new Promise(() => {})` copies and is what `toLogin()` and the terms gate both return.
- **`TermsGate` (`static/js/TermsGate.js`, `app.termsGate`).** The single client-side handler for the terms-acceptance refusal. `Http` calls `Http._gate(data, options, retry)` on **every** POST helper (`sendForm`, `send`, `postJson`); when the error payload carries `code: "terms_acceptance_required"` it hands off to `app.termsGate.intercept(error, retry)`, which shows one dialog (Accept and continue / Not now, with the Terms, Guidelines and Privacy links), POSTs `/auth/accept-terms` on accept, and then **re-runs the original request** so the click the user made actually happens. Declining returns `Http.suspend()`.
Load-bearing details: the handoff runs **before** the `options.silent` check, because the four `OptimisticAction` controllers (vote/react/bookmark/poll) pass `silent: true` and would otherwise swallow a blocking gate into a 1.5s "Error" flash; `options.termsRetry` bounds the retry to exactly one pass; and `confirm()`/`accept()` are each deduped by a stored promise so N concurrent gated requests produce one dialog and one acceptance POST. `dp-upload` bypasses `Http` (it needs `FormData`), so it checks `app.termsGate.matches(data)` itself - any other raw-`fetch` caller must do the same. Never add a per-caller terms check: the backend contract lives in `routers/auth/terms.py` `TERMS_ACCEPTANCE_CODE` and is documented in `devplacepy/services/moderation/CLAUDE.md`.
- **`Poller` (`static/js/Poller.js`).** `new Poller(fn, intervalMs, { immediate = true, pauseHidden = false })` runs `fn` on an interval with `start()`/`stop()`/`tick()`; `tick()` swallows errors so one failed poll never kills the loop, and `pauseHidden` skips the tick while `document.hidden`. Used by every live-update loop: `CounterManager` (30s, `pauseHidden`), `ContainerManager` (3s), `ContainerList` (4s), `AiUsageMonitor`, `ServiceMonitor`, and `ContainerInstance`'s detail (4s) + logs (3s). Store the `Poller`, not a raw interval id.
- **`JobPoller` (`static/js/JobPoller.js`).** `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout, intervalMs = 1500, maxAttempts = 200 })` returns a Promise; it polls `Http.getJson(statusUrl)`, swallows transient fetch errors, and fires the matching callback on `status === "done"|"failed"` or timeout. This is the one place the async-job status-poll lives - `ProjectForker` and `ZipDownloader` both call it with their own navigate/download/toast callbacks.
- **`OptimisticAction` (`static/js/OptimisticAction.js`).** Base with one method, `submit(url, params, errorTarget, render)`: `Http.sendForm` -> `render(result)` on success -> `console.error` + (when `errorTarget` is given) `Toast.flash(errorTarget, "Error", 1500)` on failure. `VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager` `extend` it and call `this.submit(...)` for their POST, **keeping their own event wiring** (so `ReactionBar`'s palette toggle and `PollManager`'s multi-action handlers and `VoteManager`'s per-button `stopPropagation` are untouched). Pass `errorTarget` only where the old code toasted (`VoteManager`); the others pass `null` to keep their console-only behaviour.
+28 -5
View File
@@ -6,6 +6,17 @@ export class Http {
window.location.href = `/auth/login?next=${next}`;
}
static suspend() {
return new Promise(() => {});
}
static _gate(data, options, retry) {
if (options.termsRetry) return null;
const gate = window.app && window.app.termsGate;
if (!gate || !gate.matches(data)) return null;
return gate.intercept(data.error, retry);
}
static notifyError(message) {
const text = (message && String(message).trim()) || "Something went wrong. Please try again.";
const app = window.app;
@@ -54,10 +65,14 @@ export class Http {
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
return Http.suspend();
}
if (!response.ok) {
const { message } = await Http._messageFrom(response);
const { data, message } = await Http._messageFrom(response);
const gate = Http._gate(data, options, () =>
Http.sendForm(url, params, { ...options, termsRetry: true })
);
if (gate) return gate;
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
@@ -75,11 +90,15 @@ export class Http {
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
return Http.suspend();
}
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
const message = (data.error && data.error.message) || `request failed: ${response.status}`;
const gate = Http._gate(data, options, () =>
Http.send(url, params, { ...options, termsRetry: true })
);
if (gate) return gate;
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
@@ -94,10 +113,14 @@ export class Http {
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
return Http.suspend();
}
if (!response.ok) {
const { message } = await Http._messageFrom(response);
const { data, message } = await Http._messageFrom(response);
const gate = Http._gate(data, options, () =>
Http.postJson(url, body, { ...options, termsRetry: true })
);
if (gate) return gate;
if (!options.silent) Http.notifyError(message);
throw Http._error(message, response.status);
}
+79
View File
@@ -0,0 +1,79 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export const TERMS_ACCEPTANCE_CODE = "terms_acceptance_required";
const ACCEPT_URL = "/auth/accept-terms";
const TERMS_LINKS = [
{ href: "/docs/terms.html", label: "Terms of Service" },
{ href: "/docs/community-guidelines.html", label: "Community Guidelines" },
{ href: "/docs/privacy.html", label: "Privacy Policy" },
];
export class TermsGate {
constructor(dialog) {
this.dialog = dialog;
this.prompted = null;
this.accepted = null;
}
matches(payload) {
const error = payload && payload.error;
return !!error && error.code === TERMS_ACCEPTANCE_CODE;
}
async intercept(error, retry) {
if (!(await this.confirm(error))) {
return Http.suspend();
}
if (!(await this.accept())) {
return Http.suspend();
}
return retry();
}
confirm(error) {
if (!this.prompted) {
this.prompted = this.dialog
.confirm({
title: "Terms of Service",
message: TermsGate.message(error),
links: TERMS_LINKS,
confirmLabel: "Accept and continue",
cancelLabel: "Not now",
})
.then((answer) => {
this.prompted = null;
return answer === true;
});
}
return this.prompted;
}
accept() {
if (!this.accepted) {
this.accepted = Http.sendForm(ACCEPT_URL, {}, { silent: true, termsRetry: true })
.then(() => true)
.catch(() => {
Http.notifyError("Your acceptance could not be recorded. Please try again.");
return false;
})
.finally(() => {
this.accepted = null;
});
}
return this.accepted;
}
static message(error) {
const version = error && error.terms_version;
const subject = version ? `Version ${version} of the terms` : "The terms";
return (
`${subject} is in force and your account has not accepted it yet, ` +
"so this action was not carried out. Accepting records your agreement and repeats the action. " +
"Reading the site and deleting your account are never blocked."
);
}
}
@@ -24,6 +24,7 @@ export class AppDialog extends Component {
'<div class="modal-header"><h3 class="dialog-title"></h3>' +
'<button type="button" class="modal-close btn-ghost btn-icon dialog-close">&times;</button></div>' +
'<p class="dialog-message"></p>' +
'<ul class="dialog-links" hidden></ul>' +
'<div class="dialog-field" hidden><label class="dialog-field-label"></label>' +
'<input type="text" class="dialog-input" autocomplete="off"></div>' +
'<div class="modal-footer">' +
@@ -40,6 +41,7 @@ export class AppDialog extends Component {
this.messageEl.id = `dialog-message-${uid}`;
overlay.setAttribute("aria-labelledby", this.titleEl.id);
overlay.setAttribute("aria-describedby", this.messageEl.id);
this.links = overlay.querySelector(".dialog-links");
this.field = overlay.querySelector(".dialog-field");
this.fieldLabel = overlay.querySelector(".dialog-field-label");
this.input = overlay.querySelector(".dialog-input");
@@ -94,6 +96,7 @@ export class AppDialog extends Component {
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
this.cancelBtn.style.display = mode === "alert" ? "none" : "";
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
this.renderLinks(opts.links);
if (mode === "prompt") {
this.field.hidden = false;
@@ -116,6 +119,22 @@ export class AppDialog extends Component {
return new Promise((resolve) => { this.resolver = resolve; });
}
renderLinks(links) {
const items = Array.isArray(links) ? links : [];
this.links.replaceChildren();
this.links.hidden = !items.length;
for (const item of items) {
const anchor = document.createElement("a");
anchor.href = item.href;
anchor.textContent = item.label;
anchor.target = "_blank";
anchor.rel = "noopener";
const row = document.createElement("li");
row.appendChild(anchor);
this.links.appendChild(row);
}
}
accept() {
const value = this.mode === "prompt" ? this.input.value : true;
this.close(value);
+5 -1
View File
@@ -163,7 +163,7 @@ export class AppUpload extends Component {
return true;
}
async upload(file) {
async upload(file, termsRetry = false) {
this.button.classList.add("uploading");
this.setBusy(true);
const body = new FormData();
@@ -179,6 +179,10 @@ export class AppUpload extends Component {
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false || data.error) {
const gate = window.app && window.app.termsGate;
if (!termsRetry && gate && gate.matches(data)) {
return gate.intercept(data.error, () => this.upload(file, true));
}
const message = (data.error && (data.error.message || data.error)) || "Upload failed";
throw new Error(message);
}
+2 -2
View File
@@ -52,8 +52,8 @@ The last two subagents are not reviewers.
Each subagent operates in one of two modes, chosen by how it is invoked.
- **Report** (default): record findings only, change nothing.
- **Fix**: apply a minimal root-cause fix per the doctrine, then run the project
validator (`hawk .`) and confirm the build still imports.
- **Fix**: apply a minimal root-cause fix per the doctrine, then re-validate every
touched file with the per-language checks and confirm the build still imports.
A subagent never runs the test suite and never performs a git write.
@@ -17,7 +17,11 @@ Each returns a Promise resolving when the user responds.
| `alert(options)` | `undefined` once acknowledged. |
`options`: `title`, `message`, `confirmLabel`, `cancelLabel`, `danger` (red confirm button),
and for `prompt`: `label`, `value`, `placeholder`.
`links`, and for `prompt`: `label`, `value`, `placeholder`.
`links` is an optional array of `{href, label}` rendered as a row of links between the message and
the buttons, each opening in a new tab so the dialog and the pending action survive the click. Use it
when the user is being asked to agree to something they must be able to read first.
## Usage
@@ -52,11 +52,14 @@ order so nothing is dropped:
Never declare work done with a broken import or a validation error:
```bash
python -c "from devplacepy.main import app" # must import clean
hawk . # Python, JS, CSS, templates
python -c "from devplacepy.main import app" # must import clean
python -m py_compile <changed .py> # Python syntax
python -m pyflakes <changed .py> # Python lint
node --check <changed .js> # JavaScript
```
Tests live in
Changed stylesheets are checked for brace balance and changed templates for tag
and `{% %}` balance. Tests live in
`tests/` and run with `make test-unit`, `make test-api`, and `make test-e2e`.
## Read next