Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"typecheck": "cd ../.. && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cors": "~8.5.0",
"@fastify/helmet": "~11.1.1",
"@vooster/contracts": "workspace:*"
}
}
14 changes: 14 additions & 0 deletions apps/api/src/http/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify";
import fastifyCors from "@fastify/cors";
import fastifyHelmet from "@fastify/helmet";
import { healthResponseSchema } from "@vooster/contracts";
import { createMemoryApiKeyStore } from "../infrastructure/memory-api-key-store.js";
import { createMemoryActorStore } from "../infrastructure/memory-actor-store.js";
Expand Down Expand Up @@ -63,6 +65,10 @@ import type { UserStore } from "../ports/user-store.js";
export async function createServer(options: ServerOptions): Promise<FastifyInstance> {
const serverOptions = withGithubOAuthFromEnv(options);
const app = Fastify({ logger: false });
await app.register(fastifyHelmet);
await app.register(fastifyCors, {
origin: allowedCorsOriginsFromEnv()
});
const state = initialState();
const apiKeyStore = serverOptions.signupStore ?? createMemoryApiKeyStore();
const actorStore = serverOptions.signupStore ?? createMemoryActorStore();
Expand Down Expand Up @@ -425,6 +431,14 @@ export async function createServer(options: ServerOptions): Promise<FastifyInsta
return app;
}

function allowedCorsOriginsFromEnv(): false | string[] {
const origins = process.env.VSPEC_ALLOWED_ORIGINS?.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);

return origins && origins.length > 0 ? origins : false;
}

function initialState(): SignupState {
const state: SignupState = {
pendingOAuth: new Map(),
Expand Down
72 changes: 71 additions & 1 deletion apps/api/tests/unit/http/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createServer } from "../../../src/http/server.js";
import type { StoredUser } from "../../../src/domain/entities/index.js";
import type { SignupStore } from "../../../src/ports/signup-store.js";

describe("server startup", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

test("does not reseed the stub user when it already exists", async () => {
const saveUser = vi.fn<SignupStore["saveUser"]>();
const app = await createServer({
Expand Down Expand Up @@ -45,6 +49,72 @@ describe("server startup", () => {
})
).rejects.toThrow("lookup failed");
});

test("adds default security headers to health responses", async () => {
const app = await createServer({ authStub: true });

try {
const response = await app.inject({ method: "GET", url: "/healthz" });

expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["x-frame-options"]).toBe("SAMEORIGIN");
} finally {
await app.close();
}
});

test("does not allow cross-origin requests without an allow-list", async () => {
const app = await createServer({ authStub: true });

try {
const response = await app.inject({
method: "OPTIONS",
url: "/healthz",
headers: {
"access-control-request-method": "GET",
origin: "https://evil.example"
}
});

expect(response.headers["access-control-allow-origin"]).toBeUndefined();
} finally {
await app.close();
}
});

test("allows configured CORS origins and trims list entries", async () => {
vi.stubEnv(
"VSPEC_ALLOWED_ORIGINS",
"https://app.example.com, https://admin.example.com"
);
const app = await createServer({ authStub: true });

try {
const allowed = await app.inject({
method: "OPTIONS",
url: "/healthz",
headers: {
"access-control-request-method": "GET",
origin: "https://admin.example.com"
}
});
const blocked = await app.inject({
method: "OPTIONS",
url: "/healthz",
headers: {
"access-control-request-method": "GET",
origin: "https://evil.example"
}
});

expect(allowed.headers["access-control-allow-origin"]).toBe(
"https://admin.example.com"
);
expect(blocked.headers["access-control-allow-origin"]).toBeUndefined();
} finally {
await app.close();
}
});
});

function signupStore(overrides: Partial<SignupStore>): SignupStore {
Expand Down
29 changes: 29 additions & 0 deletions docs/decisions/ADR-001-fastify-security-headers-cors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ADR-001 - Fastify Security Headers and CORS Plugins

Date: 2026-06-15
Status: ACCEPTED

## Context

The API server had no centralized HTTP security header middleware and no explicit
CORS policy. The MVP web app and API are separate deployment surfaces, so the API
needs a boring, framework-supported way to add common response hardening headers
and to avoid reflecting arbitrary browser origins.

## Decision

Add `@fastify/helmet` and `@fastify/cors` to `@vooster/api`.

Register Helmet globally with its defaults. Register CORS globally with origins
read from `VSPEC_ALLOWED_ORIGINS`, parsed as a comma-separated list. When the
environment variable is absent or empty, configure CORS with `origin: false` so
the API does not emit permissive cross-origin headers by default.

## Consequences

API responses include common security headers such as `X-Content-Type-Options`
and `X-Frame-Options`.

Browser cross-origin access must be configured explicitly for deployed frontends.
Local or production environments that need browser calls to the API must set
`VSPEC_ALLOWED_ORIGINS` to the exact allowed origins.
2 changes: 1 addition & 1 deletion docs/decisions/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ What follows from this — both costs and benefits.

## Index

(none yet)
- [ADR-001 - Fastify Security Headers and CORS Plugins](ADR-001-fastify-security-headers-cors.md)
45 changes: 45 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.