fix(collaboration): configure federation from existing deployment settings

This commit is contained in:
2026-10-02 16:58:57 +02:00
parent d7382e7319
commit 09a002d600
11 changed files with 146 additions and 15 deletions

View File

@@ -0,0 +1,30 @@
import { instanceUrl } from "./transport"
type Settings = {
API_BASE_URL?: string;
ENCRYPTION_KEY?: string;
FEDEO_PUBLIC_URL?: string;
FEDEO_FEDERATION_URL?: string;
FEDEO_FEDERATION_ENABLED?: string;
}
/** Existing deployment settings are sufficient; extra URLs are optional overrides. */
export function collaborationConfig(settings: Settings) {
const configuredApi = settings.API_BASE_URL?.trim() || ""
let apiUrl = (settings.FEDEO_FEDERATION_URL?.trim() || configuredApi).replace(/\/+$/, "")
let publicUrl = settings.FEDEO_PUBLIC_URL?.trim().replace(/\/+$/, "") || ""
if (!publicUrl) {
try { publicUrl = new URL(configuredApi || apiUrl).origin } catch { /* Local development may have no public URL. */ }
}
const mode = settings.FEDEO_FEDERATION_ENABLED?.trim().toLowerCase() || "auto"
if (!["auto", "true", "false"].includes(mode)) throw new Error("FEDEO_FEDERATION_ENABLED muss auto, true oder false sein")
if (mode === "false") return { apiUrl, publicUrl, enabled: false, disabledReason: "Die Föderation wurde vom Betreiber deaktiviert." }
let disabledReason: string | undefined
try { apiUrl = instanceUrl(apiUrl) }
catch { disabledReason = "Föderation benötigt eine gültige öffentliche HTTPS-API_BASE_URL auf Port 443." }
if (!disabledReason && !/^[a-f0-9]{64}$/i.test(settings.ENCRYPTION_KEY || "")) {
disabledReason = "Föderation benötigt einen gültigen ENCRYPTION_KEY mit 64 Hex-Zeichen." }
if (mode === "true" && disabledReason) throw new Error(disabledReason)
return { apiUrl, publicUrl, enabled: !disabledReason, disabledReason }
}

View File

@@ -7,6 +7,7 @@ import { FederationKey, Headers, instanceUrl, safeTransport, signedHeaders, Tran
type Options = {
apiUrl: string; enabled: boolean;
disabledReason?: string;
seal: (value: string) => any; unseal: (value: any) => string;
transport?: Transport;
}
@@ -22,7 +23,7 @@ export class FederationService {
this.transport = options.transport || safeTransport
}
enabled() {
if (!this.options.enabled) fail("Der Betreiber muss die Föderation zuerst aktivieren", 503)
if (!this.options.enabled) fail(this.options.disabledReason || "Der Betreiber muss die Föderation zuerst aktivieren", 503)
instanceUrl(this.origin)
}
async keys() {
@@ -71,7 +72,7 @@ export class FederationService {
VALUES (${ctx.tenantId},${origin},${instanceId},${keyId},${publicKey}) ON CONFLICT(tenant_id,origin) DO UPDATE SET active=true,updated_at=now()`)
}
}
return { enabled: this.options.enabled, origin: this.origin, partners: (await rows(this.service.db,
return { enabled: this.options.enabled, disabledReason: this.options.disabledReason, origin: this.origin, partners: (await rows(this.service.db,
sql`SELECT origin,active,public_key FROM collaboration_partners WHERE tenant_id=${ctx.tenantId} ORDER BY origin`)).map(p => ({ origin: p.origin, active: p.active, fingerprint: hash(p.public_key) })) }
}
async call(tenantId: number, origin: string, action: string, payload: any) {

View File

@@ -4,16 +4,18 @@ import { sendMail } from "../../utils/mailer"
import { CollaborationService } from "./service"
import { FederationService } from "./federation"
import { encrypt, decrypt } from "../../utils/crypt"
import { collaborationConfig } from "./config"
export function collaborationRuntime(server: FastifyInstance) {
const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "")
const publicUrl = String(process.env.FEDEO_PUBLIC_URL || (apiUrl ? new URL(apiUrl).origin : "")).replace(/\/$/, "")
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
...process.env, API_BASE_URL: secrets.API_BASE_URL, ENCRYPTION_KEY: secrets.ENCRYPTION_KEY,
})
const service = new CollaborationService(server.db, {
publicUrl,
guestPepper: () => secrets.ENCRYPTION_KEY || "",
sendMail,
})
const federation = new FederationService(service, { apiUrl, enabled: process.env.FEDEO_FEDERATION_ENABLED === "true", seal: encrypt, unseal: decrypt })
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
service.options.changed = (roomId, db) => federation.changed(roomId, db)
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
let timer: ReturnType<typeof setInterval>

View File

@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import test from "node:test"
import { collaborationConfig } from "../src/modules/collaboration/config"
const deployment = { API_BASE_URL: "https://fedeo.example/backend", ENCRYPTION_KEY: "a".repeat(64) }
test("existing HTTPS deployment enables federation without additional environment values", () => {
const config = collaborationConfig(deployment)
assert.equal(config.enabled, true)
assert.equal(config.apiUrl, deployment.API_BASE_URL)
assert.equal(config.publicUrl, "https://fedeo.example")
assert.equal(config.disabledReason, undefined)
assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "auto", FEDEO_FEDERATION_URL: "", FEDEO_PUBLIC_URL: "" }).enabled, true)
})
test("custom API paths are retained and instance addresses are canonicalized", () => {
const config = collaborationConfig({ ...deployment, API_BASE_URL: "https://FEDEO.example:443/application/api/" })
assert.equal(config.apiUrl, "https://fedeo.example/application/api")
assert.equal(config.publicUrl, "https://fedeo.example")
assert.equal(config.enabled, true)
})
test("explicit opt-out stays disabled even on a correctly configured deployment", () => {
const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "false" })
assert.equal(config.enabled, false)
assert.match(config.disabledReason!, /Betreiber deaktiviert/)
})
test("optional gateway override does not move guest links away from the web application", () => {
const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_URL: "https://gateway.example/federation-api/" })
assert.equal(config.apiUrl, "https://gateway.example/federation-api")
assert.equal(config.publicUrl, "https://fedeo.example")
assert.equal(collaborationConfig({ ...deployment, FEDEO_PUBLIC_URL: "https://portal.example/app/" }).publicUrl, "https://portal.example/app")
})
test("local and incomplete deployments keep local collaboration available without federation", () => {
for (const API_BASE_URL of [undefined, "", "invalid", "http://localhost:3100", "https://fedeo.example:8443/backend", "https://user:password@fedeo.example/backend"]) {
const config = collaborationConfig({ ...deployment, API_BASE_URL })
assert.equal(config.enabled, false, API_BASE_URL)
assert.match(config.disabledReason!, /HTTPS-API_BASE_URL/)
}
for (const ENCRYPTION_KEY of [undefined, "", "invalid", "x".repeat(64)]) {
const config = collaborationConfig({ ...deployment, ENCRYPTION_KEY })
assert.equal(config.enabled, false)
assert.match(config.disabledReason!, /ENCRYPTION_KEY/)
}
})
test("forced activation reports invalid configuration instead of silently ignoring it", () => {
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", API_BASE_URL: "http://localhost" }), /HTTPS/)
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", ENCRYPTION_KEY: "invalid" }), /ENCRYPTION_KEY/)
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "typo" }), /auto, true oder false/)
assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true" }).enabled, true)
})