@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.
- package/dist/errors/index.d.ts +96 -25
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/events/index.d.ts +127 -20
- package/dist/events/index.d.ts.map +1 -1
- package/dist/index.d.ts +250 -47
- package/dist/index.d.ts.map +1 -1
- package/dist/models/agent.d.ts +25 -4
- package/dist/models/agent.d.ts.map +1 -1
- package/dist/models/app-data.d.ts +84 -0
- package/dist/models/app-data.d.ts.map +1 -0
- package/dist/models/conversation.d.ts +12 -2
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/delegation.d.ts +12 -3
- package/dist/models/delegation.d.ts.map +1 -1
- package/dist/models/device-boot-state.d.ts +12 -0
- package/dist/models/device-boot-state.d.ts.map +1 -0
- package/dist/models/device-identity.d.ts +11 -3
- package/dist/models/device-identity.d.ts.map +1 -1
- package/dist/models/graph.d.ts +26 -3
- package/dist/models/graph.d.ts.map +1 -1
- package/dist/models/index.d.ts +82 -14
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/json.d.ts +5 -5
- package/dist/models/json.d.ts.map +1 -1
- package/dist/models/l402.d.ts +4 -5
- package/dist/models/l402.d.ts.map +1 -1
- package/dist/models/live-query-definition.d.ts +15 -16
- package/dist/models/live-query-definition.d.ts.map +1 -1
- package/dist/models/node-app-memory.d.ts +107 -0
- package/dist/models/node-app-memory.d.ts.map +1 -0
- package/dist/models/outbox-intent.d.ts +3 -3
- package/dist/models/outbox-intent.d.ts.map +1 -1
- package/dist/models/outbox.d.ts +67 -15
- package/dist/models/outbox.d.ts.map +1 -1
- package/dist/models/owner-views.d.ts +28 -28
- package/dist/models/owner-views.d.ts.map +1 -1
- package/dist/models/payment.d.ts +28 -9
- package/dist/models/payment.d.ts.map +1 -1
- package/dist/models/query-diff.d.ts +2 -2
- package/dist/models/query-diff.d.ts.map +1 -1
- package/dist/models/release-manifest.d.ts +32 -47
- package/dist/models/release-manifest.d.ts.map +1 -1
- package/dist/models/repository-write.d.ts +3 -3
- package/dist/models/repository-write.d.ts.map +1 -1
- package/dist/models/revisioned-commands.d.ts +66 -13
- package/dist/models/revisioned-commands.d.ts.map +1 -1
- package/dist/models/revisioned.d.ts +50 -10
- package/dist/models/revisioned.d.ts.map +1 -1
- package/dist/models/settings.d.ts +2 -2
- package/dist/models/settings.d.ts.map +1 -1
- package/dist/models/sync.d.ts +18 -18
- package/dist/models/sync.d.ts.map +1 -1
- package/dist/models/wallet.d.ts +16 -16
- package/dist/models/wallet.d.ts.map +1 -1
- package/dist/queries/certificate-trust.d.ts +13 -0
- package/dist/queries/certificate-trust.d.ts.map +1 -0
- package/dist/queries/conversations.d.ts +38 -7
- package/dist/queries/conversations.d.ts.map +1 -1
- package/dist/queries/discovery.d.ts +10 -10
- package/dist/queries/discovery.d.ts.map +1 -1
- package/dist/queries/notifications.d.ts +24 -4
- package/dist/queries/notifications.d.ts.map +1 -1
- package/dist/queries/onboarding-flow.d.ts +25 -0
- package/dist/queries/onboarding-flow.d.ts.map +1 -0
- package/dist/queries/onboarding-lcd-screen.d.ts +56 -0
- package/dist/queries/onboarding-lcd-screen.d.ts.map +1 -0
- package/dist/queries/onboarding-status.d.ts +10 -10
- package/dist/queries/onboarding-status.d.ts.map +1 -1
- package/dist/queries/revisioned.d.ts +47 -13
- package/dist/queries/revisioned.d.ts.map +1 -1
- package/dist/queries/wallet.d.ts +40 -10
- package/dist/queries/wallet.d.ts.map +1 -1
- package/dist/services/abort-signal.d.ts +54 -0
- package/dist/services/abort-signal.d.ts.map +1 -0
- package/dist/services/certificate-trust-service.d.ts +42 -0
- package/dist/services/certificate-trust-service.d.ts.map +1 -0
- package/dist/services/conversation-service.d.ts +103 -15
- package/dist/services/conversation-service.d.ts.map +1 -1
- package/dist/services/conversation-sync.d.ts +9 -5
- package/dist/services/conversation-sync.d.ts.map +1 -1
- package/dist/services/device-boot-state-service.d.ts +17 -0
- package/dist/services/device-boot-state-service.d.ts.map +1 -0
- package/dist/services/device-cleanup-service.d.ts +47 -0
- package/dist/services/device-cleanup-service.d.ts.map +1 -0
- package/dist/services/discovery-probe-service.d.ts +37 -46
- package/dist/services/discovery-probe-service.d.ts.map +1 -1
- package/dist/services/gossip-service.d.ts +28 -13
- package/dist/services/gossip-service.d.ts.map +1 -1
- package/dist/services/invite-redemption-service.d.ts +43 -0
- package/dist/services/invite-redemption-service.d.ts.map +1 -0
- package/dist/services/lan-candidate-refresh-service.d.ts +22 -7
- package/dist/services/lan-candidate-refresh-service.d.ts.map +1 -1
- package/dist/services/lcd-decision-service.d.ts +58 -0
- package/dist/services/lcd-decision-service.d.ts.map +1 -0
- package/dist/services/onboarding-checkpoint-store.d.ts +54 -0
- package/dist/services/onboarding-checkpoint-store.d.ts.map +1 -0
- package/dist/services/onboarding-flow-service.d.ts +180 -0
- package/dist/services/onboarding-flow-service.d.ts.map +1 -0
- package/dist/services/onboarding-status-service.d.ts +21 -38
- package/dist/services/onboarding-status-service.d.ts.map +1 -1
- package/dist/services/outbox-service.d.ts +105 -29
- package/dist/services/outbox-service.d.ts.map +1 -1
- package/dist/services/owner-claim-service.d.ts +187 -0
- package/dist/services/owner-claim-service.d.ts.map +1 -0
- package/dist/services/owner-view-refresh-service.d.ts +40 -0
- package/dist/services/owner-view-refresh-service.d.ts.map +1 -0
- package/dist/services/owner-view-service.d.ts +49 -4
- package/dist/services/owner-view-service.d.ts.map +1 -1
- package/dist/services/peer-directory-service.d.ts +53 -17
- package/dist/services/peer-directory-service.d.ts.map +1 -1
- package/dist/services/release-manifest-verifier.d.ts +18 -20
- package/dist/services/release-manifest-verifier.d.ts.map +1 -1
- package/dist/services/revisioned-domain-service.d.ts +50 -17
- package/dist/services/revisioned-domain-service.d.ts.map +1 -1
- package/dist/services/runtime-globals.d.ts +39 -0
- package/dist/services/runtime-globals.d.ts.map +1 -0
- package/dist/services/session-service.d.ts +23 -3
- package/dist/services/session-service.d.ts.map +1 -1
- package/dist/services/sync/authoritative-mirror.d.ts +88 -15
- package/dist/services/sync/authoritative-mirror.d.ts.map +1 -1
- package/dist/services/sync/index.d.ts +17 -5
- package/dist/services/sync/index.d.ts.map +1 -1
- package/dist/services/sync/log-sync.d.ts +42 -8
- package/dist/services/sync/log-sync.d.ts.map +1 -1
- package/dist/services/sync/revision-merge.d.ts +80 -13
- package/dist/services/sync/revision-merge.d.ts.map +1 -1
- package/dist/services/sync/strategy.d.ts +11 -1
- package/dist/services/sync/strategy.d.ts.map +1 -1
- package/dist/services/transport-manager.d.ts +84 -29
- package/dist/services/transport-manager.d.ts.map +1 -1
- package/dist/services/wallet-mirror-service.d.ts +81 -18
- package/dist/services/wallet-mirror-service.d.ts.map +1 -1
- package/dist/services/wifi-provisioning-service.d.ts +146 -0
- package/dist/services/wifi-provisioning-service.d.ts.map +1 -0
- package/package.json +18 -12
- package/src/errors/index.js +126 -0
- package/src/events/index.js +124 -0
- package/src/index.js +566 -0
- package/src/models/agent.js +43 -0
- package/src/models/app-data.js +122 -0
- package/src/models/conversation.js +36 -0
- package/{dist → src}/models/delegation.js +18 -1
- package/src/models/device-boot-state.js +3 -0
- package/{dist → src}/models/device-identity.js +18 -2
- package/{dist → src}/models/graph.js +30 -3
- package/src/models/index.js +175 -0
- package/src/models/json.js +18 -0
- package/src/models/l402.js +9 -0
- package/src/models/live-query-definition.js +30 -0
- package/src/models/node-app-memory.js +130 -0
- package/src/models/outbox-intent.js +12 -0
- package/{dist → src}/models/outbox.js +102 -3
- package/src/models/owner-views.js +59 -0
- package/src/models/payment.js +24 -0
- package/src/models/query-diff.js +7 -0
- package/src/models/release-manifest.js +160 -0
- package/src/models/repository-write.js +10 -0
- package/src/models/revisioned-commands.js +92 -0
- package/src/models/revisioned.js +41 -0
- package/src/models/settings.js +16 -0
- package/src/models/sync.js +44 -0
- package/src/models/wallet.js +40 -0
- package/src/queries/certificate-trust.js +31 -0
- package/{dist → src}/queries/conversations.js +54 -3
- package/src/queries/discovery.js +34 -0
- package/{dist → src}/queries/notifications.js +25 -2
- package/src/queries/onboarding-flow.js +48 -0
- package/src/queries/onboarding-lcd-screen.js +100 -0
- package/src/queries/onboarding-status.js +34 -0
- package/src/queries/revisioned.js +128 -0
- package/{dist → src}/queries/wallet.js +35 -1
- package/src/services/abort-signal.js +69 -0
- package/src/services/certificate-trust-service.js +119 -0
- package/{dist → src}/services/conversation-service.js +108 -1
- package/src/services/conversation-sync.js +28 -0
- package/src/services/device-boot-state-service.js +45 -0
- package/src/services/device-cleanup-service.js +112 -0
- package/src/services/discovery-probe-service.js +196 -0
- package/{dist → src}/services/gossip-service.js +54 -4
- package/src/services/invite-redemption-service.js +64 -0
- package/src/services/lan-candidate-refresh-service.js +164 -0
- package/src/services/lcd-decision-service.js +93 -0
- package/src/services/onboarding-checkpoint-store.js +155 -0
- package/src/services/onboarding-flow-service.js +1096 -0
- package/src/services/onboarding-status-service.js +64 -0
- package/{dist → src}/services/outbox-service.js +132 -1
- package/src/services/owner-claim-service.js +347 -0
- package/src/services/owner-view-refresh-service.js +208 -0
- package/src/services/owner-view-service.js +87 -0
- package/{dist → src}/services/peer-directory-service.js +72 -3
- package/src/services/release-manifest-verifier.js +108 -0
- package/{dist → src}/services/revisioned-domain-service.js +189 -9
- package/src/services/runtime-globals.js +174 -0
- package/src/services/session-service.js +50 -0
- package/{dist → src}/services/sync/authoritative-mirror.js +90 -1
- package/src/services/sync/index.js +67 -0
- package/src/services/sync/log-sync.js +60 -0
- package/src/services/sync/revision-merge.js +119 -0
- package/src/services/sync/strategy.js +44 -0
- package/src/services/transport-manager.js +478 -0
- package/{dist → src}/services/wallet-mirror-service.js +226 -6
- package/src/services/wifi-provisioning-service.js +278 -0
- package/dist/errors/index.js +0 -45
- package/dist/errors/index.js.map +0 -1
- package/dist/events/index.js +0 -17
- package/dist/events/index.js.map +0 -1
- package/dist/index.js +0 -31
- package/dist/index.js.map +0 -1
- package/dist/models/agent.js +0 -17
- package/dist/models/agent.js.map +0 -1
- package/dist/models/conversation.js +0 -2
- package/dist/models/conversation.js.map +0 -1
- package/dist/models/delegation.js.map +0 -1
- package/dist/models/device-identity.js.map +0 -1
- package/dist/models/graph.js.map +0 -1
- package/dist/models/index.js +0 -8
- package/dist/models/index.js.map +0 -1
- package/dist/models/json.js +0 -2
- package/dist/models/json.js.map +0 -1
- package/dist/models/l402.js +0 -2
- package/dist/models/l402.js.map +0 -1
- package/dist/models/live-query-definition.js +0 -2
- package/dist/models/live-query-definition.js.map +0 -1
- package/dist/models/outbox-intent.js +0 -2
- package/dist/models/outbox-intent.js.map +0 -1
- package/dist/models/outbox.js.map +0 -1
- package/dist/models/owner-views.js +0 -7
- package/dist/models/owner-views.js.map +0 -1
- package/dist/models/payment.js +0 -3
- package/dist/models/payment.js.map +0 -1
- package/dist/models/query-diff.js +0 -2
- package/dist/models/query-diff.js.map +0 -1
- package/dist/models/release-manifest.js +0 -147
- package/dist/models/release-manifest.js.map +0 -1
- package/dist/models/repository-write.js +0 -2
- package/dist/models/repository-write.js.map +0 -1
- package/dist/models/revisioned-commands.js +0 -35
- package/dist/models/revisioned-commands.js.map +0 -1
- package/dist/models/revisioned.js +0 -2
- package/dist/models/revisioned.js.map +0 -1
- package/dist/models/settings.js +0 -2
- package/dist/models/settings.js.map +0 -1
- package/dist/models/sync.js +0 -2
- package/dist/models/sync.js.map +0 -1
- package/dist/models/wallet.js +0 -2
- package/dist/models/wallet.js.map +0 -1
- package/dist/queries/conversations.js.map +0 -1
- package/dist/queries/discovery.js +0 -27
- package/dist/queries/discovery.js.map +0 -1
- package/dist/queries/notifications.js.map +0 -1
- package/dist/queries/onboarding-status.js +0 -27
- package/dist/queries/onboarding-status.js.map +0 -1
- package/dist/queries/revisioned.js +0 -58
- package/dist/queries/revisioned.js.map +0 -1
- package/dist/queries/wallet.js.map +0 -1
- package/dist/services/conversation-service.js.map +0 -1
- package/dist/services/conversation-sync.js +0 -9
- package/dist/services/conversation-sync.js.map +0 -1
- package/dist/services/discovery-probe-service.js +0 -129
- package/dist/services/discovery-probe-service.js.map +0 -1
- package/dist/services/gossip-service.js.map +0 -1
- package/dist/services/lan-candidate-refresh-service.js +0 -79
- package/dist/services/lan-candidate-refresh-service.js.map +0 -1
- package/dist/services/onboarding-status-service.js +0 -32
- package/dist/services/onboarding-status-service.js.map +0 -1
- package/dist/services/outbox-service.js.map +0 -1
- package/dist/services/owner-view-service.js +0 -21
- package/dist/services/owner-view-service.js.map +0 -1
- package/dist/services/peer-directory-service.js.map +0 -1
- package/dist/services/release-manifest-verifier.js +0 -84
- package/dist/services/release-manifest-verifier.js.map +0 -1
- package/dist/services/revisioned-domain-service.js.map +0 -1
- package/dist/services/session-service.js +0 -20
- package/dist/services/session-service.js.map +0 -1
- package/dist/services/sync/authoritative-mirror.js.map +0 -1
- package/dist/services/sync/index.js +0 -4
- package/dist/services/sync/index.js.map +0 -1
- package/dist/services/sync/log-sync.js +0 -20
- package/dist/services/sync/log-sync.js.map +0 -1
- package/dist/services/sync/revision-merge.js +0 -46
- package/dist/services/sync/revision-merge.js.map +0 -1
- package/dist/services/sync/strategy.js +0 -2
- package/dist/services/sync/strategy.js.map +0 -1
- package/dist/services/transport-manager.js +0 -234
- package/dist/services/transport-manager.js.map +0 -1
- 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
|