Skip to content

Commit c7a6e87

Browse files
committed
feat(webapp): let the deployment S2 endpoint be overridden
The S2 SDK honours only endpoints passed to its constructor. S2Environment.parse(), which reads the endpoint variables, is an opt-in helper the webapp never called, so deployment event logs always went to hosted S2 and could not be pointed at the local s2 container in docker compose. Realtime streams already had this knob. S2_DEPLOYMENT_ENDPOINT is a single value covering both the account and basin hosts. Two separate variables would let a half-set config send the access token to the hosted service while the operator believed the client was entirely local. With the variable unset the options object carries no endpoints key at all, so the call into the SDK is the one production already makes. An endpoints key with undefined members resolves to the same hosted URLs, so no assertion on the built client would catch a regression there; the options builder is asserted directly instead, from a module that reads no environment. The value is trimmed and validated: https anywhere, http only for a loopback host. The access token is sent to whatever is configured as a bearer token, so cleartext off the machine would leak it. Zod url() is too weak for this on its own, accepting both "htp:/localhost" and a plain http host.
1 parent fe94700 commit c7a6e87

8 files changed

Lines changed: 163 additions & 4 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BoolEnv } from "./utils/boolEnv";
44
import { isValidDatabaseUrl } from "./utils/db";
55
import { parseRunOpsShards, validateShardListAgainstNewUrl } from "~/v3/runOpsShards.server";
66
import { isValidRegex } from "./utils/regex";
7+
import { isValidS2Endpoint } from "./utils/s2Endpoint";
78
import { isValidDuration } from "./services/realtime/duration.server";
89

910
// `z.string()` constrained to a `parseDuration`-parseable string (e.g.
@@ -79,6 +80,19 @@ const S2EnvSchema = z.preprocess(
7980
S2_ACCESS_TOKEN: z.string(),
8081
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
8182
S2_DEPLOYMENT_STREAMS_LOCAL: z.string().default("0"),
83+
// Points deployment event logs at an S2 service other than the hosted one, e.g. the
84+
// local s2-lite in docker compose. One value covers both the account and basin
85+
// endpoints: splitting them lets a half-set config send the access token to the
86+
// hosted service while the operator believes they are entirely local.
87+
S2_DEPLOYMENT_ENDPOINT: z
88+
.string()
89+
.trim()
90+
.transform((value) => (value === "" ? undefined : value))
91+
.refine(
92+
(value) => value === undefined || isValidS2Endpoint(value),
93+
"must be an http(s) URL; http is only allowed for a loopback host, because the S2 access token is sent to it as a bearer token"
94+
)
95+
.optional(),
8296
}),
8397
z.object({
8498
S2_ENABLED: z.literal("0"),

apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { type User } from "~/models/user.server";
1313
import { getUsername } from "~/utils/username";
1414
import { processGitMetadata } from "./BranchesPresenter.server";
1515
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
16-
import { S2 } from "@s2-dev/streamstore";
16+
import { createDeploymentS2Client } from "~/v3/s2Client.server";
1717
import { env } from "~/env.server";
1818
import { createRedisClient } from "~/redis.server";
1919
import { tryCatch } from "@trigger.dev/core";
@@ -30,7 +30,7 @@ const s2TokenRedis = createRedisClient("s2-token-cache", {
3030
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
3131
});
3232

33-
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
33+
const s2 = createDeploymentS2Client();
3434

3535
export type ErrorData = {
3636
name: string;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
2+
3+
// The S2 access token is sent as a bearer header to whatever endpoint is configured, so cleartext
4+
// is only acceptable when the endpoint cannot leave the machine.
5+
export function isValidS2Endpoint(value: string): boolean {
6+
let url: URL;
7+
try {
8+
url = new URL(value);
9+
} catch {
10+
return false;
11+
}
12+
13+
if (url.protocol === "https:") {
14+
return true;
15+
}
16+
17+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
18+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { S2 } from "@s2-dev/streamstore";
2+
import { env } from "~/env.server";
3+
import { buildDeploymentS2Client } from "~/v3/s2ClientConfig";
4+
5+
export function createDeploymentS2Client(): S2 | undefined {
6+
if (env.S2_ENABLED !== "1") {
7+
return undefined;
8+
}
9+
10+
return buildDeploymentS2Client({
11+
accessToken: env.S2_ACCESS_TOKEN,
12+
endpoint: env.S2_DEPLOYMENT_ENDPOINT,
13+
});
14+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { S2 } from "@s2-dev/streamstore";
2+
3+
export type DeploymentS2Config = {
4+
accessToken: string;
5+
endpoint?: string;
6+
};
7+
8+
type DeploymentS2ClientOptions = {
9+
accessToken: string;
10+
endpoints?: { account: string; basin: string };
11+
};
12+
13+
// Exported so a test can pin the shape handed to the SDK: with no endpoint the options must carry
14+
// no `endpoints` key, matching the call production already makes.
15+
export function deploymentS2ClientOptions({
16+
accessToken,
17+
endpoint,
18+
}: DeploymentS2Config): DeploymentS2ClientOptions {
19+
if (endpoint === undefined) {
20+
return { accessToken };
21+
}
22+
23+
// One value drives both hosts. Overriding just one would send the access token to the hosted
24+
// service while the other half went elsewhere.
25+
return { accessToken, endpoints: { account: endpoint, basin: endpoint } };
26+
}
27+
28+
export function buildDeploymentS2Client(config: DeploymentS2Config): S2 {
29+
return new S2(deploymentS2ClientOptions(config));
30+
}

apps/webapp/app/v3/services/deployment.server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
import { FEATURE_FLAG, type FeatureFlagKey } from "../featureFlags";
2424
import { flags } from "../featureFlags.server";
2525
import { globalFlagsRegistry } from "../globalFlagsRegistry.server";
26-
import { AppendInput, AppendRecord, S2 } from "@s2-dev/streamstore";
26+
import { AppendInput, AppendRecord } from "@s2-dev/streamstore";
27+
import { createDeploymentS2Client } from "~/v3/s2Client.server";
2728
import { createRedisClient } from "~/redis.server";
2829

2930
const S2_TOKEN_KEY_PREFIX = "s2-token:read:deployment-event-stream:project:";
@@ -35,7 +36,7 @@ const s2TokenRedis = createRedisClient("s2-token-cache", {
3536
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
3637
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
3738
});
38-
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
39+
const s2 = createDeploymentS2Client();
3940

4041
const DEPLOY_BUILD_PATH_ENV_FLAG: Partial<Record<RuntimeEnvironmentType, FeatureFlagKey>> = {
4142
PREVIEW: FEATURE_FLAG.deployBuildPathPreview,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildDeploymentS2Client, deploymentS2ClientOptions } from "~/v3/s2ClientConfig";
3+
4+
const BASIN = "trigger-local";
5+
6+
describe("buildDeploymentS2Client", () => {
7+
// The SDK resolves an endpoints key with undefined members to the same hosted URLs, so nothing
8+
// on the built client distinguishes the two calls. Assert on the options instead.
9+
it("hands the SDK no endpoints key at all when no endpoint is configured", () => {
10+
expect(deploymentS2ClientOptions({ accessToken: "token" })).toEqual({ accessToken: "token" });
11+
expect(deploymentS2ClientOptions({ accessToken: "token" })).not.toHaveProperty("endpoints");
12+
});
13+
14+
it("hands the SDK one endpoint for both hosts when configured", () => {
15+
expect(
16+
deploymentS2ClientOptions({ accessToken: "token", endpoint: "http://localhost:4566" })
17+
).toEqual({
18+
accessToken: "token",
19+
endpoints: { account: "http://localhost:4566", basin: "http://localhost:4566" },
20+
});
21+
});
22+
23+
it("still resolves the SDK's hosted defaults when no endpoint is configured", () => {
24+
const client = buildDeploymentS2Client({ accessToken: "token" });
25+
26+
expect(client.endpoints.accountBaseUrl()).toBe("https://a.s2.dev/v1");
27+
expect(client.endpoints.basinBaseUrl(BASIN)).toBe(`https://${BASIN}.b.s2.dev/v1`);
28+
expect(client.endpoints.includeBasinHeader).toBe(false);
29+
});
30+
31+
it("points both the account and basin hosts at a configured endpoint", () => {
32+
const client = buildDeploymentS2Client({
33+
accessToken: "token",
34+
endpoint: "http://localhost:4566",
35+
});
36+
37+
expect(client.endpoints.accountBaseUrl()).toBe("http://localhost:4566/v1");
38+
expect(client.endpoints.basinBaseUrl(BASIN)).toBe("http://localhost:4566/v1");
39+
expect(client.endpoints.includeBasinHeader).toBe(true);
40+
});
41+
42+
// A split configuration would send the access token to the hosted service while the operator
43+
// believed the client was entirely local, so one value has to drive both hosts.
44+
it("never leaves one host hosted while the other is overridden", () => {
45+
const client = buildDeploymentS2Client({
46+
accessToken: "token",
47+
endpoint: "http://localhost:4566",
48+
});
49+
50+
expect(client.endpoints.accountBaseUrl()).not.toContain("s2.dev");
51+
expect(client.endpoints.basinBaseUrl(BASIN)).not.toContain("s2.dev");
52+
});
53+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isValidS2Endpoint } from "~/utils/s2Endpoint";
3+
4+
describe("isValidS2Endpoint", () => {
5+
it("accepts https anywhere", () => {
6+
expect(isValidS2Endpoint("https://a.s2.dev")).toBe(true);
7+
expect(isValidS2Endpoint("https://s2.internal:4566/v1")).toBe(true);
8+
});
9+
10+
it("accepts http only for a loopback host", () => {
11+
expect(isValidS2Endpoint("http://localhost:4566")).toBe(true);
12+
expect(isValidS2Endpoint("http://127.0.0.1:4566")).toBe(true);
13+
expect(isValidS2Endpoint("http://[::1]:4566")).toBe(true);
14+
});
15+
16+
// The access token is sent to whatever is configured, so cleartext off-box is a credential leak.
17+
it("rejects http to a non-loopback host", () => {
18+
expect(isValidS2Endpoint("http://s2.example.com")).toBe(false);
19+
expect(isValidS2Endpoint("http://10.0.0.5:4566")).toBe(false);
20+
});
21+
22+
// All four pass zod's `.url()`, which is why the schema refines on this instead.
23+
it("rejects malformed and non-http schemes", () => {
24+
expect(isValidS2Endpoint("htp:/localhost:4566")).toBe(false);
25+
expect(isValidS2Endpoint("ftp://localhost")).toBe(false);
26+
expect(isValidS2Endpoint("not a url")).toBe(false);
27+
expect(isValidS2Endpoint("")).toBe(false);
28+
});
29+
});

0 commit comments

Comments
 (0)