# Authentication

> Gate documentation pages with Clerk — as a standalone site, or as one satellite in a network of documentation sites

**Site index:** [https://boilerplate.2plot.dev/llms.txt](https://boilerplate.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://boilerplate.2plot.dev/sitemap.xml  


---



### Start here: you probably don't need this

Authentication in this template is **entirely optional and off by default**.
With no `CLERK_*` keys in the environment — which is how a fresh clone runs —
nothing on this page is active. No script injection, no session machinery, no
routes, no per-request checks. Every page is public, exactly as it would be if
this page did not exist.

That is deliberate, and it is the first design rule worth stating: **a
documentation site must never break because a deploy forgot a credential.**
Everything below fails in that direction.

Read the layer you need and stop:

| You want | Read |
|---|---|
| Public documentation, no accounts | Nothing. You're done. |
| One site, some pages behind a login | **Layer 1** |
| Several documentation sites sharing one account system | **Layer 2** |

---

### The distinction the whole design follows from

Two different questions get asked of a documentation site, and only one of them
arrives with a session attached:

| | Question | Answered by | Asked by |
|---|---|---|---|
| 1 | Who is this human? | A session cookie | A person in a browser |
| 2 | May this fetch of a text file proceed? | A key in the URL | An agent, later, elsewhere |

**A cookie can never answer question 2.** When someone signs in, copies
`https://yoursite.dev/guide/llms.txt`, and pastes it into an assistant, the
assistant fetches that URL with **no cookie**. A cookie-only gate therefore
returns the gate document to the one consumer the URL exists for, and your site
looks broken to exactly the audience `llms.txt` was built for.

So authority has to be able to travel *in the URL*:

```
https://yoursite.dev/guide/llms.txt?key=k2p_8f31…
```

Layer 1 needs only question 1. Layer 2 needs both.

---

### Layer 1 — one site, your own Clerk

The simplest useful setup: your docs, your Clerk application, some pages
requiring a login.

#### 1. Install the auth package

`dash-clerk-auth` is not on PyPI. Vendor the wheel and install it:

```bash
pip install ./vendor/dash_clerk_auth-0.9.1.tar.gz
```

It stays out of the active `requirements.txt` on purpose — a default install
should not pull in an auth stack the site does not use.

#### 2. Set the keys

```env
CLERK_SECRET_KEY=sk_test_...        # backend SDK key, never sent to the client
CLERK_PUBLISHABLE_KEY=pk_test_...   # embedded in the injected <script>
CLERK_SIGN_IN_URL=https://your-app.accounts.dev/sign-in
SESSION_SECRET=<a long random string>
```

All three `CLERK_*` values must be present. `lib/auth.py`'s `clerk_enabled()`
is the single source of truth, and it is all-or-nothing by design — a
half-configured auth stack is worse than none, because it looks enabled.

`SESSION_SECRET` is not optional in production. Without it `dash-clerk-auth`
falls back to a **public development default**, and the app warns loudly at
startup.

#### 3. Mark a page

Tiers live in the page's markdown frontmatter:

```markdown
---
name: Internal Runbook
description: How we deploy
endpoint: /runbook
tier: admin
---
```

Four tiers, least to most restrictive:

| Tier | Who may read | The document | Listed in sitemap and index? |
|---|---|---|---|
| `public` | anyone, including agents and crawlers | prose, 200 | yes |
| `auth` | any signed-in user | gate document, **200** | **yes** |
| `admin` | signed in *and* allowlisted | gate document, 200 | yes |
| `hidden` | nobody | **404** | no |

`auth` and `hidden` are genuinely different intents, and the difference is what
makes a sign-up funnel work:

- **`auth` / `admin` → gated.** "This document exists and you may not read it
  yet." The URL stays in the sitemap and the index, because a page's existence
  is public knowledge even when its content is not. An agent that fetches it
  learns what the page is and how its user gets access.
- **`hidden` → denied.** "There is nothing here." Same 404 as a page that never
  existed, delisted everywhere. Use it where advertising the URL is itself a
  disclosure.

Absent frontmatter, a page is `public`. Change the default for a whole site
with `PAGE_DEFAULT_TIER`.

#### 3b. Flip tiers live: the control board

Frontmatter is the *declaration*; `/admin/control-board` is the *override*.
Every docs page gets a row there with a tier selector and an
`llms.txt public` switch, and a toggle applies on the next page render —
no restart, no redeploy. The full precedence, strongest claim first:

```
control-board override  ->  frontmatter tier:  ->  PAGE_DEFAULT_TIER
```

with the hub's network ceiling still applied on top of whatever resolves
locally — an override can loosen your own declaration, never what the
network restricted.

The board is owner/admin-only (the `ADMIN_EMAILS` / `ADMIN_USER_IDS`
allowlist below) and **fails closed**: without Clerk configured it
returns a 404-style card rather than a working panel, because a board
that can hide any page on the site must never fall open. Set
`ALLOW_UNGATED_ADMIN=1` to work on it locally.

Overrides persist to `PAGE_VISIBILITY_FILE` — point it at a mounted disk
in production (`/var/data/page_visibility.json` in `render.yaml`) or
every toggle silently resets on the next deploy. The boot log warns
loudly when the variable is unset *or* the path is not on a real mount;
the absence of that `[visibility]` warning in a deploy log is the
acceptance check.

#### 4. Who counts as an admin

```env
ADMIN_EMAILS=you@example.com,colleague@example.com
ADMIN_USER_IDS=user_2abc...
OWNER_EMAIL=you@example.com
```

The owner's email always counts, so a deploy that set the Clerk keys but forgot
`ADMIN_EMAILS` still lets you in.

#### 5. Local development

```env
DISABLE_CLERK=1
```

Reads as "intentionally off" without touching the `CLERK_*` values. This exists
because a shell env prefix does not survive an IDE run configuration, and
half-disabling auth by deleting one key produces confusing failures.

---

### Layer 2 — a network of documentation sites

This is what `*.2plot.dev` runs. One Clerk application serves a **primary**
domain and any number of **satellite** domains, so the same person is
recognised across every site without signing in twice.

```
2plot.ai            Clerk primary — hosts sign-in, owns the accounts
2plot.dev           satellite + network hub — mints and verifies keys
leaflet.2plot.dev   satellite
flows.2plot.dev     satellite
…                   every component documentation site
```

#### Satellite configuration

```env
CLERK_IS_SATELLITE=true
CLERK_SATELLITE_DOMAIN=flows.2plot.dev   # host only, no scheme
CLERK_FRONTEND_API=https://<app>.clerk.accounts.dev
CLERK_SIGN_IN_URL=https://2plot.ai/sign-in
CLERK_SIGN_UP_URL=https://2plot.ai/sign-up
```

`CLERK_SATELLITE_DOMAIN` defaults to the host in `APP_BASE_URL`, which every
deployment must set anyway — so in practice you set one variable and the
satellite domain follows. That is deliberate: it removes a way to announce
another site's domain to Clerk.

`CLERK_FRONTEND_API` is **required** in satellite mode. A production
custom-domain instance cannot derive it from `CLERK_SIGN_IN_URL`.

Leave satellite mode off locally — Clerk rejects satellites on `localhost`. A
`pk_test` key stays primary; a `pk_live` key with a satellite domain
auto-enables satellite mode even if `CLERK_IS_SATELLITE` was missed, so
production cannot silently boot in the wrong one.

Each subdomain must also be added to the **allowed-subdomains list in the Clerk
dashboard** before it will work.

#### The ordering that matters

`lib/access.py` resolves in this order, and the order is the design:

```
tier  =  board override -> frontmatter -> PAGE_DEFAULT_TIER   (then the hub ceiling)
      ->  short-circuit public / hidden
      ->  local Clerk session          (a person in a browser)
      ->  hub verification of ?key=    (an agent, later, elsewhere)
```

**Local session first, hub only as a fallback.** A signed-in visitor is
resolved entirely on their own satellite, so the hub being down gates nothing
for them. Only the agent path — no cookie, key in the URL — needs the hub.

Reversing this couples every satellite's availability to one host for no
benefit: the fail-safe is `gated`, so a hub outage would silently gate every
restricted document on every subdomain at once. It is the single most
consequential ordering in this template.

#### Where key material lives, and why not here

A satellite holds **no key material at all**. It cannot mint keys and it cannot
verify them offline; it asks the hub.

The tempting alternative is to share the hub's signing secret so every
satellite can verify locally with no round trip. Reject it: the key is derived
by HMAC over the user, session and scope, so **anything that can verify can
also mint** — including an admin-scoped key. Sharing it puts a network-wide
admin-minting secret on twenty deployments, where one compromised satellite is
a full compromise, and rotation means redeploying all of them at once.

What a satellite *does* hold is `CROSS_APP_WEBHOOK_SECRET` — the same shared
secret the traffic reporter already uses — which authenticates *who is asking*.
It signs the request; it derives nothing. A verify endpoint open to the
internet would be a key-guessing oracle, which is why the caller is
authenticated as well as the key.

#### The hub sets the ceiling

Once the hub publishes tiers, two writers exist over one value. The rule:

```
effective_tier = more_restrictive_of(hub_tier, local_tier)
```

A satellite may lock its own page down further. It may **never** loosen what
the hub restricted. So a satellite misconfiguration cannot expose something the
network gated, and "loosen this page" stays a hub action — which is where that
authority belongs.

#### Keep the network catalogue public

The hub's `/llms.txt` is the map of what exists. **Gate individual documents,
not the map.** An agent that follows a satellite's "Network index" link and
arrives with no key should get exactly what it asked for: a list of what the
network contains.

This is also why the package never appends a key to a cross-host link. A
capability should not travel to another origin, and with a public catalogue it
does not need to.

---

### Two traps worth naming

**Do not wire the Clerk token's `iat` as "signed in since".** The session token
is refreshed roughly every 60 seconds, so `iat` is the age of the *token*, not
of the sign-in. Wiring it renders a clock that resets every minute. `lib/auth.py`
keeps a session-first-seen timestamp keyed on `ClerkUser.session_id` instead.

**Never put the visitor's identity in the network bulletin.** The bulletin is
TTL-cached and shared by every viewer of every satellite. A username in it is a
privacy bug wearing a cache. Identity is per-request and local — that is what
`configure_viewer_identity()` is for, and it renders in the HTML viewer only,
so the Markdown an agent receives is byte-identical either way.

---

### What the reader actually sees

| Reader | `auth` page | Its `llms.txt` |
|---|---|---|
| Anonymous browser | gate document | gate document, 200 |
| Signed-in browser | full prose | full prose, plus their name in the viewer banner |
| Agent, no key | gate document, 200 | gate document explaining how to get access |
| Agent with a valid key | full prose | full prose, and generated links carry the key onward |
| Any reader, `hidden` page | 404 | 404 |

The gate document is deliberately useful rather than a wall: it names the page,
says what would unlock it, and points at where to sign in. An agent that
fetches a gated document should come away knowing what it is and how its user
gets access — that is the difference between a gate and a dead end.

---

### Verifying

The checks that justify this design over the simpler ones, all automated in
`tests/test_access.py`:

```
1. Signed-in browser, hub UNREACHABLE  -> still allowed   (local resolution)
2. Agent with valid key, hub reachable -> allowed
3. Agent with valid key, hub down      -> gated, not 500, not prose
4. Satellite sets public, hub says auth -> effective auth  (ceiling holds)
5. Key never appears in sitemap.xml, canonical tags, or cross-host links
6. Identity renders in the HTML viewer only; Markdown byte-identical
7. No tiers declared -> byte-identical to a build with no access control
```

Checks 1 and 3 are the point. Check 7 is what lets a fork inherit all of this
without inheriting a gate it did not ask for.


```python
# File: lib/access.py

"""The access policy this site hands to dash-improve-my-llms.

One function matters — :func:`check` — and its *ordering* is the whole design:

    tier -> short-circuit public/hidden
         -> local Clerk session          (a person in a browser)
         -> hub verification of ?key=    (an agent, later, elsewhere)

**Local session first, hub only as a fallback.** A signed-in visitor is
resolved entirely on this host, so the hub being down gates nothing for them.
Only the agent path — a fetch with no cookie, carrying a key in the URL —
needs the hub at all. Reversing this order would couple every satellite's
availability to one host for no benefit: it is the single most consequential
line in this file.

Two questions get asked of a documentation site, and only one has a session
attached. Clerk answers "who is this human?". It can never answer "may this
fetch of a text file proceed?", because when someone pastes
``https://host/guide/llms.txt`` into an assistant, the fetch arrives with no
cookie. That is why authority has to be able to travel in the URL, and why
both mechanisms exist rather than one.

Unwired by default: :func:`configure` does nothing unless this deployment has
tiers that are not all public, so a fork inherits the machinery without
inheriting a gate it did not ask for.
"""

from __future__ import annotations

import logging
from typing import Optional

from lib import auth, hub_client, page_tiers, page_visibility

logger = logging.getLogger(__name__)


def local_tier(path: str) -> str:
    """This page's tier before the hub ceiling — override, then frontmatter.

    ``page_visibility.tier_override`` answers None for a page the control
    board never touched, which is the distinction that makes this work: an
    untouched page falls through to what its frontmatter declared (or the
    ``PAGE_DEFAULT_TIER`` env), rather than to the override store's own
    unknown-path default. Both ledgers speak the same four-value vocabulary.
    """
    override = page_visibility.tier_override(path)
    if override is not None:
        return override
    return page_tiers.local_tier(path)


def llms_public(path: str) -> bool:
    """The machine-surface axis, resolved with the same precedence.

    ``LLMS_PUBLIC_DEFAULT`` still governs every page that neither the board
    nor the frontmatter pinned, so the phase-4 agent flip stays one
    environment change.
    """
    override = page_visibility.llms_public_override(path)
    if override is not None:
        return override
    return page_tiers.get_llms_public(path)


def _raw_tier(path: str) -> str:
    """Local tier (override-aware) with the hub's ceiling applied, no
    degradation. The ceiling only ever RAISES: a board override can loosen a
    local declaration, never what the network restricted."""
    hub_tier = hub_client.hub_tiers().get(path)
    local = local_tier(path)
    return page_tiers.more_restrictive(local, hub_tier) if hub_tier else local


def _request_key() -> str:
    """The ``?key=`` on the current request, or "". Never raises, never logs."""
    try:
        from flask import request, has_request_context

        if not has_request_context():
            return ""
        return (request.args.get("key") or "").strip()
    except Exception:
        return ""


def effective_tier(path: str) -> str:
    """This page's tier, with the hub ceiling applied and degradation handled."""
    tier = _raw_tier(path)
    if not auth.clerk_enabled():
        # No way to identify anyone: everything except `hidden` falls open.
        # See lib/page_tiers.degraded_tier for why that is the right trade
        # for reading documentation, and why admin surfaces don't rely on it.
        return page_tiers.degraded_tier(tier)
    return tier


def check(path: str) -> str:
    """``allow`` | ``gated`` | ``deny`` for the current request.

    Runs inside the request, on every request, on paths that may be hot — so
    it stays cheap: a dict lookup, and at most one cached hub call for the
    agent path.
    """
    tier = effective_tier(path)

    if tier == "hidden":
        return "deny"
    if tier == "public":
        return "allow"

    # The second axis: an `auth` page whose machine twin stays open. This is
    # the window posture — humans meet the sign-in card (resolve_page_access,
    # the interactive lane) while llms.txt, crawler HTML and the prerender
    # keep serving prose so the crawl-demand dataset survives. The phase-4
    # agent flip is LLMS_PUBLIC_DEFAULT=0, which turns this branch off
    # everywhere at once; admin/hidden never reach it. The exemption applies
    # ONLY to a locally declared gate: when the hub's ceiling is what raised
    # this page above public, the machine lane must stay bound too — a
    # satellite's env default cannot loosen what the network restricted.
    if tier == "auth" and llms_public(path):
        hub_tier = hub_client.hub_tiers().get(path)
        if hub_tier not in ("auth", "admin", "hidden"):
            return "allow"

    # A signed-in reader in a browser. Resolved here, without the hub.
    user = auth.current_user()
    if user is not None:
        if tier == "admin":
            return "allow" if auth.is_admin_user(user) else "gated"
        return "allow"

    # No session: an agent, or an anonymous browser. Only a key can help now.
    #
    # THE VERDICT IS NOT THE AUTHORISATION (1.6.44 item 18, from the
    # 2026-09-02 hub incident, 0.26.0 → 0.26.1). The hub gated two admin-data
    # routes on this same endpoint, whose all-unknown-tier fallback answered
    # "allow" WITHOUT READING THE KEY, and the lane was open for 24 minutes.
    # The contract the fleet took from it: a host's own data is gated by a
    # secret THAT HOST HOLDS; a verify verdict may be a second factor and is
    # metering evidence first; "ask the authority" is the wrong shape for a
    # gate, because a tier the authority has not learned yet is UNVERIFIED,
    # not permitted.
    #
    # Why this call is within the contract, stated rather than assumed:
    #
    #   * it is the machine lane only. Human and admin access never reach
    #     here — `resolve_page_access` answers those locally, from Clerk,
    #     and `check` has already returned for a signed-in user above.
    #   * the host-held secret is CROSS_APP_WEBHOOK_SECRET. `hub_client`
    #     cannot even ask without it (`enabled()`), and with no secret the
    #     answer is `gated`. So the secret this host holds is load-bearing
    #     on every path through this call, which is exactly what the
    #     contract requires.
    #   * every fallback is CLOSED, in the opposite direction to the
    #     incident: no key, no secret, no response, an unrecognised verdict
    #     — all four return `gated`. `tests/test_access.py` source-pins
    #     those four, because a behavioural suite cannot see a restored
    #     default that pre-empts its own guard.
    key = _request_key()
    if not key:
        return "gated"
    return hub_client.verify(key, path, tier)


def resolve_page_access(path: str) -> str:
    """``allow`` | ``sign_in`` | ``forbidden`` | ``hidden`` — the INTERACTIVE
    verdict, for browser layouts (lib/gate_layouts wraps every docs page in
    it).

    Distinct from :func:`check` on purpose: the machine lane answers "may
    this fetch of a text file proceed" and honours ``?key=`` and
    ``llms_public``; a browser layout is a different question — keys never
    unlock layouts, and ``llms_public`` says nothing about the interactive
    experience.

    Fail postures are the boilerplate's, not pip-docs+'s: with Clerk
    unavailable, ``auth`` pages fall OPEN (documentation must never brick on
    a missing credential) while ``admin`` pages stay CLOSED unless
    ``ALLOW_UNGATED_ADMIN`` says this is a local box. Uses the raw effective
    tier — not ``degraded_tier()``, which maps admin to public and would
    fall the wrong way here.
    """
    tier = _raw_tier(path)

    if tier == "hidden":
        return "hidden"
    if tier == "public":
        return "allow"

    if not auth.clerk_enabled():
        if tier == "admin":
            return "allow" if auth.admin_access_open() else "forbidden"
        return "allow"

    user = auth.current_user()
    if user is None:
        return "sign_in"
    if tier == "admin" and not auth.is_admin_user(user):
        return "forbidden"
    return "allow"


def gate_doc(path: str) -> str:
    """The Markdown an unauthorised reader receives at 200.

    Deliberately useful rather than a wall: it names the page, says what would
    unlock it and where to sign in. An agent that fetches a gated document
    should come away knowing what it is and how its user gets access — that is
    the difference between a gate and a dead end, and it is why `gated` keeps
    the URL listed in the index and sitemap.
    """
    tier = effective_tier(path)
    name = _page_name(path) or path
    sign_in = _sign_in_url()

    lines = [
        f"# {name}",
        "",
        "> This document exists but is not public.",
        "",
        "---",
        "",
    ]
    if tier == "admin":
        lines += ["Reading it requires an administrator account on this site."]
    else:
        lines += ["Reading it requires a signed-in account."]
    lines += [
        "",
        "**If you are a person:** sign in"
        + (f" at {sign_in}" if sign_in else "")
        + ", then reload this page.",
        "",
        "**If you are an assistant:** ask whoever gave you this URL to copy it "
        "again while signed in. The copied link carries a key that authorises "
        "this document, and it will work when pasted here.",
        "",
        "The page's existence is public; only its content is restricted.",
        "",
    ]
    return "\n".join(lines)


def link_suffix() -> str:
    """Carry authority to the links generated in *this* response.

    An agent handed an authorised URL must be able to follow the site-index
    and per-page links inside it, or the catalogue is untraversable one hop
    from the document it was given. The package appends this to same-origin
    generated URLs only — never to canonical tags, the sitemap, or peer hosts,
    because a capability should not travel to another origin.
    """
    key = _request_key()
    return f"?key={key}" if key else ""


def _page_name(path: str) -> Optional[str]:
    try:
        import dash

        for entry in dash.page_registry.values():
            if page_tiers.normalize(entry.get("path", "")) == page_tiers.normalize(path):
                return entry.get("name")
    except Exception:
        pass
    return None


def sign_in_url() -> Optional[str]:
    """Where a reader signs in: the network bulletin's word first, env second.

    The bulletin travels with the hub's announcements, so a network-wide
    sign-in move is one hub edit instead of N satellite env changes; the env
    remains the local override and the offline fallback.
    """
    try:
        from dash_improve_my_llms.bulletin import get_bulletin

        url = ((get_bulletin() or {}).get("network") or {}).get("sign_in_url")
        if isinstance(url, str) and url.strip():
            return url.strip()
    except Exception:
        pass
    import os

    return (os.getenv("CLERK_SIGN_IN_URL") or "").strip() or None


# Old private name — gate_doc() predates the bulletin-aware lookup.
_sign_in_url = sign_in_url


def gating_configured() -> bool:
    """True when at least one page is not public.

    A site whose pages are all public gains nothing from a per-request check
    and pays for it on every hot path, so the wiring stays off until a tier
    says otherwise.
    """
    return any(tier != "public" for tier in page_tiers.registered().values())


def configure(force: bool = False) -> bool:
    """Hand the policy to the package. Returns True when access control is on.

    Call after the pages are registered — the decision depends on their tiers.
    """
    if not (force or gating_configured()):
        return False

    from dash_improve_my_llms import configure_access, configure_viewer_identity

    configure_access(check, gate_doc=gate_doc, link_suffix=link_suffix)
    configure_viewer_identity(auth.viewer_identity)
    logger.info(
        "access control ON (clerk=%s, hub=%s)",
        auth.clerk_enabled(),
        hub_client.enabled(),
    )
    return True
```


---

*Source: /authentication*
