Codumentor logo Codumentor

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,
version)
. Bumping the version re-prompts everyone.

Use cases this capability is designed for:

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

FieldTypeRequiredDescription
gate_idstringyesUnique identifier. Convention: {plugin_id}.{name}. Used in URLs and acceptance rows.
versionstringyesBump to re-prompt every user. Free-form (semver, date, anything).
titlestringyesModal header text.
content_pathstringone ofPath to a markdown file inside the plugin package. Core renders it with the standard markdown pipeline.
custom_elementstringone ofCustom-element tag from the plugin's UI bundle, mounted inside the modal body. For rich content (forms, interactive flows).
dismissableboolno (default false)If true, an "X" closes the modal without recording acceptance; the gate reappears next login.
completionenumno (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).
audienceobjectnoIf 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.
priorityintno (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:

  1. Auto-instance match: audience is omitted, or U matches at least one listed filter; or
  2. Runtime row: a row exists in pending_user_gates for (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


HTTP endpoints

The runtime exposes three endpoints; you generally don't call these
directly from a plugin, but tests and admin tooling do:

MethodPathPurpose
GET/ui/user/pending-gatesList gates pending for the caller
POST/ui/user/gates/{gate_id}/acceptRecord acceptance (body: {"version": "..."}). 409 Conflict if the supplied version no longer matches the template's current version
POST/ui/admin/gatesAdmin-only runtime enqueue. Body: `{"target": {"user_id"\

Examples

For end-to-end testing patterns (admin-enqueue + sqlite cleanup so the
spec is repeatable locally), see ui/e2e/eula-popup.spec.ts.