[@layout]: ../docs.layout.md

# Authentication

Optional authentication and account UI for PathMX applications. Development
mode provides local Actor switching; production supports provider-based
credentials such as magic links.

---

## Install

```sh
bun run pmx plugins add auth
```

This installs and activates the package in `package.json#pathmx.plugins`.

Each deployment chooses its own credential provider.

## Links to private pages

When a signed-out reader opens an existing private page in a browser, PathMX
redirects to the configured same-origin sign-in page. After authentication,
`returnTo` brings them back to the original path and query parameters. The same
flow uses the local Actor chooser during development.

This redirect applies to authored pages. Protected static files, including HTML
documents, images and scripts, return 404 until the reader signs in. Link to an
authored page in the protected folder first when sharing a standalone HTML file.

Signing in does not grant access to another person's private content. A signed-in
reader without permission still receives 404, as do missing pages. Raw Markdown
and API requests retain their normal access-denied responses. Embedded Sources
and live refreshes also keep those responses, so revoked content is removed
from open references.

---

<!-- id: local-actor -->

## Sign in locally and save personal work

Start with an initialized [Quickstart project](../start/first-site.guide.md), then
install Auth as above. Create `paths/ada.user.md`:

```md
---
actor: true
---

# Ada
```

Create `paths/ada/index.md`:

```md
---
access:
  type: home
  owner: /ada.user
---

# Ada's work

Your saved responses and progress belong in this Home.
```

Add `<x-auth-session />` to `paths/site.layout.md`, inside its header, and run:

```sh
bun run dev
```

Open the local site, choose **Sign in**, and select **Ada**. The session control
now shows Ada and links to her Home. Development supplies local Actor switching;
it does not send an email. If Ada is missing, check the `.user.md` extension and
`actor: true`, then run `bun run check` and inspect the server's diagnostics.

Input, Assessment, Actor Completion, and uploads need an admitted Actor and a
writable Home. This development host stores Ada's personal Sources below
`paths/ada`; the host process must be able to write that folder. Signing out
changes who can access the files; it does not delete them.

The standard local chooser stores sessions in the project's Auth state database.
A session survives server restarts for up to 30 days; signing out or revoking the
Actor's credentials invalidates it. Keep the same state directory across restarts.
The chooser still requires a loopback URL and loopback connection. For remote
previews, configure production credentials instead of exposing the local chooser.

For a trusted review through a local tunnel, such as `cloudflared`, you can admit
exactly one public origin to the development chooser:

```sh
PATHMX_DEV_AUTH_ORIGIN=https://review.example.com
PATHMX_ORIGIN=https://review.example.com
```

`pmx dev` then accepts chooser requests for that exact HTTPS origin when they arrive
from a loopback connection (the tunnel), with a matching `Origin` header. Sessions
stay in the same persistent store. `PATHMX_ORIGIN` lets saves pass the same-origin
check behind the tunnel's TLS; while it is set, use the public URL for saving.
Anyone who can reach the origin can choose any repository user, so use it only
with an access-controlled tunnel or data you are happy to share. `pmx serve`
ignores `PATHMX_DEV_AUTH_ORIGIN`.

The explicit `developmentCredentialHost()` helper uses temporary in-memory
sessions, useful for isolated tests; it accepts the same `origin` option.

Ordinary static exports do not provide a writable account server. For personal
work stored on one device without sign-in, use a
[browser publication](../extend/state.reference.md#save-input-and-progress-in-the-browser).
For shared hosting and cross-device accounts, configure production credentials
below and keep the server's Source repository on persistent writable storage.

---

## Email sign-in codes

Offer a six-digit code alongside the magic link in the same email:

```json
{
  "pathmx": {
    "plugins": {
      "@pathmx/auth": {
        "magicLink": {
          "code": true,
          "email": { "name": "CSC-105" }
        }
      }
    }
  }
}
```

Request the email on one device, read it on another, and enter the code in the
browser you want to sign in. The existing `<x-auth-sign-in />` automatically shows
a code field after sending. **I already have a code** accepts email and code in
another browser without requesting a new email. Both flows also work without
JavaScript. Codes stay out of URLs and browser storage.

Codes expire after ten minutes and work once. A newly issued code replaces the
previous code for that email. Five verification attempts are allowed per email
in a ten-minute window, including across resends and server restarts; request/IP
limits also apply. After exhausting the budget, wait ten minutes and request a
new code. Resend cooldown and existing User Actor admission still apply.

Codes and links are independent: using either leaves the other available under
its own rules. Links retain their three-use limit and do not expire. Omitting
`code` keeps link-only sign-in. Explicit `magicLinkProvider({ code: true, ... })`
integrations enable the same behavior.

The default email includes both the code and link. Email Source templates receive
`code` and `codeExpiresAt` (an ISO timestamp) in addition to `confirmUrl`; custom
renderers receive `codeExpiresAt` as a `Date`. Include the code, its expiration,
and the link. Auth rejects delivery if the plain-text fallback omits the code or
link. Custom renderers own escaping and can format the expiration for their audience.

```html
<x-auth-sign-in
  email-label="School email"
  submit-label="Send sign-in email"
  code-label="Email code"
  verify-label="Continue"
/>
```

The single code field supports paste, numeric keyboards, and one-time-code autofill.
The form follows shared theme controls and tokens; `.pmx-auth__code`,
`.pmx-auth__actions`, and `.pmx-auth__text-button` allow scoped style overrides.

---

## Session control

Place `<x-auth-session />` in the site header. Signed-out visitors see Sign in.
Signed-in users see a shared split control: their avatar and name link directly
to their home, and the adjacent Account menu opens a dropdown with the home
index's authored title and map-type icon, followed by Sign out. A user without
a Home, such as a staff reviewer, sees one button with their avatar and name that
opens a menu containing Sign out. Home metadata
and actor metadata changes refresh the control on open pages through live sync. Without an avatar, a user icon is shown.
The control uses native HTML and a small browser behavior for Escape,
outside-click dismissal, and viewport positioning. Its links and sign-out form
work without JavaScript. Session and magic-link confirmation do not load React;
the sign-in form loads its React client when the form is rendered.

---

## Magic-link setup

Generate the stock sign-in pages and environment template:

```sh
bun run pmx auth setup
```

Copy the generated `.env.example` values into your local `.env` and configure
them for the deployment:

```dotenv
PATHMX_AUTH_MODE=magic-link
PATHMX_AUTH_ORIGIN=https://example.com
PATHMX_AUTH_SECRET=replace-with-at-least-32-characters
PATHMX_AUTH_EMAIL_FROM=PathMX <auth@example.com>
RESEND_API_KEY=replace-with-your-key
```

Only emails declared on explicit User Actors can receive a sign-in link:

```yaml
---
actor: true
emails: [ada@example.com]
---
```

Replace the sample origin/email with your public HTTPS origin and sender, use a
verified email-provider sender, and keep secrets in the server environment. See
[Server deployment](../hosting/deployment.reference.md) for writable hosting.

Before deploying, verify the configuration with:

```sh
bun run pmx auth doctor
```

If a link does not arrive, check that the email is on an explicit User Actor,
then check the provider delivery result and `auth doctor`. The stock magic link does not expire, but permits at most three direct uses.
Request a fresh link after that limit and open it on the configured origin.
Do not use development Actor switching as production authentication.

Auth applies a 60-second atomic resend cooldown per normalized email and public
per-client burst limits. Unknown and email-cooled requests return the same accepted
response. Programmatic providers can set `resendCooldownSeconds`; do not disable it
on a public service without another delivery limiter.

Custom integrations can import `AuthPlugin`, `magicLinkProvider`, and provider
types from `@pathmx/auth` or `@pathmx/auth/providers`.

---

## Customize sign-in and email

Auth's stock form includes sending, inbox confirmation, resend cooldown, change
email, and request-error recovery. Unusable links return to sign-in with a safe
local destination; confirmation URLs are not copied into recovery links.
Unknown and registered addresses receive the same request response. A successful
request does not establish that an email was delivered; server logs report
preparation/delivery failures without exposing their credential-bearing content.

Compose your page with ordinary Markdown and HTML:

```md
# Sign in

<x-auth-sign-in
  id="sign-in"
  email-label="School email"
  email-help="Use the email address registered for your course."
/>
```

`code-label` defaults to “Sign-in code” and `verify-label` to “Sign in”.
`email-label` defaults to “Email address”; with codes enabled, `submit-label` defaults
to “Email me a sign-in code”. Otherwise, `submit-label` defaults to “Email me a
sign-in link”. `email-help` adds an accessible field description. An authored
`id` preserves the form's browser state when surrounding content moves during
live edits; use a distinct id for each form on a page. Without JavaScript,
submission returns to the sign-in page with confirmation and another usable form.

For a branded stock email, configure the Auth package:

```json
{
  "pathmx": {
    "plugins": {
      "@pathmx/auth": {
        "magicLink": { "email": { "name": "CSC-105" } }
      }
    }
  }
}
```

For an authored email, replace `name` with
`"template": "/emails/sign-in.email"` and create
`paths/emails/sign-in.email.md`:

```md
---
subject: "Sign in to CSC-105"
---

[Sign in to your course]({{ confirmUrl }})

Keep this link private. If you didn't request it, you can ignore this email.
```

The canonical template id omits `.md`. Auth supplies `confirmUrl`; Email renders
both HTML and plain text. Email Sources have no public page, raw Markdown route,
or search entry. Template edits apply to subsequent requests. Auth lint checks
that the selected template exists and uses supported input.

For custom HTML, configure a local plugin instead:

```ts
import AuthPlugin from "@pathmx/auth"

export default function Auth() {
  return AuthPlugin({
    magicLink: {
      email: {
        render: ({ confirmUrl }) => {
          const href = confirmUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;")
            .replaceAll("<", "&lt;").replaceAll(">", "&gt;")
          return {
            subject: "Your sign-in link",
            html: `<p><a href="${href}">Sign in</a></p>`,
            text: `Use your sign-in link: ${confirmUrl}`,
          }
        },
      },
    },
  })
}
```

Export a factory so each host creates its own Auth instance. Auth instances hold
session-provider state and must not be shared between running hosts.

`render` receives `MagicLinkDelivery` and returns `{ subject, html, text }`,
synchronously or asynchronously. Custom renderers own HTML escaping and must include the confirmation URL in
the plain-text fallback. Choose one
of `name`, `template`, or `render`. Auth's default setup composes an Email service
using the existing Auth origin, sender, and Resend environment settings.

Repositories also using transactional Email should configure one Email service
and pass it as `magicLink.email.service`; see
[sharing Email with other plugins](../email.reference#share-email-with-other-plugins).
Do not install a second Email instance alongside it. `AuthPlugin(...)` is a plugin
composition recipe when it supplies the default Email service; install its result
through the normal plugin/preset mechanism.

Explicit `providers` retain their own `mailer` configuration and cannot be
combined with `magicLink` options. Auth's provider-specific settings do not enable
self-registration; existing User Actors still determine eligible addresses.

---

## Persistent sessions

Magic-link sessions last 30 days. While a signed-in visitor uses the site, Auth
renews the session after one day and delivers the updated expiry to the browser.
Renewal works on every authenticated page, including pages without
`<x-auth-session />`. Returning to a visible tab also checks for renewal. An
inactive session expires, and signing out revokes it immediately.

Set the lifetime and renewal threshold in seconds in the environment:

```dotenv
PATHMX_AUTH_SESSION_EXPIRES_IN=2592000
PATHMX_AUTH_SESSION_UPDATE_AGE=86400
```

Explicit `magicLinkProvider` integrations can set the same values through
`session: { expiresIn, updateAge }`. Both values must be positive integer seconds, with `updateAge` less than
`expiresIn`. Omitted values use the defaults above. Browser renewal timing is
automatic; custom credential providers retain ownership of their own sessions.

---

## User admission and optional Homes

Auth requires the standard `SourceAccessPlugin`, already included by `defaultPlugins`.
Portable hosts supplying explicit plugins install that policy before Auth. A host
with a different base policy can provide credentials through its own plugin.

The stock credential host admits explicit `.user.md` Actors. Embedded hosts may
supply their own `CredentialHost` through `AuthPlugin({ credentials })` to admit
other domain Actor types. Core itself uses `actor: true` as the discriminator.

`CredentialHost.resolve` runs before route and Action body parsing. Inspect headers
and cookies there; do not consume or lock the request body. If credential logic
must inspect a body, read a clone so the original remains available to its handler.

A Home is optional and belongs to its directory index Source, not the Actor. The
index authors `access.type: home` and the canonical Actor Source id as `owner`:

```md
---
access:
  type: home
  owner: /people/ada.user
---

# Ada's work
```

That claim protects the index directory and all descendants, including binary
files, even if the Actor is later missing. An Actor without a Home can still
perform Actions that do not require personal storage, including editing an event
badge or creating an explicitly shared submission. See
[Source access](../extend/core.reference.md#choose-the-source-access-policy).

Auth interprets a User's optional `role: staff` as read-only review access to
current Actor Homes, including files stored there. It adds no general write
permission. Core does not assign a default role, and another Actor type's `role`
is ordinary domain data. View restrictions can narrow Auth's review grant.

A shared Source admits reviewers through its members, like anyone else. List a
reviewer with `can: [read]` on the shared folder's index:

```yaml
access:
  type: shared
  owner: /people/ada.user
  members:
    - actor: /people/ada.user
      can: [read, write, execute]
    - actor: /people/sam.user
      can: [read, execute]
    - actor: /people/lee.user
      can: [read]
```

The reviewer reads the folder and everything it protects. A nested shared index
that omits the reviewer hides its subtree from them.

User and email changes take effect while the server is running. A magic link records
the exact Actor and credential generation before delivery; confirmation rechecks
that binding and current eligibility. Sessions retain it too, so moving an email
from Alice to Bob never turns Alice's old link or cookie into Bob. Multiple unique
emails may name one User. A verified first confirmation provisions the provider
account on demand; Auth no longer mirrors the entire roster into its SQL database
at startup.

Revoke one Actor's sessions and outstanding links together with:

```sh
bun run pmx auth credentials revoke /people/ada.user
```

Revocation rotates that Actor's generation and survives restart. It does not remove
the authored email, so a later fresh proof can sign in again. Deleting and recreating
the same Actor Source with identical authored identity is not distinguishable from
a temporary removal; use this command when replacing an identity deliberately.

---

## Credential changes and navigation

Standard production Auth enables private, tab-local reuse of eligible rendered pages.
Core rotates a non-secret `pmx-navigation` cookie whenever an authentication response
sets cookies, including sign-in and sign-out. Navigation checks this generation before
reuse and discards responses that finish across an account change. The live channel
also rechecks admission and credential expiry. The generation cookie does not grant
access; confirmation remains bound to the actual credentials and admitted Actor.

A custom provider can opt in when all browser credential changes go through its
`fetch` handler and admission depends on the current Source-backed Actor directory:

```ts
import {
  createCredentialHost,
  type ActorAdmission,
  type CredentialProvider,
} from "@pathmx/auth/credentials"
import { AuthPlugin } from "@pathmx/auth"

const credentials = createCredentialHost(provider, actorAdmission, {
  navigation: "cookie-session",
})
const auth = AuthPlugin({ credentials })
```

Here `provider` implements `CredentialProvider` and `actorAdmission` implements
`ActorAdmission`. The provider must validate expiry and revocation on every `resolve`
call. Changes to credentials outside its handler, or external admission inputs that
can change without Source invalidation, do not satisfy this contract; omit the option
for those hosts. Deferred hosts can delegate the resolved session unchanged.

`CredentialDirectory.prepare(identity)` resolves a current candidate before proof
delivery or acceptance. Persist its `{ actor, identity }` binding with the proof.
After proof, call `admit(binding)` to recheck the exact candidate, bind the resulting
session to that same Actor, and return the binding from `provider.resolve`.
`CredentialDirectory.admit(binding)` is also rechecked on every request. This contract
also supports non-User Actors and hosts without SQL. Custom providers get fresh
navigation reads unless `createCredentialHost` receives the explicit
`navigation: "cookie-session"` coverage option shown above.

When upgrading an existing Auth database, the actor-binding migration preserves
provider users but invalidates pre-migration sessions and magic links because they
cannot be safely assigned from today's email owner. Users sign in once with a fresh
link after the upgrade.

Development Actor switching, credential overlays, contextual Views, and pages with
unverified rendering inputs continue to use fresh reads. Auth components and Input
forms with per-render identifiers also use fresh reads. Shared caches never store
these private responses.
