@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.
@@ -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.0",
4
4
  "description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -2,6 +2,7 @@ import { Readable } from 'node:stream';
2
2
  import { createDomainPolicy } from '../apUri';
3
3
  import {
4
4
  createDeliveryService,
5
+ DeliveryAuthorizationDeniedError,
5
6
  type DeliveryServiceConfig,
6
7
  type DeliveryActorFields,
7
8
  type DeliveryQueueJob,
@@ -39,6 +40,7 @@ function makeRig(
39
40
  sharingEnabled?: boolean;
40
41
  assertSafeInboxUrl?: DeliveryServiceConfig<StoredActor>['assertSafeInboxUrl'];
41
42
  blockedHosts?: string[];
43
+ authorizeDelivery?: DeliveryServiceConfig<StoredActor>['authorizeDelivery'];
42
44
  } = {},
43
45
  ) {
44
46
  const deliveredBodies: Array<{ url: string; body: string }> = [];
@@ -53,7 +55,11 @@ function makeRig(
53
55
  const deletedFollowIds: unknown[] = [];
54
56
  const refreshedInBackground: string[] = [];
55
57
 
56
- const sign: HttpSignatureSigner = async () => 'TESTSIG';
58
+ const signatures: string[] = [];
59
+ const sign: HttpSignatureSigner = async (keyId) => {
60
+ signatures.push(keyId);
61
+ return 'TESTSIG';
62
+ };
57
63
  const domainPolicy =
58
64
  overrides.blockedHosts === undefined
59
65
  ? createDomainPolicy({ domain: DOMAIN })
@@ -61,6 +67,7 @@ function makeRig(
61
67
 
62
68
  const config: DeliveryServiceConfig<StoredActor> = {
63
69
  federationEnabled: overrides.federationEnabled ?? true,
70
+ authorizeDelivery: overrides.authorizeDelivery,
64
71
  userAgent: 'Mention/mention.earth (ActivityPub)',
65
72
  apContentType: 'application/activity+json',
66
73
  keys: {
@@ -145,6 +152,7 @@ function makeRig(
145
152
 
146
153
  return {
147
154
  service: createDeliveryService(config),
155
+ signatures,
148
156
  deliveredBodies,
149
157
  enqueued,
150
158
  fallbackCreates,
@@ -462,3 +470,103 @@ describe('federateActorUpdate', () => {
462
470
  expect(rig.enqueued).toHaveLength(0);
463
471
  });
464
472
  });
473
+
474
+ describe('live delivery authorization', () => {
475
+ const activity = {
476
+ type: 'Create',
477
+ actor: 'https://untrusted.example/not-the-sender',
478
+ object: { type: 'Note', content: 'protected' },
479
+ };
480
+ const remoteActorUri = 'https://remote.example/users/bob';
481
+ const actorsByUri = {
482
+ [remoteActorUri]: { uri: remoteActorUri, inboxUrl: 'https://remote.example/inbox' },
483
+ };
484
+
485
+ it('uses trusted sender metadata and refuses before signing, sending or enqueueing', async () => {
486
+ const authorizeDelivery = jest.fn(async () => false);
487
+ const rig = makeRig({ authorizeDelivery });
488
+ await expect(
489
+ rig.service.deliverActivity(
490
+ activity,
491
+ 'https://remote.example/inbox',
492
+ 'canonical-sender',
493
+ 'alice',
494
+ ),
495
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
496
+ await expect(
497
+ rig.service.queueDelivery(activity, 'https://remote.example/inbox', 'canonical-sender'),
498
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
499
+ expect(authorizeDelivery).toHaveBeenCalledWith({
500
+ activity,
501
+ senderAccountId: 'canonical-sender',
502
+ });
503
+ expect(rig.signatures).toEqual([]);
504
+ expect(rig.deliveredBodies).toEqual([]);
505
+ expect(rig.enqueued).toEqual([]);
506
+ expect(rig.fallbackCreates).toEqual([]);
507
+ });
508
+
509
+ it('propagates transient authority failure without disguising it as a denial or send success', async () => {
510
+ const unavailable = new Error('policy service unavailable');
511
+ const rig = makeRig({
512
+ authorizeDelivery: async () => {
513
+ throw unavailable;
514
+ },
515
+ });
516
+ await expect(
517
+ rig.service.deliverActivity(activity, 'https://remote.example/inbox', 'sender', 'alice'),
518
+ ).rejects.toBe(unavailable);
519
+ expect(rig.signatures).toEqual([]);
520
+ expect(rig.deliveredBodies).toEqual([]);
521
+ });
522
+
523
+ it('checks internal Follow and Accept paths and does not commit a refused follow', async () => {
524
+ const rig = makeRig({ actorsByUri, authorizeDelivery: async () => false });
525
+ await expect(rig.service.sendFollow('sender', 'alice', remoteActorUri)).rejects.toBeInstanceOf(
526
+ DeliveryAuthorizationDeniedError,
527
+ );
528
+ await expect(
529
+ rig.service.sendAccept('sender', 'alice', 'follow-id', remoteActorUri),
530
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
531
+ await flush();
532
+ expect(rig.upsertedFollows).toEqual([]);
533
+ expect(rig.refreshedInBackground).toEqual([]);
534
+ expect(rig.signatures).toEqual([]);
535
+ expect(rig.deliveredBodies).toEqual([]);
536
+ expect(rig.enqueued).toEqual([]);
537
+ });
538
+
539
+ it('authorizes one fanout once and rechecks a durable delivery after revocation', async () => {
540
+ const authorizeDelivery = jest.fn(async () => true);
541
+ const rig = makeRig({
542
+ authorizeDelivery,
543
+ followerActorUris: ['a', 'b'],
544
+ followerInboxes: {
545
+ a: { inboxUrl: 'https://a.example/inbox' },
546
+ b: { inboxUrl: 'https://b.example/inbox' },
547
+ },
548
+ });
549
+ await rig.service.deliverToFollowers(activity, 'sender', 'alice');
550
+ expect(authorizeDelivery).toHaveBeenCalledTimes(1);
551
+ expect(rig.enqueued).toHaveLength(2);
552
+ authorizeDelivery.mockResolvedValue(false);
553
+ const job = rig.enqueued[0];
554
+ await expect(
555
+ rig.service.deliverActivity(job.activityJson, job.targetInbox, job.senderOxyUserId, 'alice'),
556
+ ).rejects.toBeInstanceOf(DeliveryAuthorizationDeniedError);
557
+ expect(rig.signatures).toEqual([]);
558
+ });
559
+
560
+ it('preserves approved adult delivery and checks a Follow once per immediate operation', async () => {
561
+ const authorizeDelivery = jest.fn(async () => true);
562
+ const rig = makeRig({ actorsByUri, authorizeDelivery });
563
+ expect(await rig.service.sendFollow('adult', 'alice', remoteActorUri)).toEqual({
564
+ success: true,
565
+ pending: false,
566
+ });
567
+ await flush();
568
+ expect(authorizeDelivery).toHaveBeenCalledTimes(1);
569
+ expect(rig.deliveredBodies).toHaveLength(1);
570
+ expect(rig.signatures).toHaveLength(1);
571
+ });
572
+ });
@@ -123,6 +123,52 @@ function makeActorApp(
123
123
  }
124
124
 
125
125
  describe('webfinger router', () => {
126
+ it('keeps the existing one-hour HTTP caching default', async () => {
127
+ const response = await request(makeWebfingerApp())
128
+ .get('/.well-known/webfinger')
129
+ .query({ resource: 'acct:alice@mention.earth' });
130
+ expect(response.headers['cache-control']).toBe('max-age=3600');
131
+ });
132
+
133
+ it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 86401])(
134
+ 'rejects invalid HTTP cache TTL %s at construction',
135
+ (httpCacheTtlSeconds) => {
136
+ expect(() => makeWebfingerApp({ httpCacheTtlSeconds })).toThrow(RangeError);
137
+ },
138
+ );
139
+
140
+ it('makes fresh, cached and instance discovery noncacheable when TTL is zero', async () => {
141
+ const cacheGet = jest.fn(async () => null as { subject: string; links: [] } | null);
142
+ const app = makeWebfingerApp({
143
+ httpCacheTtlSeconds: 0,
144
+ cache: { get: cacheGet, set: () => {} },
145
+ });
146
+ const fresh = await request(app)
147
+ .get('/.well-known/webfinger')
148
+ .query({ resource: 'acct:alice@mention.earth' });
149
+ expect(fresh.status).toBe(200);
150
+ expect(fresh.headers['cache-control']).toBe('no-store');
151
+ cacheGet.mockResolvedValue({ subject: 'acct:alice@mention.earth', links: [] });
152
+ const cached = await request(app)
153
+ .get('/.well-known/webfinger')
154
+ .query({ resource: 'acct:alice@mention.earth' });
155
+ expect(cached.status).toBe(200);
156
+ expect(cached.headers['cache-control']).toBe('no-store');
157
+ expect(cached.body.links).toEqual([]);
158
+ const instance = await request(app)
159
+ .get('/.well-known/webfinger')
160
+ .query({ resource: 'acct:instance@mention.earth' });
161
+ expect(instance.status).toBe(200);
162
+ expect(instance.headers['cache-control']).toBe('no-store');
163
+ });
164
+
165
+ it('accepts the documented maximum HTTP cache TTL', async () => {
166
+ const response = await request(makeWebfingerApp({ httpCacheTtlSeconds: 86400 }))
167
+ .get('/.well-known/webfinger')
168
+ .query({ resource: 'acct:alice@mention.earth' });
169
+ expect(response.headers['cache-control']).toBe('max-age=86400');
170
+ });
171
+
126
172
  it('resolves acct:alice@mention.earth to the actor self link (200)', async () => {
127
173
  const res = await request(makeWebfingerApp())
128
174
  .get('/.well-known/webfinger')
@@ -329,6 +375,30 @@ describe('webfinger router — the instance (server) actor', () => {
329
375
  });
330
376
 
331
377
  describe('actor router — GET /ap/users/:username', () => {
378
+ it('keeps the existing half-hour actor caching default', async () => {
379
+ const response = await request(makeActorApp())
380
+ .get('/ap/users/alice')
381
+ .set('Accept', 'application/activity+json');
382
+ expect(response.status).toBe(200);
383
+ expect(response.headers['cache-control']).toBe('max-age=1800');
384
+ });
385
+ it.each(['alice', 'instance'])(
386
+ 'serves %s without HTTP caching when TTL is zero',
387
+ async (username) => {
388
+ const response = await request(makeActorApp({ actorConfig: { httpCacheTtlSeconds: 0 } }))
389
+ .get(`/ap/users/${username}`)
390
+ .set('Accept', 'application/activity+json');
391
+ expect(response.status).toBe(200);
392
+ expect(response.headers['cache-control']).toBe('no-store');
393
+ },
394
+ );
395
+ it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 86401])(
396
+ 'rejects invalid actor cache TTL %s',
397
+ (httpCacheTtlSeconds) => {
398
+ expect(() => makeActorApp({ actorConfig: { httpCacheTtlSeconds } })).toThrow(RangeError);
399
+ },
400
+ );
401
+
332
402
  it('serves the actor as 200 (NOT a redirect) for an AP Accept', async () => {
333
403
  const res = await request(makeActorApp())
334
404
  .get('/ap/users/alice')
@@ -23,6 +23,7 @@ import { Router, type Request, type Response } from 'express';
23
23
  import type { AccountKind } from '@oxy.so/contracts';
24
24
  import type { User } from '@oxy.so/core';
25
25
  import { AP_CONTEXT } from '../apContext';
26
+ import { discoveryCacheControl } from './httpCacheControl';
26
27
  import { verifyHttpSignature } from '../httpSignature';
27
28
  import type { UrlBuilders } from '../urls';
28
29
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
@@ -121,6 +122,8 @@ export interface ActorRouterConfig {
121
122
  offset: number,
122
123
  limit: number,
123
124
  ): Promise<FollowPage>;
125
+ /** Actor HTTP response TTL in whole seconds, 0..86400. Defaults to 1800; 0 sends no-store. */
126
+ httpCacheTtlSeconds?: number;
124
127
  /** Diagnostics sink. */
125
128
  logger: ActorRouterLogger;
126
129
  }
@@ -158,6 +161,7 @@ function parseFollowOffset(raw: unknown): number {
158
161
  export function createActorRouter(config: ActorRouterConfig): Router {
159
162
  const router = Router();
160
163
  const { urls, domain, apContentType, logger } = config;
164
+ const httpCacheControl = discoveryCacheControl(config.httpCacheTtlSeconds, 1800);
161
165
 
162
166
  function wantsActivityPub(req: Request): boolean {
163
167
  return config.wantsActivityPub(req.headers.accept);
@@ -383,7 +387,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
383
387
  },
384
388
  };
385
389
  res.set('Content-Type', apContentType);
386
- res.set('Cache-Control', 'max-age=1800');
390
+ res.set('Cache-Control', httpCacheControl);
387
391
  return res.json(actorObject);
388
392
  }
389
393
 
@@ -424,7 +428,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
424
428
  });
425
429
 
426
430
  res.set('Content-Type', apContentType);
427
- res.set('Cache-Control', 'max-age=1800');
431
+ res.set('Cache-Control', httpCacheControl);
428
432
  return res.json({ '@context': AP_CONTEXT, ...actorObject });
429
433
  } catch (err) {
430
434
  logger.error('Actor endpoint error:', err);
@@ -38,6 +38,22 @@ const DELIVERY_RESPONSE_PREVIEW_MAX_BYTES = 1024;
38
38
  /** The ActivityStreams public collection — the `to` addressee of a public activity. */
39
39
  const AP_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
40
40
 
41
+ /** Trusted sender metadata supplied by the application or its durable job. */
42
+ export interface DeliveryAuthorizationContext {
43
+ activity: Readonly<Record<string, unknown>>;
44
+ senderAccountId: string;
45
+ }
46
+
47
+ /** A definitive refusal: workers must discard, not retry or count a delivery. */
48
+ export class DeliveryAuthorizationDeniedError extends Error {
49
+ readonly code = 'DELIVERY_AUTHORIZATION_DENIED';
50
+
51
+ constructor() {
52
+ super('Outbound federation delivery is not authorized');
53
+ this.name = 'DeliveryAuthorizationDeniedError';
54
+ }
55
+ }
56
+
41
57
  /** Minimal logging sink the delivery service writes to. */
42
58
  export interface DeliveryLogger {
43
59
  debug(message: string, detail?: unknown): void;
@@ -228,6 +244,13 @@ export interface DeliveryServiceConfig<TActor extends DeliveryActorFields> {
228
244
  deliverSingleHop: DeliverSingleHop;
229
245
  /** SSRF pre-check for a durable inbox enqueue (never queue a delivery to an unsafe URL). */
230
246
  assertSafeInboxUrl(url: string): Promise<SafeUrlVerdict>;
247
+ /**
248
+ * Optional live policy check. False throws DeliveryAuthorizationDeniedError before
249
+ * signing or enqueueing. A thrown authority error propagates unchanged so a
250
+ * durable worker can retry it. Checked once per fanout, then again at delivery.
251
+ * Never derive the sender from the activity actor URL or its signing key.
252
+ */
253
+ authorizeDelivery?(context: DeliveryAuthorizationContext): Promise<boolean>;
231
254
  /** BullMQ enqueue + the durable fallback queue. */
232
255
  transport: DeliveryTransport;
233
256
  /** The AP actor cache store. */
@@ -306,7 +329,10 @@ export interface DeliveryService {
306
329
  localUsername: string,
307
330
  remoteActorUri: string,
308
331
  ): Promise<{ success: boolean; pending: boolean }>;
309
- /** Send an Undo(Follow) to a remote actor (removes the local follow first). */
332
+ /**
333
+ * Remove the local follow and attempt Undo delivery. True means the local
334
+ * relationship was removed, never that remote delivery succeeded.
335
+ */
310
336
  sendUndoFollow(
311
337
  localOxyUserId: string,
312
338
  localUsername: string,
@@ -344,11 +370,32 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
344
370
  return host === null || config.isBlockedDomain(host);
345
371
  }
346
372
 
373
+ async function authorizeDelivery(
374
+ activity: Record<string, unknown>,
375
+ senderAccountId: string,
376
+ ): Promise<void> {
377
+ if (
378
+ config.authorizeDelivery &&
379
+ !(await config.authorizeDelivery({ activity, senderAccountId }))
380
+ ) {
381
+ throw new DeliveryAuthorizationDeniedError();
382
+ }
383
+ }
384
+
347
385
  async function deliverActivity(
348
386
  activity: Record<string, unknown>,
349
387
  targetInbox: string,
350
388
  senderOxyUserId: string,
351
389
  senderUsername: string,
390
+ ): Promise<boolean> {
391
+ await authorizeDelivery(activity, senderOxyUserId);
392
+ return deliverAuthorizedActivity(activity, targetInbox, senderUsername);
393
+ }
394
+
395
+ async function deliverAuthorizedActivity(
396
+ activity: Record<string, unknown>,
397
+ targetInbox: string,
398
+ senderUsername: string,
352
399
  ): Promise<boolean> {
353
400
  if (isBlockedUrl(targetInbox)) {
354
401
  logger.warn(`[FedDeliver] refusing outbound delivery to blocked inbox ${targetInbox}`);
@@ -400,6 +447,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
400
447
  targetInbox: string,
401
448
  senderOxyUserId: string,
402
449
  ): Promise<void> {
450
+ await authorizeDelivery(activity, senderOxyUserId);
403
451
  if (isBlockedUrl(targetInbox)) {
404
452
  logger.warn(`[FedDeliver] not queueing delivery to blocked inbox ${targetInbox}`);
405
453
  return;
@@ -446,6 +494,7 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
446
494
  senderUsername: string,
447
495
  options: { extraInboxes?: string[] } = {},
448
496
  ): Promise<void> {
497
+ await authorizeDelivery(activity, senderOxyUserId);
449
498
  const actorUris = await config.follows.listAcceptedInboundFollowerActorUris(senderOxyUserId);
450
499
  const actors = actorUris.length > 0 ? await config.store.findActorInboxesByUris(actorUris) : [];
451
500
 
@@ -559,10 +608,6 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
559
608
  // queue the Follow activity, then refresh the actor in the background.
560
609
  const cached = await config.store.findActorByUri(remoteActorUri);
561
610
 
562
- // Always refresh the actor in the background so its inbox/profile stay
563
- // current (and so a missing actor gets resolved for delivery shortly).
564
- config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
565
-
566
611
  const canonicalUri = cached?.uri ?? remoteActorUri;
567
612
  const localActorUri = urls.actor(localUsername);
568
613
  // Use the actor _id when known, otherwise a stable hash of the URI so the
@@ -570,9 +615,6 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
570
615
  const activityIdSuffix = cached?._id ? String(cached._id) : encodeURIComponent(canonicalUri);
571
616
  const activityId = `${localActorUri}/follows/${activityIdSuffix}`;
572
617
 
573
- // Create or update the follow record
574
- await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
575
-
576
618
  const activity: Record<string, unknown> = {
577
619
  '@context': 'https://www.w3.org/ns/activitystreams',
578
620
  id: activityId,
@@ -581,11 +623,17 @@ export function createDeliveryService<TActor extends DeliveryActorFields>(
581
623
  object: canonicalUri,
582
624
  };
583
625
 
626
+ // Refuse before committing an outbound follow or reporting success.
627
+ await authorizeDelivery(activity, localOxyUserId);
628
+ // A refused follow must not start remote actor discovery either.
629
+ config.actorRefresh.refreshActorInBackground(remoteActorUri, cached ?? undefined);
630
+ await config.follows.upsertOutboundPending(localOxyUserId, canonicalUri, activityId);
631
+
584
632
  // If we know the inbox, attempt delivery in the background; otherwise queue
585
633
  // for the delivery worker, which resolves the inbox once the actor lands.
586
634
  const targetInbox = cached?.sharedInboxUrl ?? cached?.inboxUrl;
587
635
  if (targetInbox && !isBlockedUrl(targetInbox)) {
588
- void deliverActivity(activity, targetInbox, localOxyUserId, localUsername)
636
+ void deliverAuthorizedActivity(activity, targetInbox, localUsername)
589
637
  .then((delivered) => {
590
638
  if (!delivered) return queueDelivery(activity, targetInbox, localOxyUserId);
591
639
  })
@@ -0,0 +1,8 @@
1
+ /** Shared discovery config validation; zero forbids downstream response retention. */
2
+ export function discoveryCacheControl(value: number | undefined, defaultSeconds: number): string {
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
+ }
package/src/node/index.ts CHANGED
@@ -81,6 +81,8 @@ export {
81
81
  */
82
82
  export {
83
83
  createDeliveryService,
84
+ DeliveryAuthorizationDeniedError,
85
+ type DeliveryAuthorizationContext,
84
86
  type DeliveryService,
85
87
  type DeliveryServiceConfig,
86
88
  type DeliveryLogger,
@@ -13,11 +13,12 @@
13
13
  */
14
14
 
15
15
  import { Router, type Request, type Response } from 'express';
16
+ import { discoveryCacheControl } from './httpCacheControl';
16
17
  import { isSameFederationHost } from '../apUri';
17
18
  import type { UrlBuilders } from '../urls';
18
19
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
19
20
 
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}`;
@@ -64,6 +65,8 @@ export interface WebfingerRouterConfig {
64
65
  get(username: string): Promise<WebfingerJrd | null>;
65
66
  set(username: string, jrd: WebfingerJrd): void;
66
67
  };
68
+ /** HTTP response TTL in whole seconds, 0..86400. Defaults to 3600; 0 sends no-store. */
69
+ httpCacheTtlSeconds?: number;
67
70
  /** Diagnostics sink. */
68
71
  logger: WebfingerLogger;
69
72
  }
@@ -72,6 +75,7 @@ export interface WebfingerRouterConfig {
72
75
  export function createWebfingerRouter(config: WebfingerRouterConfig): Router {
73
76
  const router = Router();
74
77
  const { domain } = config;
78
+ const httpCacheControl = discoveryCacheControl(config.httpCacheTtlSeconds, WEBFINGER_CACHE_TTL);
75
79
  const webfingerTemplate = `https://${domain}/.well-known/webfinger?resource={uri}`;
76
80
 
77
81
  router.get('/webfinger', async (req: Request, res: Response) => {
@@ -130,7 +134,7 @@ export function createWebfingerRouter(config: WebfingerRouterConfig): Router {
130
134
  ],
131
135
  };
132
136
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
133
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
137
+ res.set('Cache-Control', httpCacheControl);
134
138
  return res.json(instanceJrd);
135
139
  }
136
140
 
@@ -138,7 +142,7 @@ export function createWebfingerRouter(config: WebfingerRouterConfig): Router {
138
142
  const cached = await config.cache.get(username);
139
143
  if (cached) {
140
144
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
141
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
145
+ res.set('Cache-Control', httpCacheControl);
142
146
  return res.json(cached);
143
147
  }
144
148
 
@@ -183,7 +187,7 @@ export function createWebfingerRouter(config: WebfingerRouterConfig): Router {
183
187
  config.cache.set(username, response);
184
188
 
185
189
  res.set('Content-Type', 'application/jrd+json; charset=utf-8');
186
- res.set('Cache-Control', `max-age=${WEBFINGER_CACHE_TTL}`);
190
+ res.set('Cache-Control', httpCacheControl);
187
191
  return res.json(response);
188
192
  } catch (err) {
189
193
  config.logger.error('WebFinger error:', err);