Use the Bun adapter to host a PathMX repository programmatically. For ordinary site authoring, the CLI supplies this setup.
Choose the job you need:
- Run a host or provide a portable environment.
- Connect a browser and handle reads, Actions, and live updates.
- Export static routes or publish a writable browser app.
- Write Plugins, configure packages, and generate derived assets.
- Set Source access or narrow a contextual View.
Use an initialized Quickstart project with its
welcome page. Create host.ts in the project root:
import { PathMX, handleRequest } from "@pathmx/core/bun"
const app = await PathMX({ root: "./paths" })
const server = Bun.serve({
hostname: "127.0.0.1",
port: 3000,
fetch: (request) => handleRequest(app, request),
})
console.log(`Open ${server.url}`)
const stop = async () => {
server.stop()
await app.close()
process.exit(0)
}
process.once("SIGINT", stop)
process.once("SIGTERM", stop)
Run bun run host.ts and open http://localhost:3000/: it serves the authored
welcome page. curl -I http://localhost:3000/api/runtime/health returns 204 for
a healthy repository. Stop with Ctrl+C to close the application. Choose another
port if 3000 is occupied. Installed production Auth requires its configured
credentials; see Authentication.
PathMX() supplies the Bun environment, default plugins, and repository plugin
discovery. Host lifecycle owns shutdown and resource cleanup.
Expected authored Source errors are collected by default; inspect
app.sourceDiagnostics() for their details. Invalid Sources and their affected
consumers remain unavailable while unrelated content works. See
Source recovery.
Use PathMX({ root: "./paths", diagnostics: "throw" }) when a validation caller
needs startup to fail on the first authoring diagnostic. Storage and host failures
still throw in either mode.
Define validated domain operations with the Action API.
Trusted plugin contexts also provide app.view(session); invoke Actions through
that admitted View. Server routes use ctx.view(await ctx.session()). They can
serve a mounted contextual View
with ctx.serve(view, { basePath }). Trusted hosts
can select a domain context with
app.view(session, { context: sourceId }).
The package root exposes runtime-neutral contracts. A host supplies the environment and selects plugins explicitly:
import { createApp } from "@pathmx/core"
import { bunEnvironment } from "@pathmx/core/bun"
import { defaultPlugins } from "@pathmx/core/plugins"
const app = await createApp({
root: "./paths",
environment: bunEnvironment,
plugins: defaultPlugins,
})
// Await app.close() when this host is finished.
The Bun environment is one adapter. Keep runtime-specific setup at the host boundary. The core foundation supplies Source access, metadata, schema, queries, and Blocks; explicit plugins add behavior. Plugin code uses the provided contexts rather than importing internal engine or registry modules.
A custom PathMXEnvironment can supply storage.commit to persist all Source
changes from one Action atomically:
import type { PathMXEnvironment } from "@pathmx/core"
type Commit = NonNullable<PathMXEnvironment["storage"]["commit"]>
// commit([{ path: "/reader/notes.md", before: "# Notes\n", after: "# Updated\n" }])
Each change contains an
adapter storage path, expected before text, and replacement after text.
null means the file is absent; an empty string is a present file. Core validates
the full Source change set and supplies each path once, with its final contents.
The adapter must compare every expected value and apply every replacement in one atomic operation. Reject on a conflict or storage failure with all files unchanged. Resolve only after the complete batch is durable. Core then refreshes the graph and continues the Action lifecycle. Plugins keep using the existing Source transaction API.
Without this optional capability, core uses the individual storage write/delete
methods. The Bun filesystem adapter uses that sequential behavior: an error can
leave a partially persisted batch. A method that merely loops over file writes
does not satisfy the atomic commit contract.
storage.moveTree(move, changes, options?) supports semantic folder moves.
move contains adapter paths root, from, and to, plus a files inventory
relative to from. changes has the same path, before, and after text
shape as storage.commit. Preconditions describe storage before the move;
replacements describe the final tree. Sources being relocated appear as an old
path deletion and new path creation. Assets travel with the tree.
import type { PathMXEnvironment } from "@pathmx/core"
async function previewFolderMove({ storage }: PathMXEnvironment) {
if (!storage.moveTree) throw new Error("Folder moves are unavailable.")
await storage.moveTree(
{
root: "/project/paths",
from: "/project/paths/old",
to: "/project/paths/new",
files: ["a.md", "portrait.jpg"],
},
[
{ path: "/project/paths/old/a.md", before: "# A\n", after: null },
{ path: "/project/paths/new/a.md", before: null, after: "# A\n" },
{
path: "/project/paths/index.md",
before: "[A](./old/a.md)\n",
after: "[A](./new/a.md)\n",
},
],
{ dryRun: true },
)
}
The adapter must validate the Source-root boundary, exact inventory, absent
destination, and expected text before mutations. With dryRun: true, it performs
validation only. Apply must either complete the move and text changes or restore
the original tree and texts on a handled write failure; an incomplete recovery
must fail explicitly. Binary data must retain its bytes. Planning requests
storage.list(root, { includeHidden: true }) to inventory all files without changing normal Source discovery. Native directories may also
implement storage.isDirectory(path) so planning recognizes empty folders.
The Bun adapter uses a directory rename with text rollback; the browser adapter
uses an IndexedDB transaction. Bun's guarantee covers handled I/O failures, not
power loss or edits by another process during the operation. Ordinary Source
Actions continue using the separate atomic storage.commit capability described
above. Adapters without moveTree reject folder moves before writing.
Individual assets use optional storage.moveFile(move, changes, options?) with
the same validation and handled-failure rollback contract. The move contains
root, from, to, and the planned bytes' SHA-256 hex digest. The adapter
checks those bytes, an absent destination, and all reference-edit preconditions
before mutating. Binary content must remain exact. The Bun adapter implements
this capability; adapters without it reject individual asset moves.
Core's durable Bun host additionally journals the asset identity and digest with its Source edits. Recovery accepts either the original asset or its renamed counterpart with matching bytes, then completes the reference edits. Unexpected bytes or both locations being occupied stop recovery; the journal does not guess which external edits to discard.
A ViewConnection provides authorized requests, page reads, invalidation subscriptions, and
Actions without tying the UI to a transport. The host binds identity and context;
callers do not pass an Actor with each operation.
import { createHTTPConnection } from "@pathmx/core/connection"
const connection = createHTTPConnection(location.origin)
const href = "/lessons/check-in.assessment"
const unsubscribe = connection.subscribe(href, () => {
// Re-read and reconcile through your UI's existing renderer.
})
const response = await connection.read(href)
if (!response.ok) throw new Error("Page is unavailable.")
const document = await response.text()
const receipt = await connection.actions.submit("input.submit", [
["_target", href],
["_response", "assessment"],
["contrast", "strong"],
])
// The receipt confirms persistence. Refresh the page separately.
unsubscribe()
connection.close()
read(href) returns a standard Response containing the authorized HTML document
and its existing document assets. A .md href returns Source Markdown. Subscribe
before the initial read. Notifications are invalidation hints, not document payloads;
reads always recheck access. Apply updates with the normal document renderer so
focused controls and unsent edits retain their continuity.
request(href, init?) sends a same-site request and returns a standard Response.
Plugin routes receive the host-admitted HostSession from ctx.session(); request
headers cannot select a different identity on a local connection.
const response = await connection.request("/api/search?q=contrast", {
headers: { Accept: "application/json" },
})
if (!response.ok) throw new Error("Search is unavailable.")
const { documents } = await response.json()
Browser plugin clients use getViewReader() from @pathmx/core/connection for
request and read. It selects the active host connection, including a worker.
Static publications supply a reader that permits only GET and HEAD.
getViewConnection() supplies the full connection when Actions are available.
Worker requests preserve methods, headers, status, and finite binary or multipart
bodies. Bodies are buffered across worker messages; this transport does not stream
responses or report upload progress. An AbortSignal cancels the caller's wait and
signals the host handler. Cancellation does not roll back a mutation already applied.
Scoped Views expose only their admitted core routes; arbitrary plugin routes are
not redispatched through a broader View. Credential endpoints require an HTTP host.
createHTTPConnection(baseURL) uses same-origin credentials and the host's Action
and live endpoints. Supply the mount URL for a contextual View. Root-relative
canonical hrefs resolve within that mount; absolute URLs outside it are rejected.
Authored same-site download links read through the active reader, so locally
stored files can be downloaded offline. Static readers use the publication's read-only runtime and do not ship mutation
or live-server transport code.
createWorkerConnection(worker) and serveWorkerConnection(connectionPromise, port)
from the same entrypoint implement the identical contract over worker messages.
A trusted host creates the underlying connection with
createViewConnection(app, session, viewOptions?) from @pathmx/core, using its
own environment and admitted HostSession. A local owner uses
{ type: "local", actor: "/people/sam.person" }; this grants no repository
administration or remote authentication. These adapters do not supply
identity admission, publication loading, or durable storage for the host.
getViewConnection() from @pathmx/core/connection returns the active connection
installed by the document runtime. Independently bundled clients share this same
host admission. It rejects when no connection is installed, including read-only
static publications.
actions.run(name, input, { target, request }) matches the View Action runner.
actions.submit(name, fields, { request }) accepts ordered text pairs, consumes _target,
and invokes the Action's form decoder. Binary transfer uses a plugin route
through request; it is not converted into this text-form contract.
Closing a connection releases its subscriptions and pending transport operations,
without shutting down a shared host. A mutation interrupted before acknowledgment
has an uncertain outcome.
The optional request identifies one invocation across retries. HTTP connections
generate it automatically and retry transient failures with the same payload;
explicit identifiers can be retained across closed connections. See
retry a Save for receipt replay and scope.
A successful Action receipt confirms the complete persistence operation, including a successful no-op or replay. Reading the updated representation is a separate operation: a failed refresh never requires repeating the Action. The host repairs failed projections from Sources before another mutation. Connection reads request that repair and fail if the host cannot recover.
ActionFailure exposes outcome: rejected means no writes are confirmed to have
occurred; partial means a sequential adapter confirms an incomplete batch;
uncertain means the result cannot be confirmed. Preserve the form on rejection.
applied records the number of confirmed Source changes before failure; HTTP and
worker connections preserve that count. Zero does not prove that an uncertain
operation made no changes.
For partial or uncertain outcomes, reconcile before another submission. Generic
Actions do not promise exactly-once execution or receipt recovery after a crash.
@pathmx/core/publish provides createPublication for hosts that consume a
publication directly. Create the application with a static runtime target:
import { PathMX } from "@pathmx/core/bun"
import { createPublication } from "@pathmx/core/publish"
const app = await PathMX({
root: "./paths",
runtime: { target: "static" },
})
try {
const bundle = await createPublication(app, {
origin: "https://example.com",
})
const route = bundle.manifest.routes.find((route) => route.pathname === "/")
const representation = route?.representations.find((item) => item.default)
const artifact = bundle.artifacts.find(
(item) => item.path === representation?.artifact,
)
if (!artifact) throw new Error("Home page has no default artifact.")
const html = await (await artifact.read()).text()
console.log(bundle.manifest.build, html)
} finally {
await app.close()
}
The bundle separates public routes from stored artifact paths. Each route lists
its HTML, Markdown, or file representations with content types and any response
headers. Resolve requests through that route contract; an artifact filename is
not necessarily the reader-facing URL. The manifest records the build identity,
origin, mount basePath, and artifact size and cache identity. Optional basePath
relocates a publication beneath an origin, such as /course/.
Status representations use a route with type: "status" and a numeric status.
For an unmatched request, a host selects the route whose status is 404,
negotiates its HTML or Markdown representation, and serves it with that HTTP
status without redirecting. The same status applies when its /404.html route
is requested directly. See missing pages
for authored overrides and directory-host configuration.
The builder reads anonymous public content, collects Plugin publication routes,
and validates the completed publication. Unsupported server-dependent behavior
or invalid publication routes reject with PublicationError. Static output has
no server Action or live-update endpoints. Use browser publications
when local personal writes are required.
By default, artifact bytes stay in memory. A host can supply a PublicationStore
through store; its put(path, blob) returns the stored size and a reusable
asynchronous read() function. Keep staging private until the whole build
succeeds, and keep stored bytes available for as long as the bundle is consumed.
Artifact readers do not depend on the running application. For a ready-to-serve
directory, use the export command instead of writing
a storage and routing adapter.
publishBrowserProject from @pathmx/core/bun builds an ordinary project into a
browser-hosted application. The output contains public Sources, explicitly authored
starter files, the selected plugins, their browser clients, and local assets.
Package activation and repository plugin configuration use the normal
project setup.
Create publish-browser.ts in the project root and run it with
bun run publish-browser.ts after supplying the starter files below:
import { publishBrowserProject } from "@pathmx/core/bun"
await publishBrowserProject("./paths", {
out: "./dist",
origin: "https://example.com",
local: {
name: "my-course",
session: { type: "local", actor: "/people/local.user" },
writableRoot: "/people/local/",
seedSources: "./starter",
},
})
The starter folder mirrors repository-relative paths. It may contain the declared
local Actor Source and files inside writableRoot:
starter/
people/
local.user.md
local/
my.bookmarks.md
Declare the Actor in starter/people/local.user.md:
---
actor: true
---
# Local reader
Declare the Home on starter/people/local/index.md:
---
access:
type: home
owner: /people/local.user
---
# Local reader's files
starter/people/local/my.bookmarks.md can contain # My bookmarks. Starter files
are deliberately published templates. Existing private Sources in paths are
excluded; they do not become starter files automatically. The Actor declaration
is read-only, while files inside the writable subtree seed IndexedDB only when
that named repository is first created. Keep the repository name, Actor, and Home
stable when updating a site; saved Sources and binary uploads remain intact.
Serve dist at the root of an HTTPS origin, or localhost during development.
Serve existing files normally and use index.html as the fallback for page
navigation. The output includes a service worker at /pathmx-browser-sw.js and
requires access to the /.pmx directory. No Action server is required.
origin records the canonical HTTP(S) origin; subpath hosting is not supported by
this builder.
The service worker caches and verifies the complete publication before the first opening creates a personal repository. Once installed, the site can reopen offline at a deep link. Updates wait until tabs using the previous build close, then replace its cached publication. Incomplete or corrupted updates leave the installed build available. These caches are separate from the saved Source repository.
Plugins must run with the browser host's capabilities. Clients and package-relative
assets are prebuilt automatically; runtime module loading and SQL are unsupported.
HTML rewrites operate on complete buffered documents, including text nodes. Import reusable code through repository plugins.
Externally hosted media and services still require their own network access.
Include <x-browser-files-tools /> for personal-file export, atomic import, and reset.
See saved files for archive limits.
createBrowserHost from @pathmx/core/browser opens an IndexedDB repository and
returns a ViewConnection. Its writable subtree overlays the supplied read-only
Source snapshot. Snapshot values may be Markdown strings or binary Blobs. Personal binary assets retain their bytes and media types as
IndexedDB Blobs. Atomic Source batches compare expected bytes and commit together;
separate asset writes are not part of a Source batch. Web Locks serialize refresh,
Actions, and route requests across tabs; BroadcastChannel invalidations
cause subscribed pages to read the saved Sources again. Closing this connection
also closes its owned repository and storage.
A small worker can host a personal Source repository:
import { createBrowserHost } from "@pathmx/core/browser"
import { serveWorkerConnection } from "@pathmx/core/connection"
import { SourceAccessPlugin } from "@pathmx/core/plugins"
const connection = createBrowserHost({
name: "reading-notes-v1",
writableRoot: "/people/sam/",
session: { type: "local", actor: "/people/sam.person" },
sources: {
"/index.md": "# Reading notes\n\nWelcome to your notebook.",
"/people/sam.person.md": "---\nactor: true\n---\n# Sam",
"/people/sam/index.md":
"---\naccess: {type: home, owner: /people/sam.person}\n---\n# My files",
"/people/sam/notes.md": "# My notes\n",
},
plugins: [SourceAccessPlugin],
resources: {
async loadAsset(url) {
throw new Error(`Asset is not prebuilt: ${url}`)
},
async bundle(entry) {
throw new Error(`Client is not prebuilt: ${entry}`)
},
async loadModule(name) {
throw new Error(`Module is not installed: ${name}`)
},
},
})
serveWorkerConnection(connection, self)
The example uses only Markdown and Source access, so its resource handlers reject
unused capabilities. Supply prebuilt assets, bundles, and installed modules when
plugins require them. The browser environment provides Markdown, YAML, and buffered
HTML and text-node rewriting; SQL is not supported by this
adapter. createBrowserStorage and createBrowserEnvironment are available from
the same entrypoint for hosts that assemble their own lifecycle.
A bundle resource returns { entrypoint, styles, assets, dependencies }.
entrypoint and styles are output-relative paths into assets; styles contains
only the entry’s initial CSS. assets retains the complete module graph, including
lazy chunks and binary files, and dependencies lists local input URLs used for
invalidation. A CSS-only bundle has entrypoint: null. For an already published
bundle, return absolute publication paths with an empty assets array. Core emits
only the selected entry and initial styles; supporting artifacts remain available
for later imports.
Source snapshots must contain only data appropriate for that browser. Personal
Sources seed the database on first creation; reopening preserves saved bytes and
rejects malformed stored content. Use publishBrowserProject for publication
filtering, resource packaging, and offline startup. Hosts assembled directly with
createBrowserHost supply their own snapshot loading. Database migration and
backups remain application concerns.
startViewRuntime from @pathmx/core/runtime binds canonical PathMX documents to
any View connection. It installs navigation, native Action forms, and live
reconciliation with focus and draft continuity:
import { createWorkerConnection } from "@pathmx/core/connection"
import { startViewRuntime } from "@pathmx/core/runtime"
const connection = createWorkerConnection(
new Worker("/worker.js", { type: "module" }),
)
await startViewRuntime(connection, { initialRead: true })
Load this client from a shell containing one [data-pmx-view] element and an empty
<div data-pmx-augmentations></div> directly under body. initialRead renders the
current URL through the connection before installing navigation. Serve that shell
for authored page routes so reloads can reopen them. Rendered documents must include
their required prebuilt client and style assets. For an already rendered HTTP page,
pass createHTTPConnection(location.origin) and omit initialRead.
@pathmx/core/http shares representation handling between the Bun server and
publication adapters. It depends only on web-standard Request, Response,
Headers, and URL:
import { prefersMarkdown, withMarkdownAlternate } from "@pathmx/core/http"
export default {
fetch(request: Request) {
const path = new URL(request.url).pathname
if (path !== "/hello.page" && path !== "/hello.page.md") {
return new Response("Not found", { status: 404 })
}
const markdown = path.endsWith(".md") || prefersMarkdown(request)
const content = markdown ? "# Hello\n" : "<h1>Hello</h1>"
const response = new Response(content, {
headers: {
"Content-Type": markdown ? "text/markdown" : "text/html",
},
})
return withMarkdownAlternate(response, "/hello.page.md")
},
}
prefersMarkdown compares accepted quality and specificity, preferring HTML on
a tie. withMarkdownAlternate adds Vary: Accept and a Markdown alternate Link
to successful responses. appendVary(headers, name) adds a case-insensitive
header name without duplicating existing entries. Hosts own routing and Source
access before selecting a representation.
PathMX uses Zod 4 for validation and derives JSON Schema for field discovery. Plugins may use Zod or Zod Mini:
import { defineEvent, definePlugin } from "@pathmx/core"
import * as z from "zod/mini"
const Submitted = defineEvent({
data: z.object({ response: z.string() }),
})
export default definePlugin({
id: "course",
schema: {
sources: { course: { label: "Course" } },
},
events: { submitted: Submitted },
})
Plugins declare actions, queries, and events directly, alongside components
and routes. Each map uses local names; the plugin id supplies the namespace, so
this Event is course.submitted. Registration validates these declarations in the
same installation pass. Server emission, Action provenance, bounded subscriptions,
and browser observation declarations are documented in Events.
schema holds sources, blocks, props, and settings. defineSchema can extract
that data contract into a reusable fragment. Query metadata remains discoverable
through app.schema.query(id); executable query definitions belong in
plugin.queries. Keep browser bindings and optional deployment adapters
outside the portable import surface.
@pathmx/core/theme provides the theme CSS and attributes for a standalone
HTML document, such as a generated share image. It uses the same normalization
and presets as the Theme plugin without applying reader preferences.
import { renderTheme } from "@pathmx/core/theme"
const theme = renderTheme({
root: { preset: "paper", color: { accent: "#7545a0" } },
page: { font: { heading: "Georgia, serif" } },
scheme: "light",
})
const html = `<!doctype html>
<html data-pmx-theme="${theme.preset}" data-pmx-scheme="${theme.scheme}">
<head><style>${theme.css}</style></head>
<body style="background:var(--pmx-color-bg);color:var(--pmx-color-fg);font-family:var(--pmx-font-heading)">
<h1>Learning to see type</h1>
</body></html>`
Page values override root values. scheme is a fallback: an authored scheme or
fixed preset scheme takes priority. The result contains css, preset, and
scheme. This entry point is portable; capturing a PNG belongs to a host plugin.
Plugin contexts expose app.assets.read(path) for local authored binary inputs
and app.assets.publishDerived(sourcePath, name, blob) for generated variants.
The source must exist. Returned content-addressed URLs stay beside the original
and use its access boundary; static publication includes referenced variants.
The default Bun Image plugin uses these methods for responsive image files.
Use app.assets.read(path) and app.assets.publishDerived(path, name, blob)
from a Plugin host operation to produce a transformed download. Check the
embedding boundary before reading the original. Derived bytes retain that
original's access boundary and receive content-addressed URLs; do not author
those generated hashes.
Keep the transformation in tracked authored preparation or a domain operation, then pass the resulting URL as plain data to a native component. A portable presenter should not open files or perform the transformation on every render. See Plugin assets.
Use parseDirectiveLabel when a plugin needs to interpret an authored directive
label, such as the text of a rendered link:
import { parseDirectiveLabel } from "@pathmx/core"
const result = parseDirectiveLabel(
'@video(thumbnail="./preview.jpg" width=640) Motion study',
)
if (result.issue) throw new Error(result.issue)
// result.label: "Motion study"
// result.directive: {
// name: "video",
// params: { thumbnail: "./preview.jpg", width: 640 },
// qualifiers: [],
// }
The result contains label, directive, and issue when applicable. Ordinary
text produces a label without a directive. Parameters contain scalar strings,
numbers, or booleans. The parser checks shared syntax; each plugin validates its
accepted names, values, and combinations. Check issue before consuming a
recognized directive.
Bare createApp has no implicit public-read or personal-folder policy. It still
enforces current Actor identity, private Actor Sources, View restrictions, and
bounded Action effects. Install a base policy to choose the application's ordinary
Source permissions:
import { createApp } from "@pathmx/core"
import { bunEnvironment } from "@pathmx/core/bun"
import { SourceAccessPlugin } from "@pathmx/core/plugins"
const app = await createApp({
root: "./paths",
environment: bunEnvironment,
plugins: [SourceAccessPlugin],
})
SourceAccessPlugin owns the standard public, explicit Home, shared Source, and
attachment rules, including authoring validation, mutation checks, embedding, and
permission explanations. defaultPlugins includes it, so ordinary PathMX()
applications retain their standard behavior. Supplying a replacement plugins
list replaces the defaults; include this policy explicitly when you need its rules.
A base policy is supplied by Plugin.access(app) once, before Source indexing.
Only one base policy may be installed. Other plugins add capabilities with grants
or narrow contextual Views; they do not silently replace the base policy.
This complete alternative publishes only Sources with public: true:
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "publication-policy",
access(app) {
const published = (id: string) => app.sources.get(id)?.data.public === true
return {
resolve(_principal, target) {
const id = typeof target === "string" ? target : target.id
return published(id)
? { visibility: "public", capabilities: ["read"] }
: { visibility: "denied", capabilities: [] }
},
authorize() {
throw new Error("Edit these files through the host.")
},
canEmbed(container, dependency) {
return published(container) && published(dependency)
},
}
},
})
Install this plugin instead of SourceAccessPlugin. Its authoring has no Home or
shared-Source convention. It may optionally supply home(actorId) and
homeActorId(path) for personal State and ~/ paths. Trusted consumers use
app.access.home(actorId); Actor identity itself contains no Home.
resolve returns visibility (public, private, or denied) and a capability
array. blocked: true prevents supplemental grants from opening an invalid or
protected target. An optional reason explains permission inspection; target
may identify the Source owning an attachment. Inconsistent or malformed decisions
fail closed. authorize(principal, change) validates mutations; an undefined
principal denotes an already bounded Action effect. canEmbed checks whether a
compiled dependency may be included in its container. The policy may provide
diagnostics() and validate() for its authoring. Cross-Source invariants belong
in the owning Plugin's validateSources(prepared, app) hook, which sees parsed
changes and the proposed final repository before durable writes. Its Source hook
runs before other Source consumers, after Actor indexing.
Core independently rejects ordinary changes that create/delete an Actor, promote a Source into an Actor, or change an untyped file's derived Source id. Plugins such as Auth add their own authority-field invariants. Repository-admin authoring remains the explicit management boundary for those changes.
Policy changes must use normal Source commits and indexing. Retained Views and
query caches observe the new revision, and asynchronous delivery discards stale
results. ~/ resolution is unavailable when the policy supplies no Home.
The standard policy reads a Home claim from the directory's canonical index Source:
---
access:
type: home
owner: /people/sam.person
---
The claim protects that directory and every descendant Source or asset. It remains reserved if its Actor is missing, including after restart and during static export. Duplicate, overlapping, or case-aliased claims fail closed. Ordinary Actor and Action mutations cannot create, remove, retarget, or relocate Home policy; trusted repository changes validate the complete final claim set before writing. Moving or removing a claim is rejected while any Source or asset remains in the old namespace.
A canonical directory index with access.type: shared similarly protects itself
and every descendant Source and asset. The nearest nested shared index or an
explicit non-index shared policy may narrow access for its subtree or Source, but
cannot grant capabilities excluded by an ancestor boundary. Inheritance is based
on storage location, not links. A shared subtree inside a Home can deliberately
admit collaborators; Home ownership and shared-directory policy remain separate.
Removing a shared index is rejected while retained descendants would become public.
Permission inspection reports the index Source from which access was inherited.
The default SourceAccessPlugin reserves /private/ for files that must remain
unavailable without explicit access. It requires private attachment bindings to
use this namespace, an existing protected Home, or a shared directory boundary.
Public file locations remain
ordinary public files; private bindings outside protected storage are invalid.
Protected namespaces also deny case aliases, so a URL such as /PRIVATE/file.png
cannot bypass protection on a case-insensitive filesystem.
A repository-admin View does not override a policy-blocked /private/ Source.
Its readSource returns no Source and its queries omit that Source, even though
the same authority can inspect Homes. Trusted host reads through app.sources
are a different boundary from reads through a View.
For example, a shared document can bind an image without giving its Actor a Home:
---
access:
type: shared
owner: /people/alice.person
members:
- actor: /people/alice.person
can: [read, write]
attachments:
- /private/sketch.png
---
# Team sketch

The owner is an Actor Source with actor: true. The binding grants the document's
readers access to the file and its derived images. A public Source can explicitly
share a file from /private/ or a Home. A shared directory's allowlist remains a
ceiling: an attachment binding cannot grant access excluded by the file's enclosing
shared indexes. Unclaimed files inherit those indexes directly. Direct reads and
embedding checks use the same access resolver; contextual View restrictions still apply.
Actor-authored mutations can add a binding only when the Actor already has read
access to the file. Owning a document cannot claim someone else's private file or
an unbound /private/ file. Trusted repository authoring and bounded service
Actions can establish new bindings, as upload handling already does.
Removing a binding or deleting its Source does not delete file bytes. Unbound files
under /private/ remain denied after restart and excluded from anonymous static
export. Files in a Home return to that Home's access rules. This is a convention
of the default access plugin, not a new core storage API.
An Actor is a Source with actor: true. Core does not require a role, email, or
Home. With SourceAccessPlugin, a Home is declared by access.type: home and
owner on that directory's index Source. An Actor may own zero or one Home. Plugins
own role meaning and credential admission. @pathmx/auth supplies User admission
and its staff-review convention.
A plugin's synchronous grants hook adds Source capabilities from current indexed
state. This complete example lets an event host edit badges for that event:
import { definePlugin } from "@pathmx/core"
export default definePlugin({
id: "event-access",
grants(target, { principal }, app) {
if (principal.type !== "actor") return
if (typeof target === "string" || target.type !== "badge") return
if (typeof target.data.event !== "string") return
const event = app.sources.get(target.data.event)
if (event?.data.host === principal.actor) return ["read", "write"]
},
})
The badge authors event: /saturday.event; that Event authors
host: /people/sam.participant. No core role or personal folder is needed.
A general badge-editing Action can use available: ({ can }) => can("write").
Grant contributions combine with the installed base policy. The standard policy provides public, explicit Home, and shared-Source permissions. Contextual View restrictions still apply to every resulting capability. Grants cannot resurrect stale Actors, expose Actor Sources, override malformed shared access, or unlock reserved orphaned Home paths. Private Home/shared access requires a host-admitted Actor. Explicit repository administration retains its host-owned bypass. Attachment reads resolve through their owning Source.
Return an array of read, write, and/or execute, or undefined to decline.
An empty array adds nothing. Malformed or throwing providers deny access. Providers
must use indexed Sources and synchronous policy state; do not call app.access
recursively from a grant hook. Mutate policy through normal Source changes so
indexing, query caches, and asynchronous freshness checks update together.
Grant capabilities describe ordinary Source access. Actions that perform a narrower domain operation still declare finite prepared effects; this hook does not replace Action availability or expand those effects. See Actions and Views.
- Components — Markdown and plugin rendering.
- React — application bindings.
- Packages — package boundaries and activation.
- CLI — command behavior and plugin lint contributions.
await app.sources.discoverable(view) returns canonical page Sources reachable from
/ through Sources that the supplied View can read. Layout, navigation, and
other authored Source references participate in traversal. Authored queries also
connect their returned internal href references, evaluated in the supplied View
with the same filters, sorting, and limit as the page query. Supporting Sources
can connect pages without appearing as page results. This does not change
app.sources.all(), general Source queries, or direct URL access.
import { route, type Plugin } from "@pathmx/core"
export default function PageIndexPlugin(): Plugin {
return {
id: "page-index",
routes: [
route.get("", async (ctx) => {
const view = ctx.view(await ctx.session())
const pages = await ctx.sources.discoverable(view)
return Response.json(
pages.map((source) => ({
id: source.id,
markdown: source.path,
})),
)
}),
],
}
}
For an anonymous index, use ctx.view({ type: "anonymous", signIn: null }).
The result is a read-only list; copy it before sorting. Discovery uses the
indexed graph and View-aware query relations; it does not compile or render pages.
Browser plugins register navigation, transport, or utility controls into the framework's augmentation dock. Core owns spacing, safe-area placement, and utility overflow; plugins own their content and availability.
import { surfaces } from "@pathmx/core/controls/browser"
const control = surfaces.register({
id: "path.navigation",
element: navigation,
group: "navigation",
presentation: { document: "full", play: "compact" },
})
// Dispose when the authored feature host becomes unavailable.
control.dispose()
Register each id once per Document. Keep an authored availability host in the View, and move only its control element. Registration moves the element without remounting its state. Disposal restores its original position when available, or removes it. React consumers can render through a portal into a registered host. Embedded Source documents have their own scoped registry.
The presentation host calls surfaces.setContext("play", { visible }) with its
interaction visibility and restores "document" on exit. The entire dock fades
together; hover, keyboard focus, and expanded utility controls keep it visible.
Plugins retain ownership of availability, including development-only controls.
Compact surfaces share --pmx-augmented-control-size (3rem). Use the shared
pmx-button floating/icon variants for individual actions.
Call a utility registration’s reveal() before returning focus to it from a
dialog, so its overflow group is available. Contributions receive
data-pmx-surface-presentation="full" or "compact" according to their declared
mapping. Utility controls use More controls in Play and at narrow widths.
Navigation and transport stay directly available; disabled navigation controls
should retain their space. Dialogs and popovers keep their normal focus and
accessibility semantics.
For a custom action flow, executeBrowserAction({ action, request, pending })
uses the mounted View’s action runner and reconciliation. request accepts
{ kind: "form", fields } or { kind: "input", value }. Check the returned
reconciliation.status before navigation: only "applied" confirms that the
current mount received the result; "failed" and "superseded" do not. Prefer
the normal authored action form unless custom post-action behavior is needed.