palmier 0.6.0 → 0.6.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.
Files changed (110) hide show
  1. package/.github/workflows/publish.yml +15 -2
  2. package/CLAUDE.md +2 -2
  3. package/DISCLAIMER.md +36 -0
  4. package/README.md +76 -87
  5. package/dist/agents/agent-instructions.md +1 -1
  6. package/dist/agents/agent.d.ts +2 -0
  7. package/dist/agents/agent.js +21 -0
  8. package/dist/agents/aider.d.ts +9 -0
  9. package/dist/agents/aider.js +32 -0
  10. package/dist/agents/cursor.d.ts +9 -0
  11. package/dist/agents/cursor.js +35 -0
  12. package/dist/agents/deepagents.d.ts +9 -0
  13. package/dist/agents/deepagents.js +35 -0
  14. package/dist/agents/droid.d.ts +9 -0
  15. package/dist/agents/droid.js +32 -0
  16. package/dist/agents/goose.d.ts +9 -0
  17. package/dist/agents/goose.js +32 -0
  18. package/dist/agents/opencode.d.ts +9 -0
  19. package/dist/agents/opencode.js +35 -0
  20. package/dist/agents/openhands.d.ts +9 -0
  21. package/dist/agents/openhands.js +35 -0
  22. package/dist/commands/pair.d.ts +1 -1
  23. package/dist/commands/pair.js +1 -1
  24. package/dist/commands/run.js +2 -2
  25. package/dist/pwa/apple-touch-icon.png +0 -0
  26. package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
  27. package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
  28. package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
  29. package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  30. package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
  31. package/dist/pwa/favicon.ico +0 -0
  32. package/dist/pwa/index.html +17 -0
  33. package/dist/pwa/manifest.webmanifest +1 -0
  34. package/dist/pwa/pwa-192x192.png +0 -0
  35. package/dist/pwa/pwa-512x512.png +0 -0
  36. package/dist/pwa/registerSW.js +1 -0
  37. package/dist/pwa/service-worker.js +2 -0
  38. package/dist/rpc-handler.d.ts +4 -0
  39. package/dist/rpc-handler.js +5 -4
  40. package/dist/transports/http-transport.js +29 -41
  41. package/package.json +2 -2
  42. package/palmier-server/.github/workflows/ci.yml +21 -0
  43. package/palmier-server/.github/workflows/deploy.yml +38 -0
  44. package/palmier-server/CLAUDE.md +13 -0
  45. package/palmier-server/PRODUCTION.md +355 -0
  46. package/palmier-server/README.md +187 -0
  47. package/palmier-server/nats.conf +15 -0
  48. package/palmier-server/package.json +8 -0
  49. package/palmier-server/pnpm-lock.yaml +6597 -0
  50. package/palmier-server/pnpm-workspace.yaml +3 -0
  51. package/palmier-server/pwa/index.html +16 -0
  52. package/palmier-server/pwa/logo/logo-prompt.md +28 -0
  53. package/palmier-server/pwa/logo/logo_20260330.png +0 -0
  54. package/palmier-server/pwa/package.json +30 -0
  55. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  56. package/palmier-server/pwa/public/favicon.ico +0 -0
  57. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  58. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  59. package/palmier-server/pwa/src/App.css +2387 -0
  60. package/palmier-server/pwa/src/App.tsx +21 -0
  61. package/palmier-server/pwa/src/agentLabels.ts +11 -0
  62. package/palmier-server/pwa/src/api.ts +61 -0
  63. package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
  64. package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
  65. package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
  66. package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
  67. package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
  68. package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
  69. package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
  70. package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
  71. package/palmier-server/pwa/src/constants.ts +2 -0
  72. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
  73. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
  74. package/palmier-server/pwa/src/formatTime.ts +10 -0
  75. package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
  76. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
  77. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
  78. package/palmier-server/pwa/src/main.tsx +14 -0
  79. package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
  80. package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
  81. package/palmier-server/pwa/src/service-worker.ts +139 -0
  82. package/palmier-server/pwa/src/types.ts +79 -0
  83. package/palmier-server/pwa/src/vite-env.d.ts +11 -0
  84. package/palmier-server/pwa/tsconfig.json +21 -0
  85. package/palmier-server/pwa/tsconfig.node.json +19 -0
  86. package/palmier-server/pwa/vite.config.ts +47 -0
  87. package/palmier-server/server/.env.example +16 -0
  88. package/palmier-server/server/package.json +33 -0
  89. package/palmier-server/server/src/db.ts +34 -0
  90. package/palmier-server/server/src/index.ts +219 -0
  91. package/palmier-server/server/src/nats.ts +25 -0
  92. package/palmier-server/server/src/push.ts +68 -0
  93. package/palmier-server/server/src/routes/hosts.ts +45 -0
  94. package/palmier-server/server/src/routes/push.ts +100 -0
  95. package/palmier-server/server/tsconfig.json +20 -0
  96. package/palmier-server/spec.md +415 -0
  97. package/src/agents/agent-instructions.md +1 -1
  98. package/src/agents/agent.ts +23 -0
  99. package/src/agents/aider.ts +37 -0
  100. package/src/agents/cursor.ts +38 -0
  101. package/src/agents/deepagents.ts +38 -0
  102. package/src/agents/droid.ts +37 -0
  103. package/src/agents/goose.ts +35 -0
  104. package/src/agents/opencode.ts +38 -0
  105. package/src/agents/openhands.ts +38 -0
  106. package/src/commands/pair.ts +1 -1
  107. package/src/commands/run.ts +2 -2
  108. package/src/rpc-handler.ts +5 -4
  109. package/src/transports/http-transport.ts +31 -43
  110. package/test/result-state.test.ts +110 -0
@@ -0,0 +1,47 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import { VitePWA } from "vite-plugin-pwa";
4
+
5
+ export default defineConfig({
6
+ server: {
7
+ host: true,
8
+ proxy: {
9
+ "/api": process.env.API_URL || "http://localhost:3000"
10
+ },
11
+ },
12
+ plugins: [
13
+ react(),
14
+ VitePWA({
15
+ strategies: "injectManifest",
16
+ srcDir: "src",
17
+ filename: "service-worker.ts",
18
+ registerType: "autoUpdate",
19
+ devOptions: {
20
+ enabled: true,
21
+ type: "module",
22
+ },
23
+ includeAssets: ["favicon.ico", "apple-touch-icon.png"],
24
+ manifest: {
25
+ name: "Palmier",
26
+ short_name: "Palmier",
27
+ description: "Control AI agents running on your machine from any device. Schedule tasks, monitor runs, and stay in control.",
28
+ start_url: "/",
29
+ display: "standalone",
30
+ background_color: "#ffffff",
31
+ theme_color: "#2E5CE5",
32
+ icons: [
33
+ {
34
+ src: "pwa-192x192.png",
35
+ sizes: "192x192",
36
+ type: "image/png",
37
+ },
38
+ {
39
+ src: "pwa-512x512.png",
40
+ sizes: "512x512",
41
+ type: "image/png",
42
+ },
43
+ ],
44
+ },
45
+ }),
46
+ ],
47
+ });
@@ -0,0 +1,16 @@
1
+ PORT=3000
2
+
3
+ # PostgreSQL
4
+ DATABASE_URL=postgresql://user:password@localhost:5432/palmier
5
+
6
+ # NATS
7
+ NATS_URL=nats://localhost:4222
8
+ NATS_HOST_URL=nats://192.168.1.100:4222
9
+ # Production: wss://nats.palmier.me LAN dev: ws://192.168.1.100:9222
10
+ NATS_WS_URL=ws://192.168.1.100:9222
11
+ NATS_TOKEN=
12
+
13
+ # Web Push (generate with: npx web-push generate-vapid-keys)
14
+ VAPID_PUBLIC_KEY=
15
+ VAPID_PRIVATE_KEY=
16
+ VAPID_MAILTO=mailto:admin@example.com
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "palmier-server",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "tsx watch src/index.ts",
7
+ "build": "tsc",
8
+ "start": "node dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "bcrypt": "^5.1.1",
12
+ "cors": "^2.8.5",
13
+ "dotenv": "^16.4.7",
14
+ "express": "^4.21.2",
15
+ "helmet": "^8.0.0",
16
+ "jsonwebtoken": "^9.0.2",
17
+ "nats": "^2.29.1",
18
+ "pg": "^8.13.1",
19
+ "uuid": "^11.0.5",
20
+ "web-push": "^3.6.7"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bcrypt": "^5.0.2",
24
+ "@types/cors": "^2.8.17",
25
+ "@types/express": "^5.0.0",
26
+ "@types/jsonwebtoken": "^9.0.7",
27
+ "@types/pg": "^8.11.10",
28
+ "@types/uuid": "^10.0.0",
29
+ "@types/web-push": "^3.6.4",
30
+ "tsx": "^4.19.2",
31
+ "typescript": "^5.7.3"
32
+ }
33
+ }
@@ -0,0 +1,34 @@
1
+ import pg from "pg";
2
+
3
+ const { Pool } = pg;
4
+
5
+ export const pool = new Pool({
6
+ connectionString: process.env.DATABASE_URL,
7
+ });
8
+
9
+ export async function initDb(): Promise<void> {
10
+ const client = await pool.connect();
11
+ try {
12
+ await client.query(`
13
+ CREATE TABLE IF NOT EXISTS hosts (
14
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15
+ name VARCHAR(255),
16
+ created_at TIMESTAMPTZ DEFAULT NOW()
17
+ );
18
+ `);
19
+ await client.query(`
20
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
21
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
22
+ host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
23
+ endpoint TEXT NOT NULL,
24
+ p256dh TEXT NOT NULL,
25
+ auth TEXT NOT NULL,
26
+ created_at TIMESTAMPTZ DEFAULT NOW(),
27
+ UNIQUE(host_id, endpoint)
28
+ );
29
+ `);
30
+ console.log("Database tables initialized.");
31
+ } finally {
32
+ client.release();
33
+ }
34
+ }
@@ -0,0 +1,219 @@
1
+ import "dotenv/config";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import express from "express";
5
+ import helmet from "helmet";
6
+ import { pool, initDb } from "./db.js";
7
+ import { connectNats, getNatsConnection } from "./nats.js";
8
+ import { sendPushToHost } from "./push.js";
9
+
10
+ import { StringCodec } from "nats";
11
+
12
+ import hostsRoutes from "./routes/hosts.js";
13
+ import pushRoutes from "./routes/push.js";
14
+
15
+ const PORT = parseInt(process.env.PORT || "3000", 10);
16
+
17
+ async function main(): Promise<void> {
18
+ // Initialize database tables
19
+ await initDb();
20
+
21
+ // Connect to NATS
22
+ await connectNats();
23
+ const sc = StringCodec();
24
+
25
+ // Subscribe to unified host-event pub/sub for push notifications
26
+ (async () => {
27
+ try {
28
+ const conn = await getNatsConnection();
29
+ const sub = conn.subscribe("host-event.>");
30
+ console.log("Listening for host-event notifications");
31
+
32
+ for await (const msg of sub) {
33
+ try {
34
+ // Subject: host-event.<host_id>.<task_id>
35
+ const tokens = msg.subject.split(".");
36
+ if (tokens.length < 3) continue;
37
+ const hostId = tokens[1];
38
+ const taskId = tokens.slice(2).join(".");
39
+
40
+ const data = JSON.parse(sc.decode(msg.data)) as {
41
+ event_type: string;
42
+ running_state?: string;
43
+ name?: string;
44
+ run_id?: string;
45
+ required_permissions?: Array<{ name: string; description: string }>;
46
+ input_descriptions?: string[];
47
+ result_file?: string;
48
+ };
49
+
50
+ if (data.event_type === "confirm-request") {
51
+ await sendPushToHost(hostId, {
52
+ type: "confirm",
53
+ task_id: taskId,
54
+ host_id: hostId,
55
+ });
56
+ } else if (data.event_type === "confirm-resolved") {
57
+ await sendPushToHost(hostId, {
58
+ type: "confirm-dismiss",
59
+ task_id: taskId,
60
+ host_id: hostId,
61
+ });
62
+ } else if (data.event_type === "permission-request") {
63
+ const taskLabel = data.name
64
+ ? data.name.length > 60 ? data.name.slice(0, 60) + "…" : data.name
65
+ : "A task";
66
+ await sendPushToHost(hostId, {
67
+ type: "permission",
68
+ title: "Permission Required",
69
+ body: `${taskLabel} needs additional permissions to continue.`,
70
+ task_id: taskId,
71
+ host_id: hostId,
72
+ });
73
+ } else if (data.event_type === "permission-resolved") {
74
+ await sendPushToHost(hostId, {
75
+ type: "permission-dismiss",
76
+ task_id: taskId,
77
+ host_id: hostId,
78
+ });
79
+ } else if (data.event_type === "input-request") {
80
+ const taskLabel = data.name
81
+ ? data.name.length > 60 ? data.name.slice(0, 60) + "…" : data.name
82
+ : "A task";
83
+ await sendPushToHost(hostId, {
84
+ type: "input",
85
+ title: "Input Required",
86
+ body: `${taskLabel} needs your input to continue.`,
87
+ task_id: taskId,
88
+ host_id: hostId,
89
+ });
90
+ } else if (data.event_type === "input-resolved") {
91
+ await sendPushToHost(hostId, {
92
+ type: "input-dismiss",
93
+ task_id: taskId,
94
+ host_id: hostId,
95
+ });
96
+ } else if (data.event_type === "report-generated" || (data.event_type === "running-state" && data.running_state === "failed")) {
97
+ const label = data.name;
98
+ const taskLabel = label
99
+ ? label.length > 60 ? label.slice(0, 60) + "…" : label
100
+ : "Task";
101
+ const isFailure = data.running_state === "failed";
102
+ const body = isFailure ? `${taskLabel} — failed` : `${taskLabel} — report ready`;
103
+ await sendPushToHost(hostId, {
104
+ type: isFailure ? "fail" : "complete",
105
+ title: "Palmier",
106
+ body,
107
+ task_id: taskId,
108
+ host_id: hostId,
109
+ run_id: data.run_id,
110
+ result_file: data.result_file,
111
+ });
112
+ }
113
+ } catch (err) {
114
+ console.error("[host-event→Push] Error:", err);
115
+ }
116
+ }
117
+ } catch (err) {
118
+ console.error("Failed to subscribe to host-event:", err);
119
+ }
120
+ })();
121
+
122
+ // Subscribe to push notification requests from hosts
123
+ (async () => {
124
+ try {
125
+ const conn = await getNatsConnection();
126
+ const sub = conn.subscribe("host.*.push.send");
127
+ console.log("Listening for push notification requests");
128
+
129
+ for await (const msg of sub) {
130
+ try {
131
+ const data = JSON.parse(sc.decode(msg.data)) as {
132
+ hostId: string;
133
+ title: string;
134
+ body: string;
135
+ task_id?: string;
136
+ };
137
+
138
+ // Validate hostId in subject matches payload
139
+ const subjectHostId = msg.subject.split(".")[1];
140
+ if (data.hostId !== subjectHostId) {
141
+ if (msg.reply) {
142
+ msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
143
+ }
144
+ continue;
145
+ }
146
+
147
+ console.log(`[Push] Sending notification for host ${data.hostId}`);
148
+ await sendPushToHost(data.hostId, {
149
+ type: "notification",
150
+ title: data.title,
151
+ body: data.body,
152
+ ...(data.task_id ? { task_id: data.task_id } : {}),
153
+ });
154
+
155
+ if (msg.reply) {
156
+ msg.respond(sc.encode(JSON.stringify({ ok: true })));
157
+ }
158
+ } catch (err) {
159
+ console.error("[Push] Error sending notification:", err);
160
+ if (msg.reply) {
161
+ msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
162
+ }
163
+ }
164
+ }
165
+ } catch (err) {
166
+ console.error("Failed to subscribe to push requests:", err);
167
+ }
168
+ })();
169
+
170
+ // Create Express app
171
+ const app = express();
172
+
173
+ app.use(
174
+ helmet({
175
+ contentSecurityPolicy: false,
176
+ })
177
+ );
178
+ app.use(express.json());
179
+
180
+ // Mount routes
181
+ app.use("/api/hosts", hostsRoutes);
182
+ app.use("/api/push", pushRoutes);
183
+
184
+ // Public NATS config endpoint (used by PWA for pairing)
185
+ app.get("/api/config", (_req, res) => {
186
+ res.json({
187
+ natsWsUrl: process.env.NATS_WS_URL || "",
188
+ natsToken: process.env.NATS_TOKEN || "",
189
+ });
190
+ });
191
+
192
+ // Health check
193
+ app.get("/health", async (_req, res) => {
194
+ try {
195
+ await pool.query("SELECT 1");
196
+ res.json({ status: "ok" });
197
+ } catch {
198
+ res.status(503).json({ status: "unhealthy" });
199
+ }
200
+ });
201
+
202
+ // Serve built PWA static files (production only)
203
+ const pwaDistPath = path.resolve(process.cwd(), "../pwa/dist");
204
+ if (fs.existsSync(pwaDistPath)) {
205
+ app.use(express.static(pwaDistPath));
206
+ app.get("*", (_req, res) => {
207
+ res.sendFile(path.join(pwaDistPath, "index.html"));
208
+ });
209
+ }
210
+
211
+ app.listen(PORT, () => {
212
+ console.log(`Palmier server listening on port ${PORT}`);
213
+ });
214
+ }
215
+
216
+ main().catch((err) => {
217
+ console.error("Failed to start server:", err);
218
+ process.exit(1);
219
+ });
@@ -0,0 +1,25 @@
1
+ import { connect, NatsConnection } from "nats";
2
+
3
+ let nc: NatsConnection | null = null;
4
+
5
+ export async function connectNats(): Promise<NatsConnection> {
6
+ if (nc) return nc;
7
+
8
+ const url = process.env.NATS_URL || "nats://localhost:4222";
9
+ const token = process.env.NATS_TOKEN;
10
+
11
+ nc = await connect({
12
+ servers: url,
13
+ ...(token ? { token } : {}),
14
+ });
15
+
16
+ console.log(`Connected to NATS at ${url}`);
17
+ return nc;
18
+ }
19
+
20
+ export async function getNatsConnection(): Promise<NatsConnection> {
21
+ if (!nc) {
22
+ return connectNats();
23
+ }
24
+ return nc;
25
+ }
@@ -0,0 +1,68 @@
1
+ import webPush from "web-push";
2
+ import { pool } from "./db.js";
3
+
4
+ let configured = false;
5
+
6
+ function ensureConfigured(): void {
7
+ if (configured) return;
8
+
9
+ const publicKey = process.env.VAPID_PUBLIC_KEY;
10
+ const privateKey = process.env.VAPID_PRIVATE_KEY;
11
+ const mailto = process.env.VAPID_MAILTO || "mailto:admin@example.com";
12
+
13
+ if (!publicKey || !privateKey) {
14
+ console.warn("VAPID keys not configured. Web Push will not work.");
15
+ return;
16
+ }
17
+
18
+ webPush.setVapidDetails(mailto, publicKey, privateKey);
19
+ configured = true;
20
+ }
21
+
22
+ export async function sendPushToHost(
23
+ hostId: string,
24
+ payload: object | string
25
+ ): Promise<void> {
26
+ ensureConfigured();
27
+ if (!configured) {
28
+ console.warn("[Push] VAPID not configured, skipping push for host", hostId);
29
+ return;
30
+ }
31
+
32
+ const result = await pool.query(
33
+ "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE host_id = $1",
34
+ [hostId]
35
+ );
36
+
37
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
38
+
39
+ const sendPromises = result.rows.map(async (row) => {
40
+ const subscription: webPush.PushSubscription = {
41
+ endpoint: row.endpoint,
42
+ keys: {
43
+ p256dh: row.p256dh,
44
+ auth: row.auth,
45
+ },
46
+ };
47
+
48
+ try {
49
+ await webPush.sendNotification(subscription, body);
50
+ } catch (err: any) {
51
+ if (err.statusCode === 410 || err.statusCode === 404) {
52
+ // Subscription expired or invalid, remove it
53
+ await pool.query(
54
+ "DELETE FROM push_subscriptions WHERE host_id = $1 AND endpoint = $2",
55
+ [hostId, row.endpoint]
56
+ );
57
+ console.log(`Removed stale push subscription for host ${hostId}`);
58
+ } else {
59
+ console.error(
60
+ `Failed to send push to ${row.endpoint}:`,
61
+ err.message
62
+ );
63
+ }
64
+ }
65
+ });
66
+
67
+ await Promise.allSettled(sendPromises);
68
+ }
@@ -0,0 +1,45 @@
1
+ import { Router, Request, Response } from "express";
2
+ import type { Router as RouterType } from "express";
3
+ import { pool } from "../db.js";
4
+
5
+ const router: RouterType = Router();
6
+
7
+ // POST /api/hosts/register - Register a host (called by palmier init).
8
+ // If the request includes a hostId that already exists, the row is reused
9
+ // so re-initializing a host doesn't create a duplicate entry.
10
+ router.post("/register", async (req: Request, res: Response) => {
11
+ try {
12
+ const requestedId: string | undefined = req.body?.hostId;
13
+
14
+ let hostId: string;
15
+ if (requestedId) {
16
+ // Reuse existing row or create one with the requested id.
17
+ await pool.query(
18
+ `INSERT INTO hosts (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
19
+ [requestedId],
20
+ );
21
+ hostId = requestedId;
22
+ } else {
23
+ const result = await pool.query(
24
+ "INSERT INTO hosts DEFAULT VALUES RETURNING id"
25
+ );
26
+ hostId = result.rows[0].id;
27
+ }
28
+
29
+ const natsUrl = process.env.NATS_HOST_URL || process.env.NATS_URL || "nats://localhost:4222";
30
+ const natsWsUrl = process.env.NATS_WS_URL || "";
31
+ const natsToken = process.env.NATS_TOKEN || "";
32
+
33
+ res.status(201).json({
34
+ hostId,
35
+ natsUrl,
36
+ natsWsUrl,
37
+ natsToken,
38
+ });
39
+ } catch (err) {
40
+ console.error("Register host error:", err);
41
+ res.status(500).json({ error: "Internal server error" });
42
+ }
43
+ });
44
+
45
+ export default router;
@@ -0,0 +1,100 @@
1
+ import { Router, Request, Response } from "express";
2
+ import type { Router as RouterType } from "express";
3
+ import { pool } from "../db.js";
4
+ import { getNatsConnection } from "../nats.js";
5
+ import { StringCodec } from "nats";
6
+
7
+ const router: RouterType = Router();
8
+
9
+ // POST /api/push/subscribe - Upsert push subscription (keyed by hostId)
10
+ router.post("/subscribe", async (req: Request, res: Response) => {
11
+ try {
12
+ const { hostId, endpoint, keys } = req.body;
13
+
14
+ if (!hostId || !endpoint || !keys || !keys.p256dh || !keys.auth) {
15
+ res.status(400).json({
16
+ error: "hostId, endpoint, keys.p256dh, and keys.auth are required",
17
+ });
18
+ return;
19
+ }
20
+
21
+ await pool.query(
22
+ `INSERT INTO push_subscriptions (host_id, endpoint, p256dh, auth)
23
+ VALUES ($1, $2, $3, $4)
24
+ ON CONFLICT (host_id, endpoint)
25
+ DO UPDATE SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth`,
26
+ [hostId, endpoint, keys.p256dh, keys.auth]
27
+ );
28
+
29
+ res.status(201).json({ message: "Push subscription saved" });
30
+ } catch (err) {
31
+ console.error("Push subscribe error:", err);
32
+ res.status(500).json({ error: "Internal server error" });
33
+ }
34
+ });
35
+
36
+ // DELETE /api/push/subscribe - Delete push subscription by endpoint
37
+ router.delete("/subscribe", async (req: Request, res: Response) => {
38
+ try {
39
+ const { hostId, endpoint } = req.body;
40
+
41
+ if (!hostId || !endpoint) {
42
+ res.status(400).json({ error: "hostId and endpoint are required" });
43
+ return;
44
+ }
45
+
46
+ const result = await pool.query(
47
+ "DELETE FROM push_subscriptions WHERE host_id = $1 AND endpoint = $2 RETURNING id",
48
+ [hostId, endpoint]
49
+ );
50
+
51
+ if (result.rows.length === 0) {
52
+ res.status(404).json({ error: "Subscription not found" });
53
+ return;
54
+ }
55
+
56
+ res.json({ message: "Push subscription removed" });
57
+ } catch (err) {
58
+ console.error("Push unsubscribe error:", err);
59
+ res.status(500).json({ error: "Internal server error" });
60
+ }
61
+ });
62
+
63
+ // GET /api/push/vapid-key - Public endpoint
64
+ router.get("/vapid-key", (_req, res: Response) => {
65
+ const publicKey = process.env.VAPID_PUBLIC_KEY || "";
66
+ res.json({ publicKey });
67
+ });
68
+
69
+ // POST /api/push/respond - Respond to a pending confirmation via NATS request-reply
70
+ router.post("/respond", async (req: Request, res: Response) => {
71
+ try {
72
+ const { task_id, host_id, response } = req.body;
73
+
74
+ if (!task_id || !host_id || !response) {
75
+ res.status(400).json({
76
+ error: "task_id, host_id, and response are required",
77
+ });
78
+ return;
79
+ }
80
+
81
+ const conn = await getNatsConnection();
82
+ const sc = StringCodec();
83
+ const subject = `host.${host_id}.rpc.task.user_input`;
84
+ const payload = sc.encode(JSON.stringify({ id: task_id, value: [response] }));
85
+
86
+ const reply = await conn.request(subject, payload, { timeout: 5000 });
87
+ const result = JSON.parse(sc.decode(reply.data));
88
+
89
+ res.json({ message: "Confirmation response recorded", ...result });
90
+ } catch (err: any) {
91
+ console.error("Push respond error:", err);
92
+ if (err.code === "503" || err.message?.includes("timeout")) {
93
+ res.status(504).json({ error: "Host did not respond" });
94
+ return;
95
+ }
96
+ res.status(500).json({ error: "Internal server error" });
97
+ }
98
+ });
99
+
100
+ export default router;
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "lib": ["ES2023"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }