glashjs 0.15.0 → 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,7 +133,7 @@ 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),
127
- translate: optionBool('--no-translate', undefined),
136
+ translate: flag('--translate') ? true : optionBool('--no-translate', undefined),
128
137
  animation: flag('--animation') ? true : undefined,
129
138
  });
130
139
  break;
@@ -185,8 +194,9 @@ async function main() {
185
194
 
186
195
  Usage: (run as "glashjs <cmd>"; "glash <cmd>" also works unless the glashdb deploy CLI owns that name)
187
196
  glashjs run dev Alias for glashjs dev (also: glash run dev)
188
- glashjs create [name] Create a new Glash project (interactive)
189
- 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
190
200
  glashjs serve [--port 3000] Run the production server over routes/ + built assets
191
201
  glashjs build [--root <dir>] Optimize assets, precompile routes, generate offline SW + PWA + security
192
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.15.0",
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');
@@ -135,10 +135,8 @@ export default defineConfig({
135
135
  publicDir: 'public',
136
136
  outDir: '.glash/out',
137
137
  offline: true,
138
- // Built-in IP auto-translation: detect the visitor's country and offer a
139
- // one-tap in-place translation into their native language. The framework
140
- // serves the runtime + geo endpoint and relaxes the CSP for the translate
141
- // widget automatically — no route or layout wiring needed.
138
+ // Built-in IP auto-translation is opt-in because it calls third-party
139
+ // translation/geolocation services and relaxes CSP for that widget.
142
140
  i18n: ${answers.translate ? 'true' : 'false'},
143
141
  favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
144
142
  animatedFavicon: false,
@@ -300,21 +298,21 @@ export const GET = () => json({
300
298
  function authRoutes() {
301
299
  return {
302
300
  'routes/api/auth/signup.mjs': `import { json } from 'glashjs';
303
- import { glashAuth, sessionCookie } from 'glashjs/auth';
301
+ import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
304
302
 
305
303
  export const POST = async (ctx) => {
306
304
  const auth = glashAuth();
307
305
  const session = await auth.signup(ctx.body);
308
- return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
306
+ return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
309
307
  };
310
308
  `,
311
309
  'routes/api/auth/signin.mjs': `import { json } from 'glashjs';
312
- import { glashAuth, sessionCookie } from 'glashjs/auth';
310
+ import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
313
311
 
314
312
  export const POST = async (ctx) => {
315
313
  const auth = glashAuth();
316
314
  const session = await auth.signin(ctx.body);
317
- return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
315
+ return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
318
316
  };
319
317
  `,
320
318
  'routes/api/auth/me.mjs': `import { json } from 'glashjs';
@@ -345,20 +343,6 @@ export const POST = serverFunction(async ({ email }) => {
345
343
  \`;
346
344
  return { message: \`Saved \${value} in Postgres.\` };
347
345
  });
348
- `,
349
- 'routes/api/sql/run.mjs': `import { json } from 'glashjs';
350
- import { query } from 'glashjs/postgres';
351
-
352
- export const POST = async (ctx) => {
353
- const expected = process.env.GLASH_SQL_RUNNER_TOKEN;
354
- const token = String(ctx.headers.authorization || '').replace(/^Bearer\\s+/i, '');
355
- if (!expected || token !== expected) return json({ error: 'SQL runner token required' }, { status: 401 });
356
- const statement = String(ctx.body?.statement || '');
357
- const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
358
- if (!statement.trim()) return json({ error: 'statement is required' }, { status: 400 });
359
- const result = await query(statement, params);
360
- return json({ rowCount: result.rowCount, rows: result.rows });
361
- };
362
346
  `,
363
347
  };
364
348
  }
@@ -380,7 +364,7 @@ GLASHDB_PROJECT_ID=""
380
364
  DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
381
365
  DIRECT_URL=""
382
366
  GLASHDB_ANON_KEY=""
383
- GLASH_SQL_RUNNER_TOKEN="${randomToken()}"
367
+ GLASH_SESSION_SECRET="replace-with-at-least-32-random-characters"
384
368
  `;
385
369
  }
386
370
 
@@ -391,7 +375,7 @@ GLASHDB_PROJECT_ID=""
391
375
  DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
392
376
  DIRECT_URL=""
393
377
  GLASHDB_ANON_KEY=""
394
- GLASH_SQL_RUNNER_TOKEN="${randomToken()}"
378
+ GLASH_SESSION_SECRET="${randomToken()}.${randomToken()}"
395
379
  `;
396
380
  }
397
381
 
@@ -533,9 +517,9 @@ function tailwindInput() {
533
517
  function faviconSvg() {
534
518
  // Use the bundled glash mark (the official metallic glash favicon) so a new
535
519
  // app ships the real brand favicon, not a placeholder. Single source of truth:
536
- // glashjs/templates/favicon.svg.
520
+ // glashjs/templates/glash_favicon.svg.
537
521
  try {
538
- 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');
539
523
  } catch {
540
524
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
541
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
  }
@@ -25,6 +25,29 @@ const I18N_CSP = {
25
25
  };
26
26
  const merge = (base, extra) => [...new Set([...base, ...extra])];
27
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
+
28
51
  export function buildCsp({
29
52
  connectSrc = ["'self'"],
30
53
  imgSrc = ["'self'", 'data:', 'blob:'],
@@ -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
@@ -99,6 +101,9 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
99
101
  const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
100
102
  return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
101
103
  }
104
+ if (isCrossSiteUnsafeRequest(req, url)) {
105
+ return send(res, 403, 'application/json', JSON.stringify({ error: 'cross-site request blocked' }), secHeaders);
106
+ }
102
107
  const match = matchRoute(routes, pathname);
103
108
  if (!match) return await handleNotFound(res, routes, req, url, cfg, secHeaders, root, routesDir, dev);
104
109
  const ctx = makeCtx(req, res, url, match.params);
@@ -138,7 +143,7 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
138
143
  }
139
144
  });
140
145
 
141
- 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') =>
142
147
  new Promise((resolve, reject) => {
143
148
  const onError = (error) => {
144
149
  server.off('listening', onListening);
@@ -158,26 +163,37 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
158
163
 
159
164
  async function handleApi(res, mod, req, ctx, secHeaders) {
160
165
  const method = req.method.toUpperCase();
166
+ const apiHeaders = { ...secHeaders, 'cache-control': 'no-store' };
161
167
  const handler = mod[method] || (method === 'GET' && mod.default) || mod.handler;
162
168
  if (typeof handler !== 'function') {
163
- 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
+ }
164
180
  }
165
- if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) ctx.body = await readJson(req);
166
181
  const result = await handler(ctx);
167
182
  // Next-style route handlers return a Web `Response` (e.g. Response.json(...)).
168
183
  // Pass it through so migrated API routes work unchanged.
169
184
  if (typeof Response !== 'undefined' && result instanceof Response) {
170
- const headers = { ...secHeaders };
171
- 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);
172
188
  const buf = Buffer.from(await result.arrayBuffer());
173
189
  res.writeHead(result.status || 200, headers);
174
190
  return res.end(buf);
175
191
  }
176
192
  if (result && result.__response) {
177
193
  return send(res, result.status || 200, result.contentType || 'application/json',
178
- 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));
179
195
  }
180
- send(res, 200, 'application/json', JSON.stringify(result ?? null), secHeaders);
196
+ send(res, 200, 'application/json', JSON.stringify(result ?? null), apiHeaders);
181
197
  }
182
198
 
183
199
  async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
@@ -196,7 +212,8 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
196
212
  i18n: cfg.i18n,
197
213
  nonce, dev,
198
214
  });
199
- 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));
200
217
  }
201
218
 
202
219
  async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, routesDir, dev) {
@@ -211,7 +228,8 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
211
228
  // so <Link> can swap #glash-root and re-hydrate without a full reload.
212
229
  if (String(ctx.headers['x-glash-nav'] || '') === '1') {
213
230
  const rendered = await renderComponent(mod, props);
214
- 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' });
215
233
  }
216
234
 
217
235
  const nonce = randomBytes(16).toString('base64');
@@ -222,7 +240,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
222
240
  title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, i18n: cfg.i18n, nonce, dev,
223
241
  });
224
242
  const bundleTag = `</div><script type="module" src="/_glash/${id}.js"></script>`;
225
- 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' });
226
244
  res.write(open + '<div id="glash-root">'); // flush the shell before rendering
227
245
 
228
246
  // True Suspense streaming: render the boundary's fallback into the shell now,
@@ -280,22 +298,26 @@ function safeJson(value) {
280
298
  async function serveStatic(res, outDir, pathname, req, secHeaders) {
281
299
  if (pathname === '/') return false; // let the index page route render
282
300
  const rel = pathname.replace(/^\/+/, '');
283
- const file = path.join(outDir, rel);
284
- 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
285
305
  const head = req.method === 'HEAD';
286
306
  const range = req.headers.range;
287
307
  const ae = String(req.headers['accept-encoding'] || '');
288
308
 
289
309
  // Brotli precompressed sibling — only when the client isn't asking for a byte range.
290
- if (!range && ae.includes('br') && existsSync(file + '.br')) {
291
- 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);
292
313
  res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', vary: 'Accept-Encoding', 'cache-control': 'public, max-age=31536000, immutable' });
293
314
  res.end(head ? undefined : buf);
294
315
  return true;
295
316
  }
296
- if (!(existsSync(file) && statSync(file).isFile())) return false;
317
+ const safe = await staticFile(file, root);
318
+ if (!safe) return false;
297
319
 
298
- const stat = statSync(file);
320
+ const { stat } = safe;
299
321
  const ct = mime(file);
300
322
  // Range requests (video/audio seeking) -> 206 Partial Content.
301
323
  if (range) {
@@ -309,13 +331,13 @@ async function serveStatic(res, outDir, pathname, req, secHeaders) {
309
331
  }
310
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' });
311
333
  if (head) { res.end(); return true; }
312
- createReadStream(file, { start, end }).pipe(res);
334
+ createReadStream(safe.file, { start, end }).pipe(res);
313
335
  return true;
314
336
  }
315
337
  // Full file — streamed (handles large assets without buffering them in memory).
316
338
  res.writeHead(200, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-length': stat.size, 'cache-control': 'public, max-age=3600' });
317
339
  if (head) { res.end(); return true; }
318
- createReadStream(file).pipe(res);
340
+ createReadStream(safe.file).pipe(res);
319
341
  return true;
320
342
  }
321
343
 
@@ -392,11 +414,41 @@ function makeCtx(req, res, url, params) {
392
414
  }
393
415
 
394
416
  function readJson(req) {
395
- return new Promise((resolve) => {
396
- let data = '';
397
- req.on('data', (c) => { data += c; if (data.length > 2_000_000) req.destroy(); });
398
- req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); } });
399
- 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')));
400
452
  });
401
453
  }
402
454
 
@@ -426,9 +478,56 @@ function sendMiddlewareResult(res, result, secHeaders) {
426
478
  }
427
479
  if (result.__response) {
428
480
  return send(res, result.status || 200, result.contentType || 'application/json',
429
- 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));
430
482
  }
431
483
  // A bare object/string from middleware is treated as a JSON/text body.
432
484
  if (typeof result === 'string') return send(res, 200, 'text/plain; charset=utf-8', result, secHeaders);
433
485
  return send(res, 200, 'application/json', JSON.stringify(result), secHeaders);
434
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
+ }