@openparachute/vault 0.3.1 → 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.
Files changed (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -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"
@@ -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
- });