@oxy.so/federation 2.7.0 → 2.9.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 +36 -0
- package/dist/cjs/actorObject.js +4 -1
- package/dist/cjs/groupCollections.js +61 -0
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/node/actorRouter.js +33 -10
- package/dist/cjs/node/delivery.js +7 -0
- package/dist/cjs/node/goneActor.js +43 -0
- package/dist/cjs/node/index.js +8 -1
- package/dist/cjs/node/webfingerRouter.js +6 -1
- package/dist/esm/actorObject.js +4 -1
- package/dist/esm/groupCollections.js +55 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/node/actorRouter.js +33 -10
- package/dist/esm/node/delivery.js +7 -0
- package/dist/esm/node/goneActor.js +39 -0
- package/dist/esm/node/index.js +5 -0
- package/dist/esm/node/webfingerRouter.js +6 -1
- package/dist/types/groupCollections.d.ts +42 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/node/actorRouter.d.ts +11 -2
- package/dist/types/node/delivery.d.ts +9 -0
- package/dist/types/node/goneActor.d.ts +29 -0
- package/dist/types/node/index.d.ts +5 -0
- package/dist/types/node/webfingerRouter.d.ts +7 -0
- package/package.json +1 -1
- package/src/__tests__/actorObject.test.ts +1 -0
- package/src/__tests__/delivery.test.ts +23 -0
- package/src/__tests__/routers.test.ts +72 -0
- package/src/actorObject.ts +4 -1
- package/src/groupCollections.ts +67 -0
- package/src/index.ts +11 -0
- package/src/node/actorRouter.ts +40 -16
- package/src/node/delivery.ts +20 -0
- package/src/node/goneActor.ts +45 -0
- package/src/node/index.ts +6 -0
- package/src/node/webfingerRouter.ts +14 -1
|
@@ -87,8 +87,13 @@ export function createWebfingerRouter(config) {
|
|
|
87
87
|
return res.json(cached);
|
|
88
88
|
}
|
|
89
89
|
const user = await config.resolveUser(username);
|
|
90
|
-
if (!user)
|
|
90
|
+
if (!user) {
|
|
91
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
92
|
+
res.set('Cache-Control', httpCacheControl);
|
|
93
|
+
return res.status(410).json({ error: 'User deleted' });
|
|
94
|
+
}
|
|
91
95
|
return res.status(404).json({ error: 'User not found' });
|
|
96
|
+
}
|
|
92
97
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
93
98
|
// body, no separate error code. UNLIKE the other user-scoped surfaces,
|
|
94
99
|
// webfinger does a SECOND, uncached consent read here rather than reusing
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
3
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
4
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
5
|
+
*
|
|
6
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
7
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
8
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
9
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
10
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
11
|
+
* `orderedItems` and nothing else.
|
|
12
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
13
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
14
|
+
* list stays private; this does the same.
|
|
15
|
+
*
|
|
16
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
17
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
18
|
+
* the paged shape Mastodon walks.
|
|
19
|
+
*/
|
|
20
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
21
|
+
export declare const GROUP_OUTBOX_SIZE = 50;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
24
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
25
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isGroupActorKind(kind: unknown): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
30
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
31
|
+
* dropped; the collection's covers it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function groupOutboxCollection(params: {
|
|
34
|
+
id: string;
|
|
35
|
+
totalItems: number;
|
|
36
|
+
orderedItems: readonly Record<string, unknown>[];
|
|
37
|
+
}): Record<string, unknown>;
|
|
38
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
39
|
+
export declare function groupFollowersCollection(params: {
|
|
40
|
+
id: string;
|
|
41
|
+
totalItems: number;
|
|
42
|
+
}): Record<string, unknown>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -63,6 +63,11 @@ export { canonicalFederationHost, isSameFederationHost, extractActorUriFromActiv
|
|
|
63
63
|
* follows the Oxy account kind ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}).
|
|
64
64
|
*/
|
|
65
65
|
export { createLocalActorBuilder, normalizeAlsoKnownAs, localActorTypeForAccountKind, accountKindForActor, type ActorKindSignals, isApActorType, AP_ACTOR_TYPES, LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, type ApActorType, type LocalActorType, type LocalActorBuilder, type LocalActorBuilderConfig, type BuildLocalActorParams, type ActorMediaResolver, } from './actorObject';
|
|
66
|
+
/**
|
|
67
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
68
|
+
* back; every other actor keeps the paged collections.
|
|
69
|
+
*/
|
|
70
|
+
export { GROUP_OUTBOX_SIZE, isGroupActorKind, groupOutboxCollection, groupFollowersCollection, } from './groupCollections';
|
|
66
71
|
/**
|
|
67
72
|
* Supported external networks, keyed by the transport a connector speaks.
|
|
68
73
|
* `instagram-graph` is Meta's Graph API Business Discovery: a read-only
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
9
9
|
* 202 + async dispatch to the injected inbound dispatcher,
|
|
10
10
|
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
11
|
-
* (local + bridged federated edges) as paginated `OrderedCollection`s
|
|
11
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s; a
|
|
12
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
12
13
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
13
14
|
*
|
|
14
15
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -23,7 +24,8 @@ import { Router } from 'express';
|
|
|
23
24
|
import type { AccountKind } from '@oxy.so/contracts';
|
|
24
25
|
import type { User } from '@oxy.so/core';
|
|
25
26
|
import type { UrlBuilders } from '../urls';
|
|
26
|
-
import {
|
|
27
|
+
import type { LocalActorBuilder } from '../actorObject';
|
|
28
|
+
import { type IsActorGone } from './goneActor';
|
|
27
29
|
/** The resolved-user fields the actor + collection routes read. */
|
|
28
30
|
export interface ActorRouteUser {
|
|
29
31
|
_id?: string | null;
|
|
@@ -114,6 +116,13 @@ export interface ActorRouterConfig {
|
|
|
114
116
|
* the route is not mounted; any account that is not a community 404s.
|
|
115
117
|
*/
|
|
116
118
|
fetchModerators?(userId: string): Promise<User[]>;
|
|
119
|
+
/**
|
|
120
|
+
* Whether a username that resolved to nobody names a DELETED local actor
|
|
121
|
+
* (`goneActor.ts`). True ⇒ the actor, its inbox and its collections answer
|
|
122
|
+
* `410 Gone` (the actor with a `Tombstone`) instead of 404. Omitted ⇒ every
|
|
123
|
+
* miss is a 404.
|
|
124
|
+
*/
|
|
125
|
+
isActorGone?: IsActorGone;
|
|
117
126
|
/** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
|
|
118
127
|
httpCacheTtlSeconds?: number;
|
|
119
128
|
/** Diagnostics sink. */
|
|
@@ -270,6 +270,15 @@ export interface DeliveryService {
|
|
|
270
270
|
sendReject(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
|
|
271
271
|
/** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
|
|
272
272
|
federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
|
|
273
|
+
/**
|
|
274
|
+
* Tell every remote follower the actor is gone: its `Delete` of itself
|
|
275
|
+
* (`goneActor.ts`), one per (shared) inbox, signed with its own key. Needs the
|
|
276
|
+
* accepted follower rows, so call it BEFORE deleting them. Asks no consent and
|
|
277
|
+
* resolves no profile, since the account is already gone at its identity
|
|
278
|
+
* provider; it needs only the handle and the key the handle addresses. Throws
|
|
279
|
+
* when it cannot queue, so the caller's job retries.
|
|
280
|
+
*/
|
|
281
|
+
federateActorDelete(actorOxyUserId: string, username: string): Promise<void>;
|
|
273
282
|
}
|
|
274
283
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
275
284
|
export declare function createDeliveryService<TActor extends DeliveryActorFields>(config: DeliveryServiceConfig<TActor>): DeliveryService;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A DELETED local actor, as the fediverse is told about it.
|
|
3
|
+
*
|
|
4
|
+
* Two halves, following Mastodon's account deletion:
|
|
5
|
+
*
|
|
6
|
+
* 1. Once, the app sends `Delete{actor}`: the actor deletes itself, signed with
|
|
7
|
+
* its own key, to every follower's (shared) inbox. That is
|
|
8
|
+
* `DeliveryService.federateActorDelete`, built by {@link buildActorDeleteActivity}.
|
|
9
|
+
* 2. From then on the actor's URLs answer `410 Gone` instead of `404`. That is
|
|
10
|
+
* what a server that missed the Delete, or never acts on one, reads as
|
|
11
|
+
* permanent: Mastodon deletes its copy of an account whose WebFinger answers
|
|
12
|
+
* 410, and Lemmy, PieFed and Mbin mark a community or person deleted when a
|
|
13
|
+
* refetch answers 410 or a `Tombstone`. A 404 tells all of them nothing, so
|
|
14
|
+
* they keep the ghost profile and keep delivering to it.
|
|
15
|
+
*
|
|
16
|
+
* The routers ask the app's `isActorGone(username)` only after `resolveUser`
|
|
17
|
+
* found nobody, so a live account is never answered as gone.
|
|
18
|
+
*/
|
|
19
|
+
/** Does `username` name a local actor that was deleted? Asked only for a username that resolved to nobody. */
|
|
20
|
+
export type IsActorGone = (username: string) => Promise<boolean>;
|
|
21
|
+
/** The body of a `410 Gone` for a deleted object: a bare `Tombstone` naming it. */
|
|
22
|
+
export declare function goneTombstone(id: string): Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* The actor's `Delete` of itself. The id is DETERMINISTIC (no timestamp), the
|
|
25
|
+
* shape Mastodon sends (`<actor>#delete`), so a retried run re-sends the same
|
|
26
|
+
* activity and a delivery queue that dedupes on activity id + inbox sends each
|
|
27
|
+
* inbox one copy.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildActorDeleteActivity(actorUri: string): Record<string, unknown>;
|
|
@@ -53,4 +53,9 @@ export { createWebfingerRouter, type WebfingerRouterConfig, type WebfingerSharin
|
|
|
53
53
|
* The ActivityPub actor + inbox + follow-graph router (actor GET incl. the
|
|
54
54
|
* `instance` actor, inbox POST with HTTP-sig verify, followers/following pages).
|
|
55
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* A deleted local actor: its `Delete` of itself, and the `Tombstone` its URLs
|
|
58
|
+
* answer `410 Gone` with afterwards.
|
|
59
|
+
*/
|
|
60
|
+
export { buildActorDeleteActivity, goneTombstone, type IsActorGone } from './goneActor';
|
|
56
61
|
export { createActorRouter, type ActorRouterConfig, type ActorRouteUser, type ActorSharingState, type ActorRouterLogger, type FollowPage, } from './actorRouter';
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { Router } from 'express';
|
|
15
15
|
import type { UrlBuilders } from '../urls';
|
|
16
|
+
import type { IsActorGone } from './goneActor';
|
|
16
17
|
/** The tri-state consent read for a username with no already-resolved user object. */
|
|
17
18
|
export type WebfingerSharingState = 'enabled' | 'disabled' | 'unknown-user' | 'unavailable';
|
|
18
19
|
/** The resolved-user fields the webfinger consent fallback reads. */
|
|
@@ -55,6 +56,12 @@ export interface WebfingerRouterConfig {
|
|
|
55
56
|
get(username: string): Promise<WebfingerJrd | null>;
|
|
56
57
|
set(username: string, jrd: WebfingerJrd): void;
|
|
57
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether a username that resolved to nobody names a DELETED local actor
|
|
61
|
+
* (`goneActor.ts`). True ⇒ `410 Gone` instead of 404, which is what makes
|
|
62
|
+
* Mastodon delete its copy of the account. Omitted ⇒ every miss is a 404.
|
|
63
|
+
*/
|
|
64
|
+
isActorGone?: IsActorGone;
|
|
58
65
|
/** HTTP response TTL in whole seconds, 0..86400. Defaults to 3600; 0 sends no-store. */
|
|
59
66
|
httpCacheTtlSeconds?: number;
|
|
60
67
|
/** Diagnostics sink. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/federation",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -222,6 +222,7 @@ describe('actor type by Oxy account kind', () => {
|
|
|
222
222
|
expect(community.attributedTo).toBe('https://mention.earth/ap/users/nate/moderators');
|
|
223
223
|
expect(community.moderators).toBe(community.attributedTo);
|
|
224
224
|
expect(community.postingRestrictedToMods).toBe(false);
|
|
225
|
+
expect(community.sensitive).toBe(false);
|
|
225
226
|
// Read back by the same table: an open Group is a community, not a channel.
|
|
226
227
|
expect(accountKindForActor(community)).toBe('community');
|
|
227
228
|
const person = buildActor(PARAMS);
|
|
@@ -471,6 +471,29 @@ describe('federateActorUpdate', () => {
|
|
|
471
471
|
});
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
describe('federateActorDelete', () => {
|
|
475
|
+
it("sends the actor's own Delete, once per shared inbox, with no consent or profile read", async () => {
|
|
476
|
+
const shared = 'https://shared.example/inbox';
|
|
477
|
+
const rig = makeRig({
|
|
478
|
+
sharingEnabled: false,
|
|
479
|
+
followerActorUris: ['a', 'b'],
|
|
480
|
+
followerInboxes: { a: { sharedInboxUrl: shared }, b: { sharedInboxUrl: shared } },
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
await rig.service.federateActorDelete('u-alice', 'alice');
|
|
484
|
+
|
|
485
|
+
expect(rig.enqueued).toHaveLength(1);
|
|
486
|
+
expect(rig.enqueued[0].activityJson).toEqual({
|
|
487
|
+
'@context': AP_CONTEXT,
|
|
488
|
+
id: `${urls.actor('alice')}#delete`,
|
|
489
|
+
type: 'Delete',
|
|
490
|
+
actor: urls.actor('alice'),
|
|
491
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
492
|
+
object: urls.actor('alice'),
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
474
497
|
describe('live delivery authorization', () => {
|
|
475
498
|
const activity = {
|
|
476
499
|
type: 'Create',
|
|
@@ -6,6 +6,8 @@ import { createInboundDispatcher } from '../node/inboundDispatch';
|
|
|
6
6
|
import { createWebfingerRouter, type WebfingerRouterConfig } from '../node/webfingerRouter';
|
|
7
7
|
import { createActorRouter, type ActorRouterConfig } from '../node/actorRouter';
|
|
8
8
|
import { createUrlBuilders } from '../urls';
|
|
9
|
+
import { AP_CONTEXT } from '../apContext';
|
|
10
|
+
import { groupOutboxCollection } from '../groupCollections';
|
|
9
11
|
import { signRequest, type HttpSignatureSigner } from '../httpSignature';
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -210,6 +212,16 @@ describe('webfinger router', () => {
|
|
|
210
212
|
expect(res.body.links[0].href).toBe(urls.actor('alice'));
|
|
211
213
|
});
|
|
212
214
|
|
|
215
|
+
it('answers 410 for a deleted actor, and still 404 for an unknown one', async () => {
|
|
216
|
+
const app = makeWebfingerApp({ isActorGone: async (username) => username === 'gone' });
|
|
217
|
+
const gone = await request(app).get('/.well-known/webfinger?resource=acct:gone@mention.earth');
|
|
218
|
+
expect(gone.status).toBe(410);
|
|
219
|
+
const unknown = await request(app).get(
|
|
220
|
+
'/.well-known/webfinger?resource=acct:ghost@mention.earth',
|
|
221
|
+
);
|
|
222
|
+
expect(unknown.status).toBe(404);
|
|
223
|
+
});
|
|
224
|
+
|
|
213
225
|
it('404s a sharing-disabled user indistinguishably', async () => {
|
|
214
226
|
const app = makeWebfingerApp({
|
|
215
227
|
consent: {
|
|
@@ -589,6 +601,35 @@ describe('actor router — inbox POST', () => {
|
|
|
589
601
|
});
|
|
590
602
|
});
|
|
591
603
|
|
|
604
|
+
describe('actor router — a deleted actor', () => {
|
|
605
|
+
it('answers 410 with a Tombstone on the actor, its inbox and collections; an unknown user stays 404', async () => {
|
|
606
|
+
const app = makeActorApp({
|
|
607
|
+
actorConfig: { isActorGone: async (username) => username === 'gone' },
|
|
608
|
+
});
|
|
609
|
+
const actor = await request(app)
|
|
610
|
+
.get('/ap/users/gone')
|
|
611
|
+
.set('Accept', 'application/activity+json');
|
|
612
|
+
expect(actor.status).toBe(410);
|
|
613
|
+
expect(actor.body).toMatchObject({ id: urls.actor('gone'), type: 'Tombstone' });
|
|
614
|
+
|
|
615
|
+
const inbox = await request(app)
|
|
616
|
+
.post('/ap/users/gone/inbox')
|
|
617
|
+
.set('Content-Type', 'application/activity+json')
|
|
618
|
+
.send({ type: 'Follow' });
|
|
619
|
+
expect(inbox.status).toBe(410);
|
|
620
|
+
|
|
621
|
+
const followers = await request(app)
|
|
622
|
+
.get('/ap/users/gone/followers')
|
|
623
|
+
.set('Accept', 'application/activity+json');
|
|
624
|
+
expect(followers.status).toBe(410);
|
|
625
|
+
|
|
626
|
+
const unknown = await request(app)
|
|
627
|
+
.get('/ap/users/ghost')
|
|
628
|
+
.set('Accept', 'application/activity+json');
|
|
629
|
+
expect(unknown.status).toBe(404);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
592
633
|
describe('community moderators collection', () => {
|
|
593
634
|
const moderatorsApp = () =>
|
|
594
635
|
makeActorApp({
|
|
@@ -629,4 +670,35 @@ describe('community moderators collection', () => {
|
|
|
629
670
|
const res = await request(moderatorsApp()).get('/ap/users/alice/moderators');
|
|
630
671
|
expect(res.status).toBe(404);
|
|
631
672
|
});
|
|
673
|
+
|
|
674
|
+
// Lemmy's `GroupFollowers { type: Collection, totalItems, items }` and
|
|
675
|
+
// `GroupOutbox { type: OrderedCollection, totalItems, orderedItems }`: no
|
|
676
|
+
// paging, items inline. A Person keeps the paged shape Mastodon walks.
|
|
677
|
+
it("serves a Group's followers and outbox in the shapes Lemmy deserializes", async () => {
|
|
678
|
+
const followers = await request(moderatorsApp()).get('/ap/users/forum/followers?page=true');
|
|
679
|
+
expect(followers.body).toEqual({
|
|
680
|
+
'@context': AP_CONTEXT,
|
|
681
|
+
id: urls.followers('forum'),
|
|
682
|
+
type: 'Collection',
|
|
683
|
+
totalItems: 3,
|
|
684
|
+
items: [],
|
|
685
|
+
});
|
|
686
|
+
const announce = { id: `${urls.actor('forum')}/relays/1`, type: 'Announce' };
|
|
687
|
+
expect(
|
|
688
|
+
groupOutboxCollection({
|
|
689
|
+
id: urls.outbox('forum'),
|
|
690
|
+
totalItems: 1,
|
|
691
|
+
orderedItems: [{ '@context': AP_CONTEXT, ...announce }],
|
|
692
|
+
}),
|
|
693
|
+
).toEqual({
|
|
694
|
+
'@context': AP_CONTEXT,
|
|
695
|
+
id: urls.outbox('forum'),
|
|
696
|
+
type: 'OrderedCollection',
|
|
697
|
+
totalItems: 1,
|
|
698
|
+
orderedItems: [announce],
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const person = await request(moderatorsApp()).get('/ap/users/alice/followers');
|
|
702
|
+
expect(person.body).toMatchObject({ type: 'OrderedCollection', first: expect.any(String) });
|
|
703
|
+
});
|
|
632
704
|
});
|
package/src/actorObject.ts
CHANGED
|
@@ -400,11 +400,14 @@ export function createLocalActorBuilder(config: LocalActorBuilderConfig): LocalA
|
|
|
400
400
|
// `attributedTo` (Lemmy, PieFed; Mbin reads `moderators`) and says anybody
|
|
401
401
|
// may post into it. PeerTube refuses a `Group` without `attributedTo`, and a
|
|
402
402
|
// `Group` claiming `postingRestrictedToMods` is read as a channel by every
|
|
403
|
-
// implementation, ours included ({@link accountKindForActor}).
|
|
403
|
+
// implementation, ours included ({@link accountKindForActor}). `sensitive`
|
|
404
|
+
// is Lemmy's NSFW flag on a community, sent as Lemmy sends it; an Oxy
|
|
405
|
+
// community has no NSFW setting.
|
|
404
406
|
if (actorObject.type === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community) {
|
|
405
407
|
actorObject.attributedTo = config.urls.moderators(username);
|
|
406
408
|
actorObject.moderators = config.urls.moderators(username);
|
|
407
409
|
actorObject.postingRestrictedToMods = false;
|
|
410
|
+
actorObject.sensitive = false;
|
|
408
411
|
}
|
|
409
412
|
|
|
410
413
|
const aliases = normalizeAlsoKnownAs(params.alsoKnownAs);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a local `Group` (an Oxy community) publishes its outbox and followers:
|
|
3
|
+
* the shapes Lemmy itself serves and reads back, not the paged
|
|
4
|
+
* `OrderedCollection` + `first` link every other local actor serves.
|
|
5
|
+
*
|
|
6
|
+
* Lemmy deserializes a community's collections into fixed structs that know
|
|
7
|
+
* nothing of paging (`crates/apub/apub/src/protocol/collections/`):
|
|
8
|
+
* - outbox → `GroupOutbox { orderedItems: Vec<Announce> }`, `orderedItems`
|
|
9
|
+
* REQUIRED inline. A summary with only `first` fails to parse, so a server
|
|
10
|
+
* discovering the community backfills nothing. PieFed reads the same inline
|
|
11
|
+
* `orderedItems` and nothing else.
|
|
12
|
+
* - followers → `GroupFollowers { type: "Collection", totalItems, items }`,
|
|
13
|
+
* read for the subscriber count. Lemmy serves `items: []`, so the follower
|
|
14
|
+
* list stays private; this does the same.
|
|
15
|
+
*
|
|
16
|
+
* Mastodon reads `totalItems` from either shape, so a `Group` followed from
|
|
17
|
+
* Mastodon keeps its counts. `Person`, `Organization` and `Service` actors keep
|
|
18
|
+
* the paged shape Mastodon walks.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND, localActorTypeForAccountKind } from './actorObject';
|
|
22
|
+
import { AP_CONTEXT } from './apContext';
|
|
23
|
+
|
|
24
|
+
/** How many of its latest `Announce`s a `Group`'s outbox lists inline (Lemmy's own size). */
|
|
25
|
+
export const GROUP_OUTBOX_SIZE = 50;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a local account of this kind is announced as a `Group`
|
|
29
|
+
* ({@link LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND}) and so serves the Group
|
|
30
|
+
* collections: moderators, and the outbox and followers in the shapes above.
|
|
31
|
+
*/
|
|
32
|
+
export function isGroupActorKind(kind: unknown): boolean {
|
|
33
|
+
return localActorTypeForAccountKind(kind) === LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A `Group`'s outbox: one unpaged `OrderedCollection` of its latest
|
|
38
|
+
* `Announce { Create }` activities, newest first. Each item's own `@context` is
|
|
39
|
+
* dropped; the collection's covers it.
|
|
40
|
+
*/
|
|
41
|
+
export function groupOutboxCollection(params: {
|
|
42
|
+
id: string;
|
|
43
|
+
totalItems: number;
|
|
44
|
+
orderedItems: readonly Record<string, unknown>[];
|
|
45
|
+
}): Record<string, unknown> {
|
|
46
|
+
return {
|
|
47
|
+
'@context': AP_CONTEXT,
|
|
48
|
+
id: params.id,
|
|
49
|
+
type: 'OrderedCollection',
|
|
50
|
+
totalItems: params.totalItems,
|
|
51
|
+
orderedItems: params.orderedItems.map(({ '@context': _context, ...item }) => item),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A `Group`'s followers: the count, and an empty `items` list. */
|
|
56
|
+
export function groupFollowersCollection(params: {
|
|
57
|
+
id: string;
|
|
58
|
+
totalItems: number;
|
|
59
|
+
}): Record<string, unknown> {
|
|
60
|
+
return {
|
|
61
|
+
'@context': AP_CONTEXT,
|
|
62
|
+
id: params.id,
|
|
63
|
+
type: 'Collection',
|
|
64
|
+
totalItems: params.totalItems,
|
|
65
|
+
items: [],
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -132,6 +132,17 @@ export {
|
|
|
132
132
|
type ActorMediaResolver,
|
|
133
133
|
} from './actorObject';
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* A local `Group`'s outbox and followers, in the shapes Lemmy serves and reads
|
|
137
|
+
* back; every other actor keeps the paged collections.
|
|
138
|
+
*/
|
|
139
|
+
export {
|
|
140
|
+
GROUP_OUTBOX_SIZE,
|
|
141
|
+
isGroupActorKind,
|
|
142
|
+
groupOutboxCollection,
|
|
143
|
+
groupFollowersCollection,
|
|
144
|
+
} from './groupCollections';
|
|
145
|
+
|
|
135
146
|
/**
|
|
136
147
|
* Supported external networks, keyed by the transport a connector speaks.
|
|
137
148
|
* `instagram-graph` is Meta's Graph API Business Discovery: a read-only
|
package/src/node/actorRouter.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* signature verification (Phase 2, `trustForwardedHost`) and actor-match, then
|
|
9
9
|
* 202 + async dispatch to the injected inbound dispatcher,
|
|
10
10
|
* - `GET /users/:username/followers` + `/following` — the OXY follow graph
|
|
11
|
-
* (local + bridged federated edges) as paginated `OrderedCollection`s
|
|
11
|
+
* (local + bridged federated edges) as paginated `OrderedCollection`s; a
|
|
12
|
+
* community `Group`'s followers are only counted (`groupCollections.ts`),
|
|
12
13
|
* - `GET /users/:username/moderators` — a community `Group`'s moderators.
|
|
13
14
|
*
|
|
14
15
|
* The CONTENT routes (`outbox`, `featured`, per-post dereference) stay in the app,
|
|
@@ -28,11 +29,9 @@ import { discoveryCacheControl } from './httpCacheControl';
|
|
|
28
29
|
import { verifyHttpSignature } from '../httpSignature';
|
|
29
30
|
import type { UrlBuilders } from '../urls';
|
|
30
31
|
import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
type LocalActorBuilder,
|
|
35
|
-
} from '../actorObject';
|
|
32
|
+
import type { LocalActorBuilder } from '../actorObject';
|
|
33
|
+
import { groupFollowersCollection, isGroupActorKind } from '../groupCollections';
|
|
34
|
+
import { goneTombstone, type IsActorGone } from './goneActor';
|
|
36
35
|
|
|
37
36
|
/** Page size for the paginated followers/following collections (mirrors the outbox). */
|
|
38
37
|
const FOLLOW_PAGE_SIZE = 20;
|
|
@@ -133,6 +132,13 @@ export interface ActorRouterConfig {
|
|
|
133
132
|
* the route is not mounted; any account that is not a community 404s.
|
|
134
133
|
*/
|
|
135
134
|
fetchModerators?(userId: string): Promise<User[]>;
|
|
135
|
+
/**
|
|
136
|
+
* Whether a username that resolved to nobody names a DELETED local actor
|
|
137
|
+
* (`goneActor.ts`). True ⇒ the actor, its inbox and its collections answer
|
|
138
|
+
* `410 Gone` (the actor with a `Tombstone`) instead of 404. Omitted ⇒ every
|
|
139
|
+
* miss is a 404.
|
|
140
|
+
*/
|
|
141
|
+
isActorGone?: IsActorGone;
|
|
136
142
|
/** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
|
|
137
143
|
httpCacheTtlSeconds?: number;
|
|
138
144
|
/** Diagnostics sink. */
|
|
@@ -178,6 +184,19 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
178
184
|
return config.wantsActivityPub(req.headers.accept);
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The answer for a username nobody resolved to: 410 with the actor's
|
|
189
|
+
* `Tombstone` when it is a deleted actor, else the usual 404.
|
|
190
|
+
*/
|
|
191
|
+
async function userMissing(res: Response, username: string): Promise<Response> {
|
|
192
|
+
if (config.isActorGone && (await config.isActorGone(username))) {
|
|
193
|
+
res.set('Content-Type', apContentType);
|
|
194
|
+
res.set('Cache-Control', httpCacheControl);
|
|
195
|
+
return res.status(410).json(goneTombstone(urls.actor(username)));
|
|
196
|
+
}
|
|
197
|
+
return res.status(404).json({ error: 'User not found' });
|
|
198
|
+
}
|
|
199
|
+
|
|
181
200
|
/** Common inbox handler with HTTP signature verification. */
|
|
182
201
|
async function handleInbox(req: InboxRequest, res: Response): Promise<Response> {
|
|
183
202
|
try {
|
|
@@ -249,7 +268,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
249
268
|
|
|
250
269
|
/**
|
|
251
270
|
* Serve a user's followers OR following as a paginated `OrderedCollection` over
|
|
252
|
-
* the authoritative Oxy follow graph (local + bridged federated edges).
|
|
271
|
+
* the authoritative Oxy follow graph (local + bridged federated edges). A
|
|
272
|
+
* `Group`'s followers are the unpaged, itemless `Collection` Lemmy reads.
|
|
253
273
|
*/
|
|
254
274
|
async function serveFollowCollection(
|
|
255
275
|
req: Request,
|
|
@@ -264,7 +284,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
264
284
|
|
|
265
285
|
try {
|
|
266
286
|
const user = await config.resolveUser(username);
|
|
267
|
-
if (!user) return res
|
|
287
|
+
if (!user) return userMissing(res, username);
|
|
268
288
|
|
|
269
289
|
if (!config.consent.isSharingEnabledFromUser(user)) {
|
|
270
290
|
return res.status(404).json({ error: 'User not found' });
|
|
@@ -276,7 +296,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
276
296
|
direction === 'followers' ? user._count?.followers : user._count?.following;
|
|
277
297
|
const profileTotal = typeof rawCount === 'number' ? rawCount : undefined;
|
|
278
298
|
|
|
279
|
-
|
|
299
|
+
const groupFollowers = direction === 'followers' && isGroupActorKind(user.kind);
|
|
300
|
+
if (!page || groupFollowers) {
|
|
280
301
|
// Prefer the profile `_count`; only when absent (the rare search fallback)
|
|
281
302
|
// fetch the authoritative total from a minimal graph list call. Fail-soft
|
|
282
303
|
// to 0 — never 500 the summary.
|
|
@@ -294,6 +315,9 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
294
315
|
}
|
|
295
316
|
|
|
296
317
|
res.set('Content-Type', apContentType);
|
|
318
|
+
if (groupFollowers) {
|
|
319
|
+
return res.json(groupFollowersCollection({ id: collectionUrl(username), totalItems }));
|
|
320
|
+
}
|
|
297
321
|
return res.json({
|
|
298
322
|
'@context': AP_CONTEXT,
|
|
299
323
|
id: collectionUrl(username),
|
|
@@ -403,7 +427,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
403
427
|
}
|
|
404
428
|
|
|
405
429
|
const user = await config.resolveUser(username);
|
|
406
|
-
if (!user) return res
|
|
430
|
+
if (!user) return userMissing(res, username);
|
|
407
431
|
|
|
408
432
|
// Sharing OFF must be indistinguishable from a nonexistent user — same 404
|
|
409
433
|
// body, no separate error code. Derived from the already-resolved user.
|
|
@@ -457,9 +481,12 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
457
481
|
// server drop it permanently rather than retry, so availability wins over
|
|
458
482
|
// gating freshness — the activity is processed and any consent decision is
|
|
459
483
|
// re-checked downstream by the id-based (fail-open) gates.
|
|
484
|
+
// A DELETED actor's inbox refuses delivery with 410, so the sender stops
|
|
485
|
+
// rather than treating it as a transient miss.
|
|
460
486
|
const username = getUsername(req);
|
|
461
487
|
const sharingState = await config.consent.getSharingStateByUsername(username);
|
|
462
|
-
if (sharingState === '
|
|
488
|
+
if (sharingState === 'unknown-user') return userMissing(res, username);
|
|
489
|
+
if (sharingState === 'disabled') {
|
|
463
490
|
return res.status(404).json({ error: 'User not found' });
|
|
464
491
|
}
|
|
465
492
|
|
|
@@ -492,11 +519,8 @@ export function createActorRouter(config: ActorRouterConfig): Router {
|
|
|
492
519
|
const username = getUsername(req);
|
|
493
520
|
try {
|
|
494
521
|
const user = await config.resolveUser(username);
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
!config.consent.isSharingEnabledFromUser(user) ||
|
|
498
|
-
localActorTypeForAccountKind(user.kind) !== LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND.community
|
|
499
|
-
) {
|
|
522
|
+
if (!user) return userMissing(res, username);
|
|
523
|
+
if (!config.consent.isSharingEnabledFromUser(user) || !isGroupActorKind(user.kind)) {
|
|
500
524
|
return res.status(404).json({ error: 'User not found' });
|
|
501
525
|
}
|
|
502
526
|
const moderators = await fetchModerators(String(user._id || user.id));
|
package/src/node/delivery.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { readBodyChunks } from './boundedBody';
|
|
|
30
30
|
import { signRequest, type HttpSignatureSigner } from '../httpSignature';
|
|
31
31
|
import type { UrlBuilders } from '../urls';
|
|
32
32
|
import type { LocalActorBuilder } from '../actorObject';
|
|
33
|
+
import { buildActorDeleteActivity } from './goneActor';
|
|
33
34
|
|
|
34
35
|
/** Total time budget for a single delivery POST (connect + response headers). */
|
|
35
36
|
const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
|
|
@@ -360,6 +361,15 @@ export interface DeliveryService {
|
|
|
360
361
|
): Promise<void>;
|
|
361
362
|
/** Rebroadcast the FULL actor document as an Update(Person) to remote followers. */
|
|
362
363
|
federateActorUpdate(actorOxyUserId: string, username: string): Promise<void>;
|
|
364
|
+
/**
|
|
365
|
+
* Tell every remote follower the actor is gone: its `Delete` of itself
|
|
366
|
+
* (`goneActor.ts`), one per (shared) inbox, signed with its own key. Needs the
|
|
367
|
+
* accepted follower rows, so call it BEFORE deleting them. Asks no consent and
|
|
368
|
+
* resolves no profile, since the account is already gone at its identity
|
|
369
|
+
* provider; it needs only the handle and the key the handle addresses. Throws
|
|
370
|
+
* when it cannot queue, so the caller's job retries.
|
|
371
|
+
*/
|
|
372
|
+
federateActorDelete(actorOxyUserId: string, username: string): Promise<void>;
|
|
363
373
|
}
|
|
364
374
|
|
|
365
375
|
/** Build the outbound delivery + follow-lifecycle service from an app's adapters. */
|
|
@@ -850,6 +860,15 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
850
860
|
}
|
|
851
861
|
}
|
|
852
862
|
|
|
863
|
+
async function federateActorDelete(actorOxyUserId: string, username: string): Promise<void> {
|
|
864
|
+
if (!config.federationEnabled) return;
|
|
865
|
+
await deliverToFollowers(
|
|
866
|
+
buildActorDeleteActivity(urls.actor(username)),
|
|
867
|
+
actorOxyUserId,
|
|
868
|
+
username,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
853
872
|
return {
|
|
854
873
|
deliverActivity,
|
|
855
874
|
queueDelivery,
|
|
@@ -860,5 +879,6 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
|
|
|
860
879
|
sendAccept,
|
|
861
880
|
sendReject,
|
|
862
881
|
federateActorUpdate,
|
|
882
|
+
federateActorDelete,
|
|
863
883
|
};
|
|
864
884
|
}
|