This release is 0 versions behind 1.3.0 — the latest version of @fedify/fedify. Jump to latest
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
An ActivityPub/fediverse server framework
This package works with Node.js, Deno, Bun
JSR Score
100%
Published
a month ago (1.3.0-dev.477+5c1eaa6b)
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025import { getLogger, withContext } from "jsr:@logtape/logtape@^0.7.1"; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.ts"; import { type AuthenticatedDocumentLoaderFactory, type DocumentLoader, fetchDocumentLoader, getAuthenticatedDocumentLoader, kvCache, } from "../runtime/docloader.ts"; import { verifyRequest } from "../sig/http.ts"; import { exportJwk, importJwk, validateCryptoKey } from "../sig/key.ts"; import { hasSignature, signJsonLd } from "../sig/ld.ts"; import { getKeyOwner } from "../sig/owner.ts"; import { signObject } from "../sig/proof.ts"; import type { Actor, Recipient } from "../vocab/actor.ts"; import { lookupObject, type LookupObjectOptions, traverseCollection, type TraverseCollectionOptions, } from "../vocab/lookup.ts"; import { Activity, type Collection, CryptographicKey, type Hashtag, type Like, type Link, Multikey, type Object, } from "../vocab/vocab.ts"; import { handleWebFinger } from "../webfinger/handler.ts"; import type { ActorDispatcher, ActorHandleMapper, ActorKeyPairsDispatcher, AuthorizePredicate, CollectionCounter, CollectionCursor, CollectionDispatcher, InboxErrorHandler, InboxListener, NodeInfoDispatcher, ObjectAuthorizePredicate, ObjectDispatcher, OutboxErrorHandler, SharedInboxKeyDispatcher, } from "./callback.ts"; import { buildCollectionSynchronizationHeader } from "./collection.ts"; import type { ActorKeyPair, Context, ForwardActivityOptions, InboxContext, ParseUriResult, RequestContext, SendActivityOptions, } from "./context.ts"; import type { ActorCallbackSetters, CollectionCallbackSetters, Federation, FederationFetchOptions, FederationStartQueueOptions, InboxListenerSetters, ObjectCallbackSetters, } from "./federation.ts"; import { type CollectionCallbacks, handleActor, handleCollection, handleInbox, handleObject, } from "./handler.ts"; import { InboxListenerSet } from "./inbox.ts"; import type { KvKey, KvStore } from "./kv.ts"; import type { MessageQueue } from "./mq.ts"; import type { InboxMessage, Message, OutboxMessage, SenderKeyJwkPair, } from "./queue.ts"; import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts"; import { Router, RouterError } from "./router.ts"; import { extractInboxes, sendActivity, type SenderKeyPair } from "./send.ts"; /** * Options for {@link createFederation} function. * @since 0.10.0 */ export interface CreateFederationOptions { /** * The key-value store used for caching, outbox queues, and inbox idempotence. */ kv: KvStore; /** * Prefixes for namespacing keys in the Deno KV store. By default, all keys * are prefixed with `["_fedify"]`. */ kvPrefixes?: Partial<FederationKvPrefixes>; /** * The message queue for sending activities to recipients' inboxes. * If not provided, activities will not be queued and will be sent * immediately. */ queue?: MessageQueue; /** * Whether to start the task queue manually or automatically. * * If `true`, the task queue will not start automatically and you need to * manually start it by calling the {@link Federation.startQueue} method. * * If `false`, the task queue will start automatically as soon as * the first task is enqueued. * * By default, the queue starts automatically. * * @since 0.12.0 */ manuallyStartQueue?: boolean; /** * A custom JSON-LD document loader. By default, this uses the built-in * cache-backed loader that fetches remote documents over HTTP(S). */ documentLoader?: DocumentLoader; /** * A custom JSON-LD context loader. By default, this uses the same loader * as the document loader. */ contextLoader?: DocumentLoader; /** * A factory function that creates an authenticated document loader for a * given identity. This is used for fetching documents that require * authentication. */ authenticatedDocumentLoaderFactory?: AuthenticatedDocumentLoaderFactory; /** * Whether to allow fetching private network addresses in the document loader. * * If turned on, {@link CreateFederationOptions.documentLoader}, * {@link CreateFederationOptions.contextLoader}, and * {@link CreateFederationOptions.authenticatedDocumentLoaderFactory} * cannot be configured. * * Mostly useful for testing purposes. *Do not use in production.* * * Turned off by default. */ allowPrivateAddress?: boolean; /** * A callback that handles errors during outbox processing. Note that this * callback can be called multiple times for the same activity, because * the delivery is retried according to the backoff schedule until it * succeeds or reaches the maximum retry count. * * If any errors are thrown in this callback, they are ignored. */ onOutboxError?: OutboxErrorHandler; /** * The time window for verifying HTTP Signatures of incoming requests. If the * request is older or newer than this window, it is rejected. Or if it is * `false`, the request's timestamp is not checked at all. * * By default, the window is an hour. */ signatureTimeWindow?: Temporal.Duration | Temporal.DurationLike | false; /** * Whether to skip HTTP Signatures verification for incoming activities. * This is useful for testing purposes, but should not be used in production. * * By default, this is `false` (i.e., signatures are verified). * @since 0.13.0 */ skipSignatureVerification?: boolean; /** * The retry policy for sending activities to recipients' inboxes. * By default, this uses an exponential backoff strategy with a maximum of * 10 attempts and a maximum delay of 12 hours. * @since 0.12.0 */ outboxRetryPolicy?: RetryPolicy; /** * The retry policy for processing incoming activities. By default, this * uses an exponential backoff strategy with a maximum of 10 attempts and a * maximum delay of 12 hours. * @since 0.12.0 */ inboxRetryPolicy?: RetryPolicy; /** * Whether the router should be insensitive to trailing slashes in the URL * paths. For example, if this option is `true`, `/foo` and `/foo/` are * treated as the same path. Turned off by default. * @since 0.12.0 */ trailingSlashInsensitive?: boolean; } /** * Prefixes for namespacing keys in the Deno KV store. */ export interface FederationKvPrefixes { /** * The key prefix used for storing whether activities have already been * processed or not. `["_fedify", "activityIdempotence"]` by default. */ activityIdempotence: KvKey; /** * The key prefix used for storing remote JSON-LD documents. * `["_fedify", "remoteDocument"]` by default. */ remoteDocument: KvKey; /** * The key prefix used for caching public keys. * `["_fedify", "publicKey"]` by default. * @since 0.12.0 */ publicKey: KvKey; } /** * Create a new {@link Federation} instance. * @param parameters Parameters for initializing the instance. * @returns A new {@link Federation} instance. * @since 0.10.0 */ export function createFederation<TContextData>( options: CreateFederationOptions, ): Federation<TContextData> { return new FederationImpl<TContextData>(options); } export class FederationImpl<TContextData> implements Federation<TContextData> { kv: KvStore; kvPrefixes: FederationKvPrefixes; queue?: MessageQueue; queueStarted: boolean; manuallyStartQueue: boolean; router: Router; nodeInfoDispatcher?: NodeInfoDispatcher<TContextData>; actorCallbacks?: ActorCallbacks<TContextData>; objectCallbacks: Record<string, ObjectCallbacks<TContextData, string>>; objectTypeIds: Record< string, // deno-lint-ignore no-explicit-any (new (...args: any[]) => Object) & { typeId: URL } >; inboxPath?: string; inboxCallbacks?: CollectionCallbacks< Activity, RequestContext<TContextData>, TContextData, void >; outboxCallbacks?: CollectionCallbacks< Activity, RequestContext<TContextData>, TContextData, void >; followingCallbacks?: CollectionCallbacks< Actor | URL, RequestContext<TContextData>, TContextData, void >; followersCallbacks?: CollectionCallbacks< Recipient, Context<TContextData>, TContextData, URL >; likedCallbacks?: CollectionCallbacks< Like, RequestContext<TContextData>, TContextData, void >; featuredCallbacks?: CollectionCallbacks< Object, RequestContext<TContextData>, TContextData, void >; featuredTagsCallbacks?: CollectionCallbacks< Hashtag, RequestContext<TContextData>, TContextData, void >; inboxListeners?: InboxListenerSet<TContextData>; inboxErrorHandler?: InboxErrorHandler<TContextData>; sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>; documentLoader: DocumentLoader; contextLoader: DocumentLoader; authenticatedDocumentLoaderFactory: AuthenticatedDocumentLoaderFactory; onOutboxError?: OutboxErrorHandler; signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false; skipSignatureVerification: boolean; outboxRetryPolicy: RetryPolicy; inboxRetryPolicy: RetryPolicy; constructor(options: CreateFederationOptions) { this.kv = options.kv; this.kvPrefixes = { ...({ activityIdempotence: ["_fedify", "activityIdempotence"], remoteDocument: ["_fedify", "remoteDocument"], publicKey: ["_fedify", "publicKey"], } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; this.queue = options.queue; this.queueStarted = false; this.manuallyStartQueue = options.manuallyStartQueue ?? false; this.router = new Router({ trailingSlashInsensitive: options.trailingSlashInsensitive, }); this.router.add("/.well-known/webfinger", "webfinger"); this.router.add("/.well-known/nodeinfo", "nodeInfoJrd"); this.objectCallbacks = {}; this.objectTypeIds = {}; if (options.allowPrivateAddress) { if (options.documentLoader != null) { throw new TypeError( "Cannot set documentLoader with allowPrivateAddress turned on.", ); } else if (options.contextLoader != null) { throw new TypeError( "Cannot set contextLoader with allowPrivateAddress turned on.", ); } else if (options.authenticatedDocumentLoaderFactory != null) { throw new TypeError( "Cannot set authenticatedDocumentLoaderFactory with " + "allowPrivateAddress turned on.", ); } } this.documentLoader = options.documentLoader ?? kvCache({ loader: options.allowPrivateAddress ? (url) => fetchDocumentLoader(url, true) : fetchDocumentLoader, kv: options.kv, prefix: this.kvPrefixes.remoteDocument, }); this.contextLoader = options.contextLoader ?? this.documentLoader; this.authenticatedDocumentLoaderFactory = options.authenticatedDocumentLoaderFactory ?? (options.allowPrivateAddress ? (identity) => getAuthenticatedDocumentLoader(identity, true) : getAuthenticatedDocumentLoader); this.onOutboxError = options.onOutboxError; this.signatureTimeWindow = options.signatureTimeWindow ?? { hours: 1 }; this.skipSignatureVerification = options.skipSignatureVerification ?? false; this.outboxRetryPolicy = options.outboxRetryPolicy ?? createExponentialBackoffPolicy(); this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy(); } async #startQueue( ctxData: TContextData, signal?: AbortSignal, ): Promise<void> { if (this.queue != null && !this.queueStarted) { const logger = getLogger(["fedify", "federation", "queue"]); logger.debug("Starting a task queue."); this.queueStarted = true; await this.queue?.listen( (msg) => this.#listenQueue(ctxData, msg), { signal }, ); } } #listenQueue(ctxData: TContextData, message: Message): Promise<void> { return withContext({ messageId: message.id }, async () => { if (message.type === "outbox") { await this.#listenOutboxMessage(ctxData, message); } else if (message.type === "inbox") { await this.#listenInboxMessage(ctxData, message); } }); } async #listenOutboxMessage( _: TContextData, message: OutboxMessage, ): Promise<void> { const logger = getLogger(["fedify", "federation", "outbox"]); const logData = { keyIds: message.keys.map((pair) => pair.keyId), inbox: message.inbox, activity: message.activity, activityId: message.activityId, attempt: message.attempt, headers: message.headers, }; const keys: SenderKeyPair[] = []; let rsaKeyPair: SenderKeyPair | null = null; for (const { keyId, privateKey } of message.keys) { const pair: SenderKeyPair = { keyId: new URL(keyId), privateKey: await importJwk(privateKey, "private"), }; if ( rsaKeyPair == null && pair.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5" ) { rsaKeyPair = pair; } keys.push(pair); } try { await sendActivity({ keys, activity: message.activity, activityId: message.activityId, inbox: new URL(message.inbox), headers: new Headers(message.headers), }); } catch (error) { const activity = await Activity.fromJsonLd(message.activity, { contextLoader: this.contextLoader, documentLoader: rsaKeyPair == null ? this.documentLoader : this.authenticatedDocumentLoaderFactory(rsaKeyPair), }); try { this.onOutboxError?.(error as Error, activity); } catch (error) { logger.error( "An unexpected error occurred in onError handler:\n{error}", { ...logData, error }, ); } const delay = this.outboxRetryPolicy({ elapsedTime: Temporal.Instant.from(message.started).until( Temporal.Now.instant(), ), attempts: message.attempt, }); if (delay != null) { logger.error( "Failed to send activity {activityId} to {inbox} (attempt " + "#{attempt}); retry...:\n{error}", { ...logData, error }, ); this.queue?.enqueue( { ...message, attempt: message.attempt + 1, } satisfies OutboxMessage, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay, }, ); } else { logger.error( "Failed to send activity {activityId} to {inbox} after {attempt} " + "attempts; giving up:\n{error}", { ...logData, error }, ); } return; } logger.info( "Successfully sent activity {activityId} to {inbox}.", { ...logData }, ); } async #listenInboxMessage( ctxData: TContextData, message: InboxMessage, ): Promise<void> { const logger = getLogger(["fedify", "federation", "inbox"]); const baseUrl = new URL(message.baseUrl); let context = this.#createContext(baseUrl, ctxData); if (message.identifier != null) { context = this.#createContext(baseUrl, ctxData, { documentLoader: await context.getDocumentLoader({ identifier: message.identifier, }), }); } else if (this.sharedInboxKeyDispatcher != null) { const identity = await this.sharedInboxKeyDispatcher(context); if (identity != null) { context = this.#createContext(baseUrl, ctxData, { documentLoader: "identifier" in identity || "username" in identity || "handle" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity), }); } } const activity = await Activity.fromJsonLd(message.activity, context); const cacheKey = activity.id == null ? null : [ ...this.kvPrefixes.activityIdempotence, activity.id.href, ] satisfies KvKey; if (cacheKey != null) { const cached = await this.kv.get(cacheKey); if (cached === true) { logger.debug("Activity {activityId} has already been processed.", { activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, }); return; } } const listener = this.inboxListeners?.dispatch(activity); if (listener == null) { logger.error( "Unsupported activity type:\n{activity}", { activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, trial: message.attempt, }, ); return; } try { await listener( context.toInboxContext(message.identifier, message.activity), activity, ); } catch (error) { try { await this.inboxErrorHandler?.(context, error as Error); } catch (error) { logger.error( "An unexpected error occurred in inbox error handler:\n{error}", { error, trial: message.attempt, activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, }, ); } const delay = this.inboxRetryPolicy({ elapsedTime: Temporal.Instant.from(message.started).until( Temporal.Now.instant(), ), attempts: message.attempt, }); if (delay != null) { logger.error( "Failed to process the incoming activity {activityId} (attempt " + "#{attempt}); retry...:\n{error}", { error, attempt: message.attempt, activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, }, ); this.queue?.enqueue( { ...message, attempt: message.attempt + 1, } satisfies InboxMessage, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay, }, ); } else { logger.error( "Failed to process the incoming activity {activityId} after " + "{trial} attempts; giving up:\n{error}", { error, activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, }, ); } return; } if (cacheKey != null) { await this.kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }), }); } logger.info( "Activity {activityId} has been processed.", { activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, }, ); } startQueue( contextData: TContextData, options: FederationStartQueueOptions = {}, ): Promise<void> { return this.#startQueue(contextData, options.signal); } createContext(baseUrl: URL, contextData: TContextData): Context<TContextData>; createContext( request: Request, contextData: TContextData, ): RequestContext<TContextData>; createContext( urlOrRequest: Request | URL, contextData: TContextData, ): Context<TContextData> { return urlOrRequest instanceof Request ? this.#createContext(urlOrRequest, contextData) : this.#createContext(urlOrRequest, contextData); } #createContext( baseUrl: URL, contextData: TContextData, opts?: { documentLoader?: DocumentLoader }, ): ContextImpl<TContextData>; #createContext( request: Request, contextData: TContextData, opts?: { documentLoader?: DocumentLoader; invokedFromActorDispatcher?: { identifier: string }; invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; values: Record<string, string>; }; }, ): RequestContextImpl<TContextData>; #createContext( urlOrRequest: Request | URL, contextData: TContextData, opts: { documentLoader?: DocumentLoader; invokedFromActorDispatcher?: { identifier: string }; invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; values: Record<string, string>; }; } = {}, ): ContextImpl<TContextData> | RequestContextImpl<TContextData> { const request = urlOrRequest instanceof Request ? urlOrRequest : null; const url = urlOrRequest instanceof URL ? new URL(urlOrRequest) : new URL(urlOrRequest.url); if (request == null) { url.pathname = "/"; url.hash = ""; url.search = ""; } const ctxOptions: ContextOptions<TContextData> = { url, federation: this, data: contextData, documentLoader: opts.documentLoader ?? this.documentLoader, }; if (request == null) return new ContextImpl(ctxOptions); return new RequestContextImpl({ ...ctxOptions, request, invokedFromActorDispatcher: opts.invokedFromActorDispatcher, invokedFromObjectDispatcher: opts.invokedFromObjectDispatcher, }); } setNodeInfoDispatcher( path: string, dispatcher: NodeInfoDispatcher<TContextData>, ) { if (this.router.has("nodeInfo")) { throw new RouterError("NodeInfo dispatcher already set."); } const variables = this.router.add(path, "nodeInfo"); if (variables.size !== 0) { throw new RouterError( "Path for NodeInfo dispatcher must have no variables.", ); } this.nodeInfoDispatcher = dispatcher; } setActorDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: ActorDispatcher<TContextData>, ): ActorCallbackSetters<TContextData> { if (this.router.has("actor")) { throw new RouterError("Actor dispatcher already set."); } const variables = this.router.add(path, "actor"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for actor dispatcher must have one variable: {identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "actor"]).warn( "The {{handle}} variable in the actor dispatcher path is deprecated. " + "Use {{identifier}} instead.", ); } const callbacks: ActorCallbacks<TContextData> = { dispatcher: async (context, identifier) => { const actor = await dispatcher(context, identifier); if (actor == null) return null; const logger = getLogger(["fedify", "federation", "actor"]); if (actor.id == null) { logger.warn( "Actor dispatcher returned an actor without an id property. " + "Set the property with Context.getActorUri(identifier).", ); } else if (actor.id.href != context.getActorUri(identifier).href) { logger.warn( "Actor dispatcher returned an actor with an id property that " + "does not match the actor URI. Set the property with " + "Context.getActorUri(identifier).", ); } if ( this.followingCallbacks != null && this.followingCallbacks.dispatcher != null ) { if (actor.followingId == null) { logger.warn( "You configured a following collection dispatcher, but the " + "actor does not have a following property. Set the property " + "with Context.getFollowingUri(identifier).", ); } else if ( actor.followingId.href != context.getFollowingUri(identifier).href ) { logger.warn( "You configured a following collection dispatcher, but the " + "actor's following property does not match the following " + "collection URI. Set the property with " + "Context.getFollowingUri(identifier).", ); } } if ( this.followersCallbacks != null && this.followersCallbacks.dispatcher != null ) { if (actor.followersId == null) { logger.warn( "You configured a followers collection dispatcher, but the " + "actor does not have a followers property. Set the property " + "with Context.getFollowersUri(identifier).", ); } else if ( actor.followersId.href != context.getFollowersUri(identifier).href ) { logger.warn( "You configured a followers collection dispatcher, but the " + "actor's followers property does not match the followers " + "collection URI. Set the property with " + "Context.getFollowersUri(identifier).", ); } } if ( this.outboxCallbacks != null && this.outboxCallbacks.dispatcher != null ) { if (actor?.outboxId == null) { logger.warn( "You configured an outbox collection dispatcher, but the " + "actor does not have an outbox property. Set the property " + "with Context.getOutboxUri(identifier).", ); } else if ( actor.outboxId.href != context.getOutboxUri(identifier).href ) { logger.warn( "You configured an outbox collection dispatcher, but the " + "actor's outbox property does not match the outbox collection " + "URI. Set the property with Context.getOutboxUri(identifier).", ); } } if ( this.likedCallbacks != null && this.likedCallbacks.dispatcher != null ) { if (actor?.likedId == null) { logger.warn( "You configured a liked collection dispatcher, but the " + "actor does not have a liked property. Set the property " + "with Context.getLikedUri(identifier).", ); } else if ( actor.likedId.href != context.getLikedUri(identifier).href ) { logger.warn( "You configured a liked collection dispatcher, but the " + "actor's liked property does not match the liked collection " + "URI. Set the property with Context.getLikedUri(identifier).", ); } } if ( this.featuredCallbacks != null && this.featuredCallbacks.dispatcher != null ) { if (actor?.featuredId == null) { logger.warn( "You configured a featured collection dispatcher, but the " + "actor does not have a featured property. Set the property " + "with Context.getFeaturedUri(identifier).", ); } else if ( actor.featuredId.href != context.getFeaturedUri(identifier).href ) { logger.warn( "You configured a featured collection dispatcher, but the " + "actor's featured property does not match the featured collection " + "URI. Set the property with Context.getFeaturedUri(identifier).", ); } } if ( this.featuredTagsCallbacks != null && this.featuredTagsCallbacks.dispatcher != null ) { if (actor?.featuredTagsId == null) { logger.warn( "You configured a featured tags collection dispatcher, but the " + "actor does not have a featuredTags property. Set the property " + "with Context.getFeaturedTagsUri(identifier).", ); } else if ( actor.featuredTagsId.href != context.getFeaturedTagsUri(identifier).href ) { logger.warn( "You configured a featured tags collection dispatcher, but the " + "actor's featuredTags property does not match the featured tags " + "collection URI. Set the property with " + "Context.getFeaturedTagsUri(identifier).", ); } } if (this.router.has("inbox")) { if (actor.inboxId == null) { logger.warn( "You configured inbox listeners, but the actor does not " + "have an inbox property. Set the property with " + "Context.getInboxUri(identifier).", ); } else if ( actor.inboxId.href != context.getInboxUri(identifier).href ) { logger.warn( "You configured inbox listeners, but the actor's inbox " + "property does not match the inbox URI. Set the property " + "with Context.getInboxUri(identifier).", ); } if (actor.endpoints == null || actor.endpoints.sharedInbox == null) { logger.warn( "You configured inbox listeners, but the actor does not have " + "a endpoints.sharedInbox property. Set the property with " + "Context.getInboxUri().", ); } else if ( actor.endpoints.sharedInbox.href != context.getInboxUri().href ) { logger.warn( "You configured inbox listeners, but the actor's " + "endpoints.sharedInbox property does not match the shared inbox " + "URI. Set the property with Context.getInboxUri().", ); } } if (callbacks.keyPairsDispatcher != null) { if (actor.publicKeyId == null) { logger.warn( "You configured a key pairs dispatcher, but the actor does " + "not have a publicKey property. Set the property with " + "Context.getActorKeyPairs(identifier).", ); } if (actor.assertionMethodId == null) { logger.warn( "You configured a key pairs dispatcher, but the actor does " + "not have an assertionMethod property. Set the property " + "with Context.getActorKeyPairs(identifier).", ); } } return actor; }, }; this.actorCallbacks = callbacks; const setters: ActorCallbackSetters<TContextData> = { setKeyPairsDispatcher(dispatcher: ActorKeyPairsDispatcher<TContextData>) { callbacks.keyPairsDispatcher = dispatcher; return setters; }, mapHandle(mapper: ActorHandleMapper<TContextData>) { callbacks.handleMapper = mapper; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setObjectDispatcher<TObject extends Object, TParam extends string>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: `${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}`, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam>; setObjectDispatcher<TObject extends Object, TParam extends string>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: `${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}`, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam>; setObjectDispatcher<TObject extends Object, TParam extends string>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: `${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}`, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam>; setObjectDispatcher<TObject extends Object, TParam extends string>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: `${string}{${TParam}}${string}{${TParam}}${string}{${TParam}}${string}`, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam>; setObjectDispatcher<TObject extends Object, TParam extends string>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: `${string}{${TParam}}${string}{${TParam}}${string}`, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam>; setObjectDispatcher<TObject extends Object, TParam extends string>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: `${string}{${TParam}}${string}`, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam>; setObjectDispatcher<TObject extends Object, TParam extends string>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, path: string, dispatcher: ObjectDispatcher<TContextData, TObject, TParam>, ): ObjectCallbackSetters<TContextData, TObject, TParam> { const routeName = `object:${cls.typeId.href}`; if (this.router.has(routeName)) { throw new RouterError(`Object dispatcher for ${cls.name} already set.`); } const variables = this.router.add(path, routeName); if (variables.size < 1) { throw new RouterError( "Path for object dispatcher must have at least one variable.", ); } const callbacks: ObjectCallbacks<TContextData, TParam> = { dispatcher, parameters: variables as unknown as Set<TParam>, }; this.objectCallbacks[cls.typeId.href] = callbacks; this.objectTypeIds[cls.typeId.href] = cls; const setters: ObjectCallbackSetters<TContextData, TObject, TParam> = { authorize(predicate: ObjectAuthorizePredicate<TContextData, TParam>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setInboxDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { if (this.inboxCallbacks != null) { throw new RouterError("Inbox dispatcher already set."); } if (this.router.has("inbox")) { if (this.inboxPath !== path) { throw new RouterError( "Inbox dispatcher path must match inbox listener path.", ); } } else { const variables = this.router.add(path, "inbox"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for inbox dispatcher must have one variable: {identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "inbox"]).warn( "The {{handle}} variable in the inbox dispatcher path is deprecated. " + "Use {{identifier}} instead.", ); } this.inboxPath = path; } const callbacks: CollectionCallbacks< Activity, RequestContext<TContextData>, TContextData, void > = { dispatcher }; this.inboxCallbacks = callbacks; const setters: CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setOutboxDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Activity, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { if (this.router.has("outbox")) { throw new RouterError("Outbox dispatcher already set."); } const variables = this.router.add(path, "outbox"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for outbox dispatcher must have one variable: {identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "outbox"]).warn( "The {{handle}} variable in the outbox dispatcher path is deprecated. " + "Use {{identifier}} instead.", ); } const callbacks: CollectionCallbacks< Activity, RequestContext<TContextData>, TContextData, void > = { dispatcher }; this.outboxCallbacks = callbacks; const setters: CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setFollowingDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Actor | URL, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { if (this.router.has("following")) { throw new RouterError("Following collection dispatcher already set."); } const variables = this.router.add(path, "following"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for following collection dispatcher must have one variable: " + "{identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn( "The {{handle}} variable in the following collection dispatcher path " + "is deprecated. Use {{identifier}} instead.", ); } const callbacks: CollectionCallbacks< Actor | URL, RequestContext<TContextData>, TContextData, void > = { dispatcher }; this.followingCallbacks = callbacks; const setters: CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setFollowersDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Recipient, Context<TContextData>, TContextData, URL >, ): CollectionCallbackSetters<Context<TContextData>, TContextData, URL> { if (this.router.has("followers")) { throw new RouterError("Followers collection dispatcher already set."); } const variables = this.router.add(path, "followers"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for followers collection dispatcher must have one variable: " + "{identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn( "The {{handle}} variable in the followers collection dispatcher path " + "is deprecated. Use {{identifier}} instead.", ); } const callbacks: CollectionCallbacks< Recipient, Context<TContextData>, TContextData, URL > = { dispatcher }; this.followersCallbacks = callbacks; const setters: CollectionCallbackSetters< Context<TContextData>, TContextData, URL > = { setCounter(counter: CollectionCounter<TContextData, URL>) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CollectionCursor<Context<TContextData>, TContextData, URL>, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CollectionCursor<Context<TContextData>, TContextData, URL>, ) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setLikedDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Like, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { if (this.router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } const variables = this.router.add(path, "liked"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for liked collection dispatcher must have one variable: " + "{identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn( "The {{handle}} variable in the liked collection dispatcher path " + "is deprecated. Use {{identifier}} instead.", ); } const callbacks: CollectionCallbacks< Like, RequestContext<TContextData>, TContextData, void > = { dispatcher }; this.likedCallbacks = callbacks; const setters: CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setFeaturedDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Object, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { if (this.router.has("featured")) { throw new RouterError("Featured collection dispatcher already set."); } const variables = this.router.add(path, "featured"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for featured collection dispatcher must have one variable: " + "{identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn( "The {{handle}} variable in the featured collection dispatcher path " + "is deprecated. Use {{identifier}} instead.", ); } const callbacks: CollectionCallbacks< Object, RequestContext<TContextData>, TContextData, void > = { dispatcher }; this.featuredCallbacks = callbacks; const setters: CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setFeaturedTagsDispatcher( path: `${string}{identifier}${string}` | `${string}{handle}${string}`, dispatcher: CollectionDispatcher< Hashtag, RequestContext<TContextData>, TContextData, void >, ): CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > { if (this.router.has("featuredTags")) { throw new RouterError("Featured tags collection dispatcher already set."); } const variables = this.router.add(path, "featuredTags"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for featured tags collection dispatcher must have one " + "variable: {identifier}", ); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn( "The {{handle}} variable in the featured tags collection dispatcher " + "path is deprecated. Use {{identifier}} instead.", ); } const callbacks: CollectionCallbacks< Hashtag, RequestContext<TContextData>, TContextData, void > = { dispatcher }; this.featuredTagsCallbacks = callbacks; const setters: CollectionCallbackSetters< RequestContext<TContextData>, TContextData, void > = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.firstCursor = cursor; return setters; }, setLastCursor( cursor: CollectionCursor< RequestContext<TContextData>, TContextData, void >, ) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setInboxListeners( inboxPath: `${string}{identifier}${string}` | `${string}{handle}${string}`, sharedInboxPath?: string, ): InboxListenerSetters<TContextData> { if (this.inboxListeners != null) { throw new RouterError("Inbox listeners already set."); } if (this.router.has("inbox")) { if (this.inboxPath !== inboxPath) { throw new RouterError( "Inbox listener path must match inbox dispatcher path.", ); } } else { const variables = this.router.add(inboxPath, "inbox"); if ( variables.size !== 1 || !(variables.has("identifier") || variables.has("handle")) ) { throw new RouterError( "Path for inbox must have one variable: {identifier}", ); } this.inboxPath = inboxPath; if (variables.has("handle")) { getLogger(["fedify", "federation", "inbox"]).warn( "The {{handle}} variable in the inbox path is deprecated. " + "Use {{identifier}} instead.", ); } } if (sharedInboxPath != null) { const siVars = this.router.add(sharedInboxPath, "sharedInbox"); if (siVars.size !== 0) { throw new RouterError( "Path for shared inbox must have no variables.", ); } } const listeners = this.inboxListeners = new InboxListenerSet(); const setters: InboxListenerSetters<TContextData> = { on<TActivity extends Activity>( // deno-lint-ignore no-explicit-any type: new (...args: any[]) => TActivity, listener: InboxListener<TContextData, TActivity>, ): InboxListenerSetters<TContextData> { listeners.add(type, listener as InboxListener<TContextData, Activity>); return setters; }, onError: ( handler: InboxErrorHandler<TContextData>, ): InboxListenerSetters<TContextData> => { this.inboxErrorHandler = handler; return setters; }, setSharedKeyDispatcher: ( dispatcher: SharedInboxKeyDispatcher<TContextData>, ): InboxListenerSetters<TContextData> => { this.sharedInboxKeyDispatcher = dispatcher; return setters; }, }; return setters; } async sendActivity( keys: SenderKeyPair[], recipients: Recipient | Recipient[], activity: Activity, options: SendActivityInternalOptions<TContextData>, ): Promise<void> { const logger = getLogger(["fedify", "federation", "outbox"]); const { preferSharedInbox, immediate, excludeBaseUris, collectionSync, contextData, } = options; if (keys.length < 1) { throw new TypeError("The sender's keys must not be empty."); } for (const { privateKey } of keys) { validateCryptoKey(privateKey, "private"); } if (activity.actorId == null) { logger.error( "Activity {activityId} to send does not have an actor.", { activity, activityId: activity?.id?.href }, ); throw new TypeError( "The activity to send must have at least one actor property.", ); } if (activity.id == null) { activity = activity.clone({ id: new URL(`urn:uuid:${crypto.randomUUID()}`), }); } const inboxes = extractInboxes({ recipients: Array.isArray(recipients) ? recipients : [recipients], preferSharedInbox, excludeBaseUris, }); logger.debug("Sending activity {activityId} to inboxes:\n{inboxes}", { inboxes: globalThis.Object.keys(inboxes), activityId: activity.id?.href, activity, }); if (activity.id == null) { throw new TypeError("The activity to send must have an id."); } if (activity.actorId == null) { throw new TypeError( "The activity to send must have at least one actor property.", ); } else if (keys.length < 1) { throw new TypeError("The keys must not be empty."); } const activityId = activity.id.href; let proofCreated = false; let rsaKey: { keyId: URL; privateKey: CryptoKey } | null = null; for (const { keyId, privateKey } of keys) { validateCryptoKey(privateKey, "private"); if (rsaKey == null && privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") { rsaKey = { keyId, privateKey }; continue; } if (privateKey.algorithm.name === "Ed25519") { activity = await signObject(activity, privateKey, keyId, { contextLoader: this.contextLoader, }); proofCreated = true; } } let jsonLd = await activity.toJsonLd({ format: "compact", contextLoader: this.contextLoader, }); if (rsaKey == null) { logger.warn( "No supported key found to create a Linked Data signature for " + "the activity {activityId}. The activity will be sent without " + "a Linked Data signature. In order to create a Linked Data " + "signature, at least one RSASSA-PKCS1-v1_5 key must be provided.", { activityId, keys: keys.map((pair) => ({ keyId: pair.keyId.href, privateKey: pair.privateKey, })), }, ); } else { jsonLd = await signJsonLd(jsonLd, rsaKey.privateKey, rsaKey.keyId, { contextLoader: this.contextLoader, }); } if (!proofCreated) { logger.warn( "No supported key found to create a proof for the activity {activityId}. " + "The activity will be sent without a proof. " + "In order to create a proof, at least one Ed25519 key must be provided.", { activityId, keys: keys.map((pair) => ({ keyId: pair.keyId.href, privateKey: pair.privateKey, })), }, ); } if (immediate || this.queue == null) { if (immediate) { logger.debug( "Sending activity immediately without queue since immediate option " + "is set.", { activityId: activity.id!.href, activity: jsonLd }, ); } else { logger.debug( "Sending activity immediately without queue since queue is not set.", { activityId: activity.id!.href, activity: jsonLd }, ); } const promises: Promise<void>[] = []; for (const inbox in inboxes) { promises.push( sendActivity({ keys, activity: jsonLd, activityId: activity.id?.href, inbox: new URL(inbox), headers: collectionSync == null ? undefined : new Headers({ "Collection-Synchronization": await buildCollectionSynchronizationHeader( collectionSync, inboxes[inbox], ), }), }), ); } await Promise.all(promises); return; } logger.debug( "Enqueuing activity {activityId} to send later.", { activityId: activity.id!.href, activity: jsonLd }, ); const keyJwkPairs: SenderKeyJwkPair[] = []; for (const { keyId, privateKey } of keys) { const privateKeyJwk = await exportJwk(privateKey); keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk }); } if (!this.manuallyStartQueue) this.#startQueue(contextData); for (const inbox in inboxes) { const message: OutboxMessage = { type: "outbox", id: crypto.randomUUID(), keys: keyJwkPairs, activity: jsonLd, activityId: activity.id?.href, inbox, started: new Date().toISOString(), attempt: 0, headers: collectionSync == null ? {} : { "Collection-Synchronization": await buildCollectionSynchronizationHeader( collectionSync, inboxes[inbox], ), }, }; this.queue.enqueue(message); } } fetch( request: Request, options: FederationFetchOptions<TContextData>, ): Promise<Response> { const requestId = getRequestId(request); return withContext({ requestId }, async () => { const response = await this.#fetch(request, options); const logger = getLogger(["fedify", "federation", "http"]); const url = new URL(request.url); const logTpl = "{method} {path}: {status}"; const values = { method: request.method, path: `${url.pathname}${url.search}`, url: request.url, status: response.status, }; if (response.status >= 500) logger.error(logTpl, values); else if (response.status >= 400) logger.warn(logTpl, values); else logger.info(logTpl, values); return response; }); } async #fetch( request: Request, { onNotFound, onNotAcceptable, onUnauthorized, contextData, }: FederationFetchOptions<TContextData>, ): Promise<Response> { onNotFound ??= notFound; onNotAcceptable ??= notAcceptable; onUnauthorized ??= unauthorized; const url = new URL(request.url); const route = this.router.route(url.pathname); if (route == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } let context = this.#createContext(request, contextData); const routeName = route.name.replace(/:.*$/, ""); switch (routeName) { case "webfinger": return await handleWebFinger(request, { context, actorDispatcher: this.actorCallbacks?.dispatcher, actorHandleMapper: this.actorCallbacks?.handleMapper, onNotFound, }); case "nodeInfoJrd": return await handleNodeInfoJrd(request, context); case "nodeInfo": return await handleNodeInfo(request, { context, nodeInfoDispatcher: this.nodeInfoDispatcher!, }); case "actor": context = this.#createContext(request, contextData, { invokedFromActorDispatcher: { identifier: route.values.identifier ?? route.values.handle, }, }); return await handleActor(request, { identifier: route.values.identifier ?? route.values.handle, context, actorDispatcher: this.actorCallbacks?.dispatcher, authorizePredicate: this.actorCallbacks?.authorizePredicate, onUnauthorized, onNotFound, onNotAcceptable, }); case "object": { const typeId = route.name.replace(/^object:/, ""); const callbacks = this.objectCallbacks[typeId]; const cls = this.objectTypeIds[typeId]; context = this.#createContext(request, contextData, { invokedFromObjectDispatcher: { cls, values: route.values }, }); return await handleObject(request, { values: route.values, context, objectDispatcher: callbacks?.dispatcher, authorizePredicate: callbacks?.authorizePredicate, onUnauthorized, onNotFound, onNotAcceptable, }); } case "outbox": return await handleCollection(request, { name: "outbox", identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getOutboxUri.bind(context), context, collectionCallbacks: this.outboxCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); case "inbox": if (request.method !== "POST") { return await handleCollection(request, { name: "inbox", identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getInboxUri.bind(context), context, collectionCallbacks: this.inboxCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); } context = this.#createContext(request, contextData, { documentLoader: await context.getDocumentLoader({ identifier: route.values.identifier ?? route.values.handle, }), }); // falls through case "sharedInbox": if (routeName !== "inbox" && this.sharedInboxKeyDispatcher != null) { const identity = await this.sharedInboxKeyDispatcher(context); if (identity != null) { context = this.#createContext(request, contextData, { documentLoader: "identifier" in identity || "username" in identity || "handle" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity), }); } } if (!this.manuallyStartQueue) this.#startQueue(contextData); return await handleInbox(request, { recipient: route.values.identifier ?? route.values.handle ?? null, context, inboxContextFactory: context.toInboxContext.bind(context), kv: this.kv, kvPrefixes: this.kvPrefixes, queue: this.queue, actorDispatcher: this.actorCallbacks?.dispatcher, inboxListeners: this.inboxListeners, inboxErrorHandler: this.inboxErrorHandler, onNotFound, signatureTimeWindow: this.signatureTimeWindow, skipSignatureVerification: this.skipSignatureVerification, }); case "following": return await handleCollection(request, { name: "following", identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFollowingUri.bind(context), context, collectionCallbacks: this.followingCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); case "followers": { let baseUrl = url.searchParams.get("base-url"); if (baseUrl != null) { const u = new URL(baseUrl); baseUrl = `${u.origin}/`; } return await handleCollection(request, { name: "followers", identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFollowersUri.bind(context), context, filter: baseUrl != null ? new URL(baseUrl) : undefined, filterPredicate: baseUrl != null ? ((i) => (i instanceof URL ? i.href : i.id?.href ?? "").startsWith( baseUrl!, )) : undefined, collectionCallbacks: this.followersCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); } case "liked": return await handleCollection(request, { name: "liked", identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getLikedUri.bind(context), context, collectionCallbacks: this.likedCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); case "featured": return await handleCollection(request, { name: "featured", identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFeaturedUri.bind(context), context, collectionCallbacks: this.featuredCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); case "featuredTags": return await handleCollection(request, { name: "featured tags", identifier: route.values.identifier ?? route.values.handle, uriGetter: context.getFeaturedTagsUri.bind(context), context, collectionCallbacks: this.featuredTagsCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); default: { const response = onNotFound(request); return response instanceof Promise ? await response : response; } } } } interface ContextOptions<TContextData> { url: URL; federation: FederationImpl<TContextData>; data: TContextData; documentLoader: DocumentLoader; invokedFromActorKeyPairsDispatcher?: { identifier: string }; } export class ContextImpl<TContextData> implements Context<TContextData> { readonly url: URL; readonly federation: FederationImpl<TContextData>; readonly data: TContextData; readonly documentLoader: DocumentLoader; readonly invokedFromActorKeyPairsDispatcher?: { identifier: string }; constructor( { url, federation, data, documentLoader, invokedFromActorKeyPairsDispatcher, }: ContextOptions<TContextData>, ) { this.url = url; this.federation = federation; this.data = data; this.documentLoader = documentLoader; this.invokedFromActorKeyPairsDispatcher = invokedFromActorKeyPairsDispatcher; } toInboxContext( recipient: string | null, activity: unknown, ): InboxContextImpl<TContextData> { return new InboxContextImpl(recipient, activity, { url: this.url, federation: this.federation, data: this.data, documentLoader: this.documentLoader, invokedFromActorKeyPairsDispatcher: this.invokedFromActorKeyPairsDispatcher, }); } get hostname(): string { return this.url.hostname; } get host(): string { return this.url.host; } get origin(): string { return this.url.origin; } get contextLoader(): DocumentLoader { return this.federation.contextLoader; } getNodeInfoUri(): URL { const path = this.federation.router.build("nodeInfo", {}); if (path == null) { throw new RouterError("No NodeInfo dispatcher registered."); } return new URL(path, this.url); } getActorUri(identifier: string): URL { const path = this.federation.router.build( "actor", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No actor dispatcher registered."); } return new URL(path, this.url); } getObjectUri<TObject extends Object>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, values: Record<string, string>, ): URL { const callbacks = this.federation.objectCallbacks[cls.typeId.href]; if (callbacks == null) { throw new RouterError("No object dispatcher registered."); } for (const param of callbacks.parameters) { if (!(param in values)) { throw new TypeError(`Missing parameter: ${param}`); } } const path = this.federation.router.build( `object:${cls.typeId.href}`, values, ); if (path == null) { throw new RouterError("No object dispatcher registered."); } return new URL(path, this.url); } getOutboxUri(identifier: string): URL { const path = this.federation.router.build( "outbox", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No outbox dispatcher registered."); } return new URL(path, this.url); } getInboxUri(): URL; getInboxUri(identifier: string): URL; getInboxUri(identifier?: string): URL { if (identifier == null) { const path = this.federation.router.build("sharedInbox", {}); if (path == null) { throw new RouterError("No shared inbox path registered."); } return new URL(path, this.url); } const path = this.federation.router.build( "inbox", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No inbox path registered."); } return new URL(path, this.url); } getFollowingUri(identifier: string): URL { const path = this.federation.router.build( "following", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No following collection path registered."); } return new URL(path, this.url); } getFollowersUri(identifier: string): URL { const path = this.federation.router.build( "followers", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No followers collection path registered."); } return new URL(path, this.url); } getLikedUri(identifier: string): URL { const path = this.federation.router.build( "liked", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No liked collection path registered."); } return new URL(path, this.url); } getFeaturedUri(identifier: string): URL { const path = this.federation.router.build( "featured", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No featured collection path registered."); } return new URL(path, this.url); } getFeaturedTagsUri(identifier: string): URL { const path = this.federation.router.build( "featuredTags", { identifier, handle: identifier }, ); if (path == null) { throw new RouterError("No featured tags collection path registered."); } return new URL(path, this.url); } parseUri(uri: URL | null): ParseUriResult | null { if (uri == null) return null; if (uri.origin !== this.url.origin) return null; const route = this.federation.router.route(uri.pathname); const logger = getLogger(["fedify", "federation"]); if (route == null) return null; else if (route.name === "sharedInbox") { return { type: "inbox", identifier: undefined, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return undefined; }, }; } const identifier = "identifier" in route.values ? route.values.identifier : route.values.handle; if (route.name === "actor") { return { type: "actor", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } else if (route.name.startsWith("object:")) { const typeId = route.name.replace(/^object:/, ""); return { type: "object", class: this.federation.objectTypeIds[typeId], typeId: new URL(typeId), values: route.values, }; } else if (route.name === "inbox") { return { type: "inbox", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } else if (route.name === "outbox") { return { type: "outbox", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } else if (route.name === "following") { return { type: "following", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } else if (route.name === "followers") { return { type: "followers", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } else if (route.name === "liked") { return { type: "liked", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } else if (route.name === "featured") { return { type: "featured", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } else if (route.name === "featuredTags") { return { type: "featuredTags", identifier, get handle() { logger.warn( "The ParseUriResult.handle property is deprecated; " + "use ParseUriResult.identifier instead.", ); return identifier; }, }; } return null; } async getActorKeyPairs(identifier: string): Promise<ActorKeyPair[]> { const logger = getLogger(["fedify", "federation", "actor"]); if (this.invokedFromActorKeyPairsDispatcher != null) { logger.warn( "Context.getActorKeyPairs({getActorKeyPairsIdentifier}) method is " + "invoked from the actor key pairs dispatcher " + "({actorKeyPairsDispatcherIdentifier}); this may cause " + "an infinite loop.", { getActorKeyPairsIdentifier: identifier, actorKeyPairsDispatcherIdentifier: this.invokedFromActorKeyPairsDispatcher.identifier, }, ); } let keyPairs: (CryptoKeyPair & { keyId: URL })[]; try { keyPairs = await this.getKeyPairsFromIdentifier(identifier); } catch (_) { logger.warn("No actor key pairs dispatcher registered."); return []; } const owner = this.getActorUri(identifier); const result = []; for (const keyPair of keyPairs) { const newPair: ActorKeyPair = { ...keyPair, cryptographicKey: new CryptographicKey({ id: keyPair.keyId, owner, publicKey: keyPair.publicKey, }), multikey: new Multikey({ id: keyPair.keyId, controller: owner, publicKey: keyPair.publicKey, }), }; result.push(newPair); } return result; } protected async getKeyPairsFromIdentifier( identifier: string, ): Promise<(CryptoKeyPair & { keyId: URL })[]> { const logger = getLogger(["fedify", "federation", "actor"]); if (this.federation.actorCallbacks?.keyPairsDispatcher == null) { throw new Error("No actor key pairs dispatcher registered."); } const path = this.federation.router.build( "actor", { identifier, handle: identifier }, ); if (path == null) { logger.warn("No actor dispatcher registered."); return []; } const actorUri = new URL(path, this.url); const keyPairs = await this.federation.actorCallbacks?.keyPairsDispatcher( new ContextImpl({ ...this, invokedFromActorKeyPairsDispatcher: { identifier }, }), identifier, ); if (keyPairs.length < 1) { logger.warn("No key pairs found for actor {identifier}.", { identifier }); } let i = 0; const result = []; for (const keyPair of keyPairs) { result.push({ ...keyPair, keyId: new URL( // For backwards compatibility, the first key is always the #main-key: i == 0 ? `#main-key` : `#key-${i + 1}`, actorUri, ), }); i++; } return result; } protected async getRsaKeyPairFromIdentifier( identifier: string, ): Promise<CryptoKeyPair & { keyId: URL } | null> { const keyPairs = await this.getKeyPairsFromIdentifier(identifier); for (const keyPair of keyPairs) { const { privateKey } = keyPair; if ( privateKey.algorithm.name === "RSASSA-PKCS1-v1_5" && (privateKey.algorithm as unknown as { hash: { name: string } }).hash .name === "SHA-256" ) { return keyPair; } } getLogger(["fedify", "federation", "actor"]).warn( "No RSA-PKCS#1-v1.5 SHA-256 key found for actor {identifier}.", { identifier }, ); return null; } getDocumentLoader( identity: | { identifier: string } | { username: string } | { handle: string }, ): Promise<DocumentLoader>; getDocumentLoader(identity: SenderKeyPair): DocumentLoader; getDocumentLoader( identity: | SenderKeyPair | { identifier: string } | { username: string } | { handle: string }, ): DocumentLoader | Promise<DocumentLoader> { if ( "identifier" in identity || "username" in identity || "handle" in identity ) { let identifierPromise: Promise<string | null>; if ("username" in identity || "handle" in identity) { let username: string; if ("username" in identity) { username = identity.username; } else { username = identity.handle; getLogger(["fedify", "runtime", "docloader"]).warn( 'The "handle" property is deprecated; use "identifier" or ' + '"username" instead.', { identity }, ); } const mapper = this.federation.actorCallbacks?.handleMapper; if (mapper == null) { identifierPromise = Promise.resolve(username); } else { const identifier = mapper(this, username); identifierPromise = identifier instanceof Promise ? identifier : Promise.resolve(identifier); } } else { identifierPromise = Promise.resolve(identity.identifier); } return identifierPromise.then((identifier) => { if (identifier == null) return this.documentLoader; const keyPair = this.getRsaKeyPairFromIdentifier(identifier); return keyPair.then((pair) => pair == null ? this.documentLoader : this.federation.authenticatedDocumentLoaderFactory(pair) ); }); } return this.federation.authenticatedDocumentLoaderFactory(identity); } lookupObject( identifier: string | URL, options: LookupObjectOptions = {}, ): Promise<Object | null> { return lookupObject(identifier, { documentLoader: options.documentLoader ?? this.documentLoader, contextLoader: options.contextLoader ?? this.contextLoader, }); } traverseCollection( collection: Collection, options: TraverseCollectionOptions = {}, ): AsyncIterable<Object | Link> { return traverseCollection(collection, { documentLoader: options.documentLoader ?? this.documentLoader, contextLoader: options.contextLoader ?? this.contextLoader, }); } async sendActivity( sender: | SenderKeyPair | SenderKeyPair[] | { identifier: string } | { username: string } | { handle: string }, recipients: Recipient | Recipient[] | "followers", activity: Activity, options: SendActivityOptions = {}, ): Promise<void> { let keys: SenderKeyPair[]; let identifier: string | null = null; if ("identifier" in sender || "username" in sender || "handle" in sender) { if ("identifier" in sender) { identifier = sender.identifier; } else { let username: string; if ("username" in sender) { username = sender.username; } else { username = sender.handle; getLogger(["fedify", "federation", "outbox"]).warn( 'The "handle" property for the sender parameter is deprecated; ' + 'use "identifier" or "username" instead.', { sender }, ); } if (this.federation.actorCallbacks?.handleMapper == null) { identifier = username; } else { const mapped = await this.federation.actorCallbacks.handleMapper( this, username, ); if (mapped == null) { throw new Error( `No actor found for the given username ${ JSON.stringify(username) }.`, ); } identifier = mapped; } } keys = await this.getKeyPairsFromIdentifier(identifier); if (keys.length < 1) { throw new Error( `No key pair found for actor ${JSON.stringify(identifier)}.`, ); } } else if (Array.isArray(sender)) { if (sender.length < 1) { throw new Error("The sender's key pairs are empty."); } keys = sender; } else { keys = [sender]; } const opts: SendActivityInternalOptions<TContextData> = { contextData: this.data, ...options, }; let expandedRecipients: Recipient[]; if (Array.isArray(recipients)) { expandedRecipients = recipients; } else if (recipients === "followers") { if (identifier == null) { throw new Error( 'If recipients is "followers", ' + "sender must be an actor identifier or username.", ); } expandedRecipients = []; for await ( const recipient of this.getFollowers(identifier) ) { expandedRecipients.push(recipient); } const collectionId = this.federation.router.build( "followers", { identifier, handle: identifier }, ); opts.collectionSync = collectionId == null ? undefined : new URL(collectionId, this.url).href; } else { expandedRecipients = [recipients]; } return await this.federation.sendActivity( keys, expandedRecipients, activity, opts, ); } async *getFollowers(identifier: string): AsyncIterable<Recipient> { if (this.federation.followersCallbacks == null) { throw new Error("No followers collection dispatcher registered."); } const result = await this.federation.followersCallbacks.dispatcher( this, identifier, null, ); if (result != null) { for (const recipient of result.items) yield recipient; return; } if (this.federation.followersCallbacks.firstCursor == null) { throw new Error( "No first cursor dispatcher registered for followers collection.", ); } let cursor = await this.federation.followersCallbacks.firstCursor( this, identifier, ); while (cursor != null) { const result = await this.federation.followersCallbacks.dispatcher( this, identifier, cursor, ); if (result == null) break; for (const recipient of result.items) yield recipient; cursor = result.nextCursor ?? null; } } } interface RequestContextOptions<TContextData> extends ContextOptions<TContextData> { request: Request; invokedFromActorDispatcher?: { identifier: string }; invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; values: Record<string, string>; }; } class RequestContextImpl<TContextData> extends ContextImpl<TContextData> implements RequestContext<TContextData> { readonly #invokedFromActorDispatcher?: { identifier: string }; readonly #invokedFromObjectDispatcher?: { // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => Object) & { typeId: URL }; values: Record<string, string>; }; readonly request: Request; override readonly url: URL; constructor(options: RequestContextOptions<TContextData>) { super(options); this.#invokedFromActorDispatcher = options.invokedFromActorDispatcher; this.#invokedFromObjectDispatcher = options.invokedFromObjectDispatcher; this.request = options.request; this.url = options.url; } async getActor(identifier: string): Promise<Actor | null> { if ( this.federation.actorCallbacks == null || this.federation.actorCallbacks.dispatcher == null ) { throw new Error("No actor dispatcher registered."); } if (this.#invokedFromActorDispatcher != null) { getLogger(["fedify", "federation", "actor"]).warn( "RequestContext.getActor({getActorIdentifier}) is invoked from " + "the actor dispatcher ({actorDispatcherIdentifier}); " + "this may cause an infinite loop.", { getActorIdentifier: identifier, actorDispatcherIdentifier: this.#invokedFromActorDispatcher.identifier, }, ); } return await this.federation.actorCallbacks.dispatcher( new RequestContextImpl({ ...this, invokedFromActorDispatcher: { identifier }, }), identifier, ); } async getObject<TObject extends Object>( // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => TObject) & { typeId: URL }, values: Record<string, string>, ): Promise<TObject | null> { const callbacks = this.federation.objectCallbacks[cls.typeId.href]; if (callbacks == null) { throw new Error("No object dispatcher registered."); } for (const param of callbacks.parameters) { if (!(param in values)) { throw new TypeError(`Missing parameter: ${param}`); } } if (this.#invokedFromObjectDispatcher != null) { getLogger(["fedify", "federation"]).warn( "RequestContext.getObject({getObjectClass}, " + "{getObjectValues}) is invoked from the object dispatcher " + "({actorDispatcherClass}, {actorDispatcherValues}); " + "this may cause an infinite loop.", { getObjectClass: cls.name, getObjectValues: values, actorDispatcherClass: this.#invokedFromObjectDispatcher.cls.name, actorDispatcherValues: this.#invokedFromObjectDispatcher.values, }, ); } return await callbacks.dispatcher( new RequestContextImpl({ ...this, invokedFromObjectDispatcher: { cls, values }, }), values, // deno-lint-ignore no-explicit-any ) as any; } #signedKey: CryptographicKey | null | undefined = undefined; async getSignedKey(): Promise<CryptographicKey | null> { if (this.#signedKey !== undefined) return this.#signedKey; return this.#signedKey = await verifyRequest(this.request, { ...this, timeWindow: this.federation.signatureTimeWindow, }); } #signedKeyOwner: Actor | null | undefined = undefined; async getSignedKeyOwner(): Promise<Actor | null> { if (this.#signedKeyOwner !== undefined) return this.#signedKeyOwner; const key = await this.getSignedKey(); if (key == null) return this.#signedKeyOwner = null; return this.#signedKeyOwner = await getKeyOwner(key, this); } } export class InboxContextImpl<TContextData> extends ContextImpl<TContextData> implements InboxContext<TContextData> { readonly recipient: string | null; readonly activity: unknown; constructor( recipient: string | null, activity: unknown, options: ContextOptions<TContextData>, ) { super(options); this.recipient = recipient; this.activity = activity; } forwardActivity( forwarder: | SenderKeyPair | SenderKeyPair[] | { identifier: string } | { username: string } | { handle: string }, recipients: Recipient | Recipient[], options?: ForwardActivityOptions, ): Promise<void>; forwardActivity( forwarder: | { identifier: string } | { username: string } | { handle: string }, recipients: "followers", options?: ForwardActivityOptions, ): Promise<void>; async forwardActivity( forwarder: | SenderKeyPair | SenderKeyPair[] | { identifier: string } | { username: string } | { handle: string }, recipients: Recipient | Recipient[] | "followers", options?: ForwardActivityOptions, ): Promise<void> { const logger = getLogger(["fedify", "federation", "inbox"]); let keys: SenderKeyPair[]; let identifier: string | null = null; if ( "identifier" in forwarder || "username" in forwarder || "handle" in forwarder ) { if ("identifier" in forwarder) { identifier = forwarder.identifier; } else { let username: string; if ("username" in forwarder) { username = forwarder.username; } else { username = forwarder.handle; logger.warn( 'The "handle" property for the forwarder parameter is deprecated; ' + 'use "identifier" or "username" instead.', { forwarder }, ); } if (this.federation.actorCallbacks?.handleMapper == null) { identifier = username; } else { const mapped = await this.federation.actorCallbacks.handleMapper( this, username, ); if (mapped == null) { throw new Error( `No actor found for the given username ${ JSON.stringify(username) }.`, ); } identifier = mapped; } } keys = await this.getKeyPairsFromIdentifier(identifier); if (keys.length < 1) { throw new Error( `No key pair found for actor ${JSON.stringify(identifier)}.`, ); } } else if (Array.isArray(forwarder)) { if (forwarder.length < 1) { throw new Error("The forwarder's key pairs are empty."); } keys = forwarder; } else { keys = [forwarder]; } let activityId: string | undefined = undefined; if (!hasSignature(this.activity)) { let hasProof: boolean; try { const activity = await Activity.fromJsonLd(this.activity, this); activityId = activity.id?.href; hasProof = await activity.getProof() != null; } catch { hasProof = false; } if (!hasProof) { if (options?.skipIfUnsigned) return; logger.warn( "The received activity {activityId} is not signed; even if it is " + "forwarded to other servers as is, it may not be accepted by " + "them due to the lack of a signature/proof.", ); } } if ( activityId == null && typeof this.activity === "object" && this.activity != null ) { activityId = "@id" in this.activity && typeof this.activity["@id"] === "string" ? this.activity["@id"] : "id" in this.activity && typeof this.activity.id === "string" ? this.activity.id : undefined; } if (recipients === "followers") { if (identifier == null) { throw new Error( 'If recipients is "followers", ' + "forwarder must be an actor identifier or username.", ); } const followers: Recipient[] = []; for await (const recipient of this.getFollowers(identifier)) { followers.push(recipient); } recipients = followers; } const inboxes = extractInboxes({ recipients: Array.isArray(recipients) ? recipients : [recipients], preferSharedInbox: options?.preferSharedInbox, excludeBaseUris: options?.excludeBaseUris, }); logger.debug("Forwarding activity {activityId} to inboxes:\n{inboxes}", { inboxes: globalThis.Object.keys(inboxes), activityId, activity: this.activity, }); if (options?.immediate || this.federation.queue == null) { if (options?.immediate) { logger.debug( "Forwarding activity immediately without queue since immediate " + "option is set.", ); } else { logger.debug( "Forwarding activity immediately without queue since queue is not " + "set.", ); } const promises: Promise<void>[] = []; for (const inbox in inboxes) { promises.push( sendActivity({ keys, activity: this.activity, activityId: activityId, inbox: new URL(inbox), }), ); } await Promise.all(promises); return; } logger.debug( "Enqueuing activity {activityId} to forward later.", { activityId, activity: this.activity }, ); const keyJwkPairs: SenderKeyJwkPair[] = []; for (const { keyId, privateKey } of keys) { const privateKeyJwk = await exportJwk(privateKey); keyJwkPairs.push({ keyId: keyId.href, privateKey: privateKeyJwk }); } for (const inbox in inboxes) { const message: OutboxMessage = { type: "outbox", id: crypto.randomUUID(), keys: keyJwkPairs, activity: this.activity, activityId, inbox, started: new Date().toISOString(), attempt: 0, headers: {}, }; this.federation.queue.enqueue(message); } } } interface ActorCallbacks<TContextData> { dispatcher?: ActorDispatcher<TContextData>; keyPairsDispatcher?: ActorKeyPairsDispatcher<TContextData>; handleMapper?: ActorHandleMapper<TContextData>; authorizePredicate?: AuthorizePredicate<TContextData>; } interface ObjectCallbacks<TContextData, TParam extends string> { dispatcher: ObjectDispatcher<TContextData, Object, string>; parameters: Set<TParam>; authorizePredicate?: ObjectAuthorizePredicate<TContextData, TParam>; } interface SendActivityInternalOptions<TContextData> extends SendActivityOptions { collectionSync?: string; contextData: TContextData; } function notFound(_request: Request): Response { return new Response("Not Found", { status: 404 }); } function notAcceptable(_request: Request): Response { return new Response("Not Acceptable", { status: 406, headers: { Vary: "Accept, Signature", }, }); } function unauthorized(_request: Request): Response { return new Response("Unauthorized", { status: 401, headers: { Vary: "Accept, Signature", }, }); } /** * Generates or extracts a unique identifier for a request. * * This function first attempts to extract an existing request ID from standard * tracing headers. If none exists, it generates a new one. The ID format is: * * - If from headers, uses the existing ID. * - If generated, uses format `req_` followed by a base36 timestamp and * 6 random chars. * * @param request The incoming HTTP request. * @returns A string identifier unique to this request. */ function getRequestId(request: Request): string { // First try to get existing trace ID from standard headers: const traceId = request.headers.get("X-Request-Id") || request.headers.get("X-Correlation-Id") || request.headers.get("Traceparent")?.split("-")[1]; if (traceId != null) return traceId; // Generate new ID if none exists: // - Use timestamp for rough chronological ordering // - Add random suffix for uniqueness within same millisecond // - Prefix to distinguish from potential existing IDs const timestamp = Date.now().toString(36); const random = Math.random().toString(36).slice(2, 8); return `req_${timestamp}${random}`; }