mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleAuth: vi.fn(),
|
||||
handleOAuth: vi.fn(),
|
||||
handleRpc: vi.fn(),
|
||||
handleOpenApi: vi.fn(),
|
||||
handleHealth: vi.fn(),
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
handleOpenIdConfiguration: vi.fn(),
|
||||
handleWellKnownFallback: vi.fn(),
|
||||
handleRobots: vi.fn(),
|
||||
handleSitemap: vi.fn(),
|
||||
handleLlms: vi.fn(),
|
||||
serveWebDistStatic: vi.fn(),
|
||||
handleWebApp: vi.fn(),
|
||||
handleWebAppHead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
handleAuth: mocks.handleAuth,
|
||||
handleOAuth: mocks.handleOAuth,
|
||||
}));
|
||||
|
||||
vi.mock("./health", () => ({
|
||||
handleHealth: mocks.handleHealth,
|
||||
}));
|
||||
|
||||
vi.mock("../rpc/handler", () => ({
|
||||
handleRpc: mocks.handleRpc,
|
||||
}));
|
||||
|
||||
vi.mock("../openapi/handler", () => ({
|
||||
handleOpenApi: mocks.handleOpenApi,
|
||||
}));
|
||||
|
||||
vi.mock("../openapi/metadata", () => ({
|
||||
handleMcpServerCard: mocks.handleMcpServerCard,
|
||||
handleOAuthAuthorizationServer: mocks.handleOAuthAuthorizationServer,
|
||||
handleOAuthProtectedResource: mocks.handleOAuthProtectedResource,
|
||||
handleOpenIdConfiguration: mocks.handleOpenIdConfiguration,
|
||||
handleWellKnownFallback: mocks.handleWellKnownFallback,
|
||||
}));
|
||||
|
||||
vi.mock("../static/uploads", () => ({
|
||||
handleUpload: mocks.handleUpload,
|
||||
}));
|
||||
|
||||
vi.mock("../static/seo", () => ({
|
||||
handleRobots: mocks.handleRobots,
|
||||
handleSitemap: mocks.handleSitemap,
|
||||
handleLlms: mocks.handleLlms,
|
||||
}));
|
||||
|
||||
vi.mock("../static/web", () => ({
|
||||
serveWebDistStatic: mocks.serveWebDistStatic,
|
||||
handleWebApp: mocks.handleWebApp,
|
||||
handleWebAppHead: mocks.handleWebAppHead,
|
||||
}));
|
||||
|
||||
vi.mock("../mcp/handler", () => ({
|
||||
handleMcp: mocks.handleMcp,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
|
||||
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
|
||||
mocks.handleOpenApi.mockResolvedValue(new Response("openapi"));
|
||||
mocks.handleHealth.mockReturnValue(new Response("health"));
|
||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||
mocks.handleOpenIdConfiguration.mockReturnValue(new Response("openid-configuration"));
|
||||
mocks.handleWellKnownFallback.mockReturnValue(new Response("well-known"));
|
||||
mocks.handleRobots.mockReturnValue(new Response("robots"));
|
||||
mocks.handleSitemap.mockReturnValue(new Response("sitemap"));
|
||||
mocks.handleLlms.mockReturnValue(new Response("llms"));
|
||||
mocks.serveWebDistStatic.mockResolvedValue(undefined);
|
||||
mocks.handleWebApp.mockResolvedValue(new Response("web"));
|
||||
mocks.handleWebAppHead.mockReturnValue(new Response(null));
|
||||
});
|
||||
|
||||
describe("createApp", () => {
|
||||
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/api/auth/oauth?client_id=test-client");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe("oauth");
|
||||
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
|
||||
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||
["GET", "/sitemap.xml", "sitemap", mocks.handleSitemap],
|
||||
["HEAD", "/sitemap.xml", "", mocks.handleSitemap],
|
||||
["GET", "/llms.txt", "llms", mocks.handleLlms],
|
||||
["HEAD", "/llms.txt", "", mocks.handleLlms],
|
||||
])("routes %s %s before the static fallback", async (method, pathname, expectedBody, handler) => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request(`http://localhost:3001${pathname}`, { method });
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe(expectedBody);
|
||||
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebAppHead).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Hono } from "hono";
|
||||
import { handleMcp } from "../mcp/handler";
|
||||
import { handleOpenApi } from "../openapi/handler";
|
||||
import {
|
||||
handleMcpServerCard,
|
||||
handleOAuthAuthorizationServer,
|
||||
handleOAuthProtectedResource,
|
||||
handleOpenIdConfiguration,
|
||||
handleWellKnownFallback,
|
||||
} from "../openapi/metadata";
|
||||
import { handleRpc } from "../rpc/handler";
|
||||
import { handleSchemaJson } from "../static/schema";
|
||||
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
|
||||
import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
|
||||
import { handleAuth, handleOAuth } from "./auth";
|
||||
import { handleHealth } from "./health";
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw));
|
||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
||||
app.get("/api/health", () => handleHealth());
|
||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/schema.json", () => handleSchemaJson());
|
||||
app.all("/mcp", (c) => handleMcp(c.req.raw));
|
||||
app.all("/mcp/*", (c) => handleMcp(c.req.raw));
|
||||
|
||||
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
|
||||
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||
app.get("/.well-known/oauth-authorization-server/*", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||
app.get("/.well-known/openid-configuration", (c) => handleOpenIdConfiguration(c.req.raw));
|
||||
app.get("/.well-known/oauth-protected-resource", () => handleOAuthProtectedResource());
|
||||
app.get("/.well-known/oauth-protected-resource/*", () => handleOAuthProtectedResource());
|
||||
app.all("/.well-known/*", () => handleWellKnownFallback());
|
||||
|
||||
app.on(["GET", "HEAD"], "/robots.txt", (c) => handleRobots({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||
|
||||
app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
app.on(["HEAD"], "/*", (c) => handleWebAppHead(c.req.raw));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
handler: vi.fn(),
|
||||
env: {
|
||||
SERVER_PORT: 3001,
|
||||
APP_URL: "http://localhost:3000",
|
||||
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: mocks.getSession,
|
||||
},
|
||||
handler: mocks.handler,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
|
||||
mocks.handler.mockResolvedValue(new Response("ok"));
|
||||
});
|
||||
|
||||
describe("handleAuth", () => {
|
||||
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
|
||||
const response = await handleAuth(
|
||||
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "invalid_redirect_uri",
|
||||
error_description: "redirect_uri is not allowed",
|
||||
});
|
||||
expect(mocks.handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
|
||||
|
||||
const response = await handleAuth(
|
||||
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleOAuth", () => {
|
||||
it("redirects unauthenticated users to the same-origin login route", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await handleOAuth(
|
||||
new Request(
|
||||
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get("Location");
|
||||
expect(location).toMatch(/^\/auth\/login\?/);
|
||||
|
||||
const loginUrl = new URL(location ?? "", "http://localhost:3000");
|
||||
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
|
||||
|
||||
expect(loginUrl.origin).toBe("http://localhost:3000");
|
||||
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
|
||||
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
|
||||
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
||||
expect(callbackUrl.searchParams.get("state")).toBe("abc");
|
||||
expect(callbackUrl.searchParams.has("exp")).toBe(false);
|
||||
expect(callbackUrl.searchParams.has("sig")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
||||
|
||||
const oauthAuthorizeSanitizedParams = [
|
||||
"prompt",
|
||||
"redirect_uri",
|
||||
"client_id",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"response_type",
|
||||
"scope",
|
||||
"state",
|
||||
"resource",
|
||||
] as const;
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
|
||||
|
||||
const sanitizeValue = (value: string) =>
|
||||
value
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sanitizeParam = (key: string) => {
|
||||
const value = url.searchParams.get(key);
|
||||
if (!value) return;
|
||||
url.searchParams.set(key, sanitizeValue(value));
|
||||
};
|
||||
|
||||
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
||||
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
if (redirectUri && !URL.canParse(redirectUri)) {
|
||||
try {
|
||||
const decodedRedirectUri = decodeURIComponent(redirectUri);
|
||||
if (URL.canParse(decodedRedirectUri)) {
|
||||
url.searchParams.set("redirect_uri", decodedRedirectUri);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed encoded values and let Better Auth validation handle them.
|
||||
}
|
||||
}
|
||||
|
||||
if (url.toString() === request.url) return request;
|
||||
return new Request(url.toString(), request);
|
||||
}
|
||||
|
||||
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return request;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
|
||||
if (!request.headers.get("authorization")) {
|
||||
body.token_endpoint_auth_method = "none";
|
||||
}
|
||||
|
||||
return new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
|
||||
if (request.method !== "POST") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
} catch {
|
||||
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
|
||||
|
||||
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
||||
for (const redirectUri of redirectUris) {
|
||||
if (
|
||||
typeof redirectUri !== "string" ||
|
||||
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
|
||||
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
|
||||
})
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAuth(request: Request) {
|
||||
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
||||
if (registrationValidationError) return registrationValidationError;
|
||||
|
||||
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
||||
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
||||
|
||||
return auth.handler(finalRequest);
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||
}
|
||||
|
||||
export async function handleOAuth(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (session?.user) {
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const state = url.searchParams.get("state");
|
||||
const scope = url.searchParams.get("scope");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||
|
||||
if (!client) {
|
||||
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = hashCode(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 600_000);
|
||||
|
||||
await db.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier: hashedCode,
|
||||
value: JSON.stringify({
|
||||
type: "authorization_code",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
},
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
authTime: new Date(session.session.createdAt).getTime(),
|
||||
}),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: callbackUrl.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/auth/login", env.APP_URL);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!["exp", "sig"].includes(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function getCookie(request: Request, name: string): string | undefined {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) return;
|
||||
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawName, ...rawValue] = part.trim().split("=");
|
||||
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
|
||||
if ([...headers].length === 0) return response;
|
||||
|
||||
const nextHeaders = new Headers(response.headers);
|
||||
for (const [key, value] of headers) nextHeaders.append(key, value);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: nextHeaders,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
|
||||
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
||||
|
||||
type CheckResult = {
|
||||
status: "healthy" | "unhealthy";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const data = await withTimeout(check(), HEALTHCHECK_TIMEOUT_MS);
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const result = data as { status?: string };
|
||||
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
|
||||
return { ...(data as object), status: "healthy", latencyMs };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: getErrorMessage(error),
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const storageService = getStorageService();
|
||||
return await storageService.healthcheck();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleHealth() {
|
||||
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
||||
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
|
||||
const checks = {
|
||||
service: "reactive-resume",
|
||||
version: process.env.npm_package_version,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database,
|
||||
storage,
|
||||
};
|
||||
|
||||
if (status === "unhealthy") {
|
||||
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
const body = JSON.stringify(checks);
|
||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
||||
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
|
||||
|
||||
return new Response(body, {
|
||||
headers,
|
||||
status: checks.status === "unhealthy" ? 503 : 200,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { createApp } from "./http/app";
|
||||
import { runStartupChecks } from "./startup/checks";
|
||||
|
||||
export { createApp } from "./http/app";
|
||||
|
||||
async function main() {
|
||||
await runStartupChecks();
|
||||
|
||||
const port =
|
||||
process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT;
|
||||
|
||||
const app = createApp();
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
},
|
||||
(info) => {
|
||||
console.info(`🚀 Up and running on http://localhost:${info.port}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateRequest(request: Request): Promise<void> {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||
if (payload?.sub) return;
|
||||
} catch {
|
||||
// Invalid or expired token; fall through to API key auth.
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
try {
|
||||
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
|
||||
if (result.valid) return;
|
||||
} catch {
|
||||
// Invalid or malformed key; fall through to AuthError.
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthError();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { AuthError, authenticateRequest } from "./auth";
|
||||
import { createMcpServer } from "./server";
|
||||
|
||||
export async function handleMcp(request: Request) {
|
||||
try {
|
||||
await authenticateRequest(request);
|
||||
|
||||
const server = await createMcpServer(request);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
return await transport.handleRequest(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return Response.json(
|
||||
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[MCP]", error);
|
||||
|
||||
return Response.json({
|
||||
id: null,
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RouterClient } from "@orpc/server";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { onError } from "@orpc/client";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||
return createRouterClient(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[MCP oRPC]", error);
|
||||
}),
|
||||
],
|
||||
context: () => ({
|
||||
locale: getRequestLocale(request),
|
||||
reqHeaders: request.headers,
|
||||
resHeaders: new Headers(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMcpServer(request: Request) {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "reactive-resume",
|
||||
version: __APP_VERSION__,
|
||||
title: "Reactive Resume",
|
||||
websiteUrl: "https://rxresu.me",
|
||||
description:
|
||||
"Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
|
||||
icons: [
|
||||
{
|
||||
src: "https://rxresu.me/icon/light.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
src: "https://rxresu.me/icon/dark.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "dark",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
instructions: [
|
||||
"You are connected to Reactive Resume over MCP.",
|
||||
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
|
||||
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
|
||||
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
|
||||
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
|
||||
].join(" "),
|
||||
},
|
||||
);
|
||||
|
||||
const client = createRequestClient(request);
|
||||
registerResources(server, client);
|
||||
registerTools(server, client, request.headers);
|
||||
registerPrompts(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
const openAPIRouter = {
|
||||
...router,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
plugins: [
|
||||
new BatchHandlerPlugin(),
|
||||
new RequestHeadersPlugin(),
|
||||
new StrictGetMethodPlugin(),
|
||||
new SmartCoercionPlugin({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[OpenAPI]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
});
|
||||
|
||||
export async function handleOpenApi(request: Request) {
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: __APP_VERSION__,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${env.APP_URL}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
|
||||
return Response.json(spec);
|
||||
}
|
||||
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await openAPIHandler.handle(request, {
|
||||
prefix: "/api/openapi",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
return mergeResponseHeaders(response, resHeaders);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||
|
||||
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
||||
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export function handleWellKnownFallback() {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
|
||||
export function handleMcpServerCard() {
|
||||
return Response.json(buildMcpServerCard(__APP_VERSION__), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function handleOAuthAuthorizationServer(request: Request) {
|
||||
return oauthAuthorizationServerHandler(request);
|
||||
}
|
||||
|
||||
export function handleOpenIdConfiguration(request: Request) {
|
||||
return openIdConfigurationHandler(request);
|
||||
}
|
||||
|
||||
export async function handleOAuthProtectedResource() {
|
||||
const metadata = {
|
||||
resource: env.APP_URL,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [env.APP_URL, `${env.APP_URL}/api/auth`],
|
||||
};
|
||||
|
||||
return Response.json(metadata, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "./locale";
|
||||
|
||||
const rpcHandler = new RPCHandler(router, {
|
||||
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[oRPC Server]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleRpc(request: Request) {
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
return mergeResponseHeaders(response, resHeaders);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||
import { getCookie } from "../http/headers";
|
||||
|
||||
export function getRequestLocale(request: Request): Locale {
|
||||
const locale = getCookie(request, "locale");
|
||||
return isLocale(locale) ? locale : defaultLocale;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { constants, existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { Pool } from "pg";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
|
||||
|
||||
function resolveFromCurrentModule(relativePath: string) {
|
||||
return fileURLToPath(new URL(relativePath, import.meta.url));
|
||||
}
|
||||
|
||||
function resolveWorkspaceFolder(folderName: string): string {
|
||||
let dir = resolveFromCurrentModule(".");
|
||||
|
||||
while (dir !== path.dirname(dir)) {
|
||||
const candidate = path.join(dir, folderName);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
|
||||
}
|
||||
|
||||
async function runDatabaseMigrations() {
|
||||
console.info("Running database migrations...");
|
||||
|
||||
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||
const db = drizzle({ client: pool });
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
|
||||
console.info("Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Database migrations failed", { error });
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function validateLocalStoragePath() {
|
||||
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
|
||||
|
||||
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
||||
console.info(`Validating local storage path: ${dataDirectory}`);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dataDirectory, { recursive: true });
|
||||
await fs.access(dataDirectory, constants.R_OK | constants.W_OK);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(
|
||||
`Local storage path is not writable: ${dataDirectory}\n` +
|
||||
` ${message}\n` +
|
||||
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runStartupChecks() {
|
||||
await runDatabaseMigrations();
|
||||
await validateLocalStoragePath();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export function handleSchemaJson() {
|
||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||
|
||||
return Response.json(resumeDataJSONSchema, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/schema+json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
"Surrogate-Control": "max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Robots-Tag": "index, follow",
|
||||
ETag: __APP_VERSION__,
|
||||
Vary: "Accept",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: {
|
||||
APP_URL: "https://app.example.com/",
|
||||
},
|
||||
}));
|
||||
|
||||
const { handleLlms, handleRobots, handleSitemap } = await import("./seo");
|
||||
|
||||
describe("SEO static endpoints", () => {
|
||||
it("generates robots.txt from the normalized app URL", async () => {
|
||||
const response = handleRobots();
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(text).toContain("User-agent: *");
|
||||
expect(text).toContain("Allow: /");
|
||||
expect(text).toContain("Disallow: /api/rpc");
|
||||
expect(text).toContain("Disallow: /api/auth");
|
||||
expect(text).toContain("Disallow: /mcp");
|
||||
expect(text).toContain("Disallow: /.well-known");
|
||||
expect(text).toContain("Sitemap: https://app.example.com/sitemap.xml");
|
||||
expect(text).toContain("Sitemap: https://docs.rxresu.me/sitemap.xml");
|
||||
expect(text).not.toMatch(/GPTBot|ClaudeBot|PerplexityBot|CCBot|ChatGPT-User/);
|
||||
});
|
||||
|
||||
it("generates an app-domain-only sitemap", async () => {
|
||||
const response = handleSitemap();
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
|
||||
expect(text).toContain("<loc>https://app.example.com/</loc>");
|
||||
expect(text).not.toContain("docs.rxresu.me");
|
||||
expect(text).not.toContain("/auth");
|
||||
expect(text).not.toContain("/dashboard");
|
||||
expect(text).not.toContain("/builder");
|
||||
expect(text).not.toContain("/templates");
|
||||
expect(text).not.toContain("/schema.json");
|
||||
});
|
||||
|
||||
it("generates a lightweight llms.txt product index", async () => {
|
||||
const response = handleLlms();
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(text).toContain("# Reactive Resume");
|
||||
expect(text).toContain("- Product: https://app.example.com");
|
||||
expect(text).toContain("- Documentation: https://docs.rxresu.me");
|
||||
expect(text).toContain("- Documentation sitemap: https://docs.rxresu.me/sitemap.xml");
|
||||
expect(text).toContain("- Documentation llms.txt: https://docs.rxresu.me/llms.txt");
|
||||
expect(text).toContain("- API documentation: https://docs.rxresu.me/api-reference");
|
||||
expect(text).toContain("- Resume schema: https://app.example.com/schema.json");
|
||||
expect(text).toContain("- MCP documentation: https://docs.rxresu.me/guides/using-the-mcp-server");
|
||||
expect(text).toContain("- OpenAPI specification: https://app.example.com/api/openapi/spec.json");
|
||||
});
|
||||
|
||||
it("returns headers without a body for HEAD responses", async () => {
|
||||
const response = handleLlms({ head: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
const DOCS_URL = "https://docs.rxresu.me";
|
||||
|
||||
type StaticSeoOptions = {
|
||||
head?: boolean;
|
||||
};
|
||||
|
||||
function appUrl() {
|
||||
return env.APP_URL.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function textResponse(body: string, options: StaticSeoOptions = {}) {
|
||||
return new Response(options.head ? null : body, {
|
||||
headers: { "Content-Type": "text/plain; charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function handleRobots(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
"Disallow: /api/rpc",
|
||||
"Disallow: /api/auth",
|
||||
"Disallow: /mcp",
|
||||
"Disallow: /.well-known",
|
||||
"",
|
||||
`Sitemap: ${baseUrl}/sitemap.xml`,
|
||||
`Sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return textResponse(body, options);
|
||||
}
|
||||
|
||||
export function handleSitemap(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
" <url>",
|
||||
` <loc>${baseUrl}/</loc>`,
|
||||
" </url>",
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return new Response(options?.head ? null : body, {
|
||||
headers: { "Content-Type": "application/xml; charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function handleLlms(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
"# Reactive Resume",
|
||||
"",
|
||||
"Reactive Resume is an open-source resume builder for creating, managing, and exporting resumes.",
|
||||
"",
|
||||
"## Links",
|
||||
"",
|
||||
`- Product: ${baseUrl}`,
|
||||
`- Documentation: ${DOCS_URL}`,
|
||||
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
|
||||
`- API documentation: ${DOCS_URL}/api-reference`,
|
||||
`- Resume schema: ${baseUrl}/schema.json`,
|
||||
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
|
||||
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return textResponse(body, options);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readMock = vi.fn();
|
||||
|
||||
vi.mock("@reactive-resume/api/features/storage", () => ({
|
||||
getStorageService: () => ({
|
||||
read: readMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: {
|
||||
APP_URL: "https://example.com",
|
||||
},
|
||||
}));
|
||||
|
||||
const { handleUpload } = await import("./uploads");
|
||||
|
||||
describe("handleUpload", () => {
|
||||
beforeEach(() => {
|
||||
readMock.mockReset();
|
||||
});
|
||||
|
||||
it("serves public upload keys", async () => {
|
||||
readMock.mockResolvedValueOnce({
|
||||
data: new TextEncoder().encode("image"),
|
||||
size: 5,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
const response = await handleUpload(new Request("https://example.com/api/uploads/user-1/pictures/photo.jpeg"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
|
||||
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("does not serve private agent attachment keys through the public uploads route", async () => {
|
||||
readMock.mockResolvedValueOnce({
|
||||
data: new TextEncoder().encode("secret"),
|
||||
size: 6,
|
||||
contentType: "text/plain",
|
||||
});
|
||||
|
||||
const response = await handleUpload(
|
||||
new Request("https://example.com/api/uploads/user-1/agent/thread-1/attachment.txt"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(readMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
export async function handleUpload(request: Request) {
|
||||
const { userId, filePath } = parseRouteParams(request.url);
|
||||
|
||||
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
|
||||
|
||||
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
|
||||
if (isPrivateUploadPath(filePath)) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const storageService = getStorageService();
|
||||
const key = `uploads/${userId}/${filePath}`;
|
||||
const storedFile = await storageService.read(key);
|
||||
if (!storedFile) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const ext = extname(filename).toLowerCase();
|
||||
const contentType = storedFile.contentType ?? inferContentTypeFromExtension(ext);
|
||||
const etag = createEtag(storedFile);
|
||||
|
||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
|
||||
const shouldForceDownload = [".pdf"].includes(ext);
|
||||
const headers = buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
});
|
||||
|
||||
const buffer = toArrayBuffer(storedFile.data);
|
||||
|
||||
return new Response(buffer, { headers });
|
||||
}
|
||||
|
||||
function inferContentTypeFromExtension(ext: string): string {
|
||||
switch (ext) {
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||
const pathname = new URL(url).pathname;
|
||||
const [, pathAfterUploads] = pathname.split("/uploads/");
|
||||
if (!pathAfterUploads) return { userId: undefined, filePath: undefined };
|
||||
const firstSlashIndex = pathAfterUploads.indexOf("/");
|
||||
|
||||
if (firstSlashIndex === -1) {
|
||||
return { userId: pathAfterUploads, filePath: undefined };
|
||||
}
|
||||
|
||||
const userId = pathAfterUploads.slice(0, firstSlashIndex);
|
||||
const filePath = pathAfterUploads.slice(firstSlashIndex + 1);
|
||||
|
||||
return { userId, filePath: filePath || undefined };
|
||||
}
|
||||
|
||||
function isValidPath(segment: string): boolean {
|
||||
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
|
||||
return normalized === segment;
|
||||
}
|
||||
|
||||
function isValidPathSegments(path: string): boolean {
|
||||
const segments = path.split("/");
|
||||
|
||||
return segments.every((segment) => isValidPath(segment));
|
||||
}
|
||||
|
||||
function isPrivateUploadPath(path: string): boolean {
|
||||
return path.split("/")[0] === "agent";
|
||||
}
|
||||
|
||||
function isNotModified(headers: Headers, etag: string): boolean {
|
||||
const ifNoneMatch = headers.get("If-None-Match");
|
||||
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
|
||||
|
||||
return candidates.includes(etag);
|
||||
}
|
||||
|
||||
function makeNotModifiedResponse(etag: string): Response {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: { ETag: etag, "Cache-Control": "public, max-age=31536000, immutable" },
|
||||
});
|
||||
}
|
||||
|
||||
type BuildResponseHeaderArgs = {
|
||||
filename: string;
|
||||
storedFile: { size: number };
|
||||
contentType: string;
|
||||
etag: string;
|
||||
shouldForceDownload: boolean;
|
||||
};
|
||||
|
||||
function buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
}: BuildResponseHeaderArgs): Headers {
|
||||
const headers = new Headers();
|
||||
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||
? (data.buffer as ArrayBuffer)
|
||||
: (data.slice().buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
|
||||
if (storedFile.etag) {
|
||||
const tag = storedFile.etag.trim();
|
||||
|
||||
if (tag.startsWith("W/") || tag.startsWith('"')) {
|
||||
return tag;
|
||||
}
|
||||
return `"${tag}"`;
|
||||
}
|
||||
|
||||
const hash = createHash("sha256").update(storedFile.data).digest("hex");
|
||||
|
||||
return `"${storedFile.size}-${hash}"`;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
default: {
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@hono/node-server/serve-static", () => ({
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
const { handleWebApp, handleWebAppHead } = await import("./web");
|
||||
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
|
||||
});
|
||||
|
||||
it("serves the shell for the root app route without noindex", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/auth/login",
|
||||
"/dashboard",
|
||||
"/builder/resume-1",
|
||||
"/agent",
|
||||
"/templates",
|
||||
"/templates/azurill.pdf",
|
||||
])("serves noindex shell for known app prefix %s", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it("serves noindex shell for public resume shaped routes", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it("returns noindex 404 for unknown non-asset routes", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/api/foo",
|
||||
"/mcp/foo",
|
||||
"/uploads/foo",
|
||||
])("does not treat reserved two-segment path %s as a public resume", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns plain 404 for missing asset-looking paths", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mirrors fallback status and headers for HEAD without a body", async () => {
|
||||
const knownResponse = handleWebAppHead(new Request("https://example.com/dashboard"));
|
||||
const unknownResponse = handleWebAppHead(new Request("https://example.com/unknown/extra/path"));
|
||||
|
||||
expect(knownResponse.status).toBe(200);
|
||||
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await knownResponse.text()).toBe("");
|
||||
|
||||
expect(unknownResponse.status).toBe(404);
|
||||
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await unknownResponse.text()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
|
||||
function resolveWebDistPath() {
|
||||
const candidates = [
|
||||
// Source layout: apps/server/src/static/web.ts -> apps/web/dist
|
||||
fileURLToPath(new URL("../../../web/dist", import.meta.url)),
|
||||
// Bundled layout: apps/server/dist/index.mjs -> apps/web/dist
|
||||
fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||
];
|
||||
const [fallback] = candidates;
|
||||
if (!fallback) throw new Error("Could not resolve web dist path");
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? fallback;
|
||||
}
|
||||
|
||||
const staticRoot = resolveWebDistPath();
|
||||
const indexHtmlPath = `${staticRoot}/index.html`;
|
||||
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
|
||||
const reservedPublicResumeSegments = new Set([
|
||||
"api",
|
||||
"mcp",
|
||||
".well-known",
|
||||
"uploads",
|
||||
"auth",
|
||||
"dashboard",
|
||||
"builder",
|
||||
"agent",
|
||||
"templates",
|
||||
]);
|
||||
|
||||
export const serveWebDistStatic = serveStatic({ root: staticRoot });
|
||||
|
||||
function isAssetPath(pathname: string): boolean {
|
||||
return pathname.split("/").pop()?.includes(".") ?? false;
|
||||
}
|
||||
|
||||
function getPathSegments(pathname: string) {
|
||||
return pathname.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function isNoindexShellPath(pathname: string): boolean {
|
||||
return noindexShellPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
function isPublicResumePath(pathname: string): boolean {
|
||||
const segments = getPathSegments(pathname);
|
||||
const [firstSegment] = segments;
|
||||
|
||||
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
|
||||
}
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8" };
|
||||
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Robots-Tag": "noindex, follow",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
||||
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
|
||||
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
|
||||
return new Response(options.head ? null : "Not Found", {
|
||||
status: 404,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleWebApp(request: Request) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
if (!headers) return notFoundResponse({ noindex: true });
|
||||
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
return new Response(html, { headers });
|
||||
}
|
||||
|
||||
export function handleWebAppHead(request: Request) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response(null, { status: 404 });
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
if (!headers) return notFoundResponse({ head: true, noindex: true });
|
||||
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __APP_VERSION__: string;
|
||||
Reference in New Issue
Block a user