fedipod-server 0.17.0 → 0.18.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.
@@ -8,6 +8,11 @@
8
8
  // GETs on the actor and outbox are redirects: the pod's documents are the
9
9
  // canonical ones, and a second renderer here would only drift from them.
10
10
  //
11
+ // `dispatch` takes an activity and answers with { status, body, headers }; it
12
+ // touches no request or response. The HTTP handler below is one caller. The
13
+ // other is the inbox drain, handing over an activity the Gateway took at the
14
+ // outbox door on the owner's behalf and stamped as theirs.
15
+ //
11
16
  // The inbox is the exception, and has to be. Deliveries land in a container on
12
17
  // the pod which the drain empties as it handles each item, so reading that
13
18
  // container tells the owner only what has not been dealt with yet. What was
@@ -18,6 +23,7 @@
18
23
  import * as social from '../core/social.mjs';
19
24
  import * as wire from '../core/wire.mjs';
20
25
  import { readLenient } from '../core/as2.mjs';
26
+ import { safeSlug } from '../core/publisher/notes.mjs';
21
27
 
22
28
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
23
29
 
@@ -154,7 +160,10 @@ export class C2S {
154
160
  async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
155
161
  if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
156
162
  if (req.method === 'OPTIONS') {
157
- res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
163
+ // Accept-Post is what a client such as dokieli reads to choose a format;
164
+ // naming JSON only is what makes it send JSON-LD rather than HTML.
165
+ res.writeHead(204, pathname === '/ap/inbox' ? { allow: 'GET, OPTIONS' }
166
+ : { allow: 'GET, POST, OPTIONS', 'accept-post': 'application/ld+json, application/activity+json' });
158
167
  res.end(); return true;
159
168
  }
160
169
  if (!this.agent.configured() || !this.urls) {
@@ -196,9 +205,16 @@ export class C2S {
196
205
  // takes decisions from it and the publisher builds the document that is
197
206
  // actually posted, so nothing a client sent is republished verbatim and a
198
207
  // term it aliased still means what it says.
199
- let activity;
208
+ // `raw` is the document as the client wrote it. The graph is what
209
+ // decisions are taken from; the bytes are what an object that is stored
210
+ // as sent (publishObject) is stored from — a read against our contexts
211
+ // rewrites a stranger's terms to full IRIs, which is right for reading and
212
+ // wrong for keeping.
213
+ let activity; let raw = null;
200
214
  try {
201
- const read = await readLenient(await readBody(req));
215
+ const body = await readBody(req);
216
+ try { raw = JSON.parse(body); } catch { raw = null; }
217
+ const read = await readLenient(body);
202
218
  activity = read.view ?? read.doc;
203
219
  } catch (e) {
204
220
  return this.send(res, 400, { error: `unreadable body: ${e.message}` });
@@ -206,34 +222,47 @@ export class C2S {
206
222
  if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
207
223
  return this.send(res, 400, { error: 'a typed ActivityStreams object is required' });
208
224
  }
209
- // A bare object arrives without an activity around it; the server supplies
210
- // the Create (§6.2.1), carrying the object's own addressing up onto it.
211
- if (!ACTIVITY_TYPES.has(activity.type)) {
212
- activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
213
- }
214
-
215
- try {
216
- return await this.dispatch(res, activity);
217
- } catch (e) {
218
- this.log(`c2s ${activity.type}: ${e.message}`);
219
- return this.send(res, 422, { error: e.message || String(e) });
220
- }
225
+ // The name the client asks for its new document (LDP's Slug), taken when
226
+ // it is plain and free — it is what lets the client know the address of
227
+ // what it made before anything answers.
228
+ const slug = safeSlug(req.headers.slug) || null;
229
+ const r = await this.dispatch(activity, { slug, raw });
230
+ return this.send(res, r.status, r.body, r.headers);
221
231
  }
222
232
 
223
233
  // Addressing → the facade's four visibilities, inverting the table the
224
- // composer writes (wire.noteDoc). Addressing is required: a post whose
225
- // audience the client never stated is not guessed at in either direction.
234
+ // composer writes (wire.addressing). Nothing stated is a public post — what
235
+ // every client means by a post with no audience chosen, and what a client
236
+ // that never addresses (dokieli's annotations) needs.
226
237
  visibilityOf(activity, object) {
227
238
  const to = arr(activity.to ?? object?.to).map(idOf);
228
239
  const cc = arr(activity.cc ?? object?.cc).map(idOf);
229
- if (!to.length && !cc.length) return null;
240
+ if (!to.length && !cc.length) return 'public';
230
241
  if (to.includes(wire.PUBLIC)) return 'public';
231
242
  if (cc.includes(wire.PUBLIC)) return 'unlisted';
232
243
  if (to.includes(this.urls.followers)) return 'private';
233
244
  return 'direct';
234
245
  }
235
246
 
236
- async dispatch(res, activity) {
247
+ async dispatch(activity, { slug = null, raw = null } = {}) {
248
+ const reply = (status, body, headers = {}) => ({ status, body, headers });
249
+ if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
250
+ return reply(400, { error: 'a typed ActivityStreams object is required' });
251
+ }
252
+ // A bare object arrives without an activity around it; the server supplies
253
+ // the Create (§6.2.1), carrying the object's own addressing up onto it.
254
+ if (!ACTIVITY_TYPES.has(activity.type)) {
255
+ activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
256
+ }
257
+ try {
258
+ return await this._dispatch(activity, { slug, raw, reply });
259
+ } catch (e) {
260
+ this.log(`c2s ${activity?.type}: ${e.message}`);
261
+ return reply(422, { error: e.message || String(e) });
262
+ }
263
+ }
264
+
265
+ async _dispatch(activity, { slug, raw, reply }) {
237
266
  const agent = this.agent;
238
267
  const object = typeof activity.object === 'object' && activity.object !== null
239
268
  ? activity.object : null;
@@ -242,18 +271,22 @@ export class C2S {
242
271
  switch (activity.type) {
243
272
  case 'Create': {
244
273
  const makes = object?.type || 'Note';
245
- if (!object || (makes !== 'Note' && makes !== 'Question')) {
246
- return this.send(res, 422, { error: 'only a Note or a Question (or a bare Note) can be created here' });
247
- }
274
+ if (!object) return reply(422, { error: 'a Create carries the object it creates' });
248
275
  const visibility = this.visibilityOf(activity, object);
249
- if (!visibility) {
250
- return this.send(res, 400, { error: 'state the audience: to/cc must address someone (as:Public, your followers collection, or actors)' });
276
+ // Not a Note and not a poll: stored as sent, under this actor, and
277
+ // the Create around it delivered a Web Annotation, for one.
278
+ if (makes !== 'Note' && makes !== 'Question') {
279
+ const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
280
+ ? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
281
+ : null;
282
+ const made = await agent.publisher.publishObject(asSent || object, { visibility, slug });
283
+ return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
251
284
  }
252
285
  // `source.content` is the client's plain text when it sends one; bare
253
286
  // `content` is TREATED as plain text and escaped — markup survives as
254
287
  // visible characters rather than as markup. Documented v1 limit.
255
288
  const text = String(object.source?.content ?? object.content ?? '');
256
- if (!text.trim()) return this.send(res, 422, { error: 'the note has no content' });
289
+ if (!text.trim()) return reply(422, { error: 'the note has no content' });
257
290
 
258
291
  // A Question is a poll: the choices are in oneOf (pick one) or anyOf
259
292
  // (pick several), each naming itself, and endTime is when it shuts.
@@ -270,11 +303,11 @@ export class C2S {
270
303
  visibility,
271
304
  spoilerText: object.summary || null,
272
305
  });
273
- return this.send(res, 201,
306
+ return reply(201,
274
307
  { id: wire.createActivityId(question.id), object: question.id },
275
308
  { location: wire.createActivityId(question.id) });
276
309
  } catch (e) {
277
- return this.send(res, 422, { error: e.message });
310
+ return reply(422, { error: e.message });
278
311
  }
279
312
  }
280
313
 
@@ -287,22 +320,23 @@ export class C2S {
287
320
  attachments,
288
321
  visibility,
289
322
  spoilerText: object.summary || null,
323
+ slug,
290
324
  });
291
- return this.send(res, 201, { id: wire.createActivityId(note.id), object: note.id },
325
+ return reply(201, { id: wire.createActivityId(note.id), object: note.id },
292
326
  { location: wire.createActivityId(note.id) });
293
327
  }
294
328
 
295
329
  case 'Update': {
296
330
  if (objectId === this.urls.actor) {
297
- return this.send(res, 422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
331
+ return reply(422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
298
332
  }
299
333
  const s = this.byIri(objectId);
300
- if (!s) return this.send(res, 404, { error: 'no such note here' });
334
+ if (!s) return reply(404, { error: 'no such note here' });
301
335
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
302
- return this.send(res, 403, { error: 'not your note' });
336
+ return reply(403, { error: 'not your note' });
303
337
  }
304
338
  const text = String(object?.source?.content ?? object?.content ?? '');
305
- if (!text.trim()) return this.send(res, 422, { error: 'the edit has no content' });
339
+ if (!text.trim()) return reply(422, { error: 'the edit has no content' });
306
340
  const attachments = object?.attachment !== undefined
307
341
  ? arr(object.attachment).map((a) => ({
308
342
  url: a?.url, mediaType: a?.mediaType,
@@ -312,52 +346,52 @@ export class C2S {
312
346
  await agent.publisher.updateNote(s, {
313
347
  content: text, spoilerText: object?.summary || null, attachments,
314
348
  });
315
- return this.send(res, 200, { ok: true, object: s.noteId });
349
+ return reply(200, { ok: true, object: s.noteId });
316
350
  }
317
351
 
318
352
  case 'Delete': {
319
353
  if (objectId === this.urls.actor) {
320
- return this.send(res, 422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
354
+ return reply(422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
321
355
  }
322
356
  const s = this.byIri(objectId);
323
- if (!s) return this.send(res, 404, { error: 'no such note here' });
357
+ if (!s) return reply(404, { error: 'no such note here' });
324
358
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
325
- return this.send(res, 403, { error: 'not your note' });
359
+ return reply(403, { error: 'not your note' });
326
360
  }
327
361
  const r = await social.deleteNote(agent, s);
328
- if (!r.ok) return this.send(res, 502, { error: r.error, stillPublished: r.stillPublished });
329
- return this.send(res, 200, { ok: true });
362
+ if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
363
+ return reply(200, { ok: true });
330
364
  }
331
365
 
332
366
  case 'Follow': {
333
- if (!objectId) return this.send(res, 400, { error: 'whom? object must name an actor' });
367
+ if (!objectId) return reply(400, { error: 'whom? object must name an actor' });
334
368
  // An acct: form or bare handle resolves through WebFinger; an https
335
369
  // IRI is fetched directly.
336
370
  if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
337
371
  const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
338
372
  const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
339
- return this.send(res, 201, { id: rec?.followActivity?.id, object: r.actor },
373
+ return reply(201, { id: rec?.followActivity?.id, object: r.actor },
340
374
  rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
341
375
  }
342
376
  const doc = await social.followActor(agent, objectId);
343
377
  const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
344
- return this.send(res, 201, { id: rec?.followActivity?.id, object: doc.id },
378
+ return reply(201, { id: rec?.followActivity?.id, object: doc.id },
345
379
  rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
346
380
  }
347
381
 
348
382
  case 'Like': {
349
383
  const s = this.byIri(objectId);
350
- if (!s) return this.send(res, 422, { error: 'that note is not held here — like what the timeline holds' });
384
+ if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
351
385
  const updated = await social.favourite(agent, s);
352
- return this.send(res, 201, { id: updated.likeActivity?.id, object: s.noteId },
386
+ return reply(201, { id: updated.likeActivity?.id, object: s.noteId },
353
387
  updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
354
388
  }
355
389
 
356
390
  case 'Announce': {
357
391
  const s = this.byIri(objectId);
358
- if (!s) return this.send(res, 422, { error: 'that note is not held here — boost what the timeline holds' });
392
+ if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
359
393
  const updated = await social.reblog(agent, s);
360
- return this.send(res, 201, { id: updated.announceActivity?.id, object: s.noteId },
394
+ return reply(201, { id: updated.announceActivity?.id, object: s.noteId },
361
395
  updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
362
396
  }
363
397
 
@@ -368,37 +402,37 @@ export class C2S {
368
402
  const innerId = idOf(activity.object);
369
403
  if (inner?.type === 'Block') {
370
404
  const target = idOf(inner.object);
371
- if (!target) return this.send(res, 400, { error: 'unblock whom?' });
405
+ if (!target) return reply(400, { error: 'unblock whom?' });
372
406
  await social.unblockActor(agent, target);
373
- return this.send(res, 200, { ok: true, object: target });
407
+ return reply(200, { ok: true, object: target });
374
408
  }
375
409
  const statuses = this.store.getStatuses();
376
410
  let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
377
411
  if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
378
412
  if (s?.favourited) {
379
413
  const updated = await social.unfavourite(agent, s);
380
- return this.send(res, 200, { ok: true, object: updated.noteId });
414
+ return reply(200, { ok: true, object: updated.noteId });
381
415
  }
382
416
  s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
383
417
  if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
384
418
  if (s?.reblogged) {
385
419
  const updated = await social.unreblog(agent, s);
386
- return this.send(res, 200, { ok: true, object: updated.noteId });
420
+ return reply(200, { ok: true, object: updated.noteId });
387
421
  }
388
422
  const following = this.store.getContacts().following;
389
423
  const rec = following.find((f) => f.followActivity?.id === innerId)
390
424
  || (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
391
425
  if (rec) {
392
426
  await social.unfollowActor(agent, rec.actor);
393
- return this.send(res, 200, { ok: true, object: rec.actor });
427
+ return reply(200, { ok: true, object: rec.actor });
394
428
  }
395
- return this.send(res, 422, { error: 'nothing here matches what that Undo names' });
429
+ return reply(422, { error: 'nothing here matches what that Undo names' });
396
430
  }
397
431
 
398
432
  case 'Block': {
399
- if (!objectId) return this.send(res, 400, { error: 'block whom? object must name an actor' });
433
+ if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
400
434
  await social.blockActor(agent, objectId);
401
- return this.send(res, 200, { ok: true, object: objectId });
435
+ return reply(200, { ok: true, object: objectId });
402
436
  }
403
437
 
404
438
  case 'Add':
@@ -406,12 +440,12 @@ export class C2S {
406
440
  // The one collection a client may edit is the pins (§7.6/§7.9 in the
407
441
  // other direction): target must be the featured collection.
408
442
  if (idOf(activity.target) !== this.urls.featured) {
409
- return this.send(res, 422, { error: 'the featured collection is the one Add/Remove edits here' });
443
+ return reply(422, { error: 'the featured collection is the one Add/Remove edits here' });
410
444
  }
411
445
  const s = this.byIri(objectId);
412
- if (!s) return this.send(res, 404, { error: 'no such note here' });
446
+ if (!s) return reply(404, { error: 'no such note here' });
413
447
  const updated = await social.pinStatus(agent, s, activity.type === 'Add');
414
- return this.send(res, 200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
448
+ return reply(200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
415
449
  }
416
450
 
417
451
  case 'Accept':
@@ -419,19 +453,19 @@ export class C2S {
419
453
  // Answering a held follow request: the object is the Follow (or the
420
454
  // requester). Which request is meant comes from the Follow's actor.
421
455
  const requester = object?.actor ? idOf(object.actor) : objectId;
422
- if (!requester) return this.send(res, 400, { error: 'whose request? object must name the Follow or its actor' });
456
+ if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
423
457
  const r = activity.type === 'Accept'
424
458
  ? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
425
459
  : await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
426
- if (r.error) return this.send(res, 404, { error: r.error });
427
- return this.send(res, 200, { ok: true, object: requester });
460
+ if (r.error) return reply(404, { error: r.error });
461
+ return reply(200, { ok: true, object: requester });
428
462
  }
429
463
 
430
464
  case 'Move':
431
- return this.send(res, 422, { error: 'moving the account is done on the admin surface, where it asks twice' });
465
+ return reply(422, { error: 'moving the account is done on the admin surface, where it asks twice' });
432
466
 
433
467
  default:
434
- return this.send(res, 422, { error: `no handler for ${activity.type}` });
468
+ return reply(422, { error: `no handler for ${activity.type}` });
435
469
  }
436
470
  }
437
471
  }
@@ -0,0 +1,126 @@
1
+ {
2
+ "@context": {
3
+ "oa": "http://www.w3.org/ns/oa#",
4
+ "dc": "http://purl.org/dc/elements/1.1/",
5
+ "dcterms": "http://purl.org/dc/terms/",
6
+ "dctypes": "http://purl.org/dc/dcmitype/",
7
+ "foaf": "http://xmlns.com/foaf/0.1/",
8
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
9
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
10
+ "skos": "http://www.w3.org/2004/02/skos/core#",
11
+ "xsd": "http://www.w3.org/2001/XMLSchema#",
12
+ "iana": "http://www.iana.org/assignments/relation/",
13
+ "owl": "http://www.w3.org/2002/07/owl#",
14
+ "as": "http://www.w3.org/ns/activitystreams#",
15
+ "schema": "http://schema.org/",
16
+
17
+ "id": {"@type": "@id", "@id": "@id"},
18
+ "type": {"@type": "@id", "@id": "@type"},
19
+
20
+ "Annotation": "oa:Annotation",
21
+ "Dataset": "dctypes:Dataset",
22
+ "Image": "dctypes:StillImage",
23
+ "Video": "dctypes:MovingImage",
24
+ "Audio": "dctypes:Sound",
25
+ "Text": "dctypes:Text",
26
+ "TextualBody": "oa:TextualBody",
27
+ "ResourceSelection": "oa:ResourceSelection",
28
+ "SpecificResource": "oa:SpecificResource",
29
+ "FragmentSelector": "oa:FragmentSelector",
30
+ "CssSelector": "oa:CssSelector",
31
+ "XPathSelector": "oa:XPathSelector",
32
+ "TextQuoteSelector": "oa:TextQuoteSelector",
33
+ "TextPositionSelector": "oa:TextPositionSelector",
34
+ "DataPositionSelector": "oa:DataPositionSelector",
35
+ "SvgSelector": "oa:SvgSelector",
36
+ "RangeSelector": "oa:RangeSelector",
37
+ "TimeState": "oa:TimeState",
38
+ "HttpRequestState": "oa:HttpRequestState",
39
+ "CssStylesheet": "oa:CssStyle",
40
+ "Choice": "oa:Choice",
41
+ "Person": "foaf:Person",
42
+ "Software": "as:Application",
43
+ "Organization": "foaf:Organization",
44
+ "AnnotationCollection": "as:OrderedCollection",
45
+ "AnnotationPage": "as:OrderedCollectionPage",
46
+ "Audience": "schema:Audience",
47
+
48
+ "Motivation": "oa:Motivation",
49
+ "bookmarking": "oa:bookmarking",
50
+ "classifying": "oa:classifying",
51
+ "commenting": "oa:commenting",
52
+ "describing": "oa:describing",
53
+ "editing": "oa:editing",
54
+ "highlighting": "oa:highlighting",
55
+ "identifying": "oa:identifying",
56
+ "linking": "oa:linking",
57
+ "moderating": "oa:moderating",
58
+ "questioning": "oa:questioning",
59
+ "replying": "oa:replying",
60
+ "reviewing": "oa:reviewing",
61
+ "assessing": "oa:assessing",
62
+ "tagging": "oa:tagging",
63
+
64
+ "auto": "oa:autoDirection",
65
+ "ltr": "oa:ltrDirection",
66
+ "rtl": "oa:rtlDirection",
67
+
68
+ "body": {"@type": "@id", "@id": "oa:hasBody"},
69
+ "target": {"@type": "@id", "@id": "oa:hasTarget"},
70
+ "source": {"@type": "@id", "@id": "oa:hasSource"},
71
+ "selector": {"@type": "@id", "@id": "oa:hasSelector"},
72
+ "state": {"@type": "@id", "@id": "oa:hasState"},
73
+ "scope": {"@type": "@id", "@id": "oa:hasScope"},
74
+ "refinedBy": {"@type": "@id", "@id": "oa:refinedBy"},
75
+ "startSelector": {"@type": "@id", "@id": "oa:hasStartSelector"},
76
+ "endSelector": {"@type": "@id", "@id": "oa:hasEndSelector"},
77
+ "renderedVia": {"@type": "@id", "@id": "oa:renderedVia"},
78
+ "creator": {"@type": "@id", "@id": "dcterms:creator"},
79
+ "generator": {"@type": "@id", "@id": "as:generator"},
80
+ "rights": {"@type": "@id", "@id": "dcterms:rights"},
81
+ "homepage": {"@type": "@id", "@id": "foaf:homepage"},
82
+ "via": {"@type": "@id", "@id": "oa:via"},
83
+ "canonical": {"@type": "@id", "@id": "oa:canonical"},
84
+ "stylesheet": {"@type": "@id", "@id": "oa:styledBy"},
85
+ "cached": {"@type": "@id", "@id": "oa:cachedSource"},
86
+ "conformsTo": {"@type": "@id", "@id": "dcterms:conformsTo"},
87
+ "items": {"@type": "@id", "@id": "as:items", "@container": "@list"},
88
+ "partOf": {"@type": "@id", "@id": "as:partOf"},
89
+ "first": {"@type": "@id", "@id": "as:first"},
90
+ "last": {"@type": "@id", "@id": "as:last"},
91
+ "next": {"@type": "@id", "@id": "as:next"},
92
+ "prev": {"@type": "@id", "@id": "as:prev"},
93
+ "audience": {"@type": "@id", "@id": "schema:audience"},
94
+ "motivation": {"@type": "@vocab", "@id": "oa:motivatedBy"},
95
+ "purpose": {"@type": "@vocab", "@id": "oa:hasPurpose"},
96
+ "textDirection": {"@type": "@vocab", "@id": "oa:textDirection"},
97
+
98
+ "accessibility": "schema:accessibilityFeature",
99
+ "bodyValue": "oa:bodyValue",
100
+ "format": "dc:format",
101
+ "language": "dc:language",
102
+ "processingLanguage": "oa:processingLanguage",
103
+ "value": "rdf:value",
104
+ "exact": "oa:exact",
105
+ "prefix": "oa:prefix",
106
+ "suffix": "oa:suffix",
107
+ "styleClass": "oa:styleClass",
108
+ "name": "foaf:name",
109
+ "email": "foaf:mbox",
110
+ "email_sha1": "foaf:mbox_sha1sum",
111
+ "nickname": "foaf:nick",
112
+ "label": "rdfs:label",
113
+
114
+ "created": {"@id": "dcterms:created", "@type": "xsd:dateTime"},
115
+ "modified": {"@id": "dcterms:modified", "@type": "xsd:dateTime"},
116
+ "generated": {"@id": "dcterms:issued", "@type": "xsd:dateTime"},
117
+ "sourceDate": {"@id": "oa:sourceDate", "@type": "xsd:dateTime"},
118
+ "sourceDateStart": {"@id": "oa:sourceDateStart", "@type": "xsd:dateTime"},
119
+ "sourceDateEnd": {"@id": "oa:sourceDateEnd", "@type": "xsd:dateTime"},
120
+
121
+ "start": {"@id": "oa:start", "@type": "xsd:nonNegativeInteger"},
122
+ "end": {"@id": "oa:end", "@type": "xsd:nonNegativeInteger"},
123
+ "total": {"@id": "as:totalItems", "@type": "xsd:nonNegativeInteger"},
124
+ "startIndex": {"@id": "as:startIndex", "@type": "xsd:nonNegativeInteger"}
125
+ }
126
+ }
@@ -25,6 +25,9 @@ import ctx10 from './fep-5711.json' with { type: 'json' };
25
25
  import ctx11 from './join-lemmy.json' with { type: 'json' };
26
26
  import ctx12 from './joinmastodon.json' with { type: 'json' };
27
27
  import ctx13 from './miscellany.json' with { type: 'json' };
28
+ // Not from fedify: the W3C Web Annotation context, which a client such as
29
+ // dokieli names on the annotations it posts to the outbox.
30
+ import ctx14 from './anno.json' with { type: 'json' };
28
31
 
29
32
  /** URL → the context document itself. Nothing outside this map is ever resolved. */
30
33
  export const CONTEXTS = {
@@ -42,4 +45,5 @@ export const CONTEXTS = {
42
45
  'https://join-lemmy.org/context.json': ctx11,
43
46
  'http://joinmastodon.org/ns': ctx12,
44
47
  'https://purl.archive.org/miscellany': ctx13,
48
+ 'http://www.w3.org/ns/anno.jsonld': ctx14,
45
49
  };
@@ -12,5 +12,6 @@
12
12
  "https://w3id.org/fep/5711": "fep-5711.json",
13
13
  "https://join-lemmy.org/context.json": "join-lemmy.json",
14
14
  "http://joinmastodon.org/ns": "joinmastodon.json",
15
- "https://purl.archive.org/miscellany": "miscellany.json"
15
+ "https://purl.archive.org/miscellany": "miscellany.json",
16
+ "http://www.w3.org/ns/anno.jsonld": "anno.json"
16
17
  }
@@ -59,8 +59,10 @@ const MAX_ITEM_ATTEMPTS = 5;
59
59
  const MAX_ITEMS_PER_DRAIN = 50;
60
60
 
61
61
  export class Intake {
62
- constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null }) {
63
- Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds });
62
+ // `ownerPost(activity, { raw, slug })` is the client-to-server dispatcher,
63
+ // for an item the Gateway's outbox door took on the owner's behalf.
64
+ constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null, ownerPost = null }) {
65
+ Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds, ownerPost });
64
66
  this.serial = Date.now();
65
67
  this.stopped = false;
66
68
  // (attempt counts are kept in pod state — see _bumpAttempt)
@@ -429,9 +431,14 @@ export class Intake {
429
431
  // receipt reads as null, which is exactly today's unverified behavior.
430
432
  const receipt = activity ? await this._readReceipt(url) : null;
431
433
  if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
432
- const rejection = activity ? await this.handle(activity, receipt) : 'unparsable JSON';
433
- if (!rejection && raw) await this._archive(url, raw, activity);
434
- if (!rejection) await this._maybeForward(activity); // §7.1.2, only what we accepted
434
+ // The owner's own post, taken at the outbox door: not mail to read
435
+ // but a write to make. Never archived or forwarded as if received.
436
+ const owned = activity && this.isOwnerPost(receipt);
437
+ const rejection = !activity ? 'unparsable JSON'
438
+ : owned ? await this.ownerPostFrom(activity, raw, receipt)
439
+ : await this.handle(activity, receipt);
440
+ if (!rejection && raw && !owned) await this._archive(url, raw, activity);
441
+ if (!rejection && !owned) await this._maybeForward(activity); // §7.1.2, only what we accepted
435
442
  if (rejection) {
436
443
  this.store.addDeadLetter({
437
444
  inboxUrl: url, reason: rejection, activity: trimActivity(activity),
@@ -594,6 +601,26 @@ export class Intake {
594
601
  gatewaySecret(...a) { return verify.gatewaySecret(this, ...a); }
595
602
  _bumpGatewayStat(...a) { return verify.bumpGatewayStat(this, ...a); }
596
603
  _readReceipt(...a) { return verify.readReceipt(this, ...a); }
604
+
605
+ // A receipt the door stamped `c2s` for THIS actor, and only a receipt whose
606
+ // HMAC verified (readReceipt returns nothing else). A stranger appending an
607
+ // item that claims to be ours has no such receipt and is read as mail.
608
+ isOwnerPost(receipt) {
609
+ return !!(this.ownerPost && receipt?.verified && receipt.method === 'c2s' && receipt.actor === this.urls.actor);
610
+ }
611
+
612
+ // Publish what the owner posted at the door. The bytes as the client wrote
613
+ // them go to the dispatcher (an object stored as sent must be the client's
614
+ // document, not our reading of it); the graph is what it decides from.
615
+ // Returns null when published, else the reason it was not — a dead letter.
616
+ async ownerPostFrom(activity, raw, receipt) {
617
+ let asSent = null;
618
+ try { asSent = raw ? JSON.parse(raw) : null; } catch { asSent = null; }
619
+ const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null });
620
+ if (!r || r.status >= 300) return `owner post refused (${r?.status || '?'}): ${r?.body?.error || ''}`;
621
+ this.log(`owner post from the outbox door published: ${r.body?.object || r.body?.id || activity.type}`);
622
+ return null;
623
+ }
597
624
  receiptVouchesFor(...a) { return verify.receiptVouchesFor(this, ...a); }
598
625
  isGone(...a) { return verify.isGone(this, ...a); }
599
626
 
@@ -76,6 +76,15 @@ export class Publisher {
76
76
  // republish button. Without it, an actor lost from the pod would match the
77
77
  // digest, be skipped, and leave the agent reporting success while nobody can
78
78
  // resolve it.
79
+ // The Gateway's outbox door for this account, when a Gateway is attached:
80
+ // beside its inbox door, or under the fronted actor.
81
+ gatewayOutbox() {
82
+ const gw = this.config.gateway;
83
+ if (!(gw && gw.url && gw.mode && gw.mode !== 'off')) return null;
84
+ if (gw.frontActor) return String(gw.frontActor).replace(/ap\/actor\/?$/u, 'ap/outbox');
85
+ return String(gw.url).replace(/ap\/inbox\/?$/u, 'ap/outbox');
86
+ }
87
+
79
88
  async publishProfile({ force = false } = {}) {
80
89
  const { urls } = this;
81
90
  const host = new URL(urls.base).host;
@@ -105,7 +114,8 @@ export class Publisher {
105
114
  inbox: gwActive ? gw.url : null,
106
115
  // The agent's own outbox endpoint, where it is reachable: a client
107
116
  // following the actor must arrive somewhere that will take a write.
108
- outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
117
+ // Otherwise the Gateway's outbox door, when one is attached.
118
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
109
119
  // How a client-to-server client finds the way in with nothing configured
110
120
  // by hand. Advertised only where the surface is publicly reachable.
111
121
  oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
@@ -163,6 +173,8 @@ export class Publisher {
163
173
  actorUrl: urls.actor,
164
174
  accountName: `@${this.config.handle}@${host}`,
165
175
  kind: this.config.kind,
176
+ // Where a Solid client posts: dokieli reads `as:outbox` off the WebID.
177
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
166
178
  });
167
179
  if (wrote) this.log('WebID profile now lists the actor as a foaf:account');
168
180
  } catch (e) {
@@ -400,6 +412,7 @@ export class Publisher {
400
412
  reconcileFollowers(...a) { return restore.reconcileFollowers(this, ...a); }
401
413
  reconcileOutbox(...a) { return restore.reconcileOutbox(this, ...a); }
402
414
  rebuildStatuses(...a) { return restore.rebuildStatuses(this, ...a); }
415
+ healStatuses(...a) { return restore.healStatuses(this, ...a); }
403
416
 
404
417
  // notes.mjs
405
418
  ensureMediaContainer(...a) { return notes.ensureMediaContainer(this, ...a); }
@@ -408,6 +421,7 @@ export class Publisher {
408
421
  privateReady(...a) { return notes.privateReady(this, ...a); }
409
422
  _mentionsFor(...a) { return notes.mentionsFor(this, ...a); }
410
423
  publishNote(...a) { return notes.publishNote(this, ...a); }
424
+ publishObject(...a) { return notes.publishObject(this, ...a); }
411
425
  updateNote(...a) { return notes.updateNote(this, ...a); }
412
426
 
413
427
  // questions.mjs