@openparachute/vault 0.6.0-rc.1 → 0.6.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 +14 -3
- package/README.md +7 -7
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +58 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/mirror-routes.ts
CHANGED
|
@@ -26,16 +26,20 @@
|
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
defaultMirrorConfig,
|
|
29
|
+
readMirrorConfigForVault,
|
|
29
30
|
validateExternalPath,
|
|
30
31
|
validateMirrorConfigShape,
|
|
31
32
|
type MirrorConfig,
|
|
32
33
|
} from "./mirror-config.ts";
|
|
33
34
|
import type { MirrorManager } from "./mirror-manager.ts";
|
|
35
|
+
import { getMirrorManager } from "./mirror-registry.ts";
|
|
34
36
|
import {
|
|
35
37
|
applyToGitRemote,
|
|
36
38
|
deleteCredentials,
|
|
37
39
|
emptyCredentials,
|
|
40
|
+
githubAuthedRemoteUrl,
|
|
38
41
|
readCredentials,
|
|
42
|
+
redactRemoteUrl,
|
|
39
43
|
sanitizeCredentials,
|
|
40
44
|
unsetGitRemote,
|
|
41
45
|
writeCredentials,
|
|
@@ -62,6 +66,7 @@ import {
|
|
|
62
66
|
type ImportResult,
|
|
63
67
|
} from "./mirror-import.ts";
|
|
64
68
|
import { redactToken } from "./export-watch.ts";
|
|
69
|
+
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
65
70
|
import { getVaultStore } from "./vault-store.ts";
|
|
66
71
|
import { assetsDir } from "./routes.ts";
|
|
67
72
|
|
|
@@ -120,6 +125,10 @@ export function handleMirrorGet(manager: MirrorManager): Response {
|
|
|
120
125
|
export async function handleMirrorPut(
|
|
121
126
|
req: Request,
|
|
122
127
|
manager: MirrorManager,
|
|
128
|
+
// Test seam for the external-path git-presence preflight (default
|
|
129
|
+
// `Bun.which` inside `validateExternalPath`). Inject a fn returning `null`
|
|
130
|
+
// to exercise the git_not_installed 503 path without uninstalling git.
|
|
131
|
+
whichOverride?: (cmd: string) => string | null,
|
|
123
132
|
): Promise<Response> {
|
|
124
133
|
let body: unknown;
|
|
125
134
|
try {
|
|
@@ -154,7 +163,25 @@ export async function handleMirrorPut(
|
|
|
154
163
|
// to *do* something with an external path. Disabling the mirror by-
|
|
155
164
|
// flipping enabled to false shouldn't fail because the path went away.
|
|
156
165
|
if (config.enabled && config.location === "external" && config.external_path) {
|
|
157
|
-
|
|
166
|
+
let pathCheck;
|
|
167
|
+
try {
|
|
168
|
+
pathCheck = await validateExternalPath(config.external_path, whichOverride);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err instanceof GitNotInstalledError) {
|
|
171
|
+
// 503 git_not_installed — consistent with the import route. The
|
|
172
|
+
// server can't validate (or later sync) an external git mirror
|
|
173
|
+
// without git installed; the message tells the operator how to fix.
|
|
174
|
+
return Response.json(
|
|
175
|
+
{
|
|
176
|
+
error: "git not installed",
|
|
177
|
+
error_type: "git_not_installed",
|
|
178
|
+
message: err.message,
|
|
179
|
+
},
|
|
180
|
+
{ status: 503 },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
158
185
|
if (!pathCheck.ok) {
|
|
159
186
|
return Response.json(
|
|
160
187
|
{
|
|
@@ -597,6 +624,26 @@ export async function handleAuthPat(
|
|
|
597
624
|
);
|
|
598
625
|
}
|
|
599
626
|
|
|
627
|
+
// Preflight: the validation probe (`git ls-remote`) and every later push
|
|
628
|
+
// shell `git`. On a git-less server, surface the friendly, actionable
|
|
629
|
+
// 503 instead of letting the probe's `Bun.spawn` throw a raw
|
|
630
|
+
// "Executable not found in $PATH: \"git\"".
|
|
631
|
+
try {
|
|
632
|
+
ensureGitAvailable();
|
|
633
|
+
} catch (err) {
|
|
634
|
+
if (err instanceof GitNotInstalledError) {
|
|
635
|
+
return Response.json(
|
|
636
|
+
{
|
|
637
|
+
error: "git not installed",
|
|
638
|
+
error_type: "git_not_installed",
|
|
639
|
+
message: err.message,
|
|
640
|
+
},
|
|
641
|
+
{ status: 503 },
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
throw err;
|
|
645
|
+
}
|
|
646
|
+
|
|
600
647
|
// Validate via `git ls-remote <embedded-auth-url>` — uses the same
|
|
601
648
|
// x-access-token shape we'd embed at push time so the probe exercises
|
|
602
649
|
// the actual auth path. If the operator pasted a URL that already has
|
|
@@ -1063,15 +1110,32 @@ export async function applyCredentialsToMirror(
|
|
|
1063
1110
|
// "mode": "merge" | "replace",
|
|
1064
1111
|
// "credentials": null
|
|
1065
1112
|
// | { "kind": "pat", "token": "ghp_..." }
|
|
1066
|
-
// | { "kind": "none" }
|
|
1113
|
+
// | { "kind": "none" },
|
|
1114
|
+
// "enable_sync": true // optional, DEFAULT TRUE
|
|
1067
1115
|
// }
|
|
1068
1116
|
//
|
|
1069
1117
|
// `credentials: null` means "use the stored mirror credentials." Passing
|
|
1070
|
-
// `{kind: "pat", token}` is the one-shot path — token doesn't get persisted
|
|
1118
|
+
// `{kind: "pat", token}` is the one-shot path — token doesn't get persisted
|
|
1119
|
+
// for the CLONE, but IS persisted when sync is enabled (it's the push
|
|
1120
|
+
// credential for the now-configured mirror).
|
|
1121
|
+
//
|
|
1122
|
+
// `enable_sync` (vault#416) — DEFAULT TRUE when omitted. After a successful
|
|
1123
|
+
// import, auto-enable mirror push-back to the SAME repo, reusing the import's
|
|
1124
|
+
// credentials. Makes "import a repo" and "back up to that repo going forward"
|
|
1125
|
+
// one fluid flow. The UI ships a checked-by-default checkbox the operator can
|
|
1126
|
+
// uncheck. Edge cases (handled in `enableSyncToImportedRepo`, never fail the
|
|
1127
|
+
// whole import):
|
|
1128
|
+
// - `auth: none` (public repo, no push creds) → skip + warn (can't push
|
|
1129
|
+
// without a credential).
|
|
1130
|
+
// - A mirror already points at a DIFFERENT remote → skip + warn (don't
|
|
1131
|
+
// clobber the operator's existing backup target).
|
|
1132
|
+
// - A mirror already points at the SAME remote → no-op success.
|
|
1133
|
+
// - Sync setup throws → import result still returned (success);
|
|
1134
|
+
// `sync_enabled: false` + a warning. Import success is never lost.
|
|
1071
1135
|
//
|
|
1072
1136
|
// Response:
|
|
1073
1137
|
// 200 { notes_imported, tags_imported, attachments_imported,
|
|
1074
|
-
// notes_deleted?, warnings }
|
|
1138
|
+
// notes_deleted?, warnings, sync_enabled, sync_warning? }
|
|
1075
1139
|
// 400 { error, error_type, message } — validation / not-a-vault-export
|
|
1076
1140
|
// 409 { error, error_type, message } — concurrent import for this vault
|
|
1077
1141
|
// 502 { error, message } — clone failed (auth, network, …)
|
|
@@ -1089,16 +1153,29 @@ export async function applyCredentialsToMirror(
|
|
|
1089
1153
|
*
|
|
1090
1154
|
* `spawnOverride` is a test seam: lets the test inject a fake git binary.
|
|
1091
1155
|
* Production callers omit it; `cloneAndImport` falls back to `defaultGitSpawn`.
|
|
1156
|
+
*
|
|
1157
|
+
* `whichOverride` is a test seam for the git-presence preflight (default
|
|
1158
|
+
* `Bun.which` inside `cloneAndImport`). Inject a fn returning `null` to
|
|
1159
|
+
* exercise the git_not_installed 503 path without uninstalling git.
|
|
1160
|
+
*
|
|
1161
|
+
* `managerOverride` is a test seam for the post-import sync-enable step
|
|
1162
|
+
* (vault#416). Production callers omit it; the route resolves the per-vault
|
|
1163
|
+
* manager via `getMirrorManager(vaultName)` (same registry the auth /
|
|
1164
|
+
* run-now / push-now routes use). Tests inject a manager so they can assert
|
|
1165
|
+
* on the config it ends up with without standing up the full registry.
|
|
1092
1166
|
*/
|
|
1093
1167
|
export async function handleMirrorImport(
|
|
1094
1168
|
req: Request,
|
|
1095
1169
|
vaultName: string,
|
|
1096
1170
|
spawnOverride?: GitSpawn,
|
|
1171
|
+
whichOverride?: (cmd: string) => string | null,
|
|
1172
|
+
managerOverride?: MirrorManager,
|
|
1097
1173
|
): Promise<Response> {
|
|
1098
1174
|
let body: {
|
|
1099
1175
|
remote_url?: unknown;
|
|
1100
1176
|
mode?: unknown;
|
|
1101
1177
|
credentials?: unknown;
|
|
1178
|
+
enable_sync?: unknown;
|
|
1102
1179
|
};
|
|
1103
1180
|
try {
|
|
1104
1181
|
body = (await req.json()) as Record<string, unknown>;
|
|
@@ -1191,6 +1268,25 @@ export async function handleMirrorImport(
|
|
|
1191
1268
|
);
|
|
1192
1269
|
}
|
|
1193
1270
|
|
|
1271
|
+
// vault#416: `enable_sync` defaults TRUE when omitted — the default-on UX.
|
|
1272
|
+
// Only a literal `false` opts out; any other type is a validation error so
|
|
1273
|
+
// a malformed body never silently flips the default.
|
|
1274
|
+
let enableSync = true;
|
|
1275
|
+
if ("enable_sync" in body && body.enable_sync !== undefined) {
|
|
1276
|
+
if (typeof body.enable_sync !== "boolean") {
|
|
1277
|
+
return Response.json(
|
|
1278
|
+
{
|
|
1279
|
+
error: "enable_sync invalid",
|
|
1280
|
+
error_type: "validation",
|
|
1281
|
+
field: "enable_sync",
|
|
1282
|
+
message: "enable_sync must be a boolean (defaults to true when omitted).",
|
|
1283
|
+
},
|
|
1284
|
+
{ status: 400 },
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
enableSync = body.enable_sync;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1194
1290
|
// Resolve the target vault's store + assets dir. The route is gated on
|
|
1195
1291
|
// `vault:<name>:admin`, so we trust vaultName is real by the time we
|
|
1196
1292
|
// reach this code path; defensively re-resolve in case the vault was
|
|
@@ -1208,8 +1304,21 @@ export async function handleMirrorImport(
|
|
|
1208
1304
|
store,
|
|
1209
1305
|
assetsDir: assets,
|
|
1210
1306
|
spawn: spawnOverride,
|
|
1307
|
+
which: whichOverride,
|
|
1211
1308
|
});
|
|
1212
1309
|
} catch (err) {
|
|
1310
|
+
if (err instanceof GitNotInstalledError) {
|
|
1311
|
+
// 503 Service Unavailable — the server isn't configured to do this
|
|
1312
|
+
// yet (git missing). The message tells the operator how to fix it.
|
|
1313
|
+
return Response.json(
|
|
1314
|
+
{
|
|
1315
|
+
error: "git not installed",
|
|
1316
|
+
error_type: "git_not_installed",
|
|
1317
|
+
message: err.message,
|
|
1318
|
+
},
|
|
1319
|
+
{ status: 503 },
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1213
1322
|
if (err instanceof ImportConflictError) {
|
|
1214
1323
|
return Response.json(
|
|
1215
1324
|
{
|
|
@@ -1250,7 +1359,368 @@ export async function handleMirrorImport(
|
|
|
1250
1359
|
);
|
|
1251
1360
|
}
|
|
1252
1361
|
|
|
1362
|
+
// ---- vault#416: auto-enable sync to the imported repo (default-on) -------
|
|
1363
|
+
//
|
|
1364
|
+
// The import SUCCEEDED above. From here on, every failure is non-fatal —
|
|
1365
|
+
// we never lose a successful import to a sync-setup error. `result` already
|
|
1366
|
+
// carries `sync_enabled: false` (set by importResultFromStats); we flip it
|
|
1367
|
+
// true only when sync is actually wired (or already wired to this remote).
|
|
1368
|
+
if (enableSync) {
|
|
1369
|
+
try {
|
|
1370
|
+
const manager =
|
|
1371
|
+
managerOverride ?? getMirrorManager(vaultName) ?? undefined;
|
|
1372
|
+
const outcome = await enableSyncToImportedRepo({
|
|
1373
|
+
vaultName,
|
|
1374
|
+
remoteUrl: remote_url,
|
|
1375
|
+
auth,
|
|
1376
|
+
manager,
|
|
1377
|
+
});
|
|
1378
|
+
result.sync_enabled = outcome.sync_enabled;
|
|
1379
|
+
if (outcome.warning) result.sync_warning = outcome.warning;
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
// Defense-in-depth: enableSyncToImportedRepo is written to not throw
|
|
1382
|
+
// (it catches its own write/credential/reload errors), but a future
|
|
1383
|
+
// edit or an unexpected throw must NOT take down a successful import.
|
|
1384
|
+
const msg = redactToken((err as Error).message ?? String(err));
|
|
1385
|
+
console.warn(
|
|
1386
|
+
`[mirror-import] sync-enable threw after a successful import (non-fatal): ${msg}`,
|
|
1387
|
+
);
|
|
1388
|
+
result.sync_enabled = false;
|
|
1389
|
+
result.sync_warning =
|
|
1390
|
+
"Import succeeded, but enabling Sync failed. Set up Sync separately from the Git remote section.";
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1253
1394
|
return Response.json(result, {
|
|
1254
1395
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
1255
1396
|
});
|
|
1256
1397
|
}
|
|
1398
|
+
|
|
1399
|
+
// ---------------------------------------------------------------------------
|
|
1400
|
+
// vault#416 — sync-enable after a successful import.
|
|
1401
|
+
// ---------------------------------------------------------------------------
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* After a successful import, turn the imported-from repo into a configured,
|
|
1405
|
+
* credential-backed, auto-pushing mirror — reusing the import's credentials.
|
|
1406
|
+
* This is the back half of the default-on "import → also sync back" flow.
|
|
1407
|
+
*
|
|
1408
|
+
* Reuses the SAME machinery the manual mirror-setup flow uses:
|
|
1409
|
+
* - `writeCredentials` to persist the push credential (per-vault, vault#399).
|
|
1410
|
+
* - `MirrorConfig` + `manager.reload()` to enable `auto_push` and restart
|
|
1411
|
+
* the lifecycle — which calls `applyCredentialsToRemote` to set `origin`
|
|
1412
|
+
* from the stored credentials (exactly like handleAuthPat /
|
|
1413
|
+
* handleAuthGithubSelectRepo do).
|
|
1414
|
+
*
|
|
1415
|
+
* Mirror location is left `internal` (vault-managed dir under the vault's
|
|
1416
|
+
* data dir) — the import didn't ask the operator to pick an external path,
|
|
1417
|
+
* and `auto_push + internal + credentials` is the supported "push to a
|
|
1418
|
+
* GitHub/GitLab remote" shape (see mirror-config.ts validation note). The
|
|
1419
|
+
* remote lives in credentials, not the config; that's why we persist
|
|
1420
|
+
* credentials AND flip auto_push.
|
|
1421
|
+
*
|
|
1422
|
+
* Never throws — every failure path returns `{ sync_enabled: false, warning }`.
|
|
1423
|
+
* The import already succeeded by the time this runs; a sync-setup error must
|
|
1424
|
+
* not be surfaced as an import failure.
|
|
1425
|
+
*
|
|
1426
|
+
* Edge cases (returns `sync_enabled: false` + a warning, no broken mirror
|
|
1427
|
+
* left behind):
|
|
1428
|
+
* - **No push-capable credential** — `auth: none`, OR `credentialsFile`
|
|
1429
|
+
* with no stored credential that covers this remote's host. Can't push
|
|
1430
|
+
* without a credential; skip rather than configure a mirror that would
|
|
1431
|
+
* just fail every push.
|
|
1432
|
+
* - **A mirror already targets a DIFFERENT remote** — don't clobber the
|
|
1433
|
+
* operator's existing backup target. Detected by comparing the existing
|
|
1434
|
+
* stored credential's remote host/path against the import remote.
|
|
1435
|
+
* - **A mirror already targets the SAME remote** — no-op success.
|
|
1436
|
+
*/
|
|
1437
|
+
export async function enableSyncToImportedRepo(opts: {
|
|
1438
|
+
vaultName: string;
|
|
1439
|
+
remoteUrl: string;
|
|
1440
|
+
auth: ImportAuth;
|
|
1441
|
+
/**
|
|
1442
|
+
* The per-vault mirror manager. Undefined only in the boot-race window
|
|
1443
|
+
* where the registry factory hasn't been installed; we then skip (warn)
|
|
1444
|
+
* rather than persisting a half-configured mirror with no live manager.
|
|
1445
|
+
*/
|
|
1446
|
+
manager: MirrorManager | undefined;
|
|
1447
|
+
}): Promise<{ sync_enabled: boolean; warning?: string }> {
|
|
1448
|
+
const { vaultName, remoteUrl, auth, manager } = opts;
|
|
1449
|
+
|
|
1450
|
+
if (!manager) {
|
|
1451
|
+
return {
|
|
1452
|
+
sync_enabled: false,
|
|
1453
|
+
warning:
|
|
1454
|
+
"Sync not enabled — the vault's mirror manager wasn't ready. Set up Sync from the Git remote section.",
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// --- Resolve the push credential we'll persist for this remote. ----------
|
|
1459
|
+
// We need a credential that can PUSH to `remoteUrl`. The clone-time auth
|
|
1460
|
+
// shapes map onto push credentials as follows:
|
|
1461
|
+
// - pat → persist the supplied PAT against this remote.
|
|
1462
|
+
// - none → no push credential; cannot enable a working sync.
|
|
1463
|
+
// - credentialsFile → reuse the already-stored credential, but only if it
|
|
1464
|
+
// actually covers this remote's host (a stored PAT for
|
|
1465
|
+
// a different host can't push here).
|
|
1466
|
+
let credentialToWrite: MirrorCredentials | null = null;
|
|
1467
|
+
|
|
1468
|
+
if (auth.kind === "none") {
|
|
1469
|
+
return {
|
|
1470
|
+
sync_enabled: false,
|
|
1471
|
+
warning:
|
|
1472
|
+
"Sync not enabled — pushing changes back needs write credentials (a PAT or GitHub sign-in). Set up Sync separately to enable it.",
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (auth.kind === "pat") {
|
|
1477
|
+
// Persist the supplied PAT against this remote — the same x-access-token
|
|
1478
|
+
// embedding the manual PAT route stores, so bare `git push` works.
|
|
1479
|
+
const embedded = embedTokenInRemoteUrl(remoteUrl, auth.token);
|
|
1480
|
+
if (!embedded) {
|
|
1481
|
+
return {
|
|
1482
|
+
sync_enabled: false,
|
|
1483
|
+
warning:
|
|
1484
|
+
"Sync not enabled — the remote URL couldn't be parsed to embed the access token. Set up Sync separately from the Git remote section.",
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
credentialToWrite = {
|
|
1488
|
+
...(readCredentials(vaultName) ?? emptyCredentials()),
|
|
1489
|
+
active_method: "pat",
|
|
1490
|
+
pat: {
|
|
1491
|
+
token: auth.token,
|
|
1492
|
+
remote_url: embedded,
|
|
1493
|
+
label: "Imported-repo sync",
|
|
1494
|
+
},
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// For credentialsFile we DON'T overwrite — we reuse what's already stored,
|
|
1499
|
+
// but must verify it covers this remote (host match). Resolve the existing
|
|
1500
|
+
// credential's effective remote so the conflict check below has something
|
|
1501
|
+
// to compare against.
|
|
1502
|
+
const existing = readCredentials(vaultName);
|
|
1503
|
+
|
|
1504
|
+
// --- Conflict / idempotency check against any already-configured mirror. --
|
|
1505
|
+
// The mirror's remote lives in credentials, not in MirrorConfig. Compare
|
|
1506
|
+
// the existing stored credential's remote against the import remote.
|
|
1507
|
+
const persistedConfig = readMirrorConfigForVault(vaultName);
|
|
1508
|
+
const mirrorAlreadyConfigured = !!persistedConfig && persistedConfig.enabled;
|
|
1509
|
+
const existingRemote = existing ? effectiveRemoteOf(existing) : null;
|
|
1510
|
+
|
|
1511
|
+
if (mirrorAlreadyConfigured && existingRemote) {
|
|
1512
|
+
if (sameRemote(existingRemote, remoteUrl)) {
|
|
1513
|
+
// Same remote — make sure auto_push is on, then no-op success. We don't
|
|
1514
|
+
// need to rewrite credentials (credentialsFile) or can refresh the PAT
|
|
1515
|
+
// (pat path) — either way the mirror already points here.
|
|
1516
|
+
if (auth.kind === "pat" && credentialToWrite) {
|
|
1517
|
+
try {
|
|
1518
|
+
writeCredentials(vaultName, credentialToWrite);
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
return {
|
|
1521
|
+
sync_enabled: false,
|
|
1522
|
+
warning: `Import succeeded; mirror already targets this repo but the credential refresh failed: ${redactToken((err as Error).message ?? String(err))}`,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return await applyEnabledAutoPush(manager);
|
|
1527
|
+
}
|
|
1528
|
+
// Different remote — don't clobber the operator's existing backup target.
|
|
1529
|
+
return {
|
|
1530
|
+
sync_enabled: false,
|
|
1531
|
+
warning: `Sync not enabled — this vault already syncs to a different repo (${redactRemoteUrl(existingRemote)}). Leaving your existing backup target untouched. Change it from the Git remote section if you want to switch.`,
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// A mirror is already configured + enabled with an active credential, but we
|
|
1536
|
+
// couldn't read its remote to compare (github_oauth stores no owner/repo at
|
|
1537
|
+
// the credential level — its remote lives on the mirror dir's `origin`). To
|
|
1538
|
+
// avoid clobbering an existing GitHub-connected backup target by switching
|
|
1539
|
+
// `active_method` to a PAT, refuse unless the existing credential is OAuth
|
|
1540
|
+
// and this is the same GitHub host (then the manual select-repo flow already
|
|
1541
|
+
// points origin where it should; we treat that as "already set up — leave
|
|
1542
|
+
// it"). Conservative: don't auto-switch an existing connected mirror.
|
|
1543
|
+
if (
|
|
1544
|
+
mirrorAlreadyConfigured &&
|
|
1545
|
+
existing?.active_method === "github_oauth" &&
|
|
1546
|
+
existing.github_oauth
|
|
1547
|
+
) {
|
|
1548
|
+
return {
|
|
1549
|
+
sync_enabled: false,
|
|
1550
|
+
warning:
|
|
1551
|
+
"Sync not enabled — this vault already syncs via a connected GitHub account. Leaving your existing backup target untouched. Switch it from the Git remote section if you want to point sync at this repo instead.",
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// --- No conflicting mirror. Wire it up. ----------------------------------
|
|
1556
|
+
if (auth.kind === "credentialsFile") {
|
|
1557
|
+
// Reuse the stored credential — but only if it can push to this remote.
|
|
1558
|
+
const covers = existing && existingRemote && sameRemote(existingRemote, remoteUrl);
|
|
1559
|
+
const hasOauthForGithub =
|
|
1560
|
+
existing?.active_method === "github_oauth" &&
|
|
1561
|
+
!!existing.github_oauth &&
|
|
1562
|
+
isGithubRemote(remoteUrl);
|
|
1563
|
+
if (!covers && !hasOauthForGithub) {
|
|
1564
|
+
return {
|
|
1565
|
+
sync_enabled: false,
|
|
1566
|
+
warning:
|
|
1567
|
+
"Sync not enabled — no saved credential can push to this repo. Connect GitHub or paste a Personal Access Token in the Git remote section to enable Sync.",
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
if (existing?.active_method === "github_oauth" && hasOauthForGithub) {
|
|
1571
|
+
// OAuth path: persist origin via the github authed URL, same as the
|
|
1572
|
+
// select-repo flow. Parse owner/repo from the import remote.
|
|
1573
|
+
const ownerRepo = parseGithubOwnerRepo(remoteUrl);
|
|
1574
|
+
if (!ownerRepo) {
|
|
1575
|
+
return {
|
|
1576
|
+
sync_enabled: false,
|
|
1577
|
+
warning:
|
|
1578
|
+
"Sync not enabled — couldn't parse owner/repo from the GitHub URL. Pick the repo from the Git remote section to enable Sync.",
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
const status = manager.getStatus();
|
|
1582
|
+
const authedUrl = githubAuthedRemoteUrl(
|
|
1583
|
+
existing.github_oauth!.access_token,
|
|
1584
|
+
ownerRepo.owner,
|
|
1585
|
+
ownerRepo.repo,
|
|
1586
|
+
);
|
|
1587
|
+
if (status.mirror_path) {
|
|
1588
|
+
await applyToGitRemote(status.mirror_path, authedUrl);
|
|
1589
|
+
}
|
|
1590
|
+
// Stash a PAT-shaped credential so a restart re-applies the right
|
|
1591
|
+
// origin without needing the operator to re-pick the repo. (The OAuth
|
|
1592
|
+
// token works through the same x-access-token shape.) This mirrors how
|
|
1593
|
+
// applyCredentialsToRemote refreshes github origins on restart.
|
|
1594
|
+
credentialToWrite = {
|
|
1595
|
+
...existing,
|
|
1596
|
+
active_method: "github_oauth",
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
// covers === true → existing PAT already targets this remote; nothing to
|
|
1600
|
+
// re-persist. Fall through to enabling auto_push.
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (credentialToWrite) {
|
|
1604
|
+
try {
|
|
1605
|
+
writeCredentials(vaultName, credentialToWrite);
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
return {
|
|
1608
|
+
sync_enabled: false,
|
|
1609
|
+
warning: `Import succeeded, but saving the sync credential failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
return await applyEnabledAutoPush(manager);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Flip the mirror config to `enabled: true, auto_push: true` (internal
|
|
1619
|
+
* location) and reload the manager — which applies the just-persisted
|
|
1620
|
+
* credentials to `origin` and starts the event-driven export loop. Returns
|
|
1621
|
+
* the sync outcome; reload failures are non-fatal (import already succeeded).
|
|
1622
|
+
*/
|
|
1623
|
+
async function applyEnabledAutoPush(
|
|
1624
|
+
manager: MirrorManager,
|
|
1625
|
+
): Promise<{ sync_enabled: boolean; warning?: string }> {
|
|
1626
|
+
const current = manager.getEffectiveConfig();
|
|
1627
|
+
const next: MirrorConfig = {
|
|
1628
|
+
...current,
|
|
1629
|
+
enabled: true,
|
|
1630
|
+
// Keep the operator's existing location if they'd set external; default
|
|
1631
|
+
// internal for the fresh case. Internal + credentials is the supported
|
|
1632
|
+
// "push to a hosted remote" shape.
|
|
1633
|
+
location: current.location,
|
|
1634
|
+
auto_push: true,
|
|
1635
|
+
};
|
|
1636
|
+
try {
|
|
1637
|
+
await manager.reload(next);
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
return {
|
|
1640
|
+
sync_enabled: false,
|
|
1641
|
+
warning: `Import succeeded, but enabling Sync failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
// reload → start sets status.enabled iff bootstrap succeeded. If bootstrap
|
|
1645
|
+
// failed (e.g. git-less host, though import would've 503'd earlier), surface
|
|
1646
|
+
// that rather than claiming sync is on.
|
|
1647
|
+
const status = manager.getStatus();
|
|
1648
|
+
if (!status.enabled) {
|
|
1649
|
+
return {
|
|
1650
|
+
sync_enabled: false,
|
|
1651
|
+
warning: status.last_error
|
|
1652
|
+
? `Import succeeded, but the mirror couldn't start: ${redactToken(status.last_error)}`
|
|
1653
|
+
: "Import succeeded, but the mirror couldn't start. Check the Git remote section.",
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
return { sync_enabled: true };
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Embed an x-access-token credential into an HTTPS remote URL, the same shape
|
|
1661
|
+
* the manual PAT route persists. Returns null when the URL can't be parsed or
|
|
1662
|
+
* isn't http(s) (SSH / local-path remotes can't carry a token in userinfo —
|
|
1663
|
+
* those rely on the operator's ssh-agent, so there's no push credential for
|
|
1664
|
+
* us to embed). When the URL already carries userinfo, trust it verbatim.
|
|
1665
|
+
*/
|
|
1666
|
+
function embedTokenInRemoteUrl(remoteUrl: string, token: string): string | null {
|
|
1667
|
+
let parsed: URL;
|
|
1668
|
+
try {
|
|
1669
|
+
parsed = new URL(remoteUrl);
|
|
1670
|
+
} catch {
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
1674
|
+
if (parsed.username || parsed.password) return remoteUrl;
|
|
1675
|
+
parsed.username = "x-access-token";
|
|
1676
|
+
parsed.password = token;
|
|
1677
|
+
return parsed.toString();
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* The remote a stored credential effectively pushes to:
|
|
1682
|
+
* - pat → its `remote_url` (userinfo-embedded; comparison strips userinfo).
|
|
1683
|
+
* - github_oauth → null here (no owner/repo stored at the credential level;
|
|
1684
|
+
* the origin is set when a repo is picked). Callers treat null as "no
|
|
1685
|
+
* known remote to conflict with."
|
|
1686
|
+
*/
|
|
1687
|
+
function effectiveRemoteOf(creds: MirrorCredentials): string | null {
|
|
1688
|
+
if (creds.active_method === "pat" && creds.pat) return creds.pat.remote_url;
|
|
1689
|
+
return null;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Compare two remote URLs ignoring userinfo (embedded tokens) + a trailing
|
|
1694
|
+
* `.git` + a trailing slash, case-insensitively on host. "Same repo" for the
|
|
1695
|
+
* clobber check. Falls back to a trimmed string compare for non-URL remotes
|
|
1696
|
+
* (SSH shorthand, local paths).
|
|
1697
|
+
*/
|
|
1698
|
+
function sameRemote(a: string, b: string): boolean {
|
|
1699
|
+
const norm = (u: string): string => {
|
|
1700
|
+
try {
|
|
1701
|
+
const url = new URL(u);
|
|
1702
|
+
const host = url.host.toLowerCase();
|
|
1703
|
+
const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1704
|
+
return `${url.protocol}//${host}${path}`;
|
|
1705
|
+
} catch {
|
|
1706
|
+
return u.trim().replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
return norm(a) === norm(b);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/** True when a remote URL is on github.com (host-exact, case-insensitive). */
|
|
1713
|
+
function isGithubRemote(remoteUrl: string): boolean {
|
|
1714
|
+
try {
|
|
1715
|
+
return new URL(remoteUrl).host.toLowerCase() === "github.com";
|
|
1716
|
+
} catch {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Parse `owner` + `repo` out of a github.com HTTPS URL. Null when it doesn't match. */
|
|
1722
|
+
function parseGithubOwnerRepo(remoteUrl: string): { owner: string; repo: string } | null {
|
|
1723
|
+
const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?(?:\/)?$/i);
|
|
1724
|
+
if (!match) return null;
|
|
1725
|
+
return { owner: match[1]!, repo: match[2]! };
|
|
1726
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { handleAuthorizationServer, handleProtectedResource } from "./oauth-discovery.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression: the per-vault discovery documents MUST advertise resource-narrowed
|
|
6
|
+
* `vault:<name>:<verb>` scopes, never broad `vault:<verb>`. A broad scope makes a
|
|
7
|
+
* spec-following MCP client (Claude) request `vault:read`, which the hub stamps
|
|
8
|
+
* `aud=vault`, which the per-vault MCP endpoint rejects ("audience mismatch:
|
|
9
|
+
* expected vault.<name>, got vault") — the live "Authorization with the MCP
|
|
10
|
+
* server failed" bug. See oauth-discovery.ts + auth.ts findBroadVaultScopes.
|
|
11
|
+
*/
|
|
12
|
+
describe("oauth-discovery per-vault scopes_supported is resource-narrowed", () => {
|
|
13
|
+
const req = new Request("https://hub.example.com/vault/default/.well-known/oauth-protected-resource");
|
|
14
|
+
|
|
15
|
+
test("protected-resource metadata advertises vault:<name>:<verb>, never broad", async () => {
|
|
16
|
+
const body = (await handleProtectedResource(req, "default").json()) as {
|
|
17
|
+
resource: string;
|
|
18
|
+
scopes_supported: string[];
|
|
19
|
+
};
|
|
20
|
+
expect(body.scopes_supported).toEqual([
|
|
21
|
+
"vault:default:read",
|
|
22
|
+
"vault:default:write",
|
|
23
|
+
"vault:default:admin",
|
|
24
|
+
]);
|
|
25
|
+
// No broad scope leaks through — vault rejects those on audience mismatch.
|
|
26
|
+
for (const s of body.scopes_supported) {
|
|
27
|
+
expect(s).not.toBe("vault:read");
|
|
28
|
+
expect(s).not.toBe("vault:write");
|
|
29
|
+
expect(s).not.toBe("vault:admin");
|
|
30
|
+
}
|
|
31
|
+
expect(body.resource).toContain("/vault/default/mcp");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("scopes are narrowed to the specific vault name", async () => {
|
|
35
|
+
const body = (await handleProtectedResource(req, "work-notes").json()) as {
|
|
36
|
+
scopes_supported: string[];
|
|
37
|
+
};
|
|
38
|
+
expect(body.scopes_supported).toEqual([
|
|
39
|
+
"vault:work-notes:read",
|
|
40
|
+
"vault:work-notes:write",
|
|
41
|
+
"vault:work-notes:admin",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("authorization-server forwarding doc also advertises narrowed scopes", async () => {
|
|
46
|
+
const body = (await handleAuthorizationServer(req, "default").json()) as {
|
|
47
|
+
scopes_supported: string[];
|
|
48
|
+
};
|
|
49
|
+
expect(body.scopes_supported).toEqual([
|
|
50
|
+
"vault:default:read",
|
|
51
|
+
"vault:default:write",
|
|
52
|
+
"vault:default:admin",
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -28,8 +28,27 @@
|
|
|
28
28
|
|
|
29
29
|
import { getHubOrigin } from "./hub-jwt.ts";
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* OAuth scopes vault publishes through discovery, RESOURCE-NARROWED to the
|
|
33
|
+
* specific vault instance the metadata document describes.
|
|
34
|
+
*
|
|
35
|
+
* This MUST be narrowed (`vault:<name>:<verb>`), not broad (`vault:<verb>`).
|
|
36
|
+
* Post auth-unification (vault#282/#412), vault is a pure hub resource server
|
|
37
|
+
* that REJECTS broad `vault:<verb>` tokens (`findBroadVaultScopes` → 401) and
|
|
38
|
+
* requires `vault:<name>:<verb>` scopes carrying `aud=vault.<name>`. A
|
|
39
|
+
* spec-following MCP client (e.g. Claude) reads `scopes_supported` from this
|
|
40
|
+
* PRM and requests exactly those scopes; if we advertise broad `vault:read`
|
|
41
|
+
* the client gets a token stamped `aud=vault` and the per-vault MCP endpoint
|
|
42
|
+
* rejects it ("audience mismatch: expected vault.<name>, got vault") — the
|
|
43
|
+
* "Authorization with the MCP server failed" symptom. Advertising the narrowed
|
|
44
|
+
* shape makes the client request the scope vault will actually accept. The
|
|
45
|
+
* hub's RFC 8707 resource-binding narrows too, but only when the client echoes
|
|
46
|
+
* the `resource` param — advertising narrowed scopes here is the belt that
|
|
47
|
+
* works regardless. See scopes.ts for enforcement.
|
|
48
|
+
*/
|
|
49
|
+
function scopesSupportedFor(vaultName: string): string[] {
|
|
50
|
+
return [`vault:${vaultName}:read`, `vault:${vaultName}:write`, `vault:${vaultName}:admin`];
|
|
51
|
+
}
|
|
33
52
|
|
|
34
53
|
/**
|
|
35
54
|
* Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
|
|
@@ -63,7 +82,7 @@ export function handleProtectedResource(req: Request, vaultName: string): Respon
|
|
|
63
82
|
return Response.json({
|
|
64
83
|
resource: `${base}${prefix}/mcp`,
|
|
65
84
|
authorization_servers: [getHubOrigin()],
|
|
66
|
-
scopes_supported:
|
|
85
|
+
scopes_supported: scopesSupportedFor(vaultName),
|
|
67
86
|
bearer_methods_supported: ["header"],
|
|
68
87
|
});
|
|
69
88
|
}
|
|
@@ -78,7 +97,7 @@ export function handleProtectedResource(req: Request, vaultName: string): Respon
|
|
|
78
97
|
* land here and discover the hub's actual endpoints; conformant clients that
|
|
79
98
|
* probe AS metadata directly at the vault path get the same answer.
|
|
80
99
|
*/
|
|
81
|
-
export function handleAuthorizationServer(_req: Request,
|
|
100
|
+
export function handleAuthorizationServer(_req: Request, vaultName: string): Response {
|
|
82
101
|
const hub = getHubOrigin();
|
|
83
102
|
return Response.json({
|
|
84
103
|
issuer: hub,
|
|
@@ -90,6 +109,6 @@ export function handleAuthorizationServer(_req: Request, _vaultName: string): Re
|
|
|
90
109
|
code_challenge_methods_supported: ["S256"],
|
|
91
110
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
92
111
|
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
|
|
93
|
-
scopes_supported:
|
|
112
|
+
scopes_supported: scopesSupportedFor(vaultName),
|
|
94
113
|
});
|
|
95
114
|
}
|