From 3ed2eb8bfdac1a47215132e805204965005a982c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 13 May 2026 16:31:02 +0000 Subject: [PATCH 1/5] fix(api): enforce federation jsonld context --- packages/api/src/api/contracts.ts | 26 +++- packages/api/src/http.ts | 27 ++-- packages/api/src/services/federation.ts | 178 ++++++++++++++++-------- packages/api/tests/federation.test.ts | 60 +++++++- packages/api/tests/http-config.test.ts | 53 +++++++ 5 files changed, 270 insertions(+), 74 deletions(-) diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 716b131f..a5ae6620 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -490,6 +490,26 @@ export type ContainerTaskSnapshot = { readonly agents: ReadonlyArray } +export const activityStreamsJsonLdContext = "https://www.w3.org/ns/activitystreams" as const +export const forgeFedJsonLdContext = "https://forgefed.org/ns" as const +export const securityJsonLdContext = "https://w3id.org/security/v1" as const +export const activityForgeFedJsonLdContext = [ + activityStreamsJsonLdContext, + forgeFedJsonLdContext +] as const +export const actorJsonLdContext = [ + activityStreamsJsonLdContext, + securityJsonLdContext, + forgeFedJsonLdContext +] as const +export const federationJsonLdContentType = + `application/ld+json; profile="${activityStreamsJsonLdContext}"` as const +export const federationJsonLdResponseContentType = + `${federationJsonLdContentType}; charset=utf-8` as const + +export type ActivityForgeFedJsonLdContext = typeof activityForgeFedJsonLdContext +export type ActorJsonLdContext = typeof actorJsonLdContext + export type ForgeFedTicket = { readonly id: string readonly attributedTo: string @@ -550,7 +570,7 @@ export type CreateFollowRequest = { export type FollowStatus = "pending" | "accepted" | "rejected" export type ActivityPubFollowActivity = { - readonly "@context": string | ReadonlyArray + readonly "@context": ActivityForgeFedJsonLdContext readonly id: string readonly type: "Follow" readonly actor: string @@ -566,7 +586,7 @@ export type ActivityPubPublicKey = { } export type ActivityPubPerson = { - readonly "@context": "https://www.w3.org/ns/activitystreams" + readonly "@context": ActorJsonLdContext readonly type: "Person" readonly id: string readonly name: string @@ -584,7 +604,7 @@ export type ActivityPubPerson = { } export type ActivityPubOrderedCollection = { - readonly "@context": "https://www.w3.org/ns/activitystreams" | ReadonlyArray + readonly "@context": ActivityForgeFedJsonLdContext readonly type: "OrderedCollection" readonly id: string readonly totalItems: number diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index f0b868dc..301f6db9 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema" import { renderError, type AppError } from "@effect-template/lib/usecases/errors" import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" -import type { ApplyProjectRequest } from "./api/contracts.js" +import { federationJsonLdResponseContentType, type ApplyProjectRequest } from "./api/contracts.js" import { AuthMenuRequestSchema, AuthTerminalSessionRequestSchema, @@ -275,8 +275,8 @@ const binaryResponse = (data: Uint8Array, contentType: string, status = 200) => ) ) -const activityJsonResponse = (data: unknown, status: number) => - textResponse(JSON.stringify(data), "application/activity+json; charset=utf-8", status) +const jsonLdResponse = (data: unknown, status: number) => + textResponse(JSON.stringify(data), federationJsonLdResponseContentType, status) const parseQueryInt = (url: string, key: string, fallback: number): number => { const parsed = Number(new URL(url, "http://localhost").searchParams.get(key) ?? "") @@ -595,6 +595,13 @@ export const federationExchangeStatusResponse = () => return yield* _(jsonResponse(makeFederationExchangeStatus(context), 200)) }).pipe(Effect.catchAll(errorResponse)) +export const federationActorDocumentResponse = () => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonLdResponse(makeFederationActorDocument(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const upgrade = readHeader(request, "upgrade")?.toLowerCase() @@ -859,18 +866,14 @@ export const makeRouter = () => { ), HttpRouter.get( "/federation/actor", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - const context = yield* _(resolveFederationContext(request)) - return yield* _(activityJsonResponse(makeFederationActorDocument(context), 200)) - }).pipe(Effect.catchAll(errorResponse)) + federationActorDocumentResponse() ), HttpRouter.get( "/federation/outbox", Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _(activityJsonResponse(makeFederationOutboxCollection(context), 200)) + return yield* _(jsonLdResponse(makeFederationOutboxCollection(context), 200)) }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( @@ -878,7 +881,7 @@ export const makeRouter = () => { Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _(activityJsonResponse(makeFederationFollowersCollection(context), 200)) + return yield* _(jsonLdResponse(makeFederationFollowersCollection(context), 200)) }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( @@ -886,7 +889,7 @@ export const makeRouter = () => { Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _(activityJsonResponse(makeFederationFollowingCollection(context), 200)) + return yield* _(jsonLdResponse(makeFederationFollowingCollection(context), 200)) }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( @@ -894,7 +897,7 @@ export const makeRouter = () => { Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const context = yield* _(resolveFederationContext(request)) - return yield* _(activityJsonResponse(makeFederationLikedCollection(context), 200)) + return yield* _(jsonLdResponse(makeFederationLikedCollection(context), 200)) }).pipe(Effect.catchAll(errorResponse)) ), HttpRouter.get( diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index 2449a307..12f424d0 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -33,6 +33,13 @@ import type { ForgeFedTicketSource, ProjectDetails } from "../api/contracts.js" +import { + activityForgeFedJsonLdContext, + activityStreamsJsonLdContext, + actorJsonLdContext, + federationJsonLdContentType, + forgeFedJsonLdContext +} from "../api/contracts.js" import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/errors.js" import { getAgent, readAgentLogs, startAgent } from "./agents.js" import { createProjectFromRequest } from "./projects.js" @@ -74,6 +81,7 @@ type IngestOptions = { readonly scheduleTask?: boolean | undefined readonly context?: FederationContext | undefined readonly subscription?: FollowSubscription | undefined + readonly inheritedJsonLdContext?: unknown } export type FederationContextInput = { @@ -96,8 +104,7 @@ export type FederationContext = { const defaultActorUsername = "docker-git" const activityJsonContentType = "application/activity+json" -const jsonLdContentType = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" -const activityAcceptHeader = `${jsonLdContentType}, ${activityJsonContentType}, application/json` +const activityAcceptHeader = `${federationJsonLdContentType}, ${activityJsonContentType}, application/json` const defaultExchangeQueue = "code" const stateVersion = 1 as const const exchangeEventLimit = 100 @@ -119,6 +126,49 @@ const isRecord = (value: unknown): value is JsonRecord => const asRecord = (value: unknown): JsonRecord | null => isRecord(value) ? value : null +const jsonLdContextValues = (value: unknown): ReadonlySet => { + if (typeof value === "string") { + return new Set([value]) + } + if (!Array.isArray(value)) { + return new Set() + } + return new Set(value.filter((item): item is string => typeof item === "string")) +} + +const hasFederationJsonLdContext = (value: unknown): boolean => { + const contexts = jsonLdContextValues(value) + return contexts.has(activityStreamsJsonLdContext) && contexts.has(forgeFedJsonLdContext) +} + +const requireFederationJsonLdContext = ( + payload: JsonRecord, + label: string, + inheritedContext?: unknown +): Effect.Effect => { + const context = payload["@context"] + const valid = context === undefined + ? hasFederationJsonLdContext(inheritedContext) + : hasFederationJsonLdContext(context) + + return valid + ? Effect.void + : Effect.fail( + new ApiBadRequestError({ + message: `${label} must include JSON-LD @context with ActivityStreams and ForgeFed contexts.` + }) + ) +} + +const requireNestedFederationJsonLdContext = ( + payload: JsonRecord, + label: string, + inheritedContext: unknown +): Effect.Effect => + payload["@context"] === undefined + ? Effect.void + : requireFederationJsonLdContext(payload, label, inheritedContext) + const asNonEmptyString = (value: unknown): string | null => typeof value === "string" && value.trim().length > 0 ? value.trim() : null @@ -491,7 +541,7 @@ const publicKeyForContext = (context: FederationContext): ActivityPubPublicKey = export const makeFederationActorDocument = ( context: FederationContext ): ActivityPubPerson => ({ - "@context": "https://www.w3.org/ns/activitystreams", + "@context": actorJsonLdContext, type: "Person", id: context.actorId, name: "docker-git task feed", @@ -513,7 +563,7 @@ export const makeFederationOutboxCollection = ( ): ActivityPubOrderedCollection => { const orderedItems = listFollowSubscriptions().map((subscription) => subscription.activity) return { - "@context": "https://www.w3.org/ns/activitystreams", + "@context": activityForgeFedJsonLdContext, type: "OrderedCollection", id: context.outbox, totalItems: orderedItems.length, @@ -524,7 +574,7 @@ export const makeFederationOutboxCollection = ( export const makeFederationFollowersCollection = ( context: FederationContext ): ActivityPubOrderedCollection => ({ - "@context": "https://www.w3.org/ns/activitystreams", + "@context": activityForgeFedJsonLdContext, type: "OrderedCollection", id: context.followers, totalItems: 0, @@ -539,7 +589,7 @@ export const makeFederationFollowingCollection = ( .map((subscription) => subscription.object) return { - "@context": "https://www.w3.org/ns/activitystreams", + "@context": activityForgeFedJsonLdContext, type: "OrderedCollection", id: context.following, totalItems: orderedItems.length, @@ -550,7 +600,7 @@ export const makeFederationFollowingCollection = ( export const makeFederationLikedCollection = ( context: FederationContext ): ActivityPubOrderedCollection => ({ - "@context": "https://www.w3.org/ns/activitystreams", + "@context": activityForgeFedJsonLdContext, type: "OrderedCollection", id: context.liked, totalItems: 0, @@ -577,9 +627,11 @@ const readTicketAttachment = (payload: JsonRecord): ReadonlyArray | und } const parseTicket = ( - payload: JsonRecord + payload: JsonRecord, + inheritedJsonLdContext: unknown ): Effect.Effect => Effect.gen(function*(_) { + yield* _(requireNestedFederationJsonLdContext(payload, "ForgeFed ticket", inheritedJsonLdContext)) if (!hasType(payload, "Ticket")) { return yield* _( Effect.fail( @@ -701,10 +753,12 @@ const resolveFollowFromInbox = ( }) const ingestOfferTicket = ( - payload: JsonRecord + payload: JsonRecord, + inheritedJsonLdContext: unknown ): Effect.Effect => Effect.gen(function*(_) { const objectPayload = yield* _(readObjectRecord(payload, "object", "ForgeFed offer")) + yield* _(requireNestedFederationJsonLdContext(objectPayload, "ForgeFed offer Ticket", inheritedJsonLdContext)) if (!hasType(objectPayload, "Ticket")) { return yield* _( Effect.fail( @@ -715,7 +769,7 @@ const ingestOfferTicket = ( ) } - const ticket = yield* _(parseTicket(objectPayload)) + const ticket = yield* _(parseTicket(objectPayload, inheritedJsonLdContext)) return upsertIssue({ issueId: ticket.id, offerId: readOptionalString(payload, "id"), @@ -730,9 +784,10 @@ const ingestOfferTicket = ( }) const ingestDirectTicket = ( - payload: JsonRecord + payload: JsonRecord, + inheritedJsonLdContext: unknown ): Effect.Effect => - Effect.map(parseTicket(payload), (ticket) => + Effect.map(parseTicket(payload, inheritedJsonLdContext), (ticket) => upsertIssue({ issueId: ticket.id, status: "accepted", @@ -743,10 +798,12 @@ const ingestDirectTicket = ( const ingestCreateTicket = ( payload: JsonRecord, - options: IngestOptions + options: IngestOptions, + inheritedJsonLdContext: unknown ): Effect.Effect => Effect.gen(function*(_) { const objectPayload = yield* _(readObjectRecord(payload, "object", "ActivityPub Create")) + yield* _(requireNestedFederationJsonLdContext(objectPayload, "ActivityPub Create Ticket", inheritedJsonLdContext)) if (!hasType(objectPayload, "Ticket")) { return yield* _( Effect.fail( @@ -757,7 +814,7 @@ const ingestCreateTicket = ( ) } - const ticket = yield* _(parseTicket(objectPayload)) + const ticket = yield* _(parseTicket(objectPayload, inheritedJsonLdContext)) const subscription = options.subscription const issue = upsertIssue({ issueId: ticket.id, @@ -817,20 +874,23 @@ export const ingestFederationInbox = ( ) } + yield* _(requireFederationJsonLdContext(record, "Federation inbox payload", options.inheritedJsonLdContext)) + const inheritedJsonLdContext = record["@context"] ?? options.inheritedJsonLdContext + if (hasType(record, "Offer")) { - const issue = yield* _(ingestOfferTicket(record)) + const issue = yield* _(ingestOfferTicket(record, inheritedJsonLdContext)) recordIssueReceivedEvent(issue, options) return { kind: "issue.offer", issue } } if (hasType(record, "Create")) { - const issue = yield* _(ingestCreateTicket(record, options)) + const issue = yield* _(ingestCreateTicket(record, options, inheritedJsonLdContext)) recordIssueReceivedEvent(issue, options) return { kind: "issue.create", issue } } if (hasType(record, "Ticket")) { - const issue = yield* _(ingestDirectTicket(record)) + const issue = yield* _(ingestDirectTicket(record, inheritedJsonLdContext)) recordIssueReceivedEvent(issue, options) return { kind: "issue.ticket", issue } } @@ -885,7 +945,7 @@ const signRequestHeaders = ( return { accept: activityAcceptHeader, - "content-type": activityJsonContentType, + "content-type": federationJsonLdContentType, date, digest, signature: [ @@ -972,10 +1032,7 @@ export const createFollowSubscription = ( const createdAt = nowIso() const activity: ActivityPubFollowActivity = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://forgefed.org/ns" - ], + "@context": activityForgeFedJsonLdContext, id: activityId, type: "Follow", actor, @@ -1131,9 +1188,11 @@ const fetchJson = ( const parseRemoteActorDocument = ( payload: JsonRecord, - fallbackActor: string + fallbackActor: string, + inheritedJsonLdContext: unknown ): Effect.Effect => Effect.gen(function*(_) { + yield* _(requireFederationJsonLdContext(payload, "Exchange actor", inheritedJsonLdContext)) const id = readOptionalString(payload, "id") ?? fallbackActor const outbox = readOptionalString(payload, "outbox") if (outbox === undefined) { @@ -1182,21 +1241,33 @@ const parseExchangeActorPayload = ( target: ExchangeTarget, candidateActor: string, payload: JsonRecord -): Effect.Effect => { - const collectionItems = collectionActorItems(payload) - const outbox = readOptionalString(payload, "outbox") - if (outbox === undefined && collectionItems.length > 0) { - const selected = collectionItems.find((item) => actorItemMatchesQueue(item, target.queue)) - return selected === undefined - ? Effect.fail( - new ApiBadRequestError({ - message: `Exchange actor collection did not include queue "${target.queue}".` - }) +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(requireFederationJsonLdContext(payload, "Exchange actor")) + const inheritedJsonLdContext = payload["@context"] + const collectionItems = collectionActorItems(payload) + const outbox = readOptionalString(payload, "outbox") + if (outbox === undefined && collectionItems.length > 0) { + const selected = collectionItems.find((item) => actorItemMatchesQueue(item, target.queue)) + if (selected === undefined) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: `Exchange actor collection did not include queue "${target.queue}".` + }) + ) + ) + } + return yield* _( + parseRemoteActorDocument( + selected, + readOptionalString(selected, "id") ?? target.remoteActor, + inheritedJsonLdContext + ) ) - : parseRemoteActorDocument(selected, readOptionalString(selected, "id") ?? target.remoteActor) - } - return parseRemoteActorDocument(payload, candidateActor) -} + } + return yield* _(parseRemoteActorDocument(payload, candidateActor, inheritedJsonLdContext)) + }) const fetchExchangeActorDocument = ( target: ExchangeTarget @@ -1232,10 +1303,7 @@ const buildFollowActivity = ( to: ReadonlyArray, capability: string | undefined ): ActivityPubFollowActivity => ({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://forgefed.org/ns" - ], + "@context": activityForgeFedJsonLdContext, id: `${context.followsActivityPrefix}/${randomUUID()}`, type: "Follow", actor, @@ -1424,15 +1492,17 @@ const fetchOutbox = ( url: string ): Effect.Effect => fetchJson(url, "Exchange outbox").pipe( - Effect.map((record) => ({ - "@context": Array.isArray(record["@context"]) - ? record["@context"].filter((item): item is string => typeof item === "string") - : "https://www.w3.org/ns/activitystreams", - type: "OrderedCollection" as const, - id: readOptionalString(record, "id") ?? url, - totalItems: typeof record["totalItems"] === "number" ? record["totalItems"] : 0, - orderedItems: Array.isArray(record["orderedItems"]) ? record["orderedItems"] : [] - })) + Effect.flatMap((record) => + requireFederationJsonLdContext(record, "Exchange outbox").pipe( + Effect.as({ + "@context": activityForgeFedJsonLdContext, + type: "OrderedCollection" as const, + id: readOptionalString(record, "id") ?? url, + totalItems: typeof record["totalItems"] === "number" ? record["totalItems"] : 0, + orderedItems: Array.isArray(record["orderedItems"]) ? record["orderedItems"] : [] + }) + ) + ) ) const matchesPollRequest = (subscription: FollowSubscription, request: ExchangePollRequest): boolean => { @@ -1478,7 +1548,8 @@ export const pollExchangeOutboxes = ( ingestFederationInbox(item, { scheduleTask: true, context, - subscription + subscription, + inheritedJsonLdContext: collection["@context"] }).pipe(Effect.either) ) processedOutboxItems.add(itemId) @@ -1629,10 +1700,7 @@ const buildIssueUpdateActivity = ( status: FederationIssueRecord["status"], message: string ) => ({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://forgefed.org/ns" - ], + "@context": activityForgeFedJsonLdContext, id: `${context.exchangeActivityPrefix}/${issueSlug(issue)}/${status}/${randomUUID()}`, type: "Update", actor: context.actorId, diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts index 3d009a59..bcd561e4 100644 --- a/packages/api/tests/federation.test.ts +++ b/packages/api/tests/federation.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { vi } from "vitest" +import { + activityForgeFedJsonLdContext, + actorJsonLdContext, + federationJsonLdContentType +} from "../src/api/contracts.js" import { clearFederationState, createFollowSubscription, @@ -76,11 +81,13 @@ describe("federation service", () => { expect(created.subscription.status).toBe("pending") expect(created.activity.type).toBe("Follow") + expect(created.activity["@context"]).toEqual(activityForgeFedJsonLdContext) expect(created.activity.id).toContain("https://social.provercoder.ai/federation/activities/follows/") expect(created.activity.actor).toBe("https://social.provercoder.ai/federation/actor") const accepted = yield* _( ingestFederationInbox({ + "@context": activityForgeFedJsonLdContext, type: "Accept", actor: "https://tracker.example/system", object: created.activity.id @@ -136,6 +143,7 @@ describe("federation service", () => { const person = makeFederationActorDocument(context) expect(person.type).toBe("Person") + expect(person["@context"]).toEqual(actorJsonLdContext) expect(person.id).toBe("https://social.provercoder.ai/federation/actor") expect(person.preferredUsername).toBe("tasks") expect(person.followers).toBe("https://social.provercoder.ai/federation/followers") @@ -151,6 +159,7 @@ describe("federation service", () => { yield* _( ingestFederationInbox({ + "@context": activityForgeFedJsonLdContext, type: "Accept", object: created.activity.id }) @@ -199,10 +208,6 @@ describe("federation service", () => { type: "Create", actor: "https://exchange.lefine.pro/actor/code", object: { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://forgefed.org/ns" - ], type: "Ticket", id: "https://exchange.lefine.pro/orders/111", attributedTo: "https://exchange.lefine.pro/actor/code", @@ -229,6 +234,45 @@ describe("federation service", () => { } })) + it.effect("rejects federation inbox payloads without JSON-LD ForgeFed context", () => + Effect.gen(function*(_) { + clearFederationState() + + const missingContext = yield* _( + ingestFederationInbox({ + id: "https://tracker.example/offers/42", + type: "Offer", + object: { + type: "Ticket", + id: "https://tracker.example/issues/42", + attributedTo: "https://origin.example/users/alice", + summary: "Need context", + content: "Missing JSON-LD context." + } + }).pipe(Effect.flip) + ) + + const missingForgeFed = yield* _( + ingestFederationInbox({ + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://tracker.example/offers/43", + type: "Offer", + object: { + type: "Ticket", + id: "https://tracker.example/issues/43", + attributedTo: "https://origin.example/users/alice", + summary: "Need ForgeFed context", + content: "Missing ForgeFed JSON-LD context." + } + }).pipe(Effect.flip) + ) + + expect(missingContext._tag).toBe("ApiBadRequestError") + expect(missingContext.message).toContain("JSON-LD @context") + expect(missingForgeFed._tag).toBe("ApiBadRequestError") + expect(missingForgeFed.message).toContain("ForgeFed") + })) + it.effect("discovers exchange root target and deduplicates polled Create tasks", () => Effect.gen(function*(_) { clearFederationState() @@ -303,14 +347,21 @@ describe("federation service", () => { const created = yield* _(ensureExchangeSubscription({ target: "https://exchange.lefine.pro" }, context)) expect(created.subscription.remoteOutbox).toBe("https://exchange.lefine.pro/outbox/code") expect(created.subscription.queue).toBe("code") + expect(created.activity["@context"]).toEqual(activityForgeFedJsonLdContext) expect(listExchangeSubscriptions()).toHaveLength(1) + const followPost = fetchMock.mock.calls.find(([, init]) => init?.method === "POST") + expect(followPost?.[1]?.headers).toMatchObject({ + "content-type": federationJsonLdContentType + }) + const pendingStatus = makeFederationExchangeStatus(context) expect(pendingStatus.summary.pending).toBe(1) expect(pendingStatus.recentEvents.map((event) => event.kind)).toContain("follow.sent") yield* _( ingestFederationInbox({ + "@context": activityForgeFedJsonLdContext, type: "Accept", actor: "https://exchange.lefine.pro/actor/code", object: created.activity.id @@ -364,6 +415,7 @@ describe("federation service", () => { for (let index = 0; index < 105; index += 1) { yield* _( ingestFederationInbox({ + "@context": activityForgeFedJsonLdContext, type: "Ticket", id: `https://tracker.example/issues/${index}`, attributedTo: "https://origin.example/users/alice", diff --git a/packages/api/tests/http-config.test.ts b/packages/api/tests/http-config.test.ts index 4835362a..23d9d404 100644 --- a/packages/api/tests/http-config.test.ts +++ b/packages/api/tests/http-config.test.ts @@ -5,6 +5,11 @@ import { Effect } from "effect" import fc from "fast-check" import { + actorJsonLdContext, + federationJsonLdResponseContentType +} from "../src/api/contracts.js" +import { + federationActorDocumentResponse, federationExchangeStatusResponse, resolveConfiguredFederationPublicOrigin } from "../src/http.js" @@ -43,6 +48,16 @@ const federationStatusHandler = HttpApp.toWebHandler( ) ) +const federationDocumentHandler = HttpApp.toWebHandler( + Effect.flatten( + HttpRouter.toHttpApp( + HttpRouter.empty.pipe( + HttpRouter.get("/federation/actor", federationActorDocumentResponse()) + ) + ) + ) +) + const readFederationStatusRoute = (path: string) => Effect.gen(function*(_) { const response = yield* _( @@ -68,6 +83,31 @@ const readFederationStatusRoute = (path: string) => return { body, status: response.status } }) +const readFederationDocumentRoute = (path: string) => + Effect.gen(function*(_) { + const response = yield* _( + Effect.tryPromise({ + try: () => + federationDocumentHandler( + new Request(`http://127.0.0.1${path}`, { + headers: { + "x-forwarded-host": "public.example.test", + "x-forwarded-proto": "https" + } + }) + ), + catch: (cause) => new Error(String(cause)) + }) + ) + const body = yield* _( + Effect.tryPromise({ + try: () => response.text(), + catch: (cause) => new Error(String(cause)) + }) + ) + return { body, contentType: response.headers.get("content-type"), status: response.status } + }) + const parseJsonObject = (raw: string): object | null => { const parsed: unknown = JSON.parse(raw) return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) @@ -139,4 +179,17 @@ describe("api http config", () => { expect(Array.isArray(readField(payload, "subscriptions"))).toBe(true) expect(Array.isArray(readField(payload, "recentEvents"))).toBe(true) })) + + it.effect("serves federation actor documents as JSON-LD", () => + Effect.gen(function*(_) { + yield* _(Effect.sync(() => clearFederationState())) + + const actor = yield* _(readFederationDocumentRoute("/federation/actor")) + const payload = parseJsonObject(actor.body) + + expect(actor.status).toBe(200) + expect(actor.contentType).toBe(federationJsonLdResponseContentType) + expect(readField(payload, "@context")).toEqual(actorJsonLdContext) + expect(readField(payload, "id")).toBe("https://public.example.test/federation/actor") + })) }) From 52715605da948c8d49a2de170cd8656d5ddbf9fb Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 13 May 2026 16:51:32 +0000 Subject: [PATCH 2/5] test(api): cover federation jsonld document routes --- packages/api/src/http.ts | 52 +++++++++++------- packages/api/src/services/federation.ts | 9 ++-- packages/api/tests/http-config.test.ts | 72 +++++++++++++++++++++---- 3 files changed, 97 insertions(+), 36 deletions(-) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 301f6db9..8a52a74d 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -602,6 +602,34 @@ export const federationActorDocumentResponse = () => return yield* _(jsonLdResponse(makeFederationActorDocument(context), 200)) }).pipe(Effect.catchAll(errorResponse)) +export const federationOutboxDocumentResponse = () => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonLdResponse(makeFederationOutboxCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + +export const federationFollowersDocumentResponse = () => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonLdResponse(makeFederationFollowersCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + +export const federationFollowingDocumentResponse = () => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonLdResponse(makeFederationFollowingCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + +export const federationLikedDocumentResponse = () => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonLdResponse(makeFederationLikedCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) const upgrade = readHeader(request, "upgrade")?.toLowerCase() @@ -870,35 +898,19 @@ export const makeRouter = () => { ), HttpRouter.get( "/federation/outbox", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - const context = yield* _(resolveFederationContext(request)) - return yield* _(jsonLdResponse(makeFederationOutboxCollection(context), 200)) - }).pipe(Effect.catchAll(errorResponse)) + federationOutboxDocumentResponse() ), HttpRouter.get( "/federation/followers", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - const context = yield* _(resolveFederationContext(request)) - return yield* _(jsonLdResponse(makeFederationFollowersCollection(context), 200)) - }).pipe(Effect.catchAll(errorResponse)) + federationFollowersDocumentResponse() ), HttpRouter.get( "/federation/following", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - const context = yield* _(resolveFederationContext(request)) - return yield* _(jsonLdResponse(makeFederationFollowingCollection(context), 200)) - }).pipe(Effect.catchAll(errorResponse)) + federationFollowingDocumentResponse() ), HttpRouter.get( "/federation/liked", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - const context = yield* _(resolveFederationContext(request)) - return yield* _(jsonLdResponse(makeFederationLikedCollection(context), 200)) - }).pipe(Effect.catchAll(errorResponse)) + federationLikedDocumentResponse() ), HttpRouter.get( "/federation/status", diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index 12f424d0..1d025ca5 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -106,7 +106,6 @@ const defaultActorUsername = "docker-git" const activityJsonContentType = "application/activity+json" const activityAcceptHeader = `${federationJsonLdContentType}, ${activityJsonContentType}, application/json` const defaultExchangeQueue = "code" -const stateVersion = 1 as const const exchangeEventLimit = 100 const issueStore: Map = new Map() @@ -380,7 +379,7 @@ const normalizeHttpUrl = ( }) const serializeState = (): StoredFederationState => ({ - version: stateVersion, + version: 1, issues: [...issueStore.values()], follows: [...followStore.values()], processedOutboxItems: [...processedOutboxItems], @@ -1494,13 +1493,13 @@ const fetchOutbox = ( fetchJson(url, "Exchange outbox").pipe( Effect.flatMap((record) => requireFederationJsonLdContext(record, "Exchange outbox").pipe( - Effect.as({ + Effect.map((): ActivityPubOrderedCollection => ({ "@context": activityForgeFedJsonLdContext, - type: "OrderedCollection" as const, + type: "OrderedCollection", id: readOptionalString(record, "id") ?? url, totalItems: typeof record["totalItems"] === "number" ? record["totalItems"] : 0, orderedItems: Array.isArray(record["orderedItems"]) ? record["orderedItems"] : [] - }) + })) ) ) ) diff --git a/packages/api/tests/http-config.test.ts b/packages/api/tests/http-config.test.ts index 23d9d404..3e675bb4 100644 --- a/packages/api/tests/http-config.test.ts +++ b/packages/api/tests/http-config.test.ts @@ -5,12 +5,17 @@ import { Effect } from "effect" import fc from "fast-check" import { + activityForgeFedJsonLdContext, actorJsonLdContext, federationJsonLdResponseContentType } from "../src/api/contracts.js" import { federationActorDocumentResponse, federationExchangeStatusResponse, + federationFollowersDocumentResponse, + federationFollowingDocumentResponse, + federationLikedDocumentResponse, + federationOutboxDocumentResponse, resolveConfiguredFederationPublicOrigin } from "../src/http.js" import { clearFederationState } from "../src/services/federation.js" @@ -52,7 +57,11 @@ const federationDocumentHandler = HttpApp.toWebHandler( Effect.flatten( HttpRouter.toHttpApp( HttpRouter.empty.pipe( - HttpRouter.get("/federation/actor", federationActorDocumentResponse()) + HttpRouter.get("/federation/actor", federationActorDocumentResponse()), + HttpRouter.get("/federation/outbox", federationOutboxDocumentResponse()), + HttpRouter.get("/federation/followers", federationFollowersDocumentResponse()), + HttpRouter.get("/federation/following", federationFollowingDocumentResponse()), + HttpRouter.get("/federation/liked", federationLikedDocumentResponse()) ) ) ) @@ -118,6 +127,44 @@ const parseJsonObject = (raw: string): object | null => { const readField = (value: object | null, key: string): unknown => value === null ? undefined : Reflect.get(value, key) +const federationDocumentCases: ReadonlyArray<{ + readonly path: string + readonly expectedContext: unknown + readonly expectedId: string + readonly expectedType: string +}> = [ + { + path: "/federation/actor", + expectedContext: actorJsonLdContext, + expectedId: "https://public.example.test/federation/actor", + expectedType: "Person" + }, + { + path: "/federation/outbox", + expectedContext: activityForgeFedJsonLdContext, + expectedId: "https://public.example.test/federation/outbox", + expectedType: "OrderedCollection" + }, + { + path: "/federation/followers", + expectedContext: activityForgeFedJsonLdContext, + expectedId: "https://public.example.test/federation/followers", + expectedType: "OrderedCollection" + }, + { + path: "/federation/following", + expectedContext: activityForgeFedJsonLdContext, + expectedId: "https://public.example.test/federation/following", + expectedType: "OrderedCollection" + }, + { + path: "/federation/liked", + expectedContext: activityForgeFedJsonLdContext, + expectedId: "https://public.example.test/federation/liked", + expectedType: "OrderedCollection" + } +] + describe("api http config", () => { it.effect("ignores empty federation public origin values", () => Effect.sync(() => { @@ -180,16 +227,19 @@ describe("api http config", () => { expect(Array.isArray(readField(payload, "recentEvents"))).toBe(true) })) - it.effect("serves federation actor documents as JSON-LD", () => - Effect.gen(function*(_) { - yield* _(Effect.sync(() => clearFederationState())) + for (const documentCase of federationDocumentCases) { + it.effect(`serves ${documentCase.path} as JSON-LD`, () => + Effect.gen(function*(_) { + yield* _(Effect.sync(() => clearFederationState())) - const actor = yield* _(readFederationDocumentRoute("/federation/actor")) - const payload = parseJsonObject(actor.body) + const document = yield* _(readFederationDocumentRoute(documentCase.path)) + const payload = parseJsonObject(document.body) - expect(actor.status).toBe(200) - expect(actor.contentType).toBe(federationJsonLdResponseContentType) - expect(readField(payload, "@context")).toEqual(actorJsonLdContext) - expect(readField(payload, "id")).toBe("https://public.example.test/federation/actor") - })) + expect(document.status).toBe(200) + expect(document.contentType).toBe(federationJsonLdResponseContentType) + expect(readField(payload, "@context")).toEqual(documentCase.expectedContext) + expect(readField(payload, "type")).toBe(documentCase.expectedType) + expect(readField(payload, "id")).toBe(documentCase.expectedId) + })) + } }) From be85356e91c4bf108362a26f5ad3a694fc0878bb Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 13 May 2026 19:53:55 +0000 Subject: [PATCH 3/5] docs(api): document federation jsonld helpers --- packages/api/src/http.ts | 80 +++++++++++++++++++++++++ packages/api/src/services/federation.ts | 60 +++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 8a52a74d..bdd21bc5 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -275,6 +275,21 @@ const binaryResponse = (data: Uint8Array, contentType: string, status = 200) => ) ) +/** + * Serializes a federation JSON-LD document with the ForgeFed response content type. + * + * @param data - JSON-LD payload that satisfies the JSON.stringify serializability precondition. + * @param status - HTTP status code assigned to the response. + * @returns Effect that yields an HTTP text response containing the serialized JSON-LD document. + * + * @pure false + * @effect Delegates response allocation to textResponse and preserves no-store HTTP headers. + * @invariant successful responses always use federationJsonLdResponseContentType. + * @precondition data is JSON.stringify-serializable and status is a valid HTTP status code. + * @postcondition response body equals JSON.stringify(data) and response status equals status. + * @complexity O(n) time and O(n) space where n is the serialized JSON-LD payload size. + * @throws TypeError when data violates the JSON.stringify serializability precondition. + */ const jsonLdResponse = (data: unknown, status: number) => textResponse(JSON.stringify(data), federationJsonLdResponseContentType, status) @@ -595,6 +610,19 @@ export const federationExchangeStatusResponse = () => return yield* _(jsonResponse(makeFederationExchangeStatus(context), 200)) }).pipe(Effect.catchAll(errorResponse)) +/** + * Builds the federation actor JSON-LD HTTP handler. + * + * @returns Effect that yields the local ActivityPub actor document response. + * + * @pure false + * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationActorDocument, serializes with jsonLdResponse, and maps failures through errorResponse. + * @invariant successful responses contain the actor id derived from the resolved federation context. + * @precondition request headers or configured env provide a non-empty public origin. + * @postcondition successful responses contain a JSON-LD Person document with HTTP 200. + * @complexity O(1) time and O(1) space for document construction, excluding serialization size. + * @throws Never; failures are represented through the Effect error channel and converted by errorResponse. + */ export const federationActorDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) @@ -602,6 +630,19 @@ export const federationActorDocumentResponse = () => return yield* _(jsonLdResponse(makeFederationActorDocument(context), 200)) }).pipe(Effect.catchAll(errorResponse)) +/** + * Builds the federation outbox JSON-LD HTTP handler. + * + * @returns Effect that yields the local ActivityPub outbox collection response. + * + * @pure false + * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationOutboxCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @invariant successful responses contain the outbox id derived from the resolved federation context. + * @precondition request headers or configured env provide a non-empty public origin. + * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. + * @complexity O(1) time and O(1) space for document construction, excluding serialization size. + * @throws Never; failures are represented through the Effect error channel and converted by errorResponse. + */ export const federationOutboxDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) @@ -609,6 +650,19 @@ export const federationOutboxDocumentResponse = () => return yield* _(jsonLdResponse(makeFederationOutboxCollection(context), 200)) }).pipe(Effect.catchAll(errorResponse)) +/** + * Builds the federation followers JSON-LD HTTP handler. + * + * @returns Effect that yields the local ActivityPub followers collection response. + * + * @pure false + * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationFollowersCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @invariant successful responses contain the followers id derived from the resolved federation context. + * @precondition request headers or configured env provide a non-empty public origin. + * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. + * @complexity O(1) time and O(1) space for document construction, excluding serialization size. + * @throws Never; failures are represented through the Effect error channel and converted by errorResponse. + */ export const federationFollowersDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) @@ -616,6 +670,19 @@ export const federationFollowersDocumentResponse = () => return yield* _(jsonLdResponse(makeFederationFollowersCollection(context), 200)) }).pipe(Effect.catchAll(errorResponse)) +/** + * Builds the federation following JSON-LD HTTP handler. + * + * @returns Effect that yields the local ActivityPub following collection response. + * + * @pure false + * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationFollowingCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @invariant successful responses contain the following id derived from the resolved federation context. + * @precondition request headers or configured env provide a non-empty public origin. + * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. + * @complexity O(1) time and O(1) space for document construction, excluding serialization size. + * @throws Never; failures are represented through the Effect error channel and converted by errorResponse. + */ export const federationFollowingDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) @@ -623,6 +690,19 @@ export const federationFollowingDocumentResponse = () => return yield* _(jsonLdResponse(makeFederationFollowingCollection(context), 200)) }).pipe(Effect.catchAll(errorResponse)) +/** + * Builds the federation liked JSON-LD HTTP handler. + * + * @returns Effect that yields the local ActivityPub liked collection response. + * + * @pure false + * @effect Reads HttpServerRequest, resolves federation context, renders makeFederationLikedCollection, serializes with jsonLdResponse, and maps failures through errorResponse. + * @invariant successful responses contain the liked collection id derived from the resolved federation context. + * @precondition request headers or configured env provide a non-empty public origin. + * @postcondition successful responses contain a JSON-LD OrderedCollection document with HTTP 200. + * @complexity O(1) time and O(1) space for document construction, excluding serialization size. + * @throws Never; failures are represented through the Effect error channel and converted by errorResponse. + */ export const federationLikedDocumentResponse = () => Effect.gen(function*(_) { const request = yield* _(HttpServerRequest.HttpServerRequest) diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index 1d025ca5..a43f7780 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -125,6 +125,20 @@ const isRecord = (value: unknown): value is JsonRecord => const asRecord = (value: unknown): JsonRecord | null => isRecord(value) ? value : null +/** + * Extracts string JSON-LD context entries from a boundary value. + * + * @param value - Unknown ActivityPub or ForgeFed @context field value. + * @returns Immutable set of string context identifiers; unsupported shapes produce an empty set. + * + * @pure true + * @effect none + * @invariant every returned element is a string from value, and no non-string value is introduced. + * @precondition value may be any boundary value. + * @postcondition result contains value iff value is a string; result contains each string item iff value is an array. + * @complexity O(n) time and O(n) space where n is array length, or O(1) for non-array values. + * @throws Never. + */ const jsonLdContextValues = (value: unknown): ReadonlySet => { if (typeof value === "string") { return new Set([value]) @@ -135,11 +149,41 @@ const jsonLdContextValues = (value: unknown): ReadonlySet => { return new Set(value.filter((item): item is string => typeof item === "string")) } +/** + * Checks whether a JSON-LD context value contains both required federation contexts. + * + * @param value - Unknown @context value to validate. + * @returns True when ActivityStreams and ForgeFed context identifiers are both present. + * + * @pure true + * @effect none + * @invariant result is true iff jsonLdContextValues(value) contains both required context URIs. + * @precondition value may be any boundary value. + * @postcondition result does not mutate or normalize the input value. + * @complexity O(n) time and O(n) space where n is array length, or O(1) for non-array values. + * @throws Never. + */ const hasFederationJsonLdContext = (value: unknown): boolean => { const contexts = jsonLdContextValues(value) return contexts.has(activityStreamsJsonLdContext) && contexts.has(forgeFedJsonLdContext) } +/** + * Requires a top-level federation document to declare or inherit valid JSON-LD contexts. + * + * @param payload - Parsed JSON object representing the federation document being checked. + * @param label - Human-readable document label used in the typed validation error. + * @param inheritedContext - Optional parent @context accepted when payload omits its own @context. + * @returns Effect that succeeds when ActivityStreams and ForgeFed contexts are available. + * + * @pure false + * @effect Constructs a typed ApiBadRequestError in the Effect error channel when validation fails. + * @invariant success implies payload.@context or inheritedContext contains ActivityStreams and ForgeFed contexts. + * @precondition payload is a decoded JSON object at the federation boundary. + * @postcondition failure message identifies the invalid document label. + * @complexity O(n) time and O(n) space where n is the checked context array length. + * @throws Never; validation failures are represented as ApiBadRequestError. + */ const requireFederationJsonLdContext = ( payload: JsonRecord, label: string, @@ -159,6 +203,22 @@ const requireFederationJsonLdContext = ( ) } +/** + * Requires nested federation objects to use valid JSON-LD context only when they override inheritance. + * + * @param payload - Parsed nested JSON object being checked. + * @param label - Human-readable nested object label used in the typed validation error. + * @param inheritedContext - Parent @context available to nested objects that omit @context. + * @returns Effect that succeeds when the nested object inherits context or declares a valid override. + * + * @pure false + * @effect Delegates invalid explicit context failures to requireFederationJsonLdContext. + * @invariant missing nested @context is accepted, explicit nested @context must contain ActivityStreams and ForgeFed contexts. + * @precondition payload is a decoded nested JSON object and inheritedContext is the parent document context. + * @postcondition success preserves JSON-LD inheritance semantics for nested ForgeFed objects. + * @complexity O(n) time and O(n) space where n is the explicit nested context array length, or O(1) if omitted. + * @throws Never; validation failures are represented as ApiBadRequestError. + */ const requireNestedFederationJsonLdContext = ( payload: JsonRecord, label: string, From e42c7673f0ecbc9d3b97f7a9b9df29b82b2fca07 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 13 May 2026 20:03:49 +0000 Subject: [PATCH 4/5] fix(docker): pin rtk installer version --- packages/app/src/lib/core/templates/dockerfile.ts | 3 ++- packages/lib/src/core/templates/dockerfile.ts | 3 ++- packages/lib/tests/core/templates.test.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index d2adfdaa..a7a22f59 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -94,11 +94,12 @@ RUN gemini --version` // COMPLEXITY: O(1) const renderDockerfileRtk = (): string => `# Tooling: RTK (Rust Token Killer) +ARG RTK_VERSION=v0.39.0 RUN set -eu; \ curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh \ -o /tmp/rtk-install.sh; \ - RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \ + RTK_VERSION="\${RTK_VERSION}" RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \ rm -f /tmp/rtk-install.sh; \ rtk --version; \ rtk gain >/dev/null 2>&1 || true` diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index d2adfdaa..a7a22f59 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -94,11 +94,12 @@ RUN gemini --version` // COMPLEXITY: O(1) const renderDockerfileRtk = (): string => `# Tooling: RTK (Rust Token Killer) +ARG RTK_VERSION=v0.39.0 RUN set -eu; \ curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh \ -o /tmp/rtk-install.sh; \ - RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \ + RTK_VERSION="\${RTK_VERSION}" RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \ rm -f /tmp/rtk-install.sh; \ rtk --version; \ rtk gain >/dev/null 2>&1 || true` diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index e988469f..e72e6a5c 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -66,8 +66,9 @@ describe("renderDockerfile", () => { "glab --version", "ncurses-term jq", "# Tooling: RTK (Rust Token Killer)", + "ARG RTK_VERSION=v0.39.0", "https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh", - "RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh", + 'RTK_VERSION="${RTK_VERSION}" RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh', "rtk --version", "rtk gain >/dev/null 2>&1 || true", 'ARG DOCKER_GIT_SESSION_SYNC_PACKAGE="@prover-coder-ai/docker-git-session-sync@latest"', From 42abc2767b552b4fbb433a74fe9fa95ff60e1e05 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 13 May 2026 20:16:06 +0000 Subject: [PATCH 5/5] fix(docker): pin rtk installer script --- packages/app/src/lib/core/templates/dockerfile.ts | 2 +- packages/lib/src/core/templates/dockerfile.ts | 2 +- packages/lib/tests/core/templates.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index a7a22f59..0071eedd 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -97,7 +97,7 @@ const renderDockerfileRtk = (): string => ARG RTK_VERSION=v0.39.0 RUN set -eu; \ curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh \ + https://raw.githubusercontent.com/rtk-ai/rtk/\${RTK_VERSION}/install.sh \ -o /tmp/rtk-install.sh; \ RTK_VERSION="\${RTK_VERSION}" RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \ rm -f /tmp/rtk-install.sh; \ diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index a7a22f59..0071eedd 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -97,7 +97,7 @@ const renderDockerfileRtk = (): string => ARG RTK_VERSION=v0.39.0 RUN set -eu; \ curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ - https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh \ + https://raw.githubusercontent.com/rtk-ai/rtk/\${RTK_VERSION}/install.sh \ -o /tmp/rtk-install.sh; \ RTK_VERSION="\${RTK_VERSION}" RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh; \ rm -f /tmp/rtk-install.sh; \ diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index e72e6a5c..9dbdeb40 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -67,7 +67,7 @@ describe("renderDockerfile", () => { "ncurses-term jq", "# Tooling: RTK (Rust Token Killer)", "ARG RTK_VERSION=v0.39.0", - "https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh", + 'https://raw.githubusercontent.com/rtk-ai/rtk/${RTK_VERSION}/install.sh', 'RTK_VERSION="${RTK_VERSION}" RTK_INSTALL_DIR=/usr/local/bin sh /tmp/rtk-install.sh', "rtk --version", "rtk gain >/dev/null 2>&1 || true",