@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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/browser-platform.d.ts +30 -0
  4. package/dist/browser-platform.d.ts.map +1 -0
  5. package/dist/browser-platform.js +30 -0
  6. package/dist/browser-profile.d.ts +85 -0
  7. package/dist/browser-profile.d.ts.map +1 -0
  8. package/dist/browser-profile.js +120 -0
  9. package/dist/browser.d.ts +48 -0
  10. package/dist/browser.d.ts.map +1 -0
  11. package/dist/browser.js +51 -0
  12. package/dist/connection-kind.d.ts +36 -0
  13. package/dist/connection-kind.d.ts.map +1 -0
  14. package/dist/connection-kind.js +52 -0
  15. package/dist/describe-error.d.ts +18 -0
  16. package/dist/describe-error.d.ts.map +1 -0
  17. package/dist/describe-error.js +72 -0
  18. package/dist/edge-control.d.ts +50 -0
  19. package/dist/edge-control.d.ts.map +1 -0
  20. package/dist/edge-control.js +69 -0
  21. package/dist/edge-dispatch.d.ts +188 -0
  22. package/dist/edge-dispatch.d.ts.map +1 -0
  23. package/dist/edge-dispatch.js +142 -0
  24. package/dist/edge-guard.d.ts +45 -0
  25. package/dist/edge-guard.d.ts.map +1 -0
  26. package/dist/edge-guard.js +62 -0
  27. package/dist/edge.d.ts +97 -0
  28. package/dist/edge.d.ts.map +1 -0
  29. package/dist/edge.js +104 -0
  30. package/dist/gateway.d.ts +49 -0
  31. package/dist/gateway.d.ts.map +1 -0
  32. package/dist/gateway.js +102 -0
  33. package/dist/identity.d.ts +78 -0
  34. package/dist/identity.d.ts.map +1 -0
  35. package/dist/identity.js +117 -0
  36. package/dist/index.d.ts +39 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +37 -0
  39. package/dist/join-blob.d.ts +125 -0
  40. package/dist/join-blob.d.ts.map +1 -0
  41. package/dist/join-blob.js +195 -0
  42. package/dist/join.d.ts +343 -0
  43. package/dist/join.d.ts.map +1 -0
  44. package/dist/join.js +409 -0
  45. package/dist/kv.d.ts +20 -0
  46. package/dist/kv.d.ts.map +1 -0
  47. package/dist/kv.js +10 -0
  48. package/dist/mesh-config.d.ts +26 -0
  49. package/dist/mesh-config.d.ts.map +1 -0
  50. package/dist/mesh-config.js +39 -0
  51. package/dist/mesh-memory.d.ts +55 -0
  52. package/dist/mesh-memory.d.ts.map +1 -0
  53. package/dist/mesh-memory.js +74 -0
  54. package/dist/node.d.ts +29 -0
  55. package/dist/node.d.ts.map +1 -0
  56. package/dist/node.js +42 -0
  57. package/dist/page-wake.d.ts +24 -0
  58. package/dist/page-wake.d.ts.map +1 -0
  59. package/dist/page-wake.js +31 -0
  60. package/dist/peer-request.d.ts +29 -0
  61. package/dist/peer-request.d.ts.map +1 -0
  62. package/dist/peer-request.js +11 -0
  63. package/dist/reset.d.ts +56 -0
  64. package/dist/reset.d.ts.map +1 -0
  65. package/dist/reset.js +103 -0
  66. package/dist/session.d.ts +214 -0
  67. package/dist/session.d.ts.map +1 -0
  68. package/dist/session.js +403 -0
  69. package/dist/start-member.d.ts +176 -0
  70. package/dist/start-member.d.ts.map +1 -0
  71. package/dist/start-member.js +307 -0
  72. package/package.json +120 -0
  73. package/src/browser-platform.ts +53 -0
  74. package/src/browser-profile.ts +179 -0
  75. package/src/browser.ts +103 -0
  76. package/src/connection-kind.ts +53 -0
  77. package/src/describe-error.ts +76 -0
  78. package/src/edge-control.ts +94 -0
  79. package/src/edge-dispatch.ts +295 -0
  80. package/src/edge-guard.ts +67 -0
  81. package/src/edge.ts +175 -0
  82. package/src/gateway.ts +141 -0
  83. package/src/identity.ts +146 -0
  84. package/src/index.ts +95 -0
  85. package/src/join-blob.ts +229 -0
  86. package/src/join.ts +632 -0
  87. package/src/kv.ts +21 -0
  88. package/src/mesh-config.ts +41 -0
  89. package/src/mesh-memory.ts +104 -0
  90. package/src/node.ts +49 -0
  91. package/src/page-wake.ts +59 -0
  92. package/src/peer-request.ts +39 -0
  93. package/src/reset.ts +127 -0
  94. package/src/session.ts +579 -0
  95. package/src/start-member.ts +476 -0
@@ -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
+ }