@serfab/cadre-core 1.14.0 → 1.15.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/README.md +293 -274
- package/dist/cadre-invite-protocol.d.ts +64 -29
- package/dist/cadre-invite-protocol.js +207 -38
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +375 -79
- package/dist/cadre-node.js +758 -186
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +92 -12
- package/dist/control-database.js +323 -142
- package/dist/control-database.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +19 -3
- package/dist/control-schema.js.map +1 -1
- package/dist/ed25519-key.d.ts +7 -9
- package/dist/ed25519-key.js +20 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.js +31 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +9 -4
- package/dist/link-budget.js +9 -4
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +6 -5
- package/dist/membership-connection-gater.js +6 -5
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +55 -0
- package/dist/node-claim-payload.js +108 -4
- package/dist/node-claim-payload.js.map +1 -1
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.js.map +1 -0
- package/dist/peer-dial.d.ts +124 -27
- package/dist/peer-dial.js +254 -30
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.js +6 -0
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +6 -45
- package/dist/peer-record.js +8 -50
- package/dist/peer-record.js.map +1 -1
- package/dist/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- package/dist/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +22 -5
- package/dist/seed-bootstrap.js +48 -25
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +4 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- package/dist/strand-wake-protocol.js +10 -7
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +212 -4
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +18 -10
- package/src/cadre-invite-protocol.ts +233 -45
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +10003 -9381
- package/src/control-database.ts +4663 -4451
- package/src/control-schema.ts +19 -3
- package/src/ed25519-key.ts +20 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +789 -722
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +9 -4
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +638 -637
- package/src/node-claim-payload.ts +138 -4
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/peer-dial.ts +604 -336
- package/src/peer-join-backfill.ts +6 -0
- package/src/peer-record.ts +159 -202
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1471
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-wake-protocol.ts +10 -7
- package/src/types.ts +220 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serfab/cadre-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core library for Sereus cadre nodes - manages control networks and strand participation",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"types": "./dist/key-store-file.d.ts",
|
|
25
25
|
"import": "./dist/key-store-file.js"
|
|
26
26
|
},
|
|
27
|
+
"./primary-lan-address": {
|
|
28
|
+
"types": "./dist/primary-lan-address.d.ts",
|
|
29
|
+
"import": "./dist/primary-lan-address.js"
|
|
30
|
+
},
|
|
27
31
|
"./trusted-owner-store-file": {
|
|
28
32
|
"types": "./dist/trusted-owner-store-file.d.ts",
|
|
29
33
|
"import": "./dist/trusted-owner-store-file.js"
|
|
@@ -43,6 +47,10 @@
|
|
|
43
47
|
"./push-node": {
|
|
44
48
|
"types": "./dist/push-node.d.ts",
|
|
45
49
|
"import": "./dist/push-node.js"
|
|
50
|
+
},
|
|
51
|
+
"./node-default-transports": {
|
|
52
|
+
"types": "./dist/node-default-transports.d.ts",
|
|
53
|
+
"import": "./dist/node-default-transports.js"
|
|
46
54
|
}
|
|
47
55
|
},
|
|
48
56
|
"repository": {
|
|
@@ -70,14 +78,11 @@
|
|
|
70
78
|
"devDependencies": {
|
|
71
79
|
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
72
80
|
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
73
|
-
"@libp2p/circuit-relay-v2": "^4.2.13",
|
|
74
81
|
"@libp2p/identify": "4.1.14",
|
|
75
82
|
"@libp2p/logger": "^6.2.13",
|
|
76
83
|
"@libp2p/peer-store": "^12.0.28",
|
|
77
|
-
"@libp2p/tcp": "^11.0.28",
|
|
78
84
|
"@libp2p/webrtc": "6.0.33",
|
|
79
|
-
"@
|
|
80
|
-
"@optimystic/db-p2p-storage-fs": "^1.12.0",
|
|
85
|
+
"@optimystic/db-p2p-storage-fs": "^1.13.0",
|
|
81
86
|
"@types/debug": "^4.1.12",
|
|
82
87
|
"@types/node": "^25.0.10",
|
|
83
88
|
"@vitest/coverage-v8": "^4.0.17",
|
|
@@ -88,18 +93,21 @@
|
|
|
88
93
|
"vitest": "^4.0.17"
|
|
89
94
|
},
|
|
90
95
|
"dependencies": {
|
|
96
|
+
"@libp2p/circuit-relay-v2": "^4.2.13",
|
|
91
97
|
"@libp2p/crypto": "^5.1.23",
|
|
92
98
|
"@libp2p/interface": "^3.3.0",
|
|
93
99
|
"@libp2p/peer-id": "^6.0.15",
|
|
94
100
|
"@libp2p/peer-record": "^9.0.16",
|
|
101
|
+
"@libp2p/tcp": "^11.0.28",
|
|
102
|
+
"@libp2p/websockets": "^10.1.21",
|
|
95
103
|
"@multiformats/multiaddr": "^13.0.3",
|
|
96
104
|
"@noble/hashes": "^2.0.0",
|
|
97
|
-
"@optimystic/db-core": "^1.
|
|
98
|
-
"@optimystic/db-p2p": "^1.
|
|
99
|
-
"@optimystic/quereus-plugin-crypto": "^1.
|
|
100
|
-
"@optimystic/quereus-plugin-optimystic": "^1.
|
|
105
|
+
"@optimystic/db-core": "^1.13.0",
|
|
106
|
+
"@optimystic/db-p2p": "^1.13.0",
|
|
107
|
+
"@optimystic/quereus-plugin-crypto": "^1.13.0",
|
|
108
|
+
"@optimystic/quereus-plugin-optimystic": "^1.13.0",
|
|
101
109
|
"@quereus/quereus": "^4.20.1",
|
|
102
|
-
"@serfab/quereus-plugin-sereus": "^1.
|
|
110
|
+
"@serfab/quereus-plugin-sereus": "^1.15.0",
|
|
103
111
|
"debug": "^4.4.3",
|
|
104
112
|
"libp2p": "^3.3.11",
|
|
105
113
|
"uint8arrays": "^6.1.1"
|
|
@@ -31,15 +31,16 @@ import {
|
|
|
31
31
|
cadreInviteConsentMessage,
|
|
32
32
|
CadreInviteIssuerUnknownError,
|
|
33
33
|
InvitationExhaustedError,
|
|
34
|
+
parseNullableStoredDatetimeMs,
|
|
34
35
|
type ControlDatabase
|
|
35
36
|
} from './control-database.js';
|
|
36
37
|
import { verifyCadreInviteRow, verifyCadreInviteRedemption } from './peer-authorization.js';
|
|
37
38
|
import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
38
|
-
import { trailingPeerId } from './peer-
|
|
39
|
-
import { nextMacrotask } from './peer-dial.js';
|
|
39
|
+
import { dialAddr, nextMacrotask, trailingPeerId } from './peer-dial.js';
|
|
40
40
|
import { chainMessages } from './control-retry.js';
|
|
41
41
|
import { isRetriableControlWriteFailure } from './control-write-retry.js';
|
|
42
|
-
import
|
|
42
|
+
import { CADRE_INVITATION_PREFIX, CADRE_JOIN_REQUEST_PREFIX } from './cadre-join-request.js';
|
|
43
|
+
import type { CadreInviteRow, CadreJoinProgress, SeedPeer } from './types.js';
|
|
43
44
|
|
|
44
45
|
const log = debug('sereus:cadre:invite-proto');
|
|
45
46
|
|
|
@@ -108,29 +109,42 @@ export interface CadreInvitation {
|
|
|
108
109
|
*/
|
|
109
110
|
ownerKeys: string[];
|
|
110
111
|
/**
|
|
111
|
-
* Multiaddrs ending in `/p2p/<peerId>`: the issuer's own first, then
|
|
112
|
-
* `
|
|
113
|
-
* tries them in order.
|
|
112
|
+
* Multiaddrs ending in `/p2p/<peerId>`: the issuer's own first, then other members', at
|
|
113
|
+
* most `CADRE_INVITATION_MAX_ADDRS` in all (`selectCadreInvitationMembers` in
|
|
114
|
+
* `invitation-bootstrap.ts`). The device tries them in order.
|
|
114
115
|
*/
|
|
115
116
|
members: string[];
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Encode a bundle for out-of-band delivery: {@link CADRE_INVITATION_PREFIX}, then base64url
|
|
121
|
+
* over JSON. The prefix lets an app tell an invitation from a join request
|
|
122
|
+
* (`cadre-join-request.ts`) and name the mistake when one is pasted for the other.
|
|
123
|
+
*/
|
|
119
124
|
export function encodeCadreInvitation(invitation: CadreInvitation): string {
|
|
120
|
-
return uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url')
|
|
125
|
+
return `${CADRE_INVITATION_PREFIX}${uint8ArrayToString(new TextEncoder().encode(JSON.stringify(invitation)), 'base64url')}`;
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
/**
|
|
124
129
|
* Decode a bundle, refusing anything that is not a well-formed version-1
|
|
125
|
-
* {@link CadreInvitation}. Standalone so a client
|
|
126
|
-
* wants to show which cadre an invitation joins) can
|
|
130
|
+
* {@link CadreInvitation} behind {@link CADRE_INVITATION_PREFIX}. Standalone so a client
|
|
131
|
+
* without a node (a settings screen that wants to show which cadre an invitation joins) can
|
|
132
|
+
* decode one.
|
|
127
133
|
*
|
|
128
|
-
* @throws on a
|
|
134
|
+
* @throws on a join request, a string without the prefix or that is not base64url JSON after
|
|
135
|
+
* it, or one that decodes to another shape or version.
|
|
129
136
|
*/
|
|
130
137
|
export function decodeCadreInvitation(encoded: string): CadreInvitation {
|
|
138
|
+
const trimmed = encoded.trim();
|
|
139
|
+
if (trimmed.startsWith(CADRE_JOIN_REQUEST_PREFIX)) {
|
|
140
|
+
throw new Error('This is a join request, not a cadre invitation; paste it into the app that manages your cadre');
|
|
141
|
+
}
|
|
142
|
+
if (!trimmed.startsWith(CADRE_INVITATION_PREFIX)) {
|
|
143
|
+
throw new Error(`Cadre invitation must start with "${CADRE_INVITATION_PREFIX}" (this one starts with "${trimmed.slice(0, CADRE_INVITATION_PREFIX.length)}")`);
|
|
144
|
+
}
|
|
131
145
|
let parsed: unknown;
|
|
132
146
|
try {
|
|
133
|
-
parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(
|
|
147
|
+
parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(trimmed.slice(CADRE_INVITATION_PREFIX.length), 'base64url')));
|
|
134
148
|
} catch (error) {
|
|
135
149
|
throw new Error('Cadre invitation does not decode as base64url JSON', { cause: error });
|
|
136
150
|
}
|
|
@@ -512,12 +526,14 @@ export interface CadreInviteHandlerOptions {
|
|
|
512
526
|
partyId: string;
|
|
513
527
|
store: CadreInviteStore;
|
|
514
528
|
/**
|
|
515
|
-
* Push this member's control store to the device
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
529
|
+
* Push this member's control store to the device, resolving once the push has finished, and
|
|
530
|
+
* keep the device counted in this member's control write cohort until the returned release
|
|
531
|
+
* is called. Called after the request verified and before the row is seated and the
|
|
532
|
+
* admission is written; the handler releases once both have settled. Absent, the writes go
|
|
533
|
+
* ahead with the device neither caught up nor in the cohort, so it does not hold them; see
|
|
534
|
+
* `admitDeviceIfLive`.
|
|
519
535
|
*/
|
|
520
|
-
|
|
536
|
+
admitDevice?: (peerId: string) => Promise<() => void>;
|
|
521
537
|
/** Time to wait for the request frame; default {@link DEFAULT_REDEEM_READ_TIMEOUT_MS}. */
|
|
522
538
|
redeemReadTimeoutMs?: number;
|
|
523
539
|
/** Cap on concurrent inbound redemptions; default {@link DEFAULT_MAX_CONCURRENT_REDEMPTIONS}. */
|
|
@@ -555,8 +571,8 @@ function writeFailureRejection(error: unknown): CadreInviteRejection {
|
|
|
555
571
|
/**
|
|
556
572
|
* Member side: registers the handler and drives each inbound stream through the checks in
|
|
557
573
|
* cost order — shape, connecting identity, both signatures, target device — so no database
|
|
558
|
-
* work happens for a stranger that cannot prove anything, then
|
|
559
|
-
*
|
|
574
|
+
* work happens for a stranger that cannot prove anything, then admits the device to this
|
|
575
|
+
* member's control replication (`admitDevice`), seats the row and redeems.
|
|
560
576
|
* Hardened as the seed handler is: a concurrency cap (over it, `busy`) and a read timeout.
|
|
561
577
|
*
|
|
562
578
|
* Registered only by a started `CadreNode`, after its control database is up, like the
|
|
@@ -668,15 +684,24 @@ export class CadreInviteHandler {
|
|
|
668
684
|
}
|
|
669
685
|
|
|
670
686
|
/**
|
|
671
|
-
*
|
|
672
|
-
* transaction; a no-op when held), then redeem. The
|
|
673
|
-
* already a write the device takes part in
|
|
674
|
-
* member does not know as an owner is the one retryable refusal left: the device
|
|
675
|
-
* next address, which may hold the issuer's row.
|
|
687
|
+
* Admit the device to this member's control replication (see {@link admitDeviceIfLive}),
|
|
688
|
+
* seat the row from the bundle (its own transaction; a no-op when held), then redeem. The
|
|
689
|
+
* admission comes first because the seat is already a write the device takes part in. An
|
|
690
|
+
* issuer this member does not know as an owner is the one retryable refusal left: the device
|
|
691
|
+
* tries the next address, which may hold the issuer's row.
|
|
676
692
|
*/
|
|
677
693
|
private async seatAndRedeem(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRedeemReply> {
|
|
694
|
+
const release = await this.admitDeviceIfLive(request.invite, remotePeerId);
|
|
695
|
+
try {
|
|
696
|
+
return await this.seatAndRedeemAdmitted(request, remotePeerId);
|
|
697
|
+
} finally {
|
|
698
|
+
release();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** {@link seatAndRedeem}'s two writes, each answered by name when it fails. */
|
|
703
|
+
private async seatAndRedeemAdmitted(request: CadreInviteRedeemRequest, remotePeerId: string): Promise<CadreInviteRedeemReply> {
|
|
678
704
|
const { store } = this.options;
|
|
679
|
-
await this.catchUpDeviceIfLive(request.invite, remotePeerId);
|
|
680
705
|
try {
|
|
681
706
|
await store.seatCadreInvite(request.invite);
|
|
682
707
|
} catch (error) {
|
|
@@ -710,12 +735,14 @@ export class CadreInviteHandler {
|
|
|
710
735
|
}
|
|
711
736
|
|
|
712
737
|
/**
|
|
713
|
-
*
|
|
714
|
-
* (the seat, when this member does not hold the row; otherwise the admission)
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
738
|
+
* Admit the device to this member's control replication before the first write of the
|
|
739
|
+
* exchange (the seat, when this member does not hold the row; otherwise the admission), and
|
|
740
|
+
* resolve to the release that ends the admission. The device is not a member until the
|
|
741
|
+
* admission is written, so only this puts it in this member's control write cohort, and it
|
|
742
|
+
* must be there to hold the rows that admit it. It is pushed the control store first,
|
|
743
|
+
* because a cohort node with no base revision of a block refuses a commit to it; a member
|
|
744
|
+
* alone with the device would then have half its cohort refusing, and the write would tear
|
|
745
|
+
* instead of committing.
|
|
719
746
|
*
|
|
720
747
|
* Only for an invitation still live here, judged on the row this member holds or, before
|
|
721
748
|
* the seat, on the bundle's copy once its issuer signature verifies
|
|
@@ -723,25 +750,27 @@ export class CadreInviteHandler {
|
|
|
723
750
|
* or of a forged copy naming a real owner, is refused without being sent anything. The
|
|
724
751
|
* retry of an admission already written can read as spent too and is sent nothing; it
|
|
725
752
|
* needs nothing, since the device is a member and the ordinary catch-up reaches it.
|
|
726
|
-
* Best-effort: a failed check or push is logged and the writes go ahead
|
|
727
|
-
* answered retryably.
|
|
753
|
+
* Best-effort: a failed check or push is logged and the writes go ahead without the device
|
|
754
|
+
* admitted, then commit or are answered retryably.
|
|
728
755
|
*/
|
|
729
|
-
private async
|
|
730
|
-
const {
|
|
731
|
-
|
|
756
|
+
private async admitDeviceIfLive(invite: CadreInviteRow, remotePeerId: string): Promise<() => void> {
|
|
757
|
+
const { admitDevice, store } = this.options;
|
|
758
|
+
const notAdmitted = (): void => undefined;
|
|
759
|
+
if (!admitDevice) return notAdmitted;
|
|
732
760
|
try {
|
|
733
761
|
if (!await store.isCadreInviteLive(invite)) {
|
|
734
|
-
log('invitation %s is not live here; %s is not
|
|
735
|
-
return;
|
|
762
|
+
log('invitation %s is not live here; %s is not admitted before the write', invite.key, remotePeerId);
|
|
763
|
+
return notAdmitted;
|
|
736
764
|
}
|
|
737
765
|
// NOTE: accepted tradeoff — when a write then fails (the seat refused, the last use
|
|
738
766
|
// taken by a race, the row expiring between this check and the write), the device keeps
|
|
739
767
|
// a copy of a control store it was not admitted to. It holds a live owner-signed
|
|
740
768
|
// invitation and proved possession and consent, and a seed hands an un-enrolled machine
|
|
741
769
|
// the same information. Revisit if a control table ever carries data a non-member must not see.
|
|
742
|
-
await
|
|
770
|
+
return await admitDevice(remotePeerId);
|
|
743
771
|
} catch (error) {
|
|
744
|
-
log('
|
|
772
|
+
log('admitting %s before its admission write failed (writing anyway): %o', remotePeerId, error);
|
|
773
|
+
return notAdmitted;
|
|
745
774
|
}
|
|
746
775
|
}
|
|
747
776
|
|
|
@@ -774,6 +803,8 @@ export interface RedeemAtMembersOptions {
|
|
|
774
803
|
/** Per-address deadline override; default derived from `linkRoundTripMs`. */
|
|
775
804
|
addressBudgetMs?: number;
|
|
776
805
|
protocolId?: string;
|
|
806
|
+
/** Cancels the dial or stream in flight and ends the pass; the call throws the signal's reason. */
|
|
807
|
+
signal?: AbortSignal;
|
|
777
808
|
}
|
|
778
809
|
|
|
779
810
|
/** What a successful redemption yields the device. */
|
|
@@ -786,8 +817,11 @@ export interface CadreInviteRedemption {
|
|
|
786
817
|
}
|
|
787
818
|
|
|
788
819
|
/**
|
|
789
|
-
*
|
|
820
|
+
* Dial `addr`, open one stream to it, send the request, half-close, read the one reply frame.
|
|
790
821
|
* `signal` is the per-address deadline: it cancels the dial and resets a live stream.
|
|
822
|
+
*
|
|
823
|
+
* The address is dialed outside libp2p's dial queue (`dialAddr`), so another dial of the
|
|
824
|
+
* member in flight cannot capture this attempt; an open connection is still reused.
|
|
791
825
|
*/
|
|
792
826
|
async function exchangeRedemption(
|
|
793
827
|
node: Libp2p,
|
|
@@ -797,8 +831,9 @@ async function exchangeRedemption(
|
|
|
797
831
|
budgetMs: number,
|
|
798
832
|
protocolId: string
|
|
799
833
|
): Promise<CadreInviteAccepted | CadreInviteRefusalFrame> {
|
|
834
|
+
const connection = await dialAddr(node, addr, signal, 'Cadre invitation redemption');
|
|
800
835
|
// `runOnLimitedConnection`: a member may be reachable only through a relay (see `register`).
|
|
801
|
-
const rawStream = await
|
|
836
|
+
const rawStream = await connection.newStream(protocolId, { runOnLimitedConnection: true, signal });
|
|
802
837
|
return await exchangeFrame(
|
|
803
838
|
rawStream as unknown as ControlStream,
|
|
804
839
|
signal,
|
|
@@ -812,7 +847,34 @@ async function exchangeRedemption(
|
|
|
812
847
|
}
|
|
813
848
|
|
|
814
849
|
function asError(error: unknown): Error {
|
|
815
|
-
|
|
850
|
+
if (error instanceof Error) return error;
|
|
851
|
+
// A WebSocket transport rejects a refused dial with an `ErrorEvent`, whose text is its `message`.
|
|
852
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
853
|
+
return new Error(typeof message === 'string' && message.length > 0 ? message : String(error), { cause: error });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Run `op` with a signal that aborts when either `deadline` or the caller's `signal` does.
|
|
858
|
+
* Relayed by hand rather than with `AbortSignal.any`, which the phone runtimes lack.
|
|
859
|
+
*/
|
|
860
|
+
async function underDeadlineAndCaller<T>(
|
|
861
|
+
deadline: AbortSignal,
|
|
862
|
+
caller: AbortSignal | undefined,
|
|
863
|
+
op: (signal: AbortSignal) => Promise<T>
|
|
864
|
+
): Promise<T> {
|
|
865
|
+
if (!caller) return await op(deadline);
|
|
866
|
+
const either = new AbortController();
|
|
867
|
+
const abort = (): void => either.abort();
|
|
868
|
+
deadline.addEventListener('abort', abort, { once: true });
|
|
869
|
+
caller.addEventListener('abort', abort, { once: true });
|
|
870
|
+
// An abort between the caller's last check and the listener above fired no event.
|
|
871
|
+
if (caller.aborted) abort();
|
|
872
|
+
try {
|
|
873
|
+
return await op(either.signal);
|
|
874
|
+
} finally {
|
|
875
|
+
deadline.removeEventListener('abort', abort);
|
|
876
|
+
caller.removeEventListener('abort', abort);
|
|
877
|
+
}
|
|
816
878
|
}
|
|
817
879
|
|
|
818
880
|
/**
|
|
@@ -827,13 +889,16 @@ function asError(error: unknown): Error {
|
|
|
827
889
|
* @throws {CadreInviteRejectedError} on a final refusal (its `retryable` is false).
|
|
828
890
|
* @throws {CadreInviteReplyInvalidError} on an acceptance that fails {@link verifyRedeemReply}.
|
|
829
891
|
* @throws {CadreInviteUnreachableError} when every address was tried and none accepted.
|
|
892
|
+
* @throws the reason of `options.signal` once it aborts.
|
|
830
893
|
*/
|
|
831
894
|
export async function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOptions): Promise<CadreInviteRedemption> {
|
|
832
895
|
const protocolId = options.protocolId ?? CADRE_INVITE_PROTOCOL;
|
|
833
896
|
const budgetMs = options.addressBudgetMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
|
|
834
897
|
const outcomes: CadreInviteAddressOutcome[] = [];
|
|
835
898
|
for (const addr of options.invitation.members) {
|
|
836
|
-
|
|
899
|
+
options.signal?.throwIfAborted();
|
|
900
|
+
// Between attempts: a relayed address's relay leg goes through libp2p's dial queue, which
|
|
901
|
+
// needs a macrotask to drop an aborted job (see `tryAddrsInTurn`).
|
|
837
902
|
if (outcomes.length > 0) await nextMacrotask();
|
|
838
903
|
let parsed: Multiaddr;
|
|
839
904
|
try {
|
|
@@ -844,7 +909,8 @@ export async function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOpti
|
|
|
844
909
|
}
|
|
845
910
|
try {
|
|
846
911
|
const reply = await withDeadline(budgetMs, `Cadre invitation redemption via ${addr}`,
|
|
847
|
-
(
|
|
912
|
+
(deadline) => underDeadlineAndCaller(deadline, options.signal,
|
|
913
|
+
(signal) => exchangeRedemption(node, parsed, options.request, signal, budgetMs, protocolId)));
|
|
848
914
|
if (!reply.accepted) {
|
|
849
915
|
const refusal = new CadreInviteRejectedError(reply.code, reply.reason);
|
|
850
916
|
if (!refusal.retryable) throw refusal;
|
|
@@ -856,9 +922,131 @@ export async function redeemAtMembers(node: Libp2p, options: RedeemAtMembersOpti
|
|
|
856
922
|
return { reply, memberPeerId: trailingPeerId(parsed), memberAddr: addr };
|
|
857
923
|
} catch (error) {
|
|
858
924
|
if (error instanceof CadreInviteRejectedError || error instanceof CadreInviteReplyInvalidError) throw error;
|
|
925
|
+
// The caller's abort cut this attempt short; its reason, not the cut, is the answer.
|
|
926
|
+
options.signal?.throwIfAborted();
|
|
859
927
|
log('redemption via %s failed before an answer (%s); trying the next address', addr, asError(error).message);
|
|
860
928
|
outcomes.push({ addr, error: asError(error) });
|
|
861
929
|
}
|
|
862
930
|
}
|
|
863
931
|
throw new CadreInviteUnreachableError(outcomes);
|
|
864
932
|
}
|
|
933
|
+
|
|
934
|
+
/** Pause after the first pass in which no member accepted; doubled after each further one. */
|
|
935
|
+
const REDEEM_PASS_FIRST_PAUSE_MS = 5_000;
|
|
936
|
+
|
|
937
|
+
/** Longest pause between passes. */
|
|
938
|
+
const REDEEM_PASS_MAX_PAUSE_MS = 60_000;
|
|
939
|
+
|
|
940
|
+
export interface RedeemInPassesOptions extends RedeemAtMembersOptions {
|
|
941
|
+
/** Keep making passes while every answer is retryable; false makes exactly one. */
|
|
942
|
+
waitForMember: boolean;
|
|
943
|
+
/** Called at each pass and each pause; an exception it throws is logged and ignored. */
|
|
944
|
+
onProgress?: (progress: CadreJoinProgress) => void;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Device side, in passes: one {@link redeemAtMembers} pass over the bundle's addresses, or,
|
|
949
|
+
* with `waitForMember`, as many as it takes, pausing between them (5 s after the first,
|
|
950
|
+
* doubling to 60 s, never past the invitation's expiry). Every pass sends the same signed
|
|
951
|
+
* request, so a member that admitted the device on a pass whose reply was lost answers a later
|
|
952
|
+
* pass as already a member, with no second usage row.
|
|
953
|
+
*
|
|
954
|
+
* A wait ends at the first acceptance, at a final refusal or an invalid reply (thrown as
|
|
955
|
+
* {@link redeemAtMembers} throws them), when the invitation expires by this device's clock
|
|
956
|
+
* (thrown as the `invite-spent` refusal a member would give), when `signal` aborts (its reason
|
|
957
|
+
* thrown), and when `node` stops. A bundle that names no address has nothing to wait for and
|
|
958
|
+
* throws at once.
|
|
959
|
+
*/
|
|
960
|
+
export async function redeemInPasses(node: Libp2p, options: RedeemInPassesOptions): Promise<CadreInviteRedemption> {
|
|
961
|
+
for (let pass = 1; ; pass++) {
|
|
962
|
+
if (options.waitForMember) {
|
|
963
|
+
// One pass leaves expiry to the member, whose clock decides; a wait has nothing else to end it.
|
|
964
|
+
refuseIfExpired(options.invitation.invite);
|
|
965
|
+
refuseIfStopped(node);
|
|
966
|
+
}
|
|
967
|
+
reportProgress(options.onProgress, { state: 'redeeming', pass });
|
|
968
|
+
try {
|
|
969
|
+
return await redeemAtMembers(node, options);
|
|
970
|
+
} catch (error) {
|
|
971
|
+
if (!options.waitForMember || !(error instanceof CadreInviteUnreachableError) || error.outcomes.length === 0) throw error;
|
|
972
|
+
const pauseMs = pauseAfterPass(pass, options.invitation.invite);
|
|
973
|
+
reportProgress(options.onProgress, {
|
|
974
|
+
state: 'waiting', pass, retryAtMs: Date.now() + pauseMs, reason: waitReason(error.outcomes), outcomes: error.outcomes
|
|
975
|
+
});
|
|
976
|
+
log('redemption pass %d found no member ready; the next pass in %d ms', pass, pauseMs);
|
|
977
|
+
await pause(pauseMs, options.signal, node);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// NOTE: judged by this device's clock, so one running ahead ends a wait before the members
|
|
983
|
+
// would refuse it. If waits are seen ending early on skewed phones, allow a grace margin here.
|
|
984
|
+
function refuseIfExpired(invite: CadreInviteRow): void {
|
|
985
|
+
const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
|
|
986
|
+
if (expiresAtMs !== null && expiresAtMs <= Date.now()) {
|
|
987
|
+
throw new CadreInviteRejectedError('invite-spent', 'the invitation expired before a member accepted it');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** A stopped node's every dial fails at once, which would otherwise read as "no member answered" until expiry. */
|
|
992
|
+
function refuseIfStopped(node: Libp2p): void {
|
|
993
|
+
if (node.status !== 'started') {
|
|
994
|
+
throw new Error(`Cadre invitation redemption stopped waiting: the node is ${node.status}`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** The pause after pass `pass`, cut short at the invitation's expiry so the next check ends the wait then. */
|
|
999
|
+
function pauseAfterPass(pass: number, invite: CadreInviteRow): number {
|
|
1000
|
+
const backoffMs = Math.min(REDEEM_PASS_FIRST_PAUSE_MS * 2 ** (pass - 1), REDEEM_PASS_MAX_PAUSE_MS);
|
|
1001
|
+
const expiresAtMs = parseNullableStoredDatetimeMs(invite.expiresAt);
|
|
1002
|
+
return expiresAtMs === null ? backoffMs : Math.max(0, Math.min(backoffMs, expiresAtMs - Date.now()));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Any refusal means a member answered and is not ready yet; otherwise none answered at all. */
|
|
1006
|
+
function waitReason(outcomes: readonly CadreInviteAddressOutcome[]): 'no-member-answered' | 'member-not-ready' {
|
|
1007
|
+
return outcomes.some((outcome) => outcome.error instanceof CadreInviteRejectedError) ? 'member-not-ready' : 'no-member-answered';
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** A UI callback must not break the join. */
|
|
1011
|
+
function reportProgress(onProgress: ((progress: CadreJoinProgress) => void) | undefined, progress: CadreJoinProgress): void {
|
|
1012
|
+
if (!onProgress) return;
|
|
1013
|
+
try {
|
|
1014
|
+
onProgress(progress);
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
log('redemption progress callback threw (ignored): %o', error);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Wait `ms`, or reject with `signal`'s reason as soon as it aborts. Ends early, resolving, when
|
|
1022
|
+
* `node` stops (or is already stopped), so the next pass's stop check ends the wait without a
|
|
1023
|
+
* timer holding the process open. Listeners and timer are cleared however it ends.
|
|
1024
|
+
*/
|
|
1025
|
+
function pause(ms: number, signal: AbortSignal | undefined, node: Libp2p): Promise<void> {
|
|
1026
|
+
return new Promise((resolve, reject) => {
|
|
1027
|
+
if (signal?.aborted) {
|
|
1028
|
+
reject(signal.reason);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (node.status !== 'started') {
|
|
1032
|
+
resolve();
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const settle = (): void => {
|
|
1036
|
+
clearTimeout(timer);
|
|
1037
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1038
|
+
node.removeEventListener('stop', onStop);
|
|
1039
|
+
};
|
|
1040
|
+
const onAbort = (): void => {
|
|
1041
|
+
settle();
|
|
1042
|
+
reject(signal!.reason);
|
|
1043
|
+
};
|
|
1044
|
+
const onStop = (): void => {
|
|
1045
|
+
settle();
|
|
1046
|
+
resolve();
|
|
1047
|
+
};
|
|
1048
|
+
const timer = setTimeout(onStop, ms);
|
|
1049
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1050
|
+
node.addEventListener('stop', onStop, { once: true });
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The join request: the text an app that wants to join a cadre copies (or shows as a QR code)
|
|
3
|
+
* so the app that manages the cadre can mint an invitation for it alone
|
|
4
|
+
* (`CadreNode.createCadreInvitation({ peerId: request.peerId, grantsOwner })`).
|
|
5
|
+
*
|
|
6
|
+
* `sereus-join-request:1.<base64url of canonical JSON>` where the JSON is
|
|
7
|
+
* `{ appName, peerId, reply?, v }`. Nothing in it is secret or signed: the peer id is public, and
|
|
8
|
+
* the name is only what the requester calls itself, shown in the owner's approval prompt. The
|
|
9
|
+
* safety is in the response: an invitation targeted at that peer id can be redeemed only by
|
|
10
|
+
* the key behind it.
|
|
11
|
+
*
|
|
12
|
+
* `reply` is present only on a request opened as a link from another app on the same phone
|
|
13
|
+
* (`@serfab/cadre-rn/app-link`): the owner app answers by opening `<reply.scheme>://…`, a link it
|
|
14
|
+
* builds itself, so the scheme is restricted to an app's own custom scheme and never a web or
|
|
15
|
+
* system one, which would send the invitation's machine addresses off the phone.
|
|
16
|
+
*
|
|
17
|
+
* Imports only `@libp2p/peer-id` and `uint8arrays`, so it loads on React Native and in the
|
|
18
|
+
* browser.
|
|
19
|
+
*/
|
|
20
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
21
|
+
import { fromString as uint8ArrayFromString, toString as uint8ArrayToString } from 'uint8arrays';
|
|
22
|
+
import { canonicalJson } from './canonical-json.js';
|
|
23
|
+
|
|
24
|
+
/** Version 1 of the join request; the only one either side reads. */
|
|
25
|
+
export const CADRE_JOIN_REQUEST_PREFIX = 'sereus-join-request:1.';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Version 1 of an encoded cadre invitation (`encodeCadreInvitation`). Declared here, beside
|
|
29
|
+
* the request's prefix, so each decoder can name the other kind of text when it is pasted in
|
|
30
|
+
* the wrong place without this codec importing the redemption protocol.
|
|
31
|
+
*/
|
|
32
|
+
export const CADRE_INVITATION_PREFIX = 'sereus-invite:1.';
|
|
33
|
+
|
|
34
|
+
/** Longest `appName` a request carries: one line in an approval prompt. */
|
|
35
|
+
export const CADRE_JOIN_REQUEST_MAX_APP_NAME = 64;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Schemes a reply may not name, though they match {@link CADRE_JOIN_REPLY_SCHEME}: each opens a
|
|
39
|
+
* web page, a file, a system action or another app's content rather than a Sereus app.
|
|
40
|
+
*/
|
|
41
|
+
const REFUSED_REPLY_SCHEMES: ReadonlySet<string> = new Set([
|
|
42
|
+
'http', 'https', 'ws', 'wss', 'ftp', 'file', 'content', 'data', 'blob',
|
|
43
|
+
'javascript', 'about', 'intent', 'mailto', 'tel', 'sms',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The form of a reply's scheme: lowercase, as an app's custom URL scheme is declared.
|
|
48
|
+
* NOTE: `@serfab/cadre-rn`'s CommonJS config plugin keeps a copy (`APP_SCHEME`) to check extra
|
|
49
|
+
* apps' schemes; change it with this one.
|
|
50
|
+
*/
|
|
51
|
+
const CADRE_JOIN_REPLY_SCHEME = /^[a-z][a-z0-9+.-]{0,63}$/;
|
|
52
|
+
|
|
53
|
+
/** The form of a reply's nonce: base64url, long enough that it cannot be guessed. */
|
|
54
|
+
const CADRE_JOIN_REPLY_NONCE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
55
|
+
|
|
56
|
+
/** Where and how the owner app answers a request that arrived as a link. */
|
|
57
|
+
export interface CadreJoinReply {
|
|
58
|
+
/** The requesting app's own custom URL scheme; the owner app opens `<scheme>://cadre-join-response/<response>`. */
|
|
59
|
+
scheme: string;
|
|
60
|
+
/** Echoed in the response, so the requesting app takes only an answer to a request it has open. */
|
|
61
|
+
nonce: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CadreJoinRequest {
|
|
65
|
+
v: 1;
|
|
66
|
+
/** The requesting node's peer id: an Ed25519 peer id, since the invitation targets that key. */
|
|
67
|
+
peerId: string;
|
|
68
|
+
/** What the requester calls itself, for the owner's approval prompt. Unverified; at most 64 characters, trimmed, non-empty. */
|
|
69
|
+
appName: string;
|
|
70
|
+
/** Present only on a request opened as a link: where and how the owner app answers. */
|
|
71
|
+
reply?: CadreJoinReply;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Encode a request as the text the requesting app copies. Refuses what {@link decodeCadreJoinRequest} would. */
|
|
75
|
+
export function encodeCadreJoinRequest(request: CadreJoinRequest): string {
|
|
76
|
+
const checked = requireCadreJoinRequest(request);
|
|
77
|
+
// canonicalJson drops an undefined `reply`, so a request without one encodes as it always has.
|
|
78
|
+
const json = canonicalJson({ appName: checked.appName, peerId: checked.peerId, reply: checked.reply, v: checked.v });
|
|
79
|
+
return `${CADRE_JOIN_REQUEST_PREFIX}${uint8ArrayToString(new TextEncoder().encode(json), 'base64url')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decode the text the owner's app was given. Refuses, naming the problem: an invitation
|
|
84
|
+
* pasted here, any prefix but version 1's, a body that is not base64url JSON of the request's
|
|
85
|
+
* shape, a peer id that does not parse or is not Ed25519, an `appName` that is empty or
|
|
86
|
+
* longer than {@link CADRE_JOIN_REQUEST_MAX_APP_NAME} characters once trimmed, and a `reply`
|
|
87
|
+
* that {@link requireCadreJoinReply} refuses.
|
|
88
|
+
*/
|
|
89
|
+
export function decodeCadreJoinRequest(text: string): CadreJoinRequest {
|
|
90
|
+
const trimmed = text.trim();
|
|
91
|
+
if (trimmed.startsWith(CADRE_INVITATION_PREFIX)) {
|
|
92
|
+
throw new Error('This is a cadre invitation, not a join request; paste it where the app asks for an invitation');
|
|
93
|
+
}
|
|
94
|
+
if (!trimmed.startsWith(CADRE_JOIN_REQUEST_PREFIX)) {
|
|
95
|
+
throw new Error(`A join request must start with "${CADRE_JOIN_REQUEST_PREFIX}" (this one starts with "${trimmed.slice(0, CADRE_JOIN_REQUEST_PREFIX.length)}")`);
|
|
96
|
+
}
|
|
97
|
+
let parsed: unknown;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(new TextDecoder().decode(uint8ArrayFromString(trimmed.slice(CADRE_JOIN_REQUEST_PREFIX.length), 'base64url')));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw new Error('A join request must carry base64url-encoded JSON after its prefix', { cause: error });
|
|
102
|
+
}
|
|
103
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
104
|
+
throw new Error('A join request must decode to a JSON object');
|
|
105
|
+
}
|
|
106
|
+
const { v, peerId, appName, reply } = parsed as Record<string, unknown>;
|
|
107
|
+
if (v !== 1) throw new Error(`A join request must be version 1 (this one says ${JSON.stringify(v)})`);
|
|
108
|
+
if (typeof peerId !== 'string') throw new Error('A join request must name the requesting node\'s peer id');
|
|
109
|
+
if (typeof appName !== 'string') throw new Error('A join request must carry the requesting app\'s name');
|
|
110
|
+
return requireCadreJoinRequest({ v, peerId, appName, ...(reply === undefined ? {} : { reply: replyShape(reply) }) });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Refuses, naming the problem, a reply whose scheme is not a lowercase URL scheme of at most 64
|
|
115
|
+
* characters or is a web or system scheme (`https`, `file`, `intent`, …), and a nonce that is
|
|
116
|
+
* not 16 to 64 base64url characters. The owner app checks it again before it opens a reply.
|
|
117
|
+
*/
|
|
118
|
+
export function requireCadreJoinReply(reply: CadreJoinReply): CadreJoinReply {
|
|
119
|
+
const { scheme, nonce } = reply;
|
|
120
|
+
if (!CADRE_JOIN_REPLY_SCHEME.test(scheme)) {
|
|
121
|
+
throw new Error(`A join request's reply scheme must be a lowercase URL scheme of at most 64 characters (this one is ${JSON.stringify(scheme)})`);
|
|
122
|
+
}
|
|
123
|
+
if (REFUSED_REPLY_SCHEMES.has(scheme)) {
|
|
124
|
+
throw new Error(`A join request's reply scheme must be an app's own scheme, not "${scheme}"`);
|
|
125
|
+
}
|
|
126
|
+
if (!CADRE_JOIN_REPLY_NONCE.test(nonce)) {
|
|
127
|
+
throw new Error('A join request\'s reply nonce must be 16 to 64 base64url characters');
|
|
128
|
+
}
|
|
129
|
+
return { scheme, nonce };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The decoded `reply`'s types, before {@link requireCadreJoinReply} judges its values. */
|
|
133
|
+
function replyShape(reply: unknown): CadreJoinReply {
|
|
134
|
+
if (typeof reply !== 'object' || reply === null || Array.isArray(reply)) {
|
|
135
|
+
throw new Error('A join request\'s reply must be an object');
|
|
136
|
+
}
|
|
137
|
+
const { scheme, nonce } = reply as Record<string, unknown>;
|
|
138
|
+
if (typeof scheme !== 'string' || typeof nonce !== 'string') {
|
|
139
|
+
throw new Error('A join request\'s reply must carry a scheme and a nonce');
|
|
140
|
+
}
|
|
141
|
+
return { scheme, nonce };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The shape rules both directions apply. Returns the request with its fields trimmed. */
|
|
145
|
+
function requireCadreJoinRequest(request: CadreJoinRequest): CadreJoinRequest {
|
|
146
|
+
const peerId = requireEd25519PeerId(request.peerId);
|
|
147
|
+
const appName = request.appName.trim();
|
|
148
|
+
if (appName.length === 0) throw new Error('A join request must name the requesting app');
|
|
149
|
+
if (appName.length > CADRE_JOIN_REQUEST_MAX_APP_NAME) {
|
|
150
|
+
throw new Error(`A join request's app name must be at most ${CADRE_JOIN_REQUEST_MAX_APP_NAME} characters (this one has ${appName.length})`);
|
|
151
|
+
}
|
|
152
|
+
return { v: 1, peerId, appName, ...(request.reply ? { reply: requireCadreJoinReply(request.reply) } : {}) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** An invitation targeted at the peer id names its key, so only an Ed25519 id could ever redeem one. */
|
|
156
|
+
function requireEd25519PeerId(text: string): string {
|
|
157
|
+
let parsed: ReturnType<typeof peerIdFromString>;
|
|
158
|
+
try {
|
|
159
|
+
parsed = peerIdFromString(text.trim());
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new Error(`A join request's peer id does not parse ("${text}")`, { cause: error });
|
|
162
|
+
}
|
|
163
|
+
if (parsed.type !== 'Ed25519') {
|
|
164
|
+
throw new Error(`A join request's peer id must be an Ed25519 peer id (this one is ${parsed.type})`);
|
|
165
|
+
}
|
|
166
|
+
return parsed.toString();
|
|
167
|
+
}
|