@decentnetwork/beagle 0.1.58 → 0.1.60
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/dist/bridge-roster.d.ts +20 -0
- package/dist/bridge-roster.js +105 -0
- package/dist/cli.js +6 -0
- package/dist/desktop/app.js +4762 -4900
- package/dist/embedded-host.js +5 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +117 -22
- package/package.json +7 -3
package/dist/embedded-host.js
CHANGED
|
@@ -201,6 +201,10 @@ export class EmbeddedHost {
|
|
|
201
201
|
// evidence than any status flag, so use it.
|
|
202
202
|
void this.#chainFlush(pubkey);
|
|
203
203
|
});
|
|
204
|
+
// `description` is the self-introduction the sender was asked to write —
|
|
205
|
+
// the entire reason for asking is so the recipient knows who is knocking.
|
|
206
|
+
// It arrives on the packet and used to be dropped here, so the request
|
|
207
|
+
// card had nothing to show but a truncated key.
|
|
204
208
|
this.#node.on("friend-request", (req) => {
|
|
205
209
|
const userid = req.userid ?? req.pubkey;
|
|
206
210
|
if (this.#autoAccept) {
|
|
@@ -214,6 +218,7 @@ export class EmbeddedHost {
|
|
|
214
218
|
userid,
|
|
215
219
|
pubkey: req.pubkey,
|
|
216
220
|
name: req.name,
|
|
221
|
+
descr: req.description,
|
|
217
222
|
hello: req.hello,
|
|
218
223
|
arrivedAt: Date.now(),
|
|
219
224
|
});
|
package/dist/server.d.ts
CHANGED
|
@@ -49,6 +49,10 @@ export interface BeagleServerOptions {
|
|
|
49
49
|
pk: string;
|
|
50
50
|
}>;
|
|
51
51
|
};
|
|
52
|
+
/** Bridge whose roster the "Here" tab shows. The desktop does not route
|
|
53
|
+
* traffic through it — it only completes the handshake to prove its key
|
|
54
|
+
* and read who else is there. */
|
|
55
|
+
bridgeWs?: string;
|
|
52
56
|
}
|
|
53
57
|
export declare function startBeagleServer(opts: BeagleServerOptions): {
|
|
54
58
|
stop: () => void;
|
package/dist/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { INSTALL_COMMAND as LAN_INSTALL_COMMAND } from "./lan-handoff.js";
|
|
|
21
21
|
import yaml from "js-yaml";
|
|
22
22
|
import { DEFAULT_EXITS } from "./exits.js";
|
|
23
23
|
import * as peerAddr from "@decentnetwork/peer";
|
|
24
|
+
import { bridgeRoster } from "./bridge-roster.js";
|
|
24
25
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
25
26
|
// scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
|
|
26
27
|
const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
|
|
@@ -54,7 +55,7 @@ async function fetchDiscoverJson(url) {
|
|
|
54
55
|
// ── beagles.eth names (ens-gateway worker) ──────────────────────────────────
|
|
55
56
|
// The same registry the mobile apps register into. Web-side registration
|
|
56
57
|
// signs the record with THIS node's Carrier identity (XEdDSA via the backend
|
|
57
|
-
// `sign` op) — the gateway verifies it the same way Sign in with
|
|
58
|
+
// `sign` op) — the gateway verifies it the same way Sign in with Beagle does.
|
|
58
59
|
const ENS_GATEWAY = process.env.BEAGLE_ENS_GATEWAY || "https://ens-gateway.beaglechat.workers.dev";
|
|
59
60
|
async function ensFetchJson(path) {
|
|
60
61
|
const ctl = new AbortController();
|
|
@@ -413,7 +414,7 @@ function findSavedFile(downloadsDir, name) {
|
|
|
413
414
|
return null;
|
|
414
415
|
}
|
|
415
416
|
/** True only when the request came from the local machine. Used to gate the
|
|
416
|
-
* "Sign in with
|
|
417
|
+
* "Sign in with Beagle" routes so binding the UI to a LAN IP cannot expose
|
|
417
418
|
* identity signing to other hosts. The popup always runs in the local user's
|
|
418
419
|
* browser, so it connects over the loopback interface. */
|
|
419
420
|
function isLocalRequest(req) {
|
|
@@ -507,7 +508,7 @@ function validateOrigin(raw) {
|
|
|
507
508
|
return null;
|
|
508
509
|
}
|
|
509
510
|
}
|
|
510
|
-
// The "Sign in with
|
|
511
|
+
// The "Sign in with Beagle" consent popup. Self-contained (no external assets),
|
|
511
512
|
// dark theme matching the desktop UI. Reads origin+nonce from its own query,
|
|
512
513
|
// shows the requesting site and this node's identity, and on Approve calls the
|
|
513
514
|
// local /api/connect-approve to sign, then postMessages the result to the
|
|
@@ -516,7 +517,7 @@ function validateOrigin(raw) {
|
|
|
516
517
|
const CONNECT_PAGE = `<!doctype html>
|
|
517
518
|
<html lang="en"><head><meta charset="utf-8">
|
|
518
519
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
519
|
-
<title>Sign in with
|
|
520
|
+
<title>Sign in with Beagle</title>
|
|
520
521
|
<style>
|
|
521
522
|
:root{--bg:#0c0d11;--panel:#14161d;--line:#262a35;--text:#e7e9ef;--faint:#8b91a3;
|
|
522
523
|
--accent:#5b8cff;--good:#46a758;--bad:#e5604d;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
@@ -533,7 +534,9 @@ const CONNECT_PAGE = `<!doctype html>
|
|
|
533
534
|
.origin{color:var(--text);font-weight:600;word-break:break-all}
|
|
534
535
|
.id{background:#0e1016;border:1px solid var(--line);border-radius:10px;padding:11px 12px;margin-bottom:14px}
|
|
535
536
|
.lbl{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--faint);margin-bottom:4px}
|
|
537
|
+
.who{font-size:13.5px;font-weight:600;margin-bottom:3px}
|
|
536
538
|
.userid{font-family:var(--mono);font-size:12.5px;color:var(--text);word-break:break-all}
|
|
539
|
+
[hidden]{display:none !important}
|
|
537
540
|
.err{background:rgba(229,96,77,.12);border:1px solid rgba(229,96,77,.4);color:#ffb4a8;
|
|
538
541
|
border-radius:9px;padding:9px 11px;font-size:12.5px;margin-bottom:13px}
|
|
539
542
|
.row{display:flex;gap:10px;margin-top:4px}
|
|
@@ -546,10 +549,14 @@ const CONNECT_PAGE = `<!doctype html>
|
|
|
546
549
|
</style></head>
|
|
547
550
|
<body>
|
|
548
551
|
<div class="card">
|
|
549
|
-
<div class="brand"><span class="d"></span>
|
|
552
|
+
<div class="brand"><span class="d"></span>Beagle</div>
|
|
550
553
|
<h1>Sign in</h1>
|
|
551
|
-
<p class="sub"><span id="origin" class="origin">…</span> wants to sign you in with your
|
|
552
|
-
<div class="id"
|
|
554
|
+
<p class="sub"><span id="origin" class="origin">…</span> wants to sign you in with your Beagle identity.</p>
|
|
555
|
+
<div class="id">
|
|
556
|
+
<div class="lbl">Your identity</div>
|
|
557
|
+
<div id="who" class="who" hidden></div>
|
|
558
|
+
<div id="userid" class="userid">loading…</div>
|
|
559
|
+
</div>
|
|
553
560
|
<div id="err" class="err" hidden></div>
|
|
554
561
|
<div class="row">
|
|
555
562
|
<button id="deny" class="btn ghost">Deny</button>
|
|
@@ -571,10 +578,11 @@ const CONNECT_PAGE = `<!doctype html>
|
|
|
571
578
|
if(!validOrigin) fail("This sign-in request has an invalid origin and was blocked.");
|
|
572
579
|
if(nonce.length===0||nonce.length>512){ validOrigin=false; fail("This sign-in request is missing a valid nonce."); }
|
|
573
580
|
fetch("/api/state").then(function(r){return r.json();}).then(function(s){
|
|
574
|
-
var uid=(s.me&&s.me.userid)||"";
|
|
581
|
+
var uid=(s.me&&s.me.userid)||"", nm=(s.me&&s.me.name)||"";
|
|
575
582
|
document.getElementById("userid").textContent=uid||"(no identity)";
|
|
583
|
+
if(nm){var w=document.getElementById("who"); w.textContent=nm; w.hidden=false;}
|
|
576
584
|
if(uid&&validOrigin) approve.disabled=false;
|
|
577
|
-
else if(!uid) fail("No local
|
|
585
|
+
else if(!uid) fail("No local Beagle identity found — is the daemon running?");
|
|
578
586
|
}).catch(function(){ fail("Could not reach the local agentnet daemon."); });
|
|
579
587
|
deny.onclick=function(){ reply({error:"denied"}); setTimeout(function(){window.close();},60); };
|
|
580
588
|
approve.onclick=function(){
|
|
@@ -583,7 +591,7 @@ const CONNECT_PAGE = `<!doctype html>
|
|
|
583
591
|
body:JSON.stringify({origin:origin,nonce:nonce})})
|
|
584
592
|
.then(function(r){return r.json();})
|
|
585
593
|
.then(function(j){ if(!j.ok) throw new Error(j.error||"sign failed");
|
|
586
|
-
reply({userid:j.userid,sig:j.sig}); setTimeout(function(){window.close();},60); })
|
|
594
|
+
reply({userid:j.userid,sig:j.sig,name:j.name,avatar:j.avatar}); setTimeout(function(){window.close();},60); })
|
|
587
595
|
.catch(function(e){ approve.disabled=false; approve.textContent="Approve"; fail("Signing failed: "+e.message); });
|
|
588
596
|
};
|
|
589
597
|
})();
|
|
@@ -678,7 +686,7 @@ export function startBeagleServer(opts) {
|
|
|
678
686
|
res.end("not found");
|
|
679
687
|
return;
|
|
680
688
|
}
|
|
681
|
-
// ── Sign in with
|
|
689
|
+
// ── Sign in with Beagle ────────────────────────────────────────────
|
|
682
690
|
// A website opens http://localhost:8765/connect?origin=…&nonce=… in a
|
|
683
691
|
// popup; on Approve we sign "decent-auth\n<origin>\n<nonce>" with this
|
|
684
692
|
// node's Carrier identity and postMessage {userid,nonce,sig} back to the
|
|
@@ -689,7 +697,7 @@ export function startBeagleServer(opts) {
|
|
|
689
697
|
if (url === "/connect" || url === "/api/connect-approve") {
|
|
690
698
|
if (!isLocalRequest(req)) {
|
|
691
699
|
res.writeHead(403, { "content-type": "text/plain" });
|
|
692
|
-
res.end("Sign in with
|
|
700
|
+
res.end("Sign in with Beagle is available only on this machine (localhost).");
|
|
693
701
|
return;
|
|
694
702
|
}
|
|
695
703
|
}
|
|
@@ -718,11 +726,30 @@ export function startBeagleServer(opts) {
|
|
|
718
726
|
sendJson(res, 502, { ok: false, error: r.error || "sign failed" });
|
|
719
727
|
return;
|
|
720
728
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
729
|
+
const userid = r.data?.userid ?? "";
|
|
730
|
+
// Name and avatar ride along so the site can render the person without
|
|
731
|
+
// a second lookup it has no way to make — nothing public maps a userid
|
|
732
|
+
// to a display name unless that user registered a beagles.eth name.
|
|
733
|
+
// Additive: a caller that ignores them behaves exactly as before, and
|
|
734
|
+
// neither is part of what the signature proves.
|
|
735
|
+
let name = "";
|
|
736
|
+
let avatar = null;
|
|
737
|
+
try {
|
|
738
|
+
const diag = await opts.call({ op: "diag" });
|
|
739
|
+
name = ((diag.ok ? diag.data : {})?.node?.name) ?? "";
|
|
740
|
+
const pub = (await ensPublicByUserid(1200)).get(userid);
|
|
741
|
+
if (pub) {
|
|
742
|
+
if (!name)
|
|
743
|
+
name = pub.displayName ?? "";
|
|
744
|
+
avatar = pub.avatarUrl ?? null;
|
|
745
|
+
if (!avatar && pub.punkId != null) {
|
|
746
|
+
const punk = await punksFetch(`/api/punks/${pub.punkId}`).catch(() => null);
|
|
747
|
+
avatar = punk?.image ?? null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch { /* identity is what matters; the trimmings are best-effort */ }
|
|
752
|
+
sendJson(res, 200, { ok: true, userid, sig: r.data?.sig ?? "", name, avatar });
|
|
726
753
|
return;
|
|
727
754
|
}
|
|
728
755
|
// ── beagles.eth name registration ──
|
|
@@ -760,7 +787,13 @@ export function startBeagleServer(opts) {
|
|
|
760
787
|
sendJson(res, 200, { ok: true, punk });
|
|
761
788
|
}
|
|
762
789
|
catch (err) {
|
|
763
|
-
|
|
790
|
+
// An id the set does not contain is a normal negative answer, not a
|
|
791
|
+
// gateway failure. Reporting 502 made a bad stored punkId look like
|
|
792
|
+
// the punks service was down, and the client — which only cached
|
|
793
|
+
// successes — retried it on every render.
|
|
794
|
+
const msg = String(err?.message ?? err);
|
|
795
|
+
const missing = /\b404\b|not found/i.test(msg);
|
|
796
|
+
sendJson(res, missing ? 404 : 502, { ok: false, error: missing ? "no such punk" : msg });
|
|
764
797
|
}
|
|
765
798
|
return;
|
|
766
799
|
}
|
|
@@ -1152,6 +1185,9 @@ export function startBeagleServer(opts) {
|
|
|
1152
1185
|
}
|
|
1153
1186
|
const q = new URL(req.url || "", "http://x").searchParams;
|
|
1154
1187
|
const userid = q.get("userid") || "";
|
|
1188
|
+
// The sender's own copy needs saving too, or its chip renders as an
|
|
1189
|
+
// empty 0 B card: the bytes went peer-to-peer and never touched here.
|
|
1190
|
+
const dir = q.get("dir") === "out" ? "out" : "in";
|
|
1155
1191
|
const rawName = q.get("name") || "agentnet-file";
|
|
1156
1192
|
const safe = rawName.replace(/[/\\]/g, "_").slice(0, 200) || "agentnet-file";
|
|
1157
1193
|
if (!userid) {
|
|
@@ -1178,7 +1214,7 @@ export function startBeagleServer(opts) {
|
|
|
1178
1214
|
ws.on("finish", () => resolve2());
|
|
1179
1215
|
});
|
|
1180
1216
|
const st = statSync(finalPath);
|
|
1181
|
-
const r = await opts.call({ op: "file-log-local", userid, dir
|
|
1217
|
+
const r = await opts.call({ op: "file-log-local", userid, dir, name: finalName, size: st.size });
|
|
1182
1218
|
if (!r.ok) {
|
|
1183
1219
|
sendJson(res, 502, r);
|
|
1184
1220
|
return;
|
|
@@ -1311,9 +1347,13 @@ export function startBeagleServer(opts) {
|
|
|
1311
1347
|
const d = diag.ok ? (diag.data ?? {}) : {};
|
|
1312
1348
|
const identity = d.identity ?? {};
|
|
1313
1349
|
const tun = d.tun ?? {};
|
|
1350
|
+
const node = d.node ?? {};
|
|
1314
1351
|
const me = {
|
|
1315
1352
|
userid: identity.userid,
|
|
1316
1353
|
address: identity.address,
|
|
1354
|
+
// The sign-in popup shows this above the key: a userid alone does not
|
|
1355
|
+
// tell you which of your identities is about to answer.
|
|
1356
|
+
name: node.name ?? "",
|
|
1317
1357
|
// The configured TUN address is what this host can actually route.
|
|
1318
1358
|
// Prefer it over a stale init/default allocation during Dora outage.
|
|
1319
1359
|
ip: tun.ip ?? d.allocatedIp,
|
|
@@ -1359,6 +1399,32 @@ export function startBeagleServer(opts) {
|
|
|
1359
1399
|
}
|
|
1360
1400
|
return;
|
|
1361
1401
|
}
|
|
1402
|
+
if (req.method === "GET" && url === "/api/bridge-roster") {
|
|
1403
|
+
// Same shape the browser build returns, so discover.jsx renders it
|
|
1404
|
+
// without knowing which client it is running in.
|
|
1405
|
+
if (!opts.bridgeWs || !opts.callIce?.keyFile) {
|
|
1406
|
+
return sendJson(res, 200, { ok: true, list: [], pending: true });
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
// diag already carries both facts, so there is no second round trip
|
|
1410
|
+
// and no op to add: the address is the identity, the nickname is the
|
|
1411
|
+
// node's own name.
|
|
1412
|
+
const diag = (await opts.call({ op: "diag" }));
|
|
1413
|
+
const r = await bridgeRoster(opts.bridgeWs, opts.callIce.keyFile, {
|
|
1414
|
+
name: diag?.node?.name,
|
|
1415
|
+
address: diag?.identity?.address,
|
|
1416
|
+
});
|
|
1417
|
+
if (!r)
|
|
1418
|
+
return sendJson(res, 200, { ok: true, list: [], pending: true });
|
|
1419
|
+
return sendJson(res, 200, {
|
|
1420
|
+
ok: true, bridge: r.bridge, online: r.online,
|
|
1421
|
+
list: r.peers.map((p) => ({ ...p, punkId: p.punk ?? null })),
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
catch (err) {
|
|
1425
|
+
return sendJson(res, 200, { ok: false, error: String(err?.message ?? err) });
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1362
1428
|
if (req.method === "GET" && url === "/api/discover-registered") {
|
|
1363
1429
|
try {
|
|
1364
1430
|
const raw = (await fetchDiscoverJson(`${ENS_GATEWAY}/names`));
|
|
@@ -1475,8 +1541,28 @@ export function startBeagleServer(opts) {
|
|
|
1475
1541
|
if (req.method === "GET" && url === "/api/ens-profile") {
|
|
1476
1542
|
const q = new URL(req.url || "", "http://localhost").searchParams;
|
|
1477
1543
|
let userid = q.get("userid") || "";
|
|
1544
|
+
// Also accept ?name= (a beagles.eth display name), so a group sender
|
|
1545
|
+
// known only by nickname still resolves to its avatar — the userid is
|
|
1546
|
+
// simply not available for those.
|
|
1547
|
+
const qname = q.get("name") || "";
|
|
1548
|
+
if (!userid && qname) {
|
|
1549
|
+
const norm = (x) => String(x || "").toLowerCase().replace(/\s+/g, "");
|
|
1550
|
+
for (const [uid, prof] of await ensPublicByUserid(1500)) {
|
|
1551
|
+
if (norm(prof.displayName || "") === norm(qname) || norm(prof.ens || "") === norm(qname)) {
|
|
1552
|
+
userid = uid;
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
// A name nobody has registered is a lookup that found nothing, not a
|
|
1558
|
+
// bad request: 400 makes the caller log an error and, worse, retry.
|
|
1559
|
+
// Only the missing PARAMETER is the client's mistake.
|
|
1478
1560
|
if (!userid) {
|
|
1479
|
-
|
|
1561
|
+
if (qname) {
|
|
1562
|
+
sendJson(res, 200, { ok: true, registered: false, profile: null });
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
sendJson(res, 400, { ok: false, error: "userid or name required" });
|
|
1480
1566
|
return;
|
|
1481
1567
|
}
|
|
1482
1568
|
// A 52-char Carrier ADDRESS also works (group cards only have that):
|
|
@@ -1596,6 +1682,10 @@ export function startBeagleServer(opts) {
|
|
|
1596
1682
|
agent: false,
|
|
1597
1683
|
lastMsg: lm ? (lm.dir === "out" ? "you: " : "") + (lm.text ?? "") : "",
|
|
1598
1684
|
lastTime: fmtTime(lm?.ts),
|
|
1685
|
+
// The raw stamp too: lastTime is formatted for display and cannot
|
|
1686
|
+
// be ordered, which is why the sidebar never reordered on new
|
|
1687
|
+
// activity.
|
|
1688
|
+
lastTs: lm?.ts ?? 0,
|
|
1599
1689
|
wire: "163",
|
|
1600
1690
|
avatarUrl: ensPub.get(uid)?.avatarUrl ?? null,
|
|
1601
1691
|
punkId: ensPub.get(uid)?.punkId ?? null,
|
|
@@ -1606,8 +1696,13 @@ export function startBeagleServer(opts) {
|
|
|
1606
1696
|
id: p.userid || p.address || `r${i}`,
|
|
1607
1697
|
carrier: p.address || p.userid || "",
|
|
1608
1698
|
userid: p.userid || "",
|
|
1609
|
-
|
|
1610
|
-
|
|
1699
|
+
// Name and introduction travel in the friend-request packet and were
|
|
1700
|
+
// being dropped here, so every request looked identical and there
|
|
1701
|
+
// was nothing to decide on.
|
|
1702
|
+
name: p.name || "",
|
|
1703
|
+
descr: p.descr || "",
|
|
1704
|
+
via: p.hello || "lan",
|
|
1705
|
+
time: fmtTime(p.arrivedAt),
|
|
1611
1706
|
}));
|
|
1612
1707
|
// Exit nodes = the AVAILABLE exits this node knows about, grouped by
|
|
1613
1708
|
// region. The authoritative list is the shipped DEFAULT_EXITS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
4
4
|
"description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js && node scripts/build-ui.mjs",
|
|
22
|
+
"build": "npm run build -w @decentnetwork/beagle-ui && tsc -p tsconfig.json && chmod +x dist/cli.js && node scripts/build-ui.mjs",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"clean": "rm -rf dist",
|
|
25
25
|
"start": "node dist/cli.js"
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"yargs": "^17.7.2"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
+
"@decentnetwork/beagle-ui": "0.1.1",
|
|
36
37
|
"@types/js-yaml": "^4.0.9",
|
|
37
38
|
"@types/node": "^20.11.0",
|
|
38
39
|
"@types/yargs": "^17.0.32",
|
|
@@ -42,5 +43,8 @@
|
|
|
42
43
|
"engines": {
|
|
43
44
|
"node": ">=20"
|
|
44
45
|
},
|
|
45
|
-
"license": "MIT"
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"workspaces": [
|
|
48
|
+
"packages/*"
|
|
49
|
+
]
|
|
46
50
|
}
|