@openparachute/vault 0.4.9-rc.7 → 0.4.9-rc.8
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/package.json +1 -1
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +484 -0
- package/src/mirror-routes.test.ts +318 -0
- package/src/mirror-routes.ts +215 -0
- package/src/routing.ts +25 -0
- package/web/ui/dist/assets/index-CudVv0Mv.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-B1DwjHsG.js +0 -60
|
@@ -26,9 +26,20 @@ import {
|
|
|
26
26
|
handleAuthGithubRepos,
|
|
27
27
|
handleAuthPat,
|
|
28
28
|
handleMirrorGet,
|
|
29
|
+
handleMirrorImport,
|
|
29
30
|
handleMirrorPut,
|
|
30
31
|
handleMirrorRunNow,
|
|
31
32
|
} from "./mirror-routes.ts";
|
|
33
|
+
import {
|
|
34
|
+
_resetImportInFlightForTest,
|
|
35
|
+
type GitSpawn,
|
|
36
|
+
} from "./mirror-import.ts";
|
|
37
|
+
import { writeVaultConfig } from "./config.ts";
|
|
38
|
+
import { clearVaultStoreCache } from "./vault-store.ts";
|
|
39
|
+
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
40
|
+
import { Database } from "bun:sqlite";
|
|
41
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
42
|
+
import { cpSync, writeFileSync as nodeWriteFileSync } from "node:fs";
|
|
32
43
|
import {
|
|
33
44
|
mirrorCredentialsPath,
|
|
34
45
|
readCredentials,
|
|
@@ -848,3 +859,310 @@ describe("auth credential routes — github repos / create-repo", () => {
|
|
|
848
859
|
});
|
|
849
860
|
});
|
|
850
861
|
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
// POST /.parachute/mirror/import — clone-and-import HTTP route (vault#391).
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Bootstrap a real vault config + db file so getVaultStore('default')
|
|
868
|
+
* succeeds inside the handler. Returns the home dir for cleanup.
|
|
869
|
+
*/
|
|
870
|
+
async function bootstrapVault(home: string): Promise<void> {
|
|
871
|
+
process.env.PARACHUTE_HOME = home;
|
|
872
|
+
process.env.HOME = home;
|
|
873
|
+
// The minimal layout vault needs to spin up its store: a per-vault
|
|
874
|
+
// dir at $PARACHUTE_HOME/vault/data/<name> + a `vault.yaml` config.
|
|
875
|
+
// writeVaultConfig creates these for us.
|
|
876
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
877
|
+
writeVaultConfig({
|
|
878
|
+
name: "default",
|
|
879
|
+
description: "import-test vault",
|
|
880
|
+
created_at: "2026-05-28T00:00:00.000Z",
|
|
881
|
+
api_keys: [],
|
|
882
|
+
});
|
|
883
|
+
clearVaultStoreCache();
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Build a real portable-md vault export, return the path. The clone
|
|
888
|
+
* spawn-mock copies this into the tempdir to simulate a successful
|
|
889
|
+
* `git clone`.
|
|
890
|
+
*/
|
|
891
|
+
async function buildExportFixture(): Promise<string> {
|
|
892
|
+
const fixture = tmp("import-route-fixture-");
|
|
893
|
+
const exportStore = new SqliteStore(new Database(":memory:"));
|
|
894
|
+
await exportStore.createNote("alpha body", { id: "n-alpha", path: "alpha", tags: ["t1"] });
|
|
895
|
+
await exportStore.createNote("beta body", { id: "n-beta", path: "beta" });
|
|
896
|
+
await exportVaultToDir(exportStore, {
|
|
897
|
+
outDir: fixture,
|
|
898
|
+
vaultName: "source",
|
|
899
|
+
exportedAt: "2026-05-28T00:00:00.000Z",
|
|
900
|
+
});
|
|
901
|
+
return fixture;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const spawnCloneSuccess = (fixture: string): GitSpawn => async (argv) => {
|
|
905
|
+
const destDir = argv[argv.length - 1]!;
|
|
906
|
+
cpSync(fixture, destDir, { recursive: true });
|
|
907
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const spawnCloneFail: GitSpawn = async () => ({
|
|
911
|
+
exitCode: 128,
|
|
912
|
+
stderr: "fatal: repository not found",
|
|
913
|
+
timedOut: false,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
describe("handleMirrorImport", () => {
|
|
917
|
+
let home: string;
|
|
918
|
+
let fixture: string;
|
|
919
|
+
|
|
920
|
+
afterEach(() => {
|
|
921
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
922
|
+
if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
|
|
923
|
+
_resetImportInFlightForTest();
|
|
924
|
+
clearVaultStoreCache();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("rejects invalid JSON body with 400", async () => {
|
|
928
|
+
home = tmp("import-route-badjson-");
|
|
929
|
+
await bootstrapVault(home);
|
|
930
|
+
const req = new Request("http://x/import", {
|
|
931
|
+
method: "POST",
|
|
932
|
+
body: "{not-json",
|
|
933
|
+
});
|
|
934
|
+
const res = await handleMirrorImport(req, "default");
|
|
935
|
+
expect(res.status).toBe(400);
|
|
936
|
+
const body = (await res.json()) as { error_type: string };
|
|
937
|
+
expect(body.error_type).toBe("invalid_json");
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test("rejects missing remote_url with 400", async () => {
|
|
941
|
+
home = tmp("import-route-no-url-");
|
|
942
|
+
await bootstrapVault(home);
|
|
943
|
+
const req = new Request("http://x/import", {
|
|
944
|
+
method: "POST",
|
|
945
|
+
body: JSON.stringify({ mode: "merge" }),
|
|
946
|
+
});
|
|
947
|
+
const res = await handleMirrorImport(req, "default");
|
|
948
|
+
expect(res.status).toBe(400);
|
|
949
|
+
const body = (await res.json()) as { field: string };
|
|
950
|
+
expect(body.field).toBe("remote_url");
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test("rejects invalid mode with 400", async () => {
|
|
954
|
+
home = tmp("import-route-bad-mode-");
|
|
955
|
+
await bootstrapVault(home);
|
|
956
|
+
const req = new Request("http://x/import", {
|
|
957
|
+
method: "POST",
|
|
958
|
+
body: JSON.stringify({ remote_url: "https://github.com/a/b.git", mode: "wipe" }),
|
|
959
|
+
});
|
|
960
|
+
const res = await handleMirrorImport(req, "default");
|
|
961
|
+
expect(res.status).toBe(400);
|
|
962
|
+
const body = (await res.json()) as { field: string };
|
|
963
|
+
expect(body.field).toBe("mode");
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("rejects per-call PAT without token", async () => {
|
|
967
|
+
home = tmp("import-route-pat-missing-token-");
|
|
968
|
+
await bootstrapVault(home);
|
|
969
|
+
const req = new Request("http://x/import", {
|
|
970
|
+
method: "POST",
|
|
971
|
+
body: JSON.stringify({
|
|
972
|
+
remote_url: "https://github.com/a/b.git",
|
|
973
|
+
mode: "merge",
|
|
974
|
+
credentials: { kind: "pat", token: "" },
|
|
975
|
+
}),
|
|
976
|
+
});
|
|
977
|
+
const res = await handleMirrorImport(req, "default");
|
|
978
|
+
expect(res.status).toBe(400);
|
|
979
|
+
const body = (await res.json()) as { field: string };
|
|
980
|
+
expect(body.field).toBe("credentials.token");
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
test("rejects unknown credentials.kind", async () => {
|
|
984
|
+
home = tmp("import-route-bad-kind-");
|
|
985
|
+
await bootstrapVault(home);
|
|
986
|
+
const req = new Request("http://x/import", {
|
|
987
|
+
method: "POST",
|
|
988
|
+
body: JSON.stringify({
|
|
989
|
+
remote_url: "https://github.com/a/b.git",
|
|
990
|
+
mode: "merge",
|
|
991
|
+
credentials: { kind: "magic" },
|
|
992
|
+
}),
|
|
993
|
+
});
|
|
994
|
+
const res = await handleMirrorImport(req, "default");
|
|
995
|
+
expect(res.status).toBe(400);
|
|
996
|
+
const body = (await res.json()) as { field: string };
|
|
997
|
+
expect(body.field).toBe("credentials.kind");
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("success path — merge mode imports notes into target vault", async () => {
|
|
1001
|
+
home = tmp("import-route-success-");
|
|
1002
|
+
await bootstrapVault(home);
|
|
1003
|
+
fixture = await buildExportFixture();
|
|
1004
|
+
const req = new Request("http://x/import", {
|
|
1005
|
+
method: "POST",
|
|
1006
|
+
body: JSON.stringify({
|
|
1007
|
+
remote_url: "https://github.com/a/b.git",
|
|
1008
|
+
mode: "merge",
|
|
1009
|
+
credentials: { kind: "none" },
|
|
1010
|
+
}),
|
|
1011
|
+
});
|
|
1012
|
+
const res = await handleMirrorImport(req, "default", spawnCloneSuccess(fixture));
|
|
1013
|
+
expect(res.status).toBe(200);
|
|
1014
|
+
const body = (await res.json()) as {
|
|
1015
|
+
notes_imported: number;
|
|
1016
|
+
tags_imported: number;
|
|
1017
|
+
attachments_imported: number;
|
|
1018
|
+
warnings: string[];
|
|
1019
|
+
notes_deleted?: number;
|
|
1020
|
+
};
|
|
1021
|
+
expect(body.notes_imported).toBe(2);
|
|
1022
|
+
expect(body.warnings).toEqual([]);
|
|
1023
|
+
expect(body.notes_deleted).toBeUndefined();
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("replace mode sets notes_deleted in response", async () => {
|
|
1027
|
+
home = tmp("import-route-replace-");
|
|
1028
|
+
await bootstrapVault(home);
|
|
1029
|
+
fixture = await buildExportFixture();
|
|
1030
|
+
// Seed the target vault with a local-only note that gets wiped.
|
|
1031
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
1032
|
+
const store = getVaultStore("default");
|
|
1033
|
+
await store.createNote("local", { id: "n-local", path: "local" });
|
|
1034
|
+
|
|
1035
|
+
const req = new Request("http://x/import", {
|
|
1036
|
+
method: "POST",
|
|
1037
|
+
body: JSON.stringify({
|
|
1038
|
+
remote_url: "https://github.com/a/b.git",
|
|
1039
|
+
mode: "replace",
|
|
1040
|
+
credentials: { kind: "none" },
|
|
1041
|
+
}),
|
|
1042
|
+
});
|
|
1043
|
+
const res = await handleMirrorImport(req, "default", spawnCloneSuccess(fixture));
|
|
1044
|
+
expect(res.status).toBe(200);
|
|
1045
|
+
const body = (await res.json()) as {
|
|
1046
|
+
notes_imported: number;
|
|
1047
|
+
notes_deleted: number;
|
|
1048
|
+
};
|
|
1049
|
+
expect(body.notes_imported).toBe(2);
|
|
1050
|
+
expect(body.notes_deleted).toBe(1);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test("clone failure returns 502 with redacted message", async () => {
|
|
1054
|
+
home = tmp("import-route-clone-fail-");
|
|
1055
|
+
await bootstrapVault(home);
|
|
1056
|
+
const req = new Request("http://x/import", {
|
|
1057
|
+
method: "POST",
|
|
1058
|
+
body: JSON.stringify({
|
|
1059
|
+
remote_url: "https://github.com/a/b.git",
|
|
1060
|
+
mode: "merge",
|
|
1061
|
+
credentials: { kind: "pat", token: "ghp_secret_xyz" },
|
|
1062
|
+
}),
|
|
1063
|
+
});
|
|
1064
|
+
const res = await handleMirrorImport(req, "default", spawnCloneFail);
|
|
1065
|
+
expect(res.status).toBe(502);
|
|
1066
|
+
const text = await res.text();
|
|
1067
|
+
expect(text).not.toContain("ghp_secret_xyz");
|
|
1068
|
+
const body = JSON.parse(text) as { error_type: string };
|
|
1069
|
+
expect(body.error_type).toBe("clone_failed");
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("not-a-vault-export returns 400 with actionable message", async () => {
|
|
1073
|
+
home = tmp("import-route-not-vault-");
|
|
1074
|
+
await bootstrapVault(home);
|
|
1075
|
+
const notAnExport = tmp("import-route-notvault-fixture-");
|
|
1076
|
+
nodeWriteFileSync(path.join(notAnExport, "README.md"), "hello");
|
|
1077
|
+
fixture = notAnExport;
|
|
1078
|
+
const req = new Request("http://x/import", {
|
|
1079
|
+
method: "POST",
|
|
1080
|
+
body: JSON.stringify({
|
|
1081
|
+
remote_url: "https://github.com/a/b.git",
|
|
1082
|
+
mode: "merge",
|
|
1083
|
+
credentials: { kind: "none" },
|
|
1084
|
+
}),
|
|
1085
|
+
});
|
|
1086
|
+
const res = await handleMirrorImport(req, "default", spawnCloneSuccess(notAnExport));
|
|
1087
|
+
expect(res.status).toBe(400);
|
|
1088
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
1089
|
+
expect(body.error_type).toBe("not_a_vault_export");
|
|
1090
|
+
expect(body.message).toContain("vault.yaml");
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
|
|
1094
|
+
home = tmp("import-route-stored-creds-");
|
|
1095
|
+
await bootstrapVault(home);
|
|
1096
|
+
fixture = await buildExportFixture();
|
|
1097
|
+
// Write a stored PAT credential matching github.com.
|
|
1098
|
+
writeCredentials({
|
|
1099
|
+
active_method: "pat",
|
|
1100
|
+
github_oauth: null,
|
|
1101
|
+
pat: {
|
|
1102
|
+
token: "ghp_stored_token_xyz",
|
|
1103
|
+
remote_url: "https://x-access-token:ghp_stored_token_xyz@github.com/a/b.git",
|
|
1104
|
+
label: "stored",
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
// Assert the spawn argv carries the stored token (proves the
|
|
1108
|
+
// credentialsFile path resolved).
|
|
1109
|
+
let observedArgv: string[] | null = null;
|
|
1110
|
+
const fakeSpawn: GitSpawn = async (argv) => {
|
|
1111
|
+
observedArgv = argv;
|
|
1112
|
+
const destDir = argv[argv.length - 1]!;
|
|
1113
|
+
cpSync(fixture, destDir, { recursive: true });
|
|
1114
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1115
|
+
};
|
|
1116
|
+
const req = new Request("http://x/import", {
|
|
1117
|
+
method: "POST",
|
|
1118
|
+
body: JSON.stringify({
|
|
1119
|
+
remote_url: "https://github.com/a/b.git",
|
|
1120
|
+
mode: "merge",
|
|
1121
|
+
credentials: null,
|
|
1122
|
+
}),
|
|
1123
|
+
});
|
|
1124
|
+
const res = await handleMirrorImport(req, "default", fakeSpawn);
|
|
1125
|
+
expect(res.status).toBe(200);
|
|
1126
|
+
// The clone URL should have the stored token embedded.
|
|
1127
|
+
expect(observedArgv).not.toBeNull();
|
|
1128
|
+
expect(observedArgv!.some((arg) => arg.includes("ghp_stored_token_xyz"))).toBe(true);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("per-call PAT override is used when supplied", async () => {
|
|
1132
|
+
home = tmp("import-route-per-call-pat-");
|
|
1133
|
+
await bootstrapVault(home);
|
|
1134
|
+
fixture = await buildExportFixture();
|
|
1135
|
+
// Stored credentials should be IGNORED when per-call PAT is supplied.
|
|
1136
|
+
writeCredentials({
|
|
1137
|
+
active_method: "pat",
|
|
1138
|
+
github_oauth: null,
|
|
1139
|
+
pat: {
|
|
1140
|
+
token: "ghp_stored_xyz",
|
|
1141
|
+
remote_url: "https://x-access-token:ghp_stored_xyz@github.com/a/b.git",
|
|
1142
|
+
label: "stored",
|
|
1143
|
+
},
|
|
1144
|
+
});
|
|
1145
|
+
let observedArgv: string[] | null = null;
|
|
1146
|
+
const fakeSpawn: GitSpawn = async (argv) => {
|
|
1147
|
+
observedArgv = argv;
|
|
1148
|
+
const destDir = argv[argv.length - 1]!;
|
|
1149
|
+
cpSync(fixture, destDir, { recursive: true });
|
|
1150
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1151
|
+
};
|
|
1152
|
+
const req = new Request("http://x/import", {
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
body: JSON.stringify({
|
|
1155
|
+
remote_url: "https://github.com/a/b.git",
|
|
1156
|
+
mode: "merge",
|
|
1157
|
+
credentials: { kind: "pat", token: "ghp_per_call_only" },
|
|
1158
|
+
}),
|
|
1159
|
+
});
|
|
1160
|
+
const res = await handleMirrorImport(req, "default", fakeSpawn);
|
|
1161
|
+
expect(res.status).toBe(200);
|
|
1162
|
+
expect(observedArgv).not.toBeNull();
|
|
1163
|
+
const joined = observedArgv!.join(" ");
|
|
1164
|
+
expect(joined).toContain("ghp_per_call_only");
|
|
1165
|
+
expect(joined).not.toContain("ghp_stored_xyz");
|
|
1166
|
+
});
|
|
1167
|
+
});
|
|
1168
|
+
|
package/src/mirror-routes.ts
CHANGED
|
@@ -52,6 +52,17 @@ import {
|
|
|
52
52
|
type FetchLike,
|
|
53
53
|
type GitHubRepoInfo,
|
|
54
54
|
} from "./github-device-flow.ts";
|
|
55
|
+
import {
|
|
56
|
+
CloneFailedError,
|
|
57
|
+
ImportConflictError,
|
|
58
|
+
NotAVaultExportError,
|
|
59
|
+
cloneAndImport,
|
|
60
|
+
type GitSpawn,
|
|
61
|
+
type ImportAuth,
|
|
62
|
+
type ImportResult,
|
|
63
|
+
} from "./mirror-import.ts";
|
|
64
|
+
import { getVaultStore } from "./vault-store.ts";
|
|
65
|
+
import { assetsDir } from "./routes.ts";
|
|
55
66
|
|
|
56
67
|
/**
|
|
57
68
|
* `GET /vault/<name>/.parachute/mirror` — return the persisted config +
|
|
@@ -874,3 +885,207 @@ export async function applyCredentialsToMirror(
|
|
|
874
885
|
// until a repo is selected. Caller is responsible for invoking
|
|
875
886
|
// select-repo separately.
|
|
876
887
|
}
|
|
888
|
+
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
// Import-from-git route — symmetric counterpart to the mirror export work.
|
|
891
|
+
//
|
|
892
|
+
// `POST /vault/<name>/.parachute/mirror/import` — clone a vault export from
|
|
893
|
+
// a git remote and import it into the named vault.
|
|
894
|
+
//
|
|
895
|
+
// Request body:
|
|
896
|
+
// {
|
|
897
|
+
// "remote_url": "https://github.com/aaron/my-vault.git",
|
|
898
|
+
// "mode": "merge" | "replace",
|
|
899
|
+
// "credentials": null
|
|
900
|
+
// | { "kind": "pat", "token": "ghp_..." }
|
|
901
|
+
// | { "kind": "none" }
|
|
902
|
+
// }
|
|
903
|
+
//
|
|
904
|
+
// `credentials: null` means "use the stored mirror credentials." Passing
|
|
905
|
+
// `{kind: "pat", token}` is the one-shot path — token doesn't get persisted.
|
|
906
|
+
//
|
|
907
|
+
// Response:
|
|
908
|
+
// 200 { notes_imported, tags_imported, attachments_imported,
|
|
909
|
+
// notes_deleted?, warnings }
|
|
910
|
+
// 400 { error, error_type, message } — validation / not-a-vault-export
|
|
911
|
+
// 409 { error, error_type, message } — concurrent import for this vault
|
|
912
|
+
// 502 { error, message } — clone failed (auth, network, …)
|
|
913
|
+
//
|
|
914
|
+
// Admin-gated upstream in routing.ts.
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* `POST /vault/<name>/.parachute/mirror/import`. See block comment above.
|
|
919
|
+
*
|
|
920
|
+
* Synchronous response: imports complete in <30s for typical vaults
|
|
921
|
+
* (a 1k-note vault clones+imports in well under that bound on Aaron's hardware).
|
|
922
|
+
* If/when bigger vaults arrive we promote to async polling — for now the
|
|
923
|
+
* synchronous path keeps the UI flow simple.
|
|
924
|
+
*
|
|
925
|
+
* `spawnOverride` is a test seam: lets the test inject a fake git binary.
|
|
926
|
+
* Production callers omit it; `cloneAndImport` falls back to `defaultGitSpawn`.
|
|
927
|
+
*/
|
|
928
|
+
export async function handleMirrorImport(
|
|
929
|
+
req: Request,
|
|
930
|
+
vaultName: string,
|
|
931
|
+
spawnOverride?: GitSpawn,
|
|
932
|
+
): Promise<Response> {
|
|
933
|
+
let body: {
|
|
934
|
+
remote_url?: unknown;
|
|
935
|
+
mode?: unknown;
|
|
936
|
+
credentials?: unknown;
|
|
937
|
+
};
|
|
938
|
+
try {
|
|
939
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
940
|
+
} catch (err) {
|
|
941
|
+
return Response.json(
|
|
942
|
+
{
|
|
943
|
+
error: "Invalid JSON body",
|
|
944
|
+
error_type: "invalid_json",
|
|
945
|
+
message: (err as Error).message ?? String(err),
|
|
946
|
+
},
|
|
947
|
+
{ status: 400 },
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const remote_url =
|
|
952
|
+
typeof body.remote_url === "string" ? body.remote_url.trim() : "";
|
|
953
|
+
if (remote_url.length === 0) {
|
|
954
|
+
return Response.json(
|
|
955
|
+
{
|
|
956
|
+
error: "remote_url required",
|
|
957
|
+
error_type: "validation",
|
|
958
|
+
field: "remote_url",
|
|
959
|
+
message: "Provide an HTTPS or SSH clone URL.",
|
|
960
|
+
},
|
|
961
|
+
{ status: 400 },
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
const mode = body.mode;
|
|
965
|
+
if (mode !== "merge" && mode !== "replace") {
|
|
966
|
+
return Response.json(
|
|
967
|
+
{
|
|
968
|
+
error: "mode invalid",
|
|
969
|
+
error_type: "validation",
|
|
970
|
+
field: "mode",
|
|
971
|
+
message: 'mode must be "merge" or "replace".',
|
|
972
|
+
},
|
|
973
|
+
{ status: 400 },
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Resolve auth shape. The wire format mirrors the internal ImportAuth
|
|
978
|
+
// type, but tightened: only `kind` and `token` cross the wire. `none`
|
|
979
|
+
// and `null` both fall through to "use stored credentials" because the
|
|
980
|
+
// common case is "I configured mirror creds; use them" — and the
|
|
981
|
+
// shorthand keeps the SPA from having to track which kind to send.
|
|
982
|
+
let auth: ImportAuth;
|
|
983
|
+
const creds = body.credentials;
|
|
984
|
+
if (creds === null || creds === undefined) {
|
|
985
|
+
auth = { kind: "credentialsFile" };
|
|
986
|
+
} else if (typeof creds === "object") {
|
|
987
|
+
const credsObj = creds as Record<string, unknown>;
|
|
988
|
+
if (credsObj.kind === "pat") {
|
|
989
|
+
const token = typeof credsObj.token === "string" ? credsObj.token.trim() : "";
|
|
990
|
+
if (token.length === 0) {
|
|
991
|
+
return Response.json(
|
|
992
|
+
{
|
|
993
|
+
error: "credentials.token required",
|
|
994
|
+
error_type: "validation",
|
|
995
|
+
field: "credentials.token",
|
|
996
|
+
message: 'When credentials.kind is "pat", credentials.token must be a non-empty string.',
|
|
997
|
+
},
|
|
998
|
+
{ status: 400 },
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
auth = { kind: "pat", token };
|
|
1002
|
+
} else if (credsObj.kind === "none") {
|
|
1003
|
+
auth = { kind: "none" };
|
|
1004
|
+
} else if (credsObj.kind === "credentialsFile") {
|
|
1005
|
+
auth = { kind: "credentialsFile" };
|
|
1006
|
+
} else {
|
|
1007
|
+
return Response.json(
|
|
1008
|
+
{
|
|
1009
|
+
error: "credentials.kind invalid",
|
|
1010
|
+
error_type: "validation",
|
|
1011
|
+
field: "credentials.kind",
|
|
1012
|
+
message: 'credentials.kind must be "pat", "credentialsFile", or "none". Or pass credentials: null.',
|
|
1013
|
+
},
|
|
1014
|
+
{ status: 400 },
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
} else {
|
|
1018
|
+
return Response.json(
|
|
1019
|
+
{
|
|
1020
|
+
error: "credentials invalid",
|
|
1021
|
+
error_type: "validation",
|
|
1022
|
+
field: "credentials",
|
|
1023
|
+
message: "credentials must be an object or null.",
|
|
1024
|
+
},
|
|
1025
|
+
{ status: 400 },
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Resolve the target vault's store + assets dir. The route is gated on
|
|
1030
|
+
// `vault:<name>:admin`, so we trust vaultName is real by the time we
|
|
1031
|
+
// reach this code path; defensively re-resolve in case the vault was
|
|
1032
|
+
// deleted between auth and now.
|
|
1033
|
+
const store = getVaultStore(vaultName);
|
|
1034
|
+
const assets = assetsDir(vaultName);
|
|
1035
|
+
|
|
1036
|
+
let result: ImportResult;
|
|
1037
|
+
try {
|
|
1038
|
+
result = await cloneAndImport({
|
|
1039
|
+
vaultName,
|
|
1040
|
+
remoteUrl: remote_url,
|
|
1041
|
+
auth,
|
|
1042
|
+
mode,
|
|
1043
|
+
store,
|
|
1044
|
+
assetsDir: assets,
|
|
1045
|
+
spawn: spawnOverride,
|
|
1046
|
+
});
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
if (err instanceof ImportConflictError) {
|
|
1049
|
+
return Response.json(
|
|
1050
|
+
{
|
|
1051
|
+
error: "Import already running",
|
|
1052
|
+
error_type: "concurrent_import",
|
|
1053
|
+
message: err.message,
|
|
1054
|
+
},
|
|
1055
|
+
{ status: 409 },
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
if (err instanceof NotAVaultExportError) {
|
|
1059
|
+
return Response.json(
|
|
1060
|
+
{
|
|
1061
|
+
error: "Not a vault export",
|
|
1062
|
+
error_type: "not_a_vault_export",
|
|
1063
|
+
message: err.message,
|
|
1064
|
+
},
|
|
1065
|
+
{ status: 400 },
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
if (err instanceof CloneFailedError) {
|
|
1069
|
+
return Response.json(
|
|
1070
|
+
{
|
|
1071
|
+
error: "Clone failed",
|
|
1072
|
+
error_type: "clone_failed",
|
|
1073
|
+
message: err.message,
|
|
1074
|
+
},
|
|
1075
|
+
{ status: 502 },
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
return Response.json(
|
|
1079
|
+
{
|
|
1080
|
+
error: "Import failed",
|
|
1081
|
+
error_type: "internal",
|
|
1082
|
+
message: (err as Error).message ?? String(err),
|
|
1083
|
+
},
|
|
1084
|
+
{ status: 500 },
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return Response.json(result, {
|
|
1089
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
1090
|
+
});
|
|
1091
|
+
}
|
package/src/routing.ts
CHANGED
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
handleAuthGithubSelectRepo,
|
|
83
83
|
handleAuthPat,
|
|
84
84
|
handleMirrorGet,
|
|
85
|
+
handleMirrorImport,
|
|
85
86
|
handleMirrorPut,
|
|
86
87
|
handleMirrorRunNow,
|
|
87
88
|
} from "./mirror-routes.ts";
|
|
@@ -556,6 +557,30 @@ export async function route(
|
|
|
556
557
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
557
558
|
}
|
|
558
559
|
|
|
560
|
+
// /.parachute/mirror/import — clone a vault export from git + import.
|
|
561
|
+
// Admin-gated. POST-only. Synchronous (imports finish in <30s for
|
|
562
|
+
// typical vaults). See mirror-routes.ts:handleMirrorImport for the
|
|
563
|
+
// request/response shape + error map. Symmetric counterpart to the
|
|
564
|
+
// export-to-git flow vault#382 + vault#384 shipped.
|
|
565
|
+
if (subpath === "/.parachute/mirror/import") {
|
|
566
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
567
|
+
return Response.json(
|
|
568
|
+
{
|
|
569
|
+
error: "Forbidden",
|
|
570
|
+
error_type: "insufficient_scope",
|
|
571
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
572
|
+
required_scope: SCOPE_ADMIN,
|
|
573
|
+
granted_scopes: auth.scopes,
|
|
574
|
+
},
|
|
575
|
+
{ status: 403 },
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (req.method !== "POST") {
|
|
579
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
580
|
+
}
|
|
581
|
+
return handleMirrorImport(req, vaultName);
|
|
582
|
+
}
|
|
583
|
+
|
|
559
584
|
// /.parachute/mirror/auth/* — UI-configurable git push credentials.
|
|
560
585
|
// GitHub OAuth Device Flow + PAT fallback. All admin-gated; the
|
|
561
586
|
// routes themselves don't carry secrets in their responses
|