@openparachute/vault 0.3.3 → 0.4.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.
- package/.parachute/module.json +15 -0
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/docs/auth-model.md
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
# Auth model
|
|
2
|
-
|
|
3
|
-
Reference for how Parachute Vault authenticates and authorizes requests. The
|
|
4
|
-
**vault is auth-gated by default**: every route that touches vault data
|
|
5
|
-
requires a credential. The narrow set of genuinely public routes (OAuth
|
|
6
|
-
discovery, the service-info card, published notes) is listed explicitly in
|
|
7
|
-
§2.
|
|
8
|
-
|
|
9
|
-
This doc describes what the server does today. For the OAuth-issuer story
|
|
10
|
-
that sits above this layer in the ecosystem, see
|
|
11
|
-
[`design/2026-04-20-hub-as-portal-oauth-and-service-catalog.md`](../../parachute.computer/design/2026-04-20-hub-as-portal-oauth-and-service-catalog.md).
|
|
12
|
-
|
|
13
|
-
## 1. Mechanisms
|
|
14
|
-
|
|
15
|
-
Two first-class paths. Either works on its own; both can be active at once.
|
|
16
|
-
A third, legacy path exists for back-compat.
|
|
17
|
-
|
|
18
|
-
### OAuth 2.1 + PKCE + DCR
|
|
19
|
-
|
|
20
|
-
The browser-based flow third-party clients use to connect. Implements:
|
|
21
|
-
|
|
22
|
-
- **RFC 7591** Dynamic Client Registration — `POST /vault/<name>/oauth/register`
|
|
23
|
-
- **RFC 6749 / OAuth 2.1** authorization code grant with **PKCE (S256 required)** —
|
|
24
|
-
`GET/POST /vault/<name>/oauth/authorize`, `POST /vault/<name>/oauth/token`
|
|
25
|
-
- **RFC 8414** Authorization Server Metadata — `/.well-known/oauth-authorization-server`
|
|
26
|
-
- **RFC 9728** Protected Resource Metadata — `/.well-known/oauth-protected-resource`
|
|
27
|
-
|
|
28
|
-
Both path-append (`/vault/<name>/.well-known/<type>`) and path-insert
|
|
29
|
-
(`/.well-known/<type>/vault/<name>`) discovery shapes are served; the
|
|
30
|
-
path-insert form is what strict clients (e.g. Claude Code's MCP SDK) probe.
|
|
31
|
-
|
|
32
|
-
**Clients that use this flow today:** claude.ai, ChatGPT, Claude Desktop,
|
|
33
|
-
Claude Code, the Notes PWA.
|
|
34
|
-
|
|
35
|
-
**Setup the owner must do first:**
|
|
36
|
-
|
|
37
|
-
1. Run `parachute vault set-password` — bcrypt hash (cost 12) stored in
|
|
38
|
-
`~/.parachute/vault/config.yaml` as `owner_password_hash`. Minimum 12 chars.
|
|
39
|
-
Until this is set, the consent page falls back to legacy vault-token
|
|
40
|
-
auth (see §5).
|
|
41
|
-
2. *(Optional)* Run `parachute vault 2fa enroll` — TOTP secret + backup codes
|
|
42
|
-
stored in the same global config. Requires an owner password to already
|
|
43
|
-
be set. After enrollment, the consent page additionally demands a TOTP
|
|
44
|
-
code or backup code.
|
|
45
|
-
|
|
46
|
-
**Credential storage:**
|
|
47
|
-
|
|
48
|
-
| Item | Location |
|
|
49
|
-
|---|---|
|
|
50
|
-
| Owner password hash | `~/.parachute/vault/config.yaml` (`owner_password_hash`) |
|
|
51
|
-
| TOTP secret + backup codes | `~/.parachute/vault/config.yaml` (`totp_secret`, backup codes) |
|
|
52
|
-
| Registered OAuth clients | Per-vault SQLite `oauth_clients` table |
|
|
53
|
-
| Auth codes (10-min TTL, single-use) | Per-vault SQLite `oauth_codes` table, pinned to issuing vault |
|
|
54
|
-
| Minted tokens | Per-vault SQLite `tokens` table (same table as API tokens) |
|
|
55
|
-
|
|
56
|
-
**Token shape:** the successful `/oauth/token` exchange mints a standard
|
|
57
|
-
`pvt_…` bearer token (see below) and returns it with `scope`, `vault`,
|
|
58
|
-
`iss`, and a `services` catalog for ecosystem peers.
|
|
59
|
-
|
|
60
|
-
**Rate limiting:** per-IP at the consent POST. 10 failed owner-auth
|
|
61
|
-
attempts within 60 s triggers a 15-minute lockout (429 with `Retry-After`).
|
|
62
|
-
In-memory; resets on process restart. Does not apply to the token endpoint
|
|
63
|
-
or to bearer-auth attempts elsewhere.
|
|
64
|
-
|
|
65
|
-
**Scopes the consent page offers:** `full` (maps to
|
|
66
|
-
`vault:read vault:write vault:admin`) or `read` (maps to `vault:read`).
|
|
67
|
-
The `scope=` query param is a hint — the user's radio-button selection wins.
|
|
68
|
-
|
|
69
|
-
### API tokens (Bearer)
|
|
70
|
-
|
|
71
|
-
Long-lived tokens for scripts, agents, and any client that won't drive a
|
|
72
|
-
browser through consent.
|
|
73
|
-
|
|
74
|
-
**Format:** `pvt_<32 base64url chars>`. Generated by
|
|
75
|
-
`token-store.ts:generateToken`.
|
|
76
|
-
|
|
77
|
-
**Storage:** per-vault SQLite `tokens` table. Only the SHA-256 hash
|
|
78
|
-
(`sha256:…`) is stored; the plaintext token is shown once on creation.
|
|
79
|
-
|
|
80
|
-
**Scopes** (persisted as a whitespace-separated string on the token row):
|
|
81
|
-
|
|
82
|
-
- `vault:read` — GETs on `/api/*` and read-only MCP tools (`query-notes`,
|
|
83
|
-
`list-tags`, `find-path`, `vault-info`)
|
|
84
|
-
- `vault:write` — all mutation routes and mutation MCP tools
|
|
85
|
-
- `vault:admin` — `GET /.parachute/config`; inherits read + write
|
|
86
|
-
|
|
87
|
-
Inheritance: `vault:admin ⊇ vault:write ⊇ vault:read`. The
|
|
88
|
-
`vault:<name>:<verb>` shape is accepted as a synonym for `vault:<verb>`
|
|
89
|
-
today (per-vault narrowing is a future phase).
|
|
90
|
-
|
|
91
|
-
**Transport:** tokens are accepted in this priority order:
|
|
92
|
-
|
|
93
|
-
1. `Authorization: Bearer <token>`
|
|
94
|
-
2. `X-API-Key: <token>`
|
|
95
|
-
3. `?key=<token>` query string (for MCP clients that can only carry a URL,
|
|
96
|
-
e.g. Claude Web)
|
|
97
|
-
|
|
98
|
-
**CLI:**
|
|
99
|
-
|
|
100
|
-
```
|
|
101
|
-
parachute vault tokens create # full-access token, default vault
|
|
102
|
-
parachute vault tokens create --read # vault:read only (shorthand for --scope vault:read)
|
|
103
|
-
parachute vault tokens create --scope vault:write # narrow to a specific scope
|
|
104
|
-
parachute vault tokens create --scope vault:read,vault:write
|
|
105
|
-
# comma-separated or repeated --scope
|
|
106
|
-
parachute vault tokens create --vault <name> # target a specific vault
|
|
107
|
-
parachute vault tokens create --label <label> # label for the `tokens list` output
|
|
108
|
-
parachute vault tokens create --expires 30d # optional TTL (h/d/w/m/y)
|
|
109
|
-
parachute vault tokens list # list across all vaults (shows t_<prefix> IDs, never the plaintext)
|
|
110
|
-
parachute vault tokens revoke <t_…> # delete by display ID or full hash
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
With no narrowing flag, a CLI-created token gets the full scope set
|
|
114
|
-
(`vault:read vault:write vault:admin`) — unchanged for back-compat.
|
|
115
|
-
`--scope` accepts any combination of `vault:read`, `vault:write`, and
|
|
116
|
-
`vault:admin`; unrecognized scopes are rejected up front so a token is
|
|
117
|
-
never minted with a scope the server can't enforce. `--scope` and
|
|
118
|
-
`--read` cannot be combined (that ambiguity fails loudly rather than
|
|
119
|
-
silently picking one).
|
|
120
|
-
|
|
121
|
-
### Legacy: YAML `api_keys` and `X-API-Key`
|
|
122
|
-
|
|
123
|
-
Older deployments stored keys as bcrypt hashes in
|
|
124
|
-
`~/.parachute/vault/config.yaml` (`api_keys`) or per-vault in
|
|
125
|
-
`~/.parachute/vault/<vault>/vault.yaml`. These keys had a `pvk_` prefix.
|
|
126
|
-
|
|
127
|
-
Status: **still accepted for back-compat** and matched after the token-DB
|
|
128
|
-
lookup fails. On `parachute vault init` they're migrated into each
|
|
129
|
-
vault's `tokens` table. Each use logs a one-time deprecation warning
|
|
130
|
-
(`[scopes] legacy permission-based auth used …`). Plan to remove one
|
|
131
|
-
release after scope enforcement settles.
|
|
132
|
-
|
|
133
|
-
The `X-API-Key` header itself is not legacy — it's still a supported
|
|
134
|
-
transport for any `pvt_…` token.
|
|
135
|
-
|
|
136
|
-
## 2. Endpoint-by-endpoint auth behavior
|
|
137
|
-
|
|
138
|
-
Per-vault resources live under `/vault/<name>/…`. The table covers every
|
|
139
|
-
route registered in `src/routing.ts`, `src/routes.ts`, and `src/mcp-http.ts`.
|
|
140
|
-
|
|
141
|
-
### Cross-vault / origin-root
|
|
142
|
-
|
|
143
|
-
| Path | Method | Auth required | Unauthenticated response | Notes |
|
|
144
|
-
|---|---|---|---|---|
|
|
145
|
-
| `/health` | GET | None (public by design) | `200 {"status":"ok"}` | With a valid bearer, additionally returns `vaults: […]`. Intentionally public so monitoring probes work without a secret. |
|
|
146
|
-
| `/vaults/list` | GET | None (public by design) | `200 {"vaults":[…]}`, or `404` if `discovery: disabled` is set in global config | Leaks vault *names*. Opt out by setting `discovery: disabled` in `~/.parachute/vault/config.yaml`. |
|
|
147
|
-
| `/vaults` | GET | Bearer (any scope) | `401 {"error":"Unauthorized", "message":"API key required"}` | Returns `{name, description, created_at}` per vault. |
|
|
148
|
-
| `/.well-known/oauth-protected-resource/vault/<name>[/mcp]` | GET | None (RFC 9728 discovery) | `200 <metadata>` or `404` if vault not found | Public by spec — advertises where to authenticate. |
|
|
149
|
-
| `/.well-known/oauth-authorization-server/vault/<name>[/mcp]` | GET | None (RFC 8414 discovery) | `200 <metadata>` or `404` if vault not found | Public by spec. |
|
|
150
|
-
|
|
151
|
-
### Per-vault OAuth flow (`/vault/<name>/…`)
|
|
152
|
-
|
|
153
|
-
These endpoints **are the auth**, so they cannot require auth themselves.
|
|
154
|
-
|
|
155
|
-
| Path | Method | Auth required | Response |
|
|
156
|
-
|---|---|---|---|
|
|
157
|
-
| `/oauth/register` | POST | None (RFC 7591 DCR) | `201 {client_id, client_name, redirect_uris, …}` |
|
|
158
|
-
| `/oauth/authorize` | GET | None (renders consent HTML) | `200 <consent page>` or `400 <error page>` |
|
|
159
|
-
| `/oauth/authorize` | POST | Consent-form credentials (owner password or legacy vault token, plus TOTP/backup code if 2FA is on) | `302` to `redirect_uri?code=…` on success; re-renders consent on failure; `429` if rate-limited |
|
|
160
|
-
| `/oauth/token` | POST | PKCE code_verifier + client_id + redirect_uri | `200 {access_token, token_type:"bearer", scope, vault, iss, services}` or `400 {error:"invalid_grant", …}` |
|
|
161
|
-
| `/.well-known/oauth-protected-resource` | GET | None (discovery) | `200 <metadata>` |
|
|
162
|
-
| `/.well-known/oauth-authorization-server` | GET | None (discovery) | `200 <metadata>` |
|
|
163
|
-
|
|
164
|
-
### Per-vault service info + icon (hub integration)
|
|
165
|
-
|
|
166
|
-
| Path | Method | Auth required | Response |
|
|
167
|
-
|---|---|---|---|
|
|
168
|
-
| `/vault/<name>/.parachute/info` | GET | None (public; `Access-Control-Allow-Origin: *`) | `200 {name, displayName, tagline, version, iconUrl, kind}` |
|
|
169
|
-
| `/vault/<name>/.parachute/icon.svg` | GET | None (public; cached 1 h) | `200 <svg>` |
|
|
170
|
-
| `/vault/<name>/.parachute/config/schema` | GET | None (public by design — schema is shape, not values) | `200 <JSON schema>` |
|
|
171
|
-
| `/vault/<name>/.parachute/config` | GET | Bearer with `vault:admin` | `200 <config>`, `401` if no credential, `403 {error:"Forbidden", error_type:"insufficient_scope", required_scope:"vault:admin", granted_scopes:[…]}` otherwise |
|
|
172
|
-
|
|
173
|
-
### Per-vault MCP + views + REST
|
|
174
|
-
|
|
175
|
-
| Path | Method | Auth required | Unauthenticated response | Authenticated-but-underscoped response |
|
|
176
|
-
|---|---|---|---|---|
|
|
177
|
-
| `/vault/<name>/mcp[/…]` | any | Bearer (any vault scope) | `401 {error:"Unauthorized", …}` + `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 challenge) | Per-tool: read-only tools require `vault:read`; mutation tools require `vault:write`. Under-scoped `tools/call` returns `{isError:true, content:[…"requires the 'vault:write' scope"…]}`. Under-scoped tools are *also filtered out of `tools/list`*. |
|
|
178
|
-
| `/vault/<name>/view/<idOrPath>` | GET | Auth-aware (see notes) | `404 Not Found` for private notes; `200 <html>` for published notes | — |
|
|
179
|
-
| `/vault/<name>/public/<id>` | GET | Auth-aware (legacy alias) | `301` to `/vault/<name>/view/<id>` preserving `?key=…` | — |
|
|
180
|
-
| `/vault/<name>` | GET | Bearer (any scope) | `401` | — |
|
|
181
|
-
| `/vault/<name>/api/notes[/…]` | GET/HEAD | Bearer with `vault:read` | `401` | `403 {error:"Forbidden", error_type:"insufficient_scope", required_scope:"vault:read", granted_scopes:[…]}` |
|
|
182
|
-
| `/vault/<name>/api/notes[/…]` | POST/PATCH/DELETE | Bearer with `vault:write` | `401` | `403` with `required_scope:"vault:write"` |
|
|
183
|
-
| `/vault/<name>/api/tags[/…]` | GET | Bearer with `vault:read` | `401` | `403` |
|
|
184
|
-
| `/vault/<name>/api/tags[/…]` | POST/PUT/DELETE | Bearer with `vault:write` | `401` | `403` |
|
|
185
|
-
| `/vault/<name>/api/find-path` | GET | Bearer with `vault:read` | `401` | `403` |
|
|
186
|
-
| `/vault/<name>/api/vault` | GET | Bearer with `vault:read` | `401` | `403` |
|
|
187
|
-
| `/vault/<name>/api/vault` | PATCH | Bearer with `vault:write` | `401` | `403` |
|
|
188
|
-
| `/vault/<name>/api/unresolved-wikilinks` | GET | Bearer with `vault:read` | `401` | `403` |
|
|
189
|
-
| `/vault/<name>/api/storage/upload` | POST | Bearer with `vault:write` | `401` | `403` |
|
|
190
|
-
| `/vault/<name>/api/storage/<path>` | GET | Bearer with `vault:read` | `401` | `403` |
|
|
191
|
-
| `/vault/<name>/api/health` | GET | Bearer with `vault:read` | `401` | `403` |
|
|
192
|
-
|
|
193
|
-
**`/view/<idOrPath>` notes:** always served over GET. Publication is
|
|
194
|
-
determined by (a) the note carrying the `published_tag` from
|
|
195
|
-
`vault.yaml` (default `publish`), or (b) `metadata.published === true`.
|
|
196
|
-
An authenticated caller sees any note; an unauthenticated caller sees
|
|
197
|
-
*only* published notes and gets `404` for everything else (same response
|
|
198
|
-
shape as a missing note — we don't leak the existence of private notes).
|
|
199
|
-
|
|
200
|
-
**Scope inheritance:** every `403` above resolves against
|
|
201
|
-
`admin ⊇ write ⊇ read`. A token with `vault:admin` passes every scope
|
|
202
|
-
gate; a `vault:write` token passes read gates; a `vault:read` token
|
|
203
|
-
fails write gates.
|
|
204
|
-
|
|
205
|
-
**MCP `tools/list` visibility:** tools the caller can't execute are
|
|
206
|
-
hidden from the list, not just rejected on call. Read-only keys see
|
|
207
|
-
`query-notes`, `list-tags`, `find-path`, `vault-info` and nothing else.
|
|
208
|
-
|
|
209
|
-
## 3. What a user has to do
|
|
210
|
-
|
|
211
|
-
Two setup paths. **Neither is a prerequisite for the other** — either
|
|
212
|
-
works on its own, and running both is fine.
|
|
213
|
-
|
|
214
|
-
### Path A: "I want humans (claude.ai, ChatGPT, Claude Desktop, …) to use this"
|
|
215
|
-
|
|
216
|
-
```
|
|
217
|
-
parachute vault set-password # set the owner password
|
|
218
|
-
parachute vault 2fa enroll # (optional) add TOTP + backup codes
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Then, in the client:
|
|
222
|
-
|
|
223
|
-
- Add the vault's MCP URL (`https://…/vault/<name>/mcp`) as a connector.
|
|
224
|
-
- The client does OAuth discovery → DCR → authorize → token exchange
|
|
225
|
-
automatically.
|
|
226
|
-
- On the consent page, enter the owner password (and TOTP if enabled),
|
|
227
|
-
pick `Full access` or `Read-only access`, and click Authorize.
|
|
228
|
-
- The client stores the minted `pvt_…` token and uses it from then on.
|
|
229
|
-
|
|
230
|
-
### Path B: "I want a script or agent to use this"
|
|
231
|
-
|
|
232
|
-
```
|
|
233
|
-
parachute vault tokens create # full-access token in default vault
|
|
234
|
-
parachute vault tokens create --read # read-only (shorthand for --scope vault:read)
|
|
235
|
-
parachute vault tokens create --scope vault:write # write-only token
|
|
236
|
-
parachute vault tokens create --scope vault:read,vault:admin
|
|
237
|
-
# combine scopes with a comma
|
|
238
|
-
parachute vault tokens create --vault <name> # specific vault
|
|
239
|
-
parachute vault tokens create --expires 30d # with TTL
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
The command prints the plaintext `pvt_…` once. Put it in the client's
|
|
243
|
-
`Authorization: Bearer <token>` header (or `X-API-Key`, or `?key=`).
|
|
244
|
-
Revoke with `parachute vault tokens revoke <t_…>`.
|
|
245
|
-
|
|
246
|
-
The default (no narrowing flag) still mints a full-scope token. Pick a
|
|
247
|
-
`--scope` to reduce blast radius; combining `--scope` with `--read` is
|
|
248
|
-
an error (see §1 "API tokens").
|
|
249
|
-
|
|
250
|
-
## 4. Default exposure posture
|
|
251
|
-
|
|
252
|
-
The Bun server binds **`127.0.0.1`** by default (`src/server.ts`,
|
|
253
|
-
resolved via `src/bind.ts`). The socket itself only accepts connections
|
|
254
|
-
arriving on the loopback interface — LAN and public interfaces are not
|
|
255
|
-
reachable unless the operator opts in. The startup log echoes the
|
|
256
|
-
resolved hostname (`Parachute Vault server listening on
|
|
257
|
-
http://127.0.0.1:1940`) so the bind is always visible.
|
|
258
|
-
|
|
259
|
-
**Overriding the default**: set `VAULT_BIND` to bind a different
|
|
260
|
-
interface. The two common reasons to override:
|
|
261
|
-
|
|
262
|
-
- `VAULT_BIND=0.0.0.0` — accept traffic from every interface. Required
|
|
263
|
-
for **Docker bridge networking** (the container's virtual interface
|
|
264
|
-
isn't loopback from the server's perspective) and for intentional
|
|
265
|
-
**LAN setups** where another machine on the local network needs to
|
|
266
|
-
reach vault directly.
|
|
267
|
-
- `VAULT_BIND=10.0.0.5` (or similar) — bind one specific interface IP
|
|
268
|
-
on a multi-homed host.
|
|
269
|
-
|
|
270
|
-
Empty or whitespace-only `VAULT_BIND` is treated as unset.
|
|
271
|
-
|
|
272
|
-
**Supported remote-access paths are unaffected by the loopback
|
|
273
|
-
default.** `parachute expose tailnet` (Tailscale Serve) and `parachute
|
|
274
|
-
expose public` (Cloudflare Tunnel) both proxy *from* loopback — they
|
|
275
|
-
connect to `127.0.0.1:1940` on the local host and forward the decrypted
|
|
276
|
-
traffic in. Neither needs `VAULT_BIND` set. The auth model does not
|
|
277
|
-
change when you expose: those commands don't rewrite auth rules, they
|
|
278
|
-
just change *which networks can attempt to reach* an already
|
|
279
|
-
auth-gated server. Everything in §2 still applies — the bearer gate,
|
|
280
|
-
the scope gate, the OAuth flow, the public-by-design endpoints. When
|
|
281
|
-
you expose, the public-by-design endpoints (`/health`, `/vaults/list`,
|
|
282
|
-
`/.well-known/*`, OAuth discovery, `/.parachute/info`,
|
|
283
|
-
`/.parachute/icon.svg`, `/.parachute/config/schema`, published notes
|
|
284
|
-
at `/view/…`) become reachable from wherever you exposed to. Treat
|
|
285
|
-
that as part of the threat model, not as a bug.
|
|
286
|
-
|
|
287
|
-
## 5. Known rough edges
|
|
288
|
-
|
|
289
|
-
Honest list. Things a user might trip over, or that the launch copy
|
|
290
|
-
should be careful about.
|
|
291
|
-
|
|
292
|
-
- **OAuth does not strictly require an owner password.** If none is set,
|
|
293
|
-
the consent page falls back to asking for a `pvt_…` vault token as
|
|
294
|
-
proof of ownership. This works, but means the "launch flow" is still
|
|
295
|
-
operable without ever running `set-password`. Recommended: require the
|
|
296
|
-
password in docs, even though the server doesn't enforce it.
|
|
297
|
-
- **CLI-created tokens still default to full scope.** `parachute vault
|
|
298
|
-
tokens create` with no flags produces a token with
|
|
299
|
-
`vault:read vault:write vault:admin`, unchanged for back-compat.
|
|
300
|
-
Narrowing is now available via `--scope vault:read`, `--scope
|
|
301
|
-
vault:read,vault:write`, etc. (see §1 "API tokens") — the scriptwriter
|
|
302
|
-
who only wants write can now mint exactly that. The *default* is still
|
|
303
|
-
a footgun for users who don't know to narrow it.
|
|
304
|
-
- **Tokens are per-vault, not vault-wide.** A token lives in one vault's
|
|
305
|
-
SQLite DB. Exception: when presented to the unified `/mcp` endpoint
|
|
306
|
-
(no vault in the URL), auth scans every vault's token table, so an
|
|
307
|
-
OAuth-minted token still works there. A CLI-created token is only
|
|
308
|
-
valid against the vault it was created in.
|
|
309
|
-
- **Rate limiting only applies to the OAuth consent POST.** Ten failed
|
|
310
|
-
owner-auth attempts in 60 s → 15-minute per-IP lockout. There is **no
|
|
311
|
-
bearer-token brute-force limit** — an attacker hammering
|
|
312
|
-
`/vault/<name>/api/notes` with random `pvt_…` guesses is not rate
|
|
313
|
-
limited. The guessing space (≈190 bits) makes this academic but worth
|
|
314
|
-
knowing when planning exposure.
|
|
315
|
-
- **Public-by-design endpoints leak structural info.** `/health` (with
|
|
316
|
-
auth) and `/vaults/list` (by default, disable with `discovery:
|
|
317
|
-
disabled`) reveal which vaults exist. `/.well-known/oauth-*` reveals
|
|
318
|
-
that a vault exists at `/vault/<name>`. `/.parachute/info` reveals the
|
|
319
|
-
running version. All of these are intentional, but each is
|
|
320
|
-
discoverable by anyone who can reach the server.
|
|
321
|
-
- **Published notes bypass auth by design.** `/vault/<name>/view/<id>`
|
|
322
|
-
serves any note tagged with `published_tag` (default `publish`) or
|
|
323
|
-
carrying `metadata.published: true` as HTML with no credential. If a
|
|
324
|
-
user inadvertently tags a private note `publish`, the whole internet
|
|
325
|
-
sees it once the vault is exposed.
|
|
326
|
-
- **Legacy `pvk_` keys and `X-API-Key` keep working.** Pre-v0.3 users'
|
|
327
|
-
YAML-stored `pvk_` keys are accepted and migrated on init; each use
|
|
328
|
-
logs a one-time deprecation warning. Plan removal is "one release
|
|
329
|
-
after scope enforcement settles" (not yet scheduled).
|
|
330
|
-
- **`WWW-Authenticate` challenges are only added on `/mcp` 401s.** The
|
|
331
|
-
REST API returns plain `401 {error:"Unauthorized"}` without an RFC
|
|
332
|
-
9728 challenge header. A generic HTTP client won't auto-discover the
|
|
333
|
-
authorization server from a REST 401 — that's fine (clients that care
|
|
334
|
-
use the MCP path), but REST API consumers must read the OAuth
|
|
335
|
-
metadata document explicitly.
|
|
336
|
-
- **`TRUST_PROXY=1` is not the default.** Rate limiting uses the socket
|
|
337
|
-
peer IP unless `TRUST_PROXY` is set, in which case it honors
|
|
338
|
-
`X-Forwarded-For`. A deployment behind Cloudflare Tunnel / nginx
|
|
339
|
-
without `TRUST_PROXY=1` will rate-limit against the proxy's IP
|
|
340
|
-
(typically loopback), effectively disabling per-user lockout.
|
package/fly.toml
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
app = "parachute-vault"
|
|
2
|
-
primary_region = "iad"
|
|
3
|
-
|
|
4
|
-
[build]
|
|
5
|
-
dockerfile = "Dockerfile"
|
|
6
|
-
|
|
7
|
-
[env]
|
|
8
|
-
PORT = "1940"
|
|
9
|
-
PARACHUTE_HOME = "/data"
|
|
10
|
-
|
|
11
|
-
[http_service]
|
|
12
|
-
internal_port = 1940
|
|
13
|
-
force_https = true
|
|
14
|
-
auto_stop_machines = "stop"
|
|
15
|
-
auto_start_machines = true
|
|
16
|
-
min_machines_running = 0
|
|
17
|
-
|
|
18
|
-
[[vm]]
|
|
19
|
-
size = "shared-cpu-1x"
|
|
20
|
-
memory = "512mb"
|
|
21
|
-
|
|
22
|
-
[[mounts]]
|
|
23
|
-
source = "vault_data"
|
|
24
|
-
destination = "/data"
|
package/package/package.json
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.2.4",
|
|
4
|
-
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
|
-
"module": "src/cli.ts",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"parachute-vault": "src/cli.ts"
|
|
9
|
-
},
|
|
10
|
-
"scripts": {
|
|
11
|
-
"start": "bun src/server.ts",
|
|
12
|
-
"cli": "bun src/cli.ts",
|
|
13
|
-
"test": "bun test src/",
|
|
14
|
-
"test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run"
|
|
15
|
-
},
|
|
16
|
-
"dependencies": {
|
|
17
|
-
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
18
|
-
"otpauth": "^9.5.0",
|
|
19
|
-
"qrcode-terminal": "^0.12.0"
|
|
20
|
-
},
|
|
21
|
-
"devDependencies": {
|
|
22
|
-
"@types/bun": "latest"
|
|
23
|
-
},
|
|
24
|
-
"peerDependencies": {
|
|
25
|
-
"typescript": "^5"
|
|
26
|
-
},
|
|
27
|
-
"repository": {
|
|
28
|
-
"type": "git",
|
|
29
|
-
"url": "https://github.com/ParachuteComputer/parachute-vault.git"
|
|
30
|
-
},
|
|
31
|
-
"license": "AGPL-3.0"
|
|
32
|
-
}
|
package/railway.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://railway.com/railway.schema.json",
|
|
3
|
-
"build": {
|
|
4
|
-
"builder": "DOCKERFILE",
|
|
5
|
-
"dockerfilePath": "Dockerfile"
|
|
6
|
-
},
|
|
7
|
-
"deploy": {
|
|
8
|
-
"startCommand": "bun src/server.ts",
|
|
9
|
-
"healthcheckPath": "/health",
|
|
10
|
-
"healthcheckTimeout": 5,
|
|
11
|
-
"restartPolicyType": "ON_FAILURE",
|
|
12
|
-
"restartPolicyMaxRetries": 3
|
|
13
|
-
}
|
|
14
|
-
}
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for scripts/migrate-audio-to-opus.ts.
|
|
3
|
-
*
|
|
4
|
-
* Spins up a temp vault layout under a fresh PARACHUTE_HOME, seeds a WAV
|
|
5
|
-
* attachment, runs the script in dry-run then real mode, asserts the DB
|
|
6
|
-
* row was rewritten, the .ogg file exists, and the original was unlinked.
|
|
7
|
-
*
|
|
8
|
-
* Uses the real ffmpeg binary (matches src/audio-encoding.test.ts).
|
|
9
|
-
*
|
|
10
|
-
* Requires `@openparachute/narrate` which is not a vault dependency.
|
|
11
|
-
* Install manually (`bun add @openparachute/narrate`) to run these tests.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
-
import { Database } from "bun:sqlite";
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
17
|
-
import { join } from "path";
|
|
18
|
-
import { tmpdir } from "os";
|
|
19
|
-
import { SCHEMA_SQL } from "../core/src/schema.ts";
|
|
20
|
-
|
|
21
|
-
// @openparachute/narrate is not a vault dependency — dynamically import
|
|
22
|
-
// so the test file can at least be parsed without the optional package.
|
|
23
|
-
let runMigration: typeof import("./migrate-audio-to-opus.ts").runMigration;
|
|
24
|
-
let hasDep = false;
|
|
25
|
-
try {
|
|
26
|
-
({ runMigration } = await import("./migrate-audio-to-opus.ts"));
|
|
27
|
-
hasDep = true;
|
|
28
|
-
} catch {
|
|
29
|
-
// dependency missing — tests will be skipped below
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function buildSilentWav(samples: number): Buffer {
|
|
33
|
-
const sampleRate = 8000;
|
|
34
|
-
const numChannels = 1;
|
|
35
|
-
const bitsPerSample = 16;
|
|
36
|
-
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
|
37
|
-
const blockAlign = (numChannels * bitsPerSample) / 8;
|
|
38
|
-
const dataSize = samples * blockAlign;
|
|
39
|
-
const chunkSize = 36 + dataSize;
|
|
40
|
-
const buf = Buffer.alloc(44 + dataSize);
|
|
41
|
-
let off = 0;
|
|
42
|
-
buf.write("RIFF", off); off += 4;
|
|
43
|
-
buf.writeUInt32LE(chunkSize, off); off += 4;
|
|
44
|
-
buf.write("WAVE", off); off += 4;
|
|
45
|
-
buf.write("fmt ", off); off += 4;
|
|
46
|
-
buf.writeUInt32LE(16, off); off += 4;
|
|
47
|
-
buf.writeUInt16LE(1, off); off += 2;
|
|
48
|
-
buf.writeUInt16LE(numChannels, off); off += 2;
|
|
49
|
-
buf.writeUInt32LE(sampleRate, off); off += 4;
|
|
50
|
-
buf.writeUInt32LE(byteRate, off); off += 4;
|
|
51
|
-
buf.writeUInt16LE(blockAlign, off); off += 2;
|
|
52
|
-
buf.writeUInt16LE(bitsPerSample, off); off += 2;
|
|
53
|
-
buf.write("data", off); off += 4;
|
|
54
|
-
buf.writeUInt32LE(dataSize, off); off += 4;
|
|
55
|
-
return buf;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
let tmpHome: string;
|
|
59
|
-
let prevHome: string | undefined;
|
|
60
|
-
let prevAssets: string | undefined;
|
|
61
|
-
|
|
62
|
-
beforeEach(() => {
|
|
63
|
-
tmpHome = join(
|
|
64
|
-
tmpdir(),
|
|
65
|
-
`migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
66
|
-
);
|
|
67
|
-
mkdirSync(tmpHome, { recursive: true });
|
|
68
|
-
prevHome = process.env.PARACHUTE_HOME;
|
|
69
|
-
prevAssets = process.env.ASSETS_DIR;
|
|
70
|
-
process.env.PARACHUTE_HOME = tmpHome;
|
|
71
|
-
delete process.env.ASSETS_DIR; // use default per-vault assets dir
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
76
|
-
else process.env.PARACHUTE_HOME = prevHome;
|
|
77
|
-
if (prevAssets === undefined) delete process.env.ASSETS_DIR;
|
|
78
|
-
else process.env.ASSETS_DIR = prevAssets;
|
|
79
|
-
try {
|
|
80
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
81
|
-
} catch {
|
|
82
|
-
// ignore
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
interface SeedResult {
|
|
87
|
-
vault: string;
|
|
88
|
-
dbPath: string;
|
|
89
|
-
assetsBase: string;
|
|
90
|
-
noteId: string;
|
|
91
|
-
attachmentId: string;
|
|
92
|
-
relWavPath: string;
|
|
93
|
-
absWavPath: string;
|
|
94
|
-
relOggPath: string;
|
|
95
|
-
absOggPath: string;
|
|
96
|
-
noteUpdatedAt: string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function seedVaultWithWav(vaultName: string): SeedResult {
|
|
100
|
-
const vaultDir = join(tmpHome, "vaults", vaultName);
|
|
101
|
-
mkdirSync(vaultDir, { recursive: true });
|
|
102
|
-
const dbPath = join(vaultDir, "vault.db");
|
|
103
|
-
const assetsBase = join(vaultDir, "assets");
|
|
104
|
-
mkdirSync(assetsBase, { recursive: true });
|
|
105
|
-
|
|
106
|
-
const db = new Database(dbPath);
|
|
107
|
-
db.exec(SCHEMA_SQL);
|
|
108
|
-
|
|
109
|
-
const noteId = "n_" + Math.random().toString(36).slice(2, 10);
|
|
110
|
-
const attachmentId = "a_" + Math.random().toString(36).slice(2, 10);
|
|
111
|
-
const now = new Date().toISOString();
|
|
112
|
-
|
|
113
|
-
db.prepare(
|
|
114
|
-
"INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
115
|
-
).run(noteId, "hello reader", null, "{}", now, now);
|
|
116
|
-
|
|
117
|
-
const relWavPath = `tts/2026-04-08/${noteId}-123.wav`;
|
|
118
|
-
const absWavPath = join(assetsBase, relWavPath);
|
|
119
|
-
mkdirSync(join(assetsBase, "tts", "2026-04-08"), { recursive: true });
|
|
120
|
-
// Write ~1s of silence WAV. ffmpeg handles this fine.
|
|
121
|
-
writeFileSync(absWavPath, buildSilentWav(8000));
|
|
122
|
-
|
|
123
|
-
db.prepare(
|
|
124
|
-
"INSERT INTO attachments (id, note_id, path, mime_type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
125
|
-
).run(attachmentId, noteId, relWavPath, "audio/wav", "{}", now);
|
|
126
|
-
|
|
127
|
-
db.close();
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
vault: vaultName,
|
|
131
|
-
dbPath,
|
|
132
|
-
assetsBase,
|
|
133
|
-
noteId,
|
|
134
|
-
attachmentId,
|
|
135
|
-
relWavPath,
|
|
136
|
-
absWavPath,
|
|
137
|
-
relOggPath: relWavPath.replace(/\.wav$/, ".ogg"),
|
|
138
|
-
absOggPath: absWavPath.replace(/\.wav$/, ".ogg"),
|
|
139
|
-
noteUpdatedAt: now,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
describe.skipIf(!hasDep)("migrate-audio-to-opus", () => {
|
|
144
|
-
test("dry-run reports candidates without touching anything", async () => {
|
|
145
|
-
const seed = seedVaultWithWav("default");
|
|
146
|
-
|
|
147
|
-
const logs: string[] = [];
|
|
148
|
-
const origLog = console.log;
|
|
149
|
-
console.log = (...args: unknown[]) => {
|
|
150
|
-
logs.push(args.map((a) => String(a)).join(" "));
|
|
151
|
-
};
|
|
152
|
-
try {
|
|
153
|
-
const summaries = await runMigration(["--vault", "default", "--dry-run"]);
|
|
154
|
-
expect(summaries.length).toBe(1);
|
|
155
|
-
expect(summaries[0].dryRunCandidates).toBe(1);
|
|
156
|
-
expect(summaries[0].converted).toBe(0);
|
|
157
|
-
expect(summaries[0].errors).toBe(0);
|
|
158
|
-
} finally {
|
|
159
|
-
console.log = origLog;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
expect(logs.some((l) => l.includes("DRY-RUN convert") && l.includes(seed.relWavPath))).toBe(
|
|
163
|
-
true,
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
// Nothing moved.
|
|
167
|
-
expect(existsSync(seed.absWavPath)).toBe(true);
|
|
168
|
-
expect(existsSync(seed.absOggPath)).toBe(false);
|
|
169
|
-
|
|
170
|
-
const db = new Database(seed.dbPath);
|
|
171
|
-
try {
|
|
172
|
-
const row = db
|
|
173
|
-
.prepare("SELECT path, mime_type FROM attachments WHERE id = ?")
|
|
174
|
-
.get(seed.attachmentId) as { path: string; mime_type: string };
|
|
175
|
-
expect(row.path).toBe(seed.relWavPath);
|
|
176
|
-
expect(row.mime_type).toBe("audio/wav");
|
|
177
|
-
} finally {
|
|
178
|
-
db.close();
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("full run converts WAV to Opus, updates DB, unlinks original, no updated_at bump", async () => {
|
|
183
|
-
const seed = seedVaultWithWav("default");
|
|
184
|
-
|
|
185
|
-
const origLog = console.log;
|
|
186
|
-
console.log = () => {};
|
|
187
|
-
try {
|
|
188
|
-
const summaries = await runMigration(["--vault", "default"]);
|
|
189
|
-
expect(summaries[0].converted).toBe(1);
|
|
190
|
-
expect(summaries[0].errors).toBe(0);
|
|
191
|
-
expect(summaries[0].bytesAfter).toBeGreaterThan(0);
|
|
192
|
-
} finally {
|
|
193
|
-
console.log = origLog;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Original .wav removed, .ogg exists and has OggS magic bytes.
|
|
197
|
-
expect(existsSync(seed.absWavPath)).toBe(false);
|
|
198
|
-
expect(existsSync(seed.absOggPath)).toBe(true);
|
|
199
|
-
const oggBytes = readFileSync(seed.absOggPath);
|
|
200
|
-
expect(oggBytes.toString("ascii", 0, 4)).toBe("OggS");
|
|
201
|
-
|
|
202
|
-
// DB row rewritten.
|
|
203
|
-
const db = new Database(seed.dbPath);
|
|
204
|
-
try {
|
|
205
|
-
const row = db
|
|
206
|
-
.prepare("SELECT path, mime_type FROM attachments WHERE id = ?")
|
|
207
|
-
.get(seed.attachmentId) as { path: string; mime_type: string };
|
|
208
|
-
expect(row.path).toBe(seed.relOggPath);
|
|
209
|
-
expect(row.mime_type).toBe("audio/ogg");
|
|
210
|
-
|
|
211
|
-
// Note's updated_at must NOT have changed — this is a storage
|
|
212
|
-
// migration, not a content edit.
|
|
213
|
-
const note = db
|
|
214
|
-
.prepare("SELECT updated_at FROM notes WHERE id = ?")
|
|
215
|
-
.get(seed.noteId) as { updated_at: string };
|
|
216
|
-
expect(note.updated_at).toBe(seed.noteUpdatedAt);
|
|
217
|
-
} finally {
|
|
218
|
-
db.close();
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("re-running after a successful migration is a no-op (idempotent)", async () => {
|
|
223
|
-
seedVaultWithWav("default");
|
|
224
|
-
|
|
225
|
-
const origLog = console.log;
|
|
226
|
-
console.log = () => {};
|
|
227
|
-
try {
|
|
228
|
-
await runMigration(["--vault", "default"]);
|
|
229
|
-
const second = await runMigration(["--vault", "default"]);
|
|
230
|
-
expect(second[0].converted).toBe(0);
|
|
231
|
-
expect(second[0].skipped).toBe(1);
|
|
232
|
-
expect(second[0].errors).toBe(0);
|
|
233
|
-
} finally {
|
|
234
|
-
console.log = origLog;
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
});
|