@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,39 @@
1
+ /**
2
+ * Everything a participant does: hold an identity, redeem or resume, connect,
3
+ * keep the link, advertise, discover, call, and serve.
4
+ *
5
+ * `startMember` is the seam rung 01 proved: one lifecycle, with the three
6
+ * things that genuinely differ between a Node process and a page injected as a
7
+ * `MemberPlatform`. `MemberHandle.fetch` IS the edge on both platforms;
8
+ * `baseUrl` is the browser-only part.
9
+ */
10
+ export { type ConnectionKind, classifyConnection, describeConnection } from "./connection-kind.js";
11
+ export { describeError } from "./describe-error.js";
12
+ export { createEdgeDispatch, type EdgeDispatchInit, PEER_ERROR_STATUS, type PeerErrorBody, stripEdgePrefix, targetPeerId, } from "./edge-dispatch.js";
13
+ /**
14
+ * The mesh as ordinary HTTP.
15
+ *
16
+ * `createGateway` turns a member into a `FetchHandler` dispatching
17
+ * `/{peerId}/{path}` — so a client with no peer object, no token and no
18
+ * knowledge of libp2p can `fetch()` a mesh resource. `GatewaySource` is
19
+ * structurally a subset of `MemberHandle`, so a member IS one.
20
+ */
21
+ export { createGateway, GATEWAY_MARKER, type GatewayInit, type GatewaySource } from "./gateway.js";
22
+ export { clearIdentity, decodeIdentity, encodeIdentity, IDENTITY_STORAGE_KEY, type IdentityStoreInit, loadOrCreateIdentity, peerIdOf, readIdentity, } from "./identity.js";
23
+ export { decodeJoinBlob, encodeJoinBlob, invitationFromQrText, JOIN_BLOB_PARAM, type JoinBlob, type JoinInput, joinUrl, readJoinInputFromSearch, readJoinInputFromText, } from "./join-blob.js";
24
+ export type { AsyncBytesBackend, AsyncKeyValueBackend } from "./kv.js";
25
+ export { parseMeshConfig } from "./mesh-config.js";
26
+ export { type CreateMeshMemoryInit, createMeshMemory, MESH_STORAGE_KEY, type MeshMemory, } from "./mesh-memory.js";
27
+ export { type PeerRequestInit, peerRequest } from "./peer-request.js";
28
+ /**
29
+ * The session machine — ISOMORPHIC, and exported from the root on purpose.
30
+ *
31
+ * It used to be the browser peer's private business. Everything platform-bound
32
+ * in it is now injected (the platform, the two stores, the reload), so a Node
33
+ * member that wants resume-or-redeem and the same four operator controls gets
34
+ * them here rather than reimplementing them. `./browser`'s `createSession` is
35
+ * the same thing with a page's defaults filled in.
36
+ */
37
+ export { createIdentityStore, createPeerSession, DEFAULT_HTTPEERS_CONFIG_URL, describeMeshDrift, type IdentityStore, type NeedsInvitationReason, type PeerSession, type PeerSessionInit, type SessionControls, type SessionPhase, type SessionStartState, type SessionState, type StartPeer, } from "./session.js";
38
+ export { type HubLink, type JoinMethod, type MemberEdge, type MemberHandle, MemberJoinError, type MemberPlatform, type MemberState, type StartMemberInit, startMember, } from "./start-member.js";
39
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,KAAK,cAAc,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AACnG,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,EACL,kBAAkB,EAClB,KAAK,gBAAgB,EACrB,iBAAiB,EACjB,KAAK,aAAa,EAClB,eAAe,EACf,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B;;;;;;;GAOG;AACH,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AACnG,OAAO,EACL,aAAa,EACb,cAAc,EACd,cAAc,EACd,oBAAoB,EACpB,KAAK,iBAAiB,EACtB,oBAAoB,EACpB,QAAQ,EACR,YAAY,GACb,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,cAAc,EACd,cAAc,EACd,oBAAoB,EACpB,eAAe,EACf,KAAK,QAAQ,EACb,KAAK,SAAS,EACd,OAAO,EACP,uBAAuB,EACvB,qBAAqB,GACtB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AACvE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EACL,KAAK,oBAAoB,EACzB,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,UAAU,GAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,KAAK,eAAe,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACtE;;;;;;;;GAQG;AACH,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,2BAA2B,EAC3B,iBAAiB,EACjB,KAAK,aAAa,EAClB,KAAK,qBAAqB,EAC1B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,eAAe,EACf,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,WAAW,GACZ,MAAM,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Everything a participant does: hold an identity, redeem or resume, connect,
3
+ * keep the link, advertise, discover, call, and serve.
4
+ *
5
+ * `startMember` is the seam rung 01 proved: one lifecycle, with the three
6
+ * things that genuinely differ between a Node process and a page injected as a
7
+ * `MemberPlatform`. `MemberHandle.fetch` IS the edge on both platforms;
8
+ * `baseUrl` is the browser-only part.
9
+ */
10
+ export { classifyConnection, describeConnection } from "./connection-kind.js";
11
+ export { describeError } from "./describe-error.js";
12
+ export { createEdgeDispatch, PEER_ERROR_STATUS, stripEdgePrefix, targetPeerId, } from "./edge-dispatch.js";
13
+ /**
14
+ * The mesh as ordinary HTTP.
15
+ *
16
+ * `createGateway` turns a member into a `FetchHandler` dispatching
17
+ * `/{peerId}/{path}` — so a client with no peer object, no token and no
18
+ * knowledge of libp2p can `fetch()` a mesh resource. `GatewaySource` is
19
+ * structurally a subset of `MemberHandle`, so a member IS one.
20
+ */
21
+ export { createGateway, GATEWAY_MARKER } from "./gateway.js";
22
+ export { clearIdentity, decodeIdentity, encodeIdentity, IDENTITY_STORAGE_KEY, loadOrCreateIdentity, peerIdOf, readIdentity, } from "./identity.js";
23
+ export { decodeJoinBlob, encodeJoinBlob, invitationFromQrText, JOIN_BLOB_PARAM, joinUrl, readJoinInputFromSearch, readJoinInputFromText, } from "./join-blob.js";
24
+ export { parseMeshConfig } from "./mesh-config.js";
25
+ export { createMeshMemory, MESH_STORAGE_KEY, } from "./mesh-memory.js";
26
+ export { peerRequest } from "./peer-request.js";
27
+ /**
28
+ * The session machine — ISOMORPHIC, and exported from the root on purpose.
29
+ *
30
+ * It used to be the browser peer's private business. Everything platform-bound
31
+ * in it is now injected (the platform, the two stores, the reload), so a Node
32
+ * member that wants resume-or-redeem and the same four operator controls gets
33
+ * them here rather than reimplementing them. `./browser`'s `createSession` is
34
+ * the same thing with a page's defaults filled in.
35
+ */
36
+ export { createIdentityStore, createPeerSession, DEFAULT_HTTPEERS_CONFIG_URL, describeMeshDrift, } from "./session.js";
37
+ export { MemberJoinError, startMember, } from "./start-member.js";
@@ -0,0 +1,125 @@
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
+ /** The invitation payload's shape (`../setup/main.ts`'s `HttpeersConfig`) plus the one-shot invitation id that goes with it. */
37
+ export interface JoinBlob {
38
+ relayAddrs: string[];
39
+ hubPeerId: string;
40
+ invitationId: string;
41
+ }
42
+ /** The query parameter a join blob travels in. */
43
+ export declare const JOIN_BLOB_PARAM = "join";
44
+ /**
45
+ * Encode a blob. UTF-8 first, THEN base64: `btoa` throws on any code point
46
+ * above U+00FF, and while nothing in a blob is human text today (multiaddrs
47
+ * and peer ids are ASCII), a relay address is operator-supplied
48
+ * (`RELAY_HOST`) and a hostname can be an IDN.
49
+ */
50
+ export declare function encodeJoinBlob(blob: JoinBlob): string;
51
+ /**
52
+ * Decode a blob, validating every field rather than trusting the shape.
53
+ * Throws with a message naming what was wrong: this value was typed or
54
+ * pasted by a person, so a malformed one is an ordinary event, and "the
55
+ * link you pasted is missing its hub peer id" is a fixable complaint where
56
+ * `undefined is not an object` is not.
57
+ */
58
+ export declare function decodeJoinBlob(text: string): JoinBlob;
59
+ /** The join URL an operator copies: `pageOrigin` with this blob attached. `pageUrl` is a full URL (e.g. `http://127.0.0.1:5175/`). */
60
+ export declare function joinUrl(pageUrl: string, blob: JoinBlob): string;
61
+ /**
62
+ * What a page found in its own URL, or in what someone pasted into its
63
+ * join form.
64
+ *
65
+ * TWO CASES, AND THE DIFFERENCE IS WHICH MESH. A `join` blob carries its
66
+ * own `relayAddrs`/`hubPeerId`, so the page joins the mesh the blob names
67
+ * -- a hub page's. A bare `invite` id carries neither, so the page falls
68
+ * back to `httpeers.json` and joins the mesh the DEPLOYMENT names -- the
69
+ * Node hub's. Both are supported deliberately: the Node hub is not going
70
+ * anywhere (Task 24's first decision), and a page that silently preferred
71
+ * one would make the other unreachable with no way to say so.
72
+ */
73
+ export type JoinInput = {
74
+ kind: "blob";
75
+ blob: JoinBlob;
76
+ } | {
77
+ kind: "invitation-id";
78
+ invitationId: string;
79
+ };
80
+ /**
81
+ * Read a join input out of a page's `location.search`. Returns `null` when
82
+ * neither parameter is present -- the page then shows its paste-in form.
83
+ */
84
+ export declare function readJoinInputFromSearch(search: string): JoinInput | null;
85
+ /**
86
+ * Read a join input out of text a person pasted into a page's join form --
87
+ * a whole join URL, a bare blob, or a bare invitation id.
88
+ *
89
+ * ACCEPTS A WHOLE URL BECAUSE THAT IS WHAT GETS COPIED. The hub page's
90
+ * panel renders a complete link; an operator who copies it and pastes it
91
+ * into the form (rather than the address bar) has done nothing wrong, and a
92
+ * form that rejected it would be the page being pedantic about a
93
+ * distinction only it can see.
94
+ */
95
+ export declare function readJoinInputFromText(text: string): JoinInput | null;
96
+ /**
97
+ * The invitation inside a QR code's text, or `null` — a PREDICATE, not a
98
+ * parser.
99
+ *
100
+ * WHY IT NEVER THROWS, AND WHY IT IS STRICTER THAN `readJoinInputFromText`. A
101
+ * live scanner sees a poster, a wifi sticker and a payment code before it sees
102
+ * an invitation, and it has to keep scanning through all of them. So every
103
+ * "no" here is the same quiet `null`: a scan that stopped on the first wrong
104
+ * QR, or threw on a malformed URL, would be worse than one that carries on
105
+ * looking. `readJoinInputFromText` is the opposite and rightly so — somebody
106
+ * PASTED that, so they meant it, and a complaint is what they need.
107
+ *
108
+ * WHAT COUNTS. A bare string must be SHAPED like a join blob (base64url of a
109
+ * JSON object, so `eyJ` and nothing outside the base64url alphabet); a bare
110
+ * invitation id does NOT count, because from a QR there is nothing to tell one
111
+ * from any other short string. In a LINK the parameter names itself, so both
112
+ * `?join=` and `?invite=` count — which is how a Node-hub deployment's
113
+ * invitation stays scannable.
114
+ *
115
+ * This is a shape check, not validation. The hub decides whether a code is
116
+ * real, current and unspent; all this does is tell "you scanned the wrong QR"
117
+ * apart from "the hub refused this code", which are different problems with
118
+ * different fixes.
119
+ *
120
+ * Lives here, not in `@statewalker/httpeers-qr`, because it is entirely about
121
+ * the JOIN format — and a QR package that knew the join format could not be
122
+ * used for anything else.
123
+ */
124
+ export declare function invitationFromQrText(text: string): string | null;
125
+ //# sourceMappingURL=join-blob.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"join-blob.d.ts","sourceRoot":"","sources":["../src/join-blob.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,gIAAgI;AAChI,MAAM,WAAW,QAAQ;IACvB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,kDAAkD;AAClD,eAAO,MAAM,eAAe,SAAS,CAAC;AAkBtC;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAErD;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAwBrD;AAED,sIAAsI;AACtI,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,CAI/D;AAED;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,SAAS,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAWxE;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAqBpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBhE"}
@@ -0,0 +1,195 @@
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
+ /** The query parameter a join blob travels in. */
37
+ export const JOIN_BLOB_PARAM = "join";
38
+ function toBase64Url(bytes) {
39
+ let binary = "";
40
+ for (const byte of bytes)
41
+ binary += String.fromCharCode(byte);
42
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
43
+ }
44
+ function fromBase64Url(text) {
45
+ const base64 = text.replaceAll("-", "+").replaceAll("_", "/");
46
+ // `atob` requires the padding `toBase64Url` stripped.
47
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
48
+ const binary = atob(padded);
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let i = 0; i < binary.length; i++)
51
+ bytes[i] = binary.charCodeAt(i);
52
+ return bytes;
53
+ }
54
+ /**
55
+ * Encode a blob. UTF-8 first, THEN base64: `btoa` throws on any code point
56
+ * above U+00FF, and while nothing in a blob is human text today (multiaddrs
57
+ * and peer ids are ASCII), a relay address is operator-supplied
58
+ * (`RELAY_HOST`) and a hostname can be an IDN.
59
+ */
60
+ export function encodeJoinBlob(blob) {
61
+ return toBase64Url(new TextEncoder().encode(JSON.stringify(blob)));
62
+ }
63
+ /**
64
+ * Decode a blob, validating every field rather than trusting the shape.
65
+ * Throws with a message naming what was wrong: this value was typed or
66
+ * pasted by a person, so a malformed one is an ordinary event, and "the
67
+ * link you pasted is missing its hub peer id" is a fixable complaint where
68
+ * `undefined is not an object` is not.
69
+ */
70
+ export function decodeJoinBlob(text) {
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(new TextDecoder().decode(fromBase64Url(text.trim())));
74
+ }
75
+ catch (err) {
76
+ throw new Error("join blob: this is not a join link from a hub page -- it did not decode. Copy the whole " +
77
+ `link, including the "?${JOIN_BLOB_PARAM}=" part. Cause: ${String(err)}`, { cause: err });
78
+ }
79
+ const blob = parsed;
80
+ const relayAddrs = blob.relayAddrs;
81
+ if (!Array.isArray(relayAddrs) || relayAddrs.length === 0) {
82
+ throw new Error("join blob: no relayAddrs -- there is nothing to dial.");
83
+ }
84
+ if (typeof blob.hubPeerId !== "string" || blob.hubPeerId === "") {
85
+ throw new Error("join blob: no hubPeerId -- this page would not know which mesh to join.");
86
+ }
87
+ if (typeof blob.invitationId !== "string" || blob.invitationId === "") {
88
+ throw new Error("join blob: no invitationId -- this page would have nothing to redeem.");
89
+ }
90
+ return { relayAddrs, hubPeerId: blob.hubPeerId, invitationId: blob.invitationId };
91
+ }
92
+ /** The join URL an operator copies: `pageOrigin` with this blob attached. `pageUrl` is a full URL (e.g. `http://127.0.0.1:5175/`). */
93
+ export function joinUrl(pageUrl, blob) {
94
+ const url = new URL(pageUrl);
95
+ url.searchParams.set(JOIN_BLOB_PARAM, encodeJoinBlob(blob));
96
+ return url.toString();
97
+ }
98
+ /**
99
+ * Read a join input out of a page's `location.search`. Returns `null` when
100
+ * neither parameter is present -- the page then shows its paste-in form.
101
+ */
102
+ export function readJoinInputFromSearch(search) {
103
+ const params = new URLSearchParams(search);
104
+ const blobParam = params.get(JOIN_BLOB_PARAM);
105
+ if (blobParam != null && blobParam.trim() !== "") {
106
+ return { kind: "blob", blob: decodeJoinBlob(blobParam) };
107
+ }
108
+ const invite = params.get("invite");
109
+ if (invite != null && invite.trim() !== "") {
110
+ return { kind: "invitation-id", invitationId: invite.trim() };
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * Read a join input out of text a person pasted into a page's join form --
116
+ * a whole join URL, a bare blob, or a bare invitation id.
117
+ *
118
+ * ACCEPTS A WHOLE URL BECAUSE THAT IS WHAT GETS COPIED. The hub page's
119
+ * panel renders a complete link; an operator who copies it and pastes it
120
+ * into the form (rather than the address bar) has done nothing wrong, and a
121
+ * form that rejected it would be the page being pedantic about a
122
+ * distinction only it can see.
123
+ */
124
+ export function readJoinInputFromText(text) {
125
+ const trimmed = text.trim();
126
+ if (trimmed === "")
127
+ return null;
128
+ if (trimmed.includes("://") || trimmed.includes(`${JOIN_BLOB_PARAM}=`)) {
129
+ // A URL, or at least something with a query string in it. `new URL`
130
+ // needs a base for the second case; the base is never used for anything
131
+ // but parsing, and the blob inside carries the real addresses.
132
+ const url = new URL(trimmed, "http://join.invalid/");
133
+ const found = readJoinInputFromSearch(url.search);
134
+ if (found != null)
135
+ return found;
136
+ throw new Error(`join: that link carries no "${JOIN_BLOB_PARAM}" or "invite" parameter -- is it the whole link?`);
137
+ }
138
+ // A bare value. A blob is base64url of a JSON object, which always starts
139
+ // `{"` -> `eyJ`; an invitation id is a UUID (`../hub/main.ts`'s
140
+ // `randomUUID`) and never does.
141
+ if (trimmed.startsWith("eyJ"))
142
+ return { kind: "blob", blob: decodeJoinBlob(trimmed) };
143
+ return { kind: "invitation-id", invitationId: trimmed };
144
+ }
145
+ /**
146
+ * The invitation inside a QR code's text, or `null` — a PREDICATE, not a
147
+ * parser.
148
+ *
149
+ * WHY IT NEVER THROWS, AND WHY IT IS STRICTER THAN `readJoinInputFromText`. A
150
+ * live scanner sees a poster, a wifi sticker and a payment code before it sees
151
+ * an invitation, and it has to keep scanning through all of them. So every
152
+ * "no" here is the same quiet `null`: a scan that stopped on the first wrong
153
+ * QR, or threw on a malformed URL, would be worse than one that carries on
154
+ * looking. `readJoinInputFromText` is the opposite and rightly so — somebody
155
+ * PASTED that, so they meant it, and a complaint is what they need.
156
+ *
157
+ * WHAT COUNTS. A bare string must be SHAPED like a join blob (base64url of a
158
+ * JSON object, so `eyJ` and nothing outside the base64url alphabet); a bare
159
+ * invitation id does NOT count, because from a QR there is nothing to tell one
160
+ * from any other short string. In a LINK the parameter names itself, so both
161
+ * `?join=` and `?invite=` count — which is how a Node-hub deployment's
162
+ * invitation stays scannable.
163
+ *
164
+ * This is a shape check, not validation. The hub decides whether a code is
165
+ * real, current and unspent; all this does is tell "you scanned the wrong QR"
166
+ * apart from "the hub refused this code", which are different problems with
167
+ * different fixes.
168
+ *
169
+ * Lives here, not in `@statewalker/httpeers-qr`, because it is entirely about
170
+ * the JOIN format — and a QR package that knew the join format could not be
171
+ * used for anything else.
172
+ */
173
+ export function invitationFromQrText(text) {
174
+ const trimmed = text.trim();
175
+ if (trimmed === "")
176
+ return null;
177
+ if (trimmed.includes("://") || trimmed.includes("?")) {
178
+ try {
179
+ const params = new URL(trimmed, "http://scan.invalid/").searchParams;
180
+ const blob = params.get(JOIN_BLOB_PARAM)?.trim();
181
+ if (blob != null && blob !== "")
182
+ return blob;
183
+ const invite = params.get("invite")?.trim();
184
+ return invite != null && invite !== "" ? invite : null;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ return looksLikeJoinBlob(trimmed) ? trimmed : null;
191
+ }
192
+ /** `eyJ` is `{"` in base64; 40 is well below a real blob and well above a stray word. */
193
+ function looksLikeJoinBlob(value) {
194
+ return value.startsWith("eyJ") && /^[A-Za-z0-9_-]{40,}$/.test(value);
195
+ }