@vyckr/tachyon 1.3.0 → 1.3.2

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/.env.example CHANGED
@@ -1,13 +1,19 @@
1
1
  # Tachyon environment variables
2
2
  PORT=8000
3
3
  TIMEOUT=70
4
+ # Maximum ms a handler process may run before it is killed (default: 30000)
5
+ HANDLER_TIMEOUT_MS=30000
4
6
  DEV=
5
7
  VALIDATE=
6
8
  HOSTNAME=127.0.0.1
7
9
  ALLOW_HEADERS=*
8
- ALLOW_ORIGINS=*
10
+ # Restrict to explicit origins in production — never use * with credentials enabled
11
+ ALLOW_ORIGINS=https://yourdomain.com
9
12
  ALLOW_CREDENTIALS=
10
- ALLOW_EXPOSE_HEADERS=*
13
+ ALLOW_EXPOSE_HEADERS=
11
14
  ALLOW_MAX_AGE=3600
12
15
  ALLOW_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS
13
- BASIC_AUTH=username:password
16
+ # Generate strong credentials at deployment time — never commit real credentials
17
+ BASIC_AUTH=
18
+ # Override default CSP if your app loads scripts/styles from external origins
19
+ CONTENT_SECURITY_POLICY=default-src 'self'
package/README.md CHANGED
@@ -13,7 +13,8 @@ Tachyon is a **polyglot, file-system-routed full-stack framework for [Bun](https
13
13
  - **Custom 404 page** — drop a `404.html` in your project root to override the default
14
14
  - **Schema validation** — per-route request/response validation via `OPTIONS` files
15
15
  - **Status code routing** — map response schemas to HTTP status codes; the framework picks the code automatically
16
- - **Auth** — built-in Basic Auth and JWT decoding
16
+ - **Auth** — built-in Basic Auth (timing-safe) and JWT decoding with expiry enforcement
17
+ - **Security headers** — X-Frame-Options, X-Content-Type-Options, HSTS, CSP, and Referrer-Policy sent on every response
17
18
  - **Streaming** — SSE responses via `Accept: text/event-stream`
18
19
 
19
20
  ## Installation
@@ -53,20 +54,28 @@ HOSTNAME=127.0.0.1
53
54
  TIMEOUT=70
54
55
  DEV=true
55
56
 
56
- # CORS
57
- ALLOW_HEADERS=*
58
- ALLOW_ORIGINS=*
57
+ # CORS — restrict to explicit origins in production; never combine * with credentials
58
+ ALLOW_HEADERS=Content-Type,Authorization
59
+ ALLOW_ORIGINS=https://yourdomain.com
59
60
  ALLOW_CREDENTIALS=false
60
- ALLOW_EXPOSE_HEADERS=*
61
+ ALLOW_EXPOSE_HEADERS=
61
62
  ALLOW_MAX_AGE=3600
62
63
  ALLOW_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS
63
64
 
64
- # Auth
65
- BASIC_AUTH=username:password
65
+ # Auth — generate strong credentials; never commit real values
66
+ BASIC_AUTH=
66
67
 
67
68
  # Validation (set to any value to enable)
68
69
  VALIDATE=true
69
70
 
71
+ # Security
72
+ # Override the default CSP if your app loads scripts/styles from external origins
73
+ CONTENT_SECURITY_POLICY=default-src 'self'
74
+ # Maximum ms a handler process may run before it is killed (default: 30000)
75
+ HANDLER_TIMEOUT_MS=30000
76
+ # Maximum length of any single route or query parameter value (default: 1000)
77
+ MAX_PARAM_LENGTH=1000
78
+
70
79
  # Custom route/asset paths (defaults to <cwd>/routes, <cwd>/components, <cwd>/assets)
71
80
  ROUTES_PATH=
72
81
  COMPONENTS_PATH=
@@ -111,11 +120,18 @@ Every handler receives the full request context on `stdin` as a JSON object:
111
120
  "paths": { "version": "v2" },
112
121
  "context": {
113
122
  "ipAddress": "127.0.0.1",
114
- "bearer": { "header": {}, "payload": {}, "signature": "..." }
123
+ "bearer": {
124
+ "header": { "alg": "HS256", "typ": "JWT" },
125
+ "payload": { "sub": "42", "exp": 1999999999 },
126
+ "signature": "...",
127
+ "verified": false
128
+ }
115
129
  }
116
130
  }
117
131
  ```
118
132
 
133
+ > **Note:** `context.bearer` is decoded but the signature is **not** verified. `verified` is always `false`. Do not trust claims in `bearer.payload` without out-of-band verification (e.g. via middleware that calls a trusted auth service). Tokens with an expired `exp` claim are rejected before reaching your handler. Use the [`jose`](https://github.com/panva/jose) library for full JWT verification.
134
+
119
135
  ### Route Handler Examples
120
136
 
121
137
  **Bun (TypeScript)**
@@ -292,6 +308,25 @@ tach.bundle
292
308
 
293
309
  Outputs compiled assets to `dist/`.
294
310
 
311
+ ## Security
312
+
313
+ Tachyon applies the following protections by default:
314
+
315
+ | Area | Protection |
316
+ |------|-----------|
317
+ | **Response headers** | `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Strict-Transport-Security`, `Content-Security-Policy`, `Referrer-Policy` on every response |
318
+ | **Basic Auth** | Credential comparison uses `timingSafeEqual` to prevent timing oracle attacks |
319
+ | **JWT** | Tokens with an expired `exp` claim are rejected; signature is decoded but not verified (see note above) |
320
+ | **Process timeout** | Handler processes that exceed `HANDLER_TIMEOUT_MS` are killed automatically |
321
+ | **Parameter limits** | Query and path parameters exceeding `MAX_PARAM_LENGTH` characters return HTTP 400 |
322
+ | **Error responses** | Unhandled server errors return a generic message; internal details are logged server-side only |
323
+ | **CORS** | Wildcard `ALLOW_ORIGINS=*` combined with `ALLOW_CREDENTIALS=true` is not recommended — set explicit origins in production |
324
+
325
+ For production deployments:
326
+ - Set `BASIC_AUTH` to a strong credential — never use a default value
327
+ - Set `ALLOW_ORIGINS` to your application's domain instead of `*`
328
+ - Consider adding a reverse proxy (nginx, Caddy) to enforce HTTPS and add rate limiting
329
+
295
330
  ## License
296
331
 
297
332
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vyckr/tachyon",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "A polyglot, file-system-routed full-stack framework for Bun",
5
5
  "author": "Chidelma",
6
6
  "license": "MIT",
package/src/cli/serve.ts CHANGED
@@ -23,7 +23,13 @@ async function loadMiddleware() {
23
23
  const filePath = `${Router.middlewarePath}${ext}`
24
24
  if (await pathExists(filePath)) {
25
25
  const mod = await import(filePath)
26
- Router.middleware = (mod.default ?? mod) as Middleware
26
+ const loaded = mod.default ?? mod
27
+ if (typeof loaded !== 'object' || loaded === null ||
28
+ (loaded.before !== undefined && typeof loaded.before !== 'function') ||
29
+ (loaded.after !== undefined && typeof loaded.after !== 'function')) {
30
+ throw new Error(`Middleware at '${filePath}' must export an object with optional before/after functions`)
31
+ }
32
+ Router.middleware = loaded as Middleware
27
33
  return
28
34
  }
29
35
  }
@@ -4,7 +4,15 @@ export default async function(props) {
4
4
 
5
5
  // script
6
6
 
7
- if(props) props.split(';').map(prop => eval(prop))
7
+ if(props) {
8
+ const __p__ = typeof props === 'string' ? JSON.parse(decodeURIComponent(props)) : props
9
+ for(const __k__ of Object.keys(__p__)) {
10
+ if(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(__k__)) {
11
+ const __v__ = __p__[__k__]
12
+ eval(`${__k__} = __v__`)
13
+ }
14
+ }
15
+ }
8
16
 
9
17
  const compRenders = new Map()
10
18
 
@@ -28,24 +36,21 @@ export default async function(props) {
28
36
  }
29
37
 
30
38
  const ty_invokeEvent = (hash, action) => {
31
-
32
39
  if(elemId === ty_generateId(hash, 'ev')) {
33
-
34
- if(event && !action.endsWith(')')) {
35
- return action + "('" + event + "')"
36
- }
37
- return action
40
+ const toCall = (event && !action.endsWith(')')) ? action + "('" + event + "')" : action
41
+ eval(toCall)
38
42
  }
39
- return "''"
43
+ return ''
40
44
  }
41
45
 
42
46
  const ty_assignValue = (hash, variable) => {
43
-
44
47
  if(elemId === ty_generateId(hash, 'bind') && event) {
45
- return variable + " = '" + event.value + "'"
48
+ if(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(variable)) {
49
+ const __val__ = event.value
50
+ eval(`${variable} = __val__`)
51
+ }
46
52
  }
47
-
48
- return variable
53
+ return ''
49
54
  }
50
55
 
51
56
  let elements = '';
@@ -119,9 +119,9 @@ export default class Yon {
119
119
 
120
120
  const formatAttr = (name: string, value: string, hash: string): string => {
121
121
  if (name.startsWith('@'))
122
- return `${name}="\${eval(ty_invokeEvent('${hash}', '${value}'))}"`;
122
+ return `${name}="\${ty_invokeEvent('${hash}', '${value}')}"`;
123
123
  if (name === ':value')
124
- return `value="\${eval(ty_assignValue('${hash}', '${value}'))}"`;
124
+ return `value="\${ty_assignValue('${hash}', '${value}')}"`;
125
125
  return `${name}="${value}"`;
126
126
  };
127
127
 
@@ -266,24 +266,28 @@ export default class Yon {
266
266
  if (key.startsWith('@')) {
267
267
  events.push(`${key}="${value.replace(/(ty_invokeEvent\(')([^"]+)(',[^)]+\))/g, `$1${hash}$3`)}"`);
268
268
  } else {
269
- props.push(`${key}=${value}`);
269
+ // Convert template-literal expr "${foo()}" → foo(), static value → JSON literal
270
+ const expr = value.startsWith('${') && value.endsWith('}')
271
+ ? value.slice(2, -1)
272
+ : JSON.stringify(value);
273
+ props.push(`"${key}": ${expr}`);
270
274
  }
271
275
  }
272
276
 
273
277
  const genId = "${ty_generateId('" + hash + "', 'id')}";
278
+ const propsObj = props.length ? `{${props.join(', ')}}` : 'null';
274
279
 
275
280
  if (isLazy) {
276
281
  const filepath = Yon.compMapping.get(component);
277
- const propsEncoded = props.length ? props.join(';') : '';
278
282
  return `
279
- elements += \`<div id="${genId}" data-lazy-component="${component}" data-lazy-path="/components/${filepath}" data-lazy-props="${propsEncoded}" ${events.join(' ')}></div>\`
283
+ elements += \`<div id="${genId}" data-lazy-component="${component}" data-lazy-path="/components/${filepath}" data-lazy-props="\${${props.length ? `encodeURIComponent(JSON.stringify(${propsObj}))` : "''"}}\" ${events.join(' ')}></div>\`
280
284
  `;
281
285
  }
282
286
 
283
287
  return `
284
288
  elements += \`<div id="${genId}" ${events.join(' ')}>\`
285
289
  if(!compRenders.has('${hash}')) {
286
- render = await ${component}(\`${props.join(';')}\`)
290
+ render = await ${component}(${propsObj})
287
291
  elements += await render(elemId, event, '${hash}')
288
292
  compRenders.set('${hash}', render)
289
293
  } else {
@@ -200,7 +200,8 @@ const lazyObserver = new IntersectionObserver((entries) => {
200
200
  async function loadLazyComponent(el: HTMLElement) {
201
201
  const path = el.dataset.lazyComponent!;
202
202
  const modulePath = el.dataset.lazyPath!;
203
- const props = el.dataset.lazyProps || '';
203
+ const propsRaw = el.dataset.lazyProps || '';
204
+ const props = propsRaw ? JSON.parse(decodeURIComponent(propsRaw)) : null;
204
205
 
205
206
  try {
206
207
  const mod = await import(modulePath);
@@ -1,4 +1,5 @@
1
1
  import { BunRequest, Server } from "bun";
2
+ import { timingSafeEqual } from "node:crypto";
2
3
  import Router, { RequestContext, RouteOptions, RequestPayload, RouteResponse } from "./route-handler.js";
3
4
  import Pool from "./process-pool.js";
4
5
  import Validate from "./schema-validator.js";
@@ -118,11 +119,15 @@ export default class Tach {
118
119
  }
119
120
 
120
121
  private static isAuthorizedClient(authorization: string | undefined, basicAuth: string): boolean {
121
- if (authorization) {
122
- const [, hash] = authorization.split(' ')
123
- return hash === btoa(basicAuth)
124
- }
125
- return false
122
+ if (!authorization) return false
123
+ const [, provided] = authorization.split(' ')
124
+ if (!provided) return false
125
+ const expected = btoa(basicAuth)
126
+ // Timing-safe comparison prevents brute-force timing oracle attacks
127
+ const a = Buffer.from(expected)
128
+ const b = Buffer.from(provided)
129
+ if (a.length !== b.length) return false
130
+ return timingSafeEqual(a, b)
126
131
  }
127
132
 
128
133
  private static async serveRequest(
@@ -187,7 +192,9 @@ export default class Tach {
187
192
 
188
193
  /**
189
194
  * Decodes a Bearer JWT from an Authorization header.
190
- * Returns `undefined` if absent, not Bearer, or the payload is malformed.
195
+ * WARNING: The signature is NOT verified. Route handlers must not trust claims
196
+ * without out-of-band verification (e.g. via a middleware that calls a trusted
197
+ * auth service). Rejects tokens whose `exp` claim is in the past.
191
198
  * @param authorization - The raw `Authorization` header value
192
199
  */
193
200
  private static decodeJWT(authorization: string | undefined) {
@@ -200,10 +207,19 @@ export default class Tach {
200
207
  const [header, payload, signature] = token.split('.')
201
208
 
202
209
  try {
210
+ const decodedPayload: Record<string, unknown> = JSON.parse(atob(payload))
211
+
212
+ // Reject expired tokens even without full signature verification
213
+ if (typeof decodedPayload.exp === 'number' && decodedPayload.exp < Math.floor(Date.now() / 1000)) {
214
+ console.warn(`Rejected expired JWT (exp=${decodedPayload.exp})`, process.pid)
215
+ return undefined
216
+ }
217
+
203
218
  return {
204
- header: JSON.parse(atob(header)),
205
- payload: JSON.parse(atob(payload)),
219
+ header: JSON.parse(atob(header)),
220
+ payload: decodedPayload,
206
221
  signature,
222
+ verified: false as const, // Signature NOT verified — do not trust claims without separate verification
207
223
  }
208
224
  } catch {
209
225
  console.warn(`Failed to decode JWT — malformed base64 or JSON payload`, process.pid)
@@ -263,9 +279,12 @@ export default class Tach {
263
279
  }
264
280
 
265
281
  } catch (err) {
266
- res = err instanceof Response
267
- ? err
268
- : Response.json({ detail: (err as Error).message }, { status: 400, headers: Router.headers })
282
+ if (err instanceof Response) {
283
+ res = err
284
+ } else {
285
+ console.error('[process-executor] Unhandled error:', err, process.pid)
286
+ res = Response.json({ error: 'Internal server error' }, { status: 500, headers: Router.headers })
287
+ }
269
288
  }
270
289
 
271
290
  console.info(`${path} - ${request!.method} - ${res.status} - ${Date.now() - start}ms`, process.pid)
@@ -3,6 +3,11 @@ import Router from "./route-handler.js"
3
3
  /** A Bun subprocess with all three stdio channels opened as pipes. */
4
4
  export type PipedProcess = ReturnType<typeof Bun.spawn<"pipe", "pipe", "pipe">>
5
5
 
6
+ /** Maximum time (ms) a handler process may run before it is killed. Default: 30 s. */
7
+ const HANDLER_TIMEOUT_MS = process.env.HANDLER_TIMEOUT_MS
8
+ ? Number(process.env.HANDLER_TIMEOUT_MS)
9
+ : 30_000
10
+
6
11
  export default class Pool {
7
12
 
8
13
  /**
@@ -58,6 +63,9 @@ export default class Pool {
58
63
  * Returns the pre-warmed process for `handler` if one exists and is still
59
64
  * running, otherwise spawns a fresh process. Immediately schedules a
60
65
  * replacement warm process for the next request.
66
+ *
67
+ * A kill-on-timeout timer is armed: if the process has not exited within
68
+ * HANDLER_TIMEOUT_MS it is killed and the event is logged.
61
69
  */
62
70
  static acquireHandler(handler: string): PipedProcess {
63
71
  const warmed = Pool.warmedProcesses.get(handler)
@@ -67,14 +75,27 @@ export default class Pool {
67
75
  setImmediate(() => Pool.prewarmHandler(handler))
68
76
 
69
77
  // If the warmed process exited early (e.g. handler syntax error), spawn fresh
70
- if (warmed && warmed.exitCode === null) return warmed
78
+ const proc: PipedProcess = (warmed && warmed.exitCode === null)
79
+ ? warmed
80
+ : Bun.spawn<"pipe", "pipe", "pipe">({
81
+ cmd: [handler],
82
+ stdin: "pipe",
83
+ stdout: "pipe",
84
+ stderr: "pipe",
85
+ env: process.env,
86
+ })
71
87
 
72
- return Bun.spawn<"pipe", "pipe", "pipe">({
73
- cmd: [handler],
74
- stdin: "pipe",
75
- stdout: "pipe",
76
- stderr: "pipe",
77
- env: process.env,
78
- })
88
+ // Kill hung processes to prevent resource exhaustion
89
+ const timeout = setTimeout(() => {
90
+ if (proc.exitCode === null) {
91
+ console.error(`[pool] Handler timed out after ${HANDLER_TIMEOUT_MS}ms — killing process`, proc.pid)
92
+ try { proc.kill() } catch { /* already exited */ }
93
+ }
94
+ }, HANDLER_TIMEOUT_MS)
95
+
96
+ // Clear the timer once the process exits naturally
97
+ proc.exited.then(() => clearTimeout(timeout)).catch(() => clearTimeout(timeout))
98
+
99
+ return proc
79
100
  }
80
101
  }
@@ -7,6 +7,8 @@ export interface RequestContext {
7
7
  header: Record<string, unknown>
8
8
  payload: Record<string, unknown>
9
9
  signature: string
10
+ /** Always false — signature is decoded but NOT cryptographically verified */
11
+ verified: false
10
12
  }
11
13
  }
12
14
 
@@ -64,7 +66,13 @@ export default class Router {
64
66
  "Access-Control-Allow-Credential": process.env.ALLOW_CREDENTIALS || "false",
65
67
  "Access-Control-Expose-Headers": process.env.ALLOW_EXPOSE_HEADERS || "",
66
68
  "Access-Control-Max-Age": process.env.ALLOW_MAX_AGE || "",
67
- "Access-Control-Allow-Methods": process.env.ALLOW_METHODS || ""
69
+ "Access-Control-Allow-Methods": process.env.ALLOW_METHODS || "",
70
+ // Security headers
71
+ "X-Frame-Options": "DENY",
72
+ "X-Content-Type-Options": "nosniff",
73
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
74
+ "Content-Security-Policy": process.env.CONTENT_SECURITY_POLICY || "default-src 'self'",
75
+ "Referrer-Policy": "strict-origin-when-cross-origin",
68
76
  }
69
77
 
70
78
  /**
@@ -153,17 +161,24 @@ export default class Router {
153
161
  return { handler: `${Router.routesPath}${route}/${request.method}`, stdin, config: requestConfig }
154
162
  }
155
163
 
164
+ /** Maximum allowed length for any single route or query parameter value. */
165
+ static readonly MAX_PARAM_LENGTH = Number(process.env.MAX_PARAM_LENGTH) || 1000
166
+
156
167
  /**
157
168
  * Coerces an array of string path segments into their native types
158
169
  * (number, boolean, null, undefined, or string).
159
170
  * @param input - Raw string segments from the URL path
160
171
  * @returns Typed parameter values
172
+ * @throws Response with status 400 if any segment exceeds MAX_PARAM_LENGTH
161
173
  */
162
174
  static parseParams(input: string[]): (string | boolean | number | null | undefined)[] {
163
175
 
164
176
  const params: (string | boolean | number | null | undefined)[] = []
165
177
 
166
178
  for (const param of input) {
179
+ if (param.length > Router.MAX_PARAM_LENGTH) {
180
+ throw Response.json({ error: 'Parameter too long' }, { status: 400 })
181
+ }
167
182
 
168
183
  const num = Number(param)
169
184
 
@@ -190,6 +205,10 @@ export default class Router {
190
205
 
191
206
  for (const [key, val] of input) {
192
207
 
208
+ if (typeof val === "string" && val.length > Router.MAX_PARAM_LENGTH) {
209
+ throw Response.json({ error: 'Parameter too long' }, { status: 400 })
210
+ }
211
+
193
212
  if (typeof val === "string") {
194
213
 
195
214
  try {