@econ-v1/domain 6.7.0 → 6.10.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,64 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @import { HomeNodeClient } from "@econ-v1/ports" */
|
|
3
|
+
/** @typedef {| "bootstrap-required" | "needs-onboarding" | "needs-pairing" | "ready"} OnboardingLifecycleState */
|
|
4
|
+
/** @typedef {{ readonly id: "onboarding-status"; readonly invitationsEnabled: boolean; readonly scenario?: "new"; readonly state: OnboardingLifecycleState; }} OnboardingLifecycleStatusRow */
|
|
5
|
+
/** @typedef {{ readonly claimed?: unknown; readonly invitations_enabled?: unknown; }} OnboardingStatusWire */
|
|
6
|
+
/** @typedef {{ readonly hasActiveSession: () => boolean; readonly locallyOnboarded?: () => Promise<boolean>; readonly node: Pick<HomeNodeClient, "invoke">; }} OnboardingStatusServiceOptions */
|
|
7
|
+
import { TransportError } from "../errors/index.js";
|
|
8
|
+
export class OnboardingStatusService {
|
|
9
|
+
/** @type {() => boolean} */
|
|
10
|
+
#hasActiveSession;
|
|
11
|
+
/** @type {(() => Promise<boolean>) | undefined} */
|
|
12
|
+
#locallyOnboarded;
|
|
13
|
+
/** @type {Pick<HomeNodeClient, "invoke">} */
|
|
14
|
+
#node;
|
|
15
|
+
#invitationsEnabled = false;
|
|
16
|
+
/** @param {OnboardingStatusServiceOptions} options */
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.#hasActiveSession = options.hasActiveSession;
|
|
19
|
+
this.#locallyOnboarded = options.locallyOnboarded;
|
|
20
|
+
this.#node = options.node;
|
|
21
|
+
}
|
|
22
|
+
/** Synchronous read of the last flag the node reported. Injected into
|
|
23
|
+
* `OnboardingFlowService` as a getter so a value arriving after boot still takes effect. */
|
|
24
|
+
/** @returns {boolean} */
|
|
25
|
+
invitationsEnabled() {
|
|
26
|
+
return this.#invitationsEnabled;
|
|
27
|
+
}
|
|
28
|
+
/** @returns {Promise<OnboardingLifecycleStatusRow>} */
|
|
29
|
+
async status() {
|
|
30
|
+
if (this.#hasActiveSession()) {
|
|
31
|
+
return { id: "onboarding-status", invitationsEnabled: this.#invitationsEnabled, state: "ready" };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
if (await this.#locallyOnboarded?.() === true) {
|
|
35
|
+
return { id: "onboarding-status", invitationsEnabled: this.#invitationsEnabled, state: "ready" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// fall through to the network derivation
|
|
40
|
+
}
|
|
41
|
+
/** @type {boolean | undefined} */
|
|
42
|
+
let claimed;
|
|
43
|
+
try {
|
|
44
|
+
const response = await this.#node.invoke({
|
|
45
|
+
operation: "GET /api/auth/onboarding-status",
|
|
46
|
+
payload: {},
|
|
47
|
+
});
|
|
48
|
+
claimed = typeof response.claimed === "boolean" ? response.claimed : undefined;
|
|
49
|
+
this.#invitationsEnabled = response.invitations_enabled === true;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof TransportError) {
|
|
53
|
+
return { id: "onboarding-status", invitationsEnabled: this.#invitationsEnabled, state: "bootstrap-required" };
|
|
54
|
+
}
|
|
55
|
+
return { id: "onboarding-status", invitationsEnabled: this.#invitationsEnabled, state: "needs-pairing" };
|
|
56
|
+
}
|
|
57
|
+
const invitationsEnabled = this.#invitationsEnabled;
|
|
58
|
+
if (claimed === undefined)
|
|
59
|
+
return { id: "onboarding-status", invitationsEnabled, state: "needs-pairing" };
|
|
60
|
+
return claimed
|
|
61
|
+
? { id: "onboarding-status", invitationsEnabled, state: "needs-pairing" }
|
|
62
|
+
: { id: "onboarding-status", invitationsEnabled, scenario: "new", state: "needs-onboarding" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -1,28 +1,89 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import("../models/outbox.js").OutboxEntry} OutboxEntry
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import("../models/outbox.js").OutboxIntentInput} OutboxIntentInput
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import("../models/outbox.js").OutboxState} OutboxState
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {{ create(entry: OutboxEntry): Promise<void>; delete(id: string): Promise<void>; list(domain?: string): Promise<readonly OutboxEntry[]>; read(id: string): Promise<OutboxEntry | undefined>; update(entry: OutboxEntry): Promise<void>; }} OutboxPersistence
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{ readonly maxAttempts?: number; readonly now?: () => string; }} OutboxServiceOptions
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{ readonly leaseMs?: number; readonly now?: string; readonly revalidateAuthoritative?: () => Promise<boolean>; }} ClaimOptions
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{ readonly now?: string; readonly owner?: string; readonly retryable: boolean; }} FailureOptions
|
|
23
|
+
*/
|
|
1
24
|
import { canonicalOutboxPayload, createOutboxIntent, validateOutboxIntent, } from "../models/outbox.js";
|
|
25
|
+
/**
|
|
26
|
+
* @extends {Error}
|
|
27
|
+
*/
|
|
2
28
|
export class OutboxIdempotencyConflictError extends Error {
|
|
29
|
+
/**
|
|
30
|
+
* @readonly
|
|
31
|
+
* @type {string}
|
|
32
|
+
*/
|
|
3
33
|
idempotencyKey;
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} idempotencyKey
|
|
36
|
+
*/
|
|
4
37
|
constructor(idempotencyKey) {
|
|
5
38
|
super("Outbox idempotency key belongs to a different command");
|
|
6
39
|
this.name = "OutboxIdempotencyConflictError";
|
|
7
40
|
this.idempotencyKey = idempotencyKey;
|
|
8
41
|
}
|
|
9
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* @extends {Error}
|
|
45
|
+
*/
|
|
10
46
|
export class OutboxTransitionError extends Error {
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} message
|
|
49
|
+
*/
|
|
11
50
|
constructor(message) {
|
|
12
51
|
super(message);
|
|
13
52
|
this.name = "OutboxTransitionError";
|
|
14
53
|
}
|
|
15
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* @extends {OutboxTransitionError}
|
|
57
|
+
*/
|
|
16
58
|
export class OutboxRevalidationRequiredError extends OutboxTransitionError {
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} id
|
|
61
|
+
*/
|
|
17
62
|
constructor(id) {
|
|
18
63
|
super(`Authoritative outbox intent ${id} requires fresh-state revalidation`);
|
|
19
64
|
this.name = "OutboxRevalidationRequiredError";
|
|
20
65
|
}
|
|
21
66
|
}
|
|
22
67
|
export class OutboxService {
|
|
68
|
+
/**
|
|
69
|
+
* @readonly
|
|
70
|
+
* @type {number}
|
|
71
|
+
*/
|
|
23
72
|
#maxAttempts;
|
|
73
|
+
/**
|
|
74
|
+
* @readonly
|
|
75
|
+
* @type {() => string}
|
|
76
|
+
*/
|
|
24
77
|
#now;
|
|
78
|
+
/**
|
|
79
|
+
* @readonly
|
|
80
|
+
* @type {OutboxPersistence}
|
|
81
|
+
*/
|
|
25
82
|
#persistence;
|
|
83
|
+
/**
|
|
84
|
+
* @param {OutboxPersistence} persistence
|
|
85
|
+
* @param {OutboxServiceOptions} [options]
|
|
86
|
+
*/
|
|
26
87
|
constructor(persistence, options = {}) {
|
|
27
88
|
this.#maxAttempts = options.maxAttempts ?? 3;
|
|
28
89
|
if (!Number.isInteger(this.#maxAttempts) || this.#maxAttempts < 1) {
|
|
@@ -31,6 +92,10 @@ export class OutboxService {
|
|
|
31
92
|
this.#now = options.now ?? (() => new Date().toISOString());
|
|
32
93
|
this.#persistence = persistence;
|
|
33
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* @param {OutboxIntentInput} input
|
|
97
|
+
* @returns {Promise<OutboxEntry>}
|
|
98
|
+
*/
|
|
34
99
|
async enqueue(input) {
|
|
35
100
|
const entry = createOutboxIntent(input, { maxAttempts: this.#maxAttempts, now: this.#now() });
|
|
36
101
|
const existing = (await this.#persistence.list()).find((candidate) => candidate.idempotencyKey === entry.idempotencyKey);
|
|
@@ -42,6 +107,12 @@ export class OutboxService {
|
|
|
42
107
|
await this.#persistence.create(entry);
|
|
43
108
|
return entry;
|
|
44
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} domain
|
|
112
|
+
* @param {string} owner
|
|
113
|
+
* @param {ClaimOptions} [options]
|
|
114
|
+
* @returns {Promise<OutboxEntry | undefined>}
|
|
115
|
+
*/
|
|
45
116
|
async claimNext(domain, owner, options = {}) {
|
|
46
117
|
requireNonEmpty(owner, "lease owner");
|
|
47
118
|
const now = options.now ?? this.#now();
|
|
@@ -71,11 +142,22 @@ export class OutboxService {
|
|
|
71
142
|
}
|
|
72
143
|
return undefined;
|
|
73
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} id
|
|
147
|
+
* @param {string} owner
|
|
148
|
+
* @returns {Promise<OutboxEntry>}
|
|
149
|
+
*/
|
|
74
150
|
async acknowledge(id, owner, now = this.#now()) {
|
|
75
151
|
const current = await this.requireEntry(id);
|
|
76
152
|
this.assertInflightOwner(current, owner);
|
|
77
153
|
return await this.save(current, { lease: undefined, state: "acked", updatedAt: now });
|
|
78
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} id
|
|
157
|
+
* @param {string} reason
|
|
158
|
+
* @param {FailureOptions} options
|
|
159
|
+
* @returns {Promise<OutboxEntry>}
|
|
160
|
+
*/
|
|
79
161
|
async fail(id, reason, options) {
|
|
80
162
|
requireNonEmpty(reason, "failure reason");
|
|
81
163
|
const current = await this.requireEntry(id);
|
|
@@ -88,6 +170,12 @@ export class OutboxService {
|
|
|
88
170
|
? { lease: undefined, state: "failed", terminalReason: reason, updatedAt: options.now ?? this.#now() }
|
|
89
171
|
: { lease: undefined, state: "pending", updatedAt: options.now ?? this.#now() });
|
|
90
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} id
|
|
175
|
+
* @param {string} reason
|
|
176
|
+
* @param {string} [owner]
|
|
177
|
+
* @returns {Promise<OutboxEntry>}
|
|
178
|
+
*/
|
|
91
179
|
async conflict(id, reason, owner, now = this.#now()) {
|
|
92
180
|
requireNonEmpty(reason, "conflict reason");
|
|
93
181
|
const current = await this.requireEntry(id);
|
|
@@ -97,6 +185,10 @@ export class OutboxService {
|
|
|
97
185
|
throw new OutboxTransitionError(`Outbox intent ${id} is not inflight`);
|
|
98
186
|
return await this.save(current, { conflictReason: reason, lease: undefined, state: "conflict", updatedAt: now });
|
|
99
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} id
|
|
190
|
+
* @returns {Promise<boolean>}
|
|
191
|
+
*/
|
|
100
192
|
async cancel(id) {
|
|
101
193
|
const current = await this.#persistence.read(id);
|
|
102
194
|
if (current === undefined)
|
|
@@ -106,6 +198,10 @@ export class OutboxService {
|
|
|
106
198
|
await this.#persistence.delete(id);
|
|
107
199
|
return true;
|
|
108
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} [domain]
|
|
203
|
+
* @returns {Promise<number>}
|
|
204
|
+
*/
|
|
109
205
|
async recoverExpiredLeases(now = this.#now(), domain) {
|
|
110
206
|
let recovered = 0;
|
|
111
207
|
for (const entry of await this.#persistence.list(domain)) {
|
|
@@ -116,36 +212,71 @@ export class OutboxService {
|
|
|
116
212
|
}
|
|
117
213
|
return recovered;
|
|
118
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* @private
|
|
217
|
+
* @param {string} id
|
|
218
|
+
* @returns {Promise<OutboxEntry>}
|
|
219
|
+
*/
|
|
119
220
|
async requireEntry(id) {
|
|
120
221
|
const entry = await this.#persistence.read(id);
|
|
121
222
|
if (entry === undefined)
|
|
122
223
|
throw new OutboxTransitionError(`Outbox intent was not found: ${id}`);
|
|
123
224
|
return validateOutboxIntent(entry);
|
|
124
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* @private
|
|
228
|
+
* @param {OutboxEntry} entry
|
|
229
|
+
* @param {string} owner
|
|
230
|
+
* @returns {void}
|
|
231
|
+
*/
|
|
125
232
|
assertInflightOwner(entry, owner) {
|
|
126
233
|
if (entry.state !== "inflight" || entry.lease?.owner !== owner) {
|
|
127
234
|
throw new OutboxTransitionError(`Outbox lease is not held by ${owner}`);
|
|
128
235
|
}
|
|
129
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* @private
|
|
239
|
+
* @param {OutboxEntry} current
|
|
240
|
+
* @param {Omit<Partial<OutboxEntry>, "lease"> & { readonly lease?: OutboxEntry["lease"]; readonly state: OutboxState; }} changes
|
|
241
|
+
* @returns {Promise<OutboxEntry>}
|
|
242
|
+
*/
|
|
130
243
|
async save(current, changes) {
|
|
131
244
|
const updated = validateOutboxIntent({ ...current, ...changes });
|
|
132
245
|
await this.#persistence.update(updated);
|
|
133
246
|
return updated;
|
|
134
247
|
}
|
|
135
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* @param {OutboxEntry} left
|
|
251
|
+
* @param {OutboxEntry} right
|
|
252
|
+
* @returns {number}
|
|
253
|
+
*/
|
|
136
254
|
function compareEntries(left, right) {
|
|
137
255
|
return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
|
|
138
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* @param {OutboxState} state
|
|
259
|
+
* @returns {boolean}
|
|
260
|
+
*/
|
|
139
261
|
function isTerminal(state) {
|
|
140
262
|
return state === "acked" || state === "failed" || state === "conflict";
|
|
141
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* @param {OutboxEntry} left
|
|
266
|
+
* @param {OutboxEntry} right
|
|
267
|
+
* @returns {boolean}
|
|
268
|
+
*/
|
|
142
269
|
function sameCommand(left, right) {
|
|
143
270
|
return left.domain === right.domain &&
|
|
144
271
|
left.authority === right.authority &&
|
|
145
272
|
canonicalOutboxPayload(left.payload) === canonicalOutboxPayload(right.payload);
|
|
146
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* @param {string} value
|
|
276
|
+
* @param {string} label
|
|
277
|
+
* @returns {void}
|
|
278
|
+
*/
|
|
147
279
|
function requireNonEmpty(value, label) {
|
|
148
280
|
if (value.trim() === "")
|
|
149
281
|
throw new TypeError(`${label} must be a non-empty string`);
|
|
150
282
|
}
|
|
151
|
-
//# sourceMappingURL=outbox-service.js.map
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @typedef {import('@econ-v1/ports').DelayPort} DelayPort */
|
|
3
|
+
/** @typedef {import('@econ-v1/ports').HomeNodeClient} HomeNodeClient */
|
|
4
|
+
/** @typedef {import('@econ-v1/ports').OwnerClaimCryptoPort} OwnerClaimCryptoPort */
|
|
5
|
+
/** @typedef {import('@econ-v1/ports').OwnerClaimSessionPort} OwnerClaimSessionPort */
|
|
6
|
+
/** @typedef {import('@econ-v1/ports').OwnerClaimSessionTokens} OwnerClaimSessionTokens */
|
|
7
|
+
const POLL_INTERVAL_MS = 2000;
|
|
8
|
+
const CONFIRMATION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
export class OwnerClaimService {
|
|
10
|
+
/**
|
|
11
|
+
* @readonly
|
|
12
|
+
*/
|
|
13
|
+
#crypto;
|
|
14
|
+
/**
|
|
15
|
+
* @readonly
|
|
16
|
+
*/
|
|
17
|
+
#delay;
|
|
18
|
+
/**
|
|
19
|
+
* @readonly
|
|
20
|
+
*/
|
|
21
|
+
#node;
|
|
22
|
+
/**
|
|
23
|
+
* @readonly
|
|
24
|
+
*/
|
|
25
|
+
#session;
|
|
26
|
+
/** @type {{ confirmed: boolean, operationHash: string | undefined, requiresConfirmation: boolean } | undefined} */
|
|
27
|
+
#pending;
|
|
28
|
+
/**
|
|
29
|
+
* @param {OwnerClaimServiceOptions} options
|
|
30
|
+
*/
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.#crypto = options.keys;
|
|
33
|
+
this.#delay = options.delay;
|
|
34
|
+
this.#node = options.node;
|
|
35
|
+
this.#session = options.session;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Generates this attempt's ephemeral key and asks the node to start its
|
|
39
|
+
* physical-confirmation gate for it. Safe to call again after a failed `completeClaim()` or
|
|
40
|
+
* an expired code — each call replaces whatever claim was previously pending, which is
|
|
41
|
+
* exactly what step 10's "Request a new code" needs.
|
|
42
|
+
*
|
|
43
|
+
* `accessCode`, when supplied, is sent as `access_code` — the shared secret that lets the
|
|
44
|
+
* node bind and auto-confirm this operation without a physical LCD tap. It is omitted from
|
|
45
|
+
* the payload entirely (never sent as `undefined`/`null`) when not supplied, and it is never
|
|
46
|
+
* logged.
|
|
47
|
+
* @param {string} [accessCode]
|
|
48
|
+
* @returns {Promise<OwnerClaimPrepareOutcome>}
|
|
49
|
+
*/
|
|
50
|
+
async prepareClaim(accessCode) {
|
|
51
|
+
const publicKeyHex = await this.#crypto.publicKey();
|
|
52
|
+
const prepared = await this.#node.invoke({
|
|
53
|
+
operation: "POST /api/v2/node/onboarding-prepare",
|
|
54
|
+
payload: {
|
|
55
|
+
public_key_multibase: publicKeyHex,
|
|
56
|
+
...(accessCode !== undefined ? { access_code: accessCode } : {}),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const requiresConfirmation = prepared.requires_lcd === true;
|
|
60
|
+
const confirmed = prepared.confirmed === true;
|
|
61
|
+
this.#pending = {
|
|
62
|
+
confirmed,
|
|
63
|
+
operationHash: prepared.operation_hash,
|
|
64
|
+
requiresConfirmation,
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
confirmed,
|
|
68
|
+
lcdHardware: prepared.lcd_hardware === true,
|
|
69
|
+
matchCode: prepared.match_code ?? "",
|
|
70
|
+
operationHash: prepared.operation_hash,
|
|
71
|
+
requiresConfirmation,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Waits for the node's physical confirmation. This is the ONLY thing that can put the
|
|
76
|
+
* pending claim into a completable state — no browser click, no timer, no LCD-pane tap
|
|
77
|
+
* substitutes for the node reporting `confirmed`.
|
|
78
|
+
*
|
|
79
|
+
* The poll cadence comes from the injected `DelayPort`, so this contains no timer of its
|
|
80
|
+
* own and stays testable with a zero-wait fake.
|
|
81
|
+
* @param {string} operationHash
|
|
82
|
+
* @returns {Promise<OwnerClaimConfirmation>}
|
|
83
|
+
*/
|
|
84
|
+
async awaitConfirmation(operationHash) {
|
|
85
|
+
const deadline = Date.now() + CONFIRMATION_TIMEOUT_MS;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const status = await this.#node.invoke({
|
|
88
|
+
operation: `GET /api/v2/node/operation-status/${encodeURIComponent(operationHash)}`,
|
|
89
|
+
payload: {},
|
|
90
|
+
});
|
|
91
|
+
if (status.confirmed === true) {
|
|
92
|
+
// Only the claim this hash actually belongs to may be unlocked: `prepareClaim()` can
|
|
93
|
+
// be called again ("Request a new code") while an earlier poll is still in flight, and
|
|
94
|
+
// a stale confirmation must never carry over onto the claim that superseded it.
|
|
95
|
+
const pending = this.#pending;
|
|
96
|
+
if (pending !== undefined && pending.operationHash === operationHash)
|
|
97
|
+
pending.confirmed = true;
|
|
98
|
+
return "confirmed";
|
|
99
|
+
}
|
|
100
|
+
if (status.status === "rejected") return "rejected";
|
|
101
|
+
if (Date.now() >= deadline) return "timed-out";
|
|
102
|
+
await this.#delay.wait(POLL_INTERVAL_MS);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Runs the legacy challenge -> sign -> `complete-onboarding` sequence to actually create the
|
|
107
|
+
* owner + DID, using the identity supplied HERE rather than one captured at prepare time.
|
|
108
|
+
* Resolves `"claimed"` only when the node's own completion call reported success.
|
|
109
|
+
*
|
|
110
|
+
* RETRY-SAFE BY DESIGN: neither `#pending` nor the ephemeral key is touched until the node's
|
|
111
|
+
* completion call is known to have SUCCEEDED. A thrown challenge/complete call, or an explicit
|
|
112
|
+
* `success: false`, leaves `#pending` (and the key) exactly as they were, so the caller can
|
|
113
|
+
* invoke `completeClaim()` again on this same instance — reaching the node a second time
|
|
114
|
+
* without a new `prepareClaim()` — for as long as the underlying failure might be transient
|
|
115
|
+
* (a dropped connection, a momentarily-busy node). Clearing state on failure, or in a
|
|
116
|
+
* `finally`, would turn every transient hiccup into a forced restart of the whole claim.
|
|
117
|
+
* @param {OwnerClaimIdentity} identity
|
|
118
|
+
* @returns {Promise<OwnerClaimOutcome>}
|
|
119
|
+
*/
|
|
120
|
+
async completeClaim(identity) {
|
|
121
|
+
const pending = this.#pending;
|
|
122
|
+
if (pending === undefined) return "not-prepared";
|
|
123
|
+
// Refuse without touching the node: an owner record must never be attempted, let alone
|
|
124
|
+
// created, before the node itself reported the physical confirmation.
|
|
125
|
+
if (pending.requiresConfirmation && !pending.confirmed) return "rejected";
|
|
126
|
+
const onboardingConfirmation = pending.requiresConfirmation
|
|
127
|
+
? pending.operationHash
|
|
128
|
+
: undefined;
|
|
129
|
+
/** @type {OnboardingCompleteWire} */
|
|
130
|
+
let result;
|
|
131
|
+
try {
|
|
132
|
+
const challenge = await this.#node.invoke({
|
|
133
|
+
operation: "POST /api/auth/onboarding-challenge",
|
|
134
|
+
payload: {},
|
|
135
|
+
});
|
|
136
|
+
const signature = await this.#crypto.sign(challenge.challenge);
|
|
137
|
+
const publicKeyHex = await this.#crypto.publicKey();
|
|
138
|
+
// OPTIONAL and best-effort: a descriptor-build failure must never turn into a rejected
|
|
139
|
+
// claim, so it gets its own inner try/catch rather than sharing the outer one (which
|
|
140
|
+
// would still swallow it into "rejected" — the same outcome, but for the wrong reason,
|
|
141
|
+
// and the outer catch's comment promises retry-safety specifically for the challenge/
|
|
142
|
+
// complete round trip, not for this). `?.` also covers session ports (like
|
|
143
|
+
// `createNullOwnerClaimSession()`) that never implement the optional member at all.
|
|
144
|
+
let genesisDevice;
|
|
145
|
+
try {
|
|
146
|
+
genesisDevice = await this.#session.describeGenesisDevice?.(challenge.challenge);
|
|
147
|
+
} catch {
|
|
148
|
+
genesisDevice = undefined;
|
|
149
|
+
}
|
|
150
|
+
result = await this.#node.invoke({
|
|
151
|
+
operation: "POST /api/auth/complete-onboarding",
|
|
152
|
+
payload: {
|
|
153
|
+
challenge_id: challenge.challenge_id,
|
|
154
|
+
device_info: this.#crypto.deviceInfo(),
|
|
155
|
+
first_name: identity.displayName,
|
|
156
|
+
last_name: "",
|
|
157
|
+
onboarding_confirmation: onboardingConfirmation,
|
|
158
|
+
public_key: publicKeyHex,
|
|
159
|
+
signature,
|
|
160
|
+
username: identity.username,
|
|
161
|
+
...(genesisDevice === undefined ? {} : { genesis_device: genesisDevice }),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
// RETRY-SAFE: `#pending` and the ephemeral key survive untouched — see the doc comment.
|
|
166
|
+
return "rejected";
|
|
167
|
+
}
|
|
168
|
+
if (result.success !== true) return "rejected";
|
|
169
|
+
// PAST THIS LINE THE OWNER AND DID EXIST ON THE NODE. Nothing below may report the claim
|
|
170
|
+
// as failed — the worst honest answer is "claimed, but this browser is not paired". That is
|
|
171
|
+
// why the session work sits outside the try/catch above rather than inside it: a throw from
|
|
172
|
+
// `adopt` inside that block would have been swallowed into `"rejected"` and told a person
|
|
173
|
+
// their setup failed after it had already succeeded.
|
|
174
|
+
//
|
|
175
|
+
// This is also the ONLY point (besides `cancel()`) where the pending claim is dropped and
|
|
176
|
+
// the ephemeral key is reset — the claim it belonged to is now settled, one way or another.
|
|
177
|
+
this.#pending = undefined;
|
|
178
|
+
this.#crypto.reset();
|
|
179
|
+
const tokens = readSessionTokens(result);
|
|
180
|
+
if (tokens === undefined) return "claimed-unpaired";
|
|
181
|
+
try {
|
|
182
|
+
return (await this.#session.adopt(tokens))
|
|
183
|
+
? "claimed"
|
|
184
|
+
: "claimed-unpaired";
|
|
185
|
+
} catch {
|
|
186
|
+
return "claimed-unpaired";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Drops any pending claim without contacting the node — used when the person abandons or
|
|
190
|
+
* restarts the flow before `completeClaim()` runs.
|
|
191
|
+
* @returns {void}
|
|
192
|
+
*/
|
|
193
|
+
cancel() {
|
|
194
|
+
this.#pending = undefined;
|
|
195
|
+
this.#crypto.reset();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* All THREE fields must be present and non-empty before any of them is used. A node that
|
|
200
|
+
* answers with a token but no expiry is a node whose session contract this client does not
|
|
201
|
+
* understand, and guessing an expiry would produce a session that silently dies mid-use.
|
|
202
|
+
*
|
|
203
|
+
* `refresh_token` is required-present for the same reason but deliberately NOT returned: see
|
|
204
|
+
* `OwnerClaimSessionTokens`.
|
|
205
|
+
* @param {OnboardingCompleteWire} wire
|
|
206
|
+
* @returns {OwnerClaimSessionTokens | undefined}
|
|
207
|
+
*/
|
|
208
|
+
function readSessionTokens(wire) {
|
|
209
|
+
const accessToken = typeof wire.token === "string" ? wire.token.trim() : "";
|
|
210
|
+
const expiresAt =
|
|
211
|
+
typeof wire.token_expires_at === "string"
|
|
212
|
+
? wire.token_expires_at.trim()
|
|
213
|
+
: "";
|
|
214
|
+
const refreshToken =
|
|
215
|
+
typeof wire.refresh_token === "string" ? wire.refresh_token.trim() : "";
|
|
216
|
+
if (
|
|
217
|
+
accessToken.length === 0 ||
|
|
218
|
+
expiresAt.length === 0 ||
|
|
219
|
+
refreshToken.length === 0
|
|
220
|
+
) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return { accessToken, expiresAt };
|
|
224
|
+
}
|
|
225
|
+
/** @typedef {"confirmed" | "rejected" | "timed-out"} OwnerClaimConfirmation */
|
|
226
|
+
/**
|
|
227
|
+
* `claimed-unpaired` is a SUCCESS, not a failure: the owner + DID now exist on the node. It
|
|
228
|
+
* says only that this browser did not end up holding a device session, so the shell must run
|
|
229
|
+
* its ordinary pairing flow. Collapsing it into `rejected` would tell a person their setup
|
|
230
|
+
* failed after it had already, irreversibly, succeeded.
|
|
231
|
+
* @typedef {| "claimed"
|
|
232
|
+
* | "claimed-unpaired"
|
|
233
|
+
* | "not-prepared"
|
|
234
|
+
* | "rejected"
|
|
235
|
+
* | "timed-out"} OwnerClaimOutcome
|
|
236
|
+
*/
|
|
237
|
+
/**
|
|
238
|
+
* First-owner onboarding — "claim this unclaimed node" (#1815 DID-blocker fix).
|
|
239
|
+
*
|
|
240
|
+
* The PWA's onboarding-gate walks a person through identity + physical-confirmation
|
|
241
|
+
* screens, but until this service existed nothing behind them ever created an owner or
|
|
242
|
+
* a DID on the backend (`.superpowers/sdd/did-blocker-report.md`): `core.did.onboard` —
|
|
243
|
+
* the only code path that writes `did_documents` — is called exclusively from
|
|
244
|
+
* `AuthApplicationService::complete()`, which backs the legacy, JWT-shaped
|
|
245
|
+
* `POST /api/auth/complete-onboarding` (`system/ui`-only until now).
|
|
246
|
+
*
|
|
247
|
+
* This service drives that SAME legacy contract from the PWA, reusing the existing,
|
|
248
|
+
* already-reviewed backend surface rather than inventing a parallel owner-creation
|
|
249
|
+
* path (see the blocker report §5): an ephemeral secp256k1 keypair
|
|
250
|
+
* (`OwnerClaimCryptoPort` — deliberately distinct from the Ed25519 `IdentityPort` used
|
|
251
|
+
* for client-device pairing afterwards), `POST /api/v2/node/onboarding-prepare` to
|
|
252
|
+
* start the node's physical-confirmation gate, a poll of
|
|
253
|
+
* `GET /api/v2/node/operation-status/{hash}` for that confirmation, then
|
|
254
|
+
* `POST /api/auth/{onboarding-challenge,complete-onboarding}` to actually create the
|
|
255
|
+
* owner + DID.
|
|
256
|
+
*
|
|
257
|
+
* The claim now also carries the browser's OWN pairing identity: `completeClaim()` asks the
|
|
258
|
+
* session port for an optional signed `genesis_device` descriptor and forwards it verbatim to
|
|
259
|
+
* `complete-onboarding`, so the node's genesis DID device IS this browser (not a synthetic
|
|
260
|
+
* placeholder) and the client-device pairing that follows reconciles onto that same row instead
|
|
261
|
+
* of minting a duplicate. Building or forwarding it can never turn a successful claim into a
|
|
262
|
+
* rejected one — see the inner try/catch around it below.
|
|
263
|
+
*
|
|
264
|
+
* SECURITY: this service never decides whether the claim is authorized — the node
|
|
265
|
+
* does. `completeClaim()` only ever *attempts* the completion call; the host's
|
|
266
|
+
* `validate_physical_confirmation` gate (`core/domain/src/services/
|
|
267
|
+
* auth_application_service.rs`) is the sole authority on whether a real physical
|
|
268
|
+
* confirmation happened, and refuses the request otherwise
|
|
269
|
+
* (`enforce_physical_presence`, `core/adapters-network/src/http/server/
|
|
270
|
+
* device_management/proxy.rs`, refuses browser/remote confirmation of an "onboard"
|
|
271
|
+
* operation outright when the node has a screen — only a physical LCD tap counts).
|
|
272
|
+
* A caller of this service who never gets the node's screen tapped simply never
|
|
273
|
+
* reaches "claimed": `awaitConfirmation()` resolves `"rejected"`/`"timed-out"` and
|
|
274
|
+
* `completeClaim()` then refuses, rather than reporting success.
|
|
275
|
+
*
|
|
276
|
+
* Once a client's `prepareClaim()` succeeds, the service holds exactly one pending claim —
|
|
277
|
+
* matching the legacy flow's "ONE device keypair for the whole onboarding attempt"
|
|
278
|
+
* invariant (the node's confirmation is bound to the specific public key sent to
|
|
279
|
+
* `onboarding-prepare`, and the app-did miniapp allows only one pending "onboard"
|
|
280
|
+
* operation node-wide).
|
|
281
|
+
*
|
|
282
|
+
* SEQUENCING (design §6.1) — `prepare(identity)`/`complete()` were split into
|
|
283
|
+
* `prepareClaim()` / `awaitConfirmation()` / `completeClaim(identity)` because the flow puts
|
|
284
|
+
* the match code at step 10 and identity at step 12, deliberately: nothing is written until
|
|
285
|
+
* physical access has been proven. That split is possible with NO backend change because
|
|
286
|
+
* `onboarding-prepare`'s payload is only `{ public_key_multibase }` — the identity that used
|
|
287
|
+
* to be handed to `prepare()` was stashed purely client-side and read back at completion.
|
|
288
|
+
*
|
|
289
|
+
* The security property is preserved and strengthened: `completeClaim()` refuses outright
|
|
290
|
+
* (without contacting the node) unless `awaitConfirmation()` has already observed the node's
|
|
291
|
+
* own `confirmed` for THIS claim's operation hash, so no owner record can exist until physical
|
|
292
|
+
* confirmation AND identity submission have both happened, in that order. The node's
|
|
293
|
+
* `validate_physical_confirmation` gate remains the sole authority; this client-side guard
|
|
294
|
+
* only stops a pointless, misleading round trip.
|
|
295
|
+
* @typedef {object} OwnerClaimIdentity
|
|
296
|
+
* @property {string} displayName
|
|
297
|
+
* @property {string} username
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* @typedef {object} OwnerClaimPrepareOutcome
|
|
301
|
+
* @property {boolean} confirmed `true` when the node already bound and auto-confirmed this operation server-side (an
|
|
302
|
+
* access code was supplied and matched) — `awaitConfirmation()` is unnecessary and
|
|
303
|
+
* `completeClaim()` proceeds straight to the challenge/complete sequence, with no LCD
|
|
304
|
+
* match-code step in between.
|
|
305
|
+
* @property {boolean} lcdHardware
|
|
306
|
+
* @property {string} matchCode
|
|
307
|
+
* @property {string | undefined} operationHash The handle `awaitConfirmation()` polls and `completeClaim()` echoes back as
|
|
308
|
+
* `onboarding_confirmation`. Absent when the node's confirmation gate is disabled.
|
|
309
|
+
* @property {boolean} requiresConfirmation
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* @typedef {object} OwnerClaimServiceOptions
|
|
313
|
+
* @property {OwnerClaimCryptoPort} keys The ephemeral secp256k1 identity for this onboarding attempt (see `OwnerClaimCryptoPort`'s
|
|
314
|
+
* doc comment). Named `keys`, not `crypto`, so this stays a plain interface field rather
|
|
315
|
+
* than reading as a reference to the browser's `crypto` global.
|
|
316
|
+
* @property {Pick<DelayPort, "wait">} delay
|
|
317
|
+
* @property {Pick<HomeNodeClient, "invoke">} node
|
|
318
|
+
* @property {OwnerClaimSessionPort} session Turns the completion response's tokens into a real device session. Required, not optional:
|
|
319
|
+
* defaulting it would make a wiring mistake look like a node that simply issued no tokens.
|
|
320
|
+
*/
|
|
321
|
+
/**
|
|
322
|
+
* @typedef {object} OnboardingPrepareWire
|
|
323
|
+
* @property {boolean} [confirmed] Present (and `true`) only when the node auto-confirmed this operation from a supplied
|
|
324
|
+
* access code; omitted entirely otherwise — never sent as an explicit `false`.
|
|
325
|
+
* @property {boolean} [lcd_hardware]
|
|
326
|
+
* @property {string} [operation_hash]
|
|
327
|
+
* @property {string} [match_code]
|
|
328
|
+
* @property {boolean} requires_lcd
|
|
329
|
+
*/
|
|
330
|
+
/**
|
|
331
|
+
* @typedef {object} OperationStatusWire
|
|
332
|
+
* @property {boolean} confirmed
|
|
333
|
+
* @property {string} [status]
|
|
334
|
+
*/
|
|
335
|
+
/**
|
|
336
|
+
* @typedef {object} OnboardingChallengeWire
|
|
337
|
+
* @property {string} challenge
|
|
338
|
+
* @property {string} challenge_id
|
|
339
|
+
*/
|
|
340
|
+
/**
|
|
341
|
+
* @typedef {object} OnboardingCompleteWire
|
|
342
|
+
* @property {unknown} [refresh_token]
|
|
343
|
+
* @property {boolean} success
|
|
344
|
+
* @property {unknown} [token]
|
|
345
|
+
* @property {unknown} [token_expires_at]
|
|
346
|
+
*/
|
|
347
|
+
// @ts-check
|