@openparachute/vault 0.2.4 → 0.3.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/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
package/src/oauth.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Implements the subset of OAuth 2.1 needed for MCP clients (Claude Web,
|
|
5
5
|
* Claude Desktop, etc.) to connect via the standard browser-based flow:
|
|
6
6
|
*
|
|
7
|
-
* 1. Dynamic Client Registration (RFC 7591) — POST /oauth/register
|
|
8
|
-
* 2. Authorization endpoint (PKCE required) — GET/POST /oauth/authorize
|
|
9
|
-
* 3. Token endpoint (code exchange) — POST /oauth/token
|
|
10
|
-
* 4. Discovery endpoints — GET
|
|
7
|
+
* 1. Dynamic Client Registration (RFC 7591) — POST /vault/<name>/oauth/register
|
|
8
|
+
* 2. Authorization endpoint (PKCE required) — GET/POST /vault/<name>/oauth/authorize
|
|
9
|
+
* 3. Token endpoint (code exchange) — POST /vault/<name>/oauth/token
|
|
10
|
+
* 4. Discovery endpoints — GET /vault/<name>/.well-known/*
|
|
11
11
|
*
|
|
12
12
|
* The flow produces a standard `pvt_` token stored in the vault's tokens table.
|
|
13
13
|
* After the OAuth handshake, all requests use the same Bearer token auth path.
|
|
@@ -19,6 +19,8 @@ import { generateToken, createToken, resolveToken } from "./token-store.ts";
|
|
|
19
19
|
import type { TokenPermission } from "./token-store.ts";
|
|
20
20
|
import { verifyOwnerPassword, authorizeRateLimit, type RateLimiter } from "./owner-auth.ts";
|
|
21
21
|
import { verifyTotpCode, verifyAndConsumeBackupCode } from "./two-factor.ts";
|
|
22
|
+
import { readManifest, ServicesManifestError } from "./services-manifest.ts";
|
|
23
|
+
import { legacyPermissionToScopes, SCOPE_READ, serializeScopes } from "./scopes.ts";
|
|
22
24
|
|
|
23
25
|
/** Options for handleAuthorizePost. */
|
|
24
26
|
export interface AuthorizePostOptions {
|
|
@@ -63,6 +65,96 @@ export function getBaseUrl(req: Request): string {
|
|
|
63
65
|
return url.origin;
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Public origin the client reached vault through. When `PARACHUTE_HUB_ORIGIN`
|
|
70
|
+
* is set AND matches the incoming request's base URL, returns the hub; else
|
|
71
|
+
* returns the request base. This is the RFC 8414 compliance hinge: discovery
|
|
72
|
+
* metadata's `issuer`, token `iss` claims, and the service catalog all stem
|
|
73
|
+
* from this, so the issuer view is always self-consistent with the origin the
|
|
74
|
+
* client is actually talking to.
|
|
75
|
+
*/
|
|
76
|
+
function resolvePublicOrigin(req: Request): string {
|
|
77
|
+
const hub = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
78
|
+
const base = getBaseUrl(req);
|
|
79
|
+
return hub && base === hub ? hub : base;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* OAuth endpoint coordinates. Hub-rooted when the request came in through the
|
|
84
|
+
* hub origin (`PARACHUTE_HUB_ORIGIN` set AND matches the incoming base URL),
|
|
85
|
+
* vault-path-rooted otherwise. The same vault exposes both views concurrently:
|
|
86
|
+
* a loopback client gets `issuer = http://127.0.0.1:<port>/vault/<name>`; a
|
|
87
|
+
* client reaching vault via the hub reverse proxy gets `issuer = <hub>`.
|
|
88
|
+
*
|
|
89
|
+
* This is how vault stays RFC 8414 compliant while a single process serves
|
|
90
|
+
* both origins — discovery always returns the issuer matching the client's
|
|
91
|
+
* origin.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveOAuthCoordinates(
|
|
94
|
+
req: Request,
|
|
95
|
+
vaultName: string,
|
|
96
|
+
): {
|
|
97
|
+
issuer: string;
|
|
98
|
+
authorizationEndpoint: string;
|
|
99
|
+
tokenEndpoint: string;
|
|
100
|
+
registrationEndpoint: string;
|
|
101
|
+
} {
|
|
102
|
+
const origin = resolvePublicOrigin(req);
|
|
103
|
+
const hub = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
104
|
+
if (hub && origin === hub) {
|
|
105
|
+
return {
|
|
106
|
+
issuer: hub,
|
|
107
|
+
authorizationEndpoint: `${hub}/oauth/authorize`,
|
|
108
|
+
tokenEndpoint: `${hub}/oauth/token`,
|
|
109
|
+
registrationEndpoint: `${hub}/oauth/register`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const prefix = `/vault/${vaultName}`;
|
|
113
|
+
return {
|
|
114
|
+
issuer: `${origin}${prefix}`,
|
|
115
|
+
authorizationEndpoint: `${origin}${prefix}/oauth/authorize`,
|
|
116
|
+
tokenEndpoint: `${origin}${prefix}/oauth/token`,
|
|
117
|
+
registrationEndpoint: `${origin}${prefix}/oauth/register`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ecosystem service catalog for the token response (Phase 1 of the
|
|
123
|
+
* hub-as-OAuth-issuer design). Reads `~/.parachute/services.json` — the same
|
|
124
|
+
* manifest the CLI maintains — and rewrites each entry's canonical path into
|
|
125
|
+
* an absolute URL rooted at the origin the client reached vault through. A
|
|
126
|
+
* client that came in via the hub gets hub-rooted URLs; a loopback client
|
|
127
|
+
* gets loopback URLs. Same vault, same manifest, origin-consistent.
|
|
128
|
+
*
|
|
129
|
+
* Failure to read the manifest is non-fatal: we log and return an empty
|
|
130
|
+
* catalog rather than refusing to issue the token. The token response shape
|
|
131
|
+
* is additive — clients that don't expect `services` ignore it.
|
|
132
|
+
*/
|
|
133
|
+
export function buildServiceCatalog(
|
|
134
|
+
req: Request,
|
|
135
|
+
): Record<string, { url: string; version: string }> {
|
|
136
|
+
let entries: ReturnType<typeof readManifest>["services"];
|
|
137
|
+
try {
|
|
138
|
+
entries = readManifest().services;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err instanceof ServicesManifestError) {
|
|
141
|
+
console.warn(`[parachute-vault] services.json unreadable: ${err.message}`);
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
const origin = resolvePublicOrigin(req);
|
|
147
|
+
const catalog: Record<string, { url: string; version: string }> = {};
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const path = entry.paths[0] ?? "/";
|
|
150
|
+
catalog[entry.name] = {
|
|
151
|
+
url: `${origin}${path}`,
|
|
152
|
+
version: entry.version,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return catalog;
|
|
156
|
+
}
|
|
157
|
+
|
|
66
158
|
function escapeHtml(s: string): string {
|
|
67
159
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
68
160
|
}
|
|
@@ -74,49 +166,61 @@ function escapeHtml(s: string): string {
|
|
|
74
166
|
/**
|
|
75
167
|
* OAuth 2.0 Protected Resource Metadata (RFC 9728).
|
|
76
168
|
*
|
|
77
|
-
* @param
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
169
|
+
* @param vaultName — the vault whose MCP endpoint is the protected resource.
|
|
170
|
+
* The metadata advertises `resource: {base}/vault/{name}/mcp`
|
|
171
|
+
* and the vault's authorization server at
|
|
172
|
+
* `{base}/vault/{name}`. Clients discover the AS metadata
|
|
173
|
+
* at `{base}/vault/{name}/.well-known/oauth-authorization-server`.
|
|
82
174
|
*/
|
|
83
|
-
export function handleProtectedResource(
|
|
84
|
-
req
|
|
85
|
-
mcpPath = "/mcp",
|
|
86
|
-
authServerPrefix = "",
|
|
87
|
-
): Response {
|
|
175
|
+
export function handleProtectedResource(req: Request, vaultName: string): Response {
|
|
176
|
+
const { issuer } = resolveOAuthCoordinates(req, vaultName);
|
|
88
177
|
const base = getBaseUrl(req);
|
|
178
|
+
const prefix = `/vault/${vaultName}`;
|
|
89
179
|
return Response.json({
|
|
90
|
-
resource: `${base}${
|
|
91
|
-
authorization_servers
|
|
92
|
-
|
|
180
|
+
resource: `${base}${prefix}/mcp`,
|
|
181
|
+
// `authorization_servers` points clients at the AS metadata doc. When the
|
|
182
|
+
// hub is the issuer (Phase 0), the AS metadata still lives on the vault
|
|
183
|
+
// itself — it's the document that tells clients where the hub endpoints
|
|
184
|
+
// are. So we use the issuer as the AS locator when set, otherwise the
|
|
185
|
+
// vault origin.
|
|
186
|
+
authorization_servers: [issuer],
|
|
187
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
93
188
|
bearer_methods_supported: ["header"],
|
|
94
189
|
});
|
|
95
190
|
}
|
|
96
191
|
|
|
97
192
|
/**
|
|
98
|
-
* OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* (`/vaults/<name>/oauth/*`) and issuer. Tokens minted
|
|
102
|
-
* via these endpoints are scoped to the named vault's DB.
|
|
193
|
+
* OAuth 2.0 Authorization Server Metadata (RFC 8414). Endpoint URLs and
|
|
194
|
+
* `issuer` honor `PARACHUTE_HUB_ORIGIN` when set — see
|
|
195
|
+
* `resolveOAuthCoordinates` for the hub-vs-standalone contract.
|
|
103
196
|
*/
|
|
104
|
-
export function handleAuthorizationServer(req: Request, vaultName
|
|
105
|
-
const
|
|
106
|
-
const prefix = vaultName ? `/vaults/${vaultName}` : "";
|
|
197
|
+
export function handleAuthorizationServer(req: Request, vaultName: string): Response {
|
|
198
|
+
const coord = resolveOAuthCoordinates(req, vaultName);
|
|
107
199
|
return Response.json({
|
|
108
|
-
issuer:
|
|
109
|
-
authorization_endpoint:
|
|
110
|
-
token_endpoint:
|
|
111
|
-
registration_endpoint:
|
|
200
|
+
issuer: coord.issuer,
|
|
201
|
+
authorization_endpoint: coord.authorizationEndpoint,
|
|
202
|
+
token_endpoint: coord.tokenEndpoint,
|
|
203
|
+
registration_endpoint: coord.registrationEndpoint,
|
|
112
204
|
response_types_supported: ["code"],
|
|
113
205
|
code_challenge_methods_supported: ["S256"],
|
|
114
206
|
grant_types_supported: ["authorization_code"],
|
|
115
207
|
token_endpoint_auth_methods_supported: ["none"],
|
|
116
|
-
scopes_supported:
|
|
208
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
117
209
|
});
|
|
118
210
|
}
|
|
119
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Scopes published in OAuth discovery. Phase 2 enforces these at request time
|
|
214
|
+
* (`vault:admin` ⊇ `vault:write` ⊇ `vault:read`). `vault:<name>:*` refinements
|
|
215
|
+
* are documented as future shape; the scope parser accepts them as synonyms
|
|
216
|
+
* for `vault:*` today.
|
|
217
|
+
*
|
|
218
|
+
* Legacy `full`/`read` remain in the list for back-compat with 0.2.x clients
|
|
219
|
+
* that hardcoded those names — they're translated into `vault:*` scopes on the
|
|
220
|
+
* way in and out.
|
|
221
|
+
*/
|
|
222
|
+
const SCOPES_SUPPORTED = ["vault:read", "vault:write", "vault:admin", "full", "read"];
|
|
223
|
+
|
|
120
224
|
// ---------------------------------------------------------------------------
|
|
121
225
|
// Dynamic Client Registration (RFC 7591)
|
|
122
226
|
// ---------------------------------------------------------------------------
|
|
@@ -486,8 +590,8 @@ export async function handleToken(
|
|
|
486
590
|
}
|
|
487
591
|
|
|
488
592
|
// Validate the code was issued for the same vault this token endpoint
|
|
489
|
-
// serves. Without this, a code issued under /
|
|
490
|
-
// could be presented to /
|
|
593
|
+
// serves. Without this, a code issued under /vault/A/oauth/authorize
|
|
594
|
+
// could be presented to /vault/B/oauth/token and the token would be
|
|
491
595
|
// minted into B's DB — privilege escalation across vault boundaries.
|
|
492
596
|
if (authCode.vault_name !== vaultName) {
|
|
493
597
|
return Response.json({ error: "invalid_grant", error_description: "vault mismatch" }, { status: 400 });
|
|
@@ -506,19 +610,35 @@ export async function handleToken(
|
|
|
506
610
|
// Mark code as used
|
|
507
611
|
db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
|
|
508
612
|
|
|
509
|
-
//
|
|
613
|
+
// Translate the consent-time selected scope into both the legacy permission
|
|
614
|
+
// column and the OAuth-standard scope list we now persist on the token row.
|
|
615
|
+
// The consent page only offers read vs full today; full becomes the
|
|
616
|
+
// admin-inheriting scope set so hub admin operations keep working.
|
|
510
617
|
const permission: TokenPermission = authCode.scope === "read" ? "read" : "full";
|
|
618
|
+
const scopes = legacyPermissionToScopes(permission);
|
|
619
|
+
const scopeString = serializeScopes(scopes);
|
|
620
|
+
|
|
511
621
|
const { fullToken } = generateToken();
|
|
512
622
|
createToken(db, fullToken, {
|
|
513
623
|
label: `oauth:${clientId.slice(0, 8)}`,
|
|
514
624
|
permission,
|
|
625
|
+
scopes,
|
|
515
626
|
});
|
|
516
627
|
|
|
628
|
+
const { issuer } = resolveOAuthCoordinates(req, vaultName);
|
|
517
629
|
return Response.json({
|
|
518
630
|
access_token: fullToken,
|
|
519
631
|
token_type: "bearer",
|
|
520
|
-
|
|
632
|
+
// RFC 6749 §5.1: scope is an OAuth-standard whitespace-separated string.
|
|
633
|
+
scope: scopeString,
|
|
521
634
|
vault: vaultName,
|
|
635
|
+
// Phase 0: identify the issuer so tokens validated by downstream services
|
|
636
|
+
// can pin trust on the hub-origin URL, not vault's internal address.
|
|
637
|
+
iss: issuer,
|
|
638
|
+
// Phase 1: bundle the ecosystem service catalog so Notes/clients learn
|
|
639
|
+
// all sibling service URLs from the token response and don't need to
|
|
640
|
+
// prompt the user for each one. Additive field — older clients ignore.
|
|
641
|
+
services: buildServiceCatalog(req),
|
|
522
642
|
});
|
|
523
643
|
}
|
|
524
644
|
|
|
@@ -708,7 +828,7 @@ function renderConsentPage(p: ConsentParams): string {
|
|
|
708
828
|
<div class="card">
|
|
709
829
|
<h1>Authorize access</h1>
|
|
710
830
|
<p><span class="client">${escapeHtml(p.clientName)}</span> wants to access your <strong>${escapeHtml(p.vaultName)}</strong> vault.</p>
|
|
711
|
-
<form method="POST" action="
|
|
831
|
+
<form method="POST" action="">
|
|
712
832
|
<input type="hidden" name="client_id" value="${escapeHtml(p.clientId)}">
|
|
713
833
|
<input type="hidden" name="redirect_uri" value="${escapeHtml(p.redirectUri)}">
|
|
714
834
|
<input type="hidden" name="code_challenge" value="${escapeHtml(p.codeChallenge)}">
|
package/src/published.test.ts
CHANGED
|
@@ -25,8 +25,8 @@ describe("/public → /view redirect", () => {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("works for vault-scoped paths", () => {
|
|
28
|
-
const url = buildRedirectUrl("http://localhost:1940/
|
|
29
|
-
expect(url).toBe("http://localhost:1940/
|
|
28
|
+
const url = buildRedirectUrl("http://localhost:1940/vault/work/public/abc?key=pvk_x", "abc", "/vault/work");
|
|
29
|
+
expect(url).toBe("http://localhost:1940/vault/work/view/abc?key=pvk_x");
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
package/src/routes.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type ExpandMode,
|
|
25
25
|
} from "../core/src/expand.ts";
|
|
26
26
|
import { join, extname, normalize } from "path";
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
28
28
|
import { vaultDir } from "./config.ts";
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
@@ -40,6 +40,13 @@ function parseBool(val: string | null, defaultVal: boolean): boolean {
|
|
|
40
40
|
return val === "true" || val === "1";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function parseBoolOrUndef(val: string | null): boolean | undefined {
|
|
44
|
+
if (val === null) return undefined;
|
|
45
|
+
if (val === "true" || val === "1") return true;
|
|
46
|
+
if (val === "false" || val === "0") return false;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
function parseQuery(url: URL, key: string): string | null {
|
|
44
51
|
return url.searchParams.get(key);
|
|
45
52
|
}
|
|
@@ -124,6 +131,7 @@ export async function handleNotes(
|
|
|
124
131
|
req: Request,
|
|
125
132
|
store: Store,
|
|
126
133
|
subpath: string,
|
|
134
|
+
vault?: string,
|
|
127
135
|
): Promise<Response> {
|
|
128
136
|
const url = new URL(req.url);
|
|
129
137
|
const method = req.method;
|
|
@@ -182,19 +190,33 @@ export async function handleNotes(
|
|
|
182
190
|
|
|
183
191
|
// Structured query
|
|
184
192
|
const tags = parseQueryList(url, "tag");
|
|
185
|
-
let results: Note[]
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
193
|
+
let results: Note[];
|
|
194
|
+
try {
|
|
195
|
+
results = await store.queryNotes({
|
|
196
|
+
tags,
|
|
197
|
+
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
198
|
+
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
199
|
+
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
200
|
+
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
201
|
+
path: parseQuery(url, "path") ?? undefined,
|
|
202
|
+
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
203
|
+
metadata: undefined, // metadata filter not practical in query params
|
|
204
|
+
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
205
|
+
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
206
|
+
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
207
|
+
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
208
|
+
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
209
|
+
offset: parseInt10(parseQuery(url, "offset")),
|
|
210
|
+
});
|
|
211
|
+
} catch (e: any) {
|
|
212
|
+
// QueryError (non-indexed order_by, unknown operator, ...) surfaces
|
|
213
|
+
// here. Duck-type on `name` + `code` — core is a separate module, so
|
|
214
|
+
// `instanceof` is fragile across bundling boundaries.
|
|
215
|
+
if (e && e.name === "QueryError") {
|
|
216
|
+
return json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400);
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
198
220
|
|
|
199
221
|
// Near-scope filter (graph neighborhood)
|
|
200
222
|
const nearNoteId = parseQuery(url, "near[note_id]");
|
|
@@ -309,9 +331,32 @@ export async function handleNotes(
|
|
|
309
331
|
if (method === "POST") {
|
|
310
332
|
const note = await resolveNote(store, idOrPath);
|
|
311
333
|
if (!note) return json({ error: "Not found" }, 404);
|
|
312
|
-
const body = await req.json() as { path: string; mimeType: string };
|
|
334
|
+
const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
|
|
313
335
|
if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
|
|
314
|
-
|
|
336
|
+
|
|
337
|
+
// `transcribe: true` asks the transcription worker to read this audio
|
|
338
|
+
// file and replace the note's content with the transcript. The caller
|
|
339
|
+
// is declaring "overwrite my current content when the transcript lands"
|
|
340
|
+
// — we persist that as `transcribe_stub: true` on the note so a later
|
|
341
|
+
// user edit (which clears the marker) can opt out before the worker
|
|
342
|
+
// runs.
|
|
343
|
+
const attMeta = body.transcribe
|
|
344
|
+
? { transcribe_status: "pending" as const, transcribe_requested_at: new Date().toISOString() }
|
|
345
|
+
: undefined;
|
|
346
|
+
|
|
347
|
+
const attachment = await store.addAttachment(note.id, body.path, body.mimeType, attMeta);
|
|
348
|
+
|
|
349
|
+
if (body.transcribe) {
|
|
350
|
+
const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
351
|
+
if (noteMeta.transcribe_stub !== true) {
|
|
352
|
+
await store.updateNote(note.id, {
|
|
353
|
+
metadata: { ...noteMeta, transcribe_stub: true },
|
|
354
|
+
skipUpdatedAt: true,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return json(attachment, 201);
|
|
315
360
|
}
|
|
316
361
|
if (method === "GET") {
|
|
317
362
|
const note = await resolveNote(store, idOrPath);
|
|
@@ -321,6 +366,29 @@ export async function handleNotes(
|
|
|
321
366
|
return json({ error: "Method not allowed" }, 405);
|
|
322
367
|
}
|
|
323
368
|
|
|
369
|
+
const attMatch = sub.match(/^\/attachments\/([^/]+)$/);
|
|
370
|
+
if (attMatch) {
|
|
371
|
+
const attId = decodeURIComponent(attMatch[1]!);
|
|
372
|
+
if (method === "DELETE") {
|
|
373
|
+
const note = await resolveNote(store, idOrPath);
|
|
374
|
+
if (!note) return json({ error: "Not found" }, 404);
|
|
375
|
+
const result = await store.deleteAttachment(note.id, attId);
|
|
376
|
+
if (!result.deleted) return json({ error: "Not found" }, 404);
|
|
377
|
+
// Unlink the storage file only if no other attachment still references
|
|
378
|
+
// the same path. Best-effort: the row is already gone, so a missing
|
|
379
|
+
// file or unlink error should not flip the DELETE to an error.
|
|
380
|
+
if (vault && result.path && result.orphaned) {
|
|
381
|
+
const assets = assetsDir(vault);
|
|
382
|
+
const filePath = normalize(join(assets, result.path));
|
|
383
|
+
if (filePath.startsWith(normalize(assets)) && existsSync(filePath)) {
|
|
384
|
+
try { unlinkSync(filePath); } catch {}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return new Response(null, { status: 204 });
|
|
388
|
+
}
|
|
389
|
+
return json({ error: "Method not allowed" }, 405);
|
|
390
|
+
}
|
|
391
|
+
|
|
324
392
|
if (sub !== "") return json({ error: "Not found" }, 404);
|
|
325
393
|
|
|
326
394
|
// GET /notes/:idOrPath — single note
|
|
@@ -351,6 +419,24 @@ export async function handleNotes(
|
|
|
351
419
|
if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
352
420
|
const body = await req.json() as any;
|
|
353
421
|
|
|
422
|
+
// --- Safety-by-default: refuse mutations without a precondition ---
|
|
423
|
+
// Mirror the MCP tool: require `if_updated_at` unless the caller
|
|
424
|
+
// explicitly sets `force: true`. 428 Precondition Required is the
|
|
425
|
+
// RFC 6585 status for exactly this case.
|
|
426
|
+
if (body.if_updated_at === undefined && body.force !== true) {
|
|
427
|
+
return json(
|
|
428
|
+
{
|
|
429
|
+
error_type: "precondition_required",
|
|
430
|
+
error: "precondition_required",
|
|
431
|
+
message:
|
|
432
|
+
"update requires `if_updated_at` (the note's last-seen updated_at) or `force: true`.",
|
|
433
|
+
note_id: note.id,
|
|
434
|
+
path: note.path ?? null,
|
|
435
|
+
},
|
|
436
|
+
428,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
354
440
|
// --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
|
|
355
441
|
// The actual link deletions happen only after the core UPDATE succeeds,
|
|
356
442
|
// so a conflict leaves the note untouched.
|
|
@@ -420,10 +506,16 @@ export async function handleNotes(
|
|
|
420
506
|
if (e && e.code === "CONFLICT") {
|
|
421
507
|
return json(
|
|
422
508
|
{
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
note_id: e.note_id,
|
|
509
|
+
// New structured shape — what agents should key on.
|
|
510
|
+
error_type: "conflict",
|
|
426
511
|
current_updated_at: e.current_updated_at ?? null,
|
|
512
|
+
your_updated_at: e.expected_updated_at,
|
|
513
|
+
path: e.note_path ?? null,
|
|
514
|
+
note_id: e.note_id,
|
|
515
|
+
message: e.message,
|
|
516
|
+
// Legacy fields — kept for the lens VaultConflictError shim and
|
|
517
|
+
// any other pre-launch callers. Safe to drop post-launch.
|
|
518
|
+
error: "conflict",
|
|
427
519
|
expected_updated_at: e.expected_updated_at,
|
|
428
520
|
},
|
|
429
521
|
409,
|
|
@@ -445,7 +537,8 @@ export async function handleNotes(
|
|
|
445
537
|
}
|
|
446
538
|
|
|
447
539
|
// ---------------------------------------------------------------------------
|
|
448
|
-
// Tags — GET/PUT/DELETE /api/tags[/:name]
|
|
540
|
+
// Tags — GET/PUT/DELETE /api/tags[/:name], POST /api/tags/merge,
|
|
541
|
+
// POST /api/tags/:name/rename
|
|
449
542
|
// ---------------------------------------------------------------------------
|
|
450
543
|
|
|
451
544
|
export async function handleTags(req: Request, store: Store, subpath = ""): Promise<Response> {
|
|
@@ -479,6 +572,54 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
|
|
|
479
572
|
return json(tags);
|
|
480
573
|
}
|
|
481
574
|
|
|
575
|
+
// POST /tags/merge — atomic multi-source merge into a target tag.
|
|
576
|
+
// Must come before the /:name matcher so "merge" isn't read as a tag name.
|
|
577
|
+
if (subpath === "/merge") {
|
|
578
|
+
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
579
|
+
const body = (await req.json().catch(() => null)) as
|
|
580
|
+
| { sources?: unknown; target?: unknown }
|
|
581
|
+
| null;
|
|
582
|
+
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
583
|
+
const sources = body.sources;
|
|
584
|
+
const target = body.target;
|
|
585
|
+
if (!Array.isArray(sources) || !sources.every((s) => typeof s === "string" && s.length > 0)) {
|
|
586
|
+
return json({ error: "sources must be a non-empty array of strings" }, 400);
|
|
587
|
+
}
|
|
588
|
+
if (typeof target !== "string" || target.length === 0) {
|
|
589
|
+
return json({ error: "target must be a non-empty string" }, 400);
|
|
590
|
+
}
|
|
591
|
+
const result = await store.mergeTags(sources, target);
|
|
592
|
+
return json(result);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// POST /tags/:name/rename — atomic rename across tags + note_tags + schema
|
|
596
|
+
const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
|
|
597
|
+
if (renameMatch) {
|
|
598
|
+
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
599
|
+
const oldName = decodeURIComponent(renameMatch[1]);
|
|
600
|
+
const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
|
|
601
|
+
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
602
|
+
const newName = body.new_name;
|
|
603
|
+
if (typeof newName !== "string" || newName.length === 0) {
|
|
604
|
+
return json({ error: "new_name must be a non-empty string" }, 400);
|
|
605
|
+
}
|
|
606
|
+
const result = await store.renameTag(oldName, newName);
|
|
607
|
+
if ("error" in result) {
|
|
608
|
+
if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
|
|
609
|
+
if (result.error === "target_exists") {
|
|
610
|
+
return json(
|
|
611
|
+
{
|
|
612
|
+
error: "target_exists",
|
|
613
|
+
target: newName,
|
|
614
|
+
message: "Target tag already exists; use POST /api/tags/merge to combine them.",
|
|
615
|
+
},
|
|
616
|
+
409,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return json(result);
|
|
621
|
+
}
|
|
622
|
+
|
|
482
623
|
// Routes with tag name
|
|
483
624
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
484
625
|
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
@@ -550,19 +691,34 @@ export async function handleFindPath(req: Request, store: Store): Promise<Respon
|
|
|
550
691
|
// Vault info — GET/PATCH /api/vault
|
|
551
692
|
// ---------------------------------------------------------------------------
|
|
552
693
|
|
|
694
|
+
type VaultConfigLike = {
|
|
695
|
+
name: string;
|
|
696
|
+
description?: string;
|
|
697
|
+
audio_retention?: "keep" | "until_transcribed" | "never";
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
|
|
701
|
+
|
|
702
|
+
function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
|
|
703
|
+
return {
|
|
704
|
+
name: vaultConfig.name,
|
|
705
|
+
description: vaultConfig.description ?? null,
|
|
706
|
+
config: {
|
|
707
|
+
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
553
712
|
export async function handleVault(
|
|
554
713
|
req: Request,
|
|
555
714
|
store: Store,
|
|
556
|
-
vaultConfig:
|
|
557
|
-
|
|
715
|
+
vaultConfig: VaultConfigLike,
|
|
716
|
+
persist?: () => void,
|
|
558
717
|
): Promise<Response> {
|
|
559
718
|
const url = new URL(req.url);
|
|
560
719
|
|
|
561
720
|
if (req.method === "GET") {
|
|
562
|
-
const result:
|
|
563
|
-
name: vaultConfig.name,
|
|
564
|
-
description: vaultConfig.description ?? null,
|
|
565
|
-
};
|
|
721
|
+
const result: Record<string, unknown> = vaultResponse(vaultConfig);
|
|
566
722
|
if (parseBool(parseQuery(url, "include_stats"), false)) {
|
|
567
723
|
result.stats = await store.getVaultStats();
|
|
568
724
|
}
|
|
@@ -570,14 +726,34 @@ export async function handleVault(
|
|
|
570
726
|
}
|
|
571
727
|
|
|
572
728
|
if (req.method === "PATCH") {
|
|
573
|
-
const body = await req.json() as {
|
|
574
|
-
|
|
575
|
-
|
|
729
|
+
const body = await req.json() as {
|
|
730
|
+
description?: string;
|
|
731
|
+
config?: { audio_retention?: string };
|
|
732
|
+
};
|
|
733
|
+
let dirty = false;
|
|
734
|
+
|
|
735
|
+
if (body.description !== undefined) {
|
|
736
|
+
vaultConfig.description = body.description;
|
|
737
|
+
dirty = true;
|
|
576
738
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
739
|
+
|
|
740
|
+
if (body.config?.audio_retention !== undefined) {
|
|
741
|
+
const v = body.config.audio_retention;
|
|
742
|
+
if (!VALID_AUDIO_RETENTION.includes(v as typeof VALID_AUDIO_RETENTION[number])) {
|
|
743
|
+
return json(
|
|
744
|
+
{
|
|
745
|
+
error: "invalid_audio_retention",
|
|
746
|
+
message: `audio_retention must be one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
|
|
747
|
+
},
|
|
748
|
+
400,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
vaultConfig.audio_retention = v as typeof VALID_AUDIO_RETENTION[number];
|
|
752
|
+
dirty = true;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (dirty && persist) persist();
|
|
756
|
+
return json(vaultResponse(vaultConfig));
|
|
581
757
|
}
|
|
582
758
|
|
|
583
759
|
return json({ error: "Method not allowed" }, 405);
|