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
68 changes: 58 additions & 10 deletions infra/stacks/chat-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ import { databaseUrl } from "./secrets";
// shape may shift between SST versions — verify `sst diff` before first deploy
// and tweak the transform if Cloudflare provider rejects the binding format.

// ── Edge session cache (ADR-0017) ────────────────────────────────────────────
// Cache-aside KV in front of WS-connect session verification. Neon stays
// authoritative; KV is a short-TTL read-through cache keyed by sha256(token),
// value { userId, exp }. Bound raw as `env.SESSION_CACHE` (a namespace binding,
// like the DO namespaces — NOT a linked secret, so it is wired via
// transform.worker.bindings below rather than `link`). The cache-aside read
// path (B1.2+) and the HMAC-gated /internal/session/purge worker (B1.5) are
// separate steps; B1.1 provisions the namespace + binding only.
export const sessionCache = new sst.cloudflare.Kv("SessionCache");

export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", {
handler: "services/chat-ws/src/index.ts",
url: true,
Expand All @@ -48,13 +58,31 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", {
// dedicated IAM user with sns:Publish scoped to this topic only.
// Provisioned out-of-band; secrets are set via `sst secret set`.
AWS_REGION: $app.providers?.aws?.region ?? "eu-west-1",
// Edge session cache (ADR-0017 §3). TTL bounds worst-case revocation lag;
// 120s is the tuned starting point (60–300s viable). String because Worker
// env vars are strings — the cache layer (B1.2) parses to seconds. Kept
// env-tunable so it can be adjusted under B1.7 load testing without a code
// change.
SESSION_CACHE_TTL: "120",
// Kill-switch (ADR-0017 consequences). "0"/"false" → the Worker falls back
// to origin-only verification (existing Lambda path) with no redeploy.
SESSION_CACHE_ENABLED: "1",
// Load-test metrics (B1.7). "1" → per-verify "SCM" log for `wrangler tail`
// to tally cache hit rate + KV write rate. Keep "0" in prod; flip to "1"
// (+ redeploy) only for a load-test run, then flip back.
SESSION_CACHE_METRICS: "1",
},
transform: {
worker: (args) => {
// Bind the Chat + UserInbox Durable Object namespaces.
// SQLite-backed DOs are the recommended path on the Workers Paid plan
// (cheaper, supports point-in-time recovery, modern API).
args.bindings = $resolve([args.bindings]).apply(([bindings]) => [
// Object form of $resolve so each value keeps its own type — the array
// form unifies mixed Output types into a union and widens namespace_id.
args.bindings = $resolve({
bindings: args.bindings,
sessionCacheId: sessionCache.id,
}).apply(({ bindings, sessionCacheId }) => [
...(bindings ?? []),
{
type: "durable_object_namespace",
Expand All @@ -66,6 +94,20 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", {
name: "USER_INBOX",
className: "UserInbox",
},
{
// Edge session cache (ADR-0017). Raw KV namespace binding →
// env.SESSION_CACHE. Not a DO class, so it does NOT touch the
// migration shape below — but adding any binding is still a Worker
// update, which requires the tag bump (see migrations note).
type: "kv_namespace",
name: "SESSION_CACHE",
// MUST be camelCase `namespaceId` — the Pulumi provider's
// WorkersScriptBinding uses camelCase (like `className` above).
// snake_case `namespace_id` is silently dropped → CF 10021 "must have
// a namespace_id". tsc does NOT catch it (excess-property check is
// bypassed through Output.apply). Do not "fix" this back to snake_case.
namespaceId: sessionCacheId,
},
]);

// Migration shape — Pulumi cloudflare provider takes a single object
Expand All @@ -86,16 +128,22 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", {
// drop classes from newSqliteClasses, and use renamedClasses /
// deletedClasses as needed. Do NOT re-declare existing classes in
// newSqliteClasses on an update (triggers 10074).
// UPDATE-SHAPE: the script already exists at migration tag v1 with the
// Chat + UserInbox SQLite DO classes created. This deploy changes only
// Worker code (no DO class add/rename/delete), so bump the tag with an
// empty migration and send oldTag so CF's precondition matches the
// deployed v1 (error 10079 "got tag '' when expected v1" = oldTag was
// omitted by the create-shape). Do NOT re-declare existing classes in
// newSqliteClasses (triggers 10074).
// UPDATE-SHAPE (B1.1, ADR-0017): this deploy adds the SESSION_CACHE KV
// binding. That is a Worker-binding change with NO DO class add/rename/
// delete, so it is an empty migration that only bumps the tag. Send
// oldTag = the currently deployed tag so CF's precondition matches
// (error 10079 "got tag X when expected Y" = oldTag ≠ deployed). Do NOT
// re-declare Chat/UserInbox in newSqliteClasses (triggers 10074).
//
// ⚠ PRECONDITION — deployed tag. This assumes the prior code-only v1→v2
// deploy (commit 265a35c) actually shipped, i.e. deployed tag == v2.
// VERIFY with `sst diff` before apply. If the v1→v2 deploy never ran
// (e.g. alice/bob was tested on local `wrangler dev`), the deployed tag
// is still v1 — in that case set { oldTag: "v1", newTag: "v2" } here and
// let the KV binding ride that deploy, rather than jumping to v3.
args.migrations = {
oldTag: "v1",
newTag: "v2",
oldTag: "v3",
newTag: "v4",
};

// Compatibility date with WebSocket auto-reply-to-close behaviour
Expand Down
183 changes: 183 additions & 0 deletions loadtest/session-cache-connect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// B1.7 — focused load test of the edge session cache (ADR-0017).
//
// Drives a WS connect / reconnect storm at the chat-ws Worker and measures
// connect latency. Pair it with the Worker's own counters:
//
// 1. Deploy chat-ws with SESSION_CACHE_METRICS=1 (infra/stacks/chat-ws.ts),
// then in another terminal: wrangler tail ChatWs --format=json | grep SCM
// 2. Run this script. Tally the SCM lines: r="hit"/"miss" = hit rate,
// w=true = KV write rate (ADR-0017 §5 — the cost metric to watch).
//
// Compare two runs — cache ON vs OFF (flip SESSION_CACHE_ENABLED, redeploy):
// the delta in sc_connect_latency p95 is the DB-hot-path saving the cache buys.
//
// Tokens: setup() self-provisions LT_USERS test accounts (sign-up, ignore if
// they exist) and signs them in for real bearer tokens. VUs reuse tokens, so the
// first connect per token is a cache miss (populate) and the rest are hits.
//
// Usage:
// k6 run \
// -e API_URL=https://<api-lambda-url> \
// -e WS_URL=wss://<chat-ws-worker-url> \
// -e LT_USERS=200 -e LT_PASSWORD=loadtest-pw-123 \
// loadtest/session-cache-connect.js

import ws from "k6/ws";
import http from "k6/http";
import encoding from "k6/encoding";
import { check, sleep } from "k6";
import { Trend, Counter } from "k6/metrics";

const API_URL = (__ENV.API_URL || "http://localhost:3001").replace(/\/$/, "");
const WS_URL = (__ENV.WS_URL || "ws://localhost:8787").replace(/\/$/, "");
const N_USERS = parseInt(__ENV.LT_USERS || "200", 10);
const PASSWORD = __ENV.LT_PASSWORD || "loadtest-pw-123";
const EMAIL_PREFIX = __ENV.LT_EMAIL_PREFIX || "loadtest";
// Run-scoped so every run provisions brand-new users → all sign-up (1 request,
// token via autoSignIn), deterministic 200/200, no cross-run password/sign-in
// dependency. Pass the same LT_RUN_ID to two runs to reuse a pool (ON vs OFF).
const RUN_ID = __ENV.LT_RUN_ID || String(Date.now());
const HOLD_MS = parseInt(__ENV.LT_HOLD_MS || "500", 10);
// Seconds to wait between setup() auth calls. Raise (e.g. 0.2) if Better Auth
// rate-limits the provisioning burst (429). Default 0 = as fast as possible.
const SETUP_DELAY = parseFloat(__ENV.LT_SETUP_DELAY || "0");

// Better Auth rejects auth requests with no Origin header (CSRF:
// MISSING_OR_NULL_ORIGIN). It auto-trusts its own baseURL, so sending the API's
// own origin passes. Override with LT_ORIGIN if your TRUSTED_ORIGINS differs.
function originOf(u) {
const m = /^(https?:\/\/[^/]+)/.exec(u);
return m ? m[1] : u;
}
const ORIGIN = __ENV.LT_ORIGIN || originOf(API_URL);
const AUTH_HEADERS = { "content-type": "application/json", Origin: ORIGIN };

const connectLatency = new Trend("sc_connect_latency", true);
const connectOk = new Counter("sc_connect_ok");
const connectFail = new Counter("sc_connect_fail");

export const options = {
// Provisioning is sequential auth calls (~0.5s each on Lambda+Neon), so the
// 60s default is too tight for a large pool. Raise it; drop LT_USERS for speed.
setupTimeout: __ENV.LT_SETUP_TIMEOUT || "300s",
scenarios: {
// Reconnect storm — each iteration is one connect+close, so ramping VUs and
// looping default() reproduces app-foreground / network-flap reconnects.
reconnect_storm: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "30s", target: 100 },
{ duration: "1m", target: 500 },
{ duration: "30s", target: 0 },
],
gracefulStop: "10s",
},
},
thresholds: {
// Tune against your baseline; the point is the ON-vs-OFF delta, not absolutes.
sc_connect_latency: ["p(95)<1500"],
sc_connect_fail: ["count<50"],
},
};

// Better Auth returns the session token in the `set-auth-token` response header
// (bearer plugin). k6 may title-case header keys, so read case-insensitively.
function readHeader(headers, name) {
const want = name.toLowerCase();
for (const k in headers) {
if (k.toLowerCase() === want) return headers[k];
}
return null;
}

function signUp(email) {
// 200 = created; 4xx (e.g. already exists) is fine — we sign in next.
return http.post(
`${API_URL}/auth/sign-up/email`,
JSON.stringify({ email, password: PASSWORD, name: email }),
{ headers: AUTH_HEADERS },
);
}

function signIn(email) {
return http.post(
`${API_URL}/auth/sign-in/email`,
JSON.stringify({ email, password: PASSWORD }),
{ headers: AUTH_HEADERS },
);
}

export function setup() {
const tokens = [];
const via = { signin: 0, signup: 0, fail: 0 };
let sampled = 0;
for (let i = 0; i < N_USERS; i++) {
const email = `${EMAIL_PREFIX}+${RUN_ID}-${i}@example.com`;
// Sign-up first: a brand-new user (unique per run) mints in ONE request via
// autoSignIn (set-auth-token header). Sign-in is only a fallback for the
// reused-LT_RUN_ID case (user already exists → 422).
let res = signUp(email);
let token = readHeader(res.headers, "set-auth-token");
if (token) {
via.signup++;
} else {
res = signIn(email);
token = readHeader(res.headers, "set-auth-token");
if (token) via.signin++;
}
if (token) {
tokens.push(token);
} else {
via.fail++;
if (sampled < 3) {
sampled++;
console.error(
`setup: ${email} no token; last ${res.status}: ${String(res.body).slice(0, 160)}`,
);
}
}
if (SETUP_DELAY > 0) sleep(SETUP_DELAY);
}
console.info(
`setup: minted ${tokens.length}/${N_USERS} tokens ` +
`(signin=${via.signin} signup=${via.signup} fail=${via.fail})`,
);
if (tokens.length === 0) {
throw new Error(
"setup: minted 0 tokens — check API_URL / Origin / credentials",
);
}
return { tokens };
}

export default function (data) {
const { tokens } = data;
// Reuse tokens across VUs → mostly cache hits after the first populate.
const token = tokens[(__VU + __ITER) % tokens.length];
// base64url (no padding) — mirrors packages/chat-transport/src/client.ts, which
// the Worker decodes in index.ts (Sec-WebSocket-Protocol tchar-safe).
const encoded = encoding.b64encode(token, "rawurl");

const params = {
headers: {
// "bearer, <base64url-token>" — exactly what the real client sends.
"Sec-WebSocket-Protocol": `bearer, ${encoded}`,
},
};

const start = Date.now();
const res = ws.connect(WS_URL, params, function (socket) {
socket.on("open", function () {
connectLatency.add(Date.now() - start);
connectOk.add(1);
// Hold, then close so the next iteration reconnects.
socket.setTimeout(function () {
socket.close();
}, HOLD_MS);
});
});

const ok = check(res, { "ws handshake 101": (r) => r && r.status === 101 });
if (!ok) connectFail.add(1);
}
22 changes: 22 additions & 0 deletions services/api/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createHash } from "node:crypto";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { bearer } from "better-auth/plugins";
import { prisma } from "@repo/database";
import { purgeSessionCache } from "./chat-ws-push";
// ── Better Auth server instance ───────────────────────────────────────────────
// Sessions are DB-backed (via Prisma) — fully revocable on logout or ban.
// The `bearer` plugin enables Authorization: Bearer <token> for API clients
Expand Down Expand Up @@ -73,6 +75,26 @@ export const auth = betterAuth({
},
},
},
// Write-through invalidation of the edge session cache (ADR-0017 §3). Fires
// on sign-out and every revoke path, one place. sha256(session.token) is the
// KV key the Worker wrote (auth.ts sha256Hex — UTF-8 bytes, lowercase hex),
// so the digests match. Fire-and-forget: the row is already deleted here
// (authoritative) and the KV TTL backstops a missed purge, so this must
// never throw or block the deletion.
session: {
delete: {
after: async (session) => {
try {
const hash = createHash("sha256")
.update(session.token)
.digest("hex");
await purgeSessionCache(hash);
} catch {
// best-effort; TTL bounds revocation lag
}
},
},
},
},

session: {
Expand Down
10 changes: 8 additions & 2 deletions services/api/src/lib/chat-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// POST /internal/chat/verify-session
// body: { token }
// 200: { userId }
// 200: { userId, exp } exp = session expiry, unix seconds
// 401: unauthenticated bearer
//
// The /internal/chat/persist endpoint was removed in M2 per ADR-010 — the
Expand All @@ -22,6 +22,10 @@ interface VerifySessionRequest {

interface VerifySessionResponse {
userId: string;
// Session expiry, unix seconds. Lets the edge cache (ADR-0017) avoid serving
// a session past its real end within the KV TTL window. Additive field —
// older Workers that ignore it are unaffected.
exp: number;
}

function verifyHmac(request: Request): boolean {
Expand Down Expand Up @@ -75,7 +79,9 @@ async function handleVerifySession(request: Request): Promise<Response> {
return new Response("invalid session", { status: 401 });
}

const out: VerifySessionResponse = { userId: session.user.id };
// expiresAt is a Date on the DB-backed session record; to unix seconds.
const exp = Math.floor(new Date(session.session.expiresAt).getTime() / 1000);
const out: VerifySessionResponse = { userId: session.user.id, exp };
return new Response(JSON.stringify(out), {
status: 200,
headers: { "content-type": "application/json" },
Expand Down
30 changes: 30 additions & 0 deletions services/api/src/lib/chat-ws-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,33 @@ export async function notifyMlsWelcome(
return { ok: false };
}
}

/**
* Evict a session from the edge cache (ADR-0017 §3, write-through invalidation).
* `tokenHash` is the hex sha256(token) — the same key the Worker writes — so the
* raw token never leaves this service.
*
* Best-effort by design: the session row is already deleted (authoritative) and
* the KV TTL backstops a missed purge, so callers MUST NOT block on the result
* or fail sign-out / revoke if this returns `{ ok: false }`.
*/
export async function purgeSessionCache(
tokenHash: string,
): Promise<{ ok: boolean }> {
const url = chatWsUrl();
if (!url || !tokenHash) return { ok: false };

try {
const resp = await fetch(`${url}/internal/session/purge`, {
method: "POST",
headers: {
"content-type": "application/json",
[SECRET_HEADER]: Resource.ChatWsHmacSecret.value,
},
body: JSON.stringify({ tokenHash }),
});
return { ok: resp.status === 200 };
} catch {
return { ok: false };
}
}
Loading
Loading