glashjs 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,8 +18,9 @@ The create CLI asks for:
18
18
  - CSS setup: Tailwind, plain CSS, or none.
19
19
  - Postgres by default.
20
20
  - glashAuth route helpers.
21
- - SQL runner support.
21
+ - SQL runner support through the local CLI.
22
22
  - AI deployment prompts.
23
+ - Optional IP auto-translation.
23
24
  - Package manager and dependency install.
24
25
 
25
26
  For non-interactive use:
@@ -40,7 +41,6 @@ routes/
40
41
  api/auth/signup.mjs
41
42
  api/auth/me.mjs
42
43
  api/functions/contact.mjs
43
- api/sql/run.mjs
44
44
  db/schema.sql
45
45
  .glash/prompts/deploy.md
46
46
  glash.config.mjs
@@ -56,7 +56,7 @@ glash.config.mjs
56
56
  - **API routes**: method exports such as `GET`, `POST`, `PUT`, and `DELETE`.
57
57
  - **Postgres by default**: `glashjs/postgres` reads `DATABASE_URL` and exposes `sql`, `query`, and transactions.
58
58
  - **glashAuth built in**: `glashjs/auth` calls the real GlashDB auth API under `/api/auth/v1/:projectId`.
59
- - **SQL runner support**: `glashjs sql db/schema.sql` runs checked-in SQL against `DATABASE_URL`.
59
+ - **SQL runner support**: `glashjs sql db/schema.sql` runs checked-in SQL against `DATABASE_URL`; starters do not expose arbitrary SQL over HTTP.
60
60
  - **AI deployment prompts**: starters include `.glash/prompts/deploy.md` for agents and deployment review.
61
61
  - **Zero-config hosting**: `glashjs deploy` builds and hands off to the GlashDB deploy CLI.
62
62
  - **Automatic env management**: `.env`, `.env.local`, and mode-specific env files load automatically; hosted deploys use GlashDB project env vars.
@@ -66,7 +66,8 @@ glash.config.mjs
66
66
 
67
67
  ```bash
68
68
  glashjs create [name] # scaffold a new Glash project
69
- glashjs dev [--port 3000] # dev server with live reload and LAN URL
69
+ glashjs dev [--port 3000] # dev server with live reload, bound to localhost
70
+ glashjs dev --host 0.0.0.0 # opt into LAN preview
70
71
  glashjs build # optimize assets, precompile routes, emit PWA/security manifests
71
72
  glashjs serve # serve the production build
72
73
  glashjs typegen # generate typed route declarations
@@ -144,6 +145,7 @@ Required env:
144
145
  GLASHDB_API_URL="https://api.glashdb.com/api"
145
146
  GLASHDB_PROJECT_ID="..."
146
147
  GLASHDB_ANON_KEY="..."
148
+ GLASH_SESSION_SECRET="at-least-32-random-characters"
147
149
  DATABASE_URL="postgresql://..."
148
150
  ```
149
151
 
@@ -181,10 +183,14 @@ export default defineConfig({
181
183
  offline: true,
182
184
  favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
183
185
  animatedFavicon: false,
186
+ i18n: false,
184
187
  dataPrefixes: ['/api/', '/auth/', '/rest/', '/live', '/stream'],
185
188
  });
186
189
  ```
187
190
 
191
+ `i18n` is opt-in because it calls third-party geolocation/translation services
192
+ and relaxes CSP for the translation widget.
193
+
188
194
  ## Deploy
189
195
 
190
196
  ```bash
@@ -199,7 +205,7 @@ npm run deploy
199
205
  3. Optimizes public assets.
200
206
  4. Precompiles JSX routes and client bundles.
201
207
  5. Emits PWA, offline, security, and asset manifests.
202
- 6. Hands off to the GlashDB CLI for upload, env sync, build logs, live URL, and hosting.
208
+ 6. Hands off to a local pinned GlashDB CLI when installed, otherwise a pinned fallback CLI package, for upload, env sync, build logs, live URL, and hosting.
203
209
 
204
210
  ## Asset Optimization
205
211
 
package/bin/glash.mjs CHANGED
@@ -26,7 +26,7 @@ function flag(name) {
26
26
  return rest.includes(name);
27
27
  }
28
28
 
29
- function optionBool(disableFlag, fallback = true) {
29
+ function optionBool(disableFlag, fallback) {
30
30
  return rest.includes(disableFlag) ? false : fallback;
31
31
  }
32
32
 
@@ -78,7 +78,8 @@ async function serve(dev) {
78
78
  const root = arg('--root', process.cwd());
79
79
  const { listen, cfg, routes } = await createGlashServer({ root, dev });
80
80
  const preferredPort = Number(arg('--port', cfg.port || 3000));
81
- const port = await listenOnAvailablePort(listen, preferredPort);
81
+ const host = arg('--host', dev ? '127.0.0.1' : '0.0.0.0');
82
+ const port = await listenOnAvailablePort((p) => listen(p, host), preferredPort);
82
83
  const pages = routes.filter((r) => !r.isApi).length;
83
84
  const apis = routes.filter((r) => r.isApi).length;
84
85
  console.log('');
@@ -90,11 +91,19 @@ async function serve(dev) {
90
91
  routes.forEach((r) => console.log(` ${r.isApi ? 'api ' : 'page'} ${r.pattern}`));
91
92
  console.log('');
92
93
  console.log(` ➜ Local: http://localhost:${port}`);
93
- for (const ip of lanAddresses()) console.log(` ➜ Network: http://${ip}:${port} (preview on other devices)`);
94
+ if (isPublicHost(host)) {
95
+ for (const ip of lanAddresses()) console.log(` ➜ Network: http://${ip}:${port} (preview on other devices)`);
96
+ } else {
97
+ console.log(' ➜ Network: disabled (use --host 0.0.0.0 to expose on your LAN)');
98
+ }
94
99
  if (dev) console.log('\n live reload on save · ctrl-c to stop');
95
100
  console.log('');
96
101
  }
97
102
 
103
+ function isPublicHost(host) {
104
+ return host === '0.0.0.0' || host === '::' || host === '';
105
+ }
106
+
98
107
  async function listenOnAvailablePort(listen, preferredPort) {
99
108
  for (let port = preferredPort; port < preferredPort + 20; port += 1) {
100
109
  try {
@@ -124,6 +133,8 @@ async function main() {
124
133
  auth: optionBool('--no-auth', undefined),
125
134
  sqlRunner: optionBool('--no-sql-runner', undefined),
126
135
  aiPrompts: optionBool('--no-ai-prompts', undefined),
136
+ translate: flag('--translate') ? true : optionBool('--no-translate', undefined),
137
+ animation: flag('--animation') ? true : undefined,
127
138
  });
128
139
  break;
129
140
  }
@@ -183,8 +194,9 @@ async function main() {
183
194
 
184
195
  Usage: (run as "glashjs <cmd>"; "glash <cmd>" also works unless the glashdb deploy CLI owns that name)
185
196
  glashjs run dev Alias for glashjs dev (also: glash run dev)
186
- glashjs create [name] Create a new Glash project (interactive)
187
- glashjs dev [--port 3000] Run the dev server (routing, SSR, API, live reload) + Network preview URL
197
+ glashjs create [name] Create a new Glash project (interactive; --translate opts into i18n)
198
+ glashjs dev [--port 3000] Run the dev server on localhost (routing, SSR, API, live reload)
199
+ glashjs dev --host 0.0.0.0 Opt into LAN preview from other devices
188
200
  glashjs serve [--port 3000] Run the production server over routes/ + built assets
189
201
  glashjs build [--root <dir>] Optimize assets, precompile routes, generate offline SW + PWA + security
190
202
  glashjs typegen Generate typed route declarations from routes/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "description": "glashjs — The Postgres-native full-stack framework for builders who want to ship without DevOps. Framework, hosting, database, auth, and deploy in one GlashDB-native runtime.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // /api/auth/v1/:projectId/signup
5
5
  // /api/auth/v1/:projectId/signin
6
6
  // /api/auth/v1/:projectId/me
7
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
7
8
 
8
9
  export function glashAuth(options = {}) {
9
10
  const apiUrl = trimSlash(options.apiUrl ?? process.env.GLASHDB_API_URL ?? 'https://api.glashdb.com/api');
@@ -55,34 +56,47 @@ export function glashAuth(options = {}) {
55
56
  };
56
57
  }
57
58
 
58
- export function readSessionCookie(ctx, name = 'glash_session') {
59
+ export function readSessionCookie(ctx, name = 'glash_session', options = {}) {
59
60
  const cookie = ctx?.headers?.cookie || ctx?.req?.headers?.cookie || '';
60
61
  const found = cookie.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
61
62
  if (!found) return null;
62
- try {
63
- return JSON.parse(Buffer.from(decodeURIComponent(found.slice(name.length + 1)), 'base64url').toString('utf8'));
64
- } catch {
65
- return null;
66
- }
63
+ return openSession(decodeURIComponent(found.slice(name.length + 1)), name, options);
67
64
  }
68
65
 
69
66
  export function sessionCookie(session, options = {}) {
70
67
  const name = options.name ?? 'glash_session';
71
68
  const maxAge = options.maxAge ?? 60 * 60 * 24 * 30;
72
- const encoded = Buffer.from(JSON.stringify(session), 'utf8').toString('base64url');
69
+ const encoded = sealSession(session, name, options);
73
70
  const parts = [
74
71
  `${name}=${encodeURIComponent(encoded)}`,
75
72
  'Path=/',
76
73
  `Max-Age=${maxAge}`,
77
74
  'HttpOnly',
78
- 'SameSite=Lax',
75
+ `SameSite=${options.sameSite ?? 'Lax'}`,
79
76
  ];
80
77
  if (options.secure ?? process.env.NODE_ENV === 'production') parts.push('Secure');
81
78
  return parts.join('; ');
82
79
  }
83
80
 
84
- export function clearSessionCookie(name = 'glash_session') {
85
- return `${name}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`;
81
+ export function clearSessionCookie(name = 'glash_session', options = {}) {
82
+ const parts = [`${name}=`, 'Path=/', 'Max-Age=0', 'HttpOnly', `SameSite=${options.sameSite ?? 'Lax'}`];
83
+ if (options.secure ?? process.env.NODE_ENV === 'production') parts.push('Secure');
84
+ return parts.join('; ');
85
+ }
86
+
87
+ export function publicSession(session = {}) {
88
+ if (!session || typeof session !== 'object') return {};
89
+ const {
90
+ accessToken,
91
+ access_token,
92
+ refreshToken,
93
+ refresh_token,
94
+ token,
95
+ idToken,
96
+ id_token,
97
+ ...safe
98
+ } = session;
99
+ return safe;
86
100
  }
87
101
 
88
102
  async function readJson(res) {
@@ -94,3 +108,53 @@ async function readJson(res) {
94
108
  function trimSlash(value) {
95
109
  return String(value || '').replace(/\/+$/, '');
96
110
  }
111
+
112
+ function sealSession(session, name, options) {
113
+ const secret = sessionSecret(options);
114
+ const iv = randomBytes(12);
115
+ const cipher = createCipheriv('aes-256-gcm', sessionKey(secret, name), iv);
116
+ const plaintext = Buffer.from(JSON.stringify(session ?? {}), 'utf8');
117
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
118
+ const tag = cipher.getAuthTag();
119
+ return ['v1', b64(iv), b64(encrypted), b64(tag)].join('.');
120
+ }
121
+
122
+ function openSession(value, name, options) {
123
+ try {
124
+ if (!String(value || '').startsWith('v1.')) {
125
+ if (!options.allowUnsigned) return null;
126
+ return JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
127
+ }
128
+ const [, ivRaw, encryptedRaw, tagRaw] = String(value).split('.');
129
+ if (!ivRaw || !encryptedRaw || !tagRaw) return null;
130
+ const secret = sessionSecret(options);
131
+ const decipher = createDecipheriv('aes-256-gcm', sessionKey(secret, name), Buffer.from(ivRaw, 'base64url'));
132
+ decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
133
+ const plain = Buffer.concat([
134
+ decipher.update(Buffer.from(encryptedRaw, 'base64url')),
135
+ decipher.final(),
136
+ ]);
137
+ return JSON.parse(plain.toString('utf8'));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function sessionSecret(options = {}) {
144
+ const secret = options.secret
145
+ ?? process.env.GLASH_SESSION_SECRET
146
+ ?? process.env.GLASHDB_SESSION_SECRET
147
+ ?? process.env.AUTH_SECRET;
148
+ if (!secret || String(secret).length < 32) {
149
+ throw new Error('GLASH_SESSION_SECRET must be set to at least 32 characters for glashAuth session cookies');
150
+ }
151
+ return String(secret);
152
+ }
153
+
154
+ function sessionKey(secret, name) {
155
+ return createHash('sha256').update(`glashjs:${name}:${secret}`).digest();
156
+ }
157
+
158
+ function b64(value) {
159
+ return Buffer.from(value).toString('base64url');
160
+ }
package/src/config.mjs CHANGED
@@ -32,7 +32,8 @@ export const DEFAULT_CONFIG = {
32
32
  // (/api/_glash/geo) automatically — no route or layout wiring per app.
33
33
  // true | false | { auto?: boolean, geoEndpoint?: string }
34
34
  // auto: translate immediately instead of prompting.
35
- i18n: true,
35
+ // Disabled by default because it calls third-party translation/geolocation services.
36
+ i18n: false,
36
37
  // Requests under these prefixes are treated as live/updated data: network-first
37
38
  // in the Service Worker, so offline mode degrades exactly there (no stale live data).
38
39
  dataPrefixes: ['/api/', '/rest/', '/auth/', '/realtime', '/live', '/stream'],
package/src/create.mjs CHANGED
@@ -42,7 +42,7 @@ async function collectAnswers(options, rl) {
42
42
  const auth = boolAnswer(options.auth, await ask(rl, 'Add glashAuth routes?', 'yes'));
43
43
  const sqlRunner = boolAnswer(options.sqlRunner, await ask(rl, 'Add SQL runner support?', 'yes'));
44
44
  const aiPrompts = boolAnswer(options.aiPrompts, await ask(rl, 'Add AI deployment prompts?', 'yes'));
45
- const translate = boolAnswer(options.translate, await ask(rl, 'Add built-in IP auto-translation?', 'yes'));
45
+ const translate = boolAnswer(options.translate, await ask(rl, 'Add built-in IP auto-translation?', 'no'));
46
46
  const animation = boolAnswer(options.animation, await ask(rl, 'Add motion + 3D (motion, three.js)?', 'no'));
47
47
  const install = boolAnswer(options.install, await ask(rl, 'Install dependencies now?', 'yes'));
48
48
  const packageManager = normalizeChoice(options.packageManager || await ask(rl, 'Package manager (npm/pnpm/yarn/bun)', detectPackageManager()), PM_CHOICES, 'npm');
@@ -111,7 +111,7 @@ function projectFiles(answers) {
111
111
  '.env.local': envLocal(answers),
112
112
  'glash.config.mjs': configFile(answers, hasCss),
113
113
  'README.md': projectReadme(answers),
114
- 'routes/_layout.jsx': layoutRoute(answers),
114
+ 'routes/_layout.jsx': layoutRoute(),
115
115
  'routes/index.jsx': indexRoute(answers),
116
116
  'routes/api/health.mjs': healthRoute(),
117
117
  'db/schema.sql': schemaSql(answers),
@@ -119,7 +119,6 @@ function projectFiles(answers) {
119
119
  ...(answers.css === 'tailwind' ? { 'styles/input.css': tailwindInput() } : {}),
120
120
  ...(answers.auth ? authRoutes() : {}),
121
121
  ...(answers.sqlRunner ? sqlRunnerRoutes() : {}),
122
- ...(answers.translate ? { 'routes/api/_glash/geo.mjs': geoRoute() } : {}),
123
122
  ...(answers.animation ? { 'routes/demo/motion.jsx': motionDemoRoute() } : {}),
124
123
  ...(answers.aiPrompts ? { '.glash/prompts/deploy.md': aiDeployPrompt(answers) } : {}),
125
124
  'public/favicon.svg': faviconSvg(),
@@ -136,6 +135,9 @@ export default defineConfig({
136
135
  publicDir: 'public',
137
136
  outDir: '.glash/out',
138
137
  offline: true,
138
+ // Built-in IP auto-translation is opt-in because it calls third-party
139
+ // translation/geolocation services and relaxes CSP for that widget.
140
+ i18n: ${answers.translate ? 'true' : 'false'},
139
141
  favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
140
142
  animatedFavicon: false,
141
143
  stylesheets: ${hasCss ? "['/app.css']" : '[]'},
@@ -149,19 +151,10 @@ export default defineConfig({
149
151
  `;
150
152
  }
151
153
 
152
- function layoutRoute(answers = {}) {
153
- const t = answers.translate;
154
- const imports = [`import { Link } from 'glashjs/link';`];
155
- if (t) {
156
- imports.push(`import { useEffect } from 'preact/hooks';`);
157
- imports.push(`import { glashAutoTranslate } from 'glashjs/i18n';`);
158
- }
159
- return `${imports.join('\n')}
154
+ function layoutRoute() {
155
+ return `import { Link } from 'glashjs/link';
160
156
 
161
- export default function RootLayout({ children }) {${t ? `
162
- // Built-in IP auto-translation: detect the visitor's country and offer to
163
- // translate the page into their native language. Reads /api/_glash/geo.
164
- useEffect(() => { glashAutoTranslate(); }, []);` : ''}
157
+ export default function RootLayout({ children }) {
165
158
  return (
166
159
  <div className="shell">
167
160
  <header className="nav">
@@ -178,16 +171,6 @@ export default function RootLayout({ children }) {${t ? `
178
171
  `;
179
172
  }
180
173
 
181
- function geoRoute() {
182
- return `// IP -> country -> native language, read from the edge geo header
183
- // (CF-IPCountry / X-Glash-Country on glashDB hosting). The client i18n runtime
184
- // calls this to decide whether to offer auto-translation.
185
- import { geoRouteHandler } from 'glashjs/i18n';
186
-
187
- export const GET = geoRouteHandler;
188
- `;
189
- }
190
-
191
174
  function motionDemoRoute() {
192
175
  return `import { useEffect, useRef } from 'preact/hooks';
193
176
  import { animate } from 'motion';
@@ -315,21 +298,21 @@ export const GET = () => json({
315
298
  function authRoutes() {
316
299
  return {
317
300
  'routes/api/auth/signup.mjs': `import { json } from 'glashjs';
318
- import { glashAuth, sessionCookie } from 'glashjs/auth';
301
+ import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
319
302
 
320
303
  export const POST = async (ctx) => {
321
304
  const auth = glashAuth();
322
305
  const session = await auth.signup(ctx.body);
323
- return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
306
+ return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
324
307
  };
325
308
  `,
326
309
  'routes/api/auth/signin.mjs': `import { json } from 'glashjs';
327
- import { glashAuth, sessionCookie } from 'glashjs/auth';
310
+ import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
328
311
 
329
312
  export const POST = async (ctx) => {
330
313
  const auth = glashAuth();
331
314
  const session = await auth.signin(ctx.body);
332
- return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
315
+ return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
333
316
  };
334
317
  `,
335
318
  'routes/api/auth/me.mjs': `import { json } from 'glashjs';
@@ -360,20 +343,6 @@ export const POST = serverFunction(async ({ email }) => {
360
343
  \`;
361
344
  return { message: \`Saved \${value} in Postgres.\` };
362
345
  });
363
- `,
364
- 'routes/api/sql/run.mjs': `import { json } from 'glashjs';
365
- import { query } from 'glashjs/postgres';
366
-
367
- export const POST = async (ctx) => {
368
- const expected = process.env.GLASH_SQL_RUNNER_TOKEN;
369
- const token = String(ctx.headers.authorization || '').replace(/^Bearer\\s+/i, '');
370
- if (!expected || token !== expected) return json({ error: 'SQL runner token required' }, { status: 401 });
371
- const statement = String(ctx.body?.statement || '');
372
- const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
373
- if (!statement.trim()) return json({ error: 'statement is required' }, { status: 400 });
374
- const result = await query(statement, params);
375
- return json({ rowCount: result.rowCount, rows: result.rows });
376
- };
377
346
  `,
378
347
  };
379
348
  }
@@ -395,7 +364,7 @@ GLASHDB_PROJECT_ID=""
395
364
  DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
396
365
  DIRECT_URL=""
397
366
  GLASHDB_ANON_KEY=""
398
- GLASH_SQL_RUNNER_TOKEN="${randomToken()}"
367
+ GLASH_SESSION_SECRET="replace-with-at-least-32-random-characters"
399
368
  `;
400
369
  }
401
370
 
@@ -406,7 +375,7 @@ GLASHDB_PROJECT_ID=""
406
375
  DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
407
376
  DIRECT_URL=""
408
377
  GLASHDB_ANON_KEY=""
409
- GLASH_SQL_RUNNER_TOKEN="${randomToken()}"
378
+ GLASH_SESSION_SECRET="${randomToken()}.${randomToken()}"
410
379
  `;
411
380
  }
412
381
 
@@ -548,9 +517,9 @@ function tailwindInput() {
548
517
  function faviconSvg() {
549
518
  // Use the bundled glash mark (the official metallic glash favicon) so a new
550
519
  // app ships the real brand favicon, not a placeholder. Single source of truth:
551
- // glashjs/templates/favicon.svg.
520
+ // glashjs/templates/glash_favicon.svg.
552
521
  try {
553
- return readFileSync(fileURLToPath(new URL('../templates/favicon.svg', import.meta.url)), 'utf8');
522
+ return readFileSync(fileURLToPath(new URL('../templates/glash_favicon.svg', import.meta.url)), 'utf8');
554
523
  } catch {
555
524
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
556
525
  <rect width="64" height="64" rx="14" fill="#0a0a0a"/>
package/src/deploy.mjs CHANGED
@@ -5,10 +5,12 @@
5
5
  // to the existing glashdb CLI (npm: `glashdb`) for upload/auth — no duplicated
6
6
  // deploy logic. The platform then runs `glash build` + `glash serve`.
7
7
  import { spawn } from 'node:child_process';
8
- import { promises as fs } from 'node:fs';
8
+ import { promises as fs, existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { build } from './build.mjs';
11
11
 
12
+ const FALLBACK_GLASHDB_CLI = 'glashdb@0.2.35';
13
+
12
14
  /** Ensure the app declares the scripts the glashdb platform runs to build/serve it. */
13
15
  async function ensureDeployScripts(root, log) {
14
16
  const pkgPath = path.join(root, 'package.json');
@@ -35,8 +37,7 @@ export async function deploy({ root = process.cwd(), dryRun = false, args = [],
35
37
  await ensureDeployScripts(root, log);
36
38
 
37
39
  // 3. Hand off to the glashdb CLI (zips + uploads, handles login).
38
- const cmd = 'npx';
39
- const cmdArgs = ['-y', 'glashdb', 'deploy', ...args];
40
+ const { cmd, cmdArgs } = resolveGlashdbCli(root, args);
40
41
  log(`\nHanding off to the glashdb CLI:\n $ ${cmd} ${cmdArgs.join(' ')}\n`);
41
42
  if (dryRun) {
42
43
  log('(dry run — build complete, not uploading)');
@@ -48,3 +49,10 @@ export async function deploy({ root = process.cwd(), dryRun = false, args = [],
48
49
  child.on('exit', (code) => (code === 0 ? resolve({ code }) : reject(new Error(`glashdb deploy exited with code ${code}`))));
49
50
  });
50
51
  }
52
+
53
+ function resolveGlashdbCli(root, args) {
54
+ const bin = process.platform === 'win32' ? 'glashdb.cmd' : 'glashdb';
55
+ const local = path.join(root, 'node_modules', '.bin', bin);
56
+ if (existsSync(local)) return { cmd: local, cmdArgs: ['deploy', ...args] };
57
+ return { cmd: 'npx', cmdArgs: ['-y', FALLBACK_GLASHDB_CLI, 'deploy', ...args] };
58
+ }
package/src/index.mjs CHANGED
@@ -8,7 +8,7 @@ export { createProject } from './create.mjs';
8
8
  export { optimizeAssets } from './assets/optimize.mjs';
9
9
  export { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
10
10
  export { generateServiceWorker } from './offline/generate-sw.mjs';
11
- export { securityHeaders, buildCsp, sri, glashSecurity } from './security/headers.mjs';
11
+ export { securityHeaders, buildCsp, sri, glashSecurity, mergeSafeHeaders } from './security/headers.mjs';
12
12
  export { createGlashServer, json, redirect } from './server/server.mjs';
13
13
  export { serverFunction, callServerFunction } from './server-functions.mjs';
14
14
  export { discoverRoutes, matchRoute, findMiddleware } from './server/router.mjs';
@@ -43,6 +43,19 @@ function isData(url) {
43
43
  return DATA_PREFIXES.some((p) => url.pathname.startsWith(p));
44
44
  }
45
45
 
46
+ function cacheableRequest(request) {
47
+ if (request.headers.has('authorization')) return false;
48
+ if (request.cache === 'no-store') return false;
49
+ return true;
50
+ }
51
+
52
+ function cacheableResponse(response) {
53
+ if (!response || !response.ok) return false;
54
+ const cc = response.headers.get('cache-control') || '';
55
+ if (/\\b(no-store|private)\\b/i.test(cc)) return false;
56
+ return true;
57
+ }
58
+
46
59
  self.addEventListener('fetch', (event) => {
47
60
  const { request } = event;
48
61
  if (request.method !== 'GET') return;
@@ -56,8 +69,6 @@ self.addEventListener('fetch', (event) => {
56
69
  const fresh = await fetch(request);
57
70
  return fresh;
58
71
  } catch {
59
- const cached = await caches.match(request);
60
- if (cached) return cached;
61
72
  return new Response(JSON.stringify({ offline: true, error: 'offline: live data unavailable' }), {
62
73
  status: 503, headers: { 'content-type': 'application/json', 'x-glash-offline': '1' },
63
74
  });
@@ -70,21 +81,30 @@ self.addEventListener('fetch', (event) => {
70
81
  if (request.mode === 'navigate') {
71
82
  event.respondWith((async () => {
72
83
  const cache = await caches.open(CACHE);
84
+ if (!cacheableRequest(request)) {
85
+ return fetch(request).catch(() => cache.match('/offline.html') || new Response('offline', { status: 503 }));
86
+ }
73
87
  const cached = await cache.match(request);
74
- const network = fetch(request).then((res) => { cache.put(request, res.clone()); return res; }).catch(() => null);
75
- return cached || (await network) || cache.match('/offline.html') || new Response('offline', { status: 503 });
88
+ const network = fetch(request).then((res) => {
89
+ if (cacheableResponse(res)) cache.put(request, res.clone());
90
+ return res;
91
+ }).catch(() => null);
92
+ return (await network) || cached || cache.match('/offline.html') || new Response('offline', { status: 503 });
76
93
  })());
77
94
  return;
78
95
  }
79
96
 
80
97
  // Hashed static assets: cache-first.
81
98
  event.respondWith((async () => {
99
+ if (!cacheableRequest(request)) return fetch(request);
82
100
  const cached = await caches.match(request);
83
101
  if (cached) return cached;
84
102
  try {
85
103
  const res = await fetch(request);
86
- const cache = await caches.open(CACHE);
87
- cache.put(request, res.clone());
104
+ if (cacheableResponse(res)) {
105
+ const cache = await caches.open(CACHE);
106
+ cache.put(request, res.clone());
107
+ }
88
108
  return res;
89
109
  } catch {
90
110
  return cached || new Response('offline', { status: 503 });
@@ -101,7 +121,7 @@ self.addEventListener('fetch', (event) => {
101
121
  */
102
122
  export async function generateServiceWorker(outDir, manifest, {
103
123
  cacheName,
104
- appShell = ['/', '/index.html', '/offline.html', '/favicon.svg'],
124
+ appShell = ['/offline.html', '/favicon.svg'],
105
125
  dataPrefixes = DATA_PREFIXES,
106
126
  } = {}) {
107
127
  const version = manifest?.version || 'dev';
package/src/postgres.mjs CHANGED
@@ -72,6 +72,12 @@ function templateToSql(strings, values) {
72
72
 
73
73
  function sslFor(connectionString) {
74
74
  if (/sslmode=disable/i.test(connectionString)) return false;
75
- if (/localhost|127\.0\.0\.1|::1/.test(connectionString)) return false;
75
+ let hostname = '';
76
+ try {
77
+ hostname = new URL(connectionString).hostname.toLowerCase();
78
+ } catch {
79
+ return { rejectUnauthorized: true };
80
+ }
81
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') return false;
76
82
  return { rejectUnauthorized: true };
77
83
  }
@@ -11,14 +11,60 @@ import { createHash } from 'node:crypto';
11
11
  * builds hash/nonce-based, so XSS via injected <script> is blocked by default.
12
12
  * Pass `connectSrc` to allow your API/realtime origins.
13
13
  */
14
+ // Google Translate widget origins used by the built-in i18n auto-translation.
15
+ // Scripts stay nonce-based (no 'unsafe-inline'): GT's element.js loads as an
16
+ // EXTERNAL script from these origins and fetches translations over XHR, so the
17
+ // framework's strict, nonce-based script CSP is preserved. Only style-src gains
18
+ // 'unsafe-inline' (low-risk) for the inline styles GT applies to translated text.
19
+ const I18N_CSP = {
20
+ script: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
21
+ style: ["'unsafe-inline'", 'https://www.gstatic.com'],
22
+ img: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
23
+ connect: ['https://translate.googleapis.com', 'https://ipapi.co'],
24
+ frame: ['https://translate.google.com'],
25
+ };
26
+ const merge = (base, extra) => [...new Set([...base, ...extra])];
27
+
28
+ export const PROTECTED_SECURITY_HEADERS = new Set([
29
+ 'content-security-policy',
30
+ 'strict-transport-security',
31
+ 'x-content-type-options',
32
+ 'x-frame-options',
33
+ 'referrer-policy',
34
+ 'permissions-policy',
35
+ 'cross-origin-opener-policy',
36
+ 'cross-origin-resource-policy',
37
+ 'cross-origin-embedder-policy',
38
+ 'origin-agent-cluster',
39
+ 'x-dns-prefetch-control',
40
+ ]);
41
+
42
+ export function mergeSafeHeaders(base = {}, extra = {}) {
43
+ const out = { ...base };
44
+ for (const [key, value] of Object.entries(extra || {})) {
45
+ if (PROTECTED_SECURITY_HEADERS.has(String(key).toLowerCase())) continue;
46
+ out[key] = value;
47
+ }
48
+ return out;
49
+ }
50
+
14
51
  export function buildCsp({
15
52
  connectSrc = ["'self'"],
16
53
  imgSrc = ["'self'", 'data:', 'blob:'],
17
54
  mediaSrc = ["'self'", 'blob:'],
18
55
  styleSrc = ["'self'"],
19
56
  scriptSrc = ["'self'"],
57
+ frameSrc = ["'self'"],
20
58
  nonce,
59
+ i18n = false,
21
60
  } = {}) {
61
+ if (i18n) {
62
+ scriptSrc = merge(scriptSrc, I18N_CSP.script);
63
+ styleSrc = merge(styleSrc, I18N_CSP.style);
64
+ imgSrc = merge(imgSrc, I18N_CSP.img);
65
+ connectSrc = merge(connectSrc, I18N_CSP.connect);
66
+ frameSrc = merge(frameSrc, I18N_CSP.frame);
67
+ }
22
68
  const script = nonce ? [...scriptSrc, `'nonce-${nonce}'`] : scriptSrc;
23
69
  const directives = {
24
70
  'default-src': ["'self'"],
@@ -31,6 +77,7 @@ export function buildCsp({
31
77
  'img-src': imgSrc,
32
78
  'media-src': mediaSrc,
33
79
  'connect-src': connectSrc,
80
+ 'frame-src': frameSrc,
34
81
  'worker-src': ["'self'"],
35
82
  'manifest-src': ["'self'"],
36
83
  'upgrade-insecure-requests': [],
@@ -27,7 +27,17 @@ async function navigate(href, push, keepScroll) {
27
27
  root.innerHTML = data.html;
28
28
  if (push) history.pushState({ glash: 1 }, '', href);
29
29
  if (!keepScroll) window.scrollTo(0, 0);
30
- if (data.bundle) { try { await import(data.bundle + '?v=' + Date.now()); } catch (e) {} }
30
+ var bundle = safeBundle(data.bundle);
31
+ if (bundle) { try { await import(bundle + '?v=' + Date.now()); } catch (e) {} }
32
+ }
33
+
34
+ function safeBundle(value) {
35
+ return /^\\/_glash\\/[a-f0-9]{10}\\.js$/i.test(String(value || '')) ? value : '';
36
+ }
37
+
38
+ function escapeCssIdent(value) {
39
+ if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
40
+ return String(value).replace(/["\\\\\\]\\[]/g, '\\\\$&');
31
41
  }
32
42
 
33
43
  document.addEventListener('click', function (e) {
@@ -65,7 +75,7 @@ window.__glashSoftRefresh = async function () {
65
75
  });
66
76
  window.scrollTo(sx, sy);
67
77
  if (focusName) {
68
- var el = root.querySelector('[name="' + focusName + '"]');
78
+ var el = root.querySelector('[name="' + escapeCssIdent(focusName) + '"]');
69
79
  if (el) { el.focus(); try { el.selectionStart = selStart; el.selectionEnd = selEnd; } catch (e) {} }
70
80
  }
71
81
  };
@@ -6,7 +6,7 @@
6
6
  // service worker, favicons) with Brotli negotiation.
7
7
  // Every response carries the secure-by-default headers.
8
8
  import http from 'node:http';
9
- import { promises as fs, existsSync, statSync, watch, createReadStream, readFileSync } from 'node:fs';
9
+ import { promises as fs, existsSync, watch, createReadStream, readFileSync } from 'node:fs';
10
10
  import { Transform } from 'node:stream';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import path from 'node:path';
@@ -16,7 +16,7 @@ import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
16
16
  import { renderDocument, documentParts, renderMeta, escapeHtml } from './html.mjs';
17
17
  import { NAV_CLIENT } from './nav-client.mjs';
18
18
  import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, composeVNode, getPipeableRenderer, routeId, findLayouts, clearJsxCaches, compileModule } from './jsx.mjs';
19
- import { securityHeaders } from '../security/headers.mjs';
19
+ import { mergeSafeHeaders, securityHeaders } from '../security/headers.mjs';
20
20
  import { loadConfig } from '../config.mjs';
21
21
  import { loadEnvFiles } from '../env.mjs';
22
22
 
@@ -28,6 +28,8 @@ const MIME = {
28
28
  '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8',
29
29
  };
30
30
  const mime = (file) => MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
31
+ const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
32
+ const JSON_BODY_LIMIT = 2_000_000;
31
33
 
32
34
  // The built-in i18n runtime served at /_glash/i18n.js. i18n.mjs is dependency-free
33
35
  // and valid browser ESM (its server-only exports are harmless in the browser), so
@@ -76,6 +78,17 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
76
78
  if (pathname === '/_glash/nav.js') {
77
79
  return send(res, 200, 'text/javascript; charset=utf-8', NAV_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
78
80
  }
81
+ // Built-in IP auto-translation runtime + geo endpoint (config: i18n).
82
+ if (cfg.i18n) {
83
+ if (pathname === '/_glash/i18n.js') {
84
+ return send(res, 200, 'text/javascript; charset=utf-8', I18N_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
85
+ }
86
+ // Geo endpoint: an app-defined routes/api/_glash/geo.mjs wins; otherwise
87
+ // the framework answers from the edge country header.
88
+ if (pathname === '/api/_glash/geo' && !matchRoute(routes, pathname)) {
89
+ return send(res, 200, 'application/json', JSON.stringify(geoRouteHandler({ headers: req.headers })), { ...secHeaders, 'cache-control': 'no-store' });
90
+ }
91
+ }
79
92
  // Static first: in production this serves prebuilt /_glash/<id>.js bundles
80
93
  // (written by `glash build`) — no runtime esbuild needed.
81
94
  if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
@@ -88,6 +101,9 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
88
101
  const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
89
102
  return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
90
103
  }
104
+ if (isCrossSiteUnsafeRequest(req, url)) {
105
+ return send(res, 403, 'application/json', JSON.stringify({ error: 'cross-site request blocked' }), secHeaders);
106
+ }
91
107
  const match = matchRoute(routes, pathname);
92
108
  if (!match) return await handleNotFound(res, routes, req, url, cfg, secHeaders, root, routesDir, dev);
93
109
  const ctx = makeCtx(req, res, url, match.params);
@@ -127,7 +143,7 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
127
143
  }
128
144
  });
129
145
 
130
- const listen = (port = cfg.port || 3000, host = '0.0.0.0') =>
146
+ const listen = (port = cfg.port || 3000, host = dev ? '127.0.0.1' : '0.0.0.0') =>
131
147
  new Promise((resolve, reject) => {
132
148
  const onError = (error) => {
133
149
  server.off('listening', onListening);
@@ -147,26 +163,37 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
147
163
 
148
164
  async function handleApi(res, mod, req, ctx, secHeaders) {
149
165
  const method = req.method.toUpperCase();
166
+ const apiHeaders = { ...secHeaders, 'cache-control': 'no-store' };
150
167
  const handler = mod[method] || (method === 'GET' && mod.default) || mod.handler;
151
168
  if (typeof handler !== 'function') {
152
- return send(res, 405, 'application/json', JSON.stringify({ error: 'method not allowed' }), secHeaders);
169
+ return send(res, 405, 'application/json', JSON.stringify({ error: 'method not allowed' }), apiHeaders);
170
+ }
171
+ if (UNSAFE_METHODS.has(method)) {
172
+ try {
173
+ ctx.body = await readJson(req);
174
+ } catch (error) {
175
+ if (error instanceof HttpError) {
176
+ return send(res, error.status, 'application/json', JSON.stringify({ error: error.message }), apiHeaders);
177
+ }
178
+ throw error;
179
+ }
153
180
  }
154
- if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) ctx.body = await readJson(req);
155
181
  const result = await handler(ctx);
156
182
  // Next-style route handlers return a Web `Response` (e.g. Response.json(...)).
157
183
  // Pass it through so migrated API routes work unchanged.
158
184
  if (typeof Response !== 'undefined' && result instanceof Response) {
159
- const headers = { ...secHeaders };
160
- result.headers.forEach((v, k) => { headers[k] = v; });
185
+ let responseHeaders = {};
186
+ result.headers.forEach((v, k) => { responseHeaders[k] = v; });
187
+ const headers = mergeSafeHeaders(apiHeaders, responseHeaders);
161
188
  const buf = Buffer.from(await result.arrayBuffer());
162
189
  res.writeHead(result.status || 200, headers);
163
190
  return res.end(buf);
164
191
  }
165
192
  if (result && result.__response) {
166
193
  return send(res, result.status || 200, result.contentType || 'application/json',
167
- typeof result.body === 'string' ? result.body : JSON.stringify(result.body), { ...secHeaders, ...(result.headers || {}) });
194
+ typeof result.body === 'string' ? result.body : JSON.stringify(result.body), mergeSafeHeaders(apiHeaders, result.headers));
168
195
  }
169
- send(res, 200, 'application/json', JSON.stringify(result ?? null), secHeaders);
196
+ send(res, 200, 'application/json', JSON.stringify(result ?? null), apiHeaders);
170
197
  }
171
198
 
172
199
  async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
@@ -182,9 +209,11 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
182
209
  body: page.body ?? '',
183
210
  offline: cfg.offline,
184
211
  animatedFavicon: !!cfg.animatedFavicon,
212
+ i18n: cfg.i18n,
185
213
  nonce, dev,
186
214
  });
187
- send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...pageHeaders(cfg, secHeaders, nonce), ...(page.headers || {}) });
215
+ send(res, page.status || 200, 'text/html; charset=utf-8', docHtml,
216
+ mergeSafeHeaders({ ...pageHeaders(cfg, secHeaders, nonce), ...dynamicCacheHeaders(ctx) }, page.headers));
188
217
  }
189
218
 
190
219
  async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, routesDir, dev) {
@@ -199,7 +228,8 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
199
228
  // so <Link> can swap #glash-root and re-hydrate without a full reload.
200
229
  if (String(ctx.headers['x-glash-nav'] || '') === '1') {
201
230
  const rendered = await renderComponent(mod, props);
202
- return send(res, 200, 'application/json', JSON.stringify({ title, html: rendered, props, bundle: `/_glash/${id}.js` }), secHeaders);
231
+ return send(res, 200, 'application/json', JSON.stringify({ title, html: rendered, props, bundle: `/_glash/${id}.js` }),
232
+ { ...secHeaders, ...dynamicCacheHeaders(ctx), 'cache-control': 'no-store' });
203
233
  }
204
234
 
205
235
  const nonce = randomBytes(16).toString('base64');
@@ -207,10 +237,10 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
207
237
  // external 'self' module — both pass the strict CSP without 'unsafe-inline'.
208
238
  const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
209
239
  const { open, tail } = documentParts({
210
- title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
240
+ title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, i18n: cfg.i18n, nonce, dev,
211
241
  });
212
242
  const bundleTag = `</div><script type="module" src="/_glash/${id}.js"></script>`;
213
- res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
243
+ res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), ...dynamicCacheHeaders(ctx), 'content-type': 'text/html; charset=utf-8' });
214
244
  res.write(open + '<div id="glash-root">'); // flush the shell before rendering
215
245
 
216
246
  // True Suspense streaming: render the boundary's fallback into the shell now,
@@ -256,7 +286,7 @@ async function resolveMeta(metadata, ctx) {
256
286
  // Per-request page headers: a fresh CSP carrying this request's script nonce, so
257
287
  // the framework's own inline <script>s run while injected scripts stay blocked.
258
288
  function pageHeaders(cfg, secHeaders, nonce) {
259
- const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce } })['Content-Security-Policy'];
289
+ const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce, i18n: !!cfg.i18n } })['Content-Security-Policy'];
260
290
  return { ...secHeaders, 'Content-Security-Policy': csp };
261
291
  }
262
292
 
@@ -268,22 +298,26 @@ function safeJson(value) {
268
298
  async function serveStatic(res, outDir, pathname, req, secHeaders) {
269
299
  if (pathname === '/') return false; // let the index page route render
270
300
  const rel = pathname.replace(/^\/+/, '');
271
- const file = path.join(outDir, rel);
272
- if (!file.startsWith(outDir + path.sep)) return false; // path traversal guard
301
+ const root = await fs.realpath(outDir).catch(() => null);
302
+ if (!root) return false;
303
+ const file = path.resolve(root, rel);
304
+ if (!isInsideRoot(file, root)) return false; // path traversal guard
273
305
  const head = req.method === 'HEAD';
274
306
  const range = req.headers.range;
275
307
  const ae = String(req.headers['accept-encoding'] || '');
276
308
 
277
309
  // Brotli precompressed sibling — only when the client isn't asking for a byte range.
278
- if (!range && ae.includes('br') && existsSync(file + '.br')) {
279
- const buf = await fs.readFile(file + '.br');
310
+ const br = !range && ae.includes('br') ? await staticFile(file + '.br', root) : null;
311
+ if (br) {
312
+ const buf = await fs.readFile(br.file);
280
313
  res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', vary: 'Accept-Encoding', 'cache-control': 'public, max-age=31536000, immutable' });
281
314
  res.end(head ? undefined : buf);
282
315
  return true;
283
316
  }
284
- if (!(existsSync(file) && statSync(file).isFile())) return false;
317
+ const safe = await staticFile(file, root);
318
+ if (!safe) return false;
285
319
 
286
- const stat = statSync(file);
320
+ const { stat } = safe;
287
321
  const ct = mime(file);
288
322
  // Range requests (video/audio seeking) -> 206 Partial Content.
289
323
  if (range) {
@@ -297,13 +331,13 @@ async function serveStatic(res, outDir, pathname, req, secHeaders) {
297
331
  }
298
332
  res.writeHead(206, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-range': `bytes ${start}-${end}/${stat.size}`, 'content-length': end - start + 1, 'cache-control': 'public, max-age=3600' });
299
333
  if (head) { res.end(); return true; }
300
- createReadStream(file, { start, end }).pipe(res);
334
+ createReadStream(safe.file, { start, end }).pipe(res);
301
335
  return true;
302
336
  }
303
337
  // Full file — streamed (handles large assets without buffering them in memory).
304
338
  res.writeHead(200, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-length': stat.size, 'cache-control': 'public, max-age=3600' });
305
339
  if (head) { res.end(); return true; }
306
- createReadStream(file).pipe(res);
340
+ createReadStream(safe.file).pipe(res);
307
341
  return true;
308
342
  }
309
343
 
@@ -380,11 +414,41 @@ function makeCtx(req, res, url, params) {
380
414
  }
381
415
 
382
416
  function readJson(req) {
383
- return new Promise((resolve) => {
384
- let data = '';
385
- req.on('data', (c) => { data += c; if (data.length > 2_000_000) req.destroy(); });
386
- req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); } });
387
- req.on('error', () => resolve({}));
417
+ return new Promise((resolve, reject) => {
418
+ const chunks = [];
419
+ let size = 0;
420
+ let done = false;
421
+ const fail = (error) => {
422
+ if (done) return;
423
+ done = true;
424
+ reject(error);
425
+ };
426
+ req.on('data', (chunk) => {
427
+ if (done) return;
428
+ size += chunk.length;
429
+ if (size > JSON_BODY_LIMIT) {
430
+ fail(new HttpError(413, `request body exceeds ${JSON_BODY_LIMIT} bytes`));
431
+ req.destroy();
432
+ return;
433
+ }
434
+ chunks.push(chunk);
435
+ });
436
+ req.on('end', () => {
437
+ if (done) return;
438
+ done = true;
439
+ const raw = Buffer.concat(chunks).toString('utf8');
440
+ if (!raw) return resolve({});
441
+ if (!isJsonContentType(req.headers['content-type'])) {
442
+ reject(new HttpError(415, 'content-type must be application/json'));
443
+ return;
444
+ }
445
+ try {
446
+ resolve(JSON.parse(raw));
447
+ } catch {
448
+ reject(new HttpError(400, 'malformed JSON body'));
449
+ }
450
+ });
451
+ req.on('error', () => fail(new HttpError(400, 'request body could not be read')));
388
452
  });
389
453
  }
390
454
 
@@ -414,9 +478,56 @@ function sendMiddlewareResult(res, result, secHeaders) {
414
478
  }
415
479
  if (result.__response) {
416
480
  return send(res, result.status || 200, result.contentType || 'application/json',
417
- typeof result.body === 'string' ? result.body : JSON.stringify(result.body), { ...secHeaders, ...(result.headers || {}) });
481
+ typeof result.body === 'string' ? result.body : JSON.stringify(result.body), mergeSafeHeaders(secHeaders, result.headers));
418
482
  }
419
483
  // A bare object/string from middleware is treated as a JSON/text body.
420
484
  if (typeof result === 'string') return send(res, 200, 'text/plain; charset=utf-8', result, secHeaders);
421
485
  return send(res, 200, 'application/json', JSON.stringify(result), secHeaders);
422
486
  }
487
+
488
+ class HttpError extends Error {
489
+ constructor(status, message) {
490
+ super(message);
491
+ this.status = status;
492
+ }
493
+ }
494
+
495
+ async function staticFile(candidate, root) {
496
+ const lst = await fs.lstat(candidate).catch(() => null);
497
+ if (!lst || !lst.isFile()) return null;
498
+ const real = await fs.realpath(candidate).catch(() => null);
499
+ if (!real || !isInsideRoot(real, root)) return null;
500
+ const stat = await fs.stat(real).catch(() => null);
501
+ if (!stat || !stat.isFile()) return null;
502
+ return { file: real, stat };
503
+ }
504
+
505
+ function isInsideRoot(file, root) {
506
+ const relative = path.relative(root, file);
507
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
508
+ }
509
+
510
+ function isJsonContentType(value = '') {
511
+ return /(^|;|\s)(application\/json|[^;\s]+\/[^;\s+]+\+json)(;|\s|$)/i.test(String(value));
512
+ }
513
+
514
+ function isCrossSiteUnsafeRequest(req, url) {
515
+ if (!UNSAFE_METHODS.has(String(req.method || '').toUpperCase())) return false;
516
+ if (!req.headers.cookie) return false;
517
+ const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
518
+ if (site === 'cross-site') return true;
519
+ if (site === 'same-origin' || site === 'same-site' || site === 'none') return false;
520
+ const source = req.headers.origin || req.headers.referer;
521
+ if (!source) return false;
522
+ try {
523
+ return new URL(source).host !== url.host;
524
+ } catch {
525
+ return true;
526
+ }
527
+ }
528
+
529
+ function dynamicCacheHeaders(ctx) {
530
+ return ctx?.headers?.cookie || ctx?.headers?.authorization
531
+ ? { 'cache-control': 'private, no-store' }
532
+ : {};
533
+ }