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
@@ -1 +1 @@
1
- import{W as t}from"./index-BirmfPUC.js";class s extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{s as AppWeb};
1
+ import{W as t}from"./index-Bt8Hhaw3.js";class s extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{s as AppWeb};
@@ -1 +1 @@
1
- import{W as p}from"./index-BirmfPUC.js";class f extends p{constructor(){super(...arguments),this.group="CapacitorStorage"}async configure({group:e}){typeof e=="string"&&(this.group=e)}async get(e){return{value:this.impl.getItem(this.applyPrefix(e.key))}}async set(e){this.impl.setItem(this.applyPrefix(e.key),e.value)}async remove(e){this.impl.removeItem(this.applyPrefix(e.key))}async keys(){return{keys:this.rawKeys().map(t=>t.substring(this.prefix.length))}}async clear(){for(const e of this.rawKeys())this.impl.removeItem(e)}async migrate(){var e;const t=[],s=[],n="_cap_",o=Object.keys(this.impl).filter(i=>i.indexOf(n)===0);for(const i of o){const r=i.substring(n.length),a=(e=this.impl.getItem(i))!==null&&e!==void 0?e:"",{value:l}=await this.get({key:r});typeof l=="string"?s.push(r):(await this.set({key:r,value:a}),t.push(r))}return{migrated:t,existing:s}}async removeOld(){const e="_cap_",t=Object.keys(this.impl).filter(s=>s.indexOf(e)===0);for(const s of t)this.impl.removeItem(s)}get impl(){return window.localStorage}get prefix(){return this.group==="NativeStorage"?"":`${this.group}.`}rawKeys(){return Object.keys(this.impl).filter(e=>e.indexOf(this.prefix)===0)}applyPrefix(e){return this.prefix+e}}export{f as PreferencesWeb};
1
+ import{W as p}from"./index-Bt8Hhaw3.js";class f extends p{constructor(){super(...arguments),this.group="CapacitorStorage"}async configure({group:e}){typeof e=="string"&&(this.group=e)}async get(e){return{value:this.impl.getItem(this.applyPrefix(e.key))}}async set(e){this.impl.setItem(this.applyPrefix(e.key),e.value)}async remove(e){this.impl.removeItem(this.applyPrefix(e.key))}async keys(){return{keys:this.rawKeys().map(t=>t.substring(this.prefix.length))}}async clear(){for(const e of this.rawKeys())this.impl.removeItem(e)}async migrate(){var e;const t=[],s=[],n="_cap_",o=Object.keys(this.impl).filter(i=>i.indexOf(n)===0);for(const i of o){const r=i.substring(n.length),a=(e=this.impl.getItem(i))!==null&&e!==void 0?e:"",{value:l}=await this.get({key:r});typeof l=="string"?s.push(r):(await this.set({key:r,value:a}),t.push(r))}return{migrated:t,existing:s}}async removeOld(){const e="_cap_",t=Object.keys(this.impl).filter(s=>s.indexOf(e)===0);for(const s of t)this.impl.removeItem(s)}get impl(){return window.localStorage}get prefix(){return this.group==="NativeStorage"?"":`${this.group}.`}rawKeys(){return Object.keys(this.impl).filter(e=>e.indexOf(this.prefix)===0)}applyPrefix(e){return this.prefix+e}}export{f as PreferencesWeb};
@@ -8,7 +8,7 @@
8
8
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
9
9
  <title>Palmier</title>
10
10
  <meta name="description" content="Remote control for AI agents running on your own machine. Schedule tasks, approve permissions, and get push notifications." />
11
- <script type="module" crossorigin src="/assets/index-BirmfPUC.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-Bt8Hhaw3.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-B-ByUHPS.css">
13
13
  <link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
14
14
  <body>
@@ -1,2 +1,2 @@
1
1
  try{self["workbox:core:7.3.0"]&&_()}catch{}const N=(n,...e)=>{let t=n;return e.length>0&&(t+=` :: ${JSON.stringify(e)}`),t},E=N;class h extends Error{constructor(e,t){const s=E(e,t);super(s),this.name=e,this.details=t}}const f={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:typeof registration<"u"?registration.scope:""},U=n=>[f.prefix,n,f.suffix].filter(e=>e&&e.length>0).join("-"),O=n=>{for(const e of Object.keys(f))n(e)},L={updateDetails:n=>{O(e=>{typeof n[e]=="string"&&(f[e]=n[e])})},getGoogleAnalyticsName:n=>n||U(f.googleAnalytics),getPrecacheName:n=>n||U(f.precache),getPrefix:()=>f.prefix,getRuntimeName:n=>n||U(f.runtime),getSuffix:()=>f.suffix};function v(n,e){const t=e();return n.waitUntil(t),t}try{self["workbox:precaching:7.3.0"]&&_()}catch{}const A="__WB_REVISION__";function M(n){if(!n)throw new h("add-to-cache-list-unexpected-type",{entry:n});if(typeof n=="string"){const i=new URL(n,location.href);return{cacheKey:i.href,url:i.href}}const{revision:e,url:t}=n;if(!t)throw new h("add-to-cache-list-unexpected-type",{entry:n});if(!e){const i=new URL(t,location.href);return{cacheKey:i.href,url:i.href}}const s=new URL(t,location.href),a=new URL(t,location.href);return s.searchParams.set(A,e),{cacheKey:s.href,url:a.href}}class W{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:s})=>{if(e.type==="install"&&t&&t.originalRequest&&t.originalRequest instanceof Request){const a=t.originalRequest.url;s?this.notUpdatedURLs.push(a):this.updatedURLs.push(a)}return s}}}class q{constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:t,params:s})=>{const a=(s==null?void 0:s.cacheKey)||this._precacheController.getCacheKeyForURL(t.url);return a?new Request(a,{headers:t.headers}):t},this._precacheController=e}}let w;function S(){if(w===void 0){const n=new Response("");if("body"in n)try{new Response(n.body),w=!0}catch{w=!1}w=!1}return w}async function j(n,e){let t=null;if(n.url&&(t=new URL(n.url).origin),t!==self.location.origin)throw new h("cross-origin-copy-response",{origin:t});const s=n.clone(),i={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},r=S()?s.body:await s.blob();return new Response(r,i)}const D=n=>new URL(String(n),location.href).href.replace(new RegExp(`^${location.origin}`),"");function T(n,e){const t=new URL(n);for(const s of e)t.searchParams.delete(s);return t.href}async function H(n,e,t,s){const a=T(e.url,t);if(e.url===a)return n.match(e,s);const i=Object.assign(Object.assign({},s),{ignoreSearch:!0}),r=await n.keys(e,i);for(const c of r){const o=T(c.url,t);if(a===o)return n.match(c,s)}}class F{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}const B=new Set;async function $(){for(const n of B)await n()}function V(n){return new Promise(e=>setTimeout(e,n))}try{self["workbox:strategies:7.3.0"]&&_()}catch{}function C(n){return typeof n=="string"?new Request(n):n}class G{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new F,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(const s of this._plugins)this._pluginStateMap.set(s,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){const{event:t}=this;let s=C(e);if(s.mode==="navigate"&&t instanceof FetchEvent&&t.preloadResponse){const r=await t.preloadResponse;if(r)return r}const a=this.hasCallback("fetchDidFail")?s.clone():null;try{for(const r of this.iterateCallbacks("requestWillFetch"))s=await r({request:s.clone(),event:t})}catch(r){if(r instanceof Error)throw new h("plugin-error-request-will-fetch",{thrownErrorMessage:r.message})}const i=s.clone();try{let r;r=await fetch(s,s.mode==="navigate"?void 0:this._strategy.fetchOptions);for(const c of this.iterateCallbacks("fetchDidSucceed"))r=await c({event:t,request:i,response:r});return r}catch(r){throw a&&await this.runCallbacks("fetchDidFail",{error:r,event:t,originalRequest:a.clone(),request:i.clone()}),r}}async fetchAndCachePut(e){const t=await this.fetch(e),s=t.clone();return this.waitUntil(this.cachePut(e,s)),t}async cacheMatch(e){const t=C(e);let s;const{cacheName:a,matchOptions:i}=this._strategy,r=await this.getCacheKey(t,"read"),c=Object.assign(Object.assign({},i),{cacheName:a});s=await caches.match(r,c);for(const o of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await o({cacheName:a,matchOptions:i,cachedResponse:s,request:r,event:this.event})||void 0;return s}async cachePut(e,t){const s=C(e);await V(0);const a=await this.getCacheKey(s,"write");if(!t)throw new h("cache-put-with-no-response",{url:D(a.url)});const i=await this._ensureResponseSafeToCache(t);if(!i)return!1;const{cacheName:r,matchOptions:c}=this._strategy,o=await self.caches.open(r),l=this.hasCallback("cacheDidUpdate"),d=l?await H(o,a.clone(),["__WB_REVISION__"],c):null;try{await o.put(a,l?i.clone():i)}catch(u){if(u instanceof Error)throw u.name==="QuotaExceededError"&&await $(),u}for(const u of this.iterateCallbacks("cacheDidUpdate"))await u({cacheName:r,oldResponse:d,newResponse:i.clone(),request:a,event:this.event});return!0}async getCacheKey(e,t){const s=`${e.url} | ${t}`;if(!this._cacheKeys[s]){let a=e;for(const i of this.iterateCallbacks("cacheKeyWillBeUsed"))a=C(await i({mode:t,request:a,event:this.event,params:this.params}));this._cacheKeys[s]=a}return this._cacheKeys[s]}hasCallback(e){for(const t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(const s of this.iterateCallbacks(e))await s(t)}*iterateCallbacks(e){for(const t of this._strategy.plugins)if(typeof t[e]=="function"){const s=this._pluginStateMap.get(t);yield i=>{const r=Object.assign(Object.assign({},i),{state:s});return t[e](r)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){const e=this._extendLifetimePromises.splice(0),s=(await Promise.allSettled(e)).find(a=>a.status==="rejected");if(s)throw s.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,s=!1;for(const a of this.iterateCallbacks("cacheWillUpdate"))if(t=await a({request:this.request,response:t,event:this.event})||void 0,s=!0,!t)break;return s||t&&t.status!==200&&(t=void 0),t}}class J{constructor(e={}){this.cacheName=L.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){const[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});const t=e.event,s=typeof e.request=="string"?new Request(e.request):e.request,a="params"in e?e.params:void 0,i=new G(this,{event:t,request:s,params:a}),r=this._getResponse(i,s,t),c=this._awaitComplete(r,i,s,t);return[r,c]}async _getResponse(e,t,s){await e.runCallbacks("handlerWillStart",{event:s,request:t});let a;try{if(a=await this._handle(t,e),!a||a.type==="error")throw new h("no-response",{url:t.url})}catch(i){if(i instanceof Error){for(const r of e.iterateCallbacks("handlerDidError"))if(a=await r({error:i,event:s,request:t}),a)break}if(!a)throw i}for(const i of e.iterateCallbacks("handlerWillRespond"))a=await i({event:s,request:t,response:a});return a}async _awaitComplete(e,t,s,a){let i,r;try{i=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:a,request:s,response:i}),await t.doneWaiting()}catch(c){c instanceof Error&&(r=c)}if(await t.runCallbacks("handlerDidComplete",{event:a,request:s,response:i,error:r}),t.destroy(),r)throw r}}class p extends J{constructor(e={}){e.cacheName=L.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=e.fallbackToNetwork!==!1,this.plugins.push(p.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){const s=await t.cacheMatch(e);return s||(t.event&&t.event.type==="install"?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let s;const a=t.params||{};if(this._fallbackToNetwork){const i=a.integrity,r=e.integrity,c=!r||r===i;s=await t.fetch(new Request(e,{integrity:e.mode!=="no-cors"?r||i:void 0})),i&&c&&e.mode!=="no-cors"&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,s.clone()))}else throw new h("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return s}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();const s=await t.fetch(e);if(!await t.cachePut(e,s.clone()))throw new h("bad-precaching-response",{url:e.url,status:s.status});return s}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(const[s,a]of this.plugins.entries())a!==p.copyRedirectedCacheableResponsesPlugin&&(a===p.defaultPrecacheCacheabilityPlugin&&(e=s),a.cacheWillUpdate&&t++);t===0?this.plugins.push(p.defaultPrecacheCacheabilityPlugin):t>1&&e!==null&&this.plugins.splice(e,1)}}p.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:n}){return!n||n.status>=400?null:n}};p.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:n}){return n.redirected?await j(n):n}};class Q{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:s=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new p({cacheName:L.getPrecacheName(e),plugins:[...t,new q({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this._installAndActiveListenersAdded=!0)}addToCacheList(e){const t=[];for(const s of e){typeof s=="string"?t.push(s):s&&s.revision===void 0&&t.push(s.url);const{cacheKey:a,url:i}=M(s),r=typeof s!="string"&&s.revision?"reload":"default";if(this._urlsToCacheKeys.has(i)&&this._urlsToCacheKeys.get(i)!==a)throw new h("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(i),secondEntry:a});if(typeof s!="string"&&s.integrity){if(this._cacheKeysToIntegrities.has(a)&&this._cacheKeysToIntegrities.get(a)!==s.integrity)throw new h("add-to-cache-list-conflicting-integrities",{url:i});this._cacheKeysToIntegrities.set(a,s.integrity)}if(this._urlsToCacheKeys.set(i,a),this._urlsToCacheModes.set(i,r),t.length>0){const c=`Workbox is precaching URLs without revision info: ${t.join(", ")}
2
- This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(c)}}}install(e){return v(e,async()=>{const t=new W;this.strategy.plugins.push(t);for(const[i,r]of this._urlsToCacheKeys){const c=this._cacheKeysToIntegrities.get(r),o=this._urlsToCacheModes.get(i),l=new Request(i,{integrity:c,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:r},request:l,event:e}))}const{updatedURLs:s,notUpdatedURLs:a}=t;return{updatedURLs:s,notUpdatedURLs:a}})}activate(e){return v(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),a=new Set(this._urlsToCacheKeys.values()),i=[];for(const r of s)a.has(r.url)||(await t.delete(r),i.push(r.url));return{deletedURLs:i}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new h("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let k;const x=()=>(k||(k=new Q),k);try{self["workbox:routing:7.3.0"]&&_()}catch{}const I="GET",b=n=>n&&typeof n=="object"?n:{handle:n};class R{constructor(e,t,s=I){this.handler=b(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=b(e)}}class z extends R{constructor(e,t,s){const a=({url:i})=>{const r=e.exec(i.href);if(r&&!(i.origin!==location.origin&&r.index!==0))return r.slice(1)};super(a,t,s)}}class X{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(a=>{typeof a=="string"&&(a=[a]);const i=new Request(...a);return this.handleRequest({request:i,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const a=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:t,request:e,sameOrigin:a,url:s});let c=r&&r.handler;const o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;let l;try{l=c.handle({url:s,request:e,event:t,params:i})}catch(u){l=Promise.reject(u)}const d=r&&r.catchHandler;return l instanceof Promise&&(this._catchHandler||d)&&(l=l.catch(async u=>{if(d)try{return await d.handle({url:s,request:e,event:t,params:i})}catch(g){g instanceof Error&&(u=g)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),l}findMatchingRoute({url:e,sameOrigin:t,request:s,event:a}){const i=this._routes.get(s.method)||[];for(const r of i){let c;const o=r.match({url:e,sameOrigin:t,request:s,event:a});if(o)return c=o,(Array.isArray(c)&&c.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(c=void 0),{route:r,params:c}}return{}}setDefaultHandler(e,t=I){this._defaultHandlerMap.set(t,b(e))}setCatchHandler(e){this._catchHandler=b(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new h("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new h("unregister-route-route-not-registered")}}let m;const Y=()=>(m||(m=new X,m.addFetchListener(),m.addCacheListener()),m);function Z(n,e,t){let s;if(typeof n=="string"){const i=new URL(n,location.href),r=({url:c})=>c.href===i.href;s=new R(r,e,t)}else if(n instanceof RegExp)s=new z(n,e,t);else if(typeof n=="function")s=new R(n,e,t);else if(n instanceof R)s=n;else throw new h("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return Y().registerRoute(s),s}function ee(n,e=[]){for(const t of[...n.searchParams.keys()])e.some(s=>s.test(t))&&n.searchParams.delete(t);return n}function*te(n,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:a}={}){const i=new URL(n,location.href);i.hash="",yield i.href;const r=ee(i,e);if(yield r.href,t&&r.pathname.endsWith("/")){const c=new URL(r.href);c.pathname+=t,yield c.href}if(s){const c=new URL(r.href);c.pathname+=".html",yield c.href}if(a){const c=a({url:i});for(const o of c)yield o.href}}class se extends R{constructor(e,t){const s=({request:a})=>{const i=e.getURLsToCacheKeys();for(const r of te(a.url,t)){const c=i.get(r);if(c){const o=e.getIntegrityForCacheKey(c);return{cacheKey:c,integrity:o}}}};super(s,e.strategy)}}function ne(n){const e=x(),t=new se(e,n);Z(t)}function ae(n){x().precache(n)}function ie(n,e){ae(n),ne(e)}ie([{"revision":"38013143dc2183340ede8bc1c5124507","url":"registerSW.js"},{"revision":"1d8c320b1a6217a07e14dc08358f068d","url":"index.html"},{"revision":null,"url":"assets/web-_b3Dvcvz.js"},{"revision":null,"url":"assets/web-Dc9-IiRD.js"},{"revision":null,"url":"assets/index-BirmfPUC.js"},{"revision":null,"url":"assets/index-B-ByUHPS.css"},{"revision":"fcc457fce855ad0df7178e0786c0d4ef","url":"apple-touch-icon.png"},{"revision":"276650c30bc4effc7d649ec66519aab6","url":"favicon.ico"},{"revision":"2e46512b835c05e17787059909305f22","url":"pwa-192x192.png"},{"revision":"ec5652b5834b4711337743e80e506a41","url":"pwa-512x512.png"},{"revision":"9f51698004b9cc4d787c75695b74de9d","url":"manifest.webmanifest"}]);const re="/api/push/respond";self.addEventListener("message",n=>{});self.addEventListener("push",n=>{var r;if(!n.data)return;let e;try{e=n.data.json()}catch{e={title:"Palmier",body:n.data.text()}}const t=e.type??((r=e.data)==null?void 0:r.type);if(t==="confirm-dismiss"||t==="permission-dismiss"||t==="input-dismiss"){const c=e.data??e,o=c.host_id,l=c.session_id,d=c.task_id;n.waitUntil(self.registration.getNotifications().then(u=>{var g,P,K;for(const y of u)if(((g=y.data)==null?void 0:g.host_id)===o){if(l&&((P=y.data)==null?void 0:P.session_id)===l){y.close();continue}d&&((K=y.data)==null?void 0:K.task_id)===d&&y.close()}}));return}const s=e.title??"Palmier";let a=e.body??"";!a&&t==="confirm"&&(a="A task requires confirmation to run."),!a&&t==="permission"&&(a="A task needs additional permissions to continue."),!a&&t==="input"&&(a="A task needs your input to continue.");const i={body:a,icon:"/pwa-192x192.png",badge:"/pwa-192x192.png",data:e.data??e,vibrate:[100,50,100]};t==="confirm"&&(i.actions=[{action:"confirm",title:"Confirm"},{action:"abort",title:"Abort"}]),n.waitUntil(self.registration.showNotification(s,i))});self.addEventListener("notificationclick",n=>{const e=n.notification;e.close();const t=e.data??{},s=n.action;if(s&&t.type==="confirm"&&t.session_id&&t.host_id){const a=s==="confirm"?"confirmed":"aborted";n.waitUntil(fetch(re,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({session_id:t.session_id,host_id:t.host_id,response:a})}).catch(i=>{console.error("Failed to send push response:",i)}))}else{const a=t.task_id,i=t.run_id,r=a&&i?`/runs/${encodeURIComponent(a)}/${encodeURIComponent(i)}`:a?`/runs/${encodeURIComponent(a)}/latest`:"/";n.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(c=>{for(const o of c)if(o.url.includes(self.location.origin)&&"focus"in o)return o.navigate(r),o.focus();return self.clients.openWindow(r)}))}});self.addEventListener("install",()=>{self.skipWaiting()});self.addEventListener("activate",n=>{n.waitUntil(self.clients.claim())});
2
+ This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(c)}}}install(e){return v(e,async()=>{const t=new W;this.strategy.plugins.push(t);for(const[i,r]of this._urlsToCacheKeys){const c=this._cacheKeysToIntegrities.get(r),o=this._urlsToCacheModes.get(i),l=new Request(i,{integrity:c,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:r},request:l,event:e}))}const{updatedURLs:s,notUpdatedURLs:a}=t;return{updatedURLs:s,notUpdatedURLs:a}})}activate(e){return v(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),a=new Set(this._urlsToCacheKeys.values()),i=[];for(const r of s)a.has(r.url)||(await t.delete(r),i.push(r.url));return{deletedURLs:i}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new h("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let k;const x=()=>(k||(k=new Q),k);try{self["workbox:routing:7.3.0"]&&_()}catch{}const I="GET",b=n=>n&&typeof n=="object"?n:{handle:n};class R{constructor(e,t,s=I){this.handler=b(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=b(e)}}class z extends R{constructor(e,t,s){const a=({url:i})=>{const r=e.exec(i.href);if(r&&!(i.origin!==location.origin&&r.index!==0))return r.slice(1)};super(a,t,s)}}class X{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(a=>{typeof a=="string"&&(a=[a]);const i=new Request(...a);return this.handleRequest({request:i,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const a=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:t,request:e,sameOrigin:a,url:s});let c=r&&r.handler;const o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;let l;try{l=c.handle({url:s,request:e,event:t,params:i})}catch(u){l=Promise.reject(u)}const d=r&&r.catchHandler;return l instanceof Promise&&(this._catchHandler||d)&&(l=l.catch(async u=>{if(d)try{return await d.handle({url:s,request:e,event:t,params:i})}catch(g){g instanceof Error&&(u=g)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),l}findMatchingRoute({url:e,sameOrigin:t,request:s,event:a}){const i=this._routes.get(s.method)||[];for(const r of i){let c;const o=r.match({url:e,sameOrigin:t,request:s,event:a});if(o)return c=o,(Array.isArray(c)&&c.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(c=void 0),{route:r,params:c}}return{}}setDefaultHandler(e,t=I){this._defaultHandlerMap.set(t,b(e))}setCatchHandler(e){this._catchHandler=b(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new h("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new h("unregister-route-route-not-registered")}}let m;const Y=()=>(m||(m=new X,m.addFetchListener(),m.addCacheListener()),m);function Z(n,e,t){let s;if(typeof n=="string"){const i=new URL(n,location.href),r=({url:c})=>c.href===i.href;s=new R(r,e,t)}else if(n instanceof RegExp)s=new z(n,e,t);else if(typeof n=="function")s=new R(n,e,t);else if(n instanceof R)s=n;else throw new h("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return Y().registerRoute(s),s}function ee(n,e=[]){for(const t of[...n.searchParams.keys()])e.some(s=>s.test(t))&&n.searchParams.delete(t);return n}function*te(n,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:a}={}){const i=new URL(n,location.href);i.hash="",yield i.href;const r=ee(i,e);if(yield r.href,t&&r.pathname.endsWith("/")){const c=new URL(r.href);c.pathname+=t,yield c.href}if(s){const c=new URL(r.href);c.pathname+=".html",yield c.href}if(a){const c=a({url:i});for(const o of c)yield o.href}}class se extends R{constructor(e,t){const s=({request:a})=>{const i=e.getURLsToCacheKeys();for(const r of te(a.url,t)){const c=i.get(r);if(c){const o=e.getIntegrityForCacheKey(c);return{cacheKey:c,integrity:o}}}};super(s,e.strategy)}}function ne(n){const e=x(),t=new se(e,n);Z(t)}function ae(n){x().precache(n)}function ie(n,e){ae(n),ne(e)}ie([{"revision":"38013143dc2183340ede8bc1c5124507","url":"registerSW.js"},{"revision":"23ecab08d0815501f6e332dae8cf9986","url":"index.html"},{"revision":null,"url":"assets/web-lx34oBi7.js"},{"revision":null,"url":"assets/web-CkWrlNwc.js"},{"revision":null,"url":"assets/index-Bt8Hhaw3.js"},{"revision":null,"url":"assets/index-B-ByUHPS.css"},{"revision":"fcc457fce855ad0df7178e0786c0d4ef","url":"apple-touch-icon.png"},{"revision":"276650c30bc4effc7d649ec66519aab6","url":"favicon.ico"},{"revision":"2e46512b835c05e17787059909305f22","url":"pwa-192x192.png"},{"revision":"ec5652b5834b4711337743e80e506a41","url":"pwa-512x512.png"},{"revision":"9f51698004b9cc4d787c75695b74de9d","url":"manifest.webmanifest"}]);const re="/api/push/respond";self.addEventListener("message",n=>{});self.addEventListener("push",n=>{var r;if(!n.data)return;let e;try{e=n.data.json()}catch{e={title:"Palmier",body:n.data.text()}}const t=e.type??((r=e.data)==null?void 0:r.type);if(t==="confirm-dismiss"||t==="permission-dismiss"||t==="input-dismiss"){const c=e.data??e,o=c.host_id,l=c.session_id,d=c.task_id;n.waitUntil(self.registration.getNotifications().then(u=>{var g,P,K;for(const y of u)if(((g=y.data)==null?void 0:g.host_id)===o){if(l&&((P=y.data)==null?void 0:P.session_id)===l){y.close();continue}d&&((K=y.data)==null?void 0:K.task_id)===d&&y.close()}}));return}const s=e.title??"Palmier";let a=e.body??"";!a&&t==="confirm"&&(a="A task requires confirmation to run."),!a&&t==="permission"&&(a="A task needs additional permissions to continue."),!a&&t==="input"&&(a="A task needs your input to continue.");const i={body:a,icon:"/pwa-192x192.png",badge:"/pwa-192x192.png",data:e.data??e,vibrate:[100,50,100]};t==="confirm"&&(i.actions=[{action:"confirm",title:"Confirm"},{action:"abort",title:"Abort"}]),n.waitUntil(self.registration.showNotification(s,i))});self.addEventListener("notificationclick",n=>{const e=n.notification;e.close();const t=e.data??{},s=n.action;if(s&&t.type==="confirm"&&t.session_id&&t.host_id){const a=s==="confirm"?"confirmed":"aborted";n.waitUntil(fetch(re,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({session_id:t.session_id,host_id:t.host_id,response:a})}).catch(i=>{console.error("Failed to send push response:",i)}))}else{const a=t.task_id,i=t.run_id,r=a&&i?`/runs/${encodeURIComponent(a)}/${encodeURIComponent(i)}`:a?`/runs/${encodeURIComponent(a)}/latest`:"/";n.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(c=>{for(const o of c)if(o.url.includes(self.location.origin)&&"focus"in o)return o.navigate(r),o.focus();return self.clients.openWindow(r)}))}});self.addEventListener("install",()=>{self.skipWaiting()});self.addEventListener("activate",n=>{n.waitUntil(self.clients.claim())});
@@ -143,13 +143,17 @@ export function createRpcHandler(config, nc) {
143
143
  switch (request.method) {
144
144
  case "task.list": {
145
145
  const tasks = listTasks(config.projectRoot);
146
- const locDevice = getCapabilityDevice("location");
146
+ const capabilities = {};
147
+ for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd"]) {
148
+ capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
149
+ }
147
150
  return {
148
151
  tasks: tasks.map((task) => flattenTask(task)),
149
152
  agents: config.agents ?? [],
150
153
  version: currentVersion,
151
154
  host_platform: process.platform,
152
- location_client_token: locDevice?.clientToken ?? null,
155
+ location_client_token: capabilities.location,
156
+ capability_tokens: capabilities,
153
157
  };
154
158
  }
155
159
  case "task.get": {
package/dist/types.d.ts CHANGED
@@ -3,7 +3,8 @@ export interface HostConfig {
3
3
  projectRoot: string;
4
4
  natsUrl?: string;
5
5
  natsWsUrl?: string;
6
- natsToken?: string;
6
+ natsJwt?: string;
7
+ natsNkeySeed?: string;
7
8
  agents?: Array<{
8
9
  key: string;
9
10
  label: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.7.4",
3
+ "version": "0.7.7",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -99,7 +99,7 @@ No need to configure TLS in NATS itself.
99
99
  sudo systemctl enable --now caddy
100
100
  ```
101
101
 
102
- ## 2. Docker Compose for NATS + PostgreSQL
102
+ ## 2. NATS + PostgreSQL
103
103
 
104
104
  Create a directory for production Docker config:
105
105
 
@@ -107,7 +107,30 @@ Create a directory for production Docker config:
107
107
  mkdir -p ~/palmier-prod
108
108
  ```
109
109
 
110
- ### docker-compose.yml
110
+ ### 2a. Generate NATS auth keys and config
111
+
112
+ ```bash
113
+ cd server && pnpm nats-setup
114
+ ```
115
+
116
+ Follow the on-screen instructions — it outputs the `NATS_ACCOUNT_SEED` env var (for step 6) and the NATS config snippet (for `nats.conf` below). Store the operator seed securely as a backup.
117
+
118
+ ### 2b. nats.conf
119
+
120
+ Create `~/palmier-prod/nats.conf`. Paste the auth snippet from step 2a after the websocket block:
121
+
122
+ ```
123
+ listen: 0.0.0.0:4222
124
+
125
+ websocket {
126
+ listen: "0.0.0.0:9222"
127
+ no_tls: true
128
+ }
129
+
130
+ # Paste the operator/resolver/resolver_preload output from nats-setup here
131
+ ```
132
+
133
+ ### 2c. docker-compose.yml
111
134
 
112
135
  Create `~/palmier-prod/docker-compose.yml`:
113
136
 
@@ -141,34 +164,13 @@ volumes:
141
164
  pgdata:
142
165
  ```
143
166
 
144
- ### nats.conf
145
-
146
- Create `~/palmier-prod/nats.conf`:
147
-
148
- ```
149
- listen: 0.0.0.0:4222
150
-
151
- websocket {
152
- listen: "0.0.0.0:9222"
153
- no_tls: true
154
- }
155
-
156
- authorization {
157
- token: "<generate-a-new-token>"
158
- }
159
- ```
160
-
161
- Generate secrets:
167
+ Generate a Postgres password:
162
168
 
163
169
  ```bash
164
- # NATS token
165
- openssl rand -hex 32
166
-
167
- # Postgres password
168
170
  openssl rand -hex 16
169
171
  ```
170
172
 
171
- Start the containers:
173
+ ### 2d. Start containers
172
174
 
173
175
  ```bash
174
176
  cd ~/palmier-prod
@@ -177,9 +179,9 @@ docker compose up -d
177
179
 
178
180
  ## 3. NATS TCP for Remote Hosts
179
181
 
180
- Hosts connect to NATS over TCP (port 4222). Since Caddy only handles HTTP/WebSocket, port 4222 must be open on the firewall for hosts to connect. The NATS token provides authentication.
182
+ Hosts connect to NATS over TCP (port 4222). Since Caddy only handles HTTP/WebSocket, port 4222 must be open on the firewall for hosts to connect. Each host authenticates with its own JWT (issued during `palmier init`), scoped to only its own NATS subjects.
181
183
 
182
- For encrypted host connections, you can add a TLS-terminating TCP proxy (e.g., nginx stream block) in front of port 4222. For a single-user setup, token auth alone is sufficient.
184
+ For encrypted host connections, you can add a TLS-terminating TCP proxy (e.g., nginx stream block) in front of port 4222.
183
185
 
184
186
  ## 4. Firewall
185
187
 
@@ -215,7 +217,7 @@ DATABASE_URL=postgresql://palmier:<your-pg-password>@localhost:5432/palmier
215
217
  NATS_URL=nats://localhost:4222
216
218
  NATS_HOST_URL=nats://nats.palmier.me:4222
217
219
  NATS_WS_URL=wss://nats.palmier.me
218
- NATS_TOKEN=<your-nats-token-from-nats.conf>
220
+ NATS_ACCOUNT_SEED=<from-nats-setup>
219
221
 
220
222
  VAPID_PUBLIC_KEY=<generated>
221
223
  VAPID_PRIVATE_KEY=<generated>
@@ -226,6 +228,7 @@ Key differences from development:
226
228
 
227
229
  - `NATS_WS_URL` uses `wss://` through Caddy (not direct `ws://`)
228
230
  - `NATS_HOST_URL` uses your public domain so remote hosts can reach NATS
231
+ - `NATS_ACCOUNT_SEED` from step 2a (`pnpm nats-setup`)
229
232
 
230
233
  Generate VAPID keys:
231
234
 
@@ -83,7 +83,7 @@ Palmier is a platform for remotely scheduling, managing, and executing autonomou
83
83
  nats-server -c nats.conf
84
84
  ```
85
85
 
86
- The config enables WebSocket on port 9222 and token-based auth.
86
+ The config enables WebSocket on port 9222. See **NATS JWT Auth Setup** below.
87
87
 
88
88
  ## Development
89
89
 
@@ -142,7 +142,7 @@ The host runs on a separate Linux machine. See the [palmier README](https://gith
142
142
  | `NATS_URL` | NATS server URL (TCP, for server's own connection) | `nats://localhost:4222` |
143
143
  | `NATS_HOST_URL` | NATS URL sent to hosts during registration (use LAN IP) | `nats://192.168.1.100:4222` |
144
144
  | `NATS_WS_URL` | NATS WebSocket URL sent to PWA clients | `wss://nats.palmier.me` (prod) or `ws://192.168.1.100:9222` (LAN) |
145
- | `NATS_TOKEN` | NATS authentication token | *(from nats.conf)* |
145
+ | `NATS_ACCOUNT_SEED` | NATS account NKey seed for signing JWTs | *(from nats-setup)* |
146
146
  | `VAPID_PUBLIC_KEY` | VAPID public key for web push | *(generated via web-push)* |
147
147
  | `VAPID_PRIVATE_KEY` | VAPID private key for web push | *(generated via web-push)* |
148
148
  | `VAPID_MAILTO` | Contact email for VAPID | `mailto:admin@example.com` |
@@ -156,8 +156,9 @@ All endpoints are prefixed with `/api`. No user authentication is required.
156
156
 
157
157
  | Method | Path | Description |
158
158
  |---|---|---|
159
- | `POST` | `/api/hosts/register` | Register a new host (returns hostId + NATS config) |
160
- | `GET` | `/api/config` | Get NATS WebSocket credentials for the PWA |
159
+ | `POST` | `/api/hosts/register` | Register a new host (returns hostId + NATS JWT credentials) |
160
+ | `GET` | `/api/config` | Get pairing-only NATS credentials (can only publish to `pair.*`) |
161
+ | `GET` | `/api/nats-credentials/:hostId` | Get host-scoped NATS credentials for PWA (RPC + events for one host) |
161
162
  | `POST` | `/api/push/subscribe` | Register a push notification subscription |
162
163
  | `DELETE` | `/api/push/subscribe` | Remove a push notification subscription |
163
164
  | `GET` | `/api/push/vapid-key` | Get the VAPID public key |
@@ -192,7 +193,36 @@ All endpoints are prefixed with `/api`. No user authentication is required.
192
193
  - **Markdown rendering** — Task results are rendered as rich formatted text using `react-markdown` with `remark-gfm` (GitHub Flavored Markdown), supporting tables, strikethrough, task lists, and autolinks.
193
194
  - **Task confirmation** — the Dashboard discovers pending confirmations from the `task.list` RPC response (tasks with a pending request in the serve daemon's in-memory registry, reported via `task.status`). When found, it shows a full-screen confirmation modal. Push notification action buttons trigger `POST /api/push/respond`, which forwards to the `task.user_input` NATS RPC.
194
195
  - **Task event tracking** — task lifecycle events are persisted to `status.json` on the host (for crash detection) and broadcast via `host-event.<host_id>.<task_id>` pub/sub and HTTP SSE. The PWA loads initial status from `task.list` and subscribes to events for real-time updates.
195
- - **NATS config** (`nats.conf`) enables WebSocket on port 9222 (for browser clients) and token-based auth.
196
+ - **NATS auth** uses decentralized JWT/NKey authentication. Each host receives scoped credentials (can only access its own subjects). PWA clients get two-phase credentials: pairing-only JWT for the pairing flow, then host-scoped JWT after pairing. The server signs all user JWTs with the account key. Run `pnpm --filter palmier-server nats-setup` to generate keys and NATS config. See **NATS JWT Auth Setup** below.
197
+
198
+ ## NATS JWT Auth Setup
199
+
200
+ NATS uses decentralized JWT/NKey authentication. Each host gets scoped credentials that restrict it to its own subjects — one host cannot read or impersonate another.
201
+
202
+ **One-time setup:**
203
+
204
+ ```bash
205
+ # Generate operator/account NKey pairs and NATS config
206
+ cd server && pnpm nats-setup
207
+ ```
208
+
209
+ This outputs:
210
+ 1. An `NATS_ACCOUNT_SEED` value — add it to `server/.env`
211
+ 2. A NATS config snippet — replace the `authorization` block in `nats.conf`
212
+
213
+ **How it works:**
214
+
215
+ | Role | Publish | Subscribe |
216
+ |------|---------|-----------|
217
+ | **Host** (id=X) | `host-event.X.>`, `host.X.>` | `host.X.>`, `pair.*` |
218
+ | **PWA** (pairing) | `pair.*` | *(none)* |
219
+ | **PWA** (connected to X) | `host.X.rpc.>` | `host-event.X.>` |
220
+ | **Server** | `>` | `>` |
221
+
222
+ - The **operator** key signs the **account** JWT (embedded in NATS server config, one-time)
223
+ - The **account** key signs **user** JWTs at runtime (per host registration, per PWA session)
224
+ - Host credentials are generated during `POST /api/hosts/register` and stored in `~/.config/palmier/host.json`
225
+ - PWA uses two-phase credentials: `GET /api/config` returns pairing-only JWT, then `GET /api/nats-credentials/:hostId` returns host-scoped JWT after pairing. A PWA client can only access the specific host it paired with.
196
226
 
197
227
  ## Related Repositories
198
228
 
@@ -1,6 +1,6 @@
1
1
  # NATS Server Configuration for Palmier
2
2
 
3
- # TCP listener (for server + agent)
3
+ # TCP listener (for server + host)
4
4
  listen: 0.0.0.0:4222
5
5
 
6
6
  # WebSocket listener (for PWA browser clients)
@@ -9,7 +9,11 @@ websocket {
9
9
  no_tls: true
10
10
  }
11
11
 
12
- # Token-based authentication
13
- authorization {
14
- token: "592b229f9df46e9ecaedbfcf9e2e5bcc6432367c114b20a0c7854b6f4542184a"
15
- }
12
+ # JWT/NKey authentication
13
+ # Generate these values by running: cd server && pnpm nats-setup
14
+ # Paste the auth snippet from the output below.
15
+ # operator: <OPERATOR_JWT>
16
+ # resolver: MEMORY
17
+ # resolver_preload: {
18
+ # <ACCOUNT_PUBLIC_KEY>: <ACCOUNT_JWT>
19
+ # }
@@ -9,6 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "dependencies": {
12
- "firebase-admin": "^13.8.0"
12
+ "firebase-admin": "^13.8.0",
13
+ "nkeys.js": "^1.1.0"
13
14
  }
14
15
  }
@@ -11,6 +11,9 @@ importers:
11
11
  firebase-admin:
12
12
  specifier: ^13.8.0
13
13
  version: 13.8.0
14
+ nkeys.js:
15
+ specifier: ^1.1.0
16
+ version: 1.1.0
14
17
 
15
18
  pwa:
16
19
  dependencies:
@@ -96,6 +99,9 @@ importers:
96
99
  nats:
97
100
  specifier: ^2.29.1
98
101
  version: 2.29.3
102
+ nkeys.js:
103
+ specifier: ^1.1.0
104
+ version: 1.1.0
99
105
  pg:
100
106
  specifier: ^8.13.1
101
107
  version: 8.20.0