fedipod-server 0.12.1 → 0.13.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.
package/README.md CHANGED
@@ -9,6 +9,13 @@ account: it accepts follows, delivers their posts, and serves their Mastodon
9
9
  client at the pod's own address. Signing up is the only way an account is
10
10
  made, and opting out is the only way one ends.
11
11
 
12
+ Every delivery to an account is checked as it arrives. The server that stores
13
+ the inbox is the server the other side's POST reaches, so the request's
14
+ signature is verified there and the result is written beside the activity. A
15
+ delivery signed with the wrong key is dropped. One with no signature is kept
16
+ and the account confirms the sender through the sender's own actor document
17
+ before acting on it. There is nothing to configure.
18
+
12
19
  With nothing configured beyond the defaults, installing the component changes
13
20
  nothing about how the server serves pods. All pods, whether or not they opt-in
14
21
  to being a Fediverse account, behave as Solid pods.
package/dist/claims.d.ts CHANGED
@@ -2,6 +2,13 @@ export declare function claims(input: {
2
2
  host?: string;
3
3
  pathname: string;
4
4
  }, frontHost: string): boolean;
5
+ /**
6
+ * The inbox container of an identity this server runs, under the root the
7
+ * embedded credential uses (lib/server/embed.mjs). A delivery POSTed here is
8
+ * verified at the door before it is written, so the request is claimed from
9
+ * the LDP handler; every other method on the container is the pod's.
10
+ */
11
+ export declare const INBOX_PATH = "/activitypods-js/ap/inbox/";
5
12
  /**
6
13
  * True when this request belongs to an identity's client surface.
7
14
  * `agentHosts` is keyed by host including port, as the Host header carries it.
@@ -9,4 +16,5 @@ export declare function claims(input: {
9
16
  export declare function agentClaims(input: {
10
17
  host?: string;
11
18
  pathname: string;
19
+ method?: string;
12
20
  }, agentHosts: Set<string>, uiPath?: string): boolean;
package/dist/claims.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // front's apex the gateway answers the fediverse routes; a pod subdomain is a
5
5
  // real Solid pod and is never claimed.
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.INBOX_PATH = void 0;
7
8
  exports.claims = claims;
8
9
  exports.agentClaims = agentClaims;
9
10
  const FRONT_PATHS = new Set(['/', '/signup', '/new-account', '/run', '/roster',
@@ -35,6 +36,13 @@ const AGENT_PATHS = new Set([
35
36
  '/.well-known/oauth-authorization-server',
36
37
  ]);
37
38
  const AGENT_PREFIXES = ['/api/', '/oauth/'];
39
+ /**
40
+ * The inbox container of an identity this server runs, under the root the
41
+ * embedded credential uses (lib/server/embed.mjs). A delivery POSTed here is
42
+ * verified at the door before it is written, so the request is claimed from
43
+ * the LDP handler; every other method on the container is the pod's.
44
+ */
45
+ exports.INBOX_PATH = '/activitypods-js/ap/inbox/';
38
46
  /**
39
47
  * True when this request belongs to an identity's client surface.
40
48
  * `agentHosts` is keyed by host including port, as the Host header carries it.
@@ -47,6 +55,8 @@ function agentClaims(input, agentHosts, uiPath = '/fedipod/') {
47
55
  const { pathname } = input;
48
56
  if (AGENT_PATHS.has(pathname))
49
57
  return true;
58
+ if (pathname === exports.INBOX_PATH)
59
+ return String(input.method ?? '').toUpperCase() === 'POST';
50
60
  if (AGENT_PREFIXES.some((prefix) => pathname.startsWith(prefix)))
51
61
  return true;
52
62
  // The owner's door, when there is one: '' turns the pages off entirely.
package/dist/handler.d.ts CHANGED
@@ -138,6 +138,13 @@ export declare class FediPodServerHandler extends HttpHandler implements Initial
138
138
  }): Promise<Record<string, unknown> & {
139
139
  httpStatus: number;
140
140
  }>;
141
+ /**
142
+ * A delivery to an identity's own inbox, verified here — the server that
143
+ * stores the inbox is the one the request reached, so the signature is
144
+ * checked while its headers exist and the receipt is written beside the
145
+ * activity. The same door code the front runs; nothing is renamed.
146
+ */
147
+ private deliverAtDoor;
141
148
  handle({ request, response }: HttpHandlerInput): Promise<void>;
142
149
  }
143
150
  export {};
package/dist/handler.js CHANGED
@@ -325,7 +325,7 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
325
325
  // starting, and then stop being served once it has.
326
326
  if ((0, claims_1.claims)({ host, pathname }, this.frontHost))
327
327
  return;
328
- if ((0, claims_1.agentClaims)({ host, pathname }, this.agentHosts, this.uiPath))
328
+ if ((0, claims_1.agentClaims)({ host, pathname, method: request.method }, this.agentHosts, this.uiPath))
329
329
  return;
330
330
  throw new Error('not a gateway route'); // reject → CSS's LDP handler takes it
331
331
  }
@@ -413,6 +413,33 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
413
413
  this.logger.info(`runtime opt-out: @${row.handle} on ${base} — the pod serves plain LDP again`);
414
414
  return { httpStatus: 200, ok: true, stopped: Boolean(identity) };
415
415
  }
416
+ /**
417
+ * A delivery to an identity's own inbox, verified here — the server that
418
+ * stores the inbox is the one the request reached, so the signature is
419
+ * checked while its headers exist and the receipt is written beside the
420
+ * activity. The same door code the front runs; nothing is renamed.
421
+ */
422
+ async deliverAtDoor(identity, request, response) {
423
+ const { deliverToInbox } = await esmImport(EMBED);
424
+ let whatwg;
425
+ try {
426
+ // The pod's own origin: the signature covers the path and the Host header, and both are the pod's.
427
+ whatwg = await (0, adapt_1.nodeToWhatwg)(request, new URL(identity.podHome).origin);
428
+ }
429
+ catch (e) {
430
+ const status = e.statusCode === 413 ? 413 : 400;
431
+ response.writeHead(status, { 'content-type': 'application/json' });
432
+ response.end(JSON.stringify({ error: e.message }));
433
+ return;
434
+ }
435
+ const out = await deliverToInbox(identity.agent, whatwg, {
436
+ podPut: (url, body, ct) => this.podPut(url, body, ct),
437
+ gatewayWebId: this.args.gatewayWebId ?? null,
438
+ });
439
+ this.logger.info(`FediPod: delivery for @${identity.handle} at the door — ${out.reason} (${out.status})`);
440
+ response.writeHead(out.status, { 'content-type': 'application/json' });
441
+ response.end(JSON.stringify({ reason: out.reason }));
442
+ }
416
443
  async handle({ request, response }) {
417
444
  const host = String(request.headers.host ?? '').toLowerCase();
418
445
  if (this.agentHosts.has(host)) {
@@ -432,6 +459,11 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
432
459
  response.end(JSON.stringify({ error: 'this identity is still starting' }));
433
460
  return;
434
461
  }
462
+ const pathname = new URL(request.url ?? '/', `https://${host}`).pathname;
463
+ if (pathname === claims_1.INBOX_PATH && String(request.method).toUpperCase() === 'POST') {
464
+ await this.deliverAtDoor(identity, request, response);
465
+ return;
466
+ }
435
467
  await identity.surface.handler(request, response);
436
468
  return;
437
469
  }
@@ -344,6 +344,10 @@
344
344
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_optOutPod",
345
345
  "memberFieldName": "optOutPod"
346
346
  },
347
+ {
348
+ "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_deliverAtDoor",
349
+ "memberFieldName": "deliverAtDoor"
350
+ },
347
351
  {
348
352
  "@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_handle",
349
353
  "memberFieldName": "handle"
@@ -192,12 +192,14 @@ export class C2S {
192
192
  }
193
193
 
194
194
  // Read as JSON-LD, so a client may send its activity with whatever context
195
- // it likes and still be understood. One we cannot read that way is read as
196
- // plain JSON rather than refused, which is what a client sending ordinary
197
- // ActivityStreams has always got.
195
+ // it likes and still be understood. What is read is the GRAPH: `dispatch`
196
+ // takes decisions from it and the publisher builds the document that is
197
+ // actually posted, so nothing a client sent is republished verbatim and a
198
+ // term it aliased still means what it says.
198
199
  let activity;
199
200
  try {
200
- activity = (await readLenient(await readBody(req))).doc;
201
+ const read = await readLenient(await readBody(req));
202
+ activity = read.view ?? read.doc;
201
203
  } catch (e) {
202
204
  return this.send(res, 400, { error: `unreadable body: ${e.message}` });
203
205
  }
package/lib/core/as2.mjs CHANGED
@@ -4,8 +4,12 @@
4
4
  // read it as one. Expanding it to a graph is what makes the document mean the
5
5
  // same thing however its sender chose to write it — `"type": "Create"` and
6
6
  // `"@type": "as:Create"` are the same statement, and only a JSON-LD reader
7
- // knows that. Compacting it back against the standard context is what lets the
8
- // handlers stay as they are: whatever arrived, they see an ordinary document.
7
+ // knows that.
8
+ //
9
+ // The graph is what the handlers read, through lib/core/graphview.mjs. `doc`,
10
+ // the copy compacted against the standard context, is kept for the callers
11
+ // that republish what they read rather than act on it — compaction alone drops
12
+ // any term the sender defined in its own context, so it is the weaker read.
9
13
  //
10
14
  // What we STORE and SEND is untouched by any of this. Bytes go into the pod as
11
15
  // they arrived, and lib/core/wire.mjs builds what we send as compacted AS2
@@ -15,16 +19,18 @@
15
19
  // common shape: one that skipped it would not be read as JSON-LD, and would
16
20
  // produce no graph, so nothing could be validated against it.
17
21
  //
18
- // CONTEXTS ARE NEVER FETCHED. `contextsFor` serves the fourteen the fediverse
22
+ // CONTEXTS ARE NEVER FETCHED. The loader serves the fourteen the fediverse
19
23
  // actually uses and refuses every other URL. That refusal is the point: this
20
24
  // code runs on documents a stranger wrote, in the same path the SSRF guards
21
25
  // protect, and expanding one would otherwise mean dereferencing a URL of their
22
- // choosing. A document naming a context we do not hold is refused with a
23
- // reason, which is a visible drop rather than a silent one.
26
+ // choosing. A document naming a context we do not hold is not lost for it —
27
+ // `groundContext` reads it against the contexts we hold and says so in
28
+ // `degraded` — but nothing is ever fetched to make that happen.
24
29
 
25
30
  import jsonld from 'jsonld';
26
31
  import { AS_CTX } from './wire.mjs';
27
32
  import { CONTEXTS } from './contexts/index.mjs';
33
+ import { graphView } from './graphview.mjs';
28
34
 
29
35
  export { CONTEXTS };
30
36
 
@@ -99,26 +105,71 @@ export async function parseAS2(raw) {
99
105
  }
100
106
 
101
107
  /**
102
- * Read AS2 bytes, and when they cannot be read as JSON-LD, read them the way
103
- * this project always did.
108
+ * Rewrite a document's `@context` to one we can read without fetching anything.
109
+ *
110
+ * A document carrying no `@context` is not a puzzle: AS2 Core §2.1 says a
111
+ * consumer meeting `application/activity+json` without one MUST assume the
112
+ * normative context still applies. That is what this does. A document naming a
113
+ * context we do not hold keeps every entry we DO hold — inline objects
114
+ * included, since those need no fetch — and loses only the unfetchable ones,
115
+ * so a Mastodon post with an unknown extension still reads as a post.
116
+ *
117
+ * Nothing here dereferences a URL. The result names only contexts already on
118
+ * disk, so the guarantee that a stranger's document cannot make us reach out
119
+ * survives the fallback.
120
+ */
121
+ export function groundContext(input) {
122
+ const ctx = input['@context'];
123
+ const held = (c) => typeof c === 'string' ? Object.hasOwn(CONTEXTS, c) : (c && typeof c === 'object');
124
+ let kept;
125
+ if (ctx === undefined || ctx === null) kept = [];
126
+ else if (Array.isArray(ctx)) kept = ctx.filter(held);
127
+ else kept = held(ctx) ? [ctx] : [];
128
+ if (!kept.some(c => c === AS_CTX)) kept.unshift(AS_CTX);
129
+ return { ...input, '@context': kept.length === 1 ? kept[0] : kept };
130
+ }
131
+
132
+ /**
133
+ * Read AS2 bytes, and when they cannot be read as JSON-LD as written, read
134
+ * them against the contexts we do hold.
104
135
  *
105
136
  * A delivery naming a context we do not hold, or carrying none at all, is
106
- * still a delivery somebody sent. Refusing it here would lose mail that
107
- * arrives perfectly well today, so the JSON-LD read is an improvement on the
108
- * plain one and never a gate in front of it. `degraded` carries the reason
109
- * when the improvement did not happen, so a caller can record it instead of
110
- * letting it pass unnoticed.
137
+ * still a delivery somebody sent. It is now grounded rather than refused, so
138
+ * `graph` is present for anything that is JSON at all — which is what lets the
139
+ * handlers read the graph instead of the document. `degraded` still carries
140
+ * the reason the document could not be read exactly as written, so a caller
141
+ * records what happened instead of letting it pass unnoticed.
111
142
  *
112
- * `doc` is null only when the bytes are not JSON at all, which is what the
113
- * plain read would have concluded too.
143
+ * `doc` and `graph` are null only when the bytes are not JSON at all, which is
144
+ * what the plain read would have concluded too.
114
145
  */
115
146
  export async function readLenient(raw) {
147
+ // The document's own node is the one its `id` names. Handed to the view
148
+ // as the root, so a document that points at itself — every actor does,
149
+ // through its key's `owner` — is still read as itself.
150
+ let input = null;
151
+ try { input = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
152
+ const own = input && typeof input === 'object' ? (input.id ?? input['@id']) : null;
153
+ const root = typeof own === 'string' && /^https?:\/\//u.test(own) ? own : null;
116
154
  try {
117
155
  const { doc, graph } = await parseAS2(raw);
118
- return { doc, graph, degraded: null };
156
+ return { doc, graph, view: graphView(graph, { root }), degraded: null };
119
157
  } catch (e) {
120
- let doc = null;
121
- try { doc = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
122
- return { doc: (doc && typeof doc === 'object') ? doc : null, graph: null, degraded: e.message };
158
+ if (!input || typeof input !== 'object') return { doc: null, graph: null, view: null, degraded: e.message };
159
+ // Grounded, so a graph exists for every document that is JSON at all.
160
+ //
161
+ // `doc` stays the bytes as they were parsed, NOT the grounded compaction.
162
+ // Compacting shortens an IRI to whatever term names it — `#Public` comes
163
+ // back as `as:Public` — and the callers that still read `doc` republish
164
+ // what they read, so rewriting it here silently changed what they posted.
165
+ try {
166
+ const { graph } = await parseAS2(groundContext(input));
167
+ return { doc: input, graph, view: graphView(graph, { root }), degraded: e.message };
168
+ } catch (inner) {
169
+ // Grounding names only held contexts, so reaching here means the document
170
+ // is malformed JSON-LD rather than unfetchable. Hand back what a plain
171
+ // read would have seen, with no graph, as this always did.
172
+ return { doc: input, graph: null, view: null, degraded: `${e.message}; grounded read also failed: ${inner.message}` };
173
+ }
123
174
  }
124
175
  }
@@ -0,0 +1,289 @@
1
+ // graphview.mjs — what the handlers read.
2
+ //
3
+ // `parseAS2` produces quads. This turns them back into something a handler can
4
+ // read by name, so the thing being read is the GRAPH: every property access
5
+ // here is a lookup over statements, not a key on the JSON a stranger sent.
6
+ //
7
+ // That distinction is the whole point. A document can say the same thing in
8
+ // many ways — `"type": "Create"`, `"@type": "as:Create"`, a term aliased by the
9
+ // sender's own context — and all of them leave the same statement in the
10
+ // graph. Reading the graph means a handler sees one answer without knowing any
11
+ // of that happened.
12
+ //
13
+ // Names are resolved through the contexts we already hold, AS2 first. They are
14
+ // not hardcoded: `movedTo` is not in the AS2 context at all (it lives in
15
+ // miscellany), and `inbox` is `ldp:inbox` rather than an `as:` term, so a map
16
+ // written by hand would get both wrong.
17
+
18
+ import { CONTEXTS } from './contexts/index.mjs';
19
+ import { AS_CTX } from './wire.mjs';
20
+
21
+ const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
22
+ const RDF_FIRST = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first';
23
+ const RDF_REST = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest';
24
+ const RDF_NIL = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil';
25
+
26
+ // Properties a handler treats as a collection. JSON-LD compaction would hand
27
+ // back a bare value when there is only one, and `Array.isArray` checks in the
28
+ // handlers then read it as empty. These always answer with an array.
29
+ // A stranger's document decides the shape of this graph, so the walk is capped
30
+ // rather than trusted to terminate on its own.
31
+ const MAX_DEPTH = 12;
32
+
33
+ const ALWAYS_LIST = new Set([
34
+ 'items', 'orderedItems', 'to', 'cc', 'bto', 'bcc', 'tag', 'attachment',
35
+ 'anyOf', 'oneOf', 'audience', 'attributedTo', 'alsoKnownAs',
36
+ ]);
37
+
38
+ /** Expand `as:object` against a context's own prefix declarations. */
39
+ function expandIri(value, prefixes) {
40
+ if (typeof value !== 'string') return null;
41
+ if (/^https?:\/\//.test(value)) return value;
42
+ const colon = value.indexOf(':');
43
+ if (colon < 1) return null;
44
+ const base = prefixes[value.slice(0, colon)];
45
+ return base ? base + value.slice(colon + 1) : null;
46
+ }
47
+
48
+ /**
49
+ * term -> { iri, list } over every context we hold, AS2 winning ties.
50
+ *
51
+ * Built once. A term already claimed by an earlier context is not overwritten,
52
+ * and AS2 is walked first, so an extension cannot quietly redefine `object`.
53
+ */
54
+ function buildTerms() {
55
+ const terms = new Map();
56
+ const order = [AS_CTX, ...Object.keys(CONTEXTS).filter(u => u !== AS_CTX)];
57
+ for (const url of order) {
58
+ const ctx = CONTEXTS[url]?.['@context'];
59
+ if (!ctx || typeof ctx !== 'object') continue;
60
+ const prefixes = {};
61
+ for (const [k, v] of Object.entries(ctx)) {
62
+ if (typeof v === 'string' && /^https?:\/\//.test(v)) prefixes[k] = v;
63
+ }
64
+ for (const [name, def] of Object.entries(ctx)) {
65
+ if (name.startsWith('@') || terms.has(name)) continue;
66
+ const raw = typeof def === 'string' ? def : def?.['@id'];
67
+ const iri = expandIri(raw, prefixes);
68
+ if (!iri) continue;
69
+ terms.set(name, { iri, list: def?.['@container'] === '@list', container: def?.['@container'] ?? null });
70
+ }
71
+ }
72
+ return terms;
73
+ }
74
+
75
+ const TERMS = buildTerms();
76
+
77
+ /** iri -> shortest term naming it, so a type comes back as `Create`. */
78
+ const BY_IRI = (() => {
79
+ const m = new Map();
80
+ for (const [name, { iri }] of TERMS) {
81
+ const held = m.get(iri);
82
+ if (!held || name.length < held.length) m.set(iri, name);
83
+ }
84
+ return m;
85
+ })();
86
+
87
+ /**
88
+ * iri -> every term naming it.
89
+ *
90
+ * More than one term can mean the same predicate: `items` and `orderedItems`
91
+ * are both `as:items`, and only the list tells them apart. Emitting just the
92
+ * shorter name would lose `orderedItems` — and with it the ordering, which is
93
+ * the one thing an OrderedCollection is for.
94
+ */
95
+ const NAMES_BY_IRI = (() => {
96
+ const m = new Map();
97
+ for (const [name, { iri, container }] of TERMS) {
98
+ // `contentMap` and `content` are the same predicate, but a language map is
99
+ // not another spelling of the plain term — emitting one as a bare string
100
+ // would invent a value the document never carried.
101
+ if (container === '@language' || container === '@index') continue;
102
+ const held = m.get(iri);
103
+ if (held) held.push(name); else m.set(iri, [name]);
104
+ }
105
+ return m;
106
+ })();
107
+
108
+ /** Index quads by subject, then by predicate. */
109
+ function indexQuads(quads) {
110
+ const bySubject = new Map();
111
+ const objects = new Set();
112
+ // A BlankNode's `.value` has no `_:` on it, so blankness is recorded here
113
+ // rather than guessed from the string. A blank node has no id, and handing a
114
+ // handler `id: "b0"` would be handing it an identifier that means nothing.
115
+ const blanks = new Set();
116
+ for (const q of quads) {
117
+ if (q.subject.termType === 'BlankNode') blanks.add(q.subject.value);
118
+ if (q.object.termType === 'BlankNode') blanks.add(q.object.value);
119
+ const s = q.subject.value;
120
+ let preds = bySubject.get(s);
121
+ if (!preds) bySubject.set(s, preds = new Map());
122
+ let vals = preds.get(q.predicate.value);
123
+ if (!vals) preds.set(q.predicate.value, vals = []);
124
+ vals.push(q.object);
125
+ if (q.object.termType === 'NamedNode' || q.object.termType === 'BlankNode') {
126
+ objects.add(q.object.value);
127
+ }
128
+ }
129
+ return { bySubject, objects, blanks };
130
+ }
131
+
132
+ /**
133
+ * The document's own node, when the caller could not name it.
134
+ *
135
+ * A named subject nothing points at is the plain case. But an actor is
136
+ * pointed at by its own key (`owner`), so it is never that; and taking "the
137
+ * first typed subject" instead handed back a profile field — a blank
138
+ * PropertyValue that happens to come first in quad order — for every
139
+ * Mastodon actor with fields on it. Named subjects therefore come before
140
+ * blank ones at every step, and a blank node is the root only when nothing
141
+ * named exists at all.
142
+ */
143
+ function findRoot({ bySubject, objects, blanks }) {
144
+ const all = [...bySubject.keys()];
145
+ const named = all.filter((s) => !blanks.has(s));
146
+ // A subject nothing points at is the document's own node — named if there
147
+ // is one, else blank: a client's Update carries no id of its own and is a
148
+ // blank root above the named note it edits.
149
+ const free = all.filter((s) => !objects.has(s));
150
+ const freeNamed = free.find((s) => !blanks.has(s));
151
+ if (freeNamed) return freeNamed;
152
+ if (free.length) return free[0];
153
+ // Everything is pointed at (an actor, through its key's owner): a typed
154
+ // named subject, before any typed blank one.
155
+ for (const s of named) if (bySubject.get(s).has(RDF_TYPE)) return s;
156
+ for (const s of all) if (bySubject.get(s).has(RDF_TYPE)) return s;
157
+ return named[0] ?? all[0] ?? null;
158
+ }
159
+
160
+ /** Walk an rdf:List into an ordered array. `orderedItems` depends on this. */
161
+ function readList(head, ctx, depth, path) {
162
+ const out = [];
163
+ const walked = new Set();
164
+ let node = head;
165
+ while (node && node !== RDF_NIL && !walked.has(node)) {
166
+ walked.add(node);
167
+ const preds = ctx.bySubject.get(node);
168
+ if (!preds) break;
169
+ const first = preds.get(RDF_FIRST)?.[0];
170
+ if (first) {
171
+ const value = toValue(first, ctx, depth, path);
172
+ if (value !== null) out.push(value);
173
+ }
174
+ node = preds.get(RDF_REST)?.[0]?.value ?? null;
175
+ }
176
+ return out;
177
+ }
178
+
179
+ function isListHead(term, ctx) {
180
+ if (term.termType !== 'BlankNode' && term.termType !== 'NamedNode') return false;
181
+ return !!ctx.bySubject.get(term.value)?.has(RDF_FIRST);
182
+ }
183
+
184
+ /**
185
+ * A term becomes what a handler expects to find there.
186
+ *
187
+ * A node we hold statements about becomes a nested view, which is what an
188
+ * embedded object was in the JSON. A node we hold nothing about becomes its
189
+ * IRI, which is what a bare reference was. Handlers already test which they
190
+ * got (`typeof activity.object === 'object'`), so both stay readable.
191
+ */
192
+ function toValue(term, ctx, depth, path) {
193
+ if (term.termType === 'Literal') return term.value;
194
+ if (isListHead(term, ctx)) return readList(term.value, ctx, depth, path);
195
+ const preds = ctx.bySubject.get(term.value);
196
+ if (preds && preds.size) return makeView(term.value, ctx, depth + 1, path);
197
+ return term.termType === 'BlankNode' ? null : term.value;
198
+ }
199
+
200
+ function readProperty(subject, name, ctx, depth = 0, path = new Set()) {
201
+ const preds = ctx.bySubject.get(subject);
202
+ if (!preds) return undefined;
203
+
204
+ if (name === 'id') return ctx.blanks.has(subject) ? undefined : subject;
205
+ if (name === 'type') {
206
+ const types = (preds.get(RDF_TYPE) ?? []).map(t => BY_IRI.get(t.value) ?? t.value);
207
+ if (!types.length) return undefined;
208
+ return types.length === 1 ? types[0] : types;
209
+ }
210
+
211
+ const term = TERMS.get(name);
212
+ if (!term) return undefined;
213
+ const values = preds.get(term.iri);
214
+ if (!values || !values.length) return undefined;
215
+
216
+ // `items` and `orderedItems` are the same predicate; the list is what tells
217
+ // them apart, so asking for the ordered one only answers when it is ordered.
218
+ const listed = values.filter(v => isListHead(v, ctx));
219
+ if (term.list) {
220
+ if (!listed.length) return undefined;
221
+ return readList(listed[0].value, ctx, depth, path);
222
+ }
223
+ if (listed.length === values.length && listed.length === 1) return readList(listed[0].value, ctx, depth, path);
224
+
225
+ const out = values.map(v => toValue(v, ctx, depth, path)).filter(v => v !== null);
226
+ if (ALWAYS_LIST.has(name)) return out.flat();
227
+ return out.length === 1 ? out[0] : out;
228
+ }
229
+
230
+ /**
231
+ * Build the plain object for one node.
232
+ *
233
+ * Materialised rather than proxied on purpose. A lazy reader would still be
234
+ * reading the graph, but it would be an exotic object, and the handlers pass
235
+ * what they read on to things that spread it, serialise it and structuredClone
236
+ * it into the store — all of which a Proxy either breaks or quietly truncates.
237
+ * What comes out here is an ordinary object whose every value came from a
238
+ * statement.
239
+ *
240
+ * `depth` and `seen` are the guards. The graph is built from a stranger's
241
+ * document, so it can be cyclic or enormous; a node already on the path
242
+ * becomes its IRI rather than being walked again.
243
+ */
244
+ function makeView(subject, ctx, depth = 0, seen = new Set()) {
245
+ if (depth > MAX_DEPTH || seen.has(subject)) {
246
+ return ctx.blanks.has(subject) ? null : subject;
247
+ }
248
+ const preds = ctx.bySubject.get(subject);
249
+ if (!preds) return ctx.blanks.has(subject) ? null : subject;
250
+
251
+ const path = new Set(seen).add(subject);
252
+ const out = {};
253
+ const id = readProperty(subject, 'id', ctx, depth, path);
254
+ if (id !== undefined) out.id = id;
255
+ const type = readProperty(subject, 'type', ctx, depth, path);
256
+ if (type !== undefined) out.type = type;
257
+
258
+ for (const predicate of preds.keys()) {
259
+ if (predicate === RDF_TYPE || predicate === RDF_FIRST || predicate === RDF_REST) continue;
260
+ // A predicate no context we hold names cannot be asked for by name, so it
261
+ // is left out rather than carried under an IRI no handler would look up.
262
+ for (const name of NAMES_BY_IRI.get(predicate) ?? []) {
263
+ if (name === 'id' || name === 'type') continue;
264
+ const value = readProperty(subject, name, ctx, depth, path);
265
+ if (value !== undefined) out[name] = value;
266
+ }
267
+ }
268
+ return out;
269
+ }
270
+
271
+ /**
272
+ * A reader over `parseAS2`'s quads.
273
+ *
274
+ * Returns null when there is nothing to read, which is the same answer the
275
+ * document-shaped read gave for bytes that were not a document.
276
+ */
277
+ export function graphView(quads, { root = null } = {}) {
278
+ if (!quads || !quads.length) return null;
279
+ const ctx = indexQuads(quads);
280
+ // The root the caller names is used only when the graph has it as a
281
+ // subject: a document whose `id` names something it says nothing about
282
+ // still reads as whatever it does describe.
283
+ const subject = (root && ctx.bySubject.has(root)) ? root : findRoot(ctx);
284
+ if (!subject) return null;
285
+ const view = makeView(subject, ctx);
286
+ return (view && typeof view === 'object') ? view : null;
287
+ }
288
+
289
+ export { TERMS, BY_IRI };
@@ -223,8 +223,8 @@ export class Intake {
223
223
  // failed and stays for the next pass.
224
224
  const got = await podInbox.readItem(this.remote, item.url, { maxBytes: MAX_ITEM_BYTES, readCapped });
225
225
  const read = got.raw === null ? null : await readLenient(got.raw);
226
- if (read?.degraded) this.log(`inbox item ${item.url} read as plain JSON: ${read.degraded}`);
227
- const activity = read?.doc ?? null;
226
+ if (read?.degraded) this.log(`inbox item ${item.url} grounded to read: ${read.degraded}`);
227
+ const activity = read?.view ?? read?.doc ?? null;
228
228
  // A Create is the content the owner just asked to be rid of. Anything
229
229
  // else changes state and is applied exactly as a drain would.
230
230
  if (keepConcerning) {
@@ -407,8 +407,8 @@ export class Intake {
407
407
  // says. A document we cannot read that way is read the way it always
408
408
  // was rather than lost, and the reason is logged.
409
409
  const read = raw ? await readLenient(raw) : null;
410
- if (read?.degraded) this.log(`inbox item ${url} read as plain JSON: ${read.degraded}`);
411
- activity = read?.doc ?? null;
410
+ if (read?.degraded) this.log(`inbox item ${url} grounded to read: ${read.degraded}`);
411
+ activity = read?.view ?? read?.doc ?? null;
412
412
  // What the shapes make of it. This decides NOTHING — the activity is
413
413
  // handled either way. It is written down so that which shapes fire on
414
414
  // real traffic is a question with an answer, rather than a guess made
@@ -25,8 +25,8 @@ export async function fetchAP(intake, url) {
25
25
  let doc = null;
26
26
  try {
27
27
  const read = await readLenient(await readCapped(res));
28
- if (read.degraded) intake.log(`fetch ${url} read as plain JSON: ${read.degraded}`);
29
- doc = read.doc;
28
+ if (read.degraded) intake.log(`fetch ${url} grounded to read: ${read.degraded}`);
29
+ doc = read.view ?? read.doc;
30
30
  } catch (e) { intake.log(`fetch ${url}: unreadable — ${e.message}`); return null; }
31
31
  if (!doc) { intake.log(`fetch ${url}: unreadable as JSON`); return null; }
32
32
  // A document is only evidence about its OWN origin.
@@ -134,7 +134,8 @@ export async function isGone(intake, url) {
134
134
  // A Tombstone answers 200 and still means deleted.
135
135
  try {
136
136
  const { readCapped } = await import('../../shared/safefetch.mjs');
137
- return (await readLenient(await readCapped(res))).doc?.type === 'Tombstone';
137
+ const read = await readLenient(await readCapped(res));
138
+ return (read.view ?? read.doc)?.type === 'Tombstone';
138
139
  } catch { return false; }
139
140
  }
140
141
  return null; // 401/403/5xx — no answer, not a denial
@@ -37,11 +37,14 @@ export async function reconcileFollowers(publisher, contacts) {
37
37
  try {
38
38
  const res = await publisher.deliverer.signedFetch(actor, { headers: { accept: ACCEPT_AP } });
39
39
  if (!res.ok) continue;
40
- const { doc, degraded } = await readLenient(await res.json());
41
- if (degraded) publisher.log?.(`actor ${actor} read as plain JSON: ${degraded}`);
42
- if (!doc?.inbox) continue;
40
+ // The graph, not the compacted copy: `inbox` is `ldp:inbox` rather than
41
+ // an `as:` term, and what is wanted here is the statement, not a key.
42
+ const { doc, view, degraded } = await readLenient(await res.json());
43
+ const actorDoc = view ?? doc;
44
+ if (degraded) publisher.log?.(`actor ${actor} grounded to read: ${degraded}`);
45
+ if (!actorDoc?.inbox) continue;
43
46
  contacts.followers.push({
44
- actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox || null, recovered: true,
47
+ actor, inbox: actorDoc.inbox, sharedInbox: actorDoc.endpoints?.sharedInbox || null, recovered: true,
45
48
  // Said explicitly, because onUndo reads it: the pod publishes WHO
46
49
  // follows, never the id of the Follow that did it, so a recovered
47
50
  // record has nothing an Undo can be matched against and must not be