# 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:** 8 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://boilerplate.2plot.dev/sitemap.xml  


---

.. llms_copy::Authentication

.. toc::

### 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`.

#### 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  ->  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

logger = logging.getLogger(__name__)


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 = page_tiers.effective_tier(path, hub_client.hub_tiers().get(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"

    # 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.
    key = _request_key()
    if not key:
        return "gated"
    return hub_client.verify(key, path, tier)


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]:
    import os

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


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*
