@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 +31 -0
- package/dist/cjs/node/actorRouter.js +4 -2
- package/dist/cjs/node/delivery.js +28 -6
- package/dist/cjs/node/httpCacheControl.js +11 -0
- package/dist/cjs/node/index.js +2 -1
- package/dist/cjs/node/webfingerRouter.js +6 -4
- package/dist/esm/node/actorRouter.js +4 -2
- package/dist/esm/node/delivery.js +26 -6
- package/dist/esm/node/httpCacheControl.js +8 -0
- package/dist/esm/node/index.js +1 -1
- package/dist/esm/node/webfingerRouter.js +6 -4
- package/dist/types/node/actorRouter.d.ts +2 -0
- package/dist/types/node/delivery.d.ts +21 -1
- package/dist/types/node/httpCacheControl.d.ts +2 -0
- package/dist/types/node/index.d.ts +1 -1
- package/dist/types/node/webfingerRouter.d.ts +2 -0
- package/package.json +5 -20
- package/src/__tests__/delivery.test.ts +109 -1
- package/src/__tests__/routers.test.ts +70 -0
- package/src/node/actorRouter.ts +6 -2
- package/src/node/delivery.ts +57 -9
- package/src/node/httpCacheControl.ts +8 -0
- package/src/node/index.ts +2 -0
- package/src/node/webfingerRouter.ts +8 -4
- package/dist/cjs/.tsbuildinfo +0 -1
- package/dist/esm/.tsbuildinfo +0 -1
- package/dist/types/.tsbuildinfo +0 -1
|
@@ -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
|
|
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')
|
package/src/node/actorRouter.ts
CHANGED
|
@@ -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',
|
|
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',
|
|
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);
|
package/src/node/delivery.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
@@ -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
|
-
/**
|
|
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',
|
|
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',
|
|
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',
|
|
190
|
+
res.set('Cache-Control', httpCacheControl);
|
|
187
191
|
return res.json(response);
|
|
188
192
|
} catch (err) {
|
|
189
193
|
config.logger.error('WebFinger error:', err);
|