@yz-social/civildefense.io 4.4.1

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 (77) hide show
  1. package/README.md +68 -0
  2. package/announce.js +24 -0
  3. package/docs/YZ-Brief.pdf +0 -0
  4. package/index.js +2 -0
  5. package/movie/camera.jpg +0 -0
  6. package/movie/movie.js +151 -0
  7. package/movie/script.js +272 -0
  8. package/movie/test.js +2 -0
  9. package/nginx/nginx.conf +83 -0
  10. package/nginx/yz.social +81 -0
  11. package/package.json +34 -0
  12. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -0
  13. package/public/about/CivilDefense.mp4 +0 -0
  14. package/public/about/In case of Nazis, use CivilDefense.io.png +0 -0
  15. package/public/about/broadcast.png +0 -0
  16. package/public/about/civil-defense.png +0 -0
  17. package/public/about/conversation-and-image-high.png +0 -0
  18. package/public/about/conversation-and-image.png +0 -0
  19. package/public/about/en.html +157 -0
  20. package/public/about/es.html +142 -0
  21. package/public/about/flood-fire-ice-high.png +0 -0
  22. package/public/about/flood-fire-ice.png +0 -0
  23. package/public/about/hero-high.png +0 -0
  24. package/public/about/hero.png +0 -0
  25. package/public/about/index.html +8 -0
  26. package/public/about/nazis.png +0 -0
  27. package/public/about/script.js +69 -0
  28. package/public/about/streaming-radio-high.png +0 -0
  29. package/public/about/streaming-radio.png +0 -0
  30. package/public/about/style.css +306 -0
  31. package/public/control-safe-rectangle.html +40 -0
  32. package/public/favicon.ico +0 -0
  33. package/public/images/Achtung.png +0 -0
  34. package/public/images/YZ Owl.png +0 -0
  35. package/public/images/civil-defense-122.png +0 -0
  36. package/public/images/civil-defense-192.png +0 -0
  37. package/public/images/civil-defense-240.png +0 -0
  38. package/public/images/civil-defense-512.png +0 -0
  39. package/public/images/civil-defense.png +0 -0
  40. package/public/images/hero-small.png +0 -0
  41. package/public/images/qr-scan.svg +2 -0
  42. package/public/images/qr.png +0 -0
  43. package/public/images/qr.svg +155 -0
  44. package/public/images/recenter.svg +5 -0
  45. package/public/images/share.png +0 -0
  46. package/public/images/share.svg +2 -0
  47. package/public/index.html +170 -0
  48. package/public/javascripts/agent.js +324 -0
  49. package/public/javascripts/display.js +25 -0
  50. package/public/javascripts/hashtags.js +205 -0
  51. package/public/javascripts/main.js +394 -0
  52. package/public/javascripts/map.js +725 -0
  53. package/public/javascripts/p2pWebNetwork.js +245 -0
  54. package/public/javascripts/s2.js +77 -0
  55. package/public/javascripts/scripting.js +112 -0
  56. package/public/javascripts/service-manager.js +149 -0
  57. package/public/javascripts/translations.js +139 -0
  58. package/public/javascripts/versions.js +23 -0
  59. package/public/manifest.json +25 -0
  60. package/public/owl.ico +0 -0
  61. package/public/platformer.html +52 -0
  62. package/public/robots.txt +6 -0
  63. package/public/service-worker.js +218 -0
  64. package/public/stylesheets/style.css +442 -0
  65. package/routes/index.js +123 -0
  66. package/server/app.js +116 -0
  67. package/server/bridge.js +397 -0
  68. package/server/dirname.js +15 -0
  69. package/server/getLocation.js +18 -0
  70. package/server/identity.js +111 -0
  71. package/server/location.json +12 -0
  72. package/spec/axonSpec.gratuitousNameChangeForSignal +246 -0
  73. package/spec/axonSpec.js +280 -0
  74. package/spec/axonSpec.jsRemoveThePartAfterJS +252 -0
  75. package/spec/civildefenseSpec.js +61 -0
  76. package/spec/pubsubSpec.js +128 -0
  77. package/spec/support/jasmine.mjs +14 -0
@@ -0,0 +1,397 @@
1
+ const { BigInt } = globalThis; // for linters
2
+ import { WebSocketServer } from 'ws';
3
+ import NodeTurn from 'node-turn';
4
+ import { KERNEL_VERSION, makeNonce } from '@axona/protocol';
5
+ import { appVersion } from '../public/javascripts/versions.js';
6
+ import { BridgeAxonaNode } from './bridge_axona_node.js'; // FIXME
7
+
8
+ // TODO: Clean up and simplify.
9
+ // TODO: There's some backward compatible cruft that we do not need. Kill it.
10
+
11
+ export async function bridge({server,
12
+ log = console.info, logErr = console.error, logDebug = console.log,
13
+ VERSION = appVersion,
14
+ MIN_PEER_VERSION = KERNEL_VERSION, // FIXME
15
+ HELLO_TIMEOUT_MS = 5e3,
16
+ }) {
17
+
18
+ const CLOSE_UPGRADE_REQUIRED = 4426; // mirrors HTTP 426 "Upgrade Required"
19
+ const externalIp = '99.122.55.96';
20
+ const internalIp = '192.168.1.145';
21
+ const turnServer = new NodeTurn({
22
+ externalIps: [internalIp], //fixme [externalIp],
23
+ //minPort: 51021, maxPort: 61000, // Avoiding conflicts on the AT&T BRG320 Gateway
24
+ authMech: 'none',
25
+ // authMech: 'long-term',
26
+ // realm: 'yz',
27
+ // credentials: {dummy: 'junk'},
28
+ debugLevel: 'debug'//fixme 'info'
29
+ });
30
+ turnServer.start();
31
+
32
+ const TURN_URLS = `turn:localhost:3478?transport=udp`;
33
+ function makeTurnCredential(peerId) {
34
+ //if (!TURN_AUTH_SECRET) return null;
35
+ // const expiry = Math.floor(Date.now() / 1000) + TURN_TTL_SECONDS;
36
+ const username = 'dummy'; //fixme `${expiry}:${peerId}`;
37
+ const credential = 'junk'; //fixme
38
+ // const credential = crypto
39
+ // .createHmac('sha1', TURN_AUTH_SECRET)
40
+ // .update(username)
41
+ // .digest('base64');
42
+ return { urls: TURN_URLS, username, credential/*, ttlSeconds: TURN_TTL_SECONDS*/ };
43
+ }
44
+
45
+ // FIXME
46
+ function flagDayFloor(version) {
47
+ return { floor: MIN_PEER_VERSION, ns: 'kernel' };
48
+ }
49
+ /** Three-component numeric semver compare; returns true iff a >= b. */
50
+ function gteVersion(a, b) {
51
+ const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
52
+ const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
53
+ for (let i = 0; i < 3; i++) {
54
+ const ai = pa[i] ?? 0, bi = pb[i] ?? 0;
55
+ if (ai > bi) return true;
56
+ if (ai < bi) return false;
57
+ }
58
+ return true;
59
+ }
60
+
61
+ // BigInt-aware JSON: the Axona wire protocol uses BigInt node IDs
62
+ // throughout (path[], queried set, fromId, etc.). Native
63
+ // JSON.stringify throws on BigInts; we use a replacer that emits
64
+ // "<digits>n" suffixed strings, mirrored by an inverse reviver on
65
+ // the receive side. This is a transitional convention while the
66
+ // canonical hex-encoding from the wire spec is rolled out across
67
+ // every field.
68
+ function bigintReplacer(_key, value) {
69
+ if (typeof value === 'bigint') return value.toString() + 'n';
70
+ if (value instanceof Set) return [...value];
71
+ return value;
72
+ }
73
+
74
+ function bigintReviver(_key, value) {
75
+ if (typeof value === 'string' && /^-?\d+n$/.test(value)) {
76
+ return BigInt(value.slice(0, -1));
77
+ }
78
+ return value;
79
+ }
80
+
81
+ // The bridge runs its own AxonaPeer as a server-class highway node.
82
+ // Its WebSocket transport piggybacks on the existing browser-bridge
83
+ // WebSocket connections; no node-webrtc dependency. See
84
+ // `bridge_axona_node.js` and `ws_transport.js` for the wire shape.
85
+ const bridgeNode = new BridgeAxonaNode({
86
+ sendToConn: (connId, msg) => sendTo(connId, msg),
87
+ isConnOpen: (connId) => connections.has(connId),
88
+ // axona/4 — close a connection with the Upgrade-Required code when its
89
+ // peer can't complete the authenticated handshake (e.g. it speaks the
90
+ // legacy axona/3 hello). This is the clean, proto-level upgrade
91
+ // signal: the peer's kernel prints "[axona] UPGRADE REQUIRED …" on a
92
+ // 4426 close. (The WS-level version gate can't separate v3 from v4
93
+ // because the peer app version is already numerically above any kernel
94
+ // threshold; the proto at the hello layer is the real boundary.)
95
+ closeConn: (connId, reason) => {
96
+ const conn = connections.get(connId);
97
+ if (conn?.ws) { try { conn.ws.close(CLOSE_UPGRADE_REQUIRED, reason); } catch { /* dying */ } }
98
+ },
99
+ log: (event, detail) => logDebug(`axona:${event}`, detail),
100
+ });
101
+ await bridgeNode.start();
102
+ log('axona-ready', {
103
+ nodeId: bridgeNode.identity.id,
104
+ region: bridgeNode.identity.region.label,
105
+ });
106
+
107
+ /** @type {Map<string, {ws: any, ip: string, since: number, lastSeenAt: number, pings: number, pongs: number, signalsRelayed: number, ua: string}>} */
108
+ const connections = new Map();
109
+ function sendTo(peerId, msg) {
110
+ const conn = connections.get(peerId);
111
+ if (!conn) return false;
112
+ try {
113
+ conn.ws.send(JSON.stringify(msg, bigintReplacer));
114
+ return true;
115
+ } catch (err) {
116
+ logErr('send-failed', { connId: peerId, type: msg.type, err: err.message });
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /** Broadcast to every peer except `exceptId` (typically the originator).
122
+ * Skips connections that haven't completed the client-hello version
123
+ * check — they should never appear in peer-list or get peer-joined
124
+ * notifications. */
125
+ function broadcast(msg, exceptId = null) {
126
+ let count = 0;
127
+ for (const [id, conn] of connections) {
128
+ if (id === exceptId) continue;
129
+ if (!conn.admitted) continue;
130
+ try {
131
+ conn.ws.send(JSON.stringify(msg, bigintReplacer));
132
+ count++;
133
+ } catch (err) {
134
+ logErr('broadcast-send-failed', { connId: id, type: msg.type, err: err.message });
135
+ }
136
+ }
137
+ return count;
138
+ }
139
+
140
+ let connSeq = 0;
141
+ const wss = new WebSocketServer({server });
142
+
143
+ wss.on('connection', (ws, req) => {
144
+ const id = `c${(++connSeq).toString(36)}`;
145
+ const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
146
+ ?? req.socket.remoteAddress
147
+ ?? 'unknown';
148
+ const ua = req.headers['user-agent'] ?? '';
149
+ const since = Date.now();
150
+
151
+ // Snapshot the *existing* peer set BEFORE adding the new one — this
152
+ // is what we'll send back as `peer-list`. If we registered first,
153
+ // the new peer would see itself in its own list.
154
+ const existingPeers = [...connections.keys()];
155
+
156
+ const conn = {
157
+ ws, ip, since,
158
+ lastSeenAt: since,
159
+ pings: 0, pongs: 0, signalsRelayed: 0,
160
+ ua,
161
+ admitted: false, // flipped to true after client-hello version check
162
+ helloTimer: null,
163
+ peerVersion: null,
164
+ };
165
+ connections.set(id, conn);
166
+
167
+ log('connect', { connId: id, ip, total: connections.size, ua: ua.slice(0, 80) });
168
+
169
+ // 1. Tell the peer the version gate exists *before* we close. This
170
+ // isn't a `welcome` — peer-list / peer-joined / hello are
171
+ // deferred until client-hello passes. The peer side sends
172
+ // 'client-hello' immediately after open; if nothing arrives
173
+ // within HELLO_TIMEOUT_MS, we close with the upgrade-required
174
+ // code so old clients trying to ride through get an obvious
175
+ // failure mode instead of a silent ghost connection.
176
+ sendTo(id, {
177
+ type: 'version-gate',
178
+ minPeerVersion: MIN_PEER_VERSION,
179
+ serverT: Date.now(),
180
+ });
181
+
182
+ conn.helloTimer = setTimeout(() => {
183
+ if (conn.admitted) return;
184
+ logErr('client-hello-timeout', { connId: id, ms: HELLO_TIMEOUT_MS });
185
+ try {
186
+ ws.close(CLOSE_UPGRADE_REQUIRED,
187
+ `client-hello not received within ${HELLO_TIMEOUT_MS}ms; ` +
188
+ `min peer v${MIN_PEER_VERSION} required`);
189
+ } catch {}
190
+ }, HELLO_TIMEOUT_MS);
191
+
192
+ function admitConnection() {
193
+ clearTimeout(conn.helloTimer);
194
+ conn.helloTimer = null;
195
+ conn.admitted = true;
196
+
197
+ // 1a. Mint a short-lived TURN credential (2h expiry) bundled
198
+ // into welcome so peer JS never sees a long-term secret.
199
+ const turn = makeTurnCredential(id);
200
+ // axona/4 — mint a fresh per-connection nonce; it (with the connId)
201
+ // is the channel-binding value the peer folds into its signed hello,
202
+ // and we fold into ours. A hello captured on one connection can't
203
+ // be replayed onto another.
204
+ const serverNonce = makeNonce();
205
+ conn.serverNonce = serverNonce;
206
+ sendTo(id, {
207
+ type: 'welcome',
208
+ connId: id,
209
+ serverT: Date.now(),
210
+ version: VERSION,
211
+ kernelVersion: KERNEL_VERSION,
212
+ serverNonce,
213
+ turn,
214
+ });
215
+
216
+ // 2. Tell the new peer about everyone ALREADY admitted.
217
+ const admittedPeers = [];
218
+ for (const [otherId, otherConn] of connections) {
219
+ if (otherId === id) continue;
220
+ if (!otherConn.admitted) continue;
221
+ admittedPeers.push(otherId);
222
+ }
223
+ sendTo(id, { type: 'peer-list', peers: admittedPeers, serverT: Date.now() });
224
+
225
+ // 3. Tell existing admitted peers that someone new arrived.
226
+ const announcedTo = broadcast(
227
+ { type: 'peer-joined', peerId: id, serverT: Date.now() },
228
+ id,
229
+ );
230
+ log('peer-announce', { connId: id, peers: admittedPeers.length, announcedTo });
231
+
232
+ // 4. Axona bootstrap-offer. The peer's BridgeTransport replies
233
+ // with an authenticated hello-ack proving its nodeId; that's
234
+ // when our bridge node admits this browser into its synaptome.
235
+ bridgeNode.sendHello(id, serverNonce);
236
+ }
237
+
238
+ ws.on('message', (data, isBinary) => {
239
+ // Any inbound bytes — even a malformed payload — count as proof
240
+ // the peer is still alive. Stamping liveness before parse means
241
+ // a peer that briefly sends junk doesn't get kicked for being
242
+ // idle on top of that.
243
+ conn.lastSeenAt = Date.now();
244
+
245
+ if (isBinary) {
246
+ logDebug('binary-dropped', { connId: id, bytes: data.length });
247
+ return;
248
+ }
249
+ let msg;
250
+ try {
251
+ msg = JSON.parse(data.toString(), bigintReviver);
252
+ } catch (err) {
253
+ logErr('bad-json', { connId: id, err: err.message });
254
+ return;
255
+ }
256
+
257
+ // Version gate. Before client-hello passes, the ONLY message we
258
+ // accept is client-hello itself. Everything else (ping, signal,
259
+ // axona, etc.) is silently dropped so we don't leak state — and
260
+ // never relay axona-protocol frames from un-validated peers.
261
+ if (!conn.admitted) {
262
+ if (msg.type !== 'client-hello') {
263
+ logDebug('pre-hello-message-dropped', { connId: id, type: msg.type });
264
+ return;
265
+ }
266
+ const peerVersion = typeof msg.version === 'string' ? msg.version : null;
267
+ conn.peerVersion = peerVersion;
268
+ if (!peerVersion) {
269
+ logErr('client-hello-missing-version', { connId: id });
270
+ try {
271
+ ws.close(CLOSE_UPGRADE_REQUIRED,
272
+ `client-hello must include 'version' (min v${MIN_PEER_VERSION})`);
273
+ } catch {}
274
+ return;
275
+ }
276
+ // Two-stage gate: the absolute floor (MIN_PEER_VERSION) AND the
277
+ // namespace-aware flag-day floor for the v2.9.0 envelope (C-2/E-4).
278
+ // Report whichever bound actually binds (the higher of the two), so
279
+ // the close reason names the version the client must reach.
280
+ const { floor, ns } = flagDayFloor(peerVersion);
281
+ const effectiveMin = gteVersion(floor, MIN_PEER_VERSION) ? floor : MIN_PEER_VERSION;
282
+ if (!gteVersion(peerVersion, MIN_PEER_VERSION) || !gteVersion(peerVersion, floor)) {
283
+ logErr('client-hello-too-old', {
284
+ connId: id, peerVersion, ns, floor, minPeerVersion: MIN_PEER_VERSION, effectiveMin,
285
+ });
286
+ try {
287
+ ws.close(CLOSE_UPGRADE_REQUIRED,
288
+ `peer v${peerVersion} below minimum v${effectiveMin} (${ns}); ` +
289
+ `reload axona.net / the demo to upgrade`);
290
+ } catch {}
291
+ return;
292
+ }
293
+ log('client-hello-admitted', {
294
+ connId: id, peerVersion, ns, floor, minPeerVersion: MIN_PEER_VERSION,
295
+ });
296
+ admitConnection();
297
+ return;
298
+ }
299
+
300
+ switch (msg.type) {
301
+ case 'ping': {
302
+ conn.pings++;
303
+ try {
304
+ ws.send(JSON.stringify({
305
+ type: 'pong',
306
+ t: msg.t, // echo client timestamp unchanged
307
+ serverT: Date.now(),
308
+ }));
309
+ conn.pongs++;
310
+ //fixme logDebug('pong', { connId: id, n: conn.pings });
311
+ } catch (err) {
312
+ logErr('pong-send-failed', { connId: id, err: err.message });
313
+ }
314
+ break;
315
+ }
316
+
317
+ case 'axona': {
318
+ // Axona wire frame from the peer. The transport unpacks
319
+ // req/res/ntf, dispatches to handlers, and writes the
320
+ // response back through the same connection.
321
+ if (msg.payload && typeof msg.payload === 'object') {
322
+ bridgeNode.handleAxonaFrame(id, msg.payload);
323
+ }
324
+ break;
325
+ }
326
+
327
+ case 'signal': {
328
+ // Relay opaque SDP / ICE between peers. The bridge does not
329
+ // inspect `payload` — it only validates that `to` is connected
330
+ // and rewrites the addressing so the recipient knows who sent it.
331
+ const to = msg.to;
332
+ if (typeof to !== 'string') {
333
+ logErr('signal-missing-to', { connId: id });
334
+ break;
335
+ }
336
+ if (!connections.has(to)) {
337
+ // Recipient is gone — silently drop. This is a normal race:
338
+ // a peer-left event raced past the signaling message. We
339
+ // don't surface an error to the sender because the sender
340
+ // will receive `peer-left` and clean up on its own.
341
+ logDebug('signal-drop-unknown-to', { connId: id, to });
342
+ break;
343
+ }
344
+ const delivered = sendTo(to, {
345
+ type: 'signal',
346
+ from: id,
347
+ payload: msg.payload,
348
+ });
349
+ if (delivered) {
350
+ conn.signalsRelayed++;
351
+ logDebug('signal-relay', { from: id, to, n: conn.signalsRelayed });
352
+ }
353
+ break;
354
+ }
355
+
356
+ default:
357
+ logDebug('unknown-type', { connId: id, type: msg.type });
358
+ }
359
+ });
360
+
361
+ ws.on('close', (code, reason) => {
362
+ const lifeS = Math.floor((Date.now() - since) / 1000);
363
+ if (conn.helloTimer) {
364
+ clearTimeout(conn.helloTimer);
365
+ conn.helloTimer = null;
366
+ }
367
+ connections.delete(id);
368
+
369
+ // Let the embedded Axona node clean up its bindings + reject any
370
+ // pending requests to this peer.
371
+ bridgeNode.handleConnClosed(id);
372
+
373
+ // Tell everyone remaining that this peer is gone. They'll tear
374
+ // down their RTCPeerConnection for this id.
375
+ const notifiedCount = broadcast(
376
+ { type: 'peer-left', peerId: id, serverT: Date.now() },
377
+ null, // peer is already removed from the registry
378
+ );
379
+
380
+ log('disconnect', {
381
+ connId: id,
382
+ code,
383
+ reason: reason?.toString() ?? '',
384
+ lifeS,
385
+ pings: conn.pings,
386
+ pongs: conn.pongs,
387
+ signals: conn.signalsRelayed,
388
+ notified: notifiedCount,
389
+ remaining: connections.size,
390
+ });
391
+ });
392
+
393
+ ws.on('error', (err) => {
394
+ logErr('ws-error', { connId: id, err: err.message });
395
+ });
396
+ });
397
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ import() resolves against the directory that contains the import.
3
+ But NodeJS fs operations resolve against the current working directory from which node was invoked.
4
+ That wasn't terribly inconvenient when require() was used, because it defined __dirname.
5
+ Here we export __dirname and resolve().
6
+ REQUIRES: that this file be imported from this directory.
7
+ */
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ export const __dirname = path.dirname(__filename); // Only meaningful if loaded from same directory as this file.
13
+ export function resolve(relativePathname) {
14
+ return path.resolve(__dirname, relativePathname);
15
+ }
@@ -0,0 +1,18 @@
1
+ /*
2
+ Exports the current estimated location, based on IP address.
3
+ The value is cached in location.json, which can also be hand-edited, cleared, etc.
4
+ */
5
+ import fs from 'fs/promises';
6
+ import { resolve } from './dirname.js';
7
+ const filename = './location.json';
8
+ export const data = await import(filename, {with: { type: 'json' }})
9
+ .catch(async () => {
10
+ const response = await fetch('https://ipinfo.io/json');
11
+ const string = await response.text();
12
+ console.log('Estimating location as', string);
13
+ await fs.writeFile(resolve(filename), string, 'utf8');
14
+ return {default: JSON.parse(string)};
15
+ });
16
+ export const [lat, lng] = data.default.loc.split(',').map(parseFloat);
17
+ export const location = {lat, lng};
18
+
@@ -0,0 +1,111 @@
1
+ // =====================================================================
2
+ // identity.js — bridge's hybrid (legacy 64-bit + kernel 264-bit) identity.
3
+ //
4
+ // v0.3 (kernel 3.0.0): migrated to the kernel's createNodeIdentity (the
5
+ // connection/node identity factory; deriveIdentity was renamed) so the
6
+ // bridge is a kernel-conformant peer in the v1.0 wire protocol — signed
7
+ // envelopes work, peer topics derive correctly under the structured
8
+ // { region, name } addressing model.
9
+ //
10
+ // Returned shape (matches axona-peer/src/identity.js after #46):
11
+ //
12
+ // Legacy (preserved for ws_transport / bridge_axona_node — they
13
+ // carry BigInt nodeIds in the hello/hello-ack envelopes that
14
+ // peer browsers still parse via hexToId)
15
+ // id — BigInt 64-bit, top 64 bits of kernel hex id.
16
+ // Same S2 prefix at the top (preserves geographic
17
+ // routing locality); bottom 56 bits deterministic
18
+ // from sha256(pubkey).
19
+ // geoBits — 8
20
+ // region — { lat, lng, label, id: 'bridge' }
21
+ // createdAt — ms
22
+ //
23
+ // Kernel (new)
24
+ // idHex — 66-char hex (kernel's full 264-bit nodeId)
25
+ // pubkey — Uint8Array (Ed25519)
26
+ // privateKey — Web Crypto Ed25519 CryptoKey
27
+ // pubkeyHex — 64-char hex
28
+ //
29
+ // Persistence: NONE (Phase 2). The bridge transport id is EPHEMERAL — a fresh
30
+ // kernel identity is derived on every start; nothing is written to disk. The
31
+ // bridge directory dedups + ranks on the bridge URL (not the signer), so a
32
+ // rotating signer just re-publishes the same-URL directory entry on restart;
33
+ // clients still discover, rank, and fail over to it. No bridge-identity.json,
34
+ // no BRIDGE_IDENTITY_PATH.
35
+ // =====================================================================
36
+
37
+ import { createNodeIdentity as kernelCreateNodeIdentity } from '@axona/protocol';
38
+
39
+ const GEO_BITS = 8;
40
+
41
+ const DEFAULT_LAT = 37.5;
42
+ const DEFAULT_LNG = -122.3;
43
+
44
+ function regionFromEnv() {
45
+ const lat = parseFloat(process.env.BRIDGE_LAT ?? DEFAULT_LAT);
46
+ const lng = parseFloat(process.env.BRIDGE_LNG ?? DEFAULT_LNG);
47
+ const label = process.env.BRIDGE_REGION_LABEL
48
+ ?? `bridge (${lat.toFixed(2)}, ${lng.toFixed(2)})`;
49
+ return { lat, lng, label, id: 'bridge' };
50
+ }
51
+
52
+ /**
53
+ * @typedef {Object} BridgeIdentity
54
+ * @property {bigint} id legacy 64-bit BigInt (top 64 bits of kernel hex)
55
+ * @property {number} geoBits 8
56
+ * @property {Object} region { lat, lng, label, id }
57
+ * @property {number} createdAt ms
58
+ * @property {string} idHex kernel 66-char hex node ID
59
+ * @property {Uint8Array} pubkey
60
+ * @property {CryptoKey} privateKey
61
+ * @property {string} pubkeyHex
62
+ */
63
+
64
+ /**
65
+ * Derive a fresh node/connection identity. ASYNC — kernel
66
+ * createNodeIdentity uses Web Crypto Ed25519 keygen (async).
67
+ *
68
+ * @returns {Promise<BridgeIdentity>}
69
+ */
70
+ export async function loadOrDeriveIdentity() {
71
+ // Phase 2: the bridge transport id is EPHEMERAL — never persisted. The bridge
72
+ // mints a fresh kernel node identity on every start (no bridge-identity.json).
73
+ // The bridge directory + first-party reputation are keyed on the bridge URL,
74
+ // not on the (now-rotating) signer, so clients still find + rank it across
75
+ // restarts; a fresh signer simply re-publishes the same-URL directory entry.
76
+ const labels = regionFromEnv();
77
+ const kernel = await kernelCreateNodeIdentity({ lat: labels.lat, lng: labels.lng });
78
+ return buildHybrid(kernel, labels);
79
+ }
80
+
81
+ /** Build the hybrid identity object from a kernel identity. */
82
+ function buildHybrid(kernel, regionLabels) {
83
+ // v1.1: full-width 264-bit node ID — same address space as
84
+ // topic IDs so K-closest XOR distance is meaningful (top 8 bits
85
+ // = S2 region prefix on both peer IDs and topic IDs). Replaces
86
+ // the previous .slice(0, 16) which left the bridge in a 64-bit
87
+ // mesh while topics were 264-bit.
88
+ const idBig = BigInt('0x' + kernel.id);
89
+ return {
90
+ // Legacy field name; value is now 264-bit BigInt.
91
+ id: idBig,
92
+ geoBits: GEO_BITS,
93
+ region: {
94
+ lat: kernel.region.lat,
95
+ lng: kernel.region.lng,
96
+ label: regionLabels.label ?? `bridge (${kernel.region.lat.toFixed(2)}, ${kernel.region.lng.toFixed(2)})`,
97
+ id: regionLabels.id ?? 'bridge',
98
+ },
99
+ createdAt: kernel.createdAt,
100
+ // Kernel
101
+ idHex: kernel.id,
102
+ pubkey: kernel.pubkey,
103
+ privateKey: kernel.privateKey,
104
+ pubkeyHex: kernel.pubkeyHex,
105
+ };
106
+ }
107
+
108
+ /** Format a 264-bit BigInt nodeId as a 66-char hex string (v1.1 wire
109
+ * convention: top 8 bits = S2 region prefix, rest = pubkey-derived
110
+ * hash; same width as topic IDs). */
111
+ export function idToHex(id) { return id.toString(16).padStart(66, '0'); }
@@ -0,0 +1,12 @@
1
+ {
2
+ "ip": "99.122.55.96",
3
+ "hostname": "99-122-55-96.lightspeed.sntcca.sbcglobal.net",
4
+ "city": "Redwood City",
5
+ "region": "California",
6
+ "country": "US",
7
+ "loc": "37.4852,-122.2364",
8
+ "org": "AS7018 AT&T Enterprises, LLC",
9
+ "postal": "94061",
10
+ "timezone": "America/Los_Angeles",
11
+ "readme": "https://ipinfo.io/missingauth"
12
+ }