@statewalker/httpeers-member 0.1.4
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/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/browser-platform.d.ts +30 -0
- package/dist/browser-platform.d.ts.map +1 -0
- package/dist/browser-platform.js +30 -0
- package/dist/browser-profile.d.ts +85 -0
- package/dist/browser-profile.d.ts.map +1 -0
- package/dist/browser-profile.js +120 -0
- package/dist/browser.d.ts +48 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +51 -0
- package/dist/connection-kind.d.ts +36 -0
- package/dist/connection-kind.d.ts.map +1 -0
- package/dist/connection-kind.js +52 -0
- package/dist/describe-error.d.ts +18 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +72 -0
- package/dist/edge-control.d.ts +50 -0
- package/dist/edge-control.d.ts.map +1 -0
- package/dist/edge-control.js +69 -0
- package/dist/edge-dispatch.d.ts +188 -0
- package/dist/edge-dispatch.d.ts.map +1 -0
- package/dist/edge-dispatch.js +142 -0
- package/dist/edge-guard.d.ts +45 -0
- package/dist/edge-guard.d.ts.map +1 -0
- package/dist/edge-guard.js +62 -0
- package/dist/edge.d.ts +97 -0
- package/dist/edge.d.ts.map +1 -0
- package/dist/edge.js +104 -0
- package/dist/gateway.d.ts +49 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +102 -0
- package/dist/identity.d.ts +78 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +117 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/join-blob.d.ts +125 -0
- package/dist/join-blob.d.ts.map +1 -0
- package/dist/join-blob.js +195 -0
- package/dist/join.d.ts +343 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +409 -0
- package/dist/kv.d.ts +20 -0
- package/dist/kv.d.ts.map +1 -0
- package/dist/kv.js +10 -0
- package/dist/mesh-config.d.ts +26 -0
- package/dist/mesh-config.d.ts.map +1 -0
- package/dist/mesh-config.js +39 -0
- package/dist/mesh-memory.d.ts +55 -0
- package/dist/mesh-memory.d.ts.map +1 -0
- package/dist/mesh-memory.js +74 -0
- package/dist/node.d.ts +29 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +42 -0
- package/dist/page-wake.d.ts +24 -0
- package/dist/page-wake.d.ts.map +1 -0
- package/dist/page-wake.js +31 -0
- package/dist/peer-request.d.ts +29 -0
- package/dist/peer-request.d.ts.map +1 -0
- package/dist/peer-request.js +11 -0
- package/dist/reset.d.ts +56 -0
- package/dist/reset.d.ts.map +1 -0
- package/dist/reset.js +103 -0
- package/dist/session.d.ts +214 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +403 -0
- package/dist/start-member.d.ts +176 -0
- package/dist/start-member.d.ts.map +1 -0
- package/dist/start-member.js +307 -0
- package/package.json +120 -0
- package/src/browser-platform.ts +53 -0
- package/src/browser-profile.ts +179 -0
- package/src/browser.ts +103 -0
- package/src/connection-kind.ts +53 -0
- package/src/describe-error.ts +76 -0
- package/src/edge-control.ts +94 -0
- package/src/edge-dispatch.ts +295 -0
- package/src/edge-guard.ts +67 -0
- package/src/edge.ts +175 -0
- package/src/gateway.ts +141 -0
- package/src/identity.ts +146 -0
- package/src/index.ts +95 -0
- package/src/join-blob.ts +229 -0
- package/src/join.ts +632 -0
- package/src/kv.ts +21 -0
- package/src/mesh-config.ts +41 -0
- package/src/mesh-memory.ts +104 -0
- package/src/node.ts +49 -0
- package/src/page-wake.ts +59 -0
- package/src/peer-request.ts +39 -0
- package/src/reset.ts +127 -0
- package/src/session.ts +579 -0
- package/src/start-member.ts +476 -0
package/src/join-blob.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The join blob: everything a page needs to join a mesh whose hub is
|
|
3
|
+
* ANOTHER BROWSER PAGE, in one copyable string.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT HAS TO EXIST. Every page in this stack has so far learned the mesh
|
|
6
|
+
* from `httpeers.json` -- `{ relayAddrs, hubPeerId }`, written once by
|
|
7
|
+
* `pnpm bootstrap` (`../setup/main.ts`) and served by both origins. That
|
|
8
|
+
* works because the Node hub's identity comes from a key file generated at
|
|
9
|
+
* bootstrap, so its peerId is knowable BEFORE it runs. The hub page's is
|
|
10
|
+
* not: it generates (or reloads) its identity in IndexedDB, in a tab, after
|
|
11
|
+
* `pnpm bootstrap` has already been and gone. Nothing on disk can name it.
|
|
12
|
+
* So the hub page hands its peerId out at runtime, alongside the relay it
|
|
13
|
+
* reserved through and a single-use invitation id -- and this module is the
|
|
14
|
+
* shape of that hand-off.
|
|
15
|
+
*
|
|
16
|
+
* ONE INVITATION PER PAGE, NOT ONE PER MESH. Invitations are single-use by
|
|
17
|
+
* construction (`../hub/hub-state.ts`'s `redeem` moves the id to
|
|
18
|
+
* `spentInvitationIds`, and the spent check runs first, unconditionally), so
|
|
19
|
+
* a blob shared between two pages lets exactly one in and fails the other
|
|
20
|
+
* with `already-redeemed`. `../pages/hub/main.ts` mints a fresh invitation
|
|
21
|
+
* per blob for that reason; this module only carries what it is given.
|
|
22
|
+
*
|
|
23
|
+
* THE ENCODING IS BASE64URL OF JSON, and it is a URL query parameter
|
|
24
|
+
* (`?join=`). Two properties matter and nothing else does: it survives a
|
|
25
|
+
* copy-paste through a terminal, a chat window and a browser address bar
|
|
26
|
+
* without needing quoting (base64url's alphabet is `A-Za-z0-9-_`, all
|
|
27
|
+
* URL-safe and none of it shell-special), and it is one token, so an
|
|
28
|
+
* operator cannot paste half of it. It is NOT encryption and not
|
|
29
|
+
* authentication -- anyone holding the blob can redeem the invitation
|
|
30
|
+
* inside it, which is exactly what an invitation is.
|
|
31
|
+
*
|
|
32
|
+
* NOT A HEADER, EVER. Same rule the rest of this app follows: human-facing
|
|
33
|
+
* text travels in a body or a URL, never a header (HTTP header values are
|
|
34
|
+
* latin1 -- see `../services/search.ts`'s own note).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The invitation payload's shape (`../setup/main.ts`'s `HttpeersConfig`) plus the one-shot invitation id that goes with it. */
|
|
38
|
+
export interface JoinBlob {
|
|
39
|
+
relayAddrs: string[];
|
|
40
|
+
hubPeerId: string;
|
|
41
|
+
invitationId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The query parameter a join blob travels in. */
|
|
45
|
+
export const JOIN_BLOB_PARAM = "join";
|
|
46
|
+
|
|
47
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
48
|
+
let binary = "";
|
|
49
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
50
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fromBase64Url(text: string): Uint8Array {
|
|
54
|
+
const base64 = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
55
|
+
// `atob` requires the padding `toBase64Url` stripped.
|
|
56
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
57
|
+
const binary = atob(padded);
|
|
58
|
+
const bytes = new Uint8Array(binary.length);
|
|
59
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Encode a blob. UTF-8 first, THEN base64: `btoa` throws on any code point
|
|
65
|
+
* above U+00FF, and while nothing in a blob is human text today (multiaddrs
|
|
66
|
+
* and peer ids are ASCII), a relay address is operator-supplied
|
|
67
|
+
* (`RELAY_HOST`) and a hostname can be an IDN.
|
|
68
|
+
*/
|
|
69
|
+
export function encodeJoinBlob(blob: JoinBlob): string {
|
|
70
|
+
return toBase64Url(new TextEncoder().encode(JSON.stringify(blob)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decode a blob, validating every field rather than trusting the shape.
|
|
75
|
+
* Throws with a message naming what was wrong: this value was typed or
|
|
76
|
+
* pasted by a person, so a malformed one is an ordinary event, and "the
|
|
77
|
+
* link you pasted is missing its hub peer id" is a fixable complaint where
|
|
78
|
+
* `undefined is not an object` is not.
|
|
79
|
+
*/
|
|
80
|
+
export function decodeJoinBlob(text: string): JoinBlob {
|
|
81
|
+
let parsed: unknown;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(new TextDecoder().decode(fromBase64Url(text.trim())));
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"join blob: this is not a join link from a hub page -- it did not decode. Copy the whole " +
|
|
87
|
+
`link, including the "?${JOIN_BLOB_PARAM}=" part. Cause: ${String(err)}`,
|
|
88
|
+
{ cause: err },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const blob = parsed as Partial<JoinBlob>;
|
|
93
|
+
const relayAddrs = blob.relayAddrs;
|
|
94
|
+
if (!Array.isArray(relayAddrs) || relayAddrs.length === 0) {
|
|
95
|
+
throw new Error("join blob: no relayAddrs -- there is nothing to dial.");
|
|
96
|
+
}
|
|
97
|
+
if (typeof blob.hubPeerId !== "string" || blob.hubPeerId === "") {
|
|
98
|
+
throw new Error("join blob: no hubPeerId -- this page would not know which mesh to join.");
|
|
99
|
+
}
|
|
100
|
+
if (typeof blob.invitationId !== "string" || blob.invitationId === "") {
|
|
101
|
+
throw new Error("join blob: no invitationId -- this page would have nothing to redeem.");
|
|
102
|
+
}
|
|
103
|
+
return { relayAddrs, hubPeerId: blob.hubPeerId, invitationId: blob.invitationId };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The join URL an operator copies: `pageOrigin` with this blob attached. `pageUrl` is a full URL (e.g. `http://127.0.0.1:5175/`). */
|
|
107
|
+
export function joinUrl(pageUrl: string, blob: JoinBlob): string {
|
|
108
|
+
const url = new URL(pageUrl);
|
|
109
|
+
url.searchParams.set(JOIN_BLOB_PARAM, encodeJoinBlob(blob));
|
|
110
|
+
return url.toString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What a page found in its own URL, or in what someone pasted into its
|
|
115
|
+
* join form.
|
|
116
|
+
*
|
|
117
|
+
* TWO CASES, AND THE DIFFERENCE IS WHICH MESH. A `join` blob carries its
|
|
118
|
+
* own `relayAddrs`/`hubPeerId`, so the page joins the mesh the blob names
|
|
119
|
+
* -- a hub page's. A bare `invite` id carries neither, so the page falls
|
|
120
|
+
* back to `httpeers.json` and joins the mesh the DEPLOYMENT names -- the
|
|
121
|
+
* Node hub's. Both are supported deliberately: the Node hub is not going
|
|
122
|
+
* anywhere (Task 24's first decision), and a page that silently preferred
|
|
123
|
+
* one would make the other unreachable with no way to say so.
|
|
124
|
+
*/
|
|
125
|
+
export type JoinInput =
|
|
126
|
+
| { kind: "blob"; blob: JoinBlob }
|
|
127
|
+
| { kind: "invitation-id"; invitationId: string };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read a join input out of a page's `location.search`. Returns `null` when
|
|
131
|
+
* neither parameter is present -- the page then shows its paste-in form.
|
|
132
|
+
*/
|
|
133
|
+
export function readJoinInputFromSearch(search: string): JoinInput | null {
|
|
134
|
+
const params = new URLSearchParams(search);
|
|
135
|
+
const blobParam = params.get(JOIN_BLOB_PARAM);
|
|
136
|
+
if (blobParam != null && blobParam.trim() !== "") {
|
|
137
|
+
return { kind: "blob", blob: decodeJoinBlob(blobParam) };
|
|
138
|
+
}
|
|
139
|
+
const invite = params.get("invite");
|
|
140
|
+
if (invite != null && invite.trim() !== "") {
|
|
141
|
+
return { kind: "invitation-id", invitationId: invite.trim() };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read a join input out of text a person pasted into a page's join form --
|
|
148
|
+
* a whole join URL, a bare blob, or a bare invitation id.
|
|
149
|
+
*
|
|
150
|
+
* ACCEPTS A WHOLE URL BECAUSE THAT IS WHAT GETS COPIED. The hub page's
|
|
151
|
+
* panel renders a complete link; an operator who copies it and pastes it
|
|
152
|
+
* into the form (rather than the address bar) has done nothing wrong, and a
|
|
153
|
+
* form that rejected it would be the page being pedantic about a
|
|
154
|
+
* distinction only it can see.
|
|
155
|
+
*/
|
|
156
|
+
export function readJoinInputFromText(text: string): JoinInput | null {
|
|
157
|
+
const trimmed = text.trim();
|
|
158
|
+
if (trimmed === "") return null;
|
|
159
|
+
|
|
160
|
+
if (trimmed.includes("://") || trimmed.includes(`${JOIN_BLOB_PARAM}=`)) {
|
|
161
|
+
// A URL, or at least something with a query string in it. `new URL`
|
|
162
|
+
// needs a base for the second case; the base is never used for anything
|
|
163
|
+
// but parsing, and the blob inside carries the real addresses.
|
|
164
|
+
const url = new URL(trimmed, "http://join.invalid/");
|
|
165
|
+
const found = readJoinInputFromSearch(url.search);
|
|
166
|
+
if (found != null) return found;
|
|
167
|
+
throw new Error(
|
|
168
|
+
`join: that link carries no "${JOIN_BLOB_PARAM}" or "invite" parameter -- is it the whole link?`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A bare value. A blob is base64url of a JSON object, which always starts
|
|
173
|
+
// `{"` -> `eyJ`; an invitation id is a UUID (`../hub/main.ts`'s
|
|
174
|
+
// `randomUUID`) and never does.
|
|
175
|
+
if (trimmed.startsWith("eyJ")) return { kind: "blob", blob: decodeJoinBlob(trimmed) };
|
|
176
|
+
return { kind: "invitation-id", invitationId: trimmed };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The invitation inside a QR code's text, or `null` — a PREDICATE, not a
|
|
181
|
+
* parser.
|
|
182
|
+
*
|
|
183
|
+
* WHY IT NEVER THROWS, AND WHY IT IS STRICTER THAN `readJoinInputFromText`. A
|
|
184
|
+
* live scanner sees a poster, a wifi sticker and a payment code before it sees
|
|
185
|
+
* an invitation, and it has to keep scanning through all of them. So every
|
|
186
|
+
* "no" here is the same quiet `null`: a scan that stopped on the first wrong
|
|
187
|
+
* QR, or threw on a malformed URL, would be worse than one that carries on
|
|
188
|
+
* looking. `readJoinInputFromText` is the opposite and rightly so — somebody
|
|
189
|
+
* PASTED that, so they meant it, and a complaint is what they need.
|
|
190
|
+
*
|
|
191
|
+
* WHAT COUNTS. A bare string must be SHAPED like a join blob (base64url of a
|
|
192
|
+
* JSON object, so `eyJ` and nothing outside the base64url alphabet); a bare
|
|
193
|
+
* invitation id does NOT count, because from a QR there is nothing to tell one
|
|
194
|
+
* from any other short string. In a LINK the parameter names itself, so both
|
|
195
|
+
* `?join=` and `?invite=` count — which is how a Node-hub deployment's
|
|
196
|
+
* invitation stays scannable.
|
|
197
|
+
*
|
|
198
|
+
* This is a shape check, not validation. The hub decides whether a code is
|
|
199
|
+
* real, current and unspent; all this does is tell "you scanned the wrong QR"
|
|
200
|
+
* apart from "the hub refused this code", which are different problems with
|
|
201
|
+
* different fixes.
|
|
202
|
+
*
|
|
203
|
+
* Lives here, not in `@statewalker/httpeers-qr`, because it is entirely about
|
|
204
|
+
* the JOIN format — and a QR package that knew the join format could not be
|
|
205
|
+
* used for anything else.
|
|
206
|
+
*/
|
|
207
|
+
export function invitationFromQrText(text: string): string | null {
|
|
208
|
+
const trimmed = text.trim();
|
|
209
|
+
if (trimmed === "") return null;
|
|
210
|
+
|
|
211
|
+
if (trimmed.includes("://") || trimmed.includes("?")) {
|
|
212
|
+
try {
|
|
213
|
+
const params = new URL(trimmed, "http://scan.invalid/").searchParams;
|
|
214
|
+
const blob = params.get(JOIN_BLOB_PARAM)?.trim();
|
|
215
|
+
if (blob != null && blob !== "") return blob;
|
|
216
|
+
const invite = params.get("invite")?.trim();
|
|
217
|
+
return invite != null && invite !== "" ? invite : null;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return looksLikeJoinBlob(trimmed) ? trimmed : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `eyJ` is `{"` in base64; 40 is well below a real blob and well above a stray word. */
|
|
227
|
+
function looksLikeJoinBlob(value: string): boolean {
|
|
228
|
+
return value.startsWith("eyJ") && /^[A-Za-z0-9_-]{40,}$/.test(value);
|
|
229
|
+
}
|