feat: scaffold gateway worker
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
coverage/
|
||||
*.log
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "cfw-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"ready": "pnpm typecheck && pnpm test"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260601.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.2.0",
|
||||
"wrangler": "^4.20.0"
|
||||
}
|
||||
}
|
||||
1862
pnpm-lock.yaml
generated
Normal file
1862
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
src/env.ts
Normal file
14
src/env.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface FetcherLike {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
export interface DispatchNamespaceLike {
|
||||
get(name: string, scriptArgs?: Record<string, unknown>, options?: Record<string, unknown>): FetcherLike;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
AUTH: FetcherLike;
|
||||
DISPATCHER?: DispatchNamespaceLike;
|
||||
INTERNAL_AUTH_SECRET: string;
|
||||
DEFAULT_WORKER_NAME: string;
|
||||
}
|
||||
63
src/index.ts
Normal file
63
src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Env } from "./env";
|
||||
import type { InternalSession } from "./internal-context";
|
||||
import { withInternalSession } from "./internal-context";
|
||||
import { resolveRoute } from "./route-policy";
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
async function verifySession(request: Request, env: Env): Promise<InternalSession | null> {
|
||||
const sessionUrl = new URL("/internal/session", request.url);
|
||||
const sessionRequest = new Request(sessionUrl, {
|
||||
method: "GET",
|
||||
headers: request.headers,
|
||||
});
|
||||
sessionRequest.headers.set("x-internal-auth-secret", env.INTERNAL_AUTH_SECRET);
|
||||
|
||||
const response = await env.AUTH.fetch(sessionRequest);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const session = (await response.json()) as InternalSession;
|
||||
return session.authenticated ? session : null;
|
||||
}
|
||||
|
||||
app.all("*", async (c) => {
|
||||
const target = resolveRoute(c.req.raw, c.env);
|
||||
|
||||
if (target.kind === "gateway-health") {
|
||||
return c.json({
|
||||
ok: true,
|
||||
service: "cfw-gateway",
|
||||
});
|
||||
}
|
||||
|
||||
if (target.kind === "auth") {
|
||||
return c.env.AUTH.fetch(c.req.raw);
|
||||
}
|
||||
|
||||
const session = await verifySession(c.req.raw, c.env);
|
||||
if (!session) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const request = withInternalSession(c.req.raw, session);
|
||||
|
||||
if (!c.env.DISPATCHER) {
|
||||
return c.json({
|
||||
ok: true,
|
||||
routedTo: target.workerName,
|
||||
userId: session.userId,
|
||||
});
|
||||
}
|
||||
|
||||
const worker = c.env.DISPATCHER.get(target.workerName, {}, {
|
||||
limits: {
|
||||
cpuMs: 20,
|
||||
subRequests: 20,
|
||||
},
|
||||
});
|
||||
|
||||
return worker.fetch(request);
|
||||
});
|
||||
|
||||
export default app;
|
||||
35
src/internal-context.ts
Normal file
35
src/internal-context.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface InternalSession {
|
||||
authenticated: boolean;
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
roles: string[];
|
||||
source: "better-auth" | "dev-token";
|
||||
}
|
||||
|
||||
const STRIPPED_HEADERS = [
|
||||
"x-internal-auth-secret",
|
||||
"x-authenticated-user-id",
|
||||
"x-authenticated-email",
|
||||
"x-authenticated-roles",
|
||||
"x-authenticated-source",
|
||||
];
|
||||
|
||||
export function cloneHeadersWithoutInternalHeaders(headers: Headers): Headers {
|
||||
const next = new Headers(headers);
|
||||
for (const header of STRIPPED_HEADERS) {
|
||||
next.delete(header);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function withInternalSession(request: Request, session: InternalSession): Request {
|
||||
const headers = cloneHeadersWithoutInternalHeaders(request.headers);
|
||||
headers.set("x-authenticated-user-id", session.userId ?? "");
|
||||
headers.set("x-authenticated-email", session.email ?? "");
|
||||
headers.set("x-authenticated-roles", session.roles.join(","));
|
||||
headers.set("x-authenticated-source", session.source);
|
||||
|
||||
return new Request(request, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
23
src/route-policy.ts
Normal file
23
src/route-policy.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Env } from "./env";
|
||||
|
||||
export type RouteTarget =
|
||||
| { kind: "auth" }
|
||||
| { kind: "dynamic-worker"; workerName: string }
|
||||
| { kind: "gateway-health" };
|
||||
|
||||
export function resolveRoute(request: Request, env: Env): RouteTarget {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/healthz") {
|
||||
return { kind: "gateway-health" };
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/auth/")) {
|
||||
return { kind: "auth" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "dynamic-worker",
|
||||
workerName: env.DEFAULT_WORKER_NAME,
|
||||
};
|
||||
}
|
||||
136
tests/gateway-worker.test.ts
Normal file
136
tests/gateway-worker.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import type { Env, FetcherLike } from "../src/env";
|
||||
|
||||
function createAuthService(status: number, body: unknown): FetcherLike {
|
||||
return {
|
||||
fetch: vi.fn(async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("cfw-gateway worker", () => {
|
||||
it("returns health status", async () => {
|
||||
const env: Env = {
|
||||
AUTH: createAuthService(401, { authenticated: false }),
|
||||
INTERNAL_AUTH_SECRET: "dev-internal-secret",
|
||||
DEFAULT_WORKER_NAME: "customer-worker-1",
|
||||
};
|
||||
|
||||
const response = await worker.fetch(new Request("http://gateway.local/healthz"), env);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: true,
|
||||
service: "cfw-gateway",
|
||||
});
|
||||
});
|
||||
|
||||
it("proxies Better Auth routes to the auth service", async () => {
|
||||
const auth = createAuthService(200, { proxied: true });
|
||||
const env: Env = {
|
||||
AUTH: auth,
|
||||
INTERNAL_AUTH_SECRET: "dev-internal-secret",
|
||||
DEFAULT_WORKER_NAME: "customer-worker-1",
|
||||
};
|
||||
|
||||
const response = await worker.fetch(new Request("http://gateway.local/api/auth/session"), env);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ proxied: true });
|
||||
expect(auth.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects non-auth routes when auth service denies the session", async () => {
|
||||
const env: Env = {
|
||||
AUTH: createAuthService(401, { authenticated: false }),
|
||||
INTERNAL_AUTH_SECRET: "dev-internal-secret",
|
||||
DEFAULT_WORKER_NAME: "customer-worker-1",
|
||||
};
|
||||
|
||||
const response = await worker.fetch(new Request("http://gateway.local/app"), env);
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toEqual({ error: "unauthorized" });
|
||||
});
|
||||
|
||||
it("routes authenticated requests to the default worker when dispatcher is absent", async () => {
|
||||
const env: Env = {
|
||||
AUTH: createAuthService(200, {
|
||||
authenticated: true,
|
||||
userId: "dev-user",
|
||||
email: "dev@example.local",
|
||||
roles: ["developer"],
|
||||
source: "dev-token",
|
||||
}),
|
||||
INTERNAL_AUTH_SECRET: "dev-internal-secret",
|
||||
DEFAULT_WORKER_NAME: "customer-worker-1",
|
||||
};
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request("http://gateway.local/app", {
|
||||
headers: {
|
||||
authorization: "Bearer dev-session-token",
|
||||
"x-authenticated-user-id": "spoofed-user",
|
||||
},
|
||||
}),
|
||||
env,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: true,
|
||||
routedTo: "customer-worker-1",
|
||||
userId: "dev-user",
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches authenticated requests with trusted identity headers", async () => {
|
||||
const dispatchedFetch = vi.fn(async (request: Request) => {
|
||||
return Response.json({
|
||||
userId: request.headers.get("x-authenticated-user-id"),
|
||||
email: request.headers.get("x-authenticated-email"),
|
||||
roles: request.headers.get("x-authenticated-roles"),
|
||||
spoofedSecret: request.headers.get("x-internal-auth-secret"),
|
||||
});
|
||||
});
|
||||
|
||||
const env: Env = {
|
||||
AUTH: createAuthService(200, {
|
||||
authenticated: true,
|
||||
userId: "dev-user",
|
||||
email: "dev@example.local",
|
||||
roles: ["developer"],
|
||||
source: "dev-token",
|
||||
}),
|
||||
DISPATCHER: {
|
||||
get: vi.fn(() => ({ fetch: dispatchedFetch })),
|
||||
},
|
||||
INTERNAL_AUTH_SECRET: "dev-internal-secret",
|
||||
DEFAULT_WORKER_NAME: "customer-worker-1",
|
||||
};
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request("http://gateway.local/app", {
|
||||
headers: {
|
||||
authorization: "Bearer dev-session-token",
|
||||
"x-internal-auth-secret": "client-spoof",
|
||||
"x-authenticated-user-id": "client-spoof",
|
||||
},
|
||||
}),
|
||||
env,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
userId: "dev-user",
|
||||
email: "dev@example.local",
|
||||
roles: "developer",
|
||||
spoofedSecret: null,
|
||||
});
|
||||
expect(dispatchedFetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"types": ["@cloudflare/workers-types", "vitest/globals"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
23
wrangler.jsonc
Normal file
23
wrangler.jsonc
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "cfw-gateway",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-06-10",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"vars": {
|
||||
"INTERNAL_AUTH_SECRET": "dev-internal-secret",
|
||||
"DEFAULT_WORKER_NAME": "customer-worker-1"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"binding": "AUTH",
|
||||
"service": "cfw-auth"
|
||||
}
|
||||
],
|
||||
"dispatch_namespaces": [
|
||||
{
|
||||
"binding": "DISPATCHER",
|
||||
"namespace": "platform-workers"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user