@openparachute/vault 0.6.0 → 0.6.1
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/README.md +25 -0
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/links.ts +76 -21
- package/core/src/mcp.ts +53 -1
- package/core/src/notes.ts +128 -40
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +30 -1
- package/package.json +1 -1
- package/src/content-range-routes.test.ts +178 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-routes.test.ts +778 -19
- package/src/mirror-routes.ts +313 -26
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CGL256oe.js +0 -60
package/src/mirror-routes.ts
CHANGED
|
@@ -24,8 +24,11 @@
|
|
|
24
24
|
* hand + restarting the vault.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { existsSync } from "node:fs";
|
|
28
|
+
|
|
27
29
|
import {
|
|
28
30
|
defaultMirrorConfig,
|
|
31
|
+
mirrorConfigPath,
|
|
29
32
|
readMirrorConfigForVault,
|
|
30
33
|
validateExternalPath,
|
|
31
34
|
validateMirrorConfigShape,
|
|
@@ -46,11 +49,17 @@ import {
|
|
|
46
49
|
type MirrorCredentials,
|
|
47
50
|
} from "./mirror-credentials.ts";
|
|
48
51
|
import {
|
|
52
|
+
GITHUB_APP_SLUG_DEFAULT,
|
|
53
|
+
GITHUB_CLIENT_ID_DEFAULT,
|
|
54
|
+
GitHubApiError,
|
|
49
55
|
createRepo,
|
|
50
56
|
fetchUser,
|
|
57
|
+
getGithubAppSlug,
|
|
51
58
|
getGithubClientId,
|
|
59
|
+
installUrlForSlug,
|
|
52
60
|
isPlaceholderClientId,
|
|
53
|
-
|
|
61
|
+
listInstallationRepos,
|
|
62
|
+
listInstallations,
|
|
54
63
|
pollForToken,
|
|
55
64
|
requestDeviceCode,
|
|
56
65
|
type FetchLike,
|
|
@@ -298,9 +307,10 @@ export function buildMirrorGetResponse(
|
|
|
298
307
|
}
|
|
299
308
|
|
|
300
309
|
// ---------------------------------------------------------------------------
|
|
301
|
-
// Credential routes — Cut 3 of the UI-configurable push credentials work
|
|
310
|
+
// Credential routes — Cut 3 of the UI-configurable push credentials work,
|
|
311
|
+
// reshaped by the GitHub-App installation semantics (vault#480).
|
|
302
312
|
//
|
|
303
|
-
//
|
|
313
|
+
// Surfaces, all `vault:<name>:admin`-gated upstream:
|
|
304
314
|
//
|
|
305
315
|
// POST /.parachute/mirror/auth/github/device-code — start GitHub Device
|
|
306
316
|
// Flow; returns { polling_id, user_code, verification_uri, expires_in,
|
|
@@ -308,19 +318,29 @@ export function buildMirrorGetResponse(
|
|
|
308
318
|
// by polling_id (a short opaque token) so the device_code doesn't
|
|
309
319
|
// land on the wire twice.
|
|
310
320
|
// POST /.parachute/mirror/auth/github/poll — poll for token, body
|
|
311
|
-
// { polling_id }. On `granted`: fetch user, save credentials,
|
|
312
|
-
//
|
|
313
|
-
//
|
|
321
|
+
// { polling_id }. On `granted`: fetch user, save credentials, enable
|
|
322
|
+
// history for a never-configured vault (vault#483), return
|
|
323
|
+
// { state: "granted", user, history_enabled }. Other states surface
|
|
324
|
+
// verbatim.
|
|
314
325
|
// POST /.parachute/mirror/auth/pat — validate + store a
|
|
315
326
|
// PAT (token + remote_url + label). Validates via `git ls-remote`.
|
|
327
|
+
// Same history-on-link behavior as the poll grant (vault#483).
|
|
316
328
|
// GET /.parachute/mirror/auth — current connection
|
|
317
|
-
// status (NO secrets). Returns the sanitized public shape.
|
|
329
|
+
// status (NO secrets, NO network). Returns the sanitized public shape.
|
|
318
330
|
// DELETE /.parachute/mirror/auth — wipe credentials,
|
|
319
331
|
// unset embedded-credential remote URL.
|
|
320
|
-
// GET /.parachute/mirror/auth/github/
|
|
321
|
-
//
|
|
332
|
+
// GET /.parachute/mirror/auth/github/installations — install state
|
|
333
|
+
// (vault#480): which app, whether it's installed anywhere, the
|
|
334
|
+
// install link, and the per-account installations. The one
|
|
335
|
+
// explicitly-network status endpoint — `GET /auth` stays offline.
|
|
336
|
+
// GET /.parachute/mirror/auth/github/repos — list the repos the
|
|
337
|
+
// operator's app INSTALLATIONS grant (user + org accounts), via the
|
|
338
|
+
// stored OAuth token. Returns { installed: false, install_url,
|
|
339
|
+
// repos: [] } when the app isn't installed anywhere yet.
|
|
322
340
|
// POST /.parachute/mirror/auth/github/create-repo — create a new private
|
|
323
|
-
// repo on behalf of the operator.
|
|
341
|
+
// repo on behalf of the operator. 403s with the shared Contents-only
|
|
342
|
+
// app (mapped to error_type "app_lacks_admin_permission" + the
|
|
343
|
+
// guided-manual path); works for BYO apps with Administration:write.
|
|
324
344
|
//
|
|
325
345
|
// ---------------------------------------------------------------------------
|
|
326
346
|
|
|
@@ -363,16 +383,18 @@ export function _resetDeviceFlowSessionsForTest(): void {
|
|
|
363
383
|
}
|
|
364
384
|
|
|
365
385
|
/**
|
|
366
|
-
* Errors out cleanly when
|
|
367
|
-
*
|
|
386
|
+
* Errors out cleanly when PARACHUTE_GITHUB_CLIENT_ID is set to a
|
|
387
|
+
* placeholder-shaped value. A real default ships in the build (the shared
|
|
388
|
+
* Parachute GitHub App — see github-device-flow.ts), so this is only
|
|
389
|
+
* reachable via a misconfigured override.
|
|
368
390
|
*/
|
|
369
391
|
function placeholderClientIdResponse(): Response {
|
|
370
392
|
return Response.json(
|
|
371
393
|
{
|
|
372
|
-
error: "GitHub
|
|
394
|
+
error: "GitHub auth misconfigured",
|
|
373
395
|
error_type: "placeholder_client_id",
|
|
374
396
|
message:
|
|
375
|
-
"
|
|
397
|
+
"PARACHUTE_GITHUB_CLIENT_ID is set to a placeholder value. Unset it to use the built-in shared Parachute GitHub App, set it to your own app's client_id, or use the Personal Access Token path instead.",
|
|
376
398
|
},
|
|
377
399
|
{ status: 503 },
|
|
378
400
|
);
|
|
@@ -515,12 +537,12 @@ export async function handleAuthGithubPoll(
|
|
|
515
537
|
}
|
|
516
538
|
// Clean up the polling session.
|
|
517
539
|
deviceFlowSessions.delete(body.polling_id);
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
540
|
+
// vault#483: linking implies backup intent — enable history for a
|
|
541
|
+
// never-configured vault (consent-respecting; see the helper). No
|
|
542
|
+
// remote URL is set here — we don't have an owner/repo yet (the
|
|
543
|
+
// operator hasn't picked a repo); that wiring happens in select-repo,
|
|
544
|
+
// which is also where auto_push flips on (Cut 3).
|
|
545
|
+
const history_enabled = await maybeEnableHistoryOnLink(manager);
|
|
524
546
|
return Response.json(
|
|
525
547
|
{
|
|
526
548
|
state: "granted",
|
|
@@ -530,6 +552,7 @@ export async function handleAuthGithubPoll(
|
|
|
530
552
|
name: user.name,
|
|
531
553
|
avatar_url: user.avatar_url,
|
|
532
554
|
},
|
|
555
|
+
history_enabled,
|
|
533
556
|
},
|
|
534
557
|
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
535
558
|
);
|
|
@@ -563,6 +586,14 @@ export async function handleAuthGithubPoll(
|
|
|
563
586
|
export async function handleAuthPat(
|
|
564
587
|
req: Request,
|
|
565
588
|
manager: MirrorManager,
|
|
589
|
+
// Test seam for the `git ls-remote` validation probe (default
|
|
590
|
+
// `probeGitLsRemote`, which spawns real git against the supplied remote).
|
|
591
|
+
// Inject a fn returning `{ok: true}` to exercise the post-validation save
|
|
592
|
+
// path (credential persist + history-on-link, vault#483) hermetically.
|
|
593
|
+
probeOverride?: (
|
|
594
|
+
url: string,
|
|
595
|
+
timeoutMs: number,
|
|
596
|
+
) => Promise<{ ok: boolean; error?: string }>,
|
|
566
597
|
): Promise<Response> {
|
|
567
598
|
let body: { token?: unknown; remote_url?: unknown; label?: unknown };
|
|
568
599
|
try {
|
|
@@ -657,7 +688,7 @@ export async function handleAuthPat(
|
|
|
657
688
|
u.password = token;
|
|
658
689
|
return u.toString();
|
|
659
690
|
})();
|
|
660
|
-
const probeResult = await probeGitLsRemote(authedUrl, 10_000);
|
|
691
|
+
const probeResult = await (probeOverride ?? probeGitLsRemote)(authedUrl, 10_000);
|
|
661
692
|
if (!probeResult.ok) {
|
|
662
693
|
return Response.json(
|
|
663
694
|
{
|
|
@@ -694,6 +725,15 @@ export async function handleAuthPat(
|
|
|
694
725
|
);
|
|
695
726
|
}
|
|
696
727
|
|
|
728
|
+
// vault#483: linking implies backup intent — enable history for a
|
|
729
|
+
// never-configured vault BEFORE the Cut-3/Cut-6 steps below, so a fresh
|
|
730
|
+
// vault gets the whole intended flow in one save: history on (the reload's
|
|
731
|
+
// start() applies the just-written PAT remote to `origin`), then Cut 3
|
|
732
|
+
// sees an enabled mirror and flips auto_push on, then Cut 6 fires the
|
|
733
|
+
// initial push. An explicitly-disabled mirror short-circuits all of it
|
|
734
|
+
// (history stays off → maybeEnableAutoPush no-ops on disabled).
|
|
735
|
+
const history_enabled = await maybeEnableHistoryOnLink(manager);
|
|
736
|
+
|
|
697
737
|
// Push the new URL onto the mirror's git remote if it's currently
|
|
698
738
|
// resolved + on disk. Non-fatal if the mirror isn't running.
|
|
699
739
|
await applyCredentialsToMirror(manager);
|
|
@@ -713,6 +753,7 @@ export async function handleAuthPat(
|
|
|
713
753
|
return Response.json(
|
|
714
754
|
{
|
|
715
755
|
...sanitizeCredentials(next),
|
|
756
|
+
history_enabled,
|
|
716
757
|
auto_push_was_already_enabled: autoPushChange.was_already_enabled,
|
|
717
758
|
auto_push_enabled: autoPushChange.auto_push_now_enabled,
|
|
718
759
|
initial_push: initialPush,
|
|
@@ -811,8 +852,102 @@ export async function handleAuthDelete(manager: MirrorManager): Promise<Response
|
|
|
811
852
|
}
|
|
812
853
|
|
|
813
854
|
/**
|
|
814
|
-
* `GET /.parachute/mirror/auth/github/
|
|
815
|
-
* the
|
|
855
|
+
* `GET /.parachute/mirror/auth/github/installations` — install state for
|
|
856
|
+
* the connect flow (vault#480). Answers three UI questions in one call:
|
|
857
|
+
* which app is in play (shared default vs BYO), is it installed ANYWHERE
|
|
858
|
+
* for this operator, and which accounts (user/orgs) carry installations.
|
|
859
|
+
*
|
|
860
|
+
* Deliberately a separate, explicitly-network endpoint: `GET /auth` stays
|
|
861
|
+
* a pure local read of the stored credential. The SPA calls this when it
|
|
862
|
+
* renders the connect flow / repo picker, not on every status poll.
|
|
863
|
+
*
|
|
864
|
+
* Response:
|
|
865
|
+
* 200 {
|
|
866
|
+
* app: { client_id, slug, is_shared_default },
|
|
867
|
+
* installed: boolean, // installations.length > 0
|
|
868
|
+
* install_url: string, // github.com/apps/<slug>/installations/new
|
|
869
|
+
* installations: [{ id, account_login, account_type, repository_selection }]
|
|
870
|
+
* }
|
|
871
|
+
* 400 { error, error_type: "github_not_connected", message } — no stored
|
|
872
|
+
* github_oauth credential; the device flow hasn't been run (or a PAT
|
|
873
|
+
* is active instead — install state is a GitHub-App concept). 400,
|
|
874
|
+
* not 401, matching the sibling repos handler: a 401 here would trip
|
|
875
|
+
* the SPA's authedFetch token-refresh machinery and clear a
|
|
876
|
+
* perfectly valid cached admin token over a non-auth condition.
|
|
877
|
+
* 502 { error, message } — GitHub unreachable / API error.
|
|
878
|
+
*/
|
|
879
|
+
export async function handleAuthGithubInstallations(
|
|
880
|
+
manager: MirrorManager,
|
|
881
|
+
fetchImpl?: FetchLike,
|
|
882
|
+
): Promise<Response> {
|
|
883
|
+
const creds = readCredentials(manager.getVaultName());
|
|
884
|
+
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
885
|
+
return Response.json(
|
|
886
|
+
{
|
|
887
|
+
error: "Not connected to GitHub",
|
|
888
|
+
error_type: "github_not_connected",
|
|
889
|
+
message:
|
|
890
|
+
"No GitHub sign-in is stored for this vault. Run the device flow first (POST /.parachute/mirror/auth/github/device-code).",
|
|
891
|
+
},
|
|
892
|
+
{ status: 400 },
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
const clientId = getGithubClientId();
|
|
896
|
+
const slug = getGithubAppSlug();
|
|
897
|
+
let installations;
|
|
898
|
+
try {
|
|
899
|
+
installations = await listInstallations(creds.github_oauth.access_token, fetchImpl);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
return Response.json(
|
|
902
|
+
{
|
|
903
|
+
error: "Installation check failed",
|
|
904
|
+
message: (err as Error).message ?? String(err),
|
|
905
|
+
},
|
|
906
|
+
{ status: 502 },
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
return Response.json(
|
|
910
|
+
{
|
|
911
|
+
app: {
|
|
912
|
+
client_id: clientId,
|
|
913
|
+
slug,
|
|
914
|
+
is_shared_default:
|
|
915
|
+
clientId === GITHUB_CLIENT_ID_DEFAULT && slug === GITHUB_APP_SLUG_DEFAULT,
|
|
916
|
+
},
|
|
917
|
+
installed: installations.length > 0,
|
|
918
|
+
install_url: installUrlForSlug(slug),
|
|
919
|
+
installations: installations.map((i) => ({
|
|
920
|
+
id: i.id,
|
|
921
|
+
account_login: i.account.login,
|
|
922
|
+
account_type: i.account.type,
|
|
923
|
+
repository_selection: i.repository_selection,
|
|
924
|
+
})),
|
|
925
|
+
},
|
|
926
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* `GET /.parachute/mirror/auth/github/repos` — list the repos the
|
|
932
|
+
* operator's app installations grant, via the stored OAuth token. Requires
|
|
933
|
+
* `active_method === "github_oauth"`.
|
|
934
|
+
*
|
|
935
|
+
* Source (vault#480): `GET /user/installations` → per-installation
|
|
936
|
+
* `GET /user/installations/{id}/repositories`, unioned. This replaces the
|
|
937
|
+
* old `GET /user/repos?type=owner` source, which (a) excluded org-owned
|
|
938
|
+
* repos by construction and (b) showed all-public-repos when the app wasn't
|
|
939
|
+
* installed at all (every GitHub App reads public repos) — Aaron walked
|
|
940
|
+
* into exactly that "looks connected, shows the wrong repos" state live.
|
|
941
|
+
*
|
|
942
|
+
* Response:
|
|
943
|
+
* 200 installed: { installed: true, repos: [{ ...GitHubRepoInfo,
|
|
944
|
+
* account_login, installation_id }], truncated }
|
|
945
|
+
* 200 not installed: { installed: false, install_url, repos: [],
|
|
946
|
+
* truncated: false } — machine-readable
|
|
947
|
+
* authorized-but-not-installed state; the UI shows
|
|
948
|
+
* the guided-install step, no string-matching needed.
|
|
949
|
+
* 400 { error, message } — no github_oauth credential stored.
|
|
950
|
+
* 502 { error, message } — GitHub unreachable / API error.
|
|
816
951
|
*/
|
|
817
952
|
export async function handleAuthGithubRepos(
|
|
818
953
|
manager: MirrorManager,
|
|
@@ -828,9 +963,58 @@ export async function handleAuthGithubRepos(
|
|
|
828
963
|
{ status: 400 },
|
|
829
964
|
);
|
|
830
965
|
}
|
|
831
|
-
|
|
966
|
+
const token = creds.github_oauth.access_token;
|
|
967
|
+
let installations;
|
|
968
|
+
try {
|
|
969
|
+
installations = await listInstallations(token, fetchImpl);
|
|
970
|
+
} catch (err) {
|
|
971
|
+
return Response.json(
|
|
972
|
+
{
|
|
973
|
+
error: "Repo list failed",
|
|
974
|
+
message: (err as Error).message ?? String(err),
|
|
975
|
+
},
|
|
976
|
+
{ status: 502 },
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (installations.length === 0) {
|
|
981
|
+
// Authorized but not installed — the device-flow grant alone reaches no
|
|
982
|
+
// repos. Distinct, machine-readable state (NOT an empty repo list that
|
|
983
|
+
// looks like "you have no repos").
|
|
984
|
+
return Response.json(
|
|
985
|
+
{
|
|
986
|
+
installed: false,
|
|
987
|
+
install_url: installUrlForSlug(getGithubAppSlug()),
|
|
988
|
+
repos: [],
|
|
989
|
+
truncated: false,
|
|
990
|
+
},
|
|
991
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const repos: Array<GitHubRepoInfo & { account_login: string; installation_id: number }> = [];
|
|
996
|
+
let truncated = false;
|
|
832
997
|
try {
|
|
833
|
-
|
|
998
|
+
for (const installation of installations) {
|
|
999
|
+
const result = await listInstallationRepos(token, installation.id, {}, fetchImpl);
|
|
1000
|
+
// `GET /user/installations/{id}/repositories` takes no `sort` param
|
|
1001
|
+
// (unlike the old `GET /user/repos?sort=updated` picker source), so
|
|
1002
|
+
// order within each account group ourselves: most-recently-updated
|
|
1003
|
+
// first, so the repo the operator probably wants sits near the top.
|
|
1004
|
+
// ISO-8601 timestamps compare lexicographically. Per-group (not
|
|
1005
|
+
// across the union) so the SPA's account grouping stays contiguous.
|
|
1006
|
+
const sorted = [...result.repos].sort((a, b) =>
|
|
1007
|
+
b.updated_at.localeCompare(a.updated_at),
|
|
1008
|
+
);
|
|
1009
|
+
for (const repo of sorted) {
|
|
1010
|
+
repos.push({
|
|
1011
|
+
...repo,
|
|
1012
|
+
account_login: installation.account.login,
|
|
1013
|
+
installation_id: installation.id,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
if (result.truncated) truncated = true;
|
|
1017
|
+
}
|
|
834
1018
|
} catch (err) {
|
|
835
1019
|
return Response.json(
|
|
836
1020
|
{
|
|
@@ -841,7 +1025,7 @@ export async function handleAuthGithubRepos(
|
|
|
841
1025
|
);
|
|
842
1026
|
}
|
|
843
1027
|
return Response.json(
|
|
844
|
-
{
|
|
1028
|
+
{ installed: true, repos, truncated },
|
|
845
1029
|
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
846
1030
|
);
|
|
847
1031
|
}
|
|
@@ -850,6 +1034,15 @@ export async function handleAuthGithubRepos(
|
|
|
850
1034
|
* `POST /.parachute/mirror/auth/github/create-repo` — create a new repo on
|
|
851
1035
|
* the operator's account, return the new RepoInfo. The SPA flows straight
|
|
852
1036
|
* from this into the "repo selected" state.
|
|
1037
|
+
*
|
|
1038
|
+
* With the shared Parachute app this 403s by design (vault#480):
|
|
1039
|
+
* `POST /user/repos` needs Administration:write; the shared app is frozen
|
|
1040
|
+
* at Contents-only. The 403 maps to error_type "app_lacks_admin_permission"
|
|
1041
|
+
* with the guided-manual path (create at github.com/new → add it to the
|
|
1042
|
+
* installation → refresh the picker). The endpoint stays functional for
|
|
1043
|
+
* BYO-app operators whose app grants Administration:write — and per the
|
|
1044
|
+
* install docs, app-created repos auto-join the installation, so their
|
|
1045
|
+
* create→push flow works end-to-end.
|
|
853
1046
|
*/
|
|
854
1047
|
export async function handleAuthGithubCreateRepo(
|
|
855
1048
|
req: Request,
|
|
@@ -892,6 +1085,20 @@ export async function handleAuthGithubCreateRepo(
|
|
|
892
1085
|
fetchImpl,
|
|
893
1086
|
);
|
|
894
1087
|
} catch (err) {
|
|
1088
|
+
if (err instanceof GitHubApiError && err.status === 403) {
|
|
1089
|
+
// Expected with the shared Contents-only app: POST /user/repos needs
|
|
1090
|
+
// Administration:write. Actionable, machine-readable mapping — the UI
|
|
1091
|
+
// renders the guided-manual checklist off error_type, not a string.
|
|
1092
|
+
return Response.json(
|
|
1093
|
+
{
|
|
1094
|
+
error: "Create not permitted for this GitHub App",
|
|
1095
|
+
error_type: "app_lacks_admin_permission",
|
|
1096
|
+
message:
|
|
1097
|
+
"The Parachute GitHub App can't create repositories (it only has Contents permission — creating repos needs Administration). Create it manually instead: 1) create a private, uninitialized repo at github.com/new, 2) add it to the app installation (GitHub → Settings → Applications → Configure), 3) refresh the repo list here and pick it.",
|
|
1098
|
+
},
|
|
1099
|
+
{ status: 403 },
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
895
1102
|
return Response.json(
|
|
896
1103
|
{ error: "Create failed", message: (err as Error).message ?? String(err) },
|
|
897
1104
|
{ status: 502 },
|
|
@@ -947,6 +1154,18 @@ export async function handleAuthGithubSelectRepo(
|
|
|
947
1154
|
name,
|
|
948
1155
|
);
|
|
949
1156
|
|
|
1157
|
+
// vault#483: linking implies backup intent — the choose-repo re-entry can
|
|
1158
|
+
// be the FIRST credential-linked action for this vault (a credential saved
|
|
1159
|
+
// before history-on-link existed, on a never-configured vault: the #483
|
|
1160
|
+
// "linked but silently inert" state). Run history-on-link here too, BEFORE
|
|
1161
|
+
// the Cut-3/Cut-6 steps below and mirroring handleAuthPat's ordering:
|
|
1162
|
+
// history on (the reload's start() resolves mirror_path so the remote
|
|
1163
|
+
// write below actually lands), then Cut 3 sees an enabled mirror and flips
|
|
1164
|
+
// auto_push, then Cut 6 fires the initial push. An explicitly-disabled
|
|
1165
|
+
// mirror short-circuits all of it (history stays off → maybeEnableAutoPush
|
|
1166
|
+
// no-ops on disabled).
|
|
1167
|
+
const history_enabled = await maybeEnableHistoryOnLink(manager);
|
|
1168
|
+
|
|
950
1169
|
// Apply to the mirror dir if running. If the mirror isn't running (no
|
|
951
1170
|
// mirror_path), we still consider this a success — the credentials are
|
|
952
1171
|
// stored, and the URL will get applied next time the mirror starts.
|
|
@@ -980,6 +1199,7 @@ export async function handleAuthGithubSelectRepo(
|
|
|
980
1199
|
// Echo the redacted form back so the SPA can show "pushing to <repo>".
|
|
981
1200
|
// No raw token in the response.
|
|
982
1201
|
remote: `https://github.com/${owner}/${name}.git`,
|
|
1202
|
+
history_enabled,
|
|
983
1203
|
auto_push_was_already_enabled: autoPushChange.was_already_enabled,
|
|
984
1204
|
auto_push_enabled: autoPushChange.auto_push_now_enabled,
|
|
985
1205
|
initial_push: initialPush,
|
|
@@ -988,6 +1208,73 @@ export async function handleAuthGithubSelectRepo(
|
|
|
988
1208
|
);
|
|
989
1209
|
}
|
|
990
1210
|
|
|
1211
|
+
/**
|
|
1212
|
+
* vault#483 fix 1 — linking implies backup intent. Outcome flag for the
|
|
1213
|
+
* credential-save responses:
|
|
1214
|
+
* - `true` — history (the mirror) is on: either this link just enabled it
|
|
1215
|
+
* (never-configured vault) or it was already enabled.
|
|
1216
|
+
* - `"left_disabled"` — a mirror config exists with `enabled: false`; we
|
|
1217
|
+
* refuse to silently flip an explicit operator choice. The UI should
|
|
1218
|
+
* offer the one-click "Turn on history now?" enable.
|
|
1219
|
+
* - `false` — we tried to enable history but the mirror didn't come up
|
|
1220
|
+
* (e.g. git missing). Config intent is persisted (PUT semantics); the
|
|
1221
|
+
* mirror status carries the actionable error.
|
|
1222
|
+
*/
|
|
1223
|
+
export type HistoryOnLink = true | false | "left_disabled";
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* When a GitHub credential lands (device-flow grant or PAT save), turn
|
|
1227
|
+
* history on for a vault that has NEVER had a mirror configured — nobody
|
|
1228
|
+
* links GitHub to a vault for any reason other than backing it up. Aaron hit
|
|
1229
|
+
* this live (vault#483): pre-#440 vaults have no mirror-config file, so
|
|
1230
|
+
* linking stored a credential and then... nothing, with the only path out
|
|
1231
|
+
* buried in advanced settings.
|
|
1232
|
+
*
|
|
1233
|
+
* Consent-respecting by design:
|
|
1234
|
+
* - **No config file** (never configured) → enable the internal mirror
|
|
1235
|
+
* with the standard defaults (enabled, internal, events, auto_commit on,
|
|
1236
|
+
* auto_push off — the same shape #440 gives new vaults). Writes through
|
|
1237
|
+
* `manager.reload()` — the exact path the PUT handler uses — so persist +
|
|
1238
|
+
* lifecycle-start stay one code path.
|
|
1239
|
+
* - **Config file exists with enabled:false** → explicit operator choice;
|
|
1240
|
+
* do NOT flip it. Return `"left_disabled"` so the UI can offer one-click
|
|
1241
|
+
* enable instead.
|
|
1242
|
+
* - **Config file exists with enabled:true** → nothing to do.
|
|
1243
|
+
*
|
|
1244
|
+
* The file-existence check (not just the parsed read) is the load-bearing
|
|
1245
|
+
* distinction: an existing-but-malformed file parses to `undefined`, same as
|
|
1246
|
+
* absent — but it still represents operator-touched state, so we treat it as
|
|
1247
|
+
* "exists, not known-enabled" and leave it alone.
|
|
1248
|
+
*/
|
|
1249
|
+
async function maybeEnableHistoryOnLink(
|
|
1250
|
+
manager: MirrorManager,
|
|
1251
|
+
): Promise<HistoryOnLink> {
|
|
1252
|
+
const vaultName = manager.getVaultName();
|
|
1253
|
+
if (!existsSync(mirrorConfigPath(vaultName))) {
|
|
1254
|
+
try {
|
|
1255
|
+
const status = await manager.reload({
|
|
1256
|
+
...defaultMirrorConfig(),
|
|
1257
|
+
enabled: true,
|
|
1258
|
+
});
|
|
1259
|
+
if (!status.enabled) {
|
|
1260
|
+
console.warn(
|
|
1261
|
+
`[mirror-auth] history-on-link: enabled the mirror config for vault "${vaultName}" but it didn't start: ${status.last_error ?? "unknown error"}`,
|
|
1262
|
+
);
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
return true;
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
console.warn(
|
|
1268
|
+
`[mirror-auth] history-on-link: could not enable the mirror for vault "${vaultName}" (non-fatal): ${(err as Error).message ?? err}`,
|
|
1269
|
+
);
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const persisted = readMirrorConfigForVault(vaultName);
|
|
1274
|
+
if (persisted?.enabled) return true;
|
|
1275
|
+
return "left_disabled";
|
|
1276
|
+
}
|
|
1277
|
+
|
|
991
1278
|
/**
|
|
992
1279
|
* Cut 3: when credentials save, flip `auto_push` from false → true on
|
|
993
1280
|
* the persisted config. Operators wiring credentials almost certainly
|
package/src/routes.ts
CHANGED
|
@@ -15,6 +15,12 @@ import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
|
15
15
|
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
17
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
|
+
import {
|
|
19
|
+
parseContentRange,
|
|
20
|
+
applyContentRange,
|
|
21
|
+
contentRangeRequiresContent,
|
|
22
|
+
type ContentRange,
|
|
23
|
+
} from "../core/src/content-range.ts";
|
|
18
24
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
19
25
|
import * as linkOps from "../core/src/links.ts";
|
|
20
26
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
@@ -103,6 +109,37 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
|
|
|
103
109
|
return val ? val.split(",") : undefined;
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Parse `?content_offset=` / `?content_length=` (content range — bounded
|
|
114
|
+
* reads for large notes; unit is UTF-8 bytes, see core/src/content-range.ts
|
|
115
|
+
* for the codepoint-boundary slicing rules). Returns `{ range: null }` when
|
|
116
|
+
* neither param is present (response byte-identical to the no-pagination
|
|
117
|
+
* shape), or `{ error }` (400 INVALID_QUERY) on invalid values or when the
|
|
118
|
+
* response shape excludes content — range params on a content-less shape
|
|
119
|
+
* error loudly rather than silently no-op, same policy as `?expand=`.
|
|
120
|
+
*/
|
|
121
|
+
function parseContentRangeQuery(
|
|
122
|
+
url: URL,
|
|
123
|
+
includeContent: boolean,
|
|
124
|
+
): { range: ContentRange | null; error?: Response } {
|
|
125
|
+
try {
|
|
126
|
+
const range = parseContentRange(
|
|
127
|
+
parseQuery(url, "content_offset") ?? undefined,
|
|
128
|
+
parseQuery(url, "content_length") ?? undefined,
|
|
129
|
+
);
|
|
130
|
+
if (range && !includeContent) throw contentRangeRequiresContent();
|
|
131
|
+
return { range };
|
|
132
|
+
} catch (e: any) {
|
|
133
|
+
// Duck-type on `name` — core is a separate module, so `instanceof`
|
|
134
|
+
// is fragile across bundling boundaries (same note as the QueryError
|
|
135
|
+
// handling in the structured-query path below).
|
|
136
|
+
if (e && e.name === "QueryError") {
|
|
137
|
+
return { range: null, error: json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400) };
|
|
138
|
+
}
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
/**
|
|
107
144
|
* Parse the extension query parameter (vault#328). Two accepted shapes:
|
|
108
145
|
* - `?extension=csv` (single value → string)
|
|
@@ -706,12 +743,18 @@ async function handleNotesInner(
|
|
|
706
743
|
return json({ error: "Note not found", id }, 404);
|
|
707
744
|
}
|
|
708
745
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
746
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
747
|
+
if (contentRange.error) return contentRange.error;
|
|
709
748
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
710
749
|
const expand = parseExpandParams(url, db, tagScope);
|
|
711
750
|
if (expand && includeContent && typeof result.content === "string") {
|
|
712
751
|
expand.ctx.expanded.add(note.id);
|
|
713
752
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
714
753
|
}
|
|
754
|
+
// Content range applies to the FINAL returned content (post-
|
|
755
|
+
// expansion) — the window the client pages through is the same
|
|
756
|
+
// document it would have received unpaged.
|
|
757
|
+
if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
|
|
715
758
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
716
759
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
717
760
|
// Tag-scope: drop links whose neighbor is out of scope so the
|
|
@@ -769,6 +812,8 @@ async function handleNotesInner(
|
|
|
769
812
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
770
813
|
const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
|
|
771
814
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
815
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
816
|
+
if (contentRange.error) return contentRange.error;
|
|
772
817
|
const inclMeta = parseIncludeMetadata(url);
|
|
773
818
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
774
819
|
const expand = parseExpandParams(url, db, tagScope);
|
|
@@ -780,6 +825,10 @@ async function handleNotesInner(
|
|
|
780
825
|
}
|
|
781
826
|
}
|
|
782
827
|
}
|
|
828
|
+
// Content range — per-note, post-expansion (see core/src/content-range.ts).
|
|
829
|
+
if (contentRange.range && includeContent) {
|
|
830
|
+
for (const n of output) applyContentRange(n, contentRange.range);
|
|
831
|
+
}
|
|
783
832
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
784
833
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
785
834
|
}
|
|
@@ -910,6 +959,8 @@ async function handleNotesInner(
|
|
|
910
959
|
results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
|
|
911
960
|
|
|
912
961
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
962
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
963
|
+
if (contentRange.error) return contentRange.error;
|
|
913
964
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
914
965
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
915
966
|
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
@@ -924,6 +975,10 @@ async function handleNotesInner(
|
|
|
924
975
|
}
|
|
925
976
|
}
|
|
926
977
|
}
|
|
978
|
+
// Content range — per-note, post-expansion (see core/src/content-range.ts).
|
|
979
|
+
if (contentRange.range && includeContent) {
|
|
980
|
+
for (const n of output) applyContentRange(n, contentRange.range);
|
|
981
|
+
}
|
|
927
982
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
928
983
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
929
984
|
}
|
|
@@ -952,8 +1007,9 @@ async function handleNotesInner(
|
|
|
952
1007
|
const nodes = output.map((n: any) => ({ id: n.id, path: n.path ?? null, tags: n.tags ?? [] }));
|
|
953
1008
|
const edges: { source: string; target: string; relationship: string }[] = [];
|
|
954
1009
|
if (includeLinks) {
|
|
1010
|
+
const linksByNote = linkOps.getLinksHydratedForNotes(db, results.map((n) => n.id));
|
|
955
1011
|
for (const n of results) {
|
|
956
|
-
for (const link of
|
|
1012
|
+
for (const link of linksByNote.get(n.id) ?? []) {
|
|
957
1013
|
// Only include edges where source is this note and target is in the result set
|
|
958
1014
|
if (link.sourceId === n.id && resultIds.has(link.targetId)) {
|
|
959
1015
|
edges.push({ source: link.sourceId, target: link.targetId, relationship: link.relationship });
|
|
@@ -965,13 +1021,19 @@ async function handleNotesInner(
|
|
|
965
1021
|
}
|
|
966
1022
|
|
|
967
1023
|
if (includeLinks || includeAttachments) {
|
|
1024
|
+
// Whole-page link hydration in a constant number of queries — the
|
|
1025
|
+
// per-note variant cost (1 link query + 1 summary query + N tag
|
|
1026
|
+
// queries) × page size. 2026-06-10 perf measurements.
|
|
1027
|
+
const linksByNote = includeLinks
|
|
1028
|
+
? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
|
|
1029
|
+
: null;
|
|
968
1030
|
const enrichedOut: any[] = [];
|
|
969
1031
|
for (const n of output) {
|
|
970
1032
|
const enriched: any = { ...n };
|
|
971
|
-
if (
|
|
1033
|
+
if (linksByNote) {
|
|
972
1034
|
// Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
|
|
973
1035
|
enriched.links = filterHydratedLinksByTagScope(
|
|
974
|
-
|
|
1036
|
+
linksByNote.get(n.id) ?? [],
|
|
975
1037
|
tagScope.allowed,
|
|
976
1038
|
tagScope.raw,
|
|
977
1039
|
);
|
|
@@ -1252,12 +1314,16 @@ async function handleNotesInner(
|
|
|
1252
1314
|
return json({ error: "Not found" }, 404);
|
|
1253
1315
|
}
|
|
1254
1316
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
1317
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1318
|
+
if (contentRange.error) return contentRange.error;
|
|
1255
1319
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
1256
1320
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1257
1321
|
if (expand && includeContent && typeof result.content === "string") {
|
|
1258
1322
|
expand.ctx.expanded.add(note.id);
|
|
1259
1323
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
1260
1324
|
}
|
|
1325
|
+
// Content range applies to the FINAL returned content (post-expansion).
|
|
1326
|
+
if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
|
|
1261
1327
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
1262
1328
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
1263
1329
|
// Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
|
package/src/routing.ts
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
handleAuthGet,
|
|
88
88
|
handleAuthGithubCreateRepo,
|
|
89
89
|
handleAuthGithubDeviceCode,
|
|
90
|
+
handleAuthGithubInstallations,
|
|
90
91
|
handleAuthGithubPoll,
|
|
91
92
|
handleAuthGithubRepos,
|
|
92
93
|
handleAuthGithubSelectRepo,
|
|
@@ -735,6 +736,13 @@ export async function route(
|
|
|
735
736
|
if (req.method === "POST") return handleAuthGithubPoll(req, manager);
|
|
736
737
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
737
738
|
}
|
|
739
|
+
if (subpath === "/.parachute/mirror/auth/github/installations") {
|
|
740
|
+
// Install state (vault#480) — which app, installed-anywhere?, install
|
|
741
|
+
// link, per-account installations. Explicitly-network (probes GitHub);
|
|
742
|
+
// the offline status read stays on GET /.parachute/mirror/auth.
|
|
743
|
+
if (req.method === "GET") return handleAuthGithubInstallations(manager);
|
|
744
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
745
|
+
}
|
|
738
746
|
if (subpath === "/.parachute/mirror/auth/github/repos") {
|
|
739
747
|
if (req.method === "GET") return handleAuthGithubRepos(manager);
|
|
740
748
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|