@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.
@@ -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
+
@@ -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