Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,31 @@ describe('envSchema', () => {
const r = envSchema.safeParse({ DATABASE_URL: 'x', NODE_ENV: 'development' });
expect(r.success).toBe(true);
});

// Regression: Railway/Nixpacks pass unset env vars to Node as "" rather than
// omitting them. A previous schema rejected NODE_ENV="" as an invalid enum,
// crash-looping the API on every Railway boot.
it('treats empty-string NODE_ENV as unset', () => {
const r = envSchema.safeParse({ DATABASE_URL: 'x', NODE_ENV: '' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.NODE_ENV).toBeUndefined();
});

it('treats empty-string LOG_LEVEL as unset', () => {
const r = envSchema.safeParse({ DATABASE_URL: 'x', LOG_LEVEL: '' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.LOG_LEVEL).toBeUndefined();
});

it('treats empty-string PUBLIC_URL as unset (would otherwise fail .url())', () => {
const r = envSchema.safeParse({ DATABASE_URL: 'x', PUBLIC_URL: '' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.PUBLIC_URL).toBeUndefined();
});

it('treats empty-string PORT as unset and falls back to default', () => {
const r = envSchema.safeParse({ DATABASE_URL: 'x', PORT: '' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.PORT).toBe(8787);
});
});
107 changes: 61 additions & 46 deletions apps/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,52 +24,67 @@ export const resultBodySchema = z

// --- Environment -------------------------------------------------------------

export const envSchema = z
.object({
DATABASE_URL: z.string().min(1),
PORT: z.coerce.number().optional().default(8787),
PUBLIC_URL: z.string().url().optional(),
PAGE_TTL_MS: z.coerce.number().optional().default(1_800_000),
ALLOWED_ORIGINS: z
.string()
.optional()
.transform((v) =>
v
? v
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined,
),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
OTEL_SERVICE_NAME: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).optional(),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(30),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
})
.superRefine((cfg, ctx) => {
if (
cfg.NODE_ENV === 'production' &&
(!cfg.ALLOWED_ORIGINS || cfg.ALLOWED_ORIGINS.length === 0)
) {
ctx.addIssue({
code: 'custom',
path: ['ALLOWED_ORIGINS'],
message:
'ALLOWED_ORIGINS is required in production. Set it to a comma-separated list of origins permitted to call the API (e.g. https://pagent.vercel.app).',
});
}
if (cfg.NODE_ENV === 'production' && !cfg.PUBLIC_URL) {
ctx.addIssue({
code: 'custom',
path: ['PUBLIC_URL'],
message:
'PUBLIC_URL is required in production. Set it to the renderer URL (e.g. https://pagent.vercel.app).',
});
}
});
// Railway, Nixpacks, and various CI runners set unset vars as empty strings
// rather than leaving them undefined. Treat "" as "not set" so .optional()
// behaves how callers expect (otherwise enum/url/coerce.number all reject "").
const stripEmptyStrings = (raw: unknown): unknown => {
if (typeof raw !== 'object' || raw === null) return raw;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
out[k] = v === '' ? undefined : v;
}
return out;
};

export const envSchema = z.preprocess(
stripEmptyStrings,
z
.object({
DATABASE_URL: z.string().min(1),
PORT: z.coerce.number().optional().default(8787),
PUBLIC_URL: z.string().url().optional(),
PAGE_TTL_MS: z.coerce.number().optional().default(1_800_000),
ALLOWED_ORIGINS: z
.string()
.optional()
.transform((v) =>
v
? v
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined,
),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
OTEL_SERVICE_NAME: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).optional(),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(30),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
})
.superRefine((cfg, ctx) => {
if (
cfg.NODE_ENV === 'production' &&
(!cfg.ALLOWED_ORIGINS || cfg.ALLOWED_ORIGINS.length === 0)
) {
ctx.addIssue({
code: 'custom',
path: ['ALLOWED_ORIGINS'],
message:
'ALLOWED_ORIGINS is required in production. Set it to a comma-separated list of origins permitted to call the API (e.g. https://pagent.vercel.app).',
});
}
if (cfg.NODE_ENV === 'production' && !cfg.PUBLIC_URL) {
ctx.addIssue({
code: 'custom',
path: ['PUBLIC_URL'],
message:
'PUBLIC_URL is required in production. Set it to the renderer URL (e.g. https://pagent.vercel.app).',
});
}
}),
);

export type Env = z.infer<typeof envSchema>;

Expand Down
16 changes: 13 additions & 3 deletions apps/mcp/server.bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -21178,9 +21178,19 @@ page_id: ${created.id}`
}

// apps/mcp/server.ts
var envSchema = external_exports.object({
PAGENT_URL: external_exports.string().url("PAGENT_URL must be a valid URL").optional()
});
var envSchema = external_exports.preprocess(
(raw) => {
if (typeof raw !== "object" || raw === null) return raw;
const out = {};
for (const [k, v] of Object.entries(raw)) {
out[k] = v === "" ? void 0 : v;
}
return out;
},
external_exports.object({
PAGENT_URL: external_exports.string().url("PAGENT_URL must be a valid URL").optional()
})
);
var env;
try {
env = envSchema.parse(process.env);
Expand Down
18 changes: 15 additions & 3 deletions apps/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,21 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod';
import { registerPagentTools, type PageOps } from '../api/mcp/tools.ts';

const envSchema = z.object({
PAGENT_URL: z.string().url('PAGENT_URL must be a valid URL').optional(),
});
// Empty strings (set by some shells / launchers when a var is "unset") need
// to be normalised to undefined before .url().optional() runs.
const envSchema = z.preprocess(
(raw) => {
if (typeof raw !== 'object' || raw === null) return raw;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
out[k] = v === '' ? undefined : v;
}
return out;
},
z.object({
PAGENT_URL: z.string().url('PAGENT_URL must be a valid URL').optional(),
}),
);

let env: z.infer<typeof envSchema>;
try {
Expand Down
1 change: 1 addition & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Allow vendored dist/ directories (root .gitignore ignores all dist/)
!vendor/**/dist/
!vendor/**/dist/**
.vercel
28 changes: 20 additions & 8 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,26 @@ import { resolve } from 'node:path';
import { z } from 'zod';
import { buildCsp } from './csp.js';

const envSchema = z.object({
API_PORT: z.coerce.number().int().min(1).max(65535).optional().default(8787),
CLIENT_PORT: z.coerce.number().int().min(1).max(65535).optional().default(8788),
VITE_API_URL: z
.string()
.url('VITE_API_URL must be a valid URL (e.g. https://pagent.up.railway.app)')
.optional(),
});
// Vercel and other CI runners surface unset env vars as empty strings, which
// would otherwise fail .url() / .coerce.number(). Normalise "" → undefined.
const envSchema = z.preprocess(
(raw) => {
if (typeof raw !== 'object' || raw === null) return raw;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
out[k] = v === '' ? undefined : v;
}
return out;
},
z.object({
API_PORT: z.coerce.number().int().min(1).max(65535).optional().default(8787),
CLIENT_PORT: z.coerce.number().int().min(1).max(65535).optional().default(8788),
VITE_API_URL: z
.string()
.url('VITE_API_URL must be a valid URL (e.g. https://pagent.up.railway.app)')
.optional(),
}),
);

// 128-bit hex page id (must match server.ts).
const PAGE_ID = String.raw`[a-f0-9]{32}`;
Expand Down
Loading