palmier 0.7.4 → 0.7.7
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/dist/agents/shared-prompt.js +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/pair.js +1 -1
- package/dist/commands/run.js +4 -4
- package/dist/commands/serve.js +1 -1
- package/dist/config.js +2 -2
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/events.js +1 -1
- package/dist/mcp-tools.js +64 -1
- package/dist/nats-client.d.ts +1 -1
- package/dist/nats-client.js +6 -3
- package/dist/pwa/assets/index-Bt8Hhaw3.js +118 -0
- package/dist/pwa/assets/{web-Dc9-IiRD.js → web-CkWrlNwc.js} +1 -1
- package/dist/pwa/assets/{web-_b3Dvcvz.js → web-lx34oBi7.js} +1 -1
- package/dist/pwa/index.html +1 -1
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +6 -2
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/palmier-server/PRODUCTION.md +31 -28
- package/palmier-server/README.md +35 -5
- package/palmier-server/nats.conf +9 -5
- package/palmier-server/package.json +2 -1
- package/palmier-server/pnpm-lock.yaml +6 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +98 -158
- package/palmier-server/pwa/src/components/TaskListView.tsx +4 -4
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
- package/palmier-server/pwa/src/pages/Dashboard.tsx +4 -4
- package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
- package/palmier-server/server/package.json +3 -1
- package/palmier-server/server/src/index.ts +83 -2
- package/palmier-server/server/src/nats-jwt.ts +299 -0
- package/palmier-server/server/src/nats-setup.ts +48 -0
- package/palmier-server/server/src/nats.ts +12 -4
- package/palmier-server/server/src/routes/device.ts +24 -0
- package/palmier-server/server/src/routes/hosts.ts +13 -2
- package/palmier-server/spec.md +6 -5
- package/src/agents/shared-prompt.ts +1 -1
- package/src/commands/init.ts +7 -5
- package/src/commands/pair.ts +1 -1
- package/src/commands/run.ts +4 -4
- package/src/commands/serve.ts +1 -1
- package/src/config.ts +2 -2
- package/src/device-capabilities.ts +1 -0
- package/src/events.ts +1 -1
- package/src/mcp-tools.ts +68 -1
- package/src/nats-client.ts +10 -3
- package/src/rpc-handler.ts +6 -2
- package/src/types.ts +3 -2
- package/test/agent-instructions.test.ts +10 -10
- package/dist/pwa/assets/index-BirmfPUC.js +0 -118
|
@@ -13,6 +13,7 @@ import fcmRoutes from "./routes/fcm.js";
|
|
|
13
13
|
import deviceRoutes from "./routes/device.js";
|
|
14
14
|
import { notifyClients } from "./notify.js";
|
|
15
15
|
import { sendFcmToClients, sendFcmToDevice } from "./fcm.js";
|
|
16
|
+
import { createPairingCredentials, createPwaCredentials } from "./nats-jwt.js";
|
|
16
17
|
|
|
17
18
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
|
18
19
|
|
|
@@ -446,6 +447,61 @@ async function main(): Promise<void> {
|
|
|
446
447
|
}
|
|
447
448
|
})();
|
|
448
449
|
|
|
450
|
+
// Subscribe to email requests from hosts
|
|
451
|
+
(async () => {
|
|
452
|
+
try {
|
|
453
|
+
const conn = await getNatsConnection();
|
|
454
|
+
const sub = conn.subscribe("host.*.fcm.email");
|
|
455
|
+
console.log("Listening for FCM email requests");
|
|
456
|
+
|
|
457
|
+
for await (const msg of sub) {
|
|
458
|
+
try {
|
|
459
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
460
|
+
hostId: string;
|
|
461
|
+
requestId: string;
|
|
462
|
+
fcmToken?: string;
|
|
463
|
+
[key: string]: string | undefined;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
467
|
+
if (data.hostId !== subjectHostId) {
|
|
468
|
+
if (msg.reply) {
|
|
469
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const fcmPayload: Record<string, string> = {
|
|
475
|
+
type: "send-email",
|
|
476
|
+
requestId: data.requestId,
|
|
477
|
+
hostId: data.hostId,
|
|
478
|
+
};
|
|
479
|
+
for (const key of ["to", "subject", "body", "cc", "bcc"]) {
|
|
480
|
+
if (data[key]) fcmPayload[key] = data[key]!;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
console.log(`[FCM] Sending email request for host ${data.hostId}`);
|
|
484
|
+
if (data.fcmToken) {
|
|
485
|
+
await sendFcmToDevice(data.fcmToken, fcmPayload);
|
|
486
|
+
} else {
|
|
487
|
+
await sendFcmToClients(data.hostId, fcmPayload);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (msg.reply) {
|
|
491
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
492
|
+
}
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.error("[FCM] Error handling email request:", err);
|
|
495
|
+
if (msg.reply) {
|
|
496
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error("Failed to subscribe to FCM email requests:", err);
|
|
502
|
+
}
|
|
503
|
+
})();
|
|
504
|
+
|
|
449
505
|
// Subscribe to battery requests from hosts
|
|
450
506
|
(async () => {
|
|
451
507
|
try {
|
|
@@ -569,11 +625,36 @@ async function main(): Promise<void> {
|
|
|
569
625
|
app.use("/api/fcm", fcmRoutes);
|
|
570
626
|
app.use("/api/device", deviceRoutes);
|
|
571
627
|
|
|
572
|
-
// Public NATS config endpoint
|
|
628
|
+
// Public NATS config endpoint — returns pairing-only credentials.
|
|
629
|
+
// These can only publish to pair.* subjects (no RPC, no event subscriptions).
|
|
573
630
|
app.get("/api/config", (_req, res) => {
|
|
631
|
+
const accountSeed = process.env.NATS_ACCOUNT_SEED;
|
|
632
|
+
if (!accountSeed) {
|
|
633
|
+
res.status(500).json({ error: "Server NATS auth not configured" });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const creds = createPairingCredentials(accountSeed);
|
|
637
|
+
res.json({
|
|
638
|
+
natsWsUrl: process.env.NATS_WS_URL || "",
|
|
639
|
+
natsJwt: creds.jwt,
|
|
640
|
+
natsNkeySeed: creds.nkeySeed,
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Host-scoped NATS credentials — returns JWT scoped to a single host's subjects.
|
|
645
|
+
// Called by the PWA after pairing to get credentials for RPC + event subscriptions.
|
|
646
|
+
app.get("/api/nats-credentials/:hostId", (req, res) => {
|
|
647
|
+
const accountSeed = process.env.NATS_ACCOUNT_SEED;
|
|
648
|
+
if (!accountSeed) {
|
|
649
|
+
res.status(500).json({ error: "Server NATS auth not configured" });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const { hostId } = req.params;
|
|
653
|
+
const creds = createPwaCredentials(accountSeed, hostId);
|
|
574
654
|
res.json({
|
|
575
655
|
natsWsUrl: process.env.NATS_WS_URL || "",
|
|
576
|
-
|
|
656
|
+
natsJwt: creds.jwt,
|
|
657
|
+
natsNkeySeed: creds.nkeySeed,
|
|
577
658
|
});
|
|
578
659
|
});
|
|
579
660
|
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS JWT/NKey utilities for decentralized authentication.
|
|
3
|
+
*
|
|
4
|
+
* NATS supports a decentralized auth model where authorization is embedded in
|
|
5
|
+
* signed JWTs rather than managed in server config files. This means new hosts
|
|
6
|
+
* can be onboarded without restarting or reconfiguring the NATS server.
|
|
7
|
+
*
|
|
8
|
+
* Key hierarchy:
|
|
9
|
+
*
|
|
10
|
+
* Operator (root of trust)
|
|
11
|
+
* └─ signs Account JWT → embedded in NATS server config (one-time)
|
|
12
|
+
* └─ Account (runtime signing key, held by palmier-server)
|
|
13
|
+
* ├─ signs Host User JWTs → scoped to host.{id}.* subjects
|
|
14
|
+
* ├─ signs PWA User JWTs → scoped to RPC + pairing subjects
|
|
15
|
+
* └─ signs Server User JWT → full access for relaying
|
|
16
|
+
*
|
|
17
|
+
* JWT format (NATS-specific, not standard JWT):
|
|
18
|
+
* Header: {"typ":"JWT","alg":"ed25519-nkey"}
|
|
19
|
+
* Payload: NATS claims (issuer, subject, permissions)
|
|
20
|
+
* Signature: Ed25519(header.payload, signing_key)
|
|
21
|
+
*
|
|
22
|
+
* Subject permission model:
|
|
23
|
+
*
|
|
24
|
+
* | Role | Publish | Subscribe |
|
|
25
|
+
* |-------------------|--------------------------|------------------------|
|
|
26
|
+
* | Host (X) | host-event.X.>, host.X.> | host.X.>, pair.* |
|
|
27
|
+
* | PWA (pairing) | pair.* | (none) |
|
|
28
|
+
* | PWA (connected X) | host.X.rpc.> | host-event.X.> |
|
|
29
|
+
* | Server | > (full) | > (full) |
|
|
30
|
+
*
|
|
31
|
+
* Request/reply inbox subjects (_INBOX.>) are allowed automatically via the
|
|
32
|
+
* `resp` field in the JWT claims.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createOperator, createAccount, createUser, fromSeed, type KeyPair } from "nkeys.js";
|
|
36
|
+
import crypto from "crypto";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Base64url helpers
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function base64urlEncode(data: Uint8Array): string {
|
|
43
|
+
return Buffer.from(data).toString("base64url");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function base64urlEncodeStr(str: string): string {
|
|
47
|
+
return Buffer.from(str, "utf-8").toString("base64url");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// NKey seed ↔ string helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export function seedToString(seed: Uint8Array): string {
|
|
55
|
+
return new TextDecoder().decode(seed);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function seedFromString(seed: string): Uint8Array {
|
|
59
|
+
return new TextEncoder().encode(seed);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// JWT signing
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** Sign a NATS JWT: base64url(header).base64url(claims).base64url(ed25519_sig). */
|
|
67
|
+
function signJwt(claims: Record<string, unknown>, signingKey: KeyPair): string {
|
|
68
|
+
const header = base64urlEncodeStr(JSON.stringify({ typ: "JWT", alg: "ed25519-nkey" }));
|
|
69
|
+
const payload = base64urlEncodeStr(JSON.stringify(claims));
|
|
70
|
+
const sigInput = new TextEncoder().encode(`${header}.${payload}`);
|
|
71
|
+
const signature = base64urlEncode(signingKey.sign(sigInput));
|
|
72
|
+
return `${header}.${payload}.${signature}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Account JWT
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Create an Operator JWT (self-signed). Used in the NATS server `operator` field. */
|
|
80
|
+
export function createOperatorJwt(operatorSeed: string): string {
|
|
81
|
+
const operatorKp = fromSeed(seedFromString(operatorSeed));
|
|
82
|
+
const claims = {
|
|
83
|
+
jti: crypto.randomUUID().replace(/-/g, ""),
|
|
84
|
+
iat: Math.floor(Date.now() / 1000),
|
|
85
|
+
iss: operatorKp.getPublicKey(),
|
|
86
|
+
name: "palmier-operator",
|
|
87
|
+
sub: operatorKp.getPublicKey(),
|
|
88
|
+
nats: {
|
|
89
|
+
type: "operator",
|
|
90
|
+
version: 2,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const jwt = signJwt(claims, operatorKp);
|
|
94
|
+
operatorKp.clear();
|
|
95
|
+
return jwt;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Create an Account JWT signed by the operator. Embedded in NATS server config. */
|
|
99
|
+
export function createAccountJwt(operatorSeed: string, accountPublicKey: string): string {
|
|
100
|
+
const operatorKp = fromSeed(seedFromString(operatorSeed));
|
|
101
|
+
const claims = {
|
|
102
|
+
jti: crypto.randomUUID().replace(/-/g, ""),
|
|
103
|
+
iat: Math.floor(Date.now() / 1000),
|
|
104
|
+
iss: operatorKp.getPublicKey(),
|
|
105
|
+
name: "palmier",
|
|
106
|
+
sub: accountPublicKey,
|
|
107
|
+
nats: {
|
|
108
|
+
limits: {
|
|
109
|
+
subs: -1,
|
|
110
|
+
data: -1,
|
|
111
|
+
payload: -1,
|
|
112
|
+
imports: -1,
|
|
113
|
+
exports: -1,
|
|
114
|
+
wildcards: true,
|
|
115
|
+
conn: -1,
|
|
116
|
+
leaf: -1,
|
|
117
|
+
},
|
|
118
|
+
type: "account",
|
|
119
|
+
version: 2,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const jwt = signJwt(claims, operatorKp);
|
|
123
|
+
operatorKp.clear();
|
|
124
|
+
return jwt;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// User JWT (generic)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
interface UserPermissions {
|
|
132
|
+
pub: { allow: string[] };
|
|
133
|
+
sub: { allow: string[] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create a User JWT signed by the account key.
|
|
138
|
+
* _INBOX.> is added to subscribe permissions so request/reply works.
|
|
139
|
+
* Unlimited values (-1) for subs/data/payload mean no artificial caps.
|
|
140
|
+
*/
|
|
141
|
+
function createUserJwt(
|
|
142
|
+
accountSeed: string,
|
|
143
|
+
userPublicKey: string,
|
|
144
|
+
name: string,
|
|
145
|
+
permissions: UserPermissions,
|
|
146
|
+
): string {
|
|
147
|
+
const accountKp = fromSeed(seedFromString(accountSeed));
|
|
148
|
+
const claims = {
|
|
149
|
+
jti: crypto.randomUUID().replace(/-/g, ""),
|
|
150
|
+
iat: Math.floor(Date.now() / 1000),
|
|
151
|
+
iss: accountKp.getPublicKey(),
|
|
152
|
+
name,
|
|
153
|
+
sub: userPublicKey,
|
|
154
|
+
nats: {
|
|
155
|
+
pub: { allow: [...permissions.pub.allow, "_INBOX.>"] },
|
|
156
|
+
sub: { allow: [...permissions.sub.allow, "_INBOX.>"] },
|
|
157
|
+
subs: -1,
|
|
158
|
+
data: -1,
|
|
159
|
+
payload: -1,
|
|
160
|
+
type: "user",
|
|
161
|
+
version: 2,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
const jwt = signJwt(claims, accountKp);
|
|
165
|
+
accountKp.clear();
|
|
166
|
+
return jwt;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Role-specific JWT generators
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export interface NatsCredentials {
|
|
174
|
+
jwt: string;
|
|
175
|
+
nkeySeed: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create credentials for a host daemon, scoped to the host's own subjects.
|
|
180
|
+
*
|
|
181
|
+
* A host with id X can only:
|
|
182
|
+
* - Publish to: host-event.X.> (task events), host.X.> (FCM requests, push)
|
|
183
|
+
* - Subscribe to: host.X.> (RPC, device responses), pair.* (during pairing)
|
|
184
|
+
*
|
|
185
|
+
* It cannot access any other host's subjects.
|
|
186
|
+
*/
|
|
187
|
+
export function createHostCredentials(accountSeed: string, hostId: string): NatsCredentials {
|
|
188
|
+
const userKp = createUser();
|
|
189
|
+
const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), `host-${hostId}`, {
|
|
190
|
+
pub: { allow: [`host-event.${hostId}.>`, `host.${hostId}.>`] },
|
|
191
|
+
sub: { allow: [`host.${hostId}.>`, "pair.*"] },
|
|
192
|
+
});
|
|
193
|
+
const nkeySeed = seedToString(userKp.getSeed());
|
|
194
|
+
userKp.clear();
|
|
195
|
+
return { jwt, nkeySeed };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create pairing-only credentials for the PWA. Used during the pairing flow
|
|
200
|
+
* before the PWA knows which host it will connect to.
|
|
201
|
+
*
|
|
202
|
+
* Minimal permissions:
|
|
203
|
+
* - Publish to: pair.* (send pairing request)
|
|
204
|
+
* - Subscribe to: (none — reply inbox handled by `resp`)
|
|
205
|
+
*/
|
|
206
|
+
export function createPairingCredentials(accountSeed: string): NatsCredentials {
|
|
207
|
+
const userKp = createUser();
|
|
208
|
+
const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), "pwa-pairing", {
|
|
209
|
+
pub: { allow: ["pair.*"] },
|
|
210
|
+
sub: { allow: [] },
|
|
211
|
+
});
|
|
212
|
+
const nkeySeed = seedToString(userKp.getSeed());
|
|
213
|
+
userKp.clear();
|
|
214
|
+
return { jwt, nkeySeed };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create host-scoped credentials for a PWA client after pairing.
|
|
219
|
+
*
|
|
220
|
+
* Scoped to a single host:
|
|
221
|
+
* - Publish to: host.{hostId}.rpc.> (send RPC to paired host)
|
|
222
|
+
* - Subscribe to: host-event.{hostId}.> (receive task events from paired host)
|
|
223
|
+
*
|
|
224
|
+
* A PWA client cannot see events or send RPC to any other host.
|
|
225
|
+
*/
|
|
226
|
+
export function createPwaCredentials(accountSeed: string, hostId: string): NatsCredentials {
|
|
227
|
+
const userKp = createUser();
|
|
228
|
+
const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), `pwa-${hostId}`, {
|
|
229
|
+
pub: { allow: [`host.${hostId}.rpc.>`] },
|
|
230
|
+
sub: { allow: [`host-event.${hostId}.>`] },
|
|
231
|
+
});
|
|
232
|
+
const nkeySeed = seedToString(userKp.getSeed());
|
|
233
|
+
userKp.clear();
|
|
234
|
+
return { jwt, nkeySeed };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Create credentials for the palmier server itself (full access for relaying).
|
|
239
|
+
*/
|
|
240
|
+
export function createServerCredentials(accountSeed: string): NatsCredentials {
|
|
241
|
+
const userKp = createUser();
|
|
242
|
+
const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), "server", {
|
|
243
|
+
pub: { allow: [">"] },
|
|
244
|
+
sub: { allow: [">"] },
|
|
245
|
+
});
|
|
246
|
+
const nkeySeed = seedToString(userKp.getSeed());
|
|
247
|
+
userKp.clear();
|
|
248
|
+
return { jwt, nkeySeed };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Setup: generate operator + account keys and NATS config
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export interface NatsSetupResult {
|
|
256
|
+
operatorSeed: string;
|
|
257
|
+
operatorPublicKey: string;
|
|
258
|
+
accountSeed: string;
|
|
259
|
+
accountPublicKey: string;
|
|
260
|
+
operatorJwt: string;
|
|
261
|
+
accountJwt: string;
|
|
262
|
+
natsConfig: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generate all keys and the NATS server config snippet for JWT auth.
|
|
267
|
+
* Run once during initial deployment, then store seeds as env vars.
|
|
268
|
+
*
|
|
269
|
+
* The `operator` field in nats.conf requires a full JWT (not a public key) —
|
|
270
|
+
* NATS treats non-JWT strings as file paths.
|
|
271
|
+
*/
|
|
272
|
+
export function generateNatsSetup(): NatsSetupResult {
|
|
273
|
+
const operatorKp = createOperator();
|
|
274
|
+
const accountKp = createAccount();
|
|
275
|
+
|
|
276
|
+
const operatorSeed = seedToString(operatorKp.getSeed());
|
|
277
|
+
const operatorPublicKey = operatorKp.getPublicKey();
|
|
278
|
+
const accountSeed = seedToString(accountKp.getSeed());
|
|
279
|
+
const accountPublicKey = accountKp.getPublicKey();
|
|
280
|
+
|
|
281
|
+
const operatorJwt = createOperatorJwt(operatorSeed);
|
|
282
|
+
const accountJwt = createAccountJwt(operatorSeed, accountPublicKey);
|
|
283
|
+
|
|
284
|
+
const natsConfig = [
|
|
285
|
+
`# NATS JWT auth configuration`,
|
|
286
|
+
`# Generated by palmier nats-setup`,
|
|
287
|
+
``,
|
|
288
|
+
`operator: ${operatorJwt}`,
|
|
289
|
+
`resolver: MEMORY`,
|
|
290
|
+
`resolver_preload: {`,
|
|
291
|
+
` ${accountPublicKey}: ${accountJwt}`,
|
|
292
|
+
`}`,
|
|
293
|
+
].join("\n");
|
|
294
|
+
|
|
295
|
+
operatorKp.clear();
|
|
296
|
+
accountKp.clear();
|
|
297
|
+
|
|
298
|
+
return { operatorSeed, operatorPublicKey, accountSeed, accountPublicKey, operatorJwt, accountJwt, natsConfig };
|
|
299
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time setup script for NATS JWT authentication.
|
|
3
|
+
*
|
|
4
|
+
* Run this once when deploying Palmier for the first time (or when rotating keys).
|
|
5
|
+
* It generates the cryptographic keys and config needed for NATS to enforce
|
|
6
|
+
* per-host subject isolation.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* cd server && pnpm nats-setup
|
|
10
|
+
*
|
|
11
|
+
* What it generates:
|
|
12
|
+
*
|
|
13
|
+
* Operator NKey pair
|
|
14
|
+
* └─ Signs the Account JWT (one-time). Store the seed securely as a backup —
|
|
15
|
+
* you only need it again if you regenerate the account JWT.
|
|
16
|
+
*
|
|
17
|
+
* Account NKey pair
|
|
18
|
+
* └─ Signs User JWTs at runtime. The server uses NATS_ACCOUNT_SEED to create
|
|
19
|
+
* scoped credentials for each host (during registration) and each PWA session.
|
|
20
|
+
*
|
|
21
|
+
* Account JWT
|
|
22
|
+
* └─ Embedded in the NATS server config (resolver_preload). Tells the NATS server
|
|
23
|
+
* to trust User JWTs signed by this account key.
|
|
24
|
+
*
|
|
25
|
+
* NATS config snippet
|
|
26
|
+
* └─ Drop-in replacement for the authorization block in nats.conf.
|
|
27
|
+
*
|
|
28
|
+
* See PRODUCTION.md for the full deployment flow.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { generateNatsSetup } from "./nats-jwt.js";
|
|
32
|
+
|
|
33
|
+
const setup = generateNatsSetup();
|
|
34
|
+
|
|
35
|
+
console.log("=== NATS JWT Auth Setup ===\n");
|
|
36
|
+
|
|
37
|
+
console.log("NATS_ACCOUNT_SEED (add to server/.env):\n");
|
|
38
|
+
console.log(` ${setup.accountSeed}\n`);
|
|
39
|
+
|
|
40
|
+
console.log("nats.conf auth section (replace any existing auth block):\n");
|
|
41
|
+
console.log(setup.natsConfig);
|
|
42
|
+
console.log();
|
|
43
|
+
|
|
44
|
+
console.log("Keys (store securely, do not commit):\n");
|
|
45
|
+
console.log(` Operator seed: ${setup.operatorSeed}`);
|
|
46
|
+
console.log(` Operator public key: ${setup.operatorPublicKey}`);
|
|
47
|
+
console.log(` Account seed: ${setup.accountSeed}`);
|
|
48
|
+
console.log(` Account public key: ${setup.accountPublicKey}`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { connect, NatsConnection } from "nats";
|
|
1
|
+
import { connect, jwtAuthenticator, NatsConnection } from "nats";
|
|
2
|
+
import { createServerCredentials, seedFromString } from "./nats-jwt.js";
|
|
2
3
|
|
|
3
4
|
let nc: NatsConnection | null = null;
|
|
4
5
|
|
|
@@ -6,14 +7,21 @@ export async function connectNats(): Promise<NatsConnection> {
|
|
|
6
7
|
if (nc) return nc;
|
|
7
8
|
|
|
8
9
|
const url = process.env.NATS_URL || "nats://localhost:4222";
|
|
9
|
-
const
|
|
10
|
+
const accountSeed = process.env.NATS_ACCOUNT_SEED;
|
|
11
|
+
|
|
12
|
+
if (!accountSeed) {
|
|
13
|
+
throw new Error("NATS_ACCOUNT_SEED is required for JWT auth");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Generate server credentials with full access
|
|
17
|
+
const creds = createServerCredentials(accountSeed);
|
|
10
18
|
|
|
11
19
|
nc = await connect({
|
|
12
20
|
servers: url,
|
|
13
|
-
|
|
21
|
+
authenticator: jwtAuthenticator(creds.jwt, seedFromString(creds.nkeySeed)),
|
|
14
22
|
});
|
|
15
23
|
|
|
16
|
-
console.log(`Connected to NATS at ${url}`);
|
|
24
|
+
console.log(`Connected to NATS at ${url} (JWT auth)`);
|
|
17
25
|
return nc;
|
|
18
26
|
}
|
|
19
27
|
|
|
@@ -197,4 +197,28 @@ router.post("/ringer-response", async (req: Request, res: Response) => {
|
|
|
197
197
|
}
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
// POST /api/device/email-response - Receive email response from Android, relay to host via NATS
|
|
201
|
+
router.post("/email-response", async (req: Request, res: Response) => {
|
|
202
|
+
try {
|
|
203
|
+
const { requestId, hostId, result } = req.body;
|
|
204
|
+
|
|
205
|
+
if (!requestId || !hostId) {
|
|
206
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const conn = await getNatsConnection();
|
|
211
|
+
const sc = StringCodec();
|
|
212
|
+
conn.publish(
|
|
213
|
+
`host.${hostId}.email.${requestId}`,
|
|
214
|
+
sc.encode(JSON.stringify(result)),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
res.json({ ok: true });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error("Device email response relay error:", err);
|
|
220
|
+
res.status(500).json({ error: "Internal server error" });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
200
224
|
export default router;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Router, Request, Response } from "express";
|
|
2
2
|
import type { Router as RouterType } from "express";
|
|
3
3
|
import { pool } from "../db.js";
|
|
4
|
+
import { createHostCredentials } from "../nats-jwt.js";
|
|
4
5
|
|
|
5
6
|
const router: RouterType = Router();
|
|
6
7
|
|
|
@@ -9,6 +10,13 @@ const router: RouterType = Router();
|
|
|
9
10
|
// so re-initializing a host doesn't create a duplicate entry.
|
|
10
11
|
router.post("/register", async (req: Request, res: Response) => {
|
|
11
12
|
try {
|
|
13
|
+
const accountSeed = process.env.NATS_ACCOUNT_SEED;
|
|
14
|
+
if (!accountSeed) {
|
|
15
|
+
console.error("NATS_ACCOUNT_SEED not configured");
|
|
16
|
+
res.status(500).json({ error: "Server NATS auth not configured" });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
const requestedId: string | undefined = req.body?.hostId;
|
|
13
21
|
|
|
14
22
|
let hostId: string;
|
|
@@ -26,15 +34,18 @@ router.post("/register", async (req: Request, res: Response) => {
|
|
|
26
34
|
hostId = result.rows[0].id;
|
|
27
35
|
}
|
|
28
36
|
|
|
37
|
+
// Generate per-host NATS credentials (scoped to this host's subjects)
|
|
38
|
+
const creds = createHostCredentials(accountSeed, hostId);
|
|
39
|
+
|
|
29
40
|
const natsUrl = process.env.NATS_HOST_URL || process.env.NATS_URL || "nats://localhost:4222";
|
|
30
41
|
const natsWsUrl = process.env.NATS_WS_URL || "";
|
|
31
|
-
const natsToken = process.env.NATS_TOKEN || "";
|
|
32
42
|
|
|
33
43
|
res.status(201).json({
|
|
34
44
|
hostId,
|
|
35
45
|
natsUrl,
|
|
36
46
|
natsWsUrl,
|
|
37
|
-
|
|
47
|
+
natsJwt: creds.jwt,
|
|
48
|
+
natsNkeySeed: creds.nkeySeed,
|
|
38
49
|
});
|
|
39
50
|
} catch (err) {
|
|
40
51
|
console.error("Register host error:", err);
|
package/palmier-server/spec.md
CHANGED
|
@@ -59,9 +59,9 @@ Each host machine is provisioned via `palmier init`, an interactive wizard that
|
|
|
59
59
|
`palmier init` is an interactive wizard that:
|
|
60
60
|
|
|
61
61
|
1. Detects installed agent CLIs.
|
|
62
|
-
2. Asks whether to enable LAN access and which HTTP port to use (default
|
|
62
|
+
2. Asks whether to enable LAN access and which HTTP port to use (default 7256).
|
|
63
63
|
3. Shows a summary of task storage directory, local access URL, LAN URL (if enabled), detected agents, and any existing tasks to recover. Asks for confirmation before proceeding.
|
|
64
|
-
4. Registers with the Palmier server via `POST <url>/api/hosts/register` — server returns `{ hostId, natsUrl, natsWsUrl,
|
|
64
|
+
4. Registers with the Palmier server via `POST <url>/api/hosts/register` — server returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }`.
|
|
65
65
|
5. Saves config to `~/.config/palmier/host.json` (includes `httpPort`, `lanEnabled`, NATS credentials).
|
|
66
66
|
6. Installs a systemd user service (Linux) or Task Scheduler entry (Windows) and auto-enters pair mode.
|
|
67
67
|
|
|
@@ -273,7 +273,7 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
|
|
|
273
273
|
|
|
274
274
|
1. PWA loads. If no hosts are paired, it shows an empty state with a "Pair Host" button.
|
|
275
275
|
|
|
276
|
-
2. If hosts are paired, PWA fetches NATS credentials from `GET /api/
|
|
276
|
+
2. If hosts are paired, PWA fetches host-scoped NATS credentials from `GET /api/nats-credentials/<hostId>` (returns `{ natsWsUrl, natsJwt, natsNkeySeed }`) and connects to NATS via WebSocket using JWT auth. The credentials are scoped to the paired host's subjects only.
|
|
277
277
|
|
|
278
278
|
3. PWA sends a `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload.
|
|
279
279
|
|
|
@@ -430,8 +430,9 @@ All endpoints are served over HTTPS. No user authentication is required — the
|
|
|
430
430
|
|
|
431
431
|
| Method | Path | Description |
|
|
432
432
|
|--------|------|-------------|
|
|
433
|
-
| `POST` | `/api/hosts/register` | Register a new host. Returns `{ hostId, natsUrl, natsWsUrl,
|
|
434
|
-
| `GET` | `/api/config` | Returns NATS
|
|
433
|
+
| `POST` | `/api/hosts/register` | Register a new host. Returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }` with host-scoped NATS credentials. |
|
|
434
|
+
| `GET` | `/api/config` | Returns pairing-only NATS credentials: `{ natsWsUrl, natsJwt, natsNkeySeed }`. JWT can only publish to `pair.*`. |
|
|
435
|
+
| `GET` | `/api/nats-credentials/:hostId` | Returns host-scoped NATS credentials for PWA: `{ natsWsUrl, natsJwt, natsNkeySeed }`. JWT scoped to one host's RPC + events. |
|
|
435
436
|
| `POST` | `/api/push/subscribe` | Register a push subscription. Body: `{ hostId, endpoint, keys: { p256dh, auth } }`. |
|
|
436
437
|
| `DELETE` | `/api/push/subscribe` | Unregister a push subscription. Body: `{ hostId, endpoint }`. |
|
|
437
438
|
| `GET` | `/api/push/vapid-key` | Returns the server's VAPID public key for push subscription. |
|
|
@@ -16,7 +16,7 @@ const AGENT_INSTRUCTIONS_TEMPLATE = fs.readFileSync(
|
|
|
16
16
|
* Build the full agent prompt: instructions + endpoint docs + task description.
|
|
17
17
|
*/
|
|
18
18
|
export function getAgentInstructions(task: ParsedTask, skipPermissions?: boolean): string {
|
|
19
|
-
const port = loadConfig().httpPort ??
|
|
19
|
+
const port = loadConfig().httpPort ?? 7256;
|
|
20
20
|
const taskDescription = task.frontmatter.user_prompt;
|
|
21
21
|
let instructions = AGENT_INSTRUCTIONS_TEMPLATE
|
|
22
22
|
.replace(/\{\{ENDPOINT_DOCS\}\}/g, generateEndpointDocs(port, task.frontmatter.id))
|
package/src/commands/init.ts
CHANGED
|
@@ -45,7 +45,7 @@ export async function initCommand(): Promise<void> {
|
|
|
45
45
|
const lanAnswer = await ask("Enable LAN access (direct HTTP from local network)? (y/N): ");
|
|
46
46
|
const lanEnabled = lanAnswer.trim().toLowerCase() === "y";
|
|
47
47
|
|
|
48
|
-
let httpPort =
|
|
48
|
+
let httpPort = 7256;
|
|
49
49
|
const portLabel = lanEnabled ? "HTTP port for local and LAN access" : "HTTP port for local access";
|
|
50
50
|
const portAnswer = await ask(`${portLabel} (default ${httpPort}): `);
|
|
51
51
|
const parsed = parseInt(portAnswer.trim(), 10);
|
|
@@ -86,7 +86,7 @@ export async function initCommand(): Promise<void> {
|
|
|
86
86
|
try { existingHostId = loadConfig().hostId; } catch { /* first init */ }
|
|
87
87
|
|
|
88
88
|
const serverUrl = "https://app.palmier.me";
|
|
89
|
-
let registerResponse: { hostId: string; natsUrl: string; natsWsUrl: string;
|
|
89
|
+
let registerResponse: { hostId: string; natsUrl: string; natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
90
90
|
|
|
91
91
|
while (true) {
|
|
92
92
|
console.log(`\nRegistering host...`);
|
|
@@ -111,7 +111,8 @@ export async function initCommand(): Promise<void> {
|
|
|
111
111
|
projectRoot: process.cwd(),
|
|
112
112
|
natsUrl: registerResponse.natsUrl,
|
|
113
113
|
natsWsUrl: registerResponse.natsWsUrl,
|
|
114
|
-
|
|
114
|
+
natsJwt: registerResponse.natsJwt,
|
|
115
|
+
natsNkeySeed: registerResponse.natsNkeySeed,
|
|
115
116
|
agents,
|
|
116
117
|
httpPort,
|
|
117
118
|
lanEnabled,
|
|
@@ -138,7 +139,7 @@ export async function initCommand(): Promise<void> {
|
|
|
138
139
|
async function registerHost(
|
|
139
140
|
serverUrl: string,
|
|
140
141
|
existingHostId?: string,
|
|
141
|
-
): Promise<{ hostId: string; natsUrl: string; natsWsUrl: string;
|
|
142
|
+
): Promise<{ hostId: string; natsUrl: string; natsWsUrl: string; natsJwt: string; natsNkeySeed: string }> {
|
|
142
143
|
try {
|
|
143
144
|
const res = await fetch(`${serverUrl}/api/hosts/register`, {
|
|
144
145
|
method: "POST",
|
|
@@ -155,7 +156,8 @@ async function registerHost(
|
|
|
155
156
|
hostId: string;
|
|
156
157
|
natsUrl: string;
|
|
157
158
|
natsWsUrl: string;
|
|
158
|
-
|
|
159
|
+
natsJwt: string;
|
|
160
|
+
natsNkeySeed: string;
|
|
159
161
|
};
|
|
160
162
|
} catch (err) {
|
|
161
163
|
const message = err instanceof Error ? err.message : String(err);
|
package/src/commands/pair.ts
CHANGED
|
@@ -67,7 +67,7 @@ function httpPairRegister(port: number, code: string): Promise<boolean> {
|
|
|
67
67
|
export async function pairCommand(): Promise<void> {
|
|
68
68
|
const config = loadConfig();
|
|
69
69
|
const code = generatePairingCode();
|
|
70
|
-
const httpPort = config.httpPort ??
|
|
70
|
+
const httpPort = config.httpPort ?? 7256;
|
|
71
71
|
|
|
72
72
|
let paired = false;
|
|
73
73
|
|