@freqhole/haruspex 0.0.0

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 (250) hide show
  1. package/dist/flows/add-peer/machine.d.ts +36 -0
  2. package/dist/flows/add-peer/machine.d.ts.map +1 -0
  3. package/dist/flows/add-peer/machine.js +500 -0
  4. package/dist/flows/add-peer/machine.test.d.ts +2 -0
  5. package/dist/flows/add-peer/machine.test.d.ts.map +1 -0
  6. package/dist/flows/add-peer/machine.test.js +421 -0
  7. package/dist/flows/add-peer/runner.d.ts +16 -0
  8. package/dist/flows/add-peer/runner.d.ts.map +1 -0
  9. package/dist/flows/add-peer/runner.js +349 -0
  10. package/dist/flows/add-peer/runner.test.d.ts +2 -0
  11. package/dist/flows/add-peer/runner.test.d.ts.map +1 -0
  12. package/dist/flows/add-peer/runner.test.js +205 -0
  13. package/dist/flows/add-peer/types.d.ts +271 -0
  14. package/dist/flows/add-peer/types.d.ts.map +1 -0
  15. package/dist/flows/add-peer/types.js +10 -0
  16. package/dist/flows/index.d.ts +6 -0
  17. package/dist/flows/index.d.ts.map +1 -0
  18. package/dist/flows/index.js +6 -0
  19. package/dist/identity/idbStore.d.ts +51 -0
  20. package/dist/identity/idbStore.d.ts.map +1 -0
  21. package/dist/identity/idbStore.js +183 -0
  22. package/dist/identity/idbStore.test.d.ts +2 -0
  23. package/dist/identity/idbStore.test.d.ts.map +1 -0
  24. package/dist/identity/idbStore.test.js +103 -0
  25. package/dist/identity/index.d.ts +8 -0
  26. package/dist/identity/index.d.ts.map +1 -0
  27. package/dist/identity/index.js +3 -0
  28. package/dist/identity/resolve.d.ts +31 -0
  29. package/dist/identity/resolve.d.ts.map +1 -0
  30. package/dist/identity/resolve.js +37 -0
  31. package/dist/identity/resolve.test.d.ts +2 -0
  32. package/dist/identity/resolve.test.d.ts.map +1 -0
  33. package/dist/identity/resolve.test.js +105 -0
  34. package/dist/identity/types.d.ts +21 -0
  35. package/dist/identity/types.d.ts.map +1 -0
  36. package/dist/identity/types.js +1 -0
  37. package/dist/identity/webLocks.d.ts +22 -0
  38. package/dist/identity/webLocks.d.ts.map +1 -0
  39. package/dist/identity/webLocks.js +67 -0
  40. package/dist/identity/webLocks.test.d.ts +2 -0
  41. package/dist/identity/webLocks.test.d.ts.map +1 -0
  42. package/dist/identity/webLocks.test.js +125 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/index.test.d.ts +2 -0
  47. package/dist/index.test.d.ts.map +1 -0
  48. package/dist/index.test.js +7 -0
  49. package/dist/knock/index.d.ts +10 -0
  50. package/dist/knock/index.d.ts.map +1 -0
  51. package/dist/knock/index.js +5 -0
  52. package/dist/knock/pending-checker.d.ts +19 -0
  53. package/dist/knock/pending-checker.d.ts.map +1 -0
  54. package/dist/knock/pending-checker.js +27 -0
  55. package/dist/knock/pending-checker.test.d.ts +2 -0
  56. package/dist/knock/pending-checker.test.d.ts.map +1 -0
  57. package/dist/knock/pending-checker.test.js +93 -0
  58. package/dist/knock/requester.d.ts +26 -0
  59. package/dist/knock/requester.d.ts.map +1 -0
  60. package/dist/knock/requester.js +72 -0
  61. package/dist/knock/requester.test.d.ts +2 -0
  62. package/dist/knock/requester.test.d.ts.map +1 -0
  63. package/dist/knock/requester.test.js +108 -0
  64. package/dist/knock/responder.d.ts +13 -0
  65. package/dist/knock/responder.d.ts.map +1 -0
  66. package/dist/knock/responder.js +40 -0
  67. package/dist/knock/responder.test.d.ts +2 -0
  68. package/dist/knock/responder.test.d.ts.map +1 -0
  69. package/dist/knock/responder.test.js +89 -0
  70. package/dist/knock/store.d.ts +11 -0
  71. package/dist/knock/store.d.ts.map +1 -0
  72. package/dist/knock/store.js +211 -0
  73. package/dist/knock/store.test.d.ts +2 -0
  74. package/dist/knock/store.test.d.ts.map +1 -0
  75. package/dist/knock/store.test.js +195 -0
  76. package/dist/knock/types.d.ts +144 -0
  77. package/dist/knock/types.d.ts.map +1 -0
  78. package/dist/knock/types.js +26 -0
  79. package/dist/permissions/access.d.ts +54 -0
  80. package/dist/permissions/access.d.ts.map +1 -0
  81. package/dist/permissions/access.js +56 -0
  82. package/dist/permissions/access.test.d.ts +2 -0
  83. package/dist/permissions/access.test.d.ts.map +1 -0
  84. package/dist/permissions/access.test.js +72 -0
  85. package/dist/permissions/index.d.ts +5 -0
  86. package/dist/permissions/index.d.ts.map +1 -0
  87. package/dist/permissions/index.js +2 -0
  88. package/dist/permissions/role-table.d.ts +11 -0
  89. package/dist/permissions/role-table.d.ts.map +1 -0
  90. package/dist/permissions/role-table.js +16 -0
  91. package/dist/protocol/client.d.ts +67 -0
  92. package/dist/protocol/client.d.ts.map +1 -0
  93. package/dist/protocol/client.js +195 -0
  94. package/dist/protocol/client.test.d.ts +2 -0
  95. package/dist/protocol/client.test.d.ts.map +1 -0
  96. package/dist/protocol/client.test.js +282 -0
  97. package/dist/protocol/codec.d.ts +45 -0
  98. package/dist/protocol/codec.d.ts.map +1 -0
  99. package/dist/protocol/codec.js +111 -0
  100. package/dist/protocol/codec.test.d.ts +2 -0
  101. package/dist/protocol/codec.test.d.ts.map +1 -0
  102. package/dist/protocol/codec.test.js +93 -0
  103. package/dist/protocol/extensions.d.ts +33 -0
  104. package/dist/protocol/extensions.d.ts.map +1 -0
  105. package/dist/protocol/extensions.js +36 -0
  106. package/dist/protocol/extensions.test.d.ts +2 -0
  107. package/dist/protocol/extensions.test.d.ts.map +1 -0
  108. package/dist/protocol/extensions.test.js +65 -0
  109. package/dist/protocol/fixtures.test.d.ts +2 -0
  110. package/dist/protocol/fixtures.test.d.ts.map +1 -0
  111. package/dist/protocol/fixtures.test.js +51 -0
  112. package/dist/protocol/index.d.ts +9 -0
  113. package/dist/protocol/index.d.ts.map +1 -0
  114. package/dist/protocol/index.js +4 -0
  115. package/dist/protocol/messages.d.ts +480 -0
  116. package/dist/protocol/messages.d.ts.map +1 -0
  117. package/dist/protocol/messages.js +255 -0
  118. package/dist/protocol/messages.test.d.ts +2 -0
  119. package/dist/protocol/messages.test.d.ts.map +1 -0
  120. package/dist/protocol/messages.test.js +75 -0
  121. package/dist/share/codec.d.ts +58 -0
  122. package/dist/share/codec.d.ts.map +1 -0
  123. package/dist/share/codec.js +222 -0
  124. package/dist/share/codec.test.d.ts +2 -0
  125. package/dist/share/codec.test.d.ts.map +1 -0
  126. package/dist/share/codec.test.js +150 -0
  127. package/dist/share/detect.d.ts +24 -0
  128. package/dist/share/detect.d.ts.map +1 -0
  129. package/dist/share/detect.js +25 -0
  130. package/dist/share/detect.test.d.ts +2 -0
  131. package/dist/share/detect.test.d.ts.map +1 -0
  132. package/dist/share/detect.test.js +31 -0
  133. package/dist/share/index.d.ts +7 -0
  134. package/dist/share/index.d.ts.map +1 -0
  135. package/dist/share/index.js +3 -0
  136. package/dist/share/peer-addr.d.ts +32 -0
  137. package/dist/share/peer-addr.d.ts.map +1 -0
  138. package/dist/share/peer-addr.js +72 -0
  139. package/dist/share/peer-addr.test.d.ts +2 -0
  140. package/dist/share/peer-addr.test.d.ts.map +1 -0
  141. package/dist/share/peer-addr.test.js +58 -0
  142. package/dist/solid/auth-form.d.ts +43 -0
  143. package/dist/solid/auth-form.d.ts.map +1 -0
  144. package/dist/solid/auth-form.js +55 -0
  145. package/dist/solid/auth-form.test.d.ts +2 -0
  146. package/dist/solid/auth-form.test.d.ts.map +1 -0
  147. package/dist/solid/auth-form.test.js +131 -0
  148. package/dist/solid/auth-status.d.ts +21 -0
  149. package/dist/solid/auth-status.d.ts.map +1 -0
  150. package/dist/solid/auth-status.js +58 -0
  151. package/dist/solid/auth-status.test.d.ts +2 -0
  152. package/dist/solid/auth-status.test.d.ts.map +1 -0
  153. package/dist/solid/auth-status.test.js +60 -0
  154. package/dist/solid/index.d.ts +5 -0
  155. package/dist/solid/index.d.ts.map +1 -0
  156. package/dist/solid/index.js +10 -0
  157. package/dist/solid/knock-inbox.d.ts +61 -0
  158. package/dist/solid/knock-inbox.d.ts.map +1 -0
  159. package/dist/solid/knock-inbox.js +123 -0
  160. package/dist/solid/knock-inbox.test.d.ts +2 -0
  161. package/dist/solid/knock-inbox.test.d.ts.map +1 -0
  162. package/dist/solid/knock-inbox.test.js +153 -0
  163. package/dist/solid/reauth-flow.d.ts +26 -0
  164. package/dist/solid/reauth-flow.d.ts.map +1 -0
  165. package/dist/solid/reauth-flow.js +34 -0
  166. package/dist/solid/reauth-flow.test.d.ts +2 -0
  167. package/dist/solid/reauth-flow.test.d.ts.map +1 -0
  168. package/dist/solid/reauth-flow.test.js +91 -0
  169. package/dist/state/endpoint-control.d.ts +22 -0
  170. package/dist/state/endpoint-control.d.ts.map +1 -0
  171. package/dist/state/endpoint-control.js +34 -0
  172. package/dist/state/endpoint-control.test.d.ts +2 -0
  173. package/dist/state/endpoint-control.test.d.ts.map +1 -0
  174. package/dist/state/endpoint-control.test.js +63 -0
  175. package/dist/state/friend-directory.d.ts +37 -0
  176. package/dist/state/friend-directory.d.ts.map +1 -0
  177. package/dist/state/friend-directory.js +44 -0
  178. package/dist/state/friend-directory.test.d.ts +2 -0
  179. package/dist/state/friend-directory.test.d.ts.map +1 -0
  180. package/dist/state/friend-directory.test.js +63 -0
  181. package/dist/state/index.d.ts +6 -0
  182. package/dist/state/index.d.ts.map +1 -0
  183. package/dist/state/index.js +3 -0
  184. package/dist/state/peer-names.d.ts +7 -0
  185. package/dist/state/peer-names.d.ts.map +1 -0
  186. package/dist/state/peer-names.js +21 -0
  187. package/dist/state/peer-names.test.d.ts +2 -0
  188. package/dist/state/peer-names.test.d.ts.map +1 -0
  189. package/dist/state/peer-names.test.js +29 -0
  190. package/dist/testing/idb-harness.d.ts +9 -0
  191. package/dist/testing/idb-harness.d.ts.map +1 -0
  192. package/dist/testing/idb-harness.js +19 -0
  193. package/dist/testing/idb-harness.test.d.ts +2 -0
  194. package/dist/testing/idb-harness.test.d.ts.map +1 -0
  195. package/dist/testing/idb-harness.test.js +18 -0
  196. package/dist/testing/identity-fixtures.d.ts +10 -0
  197. package/dist/testing/identity-fixtures.d.ts.map +1 -0
  198. package/dist/testing/identity-fixtures.js +21 -0
  199. package/dist/testing/identity-fixtures.test.d.ts +2 -0
  200. package/dist/testing/identity-fixtures.test.d.ts.map +1 -0
  201. package/dist/testing/identity-fixtures.test.js +28 -0
  202. package/dist/testing/index.d.ts +7 -0
  203. package/dist/testing/index.d.ts.map +1 -0
  204. package/dist/testing/index.js +11 -0
  205. package/dist/testing/knock-fixtures.d.ts +13 -0
  206. package/dist/testing/knock-fixtures.d.ts.map +1 -0
  207. package/dist/testing/knock-fixtures.js +45 -0
  208. package/dist/testing/knock-fixtures.test.d.ts +2 -0
  209. package/dist/testing/knock-fixtures.test.d.ts.map +1 -0
  210. package/dist/testing/knock-fixtures.test.js +65 -0
  211. package/dist/testing/protocol-fixtures.d.ts +7 -0
  212. package/dist/testing/protocol-fixtures.d.ts.map +1 -0
  213. package/dist/testing/protocol-fixtures.js +28 -0
  214. package/dist/testing/protocol-fixtures.test.d.ts +2 -0
  215. package/dist/testing/protocol-fixtures.test.d.ts.map +1 -0
  216. package/dist/testing/protocol-fixtures.test.js +19 -0
  217. package/dist/testing/state-fixtures.d.ts +33 -0
  218. package/dist/testing/state-fixtures.d.ts.map +1 -0
  219. package/dist/testing/state-fixtures.js +67 -0
  220. package/dist/testing/state-fixtures.test.d.ts +2 -0
  221. package/dist/testing/state-fixtures.test.d.ts.map +1 -0
  222. package/dist/testing/state-fixtures.test.js +68 -0
  223. package/dist/testing/webauthn-mocks.d.ts +27 -0
  224. package/dist/testing/webauthn-mocks.d.ts.map +1 -0
  225. package/dist/testing/webauthn-mocks.js +78 -0
  226. package/dist/testing/webauthn-mocks.test.d.ts +2 -0
  227. package/dist/testing/webauthn-mocks.test.d.ts.map +1 -0
  228. package/dist/testing/webauthn-mocks.test.js +40 -0
  229. package/dist/webauthn/ceremony.d.ts +76 -0
  230. package/dist/webauthn/ceremony.d.ts.map +1 -0
  231. package/dist/webauthn/ceremony.js +136 -0
  232. package/dist/webauthn/ceremony.test.d.ts +2 -0
  233. package/dist/webauthn/ceremony.test.d.ts.map +1 -0
  234. package/dist/webauthn/ceremony.test.js +245 -0
  235. package/dist/webauthn/codec.d.ts +39 -0
  236. package/dist/webauthn/codec.d.ts.map +1 -0
  237. package/dist/webauthn/codec.js +118 -0
  238. package/dist/webauthn/codec.test.d.ts +2 -0
  239. package/dist/webauthn/codec.test.d.ts.map +1 -0
  240. package/dist/webauthn/codec.test.js +126 -0
  241. package/dist/webauthn/index.d.ts +7 -0
  242. package/dist/webauthn/index.d.ts.map +1 -0
  243. package/dist/webauthn/index.js +3 -0
  244. package/dist/webauthn/transport.d.ts +58 -0
  245. package/dist/webauthn/transport.d.ts.map +1 -0
  246. package/dist/webauthn/transport.js +6 -0
  247. package/dist/webauthn/types.d.ts +78 -0
  248. package/dist/webauthn/types.d.ts.map +1 -0
  249. package/dist/webauthn/types.js +29 -0
  250. package/package.json +83 -0
@@ -0,0 +1,349 @@
1
+ // the bundled effect runner for the add-peer flow: executes the effect
2
+ // descriptors the machine emits against an injected `AddPeerFlowDeps`,
3
+ // and feeds each async outcome back into the machine as an event.
4
+ //
5
+ // `createAddPeerFlow` ties machine + runner together: `send(event)` is
6
+ // the pure transition (returns the effects it produced), `runEffect`
7
+ // executes one effect and recursively drains any follow-on effects its
8
+ // result event produces. an adapter that wants full control (its own
9
+ // scheduler, effect logging, replay) can ignore `runEffect` and execute
10
+ // the descriptors itself.
11
+ //
12
+ // cancellation: CHECK_CONNECTION / CHECK_KNOCK_STATUS runs are tracked
13
+ // with a generation counter; CANCEL_IN_FLIGHT (emitted by BACK from
14
+ // `testing` and by MODAL_CLOSE) bumps it, and a superseded run's outcome
15
+ // event is discarded instead of being fed to the machine.
16
+ import { parsePeerAddress } from "../../share/peer-addr.js";
17
+ import { initialContext, projectState, transition, } from "./machine.js";
18
+ function describeConnectError(err) {
19
+ const msg = err instanceof Error ? err.message : String(err);
20
+ if (/timeout/i.test(msg)) {
21
+ return "connection timed out - peer may be offline or unreachable";
22
+ }
23
+ if (/not ?found/i.test(msg)) {
24
+ return "peer not found - check the address and try again";
25
+ }
26
+ return `connection failed: ${msg}`;
27
+ }
28
+ function isAuthRejection(err) {
29
+ const msg = err instanceof Error ? err.message : String(err);
30
+ return (msg.includes("401") ||
31
+ msg.includes("403") ||
32
+ msg.includes("Unauthorized") ||
33
+ msg.includes("Forbidden"));
34
+ }
35
+ export function createAddPeerFlow(deps) {
36
+ let ctx = initialContext();
37
+ let disposed = false;
38
+ /** generation counter for cancellable probes; CANCEL_IN_FLIGHT bumps it. */
39
+ let probeGeneration = 0;
40
+ const timerCancels = new Map();
41
+ function send(event) {
42
+ if (disposed)
43
+ return [];
44
+ const result = transition(ctx, event);
45
+ ctx = result.ctx;
46
+ return result.effects;
47
+ }
48
+ async function feed(event) {
49
+ for (const effect of send(event)) {
50
+ await runEffect(effect);
51
+ }
52
+ }
53
+ function scheduleTimer(id, ms) {
54
+ timerCancels.get(id)?.();
55
+ const fire = () => {
56
+ timerCancels.delete(id);
57
+ void feed({ type: "TIMER_FIRED", id });
58
+ };
59
+ if (deps.scheduleTimer) {
60
+ timerCancels.set(id, deps.scheduleTimer(id, ms, fire));
61
+ }
62
+ else {
63
+ const handle = setTimeout(fire, ms);
64
+ timerCancels.set(id, () => clearTimeout(handle));
65
+ }
66
+ }
67
+ async function probeConnection(target) {
68
+ let info;
69
+ try {
70
+ info = await deps.getServerInfo(target);
71
+ }
72
+ catch (err) {
73
+ // an auth rejection on the probe itself can still mean "knockable":
74
+ // the hello surface is public, so re-read it to route to the knock
75
+ // form instead of a dead-end error.
76
+ if (isAuthRejection(err)) {
77
+ try {
78
+ const helloInfo = await deps.getServerInfo(target);
79
+ if (helloInfo?.knocking_enabled) {
80
+ return {
81
+ kind: "needs_knock",
82
+ serverInfo: helloInfo,
83
+ error: "access denied - you can request access from the server admin",
84
+ };
85
+ }
86
+ }
87
+ catch {
88
+ // fall through to the generic failure below
89
+ }
90
+ }
91
+ return { kind: "failed", error: describeConnectError(err) };
92
+ }
93
+ if (!info) {
94
+ return {
95
+ kind: "failed",
96
+ error: "connection succeeded but the peer did not return valid server info",
97
+ };
98
+ }
99
+ let authed = false;
100
+ try {
101
+ authed = await deps.whoami(target);
102
+ }
103
+ catch {
104
+ authed = false;
105
+ }
106
+ if (authed)
107
+ return { kind: "already_authed", serverInfo: info };
108
+ if (info.knocking_enabled)
109
+ return { kind: "needs_knock", serverInfo: info };
110
+ return { kind: "needs_auth", serverInfo: info };
111
+ }
112
+ async function probeKnockStatus(pending) {
113
+ const target = parsePeerAddress(pending.peer_addr) ?? {
114
+ type: "p2p",
115
+ peerAddr: pending.peer_addr,
116
+ };
117
+ try {
118
+ const status = await deps.checkKnockStatus(pending.peer_addr);
119
+ if (status === "rejected")
120
+ return { kind: "denied" };
121
+ if (status === "pending")
122
+ return { kind: "pending" };
123
+ if (status === "accepted") {
124
+ const info = await deps.getServerInfo(target).catch(() => null);
125
+ let authed = false;
126
+ try {
127
+ authed = await deps.whoami(target);
128
+ }
129
+ catch {
130
+ authed = false;
131
+ }
132
+ return authed
133
+ ? { kind: "accepted_authed", serverInfo: info }
134
+ : { kind: "accepted_needs_auth", serverInfo: info };
135
+ }
136
+ // no status available: a successful plain connection means the
137
+ // knock must have been accepted
138
+ const info = await deps.getServerInfo(target).catch(() => null);
139
+ if (info) {
140
+ let authed = false;
141
+ try {
142
+ authed = await deps.whoami(target);
143
+ }
144
+ catch {
145
+ authed = false;
146
+ }
147
+ return authed
148
+ ? { kind: "accepted_authed", serverInfo: info }
149
+ : { kind: "accepted_needs_auth", serverInfo: info };
150
+ }
151
+ return { kind: "unreachable" };
152
+ }
153
+ catch {
154
+ return { kind: "unreachable" };
155
+ }
156
+ }
157
+ async function upsertPending(peerAddr, patch) {
158
+ const existing = await deps.getPendingRemoteByPeerAddr(peerAddr);
159
+ if (existing) {
160
+ await deps.updatePendingRemote(existing.id, patch);
161
+ }
162
+ else {
163
+ const target = parsePeerAddress(peerAddr) ?? { type: "p2p", peerAddr };
164
+ await deps.createPendingRemote({
165
+ peer_addr: peerAddr,
166
+ transport: deps.transportFor(target),
167
+ stage: "testing",
168
+ server_name: null,
169
+ server_description: null,
170
+ server_version: null,
171
+ server_image_data: null,
172
+ server_image_type: null,
173
+ knock_username: null,
174
+ knock_message: null,
175
+ error_message: null,
176
+ ...patch,
177
+ });
178
+ }
179
+ const records = await deps.getAllPendingRemotes();
180
+ await feed({ type: "PENDING_LOADED", records });
181
+ }
182
+ async function runEffect(effect) {
183
+ if (disposed)
184
+ return;
185
+ switch (effect.type) {
186
+ case "LOAD_PENDING_REMOTES": {
187
+ const records = await deps.getAllPendingRemotes().catch(() => []);
188
+ await feed({ type: "PENDING_LOADED", records });
189
+ return;
190
+ }
191
+ case "CHECK_DUPLICATE": {
192
+ const remotes = await deps.getAllRemotes().catch(() => []);
193
+ const target = effect.target;
194
+ const duplicate = target.type === "http"
195
+ ? remotes.find((r) => r.base_url === target.url)
196
+ : remotes.find((r) => r.peer_addr === target.peerAddr);
197
+ await feed({ type: "DUPLICATE_RESULT", duplicateName: duplicate?.name ?? null });
198
+ return;
199
+ }
200
+ case "UPSERT_PENDING":
201
+ await upsertPending(effect.peerAddr, effect.patch).catch(() => { });
202
+ return;
203
+ case "DELETE_PENDING":
204
+ await deps.deletePendingRemote(effect.id).catch(() => { });
205
+ return;
206
+ case "DELETE_PENDING_BY_ADDR": {
207
+ await deps.deletePendingRemoteByPeerAddr(effect.peerAddr).catch(() => { });
208
+ const records = await deps.getAllPendingRemotes().catch(() => []);
209
+ await feed({ type: "PENDING_LOADED", records });
210
+ return;
211
+ }
212
+ case "CHECK_CONNECTION": {
213
+ const generation = ++probeGeneration;
214
+ const outcome = await probeConnection(effect.target);
215
+ if (disposed || generation !== probeGeneration)
216
+ return; // cancelled/superseded
217
+ await feed({ type: "CONNECTION_RESULT", outcome });
218
+ return;
219
+ }
220
+ case "CHECK_KNOCK_STATUS": {
221
+ const generation = ++probeGeneration;
222
+ const outcome = await probeKnockStatus(effect.pending);
223
+ if (disposed || generation !== probeGeneration)
224
+ return;
225
+ await feed({ type: "KNOCK_STATUS_RESULT", outcome });
226
+ return;
227
+ }
228
+ case "SEND_KNOCK": {
229
+ try {
230
+ await deps.sendKnock(effect.peerAddr, effect.username, effect.message);
231
+ await feed({ type: "KNOCK_SENT_RESULT", ok: true });
232
+ }
233
+ catch (err) {
234
+ await feed({
235
+ type: "KNOCK_SENT_RESULT",
236
+ ok: false,
237
+ error: err instanceof Error ? err.message : "failed to send access request",
238
+ });
239
+ }
240
+ return;
241
+ }
242
+ case "AUTH_HTTP": {
243
+ try {
244
+ await deps.authenticateHttp(effect.url, {
245
+ mode: effect.mode,
246
+ username: effect.username,
247
+ inviteCode: effect.inviteCode,
248
+ });
249
+ await feed({ type: "AUTH_RESULT", ok: true });
250
+ }
251
+ catch (err) {
252
+ await feed({
253
+ type: "AUTH_RESULT",
254
+ ok: false,
255
+ error: err instanceof Error ? err.message : "authentication failed",
256
+ });
257
+ }
258
+ return;
259
+ }
260
+ case "AUTH_REDEEM_INVITE": {
261
+ try {
262
+ await deps.redeemInvite(effect.peerAddr, effect.username, effect.inviteCode);
263
+ await feed({ type: "AUTH_RESULT", ok: true });
264
+ }
265
+ catch (err) {
266
+ await feed({
267
+ type: "AUTH_RESULT",
268
+ ok: false,
269
+ error: err instanceof Error ? err.message : "invite code redemption failed",
270
+ });
271
+ }
272
+ return;
273
+ }
274
+ case "AUTH_PASSKEY_REGISTER": {
275
+ try {
276
+ await deps.registerWithPasskey(effect.peerAddr, effect.username, effect.inviteCode);
277
+ await feed({ type: "AUTH_RESULT", ok: true });
278
+ }
279
+ catch (err) {
280
+ await feed({
281
+ type: "AUTH_RESULT",
282
+ ok: false,
283
+ error: err instanceof Error ? err.message : "passkey registration failed",
284
+ });
285
+ }
286
+ return;
287
+ }
288
+ case "AUTH_PASSKEY_LOGIN": {
289
+ try {
290
+ await deps.loginWithPasskey(effect.target, effect.username);
291
+ await feed({ type: "AUTH_RESULT", ok: true });
292
+ }
293
+ catch (err) {
294
+ await feed({
295
+ type: "AUTH_RESULT",
296
+ ok: false,
297
+ error: err instanceof Error ? err.message : "passkey login failed",
298
+ });
299
+ }
300
+ return;
301
+ }
302
+ case "CREATE_REMOTE": {
303
+ try {
304
+ const remote = await deps.createRemote({
305
+ base_url: effect.url || undefined,
306
+ peer_addr: effect.peerAddr || undefined,
307
+ });
308
+ await feed({ type: "REMOTE_CREATED", ok: true, remote });
309
+ }
310
+ catch (err) {
311
+ await feed({
312
+ type: "REMOTE_CREATED",
313
+ ok: false,
314
+ error: err instanceof Error ? err.message : "failed to save remote",
315
+ });
316
+ }
317
+ return;
318
+ }
319
+ case "CANCEL_IN_FLIGHT":
320
+ probeGeneration++;
321
+ return;
322
+ case "SCHEDULE_TIMER":
323
+ scheduleTimer(effect.id, effect.ms);
324
+ return;
325
+ case "CLEAR_QUERY_PARAM":
326
+ deps.clearQueryParam?.();
327
+ return;
328
+ case "CALL_ON_SUCCESS":
329
+ deps.onSuccess?.(effect.remote);
330
+ return;
331
+ case "CALL_ON_CLOSE":
332
+ deps.onClose?.();
333
+ return;
334
+ }
335
+ }
336
+ return {
337
+ state: () => projectState(ctx),
338
+ send,
339
+ runEffect,
340
+ dispatch: feed,
341
+ dispose() {
342
+ disposed = true;
343
+ probeGeneration++;
344
+ for (const cancel of timerCancels.values())
345
+ cancel();
346
+ timerCancels.clear();
347
+ },
348
+ };
349
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=runner.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.test.d.ts","sourceRoot":"","sources":["../../../src/flows/add-peer/runner.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createAddPeerFlow } from "./runner.js";
3
+ const NODE_ID = "c".repeat(64);
4
+ const INFO = {
5
+ name: "hub",
6
+ version: "1.0",
7
+ requires_auth: true,
8
+ knocking_enabled: false,
9
+ };
10
+ /** in-memory deps: a pending-remote store + scriptable network answers. */
11
+ function makeDeps(overrides = {}) {
12
+ const pendingStore = new Map();
13
+ const remotes = [];
14
+ const timers = [];
15
+ let nextId = 1;
16
+ const deps = {
17
+ getAllRemotes: async () => [...remotes],
18
+ getAllPendingRemotes: async () => [...pendingStore.values()],
19
+ getPendingRemoteByPeerAddr: async (addr) => [...pendingStore.values()].find((p) => p.peer_addr === addr) ?? null,
20
+ createPendingRemote: async (record) => {
21
+ const created = { ...record, id: `p${nextId++}` };
22
+ pendingStore.set(created.id, created);
23
+ return created;
24
+ },
25
+ updatePendingRemote: async (id, patch) => {
26
+ const existing = pendingStore.get(id);
27
+ if (existing)
28
+ pendingStore.set(id, { ...existing, ...patch });
29
+ },
30
+ deletePendingRemote: async (id) => {
31
+ pendingStore.delete(id);
32
+ },
33
+ deletePendingRemoteByPeerAddr: async (addr) => {
34
+ for (const [id, p] of pendingStore) {
35
+ if (p.peer_addr === addr)
36
+ pendingStore.delete(id);
37
+ }
38
+ },
39
+ createRemote: async (input) => {
40
+ const remote = {
41
+ remote_id: `r${nextId++}`,
42
+ name: "hub",
43
+ base_url: input.base_url,
44
+ peer_addr: input.peer_addr,
45
+ };
46
+ remotes.push(remote);
47
+ return remote;
48
+ },
49
+ getServerInfo: async () => INFO,
50
+ whoami: async () => false,
51
+ sendKnock: async () => { },
52
+ checkKnockStatus: async () => "pending",
53
+ authenticateHttp: async () => { },
54
+ redeemInvite: async () => { },
55
+ registerWithPasskey: async () => { },
56
+ loginWithPasskey: async () => { },
57
+ transportFor: (target) => (target.type === "p2p" ? "wasm" : "http"),
58
+ scheduleTimer: (id, ms, fire) => {
59
+ timers.push({ id, ms, fire });
60
+ return () => { };
61
+ },
62
+ ...overrides,
63
+ };
64
+ return { deps, pendingStore, remotes, timers };
65
+ }
66
+ describe("createAddPeerFlow (bundled runner)", () => {
67
+ it("drives submit -> probe -> auth for an unauthed peer, persisting the pending stages", async () => {
68
+ const { deps, pendingStore } = makeDeps();
69
+ const flow = createAddPeerFlow(deps);
70
+ await flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
71
+ const state = flow.state();
72
+ expect(state.step).toBe("auth");
73
+ const records = [...pendingStore.values()];
74
+ expect(records).toHaveLength(1);
75
+ expect(records[0].stage).toBe("connected");
76
+ expect(records[0].server_name).toBe("hub");
77
+ expect(records[0].transport).toBe("wasm");
78
+ });
79
+ it("short-circuits to complete when whoami already succeeds, then dismisses via the timer", async () => {
80
+ const onSuccess = vi.fn();
81
+ const onClose = vi.fn();
82
+ const { deps, pendingStore, timers } = makeDeps({
83
+ whoami: async () => true,
84
+ onSuccess,
85
+ onClose,
86
+ });
87
+ const flow = createAddPeerFlow(deps);
88
+ await flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
89
+ expect(flow.state().step).toBe("complete");
90
+ // pending record was deleted after the remote was created
91
+ expect(pendingStore.size).toBe(0);
92
+ expect(timers).toHaveLength(1);
93
+ expect(timers[0].ms).toBe(1500);
94
+ timers[0].fire();
95
+ await Promise.resolve();
96
+ expect(onClose).toHaveBeenCalled();
97
+ expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ peer_addr: NODE_ID }));
98
+ expect(flow.state().step).toBe("url");
99
+ });
100
+ it("rejects a duplicate before persisting anything", async () => {
101
+ const { deps, pendingStore } = makeDeps({
102
+ getAllRemotes: async () => [{ remote_id: "r0", name: "old hub", peer_addr: NODE_ID }],
103
+ });
104
+ const flow = createAddPeerFlow(deps);
105
+ await flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
106
+ const state = flow.state();
107
+ expect(state.step).toBe("url");
108
+ expect(state.step === "url" && state.error).toContain('already added as "old hub"');
109
+ expect(pendingStore.size).toBe(0);
110
+ });
111
+ it("marks the pending record failed with a mapped error when the probe throws", async () => {
112
+ const { deps, pendingStore } = makeDeps({
113
+ getServerInfo: async () => {
114
+ throw new Error("connect timeout after 30s");
115
+ },
116
+ });
117
+ const flow = createAddPeerFlow(deps);
118
+ await flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
119
+ const state = flow.state();
120
+ expect(state.step).toBe("url");
121
+ expect(state.step === "url" && state.error).toMatch(/timed out/);
122
+ const records = [...pendingStore.values()];
123
+ expect(records[0].stage).toBe("failed");
124
+ expect(records[0].error_message).toMatch(/timed out/);
125
+ });
126
+ it("routes an auth-rejected probe to the knock form when knocking is enabled", async () => {
127
+ let calls = 0;
128
+ const { deps } = makeDeps({
129
+ getServerInfo: async () => {
130
+ calls++;
131
+ // first read rejects with a 403; the public re-read succeeds
132
+ if (calls === 1)
133
+ throw new Error("403 Forbidden");
134
+ return { ...INFO, knocking_enabled: true };
135
+ },
136
+ });
137
+ const flow = createAddPeerFlow(deps);
138
+ await flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
139
+ const state = flow.state();
140
+ expect(state.step).toBe("url");
141
+ expect(state.step === "url" && state.subStep).toBe("knock_form");
142
+ expect(state.step === "url" && state.error).toMatch(/request access/);
143
+ });
144
+ it("runs the knock flow end to end and resumes it to completion", async () => {
145
+ let knockStatus = "pending";
146
+ const sendKnock = vi.fn(async () => { });
147
+ const { deps, pendingStore } = makeDeps({
148
+ getServerInfo: async () => ({ ...INFO, knocking_enabled: true }),
149
+ sendKnock,
150
+ checkKnockStatus: async () => knockStatus,
151
+ whoami: async () => knockStatus === "accepted",
152
+ });
153
+ const flow = createAddPeerFlow(deps);
154
+ // submit -> knock form (knocking enabled, not authed)
155
+ await flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
156
+ expect(flow.state().step === "url" && flow.state().subStep).toBe("knock_form");
157
+ // send the knock
158
+ await flow.dispatch({ type: "SUBMIT_KNOCK", username: "ed", message: "hello" });
159
+ expect(sendKnock).toHaveBeenCalledWith(NODE_ID, "ed", "hello");
160
+ expect(flow.state().step).toBe("knock_sent");
161
+ const pending = [...pendingStore.values()][0];
162
+ expect(pending.stage).toBe("knock_pending");
163
+ expect(pending.knock_username).toBe("ed");
164
+ // later: the knock has been accepted; the user retries from the list
165
+ knockStatus = "accepted";
166
+ await flow.dispatch({ type: "MODAL_OPEN" });
167
+ await flow.dispatch({ type: "RETRY_PENDING", pending });
168
+ expect(flow.state().step).toBe("complete");
169
+ expect(pendingStore.size).toBe(0);
170
+ });
171
+ it("discards a probe result that was cancelled by BACK", async () => {
172
+ let release;
173
+ const gate = new Promise((resolve) => {
174
+ release = resolve;
175
+ });
176
+ const { deps } = makeDeps({
177
+ getServerInfo: async () => {
178
+ await gate;
179
+ return INFO;
180
+ },
181
+ });
182
+ const flow = createAddPeerFlow(deps);
183
+ const submitted = flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
184
+ // give the duplicate check a chance to finish and the probe to start
185
+ await new Promise((r) => setTimeout(r, 0));
186
+ await flow.dispatch({ type: "BACK" });
187
+ release();
188
+ await submitted;
189
+ // the machine stayed in url/input; the late probe result was discarded
190
+ expect(flow.state().step).toBe("url");
191
+ });
192
+ it("dispose cancels timers and ignores further events", async () => {
193
+ const cancel = vi.fn(() => { });
194
+ const { deps } = makeDeps({
195
+ whoami: async () => true,
196
+ scheduleTimer: (_id, _ms, _fire) => cancel,
197
+ });
198
+ const flow = createAddPeerFlow(deps);
199
+ await flow.dispatch({ type: "SUBMIT_URL", input: NODE_ID });
200
+ expect(flow.state().step).toBe("complete");
201
+ flow.dispose();
202
+ expect(cancel).toHaveBeenCalled();
203
+ expect(flow.send({ type: "MODAL_OPEN" })).toEqual([]);
204
+ });
205
+ });