threadforge 0.1.0

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/bin/forge.js +1050 -0
  4. package/bin/host-commands.js +344 -0
  5. package/bin/platform-commands.js +570 -0
  6. package/package.json +71 -0
  7. package/shared/auth.js +475 -0
  8. package/src/core/DirectMessageBus.js +364 -0
  9. package/src/core/EndpointResolver.js +247 -0
  10. package/src/core/ForgeContext.js +2227 -0
  11. package/src/core/ForgeHost.js +122 -0
  12. package/src/core/ForgePlatform.js +145 -0
  13. package/src/core/Ingress.js +768 -0
  14. package/src/core/Interceptors.js +420 -0
  15. package/src/core/MessageBus.js +310 -0
  16. package/src/core/Prometheus.js +305 -0
  17. package/src/core/RequestContext.js +413 -0
  18. package/src/core/RoutingStrategy.js +316 -0
  19. package/src/core/Supervisor.js +1306 -0
  20. package/src/core/ThreadAllocator.js +196 -0
  21. package/src/core/WorkerChannelManager.js +879 -0
  22. package/src/core/config.js +624 -0
  23. package/src/core/host-config.js +311 -0
  24. package/src/core/network-utils.js +166 -0
  25. package/src/core/platform-config.js +308 -0
  26. package/src/decorators/ServiceProxy.js +899 -0
  27. package/src/decorators/index.js +571 -0
  28. package/src/deploy/NginxGenerator.js +865 -0
  29. package/src/deploy/PlatformManifestGenerator.js +96 -0
  30. package/src/deploy/RouteManifestGenerator.js +112 -0
  31. package/src/deploy/index.js +984 -0
  32. package/src/frontend/FrontendDevLifecycle.js +65 -0
  33. package/src/frontend/FrontendPluginOrchestrator.js +187 -0
  34. package/src/frontend/SiteResolver.js +63 -0
  35. package/src/frontend/StaticMountRegistry.js +90 -0
  36. package/src/frontend/index.js +5 -0
  37. package/src/frontend/plugins/index.js +2 -0
  38. package/src/frontend/plugins/viteFrontend.js +79 -0
  39. package/src/frontend/types.js +35 -0
  40. package/src/index.js +56 -0
  41. package/src/internals.js +31 -0
  42. package/src/plugins/PluginManager.js +537 -0
  43. package/src/plugins/ScopedPostgres.js +192 -0
  44. package/src/plugins/ScopedRedis.js +142 -0
  45. package/src/plugins/index.js +1729 -0
  46. package/src/registry/ServiceRegistry.js +796 -0
  47. package/src/scaling/ScaleAdvisor.js +442 -0
  48. package/src/services/Service.js +195 -0
  49. package/src/services/worker-bootstrap.js +676 -0
  50. package/src/templates/auth-service.js +65 -0
  51. package/src/templates/identity-service.js +75 -0
package/shared/auth.js ADDED
@@ -0,0 +1,475 @@
1
+ /**
2
+ * ForgeHost Shared Auth Service
3
+ *
4
+ * A template auth service for multi-project hosting. Provides:
5
+ * - Login/register with email + password
6
+ * - JWT tokens with project_id claims
7
+ * - SSO flow for cross-project authentication
8
+ * - Token refresh and validation
9
+ *
10
+ * Uses the shared (unscoped) Postgres for the public.users table
11
+ * and shared Redis for sessions. Since this is a shared service
12
+ * (no _projectId), plugins connect without scoping.
13
+ *
14
+ * Customize this template for your deployment.
15
+ */
16
+
17
+ import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
18
+ import { Service } from "../src/services/Service.js";
19
+
20
+ // Lazy JWT_SECRET getter — refuses to start with weak/missing secret
21
+ let _jwtSecret;
22
+ function getJwtSecret() {
23
+ if (_jwtSecret) return _jwtSecret;
24
+ const secret = process.env.FORGE_JWT_SECRET;
25
+ if (!secret || secret.length < 32) {
26
+ throw new Error("FORGE_JWT_SECRET must be set and at least 32 characters");
27
+ }
28
+ _jwtSecret = secret;
29
+ return _jwtSecret;
30
+ }
31
+
32
+ const TOKEN_EXPIRY = 3600; // 1 hour in seconds
33
+ const REFRESH_EXPIRY = 86400 * 30; // 30 days in seconds
34
+
35
+ // SSO state store with TTL (CSRF protection)
36
+ const ssoStateStore = new Map();
37
+ const SSO_STATE_TTL = 5 * 60 * 1000; // 5 minutes
38
+ const MAX_SSO_STATES = 10000;
39
+
40
+ // Periodic cleanup instead of per-entry timers (avoids timer handle exhaustion)
41
+ setInterval(() => {
42
+ const now = Date.now();
43
+ for (const [state, timestamp] of ssoStateStore.entries()) {
44
+ if (now - timestamp > SSO_STATE_TTL) ssoStateStore.delete(state);
45
+ }
46
+ }, 60 * 1000).unref?.();
47
+
48
+ function createSsoState() {
49
+ if (ssoStateStore.size >= MAX_SSO_STATES) {
50
+ // Evict oldest entries if at capacity
51
+ const now = Date.now();
52
+ for (const [state, timestamp] of ssoStateStore.entries()) {
53
+ if (now - timestamp > SSO_STATE_TTL) ssoStateStore.delete(state);
54
+ }
55
+ if (ssoStateStore.size >= MAX_SSO_STATES) {
56
+ throw new Error("SSO state store capacity exceeded");
57
+ }
58
+ }
59
+ const state = randomBytes(16).toString("hex");
60
+ ssoStateStore.set(state, Date.now());
61
+ return state;
62
+ }
63
+ function consumeSsoState(state) {
64
+ if (!state || !ssoStateStore.has(state)) return false;
65
+ const timestamp = ssoStateStore.get(state);
66
+ ssoStateStore.delete(state);
67
+ // Reject expired states even if still in the map
68
+ if (Date.now() - timestamp > SSO_STATE_TTL) return false;
69
+ return true;
70
+ }
71
+
72
+ // Account lockout tracker
73
+ const loginAttempts = new Map();
74
+ const MAX_ATTEMPTS = 5;
75
+ const LOCKOUT_WINDOW_MS = 15 * 60 * 1000;
76
+ function checkLockout(email) {
77
+ const record = loginAttempts.get(email);
78
+ if (!record) return false;
79
+ const now = Date.now();
80
+ record.timestamps = record.timestamps.filter((t) => now - t < LOCKOUT_WINDOW_MS);
81
+ if (record.timestamps.length === 0) { loginAttempts.delete(email); return false; }
82
+ return record.timestamps.length >= MAX_ATTEMPTS;
83
+ }
84
+ function recordFailedAttempt(email) {
85
+ let record = loginAttempts.get(email);
86
+ if (!record) { record = { timestamps: [] }; loginAttempts.set(email, record); }
87
+ record.timestamps.push(Date.now());
88
+ setTimeout(() => {
89
+ const r = loginAttempts.get(email);
90
+ if (r) { r.timestamps = r.timestamps.filter((t) => Date.now() - t < LOCKOUT_WINDOW_MS); if (r.timestamps.length === 0) loginAttempts.delete(email); }
91
+ }, LOCKOUT_WINDOW_MS).unref?.();
92
+ }
93
+ function resetAttempts(email) { loginAttempts.delete(email); }
94
+
95
+ function hashRefreshToken(token) {
96
+ return createHash("sha256").update(token).digest("hex");
97
+ }
98
+
99
+ // Email validation — ASCII-only, no consecutive dots, min 2-char TLD
100
+ function isValidEmail(email) {
101
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return false;
102
+ if (!/^[\x20-\x7E]+$/.test(email)) return false; // ASCII-only
103
+ if (/\.\./.test(email)) return false; // no consecutive dots
104
+ const [, domain] = email.split("@");
105
+ if (!domain || /^\.|\.$/g.test(domain)) return false; // no leading/trailing dots in domain
106
+ return true;
107
+ }
108
+
109
+ export default class AuthService extends Service {
110
+ static contract = {
111
+ expose: ["validateToken", "refreshToken"],
112
+ routes: [
113
+ { method: "POST", path: "/login", handler: "login" },
114
+ { method: "POST", path: "/register", handler: "register" },
115
+ { method: "POST", path: "/validate", handler: "validateToken" },
116
+ { method: "POST", path: "/refresh", handler: "refreshToken" },
117
+ { method: "GET", path: "/sso", handler: "ssoRedirect" },
118
+ { method: "GET", path: "/sso/callback", handler: "ssoCallback" },
119
+ ],
120
+ };
121
+
122
+ // ── Redis-backed SSO state (cluster-safe) ──
123
+
124
+ async _createSsoState() {
125
+ if (this.redis) {
126
+ const state = randomBytes(16).toString("hex");
127
+ await this.redis.set(`sso:${state}`, "1", "EX", Math.ceil(SSO_STATE_TTL / 1000));
128
+ return state;
129
+ }
130
+ return createSsoState();
131
+ }
132
+
133
+ async _consumeSsoState(state) {
134
+ if (this.redis) {
135
+ if (!state) return false;
136
+ const val = await this.redis.get(`sso:${state}`);
137
+ if (!val) return false;
138
+ await this.redis.del(`sso:${state}`);
139
+ return true;
140
+ }
141
+ return consumeSsoState(state);
142
+ }
143
+
144
+ // ── Redis-backed lockout tracking (cluster-safe) ──
145
+
146
+ async _checkLockout(email) {
147
+ if (this.redis) {
148
+ const count = await this.redis.get(`lockout:${email}`);
149
+ return count !== null && parseInt(count, 10) >= MAX_ATTEMPTS;
150
+ }
151
+ return checkLockout(email);
152
+ }
153
+
154
+ async _recordFailedAttempt(email) {
155
+ if (this.redis) {
156
+ const key = `lockout:${email}`;
157
+ await this.redis.incr(key);
158
+ await this.redis.expire(key, Math.ceil(LOCKOUT_WINDOW_MS / 1000));
159
+ return;
160
+ }
161
+ recordFailedAttempt(email);
162
+ }
163
+
164
+ async _resetAttempts(email) {
165
+ if (this.redis) {
166
+ await this.redis.del(`lockout:${email}`);
167
+ return;
168
+ }
169
+ resetAttempts(email);
170
+ }
171
+
172
+ async onStart(ctx) {
173
+ ctx.logger.info("Auth service ready");
174
+
175
+ // Ensure users table exists (shared schema, not project-scoped)
176
+ if (this.pg) {
177
+ await this.pg.query(`
178
+ CREATE TABLE IF NOT EXISTS public.users (
179
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
180
+ email TEXT UNIQUE NOT NULL,
181
+ password_hash TEXT NOT NULL,
182
+ projects TEXT[] DEFAULT '{}',
183
+ role TEXT DEFAULT 'user',
184
+ created_at TIMESTAMPTZ DEFAULT NOW(),
185
+ updated_at TIMESTAMPTZ DEFAULT NOW()
186
+ )
187
+ `);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Login with email + password.
193
+ * Returns JWT with project_id claim based on the requested project.
194
+ */
195
+ async login(body) {
196
+ const { email, password, project_id } = body ?? {};
197
+ if (!email || !password) {
198
+ return { error: "Email and password required", status: 400 };
199
+ }
200
+ const normalizedEmail = email.toLowerCase().trim();
201
+ if (!isValidEmail(normalizedEmail)) {
202
+ return { error: "Invalid email format", status: 400 };
203
+ }
204
+ if (await this._checkLockout(normalizedEmail)) {
205
+ return { error: "Too many failed attempts. Try again later.", status: 429 };
206
+ }
207
+
208
+ const result = await this.pg.query("SELECT * FROM public.users WHERE LOWER(email) = $1", [normalizedEmail]);
209
+ const user = result.rows[0];
210
+ if (!user) {
211
+ await this._recordFailedAttempt(normalizedEmail);
212
+ return { error: "Invalid credentials", status: 401 };
213
+ }
214
+
215
+ if (!verifyPassword(password, user.password_hash)) {
216
+ await this._recordFailedAttempt(normalizedEmail);
217
+ return { error: "Invalid credentials", status: 401 };
218
+ }
219
+
220
+ await this._resetAttempts(normalizedEmail);
221
+
222
+ // Validate project access
223
+ if (project_id && !user.projects.includes(project_id)) {
224
+ return { error: "No access to project", status: 403 };
225
+ }
226
+
227
+ const token = createToken(user, project_id);
228
+ const refreshToken = randomBytes(32).toString("hex");
229
+
230
+ // Hash before storing -- never store raw refresh tokens server-side
231
+ if (this.redis) {
232
+ const hashed = hashRefreshToken(refreshToken);
233
+ await this.redis.set(
234
+ `refresh:${hashed}`,
235
+ JSON.stringify({ userId: user.id, projectId: project_id }),
236
+ "EX",
237
+ REFRESH_EXPIRY,
238
+ );
239
+ }
240
+
241
+ return { token, refreshToken, expiresIn: TOKEN_EXPIRY };
242
+ }
243
+
244
+ /**
245
+ * Register a new user.
246
+ */
247
+ async register(body) {
248
+ const { email, password, project_id } = body ?? {};
249
+ if (!email || !password) {
250
+ return { error: "Email and password required", status: 400 };
251
+ }
252
+ const normalizedEmail = email.toLowerCase().trim();
253
+ if (!isValidEmail(normalizedEmail)) {
254
+ return { error: "Invalid email format", status: 400 };
255
+ }
256
+ if (password.length < 12) {
257
+ return { error: "Password must be at least 12 characters", status: 400 };
258
+ }
259
+ // Require at least 3 of: uppercase, lowercase, digit, special character
260
+ const typesPresent = [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
261
+ if (typesPresent < 3) {
262
+ return { error: "Password must contain at least 3 of: uppercase, lowercase, digits, special characters", status: 400 };
263
+ }
264
+
265
+ // Check if email already exists
266
+ const existing = await this.pg.query("SELECT id FROM public.users WHERE LOWER(email) = $1", [normalizedEmail]);
267
+ if (existing.rows.length > 0) {
268
+ return { error: "Email already registered", status: 409 };
269
+ }
270
+
271
+ const id = randomBytes(16).toString("hex");
272
+ const passwordHash = hashPassword(password);
273
+ const projects = project_id ? [project_id] : [];
274
+
275
+ await this.pg.query(
276
+ "INSERT INTO public.users (id, email, password_hash, projects) VALUES ($1, $2, $3, $4)",
277
+ [id, normalizedEmail, passwordHash, projects],
278
+ );
279
+
280
+ const token = createToken({ id, email: normalizedEmail, role: "user", projects }, project_id);
281
+ return { token, userId: id, expiresIn: TOKEN_EXPIRY };
282
+ }
283
+
284
+ /**
285
+ * Validate a JWT token. Called by other services to verify auth.
286
+ */
287
+ async validateToken(body) {
288
+ const { token } = body ?? {};
289
+ if (!token) return { valid: false, error: "No token provided" };
290
+
291
+ const payload = decodeToken(token);
292
+ if (!payload) return { valid: false, error: "Invalid token" };
293
+
294
+ const now = Math.floor(Date.now() / 1000);
295
+ if (payload.exp && payload.exp < now) {
296
+ return { valid: false, error: "Token expired" };
297
+ }
298
+
299
+ return { valid: true, payload };
300
+ }
301
+
302
+ /**
303
+ * Refresh an expired token using a refresh token.
304
+ */
305
+ async refreshToken(body) {
306
+ const { refreshToken } = body ?? {};
307
+ if (!refreshToken) return { error: "Refresh token required", status: 400 };
308
+
309
+ if (!this.redis) return { error: "Session storage unavailable", status: 503 };
310
+
311
+ const hashedToken = hashRefreshToken(refreshToken);
312
+ const data = await this.redis.get(`refresh:${hashedToken}`);
313
+ if (!data) return { error: "Invalid refresh token", status: 401 };
314
+
315
+ let parsed;
316
+ try {
317
+ parsed = JSON.parse(Buffer.isBuffer(data) ? data.toString() : data);
318
+ } catch {
319
+ return { error: "Invalid refresh token data", status: 401 };
320
+ }
321
+
322
+ const result = await this.pg.query("SELECT * FROM public.users WHERE id = $1", [parsed.userId]);
323
+ const user = result.rows[0];
324
+ if (!user) return { error: "User not found", status: 401 };
325
+
326
+ // Delete old refresh token
327
+ await this.redis.del(`refresh:${hashedToken}`);
328
+
329
+ // Issue new tokens
330
+ const token = createToken(user, parsed.projectId);
331
+ const newRefreshToken = randomBytes(32).toString("hex");
332
+
333
+ await this.redis.set(
334
+ `refresh:${hashRefreshToken(newRefreshToken)}`,
335
+ JSON.stringify({ userId: user.id, projectId: parsed.projectId }),
336
+ "EX",
337
+ REFRESH_EXPIRY,
338
+ );
339
+
340
+ return { token, refreshToken: newRefreshToken, expiresIn: TOKEN_EXPIRY };
341
+ }
342
+
343
+ /**
344
+ * SSO redirect — initiates cross-project authentication.
345
+ * Redirect user to auth.{defaultDomain}/sso?redirect={origin}&project={id}
346
+ */
347
+ async ssoRedirect(body, params, req) {
348
+ const redirect = req?.query?.redirect;
349
+ const projectId = req?.query?.project;
350
+
351
+ if (!redirect) {
352
+ return { error: "redirect parameter required", status: 400 };
353
+ }
354
+ if (!validateRedirect(redirect)) {
355
+ return { error: "Invalid redirect URL", status: 400 };
356
+ }
357
+
358
+ // Check existing auth BEFORE creating SSO state (avoids memory waste + replay risk)
359
+ const authHeader = req?.headers?.authorization;
360
+ if (authHeader?.startsWith("Bearer ")) {
361
+ const payload = decodeToken(authHeader.slice(7));
362
+ const now = Math.floor(Date.now() / 1000);
363
+ if (payload && (!payload.exp || payload.exp > now)) {
364
+ const token = createToken(
365
+ { id: payload.sub, email: payload.email, role: payload.role, projects: payload.projects },
366
+ projectId,
367
+ );
368
+ const sep = redirect.includes("?") ? "&" : "?";
369
+ return { redirect: `${redirect}${sep}token=${token}` };
370
+ }
371
+ }
372
+
373
+ // Only create SSO state when authentication is actually required
374
+ const state = await this._createSsoState();
375
+ const sep = redirect.includes("?") ? "&" : "?";
376
+ return { redirect: `${redirect}${sep}auth_required=true&project=${projectId ?? ""}&state=${state}` };
377
+ }
378
+
379
+ /**
380
+ * SSO callback — validates token from SSO flow.
381
+ */
382
+ async ssoCallback(body, params, req) {
383
+ const token = req?.query?.token;
384
+ const state = req?.query?.state;
385
+ if (!state || !(await this._consumeSsoState(state))) {
386
+ return { error: "Invalid or missing state parameter", status: 400 };
387
+ }
388
+ if (!token) return { error: "Token required", status: 400 };
389
+
390
+ const result = await this.validateToken({ token });
391
+ return result;
392
+ }
393
+ }
394
+
395
+ // scrypt-based password hashing (replaces HMAC-SHA256)
396
+ function hashPassword(password) {
397
+ const salt = randomBytes(16);
398
+ const hash = scryptSync(password, salt, 64, { N: 65536, r: 8, p: 1, maxmem: 256 * 1024 * 1024 });
399
+ return salt.toString("hex") + ":" + hash.toString("hex");
400
+ }
401
+
402
+ function verifyPassword(password, stored) {
403
+ const parts = stored.split(":");
404
+ if (parts.length !== 2) return false;
405
+ const salt = Buffer.from(parts[0], "hex");
406
+ const storedHash = Buffer.from(parts[1], "hex");
407
+ const derivedHash = scryptSync(password, salt, 64, { N: 65536, r: 8, p: 1, maxmem: 256 * 1024 * 1024 });
408
+ if (storedHash.length !== derivedHash.length) return false;
409
+ return timingSafeEqual(storedHash, derivedHash);
410
+ }
411
+
412
+ // Redirect URL validation for SSO
413
+ function validateRedirect(redirect) {
414
+ const allowedHosts = process.env.FORGE_ALLOWED_REDIRECT_HOSTS;
415
+ if (!allowedHosts) {
416
+ return redirect.startsWith("/") && !redirect.startsWith("//");
417
+ }
418
+ try {
419
+ const url = new URL(redirect);
420
+ if (!['http:', 'https:'].includes(url.protocol)) return false;
421
+ const allowed = allowedHosts.split(",").map((h) => h.trim()).filter(Boolean);
422
+ return allowed.includes(url.hostname);
423
+ } catch {
424
+ return redirect.startsWith("/") && !redirect.startsWith("//");
425
+ }
426
+ }
427
+
428
+ function createToken(user, projectId) {
429
+ const now = Math.floor(Date.now() / 1000);
430
+ const payload = {
431
+ sub: user.id,
432
+ email: user.email,
433
+ project_id: projectId ?? null,
434
+ projects: user.projects ?? [],
435
+ role: user.role ?? "user",
436
+ iat: now,
437
+ exp: now + TOKEN_EXPIRY,
438
+ };
439
+
440
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
441
+ const claims = Buffer.from(JSON.stringify(payload)).toString("base64url");
442
+ const signature = createHmac("sha256", getJwtSecret()).update(`${header}.${claims}`).digest("base64url");
443
+
444
+ return `${header}.${claims}.${signature}`;
445
+ }
446
+
447
+ function decodeToken(token) {
448
+ try {
449
+ const [header, claims, signature] = token.split(".");
450
+ if (!header || !claims || !signature) return null;
451
+
452
+ // Validate algorithm — reject alg:none and algorithm confusion attacks
453
+ const headerObj = JSON.parse(Buffer.from(header, "base64url").toString());
454
+ if (!headerObj.alg) return null;
455
+ if (headerObj.alg !== "HS256") return null;
456
+ if (headerObj.typ && headerObj.typ !== "JWT") return null;
457
+
458
+ const expected = createHmac("sha256", getJwtSecret())
459
+ .update(`${header}.${claims}`)
460
+ .digest("base64url");
461
+
462
+ // Timing-safe comparison to prevent side-channel attacks
463
+ const sigBuf = Buffer.from(signature);
464
+ const expBuf = Buffer.from(expected);
465
+ if (sigBuf.length !== expBuf.length) return null;
466
+ if (!timingSafeEqual(sigBuf, expBuf)) return null;
467
+
468
+ return JSON.parse(Buffer.from(claims, "base64url").toString());
469
+ } catch {
470
+ return null;
471
+ }
472
+ }
473
+
474
+ // Exported for testing
475
+ export { hashPassword, verifyPassword, isValidEmail, validateRedirect, createSsoState, consumeSsoState, checkLockout, recordFailedAttempt, resetAttempts };