@sdxc/activitypub 0.0.0-pre.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/LICENSE.md +21 -0
- package/README.md +711 -0
- package/dist/collections.d.ts +67 -0
- package/dist/collections.js +38 -0
- package/dist/conformance.d.ts +80 -0
- package/dist/conformance.js +313 -0
- package/dist/discovery.d.ts +46 -0
- package/dist/discovery.js +105 -0
- package/dist/errors.d.ts +115 -0
- package/dist/errors.js +124 -0
- package/dist/federation.d.ts +281 -0
- package/dist/federation.js +513 -0
- package/dist/fixtures/gotosocial.d.ts +114 -0
- package/dist/fixtures/gotosocial.js +115 -0
- package/dist/fixtures/index.d.ts +15 -0
- package/dist/fixtures/index.js +50 -0
- package/dist/fixtures/mastodon.d.ts +489 -0
- package/dist/fixtures/mastodon.js +334 -0
- package/dist/fixtures/misskey.d.ts +254 -0
- package/dist/fixtures/misskey.js +163 -0
- package/dist/fixtures/pixelfed.d.ts +118 -0
- package/dist/fixtures/pixelfed.js +126 -0
- package/dist/fixtures/shared.d.ts +14 -0
- package/dist/fixtures/shared.js +14 -0
- package/dist/fixtures/threads.d.ts +82 -0
- package/dist/fixtures/threads.js +88 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +22 -0
- package/dist/keys.d.ts +100 -0
- package/dist/keys.js +147 -0
- package/dist/lib/availability.d.ts +37 -0
- package/dist/lib/availability.js +52 -0
- package/dist/lib/compact.d.ts +145 -0
- package/dist/lib/compact.js +514 -0
- package/dist/lib/constants.d.ts +35 -0
- package/dist/lib/constants.js +65 -0
- package/dist/lib/delivery-fan-out.d.ts +72 -0
- package/dist/lib/delivery-fan-out.js +202 -0
- package/dist/lib/delivery-post.d.ts +108 -0
- package/dist/lib/delivery-post.js +318 -0
- package/dist/lib/fetch.d.ts +64 -0
- package/dist/lib/fetch.js +103 -0
- package/dist/lib/inbox-error.d.ts +45 -0
- package/dist/lib/inbox-error.js +74 -0
- package/dist/lib/inbox-handle.d.ts +119 -0
- package/dist/lib/inbox-handle.js +587 -0
- package/dist/lib/inbox-receive.d.ts +117 -0
- package/dist/lib/inbox-receive.js +233 -0
- package/dist/lib/inbox-signature.d.ts +61 -0
- package/dist/lib/inbox-signature.js +156 -0
- package/dist/lib/inbox-summarize.d.ts +41 -0
- package/dist/lib/inbox-summarize.js +97 -0
- package/dist/lib/messages.d.ts +36 -0
- package/dist/lib/messages.js +35 -0
- package/dist/lib/parse.d.ts +42 -0
- package/dist/lib/parse.js +348 -0
- package/dist/lib/response.d.ts +45 -0
- package/dist/lib/response.js +59 -0
- package/dist/lib/stringify.d.ts +51 -0
- package/dist/lib/stringify.js +122 -0
- package/dist/lib/tombstone.d.ts +24 -0
- package/dist/lib/tombstone.js +15 -0
- package/dist/lib/types.d.ts +249 -0
- package/dist/lib/types.js +0 -0
- package/dist/memory.d.ts +77 -0
- package/dist/memory.js +146 -0
- package/dist/remote.d.ts +105 -0
- package/dist/remote.js +262 -0
- package/dist/store.d.ts +78 -0
- package/dist/store.js +8 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +10 -0
- package/package.json +54 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builders for the collections an ActivityPub server serves (outbox, followers,
|
|
3
|
+
* following, featured), so an app turns whatever it pages with into the documents
|
|
4
|
+
* `stringify` and `respond` write, with every member it leaves out written as absent.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { ActivityPub } from "./lib/types.js";
|
|
10
|
+
/** An entry of a collection as an app authors it: an IRI, or the document itself. */
|
|
11
|
+
export type CollectionItem = ActivityPub.Draft<ActivityPub.Item>;
|
|
12
|
+
/** A collection as an app authors it; every member besides `id` is optional. */
|
|
13
|
+
export interface CollectionInit {
|
|
14
|
+
id: string;
|
|
15
|
+
/** Alone, with no `first`, it answers the count and hides the members. */
|
|
16
|
+
totalItems?: number | null;
|
|
17
|
+
/** The first page's IRI; with `totalItems` and no items, the shape a paged collection serves. */
|
|
18
|
+
first?: string | null;
|
|
19
|
+
last?: string | null;
|
|
20
|
+
}
|
|
21
|
+
/** A page as an app authors it. */
|
|
22
|
+
export interface PageInit {
|
|
23
|
+
id: string;
|
|
24
|
+
/** The collection this page belongs to. */
|
|
25
|
+
partOf: string;
|
|
26
|
+
next?: string | null;
|
|
27
|
+
prev?: string | null;
|
|
28
|
+
totalItems?: number | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* An ordered collection: reverse chronological, as ActivityPub §5 requires of an outbox
|
|
32
|
+
* and of followers and following.
|
|
33
|
+
*
|
|
34
|
+
* @param init - The id, count, pages, and inline items for a short collection.
|
|
35
|
+
* @example
|
|
36
|
+
* orderedCollection({ id: FOLLOWERS_ID, totalItems: count, first: `${FOLLOWERS_ID}?page=1` });
|
|
37
|
+
*/
|
|
38
|
+
export declare function orderedCollection(init: CollectionInit & {
|
|
39
|
+
orderedItems?: CollectionItem[];
|
|
40
|
+
}): ActivityPub.Draft<ActivityPub.OrderedCollection>;
|
|
41
|
+
/**
|
|
42
|
+
* One page of an ordered collection. A `null` `next` marks the last page, which is how a
|
|
43
|
+
* crawler knows to stop.
|
|
44
|
+
*
|
|
45
|
+
* @param init - The page id, its collection, its neighbours and its items.
|
|
46
|
+
* @example
|
|
47
|
+
* orderedCollectionPage({ id: pageUrl, partOf: OUTBOX_ID, orderedItems: creates, next });
|
|
48
|
+
*/
|
|
49
|
+
export declare function orderedCollectionPage(init: PageInit & {
|
|
50
|
+
orderedItems: CollectionItem[];
|
|
51
|
+
}): ActivityPub.Draft<ActivityPub.OrderedCollectionPage>;
|
|
52
|
+
/**
|
|
53
|
+
* An unordered collection, such as an object's `replies` or `likes`.
|
|
54
|
+
*
|
|
55
|
+
* @param init - The id, count, pages, and inline items.
|
|
56
|
+
*/
|
|
57
|
+
export declare function collection(init: CollectionInit & {
|
|
58
|
+
items?: CollectionItem[];
|
|
59
|
+
}): ActivityPub.Draft<ActivityPub.Collection>;
|
|
60
|
+
/**
|
|
61
|
+
* One page of an unordered collection.
|
|
62
|
+
*
|
|
63
|
+
* @param init - The page id, its collection, its neighbours and its items.
|
|
64
|
+
*/
|
|
65
|
+
export declare function collectionPage(init: PageInit & {
|
|
66
|
+
items: CollectionItem[];
|
|
67
|
+
}): ActivityPub.Draft<ActivityPub.CollectionPage>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An ordered collection: reverse chronological, as ActivityPub §5 requires of an outbox
|
|
3
|
+
* and of followers and following.
|
|
4
|
+
*
|
|
5
|
+
* @param init - The id, count, pages, and inline items for a short collection.
|
|
6
|
+
* @example
|
|
7
|
+
* orderedCollection({ id: FOLLOWERS_ID, totalItems: count, first: `${FOLLOWERS_ID}?page=1` });
|
|
8
|
+
*/
|
|
9
|
+
export function orderedCollection(init) {
|
|
10
|
+
return { ...init, type: "OrderedCollection" };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One page of an ordered collection. A `null` `next` marks the last page, which is how a
|
|
14
|
+
* crawler knows to stop.
|
|
15
|
+
*
|
|
16
|
+
* @param init - The page id, its collection, its neighbours and its items.
|
|
17
|
+
* @example
|
|
18
|
+
* orderedCollectionPage({ id: pageUrl, partOf: OUTBOX_ID, orderedItems: creates, next });
|
|
19
|
+
*/
|
|
20
|
+
export function orderedCollectionPage(init) {
|
|
21
|
+
return { ...init, type: "OrderedCollectionPage" };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* An unordered collection, such as an object's `replies` or `likes`.
|
|
25
|
+
*
|
|
26
|
+
* @param init - The id, count, pages, and inline items.
|
|
27
|
+
*/
|
|
28
|
+
export function collection(init) {
|
|
29
|
+
return { ...init, type: "Collection" };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One page of an unordered collection.
|
|
33
|
+
*
|
|
34
|
+
* @param init - The page id, its collection, its neighbours and its items.
|
|
35
|
+
*/
|
|
36
|
+
export function collectionPage(init) {
|
|
37
|
+
return { ...init, type: "CollectionPage" };
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suites that say what each store the package calls is, registered as Vitest tests
|
|
3
|
+
* against whatever the app constructs. An app runs them over its own D1, KV or Durable Object
|
|
4
|
+
* implementations, which is what lets the package rely on the contracts its interfaces state.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { FollowerStore, KeyProvider, LocalObjects, SeenActivities } from "./store.js";
|
|
10
|
+
/** What the follower store suite needs. */
|
|
11
|
+
export interface FollowerStoreConformanceOptions {
|
|
12
|
+
/** Implementation name, which labels the registered suite. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Builds an empty store; called for every test, so each starts clean. */
|
|
15
|
+
create: () => FollowerStore | Promise<FollowerStore>;
|
|
16
|
+
}
|
|
17
|
+
/** What the seen-activities suite needs. */
|
|
18
|
+
export interface SeenActivitiesConformanceOptions {
|
|
19
|
+
/** Implementation name, which labels the registered suite. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Builds a store with nothing claimed; called for every test, so each starts clean. */
|
|
22
|
+
create: () => SeenActivities | Promise<SeenActivities>;
|
|
23
|
+
/**
|
|
24
|
+
* Moves the store's clock forward in milliseconds, which registers the expiry assertion. A
|
|
25
|
+
* store whose TTL only passes in real time leaves it out.
|
|
26
|
+
*/
|
|
27
|
+
advance?: (ms: number) => void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** What the local objects suite needs. */
|
|
30
|
+
export interface LocalObjectsConformanceOptions {
|
|
31
|
+
/** Implementation name, which labels the registered suite. */
|
|
32
|
+
name: string;
|
|
33
|
+
create: () => LocalObjects | Promise<LocalObjects>;
|
|
34
|
+
/** Ids of objects the store built by `create` serves, at least one. */
|
|
35
|
+
served: string[];
|
|
36
|
+
}
|
|
37
|
+
/** What the key provider suite needs. */
|
|
38
|
+
export interface KeyProviderConformanceOptions {
|
|
39
|
+
/** Implementation name, which labels the registered suite. */
|
|
40
|
+
name: string;
|
|
41
|
+
create: () => KeyProvider | Promise<KeyProvider>;
|
|
42
|
+
/** Ids of actors the provider built by `create` hosts, at least one. */
|
|
43
|
+
hosted: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Registers the suite every `FollowerStore` has to pass: idempotent writes keyed by
|
|
47
|
+
* `(actor, id)`, accepted-only listings, and cursors that visit each follower and inbox once.
|
|
48
|
+
*
|
|
49
|
+
* @param options The implementation under test.
|
|
50
|
+
*
|
|
51
|
+
* @example followerStoreConformance({ name: "d1", create: () => new D1FollowerStore(db) });
|
|
52
|
+
*/
|
|
53
|
+
export declare function followerStoreConformance({ name, create }: FollowerStoreConformanceOptions): void;
|
|
54
|
+
/**
|
|
55
|
+
* Registers the suite every `SeenActivities` has to pass: a claim answers `true` once per id
|
|
56
|
+
* within its TTL, and, when the clock can be moved, again after the TTL has passed.
|
|
57
|
+
*
|
|
58
|
+
* @param options The implementation under test.
|
|
59
|
+
*
|
|
60
|
+
* @example seenActivitiesConformance({ name: "kv", create: () => new KvSeenActivities(env.KV) });
|
|
61
|
+
*/
|
|
62
|
+
export declare function seenActivitiesConformance({ name, create, advance, }: SeenActivitiesConformanceOptions): void;
|
|
63
|
+
/**
|
|
64
|
+
* Registers the suite every `LocalObjects` has to pass: each served id finds its object, and
|
|
65
|
+
* any other IRI finds `null`.
|
|
66
|
+
*
|
|
67
|
+
* @param options The implementation under test and the ids it serves.
|
|
68
|
+
*
|
|
69
|
+
* @example localObjectsConformance({ name: "posts", create: () => posts(db), served: [POST_ID] });
|
|
70
|
+
*/
|
|
71
|
+
export declare function localObjectsConformance({ name, create, served, }: LocalObjectsConformanceOptions): void;
|
|
72
|
+
/**
|
|
73
|
+
* Registers the suite every `KeyProvider` has to pass: each hosted actor answers its own keys,
|
|
74
|
+
* and any other actor answers `null`.
|
|
75
|
+
*
|
|
76
|
+
* @param options The implementation under test and the actors it hosts.
|
|
77
|
+
*
|
|
78
|
+
* @example keyProviderConformance({ name: "secrets", create: () => keys(env), hosted: [ACTOR] });
|
|
79
|
+
*/
|
|
80
|
+
export declare function keyProviderConformance({ name, create, hosted, }: KeyProviderConformanceOptions): void;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suites that say what each store the package calls is, registered as Vitest tests
|
|
3
|
+
* against whatever the app constructs. An app runs them over its own D1, KV or Durable Object
|
|
4
|
+
* implementations, which is what lets the package rely on the contracts its interfaces state.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { unwrap } from "@sdxc/result";
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
/** The local actor most tests follow, and a second one that proves followers are per actor. */
|
|
12
|
+
const ACTOR = "https://local.com/users/author";
|
|
13
|
+
const OTHER_ACTOR = "https://local.com/users/editor";
|
|
14
|
+
/** A TTL long enough that nothing expires mid-test, and the same length in milliseconds. */
|
|
15
|
+
const TTL = "1 hour";
|
|
16
|
+
const TTL_MS = 3_600_000;
|
|
17
|
+
/** A follower of `actor` on `server`, accepted, delivered to through its personal inbox. */
|
|
18
|
+
function follower(server, name, overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
actor: ACTOR,
|
|
21
|
+
id: `${server}/users/${name}`,
|
|
22
|
+
inbox: `${server}/users/${name}/inbox`,
|
|
23
|
+
sharedInbox: null,
|
|
24
|
+
followId: `${server}/follows/${name}`,
|
|
25
|
+
state: "accepted",
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Every item of a listing, read page by page with `limit`, failing on a page larger than it. */
|
|
30
|
+
async function drain(read, limit) {
|
|
31
|
+
let items = [];
|
|
32
|
+
let cursor = null;
|
|
33
|
+
let pages = 0;
|
|
34
|
+
do {
|
|
35
|
+
let current = await read(cursor);
|
|
36
|
+
expect(current.items.length).toBeLessThanOrEqual(limit);
|
|
37
|
+
items.push(...current.items);
|
|
38
|
+
cursor = current.next;
|
|
39
|
+
pages += 1;
|
|
40
|
+
expect(pages, "the cursor reaches the end of the listing").toBeLessThan(100);
|
|
41
|
+
} while (cursor !== null);
|
|
42
|
+
return items;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Registers the suite every `FollowerStore` has to pass: idempotent writes keyed by
|
|
46
|
+
* `(actor, id)`, accepted-only listings, and cursors that visit each follower and inbox once.
|
|
47
|
+
*
|
|
48
|
+
* @param options The implementation under test.
|
|
49
|
+
*
|
|
50
|
+
* @example followerStoreConformance({ name: "d1", create: () => new D1FollowerStore(db) });
|
|
51
|
+
*/
|
|
52
|
+
export function followerStoreConformance({ name, create }) {
|
|
53
|
+
describe(`${name} FollowerStore conformance`, () => {
|
|
54
|
+
test("reads a follower back as it was put", async () => {
|
|
55
|
+
let store = await create();
|
|
56
|
+
let alice = follower("https://one.com", "alice", { sharedInbox: "https://one.com/inbox" });
|
|
57
|
+
unwrap(await store.put(alice));
|
|
58
|
+
expect(unwrap(await store.get(ACTOR, alice.id))).toStrictEqual(alice);
|
|
59
|
+
});
|
|
60
|
+
test("answers null for an actor that follows nobody here", async () => {
|
|
61
|
+
let store = await create();
|
|
62
|
+
expect(unwrap(await store.get(ACTOR, "https://one.com/users/nobody"))).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
test("a repeated put replaces the follower, refreshing inbox and followId", async () => {
|
|
65
|
+
let store = await create();
|
|
66
|
+
let alice = follower("https://one.com", "alice");
|
|
67
|
+
let refreshed = {
|
|
68
|
+
...alice,
|
|
69
|
+
inbox: "https://one.com/new/inbox",
|
|
70
|
+
sharedInbox: "https://one.com/inbox",
|
|
71
|
+
followId: "https://one.com/follows/again",
|
|
72
|
+
};
|
|
73
|
+
unwrap(await store.put(alice));
|
|
74
|
+
unwrap(await store.put(refreshed));
|
|
75
|
+
expect(unwrap(await store.get(ACTOR, alice.id))).toStrictEqual(refreshed);
|
|
76
|
+
expect(unwrap(await store.count(ACTOR))).toBe(1);
|
|
77
|
+
expect(unwrap(await store.list(ACTOR, { cursor: null, limit: 10 })).items).toStrictEqual([
|
|
78
|
+
refreshed,
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
test("a put moves a pending follower to accepted", async () => {
|
|
82
|
+
let store = await create();
|
|
83
|
+
let alice = follower("https://one.com", "alice", { state: "pending" });
|
|
84
|
+
unwrap(await store.put(alice));
|
|
85
|
+
unwrap(await store.put({ ...alice, state: "accepted" }));
|
|
86
|
+
expect(unwrap(await store.get(ACTOR, alice.id))?.state).toBe("accepted");
|
|
87
|
+
expect(unwrap(await store.count(ACTOR))).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
test("keys followers by (actor, id), so following two local actors is two followers", async () => {
|
|
90
|
+
let store = await create();
|
|
91
|
+
let alice = follower("https://one.com", "alice");
|
|
92
|
+
let ofEditor = { ...alice, actor: OTHER_ACTOR, followId: "https://one.com/follows/editor" };
|
|
93
|
+
unwrap(await store.put(alice));
|
|
94
|
+
unwrap(await store.put(ofEditor));
|
|
95
|
+
expect(unwrap(await store.get(ACTOR, alice.id))).toStrictEqual(alice);
|
|
96
|
+
expect(unwrap(await store.get(OTHER_ACTOR, alice.id))).toStrictEqual(ofEditor);
|
|
97
|
+
expect(unwrap(await store.count(ACTOR))).toBe(1);
|
|
98
|
+
expect(unwrap(await store.count(OTHER_ACTOR))).toBe(1);
|
|
99
|
+
unwrap(await store.remove(ACTOR, alice.id));
|
|
100
|
+
expect(unwrap(await store.get(ACTOR, alice.id))).toBeNull();
|
|
101
|
+
expect(unwrap(await store.get(OTHER_ACTOR, alice.id))).toStrictEqual(ofEditor);
|
|
102
|
+
});
|
|
103
|
+
test("keeps a pending follower readable by get and out of list, count and inboxes", async () => {
|
|
104
|
+
let store = await create();
|
|
105
|
+
let alice = follower("https://one.com", "alice");
|
|
106
|
+
let bob = follower("https://two.com", "bob", { state: "pending" });
|
|
107
|
+
unwrap(await store.put(alice));
|
|
108
|
+
unwrap(await store.put(bob));
|
|
109
|
+
expect(unwrap(await store.get(ACTOR, bob.id))).toStrictEqual(bob);
|
|
110
|
+
expect(unwrap(await store.list(ACTOR, { cursor: null, limit: 10 })).items).toStrictEqual([
|
|
111
|
+
alice,
|
|
112
|
+
]);
|
|
113
|
+
expect(unwrap(await store.count(ACTOR))).toBe(1);
|
|
114
|
+
expect(unwrap(await store.inboxes(ACTOR, { cursor: null, limit: 10 })).items).toStrictEqual([
|
|
115
|
+
alice.inbox,
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
test("answers an empty listing and a zero count for an actor nobody follows", async () => {
|
|
119
|
+
let store = await create();
|
|
120
|
+
unwrap(await store.put(follower("https://one.com", "alice")));
|
|
121
|
+
expect(unwrap(await store.list(OTHER_ACTOR, { cursor: null, limit: 10 }))).toStrictEqual({
|
|
122
|
+
items: [],
|
|
123
|
+
next: null,
|
|
124
|
+
});
|
|
125
|
+
expect(unwrap(await store.count(OTHER_ACTOR))).toBe(0);
|
|
126
|
+
expect(unwrap(await store.inboxes(OTHER_ACTOR, { cursor: null, limit: 10 }))).toStrictEqual({
|
|
127
|
+
items: [],
|
|
128
|
+
next: null,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
test("narrows list to followers whose id has the given origin", async () => {
|
|
132
|
+
let store = await create();
|
|
133
|
+
let alice = follower("https://one.com", "alice");
|
|
134
|
+
let bob = follower("https://two.com", "bob");
|
|
135
|
+
let carol = follower("https://one.com", "carol");
|
|
136
|
+
for (let one of [alice, bob, carol])
|
|
137
|
+
unwrap(await store.put(one));
|
|
138
|
+
let items = unwrap(await store.list(ACTOR, { cursor: null, limit: 10, origin: "https://one.com" })).items;
|
|
139
|
+
expect(items).toHaveLength(2);
|
|
140
|
+
expect(new Set(items.map((one) => one.id))).toStrictEqual(new Set([alice.id, carol.id]));
|
|
141
|
+
});
|
|
142
|
+
test("pages through list with limit and cursor, visiting each follower once", async () => {
|
|
143
|
+
let store = await create();
|
|
144
|
+
let all = Array.from({ length: 7 }, (_, index) => follower(`https://server${index}.com`, `user${index}`));
|
|
145
|
+
for (let one of all)
|
|
146
|
+
unwrap(await store.put(one));
|
|
147
|
+
let seen = await drain(async (cursor) => unwrap(await store.list(ACTOR, { cursor, limit: 3 })), 3);
|
|
148
|
+
expect(seen).toHaveLength(all.length);
|
|
149
|
+
expect(new Set(seen.map((one) => one.id))).toStrictEqual(new Set(all.map((one) => one.id)));
|
|
150
|
+
});
|
|
151
|
+
test("answers next null on the page that reaches the end", async () => {
|
|
152
|
+
let store = await create();
|
|
153
|
+
unwrap(await store.put(follower("https://one.com", "alice")));
|
|
154
|
+
unwrap(await store.put(follower("https://two.com", "bob")));
|
|
155
|
+
expect(unwrap(await store.list(ACTOR, { cursor: null, limit: 5 })).next).toBeNull();
|
|
156
|
+
expect(unwrap(await store.inboxes(ACTOR, { cursor: null, limit: 5 })).next).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
test("lists each delivery target once, preferring the shared inbox", async () => {
|
|
159
|
+
let store = await create();
|
|
160
|
+
let shared = "https://one.com/inbox";
|
|
161
|
+
unwrap(await store.put(follower("https://one.com", "alice", { sharedInbox: shared })));
|
|
162
|
+
unwrap(await store.put(follower("https://one.com", "carol", { sharedInbox: shared })));
|
|
163
|
+
unwrap(await store.put(follower("https://two.com", "bob")));
|
|
164
|
+
let targets = unwrap(await store.inboxes(ACTOR, { cursor: null, limit: 10 })).items;
|
|
165
|
+
expect(targets).toHaveLength(2);
|
|
166
|
+
expect(new Set(targets)).toStrictEqual(new Set([shared, "https://two.com/users/bob/inbox"]));
|
|
167
|
+
});
|
|
168
|
+
test("pages through inboxes with limit and cursor, visiting each target once", async () => {
|
|
169
|
+
let store = await create();
|
|
170
|
+
let expected = [];
|
|
171
|
+
for (let index = 0; index < 5; index += 1) {
|
|
172
|
+
let server = `https://server${index}.com`;
|
|
173
|
+
let shared = `${server}/inbox`;
|
|
174
|
+
expected.push(shared);
|
|
175
|
+
unwrap(await store.put(follower(server, "alice", { sharedInbox: shared })));
|
|
176
|
+
unwrap(await store.put(follower(server, "bob", { sharedInbox: shared })));
|
|
177
|
+
}
|
|
178
|
+
unwrap(await store.put(follower("https://solo.com", "carol")));
|
|
179
|
+
expected.push("https://solo.com/users/carol/inbox");
|
|
180
|
+
let seen = await drain(async (cursor) => unwrap(await store.inboxes(ACTOR, { cursor, limit: 2 })), 2);
|
|
181
|
+
expect(seen).toHaveLength(expected.length);
|
|
182
|
+
expect(new Set(seen)).toStrictEqual(new Set(expected));
|
|
183
|
+
});
|
|
184
|
+
test("removeInbox drops every follower delivered through that personal inbox", async () => {
|
|
185
|
+
let store = await create();
|
|
186
|
+
let alice = follower("https://one.com", "alice");
|
|
187
|
+
let bob = follower("https://two.com", "bob");
|
|
188
|
+
unwrap(await store.put(alice));
|
|
189
|
+
unwrap(await store.put({ ...alice, actor: OTHER_ACTOR }));
|
|
190
|
+
unwrap(await store.put(bob));
|
|
191
|
+
unwrap(await store.removeInbox(alice.inbox));
|
|
192
|
+
expect(unwrap(await store.get(ACTOR, alice.id))).toBeNull();
|
|
193
|
+
expect(unwrap(await store.get(OTHER_ACTOR, alice.id))).toBeNull();
|
|
194
|
+
expect(unwrap(await store.get(ACTOR, bob.id))).toStrictEqual(bob);
|
|
195
|
+
});
|
|
196
|
+
test("removeInbox drops every follower whose shared inbox it is", async () => {
|
|
197
|
+
let store = await create();
|
|
198
|
+
let shared = "https://one.com/inbox";
|
|
199
|
+
let alice = follower("https://one.com", "alice", { sharedInbox: shared });
|
|
200
|
+
let carol = follower("https://one.com", "carol", { sharedInbox: shared, state: "pending" });
|
|
201
|
+
let bob = follower("https://two.com", "bob");
|
|
202
|
+
for (let one of [alice, carol, bob])
|
|
203
|
+
unwrap(await store.put(one));
|
|
204
|
+
unwrap(await store.removeInbox(shared));
|
|
205
|
+
expect(unwrap(await store.get(ACTOR, alice.id))).toBeNull();
|
|
206
|
+
expect(unwrap(await store.get(ACTOR, carol.id))).toBeNull();
|
|
207
|
+
expect(unwrap(await store.count(ACTOR))).toBe(1);
|
|
208
|
+
expect(unwrap(await store.inboxes(ACTOR, { cursor: null, limit: 10 })).items).toStrictEqual([
|
|
209
|
+
bob.inbox,
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
test("remove deletes the follower, and succeeds again once it is gone", async () => {
|
|
213
|
+
let store = await create();
|
|
214
|
+
let alice = follower("https://one.com", "alice");
|
|
215
|
+
unwrap(await store.put(alice));
|
|
216
|
+
unwrap(await store.remove(ACTOR, alice.id));
|
|
217
|
+
unwrap(await store.remove(ACTOR, alice.id));
|
|
218
|
+
unwrap(await store.remove(ACTOR, "https://one.com/users/nobody"));
|
|
219
|
+
expect(unwrap(await store.get(ACTOR, alice.id))).toBeNull();
|
|
220
|
+
expect(unwrap(await store.count(ACTOR))).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
test("removeInbox succeeds for an inbox no follower uses", async () => {
|
|
223
|
+
let store = await create();
|
|
224
|
+
let alice = follower("https://one.com", "alice");
|
|
225
|
+
unwrap(await store.put(alice));
|
|
226
|
+
unwrap(await store.removeInbox("https://three.com/inbox"));
|
|
227
|
+
expect(unwrap(await store.count(ACTOR))).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Registers the suite every `SeenActivities` has to pass: a claim answers `true` once per id
|
|
233
|
+
* within its TTL, and, when the clock can be moved, again after the TTL has passed.
|
|
234
|
+
*
|
|
235
|
+
* @param options The implementation under test.
|
|
236
|
+
*
|
|
237
|
+
* @example seenActivitiesConformance({ name: "kv", create: () => new KvSeenActivities(env.KV) });
|
|
238
|
+
*/
|
|
239
|
+
export function seenActivitiesConformance({ name, create, advance, }) {
|
|
240
|
+
describe(`${name} SeenActivities conformance`, () => {
|
|
241
|
+
test("answers true for the first claim and false for a repeat within the TTL", async () => {
|
|
242
|
+
let seen = await create();
|
|
243
|
+
let id = "https://one.com/activities/1";
|
|
244
|
+
expect(unwrap(await seen.claim(id, TTL))).toBe(true);
|
|
245
|
+
expect(unwrap(await seen.claim(id, TTL))).toBe(false);
|
|
246
|
+
expect(unwrap(await seen.claim(id, TTL))).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
test("claims each id on its own", async () => {
|
|
249
|
+
let seen = await create();
|
|
250
|
+
expect(unwrap(await seen.claim("https://one.com/activities/1", TTL))).toBe(true);
|
|
251
|
+
expect(unwrap(await seen.claim("https://one.com/activities/2", TTL))).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
if (advance) {
|
|
254
|
+
test("answers true again once the TTL has passed", async () => {
|
|
255
|
+
let seen = await create();
|
|
256
|
+
let id = "https://one.com/activities/1";
|
|
257
|
+
expect(unwrap(await seen.claim(id, TTL))).toBe(true);
|
|
258
|
+
await advance(TTL_MS - 1);
|
|
259
|
+
expect(unwrap(await seen.claim(id, TTL))).toBe(false);
|
|
260
|
+
await advance(1);
|
|
261
|
+
expect(unwrap(await seen.claim(id, TTL))).toBe(true);
|
|
262
|
+
expect(unwrap(await seen.claim(id, TTL))).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Registers the suite every `LocalObjects` has to pass: each served id finds its object, and
|
|
269
|
+
* any other IRI finds `null`.
|
|
270
|
+
*
|
|
271
|
+
* @param options The implementation under test and the ids it serves.
|
|
272
|
+
*
|
|
273
|
+
* @example localObjectsConformance({ name: "posts", create: () => posts(db), served: [POST_ID] });
|
|
274
|
+
*/
|
|
275
|
+
export function localObjectsConformance({ name, create, served, }) {
|
|
276
|
+
describe(`${name} LocalObjects conformance`, () => {
|
|
277
|
+
test("finds every served object under its own id", async () => {
|
|
278
|
+
let objects = await create();
|
|
279
|
+
expect(served.length, "the suite needs at least one served id").toBeGreaterThan(0);
|
|
280
|
+
for (let id of served)
|
|
281
|
+
expect(unwrap(await objects.find(id))?.id).toBe(id);
|
|
282
|
+
});
|
|
283
|
+
test("answers null for an IRI it serves nothing under", async () => {
|
|
284
|
+
let objects = await create();
|
|
285
|
+
expect(unwrap(await objects.find(`https://remote.com/${crypto.randomUUID()}`))).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Registers the suite every `KeyProvider` has to pass: each hosted actor answers its own keys,
|
|
291
|
+
* and any other actor answers `null`.
|
|
292
|
+
*
|
|
293
|
+
* @param options The implementation under test and the actors it hosts.
|
|
294
|
+
*
|
|
295
|
+
* @example keyProviderConformance({ name: "secrets", create: () => keys(env), hosted: [ACTOR] });
|
|
296
|
+
*/
|
|
297
|
+
export function keyProviderConformance({ name, create, hosted, }) {
|
|
298
|
+
describe(`${name} KeyProvider conformance`, () => {
|
|
299
|
+
test("answers each hosted actor's keys, naming that actor", async () => {
|
|
300
|
+
let provider = await create();
|
|
301
|
+
expect(hosted.length, "the suite needs at least one hosted actor").toBeGreaterThan(0);
|
|
302
|
+
for (let actor of hosted) {
|
|
303
|
+
let keys = unwrap(await provider.keysOf(actor));
|
|
304
|
+
expect(keys?.actor).toBe(actor);
|
|
305
|
+
expect(keys?.rsa.id).toEqual(expect.any(String));
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
test("answers null for an actor it does not host", async () => {
|
|
309
|
+
let provider = await create();
|
|
310
|
+
expect(unwrap(await provider.keysOf(`https://remote.com/users/${crypto.randomUUID()}`))).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery of ActivityPub actors through WebFinger: the `self` link that points a
|
|
3
|
+
* handle such as `acct:user@host` at the actor document, which Mastodon follows to
|
|
4
|
+
* resolve an account, and `lookup`, which resolves a remote handle the same way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DurationInput } from "@sdxc/duration";
|
|
10
|
+
import type { Result } from "@sdxc/result";
|
|
11
|
+
import type { JrdLink } from "@sdxc/well-known/webfinger";
|
|
12
|
+
import type { ActivityPub } from "./lib/types.js";
|
|
13
|
+
import type { Resolver } from "./remote.js";
|
|
14
|
+
import { ActivityPubFetchError } from "./errors.js";
|
|
15
|
+
/** What `lookup` resolves the actor through. */
|
|
16
|
+
export interface LookupOptions {
|
|
17
|
+
/** Fetches the actor the WebFinger `self` link names. */
|
|
18
|
+
resolver: Resolver;
|
|
19
|
+
/** Sent with the WebFinger request; some instances refuse requests without one. */
|
|
20
|
+
userAgent?: string;
|
|
21
|
+
/** @default "10 seconds" */
|
|
22
|
+
timeout?: DurationInput;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The WebFinger link to an actor document: `rel="self"` typed
|
|
26
|
+
* `application/activity+json`, which Mastodon requires before it fetches the actor. WebFinger
|
|
27
|
+
* for `preferredUsername@<actor host>` must answer this link for the reverse check.
|
|
28
|
+
*
|
|
29
|
+
* @param actorId - The actor document's id.
|
|
30
|
+
* @example
|
|
31
|
+
* let jrd = { subject: "acct:hello@example.com", aliases: [], properties: {}, links: [actorLink(ACTOR_ID)] };
|
|
32
|
+
*/
|
|
33
|
+
export declare function actorLink(actorId: string): JrdLink;
|
|
34
|
+
/**
|
|
35
|
+
* Resolves a handle to its actor the way Mastodon does: WebFinger on the handle's host, the
|
|
36
|
+
* `self` link typed as ActivityStreams, then the actor through `resolver`. The actor's
|
|
37
|
+
* canonical handle is `preferredUsername@<actor host>`; unless it equals the given handle,
|
|
38
|
+
* WebFinger on the actor's host must point that canonical handle back at the same actor.
|
|
39
|
+
*
|
|
40
|
+
* @param handle - `@user@host`, `user@host` or `acct:user@host`.
|
|
41
|
+
* @param options - The resolver, and the `User-Agent` and deadline of the WebFinger requests.
|
|
42
|
+
* @returns The actor; `not-found` when WebFinger names no actor link, `id-mismatch` when the
|
|
43
|
+
* actor's host does not confirm the handle, `refused-url` for a handle that names no host.
|
|
44
|
+
* @example let actor = await lookup("@someone@mastodon.social", { resolver });
|
|
45
|
+
*/
|
|
46
|
+
export declare function lookup(handle: string, options: LookupOptions): Promise<Result<ActivityPub.Actor, ActivityPubFetchError>>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import { MEDIA_TYPE, parse } from "@sdxc/well-known/webfinger";
|
|
3
|
+
import { ActivityPubFetchError } from "./errors.js";
|
|
4
|
+
import { ACTIVITY_JSON, AS2_CONTEXT, LD_JSON } from "./lib/constants.js";
|
|
5
|
+
import { DEFAULT_TIMEOUT, fetchText } from "./lib/fetch.js";
|
|
6
|
+
/** A JRD names a handful of links; anything near this size is not a WebFinger answer. */
|
|
7
|
+
const MAX_JRD_BYTES = 64 * 1024;
|
|
8
|
+
/** `acct:user@host`, `user@host` or `@user@host`; the host may carry a port. */
|
|
9
|
+
const HANDLE_PATTERN = /^(?:acct:|@)?([^@\s/]+)@([^@\s/?#]+)$/u;
|
|
10
|
+
/**
|
|
11
|
+
* The WebFinger link to an actor document: `rel="self"` typed
|
|
12
|
+
* `application/activity+json`, which Mastodon requires before it fetches the actor. WebFinger
|
|
13
|
+
* for `preferredUsername@<actor host>` must answer this link for the reverse check.
|
|
14
|
+
*
|
|
15
|
+
* @param actorId - The actor document's id.
|
|
16
|
+
* @example
|
|
17
|
+
* let jrd = { subject: "acct:hello@example.com", aliases: [], properties: {}, links: [actorLink(ACTOR_ID)] };
|
|
18
|
+
*/
|
|
19
|
+
export function actorLink(actorId) {
|
|
20
|
+
return { rel: "self", type: ACTIVITY_JSON, href: actorId, titles: {}, properties: {} };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolves a handle to its actor the way Mastodon does: WebFinger on the handle's host, the
|
|
24
|
+
* `self` link typed as ActivityStreams, then the actor through `resolver`. The actor's
|
|
25
|
+
* canonical handle is `preferredUsername@<actor host>`; unless it equals the given handle,
|
|
26
|
+
* WebFinger on the actor's host must point that canonical handle back at the same actor.
|
|
27
|
+
*
|
|
28
|
+
* @param handle - `@user@host`, `user@host` or `acct:user@host`.
|
|
29
|
+
* @param options - The resolver, and the `User-Agent` and deadline of the WebFinger requests.
|
|
30
|
+
* @returns The actor; `not-found` when WebFinger names no actor link, `id-mismatch` when the
|
|
31
|
+
* actor's host does not confirm the handle, `refused-url` for a handle that names no host.
|
|
32
|
+
* @example let actor = await lookup("@someone@mastodon.social", { resolver });
|
|
33
|
+
*/
|
|
34
|
+
export async function lookup(handle, options) {
|
|
35
|
+
let match = HANDLE_PATTERN.exec(handle.trim());
|
|
36
|
+
let user = match?.[1];
|
|
37
|
+
let host = match?.[2];
|
|
38
|
+
if (user === undefined || host === undefined || !URL.canParse(`https://${host}/`)) {
|
|
39
|
+
return failure(new ActivityPubFetchError("refused-url", handle, `${handle} is not a user@host handle`));
|
|
40
|
+
}
|
|
41
|
+
let self = await selfLink(user, host, options);
|
|
42
|
+
if (isFailure(self))
|
|
43
|
+
return self;
|
|
44
|
+
let actor = await options.resolver.actor(self.data);
|
|
45
|
+
if (isFailure(actor))
|
|
46
|
+
return actor;
|
|
47
|
+
let actorId = actor.data.id;
|
|
48
|
+
let actorHost = new URL(actorId).host;
|
|
49
|
+
let username = actor.data.preferredUsername;
|
|
50
|
+
let sameHost = actorHost === new URL(`https://${host}/`).host;
|
|
51
|
+
if (sameHost && username.toLowerCase() === user.toLowerCase())
|
|
52
|
+
return actor;
|
|
53
|
+
let canonical = `${username}@${actorHost}`;
|
|
54
|
+
let reverse = await selfLink(username, actorHost, options);
|
|
55
|
+
if (isFailure(reverse) && reverse.error.retryable)
|
|
56
|
+
return reverse;
|
|
57
|
+
if (isFailure(reverse) || reverse.data !== actorId) {
|
|
58
|
+
return failure(new ActivityPubFetchError("id-mismatch", actorId, `${actorId} is ${canonical}, whose WebFinger does not confirm ${user}@${host}`, { cause: isFailure(reverse) ? reverse.error : undefined }));
|
|
59
|
+
}
|
|
60
|
+
return actor;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The `self` link typed as ActivityStreams that WebFinger on `host` answers for
|
|
64
|
+
* `acct:user@host`; `not-found` when it names none.
|
|
65
|
+
*/
|
|
66
|
+
async function selfLink(user, host, options) {
|
|
67
|
+
let account = `acct:${user}@${host}`;
|
|
68
|
+
let url = new URL(`https://${host}/.well-known/webfinger`);
|
|
69
|
+
url.searchParams.set("resource", account);
|
|
70
|
+
let headers = new Headers({ accept: MEDIA_TYPE });
|
|
71
|
+
if (options.userAgent !== undefined)
|
|
72
|
+
headers.set("user-agent", options.userAgent);
|
|
73
|
+
let body = await fetchText(url.href, {
|
|
74
|
+
headers,
|
|
75
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT,
|
|
76
|
+
maxBytes: MAX_JRD_BYTES,
|
|
77
|
+
});
|
|
78
|
+
if (isFailure(body))
|
|
79
|
+
return body;
|
|
80
|
+
let jrd = parse(body.data.text);
|
|
81
|
+
if (isFailure(jrd)) {
|
|
82
|
+
return failure(new ActivityPubFetchError("invalid-document", url.href, jrd.error.message, {
|
|
83
|
+
cause: jrd.error,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
let self = jrd.data.links.find((link) => link.rel === "self" && isActivityType(link.type));
|
|
87
|
+
if (self === undefined || self.href === null) {
|
|
88
|
+
return failure(new ActivityPubFetchError("not-found", url.href, `${account} names no ActivityPub actor`));
|
|
89
|
+
}
|
|
90
|
+
return success(self.href);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Whether a link's media type is ActivityStreams: `application/activity+json`, or
|
|
94
|
+
* `application/ld+json` with the AS2 profile, which some servers write instead.
|
|
95
|
+
*/
|
|
96
|
+
function isActivityType(type) {
|
|
97
|
+
if (type === null || type === undefined)
|
|
98
|
+
return false;
|
|
99
|
+
let [essence = "", ...parameters] = type.split(";").map((part) => part.trim());
|
|
100
|
+
if (essence.toLowerCase() === ACTIVITY_JSON)
|
|
101
|
+
return true;
|
|
102
|
+
if (essence.toLowerCase() !== LD_JSON)
|
|
103
|
+
return false;
|
|
104
|
+
return parameters.some((parameter) => parameter.replaceAll('"', "") === `profile=${AS2_CONTEXT}`);
|
|
105
|
+
}
|