From f048bd9fb488533d4287dbe378565700c98fcd46 Mon Sep 17 00:00:00 2001 From: JanR Date: Mon, 13 Apr 2026 17:33:40 +0200 Subject: [PATCH 01/12] feat: add F2F_APP mode with challenge-response auth and route remapping --- lib/app_auth.ts | 63 ++++++++++++++++++++ lib/app_routes.ts | 46 +++++++++++++++ lib/server.ts | 147 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 lib/app_auth.ts create mode 100644 lib/app_routes.ts diff --git a/lib/app_auth.ts b/lib/app_auth.ts new file mode 100644 index 0000000..34588c6 --- /dev/null +++ b/lib/app_auth.ts @@ -0,0 +1,63 @@ +/** + * Challenge-response authentication for the f2f-app. + * + * Flow: + * 1. POST /api/tokens/challenge {nameSlug} -> {challenge} + * 2. proof = lowercase(hex(sha512(challenge + secret))) + * 3. POST /api/tokens/exchange {nameSlug, proof} -> {token} + * 4. Cache token for 8 minutes (tokens are valid 10 min, 2 min buffer) + */ + +const TOKEN_CACHE_DURATION_MS = 8 * 60 * 1000 + +export interface AppAuth { + url: string // F2F_APP_URL + keyId: string // F2F_APP_KEY_ID (= API token nameSlug) + keySecret: string // F2F_APP_KEY_SECRET +} + +export class AppTokenManager { + private token: string | null = null + private tokenExpiresAt: number = 0 + + constructor(private auth: AppAuth) {} + + async getToken(): Promise { + if (this.token && Date.now() < this.tokenExpiresAt) { + return this.token + } + + return await this.authenticate() + } + + private async authenticate(): Promise { + const base = this.auth.url + "/api/tokens" + const headers = { "Content-Type": "application/json" } + + // 1. Request challenge + const { challenge } = await fetch(`${base}/challenge`, { + method: "POST", + headers, + body: JSON.stringify({ nameSlug: this.auth.keyId }), + }).then((res) => res.json()) as { challenge: string } + + // 2. Calculate proof (SHA-512) + const msgUint8 = new TextEncoder().encode(challenge + this.auth.keySecret) + const hashBuffer = await crypto.subtle.digest("SHA-512", msgUint8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const proof = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") + + // 3. Exchange for token + const { token } = await fetch(`${base}/exchange`, { + method: "POST", + headers, + body: JSON.stringify({ nameSlug: this.auth.keyId, proof }), + }).then((res) => res.json()) as { token: string } + + // 4. Cache token + this.token = token + this.tokenExpiresAt = Date.now() + TOKEN_CACHE_DURATION_MS + + return token + } +} diff --git a/lib/app_routes.ts b/lib/app_routes.ts new file mode 100644 index 0000000..ac26058 --- /dev/null +++ b/lib/app_routes.ts @@ -0,0 +1,46 @@ +/** + * Maps RT-CV API paths to f2f-app proxy paths. + * Static paths are looked up by exact match. + * Parameterized paths are matched by prefix — the dynamic suffix carries over. + */ + +const staticRoutes: Record = { + "/api/v1/health": "/api/private/scraper/health", + "/api/v1/scraper/status": "/api/private/scraper/status", + "/api/v1/scraper/setSlug": "/api/private/scraper/set-slug", + "/api/v1/scraper/scanCV": "/api/private/scraper/scan-cv", + "/api/v1/scraper/dryScanCV": "/api/private/scraper/dry-scan-cv", + "/api/v1/scraper/scanCVDocument": "/api/private/scraper/scan-cv-document", + "/api/v1/scraper/allCVs": "/api/private/scraper/all-cvs", + "/api/v1/scraper/log": "/api/private/scraper/log", + "/api/v1/scraperUsers": "/api/private/scraper/users", + "/api/v1/scraperUsers/reportLoginAttempt": + "/api/private/scraper/users/report-login-attempt", + "/api/v1/scraperUsers/userValidity": + "/api/private/scraper/users/user-validity", + "/api/v1/candidates": "/api/private/scraper/candidates", +} + +// Prefix-based routes for paths with dynamic segments. +// Order matters — more specific prefixes first. +const prefixRoutes: Array<[string, string]> = [ + ["/api/v1/siteStorageCredentials/scraper/", "/api/private/scraper/site-storage-credentials/"], + ["/api/v1/siteStorageCredentials/", "/api/private/scraper/site-storage-credentials/"], + ["/api/v1/visitedCvs/byReference/", "/api/private/scraper/visited-cvs/by-reference/"], +] + +export function mapRtcvPathToAppPath(rtcvPath: string): string { + const staticMatch = staticRoutes[rtcvPath] + if (staticMatch) return staticMatch + + for (const [from, to] of prefixRoutes) { + if (rtcvPath.startsWith(from)) { + return to + rtcvPath.slice(from.length) + } + } + + console.warn( + `[F2F_APP] No route mapping for path: ${rtcvPath}, passing through unchanged`, + ) + return rtcvPath +} diff --git a/lib/server.ts b/lib/server.ts index 2d04012..1226e7a 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -11,6 +11,8 @@ import { type Cv } from "./cv.ts" import { Stats } from "./stats.ts" import { formatCvFilename } from "./cv_document.ts" import { Slack } from "./slack.ts" +import { AppTokenManager } from "./app_auth.ts" +import { mapRtcvPathToAppPath } from "./app_routes.ts" import { Registry } from "prom-client" const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -21,6 +23,12 @@ export interface ServerOptions { // Optional: alternativeServer?: string | false // If not set will try to use RTCV_ALTERNATIVE_SERVER env variable, if set to false will disable alternative server + f2fAppUrl?: string // If not set will try to use F2F_APP_URL env variable + f2fAppKeyId?: string // If not set will try to use F2F_APP_KEY_ID env variable + f2fAppKeySecret?: string // If not set will try to use F2F_APP_KEY_SECRET env variable + f2fAlternativeAppUrl?: string | false // If not set will try to use F2F_ALTERNATIVE_APP_URL, if set to false will disable alternative app + f2fAlternativeAppKeyId?: string // If not set will try to use F2F_ALTERNATIVE_APP_KEY_ID env variable + f2fAlternativeAppKeySecret?: string // If not set will try to use F2F_ALTERNATIVE_APP_KEY_SECRET env variable port?: number // If not set will try to use SERVER_PORT or default to: 3000 noHealthChecks?: boolean // If set to true will disable health checks on the RT-CV server skipSlugCheck?: boolean // If set to true will not check and update the slug on the RT-CV server @@ -109,6 +117,9 @@ export class Server { private primaryServerAuth: ServerAuth private alternativeServerAuth?: ServerAuth private alternativeServer?: Server + private isAppMode: boolean = false + private appTokenManager?: AppTokenManager + private appBaseUrl?: string private externalHandlers: Map = new Map() private internalSlackCache?: Slack private externalSlackCache?: Slack @@ -137,6 +148,27 @@ export class Server { options.skipAliveCheck ?? this.mightGetEnv("SKIP_ALIVE_CHECK").toLowerCase() === "true" + // Check for F2F_APP mode + const f2fAppUrl = options.f2fAppUrl || this.mightGetEnv("F2F_APP_URL") + if (f2fAppUrl) { + const f2fAppKeyId = options.f2fAppKeyId || this.mightGetEnv("F2F_APP_KEY_ID") + const f2fAppKeySecret = options.f2fAppKeySecret || this.mightGetEnv("F2F_APP_KEY_SECRET") + if (!f2fAppKeyId || !f2fAppKeySecret) { + console.log( + "F2F_APP_KEY_ID and F2F_APP_KEY_SECRET are required when F2F_APP_URL is set", + ) + process.exit(1) + } + + this.isAppMode = true + this.appBaseUrl = f2fAppUrl.replace(/\/+$/, "") // strip trailing slash + this.appTokenManager = new AppTokenManager({ + url: this.appBaseUrl, + keyId: f2fAppKeyId, + keySecret: f2fAppKeySecret, + }) + } + const apiServer = new URL( options.apiServer || this.mustGetEnv("RTCV_SERVER"), ) @@ -161,6 +193,10 @@ export class Server { password: apiServer.password, } + if (this.isAppMode) { + console.log("F2F_APP_URL is set, outgoing requests will be routed through the app. RTCV_SERVER is used for incoming callback authentication.") + } + // Health check the RT-CV server if (!options.noHealthChecks) { this.health().catch((e) => { @@ -180,29 +216,80 @@ export class Server { } } - if (options.alternativeServer !== false) { - const alternativeServer = - options.alternativeServer || this.mightGetEnv("RTCV_ALTERNATIVE_SERVER") + if (options.alternativeServer !== false && options.f2fAlternativeAppUrl !== false) { + if (this.isAppMode) { + // In app mode, use F2F_ALTERNATIVE_APP_* vars + const altAppUrl = + options.f2fAlternativeAppUrl || this.mightGetEnv("F2F_ALTERNATIVE_APP_URL") + + const alternativeRtcvServer = this.mightGetEnv("RTCV_ALTERNATIVE_SERVER") + if (alternativeRtcvServer) { + console.warn( + "Warning: RTCV_ALTERNATIVE_SERVER is ignored because F2F_APP_URL is set. Use F2F_ALTERNATIVE_APP_URL instead.", + ) + } - if (alternativeServer) { - const alternativeServerUrl = new URL(alternativeServer) - if (alternativeServerUrl.username && alternativeServerUrl.password) { - this.alternativeServerAuth = { - username: alternativeServerUrl.username, - password: alternativeServerUrl.password, + if (altAppUrl) { + const altAppKeyId = + options.f2fAlternativeAppKeyId || this.mightGetEnv("F2F_ALTERNATIVE_APP_KEY_ID") + const altAppKeySecret = + options.f2fAlternativeAppKeySecret || this.mightGetEnv("F2F_ALTERNATIVE_APP_KEY_SECRET") + + if (!altAppKeyId || !altAppKeySecret) { + console.log( + "F2F_ALTERNATIVE_APP_KEY_ID and F2F_ALTERNATIVE_APP_KEY_SECRET are required when F2F_ALTERNATIVE_APP_URL is set", + ) + process.exit(1) } + + this.alternativeServer = new Server( + slug, + {}, + { + f2fAppUrl: altAppUrl, + f2fAppKeyId: altAppKeyId, + f2fAppKeySecret: altAppKeySecret, + apiServer: options.apiServer || this.mightGetEnv("RTCV_SERVER"), + alternativeServer: false, + f2fAlternativeAppUrl: false, + port: this.port, + }, + true, + ) + } + } else { + // In RT-CV mode, use RTCV_ALTERNATIVE_SERVER (existing behavior) + const alternativeServer = + options.alternativeServer || this.mightGetEnv("RTCV_ALTERNATIVE_SERVER") + + const alternativeAppUrl = this.mightGetEnv("F2F_ALTERNATIVE_APP_URL") + if (alternativeAppUrl) { + console.warn( + "Warning: F2F_ALTERNATIVE_APP_URL is ignored because F2F_APP_URL is not set. Use RTCV_ALTERNATIVE_SERVER instead.", + ) } - this.alternativeServer = new Server( - slug, - {}, - { - apiServer: alternativeServer, - alternativeServer: false, - port: this.port, - }, - true, - ) + if (alternativeServer) { + const alternativeServerUrl = new URL(alternativeServer) + if (alternativeServerUrl.username && alternativeServerUrl.password) { + this.alternativeServerAuth = { + username: alternativeServerUrl.username, + password: alternativeServerUrl.password, + } + } + + this.alternativeServer = new Server( + slug, + {}, + { + apiServer: alternativeServer, + alternativeServer: false, + f2fAlternativeAppUrl: false, + port: this.port, + }, + true, + ) + } } } } @@ -313,8 +400,8 @@ export class Server { } } - // Make a request to RT-CV - // Returns the response decoded as JSOn + // Make a request to RT-CV or f2f-app (depending on mode) + // Returns the response decoded as JSON public async fetch( path: string, options: FetchOptions = {}, @@ -322,12 +409,19 @@ export class Server { const controller = new AbortController() const id = setTimeout(() => controller.abort(), 60_000) + // In app mode: remap path and use app base URL + const effectivePath = this.isAppMode ? mapRtcvPathToAppPath(path) : path + const effectiveBaseUrl = this.isAppMode ? this.appBaseUrl! : this.apiServer + + const authHeader = await this.getAuthorizationHeader() + const fetchOptions: Parameters[1] = { method: options.method, headers: { ...options.headers, Accept: "application/json", - Authorization: this.authorizationHeader, + Authorization: authHeader, + ...(this.isAppMode ? { "X-Scraper-Slug": this.slug } : {}), }, signal: controller.signal, } @@ -335,7 +429,6 @@ export class Server { if (options.body) { if (options.body instanceof FormData) { fetchOptions.body = options.body - // Content-Type will be set automatically by the fetch method } else { fetchOptions.body = JSON.stringify(options.body) fetchOptions.headers = { @@ -345,7 +438,7 @@ export class Server { } } - const r = await fetch(this.apiServer + path, fetchOptions) + const r = await fetch(effectiveBaseUrl + effectivePath, fetchOptions) clearTimeout(id) if (r.status >= 400) { throw new FetchError(await r.text(), path, r.status) @@ -700,7 +793,11 @@ export class Server { // Private methods // --- - private get authorizationHeader() { + private async getAuthorizationHeader(): Promise { + if (this.isAppMode && this.appTokenManager) { + const token = await this.appTokenManager.getToken() + return `Bearer ${token}` + } return `Basic ${this.primaryServerAuth.username}:${this.primaryServerAuth.password}` } From 28dc275944c0a351b92fa50005adff6ee368d913 Mon Sep 17 00:00:00 2001 From: JanR Date: Tue, 14 Apr 2026 11:10:45 +0200 Subject: [PATCH 02/12] refactor: replace route mapping module with inline path ternaries Delete app_routes.ts and use `this.isAppMode ? appPath : rtcvPath` at each call site. Simpler, explicit, and easier to clean up when RT-CV is fully integrated (just remove the ternary branches). Also makes isAppMode public readonly so slack.ts can access it. --- lib/app_routes.ts | 46 -------------------------------------------- lib/server.ts | 49 +++++++++++++++++++++++++++-------------------- lib/slack.ts | 2 +- 3 files changed, 29 insertions(+), 68 deletions(-) delete mode 100644 lib/app_routes.ts diff --git a/lib/app_routes.ts b/lib/app_routes.ts deleted file mode 100644 index ac26058..0000000 --- a/lib/app_routes.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Maps RT-CV API paths to f2f-app proxy paths. - * Static paths are looked up by exact match. - * Parameterized paths are matched by prefix — the dynamic suffix carries over. - */ - -const staticRoutes: Record = { - "/api/v1/health": "/api/private/scraper/health", - "/api/v1/scraper/status": "/api/private/scraper/status", - "/api/v1/scraper/setSlug": "/api/private/scraper/set-slug", - "/api/v1/scraper/scanCV": "/api/private/scraper/scan-cv", - "/api/v1/scraper/dryScanCV": "/api/private/scraper/dry-scan-cv", - "/api/v1/scraper/scanCVDocument": "/api/private/scraper/scan-cv-document", - "/api/v1/scraper/allCVs": "/api/private/scraper/all-cvs", - "/api/v1/scraper/log": "/api/private/scraper/log", - "/api/v1/scraperUsers": "/api/private/scraper/users", - "/api/v1/scraperUsers/reportLoginAttempt": - "/api/private/scraper/users/report-login-attempt", - "/api/v1/scraperUsers/userValidity": - "/api/private/scraper/users/user-validity", - "/api/v1/candidates": "/api/private/scraper/candidates", -} - -// Prefix-based routes for paths with dynamic segments. -// Order matters — more specific prefixes first. -const prefixRoutes: Array<[string, string]> = [ - ["/api/v1/siteStorageCredentials/scraper/", "/api/private/scraper/site-storage-credentials/"], - ["/api/v1/siteStorageCredentials/", "/api/private/scraper/site-storage-credentials/"], - ["/api/v1/visitedCvs/byReference/", "/api/private/scraper/visited-cvs/by-reference/"], -] - -export function mapRtcvPathToAppPath(rtcvPath: string): string { - const staticMatch = staticRoutes[rtcvPath] - if (staticMatch) return staticMatch - - for (const [from, to] of prefixRoutes) { - if (rtcvPath.startsWith(from)) { - return to + rtcvPath.slice(from.length) - } - } - - console.warn( - `[F2F_APP] No route mapping for path: ${rtcvPath}, passing through unchanged`, - ) - return rtcvPath -} diff --git a/lib/server.ts b/lib/server.ts index 1226e7a..5c01938 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -12,7 +12,6 @@ import { Stats } from "./stats.ts" import { formatCvFilename } from "./cv_document.ts" import { Slack } from "./slack.ts" import { AppTokenManager } from "./app_auth.ts" -import { mapRtcvPathToAppPath } from "./app_routes.ts" import { Registry } from "prom-client" const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -117,7 +116,7 @@ export class Server { private primaryServerAuth: ServerAuth private alternativeServerAuth?: ServerAuth private alternativeServer?: Server - private isAppMode: boolean = false + public readonly isAppMode: boolean = false private appTokenManager?: AppTokenManager private appBaseUrl?: string private externalHandlers: Map = new Map() @@ -339,7 +338,7 @@ export class Server { while (true) { try { const response = await this.fetch<{ active: boolean }>( - "/api/v1/scraper/status", + this.isAppMode ? "/api/private/scraper/status" : "/api/v1/scraper/status", ) if (response.active) { this.state = lastState @@ -409,9 +408,7 @@ export class Server { const controller = new AbortController() const id = setTimeout(() => controller.abort(), 60_000) - // In app mode: remap path and use app base URL - const effectivePath = this.isAppMode ? mapRtcvPathToAppPath(path) : path - const effectiveBaseUrl = this.isAppMode ? this.appBaseUrl! : this.apiServer + const baseUrl = this.isAppMode ? this.appBaseUrl! : this.apiServer const authHeader = await this.getAuthorizationHeader() @@ -438,7 +435,7 @@ export class Server { } } - const r = await fetch(effectiveBaseUrl + effectivePath, fetchOptions) + const r = await fetch(baseUrl + path, fetchOptions) clearTimeout(id) if (r.status >= 400) { throw new FetchError(await r.text(), path, r.status) @@ -449,14 +446,14 @@ export class Server { // health checks if the api server is up and running and if not throws an error public async health() { - await this.fetchWithRetry("/api/v1/health") + await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/health" : "/api/v1/health") } // get all login users for the api key public async getUsers( mustBeAtLeastOneUser: boolean, ): Promise> { - const response = await this.fetchWithRetry("/api/v1/scraperUsers") + const response = await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/users" : "/api/v1/scraperUsers") const { users } = response as any if (users.length == 0 && mustBeAtLeastOneUser) { @@ -491,7 +488,7 @@ export class Server { : usernameOrUser.username try { - await this.fetchWithRetry("/api/v1/scraperUsers/reportLoginAttempt", { + await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/users/report-login-attempt" : "/api/v1/scraperUsers/reportLoginAttempt", { method: "POST", body: { username, @@ -519,7 +516,9 @@ export class Server { }> { const credentials: Array = await this.fetchWithRetry( - "/api/v1/siteStorageCredentials/scraper/" + this.apiKeyId, + this.isAppMode + ? "/api/private/scraper/site-storage-credentials/" + this.apiKeyId + : "/api/v1/siteStorageCredentials/scraper/" + this.apiKeyId, ) if (!Array.isArray(credentials)) { @@ -554,7 +553,9 @@ export class Server { credential: SiteStorageCredentials, ): Promise { return this.fetchWithRetry( - "/api/v1/siteStorageCredentials/" + credential.id + "/invalidate", + this.isAppMode + ? "/api/private/scraper/site-storage-credentials/" + credential.id + "/invalidate" + : "/api/v1/siteStorageCredentials/" + credential.id + "/invalidate", { method: "PATCH" }, ) } @@ -564,7 +565,9 @@ export class Server { credential: SiteStorageCredentials, ): Promise { return this.fetchWithRetry( - "/api/v1/siteStorageCredentials/" + credential.id + "/validate", + this.isAppMode + ? "/api/private/scraper/site-storage-credentials/" + credential.id + "/validate" + : "/api/v1/siteStorageCredentials/" + credential.id + "/validate", { method: "PATCH" }, ) } @@ -591,7 +594,7 @@ export class Server { async cvHasMatches(cv: Cv): Promise { this.validateCv(cv) - const response = await this.fetchWithRetry("/api/v1/scraper/dryScanCV", { + const response = await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/dry-scan-cv" : "/api/v1/scraper/dryScanCV", { body: { cv }, method: "POST", }) @@ -612,7 +615,7 @@ export class Server { }) try { - await this.fetchWithRetry("/api/v1/scraper/scanCV", { + await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/scan-cv" : "/api/v1/scraper/scanCV", { body: { cv }, method: "POST", }) @@ -664,7 +667,7 @@ export class Server { console.log("failed to send cvs list to alternative server,", e) }) const body = { cvs } - await this.fetchWithRetry("/api/v1/scraper/allCVs", { + await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/all-cvs" : "/api/v1/scraper/allCVs", { body, method: "POST", }) @@ -682,7 +685,7 @@ export class Server { body.set("metadata", JSON.stringify(metadata)) body.set("cv", cvFile, formatCvFilename(filename, cvFile.type)) - await this.fetchWithRetry("/api/v1/scraper/scanCVDocument", { + await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/scan-cv-document" : "/api/v1/scraper/scanCVDocument", { body, method: "POST", }) @@ -696,7 +699,7 @@ export class Server { // 2. If the primary server fails to scan the cv document the alternative server will also very likely fail. if (this.alternativeServer) { await this.alternativeServer - .fetch("/api/v1/scraper/scanCVDocument", { + .fetch(this.isAppMode ? "/api/private/scraper/scan-cv-document" : "/api/v1/scraper/scanCVDocument", { body, method: "POST", }) @@ -752,7 +755,7 @@ export class Server { public candidateRequestPersonalDetials( referenceNr: string, ): Promise<{ candidate: Candidate; created: boolean }> { - return this.fetch("/api/v1/candidates", { + return this.fetch(this.isAppMode ? "/api/private/scraper/candidates" : "/api/v1/candidates", { method: "POST", body: { referenceNr }, }) @@ -760,7 +763,11 @@ export class Server { public async cvVisit(referenceNr: string): Promise { try { - return await this.fetch("/api/v1/visitedCvs/byReference/" + referenceNr) + return await this.fetch( + this.isAppMode + ? "/api/private/scraper/visited-cvs/by-reference/" + referenceNr + : "/api/v1/visitedCvs/byReference/" + referenceNr, + ) } catch (e) { if ( e instanceof FetchError && @@ -924,7 +931,7 @@ export class Server { overwroteExisting: boolean } try { - slugResponse = await this.fetchWithRetry("/api/v1/scraper/setSlug", { + slugResponse = await this.fetchWithRetry(this.isAppMode ? "/api/private/scraper/set-slug" : "/api/v1/scraper/setSlug", { method: "PUT", body: { slug: this.slug }, }) diff --git a/lib/slack.ts b/lib/slack.ts index 2127a84..a2f95b2 100644 --- a/lib/slack.ts +++ b/lib/slack.ts @@ -28,7 +28,7 @@ export class Slack { fields?: Record, ) { try { - await this.server.fetch("/api/v1/scraper/log", { + await this.server.fetch(this.server.isAppMode ? "/api/private/scraper/log" : "/api/v1/scraper/log", { method: "POST", body: { internal: this.internal, From 74f0e6f8fd9a28311008f6987e52b4e5d69a72a6 Mon Sep 17 00:00:00 2001 From: JanR Date: Tue, 14 Apr 2026 11:27:11 +0200 Subject: [PATCH 03/12] refactor: use single F2F_APP env var with URL format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse F2F_APP_URL + F2F_APP_KEY_ID + F2F_APP_KEY_SECRET into F2F_APP=https://keyId:keySecret@host — same format as RTCV_SERVER, parsed with new URL(). Same for F2F_ALTERNATIVE_APP. --- lib/server.ts | 65 +++++++++++++++++++++------------------------------ 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/lib/server.ts b/lib/server.ts index 5c01938..d198a38 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -22,12 +22,8 @@ export interface ServerOptions { // Optional: alternativeServer?: string | false // If not set will try to use RTCV_ALTERNATIVE_SERVER env variable, if set to false will disable alternative server - f2fAppUrl?: string // If not set will try to use F2F_APP_URL env variable - f2fAppKeyId?: string // If not set will try to use F2F_APP_KEY_ID env variable - f2fAppKeySecret?: string // If not set will try to use F2F_APP_KEY_SECRET env variable - f2fAlternativeAppUrl?: string | false // If not set will try to use F2F_ALTERNATIVE_APP_URL, if set to false will disable alternative app - f2fAlternativeAppKeyId?: string // If not set will try to use F2F_ALTERNATIVE_APP_KEY_ID env variable - f2fAlternativeAppKeySecret?: string // If not set will try to use F2F_ALTERNATIVE_APP_KEY_SECRET env variable + f2fApp?: string | false // If not set will try to use F2F_APP env variable (format: https://keyId:keySecret@host), if set to false will disable app mode + f2fAlternativeApp?: string | false // If not set will try to use F2F_ALTERNATIVE_APP env variable, if set to false will disable alternative app port?: number // If not set will try to use SERVER_PORT or default to: 3000 noHealthChecks?: boolean // If set to true will disable health checks on the RT-CV server skipSlugCheck?: boolean // If set to true will not check and update the slug on the RT-CV server @@ -147,24 +143,23 @@ export class Server { options.skipAliveCheck ?? this.mightGetEnv("SKIP_ALIVE_CHECK").toLowerCase() === "true" - // Check for F2F_APP mode - const f2fAppUrl = options.f2fAppUrl || this.mightGetEnv("F2F_APP_URL") - if (f2fAppUrl) { - const f2fAppKeyId = options.f2fAppKeyId || this.mightGetEnv("F2F_APP_KEY_ID") - const f2fAppKeySecret = options.f2fAppKeySecret || this.mightGetEnv("F2F_APP_KEY_SECRET") - if (!f2fAppKeyId || !f2fAppKeySecret) { + // Check for F2F_APP mode (format: https://keyId:keySecret@host) + const f2fAppRaw = options.f2fApp || this.mightGetEnv("F2F_APP") + if (f2fAppRaw) { + const f2fApp = new URL(f2fAppRaw) + if (!f2fApp.username || !f2fApp.password) { console.log( - "F2F_APP_KEY_ID and F2F_APP_KEY_SECRET are required when F2F_APP_URL is set", + "F2F_APP must contain credentials like: https://keyId:keySecret@app.first2find.nl", ) process.exit(1) } this.isAppMode = true - this.appBaseUrl = f2fAppUrl.replace(/\/+$/, "") // strip trailing slash + this.appBaseUrl = f2fApp.origin this.appTokenManager = new AppTokenManager({ url: this.appBaseUrl, - keyId: f2fAppKeyId, - keySecret: f2fAppKeySecret, + keyId: f2fApp.username, + keySecret: f2fApp.password, }) } @@ -193,7 +188,7 @@ export class Server { } if (this.isAppMode) { - console.log("F2F_APP_URL is set, outgoing requests will be routed through the app. RTCV_SERVER is used for incoming callback authentication.") + console.log("F2F_APP is set, outgoing requests will be routed through the app. RTCV_SERVER is used for incoming callback authentication.") } // Health check the RT-CV server @@ -215,28 +210,24 @@ export class Server { } } - if (options.alternativeServer !== false && options.f2fAlternativeAppUrl !== false) { + if (options.alternativeServer !== false && options.f2fAlternativeApp !== false) { if (this.isAppMode) { - // In app mode, use F2F_ALTERNATIVE_APP_* vars - const altAppUrl = - options.f2fAlternativeAppUrl || this.mightGetEnv("F2F_ALTERNATIVE_APP_URL") + // In app mode, use F2F_ALTERNATIVE_APP + const altAppRaw = + options.f2fAlternativeApp || this.mightGetEnv("F2F_ALTERNATIVE_APP") const alternativeRtcvServer = this.mightGetEnv("RTCV_ALTERNATIVE_SERVER") if (alternativeRtcvServer) { console.warn( - "Warning: RTCV_ALTERNATIVE_SERVER is ignored because F2F_APP_URL is set. Use F2F_ALTERNATIVE_APP_URL instead.", + "Warning: RTCV_ALTERNATIVE_SERVER is ignored because F2F_APP is set. Use F2F_ALTERNATIVE_APP instead.", ) } - if (altAppUrl) { - const altAppKeyId = - options.f2fAlternativeAppKeyId || this.mightGetEnv("F2F_ALTERNATIVE_APP_KEY_ID") - const altAppKeySecret = - options.f2fAlternativeAppKeySecret || this.mightGetEnv("F2F_ALTERNATIVE_APP_KEY_SECRET") - - if (!altAppKeyId || !altAppKeySecret) { + if (altAppRaw) { + const altApp = new URL(altAppRaw) + if (!altApp.username || !altApp.password) { console.log( - "F2F_ALTERNATIVE_APP_KEY_ID and F2F_ALTERNATIVE_APP_KEY_SECRET are required when F2F_ALTERNATIVE_APP_URL is set", + "F2F_ALTERNATIVE_APP must contain credentials like: https://keyId:keySecret@app.first2find.nl", ) process.exit(1) } @@ -245,12 +236,10 @@ export class Server { slug, {}, { - f2fAppUrl: altAppUrl, - f2fAppKeyId: altAppKeyId, - f2fAppKeySecret: altAppKeySecret, + f2fApp: altAppRaw, apiServer: options.apiServer || this.mightGetEnv("RTCV_SERVER"), alternativeServer: false, - f2fAlternativeAppUrl: false, + f2fAlternativeApp: false, port: this.port, }, true, @@ -261,10 +250,10 @@ export class Server { const alternativeServer = options.alternativeServer || this.mightGetEnv("RTCV_ALTERNATIVE_SERVER") - const alternativeAppUrl = this.mightGetEnv("F2F_ALTERNATIVE_APP_URL") - if (alternativeAppUrl) { + const alternativeAppRaw = this.mightGetEnv("F2F_ALTERNATIVE_APP") + if (alternativeAppRaw) { console.warn( - "Warning: F2F_ALTERNATIVE_APP_URL is ignored because F2F_APP_URL is not set. Use RTCV_ALTERNATIVE_SERVER instead.", + "Warning: F2F_ALTERNATIVE_APP is ignored because F2F_APP is not set. Use RTCV_ALTERNATIVE_SERVER instead.", ) } @@ -283,7 +272,7 @@ export class Server { { apiServer: alternativeServer, alternativeServer: false, - f2fAlternativeAppUrl: false, + f2fAlternativeApp: false, port: this.port, }, true, From eec34c1a58912e81c52e133cb3ab6706b2064582 Mon Sep 17 00:00:00 2001 From: JanR Date: Tue, 14 Apr 2026 11:35:35 +0200 Subject: [PATCH 04/12] fix: add error handling to challenge-response auth Check res.ok before parsing JSON on both challenge and exchange requests. Without this, a failed challenge silently produces a wrong proof, leading to confusing "invalid proof" errors. --- lib/app_auth.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/app_auth.ts b/lib/app_auth.ts index 34588c6..0f7e8b5 100644 --- a/lib/app_auth.ts +++ b/lib/app_auth.ts @@ -35,11 +35,13 @@ export class AppTokenManager { const headers = { "Content-Type": "application/json" } // 1. Request challenge - const { challenge } = await fetch(`${base}/challenge`, { + const challengeRes = await fetch(`${base}/challenge`, { method: "POST", headers, body: JSON.stringify({ nameSlug: this.auth.keyId }), - }).then((res) => res.json()) as { challenge: string } + }) + if (!challengeRes.ok) throw new Error(`F2F App challenge failed (${challengeRes.status}): ${await challengeRes.text()}`) + const { challenge } = await challengeRes.json() as { challenge: string } // 2. Calculate proof (SHA-512) const msgUint8 = new TextEncoder().encode(challenge + this.auth.keySecret) @@ -48,11 +50,13 @@ export class AppTokenManager { const proof = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") // 3. Exchange for token - const { token } = await fetch(`${base}/exchange`, { + const tokenRes = await fetch(`${base}/exchange`, { method: "POST", headers, body: JSON.stringify({ nameSlug: this.auth.keyId, proof }), - }).then((res) => res.json()) as { token: string } + }) + if (!tokenRes.ok) throw new Error(`F2F App token exchange failed (${tokenRes.status}): ${await tokenRes.text()}`) + const { token } = await tokenRes.json() as { token: string } // 4. Cache token this.token = token From ee17fc695fd3d0faf3e1bd70e706651d6acebc4d Mon Sep 17 00:00:00 2001 From: JanR Date: Tue, 14 Apr 2026 11:48:41 +0200 Subject: [PATCH 05/12] refactor: let alternative server decide its own CV document path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use this.alternativeServer.isAppMode instead of this.isAppMode for consistency with how other methods delegate to the alternative server. Not a bug — both always share the same mode — but removes an implicit coupling. --- lib/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server.ts b/lib/server.ts index d198a38..ea3e198 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -688,7 +688,7 @@ export class Server { // 2. If the primary server fails to scan the cv document the alternative server will also very likely fail. if (this.alternativeServer) { await this.alternativeServer - .fetch(this.isAppMode ? "/api/private/scraper/scan-cv-document" : "/api/v1/scraper/scanCVDocument", { + .fetch(this.alternativeServer.isAppMode ? "/api/private/scraper/scan-cv-document" : "/api/v1/scraper/scanCVDocument", { body, method: "POST", }) From 56548165de034feaa709c38598b51e69bf06a357 Mon Sep 17 00:00:00 2001 From: JanR Date: Tue, 14 Apr 2026 11:52:12 +0200 Subject: [PATCH 06/12] chore: fix AppAuth comments to reflect single F2F_APP env var --- lib/app_auth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/app_auth.ts b/lib/app_auth.ts index 0f7e8b5..46e21c6 100644 --- a/lib/app_auth.ts +++ b/lib/app_auth.ts @@ -11,9 +11,9 @@ const TOKEN_CACHE_DURATION_MS = 8 * 60 * 1000 export interface AppAuth { - url: string // F2F_APP_URL - keyId: string // F2F_APP_KEY_ID (= API token nameSlug) - keySecret: string // F2F_APP_KEY_SECRET + url: string // from F2F_APP origin + keyId: string // from F2F_APP, API token nameSlug + keySecret: string // from F2F_APP, API token secret } export class AppTokenManager { From c19f98f25f1da50f2b9f6d684c802a49ca387740 Mon Sep 17 00:00:00 2001 From: janr Date: Tue, 14 Apr 2026 12:09:47 +0200 Subject: [PATCH 07/12] fix: update comment for RTCV_ALTERNATIVE_SERVER usage in RT-CV mode --- lib/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server.ts b/lib/server.ts index ea3e198..3437951 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -246,7 +246,7 @@ export class Server { ) } } else { - // In RT-CV mode, use RTCV_ALTERNATIVE_SERVER (existing behavior) + // In RT-CV mode, use RTCV_ALTERNATIVE_SERVER const alternativeServer = options.alternativeServer || this.mightGetEnv("RTCV_ALTERNATIVE_SERVER") From 15fb8cdb950e586dd9c4a2c3114cf31c9549cd12 Mon Sep 17 00:00:00 2001 From: janr Date: Tue, 14 Apr 2026 12:12:26 +0200 Subject: [PATCH 08/12] refactor: always send X-Scraper-Slug header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No need to conditionally add it — RT-CV ignores unknown headers, the app requires it. --- lib/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server.ts b/lib/server.ts index 3437951..2ae8c21 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -407,7 +407,7 @@ export class Server { ...options.headers, Accept: "application/json", Authorization: authHeader, - ...(this.isAppMode ? { "X-Scraper-Slug": this.slug } : {}), + "X-Scraper-Slug": this.slug, }, signal: controller.signal, } From f8fc8ac0f827025ae957a4bc299ac352ee9f3903 Mon Sep 17 00:00:00 2001 From: janr Date: Tue, 14 Apr 2026 12:22:24 +0200 Subject: [PATCH 09/12] feat: use custom f2f:// protocol for F2F_APP env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distinguishes app credentials from RTCV_SERVER basic auth. f2f://keyId:keySecret@host maps to http://host for requests. No protocol enforcement — just documented convention. --- lib/server.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/server.ts b/lib/server.ts index 2ae8c21..78f0fb6 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -22,8 +22,8 @@ export interface ServerOptions { // Optional: alternativeServer?: string | false // If not set will try to use RTCV_ALTERNATIVE_SERVER env variable, if set to false will disable alternative server - f2fApp?: string | false // If not set will try to use F2F_APP env variable (format: https://keyId:keySecret@host), if set to false will disable app mode - f2fAlternativeApp?: string | false // If not set will try to use F2F_ALTERNATIVE_APP env variable, if set to false will disable alternative app + f2fApp?: string | false // If not set will try to use F2F_APP env variable (format: f2f://keyId:keySecret@host), if set to false will disable app mode + f2fAlternativeApp?: string | false // If not set will try to use F2F_ALTERNATIVE_APP env variable (same format), if set to false will disable alternative app port?: number // If not set will try to use SERVER_PORT or default to: 3000 noHealthChecks?: boolean // If set to true will disable health checks on the RT-CV server skipSlugCheck?: boolean // If set to true will not check and update the slug on the RT-CV server @@ -143,19 +143,19 @@ export class Server { options.skipAliveCheck ?? this.mightGetEnv("SKIP_ALIVE_CHECK").toLowerCase() === "true" - // Check for F2F_APP mode (format: https://keyId:keySecret@host) + // Check for F2F_APP mode (format: f2f://keyId:keySecret@host) const f2fAppRaw = options.f2fApp || this.mightGetEnv("F2F_APP") if (f2fAppRaw) { const f2fApp = new URL(f2fAppRaw) if (!f2fApp.username || !f2fApp.password) { console.log( - "F2F_APP must contain credentials like: https://keyId:keySecret@app.first2find.nl", + "F2F_APP must contain credentials, e.g. f2f://keyId:keySecret@app.first2find.nl", ) process.exit(1) } this.isAppMode = true - this.appBaseUrl = f2fApp.origin + this.appBaseUrl = "http://" + f2fApp.host this.appTokenManager = new AppTokenManager({ url: this.appBaseUrl, keyId: f2fApp.username, @@ -227,7 +227,7 @@ export class Server { const altApp = new URL(altAppRaw) if (!altApp.username || !altApp.password) { console.log( - "F2F_ALTERNATIVE_APP must contain credentials like: https://keyId:keySecret@app.first2find.nl", + "F2F_ALTERNATIVE_APP must contain credentials, e.g. f2f://keyId:keySecret@app.first2find.nl", ) process.exit(1) } From 7a9d216e489fc1ce86d0a7bc8f5d9b2cf2287b78 Mon Sep 17 00:00:00 2001 From: janr Date: Tue, 14 Apr 2026 12:30:42 +0200 Subject: [PATCH 10/12] feat: support f2fs:// for HTTPS in F2F_APP f2f:// maps to http (local dev), f2fs:// maps to https (production). Mirrors the ws:// vs wss:// convention. --- lib/server.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/server.ts b/lib/server.ts index 78f0fb6..2fe2279 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -22,7 +22,7 @@ export interface ServerOptions { // Optional: alternativeServer?: string | false // If not set will try to use RTCV_ALTERNATIVE_SERVER env variable, if set to false will disable alternative server - f2fApp?: string | false // If not set will try to use F2F_APP env variable (format: f2f://keyId:keySecret@host), if set to false will disable app mode + f2fApp?: string | false // If not set will try to use F2F_APP env variable (format: f2f[s]://keyId:keySecret@host, s = https), if set to false will disable app mode f2fAlternativeApp?: string | false // If not set will try to use F2F_ALTERNATIVE_APP env variable (same format), if set to false will disable alternative app port?: number // If not set will try to use SERVER_PORT or default to: 3000 noHealthChecks?: boolean // If set to true will disable health checks on the RT-CV server @@ -143,19 +143,19 @@ export class Server { options.skipAliveCheck ?? this.mightGetEnv("SKIP_ALIVE_CHECK").toLowerCase() === "true" - // Check for F2F_APP mode (format: f2f://keyId:keySecret@host) + // Check for F2F_APP mode (format: f2f:// or f2fs:// for http/https) const f2fAppRaw = options.f2fApp || this.mightGetEnv("F2F_APP") if (f2fAppRaw) { const f2fApp = new URL(f2fAppRaw) if (!f2fApp.username || !f2fApp.password) { console.log( - "F2F_APP must contain credentials, e.g. f2f://keyId:keySecret@app.first2find.nl", + "F2F_APP must contain credentials, e.g. f2fs://keyId:keySecret@app.first2find.nl", ) process.exit(1) } this.isAppMode = true - this.appBaseUrl = "http://" + f2fApp.host + this.appBaseUrl = (f2fApp.protocol === "f2fs:" ? "https://" : "http://") + f2fApp.host this.appTokenManager = new AppTokenManager({ url: this.appBaseUrl, keyId: f2fApp.username, @@ -227,7 +227,7 @@ export class Server { const altApp = new URL(altAppRaw) if (!altApp.username || !altApp.password) { console.log( - "F2F_ALTERNATIVE_APP must contain credentials, e.g. f2f://keyId:keySecret@app.first2find.nl", + "F2F_ALTERNATIVE_APP must contain credentials, e.g. f2fs://keyId:keySecret@app.first2find.nl", ) process.exit(1) } From b50e343fcfab4a6cf4f19aaaa910723e12c30a41 Mon Sep 17 00:00:00 2001 From: janr Date: Tue, 14 Apr 2026 12:33:33 +0200 Subject: [PATCH 11/12] feat: enforce f2f:// or f2fs:// protocol for F2F_APP Reject other protocols with a clear error message pointing to the correct format. --- lib/server.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/server.ts b/lib/server.ts index 2fe2279..ed12fdf 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -147,6 +147,12 @@ export class Server { const f2fAppRaw = options.f2fApp || this.mightGetEnv("F2F_APP") if (f2fAppRaw) { const f2fApp = new URL(f2fAppRaw) + if (f2fApp.protocol !== "f2f:" && f2fApp.protocol !== "f2fs:") { + console.log( + "F2F_APP must use f2f:// (http) or f2fs:// (https) protocol, e.g. f2fs://keyId:keySecret@app.first2find.nl", + ) + process.exit(1) + } if (!f2fApp.username || !f2fApp.password) { console.log( "F2F_APP must contain credentials, e.g. f2fs://keyId:keySecret@app.first2find.nl", From f175eba7c96b283c1c3f67611cdfd8d9b9aff40d Mon Sep 17 00:00:00 2001 From: janr Date: Tue, 14 Apr 2026 12:37:03 +0200 Subject: [PATCH 12/12] docs: add F2F_APP env vars to .env.example --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 8de1bb1..c8a25ee 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,12 @@ RTCV_SERVER=http://rtcv_key_ID_here:rtcv_key_here@localhost:4000 # This can be set to a staging server so we don't have to run 2 instances of a scraper # RTCV_ALTERNATIVE_SERVER=http://rtcv_key_ID_here:rtcv_key_here@localhost:4000 +# Optional: route requests through the F2F App instead of directly to RT-CV +# Uses f2f:// (http) or f2fs:// (https) protocol to distinguish from RTCV_SERVER basic auth +# RTCV_SERVER is still required for incoming callback authentication +# F2F_APP=f2fs://keyId:keySecret@app.first2find.nl +# F2F_ALTERNATIVE_APP=f2fs://keyId:keySecret@app.first2find.nl + # Set to true to skip the alive check that checks if the scraper is allowed to scrape. # This is useful for local development when a scraper is disabled on RT-CV. # DO NOT DEPLOY A SCRAPER WITH THIS ENABLED!