@openparachute/vault 0.4.8-rc.6 → 0.4.8-rc.9
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/.parachute/module.json +1 -0
- package/README.md +34 -31
- package/core/src/schema.ts +8 -3
- package/package.json +7 -3
- package/src/auth.test.ts +5 -112
- package/src/backup.ts +17 -3
- package/src/cli.ts +38 -18
- package/src/export-watch.test.ts +21 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routing.test.ts +98 -97
- package/src/routing.ts +29 -43
- package/src/server.ts +1 -12
- package/src/vault-name.ts +3 -2
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
package/.parachute/module.json
CHANGED
package/README.md
CHANGED
|
@@ -66,7 +66,9 @@ A mental model for "where is my data?" and "what can I poke at?" after the one-c
|
|
|
66
66
|
vaults/ # one subdirectory per vault
|
|
67
67
|
default/
|
|
68
68
|
vault.db # the SQLite database — notes, tags, links, attachments,
|
|
69
|
-
# per-vault tokens,
|
|
69
|
+
# per-vault tokens, tag schemas (oauth_clients +
|
|
70
|
+
# oauth_codes tables persist post-workstream-E but
|
|
71
|
+
# are vestigial — no issuer writes to them anymore)
|
|
70
72
|
vault.db-wal # WAL journal (write-ahead log) — transient, recreated
|
|
71
73
|
# on demand. Carries pending writes between checkpoints.
|
|
72
74
|
vault.db-shm # WAL shared-memory index — transient, recreated on demand.
|
|
@@ -77,7 +79,7 @@ A mental model for "where is my data?" and "what can I poke at?" after the one-c
|
|
|
77
79
|
|
|
78
80
|
`~/.parachute/` itself is the ecosystem root shared across sibling services — `services.json` and `well-known/` live at the root and are managed by the top-level CLI. Everything vault owns is scoped under `~/.parachute/vault/`. Pre-0.3 installs kept vault state directly at the root; any legacy paths still there are auto-migrated into `vault/` on first post-upgrade run (see CHANGELOG).
|
|
79
81
|
|
|
80
|
-
`config.yaml` is the one file written at 0600 because it holds the bcrypt owner-password hash and the plaintext TOTP secret. `.env` is written with your umask default (typically 0644); if you add webhook API keys there, tighten the mode yourself. SQLite DBs follow your umask.
|
|
82
|
+
`config.yaml` is the one file written at 0600 because it holds the bcrypt owner-password hash and the plaintext TOTP secret (legacy fields kept for hub's expose-posture-check; the standalone consent flow they used to gate was retired in 0.4.x — workstream E). `.env` is written with your umask default (typically 0644); if you add webhook API keys there, tighten the mode yourself. SQLite DBs follow your umask.
|
|
81
83
|
|
|
82
84
|
The vault SQLite database runs in **WAL** (write-ahead logging) journal mode for multi-process concurrent access — the daemon, CLI tools, and out-of-process consumers (e.g. `parachute-runner` polling `tag:job`) can read concurrently while the daemon writes, without lock contention. WAL adds two sidecar files alongside `vault.db`: `vault.db-wal` (the journal) and `vault.db-shm` (shared-memory index). Both are recreated on demand; **don't back them up separately** — `parachute-vault backup` snapshots only `vault.db` via `VACUUM INTO`, which produces a consistent full-DB copy without needing the sidecars. If you copy a vault by hand, `vault.db` alone is sufficient on a checkpointed database; if you must capture an in-flight write, copy all three files. If WAL can't be enabled (NFS, some FUSE / Docker volume drivers don't support the `-shm` region), vault logs `[vault] WAL mode could not be enabled` on startup and falls back to the legacy single-writer mode.
|
|
83
85
|
|
|
@@ -87,7 +89,7 @@ The vault SQLite database runs in **WAL** (write-ahead logging) journal mode for
|
|
|
87
89
|
- **Linux + systemd**: a user service named `parachute-vault.service` (managed via `systemctl --user`).
|
|
88
90
|
- **Neither of the above**: `vault init` prints a reminder to start the server yourself (`bun src/server.ts` or Docker). No service registration.
|
|
89
91
|
|
|
90
|
-
The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST, MCP, and OAuth routes. `parachute-vault status` is the fast check; `parachute-vault url` prints just the URL for use in scripts.
|
|
92
|
+
The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST, MCP, and OAuth-discovery routes (the issuer itself lives on the hub — see "OAuth lives on the hub" below). `parachute-vault status` is the fast check; `parachute-vault url` prints just the URL for use in scripts.
|
|
91
93
|
|
|
92
94
|
### `~/.claude.json`
|
|
93
95
|
|
|
@@ -99,9 +101,17 @@ The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST,
|
|
|
99
101
|
|
|
100
102
|
If you said yes to (2), the `pvt_...` token is printed prominently at the end — it's the same token baked into `~/.claude.json` (if you also said yes to (1)). It's not stored anywhere retrievable — save it if you need it for `curl`, cron, or any other script. Lost it? Just mint a new one: `parachute-vault tokens create`. Tokens are SHA-256 hashed at rest in each vault's `vault.db`.
|
|
101
103
|
|
|
102
|
-
###
|
|
104
|
+
### OAuth lives on the hub
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
Vault is OAuth resource-server-only. The authorization flow — DCR, consent page, code-for-token exchange — lives on the [hub](https://github.com/ParachuteComputer/parachute-hub). Install it once you want browser-based clients (Claude Desktop, Parachute Daily, etc.) to connect:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
parachute install hub
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The hub fronts every vault on the host with a single consent surface, signs JWTs that vault validates against the hub's JWKS, and renders the operator UI. Vault still serves OAuth discovery (`/.well-known/oauth-*`) but the metadata documents forward clients to the hub. See [`docs/auth-model.md`](docs/auth-model.md) for the validation contract.
|
|
113
|
+
|
|
114
|
+
> Vault shipped its own standalone OAuth issuer through 0.4.7. It was retired in 0.4.x — workstream E. If you're upgrading from a standalone-vault posture, see [`UPGRADING.md`](UPGRADING.md#workstream-e--standalone-oauth-retired).
|
|
105
115
|
|
|
106
116
|
## Connecting a client
|
|
107
117
|
|
|
@@ -109,22 +119,10 @@ Two ways to authenticate — pick based on the client, not the deployment:
|
|
|
109
119
|
|
|
110
120
|
| Path | When to use | User action |
|
|
111
121
|
|---|---|---|
|
|
112
|
-
| **OAuth 2.1 + PKCE (browser flow)** | Claude Desktop, Parachute Daily, any third-party MCP client set up interactively | Click "Add integration", enter
|
|
122
|
+
| **OAuth 2.1 + PKCE (browser flow, via hub)** | Claude Desktop, Parachute Daily, any third-party MCP client set up interactively | Click "Add integration", enter the vault MCP URL, a browser opens to the **hub's** consent page, sign in with hub credentials, done — no token ever touches your clipboard |
|
|
113
123
|
| **Bearer token** | Claude Code (auto-wired by `vault init`), CLI scripts, cron jobs, any non-interactive caller | `curl -H "Authorization: Bearer pvt_..."` — the token is printed once at `vault init` (save it) or minted on demand with `parachute-vault tokens create` |
|
|
114
124
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
### Owner password (needed for OAuth)
|
|
118
|
-
|
|
119
|
-
`vault init` prompts you to set an owner password (minimum 12 characters). This is what the OAuth consent page asks for when a client requests access. If you skip the prompt, OAuth still works but the consent page falls back to asking for a vault token instead — functional but clunky. Set it later with:
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
parachute-vault set-password # set / change
|
|
123
|
-
parachute-vault set-password --clear # remove (reverts to token fallback)
|
|
124
|
-
parachute-vault 2fa enroll # optional: add TOTP 2FA on top
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
Password and 2FA secrets live in `~/.parachute/vault/config.yaml` at mode 0600 (bcrypt hash + base32 TOTP secret).
|
|
125
|
+
The OAuth path mints a hub-signed JWT that vault validates against the hub's JWKS. The bearer-token path mints a `pvt_` opaque token straight from vault's local DB. Either works for any client; pick based on whether you want a human-driven consent step.
|
|
128
126
|
|
|
129
127
|
### Claude Code
|
|
130
128
|
|
|
@@ -148,19 +146,19 @@ To re-point Claude Code at a different vault, change `default_vault` in `~/.para
|
|
|
148
146
|
|
|
149
147
|
### Claude Desktop (OAuth)
|
|
150
148
|
|
|
151
|
-
For Claude Desktop — or any install where the server is on a different machine from the client — use the browser-based OAuth flow:
|
|
149
|
+
For Claude Desktop — or any install where the server is on a different machine from the client — use the browser-based OAuth flow. **The flow runs against the hub**, not vault, so make sure you've run `parachute install hub` first:
|
|
152
150
|
|
|
153
151
|
1. Claude Desktop → Settings → Integrations → Add MCP server.
|
|
154
152
|
2. Enter the URL: `https://vault.yourdomain.com/vault/{name}/mcp` (replace `{name}` with your vault name — e.g. `default`). **Do not paste a bearer token** — leave the auth field empty.
|
|
155
|
-
3.
|
|
156
|
-
4.
|
|
157
|
-
5. Browser redirects back. The connection is live. The client now holds a
|
|
153
|
+
3. The MCP client discovers vault's protected-resource metadata at `/vault/{name}/.well-known/oauth-protected-resource`, which names the **hub** as the authorization server. It then drives the OAuth flow against the hub: DCR, browser-opened consent page, code exchange.
|
|
154
|
+
4. Sign in to the hub with your hub credentials, pick a scope (`full` or `read`), click Authorize.
|
|
155
|
+
5. Browser redirects back. The connection is live. The client now holds a hub-signed JWT scoped to this vault.
|
|
158
156
|
|
|
159
157
|
If you'd rather skip OAuth — e.g. you're scripting the setup — Claude Desktop also accepts a bearer token via the integration's auth header field. Use a token from `parachute-vault tokens create` (or the one from `vault init` if you still have it). This is the "manual bearer" fallback; OAuth is the recommended path.
|
|
160
158
|
|
|
161
159
|
### Parachute Daily (mobile)
|
|
162
160
|
|
|
163
|
-
Daily uses the same OAuth flow. On first launch: enter the server URL, pick the vault from the drop-down (populated from the public `GET /vaults/list` endpoint), tap **Connect to Vault**. The
|
|
161
|
+
Daily uses the same OAuth flow (hub-fronted). On first launch: enter the server URL, pick the vault from the drop-down (populated from the public `GET /vaults/list` endpoint), tap **Connect to Vault**. The consent handoff runs in your phone's browser against the hub, then redirects back to the app via the `parachute://oauth/callback` deep link. The app stores the hub-signed JWT in platform secure storage.
|
|
164
162
|
|
|
165
163
|
### Multi-vault
|
|
166
164
|
|
|
@@ -174,7 +172,7 @@ parachute-vault remove work --yes
|
|
|
174
172
|
|
|
175
173
|
**The default vault is managed for you.** `vault init` creates `default` on first install and records it as `default_vault` in `~/.parachute/config.yaml`. `vault create <name>` promotes the newly-created vault to default when no default exists or when the configured default points at a missing vault. `vault remove <name>` promotes the sole survivor when you delete the default and one vault remains; if multiple remain after removing the default, it clears the setting and tells you to edit `config.yaml` yourself. There is no `vault set-default` subcommand — to point the server at a different existing vault, edit the `default_vault:` line in `~/.parachute/config.yaml` and `parachute-vault restart`.
|
|
176
174
|
|
|
177
|
-
**URL shape.** Every vault-touching route lives under `/vault/{name}/...`: `/vault/{name}/mcp`, `/vault/{name}/
|
|
175
|
+
**URL shape.** Every vault-touching route lives under `/vault/{name}/...`: `/vault/{name}/mcp`, `/vault/{name}/api/notes`, `/vault/{name}/view/{id}`, `/vault/{name}/.well-known/oauth-*` (discovery forwarders). There is no unscoped fallback — pick the vault in the URL even if you only have one. OAuth tokens are scoped to the vault named in the audience claim (`aud=vault.<name>`); cross-vault substitution is rejected at the auth layer.
|
|
178
176
|
|
|
179
177
|
**Listing vaults from a client.** The authenticated `GET /vaults` endpoint returns full vault metadata. The public `GET /vaults/list` endpoint returns names only, no metadata, no auth required — this is what Parachute Daily's vault picker calls before the user authenticates. Operators who want to hide the vault list from unauthenticated callers can set `discovery: disabled` in `~/.parachute/vault/config.yaml` to make `/vaults/list` return 404.
|
|
180
178
|
|
|
@@ -206,13 +204,18 @@ parachute-vault mcp-install --dry-run # describe the write without touching
|
|
|
206
204
|
parachute-vault mcp-config gitcoin # emit JSON for `claude -p --mcp-config "$(...)"`
|
|
207
205
|
parachute-vault mcp-config gitcoin --env-vars # template form with ${PARACHUTE_HUB_URL}/${PARACHUTE_VAULT_TOKEN}
|
|
208
206
|
|
|
209
|
-
# OAuth — owner password + 2FA
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
# OAuth — owner password + 2FA (legacy, no longer wired up)
|
|
208
|
+
# vault's standalone OAuth consent page was retired in 0.4.x (workstream E);
|
|
209
|
+
# OAuth now runs on the hub. These commands still write to config.yaml for
|
|
210
|
+
# hub's `expose public` posture-check (which reads the same fields), but
|
|
211
|
+
# they don't gate any consent flow inside vault. Set hub credentials with
|
|
212
|
+
# `parachute auth set-password` instead.
|
|
213
|
+
parachute-vault set-password # set/change owner password (legacy YAML field)
|
|
214
|
+
parachute-vault set-password --clear # remove the owner password
|
|
212
215
|
parachute-vault 2fa status # show 2FA state + remaining backup codes
|
|
213
216
|
parachute-vault 2fa enroll # enroll TOTP (shows QR + prints one-time backup codes)
|
|
214
|
-
parachute-vault 2fa disable # disable 2FA
|
|
215
|
-
parachute-vault 2fa backup-codes # regenerate backup codes
|
|
217
|
+
parachute-vault 2fa disable # disable 2FA
|
|
218
|
+
parachute-vault 2fa backup-codes # regenerate backup codes
|
|
216
219
|
|
|
217
220
|
# Tokens
|
|
218
221
|
parachute-vault tokens # list all tokens across all vaults
|
|
@@ -793,7 +796,7 @@ The checks, in the order they're emitted:
|
|
|
793
796
|
- **Daemon won't start after a port change.** `~/.parachute/vault/.env` has the new `PORT=...` but the daemon is still trying to bind the old one, or something else already holds the new port. `parachute-vault doctor` surfaces both conditions. Fix the holder (or pick a different port) and `parachute-vault restart`.
|
|
794
797
|
- **MCP entry is stale after moving the repo.** launchd/systemd keeps pointing at the old path. `doctor` flags this as a failed `server.ts at pointer target` check; `parachute-vault init` from the new location rewrites the pointer, wrapper, and daemon registration.
|
|
795
798
|
- **Claude Code shows no vault tools.** Check in order: (1) is the daemon up (`parachute-vault status`)? (2) does `~/.claude.json` have a `parachute-vault` entry with both `url` and a valid `Authorization` header? (3) does the URL's vault name match an existing vault? `parachute-vault doctor` catches the first two. A missing or stale `Authorization` header after a bare `vault mcp-install` is the usual culprit for #2 — see the Claude Code section of [Connecting a client](#connecting-a-client) for how to rewrite it.
|
|
796
|
-
- **Claude Desktop / Daily won't connect via OAuth.**
|
|
799
|
+
- **Claude Desktop / Daily won't connect via OAuth.** Check the hub is installed and running (`parachute status hub`) — OAuth runs there, not on vault. If you're upgrading from a standalone-vault install where vault used to render its own consent page, run `parachute install hub` to bring up the issuer. See [`UPGRADING.md`](UPGRADING.md#workstream-e--standalone-oauth-retired).
|
|
797
800
|
- **Scheduled backups aren't running.** On macOS: `doctor` flags `backup agent: not loaded` when `schedule` isn't `manual` but the launchd agent is missing — rerun `parachute-vault backup --schedule <freq>` to reinstall it. On Linux: systemd-timer support for backup isn't shipped yet, so `--schedule daily` silently skips the scheduler. Run `parachute-vault backup` from cron (or similar) until that lands.
|
|
798
801
|
- **Manual `curl` against `/vault/<name>/mcp` returns `406 Not Acceptable`.** The MCP HTTP transport requires both `application/json` and `text/event-stream` in the `Accept` header (it negotiates between the JSON response and the SSE streaming variant). Claude Code's `--mcp-config` http transport sets this automatically — the symptom only shows up when you probe the endpoint by hand. The fix:
|
|
799
802
|
|
package/core/src/schema.ts
CHANGED
|
@@ -133,6 +133,11 @@ CREATE TABLE IF NOT EXISTS tokens (
|
|
|
133
133
|
);
|
|
134
134
|
|
|
135
135
|
-- OAuth: registered clients (Dynamic Client Registration)
|
|
136
|
+
-- VESTIGIAL after vault 0.4.x workstream E (2026-05-25). The standalone
|
|
137
|
+
-- OAuth issuer that wrote these rows was retired (hub is the issuer now;
|
|
138
|
+
-- vault is resource-server-only). The tables are left in place so an
|
|
139
|
+
-- upgrade doesn't trip on a missing column for any operator who still
|
|
140
|
+
-- has rows mid-upgrade. A future migration will drop them.
|
|
136
141
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
137
142
|
client_id TEXT PRIMARY KEY,
|
|
138
143
|
client_name TEXT,
|
|
@@ -141,9 +146,9 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
|
141
146
|
);
|
|
142
147
|
|
|
143
148
|
-- OAuth: authorization codes (single-use, short-lived)
|
|
144
|
-
--
|
|
145
|
-
--
|
|
146
|
-
--
|
|
149
|
+
-- VESTIGIAL — see oauth_clients above. The vault_name column survives
|
|
150
|
+
-- as a sentinel of the per-vault-pinning invariant that used to apply
|
|
151
|
+
-- when vault was the issuer.
|
|
147
152
|
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
148
153
|
code TEXT PRIMARY KEY,
|
|
149
154
|
client_id TEXT NOT NULL,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.4.8-rc.
|
|
3
|
+
"version": "0.4.8-rc.9",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -12,14 +12,18 @@
|
|
|
12
12
|
"core/src",
|
|
13
13
|
"core/package.json",
|
|
14
14
|
".parachute",
|
|
15
|
-
"tsconfig.json"
|
|
15
|
+
"tsconfig.json",
|
|
16
|
+
"web/ui/dist"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
18
19
|
"start": "bun src/server.ts",
|
|
19
20
|
"cli": "bun src/cli.ts",
|
|
20
21
|
"test": "bun test ./src/",
|
|
21
22
|
"test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run",
|
|
22
|
-
"typecheck": "tsc --noEmit"
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
25
|
+
"postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
|
|
26
|
+
"prepack": "bun run build:spa"
|
|
23
27
|
},
|
|
24
28
|
"dependencies": {
|
|
25
29
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
package/src/auth.test.ts
CHANGED
|
@@ -25,8 +25,6 @@ import {
|
|
|
25
25
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
26
26
|
import { generateToken, createToken } from "./token-store.ts";
|
|
27
27
|
import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
|
|
28
|
-
import { handleRegister, handleAuthorizePost, handleToken } from "./oauth.ts";
|
|
29
|
-
import crypto from "node:crypto";
|
|
30
28
|
|
|
31
29
|
let tmpHome: string;
|
|
32
30
|
let prevHome: string | undefined;
|
|
@@ -235,116 +233,11 @@ describe("auth — cross-vault isolation", () => {
|
|
|
235
233
|
});
|
|
236
234
|
});
|
|
237
235
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// These tests drive the OAuth handlers directly (no HTTP), then take the
|
|
244
|
-
// resulting access_token and verify it resolves at endpoints addressing
|
|
245
|
-
// its issuing vault — and only its issuing vault.
|
|
246
|
-
|
|
247
|
-
async function runOAuthFlow(vaultName: string): Promise<string> {
|
|
248
|
-
const store = getVaultStore(vaultName);
|
|
249
|
-
const db = store.db;
|
|
250
|
-
|
|
251
|
-
// Seed an owner token so consent passes in legacy-token mode.
|
|
252
|
-
const { fullToken: ownerToken } = generateToken();
|
|
253
|
-
createToken(db, ownerToken, { label: "owner", permission: "full" });
|
|
254
|
-
|
|
255
|
-
// 1. Register client
|
|
256
|
-
const regRes = await handleRegister(
|
|
257
|
-
new Request(`https://vault.test/vault/${vaultName}/oauth/register`, {
|
|
258
|
-
method: "POST",
|
|
259
|
-
headers: { "Content-Type": "application/json" },
|
|
260
|
-
body: JSON.stringify({
|
|
261
|
-
client_name: "Daily",
|
|
262
|
-
redirect_uris: ["parachute://oauth/callback"],
|
|
263
|
-
}),
|
|
264
|
-
}),
|
|
265
|
-
db,
|
|
266
|
-
);
|
|
267
|
-
const { client_id } = (await regRes.json()) as { client_id: string };
|
|
268
|
-
|
|
269
|
-
// 2. PKCE + authorize
|
|
270
|
-
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
271
|
-
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
|
|
272
|
-
const authRes = await handleAuthorizePost(
|
|
273
|
-
new Request(`https://vault.test/vault/${vaultName}/oauth/authorize`, {
|
|
274
|
-
method: "POST",
|
|
275
|
-
body: new URLSearchParams({
|
|
276
|
-
action: "authorize",
|
|
277
|
-
client_id,
|
|
278
|
-
redirect_uri: "parachute://oauth/callback",
|
|
279
|
-
code_challenge: codeChallenge,
|
|
280
|
-
code_challenge_method: "S256",
|
|
281
|
-
scope: "full",
|
|
282
|
-
owner_token: ownerToken,
|
|
283
|
-
}),
|
|
284
|
-
}),
|
|
285
|
-
db,
|
|
286
|
-
{ vaultName },
|
|
287
|
-
);
|
|
288
|
-
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
289
|
-
|
|
290
|
-
// 3. Token exchange
|
|
291
|
-
const tokRes = await handleToken(
|
|
292
|
-
new Request(`https://vault.test/vault/${vaultName}/oauth/token`, {
|
|
293
|
-
method: "POST",
|
|
294
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
295
|
-
body: new URLSearchParams({
|
|
296
|
-
grant_type: "authorization_code",
|
|
297
|
-
code,
|
|
298
|
-
code_verifier: codeVerifier,
|
|
299
|
-
client_id,
|
|
300
|
-
redirect_uri: "parachute://oauth/callback",
|
|
301
|
-
}).toString(),
|
|
302
|
-
}),
|
|
303
|
-
db,
|
|
304
|
-
vaultName,
|
|
305
|
-
);
|
|
306
|
-
const tokBody = (await tokRes.json()) as { access_token: string; vault: string };
|
|
307
|
-
expect(tokBody.vault).toBe(vaultName);
|
|
308
|
-
return tokBody.access_token;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
test("OAuth-minted token works at /vault/<name>/api/* and /vault/<name>/mcp", async () => {
|
|
312
|
-
seedVault("journal", { isDefault: true });
|
|
313
|
-
const token = await runOAuthFlow("journal");
|
|
314
|
-
const cfg = readVaultConfig("journal")!;
|
|
315
|
-
const store = getVaultStore("journal");
|
|
316
|
-
|
|
317
|
-
// /vault/journal/api/* and /vault/journal/mcp both reach this auth call.
|
|
318
|
-
const vaultAuth = await authenticateVaultRequest(bearer(token), cfg, store.db);
|
|
319
|
-
expect("error" in vaultAuth).toBe(false);
|
|
320
|
-
|
|
321
|
-
// /vaults (authenticated listing) uses authenticateGlobalRequest.
|
|
322
|
-
const global = await authenticateGlobalRequest(bearer(token));
|
|
323
|
-
expect("error" in global).toBe(false);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
test("named-vault OAuth: token works for its vault, rejected by others", async () => {
|
|
327
|
-
seedVault("journal", { isDefault: true });
|
|
328
|
-
seedVault("work");
|
|
329
|
-
const token = await runOAuthFlow("work");
|
|
330
|
-
const workCfg = readVaultConfig("work")!;
|
|
331
|
-
const workStore = getVaultStore("work");
|
|
332
|
-
|
|
333
|
-
// Valid at work's own endpoints.
|
|
334
|
-
const scoped = await authenticateVaultRequest(bearer(token), workCfg, workStore.db);
|
|
335
|
-
expect("error" in scoped).toBe(false);
|
|
336
|
-
|
|
337
|
-
// Global auth finds the token in work's DB.
|
|
338
|
-
const global = await authenticateGlobalRequest(bearer(token));
|
|
339
|
-
expect("error" in global).toBe(false);
|
|
340
|
-
|
|
341
|
-
// Isolation: the token is NOT usable against the journal vault.
|
|
342
|
-
const journalCfg = readVaultConfig("journal")!;
|
|
343
|
-
const journalStore = getVaultStore("journal");
|
|
344
|
-
const crossCheck = await authenticateVaultRequest(bearer(token), journalCfg, journalStore.db);
|
|
345
|
-
expect("error" in crossCheck).toBe(true);
|
|
346
|
-
});
|
|
347
|
-
});
|
|
236
|
+
// The "End-to-end OAuth flow" suite was retired alongside the standalone
|
|
237
|
+
// OAuth issuer in workstream E (vault#366). Per-vault token coherence is
|
|
238
|
+
// still pinned by the v16 binding tests above and by `tokens-routes.test.ts`
|
|
239
|
+
// (mint-via-CLI → present at /vault/<name>/* surfaces); the OAuth handshake
|
|
240
|
+
// itself has moved entirely to the hub.
|
|
348
241
|
|
|
349
242
|
// ---------------------------------------------------------------------------
|
|
350
243
|
// Legacy YAML global keys — scope must round-trip through the parser
|
package/src/backup.ts
CHANGED
|
@@ -572,9 +572,21 @@ export async function runBackup(opts?: {
|
|
|
572
572
|
vaultsDir: opts?.vaultsDir,
|
|
573
573
|
});
|
|
574
574
|
|
|
575
|
+
// Write the tarball to a SIBLING tempdir, not inside stagingDir.
|
|
576
|
+
//
|
|
577
|
+
// Why: `assembleTarball` runs `tar -czf <out> -C <stagingDir> <entries>`
|
|
578
|
+
// where `entries = readdirSync(stagingDir)`. If the output path lives
|
|
579
|
+
// inside stagingDir (e.g. `stagingDir/__out__/...`), that subdir shows
|
|
580
|
+
// up in `entries` and tar enumerates it while ALSO writing to it.
|
|
581
|
+
// GNU tar (Linux) treats "file changed as we read it" as fatal and
|
|
582
|
+
// aborts; BSD tar (macOS) tolerates it. The sibling-tempdir layout
|
|
583
|
+
// keeps the output completely out of tar's input set on both platforms.
|
|
584
|
+
// See vault#363.
|
|
585
|
+
const outDir = mkdtempSync(join(tmpdir(), "parachute-backup-out-"));
|
|
586
|
+
|
|
575
587
|
try {
|
|
576
588
|
const tarName = backupFilename(timestamp);
|
|
577
|
-
const tarballPath = join(
|
|
589
|
+
const tarballPath = join(outDir, tarName);
|
|
578
590
|
await assembleTarball(stagingDir, tarballPath);
|
|
579
591
|
const bytes = statSync(tarballPath).size;
|
|
580
592
|
|
|
@@ -594,9 +606,11 @@ export async function runBackup(opts?: {
|
|
|
594
606
|
|
|
595
607
|
return { tarballPath, timestamp, bytes, destinations: results, contents };
|
|
596
608
|
} finally {
|
|
597
|
-
// The staging dir
|
|
598
|
-
// destination; destinations have already been written. Safe
|
|
609
|
+
// The staging dir + out dir have the only copies of the tarball that
|
|
610
|
+
// aren't at a destination; destinations have already been written. Safe
|
|
611
|
+
// to clean both.
|
|
599
612
|
try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
|
|
613
|
+
try { rmSync(outDir, { recursive: true, force: true }); } catch {}
|
|
600
614
|
}
|
|
601
615
|
}
|
|
602
616
|
|
package/src/cli.ts
CHANGED
|
@@ -388,17 +388,11 @@ async function cmdInit(args: string[] = []) {
|
|
|
388
388
|
console.log();
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
// 5b.
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
if (!hasOwnerPassword()) {
|
|
397
|
-
console.log();
|
|
398
|
-
console.log("Public exposure + web-AI connectors (claude.ai, ChatGPT, etc.) are coming soon.");
|
|
399
|
-
console.log(" When you're ready to expose this vault publicly, run:");
|
|
400
|
-
console.log(" parachute-vault set-password # required for OAuth consent");
|
|
401
|
-
}
|
|
391
|
+
// 5b. OAuth consent now runs on the hub (workstream E, 2026-05-25). Vault
|
|
392
|
+
// no longer renders its own consent page, so no owner-password prompt
|
|
393
|
+
// belongs in `vault init`. Operators who want to expose vault publicly to
|
|
394
|
+
// browser-based clients should run `parachute install hub`; the hub owns
|
|
395
|
+
// the consent surface, the sign-in flow, and the JWT issuance.
|
|
402
396
|
|
|
403
397
|
// 6. Install daemon (platform-aware). Idempotent — safe to re-run after
|
|
404
398
|
// a folder move; this refreshes ~/.parachute/server-path and bounces the
|
|
@@ -554,8 +548,8 @@ async function promptVaultName(): Promise<string> {
|
|
|
554
548
|
|
|
555
549
|
async function promptForOwnerPassword(purpose: string): Promise<boolean> {
|
|
556
550
|
console.log(`\n${purpose}`);
|
|
557
|
-
console.log("
|
|
558
|
-
console.log("
|
|
551
|
+
console.log(" Legacy field — vault's standalone OAuth consent was retired in 0.4.x.");
|
|
552
|
+
console.log(" Stored in config.yaml for hub's expose-posture-check only.");
|
|
559
553
|
console.log(` Minimum 12 characters.\n`);
|
|
560
554
|
|
|
561
555
|
while (true) {
|
|
@@ -584,6 +578,16 @@ async function promptForOwnerPassword(purpose: string): Promise<boolean> {
|
|
|
584
578
|
}
|
|
585
579
|
|
|
586
580
|
async function cmdSetPassword(args: string[]) {
|
|
581
|
+
// Legacy command (workstream E, 2026-05-25). Vault's standalone OAuth
|
|
582
|
+
// consent page was retired; this command now only writes the
|
|
583
|
+
// `owner_password_hash` field that hub's `expose public` posture-check
|
|
584
|
+
// reads. It no longer gates any auth flow inside vault. Set hub
|
|
585
|
+
// credentials with `parachute auth set-password`.
|
|
586
|
+
console.warn(
|
|
587
|
+
"[deprecated] vault's standalone OAuth consent was retired in 0.4.x; OAuth runs on the hub.\n" +
|
|
588
|
+
" This command writes a legacy YAML field but no longer gates auth inside vault.\n" +
|
|
589
|
+
" Set hub credentials with `parachute auth set-password`.\n",
|
|
590
|
+
);
|
|
587
591
|
const wantsClear = args.includes("--clear") || args.includes("--unset");
|
|
588
592
|
if (wantsClear) {
|
|
589
593
|
if (!hasOwnerPassword()) {
|
|
@@ -594,7 +598,7 @@ async function cmdSetPassword(args: string[]) {
|
|
|
594
598
|
? " Note: 2FA management operations will require your authenticator app or a backup code instead."
|
|
595
599
|
: "";
|
|
596
600
|
const ok = await confirm(
|
|
597
|
-
`Remove the owner password? OAuth consent
|
|
601
|
+
`Remove the owner password? (Legacy field — vault's standalone OAuth consent was retired in 0.4.x; OAuth runs on the hub now.)${twoFaNote}`,
|
|
598
602
|
false,
|
|
599
603
|
);
|
|
600
604
|
if (!ok) {
|
|
@@ -675,6 +679,16 @@ async function confirmForTwoFactor(purpose: string): Promise<boolean> {
|
|
|
675
679
|
}
|
|
676
680
|
|
|
677
681
|
async function cmd2fa(args: string[]) {
|
|
682
|
+
// Legacy command (workstream E, 2026-05-25). See cmdSetPassword for the
|
|
683
|
+
// full deprecation story. 2FA on vault used to layer on top of the
|
|
684
|
+
// owner-password gate for the standalone OAuth consent page; both have
|
|
685
|
+
// been retired. The CLI still manages the legacy YAML fields for
|
|
686
|
+
// back-compat.
|
|
687
|
+
console.warn(
|
|
688
|
+
"[deprecated] vault's standalone OAuth consent was retired in 0.4.x; OAuth runs on the hub.\n" +
|
|
689
|
+
" This command writes legacy YAML fields but no longer gates auth inside vault.\n",
|
|
690
|
+
);
|
|
691
|
+
|
|
678
692
|
const sub = args[0] ?? "status";
|
|
679
693
|
|
|
680
694
|
if (sub === "status") {
|
|
@@ -742,7 +756,7 @@ async function cmd2fa(args: string[]) {
|
|
|
742
756
|
for (const code of result.backupCodes) {
|
|
743
757
|
console.log(` ${code}`);
|
|
744
758
|
}
|
|
745
|
-
console.log("\n2FA is now
|
|
759
|
+
console.log("\n2FA is now recorded in the legacy YAML field. (See deprecation note above.)");
|
|
746
760
|
return;
|
|
747
761
|
}
|
|
748
762
|
|
|
@@ -3458,12 +3472,18 @@ Tokens:
|
|
|
3458
3472
|
parachute-vault tokens create --expires 30d Expiring token
|
|
3459
3473
|
parachute-vault tokens revoke <token-id> Revoke a token (default vault)
|
|
3460
3474
|
|
|
3461
|
-
OAuth:
|
|
3462
|
-
|
|
3475
|
+
OAuth — owner password + 2FA (LEGACY):
|
|
3476
|
+
Vault's standalone OAuth consent page was retired in 0.4.x (workstream E).
|
|
3477
|
+
OAuth runs on the hub now. These commands still write the legacy YAML
|
|
3478
|
+
fields (hub's \`expose public\` posture-check reads them), but they
|
|
3479
|
+
don't gate any consent flow inside vault. Set hub credentials with
|
|
3480
|
+
\`parachute auth set-password\`.
|
|
3481
|
+
|
|
3482
|
+
parachute-vault set-password Set/change owner password (legacy YAML field)
|
|
3463
3483
|
parachute-vault set-password --clear Remove the owner password
|
|
3464
3484
|
parachute-vault 2fa status Show 2FA state
|
|
3465
3485
|
parachute-vault 2fa enroll Enable TOTP 2FA (QR + backup codes)
|
|
3466
|
-
parachute-vault 2fa disable Disable 2FA
|
|
3486
|
+
parachute-vault 2fa disable Disable 2FA
|
|
3467
3487
|
parachute-vault 2fa backup-codes Regenerate backup codes
|
|
3468
3488
|
|
|
3469
3489
|
Config:
|
package/src/export-watch.test.ts
CHANGED
|
@@ -37,6 +37,25 @@ import {
|
|
|
37
37
|
|
|
38
38
|
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
39
39
|
|
|
40
|
+
// Capture HOME / PARACHUTE_HOME at module load so `seedVaultWithNotes`'s
|
|
41
|
+
// in-process env mutation (required for the deferred `vault-store.ts`
|
|
42
|
+
// re-import to see PARACHUTE_HOME) can be reverted between tests. Without
|
|
43
|
+
// this, the polluted HOME leaks into later tests in the same process — most
|
|
44
|
+
// visibly into `resolveInstallTarget` (mcp-install.test.ts), which reads
|
|
45
|
+
// `process.env.HOME` directly and would otherwise return paths under the
|
|
46
|
+
// tmpdir. Linux-only failure because macOS BSD developers were less likely
|
|
47
|
+
// to notice in dev; CI on Linux exposed it after the tag-triggered release
|
|
48
|
+
// workflow landed (vault#361). See vault#363.
|
|
49
|
+
const ORIG_HOME = process.env.HOME;
|
|
50
|
+
const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
|
|
51
|
+
|
|
52
|
+
function restoreHomeEnv(): void {
|
|
53
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
54
|
+
else process.env.HOME = ORIG_HOME;
|
|
55
|
+
if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
|
|
56
|
+
else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
// ---------------------------------------------------------------------------
|
|
41
60
|
// Shared test helpers
|
|
42
61
|
// ---------------------------------------------------------------------------
|
|
@@ -439,6 +458,7 @@ describe("export CLI: single-shot", () => {
|
|
|
439
458
|
clearVaultStoreCache();
|
|
440
459
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
441
460
|
fs.rmSync(exportDir, { recursive: true, force: true });
|
|
461
|
+
restoreHomeEnv();
|
|
442
462
|
});
|
|
443
463
|
|
|
444
464
|
test("--git-commit requires an initialized git repo (clear error)", () => {
|
|
@@ -702,6 +722,7 @@ describe("export CLI: --watch", () => {
|
|
|
702
722
|
clearVaultStoreCache();
|
|
703
723
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
704
724
|
fs.rmSync(exportDir, { recursive: true, force: true });
|
|
725
|
+
restoreHomeEnv();
|
|
705
726
|
});
|
|
706
727
|
|
|
707
728
|
test(
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth discovery endpoints — the *resource server* side of the
|
|
3
|
+
* authorization story.
|
|
4
|
+
*
|
|
5
|
+
* Vault is a resource server, not an authorization server. The hub is the
|
|
6
|
+
* OAuth issuer (see [`design/2026-04-20-hub-as-portal-oauth-and-service-catalog.md`](
|
|
7
|
+
* ../../parachute.computer/design/2026-04-20-hub-as-portal-oauth-and-service-catalog.md)
|
|
8
|
+
* and `docs/auth-model.md`). The endpoints below advertise that contract to
|
|
9
|
+
* clients per RFC 8414 + RFC 9728:
|
|
10
|
+
*
|
|
11
|
+
* - `handleProtectedResource` — RFC 9728: "this is the protected resource
|
|
12
|
+
* at `<vault>/mcp`; the authorization server lives at <hub>"
|
|
13
|
+
* - `handleAuthorizationServer` — RFC 8414: "go to <hub>/oauth/* for the
|
|
14
|
+
* authorization endpoints" (forwarded shape — issuer + endpoints all
|
|
15
|
+
* name the hub)
|
|
16
|
+
*
|
|
17
|
+
* The standalone OAuth issuer that previously lived in `src/oauth.ts` was
|
|
18
|
+
* retired in vault#366 (workstream E of the UX audit). Hub is now a hard
|
|
19
|
+
* requirement; vault never mints OAuth tokens itself, never renders a
|
|
20
|
+
* consent UI, never accepts `/oauth/authorize|token|register` requests.
|
|
21
|
+
* Operators who need OAuth install the hub and front vault with it.
|
|
22
|
+
*
|
|
23
|
+
* `PARACHUTE_HUB_ORIGIN` is required for these endpoints to advertise the
|
|
24
|
+
* right issuer URL. Without it we fall back to the canonical loopback
|
|
25
|
+
* (`http://127.0.0.1:1939`) since the hub binds that port by default — that
|
|
26
|
+
* keeps single-host installs working without explicit configuration.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { getHubOrigin } from "./hub-jwt.ts";
|
|
30
|
+
|
|
31
|
+
/** OAuth scopes vault publishes through discovery; see scopes.ts for enforcement. */
|
|
32
|
+
const SCOPES_SUPPORTED = ["vault:read", "vault:write", "vault:admin"];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
|
|
36
|
+
* Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
|
|
37
|
+
* the right external origin in `resource` URLs.
|
|
38
|
+
*
|
|
39
|
+
* Exported so the router can build `WWW-Authenticate` challenge headers that
|
|
40
|
+
* point at the same origin as the `/.well-known/*` metadata documents.
|
|
41
|
+
*/
|
|
42
|
+
export function getBaseUrl(req: Request): string {
|
|
43
|
+
const forwardedHost = req.headers.get("x-forwarded-host");
|
|
44
|
+
const forwardedProto = req.headers.get("x-forwarded-proto");
|
|
45
|
+
if (forwardedHost) {
|
|
46
|
+
return `${forwardedProto || "https"}://${forwardedHost}`;
|
|
47
|
+
}
|
|
48
|
+
const url = new URL(req.url);
|
|
49
|
+
return url.origin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* OAuth 2.0 Protected Resource Metadata (RFC 9728).
|
|
54
|
+
*
|
|
55
|
+
* Advertises the MCP endpoint as the protected resource and names the hub as
|
|
56
|
+
* the authorization server. Clients following the spec fetch this, then fetch
|
|
57
|
+
* the AS metadata at `<hub>/.well-known/oauth-authorization-server` to drive
|
|
58
|
+
* the full flow.
|
|
59
|
+
*/
|
|
60
|
+
export function handleProtectedResource(req: Request, vaultName: string): Response {
|
|
61
|
+
const base = getBaseUrl(req);
|
|
62
|
+
const prefix = `/vault/${vaultName}`;
|
|
63
|
+
return Response.json({
|
|
64
|
+
resource: `${base}${prefix}/mcp`,
|
|
65
|
+
authorization_servers: [getHubOrigin()],
|
|
66
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
67
|
+
bearer_methods_supported: ["header"],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
|
73
|
+
*
|
|
74
|
+
* Vault is a resource server, not an authorization server — but we serve this
|
|
75
|
+
* document at `/vault/<name>/.well-known/oauth-authorization-server` (and the
|
|
76
|
+
* RFC 8414 §3.1 path-insertion shape) as a *forwarding* metadata document:
|
|
77
|
+
* issuer + every endpoint name the hub. Clients that follow the PRM pointer
|
|
78
|
+
* land here and discover the hub's actual endpoints; conformant clients that
|
|
79
|
+
* probe AS metadata directly at the vault path get the same answer.
|
|
80
|
+
*/
|
|
81
|
+
export function handleAuthorizationServer(_req: Request, _vaultName: string): Response {
|
|
82
|
+
const hub = getHubOrigin();
|
|
83
|
+
return Response.json({
|
|
84
|
+
issuer: hub,
|
|
85
|
+
authorization_endpoint: `${hub}/oauth/authorize`,
|
|
86
|
+
token_endpoint: `${hub}/oauth/token`,
|
|
87
|
+
registration_endpoint: `${hub}/oauth/register`,
|
|
88
|
+
jwks_uri: `${hub}/.well-known/jwks.json`,
|
|
89
|
+
response_types_supported: ["code"],
|
|
90
|
+
code_challenge_methods_supported: ["S256"],
|
|
91
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
92
|
+
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
|
|
93
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
94
|
+
});
|
|
95
|
+
}
|