fedipod-server 0.28.0 → 0.29.0
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/README.md +27 -28
- package/config/server.json +22 -0
- package/dist/claims.js +3 -2
- package/dist/directory.d.ts +2 -0
- package/dist/handler.d.ts +4 -1
- package/dist/handler.js +32 -3
- package/dist/handler.jsonld +4 -0
- package/dist/store-pod.js +37 -0
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +68 -22
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +50 -6
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/lease.mjs +7 -7
- package/lib/core/place.mjs +82 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +23 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/social.mjs +62 -32
- package/lib/core/store.mjs +36 -6
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +80 -111
- package/lib/gateway/gateway-core.mjs +91 -9
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +207 -25
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +73 -2
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +18 -0
- package/web/admin/actors.js +2 -0
- package/web/admin/index.html +9 -0
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +1 -1
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +24 -10
- package/web/app/boot.mjs +72 -32
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +466 -89
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +2792 -1996
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/update.js +4 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/lib/pod/transport.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import * as $rdf from 'rdflib';
|
|
16
16
|
import { readCapped, retryAfterMs } from './http.mjs';
|
|
17
17
|
import { linkTargets, REL } from './links.mjs';
|
|
18
|
+
import { podBaseOfWebId } from './urls.mjs';
|
|
18
19
|
|
|
19
20
|
const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
|
|
20
21
|
const DC = $rdf.Namespace('http://purl.org/dc/terms/');
|
|
@@ -23,6 +24,17 @@ const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
|
|
23
24
|
const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#');
|
|
24
25
|
const FOAF = $rdf.Namespace('http://xmlns.com/foaf/0.1/');
|
|
25
26
|
const AS = $rdf.Namespace('https://www.w3.org/ns/activitystreams#');
|
|
27
|
+
const RDFS = $rdf.Namespace('http://www.w3.org/2000/01/rdf-schema#');
|
|
28
|
+
|
|
29
|
+
// An inserted term must be something a reader can take for what it says it
|
|
30
|
+
// is: an absolute http(s) IRI, or a literal.
|
|
31
|
+
function termProblem(t) {
|
|
32
|
+
if (t?.termType === 'Literal') return null;
|
|
33
|
+
if (t?.termType !== 'NamedNode') return `${t?.value ?? t} is not an IRI or a literal`;
|
|
34
|
+
try {
|
|
35
|
+
return /^https?:$/.test(new URL(t.value).protocol) ? null : `${t.value} is not an http(s) IRI`;
|
|
36
|
+
} catch { return `${t.value} is not an absolute IRI`; }
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
// A pod whose access rules are ACP policies, not WAC authorizations. This
|
|
28
40
|
// library writes WAC; over an ACP resource that would be noise where the pod's
|
|
@@ -345,7 +357,31 @@ export class PodTransport {
|
|
|
345
357
|
const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
|
|
346
358
|
const url = await this.aclUrlFor(podTarget);
|
|
347
359
|
if (!await this.aclWritable(url)) return null;
|
|
348
|
-
|
|
360
|
+
const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
|
|
361
|
+
// `ifChanged`: read the rule the pod holds and write only when it differs.
|
|
362
|
+
// For a rule restated at every start — the inbox door — a read is the
|
|
363
|
+
// whole cost, where a write took the pod's lock to say the same thing.
|
|
364
|
+
if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
|
|
365
|
+
return this.put(url, doc, 'text/turtle');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Whether the pod's rule at `aclUrl` states exactly what `doc` states.
|
|
369
|
+
// Compared as graphs, not bytes: the pod serialises what it holds its own
|
|
370
|
+
// way. Every rule this file writes names its subjects, so triple sets are
|
|
371
|
+
// enough; anything unreadable or with blank nodes reads as different.
|
|
372
|
+
async aclSame(aclUrl, doc) {
|
|
373
|
+
try {
|
|
374
|
+
const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
|
|
375
|
+
if (res.status !== 200) return false;
|
|
376
|
+
const triples = (text) => {
|
|
377
|
+
const g = $rdf.graph();
|
|
378
|
+
$rdf.parse(text, g, aclUrl, 'text/turtle');
|
|
379
|
+
if (g.statements.some((st) => st.subject.termType === 'BlankNode' || st.object.termType === 'BlankNode')) return null;
|
|
380
|
+
return g.statements.map((st) => `${st.subject.value} ${st.predicate.value} ${st.object.value}`).sort().join('\n');
|
|
381
|
+
};
|
|
382
|
+
const theirs = triples(await res.text());
|
|
383
|
+
return theirs !== null && theirs === triples(doc);
|
|
384
|
+
} catch { return false; }
|
|
349
385
|
}
|
|
350
386
|
|
|
351
387
|
// Child documents of an LDP container (URLs under it, excluding aux docs).
|
|
@@ -382,12 +418,14 @@ export class PodTransport {
|
|
|
382
418
|
$rdf.parse(body, g, url, 'text/turtle');
|
|
383
419
|
const here = $rdf.sym(url);
|
|
384
420
|
const seen = new Set();
|
|
421
|
+
const receipts = new Set();
|
|
385
422
|
const list = [];
|
|
386
423
|
for (const child of g.each(here, LDP('contains'), null, here)) {
|
|
387
424
|
const u = child.value;
|
|
388
425
|
// `.receipt.json` is a verification receipt a gateway wrote beside an
|
|
389
426
|
// inbox item — read with the item, never enumerated as an item itself.
|
|
390
|
-
if (
|
|
427
|
+
if (u.endsWith('.receipt.json')) { receipts.add(u); continue; }
|
|
428
|
+
if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
|
|
391
429
|
seen.add(u);
|
|
392
430
|
list.push({
|
|
393
431
|
url: u,
|
|
@@ -395,14 +433,21 @@ export class PodTransport {
|
|
|
395
433
|
modified: g.any(child, DC('modified'), null, here)?.value || null,
|
|
396
434
|
});
|
|
397
435
|
}
|
|
436
|
+
// Whether a receipt sits beside each item, so the drain asks for one only
|
|
437
|
+
// where there is one; and the receipts nothing sits beside any more.
|
|
438
|
+
for (const item of list) item.receipt = receipts.has(item.url + '.receipt.json');
|
|
439
|
+
const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -'.receipt.json'.length)));
|
|
398
440
|
// Oldest first. An LDP listing is a set, so without this a drain works in
|
|
399
441
|
// whatever order the graph happened to parse — a mention from last week
|
|
400
442
|
// after one from today, and no way to make progress predictable.
|
|
401
443
|
list.sort((a, b) => String(a.modified || '').localeCompare(String(b.modified || '')));
|
|
402
|
-
this._listCache.set(url, { etag: res.headers.get('etag'), children: list });
|
|
444
|
+
this._listCache.set(url, { etag: res.headers.get('etag'), children: list, orphans });
|
|
403
445
|
return list;
|
|
404
446
|
}
|
|
405
447
|
|
|
448
|
+
/** Receipts in the last listing of `url` whose item is gone. */
|
|
449
|
+
orphanReceipts(url) { return this._listCache?.get(url)?.orphans ?? []; }
|
|
450
|
+
|
|
406
451
|
/**
|
|
407
452
|
* The WebID profile advertises the actor as an account:
|
|
408
453
|
* <webId> foaf:account <actor> .
|
|
@@ -412,16 +457,11 @@ export class PodTransport {
|
|
|
412
457
|
* written back — an empty or foreign body must never become the new profile.
|
|
413
458
|
*/
|
|
414
459
|
async linkAccountInProfile({ actorUrl, accountName, kind = 'person', outbox = null }) {
|
|
415
|
-
const docUrl = this.webId.split('#')[0];
|
|
416
|
-
const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
|
|
417
|
-
if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
|
|
418
|
-
const g = $rdf.graph();
|
|
419
|
-
$rdf.parse(await res.text(), g, docUrl, 'text/turtle');
|
|
420
|
-
const doc = $rdf.sym(docUrl);
|
|
421
460
|
const me = $rdf.sym(this.webId);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
461
|
+
const podBase = podBaseOfWebId(this.webId);
|
|
462
|
+
// The profile and what its seeAlso names: what either says is said.
|
|
463
|
+
const docs = await this.profileDocs(podBase);
|
|
464
|
+
const says = (s, p, o) => docs.some(({ g }) => g?.holds(s, p, o));
|
|
425
465
|
const actor = $rdf.sym(actorUrl);
|
|
426
466
|
const wanted = [
|
|
427
467
|
[me, FOAF('account'), actor],
|
|
@@ -432,22 +472,24 @@ export class PodTransport {
|
|
|
432
472
|
// WebID is what dokieli reads); only where a door exists to take it.
|
|
433
473
|
...(outbox ? [[me, AS('outbox'), $rdf.sym(outbox)]] : []),
|
|
434
474
|
];
|
|
435
|
-
const missing = wanted.filter(([s, p, o]) => !
|
|
475
|
+
const missing = wanted.filter(([s, p, o]) => !says(s, p, o));
|
|
436
476
|
// A handle change leaves the old accountName behind; ours is replaced.
|
|
437
477
|
// Likewise an outbox that moved.
|
|
438
|
-
const stale = [
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
478
|
+
const stale = [];
|
|
479
|
+
for (const { g } of docs) {
|
|
480
|
+
for (const st of g?.statementsMatching(actor, FOAF('accountName'), null) || []) {
|
|
481
|
+
if (st.object.value !== accountName) stale.push([st.subject, st.predicate, st.object]);
|
|
482
|
+
}
|
|
483
|
+
if (outbox) {
|
|
484
|
+
for (const st of g?.statementsMatching(me, AS('outbox'), null) || []) {
|
|
485
|
+
if (st.object.value !== outbox) stale.push([st.subject, st.predicate, st.object]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
442
489
|
if (!missing.length && !stale.length) return false;
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
const deletes = stale.map(st => [st.subject, st.predicate, st.object]);
|
|
447
|
-
if (await this.patchDocument(docUrl, missing, deletes)) return true;
|
|
448
|
-
for (const st of stale) g.remove(st);
|
|
449
|
-
for (const [s, p, o] of missing) g.add(s, p, o, doc);
|
|
450
|
-
await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
|
|
490
|
+
// Checked before it is written, to the profile or else to a document its
|
|
491
|
+
// seeAlso names (writeAboutWebId); a patch carries only these statements.
|
|
492
|
+
await this.writeAboutWebId(podBase, { inserts: missing, deletes: stale });
|
|
451
493
|
return true;
|
|
452
494
|
}
|
|
453
495
|
|
|
@@ -489,4 +531,144 @@ export class PodTransport {
|
|
|
489
531
|
if (res.status === 405 || res.status === 415 || res.status === 501) return false;
|
|
490
532
|
throw new Error(`[${this.label}] PATCH ${docUrl} → ${res.status}`);
|
|
491
533
|
}
|
|
534
|
+
|
|
535
|
+
// ---- writing a profile or a type index: valid RDF, or nothing ----
|
|
536
|
+
//
|
|
537
|
+
// Two rules. A change is written only if the document it leaves behind is
|
|
538
|
+
// valid RDF: the graph as it would be afterwards is serialised and parsed
|
|
539
|
+
// back, and the subject that must stay described still is. And a profile
|
|
540
|
+
// that will not take a write is not the end: the statements go to the first
|
|
541
|
+
// document its rdfs:seeAlso names, inside the same pod, that takes them —
|
|
542
|
+
// under the same check. A profile is what every Solid app signs in through;
|
|
543
|
+
// one left broken breaks them all. Reading follows the same links.
|
|
544
|
+
|
|
545
|
+
/** An IRI or a literal as a term, for the operations that pass plain values. */
|
|
546
|
+
sym(iri) {
|
|
547
|
+
try { return $rdf.sym(iri); } catch { throw new Error(`not written: ${iri} is not an absolute IRI`); }
|
|
548
|
+
}
|
|
549
|
+
literal(value) { return $rdf.literal(value); }
|
|
550
|
+
|
|
551
|
+
/** A document as a graph, or null when it is not there. */
|
|
552
|
+
async readRdf(docUrl) {
|
|
553
|
+
const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
|
|
554
|
+
if (res.status === 404 || res.status === 410) return null;
|
|
555
|
+
if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
|
|
556
|
+
const g = $rdf.graph();
|
|
557
|
+
$rdf.parse(await res.text(), g, docUrl, 'text/turtle');
|
|
558
|
+
return g;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The document as it would be after the change, checked. Returns the Turtle
|
|
563
|
+
* to write, or throws saying why nothing may be written.
|
|
564
|
+
*/
|
|
565
|
+
checkedRdf(g, docUrl, { inserts = [], deletes = [], mustDescribe = null }) {
|
|
566
|
+
const doc = $rdf.sym(docUrl);
|
|
567
|
+
for (const [s, p, o] of inserts) {
|
|
568
|
+
const bad = termProblem(s) || termProblem(p) || termProblem(o)
|
|
569
|
+
|| (s?.termType === 'Literal' || p?.termType !== 'NamedNode' ? 'a literal subject or predicate' : null);
|
|
570
|
+
if (bad) throw new Error(`not written: ${bad}`);
|
|
571
|
+
}
|
|
572
|
+
const after = $rdf.graph();
|
|
573
|
+
for (const st of g.statementsMatching(null, null, null, doc)) after.add(st.subject, st.predicate, st.object, doc);
|
|
574
|
+
for (const [s, p, o] of deletes) for (const st of after.statementsMatching(s, p, o, doc)) after.remove(st);
|
|
575
|
+
for (const [s, p, o] of inserts) if (!after.holds(s, p, o, doc)) after.add(s, p, o, doc);
|
|
576
|
+
let text;
|
|
577
|
+
try { text = $rdf.serialize(doc, after, docUrl, 'text/turtle'); } catch (e) {
|
|
578
|
+
throw new Error(`not written: ${docUrl} would not serialise (${e.message})`);
|
|
579
|
+
}
|
|
580
|
+
const back = $rdf.graph();
|
|
581
|
+
try { $rdf.parse(text, back, docUrl, 'text/turtle'); } catch (e) {
|
|
582
|
+
throw new Error(`not written: ${docUrl} would not parse back (${e.message})`);
|
|
583
|
+
}
|
|
584
|
+
if (back.statementsMatching(null, null, null, doc).length !== after.statementsMatching(null, null, null, doc).length) {
|
|
585
|
+
throw new Error(`not written: ${docUrl} would not read back as written`);
|
|
586
|
+
}
|
|
587
|
+
if (mustDescribe && !back.statementsMatching($rdf.sym(mustDescribe), null, null, doc).length) {
|
|
588
|
+
throw new Error(`not written: ${docUrl} would no longer describe ${mustDescribe}`);
|
|
589
|
+
}
|
|
590
|
+
return text;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Change one document, or refuse. `g` is the document as read (null for a
|
|
595
|
+
* new one). A PATCH carries only these statements; where the pod cannot
|
|
596
|
+
* patch, the whole checked document is written. Returns the write's status.
|
|
597
|
+
*/
|
|
598
|
+
async writeRdfChecked(docUrl, g, { inserts = [], deletes = [], mustDescribe = null }) {
|
|
599
|
+
const current = g || $rdf.graph();
|
|
600
|
+
const doc = $rdf.sym(docUrl);
|
|
601
|
+
const todo = inserts.filter(([s, p, o]) => !current.holds(s, p, o, doc));
|
|
602
|
+
const gone = deletes.filter(([s, p, o]) => current.holds(s, p, o, doc));
|
|
603
|
+
if (!todo.length && !gone.length) return 200;
|
|
604
|
+
const text = this.checkedRdf(current, docUrl, { inserts: todo, deletes: gone, mustDescribe });
|
|
605
|
+
if (g) {
|
|
606
|
+
let res = null;
|
|
607
|
+
try {
|
|
608
|
+
res = await this.fetch(docUrl, { method: 'PATCH', headers: { 'content-type': 'text/n3' },
|
|
609
|
+
body: this.n3Patch(docUrl, todo, gone) });
|
|
610
|
+
} catch { res = null; }
|
|
611
|
+
if (res && res.status < 300) return res.status;
|
|
612
|
+
// A pod that cannot patch gets the whole checked document; any other
|
|
613
|
+
// answer — a refusal, a conflict — is the answer.
|
|
614
|
+
if (res && ![405, 415, 501].includes(res.status)) return res.status;
|
|
615
|
+
}
|
|
616
|
+
const put = await this.fetch(docUrl, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: text });
|
|
617
|
+
return put.status;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The profile and the documents its rdfs:seeAlso names inside this pod,
|
|
622
|
+
* each with its graph, the profile first. What any of them says about the
|
|
623
|
+
* WebID is what the profile says.
|
|
624
|
+
*/
|
|
625
|
+
async profileDocs(podBase) {
|
|
626
|
+
const profileUrl = this.webId.split('#')[0];
|
|
627
|
+
const g = await this.readRdf(profileUrl);
|
|
628
|
+
if (!g) throw new Error(`no profile at ${profileUrl}`);
|
|
629
|
+
const out = [{ url: profileUrl, g, profile: true }];
|
|
630
|
+
const seen = new Set([profileUrl]);
|
|
631
|
+
for (const st of g.statementsMatching(null, RDFS('seeAlso'), null)) {
|
|
632
|
+
const url = st.object.value.split('#')[0];
|
|
633
|
+
if (seen.has(url) || !url.startsWith(podBase)) continue; // inside this pod only
|
|
634
|
+
seen.add(url);
|
|
635
|
+
out.push({ url, g: await this.readRdf(url).catch(() => null), profile: false });
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Every value the WebID has for `predicate`, across the profile and its seeAlso documents. */
|
|
641
|
+
webIdValues(docs, predicate) {
|
|
642
|
+
const me = $rdf.sym(this.webId);
|
|
643
|
+
const p = $rdf.sym(predicate);
|
|
644
|
+
const out = [];
|
|
645
|
+
for (const { g } of docs) for (const st of g?.statementsMatching(me, p, null) || []) out.push(st.object.value);
|
|
646
|
+
return [...new Set(out)];
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Write statements about the WebID: to the profile, or — if the profile will
|
|
651
|
+
* not take them — to the first of its seeAlso documents that will. Every
|
|
652
|
+
* write checked. Returns the document written to, or null when nothing
|
|
653
|
+
* needed writing; throws when none would take it.
|
|
654
|
+
*/
|
|
655
|
+
async writeAboutWebId(podBase, { inserts = [], deletes = [] }) {
|
|
656
|
+
const docs = await this.profileDocs(podBase);
|
|
657
|
+
const holds = ([s, p, o]) => docs.some(({ g }) => g?.holds(s, p, o));
|
|
658
|
+
const todo = inserts.filter(t => !holds(t));
|
|
659
|
+
const gone = deletes.filter(holds);
|
|
660
|
+
if (!todo.length && !gone.length) return null;
|
|
661
|
+
const refusals = [];
|
|
662
|
+
for (const d of docs) {
|
|
663
|
+
const here = gone.filter(([s, p, o]) => d.g?.holds(s, p, o));
|
|
664
|
+
const status = await this.writeRdfChecked(d.url, d.g, {
|
|
665
|
+
inserts: todo, deletes: here, mustDescribe: d.profile ? this.webId : null,
|
|
666
|
+
});
|
|
667
|
+
if (status < 300) return d.url;
|
|
668
|
+
refusals.push(`${d.url} → ${status}`);
|
|
669
|
+
if (status !== 401 && status !== 403) break; // a real answer, not "not yours to write"
|
|
670
|
+
}
|
|
671
|
+
throw new Error(`not written: neither the profile nor a document it names would take it (${refusals.join('; ')})`);
|
|
672
|
+
}
|
|
673
|
+
|
|
492
674
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// type-index.mjs — an account's place, recorded in the person's public type
|
|
2
|
+
// index: the list Solid apps read to find what kind of thing lives where.
|
|
3
|
+
//
|
|
4
|
+
// The record is a registration saying there is an instance of `as:Actor` at the
|
|
5
|
+
// account's actor on the pod:
|
|
6
|
+
//
|
|
7
|
+
// <#actor-…> a solid:TypeRegistration;
|
|
8
|
+
// solid:forClass as:Actor;
|
|
9
|
+
// solid:instance <…/ap/actor>.
|
|
10
|
+
//
|
|
11
|
+
// The instance is the actor at the pod, not a gateway's address for it, so the
|
|
12
|
+
// place can be read off it. It is also how the account is found again.
|
|
13
|
+
//
|
|
14
|
+
// Every write goes through the transport's checked writers: valid RDF, to the
|
|
15
|
+
// profile or a document its seeAlso names, or nothing.
|
|
16
|
+
|
|
17
|
+
const SOLID = 'http://www.w3.org/ns/solid/terms#';
|
|
18
|
+
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
19
|
+
const AS_ACTOR = 'https://www.w3.org/ns/activitystreams#Actor';
|
|
20
|
+
const PUBLIC_READ = ['Read'];
|
|
21
|
+
|
|
22
|
+
/** Where a new public type index goes when a person has none. */
|
|
23
|
+
export const newIndexUrl = (podBase) => `${podBase}settings/publicTypeIndex.ttl`;
|
|
24
|
+
|
|
25
|
+
/** The person's public type index, or null when their profile names none. */
|
|
26
|
+
export async function findPublicIndex(pod, podBase) {
|
|
27
|
+
const docs = await pod.profileDocs(podBase);
|
|
28
|
+
return pod.webIdValues(docs, SOLID + 'publicTypeIndex')[0] || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every actor the index registers, as the instances it names. */
|
|
32
|
+
export async function actorsIn(pod, indexUrl) {
|
|
33
|
+
const g = await pod.readRdf(indexUrl).catch(() => null);
|
|
34
|
+
if (!g) return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const reg of g.each(null, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR))) {
|
|
37
|
+
for (const inst of g.each(reg, pod.sym(SOLID + 'instance'), null)) out.push(inst.value);
|
|
38
|
+
}
|
|
39
|
+
return [...new Set(out)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One registration per actor, named by the actor, so registering again finds
|
|
43
|
+
// the same one.
|
|
44
|
+
function regFor(indexUrl, actorUrl) {
|
|
45
|
+
let h = 0;
|
|
46
|
+
for (const c of actorUrl) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
47
|
+
return `${indexUrl}#actor-${h.toString(36)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Add the account's registration, unless the index already has one for it. */
|
|
51
|
+
export async function register(pod, indexUrl, actorUrl) {
|
|
52
|
+
const g = await pod.readRdf(indexUrl);
|
|
53
|
+
if (!g) throw new Error(`no type index at ${indexUrl}`);
|
|
54
|
+
if ((await actorsIn(pod, indexUrl)).includes(actorUrl)) return false;
|
|
55
|
+
const reg = pod.sym(regFor(indexUrl, actorUrl));
|
|
56
|
+
const status = await pod.writeRdfChecked(indexUrl, g, {
|
|
57
|
+
inserts: [
|
|
58
|
+
[reg, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeRegistration')],
|
|
59
|
+
[reg, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR)],
|
|
60
|
+
[reg, pod.sym(SOLID + 'instance'), pod.sym(actorUrl)],
|
|
61
|
+
],
|
|
62
|
+
mustDescribe: indexUrl,
|
|
63
|
+
});
|
|
64
|
+
if (status >= 300) throw new Error(`the type index at ${indexUrl} did not take the registration (${status})`);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Make a public type index and name it from the profile. Only ever called
|
|
70
|
+
* after the person has said yes. An index already at the usual place is kept,
|
|
71
|
+
* not overwritten; it is only named.
|
|
72
|
+
*/
|
|
73
|
+
export async function createPublicIndex(pod, podBase) {
|
|
74
|
+
const url = newIndexUrl(podBase);
|
|
75
|
+
const doc = pod.sym(url);
|
|
76
|
+
const existing = await pod.readRdf(url).catch(() => null);
|
|
77
|
+
if (!existing) {
|
|
78
|
+
const status = await pod.writeRdfChecked(url, null, {
|
|
79
|
+
inserts: [[doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeIndex')],
|
|
80
|
+
[doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'ListedDocument')]],
|
|
81
|
+
mustDescribe: url,
|
|
82
|
+
});
|
|
83
|
+
if (status >= 300) throw new Error(`could not make a type index at ${url} (${status})`);
|
|
84
|
+
}
|
|
85
|
+
// Public, as the profile is: an app finds your things by reading it.
|
|
86
|
+
await pod.setAcl(url, PUBLIC_READ);
|
|
87
|
+
await pod.writeAboutWebId(podBase, {
|
|
88
|
+
inserts: [[pod.sym(pod.webId), pod.sym(SOLID + 'publicTypeIndex'), doc]],
|
|
89
|
+
});
|
|
90
|
+
return url;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The actors this person's index says live in this pod. Empty when there is no
|
|
95
|
+
* index or nothing registered.
|
|
96
|
+
*/
|
|
97
|
+
export async function registeredActors(pod, podBase) {
|
|
98
|
+
const index = await findPublicIndex(pod, podBase).catch(() => null);
|
|
99
|
+
if (!index) return [];
|
|
100
|
+
return (await actorsIn(pod, index)).filter(a => a.startsWith(podBase) && a.endsWith('ap/actor'));
|
|
101
|
+
}
|
package/lib/pod/urls.mjs
CHANGED
|
@@ -46,6 +46,12 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
|
|
|
46
46
|
pendingFollowers: face + 'ap/private/pending-followers',
|
|
47
47
|
pendingFollowing: face + 'ap/private/pending-following',
|
|
48
48
|
blocked: face + 'ap/private/blocked',
|
|
49
|
+
// The outbox as its owner reads it (every message, §5.1) and what the
|
|
50
|
+
// owner has liked (§5.5): the owner's alone, so they sit here too — and
|
|
51
|
+
// at the pod's own address even for a fronted identity, because the owner
|
|
52
|
+
// reads them there, with a credential the pod checks.
|
|
53
|
+
ownOutbox: home + 'ap/private/outbox',
|
|
54
|
+
liked: home + 'ap/private/liked',
|
|
49
55
|
profileHtml: face + 'ap/profile.html',
|
|
50
56
|
// Media stays on the pod even when fronted: attachment urls are not
|
|
51
57
|
// identity-checked by remotes, and proxying blobs would be pure cost.
|
package/lib/server/embed.mjs
CHANGED
|
@@ -45,10 +45,10 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
|
|
|
45
45
|
* secret is moved into the pod and the host's copy removed, so the owner's
|
|
46
46
|
* existing door link goes on working.
|
|
47
47
|
*/
|
|
48
|
-
export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
|
|
48
|
+
export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, root = 'fedipod/', log = () => {} } = {}) {
|
|
49
49
|
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
50
|
-
// Under the identity's own tree
|
|
51
|
-
const url = apUrls(base,
|
|
50
|
+
// Under the identity's own tree, where the gate reads it back.
|
|
51
|
+
const url = apUrls(base, root).state + 'door-secret.json';
|
|
52
52
|
const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
|
|
53
53
|
|
|
54
54
|
if (!rotate) {
|
|
@@ -125,7 +125,7 @@ export function handleFor(podBase) {
|
|
|
125
125
|
* pod does not travel to, so handing the pod over hands over an identity that
|
|
126
126
|
* cannot sign. An identity set up before this gets the mode added.
|
|
127
127
|
*/
|
|
128
|
-
function ensureCredential(home, { podBase, webId }) {
|
|
128
|
+
function ensureCredential(home, { podBase, webId, root = 'fedipod/' }) {
|
|
129
129
|
const file = path.join(home, 'credential.json');
|
|
130
130
|
try {
|
|
131
131
|
const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -138,7 +138,7 @@ function ensureCredential(home, { podBase, webId }) {
|
|
|
138
138
|
webId,
|
|
139
139
|
remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
|
|
140
140
|
createdAt: new Date().toISOString(),
|
|
141
|
-
root
|
|
141
|
+
root,
|
|
142
142
|
keysMode: 'pod',
|
|
143
143
|
};
|
|
144
144
|
writeJsonAtomic(file, rec, { mode: 0o600 });
|
|
@@ -269,6 +269,7 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
|
|
|
269
269
|
*/
|
|
270
270
|
export async function startEmbeddedAgent({
|
|
271
271
|
podBase,
|
|
272
|
+
root = 'fedipod/',
|
|
272
273
|
dataDir,
|
|
273
274
|
session,
|
|
274
275
|
resourceStore = null,
|
|
@@ -285,7 +286,7 @@ export async function startEmbeddedAgent({
|
|
|
285
286
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
286
287
|
|
|
287
288
|
const webId = base + webIdSuffix.replace(/^\//u, '');
|
|
288
|
-
const cred = ensureCredential(home, { podBase: base, webId });
|
|
289
|
+
const cred = ensureCredential(home, { podBase: base, webId, root });
|
|
289
290
|
|
|
290
291
|
const agent = new Agent({ home, log });
|
|
291
292
|
agent.log = log;
|
package/lib/session/README.md
CHANGED
|
@@ -18,6 +18,9 @@ Once signed in, the account gives you:
|
|
|
18
18
|
- **`post`** — publishes a post as them, public, unlisted or followers-only.
|
|
19
19
|
- **`reply`** — answers a post.
|
|
20
20
|
- **`timeline`** — their home timeline, newest first.
|
|
21
|
+
- **`outbox`** — their own posts, newest first. Pass `rdf: true` to also get
|
|
22
|
+
the real thing as RDF, for an app that wants to work with it as linked
|
|
23
|
+
data rather than as this library's own shape.
|
|
21
24
|
- **`follow`** — follows someone by handle.
|
|
22
25
|
- **`favourite`**, **`boost`** — as they say.
|
|
23
26
|
- **`profile`** — who they are as their server or pod shows them: name,
|
|
@@ -37,6 +40,8 @@ DeviceAgent or Server account, and when fedipod.net is next open for a
|
|
|
37
40
|
browser-based one.
|
|
38
41
|
|
|
39
42
|
Three files, no dependencies. It runs in a page and in a service worker.
|
|
43
|
+
The one exception: `outbox({ rdf: true })` needs the `jsonld` package, and
|
|
44
|
+
only loads it when that flag is actually used.
|
|
40
45
|
|
|
41
46
|
[The demo](https://jeff-zucker.github.io/FediPod/) shows the sign-in and
|
|
42
47
|
the profile that comes back.
|
|
@@ -73,6 +78,7 @@ const me = await accounts.current(); // null when nobody i
|
|
|
73
78
|
if (me?.notice) show(me.notice);
|
|
74
79
|
await me.post({ text: 'Hello from my app' });
|
|
75
80
|
for (const p of await me.timeline({ limit: 20 })) render(p);
|
|
81
|
+
for (const p of await me.outbox({ limit: 20 })) render(p);
|
|
76
82
|
await me.follow('@aisha@her.server');
|
|
77
83
|
await me.reply(p.url, 'Well said');
|
|
78
84
|
await me.signOut();
|
|
@@ -93,6 +99,14 @@ await me.signOut();
|
|
|
93
99
|
- **`reply(post, text)`** — the post is named by its address or its id.
|
|
94
100
|
- **`timeline({ limit })`** — each post as `{ id, url, author: { id, handle,
|
|
95
101
|
name }, html, published, inReplyTo }`.
|
|
102
|
+
- **`outbox({ limit, rdf })`** — the same shape as `timeline`, but only this
|
|
103
|
+
account's own posts. With `rdf: true`, the returned array also carries
|
|
104
|
+
`.rdf`: the real outbox parsed into an RDF/JS quad array — for a Mastodon
|
|
105
|
+
account too, since a Mastodon server is itself an ActivityPub server with
|
|
106
|
+
a real actor and outbox, same as a pod's. It comes back empty on a server
|
|
107
|
+
that requires a signed request just to read that document, which some
|
|
108
|
+
do. Reading it needs the `jsonld` package available to your app; without
|
|
109
|
+
`rdf: true` nothing changes and nothing extra loads.
|
|
96
110
|
- **`fetch`** — the raw authenticated fetch, for anything the above does not
|
|
97
111
|
cover.
|
|
98
112
|
- **`actor`** — the account's ActivityPub id.
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// await me.post({ text }); await me.timeline({ limit: 20 })
|
|
16
16
|
// await me.reply(postUrlOrId, text); await me.follow('@aisha@her.server')
|
|
17
17
|
// await me.favourite(postUrlOrId); await me.boost(postUrlOrId)
|
|
18
|
+
// await me.outbox({ limit: 20 }); // this account's own posts, newest first
|
|
19
|
+
// await me.outbox({ rdf: true }); // the same, plus the real RDF graph on .rdf
|
|
18
20
|
// await me.signOut()
|
|
19
21
|
//
|
|
20
22
|
// A Mastodon account is used through its server's API with a token the
|
|
@@ -58,6 +60,44 @@ export function fediAccount({
|
|
|
58
60
|
return body;
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
// A collection, or a page of one, may be named by its address or given
|
|
64
|
+
// whole in place (ActivityPub allows either); the whole one is used as it
|
|
65
|
+
// is, an address is fetched.
|
|
66
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id || null);
|
|
67
|
+
const asDoc = async (v) => (v && typeof v === 'object'
|
|
68
|
+
? (v.type ? v : await asDoc(v.id))
|
|
69
|
+
: (typeof v === 'string' ? await json(v, { headers: { accept: 'application/activity+json' } }) : null));
|
|
70
|
+
|
|
71
|
+
// Only loaded when `outbox({ rdf: true })` is actually called, so nobody
|
|
72
|
+
// pays for a JSON-LD parser just by importing this file. Not in
|
|
73
|
+
// `dependencies` — the app supplies "jsonld" if it wants this flag to work.
|
|
74
|
+
const loadJsonLd = async () => {
|
|
75
|
+
try { return (await import('jsonld')).default; }
|
|
76
|
+
catch { throw new Error('outbox({ rdf: true }) needs the "jsonld" package available to the app — it is not bundled with this library'); }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// The account's real outbox, read from the pod: the head collection names
|
|
80
|
+
// `first`, each page names `orderedItems` and (while there is more) `next`.
|
|
81
|
+
// Walked only far enough to cover `limit`, and run through jsonld.toRDF so
|
|
82
|
+
// the result is a genuine RDF/JS quad array, not this library's own shape.
|
|
83
|
+
const fetchOutboxRdf = async (actorUrl, limit) => {
|
|
84
|
+
if (!actorUrl) return null;
|
|
85
|
+
const jsonld = await loadJsonLd();
|
|
86
|
+
const actorDoc = await json(actorUrl, { headers: { accept: 'application/activity+json' } });
|
|
87
|
+
const head = await asDoc(actorDoc?.outbox);
|
|
88
|
+
let page = head?.first ?? null;
|
|
89
|
+
const quads = []; const seen = new Set(); let collected = 0;
|
|
90
|
+
while (page && collected < limit && !seen.has(idOf(page) || page)) {
|
|
91
|
+
seen.add(idOf(page) || page);
|
|
92
|
+
const doc = await asDoc(page);
|
|
93
|
+
if (!doc) break;
|
|
94
|
+
quads.push(...(await jsonld.toRDF(doc)));
|
|
95
|
+
collected += (doc.orderedItems || []).length;
|
|
96
|
+
page = doc.next ?? null;
|
|
97
|
+
}
|
|
98
|
+
return quads;
|
|
99
|
+
};
|
|
100
|
+
|
|
61
101
|
// ---- finding out what an address is ----
|
|
62
102
|
|
|
63
103
|
// Does this host speak the Mastodon API? Any server of that family says so
|
|
@@ -195,7 +235,7 @@ export function fediAccount({
|
|
|
195
235
|
let actor = p.actor || pick(me, FOAF_ACCOUNT);
|
|
196
236
|
let door = pick(me, AS_OUTBOX);
|
|
197
237
|
const doc = actor ? await json(actor, { headers: { accept: 'application/activity+json' } }) : null;
|
|
198
|
-
if (!door && doc?.outbox) door = doc.outbox;
|
|
238
|
+
if (!door && doc?.outbox) door = idOf(doc.outbox);
|
|
199
239
|
let root = p.root || null;
|
|
200
240
|
if (!root && doc?.preferredUsername && actor) {
|
|
201
241
|
const { aliases } = await webfinger(doc.preferredUsername, new URL(actor).host).catch(() => ({ aliases: [] }));
|
|
@@ -255,6 +295,20 @@ export function fediAccount({
|
|
|
255
295
|
const list = await call(`/api/v1/timelines/home?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
|
|
256
296
|
return (Array.isArray(list) ? list : []).map(item);
|
|
257
297
|
},
|
|
298
|
+
// This account's own posts, newest first — Mastodon's equivalent of an
|
|
299
|
+
// outbox. A Mastodon server IS an ActivityPub server, so its actor and
|
|
300
|
+
// outbox are real AS2/JSON-LD too, same as a pod's; `rdf: true` reads
|
|
301
|
+
// that, not the REST API above. It comes back empty on an instance that
|
|
302
|
+
// requires a signed request just to read the public actor document
|
|
303
|
+
// (some do — mastodon.social among them); `fetchOutboxRdf`'s plain GET
|
|
304
|
+
// then gets 401 and the loop below finds nothing to walk.
|
|
305
|
+
async outbox({ limit = 20, rdf = false } = {}) {
|
|
306
|
+
const me = await call('/api/v1/accounts/verify_credentials').then((r) => said(r, a.host));
|
|
307
|
+
const list = await call(`/api/v1/accounts/${me.id}/statuses?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
|
|
308
|
+
const items = (Array.isArray(list) ? list : []).map(item);
|
|
309
|
+
if (rdf) items.rdf = await fetchOutboxRdf(a.actor, limit);
|
|
310
|
+
return items;
|
|
311
|
+
},
|
|
258
312
|
async follow(handle) {
|
|
259
313
|
const who = parseAddress(handle);
|
|
260
314
|
if (!who?.at) throw new Error('a handle looks like @you@your.server');
|
|
@@ -295,7 +349,7 @@ export function fediAccount({
|
|
|
295
349
|
fetch: session.fetch,
|
|
296
350
|
async profile() {
|
|
297
351
|
const doc = facts.actor ? await json(facts.actor, { headers: { accept: 'application/activity+json' } }) : null;
|
|
298
|
-
const count = async (
|
|
352
|
+
const count = async (v) => (await asDoc(v))?.totalItems ?? null;
|
|
299
353
|
return { handle: facts.handle, name: doc?.name || null, url: doc?.url || null, avatar: doc?.icon?.url || (typeof doc?.icon === 'string' ? doc.icon : null), bio: doc?.summary || '',
|
|
300
354
|
followers: await count(doc?.followers), following: await count(doc?.following), posts: await count(doc?.outbox), actor: facts.actor, webId: facts.webId };
|
|
301
355
|
},
|
|
@@ -314,6 +368,23 @@ export function fediAccount({
|
|
|
314
368
|
.slice(0, limit)
|
|
315
369
|
.map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
|
|
316
370
|
},
|
|
371
|
+
// This account's own posts, newest first, read from the same local
|
|
372
|
+
// record `timeline` uses (fast, no network call). With `rdf: true`,
|
|
373
|
+
// also fetches the real outbox from the pod and parses it into RDF/JS
|
|
374
|
+
// quads on the returned array's `.rdf` — a second, live document, not
|
|
375
|
+
// derived from the local record, so it needs `jsonld` (see loadJsonLd).
|
|
376
|
+
async outbox({ limit = 20, rdf = false } = {}) {
|
|
377
|
+
const rows = await state('statuses.json');
|
|
378
|
+
const actors = (await state('actors.json')) || {};
|
|
379
|
+
const who = (id) => { const d = actors[id]?.doc || actors[id] || {}; return { id, handle: d.preferredUsername ? `@${d.preferredUsername}@${new URL(id).host}` : null, name: d.name || null }; };
|
|
380
|
+
const items = (Array.isArray(rows) ? rows : [])
|
|
381
|
+
.filter((s) => s.kind === 'post')
|
|
382
|
+
.sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
|
|
383
|
+
.slice(0, limit)
|
|
384
|
+
.map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
|
|
385
|
+
if (rdf) items.rdf = await fetchOutboxRdf(facts.actor, limit);
|
|
386
|
+
return items;
|
|
387
|
+
},
|
|
317
388
|
async follow(handle) {
|
|
318
389
|
const who = parseAddress(handle);
|
|
319
390
|
if (!who?.at) throw new Error('a handle looks like @you@your.server');
|
package/lib/session/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fediverse-account",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Type a Fediverse handle or a WebID and get back an account you can act with: post, read its timeline, follow, reply, favourite, boost. Works out whether the account lives on a Mastodon-family server or on a Solid pod through FediPod, sends the person to sign in there, brings them back to where they were, and speaks to whichever it is.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,5 +37,13 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"prepublishOnly": "node ../../scripts/check-pod-calls.mjs"
|
|
39
39
|
},
|
|
40
|
-
"dependencies": {}
|
|
40
|
+
"dependencies": {},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"jsonld": "^9.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"jsonld": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
41
49
|
}
|