Skip to content

Commit 6c16d8b

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 is accepted anywhere, http only to a host that is not reachable from the public internet: loopback, a single-label container or service name as the self-hosted stack uses, or a private address. The access token is sent to whatever is configured as a bearer token, so cleartext to a routable host would leak it, and zod url() is too weak for this alone, accepting both "htp:/localhost" and any public http host. Cached S2 read tokens are scoped by endpoint. They are issued by whichever service the endpoint names and Redis outlives a restart, so a key scoped only by project would serve a token from the previous service for the rest of its hour. Hosted keeps its existing keys, so nothing is invalidated where the endpoint cannot change.
1 parent fe94700 commit 6c16d8b

11 files changed

Lines changed: 222 additions & 6 deletions

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: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ 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";
2020
import { logger } from "~/services/logger.server";
21+
import { s2CacheScope } from "~/v3/s2CacheScope";
2122

2223
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
2324

@@ -30,7 +31,7 @@ const s2TokenRedis = createRedisClient("s2-token-cache", {
3031
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
3132
});
3233

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

3536
export type ErrorData = {
3637
name: string;
@@ -286,7 +287,7 @@ export class DeploymentPresenter {
286287
throw new Error("Failed getting S2 access token: S2 is not enabled");
287288
}
288289

289-
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
290+
const redisKey = `${S2_TOKEN_KEY_PREFIX}${s2CacheScope(env.S2_DEPLOYMENT_ENDPOINT)}${projectRef}`;
290291
const cachedToken = await s2TokenRedis.get(redisKey);
291292

292293
if (cachedToken) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
2+
3+
function isPrivateIpv4(hostname: string): boolean {
4+
const octets = hostname.split(".");
5+
if (octets.length !== 4 || octets.some((o) => !/^\d{1,3}$/.test(o))) {
6+
return false;
7+
}
8+
9+
const [a, b] = octets.map(Number) as [number, number, number, number];
10+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
11+
}
12+
13+
// A single-label hostname (no dot) is a container or service name on a private network, which is
14+
// how the self-hosted stack reaches S2, e.g. `http://s2/v1`.
15+
function isPrivateHost(hostname: string): boolean {
16+
return LOOPBACK_HOSTS.has(hostname) || !hostname.includes(".") || isPrivateIpv4(hostname);
17+
}
18+
19+
// The S2 access token is sent as a bearer header to whatever endpoint is configured, so cleartext
20+
// is only acceptable to a host that is not reachable from the public internet.
21+
export function isValidS2Endpoint(value: string): boolean {
22+
let url: URL;
23+
try {
24+
url = new URL(value);
25+
} catch {
26+
return false;
27+
}
28+
29+
if (url.protocol === "https:") {
30+
return true;
31+
}
32+
33+
return url.protocol === "http:" && url.hostname !== "" && isPrivateHost(url.hostname);
34+
}

apps/webapp/app/v3/s2CacheScope.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Cached S2 read tokens are issued by whichever S2 service the endpoint names, and Redis outlives
2+
// a restart, so a key scoped only by project would serve a token from the previous service after
3+
// the endpoint changes. Hosted keeps its existing unscoped keys so nothing is invalidated.
4+
export function s2CacheScope(endpoint: string | undefined): string {
5+
return endpoint === undefined ? "" : `endpoint:${endpoint}:`;
6+
}
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: 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 "./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: 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: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ 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";
29+
import { s2CacheScope } from "~/v3/s2CacheScope";
2830

2931
const S2_TOKEN_KEY_PREFIX = "s2-token:read:deployment-event-stream:project:";
3032
const s2TokenRedis = createRedisClient("s2-token-cache", {
@@ -35,7 +37,7 @@ const s2TokenRedis = createRedisClient("s2-token-cache", {
3537
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
3638
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
3739
});
38-
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
40+
const s2 = createDeploymentS2Client();
3941

4042
const DEPLOY_BUILD_PATH_ENV_FLAG: Partial<Record<RuntimeEnvironmentType, FeatureFlagKey>> = {
4143
PREVIEW: FEATURE_FLAG.deployBuildPathPreview,
@@ -522,7 +524,7 @@ export class DeploymentService extends BaseService {
522524
return errAsync({ type: "s2_is_disabled" as const });
523525
}
524526
const basinName = env.S2_DEPLOYMENT_LOGS_BASIN_NAME;
525-
const redisKey = `${S2_TOKEN_KEY_PREFIX}${project.externalRef}`;
527+
const redisKey = `${S2_TOKEN_KEY_PREFIX}${s2CacheScope(env.S2_DEPLOYMENT_ENDPOINT)}${project.externalRef}`;
526528

527529
const getTokenFromCache = () =>
528530
fromPromise(s2TokenRedis.get(redisKey), (error) => ({
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from "vitest";
2+
import { s2CacheScope } from "~/v3/s2CacheScope";
3+
4+
describe("s2CacheScope", () => {
5+
// Hosted must keep the keys it already has in Redis, or every project takes a needless miss.
6+
it("adds nothing when no endpoint is configured", () => {
7+
expect(s2CacheScope(undefined)).toBe("");
8+
});
9+
10+
// Redis outlives a restart, so a token issued by the previous S2 service must not be served
11+
// once the endpoint changes.
12+
it("gives each endpoint its own namespace", () => {
13+
const local = s2CacheScope("http://localhost:4566");
14+
const other = s2CacheScope("http://s2/v1");
15+
16+
expect(local).not.toBe("");
17+
expect(local).not.toBe(other);
18+
expect(local).toBe(s2CacheScope("http://localhost:4566"));
19+
});
20+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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 to 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+
// This is how the self-hosted stack reaches S2, so rejecting it would force TLS on a private
17+
// container network.
18+
it("accepts http to a container or service name and a private address", () => {
19+
expect(isValidS2Endpoint("http://s2/v1")).toBe(true);
20+
expect(isValidS2Endpoint("http://s2:80/v1")).toBe(true);
21+
expect(isValidS2Endpoint("http://10.0.0.5:4566")).toBe(true);
22+
expect(isValidS2Endpoint("http://172.20.0.3")).toBe(true);
23+
expect(isValidS2Endpoint("http://192.168.1.9")).toBe(true);
24+
});
25+
26+
// The access token is sent to whatever is configured, so cleartext to a routable host leaks it.
27+
it("rejects http to a public host", () => {
28+
expect(isValidS2Endpoint("http://s2.example.com")).toBe(false);
29+
expect(isValidS2Endpoint("http://a.s2.dev")).toBe(false);
30+
expect(isValidS2Endpoint("http://8.8.8.8")).toBe(false);
31+
expect(isValidS2Endpoint("http://172.32.0.1")).toBe(false);
32+
});
33+
34+
// All four pass zod's `.url()`, which is why the schema refines on this instead.
35+
it("rejects malformed and non-http schemes", () => {
36+
expect(isValidS2Endpoint("htp:/localhost:4566")).toBe(false);
37+
expect(isValidS2Endpoint("ftp://localhost")).toBe(false);
38+
expect(isValidS2Endpoint("not a url")).toBe(false);
39+
expect(isValidS2Endpoint("")).toBe(false);
40+
});
41+
});

0 commit comments

Comments
 (0)