User Gates
A user gate is a per-user blocking modal a plugin can declare in its
ui_manifest(). The modal appears for matching users until they accept it;
acceptance is recorded in the core user-store keyed by (user_id, gate_id,. Bumping the version re-prompts everyone.
version)
Use cases this capability is designed for:
- First-login End-User License Agreement / Terms of Service (the EULA plugin is the reference consumer)
- Privacy policy / DPA updates
- Telemetry / data-retention consent
- Forced password change (admin-triggered, per user)
- MFA enrollment, security-incident acknowledgement
- Trial / quota exhaustion notices
For the architecture, schema-migration details, and rejected alternatives,
see the design doc.
Declaring a gate template
Add a top-level user_gates array to your plugin's ui_manifest():
def ui_manifest(self) -> Dict[str, Any]:
return {
"plugin_id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"user_gates": [
{
"gate_id": "my_plugin.terms",
"version": "v1",
"title": "Terms of Service",
"content_path": "TERMS.md", # OR custom_element: "x-my-gate"
"dismissable": False,
"completion": "ack",
"priority": 10,
# audience omitted → auto-instances for every authenticated user
}
],
}
Field reference
| Field | Type | Required | Description |
|---|---|---|---|
gate_id | string | yes | Unique identifier. Convention: {plugin_id}.{name}. Used in URLs and acceptance rows. |
version | string | yes | Bump to re-prompt every user. Free-form (semver, date, anything). |
title | string | yes | Modal header text. |
content_path | string | one of | Path to a markdown file inside the plugin package. Core renders it with the standard markdown pipeline. |
custom_element | string | one of | Custom-element tag from the plugin's UI bundle, mounted inside the modal body. For rich content (forms, interactive flows). |
dismissable | bool | no (default false) | If true, an "X" closes the modal without recording acceptance; the gate reappears next login. |
completion | enum | no (default "ack") | "ack" = clicking "I accept" records acceptance. "action" = the plugin's custom element calls the host's onAccept callback when a side-effect completes (e.g. password change finished). |
audience | object | no | If omitted, the template auto-instances for every authenticated user. If present, filters: roles: string[], usernames: string[], providers: string[] — a user matches if they satisfy any listed filter. Set audience: {} (empty object) to opt out of auto-instancing entirely; the template then only surfaces via runtime enqueue. |
priority | int | no (default 50) | Lower = shown first when several gates are pending. |
How a gate becomes pending
A template G is pending for user U iff there is no acceptance row at
(user_id=U.id, gate_id=G, version=V_current) AND either:
- Auto-instance match:
audienceis omitted, orUmatches at least one listed filter; or - Runtime row: a row exists in
pending_user_gatesfor(U, G, V_current)(see runtime enqueue).
If audience is {} and no runtime row exists, the template never
surfaces for U — useful for plugins that only want to surface a gate in
response to a specific trigger (forced password change, security incident).
Runtime enqueue
Plugins or backend code can dynamically surface a gate for a specific user
by calling the GateRegistry. The registry lives on
request.app.state.gate_registry:
from fastapi import APIRouter, Request, Depends
from codumentor.auth.middleware import require_permission
router = APIRouter()
@router.post("/admin/users/{user_id}/force-password-change")
async def force_password_change(
user_id: str,
request: Request,
_admin=Depends(require_permission("admin:*")),
):
request.app.state.gate_registry.enqueue_runtime_gate(
user_id=user_id,
gate_id="auth.force_password_change",
version="v1",
payload={"reason": "admin_initiated"},
)
return {"ok": True}
Bulk variant for role-targeted gates:
request.app.state.gate_registry.enqueue_runtime_gate_for_role(
role="engineer",
gate_id="my_plugin.maintenance_notice",
version="2026-06-01",
payload={"window": "Fri 22:00 UTC"},
)
payload is opaque to core; it is forwarded as the data property on the
plugin's custom element. Use it for per-user context the modal needs to
render correctly.
Near-real-time refresh
When a runtime row is inserted, the registry marks the affected user as
"dirty". The next authenticated response that user receives carries an
X-Pending-Gates-Dirty: 1 header; the frontend listens for that header and
re-fetches /ui/user/pending-gates. No SSE/WebSocket required. The
fallback path is a route-change re-fetch in the SPA, which catches writes
from worker processes that didn't tag any of the user's responses.
Custom-element gates
When a gate sets custom_element instead of content_path, the modal body
mounts the declared tag from your UI bundle. The host wires four properties
on the element:
interface GatePluginElement extends HTMLElement {
data: unknown; // payload from the gate
gate: { gate_id: string; version: string };
onAccept: () => Promise<void>; // host-provided; element calls this
onDismiss?: () => void; // only when dismissable is true
}
For completion: "action" gates, the host does NOT render the "I accept"
button — the plugin element drives the flow and calls onAccept() when a
side-effect (password change, MFA enrollment, etc.) completes.
The bundle is loaded via the standard extensionLoader path, so the same
bundle_url / custom_elements declarations from your ui block apply.
Acceptance semantics
- Idempotent. Accepting twice is a no-op (
INSERT OR IGNOREon the primary key(user_id, gate_id, version)). - Version bumps re-prompt. A new version has no acceptance row, so the gate becomes pending again for all users it auto-instances for.
- No "ungrant". Acceptances are never deleted by the runtime. If you need to revoke (account closed and re-opened, compliance re-attestation), bump the version.
- Audit trail. The
accepted_attimestamp is the audit record. We do not store the rendered markdown in the acceptance row — each(gate_id, version)is immutable by convention, and the markdown file is checked in with the release that introduced that version. Auditors trace back via git history. - Version-skew runtime rows. A runtime row enqueued at
v1is stale if the manifest is now atv2.list_pendingfilters mismatched rows and a cleanup pass deletes them on read. A re-deploy that bumps a template version effectively cancels in-flight runtime instances — an admin must re-enqueue at the new version.
HTTP endpoints
The runtime exposes three endpoints; you generally don't call these
directly from a plugin, but tests and admin tooling do:
| Method | Path | Purpose |
|---|---|---|
GET | /ui/user/pending-gates | List gates pending for the caller |
POST | /ui/user/gates/{gate_id}/accept | Record acceptance (body: {"version": "..."}). 409 Conflict if the supplied version no longer matches the template's current version |
POST | /ui/admin/gates | Admin-only runtime enqueue. Body: `{"target": {"user_id"\ |
Examples
- EULA plugin — markdown-content gate, default audience (everyone),
dismissable: false. ~30 lines of Python + a markdown file. - Forced password change (illustrative; not currently shipped) — custom-element gate with
audience: {}so it only surfaces via runtime enqueue from an admin endpoint,completion: "action"so the plugin drives the flow.
For end-to-end testing patterns (admin-enqueue + sqlite cleanup so the
spec is repeatable locally), see ui/e2e/eula-popup.spec.ts.