From 01a5153b97ce1672e3716613eb8966ca781127d1 Mon Sep 17 00:00:00 2001 From: chrismaz11 Date: Sat, 14 Mar 2026 16:04:58 -0500 Subject: [PATCH] Handle verification API path mismatch with fallback --- README.md | 6 ++ apps/action/dist/index.js | 69 +++++++++++++---- docs/architecture.md | 30 ++++++++ src/trustsignal/client.ts | 84 +++++++++++++++++---- tests/trustsignalClient.test.ts | 130 ++++++++++++++++++++++++++++++++ 5 files changed, 289 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5b4168d..5da8748 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ Required values: Important distinction: - `TRUSTSIGNAL_API_BASE_URL` is the outbound verification API this service calls, for example `https://api.trustsignal.dev`. + - Primary route expected by this service: `${TRUSTSIGNAL_API_BASE_URL}/v1/verifications/github` + - Compatibility route (if needed): `${TRUSTSIGNAL_API_BASE_URL}/api/v1/verifications/github` + + The API base URL is distinct from the webhook host: + - App callback/base webhook host: `https://github.trustsignal.dev` + - Verification API host: `https://api.trustsignal.dev` - The GitHub App webhook host is the public host that receives inbound webhooks, for example `https://github.trustsignal.dev/webhooks/github`. Do not assume those are the same service unless your deployment explicitly serves both route sets. diff --git a/apps/action/dist/index.js b/apps/action/dist/index.js index c213344..50d6cc6 100644 --- a/apps/action/dist/index.js +++ b/apps/action/dist/index.js @@ -4152,6 +4152,7 @@ function mapProvenanceEventName(eventName) { // src/trustsignal/client.ts var TrustSignalVerificationClient = class { baseUrl; + candidatePaths = ["/v1/verifications/github", "/api/v1/verifications/github"]; timeoutMs; fetchImpl; constructor(config, fetchImpl = globalThis.fetch) { @@ -4165,27 +4166,67 @@ var TrustSignalVerificationClient = class { const payload = trustSignalVerificationRequestSchema.parse(request); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + const payloadText = JSON.stringify(payload); + let lastError = null; try { - const response = await this.fetchImpl(`${this.baseUrl}/v1/verifications/github`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${this.apiKey}` - }, - body: JSON.stringify(payload), - signal: controller.signal - }); - const text = await response.text(); - const parsed = text ? JSON.parse(text) : {}; - if (!response.ok) { - throw new Error(`TrustSignal verification request failed with status ${response.status}`); + for (const candidatePath of this.candidatePaths) { + const endpoint = `${this.baseUrl}${candidatePath}`; + const response = await this.fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${this.apiKey}` + }, + body: payloadText, + signal: controller.signal + }); + const text = await response.text(); + const contentType = response.headers?.get("content-type") ?? ""; + const isJson = contentType.includes("application/json") || looksLikeJson(text); + const preview = text.slice(0, 120); + if (!isJson) { + if (response.status === 404 && this.canFallback(candidatePath)) { + lastError = `TrustSignal verification endpoint mismatch for ${endpoint}: response is not JSON`; + continue; + } + throw new Error( + `TrustSignal verification response for ${endpoint} was not JSON (content-type: ${contentType || "missing"}, status: ${response.status}, body: ${preview})` + ); + } + let parsed; + try { + parsed = text ? JSON.parse(text) : {}; + } catch { + if (response.status === 404 && this.canFallback(candidatePath)) { + lastError = `TrustSignal verification endpoint mismatch for ${endpoint}: response body is not valid JSON (status ${response.status})`; + continue; + } + throw new Error(`TrustSignal verification response from ${endpoint} could not be parsed as JSON`); + } + if (!response.ok) { + if (response.status === 404 && this.canFallback(candidatePath)) { + lastError = `TrustSignal verification request to ${endpoint} returned HTTP ${response.status}: ${preview}`; + continue; + } + throw new Error( + `TrustSignal verification request failed with status ${response.status} on ${endpoint}: ${typeof parsed === "object" && parsed !== null && "error" in parsed ? parsed.error : preview}` + ); + } + return trustSignalVerificationResponseSchema.parse(parsed); } - return trustSignalVerificationResponseSchema.parse(parsed); + throw new Error(lastError ?? "TrustSignal verification request failed on all configured endpoint variants"); } finally { clearTimeout(timeout); } } + canFallback(path) { + return path === "/v1/verifications/github"; + } }; +function looksLikeJson(value) { + const text = value.trim(); + return text.startsWith("{") || text.startsWith("["); +} // src/trustsignal/github.ts function normalizeGitHubEventToEnvelope(input) { diff --git a/docs/architecture.md b/docs/architecture.md index f04fc1a..e9508fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,36 @@ Preferred production split: This keeps the website, outbound verification API, and inbound webhook receiver operationally independent. +### Runtime Inventory (Current Production) + +| Domain | Vercel Project | Purpose | Runtime Separation Status | +| --- | --- | --- | --- | +| `trustsignal.dev` | `v0-signal-new` | marketing/docs site | isolated | +| `github.trustsignal.dev` | `trustsignal-github-app` | GitHub App backend | isolated | +| `api.trustsignal.dev` | `api` | verification API (served from separate repo/project) | isolated | + +### Endpoint Contract Clarification + +`TRUSTSIGNAL_API_BASE_URL` must target the API origin only and is currently normalized as: + +- Primary path: `/v1/verifications/github` +- Compatibility path: `/api/v1/verifications/github` (used when the primary route is unavailable) + +If the API is moved or renamed, update `TRUSTSIGNAL_API_BASE_URL` and keep only one canonical base URL. + +### Environment Boundaries by Service + +- GitHub App backend (`trustsignal-github-app`) + - `GITHUB_*`, `TRUSTSIGNAL_API_BASE_URL`, `TRUSTSIGNAL_API_KEY`, `INTERNAL_API_KEY` + - No marketing-site environment variables should be stored here +- API service (`api`) + - API-only secrets and data-layer credentials + - No GitHub App installation credentials +- Marketing/docs site (`v0-signal-new`) + - Static and docs/runtime variables only + +Keep each environment set to only the smallest required surface. + ## Security Model - Fail closed when required GitHub App or webhook secrets are missing. diff --git a/src/trustsignal/client.ts b/src/trustsignal/client.ts index ee3d402..0490fa4 100644 --- a/src/trustsignal/client.ts +++ b/src/trustsignal/client.ts @@ -9,6 +9,9 @@ export interface TrustSignalClientConfig { export interface FetchLikeResponse { ok: boolean; status: number; + headers?: { + get(name: string): string | null; + }; text(): Promise; } @@ -26,6 +29,7 @@ export interface FetchLike { export class TrustSignalVerificationClient { private readonly baseUrl: string; + private readonly candidatePaths = ["/v1/verifications/github", "/api/v1/verifications/github"] as const; private readonly timeoutMs: number; private readonly fetchImpl: FetchLike; @@ -42,28 +46,76 @@ export class TrustSignalVerificationClient { const payload = trustSignalVerificationRequestSchema.parse(request); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + const payloadText = JSON.stringify(payload); + let lastError: string | null = null; try { - const response = await this.fetchImpl(`${this.baseUrl}/v1/verifications/github`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - - const text = await response.text(); - const parsed = text ? (JSON.parse(text) as unknown) : {}; - - if (!response.ok) { - throw new Error(`TrustSignal verification request failed with status ${response.status}`); + for (const candidatePath of this.candidatePaths) { + const endpoint = `${this.baseUrl}${candidatePath}`; + const response = await this.fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${this.apiKey}`, + }, + body: payloadText, + signal: controller.signal, + }); + + const text = await response.text(); + const contentType = response.headers?.get("content-type") ?? ""; + const isJson = contentType.includes("application/json") || looksLikeJson(text); + const preview = text.slice(0, 120); + + if (!isJson) { + if (response.status === 404 && this.canFallback(candidatePath)) { + lastError = `TrustSignal verification endpoint mismatch for ${endpoint}: response is not JSON`; + continue; + } + + throw new Error( + `TrustSignal verification response for ${endpoint} was not JSON (content-type: ${contentType || "missing"}, status: ${response.status}, body: ${preview})` + ); + } + + let parsed: unknown; + try { + parsed = text ? (JSON.parse(text) as unknown) : {}; + } catch { + if (response.status === 404 && this.canFallback(candidatePath)) { + lastError = `TrustSignal verification endpoint mismatch for ${endpoint}: response body is not valid JSON (status ${response.status})`; + continue; + } + + throw new Error(`TrustSignal verification response from ${endpoint} could not be parsed as JSON`); + } + + if (!response.ok) { + if (response.status === 404 && this.canFallback(candidatePath)) { + lastError = `TrustSignal verification request to ${endpoint} returned HTTP ${response.status}: ${preview}`; + continue; + } + + throw new Error( + `TrustSignal verification request failed with status ${response.status} on ${endpoint}: ${typeof parsed === "object" && parsed !== null && "error" in parsed ? (parsed as Record).error : preview}` + ); + } + + return trustSignalVerificationResponseSchema.parse(parsed); } - return trustSignalVerificationResponseSchema.parse(parsed); + throw new Error(lastError ?? "TrustSignal verification request failed on all configured endpoint variants"); } finally { clearTimeout(timeout); } } + + private canFallback(path: (typeof this.candidatePaths)[number]) { + return path === "/v1/verifications/github"; + } +} + +function looksLikeJson(value: string) { + const text = value.trim(); + return text.startsWith("{") || text.startsWith("["); } diff --git a/tests/trustsignalClient.test.ts b/tests/trustsignalClient.test.ts index 5273dce..7e223ea 100644 --- a/tests/trustsignalClient.test.ts +++ b/tests/trustsignalClient.test.ts @@ -61,4 +61,134 @@ describe("TrustSignalVerificationClient", () => { ); expect(result.receiptId).toBe("rcpt_1"); }); + + it("falls back to /api/v1/verifications/github when /v1 returns HTML", async () => { + const fallbackResponse = { + ok: true, + status: 200, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + status: "completed", + conclusion: "success", + title: "Artifact verification completed", + summary: "Verification succeeded", + detailsUrl: "https://trustsignal.example.com/receipts/rcpt_2", + receiptId: "rcpt_2", + verificationTimestamp: "2026-03-13T00:00:00.000Z", + provenanceNote: "workflow_run event workflow_run:101", + }) + ), + headers: new Map([["content-type", "application/json"]]), + }; + + const primaryResponse = { + ok: false, + status: 404, + text: vi.fn().mockResolvedValue("The page could not be found"), + headers: new Map([["content-type", "text/plain; charset=utf-8"]]), + }; + + const fetchImplementation = vi.fn(async (url: string) => { + const response = url === "https://trustsignal.example.com/v1/verifications/github" ? primaryResponse : fallbackResponse; + return { + ok: response.ok, + status: response.status, + headers: { get: (name: string) => response.headers.get(name.toLowerCase()) || response.headers.get(name.toUpperCase()) || null }, + text: response.text, + }; + }); + + const client = new TrustSignalVerificationClient( + { + apiBaseUrl: "https://trustsignal.example.com", + apiKey: "secret", + }, + fetchImplementation as any + ); + + const request = buildTrustSignalVerificationRequest({ + eventName: "workflow_run", + externalId: "workflow_run:101", + summaryContext: "workflow run 101", + headSha: "abc1234", + detailsUrl: "https://github.com/acme/repo/actions/runs/101", + repository: { + owner: "acme", + repo: "repo", + defaultBranch: "main", + htmlUrl: "https://github.com/acme/repo", + }, + provenance: { + conclusion: "success", + event: "push", + runId: 101, + workflowName: "CI", + }, + }); + + const result = await client.verify(request); + + expect(fetchImplementation).toHaveBeenCalledTimes(2); + expect(fetchImplementation).toHaveBeenNthCalledWith( + 1, + "https://trustsignal.example.com/v1/verifications/github", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + authorization: "Bearer secret", + }), + }) + ); + expect(fetchImplementation).toHaveBeenNthCalledWith( + 2, + "https://trustsignal.example.com/api/v1/verifications/github", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + authorization: "Bearer secret", + }), + }) + ); + expect(result.receiptId).toBe("rcpt_2"); + }); + + it("fails closed when verification endpoint returns non-JSON on all supported paths", async () => { + const fetchImplementation = vi.fn(async () => ({ + ok: false, + status: 404, + headers: { get: () => "text/plain; charset=utf-8" }, + text: vi.fn().mockResolvedValue("Not Found"), + })); + + const client = new TrustSignalVerificationClient( + { + apiBaseUrl: "https://trustsignal.example.com", + apiKey: "secret", + }, + fetchImplementation as any + ); + + const request = buildTrustSignalVerificationRequest({ + eventName: "workflow_run", + externalId: "workflow_run:101", + summaryContext: "workflow run 101", + headSha: "abc1234", + detailsUrl: "https://github.com/acme/repo/actions/runs/101", + repository: { + owner: "acme", + repo: "repo", + defaultBranch: "main", + htmlUrl: "https://github.com/acme/repo", + }, + provenance: { + conclusion: "success", + event: "push", + runId: 101, + workflowName: "CI", + }, + }); + + await expect(client.verify(request)).rejects.toThrow("TrustSignal verification response for https://trustsignal.example.com/api/v1/verifications/github was not JSON"); + expect(fetchImplementation).toHaveBeenCalledTimes(2); + }); });