@openparachute/surface-client
Browser-side library for building a custom surface over a Parachute vault — OAuth (PKCE + Dynamic Client Registration), a typed vault REST client, token storage, and runtime-tenancy helpers. Framework-agnostic core: no React, just fetch + types.
A surface is any UI that talks to a vault — a daily-capture inbox, a project dashboard, a graph explorer. Without this library you re-implement OAuth, the vault client, token storage, and the core types from scratch (the real-world adopter my-vault-ui hand-rolled ~1,300 lines of exactly that). With it, the auth + data layer is one import.
import {
ParachuteOAuth,
VaultClient,
discoverAuthServer, registerClient,
loadToken, saveToken, clearToken,
getMountBase, getTenantId, getHubOrigin, getVaultUrl,
vaultIdFromUrl, normalizeVaultUrl,
} from "@openparachute/surface-client";
oauth | ParachuteOAuth driver class — PKCE + same-hub auto-trust |
discovery | discoverAuthServer (RFC 8414) + registerClient (RFC 7591 DCR) |
vault-client | VaultClient REST client with auto-refresh on 401 + a typed error hierarchy |
subscribe | VaultClient.subscribe() — live-query SSE (snapshot + upsert/remove, auto-reconnect) |
notes-query | NotesQuery typed query builder — buildNotesQuery serializes to vault's exact wire grammar |
token-storage | loadToken / saveToken / clearToken / clearAllTokensForApp |
mount | runtime-tenancy readers — getMountBase / getTenantId / getHubOrigin / getVaultUrl |
sw-reload | reloadAfterServiceWorkerUpdate — PWA-mode SW reload helper |
vault-id | vaultIdFromUrl / normalizeVaultUrl — canonical URL ↔ storage-key mapping |
vault-types | core resource types — Note, NoteSummary, NoteLink, NoteAttachment, TagRecord, … |
Everything is re-exported from the barrel (@openparachute/surface-client) and also available on named subpaths (@openparachute/surface-client/oauth, …/vault-client, …/mount, …) for tree-shaking when you only need one piece.
Quick start — createVaultSurface (the one-liner path)
For the common case, you don't wire OAuth + VaultClient by hand at all. createVaultSurface is a turnkey factory that auto-detects whether you're hosted or standalone (see "Two deployment shapes" below), bakes sane defaults, runs Dynamic Client Registration for you when standalone, and hands back a VaultClient already wired with refresh-on-401:
import { createVaultSurface } from "@openparachute/surface-client";
const surface = createVaultSurface({ clientName: "My Vault UI" });
if (location.pathname === "/oauth/callback") {
await surface.handleCallback();
location.replace("/");
}
const vault = surface.getClient();
if (vault) {
const notes = await vault.queryNotes({ tag: "x" });
} else {
await surface.login();
}
The factory figures out the deployment shape, hub URL, redirect URI, scopes, and app name. Override any of them:
const surface = createVaultSurface({
clientName: "My Vault UI",
hubUrl: "https://my-hub.example.com",
vaultName: "default",
scope: "vault:read vault:write",
redirectUri: `${origin}/oauth/callback`,
bootstrap: "auto",
});
The returned VaultSurface is { oauth, bootstrap, hubUrl, vaultName, login(), handleCallback(), getClient(), moduleAuth(), logout() }. oauth is the underlying ParachuteOAuth if you need to drop down to the low-level dance. Everything below this section is that low-level layer — reach for it when the factory's defaults don't fit.
Second-audience tokens — moduleAuth() (0.3.1+)
A surface sometimes needs to call a different Parachute module's resource server — e.g. subscribe to the agent daemon's live turn-events SSE, which requires an agent:read token with aud: agent. That has to be a separate token from your vault token: the hub derives a token's audience from its scopes and a named-vault scope wins, so a token carrying both vault:… and agent:read resolves to aud: vault.<name> (the agent rejects it), and the hub's refresh can't re-narrow. So moduleAuth runs its own authorize scoped to the module scope alone.
const agent = surface.moduleAuth({ scope: "agent:read" });
if (!agent.getToken()) await agent.login();
const token = await agent.getAccessToken();
new EventSource(`${issuerOrigin}/agent/api/channels/${name}/turn-events?token=${token}`);
moduleAuth(opts) → ModuleAuth: { scope, storageScope, login(), handleCallback(): Promise<boolean>, getAccessToken(), getToken(), logout() }. It reuses the surface's DCR client + discovery/refresh caches, and is isolated from the vault flow — its token lives under a separate storage key (storageScope, default: the service prefix of scope — e.g. "agent" for "agent:read", "scribe" for "scribe:read"; a guard throws if it would alias the vault token), and the pending-flow state is namespaced so a single shared /oauth/callback routes correctly: handleCallback() returns false (declines without consuming) when the callback state belongs to another flow. The vault flow is 100% unchanged.
Session resilience (return visits just work). Hub access tokens live ~15 minutes, so every return visit starts with an expired-but-refreshable token. Two behaviors make that path safe without app-side workarounds:
- Cold-load refresh —
getClient()'s refresh seam re-seeds the DCR client_id from the factory's durable cache (parachute_surface_dcr:<appName> in localStorage) before exchanging the refresh token, so the first 401 on a fresh page load refreshes instead of throwing. If the cache is gone (or the hub issuer changed), refresh reports "not possible" and your UI falls back to login() — a fresh registration is never attempted mid-refresh, because the refresh token is bound to the original client_id.
- Single-flight refresh — N parallel queries hitting N 401s collapse into one token-endpoint exchange (shared promise per vault). This is correctness, not polish: the hub rotates refresh tokens and treats a concurrent re-use as replay/theft, revoking the whole token family — i.e. a second concurrent refresh used to kill the session.
Two deployment shapes — read this first
How a surface bootstraps OAuth depends on where it runs. This is the single most important thing to get right.
Standalone surface (the default for an external developer)
You serve your surface from anywhere — GitHub Pages, Netlify, localhost:5173, an S3 bucket. There is no Parachute host in front of it. This is the shape the build-a-custom-surface onboarding prompt targets, and the one examples/standalone-spa demonstrates end to end.
A standalone surface bootstraps its OAuth client with RFC 7591 Dynamic Client Registration (DCR): it discovers the hub's authorization-server metadata, registers itself as a public (PKCE-only, no-secret) client at runtime with its own URL as the redirect URI, then drives the standard authorization-code + PKCE dance. The operator approves it once on the hub consent screen (or it auto-approves if the operator's hub session cookie is present — "same-hub auto-trust").
import {
discoverAuthServer,
registerClient,
ParachuteOAuth,
} from "@openparachute/surface-client";
const HUB_URL = "https://my-hub.example.com";
const REDIRECT_URI = `${window.location.origin}/oauth/callback`;
const metadata = await discoverAuthServer(HUB_URL);
const { client_id } = await registerClient(metadata.registration_endpoint, {
clientName: "My Vault UI",
redirectUri: REDIRECT_URI,
});
const oauth = new ParachuteOAuth({ appName: "my-vault-ui", hubUrl: HUB_URL });
oauth.useClientId({ client_id, scopes: ["vault:read", "vault:write"] });
const { authorizeUrl } = await oauth.beginFlow({
vaultName: "default",
redirectUri: REDIRECT_URI,
});
window.location.assign(authorizeUrl);
const url = new URL(window.location.href);
await oauth.handleCallback(
url.searchParams.get("code")!,
url.searchParams.get("state")!,
"default",
);
Why not getClientId()? ParachuteOAuth.getClientId() fetches a hosted-only endpoint (/surface/<name>/oauth-client) that only exists when a Parachute surface-host is serving your bundle. A standalone surface has no such endpoint — it must self-register via DCR as above. useClientId(...) lets you hand ParachuteOAuth the DCR-registered client so beginFlow / handleCallback / refreshAccessToken work without ever touching the hosted endpoint.
Hosted surface (bundled under a Parachute surface-host)
If your bundle is served by @openparachute/surface under /surface/<name>/ (the way @openparachute/notes-ui ships), the host injects runtime-tenancy <meta> tags and exposes a per-surface OAuth-client endpoint. In that case you let ParachuteOAuth fetch the client_id for you — no DCR needed:
const oauth = new ParachuteOAuth({
appName: "my-app",
hubUrl: getHubOrigin() ?? window.location.origin,
});
await oauth.getClientId();
const { authorizeUrl } = await oauth.beginFlow({
vaultName: "default",
});
window.location.assign(authorizeUrl);
getClientId() succeeds only when the host endpoint exists. If you're not sure which shape you're in, the presence of a parachute-mount meta tag (see below) is the signal: present → hosted; absent → standalone.
Prefer the createVaultSurface(...) quick-start above — it collapses both bootstraps into one call with automatic hosted-vs-standalone detection. The hand-wired paths in this section are the low-level escape hatch for when the factory's defaults don't fit.
Runtime-tenancy contract (<meta> tags)
A hosted surface-host injects structured environment metadata into every served index.html, and the mount helpers read it. These tags are a hosted-surface feature — a standalone surface has no host to inject them, so the readers return null off-host (by design; they never throw). Configure your vault URL + hub origin explicitly instead (a paste-in screen or build-time config), as the standalone example does.
The canonical injected shape:
<head>
<base href="/surface/<name>/">
<meta name="parachute-mount" content="/surface/<name>">
<meta name="parachute-hub" content="https://hub.example">
<meta name="parachute-vault" content="/vault/<name>">
<meta name="parachute-vault-origin" content="https://vault.example">
</head>
getMountBase() | parachute-mount (trailing slash stripped, bare / rejected) | null | /notes (legacy) or app boot error |
getTenantId() | last segment of parachute-mount (/surface/<slug>) | null | a stable label for storage keys |
getHubOrigin() | parachute-hub | null | window.location.origin |
getVaultUrl() | parachute-vault (+ parachute-vault-origin for cross-origin) | null | an explicit, operator-entered vault URL |
getVaultUrl, not getVaultPath; tenant-id is derived, not a tag. The code exports getVaultUrl (it returns a fully-qualified URL — origin + path — so fetch(getVaultUrl()) works directly) and getTenantId derives the tenant id from the mount path. There is no getVaultPath export and no parachute-tenant-id meta tag. (The runtime-tenancy-contract.md pattern doc previously named those; the code is the source of truth and the pattern doc was reconciled to match in the same change that shipped this README.)
Using the vault
Once you have a stored token, build a VaultClient. Wire onAuthError so a 401 transparently refreshes:
const stored = oauth.getToken("default");
if (stored) {
const vault = new VaultClient({
vaultUrl: stored.vault
? `${HUB_URL}/vault/${stored.vault}`
: `${HUB_URL}/vault/default`,
accessToken: stored.accessToken,
onAuthError: async () => {
if (!stored.refreshToken) return null;
const { token } = await oauth.refreshAccessToken(stored.refreshToken, "default");
return token.access_token;
},
});
const notes = await vault.queryNotes({ tag: "x" });
}
For scripts (Bun / Node), VaultClient.fromHub({ hubOrigin, vaultName, token }) composes the canonical URL for you.
Typed queries — NotesQuery
queryNotes, queryNotesCursor, and subscribe accept a typed NotesQuery object alongside the raw URLSearchParams | Record<string,string> forms (which remain fully supported — existing callers are untouched). The typed shape covers vault's structured-query grammar so you don't memorize the wire spelling:
const notes = await vault.queryNotes({
tag: ["#work", "#decision"],
tagMatch: "any",
expand: "subtypes",
excludeTag: "#archived",
pathPrefix: "Work/",
metadata: {
status: { in: ["in-progress", "in-review"] },
priority: "now",
},
date: { field: "updated_at", from: "2026-06-01" },
orderBy: "updated_at",
sort: "desc",
limit: 50,
});
Notes on the mapping (all pinned by tests against vault's parser):
- Metadata scalar vs operator object. A scalar (
priority: "now") is shorthand equality — a JSON scan that works on non-indexed fields. An operator object ({ eq, ne, gt, gte, lt, lte, in, not_in, exists }) routes through the indexed column — vault 400s with FIELD_NOT_INDEXED if the field isn't declared in a tag schema.
date serializes to the canonical bracket bridge (meta[updated_at][gte]=…), never the deprecated flat date_field/date_from params. Bounds are half-open: from inclusive, to exclusive.
search / near are deliberately not modeled — they're separate query shapes (and invalid for subscriptions). Use the raw forms for them. Unknown keys with string values pass through verbatim, so mixing typed keys with a raw "meta[...]" param also works.
buildNotesQuery(q) / toNotesSearchParams(input) are exported if you want the URLSearchParams yourself.
Live queries — subscribe()
Any view that polls queryNotes on a timer can subscribe instead. VaultClient.subscribe() opens vault's live-query SSE endpoint (GET /api/subscribe): you get one onSnapshot(notes) with the complete matching set, then onUpsert(note) / onRemove(id) as notes enter, change, or leave the set.
const unsubscribe = vault.subscribe(
{ tag: "#channel-message", "meta[channel][eq]": "general" },
{
onSnapshot: (notes) => render(notes),
onUpsert: (note) => upsertRow(note),
onRemove: (id) => dropRow(id),
onStatus: (s) => setLive(s === "open"),
onError: (err) => console.warn(err),
},
);
Things worth knowing:
- Query grammar is the same as
queryNotes (same server-side parser), except search, near, and cursor aren't live-evaluable — subscribe() throws on them synchronously rather than letting the vault 400.
- The bearer rides the
Authorization header, not the URL. The transport is a fetch stream with hand-parsed SSE frames, not EventSource — EventSource can't set headers, which would force the token into a ?key= query param (proxy logs, browser history). This also makes subscribe() work server-side (Bun/Node) where EventSource may not exist.
- Reconnects are self-correcting. Vault has no event replay; on reconnect (capped exponential backoff) the client re-subscribes and the fresh
onSnapshot replaces your set, reconciling anything missed while disconnected. Treat every snapshot as the new truth, not a delta.
- Token expiry is handled. A 401 on (re)connect drives the client's
onAuthError refresh seam once and resubscribes with the fresh token. If refresh isn't possible, the subscription terminates: onError(VaultAuthError) then onStatus("closed") — without a "closed", it's still retrying.
- Stop it via the returned unsubscribe function or an
AbortSignal (subscribe(query, handlers, { signal })).
Typed links on create/update
UpdateNotePayload.links mutates typed links: { add?: { target, relationship, metadata? }[], remove?: { target, relationship }[] } (target is a note id or path; missing targets skip silently; vault echoes the hydrated links on the response when you mutate them). CreateNotePayload.links is a flat array — { target, relationship }[], no envelope and no per-link metadata, because that's what vault's POST branch actually reads; create first + updateNote({ links: { add } }) when you need link metadata.
Auth'd media — fetchAttachmentBlob()
VaultClient.fetchAttachmentBlob(url) GETs an attachment blob with the client's full auth contract (bearer header, refresh-on-401 retry, structured errors). It accepts an absolute URL, a vault-relative /api/storage/<path>, or a bare storage path. surface-render's vaultClientFetchBlob adapter prefers this method, so a plain VaultClient renders auth-gated images/audio with zero extra wiring. By design there is no getAccessToken accessor — the token stays inside the client (the same custody contract surface-host's scoped server-side client relies on).
Don't redeclare the core types
Note, NoteSummary, NoteLink, NoteAttachment, TagRecord, TagUpsertPayload, UpdateNotePayload, CreateNotePayload, FindPathResult (and more) are exported from the barrel and match vault's wire format byte-for-byte. Import them rather than hand-redeclaring — that drift is exactly what this package exists to kill.
Error handling
VaultClient rejects with a typed error hierarchy so you can map failures to UI affordances without string-matching messages. All concrete errors extend the abstract VaultError, so catch (e) { if (e instanceof VaultError) … } catches any vault failure.
VaultError (abstract base — "any vault error")
├── VaultAuthError 401 — token dead/missing → start the OAuth flow
│ └── VaultPermissionError 403 — token lacks the scope → ask for a broader grant
├── VaultNotFoundError 404 — note / tag / path doesn't exist
├── VaultConflictError 409 — optimistic-concurrency / tag-in-use (see error.body)
├── VaultTargetExistsError 409 — create-would-clobber an existing path
├── VaultUnreachableError network down (status 0) → "can't reach your hub" + retry
│ └── VaultServerError 5xx — hub is up but erroring → retry / report
└── VaultUploadError attachment upload failed
Recommended UI mapping:
VaultPermissionError | authed but wrong scope | "This needs more access" → re-run beginFlow with the broader scope |
VaultAuthError | token expired / revoked | bounce to sign-in (beginFlow) |
VaultNotFoundError | resource gone | inline "not found", offer to create |
VaultConflictError | concurrent edit / tag in use | reload + show the conflict; error.body carries referenced_by for tag-in-use |
VaultTargetExistsError | path collision on create | prompt for a new path |
VaultServerError | hub erroring (5xx) | "something went wrong on the hub" + retry |
VaultUnreachableError | network down | "can't reach your hub" + retry |
VaultUploadError | upload failed | retry the upload |
Because VaultPermissionError extends VaultAuthError and VaultServerError extends VaultUnreachableError, order your instanceof checks specific-before-general (check VaultPermissionError before VaultAuthError).
ParachuteOAuth adds three OAuth-flow errors: PendingApprovalError (the hub registered the client but needs operator approval — carries approveUrl for a "approve in your hub" CTA), RefreshHttpError (the hub rejected a refresh token — distinct from a network failure), and InsecureContextError (PKCE can't run outside a secure context — serve over HTTPS or localhost).
Examples
examples/standalone-spa — a minimal, framework-free standalone surface that runs the full DCR bootstrap, the OAuth dance, and a vault query. Copy it as a starting point for a custom surface served from GitHub Pages or any static host.
License
AGPL-3.0.