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

bun run pmx plugins add auth

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

Each deployment chooses its own credential provider.

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.

Sign in locally and save personal work

Start with an initialized Quickstart project, then install Auth as above. Create paths/ada.user.md:

---
actor: true
---

# Ada

Create paths/ada/index.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:

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:

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. 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:

{
  "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.

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

Generate the stock sign-in pages and environment template:

bun run pmx auth setup

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

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:

---
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 for writable hosting.

Before deploying, verify the configuration with:

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:

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

{
  "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:

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

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. 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:

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:

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

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:

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:

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:

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.