@openparachute/scope-guard
v0.2.1
Published
Hub-issued JWT validation for Parachute resource servers (vault, scribe, parachute-agent, third-party modules).
Readme
@openparachute/scope-guard
Hub-issued JWT validation for Parachute resource servers (vault, scribe, parachute-agent, third-party modules).
The Parachute hub mints OAuth access tokens as RS256 JWTs and publishes its public keys at /.well-known/jwks.json. This library is the consumer-side mirror: every resource server uses the same verifier so the trust kernel doesn't drift between modules.
What's in the box
createScopeGuard({ hubOrigin, jwks?, jwksGetter? })— factory bound to a hub origin. Holds the JWKS getter so the cache lives across requests.hubOriginmay be a string or a resolver function (for layered env-var precedence).guard.validateHubJwt(token, { expectedAudience? })— JWKS-backed verify. Pinsissto the configured hub origin, strict-checksaud(RFC 7519 string-or-array) when supplied. ThrowsHubJwtError(with acode) on failure.parseScopes(raw)/extractBearer(authHeader)/looksLikeJwt(token)— string helpers every consumer reaches for.hasScope(granted, required)— generic<resource>:<verb>and<resource>:<name>:<verb>matcher withadmin ⊇ write ⊇ readinheritance. The lib is the engine, not the dictionary; per-service vocabularies and cross-resource catch-alls stay in each service.HubJwtError.code— single error class with a coarse code:signature | issuer | expired | kid | jwks | audience | shape. Branch oncoderather than catching subclasses.
Quick start
import { createScopeGuard, extractBearer, hasScope } from "@openparachute/scope-guard";
const guard = createScopeGuard({
hubOrigin: () => process.env.PARACHUTE_HUB_ORIGIN ?? "http://127.0.0.1:1939",
});
async function enforceAuth(req: Request, vaultName: string) {
const token = extractBearer(req.headers.get("authorization"));
if (!token) return new Response("missing bearer token", { status: 401 });
try {
const claims = await guard.validateHubJwt(token, {
expectedAudience: `vault.${vaultName}`,
});
if (!hasScope(claims.scopes, `vault:${vaultName}:read`)) {
return new Response("insufficient scope", { status: 403 });
}
return { ok: true as const, claims };
} catch (err) {
return new Response(`invalid token (${(err as { code?: string }).code ?? "unknown"})`, {
status: 401,
});
}
}Design
See parachute-hub/docs/design/2026-04-29-scope-guard-library.md for full rationale, alternatives considered, and the migration sequence (vault → scribe → parachute-agent).
The lib lives as a sub-package of parachute-hub because the hub owns the JWT-issuance side and the scope vocabulary. It's published independently to npm as @openparachute/scope-guard.
Versioning
Pre-1.0 (0.x.y) — this is a young library:
- Patches (
0.1.0→0.1.1) are additive and behavior-preserving. Adopters can take patch bumps without review. - Minors (
0.1.x→0.2.0) may break adopters. Read the CHANGELOG before bumping. -rc.Nprereleases match the broader Parachute ecosystem's pre-1.0 governance — landed on thercdist-tag on every code-touching PR; promotion to@latestis a deliberatenpm dist-tagstep.
The library's RC cadence is independent of @openparachute/hub's — they're shipped from the same repo but aren't coupled in version. A hub release doesn't imply a scope-guard release and vice versa.
Post-1.0 the library will follow standard semver — minors backward-compatible, majors with a migration note.
License
AGPL-3.0
