diff --git a/apps/server/src/http/health.ts b/apps/server/src/http/health.ts index d5ef04a5d..240c85246 100644 --- a/apps/server/src/http/health.ts +++ b/apps/server/src/http/health.ts @@ -80,13 +80,5 @@ export async function handleHealth() { 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, - }); + return Response.json(checks, { status: checks.status === "unhealthy" ? 503 : 200 }); } diff --git a/apps/server/src/static/uploads-s3.test.ts b/apps/server/src/static/uploads-s3.test.ts index 3fb68ffde..e8c052b51 100644 --- a/apps/server/src/static/uploads-s3.test.ts +++ b/apps/server/src/static/uploads-s3.test.ts @@ -8,6 +8,7 @@ const envMock = vi.hoisted(() => ({ S3_SECRET_ACCESS_KEY: "test-secret-key", S3_REGION: "us-east-1", S3_ENDPOINT: "", + STORAGE_BACKEND: "s3", S3_BUCKET: "test-bucket", S3_FORCE_PATH_STYLE: true, })); diff --git a/packages/api/package.json b/packages/api/package.json index f2070f857..d265e8cf7 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -54,7 +54,7 @@ "better-auth": "1.7.6", "drizzle-orm": "1.0.0-rc.4", "drizzle-zod": "1.0.0-beta.14-a36c63d", - "es-toolkit": "^1.52.0", + "fflate": "^0.8.3", "ioredis": "^6.0.0", "jsonrepair": "^3.15.0", "ollama-ai-provider-v2": "^4.0.1", @@ -71,6 +71,7 @@ "@types/pg": "^8.23.1", "@types/sanitize-html": "^2.16.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", + "es-toolkit": "^1.52.0", "pg": "^8.23.0", "typescript": "^7.0.2" } diff --git a/packages/api/src/features/agent/service.test.ts b/packages/api/src/features/agent/service.test.ts index d11ce87e2..a3e30a2aa 100644 --- a/packages/api/src/features/agent/service.test.ts +++ b/packages/api/src/features/agent/service.test.ts @@ -39,7 +39,6 @@ const resumeServiceMock = { getById: vi.fn(), patch: vi.fn(), patchInTransaction: vi.fn(), - notifyResumePatched: vi.fn(), }; const aiProvidersServiceMock = { diff --git a/packages/api/src/features/ai-providers/service.test.ts b/packages/api/src/features/ai-providers/service.test.ts index 59abbe5b9..2b4baf594 100644 --- a/packages/api/src/features/ai-providers/service.test.ts +++ b/packages/api/src/features/ai-providers/service.test.ts @@ -59,10 +59,6 @@ vi.mock("../ai/credentials", () => ({ assertCredentialEncryptionConfigured: vi.fn(), decryptCredential: vi.fn(() => "decrypted-key"), encryptCredential: vi.fn(), - redactEncryptedCredential: vi.fn(() => ({ - apiKeyFingerprint: "fingerprint", - apiKeyPreview: "sk-...test", - })), })); vi.mock("../ai/service", () => ({ testConnection: vi.fn() })); vi.mock("../ai/url-policy", () => ({ resolveAiBaseUrl: vi.fn() })); diff --git a/packages/api/src/features/ai-providers/service.ts b/packages/api/src/features/ai-providers/service.ts index d7d7705af..ec0979b39 100644 --- a/packages/api/src/features/ai-providers/service.ts +++ b/packages/api/src/features/ai-providers/service.ts @@ -4,12 +4,7 @@ import { and, asc, desc, eq, sql } from "drizzle-orm"; import { aiProviderSchema } from "@reactive-resume/ai/types"; import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; -import { - assertCredentialEncryptionConfigured, - decryptCredential, - encryptCredential, - redactEncryptedCredential, -} from "../ai/credentials"; +import { assertCredentialEncryptionConfigured, decryptCredential, encryptCredential } from "../ai/credentials"; import { testConnection } from "../ai/service"; import { resolveAiBaseUrl } from "../ai/url-policy"; @@ -54,12 +49,6 @@ type UpdateAiProviderInput = { function toResponse(row: AiProviderRecord): AiProviderResponse { const provider = aiProviderSchema.parse(row.provider); - const { apiKeyFingerprint, apiKeyPreview } = redactEncryptedCredential({ - encryptedApiKey: row.encryptedApiKey, - apiKeySalt: row.apiKeySalt, - apiKeyHash: row.apiKeyHash, - apiKeyPreview: row.apiKeyPreview, - }); return { id: row.id, @@ -70,8 +59,9 @@ function toResponse(row: AiProviderRecord): AiProviderResponse { enabled: row.enabled, testStatus: row.testStatus, testError: row.testError, - apiKeyPreview, - apiKeyFingerprint, + apiKeyPreview: row.apiKeyPreview, + // The hash identifies the key without revealing it; the ciphertext and salt never leave the server. + apiKeyFingerprint: row.apiKeyHash, lastTestedAt: row.lastTestedAt, lastUsedAt: row.lastUsedAt, createdAt: row.createdAt, diff --git a/packages/api/src/features/ai/credentials.test.ts b/packages/api/src/features/ai/credentials.test.ts index 098d858a6..e93f516c1 100644 --- a/packages/api/src/features/ai/credentials.test.ts +++ b/packages/api/src/features/ai/credentials.test.ts @@ -7,14 +7,9 @@ const envMock = vi.hoisted(() => ({ vi.mock("@reactive-resume/env/server", () => ({ env: envMock })); -const { - assertAgentEnvironment, - decryptCredential, - encryptCredential, - fingerprintCredential, - isAgentEnvironmentConfigured, - redactEncryptedCredential, -} = await import("./credentials"); +const { assertAgentEnvironment, decryptCredential, encryptCredential, fingerprintCredential } = await import( + "./credentials" +); describe("AI credential encryption", () => { it("encrypts and decrypts provider API keys without storing plaintext", () => { @@ -34,36 +29,16 @@ describe("AI credential encryption", () => { expect(first).not.toBe(differentSalt); expect(first).not.toContain("sk-test-secret"); }); - - it("redacts stored encrypted credential fields from API responses", () => { - const encrypted = encryptCredential("sk-test-secret"); - - const redacted = redactEncryptedCredential({ - encryptedApiKey: encrypted.encryptedApiKey, - apiKeySalt: encrypted.apiKeySalt, - apiKeyHash: encrypted.apiKeyHash, - apiKeyPreview: encrypted.apiKeyPreview, - }); - - expect(redacted).toEqual({ - apiKeyFingerprint: encrypted.apiKeyHash, - apiKeyPreview: encrypted.apiKeyPreview, - }); - expect(JSON.stringify(redacted)).not.toContain(encrypted.encryptedApiKey); - expect(JSON.stringify(redacted)).not.toContain(encrypted.apiKeySalt); - }); }); describe("AI agent environment", () => { it("needs the encryption secret, and works without Redis", () => { - expect(isAgentEnvironmentConfigured()).toBe(true); expect(() => assertAgentEnvironment()).not.toThrow(); envMock.REDIS_URL = ""; - expect(isAgentEnvironmentConfigured()).toBe(true); + expect(() => assertAgentEnvironment()).not.toThrow(); envMock.ENCRYPTION_SECRET = ""; - expect(isAgentEnvironmentConfigured()).toBe(false); expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE"); }); }); diff --git a/packages/api/src/features/ai/credentials.ts b/packages/api/src/features/ai/credentials.ts index 5e1e7b30c..365ce54ae 100644 --- a/packages/api/src/features/ai/credentials.ts +++ b/packages/api/src/features/ai/credentials.ts @@ -13,11 +13,6 @@ type StoredCredentialFields = { apiKeyPreview: string; }; -type RedactedCredentialFields = { - apiKeyFingerprint: string; - apiKeyPreview: string; -}; - function getEncryptionSecret() { return env.ENCRYPTION_SECRET?.trim() ?? ""; } @@ -77,27 +72,16 @@ export function decryptCredential(payload: string) { return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString("utf8"); } -export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields { - return { - apiKeyFingerprint: fields.apiKeyHash, - apiKeyPreview: fields.apiKeyPreview, - }; -} - function isCredentialEncryptionConfigured() { return !!getEncryptionSecret(); } -// The assistant needs stored keys, so ENCRYPTION_SECRET. Redis is optional: with it, replies survive a reload -// and Stop reaches a run on another server; without it, both work within one server. -export function isAgentEnvironmentConfigured() { - return isCredentialEncryptionConfigured(); -} - export function assertCredentialEncryptionConfigured() { if (!isCredentialEncryptionConfigured()) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE"); } +// The assistant needs stored keys, so ENCRYPTION_SECRET. Redis is optional: with it, replies survive a reload +// and Stop reaches a run on another server; without it, both work within one server. export function assertAgentEnvironment() { - if (!isAgentEnvironmentConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE"); + if (!isCredentialEncryptionConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE"); } diff --git a/packages/api/src/features/ai/service.ts b/packages/api/src/features/ai/service.ts index acd7f976c..6a4f196fc 100644 --- a/packages/api/src/features/ai/service.ts +++ b/packages/api/src/features/ai/service.ts @@ -1,7 +1,6 @@ import type { AIProvider } from "@reactive-resume/ai/types"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { ModelMessage } from "ai"; -import { inflateRawSync } from "node:zlib"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createCerebras } from "@ai-sdk/cerebras"; import { createCohere } from "@ai-sdk/cohere"; @@ -16,6 +15,7 @@ import { createPerplexity } from "@ai-sdk/perplexity"; import { createTogetherAI } from "@ai-sdk/togetherai"; import { createXai } from "@ai-sdk/xai"; import { APICallError, createGateway, generateText, LoadAPIKeyError, NoSuchModelError } from "ai"; +import { strFromU8, unzipSync } from "fflate"; import { createOllama } from "ollama-ai-provider-v2"; import { match } from "ts-pattern"; import { z } from "zod"; @@ -86,11 +86,6 @@ const TEST_CONNECTION_TIMEOUT_MS = z.coerce .catch(30_000) .parse(process.env.AI_TEST_TIMEOUT_MS?.trim() || undefined); const DOCX_DOCUMENT_XML_PATH = "word/document.xml"; -const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; -const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; -const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; -const ZIP_STORED_METHOD = 0; -const ZIP_DEFLATED_METHOD = 8; export function getModel(input: GetModelInput) { const { provider, model, apiKey } = input; @@ -338,68 +333,16 @@ type ParseDocxInput = z.infer & { mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; }; -function assertZipRange(buffer: Buffer, offset: number, length: number) { - if (offset < 0 || length < 0 || offset + length > buffer.length) throw new Error("Invalid DOCX archive."); -} - -function findEndOfCentralDirectory(buffer: Buffer): number { - const minOffset = Math.max(0, buffer.length - 0xffff - 22); - - for (let offset = buffer.length - 22; offset >= minOffset; offset--) { - if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) return offset; +function readDocumentXml(data: string): string { + let entries: Record; + try { + entries = unzipSync(Buffer.from(data, "base64"), { filter: (file) => file.name === DOCX_DOCUMENT_XML_PATH }); + } catch { + throw new Error("Invalid DOCX archive."); } - - throw new Error("Invalid DOCX archive."); -} - -function readZipEntry(buffer: Buffer, entryName: string): Buffer { - const eocdOffset = findEndOfCentralDirectory(buffer); - assertZipRange(buffer, eocdOffset, 22); - - const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12); - const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16); - assertZipRange(buffer, centralDirectoryOffset, centralDirectorySize); - - let offset = centralDirectoryOffset; - const endOffset = centralDirectoryOffset + centralDirectorySize; - - while (offset < endOffset) { - assertZipRange(buffer, offset, 46); - if (buffer.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) throw new Error("Invalid DOCX archive."); - - const compressionMethod = buffer.readUInt16LE(offset + 10); - const compressedSize = buffer.readUInt32LE(offset + 20); - const fileNameLength = buffer.readUInt16LE(offset + 28); - const extraFieldLength = buffer.readUInt16LE(offset + 30); - const commentLength = buffer.readUInt16LE(offset + 32); - const localHeaderOffset = buffer.readUInt32LE(offset + 42); - const fileNameOffset = offset + 46; - assertZipRange(buffer, fileNameOffset, fileNameLength); - - const fileName = buffer.toString("utf8", fileNameOffset, fileNameOffset + fileNameLength); - - if (fileName === entryName) { - assertZipRange(buffer, localHeaderOffset, 30); - if (buffer.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) { - throw new Error("Invalid DOCX archive."); - } - - const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26); - const localExtraFieldLength = buffer.readUInt16LE(localHeaderOffset + 28); - const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraFieldLength; - assertZipRange(buffer, dataOffset, compressedSize); - - const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize); - if (compressionMethod === ZIP_STORED_METHOD) return compressed; - if (compressionMethod === ZIP_DEFLATED_METHOD) return inflateRawSync(compressed); - - throw new Error("Unsupported DOCX archive compression."); - } - - offset = fileNameOffset + fileNameLength + extraFieldLength + commentLength; - } - - throw new Error("DOCX document content not found."); + const xml = entries[DOCX_DOCUMENT_XML_PATH]; + if (!xml) throw new Error("DOCX document content not found."); + return strFromU8(xml); } function decodeXmlEntities(value: string): string { @@ -416,7 +359,7 @@ function decodeXmlEntities(value: string): string { } function extractDocxText(file: z.infer): string { - const documentXml = readZipEntry(Buffer.from(file.data, "base64"), DOCX_DOCUMENT_XML_PATH).toString("utf8"); + const documentXml = readDocumentXml(file.data); // ponytail: minimal OOXML body-text extraction; add a DOCX parser dependency if tracked changes matter. const text = decodeXmlEntities( documentXml diff --git a/packages/api/src/features/cover-letters/embedded.ts b/packages/api/src/features/cover-letters/embedded.ts index 089ae5333..c281e7338 100644 --- a/packages/api/src/features/cover-letters/embedded.ts +++ b/packages/api/src/features/cover-letters/embedded.ts @@ -1,4 +1,4 @@ -import type { db } from "@reactive-resume/db/client"; +import type { DbOrTx } from "@reactive-resume/db/client"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { and, eq, inArray, sql } from "drizzle-orm"; import * as schema from "@reactive-resume/db/schema"; @@ -7,8 +7,6 @@ import { coverLetterSchema } from "@reactive-resume/schema/cover-letter/data"; import { sanitizeCoverLetterHtml } from "./html"; import { writeLetterVersion } from "./versions"; -type DbOrTx = typeof db | Parameters[0]>[0]; - /** * Letters are documents of their own. Older app versions, API clients, imported files and restored versions can * still hand the server a resume with cover-letter sections; each resume write passes through here first, so those diff --git a/packages/api/src/features/cover-letters/versions.ts b/packages/api/src/features/cover-letters/versions.ts index 644b69ae5..fa90dabcd 100644 --- a/packages/api/src/features/cover-letters/versions.ts +++ b/packages/api/src/features/cover-letters/versions.ts @@ -1,3 +1,4 @@ +import type { DbOrTx } from "@reactive-resume/db/client"; import type { CoverLetterVersionData, CoverLetterVersionKind } from "@reactive-resume/db/schema"; import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data"; import { ORPCError } from "@orpc/client"; @@ -5,8 +6,6 @@ import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm"; import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; -type DbOrTx = typeof db | Parameters[0]>[0]; - // The same retention as resumes (see resume/version-history.ts): sessions refresh their autosave at most every two // minutes; autosaves and restore markers last 90 days; at most 500 autosaves per letter. const SESSION_REFRESH_MS = 2 * 60 * 1000; diff --git a/packages/api/src/features/resume/service.ts b/packages/api/src/features/resume/service.ts index 6dadbc7aa..f5e62af97 100644 --- a/packages/api/src/features/resume/service.ts +++ b/packages/api/src/features/resume/service.ts @@ -1,3 +1,4 @@ +import type { DbOrTx } from "@reactive-resume/db/client"; import type { JsonPatchOperation } from "@reactive-resume/resume/patch"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { Locale } from "@reactive-resume/utils/locale"; @@ -5,7 +6,6 @@ import type { ResumeUpdatedEvent } from "./events"; import { ORPCError } from "@orpc/client"; import { compare, hash } from "bcrypt"; import { and, arrayContains, asc, desc, eq, gte, isNotNull, isNull, sql } from "drizzle-orm"; -import { get } from "es-toolkit/compat"; import { match } from "ts-pattern"; import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; @@ -30,8 +30,6 @@ import { } from "./version-history"; import { clientKeyFromHeaders, shouldCountView } from "./view-dedup"; -type DbOrTx = typeof db | Parameters[0]>[0]; - function resumeVersionConflict(updatedAt: Date) { return new ORPCError("RESUME_VERSION_CONFLICT", { status: 409, @@ -40,14 +38,13 @@ function resumeVersionConflict(updatedAt: Date) { }); } -function invalidPatchOperation(message: string, index?: number, operation?: JsonPatchOperation) { - if (index !== undefined && operation !== undefined) { - return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message, data: { index, operation } }); - } - - return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message }); +function invalidPatchOperation(message: string, index: number, operation: JsonPatchOperation) { + return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message, data: { index, operation } }); } +/** The unique constraint a Postgres insert or update broke, if that's why it failed. */ +const uniqueConstraint = (error: unknown) => (error as { cause?: { constraint?: string } } | null)?.cause?.constraint; + function isValidJsonPointer(pointer: string): boolean { if (pointer === "") return true; if (!pointer.startsWith("/")) return false; @@ -423,9 +420,7 @@ export const resumeService = { and( eq(schema.resume.userId, input.userId), isNull(schema.resume.trashedAt), - match(input.tags.length) - .with(0, () => undefined) - .otherwise(() => arrayContains(schema.resume.tags, input.tags)), + input.tags.length > 0 ? arrayContains(schema.resume.tags, input.tags) : undefined, ), ) .orderBy( @@ -568,7 +563,7 @@ export const resumeService = { return id; } catch (error) { - const constraint = get(error, "cause.constraint") as string | undefined; + const constraint = uniqueConstraint(error); if (constraint === "resume_slug_user_id_unique") { throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 }); @@ -675,7 +670,7 @@ export const resumeService = { .catch((error: unknown) => { if (error instanceof ORPCError) throw error; - if (get(error, "cause.constraint") === "resume_slug_user_id_unique") { + if (uniqueConstraint(error) === "resume_slug_user_id_unique") { throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 }); } @@ -720,16 +715,6 @@ export const resumeService = { patchInTransaction: applyResumePatchTx, - notifyResumePatched: async (input: { resumeId: string; userId: string; updatedAt: Date }) => { - await notifyResumeUpdated({ - type: "resume.updated", - resumeId: input.resumeId, - userId: input.userId, - updatedAt: input.updatedAt.toISOString(), - mutation: "patch", - }); - }, - setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => { const [resume] = await db .update(schema.resume) diff --git a/packages/api/src/features/resume/slugs.ts b/packages/api/src/features/resume/slugs.ts index ab4e3a574..26b8e8891 100644 --- a/packages/api/src/features/resume/slugs.ts +++ b/packages/api/src/features/resume/slugs.ts @@ -1,10 +1,9 @@ +import type { DbOrTx } from "@reactive-resume/db/client"; import { and, eq, gt, inArray, like, or, sql } from "drizzle-orm"; import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; import { slugify } from "@reactive-resume/utils/string"; -type DbOrTx = typeof db | Parameters[0]>[0]; - /** Lowercase letters and numbers, in groups joined by single dashes. Only new and changed slugs must match. */ export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; diff --git a/packages/api/src/features/resume/version-history.ts b/packages/api/src/features/resume/version-history.ts index 1897ddd98..79adbc8c3 100644 --- a/packages/api/src/features/resume/version-history.ts +++ b/packages/api/src/features/resume/version-history.ts @@ -1,3 +1,4 @@ +import type { DbOrTx } from "@reactive-resume/db/client"; import type { ResumeVersionKind } from "@reactive-resume/db/schema"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { ORPCError } from "@orpc/client"; @@ -6,8 +7,6 @@ import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation"; -type DbOrTx = typeof db | Parameters[0]>[0]; - // An editing session's autosave is refreshed at most this often. const SESSION_REFRESH_MS = 2 * 60 * 1000; // Autosaves, AI edits and restore markers are kept this long. Named, sent, created, imported and diff --git a/packages/api/src/features/statistics/service.ts b/packages/api/src/features/statistics/service.ts index 0160acaca..aa7b7f049 100644 --- a/packages/api/src/features/statistics/service.ts +++ b/packages/api/src/features/statistics/service.ts @@ -52,17 +52,13 @@ const getCachedCount = async ( const getCountFromDatabase = async (table: typeof schema.user | typeof schema.resume): Promise => { const [result] = await db.select({ count: count() }).from(table); - if (!result) return null; - return result.count; + return result?.count ?? null; }; const fetchGitHubStarsOnce = async (): Promise => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS); - try { const response = await fetch(GITHUB_API_URL, { - signal: controller.signal, + signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), headers: { Accept: "application/vnd.github+json", }, @@ -74,8 +70,6 @@ const fetchGitHubStarsOnce = async (): Promise => { return Number.isFinite(stars) && stars > 0 ? stars : null; } catch { return null; - } finally { - clearTimeout(timeoutId); } }; diff --git a/packages/api/src/features/storage/service.ts b/packages/api/src/features/storage/service.ts index 04e2bcaf2..427c74664 100644 --- a/packages/api/src/features/storage/service.ts +++ b/packages/api/src/features/storage/service.ts @@ -330,8 +330,7 @@ export function getStorageService(): StorageService { cachedService ??= env.STORAGE_BACKEND === "blob" ? new BlobStorageService() - : env.STORAGE_BACKEND === "s3" || - (!env.STORAGE_BACKEND && env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) + : env.STORAGE_BACKEND === "s3" ? new S3StorageService() : new LocalStorageService(); return cachedService; diff --git a/packages/auth/src/trusted-origins.ts b/packages/auth/src/trusted-origins.ts index b0d113078..038cd1681 100644 --- a/packages/auth/src/trusted-origins.ts +++ b/packages/auth/src/trusted-origins.ts @@ -1,14 +1,13 @@ export function getTrustedOrigins(appUrl: string): string[] { - const normalizeOrigin = (origin: string): string => origin.replace(/\/$/, ""); const trustedOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]); const configuredUrl = new URL(appUrl); - trustedOrigins.add(normalizeOrigin(configuredUrl.origin)); + trustedOrigins.add(configuredUrl.origin); if (configuredUrl.hostname === "localhost" || configuredUrl.hostname === "127.0.0.1") { const loopbackAlias = configuredUrl.hostname === "localhost" ? "127.0.0.1" : "localhost"; configuredUrl.hostname = loopbackAlias; - trustedOrigins.add(normalizeOrigin(configuredUrl.origin)); + trustedOrigins.add(configuredUrl.origin); } return [...trustedOrigins]; diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 1e55dd196..12d8d6160 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -36,3 +36,6 @@ export function getPool() { // ponytail: two private fns collapsed; getPool() is already a singleton, global cache preserved globalThis.__drizzle ??= drizzle({ client: getPool() }); export const db = globalThis.__drizzle; + +/** The client, or a transaction on it: helpers that write take either, so callers choose the transaction. */ +export type DbOrTx = typeof db | Parameters[0]>[0]; diff --git a/packages/email/src/transport.test.ts b/packages/email/src/transport.test.ts index b53ca8d1b..3714dd53f 100644 --- a/packages/email/src/transport.test.ts +++ b/packages/email/src/transport.test.ts @@ -24,6 +24,8 @@ vi.mock("react-email", () => ({ const { sendEmail } = await import("./transport"); +const fakeReact = { $$typeof: Symbol.for("react.element") } as unknown as React.ReactElement; + const resetEnv = () => { envMock.SMTP_HOST = undefined; envMock.SMTP_USER = undefined; @@ -34,16 +36,10 @@ const resetEnv = () => { }; describe("sendEmail", () => { - it("does nothing when neither text nor html is provided", async () => { - resetEnv(); - await sendEmail({ to: "a@b.com", subject: "hi" }); - expect(sendMail).not.toHaveBeenCalled(); - }); - it("skips sending and logs when SMTP is not configured (no host)", async () => { resetEnv(); const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); - await sendEmail({ to: "a@b.com", subject: "hi", text: "body" }); + await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact }); expect(infoSpy).toHaveBeenCalledWith( "SMTP not configured; skipping email send.", @@ -60,7 +56,7 @@ describe("sendEmail", () => { envMock.SMTP_PASS = "pass"; envMock.SMTP_FROM = "noreply@example.com"; - await sendEmail({ to: "a@b.com", subject: "hi", text: "body" }); + await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact }); expect(createTransport).toHaveBeenCalledWith( expect.objectContaining({ @@ -75,24 +71,11 @@ describe("sendEmail", () => { to: "a@b.com", from: "noreply@example.com", subject: "hi", - text: "body", + text: "plain text body", }), ); }); - it("falls back to a default 'noreply@localhost' from address when SMTP_FROM is unset", async () => { - resetEnv(); - envMock.SMTP_HOST = "smtp.example.com"; - envMock.SMTP_USER = "user"; - envMock.SMTP_PASS = "pass"; - - // SMTP not "enabled" without SMTP_FROM — skipping branch — but options.from is used. - // Manually provide from in options instead. - await sendEmail({ to: "a@b.com", from: "explicit@x.com", subject: "hi", text: "body" }); - // SMTP isn't enabled, so sendMail isn't called — confirm the info-log branch instead. - expect(sendMail).not.toHaveBeenCalled(); - }); - it("renders react element into both html and plain-text bodies", async () => { resetEnv(); envMock.SMTP_HOST = "smtp.example.com"; @@ -100,7 +83,6 @@ describe("sendEmail", () => { envMock.SMTP_PASS = "pass"; envMock.SMTP_FROM = "noreply@example.com"; - const fakeReact = { $$typeof: Symbol.for("react.element") } as unknown as React.ReactElement; await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact }); expect(sendMail).toHaveBeenCalledWith( @@ -120,7 +102,7 @@ describe("sendEmail", () => { sendMail.mockRejectedValueOnce(new Error("boom")); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(sendEmail({ to: "a@b.com", subject: "hi", text: "body" })).resolves.toBeUndefined(); + await expect(sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact })).resolves.toBeUndefined(); expect(errorSpy).toHaveBeenCalled(); errorSpy.mockRestore(); }); diff --git a/packages/email/src/transport.ts b/packages/email/src/transport.ts index a69b8f075..bcfdc5102 100644 --- a/packages/email/src/transport.ts +++ b/packages/email/src/transport.ts @@ -4,14 +4,7 @@ import nodemailer from "nodemailer"; import { render } from "react-email"; import { env } from "@reactive-resume/env/server"; -type SendEmailOptions = { - to: string | string[]; - subject: string; - text?: string; - html?: string; - react?: ReactElement; - from?: string; -}; +type SendEmailOptions = { to: string; subject: string; react: ReactElement }; let cachedTransport: Transporter | undefined; @@ -29,24 +22,16 @@ const getTransport = () => { return cachedTransport; }; -export const sendEmail = async (options: SendEmailOptions) => { +export const sendEmail = async ({ to, subject, react }: SendEmailOptions) => { const transport = getTransport(); - const from = options.from ?? env.SMTP_FROM ?? "Reactive Resume "; const payload: SendMailOptions = { - to: options.to, - from, - subject: options.subject, - ...(options.text === undefined ? {} : { text: options.text }), - ...(options.html === undefined ? {} : { html: options.html }), + to, + from: env.SMTP_FROM, + subject, + html: await render(react), + text: await render(react, { plainText: true }), }; - if (options.react) { - payload.html = await render(options.react); - payload.text = options.text ?? (await render(options.react, { plainText: true })); - } - - if (!payload.text && !payload.html) return; - if (!transport) { console.info("SMTP not configured; skipping email send.", { to: payload.to, diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 3758ebe43..1efd4031b 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -131,7 +131,7 @@ export function registerTools(server: McpServer, client: RouterClient { const resume = await client.resume.getById({ id }); - return text(JSON.stringify(resume.data, null, 2)); + return json(resume.data); }), ); @@ -171,20 +171,14 @@ export function registerTools(server: McpServer, client: RouterClient { const stats = await client.resume.statistics.getById({ id }); - return text(JSON.stringify(stats, null, 2)); + return json(stats); }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0c77fb82..6dbd2875b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -914,9 +914,9 @@ importers: drizzle-zod: specifier: 1.0.0-beta.14-a36c63d version: 1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.0)(zod@4.6.5))(zod@4.6.5) - es-toolkit: - specifier: ^1.52.0 - version: 1.52.0 + fflate: + specifier: ^0.8.3 + version: 0.8.3 ioredis: specifier: ^6.0.0 version: 6.0.0(supports-color@7.2.0) @@ -960,6 +960,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 + es-toolkit: + specifier: ^1.52.0 + version: 1.52.0 pg: specifier: ^8.23.0 version: 8.23.0