Skip to content

Commit b968222

Browse files
authored
fix(api): expose federation status route (#294)
* fix(api): expose federation status route * docs(api): document federation status helpers * test(api): add federation origin property check * test(api): cover federation status aliases
1 parent 495620e commit b968222

8 files changed

Lines changed: 250 additions & 9 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ Optional env:
9696
- `GET /federation/followers`
9797
- `GET /federation/following`
9898
- `GET /federation/liked`
99+
- `GET /federation/status` (connection summary and recent exchange events)
99100
- `GET /federation/exchange/status` (connection summary and recent exchange events)
100101
- `POST /federation/exchange/subscriptions` (discover remote actor, persist metadata, send signed `Follow`)
101102
- `GET /federation/exchange/subscriptions`
@@ -132,12 +133,12 @@ Exchange targets must be explicit. Use `https://exchange.lefine.pro`, an actor U
132133
}'
133134

134135
./ctl request POST /federation/exchange/poll '{}'
135-
./ctl request GET /federation/exchange/status
136+
./ctl request GET /federation/status
136137
./ctl request GET /federation/exchange/subscriptions
137138
./ctl request GET /federation/issues
138139
```
139140

140-
`GET /federation/exchange/status` is the live observability endpoint for a Lefine connection. It reports subscription counts, accepted/pending/rejected state, `lastInboxAt`, `lastPollAt`, persisted issue count, processed outbox items, and recent events such as `follow.sent`, `inbox.follow.accept`, `inbox.issue.received`, and `poll.completed`.
141+
`GET /federation/status` is the live observability endpoint for a Lefine connection. `GET /federation/exchange/status` is kept as a compatibility alias. It reports subscription counts, accepted/pending/rejected state, `lastInboxAt`, `lastPollAt`, persisted issue count, processed outbox items, and recent events such as `follow.sent`, `inbox.follow.accept`, `inbox.issue.received`, and `poll.completed`.
141142

142143
When a polled `Create(Ticket)` has no GitHub URL in the Ticket payload, `projectRepoUrl` or `DOCKER_GIT_EXCHANGE_PROJECT_REPO_URL` is required for the automatic docker-git project/agent run.
143144

packages/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@typescript-eslint/eslint-plugin": "^8.59.3",
4545
"@typescript-eslint/parser": "^8.59.3",
4646
"eslint": "^10.3.0",
47+
"fast-check": "3.23.2",
4748
"globals": "^17.6.0",
4849
"typescript": "^6.0.3",
4950
"vitest": "^4.1.6"

packages/api/src/http.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -479,9 +479,56 @@ const readUpProjectRequest = () =>
479479
)
480480
const readInboxPayload = () => HttpServerRequest.schemaBodyJson(Schema.Unknown)
481481

482+
/**
483+
* Selects the first trimmed, non-empty string from an ordered list.
484+
*
485+
* @param values - Candidate strings in priority order.
486+
* @returns The first trimmed non-empty candidate, or undefined when none exist.
487+
*
488+
* @pure true
489+
* @effect none
490+
* @invariant result === undefined || result.length > 0
491+
* @precondition values is a readonly array of strings or undefined entries.
492+
* @postcondition result is the first trimmed non-empty value in values, otherwise undefined.
493+
* @complexity O(n * m) time where n is values.length and m is average trim cost; O(1) space.
494+
* @throws Never
495+
*/
496+
const firstNonEmptyEnv = (
497+
values: ReadonlyArray<string | undefined>
498+
): string | undefined => {
499+
for (const value of values) {
500+
const trimmed = value?.trim()
501+
if (trimmed !== undefined && trimmed.length > 0) {
502+
return trimmed
503+
}
504+
}
505+
return undefined
506+
}
507+
508+
/**
509+
* Resolves the configured federation public origin from environment variables.
510+
*
511+
* @param env - Environment map containing optional docker-git public origin keys.
512+
* @returns The first non-empty configured public origin, or undefined.
513+
*
514+
* @pure true
515+
* @effect none; delegates deterministic selection to firstNonEmptyEnv.
516+
* @invariant result belongs to trimmed env values for DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN or DOCKER_GIT_API_PUBLIC_URL, or is undefined.
517+
* @precondition env is a Record<string, string | undefined>.
518+
* @postcondition result equals the first non-empty value from federation origin keys in priority order, otherwise undefined.
519+
* @complexity O(k * m) time for k configured keys and average trim cost m; O(1) space.
520+
* @throws Never
521+
*/
522+
export const resolveConfiguredFederationPublicOrigin = (
523+
env: Record<string, string | undefined>
524+
): string | undefined =>
525+
firstNonEmptyEnv([
526+
env["DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN"],
527+
env["DOCKER_GIT_API_PUBLIC_URL"]
528+
])
529+
482530
const configuredFederationPublicOrigin =
483-
process.env["DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN"] ??
484-
process.env["DOCKER_GIT_API_PUBLIC_URL"]
531+
resolveConfiguredFederationPublicOrigin(process.env)
485532

486533
const configuredFederationActorUsername =
487534
process.env["DOCKER_GIT_FEDERATION_ACTOR"] ?? "docker-git"
@@ -530,6 +577,24 @@ const resolveFederationContext = (
530577
})
531578
}
532579

580+
/**
581+
* Builds the federation status HTTP handler shared by public and compatibility routes.
582+
*
583+
* @pure false
584+
* @effect Reads HttpServerRequest, resolves federation context, renders makeFederationExchangeStatus, serializes with jsonResponse, and maps failures through errorResponse.
585+
* @invariant same request context produces the same federation status payload for every route alias.
586+
* @precondition request headers or configured env provide a non-empty public origin.
587+
* @postcondition successful responses contain federation exchange status JSON with HTTP 200.
588+
* @complexity O(s + e log e) time where s is subscription count and e is event count; O(s + e) space.
589+
* @throws Never; failures are represented through the Effect error channel and converted by errorResponse.
590+
*/
591+
export const federationExchangeStatusResponse = () =>
592+
Effect.gen(function*(_) {
593+
const request = yield* _(HttpServerRequest.HttpServerRequest)
594+
const context = yield* _(resolveFederationContext(request))
595+
return yield* _(jsonResponse(makeFederationExchangeStatus(context), 200))
596+
}).pipe(Effect.catchAll(errorResponse))
597+
533598
const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
534599
const request = yield* _(HttpServerRequest.HttpServerRequest)
535600
const upgrade = readHeader(request, "upgrade")?.toLowerCase()
@@ -832,13 +897,13 @@ export const makeRouter = () => {
832897
return yield* _(activityJsonResponse(makeFederationLikedCollection(context), 200))
833898
}).pipe(Effect.catchAll(errorResponse))
834899
),
900+
HttpRouter.get(
901+
"/federation/status",
902+
federationExchangeStatusResponse()
903+
),
835904
HttpRouter.get(
836905
"/federation/exchange/status",
837-
Effect.gen(function*(_) {
838-
const request = yield* _(HttpServerRequest.HttpServerRequest)
839-
const context = yield* _(resolveFederationContext(request))
840-
return yield* _(jsonResponse(makeFederationExchangeStatus(context), 200))
841-
}).pipe(Effect.catchAll(errorResponse))
906+
federationExchangeStatusResponse()
842907
),
843908
HttpRouter.post(
844909
"/federation/exchange/subscriptions",
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import * as HttpApp from "@effect/platform/HttpApp"
2+
import * as HttpRouter from "@effect/platform/HttpRouter"
3+
import { describe, expect, it } from "@effect/vitest"
4+
import { Effect } from "effect"
5+
import fc from "fast-check"
6+
7+
import {
8+
federationExchangeStatusResponse,
9+
resolveConfiguredFederationPublicOrigin
10+
} from "../src/http.js"
11+
import { clearFederationState } from "../src/services/federation.js"
12+
13+
const envValueArbitrary = fc.option(
14+
fc.oneof(
15+
fc.string(),
16+
fc.constant(" "),
17+
fc.constant("\t\n")
18+
),
19+
{ nil: undefined }
20+
)
21+
22+
const expectedConfiguredFederationPublicOrigin = (
23+
federationPublicOrigin: string | undefined,
24+
apiPublicUrl: string | undefined
25+
): string | undefined => {
26+
const federation = federationPublicOrigin?.trim()
27+
if (federation !== undefined && federation.length > 0) {
28+
return federation
29+
}
30+
31+
const api = apiPublicUrl?.trim()
32+
return api !== undefined && api.length > 0 ? api : undefined
33+
}
34+
35+
const federationStatusHandler = HttpApp.toWebHandler(
36+
Effect.flatten(
37+
HttpRouter.toHttpApp(
38+
HttpRouter.empty.pipe(
39+
HttpRouter.get("/federation/status", federationExchangeStatusResponse()),
40+
HttpRouter.get("/federation/exchange/status", federationExchangeStatusResponse())
41+
)
42+
)
43+
)
44+
)
45+
46+
const readFederationStatusRoute = (path: string) =>
47+
Effect.gen(function*(_) {
48+
const response = yield* _(
49+
Effect.tryPromise({
50+
try: () =>
51+
federationStatusHandler(
52+
new Request(`http://127.0.0.1${path}`, {
53+
headers: {
54+
"x-forwarded-host": "public.example.test",
55+
"x-forwarded-proto": "https"
56+
}
57+
})
58+
),
59+
catch: (cause) => new Error(String(cause))
60+
})
61+
)
62+
const body = yield* _(
63+
Effect.tryPromise({
64+
try: () => response.text(),
65+
catch: (cause) => new Error(String(cause))
66+
})
67+
)
68+
return { body, status: response.status }
69+
})
70+
71+
const parseJsonObject = (raw: string): object | null => {
72+
const parsed: unknown = JSON.parse(raw)
73+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
74+
? parsed
75+
: null
76+
}
77+
78+
const readField = (value: object | null, key: string): unknown =>
79+
value === null ? undefined : Reflect.get(value, key)
80+
81+
describe("api http config", () => {
82+
it.effect("ignores empty federation public origin values", () =>
83+
Effect.sync(() => {
84+
expect(
85+
resolveConfiguredFederationPublicOrigin({
86+
DOCKER_GIT_API_PUBLIC_URL: " https://api.example.test ",
87+
DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: " "
88+
})
89+
).toBe("https://api.example.test")
90+
}))
91+
92+
it.effect("prefers explicit federation public origin over api public url", () =>
93+
Effect.sync(() => {
94+
expect(
95+
resolveConfiguredFederationPublicOrigin({
96+
DOCKER_GIT_API_PUBLIC_URL: "https://api.example.test",
97+
DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: "https://federation.example.test"
98+
})
99+
).toBe("https://federation.example.test")
100+
}))
101+
102+
it.effect("satisfies federation origin trim and priority invariant", () =>
103+
Effect.sync(() => {
104+
fc.assert(
105+
fc.property(
106+
envValueArbitrary,
107+
envValueArbitrary,
108+
(federationPublicOrigin, apiPublicUrl) => {
109+
const result = resolveConfiguredFederationPublicOrigin({
110+
DOCKER_GIT_API_PUBLIC_URL: apiPublicUrl,
111+
DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: federationPublicOrigin
112+
})
113+
const expected = expectedConfiguredFederationPublicOrigin(
114+
federationPublicOrigin,
115+
apiPublicUrl
116+
)
117+
118+
expect(result).toBe(expected)
119+
expect(result).toBe(result?.trim())
120+
}
121+
)
122+
)
123+
}))
124+
125+
it.effect("serves equivalent federation status aliases at the HTTP layer", () =>
126+
Effect.gen(function*(_) {
127+
yield* _(Effect.sync(() => clearFederationState()))
128+
129+
const publicStatus = yield* _(readFederationStatusRoute("/federation/status"))
130+
const compatibilityStatus = yield* _(readFederationStatusRoute("/federation/exchange/status"))
131+
const payload = parseJsonObject(publicStatus.body)
132+
133+
expect(publicStatus.status).toBe(200)
134+
expect(compatibilityStatus.status).toBe(200)
135+
expect(compatibilityStatus.body).toBe(publicStatus.body)
136+
expect(payload).not.toBeNull()
137+
expect(readField(payload, "publicActor")).toBe("https://public.example.test/federation/actor")
138+
expect(typeof readField(payload, "summary")).toBe("object")
139+
expect(Array.isArray(readField(payload, "subscriptions"))).toBe(true)
140+
expect(Array.isArray(readField(payload, "recentEvents"))).toBe(true)
141+
}))
142+
})

packages/app/scripts/serve-dist-web.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ const resolveUpstreamPath = (url) => {
123123
return `${pathname}${parsed.search}`
124124
}
125125

126+
const isFederationPath = (pathname) =>
127+
pathname === "/federation" || pathname.startsWith("/federation/")
128+
126129
const firstHeader = (value) => Array.isArray(value) ? value[0] : value
127130

128131
const proxyForwardHeaders = (request, forwardedPrefix) => {
@@ -273,6 +276,7 @@ const server = createServer((request, response) => {
273276
if (
274277
parsed.pathname === "/api" ||
275278
parsed.pathname.startsWith("/api/") ||
279+
isFederationPath(parsed.pathname) ||
276280
parsed.pathname.startsWith("/p/") ||
277281
parsed.pathname.startsWith("/b/") ||
278282
parsed.pathname.startsWith("/d/") ||

packages/app/vite.web.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ const createProxy = (apiTarget: string) => ({
2424
xfwd: true,
2525
ws: false
2626
},
27+
"/federation": {
28+
target: apiTarget,
29+
changeOrigin: false,
30+
xfwd: true,
31+
ws: false
32+
},
2733
"/api": {
2834
target: apiTarget,
2935
changeOrigin: false,

scripts/final-build/browser-web-smoke.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,23 @@ const createApiServer = () =>
7777
}))
7878
return
7979
}
80+
if (request.url === "/federation/status") {
81+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
82+
response.end(JSON.stringify({
83+
publicActor: "https://docker-git.example/federation/actor",
84+
recentEvents: [],
85+
subscriptions: [],
86+
summary: {
87+
accepted: 0,
88+
issues: 0,
89+
pending: 0,
90+
processedOutboxItems: 0,
91+
rejected: 0,
92+
subscriptions: 0
93+
}
94+
}))
95+
return
96+
}
8097
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
8198
response.end("not found")
8299
})
@@ -144,6 +161,10 @@ const main = async () => {
144161
`http://127.0.0.1:${webPort}/api/health`,
145162
({ body, status }) => status === 200 && body.includes("\"ok\":true")
146163
)
164+
await waitForText(
165+
`http://127.0.0.1:${webPort}/federation/status`,
166+
({ body, status }) => status === 200 && body.includes("\"publicActor\"")
167+
)
147168
console.log("browser web smoke passed")
148169
} catch (error) {
149170
console.error(stdout)

0 commit comments

Comments
 (0)