@naviyra/cloud-panel 0.0.0-stage → 0.0.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.
Files changed (55) hide show
  1. package/.env.example +40 -0
  2. package/README.md +134 -2
  3. package/apps/agent/package.json +13 -0
  4. package/apps/agent/src/cloudflare.mjs +733 -0
  5. package/apps/agent/src/native-worker.mjs +52 -0
  6. package/apps/agent/src/native.mjs +457 -0
  7. package/apps/agent/src/runtime.mjs +760 -0
  8. package/apps/agent/src/server.mjs +195 -0
  9. package/apps/agent/src/static-server.mjs +53 -0
  10. package/apps/panel/AGENTS.md +9 -0
  11. package/apps/panel/CLAUDE.md +1 -0
  12. package/apps/panel/app/api/[...path]/route.js +445 -0
  13. package/apps/panel/app/globals.css +2057 -0
  14. package/apps/panel/app/icon.png +0 -0
  15. package/apps/panel/app/layout.jsx +18 -0
  16. package/apps/panel/app/login/page.jsx +108 -0
  17. package/apps/panel/app/page.jsx +33 -0
  18. package/apps/panel/app/ui/cloudflare.jsx +926 -0
  19. package/apps/panel/app/ui/dashboard.jsx +1487 -0
  20. package/apps/panel/app/ui/databases.jsx +293 -0
  21. package/apps/panel/app/ui/skeleton.jsx +196 -0
  22. package/apps/panel/app/ui/theme.jsx +35 -0
  23. package/apps/panel/lib/agent.mjs +83 -0
  24. package/apps/panel/lib/auth.mjs +48 -0
  25. package/apps/panel/lib/database-runtime.mjs +243 -0
  26. package/apps/panel/lib/databases.mjs +292 -0
  27. package/apps/panel/lib/db.mjs +6 -0
  28. package/apps/panel/next.config.mjs +8 -0
  29. package/apps/panel/package.json +22 -0
  30. package/apps/panel/public/naviyra-logo.png +0 -0
  31. package/bin/naviyra.mjs +177 -0
  32. package/db/001_init.sql +28 -0
  33. package/db/002_native_runtimes.sql +3 -0
  34. package/db/003_databases.sql +8 -0
  35. package/db/004_database_engines.sql +4 -0
  36. package/deploy/naviyra-agent.service +17 -0
  37. package/docker-compose.yml +18 -0
  38. package/docs/managed-runtime.md +52 -0
  39. package/docs/native-hosts.md +48 -0
  40. package/package.json +61 -4
  41. package/packages/runtime/artifacts.json +32 -0
  42. package/packages/runtime/index.cjs +526 -0
  43. package/packages/runtime/index.mjs +10 -0
  44. package/packages/runtime/package.json +6 -0
  45. package/packages/shared/index.mjs +85 -0
  46. package/packages/shared/package.json +9 -0
  47. package/scripts/migrate.mjs +18 -0
  48. package/scripts/postgres-local.mjs +97 -0
  49. package/scripts/runtime-download-manifest.mjs +30 -0
  50. package/scripts/runtime.mjs +20 -0
  51. package/scripts/seed.mjs +35 -0
  52. package/scripts/service-supervisor.mjs +164 -0
  53. package/scripts/setup-local.mjs +215 -0
  54. package/scripts/start-naviyra.mjs +303 -0
  55. package/scripts/windows-boot.mjs +259 -0
@@ -0,0 +1,445 @@
1
+ import bcrypt from "bcryptjs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { cookies } from "next/headers";
4
+ import { db } from "../../../lib/db.mjs";
5
+ import { session, signIn } from "../../../lib/auth.mjs";
6
+ import { agent, reconcile } from "../../../lib/agent.mjs";
7
+ import { projectInput, idSchema, MAX_ZIP_BYTES } from "@naviyra/shared";
8
+ import {
9
+ listDatabases,
10
+ createDatabase,
11
+ deleteDatabase,
12
+ resetDatabasePassword,
13
+ } from "../../../lib/databases.mjs";
14
+ export const runtime = "nodejs";
15
+ export const dynamic = "force-dynamic";
16
+ const attempts = (globalThis.naviyraAttempts ||= new Map());
17
+ const json = (data, status = 200) =>
18
+ Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
19
+ async function readJson(req) {
20
+ if (Number(req.headers.get("content-length")) > 8192)
21
+ throw Object.assign(Error("Request too large"), { status: 413 });
22
+ const text = await req.text();
23
+ if (text.length > 8192)
24
+ throw Object.assign(Error("Request too large"), { status: 413 });
25
+ return JSON.parse(text);
26
+ }
27
+ async function handle(req, { params }) {
28
+ try {
29
+ const parts = (await params).path;
30
+ const route = parts.join("/");
31
+ const method = req.method;
32
+ if (method !== "GET") {
33
+ const expected = process.env.PUBLIC_ORIGIN || new URL(req.url).origin;
34
+ if (req.headers.get("origin") !== expected)
35
+ return json({ error: "Invalid request origin" }, 403);
36
+ }
37
+ if (route === "auth/login" && method === "POST") {
38
+ const body = await readJson(req);
39
+ const email = String(body.email || "")
40
+ .toLowerCase()
41
+ .trim();
42
+ const password = String(body.password || "");
43
+ if (email.length > 254 || password.length > 256)
44
+ return json({ error: "Invalid email or password" }, 400);
45
+ const now = Date.now();
46
+ for (const [key, value] of attempts)
47
+ if (value.until < now) attempts.delete(key);
48
+ if (attempts.size > 10000)
49
+ return json({ error: "Login temporarily unavailable" }, 429);
50
+ const entry = attempts.get(email) || {
51
+ count: 0,
52
+ until: now + 15 * 60 * 1000,
53
+ };
54
+ if (entry.count >= 10)
55
+ return json(
56
+ { error: "Too many attempts. Try again in 15 minutes." },
57
+ 429,
58
+ );
59
+ entry.count++;
60
+ attempts.set(email, entry);
61
+ const user = (
62
+ await db.query("SELECT * FROM users WHERE email=$1", [email])
63
+ ).rows[0];
64
+ const valid = await bcrypt.compare(
65
+ password,
66
+ user?.password_hash ||
67
+ "$2b$12$C6UzMDM.H6dfI/f/IKcEe.5S1cNcq73EqEaGcAfRm4BSNTPTzBrme",
68
+ );
69
+ if (!user || !valid)
70
+ return json({ error: "Invalid email or password" }, 401);
71
+ await signIn(user);
72
+ attempts.delete(email);
73
+ return json({ ok: true });
74
+ }
75
+ if (route === "auth/logout" && method === "POST") {
76
+ (await cookies()).delete("naviyra_session");
77
+ return json({ ok: true });
78
+ }
79
+ const user = await session();
80
+ if (!user) return json({ error: "Please sign in" }, 401);
81
+ if (route === "me") return json(user);
82
+ if (method !== "GET" && user.role !== "admin")
83
+ return json({ error: "Administrator access required" }, 403);
84
+ if (route === "system" && method === "GET") {
85
+ try {
86
+ return json(await agent("/system/metrics"));
87
+ } catch (error) {
88
+ return json({ available: false, error: error.message }, 503);
89
+ }
90
+ }
91
+ if (route === "databases" && method === "GET")
92
+ return json(await listDatabases(user.id));
93
+ if (route === "databases" && method === "POST") {
94
+ const body = await readJson(req);
95
+ const created = await createDatabase(
96
+ user.id,
97
+ body.name,
98
+ body.engine,
99
+ );
100
+ await db.query(
101
+ "INSERT INTO audit_logs(user_id,action) VALUES($1,$2)",
102
+ [user.id, "database.create"],
103
+ );
104
+ return json(created, 201);
105
+ }
106
+ const databaseRoute = route.match(
107
+ /^databases\/([0-9a-f-]{36})(\/password)?$/i,
108
+ );
109
+ if (databaseRoute) {
110
+ const id = databaseRoute[1];
111
+ if (
112
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
113
+ id,
114
+ )
115
+ )
116
+ return json({ error: "Not found" }, 404);
117
+ if (databaseRoute[2] && method === "POST") {
118
+ const reset = await resetDatabasePassword(user.id, id);
119
+ await db.query(
120
+ "INSERT INTO audit_logs(user_id,action) VALUES($1,$2)",
121
+ [user.id, "database.password"],
122
+ );
123
+ return json(reset);
124
+ }
125
+ if (!databaseRoute[2] && method === "DELETE") {
126
+ await deleteDatabase(user.id, id);
127
+ await db.query(
128
+ "INSERT INTO audit_logs(user_id,action) VALUES($1,$2)",
129
+ [user.id, "database.delete"],
130
+ );
131
+ return json({ ok: true });
132
+ }
133
+ return json({ error: "Method not allowed" }, 405);
134
+ }
135
+ if (route === "projects" && method === "GET") {
136
+ const rows = (
137
+ await db.query(
138
+ "SELECT * FROM projects WHERE user_id=$1 ORDER BY created_at DESC",
139
+ [user.id],
140
+ )
141
+ ).rows;
142
+ const projects = await Promise.all(
143
+ rows.map(async (p) => {
144
+ try {
145
+ return await reconcile(p);
146
+ } catch (error) {
147
+ return { ...p, agent_error: error.message };
148
+ }
149
+ }),
150
+ );
151
+ return json(projects);
152
+ }
153
+ if (route === "projects" && method === "POST") {
154
+ const input = projectInput.parse(await readJson(req));
155
+ const id = randomUUID();
156
+ const host = (
157
+ await db.query("SELECT id FROM agents WHERE name='primary'")
158
+ ).rows[0];
159
+ if (!host)
160
+ throw Error("Run npm run db:seed to register the primary agent");
161
+ const result = await db.query(
162
+ "INSERT INTO projects(id,user_id,agent_id,name,subdomain,source_type,git_url,type,runtime_config) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *",
163
+ [
164
+ id,
165
+ user.id,
166
+ host.id,
167
+ input.name,
168
+ input.subdomain,
169
+ input.source_type,
170
+ input.git_url,
171
+ input.type,
172
+ JSON.stringify(input.runtime_config),
173
+ ],
174
+ );
175
+ try {
176
+ await agent("/projects", {
177
+ method: "POST",
178
+ body: JSON.stringify({ id, ...input }),
179
+ });
180
+ } catch (error) {
181
+ await db.query("UPDATE projects SET status='failed' WHERE id=$1", [id]);
182
+ return json(
183
+ { ...result.rows[0], status: "failed", agent_error: error.message },
184
+ 201,
185
+ );
186
+ }
187
+ await db.query(
188
+ "INSERT INTO audit_logs(user_id,project_id,action) VALUES($1,$2,$3)",
189
+ [user.id, id, "project.create"],
190
+ );
191
+ return json(result.rows[0], 201);
192
+ }
193
+ if (parts[0] === "cloudflare") {
194
+ const cfId = /^[a-f0-9]{32}$/i;
195
+ const audit = async (action) => {
196
+ await db.query(
197
+ "INSERT INTO audit_logs(user_id,action) VALUES($1,$2)",
198
+ [user.id, action],
199
+ );
200
+ };
201
+ if (route === "cloudflare/status" && method === "GET")
202
+ return json(await agent("/cloudflare/status"));
203
+ if (route === "cloudflare/login" && method === "GET")
204
+ return json(await agent("/cloudflare/login"));
205
+ if (route === "cloudflare/login" && method === "POST")
206
+ return json(await agent("/cloudflare/login", { method: "POST" }), 202);
207
+ if (route === "cloudflare/api-login" && method === "GET")
208
+ return json(await agent("/cloudflare/api-login"));
209
+ if (route === "cloudflare/api-login" && method === "POST")
210
+ return json(
211
+ await agent("/cloudflare/api-login", { method: "POST" }),
212
+ 202,
213
+ );
214
+ if (route === "cloudflare/dns" && method === "GET")
215
+ return json(await agent("/cloudflare/dns"));
216
+ if (
217
+ parts.length === 3 &&
218
+ parts[1] === "dns" &&
219
+ method === "GET" &&
220
+ cfId.test(parts[2])
221
+ )
222
+ return json(await agent(`/cloudflare/dns/${parts[2]}`));
223
+ if (
224
+ parts.length === 3 &&
225
+ parts[1] === "dns" &&
226
+ method === "PUT" &&
227
+ cfId.test(parts[2])
228
+ ) {
229
+ const result = await agent(`/cloudflare/dns/${parts[2]}`, {
230
+ method: "PUT",
231
+ body: JSON.stringify(await readJson(req)),
232
+ });
233
+ await audit("dns.record.update");
234
+ return json(result);
235
+ }
236
+ if (route === "cloudflare/email" && method === "GET")
237
+ return json(await agent("/cloudflare/email"));
238
+ if (route === "cloudflare/email/routing" && method === "POST") {
239
+ const result = await agent("/cloudflare/email/routing", {
240
+ method: "POST",
241
+ body: JSON.stringify(await readJson(req)),
242
+ });
243
+ await audit(result.enabled ? "email.enable" : "email.disable");
244
+ return json(result);
245
+ }
246
+ if (route === "cloudflare/email/rules" && method === "POST") {
247
+ const result = await agent("/cloudflare/email/rules", {
248
+ method: "POST",
249
+ body: JSON.stringify(await readJson(req)),
250
+ });
251
+ await audit("email.rule.create");
252
+ return json(result, 201);
253
+ }
254
+ if (
255
+ parts.length === 4 &&
256
+ parts[1] === "email" &&
257
+ parts[2] === "rules" &&
258
+ cfId.test(parts[3]) &&
259
+ method === "PUT"
260
+ ) {
261
+ const result = await agent(`/cloudflare/email/rules/${parts[3]}`, {
262
+ method: "PUT",
263
+ body: JSON.stringify(await readJson(req)),
264
+ });
265
+ await audit("email.rule.update");
266
+ return json(result);
267
+ }
268
+ if (
269
+ parts.length === 4 &&
270
+ parts[1] === "email" &&
271
+ parts[2] === "rules" &&
272
+ cfId.test(parts[3]) &&
273
+ method === "DELETE"
274
+ ) {
275
+ const result = await agent(
276
+ `/cloudflare/email/rules/${parts[3]}?domain=${encodeURIComponent(new URL(req.url).searchParams.get("domain") || "")}`,
277
+ { method: "DELETE" },
278
+ );
279
+ await audit("email.rule.delete");
280
+ return json(result);
281
+ }
282
+ if (route === "cloudflare/email/addresses" && method === "POST") {
283
+ const result = await agent("/cloudflare/email/addresses", {
284
+ method: "POST",
285
+ body: JSON.stringify(await readJson(req)),
286
+ });
287
+ await audit("email.address.create");
288
+ return json(result, 201);
289
+ }
290
+ if (
291
+ parts.length === 4 &&
292
+ parts[1] === "email" &&
293
+ parts[2] === "addresses" &&
294
+ cfId.test(parts[3]) &&
295
+ method === "DELETE"
296
+ ) {
297
+ const result = await agent(`/cloudflare/email/addresses/${parts[3]}`, {
298
+ method: "DELETE",
299
+ });
300
+ await audit("email.address.delete");
301
+ return json(result);
302
+ }
303
+ return json({ error: "Not found" }, 404);
304
+ }
305
+ if (parts[0] === "projects" && parts[1]) {
306
+ const id = idSchema.parse(parts[1]);
307
+ const action = parts[2];
308
+ const project = (
309
+ await db.query("SELECT * FROM projects WHERE id=$1 AND user_id=$2", [
310
+ id,
311
+ user.id,
312
+ ])
313
+ ).rows[0];
314
+ if (!project) return json({ error: "Project not found" }, 404);
315
+ if (method === "GET" && !action) {
316
+ let current = project;
317
+ try {
318
+ current = await reconcile(project);
319
+ } catch (error) {
320
+ current = { ...project, agent_error: error.message };
321
+ }
322
+ const deployments = (
323
+ await db.query(
324
+ "SELECT * FROM deployments WHERE project_id=$1 ORDER BY created_at DESC LIMIT 20",
325
+ [id],
326
+ )
327
+ ).rows;
328
+ return json({ ...current, deployments });
329
+ }
330
+ if (method === "GET" && ["logs", "metrics"].includes(action))
331
+ return json(await agent(`/projects/${id}/${action}`));
332
+ if (method === "PUT" && action === "source") {
333
+ if (project.source_type !== "zip")
334
+ return json({ error: "This project uses Git" }, 400);
335
+ const chunks = [];
336
+ let size = 0;
337
+ for await (const chunk of req.body) {
338
+ size += chunk.byteLength;
339
+ if (size > MAX_ZIP_BYTES)
340
+ return json({ error: "ZIP limit is 25 MB" }, 413);
341
+ chunks.push(Buffer.from(chunk));
342
+ }
343
+ await agent("/projects", {
344
+ method: "POST",
345
+ body: JSON.stringify(project),
346
+ });
347
+ return json(
348
+ await agent(`/projects/${id}/source`, {
349
+ method: "PUT",
350
+ headers: { "Content-Type": "application/zip" },
351
+ body: Buffer.concat(chunks),
352
+ }),
353
+ );
354
+ }
355
+ if (method === "POST" && action === "deploy") {
356
+ // Idempotent registration also repairs a prior panel-to-agent network failure.
357
+ await agent("/projects", {
358
+ method: "POST",
359
+ body: JSON.stringify(project),
360
+ });
361
+ await reconcile(project);
362
+ const deploymentId = randomUUID();
363
+ await db.query(
364
+ "INSERT INTO deployments(id,project_id,status,source_type) VALUES($1,$2,'queued',$3)",
365
+ [deploymentId, id, project.source_type],
366
+ );
367
+ try {
368
+ const remote = await agent(`/projects/${id}/deploy`, {
369
+ method: "POST",
370
+ body: JSON.stringify({ deployment_id: deploymentId }),
371
+ });
372
+ await db.query("UPDATE projects SET status=$2 WHERE id=$1", [
373
+ id,
374
+ remote.status,
375
+ ]);
376
+ await db.query(
377
+ "INSERT INTO audit_logs(user_id,project_id,action) VALUES($1,$2,$3)",
378
+ [user.id, id, "project.deploy"],
379
+ );
380
+ return json({ id: deploymentId, status: remote.status }, 202);
381
+ } catch (error) {
382
+ // A timed-out POST may have been accepted. Reconcile before declaring failure.
383
+ let accepted = false;
384
+ try {
385
+ const remote = await agent(`/projects/${id}`);
386
+ accepted = remote.deployment_id === deploymentId;
387
+ } catch {}
388
+ if (!accepted)
389
+ await db.query(
390
+ "UPDATE deployments SET status='failed',error=$2,finished_at=now() WHERE id=$1",
391
+ [deploymentId, error.message],
392
+ );
393
+ throw error;
394
+ }
395
+ }
396
+ if (method === "POST" && action === "restart") {
397
+ const result = await agent(`/projects/${id}/restart`, {
398
+ method: "POST",
399
+ });
400
+ await db.query(
401
+ "INSERT INTO audit_logs(user_id,project_id,action) VALUES($1,$2,$3)",
402
+ [user.id, id, "project.restart"],
403
+ );
404
+ return json(result);
405
+ }
406
+ if (method === "DELETE" && !action) {
407
+ try {
408
+ await agent(`/projects/${id}`, { method: "DELETE" });
409
+ } catch (error) {
410
+ if (error.status !== 404) throw error;
411
+ }
412
+ await db.query("DELETE FROM projects WHERE id=$1", [id]);
413
+ await db.query(
414
+ "INSERT INTO audit_logs(user_id,project_id,action) VALUES($1,$2,$3)",
415
+ [user.id, id, "project.delete"],
416
+ );
417
+ return json({ ok: true });
418
+ }
419
+ }
420
+ return json({ error: "Not found" }, 404);
421
+ } catch (error) {
422
+ if (error.code === "23505")
423
+ return json(
424
+ { error: "Name, hostname, or active deployment already exists" },
425
+ 409,
426
+ );
427
+ if (error.name === "ZodError")
428
+ return json(
429
+ { error: error.issues.map((i) => i.message).join(". ") },
430
+ 400,
431
+ );
432
+ console.error("[panel API]", error.message);
433
+ return json(
434
+ {
435
+ error: error.status
436
+ ? error.message
437
+ : error instanceof SyntaxError
438
+ ? "Invalid JSON"
439
+ : "Operation failed. Check the panel and agent logs.",
440
+ },
441
+ error.status || (error instanceof SyntaxError ? 400 : 500),
442
+ );
443
+ }
444
+ }
445
+ export { handle as GET, handle as POST, handle as PUT, handle as DELETE };