movehat 0.2.0 → 0.2.2

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.
Files changed (184) hide show
  1. package/README.md +132 -279
  2. package/dist/__tests__/deployContract.test.js +56 -47
  3. package/dist/__tests__/deployContract.test.js.map +1 -1
  4. package/dist/__tests__/exports.test.d.ts +2 -0
  5. package/dist/__tests__/exports.test.d.ts.map +1 -0
  6. package/dist/__tests__/exports.test.js +30 -0
  7. package/dist/__tests__/exports.test.js.map +1 -0
  8. package/dist/__tests__/fixtures/sigint-deploy-harness.d.ts +4 -3
  9. package/dist/__tests__/fixtures/sigint-deploy-harness.d.ts.map +1 -1
  10. package/dist/__tests__/fixtures/sigint-deploy-harness.js +8 -7
  11. package/dist/__tests__/fixtures/sigint-deploy-harness.js.map +1 -1
  12. package/dist/__tests__/fork/api.test.js +7 -2
  13. package/dist/__tests__/fork/api.test.js.map +1 -1
  14. package/dist/__tests__/fork/api.timeout.test.d.ts +2 -0
  15. package/dist/__tests__/fork/api.timeout.test.d.ts.map +1 -0
  16. package/dist/__tests__/fork/api.timeout.test.js +98 -0
  17. package/dist/__tests__/fork/api.timeout.test.js.map +1 -0
  18. package/dist/__tests__/harness/Harness.proxy.test.js +7 -11
  19. package/dist/__tests__/harness/Harness.proxy.test.js.map +1 -1
  20. package/dist/__tests__/harness/codeObject.deploy.test.js +1 -1
  21. package/dist/__tests__/harness/codeObject.deploy.test.js.map +1 -1
  22. package/dist/__tests__/harness/view.test.js +3 -3
  23. package/dist/commands/__tests__/compile.toml-mutation.test.d.ts +2 -0
  24. package/dist/commands/__tests__/compile.toml-mutation.test.d.ts.map +1 -0
  25. package/dist/commands/__tests__/compile.toml-mutation.test.js +69 -0
  26. package/dist/commands/__tests__/compile.toml-mutation.test.js.map +1 -0
  27. package/dist/commands/__tests__/init.test.js +73 -11
  28. package/dist/commands/__tests__/init.test.js.map +1 -1
  29. package/dist/commands/__tests__/run.test.js +3 -3
  30. package/dist/commands/__tests__/run.test.js.map +1 -1
  31. package/dist/commands/init.d.ts +22 -0
  32. package/dist/commands/init.d.ts.map +1 -1
  33. package/dist/commands/init.js +55 -6
  34. package/dist/commands/init.js.map +1 -1
  35. package/dist/core/AccountManager.d.ts +0 -3
  36. package/dist/core/AccountManager.d.ts.map +1 -1
  37. package/dist/core/AccountManager.js +14 -7
  38. package/dist/core/AccountManager.js.map +1 -1
  39. package/dist/core/Publisher.d.ts +0 -5
  40. package/dist/core/Publisher.d.ts.map +1 -1
  41. package/dist/core/Publisher.js +52 -76
  42. package/dist/core/Publisher.js.map +1 -1
  43. package/dist/core/__tests__/AccountManager.global-state.test.d.ts +2 -0
  44. package/dist/core/__tests__/AccountManager.global-state.test.d.ts.map +1 -0
  45. package/dist/core/__tests__/AccountManager.global-state.test.js +69 -0
  46. package/dist/core/__tests__/AccountManager.global-state.test.js.map +1 -0
  47. package/dist/core/__tests__/movementProfile.test.d.ts +2 -0
  48. package/dist/core/__tests__/movementProfile.test.d.ts.map +1 -0
  49. package/dist/core/__tests__/movementProfile.test.js +112 -0
  50. package/dist/core/__tests__/movementProfile.test.js.map +1 -0
  51. package/dist/core/config.js +6 -5
  52. package/dist/core/config.js.map +1 -1
  53. package/dist/core/contract.d.ts +0 -3
  54. package/dist/core/contract.d.ts.map +1 -1
  55. package/dist/core/contract.js +0 -3
  56. package/dist/core/contract.js.map +1 -1
  57. package/dist/core/deployments.d.ts +0 -6
  58. package/dist/core/deployments.d.ts.map +1 -1
  59. package/dist/core/deployments.js +0 -12
  60. package/dist/core/deployments.js.map +1 -1
  61. package/dist/core/movementProfile.d.ts +55 -22
  62. package/dist/core/movementProfile.d.ts.map +1 -1
  63. package/dist/core/movementProfile.js +77 -99
  64. package/dist/core/movementProfile.js.map +1 -1
  65. package/dist/fork/__tests__/manager.test.js +1 -1
  66. package/dist/fork/__tests__/server.cors.test.d.ts +2 -0
  67. package/dist/fork/__tests__/server.cors.test.d.ts.map +1 -0
  68. package/dist/fork/__tests__/server.cors.test.js +79 -0
  69. package/dist/fork/__tests__/server.cors.test.js.map +1 -0
  70. package/dist/fork/api.d.ts +9 -1
  71. package/dist/fork/api.d.ts.map +1 -1
  72. package/dist/fork/api.js +37 -7
  73. package/dist/fork/api.js.map +1 -1
  74. package/dist/fork/manager.d.ts +1 -21
  75. package/dist/fork/manager.d.ts.map +1 -1
  76. package/dist/fork/manager.js +1 -41
  77. package/dist/fork/manager.js.map +1 -1
  78. package/dist/fork/server.d.ts +20 -1
  79. package/dist/fork/server.d.ts.map +1 -1
  80. package/dist/fork/server.js +19 -9
  81. package/dist/fork/server.js.map +1 -1
  82. package/dist/fork/test.d.ts +0 -1
  83. package/dist/fork/test.d.ts.map +1 -1
  84. package/dist/fork/test.js.map +1 -1
  85. package/dist/harness/Harness.d.ts +11 -13
  86. package/dist/harness/Harness.d.ts.map +1 -1
  87. package/dist/harness/Harness.js +13 -13
  88. package/dist/harness/Harness.js.map +1 -1
  89. package/dist/harness/codeObject.d.ts.map +1 -1
  90. package/dist/harness/codeObject.js +31 -38
  91. package/dist/harness/codeObject.js.map +1 -1
  92. package/dist/harness/script.d.ts +3 -3
  93. package/dist/harness/script.d.ts.map +1 -1
  94. package/dist/harness/script.js +33 -29
  95. package/dist/harness/script.js.map +1 -1
  96. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.d.ts +2 -0
  97. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.d.ts.map +1 -0
  98. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.js +172 -0
  99. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.js.map +1 -0
  100. package/dist/helpers/setupLocalTesting.d.ts +1 -2
  101. package/dist/helpers/setupLocalTesting.d.ts.map +1 -1
  102. package/dist/helpers/setupLocalTesting.js +28 -2
  103. package/dist/helpers/setupLocalTesting.js.map +1 -1
  104. package/dist/index.d.ts +1 -0
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +0 -1
  107. package/dist/index.js.map +1 -1
  108. package/dist/node/LocalNodeManager.d.ts +8 -0
  109. package/dist/node/LocalNodeManager.d.ts.map +1 -1
  110. package/dist/node/LocalNodeManager.js +10 -1
  111. package/dist/node/LocalNodeManager.js.map +1 -1
  112. package/dist/node/__tests__/LocalNodeManager.api-port.test.d.ts +2 -0
  113. package/dist/node/__tests__/LocalNodeManager.api-port.test.d.ts.map +1 -0
  114. package/dist/node/__tests__/LocalNodeManager.api-port.test.js +55 -0
  115. package/dist/node/__tests__/LocalNodeManager.api-port.test.js.map +1 -0
  116. package/dist/node/__tests__/LocalNodeManager.test.js +4 -3
  117. package/dist/node/__tests__/LocalNodeManager.test.js.map +1 -1
  118. package/dist/runtime.d.ts.map +1 -1
  119. package/dist/runtime.js +1 -3
  120. package/dist/runtime.js.map +1 -1
  121. package/dist/templates/move/Move.toml +1 -1
  122. package/dist/templates/move/sources/Counter.move +31 -4
  123. package/dist/templates/scripts/deploy-counter.ts +11 -1
  124. package/dist/templates/tests/Counter.test.ts +2 -2
  125. package/dist/types/config.d.ts +8 -1
  126. package/dist/types/config.d.ts.map +1 -1
  127. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.d.ts +2 -0
  128. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.d.ts.map +1 -0
  129. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.js +43 -0
  130. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.js.map +1 -0
  131. package/dist/utils/address.d.ts +0 -4
  132. package/dist/utils/address.d.ts.map +1 -1
  133. package/dist/utils/address.js +0 -4
  134. package/dist/utils/address.js.map +1 -1
  135. package/dist/utils/childProcessAdapter.d.ts +7 -0
  136. package/dist/utils/childProcessAdapter.d.ts.map +1 -1
  137. package/dist/utils/childProcessAdapter.js +23 -6
  138. package/dist/utils/childProcessAdapter.js.map +1 -1
  139. package/package.json +2 -1
  140. package/src/__tests__/deployContract.test.ts +59 -50
  141. package/src/__tests__/exports.test.ts +32 -0
  142. package/src/__tests__/fixtures/sigint-deploy-harness.ts +8 -7
  143. package/src/__tests__/fork/api.test.ts +7 -2
  144. package/src/__tests__/fork/api.timeout.test.ts +150 -0
  145. package/src/__tests__/harness/Harness.proxy.test.ts +7 -11
  146. package/src/__tests__/harness/codeObject.deploy.test.ts +1 -1
  147. package/src/__tests__/harness/view.test.ts +3 -3
  148. package/src/commands/__tests__/compile.toml-mutation.test.ts +77 -0
  149. package/src/commands/__tests__/init.test.ts +96 -11
  150. package/src/commands/__tests__/run.test.ts +3 -3
  151. package/src/commands/init.ts +77 -6
  152. package/src/core/AccountManager.ts +18 -13
  153. package/src/core/Publisher.ts +58 -85
  154. package/src/core/__tests__/AccountManager.global-state.test.ts +83 -0
  155. package/src/core/__tests__/movementProfile.test.ts +131 -0
  156. package/src/core/config.ts +9 -5
  157. package/src/core/contract.ts +0 -3
  158. package/src/core/deployments.ts +0 -12
  159. package/src/core/movementProfile.ts +75 -127
  160. package/src/fork/__tests__/manager.test.ts +1 -1
  161. package/src/fork/__tests__/server.cors.test.ts +101 -0
  162. package/src/fork/api.ts +69 -10
  163. package/src/fork/manager.ts +1 -41
  164. package/src/fork/server.ts +38 -9
  165. package/src/fork/test.ts +0 -1
  166. package/src/harness/Harness.ts +16 -13
  167. package/src/harness/codeObject.ts +38 -48
  168. package/src/harness/script.ts +40 -39
  169. package/src/helpers/__tests__/setupLocalTesting.fork-network.test.ts +212 -0
  170. package/src/helpers/setupLocalTesting.ts +37 -4
  171. package/src/index.ts +9 -2
  172. package/src/node/LocalNodeManager.ts +24 -2
  173. package/src/node/__tests__/LocalNodeManager.api-port.test.ts +62 -0
  174. package/src/node/__tests__/LocalNodeManager.test.ts +5 -4
  175. package/src/runtime.ts +1 -3
  176. package/src/templates/move/Move.toml +1 -1
  177. package/src/templates/move/sources/Counter.move +31 -4
  178. package/src/templates/scripts/deploy-counter.ts +11 -1
  179. package/src/templates/tests/Counter.test.ts +2 -2
  180. package/src/types/config.ts +8 -1
  181. package/src/types/runtime.ts +2 -2
  182. package/src/utils/__tests__/childProcessAdapter.maxBuffer.test.ts +51 -0
  183. package/src/utils/address.ts +0 -4
  184. package/src/utils/childProcessAdapter.ts +35 -6
@@ -1,154 +1,102 @@
1
- import {
2
- chmodSync,
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- renameSync,
7
- unlinkSync,
8
- writeFileSync,
9
- } from "fs";
10
- import { readFile } from "fs/promises";
11
- import { dirname } from "path";
1
+ import { chmodSync, existsSync, unlinkSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
12
4
  import { randomUUID } from "crypto";
13
- import * as yaml from "js-yaml";
14
5
 
15
6
  /**
16
- * Shared helpers for working with the Movement CLI's `~/.aptos/config.yaml`
17
- * profile file and the SIGINT/SIGTERM cleanup pipeline.
7
+ * Per-deploy private-key file management and SIGINT/SIGTERM cleanup
8
+ * infrastructure shared across the Movement CLI invocations (`move
9
+ * publish`, `move deploy-object`, `move upgrade-object`, `move
10
+ * run-script`).
18
11
  *
19
- * Extracted from `core/Publisher.ts` so both the existing `move publish`
20
- * flow (`Publisher`) and the new `move deploy-object` / `upgrade-object`
21
- * flows (`harness/codeObject.ts`) can share the bug #36 / #37 / #43
22
- * hardening without duplicating it.
12
+ * **Why a temp key file rather than the CLI's profile yaml?** The
13
+ * Movement CLI's `--profile <name>` flag requires a config.yaml to
14
+ * exist in the working directory (older Movement CLI variants look
15
+ * for `<cwd>/.aptos/config.yaml`; newer variants look for
16
+ * `<cwd>/.movement/config.yaml`). On fresh user installs neither
17
+ * directory exists, and the CLI errors out before it would otherwise
18
+ * fall back to `~/.aptos/config.yaml`. Writing to a temp file and
19
+ * passing `--private-key-file <path> --sender-account <addr>`
20
+ * directly avoids the entire profile-yaml lookup chain — no CWD
21
+ * dependency, no CLI-variant dependency.
22
+ *
23
+ * The same SIGINT-safe cleanup pattern applies as the old profile
24
+ * flow: the temp key file persists private key material on disk and
25
+ * MUST be removed before process exit even on abnormal termination.
23
26
  *
24
27
  * @internal — not exported from `src/index.ts`.
25
28
  */
26
29
 
27
30
  /**
28
- * In-process serializer for `~/.aptos/config.yaml` mutations. Without it,
29
- * two concurrent profile writes would race in the read-modify-write cycle
30
- * and the second writer would silently drop the first's profile. See #37.
31
+ * Write the private key to a `mode 0o600` file in the system temp
32
+ * directory and return the path. The filename is UUID-suffixed so
33
+ * concurrent deploys don't collide.
34
+ *
35
+ * The key string is written verbatim — callers should format to
36
+ * AIP-80 before calling (see `PrivateKey.formatPrivateKey` from
37
+ * `@aptos-labs/ts-sdk`).
31
38
  */
32
- let yamlLock: Promise<unknown> = Promise.resolve();
33
- export function withYamlLock<T>(fn: () => Promise<T>): Promise<T> {
34
- const prev = yamlLock;
35
- // .then(success, failure) continue even if the previous holder rejected,
36
- // so a failure in one deploy doesn't poison the lock for the others.
37
- const next = prev.then(
38
- () => fn(),
39
- () => fn()
40
- );
41
- yamlLock = next.catch(() => {}); // swallow on the shared chain; caller still gets the original
42
- return next;
43
- }
44
-
45
- export interface ProfileData {
46
- private_key: string;
47
- public_key: string;
48
- account: string;
49
- rest_url: string;
50
- }
51
-
52
- interface AptosConfigYaml {
53
- profiles?: Record<string, ProfileData>;
54
- [key: string]: unknown;
39
+ export function writeTempKeyFile(privateKey: string): string {
40
+ const path = join(tmpdir(), `movehat-key-${randomUUID()}`);
41
+ // mode in the open() call may be filtered by the process umask
42
+ // (typically 0o022 resulting perms 0o644). chmod after write
43
+ // is defense in depth so the file can never be observable as
44
+ // group-/world-readable while it carries the private key.
45
+ writeFileSync(path, privateKey, { mode: 0o600 });
46
+ chmodSync(path, 0o600);
47
+ return path;
55
48
  }
56
49
 
57
50
  /**
58
- * Atomic write: write payload to a temp sibling chmod the temp to 0o600
59
- * rename over the target. The chmod-before-rename order eliminates a
60
- * window where the target file could be observable with default umask
61
- * perms (typically 0o644) while carrying the private key.
62
- */
63
- function atomicWriteYaml(path: string, content: string): void {
64
- const tmpPath = `${path}.tmp.${randomUUID().slice(0, 8)}`;
65
- writeFileSync(tmpPath, content, { mode: 0o600 });
66
- chmodSync(tmpPath, 0o600); // defense in depth in case umask filtered the open mode
67
- renameSync(tmpPath, path);
68
- }
69
-
70
- /** Add the deploy's profile to ~/.aptos/config.yaml. Creates the file if absent. */
71
- export async function addProfile(
72
- configPath: string,
73
- name: string,
74
- data: ProfileData
75
- ): Promise<void> {
76
- const configDir = dirname(configPath);
77
- if (!existsSync(configDir)) {
78
- mkdirSync(configDir, { recursive: true, mode: 0o700 });
79
- }
80
- let yamlObj: AptosConfigYaml = {};
81
- if (existsSync(configPath)) {
82
- const raw = await readFile(configPath, "utf-8");
83
- yamlObj = (yaml.load(raw) as AptosConfigYaml) || {};
84
- }
85
- if (!yamlObj.profiles) yamlObj.profiles = {};
86
- yamlObj.profiles[name] = data;
87
- atomicWriteYaml(configPath, yaml.dump(yamlObj));
88
- }
89
-
90
- /**
91
- * Remove the deploy's profile from ~/.aptos/config.yaml. Idempotent —
92
- * a missing file or missing profile is a no-op. If removal leaves the
93
- * yaml with only an empty `profiles:` block, the whole file is unlinked
94
- * to preserve the "didn't exist before" semantic for the first-ever deploy.
51
+ * Sync unlink for SIGINT/SIGTERM handlers never throws, never logs.
52
+ * The event loop is dead by the time this runs; observability isn't
53
+ * possible. Worst case: a stale 0o600 temp file in `os.tmpdir()`
54
+ * that the OS reaps eventually.
55
+ *
56
+ * **Do not use this from a normal `finally` cleanup path** — failures
57
+ * become invisible there, hiding the case where a private-key temp
58
+ * file persists on disk after a deploy. Use {@link removeKeyFile}
59
+ * instead for those paths.
95
60
  */
96
- export async function removeProfile(configPath: string, name: string): Promise<void> {
97
- if (!existsSync(configPath)) return;
98
- const raw = await readFile(configPath, "utf-8");
99
- const yamlObj: AptosConfigYaml = (yaml.load(raw) as AptosConfigYaml) || {};
100
- if (!yamlObj.profiles || !(name in yamlObj.profiles)) return;
101
- delete yamlObj.profiles[name];
102
-
103
- const profilesEmpty = Object.keys(yamlObj.profiles).length === 0;
104
- const onlyProfilesKey =
105
- Object.keys(yamlObj).length === 1 && "profiles" in yamlObj;
106
- if (profilesEmpty && onlyProfilesKey) {
107
- // We created this file fresh; remove it.
108
- try {
109
- unlinkSync(configPath);
110
- } catch {
111
- // best-effort
112
- }
113
- return;
61
+ export function removeKeyFileSyncBestEffort(path: string): void {
62
+ try {
63
+ unlinkSync(path);
64
+ } catch {
65
+ // event loop dead best-effort
114
66
  }
115
- atomicWriteYaml(configPath, yaml.dump(yamlObj));
116
67
  }
117
68
 
118
69
  /**
119
- * Synchronous twin of `removeProfile` for the SIGINT/SIGTERM handler.
120
- * The event loop is dead by the time the handler runs we cannot
121
- * await. Bypasses the async mutex because signal handlers are
122
- * sequential by construction; the operation is idempotent so a
123
- * benign double-delete (handler then finally, or vice versa) is fine.
70
+ * Sync unlink for the normal cleanup path (a `finally` block after the
71
+ * Movement CLI invocation returns). Returns `null` on successeither
72
+ * the file was removed, or it was already gone (ENOENT is treated as
73
+ * benign success). Returns an `Error` only when the file **still
74
+ * exists on disk** after the unlink attempt failed (EPERM, EACCES,
75
+ * EBUSY, EISDIR if the path collided with a directory, etc.).
76
+ *
77
+ * Callers SHOULD `logger.warning` when a non-null Error is returned —
78
+ * a private-key temp file would otherwise persist silently.
124
79
  */
125
- export function removeProfileSync(configPath: string, name: string): void {
80
+ export function removeKeyFile(path: string): Error | null {
126
81
  try {
127
- if (!existsSync(configPath)) return;
128
- const raw = readFileSync(configPath, "utf-8");
129
- const yamlObj: AptosConfigYaml = (yaml.load(raw) as AptosConfigYaml) || {};
130
- if (!yamlObj.profiles || !(name in yamlObj.profiles)) return;
131
- delete yamlObj.profiles[name];
132
-
133
- const profilesEmpty = Object.keys(yamlObj.profiles).length === 0;
134
- const onlyProfilesKey =
135
- Object.keys(yamlObj).length === 1 && "profiles" in yamlObj;
136
- if (profilesEmpty && onlyProfilesKey) {
137
- unlinkSync(configPath);
138
- return;
139
- }
140
- atomicWriteYaml(configPath, yaml.dump(yamlObj));
141
- } catch {
142
- // Signal handlers should never throw — swallow and exit. Better to
143
- // leave a stale profile (recoverable by re-running the deploy) than
144
- // to crash the parent process mid-shutdown.
82
+ unlinkSync(path);
83
+ return null;
84
+ } catch (err) {
85
+ const code = (err as NodeJS.ErrnoException).code;
86
+ if (code === "ENOENT") return null;
87
+ // The unlink call failed but verify the file actually still exists
88
+ // before declaring this preocupante — some races (parallel cleanup,
89
+ // tmpdir reaper) can race with us and the file may already be gone
90
+ // despite the syscall reporting an unexpected error.
91
+ if (!existsSync(path)) return null;
92
+ return err instanceof Error ? err : new Error(String(err));
145
93
  }
146
94
  }
147
95
 
148
96
  /**
149
97
  * Process-level signal handling. A single registered handler iterates
150
- * the per-deploy cleanup callbacks. Install-once because multiple
151
- * concurrent deploys share the same parent process — installing per
98
+ * the per-deploy cleanup callbacks. Installed once per process because
99
+ * multiple concurrent deploys share the same parent — installing per
152
100
  * deploy would re-add the listener and exceed Node's max-listeners
153
101
  * warning threshold under heavy parallelism.
154
102
  */
@@ -159,7 +107,7 @@ export function ensureSignalHandler(): void {
159
107
  if (signalHandlerInstalled) return;
160
108
  signalHandlerInstalled = true;
161
109
  const handler = (sig: NodeJS.Signals) => {
162
- // Synchronous cleanup of every active deploy's profile entry.
110
+ // Synchronous cleanup of every active deploy's resources.
163
111
  for (const cb of [...cleanupCallbacks]) {
164
112
  try {
165
113
  cb();
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  /**
7
- * Tests for `ForkManager` (M3.3). Strategy:
7
+ * Tests for `ForkManager`. Strategy:
8
8
  * - `vi.mock` the upstream `MovementApiClient` (network layer) so
9
9
  * tests stay offline and deterministic.
10
10
  * - Use a REAL `ForkStorage` against a tmpdir (storage is already
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { AddressInfo } from "node:net";
6
+
7
+ import { ForkServer } from "../server.js";
8
+
9
+ /**
10
+ * F2 — ForkServer must not advertise `Access-Control-Allow-Origin: *`
11
+ * by default. Cached fork state may include account resources fetched
12
+ * from authenticated upstream nodes; any web page open in the dev's
13
+ * browser should not be able to read it through the loopback listener.
14
+ *
15
+ * Default contract:
16
+ * - No CORS header emitted unless the caller opts in via
17
+ * `corsAllowOrigins`.
18
+ * - When opted in, only requests whose `Origin` is in the allowlist
19
+ * receive a matching `Access-Control-Allow-Origin` (echo of origin,
20
+ * not `*`).
21
+ */
22
+
23
+ function makeForkDir(): string {
24
+ const dir = mkdtempSync(join(tmpdir(), "movehat-fork-cors-"));
25
+ mkdirSync(join(dir, "resources"), { recursive: true });
26
+ writeFileSync(
27
+ join(dir, "metadata.json"),
28
+ JSON.stringify({
29
+ network: "test",
30
+ nodeUrl: "http://example.invalid/v1",
31
+ chainId: 0,
32
+ ledgerVersion: "0",
33
+ timestamp: "0",
34
+ epoch: "0",
35
+ blockHeight: "0",
36
+ createdAt: new Date().toISOString(),
37
+ }),
38
+ );
39
+ writeFileSync(join(dir, "accounts.json"), "{}");
40
+ return dir;
41
+ }
42
+
43
+ function boundPort(server: ForkServer): number {
44
+ const internal = (server as unknown as {
45
+ server: { address(): AddressInfo };
46
+ }).server;
47
+ const addr = internal.address();
48
+ return addr.port;
49
+ }
50
+
51
+ async function fetchFromServer(
52
+ port: number,
53
+ origin?: string
54
+ ): Promise<Response> {
55
+ const headers: Record<string, string> = {};
56
+ if (origin !== undefined) headers.Origin = origin;
57
+ return fetch(`http://127.0.0.1:${port}/v1/`, { headers });
58
+ }
59
+
60
+ describe("F2 — ForkServer CORS is closed by default", () => {
61
+ let forkDir: string;
62
+ let server: ForkServer | null = null;
63
+
64
+ beforeEach(() => {
65
+ forkDir = makeForkDir();
66
+ });
67
+
68
+ afterEach(async () => {
69
+ if (server) {
70
+ await server.stop();
71
+ server = null;
72
+ }
73
+ rmSync(forkDir, { recursive: true, force: true });
74
+ });
75
+
76
+ it("does not emit Access-Control-Allow-Origin by default", async () => {
77
+ server = new ForkServer(forkDir, 0);
78
+ await server.start();
79
+ const port = boundPort(server);
80
+
81
+ const res = await fetchFromServer(port, "https://evil.example");
82
+ expect(res.status).toBe(200);
83
+ expect(res.headers.get("access-control-allow-origin")).toBeNull();
84
+ });
85
+
86
+ it("echoes only allow-listed origins when corsAllowOrigins is set", async () => {
87
+ server = new ForkServer(forkDir, 0, "127.0.0.1", {
88
+ corsAllowOrigins: ["https://trusted.example"],
89
+ });
90
+ await server.start();
91
+ const port = boundPort(server);
92
+
93
+ const allowed = await fetchFromServer(port, "https://trusted.example");
94
+ expect(allowed.headers.get("access-control-allow-origin")).toBe(
95
+ "https://trusted.example"
96
+ );
97
+
98
+ const denied = await fetchFromServer(port, "https://evil.example");
99
+ expect(denied.headers.get("access-control-allow-origin")).toBeNull();
100
+ });
101
+ });
package/src/fork/api.ts CHANGED
@@ -4,6 +4,16 @@ import { URL } from 'url';
4
4
  import type { LedgerInfo, AccountData, AccountResource } from '../types/fork.js';
5
5
  import { normalizeAddressShort } from '../utils/address.js';
6
6
 
7
+ export interface MovementApiClientOptions {
8
+ /** Abort the request after this many ms (default: 30_000). */
9
+ timeoutMs?: number;
10
+ /** Reject responses larger than this many bytes (default: 16 MiB). */
11
+ maxBytes?: number;
12
+ }
13
+
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
16
+
7
17
  /**
8
18
  * Client for interacting with Movement L1 JSON API.
9
19
  *
@@ -15,8 +25,14 @@ import { normalizeAddressShort } from '../utils/address.js';
15
25
  export class MovementApiClient {
16
26
  private nodeUrl: string;
17
27
  private readonly apiKey?: string;
18
-
19
- constructor(nodeUrl: string, apiKey?: string) {
28
+ private readonly timeoutMs: number;
29
+ private readonly maxBytes: number;
30
+
31
+ constructor(
32
+ nodeUrl: string,
33
+ apiKey?: string,
34
+ options: MovementApiClientOptions = {}
35
+ ) {
20
36
  // Remove trailing slash
21
37
  let normalized = nodeUrl.replace(/\/$/, '');
22
38
 
@@ -28,6 +44,8 @@ export class MovementApiClient {
28
44
 
29
45
  this.nodeUrl = normalized;
30
46
  if (apiKey !== undefined) this.apiKey = apiKey;
47
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
48
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
31
49
  }
32
50
 
33
51
  /**
@@ -52,31 +70,72 @@ export class MovementApiClient {
52
70
  requestOptions.headers = { Authorization: `Bearer ${this.apiKey}` };
53
71
  }
54
72
 
73
+ const timeoutMs = this.timeoutMs;
74
+ const maxBytes = this.maxBytes;
75
+
55
76
  return new Promise((resolve, reject) => {
56
- const req = client.get(fullUrl, requestOptions, (res) => {
57
- let data = '';
77
+ let settled = false;
78
+ const settle = (fn: () => void) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ fn();
82
+ };
58
83
 
59
- res.on('data', (chunk) => {
60
- data += chunk;
84
+ const req = client.get(fullUrl, requestOptions, (res) => {
85
+ const chunks: Buffer[] = [];
86
+ let totalBytes = 0;
87
+
88
+ res.on('data', (chunk: Buffer | string) => {
89
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
90
+ totalBytes += buf.length;
91
+ if (totalBytes > maxBytes) {
92
+ req.destroy();
93
+ settle(() =>
94
+ reject(
95
+ new Error(
96
+ `Response exceeded maxBytes (${maxBytes}); ${totalBytes} bytes received before abort`
97
+ )
98
+ )
99
+ );
100
+ return;
101
+ }
102
+ chunks.push(buf);
61
103
  });
62
104
 
63
105
  res.on('end', () => {
106
+ if (settled) return;
107
+ const data = Buffer.concat(chunks).toString('utf8');
64
108
  if (res.statusCode !== 200) {
65
- reject(new Error(`API request failed with status ${res.statusCode}: ${data}`));
109
+ settle(() =>
110
+ reject(
111
+ new Error(
112
+ `API request failed with status ${res.statusCode}: ${data}`
113
+ )
114
+ )
115
+ );
66
116
  return;
67
117
  }
68
118
 
69
119
  try {
70
120
  const parsed = JSON.parse(data);
71
- resolve(parsed);
121
+ settle(() => resolve(parsed));
72
122
  } catch (err) {
73
- reject(new Error(`Failed to parse JSON response: ${err}`));
123
+ settle(() =>
124
+ reject(new Error(`Failed to parse JSON response: ${err}`))
125
+ );
74
126
  }
75
127
  });
76
128
  });
77
129
 
130
+ req.setTimeout(timeoutMs, () => {
131
+ req.destroy();
132
+ settle(() =>
133
+ reject(new Error(`API request timed out after ${timeoutMs}ms`))
134
+ );
135
+ });
136
+
78
137
  req.on('error', (err) => {
79
- reject(new Error(`API request failed: ${err.message}`));
138
+ settle(() => reject(new Error(`API request failed: ${err.message}`)));
80
139
  });
81
140
 
82
141
  req.end();
@@ -70,16 +70,12 @@ export class ForkManager {
70
70
  ): Promise<void> {
71
71
  if (apiKey !== undefined) this.apiKey = apiKey;
72
72
 
73
- // Create API client (with optional Authorization header)
74
73
  this.apiClient = new MovementApiClient(nodeUrl, this.apiKey);
75
74
 
76
- // Fetch network info
77
75
  const ledgerInfo = await this.apiClient.getLedgerInfo();
78
76
 
79
- // Create fork structure
80
77
  this.storage.initialize();
81
78
 
82
- // Save metadata
83
79
  this.metadata = {
84
80
  network: networkName,
85
81
  nodeUrl,
@@ -110,9 +106,6 @@ export class ForkManager {
110
106
  this.apiClient = new MovementApiClient(this.metadata.nodeUrl, this.apiKey);
111
107
  }
112
108
 
113
- /**
114
- * Get fork metadata
115
- */
116
109
  getMetadata(): ForkMetadata {
117
110
  if (!this.metadata) {
118
111
  this.metadata = this.storage.loadMetadata();
@@ -120,18 +113,12 @@ export class ForkManager {
120
113
  return this.metadata;
121
114
  }
122
115
 
123
- /**
124
- * Get account state (with lazy loading)
125
- */
126
116
  async getAccount(address: string): Promise<AccountState> {
127
- // Normalize address
128
117
  const normalizedAddress = normalizeAddress(address);
129
118
 
130
- // Check cache first
131
119
  let accountState = this.storage.getAccount(normalizedAddress);
132
120
 
133
121
  if (!accountState) {
134
- // Fetch from network
135
122
  if (!this.apiClient) {
136
123
  throw new Error('Fork not initialized. Call initialize() or load() first.');
137
124
  }
@@ -144,7 +131,6 @@ export class ForkManager {
144
131
  authenticationKey: accountData.authentication_key,
145
132
  };
146
133
 
147
- // Cache it
148
134
  this.storage.saveAccount(normalizedAddress, accountState);
149
135
  console.log(` ✓ Cached account ${normalizedAddress}`);
150
136
  }
@@ -152,17 +138,12 @@ export class ForkManager {
152
138
  return accountState;
153
139
  }
154
140
 
155
- /**
156
- * Get a specific resource (with lazy loading)
157
- */
158
141
  async getResource(address: string, resourceType: string): Promise<any> {
159
142
  const normalizedAddress = normalizeAddress(address);
160
143
 
161
- // Check cache first
162
144
  let resource = this.storage.getResource(normalizedAddress, resourceType);
163
145
 
164
146
  if (!resource) {
165
- // Fetch from network
166
147
  if (!this.apiClient) {
167
148
  throw new Error('Fork not initialized. Call initialize() or load() first.');
168
149
  }
@@ -173,7 +154,6 @@ export class ForkManager {
173
154
  const resourceData = await this.apiClient.getAccountResource(normalizedAddress, resourceType);
174
155
  resource = resourceData.data;
175
156
 
176
- // Cache it
177
157
  this.storage.saveResource(normalizedAddress, resourceType, resource);
178
158
  console.log(` ✓ Cached resource ${resourceType}`);
179
159
  } catch (error) {
@@ -188,16 +168,11 @@ export class ForkManager {
188
168
  return resource;
189
169
  }
190
170
 
191
- /**
192
- * Get all resources for an account (with lazy loading)
193
- */
194
171
  async getAllResources(address: string): Promise<Record<string, any>> {
195
172
  const normalizedAddress = normalizeAddress(address);
196
173
 
197
- // Check if we have any cached resources
198
174
  let resources = this.storage.getAllResources(normalizedAddress);
199
175
 
200
- // If no cached resources, fetch all from network
201
176
  if (Object.keys(resources).length === 0) {
202
177
  if (!this.apiClient) {
203
178
  throw new Error('Fork not initialized. Call initialize() or load() first.');
@@ -211,7 +186,6 @@ export class ForkManager {
211
186
  resources[resource.type] = resource.data;
212
187
  }
213
188
 
214
- // Cache them
215
189
  this.storage.saveAllResources(normalizedAddress, resources);
216
190
  console.log(` ✓ Cached ${Object.keys(resources).length} resources`);
217
191
  }
@@ -219,18 +193,13 @@ export class ForkManager {
219
193
  return resources;
220
194
  }
221
195
 
222
- /**
223
- * Set a resource value (for testing/mocking)
224
- */
225
196
  async setResource(address: string, resourceType: string, data: unknown): Promise<void> {
226
197
  const normalizedAddress = normalizeAddress(address);
227
198
  this.storage.saveResource(normalizedAddress, resourceType, data);
228
199
  console.log(` ✓ Updated resource ${resourceType} for ${normalizedAddress}`);
229
200
  }
230
201
 
231
- /**
232
- * Fund an account with coins (adds to existing balance)
233
- */
202
+ /** Adds to the existing balance rather than replacing it. */
234
203
  async fundAccount(address: string, amount: number, coinType: string = '0x1::aptos_coin::AptosCoin'): Promise<void> {
235
204
  const normalizedAddress = normalizeAddress(address);
236
205
  const resourceType = `0x1::coin::CoinStore<${coinType}>`;
@@ -250,7 +219,6 @@ export class ForkManager {
250
219
  throw error;
251
220
  }
252
221
 
253
- // If doesn't exist, create new one
254
222
  coinStore = {
255
223
  coin: { value: '0' },
256
224
  deposit_events: {
@@ -275,15 +243,12 @@ export class ForkManager {
275
243
  };
276
244
  }
277
245
 
278
- // Add to existing balance (instead of replacing it)
279
246
  const currentBalance = BigInt(coinStore.coin.value ?? '0');
280
247
  const newBalance = currentBalance + BigInt(amount);
281
248
  coinStore.coin.value = newBalance.toString();
282
249
 
283
- // Save
284
250
  await this.setResource(normalizedAddress, resourceType, coinStore);
285
251
 
286
- // Also ensure account exists
287
252
  let account = this.storage.getAccount(normalizedAddress);
288
253
  if (!account) {
289
254
  account = {
@@ -296,9 +261,6 @@ export class ForkManager {
296
261
  console.log(` ✓ Funded ${normalizedAddress} with ${amount} coins`);
297
262
  }
298
263
 
299
- /**
300
- * List all accounts in the fork
301
- */
302
264
  listAccounts(): string[] {
303
265
  return this.storage.listAccounts();
304
266
  }
@@ -364,11 +326,9 @@ export class ForkManager {
364
326
  async getOrCreateAccount(address: string): Promise<AccountState> {
365
327
  const normalizedAddress = normalizeAddress(address);
366
328
 
367
- // Try to get existing account
368
329
  try {
369
330
  return await this.getAccount(normalizedAddress);
370
331
  } catch (error) {
371
- // If account doesn't exist, create a minimal one
372
332
  const newAccount: AccountState = {
373
333
  sequenceNumber: '0',
374
334
  authenticationKey: forkAuthKeyPlaceholder(normalizedAddress),