@openparachute/vault 0.6.0 → 0.6.2-rc.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 +31 -6
- 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/cli.ts +90 -25
- 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/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +1118 -46
- package/src/mirror-routes.ts +405 -32
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/src/vault.test.ts +56 -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,
|
|
@@ -66,6 +75,10 @@ import {
|
|
|
66
75
|
type ImportResult,
|
|
67
76
|
} from "./mirror-import.ts";
|
|
68
77
|
import { redactToken } from "./export-watch.ts";
|
|
78
|
+
import {
|
|
79
|
+
findConflictingVault,
|
|
80
|
+
remoteConflictMessage,
|
|
81
|
+
} from "./mirror-remote-guard.ts";
|
|
69
82
|
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
70
83
|
import { getVaultStore } from "./vault-store.ts";
|
|
71
84
|
import { assetsDir } from "./routes.ts";
|
|
@@ -298,9 +311,10 @@ export function buildMirrorGetResponse(
|
|
|
298
311
|
}
|
|
299
312
|
|
|
300
313
|
// ---------------------------------------------------------------------------
|
|
301
|
-
// Credential routes — Cut 3 of the UI-configurable push credentials work
|
|
314
|
+
// Credential routes — Cut 3 of the UI-configurable push credentials work,
|
|
315
|
+
// reshaped by the GitHub-App installation semantics (vault#480).
|
|
302
316
|
//
|
|
303
|
-
//
|
|
317
|
+
// Surfaces, all `vault:<name>:admin`-gated upstream:
|
|
304
318
|
//
|
|
305
319
|
// POST /.parachute/mirror/auth/github/device-code — start GitHub Device
|
|
306
320
|
// Flow; returns { polling_id, user_code, verification_uri, expires_in,
|
|
@@ -308,19 +322,29 @@ export function buildMirrorGetResponse(
|
|
|
308
322
|
// by polling_id (a short opaque token) so the device_code doesn't
|
|
309
323
|
// land on the wire twice.
|
|
310
324
|
// POST /.parachute/mirror/auth/github/poll — poll for token, body
|
|
311
|
-
// { polling_id }. On `granted`: fetch user, save credentials,
|
|
312
|
-
//
|
|
313
|
-
//
|
|
325
|
+
// { polling_id }. On `granted`: fetch user, save credentials, enable
|
|
326
|
+
// history for a never-configured vault (vault#483), return
|
|
327
|
+
// { state: "granted", user, history_enabled }. Other states surface
|
|
328
|
+
// verbatim.
|
|
314
329
|
// POST /.parachute/mirror/auth/pat — validate + store a
|
|
315
330
|
// PAT (token + remote_url + label). Validates via `git ls-remote`.
|
|
331
|
+
// Same history-on-link behavior as the poll grant (vault#483).
|
|
316
332
|
// GET /.parachute/mirror/auth — current connection
|
|
317
|
-
// status (NO secrets). Returns the sanitized public shape.
|
|
333
|
+
// status (NO secrets, NO network). Returns the sanitized public shape.
|
|
318
334
|
// DELETE /.parachute/mirror/auth — wipe credentials,
|
|
319
335
|
// unset embedded-credential remote URL.
|
|
320
|
-
// GET /.parachute/mirror/auth/github/
|
|
321
|
-
//
|
|
336
|
+
// GET /.parachute/mirror/auth/github/installations — install state
|
|
337
|
+
// (vault#480): which app, whether it's installed anywhere, the
|
|
338
|
+
// install link, and the per-account installations. The one
|
|
339
|
+
// explicitly-network status endpoint — `GET /auth` stays offline.
|
|
340
|
+
// GET /.parachute/mirror/auth/github/repos — list the repos the
|
|
341
|
+
// operator's app INSTALLATIONS grant (user + org accounts), via the
|
|
342
|
+
// stored OAuth token. Returns { installed: false, install_url,
|
|
343
|
+
// repos: [] } when the app isn't installed anywhere yet.
|
|
322
344
|
// POST /.parachute/mirror/auth/github/create-repo — create a new private
|
|
323
|
-
// repo on behalf of the operator.
|
|
345
|
+
// repo on behalf of the operator. 403s with the shared Contents-only
|
|
346
|
+
// app (mapped to error_type "app_lacks_admin_permission" + the
|
|
347
|
+
// guided-manual path); works for BYO apps with Administration:write.
|
|
324
348
|
//
|
|
325
349
|
// ---------------------------------------------------------------------------
|
|
326
350
|
|
|
@@ -363,16 +387,18 @@ export function _resetDeviceFlowSessionsForTest(): void {
|
|
|
363
387
|
}
|
|
364
388
|
|
|
365
389
|
/**
|
|
366
|
-
* Errors out cleanly when
|
|
367
|
-
*
|
|
390
|
+
* Errors out cleanly when PARACHUTE_GITHUB_CLIENT_ID is set to a
|
|
391
|
+
* placeholder-shaped value. A real default ships in the build (the shared
|
|
392
|
+
* Parachute GitHub App — see github-device-flow.ts), so this is only
|
|
393
|
+
* reachable via a misconfigured override.
|
|
368
394
|
*/
|
|
369
395
|
function placeholderClientIdResponse(): Response {
|
|
370
396
|
return Response.json(
|
|
371
397
|
{
|
|
372
|
-
error: "GitHub
|
|
398
|
+
error: "GitHub auth misconfigured",
|
|
373
399
|
error_type: "placeholder_client_id",
|
|
374
400
|
message:
|
|
375
|
-
"
|
|
401
|
+
"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
402
|
},
|
|
377
403
|
{ status: 503 },
|
|
378
404
|
);
|
|
@@ -515,12 +541,12 @@ export async function handleAuthGithubPoll(
|
|
|
515
541
|
}
|
|
516
542
|
// Clean up the polling session.
|
|
517
543
|
deviceFlowSessions.delete(body.polling_id);
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
544
|
+
// vault#483: linking implies backup intent — enable history for a
|
|
545
|
+
// never-configured vault (consent-respecting; see the helper). No
|
|
546
|
+
// remote URL is set here — we don't have an owner/repo yet (the
|
|
547
|
+
// operator hasn't picked a repo); that wiring happens in select-repo,
|
|
548
|
+
// which is also where auto_push flips on (Cut 3).
|
|
549
|
+
const history_enabled = await maybeEnableHistoryOnLink(manager);
|
|
524
550
|
return Response.json(
|
|
525
551
|
{
|
|
526
552
|
state: "granted",
|
|
@@ -530,6 +556,7 @@ export async function handleAuthGithubPoll(
|
|
|
530
556
|
name: user.name,
|
|
531
557
|
avatar_url: user.avatar_url,
|
|
532
558
|
},
|
|
559
|
+
history_enabled,
|
|
533
560
|
},
|
|
534
561
|
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
535
562
|
);
|
|
@@ -563,8 +590,16 @@ export async function handleAuthGithubPoll(
|
|
|
563
590
|
export async function handleAuthPat(
|
|
564
591
|
req: Request,
|
|
565
592
|
manager: MirrorManager,
|
|
593
|
+
// Test seam for the `git ls-remote` validation probe (default
|
|
594
|
+
// `probeGitLsRemote`, which spawns real git against the supplied remote).
|
|
595
|
+
// Inject a fn returning `{ok: true}` to exercise the post-validation save
|
|
596
|
+
// path (credential persist + history-on-link, vault#483) hermetically.
|
|
597
|
+
probeOverride?: (
|
|
598
|
+
url: string,
|
|
599
|
+
timeoutMs: number,
|
|
600
|
+
) => Promise<{ ok: boolean; error?: string }>,
|
|
566
601
|
): Promise<Response> {
|
|
567
|
-
let body: { token?: unknown; remote_url?: unknown; label?: unknown };
|
|
602
|
+
let body: { token?: unknown; remote_url?: unknown; label?: unknown; override?: unknown };
|
|
568
603
|
try {
|
|
569
604
|
body = (await req.json()) as Record<string, unknown>;
|
|
570
605
|
} catch (err) {
|
|
@@ -573,6 +608,7 @@ export async function handleAuthPat(
|
|
|
573
608
|
{ status: 400 },
|
|
574
609
|
);
|
|
575
610
|
}
|
|
611
|
+
const override = body.override === true;
|
|
576
612
|
const token = typeof body.token === "string" ? body.token.trim() : "";
|
|
577
613
|
const remote_url = typeof body.remote_url === "string" ? body.remote_url.trim() : "";
|
|
578
614
|
const label =
|
|
@@ -657,7 +693,7 @@ export async function handleAuthPat(
|
|
|
657
693
|
u.password = token;
|
|
658
694
|
return u.toString();
|
|
659
695
|
})();
|
|
660
|
-
const probeResult = await probeGitLsRemote(authedUrl, 10_000);
|
|
696
|
+
const probeResult = await (probeOverride ?? probeGitLsRemote)(authedUrl, 10_000);
|
|
661
697
|
if (!probeResult.ok) {
|
|
662
698
|
return Response.json(
|
|
663
699
|
{
|
|
@@ -668,11 +704,33 @@ export async function handleAuthPat(
|
|
|
668
704
|
);
|
|
669
705
|
}
|
|
670
706
|
|
|
707
|
+
// vault#482: refuse if ANOTHER vault on this server already pushes to this
|
|
708
|
+
// repo. Two vaults sharing one remote force-push over each other's backups
|
|
709
|
+
// (silent data loss). Compare against the operator-supplied (un-authed)
|
|
710
|
+
// remote_url — the normalizer strips userinfo so the token doesn't matter.
|
|
711
|
+
// Re-pointing THIS vault at its own remote is fine (the scan excludes it).
|
|
712
|
+
// `override=true` is the explicit escape hatch.
|
|
713
|
+
const vaultName = manager.getVaultName();
|
|
714
|
+
if (!override) {
|
|
715
|
+
const conflict = findConflictingVault(vaultName, remote_url);
|
|
716
|
+
if (conflict) {
|
|
717
|
+
return Response.json(
|
|
718
|
+
{
|
|
719
|
+
error: "Remote already in use by another vault",
|
|
720
|
+
error_type: "remote_conflict",
|
|
721
|
+
conflicting_vault: conflict.conflictingVault,
|
|
722
|
+
remote: conflict.remoteIdentity,
|
|
723
|
+
message: remoteConflictMessage(conflict),
|
|
724
|
+
},
|
|
725
|
+
{ status: 409 },
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
671
730
|
// Save the userinfo'd URL — that's what gets embedded as `origin` so
|
|
672
731
|
// bare `git push` works without needing GIT_ASKPASS etc. Per-vault
|
|
673
732
|
// (vault#399): the PAT + remote_url land in this vault's own file, not a
|
|
674
733
|
// server-wide one — so configuring vault B never reuses vault A's remote.
|
|
675
|
-
const vaultName = manager.getVaultName();
|
|
676
734
|
const next: MirrorCredentials = {
|
|
677
735
|
...(readCredentials(vaultName) ?? emptyCredentials()),
|
|
678
736
|
active_method: "pat",
|
|
@@ -694,6 +752,15 @@ export async function handleAuthPat(
|
|
|
694
752
|
);
|
|
695
753
|
}
|
|
696
754
|
|
|
755
|
+
// vault#483: linking implies backup intent — enable history for a
|
|
756
|
+
// never-configured vault BEFORE the Cut-3/Cut-6 steps below, so a fresh
|
|
757
|
+
// vault gets the whole intended flow in one save: history on (the reload's
|
|
758
|
+
// start() applies the just-written PAT remote to `origin`), then Cut 3
|
|
759
|
+
// sees an enabled mirror and flips auto_push on, then Cut 6 fires the
|
|
760
|
+
// initial push. An explicitly-disabled mirror short-circuits all of it
|
|
761
|
+
// (history stays off → maybeEnableAutoPush no-ops on disabled).
|
|
762
|
+
const history_enabled = await maybeEnableHistoryOnLink(manager);
|
|
763
|
+
|
|
697
764
|
// Push the new URL onto the mirror's git remote if it's currently
|
|
698
765
|
// resolved + on disk. Non-fatal if the mirror isn't running.
|
|
699
766
|
await applyCredentialsToMirror(manager);
|
|
@@ -713,6 +780,7 @@ export async function handleAuthPat(
|
|
|
713
780
|
return Response.json(
|
|
714
781
|
{
|
|
715
782
|
...sanitizeCredentials(next),
|
|
783
|
+
history_enabled,
|
|
716
784
|
auto_push_was_already_enabled: autoPushChange.was_already_enabled,
|
|
717
785
|
auto_push_enabled: autoPushChange.auto_push_now_enabled,
|
|
718
786
|
initial_push: initialPush,
|
|
@@ -811,8 +879,102 @@ export async function handleAuthDelete(manager: MirrorManager): Promise<Response
|
|
|
811
879
|
}
|
|
812
880
|
|
|
813
881
|
/**
|
|
814
|
-
* `GET /.parachute/mirror/auth/github/
|
|
815
|
-
* the
|
|
882
|
+
* `GET /.parachute/mirror/auth/github/installations` — install state for
|
|
883
|
+
* the connect flow (vault#480). Answers three UI questions in one call:
|
|
884
|
+
* which app is in play (shared default vs BYO), is it installed ANYWHERE
|
|
885
|
+
* for this operator, and which accounts (user/orgs) carry installations.
|
|
886
|
+
*
|
|
887
|
+
* Deliberately a separate, explicitly-network endpoint: `GET /auth` stays
|
|
888
|
+
* a pure local read of the stored credential. The SPA calls this when it
|
|
889
|
+
* renders the connect flow / repo picker, not on every status poll.
|
|
890
|
+
*
|
|
891
|
+
* Response:
|
|
892
|
+
* 200 {
|
|
893
|
+
* app: { client_id, slug, is_shared_default },
|
|
894
|
+
* installed: boolean, // installations.length > 0
|
|
895
|
+
* install_url: string, // github.com/apps/<slug>/installations/new
|
|
896
|
+
* installations: [{ id, account_login, account_type, repository_selection }]
|
|
897
|
+
* }
|
|
898
|
+
* 400 { error, error_type: "github_not_connected", message } — no stored
|
|
899
|
+
* github_oauth credential; the device flow hasn't been run (or a PAT
|
|
900
|
+
* is active instead — install state is a GitHub-App concept). 400,
|
|
901
|
+
* not 401, matching the sibling repos handler: a 401 here would trip
|
|
902
|
+
* the SPA's authedFetch token-refresh machinery and clear a
|
|
903
|
+
* perfectly valid cached admin token over a non-auth condition.
|
|
904
|
+
* 502 { error, message } — GitHub unreachable / API error.
|
|
905
|
+
*/
|
|
906
|
+
export async function handleAuthGithubInstallations(
|
|
907
|
+
manager: MirrorManager,
|
|
908
|
+
fetchImpl?: FetchLike,
|
|
909
|
+
): Promise<Response> {
|
|
910
|
+
const creds = readCredentials(manager.getVaultName());
|
|
911
|
+
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
912
|
+
return Response.json(
|
|
913
|
+
{
|
|
914
|
+
error: "Not connected to GitHub",
|
|
915
|
+
error_type: "github_not_connected",
|
|
916
|
+
message:
|
|
917
|
+
"No GitHub sign-in is stored for this vault. Run the device flow first (POST /.parachute/mirror/auth/github/device-code).",
|
|
918
|
+
},
|
|
919
|
+
{ status: 400 },
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
const clientId = getGithubClientId();
|
|
923
|
+
const slug = getGithubAppSlug();
|
|
924
|
+
let installations;
|
|
925
|
+
try {
|
|
926
|
+
installations = await listInstallations(creds.github_oauth.access_token, fetchImpl);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
return Response.json(
|
|
929
|
+
{
|
|
930
|
+
error: "Installation check failed",
|
|
931
|
+
message: (err as Error).message ?? String(err),
|
|
932
|
+
},
|
|
933
|
+
{ status: 502 },
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
return Response.json(
|
|
937
|
+
{
|
|
938
|
+
app: {
|
|
939
|
+
client_id: clientId,
|
|
940
|
+
slug,
|
|
941
|
+
is_shared_default:
|
|
942
|
+
clientId === GITHUB_CLIENT_ID_DEFAULT && slug === GITHUB_APP_SLUG_DEFAULT,
|
|
943
|
+
},
|
|
944
|
+
installed: installations.length > 0,
|
|
945
|
+
install_url: installUrlForSlug(slug),
|
|
946
|
+
installations: installations.map((i) => ({
|
|
947
|
+
id: i.id,
|
|
948
|
+
account_login: i.account.login,
|
|
949
|
+
account_type: i.account.type,
|
|
950
|
+
repository_selection: i.repository_selection,
|
|
951
|
+
})),
|
|
952
|
+
},
|
|
953
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* `GET /.parachute/mirror/auth/github/repos` — list the repos the
|
|
959
|
+
* operator's app installations grant, via the stored OAuth token. Requires
|
|
960
|
+
* `active_method === "github_oauth"`.
|
|
961
|
+
*
|
|
962
|
+
* Source (vault#480): `GET /user/installations` → per-installation
|
|
963
|
+
* `GET /user/installations/{id}/repositories`, unioned. This replaces the
|
|
964
|
+
* old `GET /user/repos?type=owner` source, which (a) excluded org-owned
|
|
965
|
+
* repos by construction and (b) showed all-public-repos when the app wasn't
|
|
966
|
+
* installed at all (every GitHub App reads public repos) — Aaron walked
|
|
967
|
+
* into exactly that "looks connected, shows the wrong repos" state live.
|
|
968
|
+
*
|
|
969
|
+
* Response:
|
|
970
|
+
* 200 installed: { installed: true, repos: [{ ...GitHubRepoInfo,
|
|
971
|
+
* account_login, installation_id }], truncated }
|
|
972
|
+
* 200 not installed: { installed: false, install_url, repos: [],
|
|
973
|
+
* truncated: false } — machine-readable
|
|
974
|
+
* authorized-but-not-installed state; the UI shows
|
|
975
|
+
* the guided-install step, no string-matching needed.
|
|
976
|
+
* 400 { error, message } — no github_oauth credential stored.
|
|
977
|
+
* 502 { error, message } — GitHub unreachable / API error.
|
|
816
978
|
*/
|
|
817
979
|
export async function handleAuthGithubRepos(
|
|
818
980
|
manager: MirrorManager,
|
|
@@ -828,9 +990,58 @@ export async function handleAuthGithubRepos(
|
|
|
828
990
|
{ status: 400 },
|
|
829
991
|
);
|
|
830
992
|
}
|
|
831
|
-
|
|
993
|
+
const token = creds.github_oauth.access_token;
|
|
994
|
+
let installations;
|
|
832
995
|
try {
|
|
833
|
-
|
|
996
|
+
installations = await listInstallations(token, fetchImpl);
|
|
997
|
+
} catch (err) {
|
|
998
|
+
return Response.json(
|
|
999
|
+
{
|
|
1000
|
+
error: "Repo list failed",
|
|
1001
|
+
message: (err as Error).message ?? String(err),
|
|
1002
|
+
},
|
|
1003
|
+
{ status: 502 },
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (installations.length === 0) {
|
|
1008
|
+
// Authorized but not installed — the device-flow grant alone reaches no
|
|
1009
|
+
// repos. Distinct, machine-readable state (NOT an empty repo list that
|
|
1010
|
+
// looks like "you have no repos").
|
|
1011
|
+
return Response.json(
|
|
1012
|
+
{
|
|
1013
|
+
installed: false,
|
|
1014
|
+
install_url: installUrlForSlug(getGithubAppSlug()),
|
|
1015
|
+
repos: [],
|
|
1016
|
+
truncated: false,
|
|
1017
|
+
},
|
|
1018
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const repos: Array<GitHubRepoInfo & { account_login: string; installation_id: number }> = [];
|
|
1023
|
+
let truncated = false;
|
|
1024
|
+
try {
|
|
1025
|
+
for (const installation of installations) {
|
|
1026
|
+
const result = await listInstallationRepos(token, installation.id, {}, fetchImpl);
|
|
1027
|
+
// `GET /user/installations/{id}/repositories` takes no `sort` param
|
|
1028
|
+
// (unlike the old `GET /user/repos?sort=updated` picker source), so
|
|
1029
|
+
// order within each account group ourselves: most-recently-updated
|
|
1030
|
+
// first, so the repo the operator probably wants sits near the top.
|
|
1031
|
+
// ISO-8601 timestamps compare lexicographically. Per-group (not
|
|
1032
|
+
// across the union) so the SPA's account grouping stays contiguous.
|
|
1033
|
+
const sorted = [...result.repos].sort((a, b) =>
|
|
1034
|
+
b.updated_at.localeCompare(a.updated_at),
|
|
1035
|
+
);
|
|
1036
|
+
for (const repo of sorted) {
|
|
1037
|
+
repos.push({
|
|
1038
|
+
...repo,
|
|
1039
|
+
account_login: installation.account.login,
|
|
1040
|
+
installation_id: installation.id,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
if (result.truncated) truncated = true;
|
|
1044
|
+
}
|
|
834
1045
|
} catch (err) {
|
|
835
1046
|
return Response.json(
|
|
836
1047
|
{
|
|
@@ -841,7 +1052,7 @@ export async function handleAuthGithubRepos(
|
|
|
841
1052
|
);
|
|
842
1053
|
}
|
|
843
1054
|
return Response.json(
|
|
844
|
-
{
|
|
1055
|
+
{ installed: true, repos, truncated },
|
|
845
1056
|
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
846
1057
|
);
|
|
847
1058
|
}
|
|
@@ -850,6 +1061,15 @@ export async function handleAuthGithubRepos(
|
|
|
850
1061
|
* `POST /.parachute/mirror/auth/github/create-repo` — create a new repo on
|
|
851
1062
|
* the operator's account, return the new RepoInfo. The SPA flows straight
|
|
852
1063
|
* from this into the "repo selected" state.
|
|
1064
|
+
*
|
|
1065
|
+
* With the shared Parachute app this 403s by design (vault#480):
|
|
1066
|
+
* `POST /user/repos` needs Administration:write; the shared app is frozen
|
|
1067
|
+
* at Contents-only. The 403 maps to error_type "app_lacks_admin_permission"
|
|
1068
|
+
* with the guided-manual path (create at github.com/new → add it to the
|
|
1069
|
+
* installation → refresh the picker). The endpoint stays functional for
|
|
1070
|
+
* BYO-app operators whose app grants Administration:write — and per the
|
|
1071
|
+
* install docs, app-created repos auto-join the installation, so their
|
|
1072
|
+
* create→push flow works end-to-end.
|
|
853
1073
|
*/
|
|
854
1074
|
export async function handleAuthGithubCreateRepo(
|
|
855
1075
|
req: Request,
|
|
@@ -892,6 +1112,20 @@ export async function handleAuthGithubCreateRepo(
|
|
|
892
1112
|
fetchImpl,
|
|
893
1113
|
);
|
|
894
1114
|
} catch (err) {
|
|
1115
|
+
if (err instanceof GitHubApiError && err.status === 403) {
|
|
1116
|
+
// Expected with the shared Contents-only app: POST /user/repos needs
|
|
1117
|
+
// Administration:write. Actionable, machine-readable mapping — the UI
|
|
1118
|
+
// renders the guided-manual checklist off error_type, not a string.
|
|
1119
|
+
return Response.json(
|
|
1120
|
+
{
|
|
1121
|
+
error: "Create not permitted for this GitHub App",
|
|
1122
|
+
error_type: "app_lacks_admin_permission",
|
|
1123
|
+
message:
|
|
1124
|
+
"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.",
|
|
1125
|
+
},
|
|
1126
|
+
{ status: 403 },
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
895
1129
|
return Response.json(
|
|
896
1130
|
{ error: "Create failed", message: (err as Error).message ?? String(err) },
|
|
897
1131
|
{ status: 502 },
|
|
@@ -919,7 +1153,7 @@ export async function handleAuthGithubSelectRepo(
|
|
|
919
1153
|
{ status: 400 },
|
|
920
1154
|
);
|
|
921
1155
|
}
|
|
922
|
-
let body: { owner?: unknown; name?: unknown };
|
|
1156
|
+
let body: { owner?: unknown; name?: unknown; override?: unknown };
|
|
923
1157
|
try {
|
|
924
1158
|
body = (await req.json()) as Record<string, unknown>;
|
|
925
1159
|
} catch (err) {
|
|
@@ -928,6 +1162,7 @@ export async function handleAuthGithubSelectRepo(
|
|
|
928
1162
|
{ status: 400 },
|
|
929
1163
|
);
|
|
930
1164
|
}
|
|
1165
|
+
const override = body.override === true;
|
|
931
1166
|
const owner = typeof body.owner === "string" ? body.owner.trim() : "";
|
|
932
1167
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
933
1168
|
if (!owner || !name) {
|
|
@@ -939,6 +1174,32 @@ export async function handleAuthGithubSelectRepo(
|
|
|
939
1174
|
{ status: 400 },
|
|
940
1175
|
);
|
|
941
1176
|
}
|
|
1177
|
+
|
|
1178
|
+
// vault#482: refuse if ANOTHER vault on this server already pushes to this
|
|
1179
|
+
// repo. The clobber path for the OAuth flow: select-repo writes
|
|
1180
|
+
// `github.com/<owner>/<name>` onto this vault's mirror `origin`; if a sibling
|
|
1181
|
+
// vault already has that same origin, both force-push the same branch and the
|
|
1182
|
+
// loser's backup is overwritten. Compare against the public github URL (no
|
|
1183
|
+
// token — the normalizer keys off host/owner/repo). The scan excludes THIS
|
|
1184
|
+
// vault, so re-picking the same repo (token rotation, re-entry) is fine.
|
|
1185
|
+
// `override=true` is the explicit escape hatch.
|
|
1186
|
+
if (!override) {
|
|
1187
|
+
const candidate = `https://github.com/${owner}/${name}.git`;
|
|
1188
|
+
const conflict = findConflictingVault(manager.getVaultName(), candidate);
|
|
1189
|
+
if (conflict) {
|
|
1190
|
+
return Response.json(
|
|
1191
|
+
{
|
|
1192
|
+
error: "Remote already in use by another vault",
|
|
1193
|
+
error_type: "remote_conflict",
|
|
1194
|
+
conflicting_vault: conflict.conflictingVault,
|
|
1195
|
+
remote: conflict.remoteIdentity,
|
|
1196
|
+
message: remoteConflictMessage(conflict),
|
|
1197
|
+
},
|
|
1198
|
+
{ status: 409 },
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
942
1203
|
// Reach into mirror-credentials.ts for the authed URL builder.
|
|
943
1204
|
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
944
1205
|
const authedUrl = githubAuthedRemoteUrl(
|
|
@@ -947,6 +1208,18 @@ export async function handleAuthGithubSelectRepo(
|
|
|
947
1208
|
name,
|
|
948
1209
|
);
|
|
949
1210
|
|
|
1211
|
+
// vault#483: linking implies backup intent — the choose-repo re-entry can
|
|
1212
|
+
// be the FIRST credential-linked action for this vault (a credential saved
|
|
1213
|
+
// before history-on-link existed, on a never-configured vault: the #483
|
|
1214
|
+
// "linked but silently inert" state). Run history-on-link here too, BEFORE
|
|
1215
|
+
// the Cut-3/Cut-6 steps below and mirroring handleAuthPat's ordering:
|
|
1216
|
+
// history on (the reload's start() resolves mirror_path so the remote
|
|
1217
|
+
// write below actually lands), then Cut 3 sees an enabled mirror and flips
|
|
1218
|
+
// auto_push, then Cut 6 fires the initial push. An explicitly-disabled
|
|
1219
|
+
// mirror short-circuits all of it (history stays off → maybeEnableAutoPush
|
|
1220
|
+
// no-ops on disabled).
|
|
1221
|
+
const history_enabled = await maybeEnableHistoryOnLink(manager);
|
|
1222
|
+
|
|
950
1223
|
// Apply to the mirror dir if running. If the mirror isn't running (no
|
|
951
1224
|
// mirror_path), we still consider this a success — the credentials are
|
|
952
1225
|
// stored, and the URL will get applied next time the mirror starts.
|
|
@@ -980,6 +1253,7 @@ export async function handleAuthGithubSelectRepo(
|
|
|
980
1253
|
// Echo the redacted form back so the SPA can show "pushing to <repo>".
|
|
981
1254
|
// No raw token in the response.
|
|
982
1255
|
remote: `https://github.com/${owner}/${name}.git`,
|
|
1256
|
+
history_enabled,
|
|
983
1257
|
auto_push_was_already_enabled: autoPushChange.was_already_enabled,
|
|
984
1258
|
auto_push_enabled: autoPushChange.auto_push_now_enabled,
|
|
985
1259
|
initial_push: initialPush,
|
|
@@ -988,6 +1262,73 @@ export async function handleAuthGithubSelectRepo(
|
|
|
988
1262
|
);
|
|
989
1263
|
}
|
|
990
1264
|
|
|
1265
|
+
/**
|
|
1266
|
+
* vault#483 fix 1 — linking implies backup intent. Outcome flag for the
|
|
1267
|
+
* credential-save responses:
|
|
1268
|
+
* - `true` — history (the mirror) is on: either this link just enabled it
|
|
1269
|
+
* (never-configured vault) or it was already enabled.
|
|
1270
|
+
* - `"left_disabled"` — a mirror config exists with `enabled: false`; we
|
|
1271
|
+
* refuse to silently flip an explicit operator choice. The UI should
|
|
1272
|
+
* offer the one-click "Turn on history now?" enable.
|
|
1273
|
+
* - `false` — we tried to enable history but the mirror didn't come up
|
|
1274
|
+
* (e.g. git missing). Config intent is persisted (PUT semantics); the
|
|
1275
|
+
* mirror status carries the actionable error.
|
|
1276
|
+
*/
|
|
1277
|
+
export type HistoryOnLink = true | false | "left_disabled";
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* When a GitHub credential lands (device-flow grant or PAT save), turn
|
|
1281
|
+
* history on for a vault that has NEVER had a mirror configured — nobody
|
|
1282
|
+
* links GitHub to a vault for any reason other than backing it up. Aaron hit
|
|
1283
|
+
* this live (vault#483): pre-#440 vaults have no mirror-config file, so
|
|
1284
|
+
* linking stored a credential and then... nothing, with the only path out
|
|
1285
|
+
* buried in advanced settings.
|
|
1286
|
+
*
|
|
1287
|
+
* Consent-respecting by design:
|
|
1288
|
+
* - **No config file** (never configured) → enable the internal mirror
|
|
1289
|
+
* with the standard defaults (enabled, internal, events, auto_commit on,
|
|
1290
|
+
* auto_push off — the same shape #440 gives new vaults). Writes through
|
|
1291
|
+
* `manager.reload()` — the exact path the PUT handler uses — so persist +
|
|
1292
|
+
* lifecycle-start stay one code path.
|
|
1293
|
+
* - **Config file exists with enabled:false** → explicit operator choice;
|
|
1294
|
+
* do NOT flip it. Return `"left_disabled"` so the UI can offer one-click
|
|
1295
|
+
* enable instead.
|
|
1296
|
+
* - **Config file exists with enabled:true** → nothing to do.
|
|
1297
|
+
*
|
|
1298
|
+
* The file-existence check (not just the parsed read) is the load-bearing
|
|
1299
|
+
* distinction: an existing-but-malformed file parses to `undefined`, same as
|
|
1300
|
+
* absent — but it still represents operator-touched state, so we treat it as
|
|
1301
|
+
* "exists, not known-enabled" and leave it alone.
|
|
1302
|
+
*/
|
|
1303
|
+
async function maybeEnableHistoryOnLink(
|
|
1304
|
+
manager: MirrorManager,
|
|
1305
|
+
): Promise<HistoryOnLink> {
|
|
1306
|
+
const vaultName = manager.getVaultName();
|
|
1307
|
+
if (!existsSync(mirrorConfigPath(vaultName))) {
|
|
1308
|
+
try {
|
|
1309
|
+
const status = await manager.reload({
|
|
1310
|
+
...defaultMirrorConfig(),
|
|
1311
|
+
enabled: true,
|
|
1312
|
+
});
|
|
1313
|
+
if (!status.enabled) {
|
|
1314
|
+
console.warn(
|
|
1315
|
+
`[mirror-auth] history-on-link: enabled the mirror config for vault "${vaultName}" but it didn't start: ${status.last_error ?? "unknown error"}`,
|
|
1316
|
+
);
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
return true;
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
console.warn(
|
|
1322
|
+
`[mirror-auth] history-on-link: could not enable the mirror for vault "${vaultName}" (non-fatal): ${(err as Error).message ?? err}`,
|
|
1323
|
+
);
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
const persisted = readMirrorConfigForVault(vaultName);
|
|
1328
|
+
if (persisted?.enabled) return true;
|
|
1329
|
+
return "left_disabled";
|
|
1330
|
+
}
|
|
1331
|
+
|
|
991
1332
|
/**
|
|
992
1333
|
* Cut 3: when credentials save, flip `auto_push` from false → true on
|
|
993
1334
|
* the persisted config. Operators wiring credentials almost certainly
|
|
@@ -1176,6 +1517,7 @@ export async function handleMirrorImport(
|
|
|
1176
1517
|
mode?: unknown;
|
|
1177
1518
|
credentials?: unknown;
|
|
1178
1519
|
enable_sync?: unknown;
|
|
1520
|
+
override?: unknown;
|
|
1179
1521
|
};
|
|
1180
1522
|
try {
|
|
1181
1523
|
body = (await req.json()) as Record<string, unknown>;
|
|
@@ -1287,6 +1629,13 @@ export async function handleMirrorImport(
|
|
|
1287
1629
|
enableSync = body.enable_sync;
|
|
1288
1630
|
}
|
|
1289
1631
|
|
|
1632
|
+
// vault#482: cross-vault clobber override for the sync-enable step. Default
|
|
1633
|
+
// off; a literal `true` lets the operator deliberately point this vault's
|
|
1634
|
+
// sync at a repo a SIBLING vault already backs up to (e.g. they moved the
|
|
1635
|
+
// repo between vaults). The import itself never refuses on this — only the
|
|
1636
|
+
// optional sync-enable does.
|
|
1637
|
+
const override = body.override === true;
|
|
1638
|
+
|
|
1290
1639
|
// Resolve the target vault's store + assets dir. The route is gated on
|
|
1291
1640
|
// `vault:<name>:admin`, so we trust vaultName is real by the time we
|
|
1292
1641
|
// reach this code path; defensively re-resolve in case the vault was
|
|
@@ -1374,6 +1723,7 @@ export async function handleMirrorImport(
|
|
|
1374
1723
|
remoteUrl: remote_url,
|
|
1375
1724
|
auth,
|
|
1376
1725
|
manager,
|
|
1726
|
+
override,
|
|
1377
1727
|
});
|
|
1378
1728
|
result.sync_enabled = outcome.sync_enabled;
|
|
1379
1729
|
if (outcome.warning) result.sync_warning = outcome.warning;
|
|
@@ -1444,8 +1794,14 @@ export async function enableSyncToImportedRepo(opts: {
|
|
|
1444
1794
|
* rather than persisting a half-configured mirror with no live manager.
|
|
1445
1795
|
*/
|
|
1446
1796
|
manager: MirrorManager | undefined;
|
|
1797
|
+
/**
|
|
1798
|
+
* vault#482 escape hatch — skip the cross-vault clobber guard. Default
|
|
1799
|
+
* false: refuse (warn, don't enable sync) when a SIBLING vault already
|
|
1800
|
+
* backs up to this repo.
|
|
1801
|
+
*/
|
|
1802
|
+
override?: boolean;
|
|
1447
1803
|
}): Promise<{ sync_enabled: boolean; warning?: string }> {
|
|
1448
|
-
const { vaultName, remoteUrl, auth, manager } = opts;
|
|
1804
|
+
const { vaultName, remoteUrl, auth, manager, override = false } = opts;
|
|
1449
1805
|
|
|
1450
1806
|
if (!manager) {
|
|
1451
1807
|
return {
|
|
@@ -1455,6 +1811,21 @@ export async function enableSyncToImportedRepo(opts: {
|
|
|
1455
1811
|
};
|
|
1456
1812
|
}
|
|
1457
1813
|
|
|
1814
|
+
// vault#482: don't enable sync to a repo a DIFFERENT vault on this server
|
|
1815
|
+
// already backs up to — both would force-push the same branch and clobber
|
|
1816
|
+
// each other. Import success is never lost; we just decline the optional
|
|
1817
|
+
// sync-enable + warn. The existing same-vault "different remote" check below
|
|
1818
|
+
// handles THIS vault's own prior target; this one covers SIBLING vaults.
|
|
1819
|
+
if (!override) {
|
|
1820
|
+
const conflict = findConflictingVault(vaultName, remoteUrl);
|
|
1821
|
+
if (conflict) {
|
|
1822
|
+
return {
|
|
1823
|
+
sync_enabled: false,
|
|
1824
|
+
warning: `Import succeeded, but Sync was not enabled — ${remoteConflictMessage(conflict)}`,
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1458
1829
|
// --- Resolve the push credential we'll persist for this remote. ----------
|
|
1459
1830
|
// We need a credential that can PUSH to `remoteUrl`. The clone-time auth
|
|
1460
1831
|
// shapes map onto push credentials as follows:
|
|
@@ -1696,14 +2067,16 @@ function effectiveRemoteOf(creds: MirrorCredentials): string | null {
|
|
|
1696
2067
|
* (SSH shorthand, local paths).
|
|
1697
2068
|
*/
|
|
1698
2069
|
function sameRemote(a: string, b: string): boolean {
|
|
2070
|
+
// Lower-cased whole identity: GitHub owner/repo is case-insensitive, so a
|
|
2071
|
+
// clobber guard must treat Aaron/Repo == aaron/repo (matches normalizeRemoteIdentity).
|
|
1699
2072
|
const norm = (u: string): string => {
|
|
1700
2073
|
try {
|
|
1701
2074
|
const url = new URL(u);
|
|
1702
2075
|
const host = url.host.toLowerCase();
|
|
1703
2076
|
const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1704
|
-
return `${url.protocol}//${host}${path}
|
|
2077
|
+
return `${url.protocol}//${host}${path}`.toLowerCase();
|
|
1705
2078
|
} catch {
|
|
1706
|
-
return u.trim().replace(/\.git$/, "").replace(/\/+$/, "");
|
|
2079
|
+
return u.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
|
|
1707
2080
|
}
|
|
1708
2081
|
};
|
|
1709
2082
|
return norm(a) === norm(b);
|