@econ-v1/domain 6.7.0 → 6.11.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 (286) hide show
  1. package/dist/errors/index.d.ts +96 -25
  2. package/dist/errors/index.d.ts.map +1 -1
  3. package/dist/events/index.d.ts +127 -20
  4. package/dist/events/index.d.ts.map +1 -1
  5. package/dist/index.d.ts +250 -47
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/models/agent.d.ts +25 -4
  8. package/dist/models/agent.d.ts.map +1 -1
  9. package/dist/models/app-data.d.ts +84 -0
  10. package/dist/models/app-data.d.ts.map +1 -0
  11. package/dist/models/conversation.d.ts +12 -2
  12. package/dist/models/conversation.d.ts.map +1 -1
  13. package/dist/models/delegation.d.ts +12 -3
  14. package/dist/models/delegation.d.ts.map +1 -1
  15. package/dist/models/device-boot-state.d.ts +12 -0
  16. package/dist/models/device-boot-state.d.ts.map +1 -0
  17. package/dist/models/device-identity.d.ts +11 -3
  18. package/dist/models/device-identity.d.ts.map +1 -1
  19. package/dist/models/graph.d.ts +26 -3
  20. package/dist/models/graph.d.ts.map +1 -1
  21. package/dist/models/index.d.ts +82 -14
  22. package/dist/models/index.d.ts.map +1 -1
  23. package/dist/models/json.d.ts +5 -5
  24. package/dist/models/json.d.ts.map +1 -1
  25. package/dist/models/l402.d.ts +4 -5
  26. package/dist/models/l402.d.ts.map +1 -1
  27. package/dist/models/live-query-definition.d.ts +15 -16
  28. package/dist/models/live-query-definition.d.ts.map +1 -1
  29. package/dist/models/node-app-memory.d.ts +107 -0
  30. package/dist/models/node-app-memory.d.ts.map +1 -0
  31. package/dist/models/outbox-intent.d.ts +3 -3
  32. package/dist/models/outbox-intent.d.ts.map +1 -1
  33. package/dist/models/outbox.d.ts +67 -15
  34. package/dist/models/outbox.d.ts.map +1 -1
  35. package/dist/models/owner-views.d.ts +28 -28
  36. package/dist/models/owner-views.d.ts.map +1 -1
  37. package/dist/models/payment.d.ts +28 -9
  38. package/dist/models/payment.d.ts.map +1 -1
  39. package/dist/models/query-diff.d.ts +2 -2
  40. package/dist/models/query-diff.d.ts.map +1 -1
  41. package/dist/models/release-manifest.d.ts +32 -47
  42. package/dist/models/release-manifest.d.ts.map +1 -1
  43. package/dist/models/repository-write.d.ts +3 -3
  44. package/dist/models/repository-write.d.ts.map +1 -1
  45. package/dist/models/revisioned-commands.d.ts +66 -13
  46. package/dist/models/revisioned-commands.d.ts.map +1 -1
  47. package/dist/models/revisioned.d.ts +50 -10
  48. package/dist/models/revisioned.d.ts.map +1 -1
  49. package/dist/models/settings.d.ts +2 -2
  50. package/dist/models/settings.d.ts.map +1 -1
  51. package/dist/models/sync.d.ts +18 -18
  52. package/dist/models/sync.d.ts.map +1 -1
  53. package/dist/models/wallet.d.ts +16 -16
  54. package/dist/models/wallet.d.ts.map +1 -1
  55. package/dist/queries/certificate-trust.d.ts +13 -0
  56. package/dist/queries/certificate-trust.d.ts.map +1 -0
  57. package/dist/queries/conversations.d.ts +38 -7
  58. package/dist/queries/conversations.d.ts.map +1 -1
  59. package/dist/queries/discovery.d.ts +10 -10
  60. package/dist/queries/discovery.d.ts.map +1 -1
  61. package/dist/queries/notifications.d.ts +24 -4
  62. package/dist/queries/notifications.d.ts.map +1 -1
  63. package/dist/queries/onboarding-flow.d.ts +25 -0
  64. package/dist/queries/onboarding-flow.d.ts.map +1 -0
  65. package/dist/queries/onboarding-lcd-screen.d.ts +56 -0
  66. package/dist/queries/onboarding-lcd-screen.d.ts.map +1 -0
  67. package/dist/queries/onboarding-status.d.ts +10 -10
  68. package/dist/queries/onboarding-status.d.ts.map +1 -1
  69. package/dist/queries/revisioned.d.ts +47 -13
  70. package/dist/queries/revisioned.d.ts.map +1 -1
  71. package/dist/queries/wallet.d.ts +40 -10
  72. package/dist/queries/wallet.d.ts.map +1 -1
  73. package/dist/services/abort-signal.d.ts +54 -0
  74. package/dist/services/abort-signal.d.ts.map +1 -0
  75. package/dist/services/certificate-trust-service.d.ts +42 -0
  76. package/dist/services/certificate-trust-service.d.ts.map +1 -0
  77. package/dist/services/conversation-service.d.ts +103 -15
  78. package/dist/services/conversation-service.d.ts.map +1 -1
  79. package/dist/services/conversation-sync.d.ts +9 -5
  80. package/dist/services/conversation-sync.d.ts.map +1 -1
  81. package/dist/services/device-boot-state-service.d.ts +17 -0
  82. package/dist/services/device-boot-state-service.d.ts.map +1 -0
  83. package/dist/services/device-cleanup-service.d.ts +47 -0
  84. package/dist/services/device-cleanup-service.d.ts.map +1 -0
  85. package/dist/services/discovery-probe-service.d.ts +37 -46
  86. package/dist/services/discovery-probe-service.d.ts.map +1 -1
  87. package/dist/services/gossip-service.d.ts +28 -13
  88. package/dist/services/gossip-service.d.ts.map +1 -1
  89. package/dist/services/invite-redemption-service.d.ts +43 -0
  90. package/dist/services/invite-redemption-service.d.ts.map +1 -0
  91. package/dist/services/lan-candidate-refresh-service.d.ts +22 -7
  92. package/dist/services/lan-candidate-refresh-service.d.ts.map +1 -1
  93. package/dist/services/lcd-decision-service.d.ts +58 -0
  94. package/dist/services/lcd-decision-service.d.ts.map +1 -0
  95. package/dist/services/onboarding-checkpoint-store.d.ts +54 -0
  96. package/dist/services/onboarding-checkpoint-store.d.ts.map +1 -0
  97. package/dist/services/onboarding-flow-service.d.ts +180 -0
  98. package/dist/services/onboarding-flow-service.d.ts.map +1 -0
  99. package/dist/services/onboarding-status-service.d.ts +21 -38
  100. package/dist/services/onboarding-status-service.d.ts.map +1 -1
  101. package/dist/services/outbox-service.d.ts +105 -29
  102. package/dist/services/outbox-service.d.ts.map +1 -1
  103. package/dist/services/owner-claim-service.d.ts +187 -0
  104. package/dist/services/owner-claim-service.d.ts.map +1 -0
  105. package/dist/services/owner-view-refresh-service.d.ts +40 -0
  106. package/dist/services/owner-view-refresh-service.d.ts.map +1 -0
  107. package/dist/services/owner-view-service.d.ts +49 -4
  108. package/dist/services/owner-view-service.d.ts.map +1 -1
  109. package/dist/services/peer-directory-service.d.ts +53 -17
  110. package/dist/services/peer-directory-service.d.ts.map +1 -1
  111. package/dist/services/release-manifest-verifier.d.ts +18 -20
  112. package/dist/services/release-manifest-verifier.d.ts.map +1 -1
  113. package/dist/services/revisioned-domain-service.d.ts +50 -17
  114. package/dist/services/revisioned-domain-service.d.ts.map +1 -1
  115. package/dist/services/runtime-globals.d.ts +39 -0
  116. package/dist/services/runtime-globals.d.ts.map +1 -0
  117. package/dist/services/session-service.d.ts +23 -3
  118. package/dist/services/session-service.d.ts.map +1 -1
  119. package/dist/services/sync/authoritative-mirror.d.ts +88 -15
  120. package/dist/services/sync/authoritative-mirror.d.ts.map +1 -1
  121. package/dist/services/sync/index.d.ts +17 -5
  122. package/dist/services/sync/index.d.ts.map +1 -1
  123. package/dist/services/sync/log-sync.d.ts +42 -8
  124. package/dist/services/sync/log-sync.d.ts.map +1 -1
  125. package/dist/services/sync/revision-merge.d.ts +80 -13
  126. package/dist/services/sync/revision-merge.d.ts.map +1 -1
  127. package/dist/services/sync/strategy.d.ts +11 -1
  128. package/dist/services/sync/strategy.d.ts.map +1 -1
  129. package/dist/services/transport-manager.d.ts +84 -29
  130. package/dist/services/transport-manager.d.ts.map +1 -1
  131. package/dist/services/wallet-mirror-service.d.ts +81 -18
  132. package/dist/services/wallet-mirror-service.d.ts.map +1 -1
  133. package/dist/services/wifi-provisioning-service.d.ts +146 -0
  134. package/dist/services/wifi-provisioning-service.d.ts.map +1 -0
  135. package/package.json +18 -12
  136. package/src/errors/index.js +126 -0
  137. package/src/events/index.js +124 -0
  138. package/src/index.js +566 -0
  139. package/src/models/agent.js +43 -0
  140. package/src/models/app-data.js +122 -0
  141. package/src/models/conversation.js +36 -0
  142. package/{dist → src}/models/delegation.js +18 -1
  143. package/src/models/device-boot-state.js +3 -0
  144. package/{dist → src}/models/device-identity.js +18 -2
  145. package/{dist → src}/models/graph.js +30 -3
  146. package/src/models/index.js +175 -0
  147. package/src/models/json.js +18 -0
  148. package/src/models/l402.js +9 -0
  149. package/src/models/live-query-definition.js +30 -0
  150. package/src/models/node-app-memory.js +130 -0
  151. package/src/models/outbox-intent.js +12 -0
  152. package/{dist → src}/models/outbox.js +102 -3
  153. package/src/models/owner-views.js +59 -0
  154. package/src/models/payment.js +24 -0
  155. package/src/models/query-diff.js +7 -0
  156. package/src/models/release-manifest.js +160 -0
  157. package/src/models/repository-write.js +10 -0
  158. package/src/models/revisioned-commands.js +92 -0
  159. package/src/models/revisioned.js +41 -0
  160. package/src/models/settings.js +16 -0
  161. package/src/models/sync.js +44 -0
  162. package/src/models/wallet.js +40 -0
  163. package/src/queries/certificate-trust.js +31 -0
  164. package/{dist → src}/queries/conversations.js +54 -3
  165. package/src/queries/discovery.js +34 -0
  166. package/{dist → src}/queries/notifications.js +25 -2
  167. package/src/queries/onboarding-flow.js +48 -0
  168. package/src/queries/onboarding-lcd-screen.js +100 -0
  169. package/src/queries/onboarding-status.js +34 -0
  170. package/src/queries/revisioned.js +128 -0
  171. package/{dist → src}/queries/wallet.js +35 -1
  172. package/src/services/abort-signal.js +69 -0
  173. package/src/services/certificate-trust-service.js +119 -0
  174. package/{dist → src}/services/conversation-service.js +108 -1
  175. package/src/services/conversation-sync.js +28 -0
  176. package/src/services/device-boot-state-service.js +45 -0
  177. package/src/services/device-cleanup-service.js +112 -0
  178. package/src/services/discovery-probe-service.js +196 -0
  179. package/{dist → src}/services/gossip-service.js +54 -4
  180. package/src/services/invite-redemption-service.js +64 -0
  181. package/src/services/lan-candidate-refresh-service.js +164 -0
  182. package/src/services/lcd-decision-service.js +93 -0
  183. package/src/services/onboarding-checkpoint-store.js +155 -0
  184. package/src/services/onboarding-flow-service.js +1096 -0
  185. package/src/services/onboarding-status-service.js +64 -0
  186. package/{dist → src}/services/outbox-service.js +132 -1
  187. package/src/services/owner-claim-service.js +347 -0
  188. package/src/services/owner-view-refresh-service.js +208 -0
  189. package/src/services/owner-view-service.js +87 -0
  190. package/{dist → src}/services/peer-directory-service.js +72 -3
  191. package/src/services/release-manifest-verifier.js +108 -0
  192. package/{dist → src}/services/revisioned-domain-service.js +189 -9
  193. package/src/services/runtime-globals.js +174 -0
  194. package/src/services/session-service.js +50 -0
  195. package/{dist → src}/services/sync/authoritative-mirror.js +90 -1
  196. package/src/services/sync/index.js +67 -0
  197. package/src/services/sync/log-sync.js +60 -0
  198. package/src/services/sync/revision-merge.js +119 -0
  199. package/src/services/sync/strategy.js +44 -0
  200. package/src/services/transport-manager.js +478 -0
  201. package/{dist → src}/services/wallet-mirror-service.js +226 -6
  202. package/src/services/wifi-provisioning-service.js +278 -0
  203. package/dist/errors/index.js +0 -45
  204. package/dist/errors/index.js.map +0 -1
  205. package/dist/events/index.js +0 -17
  206. package/dist/events/index.js.map +0 -1
  207. package/dist/index.js +0 -31
  208. package/dist/index.js.map +0 -1
  209. package/dist/models/agent.js +0 -17
  210. package/dist/models/agent.js.map +0 -1
  211. package/dist/models/conversation.js +0 -2
  212. package/dist/models/conversation.js.map +0 -1
  213. package/dist/models/delegation.js.map +0 -1
  214. package/dist/models/device-identity.js.map +0 -1
  215. package/dist/models/graph.js.map +0 -1
  216. package/dist/models/index.js +0 -8
  217. package/dist/models/index.js.map +0 -1
  218. package/dist/models/json.js +0 -2
  219. package/dist/models/json.js.map +0 -1
  220. package/dist/models/l402.js +0 -2
  221. package/dist/models/l402.js.map +0 -1
  222. package/dist/models/live-query-definition.js +0 -2
  223. package/dist/models/live-query-definition.js.map +0 -1
  224. package/dist/models/outbox-intent.js +0 -2
  225. package/dist/models/outbox-intent.js.map +0 -1
  226. package/dist/models/outbox.js.map +0 -1
  227. package/dist/models/owner-views.js +0 -7
  228. package/dist/models/owner-views.js.map +0 -1
  229. package/dist/models/payment.js +0 -3
  230. package/dist/models/payment.js.map +0 -1
  231. package/dist/models/query-diff.js +0 -2
  232. package/dist/models/query-diff.js.map +0 -1
  233. package/dist/models/release-manifest.js +0 -147
  234. package/dist/models/release-manifest.js.map +0 -1
  235. package/dist/models/repository-write.js +0 -2
  236. package/dist/models/repository-write.js.map +0 -1
  237. package/dist/models/revisioned-commands.js +0 -35
  238. package/dist/models/revisioned-commands.js.map +0 -1
  239. package/dist/models/revisioned.js +0 -2
  240. package/dist/models/revisioned.js.map +0 -1
  241. package/dist/models/settings.js +0 -2
  242. package/dist/models/settings.js.map +0 -1
  243. package/dist/models/sync.js +0 -2
  244. package/dist/models/sync.js.map +0 -1
  245. package/dist/models/wallet.js +0 -2
  246. package/dist/models/wallet.js.map +0 -1
  247. package/dist/queries/conversations.js.map +0 -1
  248. package/dist/queries/discovery.js +0 -27
  249. package/dist/queries/discovery.js.map +0 -1
  250. package/dist/queries/notifications.js.map +0 -1
  251. package/dist/queries/onboarding-status.js +0 -27
  252. package/dist/queries/onboarding-status.js.map +0 -1
  253. package/dist/queries/revisioned.js +0 -58
  254. package/dist/queries/revisioned.js.map +0 -1
  255. package/dist/queries/wallet.js.map +0 -1
  256. package/dist/services/conversation-service.js.map +0 -1
  257. package/dist/services/conversation-sync.js +0 -9
  258. package/dist/services/conversation-sync.js.map +0 -1
  259. package/dist/services/discovery-probe-service.js +0 -129
  260. package/dist/services/discovery-probe-service.js.map +0 -1
  261. package/dist/services/gossip-service.js.map +0 -1
  262. package/dist/services/lan-candidate-refresh-service.js +0 -79
  263. package/dist/services/lan-candidate-refresh-service.js.map +0 -1
  264. package/dist/services/onboarding-status-service.js +0 -32
  265. package/dist/services/onboarding-status-service.js.map +0 -1
  266. package/dist/services/outbox-service.js.map +0 -1
  267. package/dist/services/owner-view-service.js +0 -21
  268. package/dist/services/owner-view-service.js.map +0 -1
  269. package/dist/services/peer-directory-service.js.map +0 -1
  270. package/dist/services/release-manifest-verifier.js +0 -84
  271. package/dist/services/release-manifest-verifier.js.map +0 -1
  272. package/dist/services/revisioned-domain-service.js.map +0 -1
  273. package/dist/services/session-service.js +0 -20
  274. package/dist/services/session-service.js.map +0 -1
  275. package/dist/services/sync/authoritative-mirror.js.map +0 -1
  276. package/dist/services/sync/index.js +0 -4
  277. package/dist/services/sync/index.js.map +0 -1
  278. package/dist/services/sync/log-sync.js +0 -20
  279. package/dist/services/sync/log-sync.js.map +0 -1
  280. package/dist/services/sync/revision-merge.js +0 -46
  281. package/dist/services/sync/revision-merge.js.map +0 -1
  282. package/dist/services/sync/strategy.js +0 -2
  283. package/dist/services/sync/strategy.js.map +0 -1
  284. package/dist/services/transport-manager.js +0 -234
  285. package/dist/services/transport-manager.js.map +0 -1
  286. package/dist/services/wallet-mirror-service.js.map +0 -1
@@ -0,0 +1,1096 @@
1
+ // @ts-check
2
+ /** @typedef {import('@econ-v1/ports').OnboardingAccessCodeStore} OnboardingAccessCodeStore */
3
+ /** @typedef {import('@econ-v1/ports').OnboardingPresencePhase} OnboardingPresencePhase */
4
+ /** @typedef {import('@econ-v1/ports').OnboardingPresencePort} OnboardingPresencePort */
5
+ /** @typedef {import("./invite-redemption-service.js").InviteRedemptionService} InviteRedemptionService */
6
+ /** @typedef {import("./lcd-decision-service.js").LcdDecisionService} LcdDecisionService */
7
+ /** @typedef {import("./onboarding-checkpoint-store.js").OnboardingCheckpointStore} OnboardingCheckpointStore */
8
+ /** @typedef {import("./owner-claim-service.js").OwnerClaimIdentity} OwnerClaimIdentity */
9
+ /** @typedef {import("./owner-claim-service.js").OwnerClaimService} OwnerClaimService */
10
+ /** @typedef {import("./wifi-provisioning-service.js").WifiProvisioningService} WifiProvisioningService */
11
+ import { createNullOnboardingPresence } from "@econ-v1/ports";
12
+ import { DomainError } from "../errors/index.js";
13
+ /** @type {readonly ["join-qr", "open-browser", "choose-network", "network-password", "handoff", "way-back", "install", "match-code", "invitation", "identity", "trusted"]} */
14
+ export const ONBOARDING_FLOW_STEP_IDS = [
15
+ "join-qr",
16
+ "open-browser",
17
+ "choose-network",
18
+ "network-password",
19
+ "handoff",
20
+ "way-back",
21
+ "install",
22
+ "match-code",
23
+ "invitation",
24
+ "identity",
25
+ "trusted",
26
+ ];
27
+ /** @type {readonly OnboardingFlowStep[]} */
28
+ /** @type {readonly OnboardingFlowStep[]} */
29
+ const STEP_TABLE = [
30
+ { id: "join-qr", lcdScreenId: "onboard_qr", phase: "connect" },
31
+ { id: "open-browser", lcdScreenId: "onboard_open_browser", phase: "connect" },
32
+ { id: "choose-network", lcdScreenId: "onboard_wifi_setup", phase: "connect" },
33
+ {
34
+ id: "network-password",
35
+ lcdScreenId: "onboard_wifi_setup",
36
+ phase: "connect",
37
+ },
38
+ { id: "handoff", lcdScreenId: "onboard_wifi_joined", phase: "connect" },
39
+ { id: "way-back", lcdScreenId: "onboard_wifi_joined", phase: "connect" },
40
+ { id: "install", lcdScreenId: "onboard_working", phase: "install" },
41
+ { id: "match-code", lcdScreenId: "onboard_code", phase: "secure" },
42
+ {
43
+ gate: "invitations",
44
+ id: "invitation",
45
+ lcdScreenId: "onboard_working",
46
+ phase: "identify",
47
+ },
48
+ { id: "identity", lcdScreenId: "onboard_working", phase: "identify" },
49
+ { id: "trusted", lcdScreenId: "onboard_trusted", phase: "ready" },
50
+ ];
51
+ /** @type {OnboardingFlowStepId} */
52
+ /** @type {OnboardingFlowStepId} */
53
+ const FIRST_PWA_STEP = "choose-network";
54
+ /** @type {ReadonlySet<OnboardingFlowStepId>} */
55
+ /** @type {ReadonlySet<OnboardingFlowStepId>} */
56
+ const WIFI_SKIPPABLE_STEPS = new Set([
57
+ "choose-network",
58
+ "network-password",
59
+ ]);
60
+ const CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
61
+ const HANDOFF_WINDOW_MS = 3 * 60 * 1000;
62
+ /** @type {Readonly<Record<string, "confirm" | "reject">>} */
63
+ /** @type {Readonly<Record<string, "confirm" | "reject">>} */
64
+ const LCD_DECISION_COMPONENTS = {
65
+ confirm_btn: "confirm",
66
+ reject_btn: "reject",
67
+ };
68
+ /** @type {Readonly<Record<string, string>>} */
69
+ /** @type {Readonly<Record<string, string>>} */
70
+ const LCD_WORKING_CAPTIONS = {
71
+ identity: "Naming your node",
72
+ install: "Installing the app",
73
+ invitation: "Setting up",
74
+ };
75
+ /** @type {Readonly<Partial<Record<OnboardingFlowStepId, OnboardingPresencePhase>>>} */
76
+ /** @type {Readonly<Partial<Record<OnboardingFlowStepId, OnboardingPresencePhase>>>} */
77
+ const PRESENCE_PHASE_BY_STEP = {
78
+ "choose-network": "wifi_setup",
79
+ install: "install",
80
+ };
81
+ export class OnboardingFlowService {
82
+ /** @readonly @type {OnboardingAccessCodeStore} */
83
+ #accessCodes;
84
+ /** @readonly @type {Pick<OnboardingCheckpointStore, "clear" | "read" | "write">} */
85
+ #checkpoints;
86
+ /** @readonly @type {OnboardingFlowClaimPort} */
87
+ #claim;
88
+ /** @readonly @type {() => boolean} */
89
+ #invitationRequired;
90
+ /** @readonly @type {() => boolean} */
91
+ #invitationsEnabled;
92
+ /** @readonly @type {OnboardingFlowInvitePort} */
93
+ #invites;
94
+ /** @readonly @type {OnboardingFlowLcdPort | undefined} */
95
+ #lcd;
96
+ /** @readonly @type {() => string} */
97
+ #now;
98
+ /** @readonly @type {OnboardingPresencePort} */
99
+ #presence;
100
+ /** @readonly @type {(stepId: OnboardingFlowStepId) => void} */
101
+ #publish;
102
+ /** @readonly @type {OnboardingFlowWifiPort} */
103
+ #wifi;
104
+ /** Latched ONCE by `start()` from `#accessCodes.read() !== null` and never recomputed
105
+ * afterward — not from a later `#accessCodes.read()`, not from `cancel()`, not from success
106
+ * cleanup. Once true, `#activeSteps()` excludes `match-code` for the rest of this instance's
107
+ * life: a store that later fails to read, or is cleared on success/cancel, must never
108
+ * silently un-latch the flow back onto the legacy match-code rail mid-attempt.
109
+ * @default false
110
+ */
111
+ #accessCodeMode = false;
112
+ /** @type {OnboardingFlowApprovalState} */
113
+ #approvalState = "waiting";
114
+ /** @default 0 */
115
+ #claimGeneration = 0;
116
+ /** @type {string | undefined} */
117
+ #deadline;
118
+ /** @default undefined[] */
119
+ /** @type {readonly string[]} */
120
+ #digits = [];
121
+ /** @type {string | undefined} */
122
+ #lockedDisplayName;
123
+ /** @default false */
124
+ #lcdHardware = false;
125
+ /** @default false */
126
+ #lcdTapPending = false;
127
+ /** @default undefined[] */
128
+ /** @type {readonly OnboardingFlowNetwork[]} */
129
+ #networks = [];
130
+ /** @type {string | undefined} */
131
+ #operationHash;
132
+ /** @type {OnboardingFlowReasonCode | undefined} */
133
+ #reasonCode;
134
+ /** @default 0 */
135
+ #remainingSeconds = 0;
136
+ /** @default false */
137
+ #requiresConfirmation = false;
138
+ /** @default 0 */
139
+ #scanGeneration = 0;
140
+ /** @default false */
141
+ #scanning = false;
142
+ /** @default false */
143
+ #sessionEstablished = false;
144
+ /** @type {OnboardingFlowStepId} */
145
+ #stepId = FIRST_PWA_STEP;
146
+ /** @default false */
147
+ #submittingIdentity = false;
148
+ /** @type {OnboardingFlowWifiSecurity} */
149
+ #targetSecurity = "wpa2";
150
+ /** @default "" */
151
+ #targetSsid = "";
152
+ #reconnectGeneration = 0;
153
+ /** @default 0 */
154
+ #wifiGeneration = 0;
155
+ /** @type {string | undefined} */
156
+ #wifiOperationId;
157
+ /**
158
+ * @param {OnboardingFlowServiceOptions} options
159
+ */
160
+ constructor(options) {
161
+ this.#accessCodes = options.accessCodes;
162
+ this.#checkpoints = options.checkpoints;
163
+ this.#claim = options.claim;
164
+ this.#invitationRequired = options.invitationRequired ?? (() => true);
165
+ this.#invitationsEnabled = options.invitationsEnabled;
166
+ this.#invites = options.invites;
167
+ this.#lcd = options.lcd;
168
+ this.#now = options.now;
169
+ this.#presence = options.presence ?? createNullOnboardingPresence();
170
+ this.#publish = options.publish;
171
+ this.#wifi = options.wifi;
172
+ }
173
+ /**
174
+ * @returns {OnboardingFlowRow}
175
+ */
176
+ view() {
177
+ const steps = this.#activeSteps();
178
+ const stepId = this.#stepId;
179
+ const step = /** @type {OnboardingFlowStep} */ (STEP_TABLE.find((row) => row.id === stepId));
180
+ /** @type {OnboardingFlowRowBase} */
181
+ /** @type {OnboardingFlowRowBase} */
182
+ const base = {
183
+ lcdScreenId: this.#lcdScreenId(step),
184
+ phase: step.phase,
185
+ stepIndex: steps.findIndex((row) => row.id === stepId) + 1,
186
+ totalSteps: steps.length,
187
+ ...(this.#reasonCode === undefined
188
+ ? {}
189
+ : { reasonCode: this.#reasonCode }),
190
+ };
191
+ switch (stepId) {
192
+ case "choose-network":
193
+ return {
194
+ ...base,
195
+ networks: this.#networks,
196
+ scanning: this.#scanning,
197
+ stepId,
198
+ };
199
+ case "network-password":
200
+ return {
201
+ ...base,
202
+ security: this.#targetSecurity,
203
+ ssid: this.#targetSsid,
204
+ stepId,
205
+ };
206
+ case "handoff":
207
+ return { ...base, ssid: this.#targetSsid, stepId };
208
+ case "match-code":
209
+ return {
210
+ ...base,
211
+ approvalState: this.#approvalState,
212
+ digits: this.#digits,
213
+ remainingSeconds: this.#remainingSeconds,
214
+ stepId,
215
+ };
216
+ case "invitation":
217
+ return {
218
+ ...base,
219
+ inviteRequired: this.#invitationRequired(),
220
+ stepId,
221
+ };
222
+ case "identity":
223
+ return {
224
+ ...base,
225
+ displayName: this.#lockedDisplayName ?? "",
226
+ displayNameLocked: this.#lockedDisplayName !== undefined,
227
+ stepId,
228
+ submitting: this.#submittingIdentity,
229
+ };
230
+ case "trusted":
231
+ return {
232
+ ...base,
233
+ sessionEstablished: this.#sessionEstablished,
234
+ stepId,
235
+ };
236
+ default:
237
+ return { ...base, stepId };
238
+ }
239
+ }
240
+ /**
241
+ * @returns {void}
242
+ */
243
+ start() {
244
+ // Latched HERE, before anything else — including the checkpoint restore right below, which
245
+ // consults `#activeSteps()` (and therefore `#accessCodeMode`) to validate a resumed step id.
246
+ // See `#accessCodeMode`'s own doc comment for why this is the only place it is ever set.
247
+ this.#accessCodeMode = this.#accessCodes.read() !== null;
248
+ const checkpoint = this.#checkpoints.read();
249
+ const restored =
250
+ checkpoint === undefined
251
+ ? undefined
252
+ : this.#asActiveStepId(checkpoint.stepId);
253
+ this.#approvalState = "waiting";
254
+ this.#deadline = checkpoint?.deadline;
255
+ this.#digits = [];
256
+ // Neither the invitation's name nor the session flag is checkpointed: both are facts about
257
+ // an in-memory attempt, and a resumed flow must re-earn them rather than assert them.
258
+ this.#lockedDisplayName = undefined;
259
+ // Deliberately not restored from the checkpoint: it is an answer about THIS claim attempt,
260
+ // and a resumed flow re-earns it at the next prepare. Until then the pane offers a real
261
+ // confirm, which a node with a display simply refuses — the safe direction.
262
+ this.#lcdHardware = false;
263
+ this.#lcdTapPending = false;
264
+ this.#operationHash = checkpoint?.operationHash;
265
+ this.#reasonCode = undefined;
266
+ // A checkpointed operation hash exists only because the node issued a gated claim, so a
267
+ // resume at step 10 is by construction a resume of a confirmation that is still pending.
268
+ this.#requiresConfirmation = this.#operationHash !== undefined;
269
+ this.#scanning = false;
270
+ this.#sessionEstablished = false;
271
+ this.#stepId = restored ?? FIRST_PWA_STEP;
272
+ // The security of the checkpointed SSID is deliberately NOT persisted: the password is not
273
+ // resumable either, so step 5 is re-shown rather than replayed, and WPA2 is the demanding
274
+ // default (it asks for a secret rather than silently offering to join without one).
275
+ this.#targetSecurity = "wpa2";
276
+ this.#targetSsid = checkpoint?.targetSsid ?? "";
277
+ this.#wifiOperationId = checkpoint?.wifiOperationId;
278
+ this.#recomputeRemaining();
279
+ this.#publish(this.#stepId);
280
+ // Resume re-send (spec §6.3): a reload re-announces the resumed step's phase, the same way
281
+ // `#moveTo` does for a live transition — the node's beacon-driven screen must not be left
282
+ // showing a stale phase just because the browser reloaded rather than advanced.
283
+ const resumedPhase = PRESENCE_PHASE_BY_STEP[this.#stepId];
284
+ if (resumedPhase) void this.#presence.announce(resumedPhase);
285
+ // Access-code mode's gate-entry prepare (design §4/§5.4): possession of the code IS the
286
+ // physical-presence proof, so this fires as soon as the flow starts — the "first device to
287
+ // access" moment — independent of which step a resumed checkpoint or the Wi-Fi skip-check
288
+ // below lands on, rather than waiting for a click through the now-absent match-code step.
289
+ if (this.#accessCodeMode) void this.#prepareCode();
290
+ if (this.#stepId === "choose-network") {
291
+ void this.#scan();
292
+ }
293
+ // A node whose Wi-Fi was configured on its own captive portal (or any earlier attempt)
294
+ // needs no credentials from this flow: asking again is the double-prompt this probe
295
+ // exists to kill, and RESUBMITTING working credentials is worse than redundant — the node
296
+ // re-runs the join, and a handshake flake in that window strands it back in setup AP mode
297
+ // (observed live on Pi Zero 2W). Fired for the wifi-skippable steps only; `handoff` keeps
298
+ // its own watcher.
299
+ if (WIFI_SKIPPABLE_STEPS.has(this.#stepId)) {
300
+ void this.#skipWifiIfAlreadyConnected();
301
+ }
302
+ if (this.#stepId === "handoff" && this.#wifiOperationId !== undefined) {
303
+ // A reload during the handoff is EXPECTED — it is often the very act of rejoining the home
304
+ // network that killed the tab. Resuming the watch is what stops that from restarting the
305
+ // whole flow. The window restarts from now: the reload itself is evidence the handoff is
306
+ // still in progress, and the original deadline is deliberately not checkpointed (it would
307
+ // collide with the claim window's `deadline` slot).
308
+ this.#wifiGeneration += 1;
309
+ this.#watchHandoff(this.#wifiOperationId, this.#wifiGeneration);
310
+ }
311
+ if (this.#stepId === "match-code" && this.#operationHash !== undefined) {
312
+ this.#claimGeneration += 1;
313
+ this.#watchConfirmation(this.#operationHash, this.#claimGeneration);
314
+ }
315
+ if (this.#stepId === "way-back") {
316
+ this.#watchWayBack();
317
+ }
318
+ }
319
+ /**
320
+ * @returns {Promise<void>}
321
+ */
322
+ async advance() {
323
+ switch (this.#stepId) {
324
+ case "join-qr":
325
+ case "open-browser":
326
+ this.#moveTo(this.#nextStepAfter(this.#stepId));
327
+ return;
328
+ case "way-back":
329
+ this.#enterInstall();
330
+ return;
331
+ case "install":
332
+ if (this.#reasonCode === "claim-failed") void this.installDecision("continue-in-browser");
333
+ return;
334
+ default:
335
+ // Every other step needs its own command. Silently ignoring a stray `advance` is
336
+ // deliberate: reinterpreting it as a generic "next" would discard the step's real data.
337
+ // Step 6 is now explicitly among them — only `awaitHandoff` may end it.
338
+ return;
339
+ }
340
+ }
341
+ /**
342
+ * @param {"continue-in-browser" | "installed"} decision
343
+ * @returns {Promise<void>}
344
+ */
345
+ async installDecision(decision) {
346
+ if (this.#stepId !== "install") return;
347
+ void decision;
348
+ if (await this.#prepareCode()) this.#enterMatchCode();
349
+ }
350
+ /**
351
+ * @returns {OnboardingFlowLcdScreen}
352
+ */
353
+ lcdScreen() {
354
+ const step = /** @type {OnboardingFlowStep} */ (STEP_TABLE.find((row) => row.id === this.#stepId));
355
+ return {
356
+ awaitingNode: this.#lcdTapPending,
357
+ physicalTapOnly: this.#lcdHardware,
358
+ screenId: this.#lcdScreenId(step),
359
+ state: this.#lcdScreenState(),
360
+ };
361
+ }
362
+ /**
363
+ * @param {string} componentId
364
+ * @returns {Promise<void>}
365
+ */
366
+ async lcdTap(componentId) {
367
+ if (this.#stepId !== "match-code") {
368
+ await this.advance();
369
+ return;
370
+ }
371
+ const decision = LCD_DECISION_COMPONENTS[componentId];
372
+ const operationHash = this.#operationHash;
373
+ if (decision === undefined || operationHash === undefined) return;
374
+ this.#lcdTapPending = true;
375
+ this.#publish(this.#stepId);
376
+ if (!this.#lcdHardware && this.#lcd !== undefined) {
377
+ // The outcome is deliberately discarded: "forwarded" is not approval and "not-accepted"
378
+ // is not a failure the person can act on differently — either way the instruction is the
379
+ // same, and the node's own poll is what ends this step.
380
+ await (decision === "confirm"
381
+ ? this.#lcd.confirm(operationHash)
382
+ : this.#lcd.reject(operationHash));
383
+ }
384
+ this.#lcdTapPending = false;
385
+ this.#publish(this.#stepId);
386
+ }
387
+ /**
388
+ * @returns {Promise<void>}
389
+ */
390
+ async rescanNetworks() {
391
+ if (this.#stepId !== "choose-network") return;
392
+ await this.#scan();
393
+ }
394
+ /**
395
+ * @param {string} code
396
+ * @returns {Promise<void>}
397
+ */
398
+ async redeemInvitation(code) {
399
+ if (this.#stepId !== "invitation") return;
400
+ /** @type {Awaited<ReturnType<OnboardingFlowInvitePort["redeem"]>>} */
401
+ const outcome = await this.#invites.redeem(code);
402
+ if (this.#stepId !== "invitation") return;
403
+ if (outcome.kind === "rejected") {
404
+ this.#reasonCode = "invitation-invalid";
405
+ this.#publish(this.#stepId);
406
+ return;
407
+ }
408
+ // The name the INVITER registered wins over anything typed at step 12 — that is what makes
409
+ // the invitation mean something on the inviter's node rather than being decoration.
410
+ this.#lockedDisplayName = outcome.receiverName;
411
+ if (this.#accessCodeMode) {
412
+ // Keep-alive re-prepare (design §5.4), same idea as `installDecision()`'s: a slow person
413
+ // on the invitation step can let the operation's confirmed_at freshness window lapse just
414
+ // as easily as a slow person on step 9 can. UNLIKE `installDecision()`, this is best-effort
415
+ // — the invitation was already accepted by the node, so a re-prepare failure here must not
416
+ // strand a successful redemption behind it. `#prepareCode()`'s own error handling still
417
+ // runs (reason code set + published) but is immediately superseded by the `#moveTo` below,
418
+ // which always resets the reason code; the completion-failure belt in `submitIdentity()`
419
+ // remains the backstop if the operation is ALSO stale by the time identity is submitted.
420
+ await this.#prepareCode();
421
+ if (this.#stepId !== "invitation") return;
422
+ }
423
+ this.#moveTo(this.#nextStepAfter("invitation"));
424
+ }
425
+ /**
426
+ * @param {string} ssid
427
+ * @param {OnboardingFlowWifiSecurity} security
428
+ * @returns {Promise<void>}
429
+ */
430
+ async selectNetwork(ssid, security) {
431
+ if (this.#stepId !== "choose-network") return;
432
+ // Abandon any in-flight scan: its late result must not repaint a picker nobody is on.
433
+ this.#scanGeneration += 1;
434
+ this.#scanning = false;
435
+ this.#targetSecurity = security;
436
+ this.#targetSsid = ssid;
437
+ this.#moveTo("network-password");
438
+ }
439
+ /**
440
+ * @returns {Promise<void>}
441
+ */
442
+ async reselectNetwork() {
443
+ if (this.#stepId !== "network-password") return;
444
+ this.#enterChooseNetwork();
445
+ }
446
+ /**
447
+ * @returns {Promise<void>}
448
+ */
449
+ async skipInvitation() {
450
+ if (this.#stepId !== "invitation") return;
451
+ if (this.#invitationRequired()) {
452
+ // The node demands a code. Refusing here rather than in the UI is what stops a stale
453
+ // bundle (or a direct command) from walking past the node's own policy.
454
+ this.#reasonCode = "invitation-invalid";
455
+ this.#publish(this.#stepId);
456
+ return;
457
+ }
458
+ this.#moveTo(this.#nextStepAfter("invitation"));
459
+ }
460
+ /**
461
+ * @param {OwnerClaimIdentity} identity
462
+ * @returns {Promise<void>}
463
+ */
464
+ async submitIdentity(identity) {
465
+ if (this.#stepId !== "identity") return;
466
+ // Completion does real work on the node (owner record, DID, session mint) and routinely
467
+ // takes seconds, so the submitting window is a published state: the form renders it as a
468
+ // disabled "Setting up your node…" button instead of a dead one, and a second submit while
469
+ // one is in flight is refused rather than racing two owner creations.
470
+ if (this.#submittingIdentity) return;
471
+ this.#submittingIdentity = true;
472
+ this.#reasonCode = undefined;
473
+ this.#publish(this.#stepId);
474
+ const displayName = this.#lockedDisplayName ?? identity.displayName;
475
+ let outcome;
476
+ try {
477
+ outcome = await this.#claim.completeClaim({
478
+ displayName,
479
+ username: identity.username,
480
+ });
481
+ } catch {
482
+ // A thrown completion (network death mid-request) must not strand the flag: the button
483
+ // would read "Setting up your node…" forever with no way to retry.
484
+ outcome = "rejected";
485
+ }
486
+ this.#submittingIdentity = false;
487
+ if (this.#stepId !== "identity") return;
488
+ if (outcome !== "claimed" && outcome !== "claimed-unpaired") {
489
+ if (this.#accessCodeMode) {
490
+ // Belt for the op-expiry case (design §5.4): the node's confirmed_at freshness window
491
+ // may have lapsed while the person filled in their identity, so re-fire the idempotent
492
+ // prepare once before giving up — a press of "try again" then lands on a fresh
493
+ // operation instead of repeating the same expired one. If THIS call fails with its own
494
+ // specific access-code reason, `#prepareCode()` has already set and published it, which
495
+ // is more useful than the generic fallback below — so skip straight past it.
496
+ const reprepared = await this.#prepareCode();
497
+ if (this.#stepId !== "identity") return;
498
+ if (!reprepared) return;
499
+ }
500
+ this.#reasonCode = "claim-failed";
501
+ this.#publish(this.#stepId);
502
+ return;
503
+ }
504
+ // PAST THIS LINE THE OWNER EXISTS ON THE NODE (mirrors `OwnerClaimService.completeClaim`'s
505
+ // own comment) — cleanup of the stored code belongs here alongside the other terminal
506
+ // states, not gated on `#accessCodeMode`: an empty store's `clear()` is a documented no-op.
507
+ this.#accessCodes.clear();
508
+ this.#approvalState = "approved";
509
+ this.#sessionEstablished = outcome === "claimed";
510
+ this.#moveTo("trusted");
511
+ }
512
+ /**
513
+ * @param {string} secret
514
+ * @returns {Promise<void>}
515
+ */
516
+ async submitNetworkPassword(secret) {
517
+ if (this.#stepId !== "network-password") return;
518
+ // Named `isOpenNetwork`, not `open`: a bare `open` binding reads as the browser global to
519
+ // `client/scripts/check-boundaries.mjs`, which is exactly the kind of platform reference
520
+ // domain is not allowed to hold.
521
+ const isOpenNetwork = this.#targetSecurity === "open";
522
+ if (!isOpenNetwork && secret.length === 0) {
523
+ this.#reasonCode = "wifi-credentials-rejected";
524
+ this.#publish(this.#stepId);
525
+ return;
526
+ }
527
+ const outcome = await this.#wifi.configure(
528
+ this.#targetSsid,
529
+ isOpenNetwork ? undefined : secret,
530
+ );
531
+ if (this.#stepId !== "network-password") return;
532
+ if (outcome.kind === "credentials-rejected") {
533
+ this.#reasonCode = "wifi-credentials-rejected";
534
+ this.#publish(this.#stepId);
535
+ return;
536
+ }
537
+ if (outcome.kind === "unreachable") {
538
+ this.#reasonCode = "wifi-unreachable";
539
+ this.#publish(this.#stepId);
540
+ return;
541
+ }
542
+ this.#wifiGeneration += 1;
543
+ this.#wifiOperationId = outcome.operationId;
544
+ this.#moveTo("handoff");
545
+ this.#watchHandoff(outcome.operationId, this.#wifiGeneration);
546
+ }
547
+ /**
548
+ * @returns {Promise<void>}
549
+ */
550
+ async retryApproval() {
551
+ if (this.#stepId !== "match-code") return;
552
+ if (this.#approvalState !== "expired" && this.#approvalState !== "rejected")
553
+ return;
554
+ if (await this.#prepareCode()) this.#enterMatchCode();
555
+ }
556
+ /**
557
+ * @returns {void}
558
+ */
559
+ cancel() {
560
+ this.#claimGeneration += 1;
561
+ this.#claim.cancel();
562
+ this.#accessCodes.clear();
563
+ this.#checkpoints.clear();
564
+ this.#approvalState = "waiting";
565
+ this.#deadline = undefined;
566
+ this.#digits = [];
567
+ this.#lockedDisplayName = undefined;
568
+ this.#lcdHardware = false;
569
+ this.#lcdTapPending = false;
570
+ this.#networks = [];
571
+ this.#operationHash = undefined;
572
+ this.#reasonCode = undefined;
573
+ this.#remainingSeconds = 0;
574
+ this.#requiresConfirmation = false;
575
+ this.#scanGeneration += 1;
576
+ this.#scanning = false;
577
+ this.#sessionEstablished = false;
578
+ this.#stepId = FIRST_PWA_STEP;
579
+ this.#submittingIdentity = false;
580
+ this.#targetSecurity = "wpa2";
581
+ this.#targetSsid = "";
582
+ this.#wifiGeneration += 1;
583
+ this.#wifiOperationId = undefined;
584
+ this.#publish(this.#stepId);
585
+ }
586
+ /**
587
+ * @returns {void}
588
+ */
589
+ tick() {
590
+ if (this.#stepId !== "match-code" || this.#approvalState !== "waiting")
591
+ return;
592
+ const before = this.#remainingSeconds;
593
+ this.#recomputeRemaining();
594
+ if (this.#remainingSeconds === 0 && this.#deadline !== undefined) {
595
+ this.#approvalState = "expired";
596
+ this.#reasonCode = "approval-expired";
597
+ }
598
+ if (
599
+ this.#remainingSeconds !== before ||
600
+ this.#approvalState !== "waiting"
601
+ ) {
602
+ this.#publish(this.#stepId);
603
+ }
604
+ }
605
+ /**
606
+ * @returns {readonly OnboardingFlowStep[]}
607
+ */
608
+ #activeSteps() {
609
+ const invitations = this.#invitationsEnabled();
610
+ return STEP_TABLE.filter((row) => {
611
+ if (row.gate === "invitations" && !invitations) return false;
612
+ // Access-code mode never shows the match-code screen (design §4): the code itself was
613
+ // this claim's physical-presence proof, so there is no code to display and nothing to
614
+ // wait for. See `#accessCodeMode`'s doc comment for why this can never flip back.
615
+ if (row.id === "match-code" && this.#accessCodeMode) return false;
616
+ return true;
617
+ });
618
+ }
619
+ /**
620
+ * @param {string} value
621
+ * @returns {OnboardingFlowStepId | undefined}
622
+ */
623
+ #asActiveStepId(value) {
624
+ return this.#activeSteps().find((row) => row.id === value)?.id;
625
+ }
626
+ /**
627
+ * @param {OnboardingFlowReasonCode} [reasonCode]
628
+ * @returns {void}
629
+ */
630
+ #enterChooseNetwork(reasonCode) {
631
+ this.#networks = [];
632
+ this.#scanning = true;
633
+ this.#wifiOperationId = undefined;
634
+ this.#moveTo("choose-network", reasonCode);
635
+ void this.#scan();
636
+ }
637
+ /**
638
+ * @returns {void}
639
+ */
640
+ #enterInstall() {
641
+ this.#moveTo("install");
642
+ }
643
+ /**
644
+ * @returns {Promise<void>}
645
+ */
646
+ async #skipWifiIfAlreadyConnected() {
647
+ const outcome = await this.#wifi.status();
648
+ if (outcome.kind !== "connected") return;
649
+ if (!WIFI_SKIPPABLE_STEPS.has(this.#stepId)) return;
650
+ if (this.#wifiOperationId !== undefined) return;
651
+ if (outcome.ssid.length > 0) this.#targetSsid = outcome.ssid;
652
+ this.#enterInstall();
653
+ }
654
+ /**
655
+ * @returns {Promise<void>}
656
+ */
657
+ async #scan() {
658
+ this.#scanGeneration += 1;
659
+ const generation = this.#scanGeneration;
660
+ if (!this.#scanning) {
661
+ this.#scanning = true;
662
+ this.#publish(this.#stepId);
663
+ }
664
+ const outcome = await this.#wifi.scan();
665
+ // A superseded scan, or one whose step the person already left, must not repaint anything.
666
+ if (
667
+ generation !== this.#scanGeneration ||
668
+ this.#stepId !== "choose-network"
669
+ )
670
+ return;
671
+ this.#scanning = false;
672
+ if (outcome.kind === "scan-failed") {
673
+ this.#networks = [];
674
+ this.#reasonCode = "wifi-scan-failed";
675
+ } else {
676
+ this.#networks = outcome.networks;
677
+ // Only this scan's own failure is cleared. A `wifi-credentials-rejected` carried in from
678
+ // step 6 must survive the re-scan — it is the reason the person is back on this screen.
679
+ if (this.#reasonCode === "wifi-scan-failed") this.#reasonCode = undefined;
680
+ }
681
+ this.#publish(this.#stepId);
682
+ }
683
+ /**
684
+ * @returns {Promise<boolean>}
685
+ */
686
+ async #prepareCode() {
687
+ this.#claimGeneration += 1;
688
+ const generation = this.#claimGeneration;
689
+ // A retry must not keep showing the error it is retrying: clear the reason and publish so
690
+ // the step goes back to its working state while the new attempt is in flight. Guarded on
691
+ // there actually being a reason to clear, so a FIRST prepare still costs exactly one
692
+ // publish and the "one publish per transition" invariant holds for every ordinary walk.
693
+ if (this.#reasonCode !== undefined) {
694
+ this.#reasonCode = undefined;
695
+ this.#publish(this.#stepId);
696
+ }
697
+ // Access-code mode (design §4/§5.4): the stored code is re-read on every call, not cached
698
+ // from the gate-entry prepare — the MODE itself never un-latches (`#accessCodeMode` is set
699
+ // once, in `start()`), but the code VALUE sent on the wire always reflects the store's
700
+ // current answer, including the degenerate case where a later read comes back `null`.
701
+ const accessCode = this.#accessCodeMode ? this.#accessCodes.read() ?? undefined : undefined;
702
+ /** @type {Awaited<ReturnType<OnboardingFlowClaimPort["prepareClaim"]>>} */
703
+ let prepared;
704
+ try {
705
+ prepared = await this.#claim.prepareClaim(accessCode);
706
+ } catch (error) {
707
+ // No raw node error text ever reaches the view — only a reason code plus the step's own
708
+ // safe copy. The step holds so the person can retry. A structured access-code refusal
709
+ // (`code-already-bound` / `access-code-invalid`) gets its own specific reason instead of
710
+ // the generic `claim-failed` fallback.
711
+ if (generation !== this.#claimGeneration) return false;
712
+ this.#reasonCode = accessCodeReasonCode(error) ?? "claim-failed";
713
+ this.#publish(this.#stepId);
714
+ return false;
715
+ }
716
+ if (generation !== this.#claimGeneration) return false;
717
+ this.#approvalState = "waiting";
718
+ this.#deadline = new Date(
719
+ Date.parse(this.#now()) + CONFIRMATION_WINDOW_MS,
720
+ ).toISOString();
721
+ this.#digits = [...prepared.matchCode];
722
+ this.#lcdHardware = prepared.lcdHardware;
723
+ this.#operationHash = prepared.operationHash;
724
+ this.#requiresConfirmation = prepared.requiresConfirmation;
725
+ this.#recomputeRemaining();
726
+ return true;
727
+ }
728
+ /**
729
+ * @returns {void}
730
+ */
731
+ #enterMatchCode() {
732
+ const operationHash = this.#operationHash;
733
+ if (this.#accessCodeMode) {
734
+ // The code itself already proved physical presence (design §4) — there is no screen to
735
+ // show and no confirmation left to wait for. `#nextStepAfter("install")`, not
736
+ // `#nextStepAfter("match-code")`: `match-code` is excluded from `#activeSteps()` in this
737
+ // mode, so looking its own index up would resolve to -1 and wrap back to the first step.
738
+ this.#approvalState = "approved";
739
+ this.#moveTo(this.#nextStepAfter("install"));
740
+ return;
741
+ }
742
+ if (!this.#requiresConfirmation || operationHash === undefined) {
743
+ // The node's confirmation gate is disabled (no screen): there is nothing to wait for and
744
+ // nothing to compare, so the flow must not stall on a confirmation that never arrives.
745
+ this.#approvalState = "approved";
746
+ this.#moveTo(this.#nextStepAfter("match-code"));
747
+ return;
748
+ }
749
+ this.#moveTo("match-code");
750
+ this.#watchConfirmation(operationHash, this.#claimGeneration);
751
+ }
752
+ /**
753
+ * @param {OnboardingFlowStep} step
754
+ * @returns {OnboardingFlowLcdScreenId}
755
+ */
756
+ #lcdScreenId(step) {
757
+ if (
758
+ step.id === "match-code" &&
759
+ (this.#approvalState === "expired" || this.#approvalState === "rejected")
760
+ ) {
761
+ return "onboard_rejected";
762
+ }
763
+ return step.lcdScreenId;
764
+ }
765
+ /**
766
+ * @returns {Readonly<Record<string, number | string>>}
767
+ */
768
+ #lcdScreenState() {
769
+ switch (this.#stepId) {
770
+ case "handoff":
771
+ return this.#targetSsid === "" ? {} : { target_ssid: this.#targetSsid };
772
+ case "match-code":
773
+ return this.#operationHash === undefined
774
+ ? {}
775
+ : {
776
+ code: this.#digits.join(""),
777
+ operation_hash: this.#operationHash,
778
+ remaining_seconds: this.#remainingSeconds,
779
+ ttl_seconds: Math.round(CONFIRMATION_WINDOW_MS / 1000),
780
+ };
781
+ case "install":
782
+ case "invitation":
783
+ case "identity":
784
+ return { caption: LCD_WORKING_CAPTIONS[this.#stepId] ?? "" };
785
+ default:
786
+ // Every other screen is either stateless or holds state only the NODE knows (its LAN
787
+ // address, its AP client count). Sending nothing leaves the node's own value intact.
788
+ return {};
789
+ }
790
+ }
791
+ /**
792
+ * @param {OnboardingFlowStepId} stepId
793
+ * @param {OnboardingFlowReasonCode} [reasonCode]
794
+ * @returns {void}
795
+ */
796
+ #moveTo(stepId, reasonCode) {
797
+ this.#stepId = stepId;
798
+ this.#reasonCode = reasonCode;
799
+ this.#writeCheckpoint();
800
+ this.#publish(stepId);
801
+ const phase = PRESENCE_PHASE_BY_STEP[stepId];
802
+ if (phase) void this.#presence.announce(phase);
803
+ }
804
+ /**
805
+ * @param {OnboardingFlowStepId} stepId
806
+ * @returns {OnboardingFlowStepId}
807
+ */
808
+ #nextStepAfter(stepId) {
809
+ const steps = this.#activeSteps();
810
+ const index = steps.findIndex((row) => row.id === stepId);
811
+ return steps[index + 1]?.id ?? "trusted";
812
+ }
813
+ /**
814
+ * @returns {void}
815
+ */
816
+ #recomputeRemaining() {
817
+ if (this.#deadline === undefined) {
818
+ this.#remainingSeconds = 0;
819
+ return;
820
+ }
821
+ const remainingMs = Date.parse(this.#deadline) - Date.parse(this.#now());
822
+ this.#remainingSeconds =
823
+ remainingMs <= 0 ? 0 : Math.ceil(remainingMs / 1000);
824
+ }
825
+ /**
826
+ * @param {string} operationId
827
+ * @param {number} generation
828
+ * @returns {void}
829
+ */
830
+ #watchHandoff(operationId, generation) {
831
+ const deadline = Date.parse(this.#now()) + HANDOFF_WINDOW_MS;
832
+ const onTransportLost = () => {
833
+ if (generation !== this.#wifiGeneration) return;
834
+ if (this.#stepId !== "handoff") return;
835
+ this.#moveTo("way-back");
836
+ this.#watchWayBack();
837
+ };
838
+ void this.#wifi
839
+ .awaitHandoff(operationId, deadline, onTransportLost)
840
+ .then((outcome) => {
841
+ if (generation !== this.#wifiGeneration) return;
842
+ this.#wifiOperationId = undefined;
843
+ if (outcome === "connected") {
844
+ if (this.#stepId === "handoff") {
845
+ this.#moveTo("way-back");
846
+ this.#watchWayBack();
847
+ }
848
+ return;
849
+ }
850
+ if (outcome === "credentials-rejected") {
851
+ if (this.#stepId === "handoff" || this.#stepId === "way-back") {
852
+ this.#enterChooseNetwork("wifi-credentials-rejected");
853
+ }
854
+ return;
855
+ }
856
+ if (this.#stepId === "handoff") {
857
+ this.#moveTo("way-back", "wifi-unreachable");
858
+ this.#watchWayBack();
859
+ }
860
+ })
861
+ .catch(() => {
862
+ if (generation !== this.#wifiGeneration) return;
863
+ this.#wifiOperationId = undefined;
864
+ if (this.#stepId === "handoff") {
865
+ this.#moveTo("way-back", "wifi-unreachable");
866
+ this.#watchWayBack();
867
+ }
868
+ });
869
+ }
870
+ #watchWayBack() {
871
+ this.#reconnectGeneration += 1;
872
+ const generation = this.#reconnectGeneration;
873
+ const live = () => generation === this.#reconnectGeneration && this.#stepId === "way-back";
874
+ void this.#wifi.awaitReconnect(live).then((outcome) => {
875
+ if (outcome !== "connected" || !live()) return;
876
+ this.#enterInstall();
877
+ }).catch(() => {
878
+ // A broken probe leaves the scan instruction as the truthful exit.
879
+ });
880
+ }
881
+ /**
882
+ * @param {string} operationHash
883
+ * @param {number} generation
884
+ * @returns {void}
885
+ */
886
+ #watchConfirmation(operationHash, generation) {
887
+ void this.#claim
888
+ .awaitConfirmation(operationHash)
889
+ .then((confirmation) => {
890
+ if (generation !== this.#claimGeneration) return;
891
+ if (confirmation === "confirmed") {
892
+ this.#approvalState = "approved";
893
+ this.#moveTo(this.#nextStepAfter("match-code"));
894
+ return;
895
+ }
896
+ this.#approvalState =
897
+ confirmation === "rejected" ? "rejected" : "expired";
898
+ this.#reasonCode =
899
+ confirmation === "rejected"
900
+ ? "approval-rejected"
901
+ : "approval-expired";
902
+ this.#publish(this.#stepId);
903
+ })
904
+ .catch(() => {
905
+ if (generation !== this.#claimGeneration) return;
906
+ this.#approvalState = "rejected";
907
+ this.#reasonCode = "claim-failed";
908
+ this.#publish(this.#stepId);
909
+ });
910
+ }
911
+ /**
912
+ * @returns {void}
913
+ */
914
+ #writeCheckpoint() {
915
+ if (this.#stepId === "trusted") {
916
+ this.#checkpoints.clear();
917
+ return;
918
+ }
919
+ this.#checkpoints.write({
920
+ stepId: this.#stepId,
921
+ ...(this.#deadline === undefined ? {} : { deadline: this.#deadline }),
922
+ ...(this.#operationHash === undefined
923
+ ? {}
924
+ : { operationHash: this.#operationHash }),
925
+ ...(this.#targetSsid === "" ? {} : { targetSsid: this.#targetSsid }),
926
+ ...(this.#wifiOperationId === undefined
927
+ ? {}
928
+ : { wifiOperationId: this.#wifiOperationId }),
929
+ });
930
+ }
931
+ }
932
+ /**
933
+ * Maps a `prepareClaim()` rejection onto the access-code rail's two specific reason codes
934
+ * (design §5.4), so `#prepareCode()` can surface "the code is bound to another device" or "that
935
+ * code didn't match" instead of the generic `claim-failed` catch-all. `undefined` for anything
936
+ * else — a transport failure, a plain thrown `Error`, or a `DomainError` with an unrelated code
937
+ * — which leaves `#prepareCode()` to fall back to `claim-failed` as it always has.
938
+ * @param {unknown} error
939
+ * @returns {OnboardingFlowReasonCode | undefined}
940
+ */
941
+ function accessCodeReasonCode(error) {
942
+ if (!(error instanceof DomainError)) return undefined;
943
+ switch (error.code) {
944
+ case "code-already-bound":
945
+ return "code-already-bound";
946
+ case "access-code-invalid":
947
+ return "access-code-invalid";
948
+ default:
949
+ return undefined;
950
+ }
951
+ }
952
+ /** @typedef {(typeof ONBOARDING_FLOW_STEP_IDS)[number]} OnboardingFlowStepId */
953
+ /** @typedef {"connect" | "install" | "secure" | "identify" | "ready"} OnboardingFlowPhase */
954
+ /**
955
+ * @typedef {| "onboard_qr"
956
+ * | "onboard_open_browser"
957
+ * | "onboard_home_qr"
958
+ * | "onboard_wifi_setup"
959
+ * | "onboard_wifi_joined"
960
+ * | "onboard_working"
961
+ * | "onboard_code"
962
+ * | "onboard_trusted"
963
+ * | "onboard_rejected"} OnboardingFlowLcdScreenId
964
+ */
965
+ /** @typedef {"open" | "wep" | "wpa2" | "wpa3"} OnboardingFlowWifiSecurity */
966
+ /**
967
+ * @typedef {| "waiting"
968
+ * | "approved"
969
+ * | "rejected"
970
+ * | "expired"
971
+ * | "cancelled"} OnboardingFlowApprovalState
972
+ */
973
+ /**
974
+ * Access-code rail (2026-08-10 design): `code-already-bound`/`access-code-invalid` are the
975
+ * node's outright refusal of this device's claim — the code is already bound to a different
976
+ * device, or the supplied/missing code did not match.
977
+ * @typedef {| "approval-expired"
978
+ * | "approval-rejected"
979
+ * | "wifi-credentials-rejected"
980
+ * | "wifi-scan-failed"
981
+ * | "wifi-unreachable"
982
+ * | "invitation-invalid"
983
+ * | "claim-failed"
984
+ * | "code-already-bound"
985
+ * | "access-code-invalid"} OnboardingFlowReasonCode
986
+ */
987
+ /**
988
+ * @typedef {OnboardingFlowRowBase & ({
989
+ * readonly networks: readonly OnboardingFlowNetwork[];
990
+ * readonly scanning: boolean;
991
+ * readonly stepId: "choose-network";
992
+ * }
993
+ * | {
994
+ * readonly security: OnboardingFlowWifiSecurity;
995
+ * readonly ssid: string;
996
+ * readonly stepId: "network-password";
997
+ * }
998
+ * | {
999
+ * readonly approvalState: OnboardingFlowApprovalState;
1000
+ * readonly digits: readonly string[];
1001
+ * readonly remainingSeconds: number;
1002
+ * readonly stepId: "match-code";
1003
+ * }
1004
+ * | {
1005
+ * readonly inviteRequired: boolean;
1006
+ * readonly stepId: "invitation";
1007
+ * }
1008
+ * | {
1009
+ * readonly displayName: string;
1010
+ * readonly displayNameLocked: boolean;
1011
+ * readonly stepId: "identity";
1012
+ * readonly submitting: boolean;
1013
+ * }
1014
+ * | {
1015
+ * readonly sessionEstablished: boolean;
1016
+ * readonly stepId: "trusted";
1017
+ * }
1018
+ * | {
1019
+ * readonly ssid: string;
1020
+ * readonly stepId: "handoff";
1021
+ * }
1022
+ * | {
1023
+ * readonly stepId: Exclude<
1024
+ * OnboardingFlowStepId,
1025
+ * | "choose-network"
1026
+ * | "handoff"
1027
+ * | "identity"
1028
+ * | "invitation"
1029
+ * | "match-code"
1030
+ * | "network-password"
1031
+ * | "trusted"
1032
+ * >;
1033
+ * })} OnboardingFlowRow
1034
+ */
1035
+ /**
1036
+ * @typedef {Pick<
1037
+ * OwnerClaimService,
1038
+ * "awaitConfirmation" | "cancel" | "completeClaim" | "prepareClaim"
1039
+ * >} OnboardingFlowClaimPort
1040
+ */
1041
+ /** @typedef {Pick<InviteRedemptionService, "redeem">} OnboardingFlowInvitePort */
1042
+ /**
1043
+ * @typedef {Pick<
1044
+ * WifiProvisioningService,
1045
+ * "awaitHandoff" | "awaitReconnect" | "configure" | "scan" | "status"
1046
+ * >} OnboardingFlowWifiPort
1047
+ */
1048
+ /** @typedef {Pick<LcdDecisionService, "confirm" | "reject">} OnboardingFlowLcdPort */
1049
+ /**
1050
+ * @typedef {object} OnboardingFlowNetwork
1051
+ * @property {OnboardingFlowWifiSecurity} security
1052
+ * @property {number} signal
1053
+ * @property {string} ssid
1054
+ */
1055
+ /**
1056
+ * @typedef {object} OnboardingFlowRowBase
1057
+ * @property {OnboardingFlowLcdScreenId} lcdScreenId
1058
+ * @property {OnboardingFlowPhase} phase
1059
+ * @property {OnboardingFlowReasonCode} [reasonCode]
1060
+ * @property {number} stepIndex
1061
+ * @property {number} totalSteps
1062
+ */
1063
+ /**
1064
+ * @typedef {object} OnboardingFlowLcdScreen
1065
+ * @property {boolean} awaitingNode
1066
+ * @property {boolean} physicalTapOnly
1067
+ * @property {OnboardingFlowLcdScreenId} screenId
1068
+ * @property {Readonly<Record<string, number | string>>} state
1069
+ */
1070
+ /**
1071
+ * @typedef {object} OnboardingFlowServiceOptions
1072
+ * @property {OnboardingAccessCodeStore} accessCodes The onboarding access-code rail (2026-08-10 design): scanning the step-6 QR carries a short
1073
+ * code that becomes this claim attempt's physical-presence proof, replacing the match-code
1074
+ * screen entirely. A non-null `read()` at `start()` time LATCHES the flow into access-code
1075
+ * mode for its whole lifetime — see `#accessCodeMode`'s own doc comment. Required, not
1076
+ * optional: every composition must decide explicitly, even if that decision is "wire a store
1077
+ * that never holds a code" (a headless or test composition).
1078
+ * @property {Pick<OnboardingCheckpointStore, "clear" | "read" | "write">} checkpoints
1079
+ * @property {OnboardingFlowClaimPort} claim
1080
+ * @property {() => boolean} [invitationRequired]
1081
+ * @property {() => boolean} invitationsEnabled
1082
+ * @property {OnboardingFlowInvitePort} invites
1083
+ * @property {OnboardingFlowLcdPort} [lcd]
1084
+ * @property {() => string} now
1085
+ * @property {OnboardingPresencePort} [presence]
1086
+ * @property {(stepId: OnboardingFlowStepId) => void} publish
1087
+ * @property {OnboardingFlowWifiPort} wifi
1088
+ */
1089
+ /**
1090
+ * @typedef {object} OnboardingFlowStep
1091
+ * @property {"invitations"} [gate]
1092
+ * @property {OnboardingFlowStepId} id
1093
+ * @property {OnboardingFlowLcdScreenId} lcdScreenId
1094
+ * @property {OnboardingFlowPhase} phase
1095
+ */
1096
+ // @ts-check