Skip to content

Commit b56c7da

Browse files
authored
fix(api): enforce federation JSON-LD context (#295)
* fix(api): enforce federation jsonld context * test(api): cover federation jsonld document routes * docs(api): document federation jsonld helpers * fix(docker): pin rtk installer version * fix(docker): pin rtk installer script
1 parent de017e6 commit b56c7da

8 files changed

Lines changed: 498 additions & 98 deletions

File tree

packages/api/src/api/contracts.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,26 @@ export type ContainerTaskSnapshot = {
490490
readonly agents: ReadonlyArray<AgentSession>
491491
}
492492

493+
export const activityStreamsJsonLdContext = "https://www.w3.org/ns/activitystreams" as const
494+
export const forgeFedJsonLdContext = "https://forgefed.org/ns" as const
495+
export const securityJsonLdContext = "https://w3id.org/security/v1" as const
496+
export const activityForgeFedJsonLdContext = [
497+
activityStreamsJsonLdContext,
498+
forgeFedJsonLdContext
499+
] as const
500+
export const actorJsonLdContext = [
501+
activityStreamsJsonLdContext,
502+
securityJsonLdContext,
503+
forgeFedJsonLdContext
504+
] as const
505+
export const federationJsonLdContentType =
506+
`application/ld+json; profile="${activityStreamsJsonLdContext}"` as const
507+
export const federationJsonLdResponseContentType =
508+
`${federationJsonLdContentType}; charset=utf-8` as const
509+
510+
export type ActivityForgeFedJsonLdContext = typeof activityForgeFedJsonLdContext
511+
export type ActorJsonLdContext = typeof actorJsonLdContext
512+
493513
export type ForgeFedTicket = {
494514
readonly id: string
495515
readonly attributedTo: string
@@ -550,7 +570,7 @@ export type CreateFollowRequest = {
550570
export type FollowStatus = "pending" | "accepted" | "rejected"
551571

552572
export type ActivityPubFollowActivity = {
553-
readonly "@context": string | ReadonlyArray<string>
573+
readonly "@context": ActivityForgeFedJsonLdContext
554574
readonly id: string
555575
readonly type: "Follow"
556576
readonly actor: string
@@ -566,7 +586,7 @@ export type ActivityPubPublicKey = {
566586
}
567587

568588
export type ActivityPubPerson = {
569-
readonly "@context": "https://www.w3.org/ns/activitystreams"
589+
readonly "@context": ActorJsonLdContext
570590
readonly type: "Person"
571591
readonly id: string
572592
readonly name: string
@@ -584,7 +604,7 @@ export type ActivityPubPerson = {
584604
}
585605

586606
export type ActivityPubOrderedCollection = {
587-
readonly "@context": "https://www.w3.org/ns/activitystreams" | ReadonlyArray<string>
607+
readonly "@context": ActivityForgeFedJsonLdContext
588608
readonly type: "OrderedCollection"
589609
readonly id: string
590610
readonly totalItems: number

packages/api/src/http.ts

Lines changed: 123 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"
1111
import { renderError, type AppError } from "@effect-template/lib/usecases/errors"
1212

1313
import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js"
14-
import type { ApplyProjectRequest } from "./api/contracts.js"
14+
import { federationJsonLdResponseContentType, type ApplyProjectRequest } from "./api/contracts.js"
1515
import {
1616
AuthMenuRequestSchema,
1717
AuthTerminalSessionRequestSchema,
@@ -275,8 +275,23 @@ const binaryResponse = (data: Uint8Array, contentType: string, status = 200) =>
275275
)
276276
)
277277

278-
const activityJsonResponse = (data: unknown, status: number) =>
279-
textResponse(JSON.stringify(data), "application/activity+json; charset=utf-8", status)
278+
/**
279+
* Serializes a federation JSON-LD document with the ForgeFed response content type.
280+
*
281+
* @param data - JSON-LD payload that satisfies the JSON.stringify serializability precondition.
282+
* @param status - HTTP status code assigned to the response.
283+
* @returns Effect that yields an HTTP text response containing the serialized JSON-LD document.
284+
*
285+
* @pure false
286+
* @effect Delegates response allocation to textResponse and preserves no-store HTTP headers.
287+
* @invariant successful responses always use federationJsonLdResponseContentType.
288+
* @precondition data is JSON.stringify-serializable and status is a valid HTTP status code.
289+
* @postcondition response body equals JSON.stringify(data) and response status equals status.
290+
* @complexity O(n) time and O(n) space where n is the serialized JSON-LD payload size.
291+
* @throws TypeError when data violates the JSON.stringify serializability precondition.
292+
*/
293+
const jsonLdResponse = (data: unknown, status: number) =>
294+
textResponse(JSON.stringify(data), federationJsonLdResponseContentType, status)
280295

281296
const parseQueryInt = (url: string, key: string, fallback: number): number => {
282297
const parsed = Number(new URL(url, "http://localhost").searchParams.get(key) ?? "")
@@ -595,6 +610,106 @@ export const federationExchangeStatusResponse = () =>
595610
return yield* _(jsonResponse(makeFederationExchangeStatus(context), 200))
596611
}).pipe(Effect.catchAll(errorResponse))
597612

613+
/**
614+
* Builds the federation actor JSON-LD HTTP handler.
615+
*
616+
* @returns Effect that yields the local ActivityPub actor document response.
617+
*
618+
* @pure false
619+
* @effect Reads HttpServerRequest, resolves federation context, renders makeFederationActorDocument, serializes with jsonLdResponse, and maps failures through errorResponse.
620+
* @invariant successful responses contain the actor id derived from the resolved federation context.
621+
* @precondition request headers or configured env provide a non-empty public origin.
622+
* @postcondition successful responses contain a JSON-LD Person document with HTTP 200.
623+
* @complexity O(1) time and O(1) space for document construction, excluding serialization size.
624+
* @throws Never; failures are represented through the Effect error channel and converted by errorResponse.
625+
*/
626+
export const federationActorDocumentResponse = () =>
627+
Effect.gen(function*(_) {
628+
const request = yield* _(HttpServerRequest.HttpServerRequest)
629+
const context = yield* _(resolveFederationContext(request))
630+
return yield* _(jsonLdResponse(makeFederationActorDocument(context), 200))
631+
}).pipe(Effect.catchAll(errorResponse))
632+
633+
/**
634+
* Builds the federation outbox JSON-LD HTTP handler.
635+
*
636+
* @returns Effect that yields the local ActivityPub outbox collection response.
637+
*
638+
* @pure false
639+
* @effect Reads HttpServerRequest, resolves federation context, renders makeFederationOutboxCollection, serializes with jsonLdResponse, and maps failures through errorResponse.
640+
* @invariant successful responses contain the outbox id derived from the resolved federation context.
641+
* @precondition request headers or configured env provide a non-empty public origin.
642+
* @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200.
643+
* @complexity O(1) time and O(1) space for document construction, excluding serialization size.
644+
* @throws Never; failures are represented through the Effect error channel and converted by errorResponse.
645+
*/
646+
export const federationOutboxDocumentResponse = () =>
647+
Effect.gen(function*(_) {
648+
const request = yield* _(HttpServerRequest.HttpServerRequest)
649+
const context = yield* _(resolveFederationContext(request))
650+
return yield* _(jsonLdResponse(makeFederationOutboxCollection(context), 200))
651+
}).pipe(Effect.catchAll(errorResponse))
652+
653+
/**
654+
* Builds the federation followers JSON-LD HTTP handler.
655+
*
656+
* @returns Effect that yields the local ActivityPub followers collection response.
657+
*
658+
* @pure false
659+
* @effect Reads HttpServerRequest, resolves federation context, renders makeFederationFollowersCollection, serializes with jsonLdResponse, and maps failures through errorResponse.
660+
* @invariant successful responses contain the followers id derived from the resolved federation context.
661+
* @precondition request headers or configured env provide a non-empty public origin.
662+
* @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200.
663+
* @complexity O(1) time and O(1) space for document construction, excluding serialization size.
664+
* @throws Never; failures are represented through the Effect error channel and converted by errorResponse.
665+
*/
666+
export const federationFollowersDocumentResponse = () =>
667+
Effect.gen(function*(_) {
668+
const request = yield* _(HttpServerRequest.HttpServerRequest)
669+
const context = yield* _(resolveFederationContext(request))
670+
return yield* _(jsonLdResponse(makeFederationFollowersCollection(context), 200))
671+
}).pipe(Effect.catchAll(errorResponse))
672+
673+
/**
674+
* Builds the federation following JSON-LD HTTP handler.
675+
*
676+
* @returns Effect that yields the local ActivityPub following collection response.
677+
*
678+
* @pure false
679+
* @effect Reads HttpServerRequest, resolves federation context, renders makeFederationFollowingCollection, serializes with jsonLdResponse, and maps failures through errorResponse.
680+
* @invariant successful responses contain the following id derived from the resolved federation context.
681+
* @precondition request headers or configured env provide a non-empty public origin.
682+
* @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200.
683+
* @complexity O(1) time and O(1) space for document construction, excluding serialization size.
684+
* @throws Never; failures are represented through the Effect error channel and converted by errorResponse.
685+
*/
686+
export const federationFollowingDocumentResponse = () =>
687+
Effect.gen(function*(_) {
688+
const request = yield* _(HttpServerRequest.HttpServerRequest)
689+
const context = yield* _(resolveFederationContext(request))
690+
return yield* _(jsonLdResponse(makeFederationFollowingCollection(context), 200))
691+
}).pipe(Effect.catchAll(errorResponse))
692+
693+
/**
694+
* Builds the federation liked JSON-LD HTTP handler.
695+
*
696+
* @returns Effect that yields the local ActivityPub liked collection response.
697+
*
698+
* @pure false
699+
* @effect Reads HttpServerRequest, resolves federation context, renders makeFederationLikedCollection, serializes with jsonLdResponse, and maps failures through errorResponse.
700+
* @invariant successful responses contain the liked collection id derived from the resolved federation context.
701+
* @precondition request headers or configured env provide a non-empty public origin.
702+
* @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200.
703+
* @complexity O(1) time and O(1) space for document construction, excluding serialization size.
704+
* @throws Never; failures are represented through the Effect error channel and converted by errorResponse.
705+
*/
706+
export const federationLikedDocumentResponse = () =>
707+
Effect.gen(function*(_) {
708+
const request = yield* _(HttpServerRequest.HttpServerRequest)
709+
const context = yield* _(resolveFederationContext(request))
710+
return yield* _(jsonLdResponse(makeFederationLikedCollection(context), 200))
711+
}).pipe(Effect.catchAll(errorResponse))
712+
598713
const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
599714
const request = yield* _(HttpServerRequest.HttpServerRequest)
600715
const upgrade = readHeader(request, "upgrade")?.toLowerCase()
@@ -859,43 +974,23 @@ export const makeRouter = () => {
859974
),
860975
HttpRouter.get(
861976
"/federation/actor",
862-
Effect.gen(function*(_) {
863-
const request = yield* _(HttpServerRequest.HttpServerRequest)
864-
const context = yield* _(resolveFederationContext(request))
865-
return yield* _(activityJsonResponse(makeFederationActorDocument(context), 200))
866-
}).pipe(Effect.catchAll(errorResponse))
977+
federationActorDocumentResponse()
867978
),
868979
HttpRouter.get(
869980
"/federation/outbox",
870-
Effect.gen(function*(_) {
871-
const request = yield* _(HttpServerRequest.HttpServerRequest)
872-
const context = yield* _(resolveFederationContext(request))
873-
return yield* _(activityJsonResponse(makeFederationOutboxCollection(context), 200))
874-
}).pipe(Effect.catchAll(errorResponse))
981+
federationOutboxDocumentResponse()
875982
),
876983
HttpRouter.get(
877984
"/federation/followers",
878-
Effect.gen(function*(_) {
879-
const request = yield* _(HttpServerRequest.HttpServerRequest)
880-
const context = yield* _(resolveFederationContext(request))
881-
return yield* _(activityJsonResponse(makeFederationFollowersCollection(context), 200))
882-
}).pipe(Effect.catchAll(errorResponse))
985+
federationFollowersDocumentResponse()
883986
),
884987
HttpRouter.get(
885988
"/federation/following",
886-
Effect.gen(function*(_) {
887-
const request = yield* _(HttpServerRequest.HttpServerRequest)
888-
const context = yield* _(resolveFederationContext(request))
889-
return yield* _(activityJsonResponse(makeFederationFollowingCollection(context), 200))
890-
}).pipe(Effect.catchAll(errorResponse))
989+
federationFollowingDocumentResponse()
891990
),
892991
HttpRouter.get(
893992
"/federation/liked",
894-
Effect.gen(function*(_) {
895-
const request = yield* _(HttpServerRequest.HttpServerRequest)
896-
const context = yield* _(resolveFederationContext(request))
897-
return yield* _(activityJsonResponse(makeFederationLikedCollection(context), 200))
898-
}).pipe(Effect.catchAll(errorResponse))
993+
federationLikedDocumentResponse()
899994
),
900995
HttpRouter.get(
901996
"/federation/status",

0 commit comments

Comments
 (0)