Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 55 additions & 14 deletions apps/action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 68 additions & 16 deletions src/trustsignal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export interface TrustSignalClientConfig {
export interface FetchLikeResponse {
ok: boolean;
status: number;
headers?: {
get(name: string): string | null;
};
text(): Promise<string>;
}

Expand All @@ -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;

Expand All @@ -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<string, unknown>).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("[");
}
130 changes: 130 additions & 0 deletions tests/trustsignalClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading