@openparachute/hub 0.7.1 → 0.7.2
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.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-grants.json` — the persisted registry of approval-gated **agent
|
|
3
|
+
* connector grants** (Phase 4b-1, agent-connectors design 2026-06-17).
|
|
4
|
+
*
|
|
5
|
+
* NOT to be confused with `grants.ts` (the OAuth consent skip-list, one row per
|
|
6
|
+
* `(user_id, client_id)` in the SQLite `grants` table). THIS store is the
|
|
7
|
+
* agent-connector subsystem: an agent (a `#agent/definition` note in the agent
|
|
8
|
+
* module) declares connections it WANTS beyond its own def-vault; the agent
|
|
9
|
+
* module registers each as a PENDING grant here; the operator approves
|
|
10
|
+
* per-connection in hub admin; the hub mints/stores the secret; the agent
|
|
11
|
+
* module fetches the material at spawn and injects it.
|
|
12
|
+
*
|
|
13
|
+
* The one invariant (from the design): **a vault note can only REQUEST; it can
|
|
14
|
+
* never GRANT.** A grant created by the module sits `pending` and grants nothing
|
|
15
|
+
* until the operator approves in the hub. The minted/pasted secret (`material`)
|
|
16
|
+
* is the only sensitive field — it lives here on disk (0600), is NEVER logged,
|
|
17
|
+
* and is NEVER returned by the list endpoint (only by the approved-only
|
|
18
|
+
* `/material` endpoint).
|
|
19
|
+
*
|
|
20
|
+
* One file, a flat array. Cardinality is "a handful of grants per agent", not a
|
|
21
|
+
* hot path — small + synchronous Bun file I/O, mirroring `connections-store.ts`.
|
|
22
|
+
* Unlike `connections.json`, this file IS written 0600 because it holds the
|
|
23
|
+
* granted secrets.
|
|
24
|
+
*/
|
|
25
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { dirname } from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Verb for a vault grant. Service/mcp grants carry no access verb. */
|
|
29
|
+
export type GrantAccess = "read" | "write";
|
|
30
|
+
|
|
31
|
+
/** Where the agent side injects a resolved grant. */
|
|
32
|
+
export type GrantInject = "env" | "mcp";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A **connection spec** — what the agent WANTS. The wire shape the agent module
|
|
36
|
+
* sends to `PUT /admin/grants` and the shape echoed back by the list endpoint.
|
|
37
|
+
*
|
|
38
|
+
* - `vault` — a LOCAL vault other than the def-vault. `target` is the vault
|
|
39
|
+
* name; `access` the verb; optional `tags` narrow the grant to a tag set
|
|
40
|
+
* (rides the vault's `scoped_tags` tokens). Grant = a hub-minted
|
|
41
|
+
* `vault:<target>:<access>` token.
|
|
42
|
+
* - `service` — an external service credential (`github`, `cloudflare`).
|
|
43
|
+
* `target` is the service key; `inject` hints how the agent side wires it
|
|
44
|
+
* (`env` → an env var, `mcp` → the service's MCP server, or both). Grant =
|
|
45
|
+
* an operator-pasted API token.
|
|
46
|
+
* - `mcp` — a remote MCP / remote vault. `target` is the MCP URL. Grant =
|
|
47
|
+
* an OAuth token — NOT implemented in 4b-1 (slice 2). Modeled here; the
|
|
48
|
+
* grant stays `pending` with a clear reason.
|
|
49
|
+
*/
|
|
50
|
+
export interface ConnectionSpec {
|
|
51
|
+
readonly kind: "vault" | "service" | "mcp";
|
|
52
|
+
/** Vault name / service key / MCP URL, per `kind`. */
|
|
53
|
+
readonly target: string;
|
|
54
|
+
/** Vault grants only — `read` (default) or `write`. */
|
|
55
|
+
readonly access?: GrantAccess;
|
|
56
|
+
/** Vault grants only — tag-scope (`scoped_tags`); empty/absent = vault-wide. */
|
|
57
|
+
readonly tags?: readonly string[];
|
|
58
|
+
/** Service grants only — injection hints carried for the agent side. */
|
|
59
|
+
readonly inject?: readonly GrantInject[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Grant lifecycle. `needs_consent` (added 4b-2) is distinct from `revoked`
|
|
64
|
+
* (operator-intended teardown) — it means "an mcp grant was working, its refresh
|
|
65
|
+
* died, re-consent to revive." The status resolver treats it as not-approved
|
|
66
|
+
* (so the agent def shows it in `pending:[…]`).
|
|
67
|
+
*/
|
|
68
|
+
export type GrantStatus = "pending" | "approved" | "revoked" | "needs_consent";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The granted secret material, kept on disk ONLY. Discriminated by `kind` to
|
|
72
|
+
* mirror the spec. NEVER returned by the list endpoint; only the approved-only
|
|
73
|
+
* `/material` endpoint reads it.
|
|
74
|
+
*/
|
|
75
|
+
export type GrantMaterial =
|
|
76
|
+
| {
|
|
77
|
+
readonly kind: "vault";
|
|
78
|
+
/** The minted `vault:<target>:<access>` JWT. */
|
|
79
|
+
readonly token: string;
|
|
80
|
+
/** jti of the minted token — registered so revoke can drop it. */
|
|
81
|
+
readonly jti: string;
|
|
82
|
+
/** ISO expiry of the minted token. */
|
|
83
|
+
readonly expiresAt: string;
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
readonly kind: "service";
|
|
87
|
+
/** The operator-pasted API token. */
|
|
88
|
+
readonly token: string;
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
readonly kind: "mcp";
|
|
92
|
+
/**
|
|
93
|
+
* The remote MCP access token — OAuth-issued (auto-refreshed) OR an
|
|
94
|
+
* operator-pasted static bearer (no refresh). The `/material` WIRE shape
|
|
95
|
+
* projects this as `token` (matching the vault/service material the agent
|
|
96
|
+
* already consumes); the internal store field is `access_token`.
|
|
97
|
+
*/
|
|
98
|
+
readonly access_token: string;
|
|
99
|
+
/** OAuth refresh token. Absent for a static-bearer grant. */
|
|
100
|
+
readonly refresh_token?: string;
|
|
101
|
+
/** ISO expiry of the access token. Absent for a static bearer (never refreshed). */
|
|
102
|
+
readonly expiresAt?: string;
|
|
103
|
+
/** Issuer (for refresh + revoke). Absent for a static bearer. */
|
|
104
|
+
readonly issuer?: string;
|
|
105
|
+
/** DCR client_id (for refresh). Absent for a static bearer. */
|
|
106
|
+
readonly clientId?: string;
|
|
107
|
+
/** Cached token endpoint (for refresh). Absent for a static bearer. */
|
|
108
|
+
readonly tokenEndpoint?: string;
|
|
109
|
+
/** Cached revocation endpoint (for revoke). Absent for a static bearer / non-advertising issuer. */
|
|
110
|
+
readonly revocationEndpoint?: string;
|
|
111
|
+
/** The remote MCP URL the agent connects to. */
|
|
112
|
+
readonly mcpUrl: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export interface GrantRecord {
|
|
116
|
+
readonly id: string;
|
|
117
|
+
/** Agent name (the `#agent/definition` note's agent identity). */
|
|
118
|
+
readonly agent: string;
|
|
119
|
+
/** The declared connection spec. */
|
|
120
|
+
readonly connection: ConnectionSpec;
|
|
121
|
+
readonly status: GrantStatus;
|
|
122
|
+
/** Why a grant is pending/blocked (e.g. mcp "oauth not yet supported"). */
|
|
123
|
+
readonly reason?: string;
|
|
124
|
+
/** The secret — present only when `status === "approved"`. Never logged/listed. */
|
|
125
|
+
readonly material?: GrantMaterial;
|
|
126
|
+
readonly createdAt: string;
|
|
127
|
+
readonly approvedAt?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface GrantsFile {
|
|
131
|
+
grants: GrantRecord[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stable connection key — the idempotency key for `(agent, connection)` upsert.
|
|
136
|
+
* A spec's identity is its `kind` + `target` + (for vault) the access verb +
|
|
137
|
+
* the sorted tag set. `inject` is NOT part of the key — it's an agent-side
|
|
138
|
+
* hint, so re-declaring the same service with different inject modes updates
|
|
139
|
+
* the existing grant rather than forking a second one. The grant `id` derives
|
|
140
|
+
* from `agent` + this key.
|
|
141
|
+
*/
|
|
142
|
+
export function connectionKey(spec: ConnectionSpec): string {
|
|
143
|
+
const target = spec.target.trim().toLowerCase();
|
|
144
|
+
if (spec.kind === "vault") {
|
|
145
|
+
const access = spec.access ?? "read";
|
|
146
|
+
const tags = [...(spec.tags ?? [])].map((t) => t.trim()).sort();
|
|
147
|
+
return tags.length > 0
|
|
148
|
+
? `vault:${target}:${access}#${tags.join(",")}`
|
|
149
|
+
: `vault:${target}:${access}`;
|
|
150
|
+
}
|
|
151
|
+
if (spec.kind === "service") {
|
|
152
|
+
return `service:${target}`;
|
|
153
|
+
}
|
|
154
|
+
// mcp — keyed on the URL only (its target).
|
|
155
|
+
return `mcp:${target}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const GRANT_ID_CHARSET = /[^a-z0-9]+/g;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Derive the grant id from `(agent, connectionKey)`. A conservative slug — it
|
|
162
|
+
* lands in a URL path segment (`/admin/grants/<id>/...`). Deterministic so the
|
|
163
|
+
* same `(agent, connection)` always upserts the same row.
|
|
164
|
+
*
|
|
165
|
+
* The slug collapses non-`[a-z0-9]` runs to a single `-`. A collision (two distinct
|
|
166
|
+
* `(agent, connection)` pairs slugging identically) is infeasible given the upstream
|
|
167
|
+
* validators — `kind` is an enum, vault `target` passes `validateVaultName`, service
|
|
168
|
+
* `target` matches `SERVICE_KEY_RE`, `access` is `read|write`, tags are charset-bounded
|
|
169
|
+
* — so the separators that survive the slug are non-ambiguous in practice.
|
|
170
|
+
*/
|
|
171
|
+
export function grantId(agent: string, spec: ConnectionSpec): string {
|
|
172
|
+
const raw = `${agent.trim().toLowerCase()}--${connectionKey(spec)}`;
|
|
173
|
+
return raw.replace(GRANT_ID_CHARSET, "-").replace(/^-+|-+$/g, "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function emptyFile(): GrantsFile {
|
|
177
|
+
return { grants: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isConnectionSpec(v: unknown): v is ConnectionSpec {
|
|
181
|
+
if (!v || typeof v !== "object") return false;
|
|
182
|
+
const s = v as Record<string, unknown>;
|
|
183
|
+
return (
|
|
184
|
+
(s.kind === "vault" || s.kind === "service" || s.kind === "mcp") && typeof s.target === "string"
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Read the store. A missing/garbage file reads as empty (fresh hub). */
|
|
189
|
+
export function readGrants(storePath: string): GrantRecord[] {
|
|
190
|
+
let buf: string;
|
|
191
|
+
try {
|
|
192
|
+
buf = readFileSync(storePath, "utf8");
|
|
193
|
+
} catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
let parsed: unknown;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(buf);
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
203
|
+
const arr = (parsed as { grants?: unknown }).grants;
|
|
204
|
+
if (!Array.isArray(arr)) return [];
|
|
205
|
+
// Lenient: drop a malformed row rather than failing the whole read (mirrors
|
|
206
|
+
// the connections-store / services.json lenient-read posture).
|
|
207
|
+
return arr.filter((r): r is GrantRecord => {
|
|
208
|
+
if (!r || typeof r !== "object") return false;
|
|
209
|
+
const rec = r as Record<string, unknown>;
|
|
210
|
+
return (
|
|
211
|
+
typeof rec.id === "string" &&
|
|
212
|
+
typeof rec.agent === "string" &&
|
|
213
|
+
isConnectionSpec(rec.connection) &&
|
|
214
|
+
// NOTE (4b-2): `needs_consent` MUST be accepted here — else a row that
|
|
215
|
+
// flipped to needs_consent on a failed mcp refresh is silently dropped on
|
|
216
|
+
// re-read and `/material` 404s instead of 409ing. Regression-tested.
|
|
217
|
+
(rec.status === "pending" ||
|
|
218
|
+
rec.status === "approved" ||
|
|
219
|
+
rec.status === "revoked" ||
|
|
220
|
+
rec.status === "needs_consent")
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function writeAll(storePath: string, records: GrantRecord[]): void {
|
|
226
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
227
|
+
const file: GrantsFile = { grants: records };
|
|
228
|
+
// 0600 — UNLIKE connections.json, this file holds the granted secrets
|
|
229
|
+
// (minted vault tokens + pasted service creds in `material`). `writeFileSync`'s
|
|
230
|
+
// `mode` applies at CREATE time (passed to open(O_CREAT)), so a fresh file is
|
|
231
|
+
// never world-readable even for an instant; the chmodSync below is belt-and-
|
|
232
|
+
// suspenders for the re-write case (an existing file left at looser perms).
|
|
233
|
+
writeFileSync(storePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
234
|
+
try {
|
|
235
|
+
chmodSync(storePath, 0o600);
|
|
236
|
+
} catch {
|
|
237
|
+
// Best-effort on platforms without POSIX perms (the secret is still only
|
|
238
|
+
// reachable through the approved-only, auth-gated /material endpoint).
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Upsert by id (replace an existing record with the same id, else append). */
|
|
243
|
+
export function putGrant(storePath: string, record: GrantRecord): void {
|
|
244
|
+
const records = readGrants(storePath);
|
|
245
|
+
const idx = records.findIndex((r) => r.id === record.id);
|
|
246
|
+
if (idx >= 0) records[idx] = record;
|
|
247
|
+
else records.push(record);
|
|
248
|
+
writeAll(storePath, records);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function getGrant(storePath: string, id: string): GrantRecord | null {
|
|
252
|
+
return readGrants(storePath).find((r) => r.id === id) ?? null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** All grants for an agent, in stored order. */
|
|
256
|
+
export function listGrantsForAgent(storePath: string, agent: string): GrantRecord[] {
|
|
257
|
+
const want = agent.trim().toLowerCase();
|
|
258
|
+
return readGrants(storePath).filter((r) => r.agent.trim().toLowerCase() === want);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Remove a grant by id. Returns the removed record, or null if absent. */
|
|
262
|
+
export function removeGrant(storePath: string, id: string): GrantRecord | null {
|
|
263
|
+
const records = readGrants(storePath);
|
|
264
|
+
const idx = records.findIndex((r) => r.id === id);
|
|
265
|
+
if (idx < 0) return null;
|
|
266
|
+
const [removed] = records.splice(idx, 1);
|
|
267
|
+
writeAll(storePath, records);
|
|
268
|
+
return removed ?? null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Re-export for the unused-import linter when only the type is consumed. */
|
|
272
|
+
export const _emptyGrantsFile = emptyFile;
|
package/src/help.ts
CHANGED
|
@@ -31,6 +31,9 @@ Usage:
|
|
|
31
31
|
parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
|
|
32
32
|
etc.) — forwards to parachute-vault.
|
|
33
33
|
For lifecycle, use \`parachute start|stop|restart|logs vault\`.
|
|
34
|
+
\`vault remove <name>\` is routed through the hub's
|
|
35
|
+
identity cascade (revokes the vault's tokens/grants),
|
|
36
|
+
not the raw mechanics-only delete.
|
|
34
37
|
|
|
35
38
|
Flags:
|
|
36
39
|
--help, -h show this help (also per-subcommand: \`parachute <cmd> --help\`)
|
|
@@ -469,7 +472,7 @@ Module start commands (run by the supervisor under \`serve\`):
|
|
|
469
472
|
vault parachute-vault serve
|
|
470
473
|
scribe parachute-scribe serve
|
|
471
474
|
app parachute-app serve
|
|
472
|
-
|
|
475
|
+
agent parachute-agent
|
|
473
476
|
notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> # back-compat: legacy notes-daemon
|
|
474
477
|
`;
|
|
475
478
|
}
|