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.
Files changed (52) hide show
  1. package/dist/agents/shared-prompt.js +1 -1
  2. package/dist/commands/init.js +3 -2
  3. package/dist/commands/pair.js +1 -1
  4. package/dist/commands/run.js +4 -4
  5. package/dist/commands/serve.js +1 -1
  6. package/dist/config.js +2 -2
  7. package/dist/device-capabilities.d.ts +1 -1
  8. package/dist/events.js +1 -1
  9. package/dist/mcp-tools.js +64 -1
  10. package/dist/nats-client.d.ts +1 -1
  11. package/dist/nats-client.js +6 -3
  12. package/dist/pwa/assets/index-Bt8Hhaw3.js +118 -0
  13. package/dist/pwa/assets/{web-Dc9-IiRD.js → web-CkWrlNwc.js} +1 -1
  14. package/dist/pwa/assets/{web-_b3Dvcvz.js → web-lx34oBi7.js} +1 -1
  15. package/dist/pwa/index.html +1 -1
  16. package/dist/pwa/service-worker.js +1 -1
  17. package/dist/rpc-handler.js +6 -2
  18. package/dist/types.d.ts +2 -1
  19. package/package.json +1 -1
  20. package/palmier-server/PRODUCTION.md +31 -28
  21. package/palmier-server/README.md +35 -5
  22. package/palmier-server/nats.conf +9 -5
  23. package/palmier-server/package.json +2 -1
  24. package/palmier-server/pnpm-lock.yaml +6 -0
  25. package/palmier-server/pwa/src/components/HostMenu.tsx +98 -158
  26. package/palmier-server/pwa/src/components/TaskListView.tsx +4 -4
  27. package/palmier-server/pwa/src/constants.ts +1 -1
  28. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
  29. package/palmier-server/pwa/src/pages/Dashboard.tsx +4 -4
  30. package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
  31. package/palmier-server/server/package.json +3 -1
  32. package/palmier-server/server/src/index.ts +83 -2
  33. package/palmier-server/server/src/nats-jwt.ts +299 -0
  34. package/palmier-server/server/src/nats-setup.ts +48 -0
  35. package/palmier-server/server/src/nats.ts +12 -4
  36. package/palmier-server/server/src/routes/device.ts +24 -0
  37. package/palmier-server/server/src/routes/hosts.ts +13 -2
  38. package/palmier-server/spec.md +6 -5
  39. package/src/agents/shared-prompt.ts +1 -1
  40. package/src/commands/init.ts +7 -5
  41. package/src/commands/pair.ts +1 -1
  42. package/src/commands/run.ts +4 -4
  43. package/src/commands/serve.ts +1 -1
  44. package/src/config.ts +2 -2
  45. package/src/device-capabilities.ts +1 -0
  46. package/src/events.ts +1 -1
  47. package/src/mcp-tools.ts +68 -1
  48. package/src/nats-client.ts +10 -3
  49. package/src/rpc-handler.ts +6 -2
  50. package/src/types.ts +3 -2
  51. package/test/agent-instructions.test.ts +10 -10
  52. package/dist/pwa/assets/index-BirmfPUC.js +0 -118
@@ -64,6 +64,15 @@ interface DndAccessPlugin {
64
64
  check(): Promise<DndAccessResult>;
65
65
  }
66
66
 
67
+ interface FullScreenIntentResult {
68
+ granted: boolean;
69
+ }
70
+
71
+ interface FullScreenIntentPlugin {
72
+ request(): Promise<FullScreenIntentResult>;
73
+ check(): Promise<FullScreenIntentResult>;
74
+ }
75
+
67
76
  const NotificationListener = Capacitor.isNativePlatform()
68
77
  ? registerPlugin<NotificationListenerPlugin>("NotificationListener")
69
78
  : null;
@@ -83,6 +92,10 @@ const CalendarPermission = Capacitor.isNativePlatform()
83
92
  const DndAccess = Capacitor.isNativePlatform()
84
93
  ? registerPlugin<DndAccessPlugin>("DndAccess")
85
94
  : null;
95
+
96
+ const FullScreenIntent = Capacitor.isNativePlatform()
97
+ ? registerPlugin<FullScreenIntentPlugin>("FullScreenIntent")
98
+ : null;
86
99
  import { useHostStore } from "../contexts/HostStoreContext";
87
100
  import { useMediaQuery } from "../hooks/useMediaQuery";
88
101
 
@@ -92,13 +105,13 @@ const isNative = Capacitor.isNativePlatform();
92
105
 
93
106
  interface HostMenuProps {
94
107
  daemonVersion?: string | null;
95
- locationClientToken?: string | null;
108
+ capabilityTokens?: Record<string, string | null>;
96
109
  activeClientToken?: string | null;
97
110
  request?<T = unknown>(method: string, params?: unknown): Promise<T>;
98
- onLocationClientTokenChange?(token: string | null): void;
111
+ onCapabilityTokensChange?(tokens: Record<string, string | null>): void;
99
112
  }
100
113
 
101
- export default function HostMenu({ daemonVersion, locationClientToken, activeClientToken, request, onLocationClientTokenChange }: HostMenuProps) {
114
+ export default function HostMenu({ daemonVersion, capabilityTokens, activeClientToken, request, onCapabilityTokensChange }: HostMenuProps) {
102
115
  const { pairedHosts, activeHostId, setActiveHostId, removePairedHost, renamePairedHost } = useHostStore();
103
116
  const navigate = useNavigate();
104
117
  const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -109,22 +122,36 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
109
122
  const [renameValue, setRenameValue] = useState("");
110
123
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
111
124
  const [togglingLocation, setTogglingLocation] = useState(false);
112
- const [notificationListenerEnabled, setNotificationListenerEnabled] = useState(false);
113
125
  const [togglingNotificationListener, setTogglingNotificationListener] = useState(false);
114
- const [smsEnabled, setSmsEnabled] = useState(false);
115
126
  const [togglingSms, setTogglingSms] = useState(false);
116
- const [contactsEnabled, setContactsEnabled] = useState(false);
117
127
  const [togglingContacts, setTogglingContacts] = useState(false);
118
- const [calendarEnabled, setCalendarEnabled] = useState(false);
119
128
  const [togglingCalendar, setTogglingCalendar] = useState(false);
120
- const [dndEnabled, setDndEnabled] = useState(false);
121
129
  const [togglingDnd, setTogglingDnd] = useState(false);
122
- const [alarmEnabled, setAlarmEnabled] = useState(false);
123
130
  const [togglingAlarm, setTogglingAlarm] = useState(false);
124
- const [batteryEnabled, setBatteryEnabled] = useState(false);
125
131
  const [togglingBattery, setTogglingBattery] = useState(false);
132
+ const [togglingEmail, setTogglingEmail] = useState(false);
126
133
 
127
- const locationEnabled = !!(activeClientToken && locationClientToken === activeClientToken);
134
+ // Capability enabled = this device's client token matches the registered device for that capability
135
+ function isCapEnabled(cap: string): boolean {
136
+ return !!(activeClientToken && capabilityTokens?.[cap] === activeClientToken);
137
+ }
138
+ const locationEnabled = isCapEnabled("location");
139
+ const notificationListenerEnabled = isCapEnabled("notifications");
140
+ const smsEnabled = isCapEnabled("sms");
141
+ const contactsEnabled = isCapEnabled("contacts");
142
+ const calendarEnabled = isCapEnabled("calendar");
143
+ const dndEnabled = isCapEnabled("dnd");
144
+ const alarmEnabled = isCapEnabled("alert");
145
+ const batteryEnabled = isCapEnabled("battery");
146
+ const emailEnabled = isCapEnabled("email");
147
+
148
+ /** Update local capability tokens state after a toggle change */
149
+ function setCapEnabled(cap: string, enabled: boolean) {
150
+ const updated: Record<string, string | null> = {};
151
+ for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
152
+ updated[cap] = enabled ? (activeClientToken ?? null) : null;
153
+ onCapabilityTokensChange?.(updated);
154
+ }
128
155
 
129
156
  // Sync location toggle with permission state — on mount and when app resumes from background
130
157
  useEffect(() => {
@@ -136,7 +163,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
136
163
  if (!fine) {
137
164
  // Permission revoked — disable on host
138
165
  request!("device.location.disable").then(() => {
139
- onLocationClientTokenChange?.(null);
166
+ setCapEnabled("location", false);
140
167
  }).catch(() => {});
141
168
  }
142
169
  });
@@ -151,29 +178,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
151
178
  return () => { listener.then((h) => h.remove()); };
152
179
  }, [locationEnabled, activeClientToken]);
153
180
 
154
- // Sync notification listener toggle with system state — on mount and when app resumes
155
- useEffect(() => {
156
- if (!isNative || !NotificationListener) return;
157
-
158
- function syncNotificationListenerState() {
159
- Promise.all([
160
- NotificationListener!.check(),
161
- Preferences.get({ key: "notificationListenerEnabled" }),
162
- ]).then(([{ enabled: systemEnabled }, { value: prefValue }]) => {
163
- // Enabled only if both system permission is granted AND user toggled on
164
- setNotificationListenerEnabled(systemEnabled && prefValue !== "false");
165
- });
166
- }
167
-
168
- syncNotificationListenerState();
169
-
170
- const listener = CapApp.addListener("resume", () => {
171
- syncNotificationListenerState();
172
- });
173
-
174
- return () => { listener.then((h) => h.remove()); };
175
- }, []);
176
-
177
181
  async function handleNotificationListenerToggle() {
178
182
  if (!NotificationListener || !request) return;
179
183
  setTogglingNotificationListener(true);
@@ -181,7 +185,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
181
185
  if (notificationListenerEnabled) {
182
186
  await Preferences.set({ key: "notificationListenerEnabled", value: "false" });
183
187
  await request("device.capability.disable", { capability: "notifications" });
184
- setNotificationListenerEnabled(false);
188
+ setCapEnabled("notifications", false);
185
189
  } else {
186
190
  const { enabled: systemEnabled } = await NotificationListener.check();
187
191
  if (!systemEnabled) {
@@ -192,7 +196,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
192
196
  if (!fcmToken) { console.warn("No FCM token available"); return; }
193
197
  await Preferences.set({ key: "notificationListenerEnabled", value: "true" });
194
198
  await request("device.capability.enable", { capability: "notifications", fcmToken });
195
- setNotificationListenerEnabled(true);
199
+ setCapEnabled("notifications", true);
196
200
  }
197
201
  } catch (err) {
198
202
  console.error("Failed to toggle notification listener:", err);
@@ -201,28 +205,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
201
205
  }
202
206
  }
203
207
 
204
- // Sync SMS toggle with permission state — on mount and when app resumes
205
- useEffect(() => {
206
- if (!isNative || !SmsPermission) return;
207
-
208
- function syncSmsState() {
209
- Promise.all([
210
- SmsPermission!.check(),
211
- Preferences.get({ key: "smsListenerEnabled" }),
212
- ]).then(([{ granted }, { value: prefValue }]) => {
213
- setSmsEnabled(granted && prefValue !== "false");
214
- });
215
- }
216
-
217
- syncSmsState();
218
-
219
- const listener = CapApp.addListener("resume", () => {
220
- syncSmsState();
221
- });
222
-
223
- return () => { listener.then((h) => h.remove()); };
224
- }, []);
225
-
226
208
  async function handleSmsToggle() {
227
209
  if (!SmsPermission || !request) return;
228
210
  setTogglingSms(true);
@@ -230,7 +212,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
230
212
  if (smsEnabled) {
231
213
  await Preferences.set({ key: "smsListenerEnabled", value: "false" });
232
214
  await request("device.capability.disable", { capability: "sms" });
233
- setSmsEnabled(false);
215
+ setCapEnabled("sms", false);
234
216
  } else {
235
217
  const { granted } = await SmsPermission.check();
236
218
  if (!granted) {
@@ -241,7 +223,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
241
223
  if (!fcmToken) { console.warn("No FCM token available"); return; }
242
224
  await Preferences.set({ key: "smsListenerEnabled", value: "true" });
243
225
  await request("device.capability.enable", { capability: "sms", fcmToken });
244
- setSmsEnabled(true);
226
+ setCapEnabled("sms", true);
245
227
  }
246
228
  } catch (err) {
247
229
  console.error("Failed to toggle SMS access:", err);
@@ -250,28 +232,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
250
232
  }
251
233
  }
252
234
 
253
- // Sync contacts toggle with permission state — on mount and when app resumes
254
- useEffect(() => {
255
- if (!isNative || !ContactsPermission) return;
256
-
257
- function syncContactsState() {
258
- Promise.all([
259
- ContactsPermission!.check(),
260
- Preferences.get({ key: "contactsAccessEnabled" }),
261
- ]).then(([{ granted }, { value: prefValue }]) => {
262
- setContactsEnabled(granted && prefValue !== "false");
263
- });
264
- }
265
-
266
- syncContactsState();
267
-
268
- const listener = CapApp.addListener("resume", () => {
269
- syncContactsState();
270
- });
271
-
272
- return () => { listener.then((h) => h.remove()); };
273
- }, []);
274
-
275
235
  async function handleContactsToggle() {
276
236
  if (!ContactsPermission || !request) return;
277
237
  setTogglingContacts(true);
@@ -279,7 +239,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
279
239
  if (contactsEnabled) {
280
240
  await Preferences.set({ key: "contactsAccessEnabled", value: "false" });
281
241
  await request("device.capability.disable", { capability: "contacts" });
282
- setContactsEnabled(false);
242
+ setCapEnabled("contacts", false);
283
243
  } else {
284
244
  const { granted } = await ContactsPermission.check();
285
245
  if (!granted) {
@@ -290,7 +250,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
290
250
  if (!fcmToken) { console.warn("No FCM token available"); return; }
291
251
  await Preferences.set({ key: "contactsAccessEnabled", value: "true" });
292
252
  await request("device.capability.enable", { capability: "contacts", fcmToken });
293
- setContactsEnabled(true);
253
+ setCapEnabled("contacts", true);
294
254
  }
295
255
  } catch (err) {
296
256
  console.error("Failed to toggle contacts access:", err);
@@ -299,28 +259,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
299
259
  }
300
260
  }
301
261
 
302
- // Sync calendar toggle with permission state — on mount and when app resumes
303
- useEffect(() => {
304
- if (!isNative || !CalendarPermission) return;
305
-
306
- function syncCalendarState() {
307
- Promise.all([
308
- CalendarPermission!.check(),
309
- Preferences.get({ key: "calendarAccessEnabled" }),
310
- ]).then(([{ granted }, { value: prefValue }]) => {
311
- setCalendarEnabled(granted && prefValue !== "false");
312
- });
313
- }
314
-
315
- syncCalendarState();
316
-
317
- const listener = CapApp.addListener("resume", () => {
318
- syncCalendarState();
319
- });
320
-
321
- return () => { listener.then((h) => h.remove()); };
322
- }, []);
323
-
324
262
  async function handleCalendarToggle() {
325
263
  if (!CalendarPermission || !request) return;
326
264
  setTogglingCalendar(true);
@@ -328,7 +266,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
328
266
  if (calendarEnabled) {
329
267
  await Preferences.set({ key: "calendarAccessEnabled", value: "false" });
330
268
  await request("device.capability.disable", { capability: "calendar" });
331
- setCalendarEnabled(false);
269
+ setCapEnabled("calendar", false);
332
270
  } else {
333
271
  const { granted } = await CalendarPermission.check();
334
272
  if (!granted) {
@@ -339,7 +277,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
339
277
  if (!fcmToken) { console.warn("No FCM token available"); return; }
340
278
  await Preferences.set({ key: "calendarAccessEnabled", value: "true" });
341
279
  await request("device.capability.enable", { capability: "calendar", fcmToken });
342
- setCalendarEnabled(true);
280
+ setCapEnabled("calendar", true);
343
281
  }
344
282
  } catch (err) {
345
283
  console.error("Failed to toggle calendar access:", err);
@@ -348,33 +286,13 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
348
286
  }
349
287
  }
350
288
 
351
- // Sync DND access toggle with system state — on mount and when app resumes
352
- useEffect(() => {
353
- if (!isNative || !DndAccess) return;
354
-
355
- function syncDndState() {
356
- DndAccess!.check().then(({ enabled }) => {
357
- setDndEnabled(enabled);
358
- });
359
- }
360
-
361
- syncDndState();
362
-
363
- const listener = CapApp.addListener("resume", () => {
364
- syncDndState();
365
- });
366
-
367
- return () => { listener.then((h) => h.remove()); };
368
- }, []);
369
-
370
289
  async function handleDndToggle() {
371
290
  if (!DndAccess || !request) return;
372
291
  setTogglingDnd(true);
373
292
  try {
374
293
  if (dndEnabled) {
375
- // DND access can only be revoked in system settings, but we unregister from host
376
294
  await request("device.capability.disable", { capability: "dnd" });
377
- setDndEnabled(false);
295
+ setCapEnabled("dnd", false);
378
296
  } else {
379
297
  const { enabled: systemEnabled } = await DndAccess.check();
380
298
  if (!systemEnabled) {
@@ -384,7 +302,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
384
302
  const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
385
303
  if (!fcmToken) { console.warn("No FCM token available"); return; }
386
304
  await request("device.capability.enable", { capability: "dnd", fcmToken });
387
- setDndEnabled(true);
305
+ setCapEnabled("dnd", true);
388
306
  }
389
307
  } catch (err) {
390
308
  console.error("Failed to toggle DND access:", err);
@@ -393,58 +311,69 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
393
311
  }
394
312
  }
395
313
 
396
- // Sync alarm toggle no permission needed, just device registration
397
- useEffect(() => {
398
- if (!isNative) return;
399
- Preferences.get({ key: "alertAccessEnabled" }).then(({ value }) => {
400
- setAlarmEnabled(value === "true");
401
- });
402
- }, []);
314
+ /** Ensure full-screen intent permission is granted (needed for alert + email). */
315
+ async function ensureFullScreenIntent(): Promise<boolean> {
316
+ if (!FullScreenIntent) return true;
317
+ const { granted } = await FullScreenIntent.check();
318
+ if (granted) return true;
319
+ const result = await FullScreenIntent.request();
320
+ return result.granted;
321
+ }
403
322
 
404
323
  async function handleAlarmToggle() {
405
324
  if (!request) return;
406
325
  setTogglingAlarm(true);
407
326
  try {
408
327
  if (alarmEnabled) {
409
- await Preferences.set({ key: "alertAccessEnabled", value: "false" });
410
328
  await request("device.capability.disable", { capability: "alert" });
411
- setAlarmEnabled(false);
329
+ setCapEnabled("alert", false);
412
330
  } else {
331
+ if (!await ensureFullScreenIntent()) return;
413
332
  const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
414
333
  if (!fcmToken) { console.warn("No FCM token available"); return; }
415
- await Preferences.set({ key: "alertAccessEnabled", value: "true" });
416
334
  await request("device.capability.enable", { capability: "alert", fcmToken });
417
- setAlarmEnabled(true);
335
+ setCapEnabled("alert", true);
418
336
  }
419
337
  } catch (err) {
420
- console.error("Failed to toggle alarm access:", err);
338
+ console.error("Failed to toggle alert access:", err);
421
339
  } finally {
422
340
  setTogglingAlarm(false);
423
341
  }
424
342
  }
425
343
 
426
- // Sync battery toggle — no permission needed, just device registration
427
- useEffect(() => {
428
- if (!isNative) return;
429
- Preferences.get({ key: "batteryAccessEnabled" }).then(({ value }) => {
430
- setBatteryEnabled(value === "true");
431
- });
432
- }, []);
344
+ async function handleEmailToggle() {
345
+ if (!request) return;
346
+ setTogglingEmail(true);
347
+ try {
348
+ if (emailEnabled) {
349
+ await request("device.capability.disable", { capability: "email" });
350
+ setCapEnabled("email", false);
351
+ } else {
352
+ if (!await ensureFullScreenIntent()) return;
353
+ const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
354
+ if (!fcmToken) { console.warn("No FCM token available"); return; }
355
+ await request("device.capability.enable", { capability: "email", fcmToken });
356
+ setCapEnabled("email", true);
357
+ }
358
+ } catch (err) {
359
+ console.error("Failed to toggle email access:", err);
360
+ } finally {
361
+ setTogglingEmail(false);
362
+ }
363
+ }
433
364
 
434
365
  async function handleBatteryToggle() {
435
366
  if (!request) return;
436
367
  setTogglingBattery(true);
437
368
  try {
438
369
  if (batteryEnabled) {
439
- await Preferences.set({ key: "batteryAccessEnabled", value: "false" });
440
370
  await request("device.capability.disable", { capability: "battery" });
441
- setBatteryEnabled(false);
371
+ setCapEnabled("battery", false);
442
372
  } else {
443
373
  const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
444
374
  if (!fcmToken) { console.warn("No FCM token available"); return; }
445
- await Preferences.set({ key: "batteryAccessEnabled", value: "true" });
446
375
  await request("device.capability.enable", { capability: "battery", fcmToken });
447
- setBatteryEnabled(true);
376
+ setCapEnabled("battery", true);
448
377
  }
449
378
  } catch (err) {
450
379
  console.error("Failed to toggle battery access:", err);
@@ -459,13 +388,12 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
459
388
  try {
460
389
  if (locationEnabled) {
461
390
  await request("device.location.disable");
462
- onLocationClientTokenChange?.(null);
391
+ setCapEnabled("location", false);
463
392
  } else {
464
- // Request location permissions before enabling
465
393
  if (LocationPermission) {
466
394
  const result = await LocationPermission.request();
467
395
  if (!result.fine) {
468
- return; // User denied permission
396
+ return;
469
397
  }
470
398
  }
471
399
 
@@ -475,7 +403,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
475
403
  return;
476
404
  }
477
405
  await request("device.location.enable", { fcmToken });
478
- onLocationClientTokenChange?.(activeClientToken ?? null);
406
+ setCapEnabled("location", true);
479
407
  }
480
408
  } catch (err) {
481
409
  console.error("Failed to toggle location:", err);
@@ -759,6 +687,18 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
759
687
  <span className="toggle-switch-thumb" />
760
688
  </button>
761
689
  </label>
690
+ <label className="drawer-toggle">
691
+ <span className="drawer-toggle-label">Email Access</span>
692
+ <button
693
+ className={`toggle-switch ${emailEnabled ? "toggle-switch-on" : ""}`}
694
+ onClick={handleEmailToggle}
695
+ disabled={togglingEmail}
696
+ role="switch"
697
+ aria-checked={emailEnabled}
698
+ >
699
+ <span className="toggle-switch-thumb" />
700
+ </button>
701
+ </label>
762
702
  </div>
763
703
  </>
764
704
  )}
@@ -26,10 +26,10 @@ interface TaskListViewProps {
26
26
  onViewRun(taskId: string, runId?: string): void;
27
27
  onUpdateRequired?(required: boolean): void;
28
28
  onVersion?(version: string | null): void;
29
- onLocationClientToken?(token: string | null): void;
29
+ onCapabilityTokens?(tokens: Record<string, string | null>): void;
30
30
  }
31
31
 
32
- export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion, onLocationClientToken }: TaskListViewProps) {
32
+ export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion, onCapabilityTokens }: TaskListViewProps) {
33
33
  const [tasks, setTasks] = useState<Task[]>([]);
34
34
  const [loadingTasks, setLoadingTasks] = useState(false);
35
35
  const [taskError, setTaskError] = useState<string | null>(null);
@@ -53,7 +53,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
53
53
  setLoadingTasks(true);
54
54
  setTaskError(null);
55
55
  try {
56
- const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null }>("task.list");
56
+ const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null; capability_tokens?: Record<string, string | null> }>("task.list");
57
57
  const taskList = result.tasks ?? [];
58
58
  const initialEvents = new Map<string, TaskStatus>();
59
59
  const initialConfirms = new Map<string, { description: string; agentName?: string }>();
@@ -82,7 +82,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
82
82
  setAgentLabels(result.agents ?? []);
83
83
  const version = result.version ?? null;
84
84
  onVersion?.(version);
85
- onLocationClientToken?.(result.location_client_token ?? null);
85
+ onCapabilityTokens?.(result.capability_tokens ?? {});
86
86
  onUpdateRequired?.(!!version && isOlderThan(version, MIN_HOST_VERSION));
87
87
  } catch (err) {
88
88
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -1,2 +1,2 @@
1
1
  /** Bump when a breaking host change is made. */
2
- export const MIN_HOST_VERSION = "0.7.3";
2
+ export const MIN_HOST_VERSION = "0.7.6";
@@ -7,7 +7,7 @@ import {
7
7
  useCallback,
8
8
  type ReactNode,
9
9
  } from "react";
10
- import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
10
+ import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
11
11
  import { SERVER_URL } from "../api";
12
12
  import { useHostStore } from "./HostStoreContext";
13
13
  import type { PairedHost } from "../types";
@@ -90,12 +90,13 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
90
90
 
91
91
  async function init() {
92
92
  try {
93
- const res = await fetch(`${SERVER_URL}/api/config`);
93
+ // Fetch host-scoped NATS credentials (can only access this host's subjects)
94
+ const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
94
95
  if (!res.ok) {
95
- console.error("[NATS] Failed to fetch config");
96
+ console.error("[NATS] Failed to fetch credentials");
96
97
  return;
97
98
  }
98
- const config = await res.json() as { natsWsUrl: string; natsToken: string };
99
+ const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
99
100
  if (!config.natsWsUrl) {
100
101
  console.warn("[NATS] No WebSocket URL configured");
101
102
  return;
@@ -105,7 +106,10 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
105
106
  console.log("[NATS] Connecting to", config.natsWsUrl);
106
107
  const conn = await connect({
107
108
  servers: config.natsWsUrl,
108
- token: config.natsToken,
109
+ authenticator: jwtAuthenticator(
110
+ config.natsJwt,
111
+ new TextEncoder().encode(config.natsNkeySeed),
112
+ ),
109
113
  });
110
114
  if (cancelled) { conn.close().catch(() => {}); return; }
111
115
  console.log("[NATS] Connected");
@@ -47,7 +47,7 @@ export default function Dashboard() {
47
47
  const [updating, setUpdating] = useState(false);
48
48
  const [updateError, setUpdateError] = useState<string | null>(null);
49
49
  const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
50
- const [locationClientToken, setLocationClientToken] = useState<string | null>(null);
50
+ const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
51
51
 
52
52
  // Register push subscription for the active host
53
53
  usePushSubscription();
@@ -88,11 +88,11 @@ export default function Dashboard() {
88
88
 
89
89
  return (
90
90
  <div className="dashboard">
91
- {isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
91
+ {isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
92
92
 
93
93
  <div className="dashboard-content">
94
94
  <div className="tab-bar">
95
- {!isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
95
+ {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
96
96
  <TabBar />
97
97
  </div>
98
98
 
@@ -141,7 +141,7 @@ export default function Dashboard() {
141
141
  onViewRun={handleViewRun}
142
142
  onUpdateRequired={setUpdateRequired}
143
143
  onVersion={setDaemonVersion}
144
- onLocationClientToken={setLocationClientToken}
144
+ onCapabilityTokens={setCapabilityTokens}
145
145
  />
146
146
  </div>
147
147
  {isRunDetail ? (
@@ -1,6 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
- import { connect, StringCodec } from "nats.ws";
3
+ import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
4
4
  import { Capacitor } from "@capacitor/core";
5
5
  import { Preferences } from "@capacitor/preferences";
6
6
  import { useHostStore } from "../contexts/HostStoreContext";
@@ -54,12 +54,15 @@ export default function PairHost() {
54
54
  // Server mode — pair via NATS
55
55
  const configRes = await fetch(`${SERVER_URL}/api/config`);
56
56
  if (!configRes.ok) throw new Error("Failed to fetch server config");
57
- const config = await configRes.json() as { natsWsUrl: string; natsToken: string };
57
+ const config = await configRes.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
58
58
  if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
59
59
 
60
60
  const nc = await connect({
61
61
  servers: config.natsWsUrl,
62
- token: config.natsToken,
62
+ authenticator: jwtAuthenticator(
63
+ config.natsJwt,
64
+ new TextEncoder().encode(config.natsNkeySeed),
65
+ ),
63
66
  });
64
67
 
65
68
  const sc = StringCodec();
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "tsx watch src/index.ts",
7
7
  "build": "tsc",
8
- "start": "node dist/index.js"
8
+ "start": "node dist/index.js",
9
+ "nats-setup": "tsx src/nats-setup.ts"
9
10
  },
10
11
  "dependencies": {
11
12
  "bcrypt": "^5.1.1",
@@ -16,6 +17,7 @@
16
17
  "helmet": "^8.0.0",
17
18
  "jsonwebtoken": "^9.0.2",
18
19
  "nats": "^2.29.1",
20
+ "nkeys.js": "^1.1.0",
19
21
  "pg": "^8.13.1",
20
22
  "uuid": "^11.0.5",
21
23
  "web-push": "^3.6.7"