For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wire SupabaseAuthProvider into the Fastify server as a global onRequest hook that enforces JWT auth when Supabase env vars are set, and restricts dev mode to localhost-only.
Architecture: A single onRequest Fastify hook validates tokens before every non-public request, sets req.userId, and the bridge.ts adapter forwards that value as the x-user-id header into all Web API handlers — requiring zero changes to existing route code.
Tech Stack: Fastify v5, @nodetool/auth (SupabaseAuthProvider, LocalAuthProvider), Vitest
Spec: docs/superpowers/specs/2026-03-21-supabase-auth-middleware-design.md
userId into the bridge layerFiles:
packages/websocket/src/lib/bridge.ts (add 3 lines after the header-copy loop)packages/websocket/tests/bridge-auth.test.tsThe bridge() function converts a Fastify FastifyRequest into a Web API Request. After the existing header-copy loop we inject x-user-id from the Fastify req.userId decorator. All existing getUserId(request, "x-user-id") calls in route handlers then pick it up with no further changes.
x-user-id injection in bridge.tsCreate packages/websocket/tests/bridge-auth.test.ts:
import { describe, it, expect } from "vitest";
import { bridge } from "../src/lib/bridge.js";
import type { FastifyRequest, FastifyReply } from "fastify";
function makeMockReq(overrides: Partial<FastifyRequest> = {}): FastifyRequest {
return {
method: "GET",
url: "/api/something",
headers: { host: "localhost" },
body: null,
userId: null,
...overrides,
} as unknown as FastifyRequest;
}
function makeMockReply(): FastifyReply {
const headers: Record<string, string> = {};
return {
status: () => ({ send: () => {} }),
header: (k: string, v: string) => { headers[k] = v; },
send: () => {},
_headers: headers,
} as unknown as FastifyReply;
}
describe("bridge: userId propagation", () => {
it("sets x-user-id header when req.userId is a string", async () => {
const req = makeMockReq({ userId: "abc-123" });
const reply = makeMockReply();
let capturedUserId: string | null = null;
await bridge(req, reply, async (request) => {
capturedUserId = request.headers.get("x-user-id");
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
expect(capturedUserId).toBe("abc-123");
});
it("does not set x-user-id header when req.userId is null", async () => {
const req = makeMockReq({ userId: null });
const reply = makeMockReply();
let capturedUserId: string | null | undefined = undefined;
await bridge(req, reply, async (request) => {
capturedUserId = request.headers.get("x-user-id");
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
expect(capturedUserId).toBeNull();
});
it("does not override an existing x-user-id header if userId is null", async () => {
const req = makeMockReq({
userId: null,
headers: { host: "localhost", "x-user-id": "from-header" },
});
const reply = makeMockReply();
let capturedUserId: string | null = null;
await bridge(req, reply, async (request) => {
capturedUserId = request.headers.get("x-user-id");
return new Response("{}", { status: 200 });
});
expect(capturedUserId).toBe("from-header");
});
});
cd /Users/mg/workspace/nodetool/packages/websocket
npx vitest run tests/bridge-auth.test.ts
Expected: FAIL — x-user-id is never set because bridge doesn’t inject it yet.
bridge.tsIn packages/websocket/src/lib/bridge.ts, find the header-copy loop (lines 22–29) and add the injection immediately after it:
// Forward authenticated userId as x-user-id header for route handlers
if ((req as { userId?: string | null }).userId != null) {
headers.set("x-user-id", (req as { userId: string }).userId);
}
The full updated section looks like:
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const v of value) headers.append(key, v);
} else if (value !== undefined) {
headers.set(key, value);
}
}
// Forward authenticated userId as x-user-id header for route handlers
if ((req as { userId?: string | null }).userId != null) {
headers.set("x-user-id", (req as { userId: string }).userId);
}
Note: the cast as { userId?: string | null } avoids a TypeScript error before the full type augmentation is added in Task 2. Once the augmentation is in place (Task 2 step 3), you can simplify to if (req.userId != null) { headers.set("x-user-id", req.userId); }.
cd /Users/mg/workspace/nodetool/packages/websocket
npx vitest run tests/bridge-auth.test.ts
Expected: all 3 tests PASS.
cd /Users/mg/workspace/nodetool/packages/websocket
npx vitest run
Expected: same pass/fail ratio as before (no regressions).
cd /Users/mg/workspace/nodetool
git add packages/websocket/src/lib/bridge.ts packages/websocket/tests/bridge-auth.test.ts
git commit -m "feat: inject x-user-id from req.userId in bridge adapter"
onRequest hook in server.tsFiles:
packages/websocket/src/server.ts (add import, type augmentation, decorateRequest, hook — all before the first app.register() call)packages/websocket/tests/auth-hook.test.tsThe hook reads env vars at startup to decide mode (Supabase vs dev). In Supabase mode it validates JWTs. In dev mode it enforces localhost-only.
onRequest auth hookCreate packages/websocket/tests/auth-hook.test.ts:
/**
* Tests for the auth onRequest hook behavior.
* We test the hook logic in isolation by building a minimal Fastify app
* that registers the same hook and route, then using fastify.inject().
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import Fastify, { type FastifyInstance } from "fastify";
import { SupabaseAuthProvider, LocalAuthProvider } from "@nodetool/auth";
// Helper: build a minimal Fastify app with the auth hook and a protected route
async function buildApp(opts: {
supabaseMode: boolean;
mockVerify?: (token: string) => Promise<{ ok: boolean; userId?: string; error?: string }>;
}): Promise<FastifyInstance> {
const app = Fastify({ trustProxy: true, logger: false });
// Type augmentation is global, so we just decorate here
app.decorateRequest("userId", null);
const provider = opts.supabaseMode
? new SupabaseAuthProvider({ supabaseUrl: "http://fake", supabaseKey: "fake" })
: new LocalAuthProvider();
if (opts.mockVerify && opts.supabaseMode) {
vi.spyOn(provider, "verifyToken").mockImplementation(opts.mockVerify as any);
}
app.addHook("onRequest", async (req, reply) => {
const pathname = req.url.split("?")[0];
if (pathname === "/health" || req.url.startsWith("/api/oauth/")) return;
const isWs = req.headers["upgrade"]?.toLowerCase() === "websocket";
const searchParams = new URLSearchParams(req.url.split("?")[1] ?? "");
const token = isWs
? provider.extractTokenFromWs(req.headers as Record<string, string>, searchParams)
: provider.extractTokenFromHeaders(req.headers as Record<string, string>);
if (opts.supabaseMode) {
if (!token) { reply.status(401).send({ error: "Unauthorized" }); return; }
const result = await provider.verifyToken(token);
if (!result.ok) { reply.status(401).send({ error: result.error ?? "Unauthorized" }); return; }
(req as any).userId = result.userId ?? null;
return;
}
// Dev mode
const remoteAddr = req.socket?.remoteAddress ?? "127.0.0.1";
const isLocalhost = remoteAddr === "127.0.0.1" || remoteAddr === "::1";
if (!isLocalhost) { reply.status(401).send({ error: "Remote access requires authentication" }); return; }
(req as any).userId = "1";
});
app.get("/health", async () => ({ ok: true }));
app.get("/api/oauth/callback", async () => ({ oauth: true }));
app.get("/api/protected", async (req) => ({ userId: (req as any).userId }));
await app.ready();
return app;
}
describe("auth hook — dev mode (no Supabase)", () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildApp({ supabaseMode: false });
});
afterEach(async () => {
await app.close();
});
it("allows /health without auth", async () => {
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
});
it("allows /api/oauth/* without auth", async () => {
const res = await app.inject({ method: "GET", url: "/api/oauth/callback" });
expect(res.statusCode).toBe(200);
});
it("allows localhost requests and sets userId='1'", async () => {
// fastify.inject() uses 127.0.0.1 as remoteAddress
const res = await app.inject({ method: "GET", url: "/api/protected" });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ userId: "1" });
});
});
describe("auth hook — Supabase mode", () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildApp({
supabaseMode: true,
mockVerify: async (token: string) => {
if (token === "valid-token") return { ok: true, userId: "user-42" };
return { ok: false, error: "Invalid token" };
},
});
});
afterEach(async () => {
await app.close();
});
it("allows /health without token", async () => {
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
});
it("returns 401 with no Authorization header", async () => {
const res = await app.inject({ method: "GET", url: "/api/protected" });
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.body)).toEqual({ error: "Unauthorized" });
});
it("returns 401 with invalid token", async () => {
const res = await app.inject({
method: "GET",
url: "/api/protected",
headers: { authorization: "Bearer bad-token" },
});
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.body)).toEqual({ error: "Invalid token" });
});
it("allows request with valid token and sets userId", async () => {
const res = await app.inject({
method: "GET",
url: "/api/protected",
headers: { authorization: "Bearer valid-token" },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ userId: "user-42" });
});
it("returns 401 for WS upgrade with no api_key param", async () => {
const res = await app.inject({
method: "GET",
url: "/ws",
headers: { upgrade: "websocket", connection: "upgrade" },
});
expect(res.statusCode).toBe(401);
});
it("returns 401 for WS upgrade with invalid api_key param", async () => {
const res = await app.inject({
method: "GET",
url: "/ws?api_key=bad-token",
headers: { upgrade: "websocket", connection: "upgrade" },
});
expect(res.statusCode).toBe(401);
});
});
cd /Users/mg/workspace/nodetool/packages/websocket
npx vitest run tests/auth-hook.test.ts
Expected: FAIL — buildApp is just a local helper, so the hook itself isn’t in server.ts yet. The test infrastructure is valid though; some tests may pass (the local helper runs the same logic we’ll put in server.ts).
server.tsAt the top of packages/websocket/src/server.ts, add the import alongside existing imports:
import { SupabaseAuthProvider, LocalAuthProvider } from "@nodetool/auth";
Add the TypeScript module augmentation right after the imports (before any runtime code):
// Auth: extend FastifyRequest with userId
declare module "fastify" {
interface FastifyRequest {
userId: string | null;
}
}
After the Fastify app is created (const app: FastifyInstance = ... at line 269) and before the first app.register() call (which is app.register(fastifyCors, ...) at line 278):
Critical:
app.decorateRequest()in Fastify v5 must be called before anyapp.register()call. Place the entire auth section between theappcreation and the CORS registration.
Add:
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
const supabaseUrl = process.env["SUPABASE_URL"];
const supabaseKey = process.env["SUPABASE_KEY"];
const supabaseMode = Boolean(supabaseUrl && supabaseKey);
const supabaseProvider = supabaseMode
? new SupabaseAuthProvider({ supabaseUrl: supabaseUrl!, supabaseKey: supabaseKey! })
: null;
app.decorateRequest("userId", null);
app.addHook("onRequest", async (req, reply) => {
// Public routes — no auth required
const pathname = req.url.split("?")[0];
if (pathname === "/health" || req.url.startsWith("/api/oauth/")) {
return;
}
// Extract token from the appropriate source
const isWs = req.headers["upgrade"]?.toLowerCase() === "websocket";
const searchParams = new URLSearchParams(req.url.split("?")[1] ?? "");
const provider = supabaseProvider ?? new LocalAuthProvider();
const token = isWs
? provider.extractTokenFromWs(req.headers as Record<string, string>, searchParams)
: provider.extractTokenFromHeaders(req.headers as Record<string, string>);
if (supabaseMode) {
if (!token) {
reply.status(401).send({ error: "Unauthorized" });
return;
}
const result = await supabaseProvider!.verifyToken(token);
if (!result.ok) {
reply.status(401).send({ error: result.error ?? "Unauthorized" });
return;
}
req.userId = result.userId ?? null;
return;
}
// Dev mode: localhost only
// Use req.socket.remoteAddress rather than req.ip because trustProxy: true
// makes req.ip reflect x-forwarded-for (spoofable).
const remoteAddr = req.socket.remoteAddress ?? "";
const isLocalhost = remoteAddr === "127.0.0.1" || remoteAddr === "::1";
if (!isLocalhost) {
reply.status(401).send({ error: "Remote access requires authentication" });
return;
}
req.userId = "1";
});
bridge.tsNow that the module augmentation is in place globally, simplify bridge.ts (remove the type casts added in Task 1):
// Forward authenticated userId as x-user-id header for route handlers
if (req.userId != null) {
headers.set("x-user-id", req.userId);
}
cd /Users/mg/workspace/nodetool/packages/websocket
npx vitest run tests/auth-hook.test.ts
Expected: all tests PASS.
cd /Users/mg/workspace/nodetool/packages/websocket
npx vitest run
Expected: no regressions.
cd /Users/mg/workspace/nodetool/packages/websocket
npx tsc --noEmit
Expected: no errors. If req.socket complaints arise, add req.socket as import("net").Socket cast.
cd /Users/mg/workspace/nodetool
git add packages/websocket/src/server.ts packages/websocket/src/lib/bridge.ts packages/websocket/tests/auth-hook.test.ts
git commit -m "feat: add Supabase auth onRequest hook to Fastify server"
userId into UnifiedWebSocketRunnerFiles:
packages/websocket/src/plugins/websocket.ts (rename _req to req in /ws handler; pass userId)UnifiedWebSocketRunner already accepts userId?: string in its constructor options (field added at line 266). The /ws route currently ignores the request (_req). We rename it and pass the authenticated userId from the hook. /ws/terminal and /ws/download don’t use userId in their handlers so they need no changes beyond what the hook already provides.
userId into the WebSocket runnerThere is no simple unit test for this change because UnifiedWebSocketRunner.run() opens a live WebSocket connection. The coverage is provided by the existing unified-websocket-runner.test.ts suite. We verify correctness by TypeScript type checking and running existing tests.
websocket.tsIn packages/websocket/src/plugins/websocket.ts, find the /ws route (line 38):
app.get("/ws", { websocket: true }, (socket, _req) => {
...
const runner = new UnifiedWebSocketRunner({
resolveExecutor: (node) => {
Change _req to req and add userId as the first option:
app.get("/ws", { websocket: true }, (socket, req) => {
...
const runner = new UnifiedWebSocketRunner({
userId: req.userId ?? "1",
resolveExecutor: (node) => {
Everything else in the handler stays identical.
cd /Users/mg/workspace/nodetool/packages/websocket
npx tsc --noEmit
Expected: no errors.
cd /Users/mg/workspace/nodetool/packages/websocket
npx vitest run
Expected: all tests that were passing before still pass.
cd /Users/mg/workspace/nodetool
git add packages/websocket/src/plugins/websocket.ts
git commit -m "feat: pass authenticated userId to UnifiedWebSocketRunner"
After all three chunks are complete:
SUPABASE_URL/SUPABASE_KEY set and confirm a request to http://localhost:7777/health returns 200.http://localhost:7777/api/workflows (or any protected route) from localhost returns 200 with no auth header..env and confirm a request without a Bearer token returns 401.