Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 6 additions & 2 deletions .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,13 @@ jobs:
THREESPEAK_EMBED_API_KEY: ${{secrets.THREESPEAK_EMBED_API_KEY}}
SEO_CRON_SECRET: ${{secrets.SEO_CRON_SECRET}}
TURNSTILE_SECRET: ${{secrets.TURNSTILE_SECRET}}
SSR_INTERNAL_SECRET: ${{secrets.SSR_INTERNAL_SECRET}}
with:
host: ${{ secrets.SSH_HOST_EU }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
envs: USE_PRIVATE,PRIVATE_API_ADDR,PRIVATE_API_AUTH,HIVESIGNER_SECRET,SEARCH_API_ADDR,SEARCH_API_SECRET,STRIPE_INTERNAL_SECRET,HOSTING_INTERNAL_SECRET,HIVESEARCHER_ORIGIN_IP,PLAUSIBLE_API_KEY,BLOCKSTREAM_CLIENT_ID,BLOCKSTREAM_CLIENT_SECRET,MATTERMOST_TEAM_ID,MATTERMOST_ADMIN_TOKEN,MATTERMOST_BASE_URL,MATTERMOST_WS_ALLOWED_ORIGINS,THREESPEAK_EMBED_API_KEY,SEO_CRON_SECRET,TURNSTILE_SECRET,NEWSLETTER_API_URL,NEWSLETTER_SERVICE_TOKEN
envs: USE_PRIVATE,PRIVATE_API_ADDR,PRIVATE_API_AUTH,HIVESIGNER_SECRET,SEARCH_API_ADDR,SEARCH_API_SECRET,STRIPE_INTERNAL_SECRET,HOSTING_INTERNAL_SECRET,HIVESEARCHER_ORIGIN_IP,PLAUSIBLE_API_KEY,BLOCKSTREAM_CLIENT_ID,BLOCKSTREAM_CLIENT_SECRET,MATTERMOST_TEAM_ID,MATTERMOST_ADMIN_TOKEN,MATTERMOST_BASE_URL,MATTERMOST_WS_ALLOWED_ORIGINS,THREESPEAK_EMBED_API_KEY,SEO_CRON_SECRET,TURNSTILE_SECRET,NEWSLETTER_API_URL,NEWSLETTER_SERVICE_TOKEN,SSR_INTERNAL_SECRET
script: |
set -eo pipefail
export USE_PRIVATE=$USE_PRIVATE
Expand All @@ -166,6 +167,7 @@ jobs:
export THREESPEAK_EMBED_API_KEY=$THREESPEAK_EMBED_API_KEY
export SEO_CRON_SECRET=$SEO_CRON_SECRET
export TURNSTILE_SECRET=$TURNSTILE_SECRET
export SSR_INTERNAL_SECRET=$SSR_INTERNAL_SECRET
cd ~/vision-web
git pull origin main
cd apps/web
Expand Down Expand Up @@ -234,6 +236,7 @@ jobs:
THREESPEAK_EMBED_API_KEY: ${{secrets.THREESPEAK_EMBED_API_KEY}}
SEO_CRON_SECRET: ${{secrets.SEO_CRON_SECRET}}
TURNSTILE_SECRET: ${{secrets.TURNSTILE_SECRET}}
SSR_INTERNAL_SECRET: ${{secrets.SSR_INTERNAL_SECRET}}
# Newsletter service lives on the EU box; the US tier reaches it through the TLS relay
# on the EU origin (api.blogs.ecency.com/newsletter-relay, IP-allowlisted), so this
# region's URL is its own secret. Same token.
Expand All @@ -248,7 +251,7 @@ jobs:
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
envs: USE_PRIVATE,PRIVATE_API_ADDR,PRIVATE_API_AUTH,HIVESIGNER_SECRET,SEARCH_API_ADDR,SEARCH_API_SECRET,STRIPE_INTERNAL_SECRET,HOSTING_INTERNAL_SECRET,HIVESEARCHER_ORIGIN_IP,PLAUSIBLE_API_KEY,BLOCKSTREAM_CLIENT_ID,BLOCKSTREAM_CLIENT_SECRET,MATTERMOST_TEAM_ID,MATTERMOST_ADMIN_TOKEN,MATTERMOST_BASE_URL,MATTERMOST_WS_ALLOWED_ORIGINS,THREESPEAK_EMBED_API_KEY,SEO_CRON_SECRET,TURNSTILE_SECRET,NEWSLETTER_API_URL,NEWSLETTER_SERVICE_TOKEN,WEB_REPLICAS
envs: USE_PRIVATE,PRIVATE_API_ADDR,PRIVATE_API_AUTH,HIVESIGNER_SECRET,SEARCH_API_ADDR,SEARCH_API_SECRET,STRIPE_INTERNAL_SECRET,HOSTING_INTERNAL_SECRET,HIVESEARCHER_ORIGIN_IP,PLAUSIBLE_API_KEY,BLOCKSTREAM_CLIENT_ID,BLOCKSTREAM_CLIENT_SECRET,MATTERMOST_TEAM_ID,MATTERMOST_ADMIN_TOKEN,MATTERMOST_BASE_URL,MATTERMOST_WS_ALLOWED_ORIGINS,THREESPEAK_EMBED_API_KEY,SEO_CRON_SECRET,TURNSTILE_SECRET,NEWSLETTER_API_URL,NEWSLETTER_SERVICE_TOKEN,WEB_REPLICAS,SSR_INTERNAL_SECRET
script: |
set -eo pipefail
export USE_PRIVATE=$USE_PRIVATE
Expand All @@ -266,6 +269,7 @@ jobs:
export THREESPEAK_EMBED_API_KEY=$THREESPEAK_EMBED_API_KEY
export SEO_CRON_SECRET=$SEO_CRON_SECRET
export TURNSTILE_SECRET=$TURNSTILE_SECRET
export SSR_INTERNAL_SECRET=$SSR_INTERNAL_SECRET
# The web tier decides per request whether the newsletter service is configured, so a
# missing secret would ship a silent no-show. Fail loudly instead.
: "${NEWSLETTER_API_URL:?NEWSLETTER_API_URL is required for the US deploy}"
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ jobs:
THREESPEAK_EMBED_API_KEY: ${{secrets.THREESPEAK_EMBED_API_KEY}}
SEO_CRON_SECRET: ${{secrets.SEO_CRON_SECRET}}
TURNSTILE_SECRET: ${{secrets.TURNSTILE_SECRET}}
SSR_INTERNAL_SECRET: ${{secrets.SSR_INTERNAL_SECRET}}
# Newsletter service (ecency/news) lives on the EU production box; staging reaches
# it through the IP-allowlisted TLS relay on the EU origin, same as US, with its own
# URL secret. There is one service and one database, so subscriptions made on alpha
Expand All @@ -147,7 +148,7 @@ jobs:
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
envs: USE_PRIVATE,PRIVATE_API_ADDR,PRIVATE_API_AUTH,HIVESIGNER_SECRET,SEARCH_API_ADDR,SEARCH_API_SECRET,STRIPE_INTERNAL_SECRET,HOSTING_INTERNAL_SECRET,HIVESEARCHER_ORIGIN_IP,PLAUSIBLE_API_KEY,BLOCKSTREAM_CLIENT_ID,BLOCKSTREAM_CLIENT_SECRET,MATTERMOST_TEAM_ID,MATTERMOST_ADMIN_TOKEN,MATTERMOST_BASE_URL,MATTERMOST_WS_ALLOWED_ORIGINS,THREESPEAK_EMBED_API_KEY,SEO_CRON_SECRET,TURNSTILE_SECRET,NEWSLETTER_API_URL,NEWSLETTER_SERVICE_TOKEN
envs: USE_PRIVATE,PRIVATE_API_ADDR,PRIVATE_API_AUTH,HIVESIGNER_SECRET,SEARCH_API_ADDR,SEARCH_API_SECRET,STRIPE_INTERNAL_SECRET,HOSTING_INTERNAL_SECRET,HIVESEARCHER_ORIGIN_IP,PLAUSIBLE_API_KEY,BLOCKSTREAM_CLIENT_ID,BLOCKSTREAM_CLIENT_SECRET,MATTERMOST_TEAM_ID,MATTERMOST_ADMIN_TOKEN,MATTERMOST_BASE_URL,MATTERMOST_WS_ALLOWED_ORIGINS,THREESPEAK_EMBED_API_KEY,SEO_CRON_SECRET,TURNSTILE_SECRET,NEWSLETTER_API_URL,NEWSLETTER_SERVICE_TOKEN,SSR_INTERNAL_SECRET
script: |
export USE_PRIVATE=$USE_PRIVATE
export PRIVATE_API_ADDR=$PRIVATE_API_ADDR
Expand All @@ -164,6 +165,7 @@ jobs:
export THREESPEAK_EMBED_API_KEY=$THREESPEAK_EMBED_API_KEY
export SEO_CRON_SECRET=$SEO_CRON_SECRET
export TURNSTILE_SECRET=$TURNSTILE_SECRET
export SSR_INTERNAL_SECRET=$SSR_INTERNAL_SECRET
# Unlike production this does not fail without the newsletter settings: staging may run
# without the feature. It says so, because the web hides the controls silently otherwise.
if [ -z "$NEWSLETTER_API_URL" ] || [ -z "$NEWSLETTER_SERVICE_TOKEN" ]; then
Expand Down
10 changes: 10 additions & 0 deletions apps/web/docker-compose.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ services:
- SEARCH_API_ADDR
- SEARCH_API_SECRET
- TURNSTILE_SECRET
# Shared secret for the internal SSR RPC cache (/private-api/ssr/*); the
# web service sends it on every proxied read. Unset = those routes are off.
- SSR_INTERNAL_SECRET
- STRIPE_INTERNAL_SECRET
- BLOCKSTREAM_CLIENT_ID
- BLOCKSTREAM_CLIENT_SECRET
Expand Down Expand Up @@ -83,6 +86,13 @@ services:
- MATTERMOST_WS_ALLOWED_ORIGINS
- THREESPEAK_EMBED_API_KEY
- SEO_CRON_SECRET
# Server-side read-through RPC proxy (core/sdk-init.ts): reads go to
# vapi's /private-api/ssr/rpc over the overlay, with fallback to the node
# pool on any failure. Needs the same secret vapi was given above. The
# switch is an explicit value here, not a passthrough: production flips
# to 1 through a reviewed change to this line, after the alpha soak.
- SSR_INTERNAL_SECRET
- SSR_RPC_PROXY=0
# Newsletter service (ecency/news), single instance on the EU box. EU reaches it
# on the swarm gateway address, US through the IP-allowlisted TLS relay on the EU
# origin; each deploy job supplies its own URL and refuses to deploy without them.
Expand Down
11 changes: 11 additions & 0 deletions apps/web/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ services:
- SEARCH_API_ADDR
- SEARCH_API_SECRET
- TURNSTILE_SECRET
# Shared secret for the internal SSR RPC cache (/private-api/ssr/*); the
# web service sends it on every proxied read. Unset = those routes are off.
- SSR_INTERNAL_SECRET
- BLOCKSTREAM_CLIENT_ID
- BLOCKSTREAM_CLIENT_SECRET
# Pinned to Warning at deploy time (the CI deploy env does not forward
Expand Down Expand Up @@ -85,6 +88,14 @@ services:
- MATTERMOST_WS_ALLOWED_ORIGINS
- THREESPEAK_EMBED_API_KEY
- SEO_CRON_SECRET
# Server-side read-through RPC proxy (core/sdk-init.ts): reads go to
# vapi's /private-api/ssr/rpc over the overlay, with fallback to the node
# pool on any failure. Needs the same secret vapi was given above; the
# flag switches it on per deployment.
# The switch is on here, so the secret is required: a deploy without it
# would silently run without the proxy instead of exercising it.
- SSR_INTERNAL_SECRET=${SSR_INTERNAL_SECRET:?SSR_INTERNAL_SECRET is required while SSR_RPC_PROXY=1}
- SSR_RPC_PROXY=1
restart: always
ports:
- "3000:3000"
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/core/sdk-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ if (isServer) {
// This bounds SSR render time when a node slows or throttles under a spike,
// instead of stalled renders piling up into heap exhaustion.
ConfigManager.setResilience({ hedge: true });

// Server-side read-through RPC proxy (vapi's /private-api/ssr/rpc): the
// allowlisted reads every render makes (accounts, profiles, communities,
// per-tag feeds, posts) are answered from one cache per host instead of
// being fetched by every renderer process on its own. An optimization, not
// a dependency: the SDK falls back to the node pool on any proxy failure.
// Switched on per deployment (SSR_RPC_PROXY=1) and only when both sides
// carry the shared secret; INTERNAL_API_HOST is the overlay route to vapi.
const proxyHost = process.env.INTERNAL_API_HOST;
const proxySecret = process.env.SSR_INTERNAL_SECRET;
if (process.env.SSR_RPC_PROXY === "1" && proxyHost && proxySecret) {
ConfigManager.setServerRpcProxy({
url: `${proxyHost.replace(/\/+$/, "")}/private-api/ssr/rpc`,
headers: { "X-Ecency-Internal": proxySecret },
timeoutMs: 2000
});
}
}

// Initialize DMCA filtering immediately at module load time
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/specs/core/sdk-init-proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";

/**
* core/sdk-init.ts switches the server-side RPC proxy on at import time, and
* only when the deployment asked for it AND both halves of the wiring are
* present. A module with import-time side effects, so each case gets a fresh
* module registry and its own environment.
*/
const manager = {
setPrivateApiHost: vi.fn(),
setImageHost: vi.fn(),
setHiveNodes: vi.fn(),
setUserAgent: vi.fn(),
setResilience: vi.fn(),
setServerRpcProxy: vi.fn(),
setDmcaLists: vi.fn()
};
vi.mock("@ecency/sdk", () => ({ ConfigManager: manager }));

async function load(env: Record<string, string | undefined>): Promise<void> {
vi.resetModules();
for (const [k, v] of Object.entries(env)) {
vi.stubEnv(k, v ?? "");
}
await import("@/core/sdk-init");
}

afterEach(() => {
vi.unstubAllEnvs();
manager.setServerRpcProxy.mockClear();
});

describe("sdk-init server rpc proxy", () => {
it("enables the proxy against the overlay host with the shared secret when switched on", async () => {
await load({ SSR_RPC_PROXY: "1", SSR_INTERNAL_SECRET: "s3cret", INTERNAL_API_HOST: "http://vapi:4000/" });
expect(manager.setServerRpcProxy).toHaveBeenCalledWith({
url: "http://vapi:4000/private-api/ssr/rpc",
headers: { "X-Ecency-Internal": "s3cret" },
timeoutMs: 2000
});
});

it.each([
["the switch is off", { SSR_RPC_PROXY: undefined, SSR_INTERNAL_SECRET: "s3cret", INTERNAL_API_HOST: "http://vapi:4000" }],
["the secret is missing", { SSR_RPC_PROXY: "1", SSR_INTERNAL_SECRET: undefined, INTERNAL_API_HOST: "http://vapi:4000" }],
["the host is missing", { SSR_RPC_PROXY: "1", SSR_INTERNAL_SECRET: "s3cret", INTERNAL_API_HOST: undefined }]
])("stays off when %s", async (_label, env) => {
await load(env);
expect(manager.setServerRpcProxy).not.toHaveBeenCalled();
});
});
75 changes: 75 additions & 0 deletions apps/web/src/specs/deploy/ssr-proxy-wiring.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

/**
* The server-side RPC proxy is decided at process start in core/sdk-init.ts
* from SSR_RPC_PROXY, SSR_INTERNAL_SECRET and INTERNAL_API_HOST, and vapi only
* switches its side on when it holds the same secret. A variable missing from
* any one of these places is silent: the SDK simply keeps going to the node
* pool. This pins the whole chain: both services in both stack files, the
* deploy jobs forwarding the secret, and the origin proxy hiding the path.
*/
const root = join(__dirname, "..", "..", "..", "..", "..");
const read = (p: string): string => readFileSync(join(root, p), "utf8");

function serviceBlock(compose: string, name: string): string {
const lines = compose.split("\n");
const start = lines.findIndex((l) => l === ` ${name}:`);
if (start < 0) throw new Error(`service ${name} not found`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^ [A-Za-z_-]+:/.test(lines[i]) || /^[A-Za-z_-]+:/.test(lines[i])) {
end = i;
break;
}
}
return lines.slice(start, end).join("\n");
}

const envEntries = (block: string): string[] =>
block.split("\n").filter((l) => /^ - [A-Z_]+/.test(l)).map((l) => l.trim().slice(2));
const envNames = (entries: string[]): string[] => entries.map((e) => e.split("=")[0]);

describe("ssr rpc proxy deploy wiring", () => {
it.each(["apps/web/docker-compose.yml", "apps/web/docker-compose.production.yml"])(
"%s hands the secret to vapi and to web, and web carries the switch",
(file) => {
const compose = read(file);
expect(envEntries(serviceBlock(compose, "vapi")), `${file}: vapi`).toContain("SSR_INTERNAL_SECRET");
const web = envEntries(serviceBlock(compose, "web"));
expect(envNames(web), `${file}: web`).toContain("SSR_INTERNAL_SECRET");
expect(web.some((e) => e === "SSR_RPC_PROXY=0" || e === "SSR_RPC_PROXY=1"), `${file}: web switch`).toBe(true);
}
);

it("alpha has the switch on and requires the secret; production carries an explicit value, never a bare passthrough", () => {
const alpha = envEntries(serviceBlock(read("apps/web/docker-compose.yml"), "web"));
expect(alpha).toContain("SSR_RPC_PROXY=1");
expect(alpha.some((e) => /^SSR_INTERNAL_SECRET=\$\{SSR_INTERNAL_SECRET:\?/.test(e))).toBe(true);
const prod = envEntries(serviceBlock(read("apps/web/docker-compose.production.yml"), "web"));
expect(prod).not.toContain("SSR_RPC_PROXY");
expect(prod.some((e) => /^SSR_RPC_PROXY=[01]$/.test(e))).toBe(true);
});

it.each(["master.yml", "staging.yml"])(".github/workflows/%s forwards the secret end to end", (file) => {
const wf = read(`.github/workflows/${file}`);
const steps = wf.split("appleboy/ssh-action").length - 1;
expect(steps).toBeGreaterThan(0);
expect(wf.match(/SSR_INTERNAL_SECRET: \$\{\{secrets\.SSR_INTERNAL_SECRET\}\}/g)?.length, `${file}: env`).toBe(steps);
expect(wf.match(/envs: [^\n]*SSR_INTERNAL_SECRET/g)?.length, `${file}: envs`).toBe(steps);
expect(wf.match(/export SSR_INTERNAL_SECRET=\$SSR_INTERNAL_SECRET/g)?.length, `${file}: export`).toBe(steps);
});

it.each(["infra/origin/eu.ecency.com.conf", "infra/origin/us.ecency.com.conf"])(
"%s hides the proxy path from outside, ahead of the generic private-api location",
(file) => {
const conf = read(file);
const hidden = conf.indexOf("location ~ ^/private-api/ssr/");
const generic = conf.indexOf("location ~ ^/(private-api|search-api|wallet-api|auth-api)");
expect(hidden, `${file}: location`).toBeGreaterThan(-1);
expect(hidden, `${file}: order`).toBeLessThan(generic);
expect(conf.slice(hidden, generic)).toContain("return 404;");
}
);
});
6 changes: 6 additions & 0 deletions infra/origin/eu.ecency.com.conf
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ server {
proxy_pass http://127.0.0.1:4000;
}

# Internal SSR RPC cache: the web tier reaches it over the swarm network;
# from outside the path does not exist.
location ~ ^/private-api/ssr/ {
return 404;
}

location ~ ^/(private-api|search-api|wallet-api|auth-api) {
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header Host $http_host;
Expand Down
6 changes: 6 additions & 0 deletions infra/origin/us.ecency.com.conf
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ server {
proxy_pass http://127.0.0.1:4000;
}

# Internal SSR RPC cache: the web tier reaches it over the swarm network;
# from outside the path does not exist.
location ~ ^/private-api/ssr/ {
return 404;
}

location ~ ^/(private-api|search-api|wallet-api|auth-api) {
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header Host $http_host;
Expand Down
Loading
Loading