@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
package/dist/memory.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory implementations of every store the package calls, for tests and for a local
|
|
3
|
+
* server that needs no persistence. They pass the conformance suite, so a test that runs
|
|
4
|
+
* against them exercises the same contracts a production store is held to.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { toMs } from "@sdxc/duration";
|
|
10
|
+
import { failure, success } from "@sdxc/result";
|
|
11
|
+
/** Matches a cursor this module wrote: the decimal offset of the next item. */
|
|
12
|
+
const OFFSET_CURSOR = /^(0|[1-9]\d*)$/;
|
|
13
|
+
/**
|
|
14
|
+
* The page of `items` a cursor and limit select. The cursor is the offset of the page's first
|
|
15
|
+
* item, so pages over a list nobody writes to visit every item exactly once.
|
|
16
|
+
*/
|
|
17
|
+
function page(items, cursor, limit) {
|
|
18
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
19
|
+
return failure(new RangeError(`A page limit must be a positive integer, got ${limit}.`));
|
|
20
|
+
}
|
|
21
|
+
let start = 0;
|
|
22
|
+
if (cursor !== null) {
|
|
23
|
+
if (!OFFSET_CURSOR.test(cursor)) {
|
|
24
|
+
return failure(new TypeError(`"${cursor}" is not a cursor this store wrote.`));
|
|
25
|
+
}
|
|
26
|
+
start = Number(cursor);
|
|
27
|
+
}
|
|
28
|
+
let end = start + limit;
|
|
29
|
+
return success({
|
|
30
|
+
items: items.slice(start, end),
|
|
31
|
+
next: end < items.length ? String(end) : null,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** The origin of `url`, or `null` for a string no follower id from a parsed actor could be. */
|
|
35
|
+
function originOf(url) {
|
|
36
|
+
return URL.parse(url)?.origin ?? null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Followers held in insertion order, so every listing is stable across pages. A repeated `put`
|
|
40
|
+
* keeps the follower's position, and every read returns a copy the caller may change freely.
|
|
41
|
+
*/
|
|
42
|
+
export class MemoryFollowerStore {
|
|
43
|
+
#followers = new Map();
|
|
44
|
+
/** @param followers Followers the store starts with, as if each had been `put` in order. */
|
|
45
|
+
constructor(followers = []) {
|
|
46
|
+
for (let follower of followers)
|
|
47
|
+
this.#followers.set(keyOf(follower.actor, follower.id), { ...follower });
|
|
48
|
+
}
|
|
49
|
+
/** Inserts or replaces by `(actor, id)`, refreshing inbox, shared inbox, `followId` and state. */
|
|
50
|
+
async put(follower) {
|
|
51
|
+
this.#followers.set(keyOf(follower.actor, follower.id), { ...follower });
|
|
52
|
+
return success(undefined);
|
|
53
|
+
}
|
|
54
|
+
/** The follower in either state, or `null` when `id` does not follow `actor`. */
|
|
55
|
+
async get(actor, id) {
|
|
56
|
+
let follower = this.#followers.get(keyOf(actor, id));
|
|
57
|
+
return success(follower ? { ...follower } : null);
|
|
58
|
+
}
|
|
59
|
+
/** Succeeds whether or not the follower existed. */
|
|
60
|
+
async remove(actor, id) {
|
|
61
|
+
this.#followers.delete(keyOf(actor, id));
|
|
62
|
+
return success(undefined);
|
|
63
|
+
}
|
|
64
|
+
/** Drops followers of every local actor whose inbox or shared inbox is `inbox`. */
|
|
65
|
+
async removeInbox(inbox) {
|
|
66
|
+
for (let [key, follower] of this.#followers) {
|
|
67
|
+
if (follower.inbox === inbox || follower.sharedInbox === inbox)
|
|
68
|
+
this.#followers.delete(key);
|
|
69
|
+
}
|
|
70
|
+
return success(undefined);
|
|
71
|
+
}
|
|
72
|
+
/** Accepted followers of `actor` in insertion order, narrowed to `origin` when given. */
|
|
73
|
+
async list(actor, options) {
|
|
74
|
+
let followers = this.#accepted(actor);
|
|
75
|
+
if (options.origin !== undefined) {
|
|
76
|
+
let origin = options.origin;
|
|
77
|
+
followers = followers.filter((follower) => originOf(follower.id) === origin);
|
|
78
|
+
}
|
|
79
|
+
return page(followers.map((follower) => ({ ...follower })), options.cursor, options.limit);
|
|
80
|
+
}
|
|
81
|
+
/** Accepted followers of `actor`, the number the followers collection publishes. */
|
|
82
|
+
async count(actor) {
|
|
83
|
+
return success(this.#accepted(actor).length);
|
|
84
|
+
}
|
|
85
|
+
/** Each distinct `sharedInbox ?? inbox` once, in the order its first follower was added. */
|
|
86
|
+
async inboxes(actor, options) {
|
|
87
|
+
let targets = new Set(this.#accepted(actor).map((one) => one.sharedInbox ?? one.inbox));
|
|
88
|
+
return page([...targets], options.cursor, options.limit);
|
|
89
|
+
}
|
|
90
|
+
/** The accepted followers of `actor`, in insertion order. */
|
|
91
|
+
#accepted(actor) {
|
|
92
|
+
return [...this.#followers.values()].filter((follower) => follower.actor === actor && follower.state === "accepted");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The map key of a follower; the separator cannot appear inside an IRI. */
|
|
96
|
+
function keyOf(actor, id) {
|
|
97
|
+
return `${actor} ${id}`;
|
|
98
|
+
}
|
|
99
|
+
/** Claimed activity ids, each held until its TTL passes and then claimable again. */
|
|
100
|
+
export class MemorySeenActivities {
|
|
101
|
+
#expires = new Map();
|
|
102
|
+
#now;
|
|
103
|
+
/** @param options The clock claims are timed against. */
|
|
104
|
+
constructor(options = {}) {
|
|
105
|
+
this.#now = options.now ?? Date.now;
|
|
106
|
+
}
|
|
107
|
+
/** Fails on a TTL `@sdxc/duration` cannot read, so a mistyped TTL surfaces as an error. */
|
|
108
|
+
async claim(id, ttl) {
|
|
109
|
+
let ms = toMs(ttl);
|
|
110
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
111
|
+
return failure(new RangeError(`"${String(ttl)}" is not a duration a claim can last.`));
|
|
112
|
+
}
|
|
113
|
+
let now = this.#now();
|
|
114
|
+
for (let [seen, expires] of this.#expires)
|
|
115
|
+
if (expires <= now)
|
|
116
|
+
this.#expires.delete(seen);
|
|
117
|
+
if (this.#expires.has(id))
|
|
118
|
+
return success(false);
|
|
119
|
+
this.#expires.set(id, now + ms);
|
|
120
|
+
return success(true);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** A fixed set of objects the app serves, found by their `id`. */
|
|
124
|
+
export class MemoryLocalObjects {
|
|
125
|
+
#objects;
|
|
126
|
+
/** @param objects The objects served; a later one with a repeated `id` wins. */
|
|
127
|
+
constructor(objects = []) {
|
|
128
|
+
this.#objects = new Map(objects.map((object) => [object.id, object]));
|
|
129
|
+
}
|
|
130
|
+
/** The object served under `id`, or `null` for any other IRI. */
|
|
131
|
+
async find(id) {
|
|
132
|
+
return success(this.#objects.get(id) ?? null);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** A fixed set of hosted actors' keys, found by actor id. */
|
|
136
|
+
export class MemoryKeyProvider {
|
|
137
|
+
#keys;
|
|
138
|
+
/** @param keys The keys of every hosted actor; a later entry for the same actor wins. */
|
|
139
|
+
constructor(keys = []) {
|
|
140
|
+
this.#keys = new Map(keys.map((one) => [one.actor, one]));
|
|
141
|
+
}
|
|
142
|
+
/** The actor's keys, or `null` for an actor this provider does not host. */
|
|
143
|
+
async keysOf(actor) {
|
|
144
|
+
return success(this.#keys.get(actor) ?? null);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching remote actors, keys and objects safely: every request through
|
|
3
|
+
* `@sdxc/outbound` within a deadline and a size cap, signed when the app asks for
|
|
4
|
+
* authorized fetch, each document checked against the URL it came from, and cached.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Cache } from "@sdxc/cache";
|
|
10
|
+
import type { DurationInput } from "@sdxc/duration";
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import type { ActorKeys } from "./keys.js";
|
|
13
|
+
import type { ActivityPub } from "./lib/types.js";
|
|
14
|
+
import type { KeyProvider } from "./store.js";
|
|
15
|
+
import { ActivityPubFetchError } from "./errors.js";
|
|
16
|
+
/**
|
|
17
|
+
* Who signs the resolver's GETs: a local actor and its keys, or the provider that holds
|
|
18
|
+
* them, read on each request; a provider holding no keys for the actor leaves GETs unsigned.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResolverSigner {
|
|
21
|
+
actor: string;
|
|
22
|
+
keys: ActorKeys | KeyProvider;
|
|
23
|
+
}
|
|
24
|
+
/** How long a fetched document is reused, by what it was fetched as. */
|
|
25
|
+
export interface ResolverTtl {
|
|
26
|
+
/** @default "1 day" */
|
|
27
|
+
actor?: DurationInput;
|
|
28
|
+
/** @default "1 hour" */
|
|
29
|
+
object?: DurationInput;
|
|
30
|
+
}
|
|
31
|
+
/** What a `RemoteResolver` is built from. */
|
|
32
|
+
export interface ResolverOptions {
|
|
33
|
+
/** Remote documents, kept as JSON under `activitypub:doc:<iri>`; a failing store is a miss. */
|
|
34
|
+
cache: Cache;
|
|
35
|
+
/** Signs every GET (authorized fetch), which secure-mode Mastodon and GoToSocial require. */
|
|
36
|
+
signer?: ResolverSigner;
|
|
37
|
+
/** Sent on every request; some instances refuse requests without one. */
|
|
38
|
+
userAgent: string;
|
|
39
|
+
ttl?: ResolverTtl;
|
|
40
|
+
/** One deadline for the redirect chain and the body. @default "10 seconds" */
|
|
41
|
+
timeout?: DurationInput;
|
|
42
|
+
/** @default 1048576 */
|
|
43
|
+
maxBytes?: number;
|
|
44
|
+
}
|
|
45
|
+
/** How one lookup treats the cache. */
|
|
46
|
+
export interface ResolveOptions {
|
|
47
|
+
/** Skips the cached copy and refetches, storing what arrives. @default false */
|
|
48
|
+
fresh?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** A verified key: the actor that owns it and publishes it, and the key itself. */
|
|
51
|
+
export interface ResolvedKey {
|
|
52
|
+
/** The owning actor's id, which an inbox compares with `activity.actor`. */
|
|
53
|
+
owner: string;
|
|
54
|
+
/** A verify-only key for `@sdxc/http-signatures`'s `verify`. */
|
|
55
|
+
publicKey: CryptoKey;
|
|
56
|
+
actor: ActivityPub.Actor;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reads remote ActivityPub documents. Every method answers an `ActivityPubFetchError`
|
|
60
|
+
* rather than throwing, and `gone` means the resource was deleted: its cache entry is
|
|
61
|
+
* already evicted, so the caller forgets it too. `RemoteResolver` is the implementation;
|
|
62
|
+
* a test may supply its own.
|
|
63
|
+
*/
|
|
64
|
+
export interface Resolver {
|
|
65
|
+
/** An actor, fetched at its id. */
|
|
66
|
+
actor(iri: string, options?: ResolveOptions): Promise<Result<ActivityPub.Actor, ActivityPubFetchError>>;
|
|
67
|
+
/**
|
|
68
|
+
* The key a signature's `keyId` names, resolved to the actor that publishes it. The
|
|
69
|
+
* key's `owner` must be that actor and the actor must list the key, so a key document
|
|
70
|
+
* cannot claim an actor that never vouched for it.
|
|
71
|
+
*/
|
|
72
|
+
key(keyId: string, options?: ResolveOptions): Promise<Result<ResolvedKey, ActivityPubFetchError>>;
|
|
73
|
+
/** An object or an activity; one with an `actor` member reads as an activity. */
|
|
74
|
+
object(iri: string, options?: ResolveOptions): Promise<Result<ActivityPub.Object | ActivityPub.Activity, ActivityPubFetchError>>;
|
|
75
|
+
/** The decoded document at `iri` after the same checks, for a shape no reader models. */
|
|
76
|
+
document(iri: string, options?: ResolveOptions): Promise<Result<Record<string, unknown>, ActivityPubFetchError>>;
|
|
77
|
+
/** Forgets the cached copy of `iri`, as after an actor's `Update` or `Delete`. */
|
|
78
|
+
evict(iri: string): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reads remote documents through `@sdxc/outbound` and the app's cache. A document is
|
|
82
|
+
* accepted only when its `id` has the origin the redirect chain ended at, so only that
|
|
83
|
+
* origin can speak for an id, and a `410` or a `Tombstone` answers `gone`.
|
|
84
|
+
*
|
|
85
|
+
* @example let resolver = new RemoteResolver({ cache, signer: { actor: ACTOR_ID, keys }, userAgent: USER_AGENT });
|
|
86
|
+
*/
|
|
87
|
+
export declare class RemoteResolver implements Resolver {
|
|
88
|
+
#private;
|
|
89
|
+
/** @param options - The cache, the optional signer, the `User-Agent`, and the bounds. */
|
|
90
|
+
constructor(options: ResolverOptions);
|
|
91
|
+
/** An actor, fetched at its id. */
|
|
92
|
+
actor(iri: string, options?: ResolveOptions): Promise<Result<ActivityPub.Actor, ActivityPubFetchError>>;
|
|
93
|
+
/**
|
|
94
|
+
* The key a signature's `keyId` names, resolved to the actor that publishes it. The
|
|
95
|
+
* key's `owner` must be that actor and the actor must list the key, so a key document
|
|
96
|
+
* cannot claim an actor that never vouched for it.
|
|
97
|
+
*/
|
|
98
|
+
key(keyId: string, options?: ResolveOptions): Promise<Result<ResolvedKey, ActivityPubFetchError>>;
|
|
99
|
+
/** An object or an activity; one with an `actor` member reads as an activity. */
|
|
100
|
+
object(iri: string, options?: ResolveOptions): Promise<Result<ActivityPub.Object | ActivityPub.Activity, ActivityPubFetchError>>;
|
|
101
|
+
/** The decoded document at `iri` after the same checks, for a shape no reader models. */
|
|
102
|
+
document(iri: string, options?: ResolveOptions): Promise<Result<Record<string, unknown>, ActivityPubFetchError>>;
|
|
103
|
+
/** Drops both spellings of the entry, so a fragment never keeps a deleted copy alive. */
|
|
104
|
+
evict(iri: string): Promise<void>;
|
|
105
|
+
}
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { sign } from "@sdxc/http-signatures";
|
|
2
|
+
import { release } from "@sdxc/outbound";
|
|
3
|
+
import { failure, isFailure, isSuccess, success } from "@sdxc/result";
|
|
4
|
+
import { ActivityPubFetchError } from "./errors.js";
|
|
5
|
+
import { importPublicKey } from "./keys.js";
|
|
6
|
+
import { ACTIVITY_ACCEPT } from "./lib/constants.js";
|
|
7
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT, decodeObject, readSuccess, request, } from "./lib/fetch.js";
|
|
8
|
+
import { parseActivity, parseActor, parseObject } from "./lib/parse.js";
|
|
9
|
+
/** Mastodon refetches an actor about once a day, so a profile edit reaches it as fast. */
|
|
10
|
+
const DEFAULT_ACTOR_TTL = "1 day";
|
|
11
|
+
/** Short enough that an edited post is seen again within the hour. */
|
|
12
|
+
const DEFAULT_OBJECT_TTL = "1 hour";
|
|
13
|
+
/** The namespace of every cache entry, so the resolver shares a store with anything else. */
|
|
14
|
+
const CACHE_PREFIX = "activitypub:doc:";
|
|
15
|
+
/**
|
|
16
|
+
* Reads remote documents through `@sdxc/outbound` and the app's cache. A document is
|
|
17
|
+
* accepted only when its `id` has the origin the redirect chain ended at, so only that
|
|
18
|
+
* origin can speak for an id, and a `410` or a `Tombstone` answers `gone`.
|
|
19
|
+
*
|
|
20
|
+
* @example let resolver = new RemoteResolver({ cache, signer: { actor: ACTOR_ID, keys }, userAgent: USER_AGENT });
|
|
21
|
+
*/
|
|
22
|
+
export class RemoteResolver {
|
|
23
|
+
#options;
|
|
24
|
+
#actorTtl;
|
|
25
|
+
#objectTtl;
|
|
26
|
+
#timeout;
|
|
27
|
+
#maxBytes;
|
|
28
|
+
/** @param options - The cache, the optional signer, the `User-Agent`, and the bounds. */
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.#options = options;
|
|
31
|
+
this.#actorTtl = options.ttl?.actor ?? DEFAULT_ACTOR_TTL;
|
|
32
|
+
this.#objectTtl = options.ttl?.object ?? DEFAULT_OBJECT_TTL;
|
|
33
|
+
this.#timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
34
|
+
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
35
|
+
}
|
|
36
|
+
/** An actor, fetched at its id. */
|
|
37
|
+
async actor(iri, options) {
|
|
38
|
+
let json = await this.#load(iri, this.#actorTtl, options);
|
|
39
|
+
if (isFailure(json))
|
|
40
|
+
return json;
|
|
41
|
+
return readActor(iri, json.data);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The key a signature's `keyId` names, resolved to the actor that publishes it. The
|
|
45
|
+
* key's `owner` must be that actor and the actor must list the key, so a key document
|
|
46
|
+
* cannot claim an actor that never vouched for it.
|
|
47
|
+
*/
|
|
48
|
+
async key(keyId, options) {
|
|
49
|
+
let json = await this.#load(keyId, this.#actorTtl, options);
|
|
50
|
+
if (isFailure(json))
|
|
51
|
+
return json;
|
|
52
|
+
let actor;
|
|
53
|
+
let asActor = parseActor(json.data);
|
|
54
|
+
if (isSuccess(asActor)) {
|
|
55
|
+
actor = asActor.data;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
let owner = claimedOwner(json.data, keyId);
|
|
59
|
+
if (owner === null) {
|
|
60
|
+
return failure(new ActivityPubFetchError("invalid-document", keyId, `${keyId} is neither an actor nor a key with an owner`));
|
|
61
|
+
}
|
|
62
|
+
let fetched = await this.actor(owner, options);
|
|
63
|
+
if (isFailure(fetched))
|
|
64
|
+
return fetched;
|
|
65
|
+
actor = fetched.data;
|
|
66
|
+
}
|
|
67
|
+
let publicKey = actor.publicKey;
|
|
68
|
+
if (publicKey === null || publicKey.id !== keyId) {
|
|
69
|
+
return failure(new ActivityPubFetchError("not-found", keyId, `${actor.id} does not publish ${keyId}`));
|
|
70
|
+
}
|
|
71
|
+
if (publicKey.owner !== actor.id) {
|
|
72
|
+
return failure(new ActivityPubFetchError("id-mismatch", keyId, `${keyId} is owned by ${publicKey.owner}, not by ${actor.id}`));
|
|
73
|
+
}
|
|
74
|
+
let imported = await importPublicKey(publicKey.publicKeyPem);
|
|
75
|
+
if (isFailure(imported)) {
|
|
76
|
+
return failure(new ActivityPubFetchError("invalid-document", keyId, imported.error.message, {
|
|
77
|
+
cause: imported.error,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
return success({ owner: actor.id, publicKey: imported.data, actor });
|
|
81
|
+
}
|
|
82
|
+
/** An object or an activity; one with an `actor` member reads as an activity. */
|
|
83
|
+
async object(iri, options) {
|
|
84
|
+
let json = await this.#load(iri, this.#objectTtl, options);
|
|
85
|
+
if (isFailure(json))
|
|
86
|
+
return json;
|
|
87
|
+
let parsed = "actor" in json.data ? parseActivity(json.data) : parseObject(json.data);
|
|
88
|
+
if (isFailure(parsed))
|
|
89
|
+
return failure(invalidDocument(iri, parsed.error));
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
/** The decoded document at `iri` after the same checks, for a shape no reader models. */
|
|
93
|
+
document(iri, options) {
|
|
94
|
+
return this.#load(iri, this.#objectTtl, options);
|
|
95
|
+
}
|
|
96
|
+
/** Drops both spellings of the entry, so a fragment never keeps a deleted copy alive. */
|
|
97
|
+
async evict(iri) {
|
|
98
|
+
let cache = this.#options.cache;
|
|
99
|
+
await Promise.all([
|
|
100
|
+
cache.delete(cacheKey(iri)).catch(() => undefined),
|
|
101
|
+
cache.delete(cacheKey(withoutFragment(iri))).catch(() => undefined),
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The signer's keys, read from its provider when it names one: `null` when the provider
|
|
106
|
+
* holds none for the signer, which sends the GET unsigned, and a retryable
|
|
107
|
+
* `unauthorized` when the provider fails.
|
|
108
|
+
*/
|
|
109
|
+
async #signingKeys(url, signer) {
|
|
110
|
+
if (!("keysOf" in signer.keys))
|
|
111
|
+
return success(signer.keys);
|
|
112
|
+
let found = await signer.keys.keysOf(signer.actor);
|
|
113
|
+
if (isFailure(found)) {
|
|
114
|
+
return failure(new ActivityPubFetchError("unauthorized", url, `Could not read the keys of ${signer.actor}: ${found.error.message}`, { retryable: true, cause: found.error }));
|
|
115
|
+
}
|
|
116
|
+
return success(found.data);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The headers of one GET, signed with draft-cavage over `(request-target) host date`
|
|
120
|
+
* when a signer is set, the scheme every server that requires signed GETs accepts.
|
|
121
|
+
*/
|
|
122
|
+
async #headersFor(url) {
|
|
123
|
+
let headers = new Headers({ accept: ACTIVITY_ACCEPT, "user-agent": this.#options.userAgent });
|
|
124
|
+
let signer = this.#options.signer;
|
|
125
|
+
if (signer === undefined)
|
|
126
|
+
return success(headers);
|
|
127
|
+
let keys = await this.#signingKeys(url, signer);
|
|
128
|
+
if (isFailure(keys))
|
|
129
|
+
return keys;
|
|
130
|
+
if (keys.data === null)
|
|
131
|
+
return success(headers);
|
|
132
|
+
let signed = await sign(new Request(url, { headers }), {
|
|
133
|
+
scheme: "draft-cavage",
|
|
134
|
+
key: { id: keys.data.rsa.id, privateKey: keys.data.rsa.privateKey },
|
|
135
|
+
});
|
|
136
|
+
if (isFailure(signed)) {
|
|
137
|
+
return failure(new ActivityPubFetchError("unauthorized", url, `Could not sign the request: ${signed.error.message}`, {
|
|
138
|
+
cause: signed.error,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
return success(new Headers(signed.data.headers));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Fetches a document. A signed request redirected elsewhere carries a signature over
|
|
145
|
+
* the first URL, so when the end of the chain refuses it, it is signed for that URL
|
|
146
|
+
* and asked once more.
|
|
147
|
+
*/
|
|
148
|
+
async #fetchDocument(url) {
|
|
149
|
+
let headers = await this.#headersFor(url);
|
|
150
|
+
if (isFailure(headers))
|
|
151
|
+
return headers;
|
|
152
|
+
let maxBytes = this.#maxBytes;
|
|
153
|
+
let fetchOptions = {
|
|
154
|
+
headers: headers.data,
|
|
155
|
+
timeout: this.#timeout,
|
|
156
|
+
maxBytes,
|
|
157
|
+
};
|
|
158
|
+
let answered = await request(url, fetchOptions);
|
|
159
|
+
if (isFailure(answered))
|
|
160
|
+
return answered;
|
|
161
|
+
let refused = answered.data.response.status === 401 || answered.data.response.status === 403;
|
|
162
|
+
if (refused && this.#options.signer !== undefined && answered.data.url.href !== url) {
|
|
163
|
+
release(answered.data.response.body);
|
|
164
|
+
let final = answered.data.url.href;
|
|
165
|
+
let resigned = await this.#headersFor(final);
|
|
166
|
+
if (isFailure(resigned))
|
|
167
|
+
return resigned;
|
|
168
|
+
answered = await request(final, { ...fetchOptions, headers: resigned.data });
|
|
169
|
+
if (isFailure(answered))
|
|
170
|
+
return answered;
|
|
171
|
+
}
|
|
172
|
+
let body = await readSuccess(url, answered.data, maxBytes);
|
|
173
|
+
if (isFailure(body))
|
|
174
|
+
return body;
|
|
175
|
+
let json = decodeObject(url, body.data.text);
|
|
176
|
+
if (isFailure(json))
|
|
177
|
+
return json;
|
|
178
|
+
let id = json.data.id;
|
|
179
|
+
if (typeof id !== "string" || !URL.canParse(id)) {
|
|
180
|
+
return failure(new ActivityPubFetchError("invalid-document", url, `${url} has no id`));
|
|
181
|
+
}
|
|
182
|
+
if (new URL(id).origin !== body.data.url.origin) {
|
|
183
|
+
return failure(new ActivityPubFetchError("id-mismatch", url, `${body.data.url.href} answered a document whose id ${id} is on another origin`));
|
|
184
|
+
}
|
|
185
|
+
return success(json.data);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The document at `iri`, from the cache unless `fresh`, else fetched and stored. A
|
|
189
|
+
* Tombstone, cached or fetched, answers `gone` and evicts the entry.
|
|
190
|
+
*/
|
|
191
|
+
async #load(iri, ttl, resolveOptions) {
|
|
192
|
+
let url = withoutFragment(iri);
|
|
193
|
+
if (!URL.canParse(url)) {
|
|
194
|
+
return failure(new ActivityPubFetchError("refused-url", iri, `${iri} is not a URL`));
|
|
195
|
+
}
|
|
196
|
+
let cache = this.#options.cache;
|
|
197
|
+
let key = cacheKey(url);
|
|
198
|
+
let json = null;
|
|
199
|
+
if (resolveOptions?.fresh !== true) {
|
|
200
|
+
let cached = await cache.read(key).catch(() => null);
|
|
201
|
+
if (cached !== null && isSuccess(cached) && isRecord(cached.data))
|
|
202
|
+
json = cached.data;
|
|
203
|
+
}
|
|
204
|
+
if (json === null) {
|
|
205
|
+
let fetched = await this.#fetchDocument(url);
|
|
206
|
+
if (isFailure(fetched)) {
|
|
207
|
+
if (fetched.error.code === "gone")
|
|
208
|
+
await this.evict(url);
|
|
209
|
+
return fetched;
|
|
210
|
+
}
|
|
211
|
+
json = fetched.data;
|
|
212
|
+
if (json.type !== "Tombstone") {
|
|
213
|
+
await cache.write(key, json, { ttl }).catch(() => undefined);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (json.type === "Tombstone") {
|
|
217
|
+
await this.evict(url);
|
|
218
|
+
return failure(new ActivityPubFetchError("gone", url, `${url} was deleted`));
|
|
219
|
+
}
|
|
220
|
+
return success(json);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Reads `json` as an actor, failing `invalid-document` with the parser's explanation. */
|
|
224
|
+
function readActor(url, json) {
|
|
225
|
+
let actor = parseActor(json);
|
|
226
|
+
if (isFailure(actor))
|
|
227
|
+
return failure(invalidDocument(url, actor.error));
|
|
228
|
+
return actor;
|
|
229
|
+
}
|
|
230
|
+
/** The cache entry of a document. */
|
|
231
|
+
function cacheKey(iri) {
|
|
232
|
+
return `${CACHE_PREFIX}${iri}`;
|
|
233
|
+
}
|
|
234
|
+
/** The URL a request is made to; a fragment names a part of the document and is never sent. */
|
|
235
|
+
function withoutFragment(iri) {
|
|
236
|
+
let hash = iri.indexOf("#");
|
|
237
|
+
return hash === -1 ? iri : iri.slice(0, hash);
|
|
238
|
+
}
|
|
239
|
+
/** Whether a cached value is a document, so an entry some other writer left reads as a miss. */
|
|
240
|
+
function isRecord(value) {
|
|
241
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The actor a key document says owns `keyId`: a standalone key's `owner`, or the `owner`
|
|
245
|
+
* of the `publicKey` entry with that id on a document that is not a full actor.
|
|
246
|
+
*/
|
|
247
|
+
function claimedOwner(json, keyId) {
|
|
248
|
+
if (typeof json.owner === "string")
|
|
249
|
+
return json.owner;
|
|
250
|
+
let entries = Array.isArray(json.publicKey) ? json.publicKey : [json.publicKey];
|
|
251
|
+
for (let entry of entries) {
|
|
252
|
+
if (!isRecord(entry) || entry.id !== keyId)
|
|
253
|
+
continue;
|
|
254
|
+
if (typeof entry.owner === "string")
|
|
255
|
+
return entry.owner;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
/** A document that fetched fine but is not the shape asked for. */
|
|
260
|
+
function invalidDocument(url, cause) {
|
|
261
|
+
return new ActivityPubFetchError("invalid-document", url, cause.message, { cause });
|
|
262
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage the package calls and the app implements: followers, processed activity ids,
|
|
3
|
+
* the app's own objects and its actors' keys. Federation state lives in the app's database,
|
|
4
|
+
* in its own shape and migrations, next to the content it federates.
|
|
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 { ActorKeys } from "./keys.js";
|
|
12
|
+
import type { ActivityPub } from "./lib/types.js";
|
|
13
|
+
/** Someone who follows a local actor, as the package needs to deliver to them and answer Undo. */
|
|
14
|
+
export interface Follower {
|
|
15
|
+
/** The local actor being followed. */
|
|
16
|
+
actor: string;
|
|
17
|
+
/** The remote actor's id. */
|
|
18
|
+
id: string;
|
|
19
|
+
inbox: string;
|
|
20
|
+
/** Preferred for delivery, so one POST reaches every follower on that server. */
|
|
21
|
+
sharedInbox: string | null;
|
|
22
|
+
/** The Follow's id: an Accept echoes it and an Undo must name it. */
|
|
23
|
+
followId: string;
|
|
24
|
+
/** `pending` while a local actor that approves followers by hand has not decided. */
|
|
25
|
+
state: "accepted" | "pending";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A page of a store listing. `next` is the store's own opaque cursor, handed back unchanged to
|
|
29
|
+
* read the following page, and `null` on the last one.
|
|
30
|
+
*
|
|
31
|
+
* @template T The listed item.
|
|
32
|
+
*/
|
|
33
|
+
export interface StorePage<T> {
|
|
34
|
+
items: T[];
|
|
35
|
+
next: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** The followers of local actors. Every write is idempotent, so redelivered activities are harmless. */
|
|
38
|
+
export interface FollowerStore {
|
|
39
|
+
/** Inserts or replaces by `(actor, id)`, so a repeated Follow refreshes inbox and `followId`. */
|
|
40
|
+
put(follower: Follower): Promise<Result<void, Error>>;
|
|
41
|
+
/** The follower in any state, so an Undo can name its Follow; `null` when `id` follows nobody. */
|
|
42
|
+
get(actor: string, id: string): Promise<Result<Follower | null, Error>>;
|
|
43
|
+
/** Succeeds when the follower is already gone, so a repeated Undo is harmless. */
|
|
44
|
+
remove(actor: string, id: string): Promise<Result<void, Error>>;
|
|
45
|
+
/** Drops every follower reached through this inbox or shared inbox, after it answered 410. */
|
|
46
|
+
removeInbox(inbox: string): Promise<Result<void, Error>>;
|
|
47
|
+
/** Accepted followers, for the collection; `origin` narrows to one server (FEP-8fcf). */
|
|
48
|
+
list(actor: string, options: {
|
|
49
|
+
cursor: string | null;
|
|
50
|
+
limit: number;
|
|
51
|
+
origin?: string;
|
|
52
|
+
}): Promise<Result<StorePage<Follower>, Error>>;
|
|
53
|
+
/** Accepted followers, which is the `totalItems` the followers collection publishes. */
|
|
54
|
+
count(actor: string): Promise<Result<number, Error>>;
|
|
55
|
+
/** Distinct delivery targets of accepted followers, `sharedInbox ?? inbox`, one per URL. */
|
|
56
|
+
inboxes(actor: string, options: {
|
|
57
|
+
cursor: string | null;
|
|
58
|
+
limit: number;
|
|
59
|
+
}): Promise<Result<StorePage<string>, Error>>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Activity ids already processed. Claiming is best effort, because every handler is
|
|
63
|
+
* idempotent: this saves work on a redelivery and is never what makes processing correct.
|
|
64
|
+
*/
|
|
65
|
+
export interface SeenActivities {
|
|
66
|
+
/** `true` the first time an id is claimed within `ttl`, and again once `ttl` has passed. */
|
|
67
|
+
claim(id: string, ttl: DurationInput): Promise<Result<boolean, Error>>;
|
|
68
|
+
}
|
|
69
|
+
/** The app's own objects, so the inbox can tell a reply, Like or Announce of local content from noise. */
|
|
70
|
+
export interface LocalObjects {
|
|
71
|
+
/** The public object this app serves under `id`, or `null` when it serves none. */
|
|
72
|
+
find(id: string): Promise<Result<ActivityPub.Object | null, Error>>;
|
|
73
|
+
}
|
|
74
|
+
/** The signing keys of local actors. */
|
|
75
|
+
export interface KeyProvider {
|
|
76
|
+
/** `null` for an actor this app does not host. */
|
|
77
|
+
keysOf(actor: string): Promise<Result<ActorKeys | null, Error>>;
|
|
78
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage the package calls and the app implements: followers, processed activity ids,
|
|
3
|
+
* the app's own objects and its actors' keys. Federation state lives in the app's database,
|
|
4
|
+
* in its own shape and migrations, next to the content it federates.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an app tests its federation with: in-memory implementations of every store the
|
|
3
|
+
* package calls, and the conformance suites that hold the app's own stores to the same
|
|
4
|
+
* contracts, so tests and production run against one set of rules.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export type { FollowerStoreConformanceOptions, KeyProviderConformanceOptions, LocalObjectsConformanceOptions, SeenActivitiesConformanceOptions, } from "./conformance.js";
|
|
10
|
+
export type { MemorySeenActivitiesOptions } from "./memory.js";
|
|
11
|
+
export { followerStoreConformance, keyProviderConformance, localObjectsConformance, seenActivitiesConformance, } from "./conformance.js";
|
|
12
|
+
export { MemoryFollowerStore, MemoryKeyProvider, MemoryLocalObjects, MemorySeenActivities, } from "./memory.js";
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an app tests its federation with: in-memory implementations of every store the
|
|
3
|
+
* package calls, and the conformance suites that hold the app's own stores to the same
|
|
4
|
+
* contracts, so tests and production run against one set of rules.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export { followerStoreConformance, keyProviderConformance, localObjectsConformance, seenActivitiesConformance, } from "./conformance.js";
|
|
10
|
+
export { MemoryFollowerStore, MemoryKeyProvider, MemoryLocalObjects, MemorySeenActivities, } from "./memory.js";
|