@openparachute/hub 0.3.0-rc.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Unified `parachute expose public` provider readiness probe.
3
+ *
4
+ * Public exposure has two backends today — Tailscale Funnel and Cloudflare
5
+ * Tunnel — and every entry point that decides between them needs the same
6
+ * "what's actually usable on this box right now?" snapshot. The interactive
7
+ * picker (`expose-interactive.ts`), the non-TTY auto-pick path
8
+ * (`expose-public-auto.ts`), and any future caller share this module so the
9
+ * readiness rules stay in one place.
10
+ *
11
+ * "Ready" = the user could pick this provider and have it work end-to-end:
12
+ *
13
+ * - tailnet: binary on PATH AND logged in AND Funnel ACL grants the cap.
14
+ * Funnel without the cap fails at bringup with an opaque
15
+ * admin-console error, which we'd rather pre-empt.
16
+ * - cloudflare: binary on PATH AND `~/.cloudflared/cert.pem` exists.
17
+ * cert.pem is cloudflared's own login marker — every
18
+ * `tunnel create|list|route` call reads it.
19
+ *
20
+ * The shape (`available` + provider-specific extras) keeps the call sites
21
+ * readable: `r.tailnet.available && r.tailnet.funnelEnabled` reads as the
22
+ * sentence it represents.
23
+ */
24
+
25
+ import {
26
+ DEFAULT_CLOUDFLARED_HOME,
27
+ isCloudflaredInstalled,
28
+ isCloudflaredLoggedIn,
29
+ } from "../cloudflare/detect.ts";
30
+ import { getTailscaleStatus, isTailscaleInstalled } from "../tailscale/detect.ts";
31
+ import { type Runner, defaultRunner } from "../tailscale/run.ts";
32
+
33
+ export interface TailnetAvailability {
34
+ /** `tailscale` is on PATH. */
35
+ available: boolean;
36
+ /** This machine is logged into a tailnet (Self.DNSName populated). */
37
+ loggedIn: boolean;
38
+ /** This node's tailnet ACL grants the Funnel capability. */
39
+ funnelEnabled: boolean;
40
+ }
41
+
42
+ export interface CloudflareAvailability {
43
+ /** `cloudflared` is on PATH. */
44
+ available: boolean;
45
+ /** `~/.cloudflared/cert.pem` exists (the login marker). */
46
+ loggedIn: boolean;
47
+ }
48
+
49
+ export interface ProviderAvailability {
50
+ tailnet: TailnetAvailability;
51
+ cloudflare: CloudflareAvailability;
52
+ }
53
+
54
+ export interface DetectProvidersOpts {
55
+ runner?: Runner;
56
+ /** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
57
+ cloudflaredHome?: string;
58
+ }
59
+
60
+ export async function detectProviders(
61
+ opts: DetectProvidersOpts = {},
62
+ ): Promise<ProviderAvailability> {
63
+ const runner = opts.runner ?? defaultRunner;
64
+ const cloudflaredHome = opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME;
65
+
66
+ const tailnetAvailable = await isTailscaleInstalled(runner);
67
+ // One `tailscale status --json` covers both login state and Funnel cap;
68
+ // skip when the binary is missing — the call would just fail.
69
+ const { loggedIn: tailnetLoggedIn, funnelCapable: tailnetFunnelEnabled } = tailnetAvailable
70
+ ? await getTailscaleStatus(runner)
71
+ : { loggedIn: false, funnelCapable: false };
72
+
73
+ const cloudflareAvailable = await isCloudflaredInstalled(runner);
74
+ const cloudflareLoggedIn = cloudflareAvailable ? isCloudflaredLoggedIn(cloudflaredHome) : false;
75
+
76
+ return {
77
+ tailnet: {
78
+ available: tailnetAvailable,
79
+ loggedIn: tailnetLoggedIn,
80
+ funnelEnabled: tailnetFunnelEnabled,
81
+ },
82
+ cloudflare: {
83
+ available: cloudflareAvailable,
84
+ loggedIn: cloudflareLoggedIn,
85
+ },
86
+ };
87
+ }
88
+
89
+ /** Tailnet Funnel is usable end-to-end on this box. */
90
+ export function isTailnetReady(p: ProviderAvailability): boolean {
91
+ return p.tailnet.available && p.tailnet.loggedIn && p.tailnet.funnelEnabled;
92
+ }
93
+
94
+ /** Cloudflare Tunnel is usable end-to-end on this box. */
95
+ export function isCloudflareReady(p: ProviderAvailability): boolean {
96
+ return p.cloudflare.available && p.cloudflare.loggedIn;
97
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Human-readable explanations for first-party Parachute OAuth scopes.
3
+ *
4
+ * Used by the consent screen to render each requested scope with a
5
+ * one-sentence description, and by `/.well-known/oauth-authorization-server`
6
+ * to populate `scopes_supported` (closes cli#68).
7
+ *
8
+ * Keep these short and operator-facing. The reader is the hub's owner
9
+ * deciding whether to grant a third-party app access — they need to
10
+ * understand *what data the app will see* in plain language, not the
11
+ * technical contract.
12
+ *
13
+ * Third-party module scopes pass through here without an entry —
14
+ * `explainScope` falls back to the raw scope string and the consent UI
15
+ * renders them verbatim. cli#56 (drop SERVICE_SPECS) plus the eventual
16
+ * `parachute.json` `scopes.defines` field will let modules ship their
17
+ * own descriptions; until then, the canonical Parachute scopes are
18
+ * hardcoded here.
19
+ *
20
+ * Source of truth for the scope shape:
21
+ * `parachute-patterns/patterns/oauth-scopes.md`.
22
+ */
23
+
24
+ export interface ScopeExplanation {
25
+ /** One-sentence operator-facing description of what the scope grants. */
26
+ label: string;
27
+ /**
28
+ * "admin" scopes are highlighted in the consent UI — broad damage
29
+ * potential if the requesting app is compromised, so we make the
30
+ * operator look at them twice.
31
+ */
32
+ level: "read" | "write" | "admin" | "send";
33
+ }
34
+
35
+ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
36
+ "vault:read": {
37
+ label: "Read your notes, tags, attachments, and vault config.",
38
+ level: "read",
39
+ },
40
+ "vault:write": {
41
+ label: "Create, edit, and delete notes, tags, and attachments.",
42
+ level: "write",
43
+ },
44
+ "vault:admin": {
45
+ label: "Full vault access plus configuration changes (rotate tokens, change settings).",
46
+ level: "admin",
47
+ },
48
+ "scribe:transcribe": {
49
+ label: "Send audio to Scribe for transcription.",
50
+ level: "write",
51
+ },
52
+ "scribe:admin": {
53
+ label: "Manage Scribe configuration (provider keys, models, quotas).",
54
+ level: "admin",
55
+ },
56
+ "channel:send": {
57
+ label: "Post messages to your Channel.",
58
+ level: "send",
59
+ },
60
+ "hub:admin": {
61
+ label: "Manage hub identity (user accounts, signing keys, registered OAuth clients).",
62
+ level: "admin",
63
+ },
64
+ "parachute:host:admin": {
65
+ label:
66
+ "Provision and manage vaults across this host (create new vaults, configure cross-vault settings).",
67
+ level: "admin",
68
+ },
69
+ };
70
+
71
+ /**
72
+ * Sorted list of every first-party scope the hub recognizes. Used as the
73
+ * baseline for `scopes_supported` in the OAuth-AS metadata; module-declared
74
+ * scopes (cli#68) are unioned on top.
75
+ */
76
+ export const FIRST_PARTY_SCOPES = Object.keys(SCOPE_EXPLANATIONS).sort();
77
+
78
+ /**
79
+ * Scopes the hub will not mint via the public OAuth flow. Operator-only —
80
+ * available exclusively through local mint paths that have already proven
81
+ * the caller is the on-box operator:
82
+ *
83
+ * - `parachute auth rotate-operator` writes the long-lived operator token
84
+ * (`~/.parachute/operator.token`, mode 0600) for service accounts.
85
+ * - `GET /admin/host-admin-token` exchanges a valid `parachute_hub_session`
86
+ * cookie (set by `/admin/login` after a password check) for a
87
+ * short-lived JWT consumed by the in-tree vault-management SPA.
88
+ *
89
+ * Both surfaces predicate on local-operator identity that the public OAuth
90
+ * flow can't establish. Listed here so the issuer can:
91
+ *
92
+ * 1. Reject early at `/oauth/authorize` with RFC 6749 `invalid_scope`
93
+ * rather than letting the request walk to the consent screen.
94
+ * 2. Hide non-requestable scopes from `scopes_supported` in the AS
95
+ * metadata — clients shouldn't be advertised what we always reject.
96
+ * RFC 8414 §2 says `scopes_supported` is the list a client *can*
97
+ * request, so omitting these is the spec-compliant call.
98
+ *
99
+ * Why `parachute:host:admin` is on this list and `hub:admin` is not:
100
+ * `parachute:host:admin` provisions and destroys vaults — cross-vault
101
+ * data sovereignty that the operator alone owns. `hub:admin` is service
102
+ * management (signing keys, registered clients, user accounts) which an
103
+ * operator may legitimately delegate to a tooling app. The asymmetry is
104
+ * intentional: the blast radius of compromised cross-vault admin doesn't
105
+ * justify third-party requestability.
106
+ */
107
+ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set(["parachute:host:admin"]);
108
+
109
+ /**
110
+ * Per-vault `vault:<name>:admin` scopes are also non-requestable: they let
111
+ * the holder mint, revoke, and rotate tokens for a specific vault instance,
112
+ * which is operator-only territory. Like `parachute:host:admin`, these are
113
+ * minted by a session-cookie-gated hub endpoint (`/admin/vault-admin-token/:name`),
114
+ * never by the public OAuth flow.
115
+ *
116
+ * Pattern-based because the set is open-ended — every vault instance the
117
+ * operator creates implies a new scope, and we don't want to enumerate them.
118
+ */
119
+ const VAULT_ADMIN_RE = /^vault:[a-zA-Z0-9_-]+:admin$/;
120
+
121
+ /** True when the scope is non-requestable via the public OAuth flow. */
122
+ export function isNonRequestableScope(scope: string): boolean {
123
+ return NON_REQUESTABLE_SCOPES.has(scope) || VAULT_ADMIN_RE.test(scope);
124
+ }
125
+
126
+ /** True when the scope can appear in a public `/oauth/authorize` request. */
127
+ export function isRequestableScope(scope: string): boolean {
128
+ return !isNonRequestableScope(scope);
129
+ }
130
+
131
+ export function explainScope(scope: string): ScopeExplanation | null {
132
+ return SCOPE_EXPLANATIONS[scope] ?? null;
133
+ }
134
+
135
+ export function scopeIsAdmin(scope: string): boolean {
136
+ return explainScope(scope)?.level === "admin";
137
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Scope registry — the issuer's view of which OAuth scopes are blessed.
3
+ *
4
+ * The hub signs JWTs whose `scope` claim is the contract the resource server
5
+ * trusts. Issuing a token with a scope no module ever declared is a protocol
6
+ * violation: in Phase B2 (multi-RS validation), an unknown scope reaching a
7
+ * downstream service should be rejected, but defense-in-depth says don't
8
+ * sign claims you don't understand. This module is the gate.
9
+ *
10
+ * Source of truth for scope shape: `parachute-patterns/patterns/oauth-scopes.md`.
11
+ *
12
+ * Declared scopes come from two places:
13
+ * 1. `FIRST_PARTY_SCOPES` — the canonical Parachute scopes hardcoded in
14
+ * `scope-explanations.ts` (vault:read, scribe:transcribe, …).
15
+ * 2. Each registered service's `.parachute/module.json` `scopes.defines`
16
+ * array — third-party modules opt in by declaring up front.
17
+ *
18
+ * Resolution is per-token-request, not cached: services.json + module.json
19
+ * lookups cost a few ms at the launch scale (<10 services), and a stale cache
20
+ * is the kind of bug that takes days to surface. Re-read each call.
21
+ */
22
+ import { readFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { type ModuleManifest, validateModuleManifest } from "./module-manifest.ts";
26
+ import { FIRST_PARTY_SCOPES } from "./scope-explanations.ts";
27
+ import { readManifest as readServicesManifest } from "./services-manifest.ts";
28
+
29
+ /**
30
+ * RFC 6749 §3.3: scope strings are 1*( SP scope-token ). We accept any
31
+ * whitespace run (incl. tabs/newlines) per the looser parser rules in
32
+ * `oauth-scopes.md` so a CRLF-mangled form post still parses.
33
+ */
34
+ export function parseScopeString(scope: string): string[] {
35
+ return scope.split(/\s+/).filter((s) => s.length > 0);
36
+ }
37
+
38
+ /**
39
+ * Match a requested scope against the declared set, applying per-resource
40
+ * narrowing per `oauth-scopes.md`.
41
+ *
42
+ * - Exact match: `vault:read` against declared `vault:read` → true.
43
+ * - Narrowing: `vault:work:read` against declared `vault:read` → true
44
+ * (collapse middle segments to `<svc>:<verb>`). Phase 2 will treat
45
+ * middle segments as resource constraints, not synonyms; for now they
46
+ * parse but don't narrow enforcement.
47
+ *
48
+ * No inheritance here: `vault:admin` declared does NOT cover requested
49
+ * `vault:read`. The issuer signs exactly what the consent screen showed —
50
+ * inheritance is the resource server's call (vault enforces `admin ⊇ write
51
+ * ⊇ read` at request time, not at JWT mint).
52
+ */
53
+ export function isKnownScope(scope: string, declared: ReadonlySet<string>): boolean {
54
+ if (declared.has(scope)) return true;
55
+ const parts = scope.split(":");
56
+ if (parts.length < 3) return false;
57
+ const collapsed = `${parts[0]}:${parts[parts.length - 1]}`;
58
+ return declared.has(collapsed);
59
+ }
60
+
61
+ export function findUnknownScopes(
62
+ scopes: readonly string[],
63
+ declared: ReadonlySet<string>,
64
+ ): string[] {
65
+ return scopes.filter((s) => !isKnownScope(s, declared));
66
+ }
67
+
68
+ /**
69
+ * Read `<dir>/.parachute/module.json` and return its `scopes.defines`.
70
+ * Returns null when no manifest is found.
71
+ *
72
+ * Resolution order:
73
+ * 1. If `installDir` is provided (hub#84 stamps this on every services.json
74
+ * row at install time), read directly from there. This is the canonical
75
+ * path — services.json's `name` is the manifest's canonical short
76
+ * (e.g. "agent"), which doesn't match the npm package name on disk
77
+ * (e.g. "nanoagent" for forks). bun-globals lookup-by-name fails for
78
+ * that case; installDir is the source of truth.
79
+ * 2. Fall back to `<bun-globals>/<packageName>/.parachute/module.json`
80
+ * for entries without installDir (older installs, or services that
81
+ * registered themselves before hub#84 stamped the field).
82
+ *
83
+ * Tolerant of malformed JSON / validation errors — those are install-time
84
+ * problems, not token-issuance problems. A bad manifest blocking token
85
+ * issuance is the worst kind of cascade failure.
86
+ */
87
+ export function defaultReadModuleScopes(
88
+ packageName: string,
89
+ installDir?: string,
90
+ ): readonly string[] | null {
91
+ const candidates: string[] = [];
92
+ if (installDir) candidates.push(join(installDir, ".parachute", "module.json"));
93
+ for (const prefix of bunGlobalPrefixes()) {
94
+ candidates.push(join(prefix, ...packageName.split("/"), ".parachute", "module.json"));
95
+ }
96
+ for (const path of candidates) {
97
+ let raw: unknown;
98
+ try {
99
+ raw = JSON.parse(readFileSync(path, "utf8"));
100
+ } catch {
101
+ continue;
102
+ }
103
+ let m: ModuleManifest;
104
+ try {
105
+ m = validateModuleManifest(raw, path);
106
+ } catch {
107
+ // Malformed manifest — `parachute install` would have surfaced it; the
108
+ // token endpoint shouldn't refuse to mint over it.
109
+ return null;
110
+ }
111
+ return m.scopes?.defines ?? [];
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function bunGlobalPrefixes(): string[] {
117
+ const prefixes: string[] = [];
118
+ const fromEnv = process.env.BUN_INSTALL;
119
+ if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
120
+ prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
121
+ return prefixes;
122
+ }
123
+
124
+ export interface LoadDeclaredScopesOpts {
125
+ /** Path to services.json. Defaults to `~/.parachute/services.json`. */
126
+ manifestPath?: string;
127
+ /**
128
+ * Test seam: read a module's declared scopes by package name. Production
129
+ * walks bun's global prefixes for each registered service's
130
+ * `.parachute/module.json`.
131
+ */
132
+ readModuleScopes?: (packageName: string, installDir?: string) => readonly string[] | null;
133
+ }
134
+
135
+ /**
136
+ * Compute the union of scopes the issuer is willing to sign. Order:
137
+ * 1. `FIRST_PARTY_SCOPES` — always-on baseline.
138
+ * 2. Each registered service's `module.json` `scopes.defines`.
139
+ *
140
+ * Errors reading services.json fail open (return baseline only) — a missing
141
+ * services.json shouldn't break OAuth.
142
+ */
143
+ export function loadDeclaredScopes(opts: LoadDeclaredScopesOpts = {}): Set<string> {
144
+ const declared = new Set<string>(FIRST_PARTY_SCOPES);
145
+ const readModuleScopes = opts.readModuleScopes ?? defaultReadModuleScopes;
146
+ let services: { name: string; installDir?: string }[];
147
+ try {
148
+ services = readServicesManifest(opts.manifestPath).services;
149
+ } catch {
150
+ return declared;
151
+ }
152
+ for (const svc of services) {
153
+ const defined = readModuleScopes(svc.name, svc.installDir);
154
+ if (!defined) continue;
155
+ for (const scope of defined) declared.add(scope);
156
+ }
157
+ return declared;
158
+ }