@oxy.so/federation 2.3.0 → 2.4.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.
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { Router } from 'express';
22
22
  import { AP_CONTEXT } from '../apContext.js';
23
+ import { discoveryCacheControl } from './httpCacheControl.js';
23
24
  import { verifyHttpSignature } from '../httpSignature.js';
24
25
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
25
26
  /** Page size for the paginated followers/following collections (mirrors the outbox). */
@@ -54,6 +55,7 @@ function parseFollowOffset(raw) {
54
55
  export function createActorRouter(config) {
55
56
  const router = Router();
56
57
  const { urls, domain, apContentType, logger } = config;
58
+ const httpCacheControl = discoveryCacheControl(config.httpCacheTtlSeconds, 1800);
57
59
  function wantsActivityPub(req) {
58
60
  return config.wantsActivityPub(req.headers.accept);
59
61
  }
@@ -239,7 +241,7 @@ export function createActorRouter(config) {
239
241
  },
240
242
  };
241
243
  res.set('Content-Type', apContentType);
242
- res.set('Cache-Control', 'max-age=1800');
244
+ res.set('Cache-Control', httpCacheControl);
243
245
  return res.json(actorObject);
244
246
  }
245
247
  const user = await config.resolveUser(username);
@@ -274,7 +276,7 @@ export function createActorRouter(config) {
274
276
  alsoKnownAs: user.alsoKnownAs,
275
277
  });
276
278
  res.set('Content-Type', apContentType);
277
- res.set('Cache-Control', 'max-age=1800');
279
+ res.set('Cache-Control', httpCacheControl);
278
280
  return res.json({ '@context': AP_CONTEXT, ...actorObject });
279
281
  }
280
282
  catch (err) {
@@ -31,6 +31,14 @@ const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
31
31
  const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
32
32
  /** The ActivityStreams public collection — the `to` addressee of a public activity. */
33
33
  const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
34
+ /** A definitive refusal: workers must discard, not retry or count a delivery. */
35
+ export class DeliveryAuthorizationDeniedError extends Error {
36
+ constructor() {
37
+ super('Outbound federation delivery is not authorized');
38
+ this.code = 'DELIVERY_AUTHORIZATION_DENIED';
39
+ this.name = 'DeliveryAuthorizationDeniedError';
40
+ }
41
+ }
34
42
  /** Read a bounded prefix of a failed-delivery response body for the debug log. */
35
43
  async function readResponsePreview(response) {
36
44
  const chunks = [];
@@ -69,7 +77,17 @@ export function createDeliveryService(config) {
69
77
  const host = urlHost(url);
70
78
  return host === null || config.isBlockedDomain(host);
71
79
  }
80
+ async function authorizeDelivery(activity, senderAccountId) {
81
+ if (config.authorizeDelivery &&
82
+ !(await config.authorizeDelivery({ activity, senderAccountId }))) {
83
+ throw new DeliveryAuthorizationDeniedError();
84
+ }
85
+ }
72
86
  async function deliverActivity(activity, targetInbox, senderOxyUserId, senderUsername) {
87
+ await authorizeDelivery(activity, senderOxyUserId);
88
+ return deliverAuthorizedActivity(activity, targetInbox, senderUsername);
89
+ }
90
+ async function deliverAuthorizedActivity(activity, targetInbox, senderUsername) {
73
91
  if (isBlockedUrl(targetInbox)) {
74
92
  logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
75
93
  return false;
@@ -107,6 +125,7 @@ export function createDeliveryService(config) {
107
125
  }
108
126
  }
109
127
  async function queueDelivery(activity, targetInbox, senderOxyUserId) {
128
+ await authorizeDelivery(activity, senderOxyUserId);
110
129
  if (isBlockedUrl(targetInbox)) {
111
130
  logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
112
131
  return;
@@ -144,6 +163,7 @@ export function createDeliveryService(config) {
144
163
  return actor.sharedInboxUrl ?? actor.inboxUrl ?? undefined;
145
164
  }
146
165
  async function deliverToFollowers(activity, senderOxyUserId, senderUsername, options = {}) {
166
+ await authorizeDelivery(activity, senderOxyUserId);
147
167
  const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
148
168
  const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
149
169
  // Group by shared inbox to avoid duplicate deliveries. Follower inboxes
@@ -238,17 +258,12 @@ export function createDeliveryService(config) {
238
258
  // cached; if the actor is unknown locally we still record the follow and
239
259
  // queue the Follow activity, then refresh the actor in the background.
240
260
  const cached = await config.store.findActorByUri(remoteActorUri);
241
- // Always refresh the actor in the background so its inbox/profile stay
242
- // current (and so a missing actor gets resolved for delivery shortly).
243
- config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
244
261
  const canonicalUri = cached?.uri ?? remoteActorUri;
245
262
  const localActorUri = urls.actor(localUsername);
246
263
  // Use the actor _id when known, otherwise a stable hash of the URI so the
247
264
  // activity ID is deterministic across retries before the actor is cached.
248
265
  const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
249
266
  const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
250
- // Create or update the follow record
251
- await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
252
267
  const activity = {
253
268
  '@context': 'https://www.w3.org/ns/activitystreams',
254
269
  id: activityId,
@@ -256,11 +271,16 @@ export function createDeliveryService(config) {
256
271
  actor: localActorUri,
257
272
  object: canonicalUri,
258
273
  };
274
+ // Refuse before committing an outbound follow or reporting success.
275
+ await authorizeDelivery(activity, localOxyUserId);
276
+ // A refused follow must not start remote actor discovery either.
277
+ config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
278
+ await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
259
279
  // If we know the inbox, attempt delivery in the background; otherwise queue
260
280
  // for the delivery worker, which resolves the inbox once the actor lands.
261
281
  const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
262
282
  if (targetInbox && !isBlockedUrl(targetInbox)) {
263
- void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
283
+ void deliverAuthorizedActivity(activity, targetInbox, localUsername)
264
284
  .then((delivered) => {
265
285
  if (!delivered)
266
286
  return queueDelivery(activity, targetInbox, localOxyUserId);
@@ -0,0 +1,8 @@
1
+ /** Shared discovery config validation; zero forbids downstream response retention. */
2
+ export function discoveryCacheControl(value, defaultSeconds) {
3
+ const seconds = value ?? defaultSeconds;
4
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > 86400) {
5
+ throw new RangeError('httpCacheTtlSeconds must be an integer from 0 to 86400');
6
+ }
7
+ return seconds === 0 ? 'no-store' : `max-age=${seconds}`;
8
+ }
@@ -39,7 +39,7 @@ export { createActorResolver, ActorResolver, } from './actorResolver.js';
39
39
  * Accept(Follow)) + the `Update(Person)` actor rebroadcast, over injected key
40
40
  * custody, an SSRF-safe delivery transport, and bring-your-own-store adapters.
41
41
  */
42
- export { createDeliveryService, } from './delivery.js';
42
+ export { createDeliveryService, DeliveryAuthorizationDeniedError, } from './delivery.js';
43
43
  /**
44
44
  * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
45
45
  * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
@@ -12,9 +12,10 @@
12
12
  * Extracted behaviour-identically from Mention's `wellKnown.routes.ts`.
13
13
  */
14
14
  import { Router } from 'express';
15
+ import { discoveryCacheControl } from './httpCacheControl.js';
15
16
  import { isSameFederationHost } from '../apUri.js';
16
17
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls.js';
17
- /** 1 hour, in seconds — the WebFinger JRD cache TTL + response `max-age`. */
18
+ /** Default HTTP response max-age. The injected JRD cache has its own policy. */
18
19
  const WEBFINGER_CACHE_TTL = 3600;
19
20
  /** 24h — host-meta is effectively static. */
20
21
  const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
@@ -22,6 +23,7 @@ const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
22
23
  export function createWebfingerRouter(config) {
23
24
  const router = Router();
24
25
  const { domain } = config;
26
+ const httpCacheControl = discoveryCacheControl(config.httpCacheTtlSeconds, WEBFINGER_CACHE_TTL);
25
27
  const webfingerTemplate = `https://${domain}/.well-known/webfinger?resource={uri}`;
26
28
  router.get('/webfinger', async (req, res) => {
27
29
  if (!config.federationEnabled) {
@@ -74,14 +76,14 @@ export function createWebfingerRouter(config) {
74
76
  ],
75
77
  };
76
78
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
77
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
79
+ res.set('Cache-Control', httpCacheControl);
78
80
  return res.json(instanceJrd);
79
81
  }
80
82
  // Check the JRD cache first.
81
83
  const cached = await config.cache.get(username);
82
84
  if (cached) {
83
85
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
84
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
86
+ res.set('Cache-Control', httpCacheControl);
85
87
  return res.json(cached);
86
88
  }
87
89
  const user = await config.resolveUser(username);
@@ -122,7 +124,7 @@ export function createWebfingerRouter(config) {
122
124
  };
123
125
  config.cache.set(username, response);
124
126
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
125
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
127
+ res.set('Cache-Control', httpCacheControl);
126
128
  return res.json(response);
127
129
  }
128
130
  catch (err) {