@oxy.so/federation 2.3.0 → 2.4.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/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # Oxy federation
2
+
3
+ ## Authorizing outbound delivery
4
+
5
+ `createDeliveryService({ authorizeDelivery })` optionally checks live application
6
+ policy before signing or queueing an activity. It receives `activity` and the
7
+ trusted `senderAccountId` supplied by the app or persisted delivery job. Never
8
+ infer that identity from an activity URL or a key ID. Follower fanout checks once
9
+ for the batch; each later worker delivery checks again.
10
+
11
+ Return `true` to allow or `false` for a definitive refusal. A refusal throws
12
+ `DeliveryAuthorizationDeniedError`: a durable worker should mark the job
13
+ suppressed (or permanently failed), without marking it delivered or retrying it.
14
+ Other thrown errors are transient authority failures and should follow the
15
+ worker's normal bounded retry policy. Neither path signs or sends the activity.
16
+ Do not catch an authority failure and retry through an unguarded transport.
17
+
18
+ Without the optional hook, delivery behavior is unchanged. `sendUndoFollow`'s
19
+ boolean reports local relationship removal; it does not confirm remote delivery.
20
+
21
+ ## Discovery response caching
22
+
23
+ `createWebfingerRouter({ httpCacheTtlSeconds })` controls HTTP caching of fresh,
24
+ cached and instance WebFinger responses. The default remains 3600 seconds.
25
+ `createActorRouter` accepts the same option for human and instance actor
26
+ responses, retaining its existing 1800-second default.
27
+ Use `0` for `Cache-Control: no-store` when live privacy decisions must govern
28
+ each request. Only whole seconds from 0 through 86400 are accepted. This does
29
+ not change the injected JRD cache: applications must also authorize its reads
30
+ and their user resolver against current policy. It cannot retract documents
31
+ already cached by remote consumers before this setting changes.
@@ -23,6 +23,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.createActorRouter = createActorRouter;
24
24
  const express_1 = require("express");
25
25
  const apContext_1 = require("../apContext");
26
+ const httpCacheControl_1 = require("./httpCacheControl");
26
27
  const httpSignature_1 = require("../httpSignature");
27
28
  const urls_1 = require("../urls");
28
29
  /** Page size for the paginated followers/following collections (mirrors the outbox). */
@@ -57,6 +58,7 @@ function parseFollowOffset(raw) {
57
58
  function createActorRouter(config) {
58
59
  const router = (0, express_1.Router)();
59
60
  const { urls, domain, apContentType, logger } = config;
61
+ const httpCacheControl = (0, httpCacheControl_1.discoveryCacheControl)(config.httpCacheTtlSeconds, 1800);
60
62
  function wantsActivityPub(req) {
61
63
  return config.wantsActivityPub(req.headers.accept);
62
64
  }
@@ -242,7 +244,7 @@ function createActorRouter(config) {
242
244
  },
243
245
  };
244
246
  res.set('Content-Type', apContentType);
245
- res.set('Cache-Control', 'max-age=1800');
247
+ res.set('Cache-Control', httpCacheControl);
246
248
  return res.json(actorObject);
247
249
  }
248
250
  const user = await config.resolveUser(username);
@@ -277,7 +279,7 @@ function createActorRouter(config) {
277
279
  alsoKnownAs: user.alsoKnownAs,
278
280
  });
279
281
  res.set('Content-Type', apContentType);
280
- res.set('Cache-Control', 'max-age=1800');
282
+ res.set('Cache-Control', httpCacheControl);
281
283
  return res.json({ '@context': apContext_1.AP_CONTEXT, ...actorObject });
282
284
  }
283
285
  catch (err) {
@@ -25,6 +25,7 @@
25
25
  * call `deliverToFollowers` / `deliverActivity` / `queueDelivery` here.
26
26
  */
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.DeliveryAuthorizationDeniedError = void 0;
28
29
  exports.createDeliveryService = createDeliveryService;
29
30
  const apContext_1 = require("../apContext");
30
31
  const httpSignature_1 = require("../httpSignature");
@@ -34,6 +35,15 @@ const DELIVER_ACTIVITY_TIMEOUT_MS = 15000;
34
35
  const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
35
36
  /** The ActivityStreams public collection — the `to` addressee of a public activity. */
36
37
  const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
38
+ /** A definitive refusal: workers must discard, not retry or count a delivery. */
39
+ class DeliveryAuthorizationDeniedError extends Error {
40
+ constructor() {
41
+ super('Outbound federation delivery is not authorized');
42
+ this.code = 'DELIVERY_AUTHORIZATION_DENIED';
43
+ this.name = 'DeliveryAuthorizationDeniedError';
44
+ }
45
+ }
46
+ exports.DeliveryAuthorizationDeniedError = DeliveryAuthorizationDeniedError;
37
47
  /** Read a bounded prefix of a failed-delivery response body for the debug log. */
38
48
  async function readResponsePreview(response) {
39
49
  const chunks = [];
@@ -72,7 +82,17 @@ function createDeliveryService(config) {
72
82
  const host = urlHost(url);
73
83
  return host === null || config.isBlockedDomain(host);
74
84
  }
85
+ async function authorizeDelivery(activity, senderAccountId) {
86
+ if (config.authorizeDelivery &&
87
+ !(await config.authorizeDelivery({ activity, senderAccountId }))) {
88
+ throw new DeliveryAuthorizationDeniedError();
89
+ }
90
+ }
75
91
  async function deliverActivity(activity, targetInbox, senderOxyUserId, senderUsername) {
92
+ await authorizeDelivery(activity, senderOxyUserId);
93
+ return deliverAuthorizedActivity(activity, targetInbox, senderUsername);
94
+ }
95
+ async function deliverAuthorizedActivity(activity, targetInbox, senderUsername) {
76
96
  if (isBlockedUrl(targetInbox)) {
77
97
  logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
78
98
  return false;
@@ -110,6 +130,7 @@ function createDeliveryService(config) {
110
130
  }
111
131
  }
112
132
  async function queueDelivery(activity, targetInbox, senderOxyUserId) {
133
+ await authorizeDelivery(activity, senderOxyUserId);
113
134
  if (isBlockedUrl(targetInbox)) {
114
135
  logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
115
136
  return;
@@ -147,6 +168,7 @@ function createDeliveryService(config) {
147
168
  return actor.sharedInboxUrl ?? actor.inboxUrl ?? undefined;
148
169
  }
149
170
  async function deliverToFollowers(activity, senderOxyUserId, senderUsername, options = {}) {
171
+ await authorizeDelivery(activity, senderOxyUserId);
150
172
  const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
151
173
  const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
152
174
  // Group by shared inbox to avoid duplicate deliveries. Follower inboxes
@@ -241,17 +263,12 @@ function createDeliveryService(config) {
241
263
  // cached; if the actor is unknown locally we still record the follow and
242
264
  // queue the Follow activity, then refresh the actor in the background.
243
265
  const cached = await config.store.findActorByUri(remoteActorUri);
244
- // Always refresh the actor in the background so its inbox/profile stay
245
- // current (and so a missing actor gets resolved for delivery shortly).
246
- config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
247
266
  const canonicalUri = cached?.uri ?? remoteActorUri;
248
267
  const localActorUri = urls.actor(localUsername);
249
268
  // Use the actor _id when known, otherwise a stable hash of the URI so the
250
269
  // activity ID is deterministic across retries before the actor is cached.
251
270
  const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
252
271
  const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
253
- // Create or update the follow record
254
- await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
255
272
  const activity = {
256
273
  '@context': 'https://www.w3.org/ns/activitystreams',
257
274
  id: activityId,
@@ -259,11 +276,16 @@ function createDeliveryService(config) {
259
276
  actor: localActorUri,
260
277
  object: canonicalUri,
261
278
  };
279
+ // Refuse before committing an outbound follow or reporting success.
280
+ await authorizeDelivery(activity, localOxyUserId);
281
+ // A refused follow must not start remote actor discovery either.
282
+ config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
283
+ await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
262
284
  // If we know the inbox, attempt delivery in the background; otherwise queue
263
285
  // for the delivery worker, which resolves the inbox once the actor lands.
264
286
  const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
265
287
  if (targetInbox && !isBlockedUrl(targetInbox)) {
266
- void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
288
+ void deliverAuthorizedActivity(activity, targetInbox, localUsername)
267
289
  .then((delivered) => {
268
290
  if (!delivered)
269
291
  return queueDelivery(activity, targetInbox, localOxyUserId);
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.discoveryCacheControl = discoveryCacheControl;
4
+ /** Shared discovery config validation; zero forbids downstream response retention. */
5
+ function discoveryCacheControl(value, defaultSeconds) {
6
+ const seconds = value ?? defaultSeconds;
7
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > 86400) {
8
+ throw new RangeError('httpCacheTtlSeconds must be an integer from 0 to 86400');
9
+ }
10
+ return seconds === 0 ? 'no-store' : `max-age=${seconds}`;
11
+ }
@@ -18,7 +18,7 @@
18
18
  * actor + inbox + follow-graph Express routers.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.createActorRouter = exports.createWebfingerRouter = exports.ActorResolutionPendingError = exports.createInboundDispatcher = exports.createDeliveryService = exports.ActorResolver = exports.createActorResolver = exports.createIdentityBridge = exports.createTcoLinkExpander = exports.createSignedFetch = void 0;
21
+ exports.createActorRouter = exports.createWebfingerRouter = exports.ActorResolutionPendingError = exports.createInboundDispatcher = exports.DeliveryAuthorizationDeniedError = exports.createDeliveryService = exports.ActorResolver = exports.createActorResolver = exports.createIdentityBridge = exports.createTcoLinkExpander = exports.createSignedFetch = void 0;
22
22
  var signedFetch_1 = require("./signedFetch");
23
23
  Object.defineProperty(exports, "createSignedFetch", { enumerable: true, get: function () { return signedFetch_1.createSignedFetch; } });
24
24
  /**
@@ -49,6 +49,7 @@ Object.defineProperty(exports, "ActorResolver", { enumerable: true, get: functio
49
49
  */
50
50
  var delivery_1 = require("./delivery");
51
51
  Object.defineProperty(exports, "createDeliveryService", { enumerable: true, get: function () { return delivery_1.createDeliveryService; } });
52
+ Object.defineProperty(exports, "DeliveryAuthorizationDeniedError", { enumerable: true, get: function () { return delivery_1.DeliveryAuthorizationDeniedError; } });
52
53
  /**
53
54
  * Inbound ActivityPub dispatch — the untrusted-activity validator + switch, the
54
55
  * follow-protocol handlers (Follow / Accept / Undo(Follow) / Reject) over the
@@ -15,9 +15,10 @@
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.createWebfingerRouter = createWebfingerRouter;
17
17
  const express_1 = require("express");
18
+ const httpCacheControl_1 = require("./httpCacheControl");
18
19
  const apUri_1 = require("../apUri");
19
20
  const urls_1 = require("../urls");
20
- /** 1 hour, in seconds — the WebFinger JRD cache TTL + response `max-age`. */
21
+ /** Default HTTP response max-age. The injected JRD cache has its own policy. */
21
22
  const WEBFINGER_CACHE_TTL = 3600;
22
23
  /** 24h — host-meta is effectively static. */
23
24
  const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
@@ -25,6 +26,7 @@ const HOST_META_CACHE_CONTROL = `max-age=${60 * 60 * 24}`;
25
26
  function createWebfingerRouter(config) {
26
27
  const router = (0, express_1.Router)();
27
28
  const { domain } = config;
29
+ const httpCacheControl = (0, httpCacheControl_1.discoveryCacheControl)(config.httpCacheTtlSeconds, WEBFINGER_CACHE_TTL);
28
30
  const webfingerTemplate = `https://${domain}/.well-known/webfinger?resource={uri}`;
29
31
  router.get('/webfinger', async (req, res) => {
30
32
  if (!config.federationEnabled) {
@@ -77,14 +79,14 @@ function createWebfingerRouter(config) {
77
79
  ],
78
80
  };
79
81
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
80
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
82
+ res.set('Cache-Control', httpCacheControl);
81
83
  return res.json(instanceJrd);
82
84
  }
83
85
  // Check the JRD cache first.
84
86
  const cached = await config.cache.get(username);
85
87
  if (cached) {
86
88
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
87
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
89
+ res.set('Cache-Control', httpCacheControl);
88
90
  return res.json(cached);
89
91
  }
90
92
  const user = await config.resolveUser(username);
@@ -125,7 +127,7 @@ function createWebfingerRouter(config) {
125
127
  };
126
128
  config.cache.set(username, response);
127
129
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
128
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
130
+ res.set('Cache-Control', httpCacheControl);
129
131
  return res.json(response);
130
132
  }
131
133
  catch (err) {
@@ -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) {
@@ -107,6 +107,8 @@ export interface ActorRouterConfig {
107
107
  };
108
108
  /** Fetch one page of a user's Oxy follow graph (followers OR following). */
109
109
  fetchFollowPage(userId: string, direction: 'followers' | 'following', offset: number, limit: number): Promise<FollowPage>;
110
+ /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
111
+ httpCacheTtlSeconds?: number;
110
112
  /** Diagnostics sink. */
111
113
  logger: ActorRouterLogger;
112
114
  }
@@ -27,6 +27,16 @@ import type { AccountKind } from '@oxy.so/contracts';
27
27
  import { type HttpSignatureSigner } from '../httpSignature';
28
28
  import type { UrlBuilders } from '../urls';
29
29
  import type { LocalActorBuilder } from '../actorObject';
30
+ /** Trusted sender metadata supplied by the application or its durable job. */
31
+ export interface DeliveryAuthorizationContext {
32
+ activity: Readonly<Record<string, unknown>>;
33
+ senderAccountId: string;
34
+ }
35
+ /** A definitive refusal: workers must discard, not retry or count a delivery. */
36
+ export declare class DeliveryAuthorizationDeniedError extends Error {
37
+ readonly code = "DELIVERY_AUTHORIZATION_DENIED";
38
+ constructor();
39
+ }
30
40
  /** Minimal logging sink the delivery service writes to. */
31
41
  export interface DeliveryLogger {
32
42
  debug(message: string, detail?: unknown): void;
@@ -199,6 +209,13 @@ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
199
209
  deliverSingleHop: DeliverSingleHop;
200
210
  /** SSRF pre-check for a durable inbox enqueue (never queue a delivery to an unsafe URL). */
201
211
  assertSafeInboxUrl(url: string): Promise<SafeUrlVerdict>;
212
+ /**
213
+ * Optional live policy check. False throws DeliveryAuthorizationDeniedError before
214
+ * signing or enqueueing. A thrown authority error propagates unchanged so a
215
+ * durable worker can retry it. Checked once per fanout, then again at delivery.
216
+ * Never derive the sender from the activity actor URL or its signing key.
217
+ */
218
+ authorizeDelivery?(context: DeliveryAuthorizationContext): Promise<boolean>;
202
219
  /** BullMQ enqueue + the durable fallback queue. */
203
220
  transport: DeliveryTransport;
204
221
  /** The AP actor cache store. */
@@ -242,7 +259,10 @@ export interface DeliveryService {
242
259
  success: boolean;
243
260
  pending: boolean;
244
261
  }>;
245
- /** Send an Undo(Follow) to a remote actor (removes the local follow first). */
262
+ /**
263
+ * Remove the local follow and attempt Undo delivery. True means the local
264
+ * relationship was removed, never that remote delivery succeeded.
265
+ */
246
266
  sendUndoFollow(localOxyUserId: string, localUsername: string, remoteActorUri: string): Promise<boolean>;
247
267
  /** Send an Accept(Follow) back to a remote actor. */
248
268
  sendAccept(localOxyUserId: string, localUsername: string, followActivityId: string, remoteActorUri: string): Promise<void>;
@@ -0,0 +1,2 @@
1
+ /** Shared discovery config validation; zero forbids downstream response retention. */
2
+ export declare function discoveryCacheControl(value: number | undefined, defaultSeconds: number): string;
@@ -39,7 +39,7 @@ export { createActorResolver, ActorResolver, type ActorResolverConfig, type Acto
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, type DeliveryService, type DeliveryServiceConfig, type DeliveryLogger, type DeliveryKeys, type DeliverSingleHop, type DeliverSingleHopInit, type DeliverSingleHopResult, type DeliveryResponseStream, type DeliveryTransport, type DeliveryQueueJob, type DeliveryFallbackQueue, type DeliveryActorFields, type DeliveryActorStore, type DeliveryFollowStore, type DeliveryActorRefresh, type DeliveryConsent, type DeliveryActorProfile, type DeliveryIdentity, type DeliveryProfile, type SafeUrlVerdict, } from './delivery';
42
+ export { createDeliveryService, DeliveryAuthorizationDeniedError, type DeliveryAuthorizationContext, type DeliveryService, type DeliveryServiceConfig, type DeliveryLogger, type DeliveryKeys, type DeliverSingleHop, type DeliverSingleHopInit, type DeliverSingleHopResult, type DeliveryResponseStream, type DeliveryTransport, type DeliveryQueueJob, type DeliveryFallbackQueue, type DeliveryActorFields, type DeliveryActorStore, type DeliveryFollowStore, type DeliveryActorRefresh, type DeliveryConsent, type DeliveryActorProfile, type DeliveryIdentity, type DeliveryProfile, type SafeUrlVerdict, } from './delivery';
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
@@ -55,6 +55,8 @@ export interface WebfingerRouterConfig {
55
55
  get(username: string): Promise<WebfingerJrd | null>;
56
56
  set(username: string, jrd: WebfingerJrd): void;
57
57
  };
58
+ /** HTTP response TTL in whole seconds, 0..86400. Defaults to 3600; 0 sends no-store. */
59
+ httpCacheTtlSeconds?: number;
58
60
  /** Diagnostics sink. */
59
61
  logger: WebfingerLogger;
60
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
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",
@@ -45,6 +45,7 @@
45
45
  "files": [
46
46
  "NOTICE",
47
47
  "dist",
48
+ "!dist/**/*.tsbuildinfo",
48
49
  "src"
49
50
  ],
50
51
  "keywords": [
@@ -57,7 +58,7 @@
57
58
  ],
58
59
  "repository": {
59
60
  "type": "git",
60
- "url": "https://github.com/OxyHQ/OxyHQServices",
61
+ "url": "git+https://github.com/OxyHQ/oxy.git",
61
62
  "directory": "packages/federation"
62
63
  },
63
64
  "author": "OxyHQ",
@@ -75,26 +76,11 @@
75
76
  "typescript": "tsc --noEmit",
76
77
  "test": "jest --passWithNoTests",
77
78
  "lint": "biome lint --error-on-warnings ./src",
78
- "prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run clean && bun run build",
79
- "release": "rm -rf dist && bun run build && release-it"
80
- },
81
- "release-it": {
82
- "git": {
83
- "tagName": "@oxy.so/federation@${version}",
84
- "tagAnnotation": "Release @oxy.so/federation@${version}",
85
- "commitMessage": "chore(federation): release @oxy.so/federation@${version}"
86
- },
87
- "github": {
88
- "release": true,
89
- "releaseName": "@oxy.so/federation@${version}"
90
- },
91
- "npm": {
92
- "publish": true
93
- }
79
+ "prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run clean && bun run build"
94
80
  },
95
81
  "dependencies": {
96
82
  "@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || 4.11.0-oxy-one.0",
97
- "@oxy.so/core": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0"
83
+ "@oxy.so/core": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0"
98
84
  },
99
85
  "peerDependencies": {
100
86
  "express": "^4.18.0 || ^5.0.0"
@@ -110,7 +96,6 @@
110
96
  "@types/node": "^22.20.1",
111
97
  "express": "^4.22.2",
112
98
  "jest": "^30.5.1",
113
- "release-it": "^19.0.6",
114
99
  "supertest": "^7.0.0",
115
100
  "ts-jest": "^29.4.12",
116
101
  "typescript": "^5.9.2"