@north-light/cli 0.3.486 → 0.3.488

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 (49) hide show
  1. package/dist/api/contract.d.ts +1 -1
  2. package/dist/api/contract.js +1 -1
  3. package/dist/api/index.d.ts +2 -1
  4. package/dist/build-root.js +1 -1
  5. package/dist/builtin-memory/crouter-sdk/release-notes.md +23 -0
  6. package/dist/builtin-memory/crouter-sdk/resources.md +2 -1
  7. package/dist/builtin-memory/northlight-apps/cli.md +20 -6
  8. package/dist/builtin-memory/northlight-apps/coding-agents.md +9 -7
  9. package/dist/builtin-memory/northlight-apps/guides/manage-your-app.md +1 -0
  10. package/dist/builtin-memory/northlight-apps/quickstart.md +5 -3
  11. package/dist/clients/attach/viewer.js +505 -505
  12. package/dist/core/canvas/recovery.d.ts +5 -0
  13. package/dist/core/canvas/recovery.js +2 -2
  14. package/dist/core/command-plugins/endpoint.js +1 -1
  15. package/dist/core/grants/cover.d.ts +14 -0
  16. package/dist/core/grants/cover.js +1 -0
  17. package/dist/core/grants/mirror.d.ts +3 -1
  18. package/dist/core/grants/mirror.js +2 -2
  19. package/dist/core/human/page-errors.d.ts +4 -0
  20. package/dist/core/human/page-errors.js +1 -1
  21. package/dist/core/human/page.d.ts +10 -3
  22. package/dist/core/human/page.js +1 -1
  23. package/dist/core/peer/ingest.js +1 -1
  24. package/dist/core/peer/stubs.d.ts +4 -0
  25. package/dist/core/peer/stubs.js +1 -1
  26. package/dist/core/peer/version.d.ts +2 -2
  27. package/dist/core/peer/version.js +1 -1
  28. package/dist/core/runtime/revive.js +2 -2
  29. package/dist/daemon/api/app-listener.js +13 -13
  30. package/dist/daemon/api/handlers/human-requests.js +1 -1
  31. package/dist/daemon/api/principal.d.ts +10 -18
  32. package/dist/daemon/api/principal.js +1 -1
  33. package/dist/daemon/peer/authority.d.ts +27 -0
  34. package/dist/daemon/peer/authority.js +1 -0
  35. package/dist/daemon/peer/ingress.d.ts +17 -15
  36. package/dist/daemon/peer/ingress.js +1 -1
  37. package/dist/daemon/peer/relay-service.js +1 -1
  38. package/docs/apps/cli.md +20 -6
  39. package/docs/apps/coding-agents.md +9 -7
  40. package/docs/apps/guides/manage-your-app.md +1 -0
  41. package/docs/apps/quickstart.md +5 -3
  42. package/docs/ci-runner.md +2 -1
  43. package/docs/sdk/release-notes.md +23 -0
  44. package/docs/sdk/resources.md +2 -1
  45. package/package.json +3 -3
  46. package/packages/crouter-identity/dist/index.d.ts +55 -0
  47. package/packages/crouter-identity/dist/index.js +117 -0
  48. package/packages/crouter-identity/package.json +1 -1
  49. package/runtime.lock.json +12 -12
@@ -1 +1 @@
1
- var N=Object.defineProperty;var o=(e,t)=>N(e,"name",{value:t,configurable:!0});import{statSync as P}from"node:fs";import{isAbsolute as T}from"node:path";import{ApiError as s}from"../../../api/index.js";import{readDaemonActionById as C}from"../../../core/canvas/daemon-actions.js";import{isSafeNodeId as I}from"../../../core/canvas/paths.js";import{getNode as b}from"../../../core/canvas/canvas.js";import{assertScope as $}from"../../../core/scopes.js";import{resolvePageComponents as g}from"../../../core/config.js";import{resolveHumanAction as D}from"../../../core/human-actions.js";import{HUMAN_ACTION_NAME_PATTERN as x}from"../../../core/human/action-binding.js";import{createPageRequest as J,getRequest as F}from"../../../core/human/requests.js";import{preparePageSource as M}from"../../../core/human/page-authoring.js";import{publishPage as B,validatePageTicketResponses as G}from"../../../core/human/tickets.js";import{cancelHumanTicket as H,resolvePageTicket as L}from"../../human/finish.js";import{inboxPageProjection as U}from"./inbox-page.js";import{appOf as K}from"../router.js";const X=1e3,q=200,m={placement:"inline",inbox:!0,reply:!1},v=["sessionName","askedBy","emittedAt","profileName","nodeId"];function c(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}o(c,"isPlainObject");function l(e,t,n){for(const r of Object.keys(e))if(!t.includes(r))throw new s(400,"invalid_request",`unexpected field in ${n}: ${r}`)}o(l,"requireNoExtraKeys");function i(e){return new s(400,"invalid_request",e)}o(i,"invalid");function w(e){if(!c(e))throw i("`page` must be an object");if(l(e,["dialect","source"],"`page`"),e.dialect!=="jsx"&&e.dialect!=="html")throw i('`page.dialect` must be "jsx" or "html"');if(typeof e.source!="string"||e.source.trim()==="")throw i("`page.source` must be the authored page text");return{dialect:e.dialect,source:e.source}}o(w,"validatePage");function S(e,t){const n=e??t;if(n!=="inline"&&n!=="panel")throw i('`delivery.placement` must be "inline" or "panel"');return n}o(S,"validatePlacement");function z(e,t){if(e===void 0)return m;if(!c(e))throw i("`delivery` must be an object");l(e,["placement","inbox","reply"],"`delivery`");const n=e.inbox??m.inbox,r=e.reply??m.reply;if(typeof n!="boolean")throw i("`delivery.inbox` must be boolean");if(typeof r!="boolean")throw i("`delivery.reply` must be boolean");if(r&&t===null)throw i("`delivery.reply` requires `requester_node_id`");return{placement:S(e.placement,m.placement),inbox:n,reply:r}}o(z,"validateCreateDelivery");function V(e,t){if(e===void 0)return t;if(!c(e))throw i("`delivery` must be an object");return l(e,["placement"],"`delivery`"),{...t,placement:S(e.placement,t.placement)}}o(V,"validateReplaceDelivery");function Y(e){if(e===void 0)return;if(!c(e))throw i("`source` must be an object");l(e,v,"`source`");const t={};for(const n of v){const r=e[n];if(r!==void 0){if(typeof r!="string"||r==="")throw i(`\`source.${n}\` must be a nonempty string`);t[n]=r}}return t}o(Y,"validateSource");function Q(e){if(typeof e!="string"||e==="")throw i("`creator_cwd` is required");if(!T(e))throw i("`creator_cwd` must be an absolute path");try{if(!P(e).isDirectory())throw i(`\`creator_cwd\` is not a directory: ${e}`)}catch(t){throw t instanceof s?t:i(`\`creator_cwd\` does not exist: ${e}`)}return e}o(Q,"validateCreatorCwd");function W(e){if(e!==void 0){if(!c(e))throw i("`action` must be an object");if(l(e,["name","payload"],"`action`"),typeof e.name!="string"||!x.test(e.name))throw i("`action.name` must be a declared action name");return{name:e.name,payload:e.payload??null}}}o(W,"validateActionSelection");function Z(e){if(e==null)return null;if(typeof e!="string"||!I(e))throw i("`requester_node_id` must be a safe node id or null");return e}o(Z,"validateRequesterNodeId");function ee(e){if(!c(e))throw i("request body is required");l(e,["page","delivery","source","requester_node_id","creator_cwd","action"],"the request body");const t=Y(e.source),n=Z(e.requester_node_id),r=W(e.action);return{page:w(e.page),delivery:z(e.delivery,n),...t===void 0?{}:{source:t},requesterNodeId:n,creatorCwd:Q(e.creator_cwd),...r===void 0?{}:{action:r}}}o(ee,"validateCreateRequest");function te(e,t){if(!c(e))throw i("request body is required");return l(e,["page","delivery"],"the request body"),{page:w(e.page),delivery:V(e.delivery,t)}}o(te,"validateReplaceRequest");function _(e,t,n){if(e!==void 0){if(typeof e!="string"||e.trim()===""||e.length>n)throw i(`\`${t}\` must be nonempty after trim and at most ${n} characters`);return e}}o(_,"optionalBoundedString");function ne(e){if(!c(e))throw i("request body is required");if(l(e,["responses","actor"],"the request body"),!c(e.responses))throw i("`responses` must be an object");const t=_(e.actor,"actor",q);return{responses:e.responses,...t===void 0?{}:{actor:t}}}o(ne,"validateRespondRequest");function re(e){if(e===void 0)return{};if(!c(e))throw i("request body must be an object");l(e,["reason","actor"],"the request body");const t=_(e.reason,"reason",X),n=_(e.actor,"actor",q);return{...t===void 0?{}:{reason:t},...n===void 0?{}:{actor:n}}}o(re,"validateSettleRequest");function h(e){const t=e.params.request_id;if(typeof t!="string"||t==="")throw i("request id is required");return t}o(h,"requestId");function f(e){const t=F(e);if(t===null||t.kind!=="page")throw new s(404,"not_found","no request with that id");return t}o(f,"requirePageRequest");function A(e){try{const t=JSON.parse(e.specification_json);if(!c(t))throw new Error("not an object");return t}catch{throw new s(500,"internal","the request specification could not be read")}}o(A,"manifest");function oe(e){if(e.result_json===null)return null;try{return JSON.parse(e.result_json)}catch{throw new s(500,"internal","the request result could not be read")}}o(oe,"terminalResult");function ie(e){if(e.action_json===null)return null;try{const t=JSON.parse(e.action_json);if(t.schema!=="crtr.human-action-binding/v1"||typeof t.name!="string")throw new Error("invalid action");return t}catch{throw new s(500,"internal","the request action could not be read")}}o(ie,"frozenAction");function ae(e){return e.state!=="settled"?"pending":e.outcome==="answered"?"answered":e.outcome==="dismissed"?"dismissed":"canceled"}o(ae,"requestState");function se(e){if(e.action_delivery_id===null)return{state:"none",attempt:0};const t=C(e.action_delivery_id);if(t===null)return{state:"none",attempt:0};const n=t.state==="acknowledged"?"accepted":t.state==="permanent_failed"?"permanent_failed":t.state==="handed_off"?"running":"pending",r={state:n,attempt:t.attempts};return n==="pending"&&t.nextAttemptAt!==null&&(r.next_attempt_at=new Date(t.nextAttemptAt).toISOString()),t.acknowledgedAt!==null&&(r.accepted_at=new Date(t.acknowledgedAt).toISOString()),t.failedAt!==null&&(r.permanent_failed_at=new Date(t.failedAt).toISOString()),t.lastFailure!==null&&(r.last_failure=t.lastFailure),r}o(se,"deliveryDetail");function y(e){const t=A(e),n=oe(e),r=ae(e),a={request_id:e.request_id,inbox_ticket_id:e.inbox_ticket_id,request_dir:e.request_dir,kind:e.kind,state:r,title:e.title,source:t.source??{},emitted_at:t.source?.emittedAt??e.created_at};e.subtitle!==null&&(a.subtitle=e.subtitle),e.kind==="page"&&(a.page=U(e)),n!==null&&(a.settled_at=e.settled_at??e.updated_at,n.kind==="page"&&r==="answered"&&(a.responses=n.responses),n.kind==="canceled"&&n.reason!==void 0&&(a.reason=n.reason),n.kind==="page"&&n.actor!==void 0&&(a.actor=n.actor),n.kind==="canceled"&&n.actor!==void 0&&(a.actor=n.actor));const u=ie(e);return u!==null&&(a.action={name:u.name,payload:u.payload},a.delivery=se(e)),a}o(y,"requestToDTO");function k(e,t,n,r){try{return M({dialect:e,document:t,source:n,delivery:r,productKinds:g()})}catch(a){throw i(a instanceof Error?a.message:"the page could not be prepared")}}o(k,"prepareOrReject");function de(e){const t=ee(e.body),n=t.requesterNodeId??t.source?.nodeId;if(n!=null){const d=b(n);d!==null&&$(d,"crtr:act")}let r;if(t.action!==void 0){const d=D(t.action.name,t.creatorCwd);if("kind"in d){const E=d.kind==="unknown_action"?`no action named ${d.name} is declared from ${t.creatorCwd}`:`${d.name} is declared in ${d.configOrigin} but unusable: ${d.reason}`;throw new s(400,d.kind,E)}const p=n==null?null:b(n);if(n&&!p)throw new s(404,"not_found","action requester node not found");if(p&&e.principal.class!=="person"&&p.grantee!==K(e.principal))throw new s(403,"permission_error","action requester belongs to another app");r=JSON.stringify({schema:"crtr.human-action-binding/v1",name:t.action.name,payload:t.action.payload,creatorCwd:t.creatorCwd,configOrigin:d.configOrigin,argv:d.argv,cwd:d.cwd,app:p?.grantee??(e.principal.class==="broker"?e.principal.app:"app:terminal")})}const a=k(t.page.dialect,t.page.source,t.source,t.delivery),u=J({prepared:a,requester_node_id:t.requesterNodeId,...r===void 0?{}:{action_json:r}});return{status:201,body:{request_id:u.request_id,request_dir:u.request_dir,state:"pending",...t.action===void 0?{}:{action:{name:t.action.name}},delivery_state:"none"}}}o(de,"handleCreate");function ce(e){return{status:200,body:y(f(h(e)))}}o(ce,"handleGet");function ue(e){const t=f(h(e));if(t.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");const n=A(t),r=te(e.body,n.delivery),a=k(r.page.dialect,r.page.source,n.source,r.delivery);try{B(t.request_dir,a)}catch(u){throw u instanceof Error&&u.message===`page request is not open: ${t.request_id}`?new s(409,"already_settled","the request already has a terminal result"):u}return{status:200,body:y(f(t.request_id))}}o(ue,"handleReplace");function O(e){return e instanceof s?e:e instanceof Error&&e.message.includes("unknown page request")?new s(404,"not_found","no request with that id"):e instanceof Error&&e.message==="page_has_no_response_lifecycle"?new s(409,"not_settleable","this request carries no response lifecycle"):new s(500,"internal","the request could not be settled")}o(O,"settlementError");function R(e){if(e.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");if(!e.settleable)throw new s(409,"not_settleable","this request carries no response lifecycle")}o(R,"requireSettleable");async function le(e){const t=f(h(e)),n=ne(e.body);R(t);try{G(t.request_dir,n.responses)}catch(r){throw i(r instanceof Error?r.message:"the response map is invalid")}try{await L(t.request_id,n.responses,n.actor===void 0?{}:{actor:n.actor})}catch(r){throw O(r)}return{status:200,body:y(f(t.request_id))}}o(le,"handleRespond");async function j(e,t){const n=f(h(e)),r=re(e.body);R(n);try{await H(n.request_id,{...r,disposition:t})}catch(a){throw O(a)}return{status:200,body:y(f(n.request_id))}}o(j,"settle");function fe(){return{status:200,body:{components:[...g()]}}}o(fe,"handleComponents");const Ne=[{method:"GET",pattern:"/v1/human/components",handler:o(()=>fe(),"handler")},{method:"POST",pattern:"/v1/human/requests",scope:"crtr:act",handler:de},{method:"GET",pattern:"/v1/human/requests/:request_id",handler:ce},{method:"POST",pattern:"/v1/human/requests/:request_id/replace",handler:ue},{method:"POST",pattern:"/v1/human/requests/:request_id/respond",handler:le},{method:"POST",pattern:"/v1/human/requests/:request_id/dismiss",handler:o(e=>j(e,"dismissed"),"handler")},{method:"POST",pattern:"/v1/human/requests/:request_id/cancel",handler:o(e=>j(e,"canceled"),"handler")}];export{Ne as humanRequestRoutes};
1
+ var P=Object.defineProperty;var o=(e,t)=>P(e,"name",{value:t,configurable:!0});import{statSync as N}from"node:fs";import{isAbsolute as T}from"node:path";import{ApiError as s}from"../../../api/index.js";import{readDaemonActionById as C}from"../../../core/canvas/daemon-actions.js";import{isSafeNodeId as I}from"../../../core/canvas/paths.js";import{getNode as b}from"../../../core/canvas/canvas.js";import{assertScope as $}from"../../../core/scopes.js";import{resolvePageComponents as g}from"../../../core/config.js";import{resolveHumanAction as D}from"../../../core/human-actions.js";import{HUMAN_ACTION_NAME_PATTERN as x}from"../../../core/human/action-binding.js";import{createPageRequest as J,getRequest as F}from"../../../core/human/requests.js";import{preparePageSource as M}from"../../../core/human/page-authoring.js";import{PageCompilerError as B}from"../../../core/human/page-errors.js";import{publishPage as G,validatePageTicketResponses as H}from"../../../core/human/tickets.js";import{cancelHumanTicket as L,resolvePageTicket as U}from"../../human/finish.js";import{inboxPageProjection as K}from"./inbox-page.js";import{appOf as X}from"../router.js";const z=1e3,q=200,m={placement:"inline",inbox:!0,reply:!1},v=["sessionName","askedBy","emittedAt","profileName","nodeId"];function c(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}o(c,"isPlainObject");function l(e,t,n){for(const r of Object.keys(e))if(!t.includes(r))throw new s(400,"invalid_request",`unexpected field in ${n}: ${r}`)}o(l,"requireNoExtraKeys");function i(e){return new s(400,"invalid_request",e)}o(i,"invalid");function w(e){if(!c(e))throw i("`page` must be an object");if(l(e,["dialect","source"],"`page`"),e.dialect!=="jsx"&&e.dialect!=="html")throw i('`page.dialect` must be "jsx" or "html"');if(typeof e.source!="string"||e.source.trim()==="")throw i("`page.source` must be the authored page text");return{dialect:e.dialect,source:e.source}}o(w,"validatePage");function S(e,t){const n=e??t;if(n!=="inline"&&n!=="panel")throw i('`delivery.placement` must be "inline" or "panel"');return n}o(S,"validatePlacement");function V(e,t){if(e===void 0)return m;if(!c(e))throw i("`delivery` must be an object");l(e,["placement","inbox","reply"],"`delivery`");const n=e.inbox??m.inbox,r=e.reply??m.reply;if(typeof n!="boolean")throw i("`delivery.inbox` must be boolean");if(typeof r!="boolean")throw i("`delivery.reply` must be boolean");if(r&&t===null)throw i("`delivery.reply` requires `requester_node_id`");return{placement:S(e.placement,m.placement),inbox:n,reply:r}}o(V,"validateCreateDelivery");function Y(e,t){if(e===void 0)return t;if(!c(e))throw i("`delivery` must be an object");return l(e,["placement"],"`delivery`"),{...t,placement:S(e.placement,t.placement)}}o(Y,"validateReplaceDelivery");function Q(e){if(e===void 0)return;if(!c(e))throw i("`source` must be an object");l(e,v,"`source`");const t={};for(const n of v){const r=e[n];if(r!==void 0){if(typeof r!="string"||r==="")throw i(`\`source.${n}\` must be a nonempty string`);t[n]=r}}return t}o(Q,"validateSource");function W(e){if(typeof e!="string"||e==="")throw i("`creator_cwd` is required");if(!T(e))throw i("`creator_cwd` must be an absolute path");try{if(!N(e).isDirectory())throw i(`\`creator_cwd\` is not a directory: ${e}`)}catch(t){throw t instanceof s?t:i(`\`creator_cwd\` does not exist: ${e}`)}return e}o(W,"validateCreatorCwd");function Z(e){if(e!==void 0){if(!c(e))throw i("`action` must be an object");if(l(e,["name","payload"],"`action`"),typeof e.name!="string"||!x.test(e.name))throw i("`action.name` must be a declared action name");return{name:e.name,payload:e.payload??null}}}o(Z,"validateActionSelection");function ee(e){if(e==null)return null;if(typeof e!="string"||!I(e))throw i("`requester_node_id` must be a safe node id or null");return e}o(ee,"validateRequesterNodeId");function te(e){if(!c(e))throw i("request body is required");l(e,["page","delivery","source","requester_node_id","creator_cwd","action"],"the request body");const t=Q(e.source),n=ee(e.requester_node_id),r=Z(e.action);return{page:w(e.page),delivery:V(e.delivery,n),...t===void 0?{}:{source:t},requesterNodeId:n,creatorCwd:W(e.creator_cwd),...r===void 0?{}:{action:r}}}o(te,"validateCreateRequest");function ne(e,t){if(!c(e))throw i("request body is required");return l(e,["page","delivery"],"the request body"),{page:w(e.page),delivery:Y(e.delivery,t)}}o(ne,"validateReplaceRequest");function _(e,t,n){if(e!==void 0){if(typeof e!="string"||e.trim()===""||e.length>n)throw i(`\`${t}\` must be nonempty after trim and at most ${n} characters`);return e}}o(_,"optionalBoundedString");function re(e){if(!c(e))throw i("request body is required");if(l(e,["responses","actor"],"the request body"),!c(e.responses))throw i("`responses` must be an object");const t=_(e.actor,"actor",q);return{responses:e.responses,...t===void 0?{}:{actor:t}}}o(re,"validateRespondRequest");function oe(e){if(e===void 0)return{};if(!c(e))throw i("request body must be an object");l(e,["reason","actor"],"the request body");const t=_(e.reason,"reason",z),n=_(e.actor,"actor",q);return{...t===void 0?{}:{reason:t},...n===void 0?{}:{actor:n}}}o(oe,"validateSettleRequest");function h(e){const t=e.params.request_id;if(typeof t!="string"||t==="")throw i("request id is required");return t}o(h,"requestId");function f(e){const t=F(e);if(t===null||t.kind!=="page")throw new s(404,"not_found","no request with that id");return t}o(f,"requirePageRequest");function A(e){try{const t=JSON.parse(e.specification_json);if(!c(t))throw new Error("not an object");return t}catch{throw new s(500,"internal","the request specification could not be read")}}o(A,"manifest");function ie(e){if(e.result_json===null)return null;try{return JSON.parse(e.result_json)}catch{throw new s(500,"internal","the request result could not be read")}}o(ie,"terminalResult");function ae(e){if(e.action_json===null)return null;try{const t=JSON.parse(e.action_json);if(t.schema!=="crtr.human-action-binding/v1"||typeof t.name!="string")throw new Error("invalid action");return t}catch{throw new s(500,"internal","the request action could not be read")}}o(ae,"frozenAction");function se(e){return e.state!=="settled"?"pending":e.outcome==="answered"?"answered":e.outcome==="dismissed"?"dismissed":"canceled"}o(se,"requestState");function de(e){if(e.action_delivery_id===null)return{state:"none",attempt:0};const t=C(e.action_delivery_id);if(t===null)return{state:"none",attempt:0};const n=t.state==="acknowledged"?"accepted":t.state==="permanent_failed"?"permanent_failed":t.state==="handed_off"?"running":"pending",r={state:n,attempt:t.attempts};return n==="pending"&&t.nextAttemptAt!==null&&(r.next_attempt_at=new Date(t.nextAttemptAt).toISOString()),t.acknowledgedAt!==null&&(r.accepted_at=new Date(t.acknowledgedAt).toISOString()),t.failedAt!==null&&(r.permanent_failed_at=new Date(t.failedAt).toISOString()),t.lastFailure!==null&&(r.last_failure=t.lastFailure),r}o(de,"deliveryDetail");function y(e){const t=A(e),n=ie(e),r=se(e),a={request_id:e.request_id,inbox_ticket_id:e.inbox_ticket_id,request_dir:e.request_dir,kind:e.kind,state:r,title:e.title,source:t.source??{},emitted_at:t.source?.emittedAt??e.created_at};e.subtitle!==null&&(a.subtitle=e.subtitle),e.kind==="page"&&(a.page=K(e)),n!==null&&(a.settled_at=e.settled_at??e.updated_at,n.kind==="page"&&r==="answered"&&(a.responses=n.responses),n.kind==="canceled"&&n.reason!==void 0&&(a.reason=n.reason),n.kind==="page"&&n.actor!==void 0&&(a.actor=n.actor),n.kind==="canceled"&&n.actor!==void 0&&(a.actor=n.actor));const u=ae(e);return u!==null&&(a.action={name:u.name,payload:u.payload},a.delivery=de(e)),a}o(y,"requestToDTO");function k(e,t,n,r){try{return M({dialect:e,document:t,source:n,delivery:r,productKinds:g()})}catch(a){throw a instanceof B?a:i(a instanceof Error?a.message:"the page could not be prepared")}}o(k,"prepareOrReject");function ce(e){const t=te(e.body),n=t.requesterNodeId??t.source?.nodeId;if(n!=null){const d=b(n);d!==null&&$(d,"crtr:act")}let r;if(t.action!==void 0){const d=D(t.action.name,t.creatorCwd);if("kind"in d){const E=d.kind==="unknown_action"?`no action named ${d.name} is declared from ${t.creatorCwd}`:`${d.name} is declared in ${d.configOrigin} but unusable: ${d.reason}`;throw new s(400,d.kind,E)}const p=n==null?null:b(n);if(n&&!p)throw new s(404,"not_found","action requester node not found");if(p&&e.principal.class!=="person"&&p.grantee!==X(e.principal))throw new s(403,"permission_error","action requester belongs to another app");r=JSON.stringify({schema:"crtr.human-action-binding/v1",name:t.action.name,payload:t.action.payload,creatorCwd:t.creatorCwd,configOrigin:d.configOrigin,argv:d.argv,cwd:d.cwd,app:p?.grantee??(e.principal.class==="broker"?e.principal.app:"app:terminal")})}const a=k(t.page.dialect,t.page.source,t.source,t.delivery),u=J({prepared:a,requester_node_id:t.requesterNodeId,...r===void 0?{}:{action_json:r}});return{status:201,body:{request_id:u.request_id,request_dir:u.request_dir,state:"pending",...t.action===void 0?{}:{action:{name:t.action.name}},delivery_state:"none"}}}o(ce,"handleCreate");function ue(e){return{status:200,body:y(f(h(e)))}}o(ue,"handleGet");function le(e){const t=f(h(e));if(t.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");const n=A(t),r=ne(e.body,n.delivery),a=k(r.page.dialect,r.page.source,n.source,r.delivery);try{G(t.request_dir,a)}catch(u){throw u instanceof Error&&u.message===`page request is not open: ${t.request_id}`?new s(409,"already_settled","the request already has a terminal result"):u}return{status:200,body:y(f(t.request_id))}}o(le,"handleReplace");function O(e){return e instanceof s?e:e instanceof Error&&e.message.includes("unknown page request")?new s(404,"not_found","no request with that id"):e instanceof Error&&e.message==="page_has_no_response_lifecycle"?new s(409,"not_settleable","this request carries no response lifecycle"):new s(500,"internal","the request could not be settled")}o(O,"settlementError");function R(e){if(e.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");if(!e.settleable)throw new s(409,"not_settleable","this request carries no response lifecycle")}o(R,"requireSettleable");async function fe(e){const t=f(h(e)),n=re(e.body);R(t);try{H(t.request_dir,n.responses)}catch(r){throw i(r instanceof Error?r.message:"the response map is invalid")}try{await U(t.request_id,n.responses,n.actor===void 0?{}:{actor:n.actor})}catch(r){throw O(r)}return{status:200,body:y(f(t.request_id))}}o(fe,"handleRespond");async function j(e,t){const n=f(h(e)),r=oe(e.body);R(n);try{await L(n.request_id,{...r,disposition:t})}catch(a){throw O(a)}return{status:200,body:y(f(n.request_id))}}o(j,"settle");function pe(){return{status:200,body:{components:[...g()]}}}o(pe,"handleComponents");const Te=[{method:"GET",pattern:"/v1/human/components",handler:o(()=>pe(),"handler")},{method:"POST",pattern:"/v1/human/requests",scope:"crtr:act",handler:ce},{method:"GET",pattern:"/v1/human/requests/:request_id",handler:ue},{method:"POST",pattern:"/v1/human/requests/:request_id/replace",handler:le},{method:"POST",pattern:"/v1/human/requests/:request_id/respond",handler:fe},{method:"POST",pattern:"/v1/human/requests/:request_id/dismiss",handler:o(e=>j(e,"dismissed"),"handler")},{method:"POST",pattern:"/v1/human/requests/:request_id/cancel",handler:o(e=>j(e,"canceled"),"handler")}];export{Te as humanRequestRoutes};
@@ -1,5 +1,5 @@
1
- import { JwksCache, type Scope } from '@north-light/identity';
2
- import { type ActiveGrant, type GrantDependencies, type GrantScope } from '../../core/grants/mirror.js';
1
+ import { JwksCache, type CallToken, type Scope } from '@north-light/identity';
2
+ import type { ActiveGrant, GrantDependencies, GrantScope } from '../../core/grants/mirror.js';
3
3
  import { type NoticeTicket } from './sync-notice.js';
4
4
  export interface AppPrincipal {
5
5
  sub: string;
@@ -48,23 +48,15 @@ export declare class AppPrincipalResolver {
48
48
  * the one this route accepts. It names no owner and is not single-use, so
49
49
  * there is no `sub` or `jti` requirement. Every failure is 401. */
50
50
  verifyDirectoryRead(authorization: string | undefined, purpose: string): Promise<void>;
51
- /** grant id → the highest token `rev` a successful fresh pull failed to
52
- * cover, valid only for the mirror generation it was recorded under: any
53
- * later `replaceGrants` clears it. A call at or below it answers from the
54
- * mirror without pulling again (contract 1 "Grant sync"). */
55
- private behind;
56
- private behindGeneration;
57
- private behindRev;
58
- /** The ordering rule (contract 1 "Grant sync"): the mirror covers a token
59
- * stamped `rev = N` when it holds the grant at `rev >= N`, and the decision
60
- * is then the mirror's alone. Otherwise one fresh pull — one that starts
61
- * after this call — is awaited first. A failed pull answers 503; a
62
- * successful pull that still does not cover the token is a directory fault:
63
- * an absent grant is refused `grant_revoked`, a grant below the token's rev
64
- * answers 503, and repeats of either answer without pulling. No clock or
65
- * timer enters the decision. */
66
- private ensureCovers;
51
+ /** Verify a bearer once as a call token: ES256 signature against the JWKS mirror and every check of
52
+ * `verifyCallToken` (any shape: new, superset or legacy). The caller routes on `chain[0]`. Authorizes nothing.
53
+ * 503 before the mirror loads; every other failure is 401. */
54
+ verifyCall(authorization: string | undefined): Promise<CallToken>;
67
55
  resolve(authorization: string | undefined, scope?: string): Promise<AppPrincipal>;
56
+ /** The caller an already-verified app token names. Grantee, grant and rev come from the token's app link
57
+ * (`chain[0]`, which must be the whole chain); the mirror is made to cover the link's `rev` and the grant
58
+ * is read now (`coverGrant`). A token whose presenter is not an app link holding a grant is refused 401. */
59
+ resolveVerified(token: CallToken, scope?: string): Promise<AppPrincipal>;
68
60
  }
69
61
  /** The mirrored grant a caller's token names, read now, when it is still the
70
62
  * caller's: active, the same `sub` and `grantee`, and not past `expires_at`.
@@ -1 +1 @@
1
- var p=Object.defineProperty;var o=(a,e)=>p(a,"name",{value:e,configurable:!0});import{covers as m,JwksCache as _,MissingRevClaimError as b,verifyAccessToken as k}from"@north-light/identity";import{createLocalJWKSet as g,decodeProtectedHeader as f,jwtVerify as w}from"jose";import{emitEvent as x}from"../../core/events/emit.js";import{activeGrant as G,grantSyncState as c,grantsGeneration as R}from"../../core/grants/mirror.js";import{RunError as n}from"../../core/runs/operations.js";import{NoticeLedger as S}from"./sync-notice.js";class A{static{o(this,"AppPrincipalResolver")}issuer;audience;syncGrants;cache;ready=!1;timer;constructor(e,r,t,i){this.issuer=e,this.audience=r,this.syncGrants=t,this.cache=new _(e,fetch,i),this.load(),this.timer=setInterval(()=>{this.ready||this.load()},5e3),this.timer.unref()}async load(){try{await this.cache.load(),this.ready=!0}catch{}}isReady(){return this.ready}get jwks(){return this.cache}close(){this.timer&&clearInterval(this.timer)}ledger=new S;async verifySyncNotice(e){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");return this.ledger.accept(e,this.cache,this.issuer,this.audience)}async verifyHandoff(e){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");const r=o(()=>new n(401,"unauthorized","invalid vendor sign-in handoff"),"refuse");if(!e)throw r();let t;try{const u=f(e);if(u.alg!=="ES256")throw r();const l=await this.cache.forKid(u.kid);({payload:t}=await w(e,g(l),{issuer:this.issuer,audience:this.audience,algorithms:["ES256"],requiredClaims:["exp","jti","sub"]}))}catch{throw r()}const{sub:i,jti:s,exp:h,purpose:d,aud:y}=t;if(d!=="vendor_sign_in"||y!==this.audience||typeof i!="string"||typeof s!="string"||s===""||typeof h!="number")throw r();return{sub:i,jti:s,exp:h}}async verifyDirectoryRead(e,r){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");const t=o(()=>new n(401,"unauthorized","invalid directory token"),"refuse");if(!e?.startsWith("Bearer ")||e.length<=7)throw t();const i=e.slice(7);let s;try{const h=f(i);if(h.alg!=="ES256")throw t();const d=await this.cache.forKid(h.kid);({payload:s}=await w(i,g(d),{issuer:this.issuer,audience:this.audience,algorithms:["ES256"],requiredClaims:["exp"]}))}catch{throw t()}if(s.purpose!==r||s.aud!==this.audience)throw t()}behind=new Map;behindGeneration=-1;behindRev(e){const r=R();return r!==this.behindGeneration&&(this.behind=new Map,this.behindGeneration=r),this.behind.get(e)}async ensureCovers(e){if(v(c(e.grant),e.rev))return;const r=this.behindRev(e.grant);if(r===void 0||e.rev>r){try{await this.syncGrants({fresh:!0})}catch{throw new n(503,"service_unavailable","grant sync failed")}const t=c(e.grant);if(v(t,e.rev))return;this.behindRev(e.grant),this.behind.set(e.grant,Math.max(e.rev,this.behind.get(e.grant)??0)),x({level:"error",event:"daemon.grants.behind_token",fields:{grant:e.grant,token_rev:e.rev,mirror_rev:t?.rev??0}})}throw c(e.grant)?new n(503,"service_unavailable","the directory answered grant sync below the access token's grant revision"):new n(401,"grant_revoked","grant is not active")}async resolve(e,r){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");if(!e?.startsWith("Bearer ")||e.length<=7)throw new n(401,"unauthorized","a directory access token is required");let t;try{t=await k({token:e.slice(7),jwks:this.cache,issuer:this.issuer,audience:this.audience})}catch(s){throw s instanceof b?new n(401,"unauthorized",s.message):new n(401,"unauthorized","invalid directory access token")}await this.ensureCovers(t);const i=E(t);if(r&&!i.resolved.some(s=>m(s,r)))throw new n(403,"scope_missing",`missing scope ${r}`,void 0,void 0,r);return{sub:t.sub,grantee:t.grantee,client_id:t.client_id,grant:t.grant,rev:i.rev,resolved:i.resolved,resolved_scopes:i.resolved_scopes,spend_cap:i.spend_cap,dependencies:i.dependencies??{},exp:t.exp}}}function E(a){const e=G(a.grant);if(!e||e.sub!==a.sub||e.grantee!==a.grantee||e.expires_at!==null&&!(Date.parse(e.expires_at)>Date.now()))throw new n(401,"grant_revoked","grant is not active");return e}o(E,"callerGrant");function v(a,e){return a!==null&&a.rev>=e}o(v,"covered");export{A as AppPrincipalResolver,E as callerGrant};
1
+ var g=Object.defineProperty;var d=(c,e)=>g(c,"name",{value:e,configurable:!0});import{covers as v,JwksCache as m,MissingRevClaimError as p,verifyCallToken as _}from"@north-light/identity";import{createLocalJWKSet as l,decodeProtectedHeader as h,jwtVerify as f}from"jose";import{callerGrantOf as b,coverGrant as k}from"../../core/grants/cover.js";import{RunError as n}from"../../core/runs/operations.js";import{NoticeLedger as E}from"./sync-notice.js";class W{static{d(this,"AppPrincipalResolver")}issuer;audience;syncGrants;cache;ready=!1;timer;constructor(e,t,r,i){this.issuer=e,this.audience=t,this.syncGrants=r,this.cache=new m(e,fetch,i),this.load(),this.timer=setInterval(()=>{this.ready||this.load()},5e3),this.timer.unref()}async load(){try{await this.cache.load(),this.ready=!0}catch{}}isReady(){return this.ready}get jwks(){return this.cache}close(){this.timer&&clearInterval(this.timer)}ledger=new E;async verifySyncNotice(e){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");return this.ledger.accept(e,this.cache,this.issuer,this.audience)}async verifyHandoff(e){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");const t=d(()=>new n(401,"unauthorized","invalid vendor sign-in handoff"),"refuse");if(!e)throw t();let r;try{const u=h(e);if(u.alg!=="ES256")throw t();const y=await this.cache.forKid(u.kid);({payload:r}=await f(e,l(y),{issuer:this.issuer,audience:this.audience,algorithms:["ES256"],requiredClaims:["exp","jti","sub"]}))}catch{throw t()}const{sub:i,jti:s,exp:a,purpose:o,aud:w}=r;if(o!=="vendor_sign_in"||w!==this.audience||typeof i!="string"||typeof s!="string"||s===""||typeof a!="number")throw t();return{sub:i,jti:s,exp:a}}async verifyDirectoryRead(e,t){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");const r=d(()=>new n(401,"unauthorized","invalid directory token"),"refuse");if(!e?.startsWith("Bearer ")||e.length<=7)throw r();const i=e.slice(7);let s;try{const a=h(i);if(a.alg!=="ES256")throw r();const o=await this.cache.forKid(a.kid);({payload:s}=await f(i,l(o),{issuer:this.issuer,audience:this.audience,algorithms:["ES256"],requiredClaims:["exp"]}))}catch{throw r()}if(s.purpose!==t||s.aud!==this.audience)throw r()}async verifyCall(e){if(!this.ready)throw new n(503,"runtime_starting","runtime_starting");if(!e?.startsWith("Bearer ")||e.length<=7)throw new n(401,"unauthorized","a directory access token is required");const t=e.slice(7);try{if(h(t).alg!=="ES256")throw new Error("Invalid call token header");return await _({token:t,jwks:this.cache,issuer:this.issuer,audience:this.audience})}catch(r){throw r instanceof p?new n(401,"unauthorized",r.message):new n(401,"unauthorized","invalid directory access token")}}async resolve(e,t){return this.resolveVerified(await this.verifyCall(e),t)}async resolveVerified(e,t){const r=e.chain[0],i=e.claims;if(e.chain.length!==1||!r||!r.sub.startsWith("app:")||r.grant===void 0||r.rev===void 0||!/^[A-Za-z0-9_-]{1,64}$/.test(i.client_id)||i.grantee!==void 0&&i.grantee!==r.sub||i.rev!==void 0&&i.rev!==r.rev)throw new n(401,"unauthorized","invalid directory access token");const s=await k(r.grant,r.rev,e.sub,r.sub,this.syncGrants);if(t&&!s.resolved.some(a=>v(a,t)))throw new n(403,"scope_missing",`missing scope ${t}`,void 0,void 0,t);return{sub:e.sub,grantee:r.sub,client_id:i.client_id,grant:r.grant,rev:s.rev,resolved:s.resolved,resolved_scopes:s.resolved_scopes,spend_cap:s.spend_cap,dependencies:s.dependencies??{},exp:i.exp}}}function q(c){return b(c.grant,c.sub,c.grantee)}d(q,"callerGrant");export{W as AppPrincipalResolver,q as callerGrant};
@@ -0,0 +1,27 @@
1
+ import type { CallLink, CallToken } from '@north-light/identity';
2
+ import { type GrantSync } from '../../core/grants/cover.js';
3
+ import { type ActiveGrant } from '../../core/grants/mirror.js';
4
+ import type { ForwardedPrincipal, MachinePrincipal } from '../api/router.js';
5
+ /** What a person may do on this runtime. Shares will add `'watch' | 'message' | 'spawn'`; only `owner` grants the person's authority. */
6
+ export type Relation = 'owner';
7
+ export interface AuthorityDeps {
8
+ /** This machine (`runtime:<id>`). */
9
+ self(): string;
10
+ /** The relation `sub` (`user:<id>`) holds to this runtime: ownership is `sub` = the runtime's owner; the share lookup is stubbed to null. */
11
+ relation(sub: string): Relation | null;
12
+ /** The mirrored grant an app link names, covered to the link's revision and checked against the token's `sub`. Throws `RunError` 403 `app_not_granted` or 503 `service_unavailable`. */
13
+ grantLink(link: CallLink, sub: string): Promise<ActiveGrant>;
14
+ }
15
+ export type PeerRefusal = {
16
+ status: number;
17
+ code: string;
18
+ message: string;
19
+ };
20
+ export type PeerAuthority = {
21
+ principal: MachinePrincipal | ForwardedPrincipal;
22
+ } | PeerRefusal;
23
+ /** `deps.grantLink` over the mirror: a link carrying `grant` reads that grant (pulling grants when the mirror is below `link.rev`), a link
24
+ * with none (an app named only in the envelope) reads the app's grant for `sub`. Missing, revoked, foreign or expired is 403 `app_not_granted`. */
25
+ export declare function mirrorGrantLink(syncGrants: GrantSync): AuthorityDeps['grantLink'];
26
+ /** The principal a verified call token plus its envelope yields, or the refusal. Authorizes nothing outside the eight rules in order. */
27
+ export declare function peerAuthority(token: CallToken, envelopeHeader: string | undefined, deps: AuthorityDeps): Promise<PeerAuthority>;
@@ -0,0 +1 @@
1
+ var b=Object.defineProperty;var c=(e,n)=>b(e,"name",{value:n,configurable:!0});import{decodeEnvelope as m,EnvelopeError as v}from"../../core/peer/envelope.js";import{coverGrant as w}from"../../core/grants/cover.js";import{grantFor as g}from"../../core/grants/mirror.js";import{RunError as f}from"../../core/runs/operations.js";const r=c((e,n,o)=>({status:e,code:n,message:o}),"refuse"),l="the envelope does not match its token";function G(e){return async(n,o)=>{const t=c(()=>new f(403,"app_not_granted",`${n.sub} holds no active grant for ${o} on this machine`),"notGranted");if(n.grant===void 0){const i=g(n.sub,o);if(i===null)throw t();return i}try{return await w(n.grant,n.rev??1,o,n.sub,e)}catch(i){throw i instanceof f&&i.status===401?t():i}}}c(G,"mirrorGrantLink");async function L(e,n,o){const t=e.chain[0];if(t===void 0||!t.sub.startsWith("runtime:"))return r(401,"unauthorized","not a machine's token");if(t.sub===o.self())return r(401,"unauthorized","a machine does not call itself");if(e.scope!==void 0)return r(401,"unauthorized","a provider token does not authorize on a machine");const i=o.relation(e.sub);if(i===null)return r(403,"forbidden","no relation reaches this machine");let u;if(n!==void 0){try{u=m(n)}catch(a){return r(400,"invalid_request",a instanceof v?a.message:"invalid envelope")}if(u.act.sub!==t.sub||u.sub!==e.sub)return r(401,"unauthorized",l);if(u.act.act!==void 0&&u.act.act.place===null)return r(400,"invalid_request","the envelope names an app with no run place")}const s=u?.act.act,p=e.chain.slice(1).filter(a=>a.sub.startsWith("app:"));if(p.length>1)return r(401,"unauthorized","the token names more than one app");const d=p[0];if(d!==void 0&&(s===void 0||s.sub!==d.sub))return r(401,"unauthorized",l);const h=d??(s===void 0?void 0:{sub:s.sub});if(h!==void 0)try{await o.grantLink(h,e.sub)}catch(a){if(a instanceof f)return r(a.status,a.code,a.message);throw a}return u===void 0?{principal:{class:"machine",via:t.sub,sub:e.sub}}:s!==void 0?{principal:{class:"forwarded",via:t.sub,sub:e.sub,actor:{kind:"app",app:s.sub,node:s.node,node_scopes:s.node_scopes,place:s.place}}}:i!=="owner"?r(403,"forbidden","only the owner acts as the person"):{principal:{class:"forwarded",via:t.sub,sub:e.sub,actor:{kind:"person"}}}}c(L,"peerAuthority");export{G as mirrorGrantLink,L as peerAuthority};
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { type JwksCache, type MachineTokenClaims } from '@north-light/identity';
3
- import { Router, type RequestPrincipal } from '../api/router.js';
2
+ import { type CallToken, type JwksCache } from '@north-light/identity';
3
+ import { Router } from '../api/router.js';
4
+ import type { GrantSync } from '../../core/grants/cover.js';
4
5
  export interface PeerIngressContext {
5
6
  /** The directory the machine tokens come from. */
6
7
  issuer: string;
@@ -8,27 +9,28 @@ export interface PeerIngressContext {
8
9
  jwks: JwksCache;
9
10
  /** The audience machine tokens must carry: `machineUrl()` on the laptop, `runtime.audience` on the hosted runtime. Null before it is known: every request is refused 503. */
10
11
  audience(): string | null;
11
- /** The account owner (`user:<id>`) this runtime belongs to. Null when it has none: every request is refused 401. */
12
+ /** The account owner (`user:<id>`) this runtime belongs to. Null when it has none: no relation reaches the machine and every request is refused 403. */
12
13
  owner(): string | null;
13
14
  /** This machine's id (`runtime:<id>`). */
14
15
  self(): string;
16
+ /** Pulls the directory's grants when a token's app link is ahead of the mirror. Absent: a mirror behind a token's `rev` answers 503. */
17
+ syncGrants?: GrantSync;
15
18
  /** The router that serves the request; defaults to the daemon's own (`buildRouter(() => null)`), built on first use. */
16
19
  router?: () => Router | Promise<Router>;
17
20
  /** False while the JWKS or grants are not loaded: every request is refused 503 (audited). Defaults to ready. */
18
21
  ready?: () => boolean;
19
22
  }
20
- /** The principal a verified token plus envelope yields, or the refusal. The checks are the C2 row: the envelope's `act.sub` is the token's `machine_id`, its `sub` the token's `sub`. */
21
- export declare function peerPrincipal(claims: Pick<MachineTokenClaims, 'sub' | 'machine_id'>, envelopeHeader: string | undefined): {
22
- principal: RequestPrincipal & {
23
- class: 'forwarded' | 'machine';
24
- };
25
- } | {
26
- status: number;
27
- code: string;
28
- message: string;
29
- };
23
+ /** Whether the daemon's own route table serves this request: the routes `servePeer` hands to its router (`peer.*` and every daemon route a forwarded call reaches). */
24
+ export declare function isDaemonRoute(method: string, pathname: string): Promise<boolean>;
30
25
  /** The account owner this runtime belongs to: the owner a successful `crtr sys join` recorded (a laptop), else — on a hosted runtime, which is
31
26
  * provisioned without join — the one `sub` its mirrored grants all carry. Null when neither names exactly one owner. Never throws. */
32
27
  export declare function runtimeOwner(): string | null;
33
- /** Serve one peer request. Never throws. Every answer writes one `audit_rows` row: a request whose token never verified is recorded with `via` = `unauthenticated` and no identity. */
34
- export declare function servePeer(req: IncomingMessage, res: ServerResponse, ctx: PeerIngressContext): Promise<void>;
28
+ /** A bearer the caller already tried to verify and that failed: `servePeer` answers it as its own refusal (and audits it). */
29
+ export interface PeerRefusal {
30
+ status: number;
31
+ code: string;
32
+ message: string;
33
+ }
34
+ /** Serve one peer request; `verified` is a token the caller already checked against this runtime's audience, or the refusal its check ended in (so verification
35
+ * happens once, in the caller). Never throws. Every answer writes one `audit_rows` row: a request whose token never verified is recorded with `via` = `unauthenticated` and no identity. */
36
+ export declare function servePeer(req: IncomingMessage, res: ServerResponse, ctx: PeerIngressContext, verified?: CallToken | PeerRefusal): Promise<void>;
@@ -1 +1 @@
1
- var S=Object.defineProperty;var s=(e,n)=>S(e,"name",{value:n,configurable:!0});import{randomUUID as q}from"node:crypto";import{verifyMachineToken as j}from"@north-light/identity";import{decodeEnvelope as A,ENVELOPE_HEADER as C,EnvelopeError as H}from"../../core/peer/envelope.js";import{peerVersionRefusal as I,PEER_VERSION_HEADER as P}from"../../core/peer/version.js";import{auditOutcome as k,writePeerAudit as U}from"../../core/audit.js";import{emitEvent as R}from"../../core/events/emit.js";import{joinedOwner as D}from"../../core/directory/join.js";import{allGrants as L}from"../../core/grants/mirror.js";import{errorCodes as N}from"../../api/error-codes.js";let z;function T(){return z??=import("../api/server.js").then(e=>e.buildRouter(()=>null)),z}s(T,"defaultRouter");function V(e,n,r,t,u,c){if(e.headersSent){e.destroy();return}const p=N[r];e.writeHead(n,{"content-type":"application/json; charset=utf-8","Request-Id":u,"X-Content-Type-Options":"nosniff"}),e.end(JSON.stringify({error:{code:r,message:t,...c===void 0?{}:{details:c},...p?{type:p.type}:{},request_id:u}}))}s(V,"refuse");const _=s((e,n)=>{const r=e.headers[n.toLowerCase()];return Array.isArray(r)?r[0]:r},"header");function G(e,n){if(n===void 0)return{principal:{class:"machine",via:e.machine_id,sub:e.sub}};let r;try{r=A(n)}catch(u){return{status:400,code:"invalid_request",message:u instanceof H?u.message:"invalid envelope"}}if(r.act.sub!==e.machine_id||r.sub!==e.sub)return{status:401,code:"unauthorized",message:"the envelope does not match the machine token"};const t=r.act.act;return t===void 0?{principal:{class:"forwarded",via:e.machine_id,sub:e.sub,actor:{kind:"person"}}}:t.place===null?{status:400,code:"invalid_request",message:"the envelope names an app with no run place"}:{principal:{class:"forwarded",via:e.machine_id,sub:e.sub,actor:{kind:"app",app:t.sub,node:t.node,node_scopes:t.node_scopes,place:t.place}}}}s(G,"peerPrincipal");function Z(){try{const e=D();if(e!==null)return e;const n=new Set(L().map(r=>r.sub));return n.size===1?[...n][0]:null}catch{return null}}s(Z,"runtimeOwner");async function x(e,n,r){const t=n.getHeader("Request-Id")?.toString()??q();n.setHeader("Request-Id",t);const u=_(e,C),c=new URL(e.url??"/","http://peer.local"),p=(e.method??"GET").toUpperCase();let d={class:u===void 0?"machine":"forwarded",grantee:null,sub:null,via:"unauthenticated",operation:`${p} ${c.pathname}`,node_id:null},v=!1;const b=s((a,l={})=>{if(!v){v=!0;try{U({request_id:t,resource:c.pathname,...d,...l,outcome:a})}catch(o){R({level:"error",event:"daemon.peer.audit_failed",error:o instanceof Error?o:new Error(String(o)),fields:{request_id:t}})}}},"record"),i=s((a,l,o,m)=>{b(k(a)),V(n,a,l,o,t,m)},"deny");try{if(r.ready&&!r.ready()){i(503,"runtime_starting","runtime_starting");return}const a=_(e,"authorization"),l=r.audience();if(l===null){i(503,"runtime_starting","this machine does not yet know its own address");return}if(!a?.startsWith("Bearer ")||a.length<=7){i(401,"unauthorized","a machine token is required");return}let o;try{o=await j({token:a.slice(7),jwks:r.jwks,issuer:r.issuer,audience:l})}catch{i(401,"unauthorized","invalid machine token");return}d={...d,grantee:o.sub,sub:o.sub,via:o.machine_id};const m=r.owner();if(m===null||o.sub!==m){i(401,"unauthorized","the machine token is for another account");return}if(o.machine_id===r.self()){i(401,"unauthorized","a machine does not call itself");return}const g=await(r.router??T)(),y=g.match(p,c.pathname);d={...d,operation:y?.operation.name??d.operation,node_id:y?.params.id??null};const h=I(_(e,P));if(h!==null){i(426,h.code,h.message,h.details);return}const f=G(o,u);if("status"in f){i(f.status,f.code,f.message);return}const{principal:w}=f,E=w.class==="forwarded"&&w.actor.kind==="app"?w.actor:null,O={grantee:E?.app??o.sub,node_id:E?.node??d.node_id};n.once("close",()=>b(n.writableFinished?k(n.statusCode):"aborted",O)),await g.handle(e,n,null,w)}catch(a){R({level:"error",event:"daemon.peer.ingress_failed",error:a instanceof Error?a:new Error(String(a)),fields:{request_id:t}}),i(500,"server_failure","peer request failed")}}s(x,"servePeer");export{G as peerPrincipal,Z as runtimeOwner,x as servePeer};
1
+ var H=Object.defineProperty;var o=(e,t)=>H(e,"name",{value:t,configurable:!0});import{randomUUID as j}from"node:crypto";import{verifyCallToken as z}from"@north-light/identity";import{ENVELOPE_HEADER as I}from"../../core/peer/envelope.js";import{mirrorGrantLink as L,peerAuthority as U}from"./authority.js";import{peerVersionRefusal as q,PEER_VERSION_HEADER as D}from"../../core/peer/version.js";import{auditOutcome as v,writePeerAudit as G}from"../../core/audit.js";import{emitEvent as C}from"../../core/events/emit.js";import{joinedOwner as P}from"../../core/directory/join.js";import{allGrants as N}from"../../core/grants/mirror.js";import{errorCodes as T}from"../../api/error-codes.js";let O;function S(){return O??=import("../api/server.js").then(e=>e.buildRouter(()=>null)),O}o(S,"defaultRouter");async function x(e,t){return(await S()).match(e.toUpperCase(),t)!==null}o(x,"isDaemonRoute");function V(e,t,r,a,i,m){if(e.headersSent){e.destroy();return}const l=T[r];e.writeHead(t,{"content-type":"application/json; charset=utf-8","Request-Id":i,"X-Content-Type-Options":"nosniff"}),e.end(JSON.stringify({error:{code:r,message:a,...m===void 0?{}:{details:m},...l?{type:l.type}:{},request_id:i}}))}o(V,"refuse");const _=o((e,t)=>{const r=e.headers[t.toLowerCase()];return Array.isArray(r)?r[0]:r},"header");function ee(){try{const e=P();if(e!==null)return e;const t=new Set(N().map(r=>r.sub));return t.size===1?[...t][0]:null}catch{return null}}o(ee,"runtimeOwner");async function te(e,t,r,a){const i=t.getHeader("Request-Id")?.toString()??j();t.setHeader("Request-Id",i);const m=_(e,I),l=new URL(e.url??"/","http://peer.local"),E=(e.method??"GET").toUpperCase();let d={class:m===void 0?"machine":"forwarded",grantee:null,sub:null,via:"unauthenticated",operation:`${E} ${l.pathname}`,node_id:null},R=!1;const b=o((n,c={})=>{if(!R){R=!0;try{G({request_id:i,resource:l.pathname,...d,...c,outcome:n})}catch(u){C({level:"error",event:"daemon.peer.audit_failed",error:u instanceof Error?u:new Error(String(u)),fields:{request_id:i}})}}},"record"),s=o((n,c,u,f)=>{b(v(n)),V(t,n,c,u,i,f)},"deny");try{if(r.ready&&!r.ready()){s(503,"runtime_starting","runtime_starting");return}let n;if(a!==void 0&&!("chain"in a)){s(a.status,a.code,a.message);return}if(a!==void 0)n=a;else{const y=r.audience();if(y===null){s(503,"runtime_starting","this machine does not yet know its own address");return}const g=_(e,"authorization");if(!g?.startsWith("Bearer ")||g.length<=7){s(401,"unauthorized","a machine token is required");return}try{n=await z({token:g.slice(7),jwks:r.jwks,issuer:r.issuer,audience:y})}catch{s(401,"unauthorized","invalid machine token");return}}d={...d,grantee:n.sub,sub:n.sub,via:n.chain[0]?.sub??"unauthenticated"};const c=await(r.router??S)(),u=c.match(E,l.pathname);d={...d,operation:u?.operation.name??d.operation,node_id:u?.params.id??null};const f=r.owner(),p=await U(n,m,{self:r.self,relation:o(y=>f!==null&&y===f?"owner":null,"relation"),grantLink:L(r.syncGrants??(async()=>{}))});if("status"in p){s(p.status,p.code,p.message);return}const h=q(_(e,D));if(h!==null){s(426,h.code,h.message,h.details);return}const{principal:w}=p,k=w.class==="forwarded"&&w.actor.kind==="app"?w.actor:null,A={grantee:k?.app??n.sub,node_id:k?.node??d.node_id};t.once("close",()=>b(t.writableFinished?v(t.statusCode):"aborted",A)),await c.handle(e,t,null,w)}catch(n){C({level:"error",event:"daemon.peer.ingress_failed",error:n instanceof Error?n:new Error(String(n)),fields:{request_id:i}}),s(500,"server_failure","peer request failed")}}o(te,"servePeer");export{x as isDaemonRoute,ee as runtimeOwner,te as servePeer};
@@ -1 +1 @@
1
- var U=Object.defineProperty;var o=(e,c)=>U(e,"name",{value:c,configurable:!0});import{JwksCache as E}from"@north-light/identity";import{randomUUID as N}from"node:crypto";import{readConfig as k}from"../../core/config.js";import{hasSyncedGrants as O}from"../../core/grants/mirror.js";import{emitEvent as _}from"../../core/events/emit.js";import{directoryIssuer as b,machineUrl as j,ownMachineId as R}from"../../core/peer/directory.js";import{RunError as l}from"../../core/runs/operations.js";import{createResumeDetector as H}from"../resume-detector.js";import{applySyncNotice as M,NoticeLedger as J}from"../api/sync-notice.js";import{runtimeOwner as T,servePeer as D}from"./ingress.js";import{startLocalIngress as G}from"./local-ingress.js";import{startRelayClient as L}from"./relay-client.js";const $="/v1/runtime/sync-notice",z=5e3;async function A(e,c,f){if(e.headersSent){e.destroy();return}e.writeHead(c,{"content-type":"application/json; charset=utf-8"}),e.end(JSON.stringify(f))}o(A,"answerJson");function oe(e){const c=e.unsandboxed??(()=>k("user").runtime?.unsandboxed===!0),f=e.issuer??b;let s;const S=new J;async function m(){const t=s;s=void 0,t!==void 0&&(clearInterval(t.resume),await t.client.stop(),await t.ingress.close())}o(m,"stopStarted");async function C(t,r){const i=new E(r,fetch,e.jwksUrl?e.jwksUrl():k("user").runtime?.jwksUrl),d={issuer:r,jwks:i,audience:j,owner:T,self:R,ready:o(()=>O(r.replace(/\/$/,"")),"ready"),...e.ingress},w=await G(o(async(a,u)=>{const x=(a.url??"").split("?",1)[0];if(a.method==="POST"&&x===$){a.resume();try{const n=d.audience();if(n===null||d.ready?.()===!1)throw new l(503,"runtime_starting","runtime_starting");await M(await S.accept(a.headers.authorization,i,r,n),e.syncGrants,N()),u.writeHead(204),u.end()}catch(n){const I=n instanceof l?n.status:500;await A(u,I,{error:{code:n instanceof l?n.code:"server_failure",message:n instanceof l?n.message:"sync notice failed"}})}return}await D(a,u,d)},"serve"),e.socketPath);let y;try{y=L({issuer:r,relayOrigin:t,socketPath:w.socketPath,...e.client})}catch(a){throw await w.close(),a}const P=H(),v=setInterval(()=>{P.check()&&(_({level:"info",event:"daemon.peer.relay_resume"}),y.reconnectNow())},e.resumeCheckMs??z);v.unref(),s={relay:t,ingress:w,client:y,resume:v,jwks:i},_({level:"info",event:"daemon.peer.relay_started",fields:{relay:t}})}o(C,"begin");let h=Promise.resolve(),p=!1;const g=o(t=>{const r=h.then(t);return h=r.catch(()=>{}),r},"serial");return{observe(t){return g(async()=>{if(p)return;const r=f();if(t===null)return;if(t.reach?.kind!=="relay"||!c()||r===null){await m();return}const i=t.reach.relay.replace(/\/$/,"");s?.relay!==i&&(await m(),await C(i,r))})},running:o(()=>s!==void 0,"running"),stop(){return p=!0,g(m)}}}o(oe,"createPeerRelay");export{z as RESUME_CHECK_MS,$ as SYNC_NOTICE_PATH,oe as createPeerRelay};
1
+ var U=Object.defineProperty;var o=(e,i)=>U(e,"name",{value:i,configurable:!0});import{JwksCache as E}from"@north-light/identity";import{randomUUID as N}from"node:crypto";import{readConfig as k}from"../../core/config.js";import{hasSyncedGrants as O}from"../../core/grants/mirror.js";import{emitEvent as _}from"../../core/events/emit.js";import{directoryIssuer as b,machineUrl as j,ownMachineId as R}from"../../core/peer/directory.js";import{RunError as l}from"../../core/runs/operations.js";import{createResumeDetector as G}from"../resume-detector.js";import{applySyncNotice as H,NoticeLedger as M}from"../api/sync-notice.js";import{runtimeOwner as J,servePeer as T}from"./ingress.js";import{startLocalIngress as D}from"./local-ingress.js";import{startRelayClient as L}from"./relay-client.js";const $="/v1/runtime/sync-notice",z=5e3;async function A(e,i,f){if(e.headersSent){e.destroy();return}e.writeHead(i,{"content-type":"application/json; charset=utf-8"}),e.end(JSON.stringify(f))}o(A,"answerJson");function oe(e){const i=e.unsandboxed??(()=>k("user").runtime?.unsandboxed===!0),f=e.issuer??b;let s;const S=new M;async function m(){const t=s;s=void 0,t!==void 0&&(clearInterval(t.resume),await t.client.stop(),await t.ingress.close())}o(m,"stopStarted");async function C(t,r){const c=new E(r,fetch,e.jwksUrl?e.jwksUrl():k("user").runtime?.jwksUrl),d={issuer:r,jwks:c,audience:j,owner:J,self:R,ready:o(()=>O(r.replace(/\/$/,"")),"ready"),syncGrants:e.syncGrants,...e.ingress},w=await D(o(async(a,u)=>{const x=(a.url??"").split("?",1)[0];if(a.method==="POST"&&x===$){a.resume();try{const n=d.audience();if(n===null||d.ready?.()===!1)throw new l(503,"runtime_starting","runtime_starting");await H(await S.accept(a.headers.authorization,c,r,n),e.syncGrants,N()),u.writeHead(204),u.end()}catch(n){const I=n instanceof l?n.status:500;await A(u,I,{error:{code:n instanceof l?n.code:"server_failure",message:n instanceof l?n.message:"sync notice failed"}})}return}await T(a,u,d)},"serve"),e.socketPath);let y;try{y=L({issuer:r,relayOrigin:t,socketPath:w.socketPath,...e.client})}catch(a){throw await w.close(),a}const P=G(),v=setInterval(()=>{P.check()&&(_({level:"info",event:"daemon.peer.relay_resume"}),y.reconnectNow())},e.resumeCheckMs??z);v.unref(),s={relay:t,ingress:w,client:y,resume:v,jwks:c},_({level:"info",event:"daemon.peer.relay_started",fields:{relay:t}})}o(C,"begin");let h=Promise.resolve(),p=!1;const g=o(t=>{const r=h.then(t);return h=r.catch(()=>{}),r},"serial");return{observe(t){return g(async()=>{if(p)return;const r=f();if(t===null)return;if(t.reach?.kind!=="relay"||!i()||r===null){await m();return}const c=t.reach.relay.replace(/\/$/,"");s?.relay!==c&&(await m(),await C(c,r))})},running:o(()=>s!==void 0,"running"),stop(){return p=!0,g(m)}}}o(oe,"createPeerRelay");export{z as RESUME_CHECK_MS,$ as SYNC_NOTICE_PATH,oe as createPeerRelay};
package/docs/apps/cli.md CHANGED
@@ -5,7 +5,9 @@ description: The npx @north-light/sdk commands for scaffolding an app, logging i
5
5
 
6
6
  # CLI reference
7
7
 
8
- `npx @north-light/sdk <command>` runs the package's one command-line tool. Run it with no command, or with `--help`, to print the list below. An unknown command prints the list and exits 1.
8
+ `npx @north-light/sdk <command>` runs the package's one command-line tool. Run it with no command, or with `--help`, to print the list below. `npx @north-light/sdk <command> --help` (or `-h`) prints that command's usage and exits 0. An unknown command, or a bad flag, prints the usage and exits 1.
9
+
10
+ With no dependencies installed, `npx` downloads the newest published release of the SDK (the one tagged latest on npm), not the version your project pins. Run `npm i` in the project first if the pinned version must run; `init` pins the version of the CLI that ran it.
9
11
 
10
12
  | Command | What it does | Needs crtr |
11
13
  |---|---|---|
@@ -13,11 +15,11 @@ description: The npx @north-light/sdk commands for scaffolding an app, logging i
13
15
  | `npx @north-light/sdk skill [--agent claude\|agents\|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
14
16
  | `npx @north-light/sdk keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
15
17
  | `npx @north-light/sdk env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br/>`npx @north-light/sdk env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
16
- | `npx @north-light/sdk login [--no-browser] [--new] [--issuer <url>]` | Log this computer in to your Northlight account: prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json`. Run it again if it times out: it resumes the same code. Calls only the Northlight directory. | No |
17
- | `npx @north-light/sdk logout [--issuer <url>]` | Revoke this computer's login on the server and delete it locally. | No |
18
+ | `npx @north-light/sdk login [--no-browser] [--new] [--issuer <url>]` | Log this computer in to your Northlight account: prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json`. Run it again if it times out or is interrupted: it resumes the same code. When this computer is already logged in it says so and exits 0; `--new` logs in again. Calls only the Northlight directory. | No |
19
+ | `npx @north-light/sdk logout [--issuer <url>]` | Revoke this computer's login on the server and remove it from the credentials file. The file stays, without that login. | No |
18
20
  | `npx @north-light/sdk whoami [--issuer <url>]` | Show the account this computer is logged in as and when the login expires. Exits 1 when not logged in. | No |
19
21
  | `npx @north-light/sdk register [--name <text>] [--scope <scope>]... [--logo <url>] [--justification <text>] [--redirect-uri <uri>]... [--issuer <url>]` | Run in a project made by `init`, after `login`: registers the app with the public key from `local/app-key.json`, adds each `--scope` beyond `crtr:llm crtr:act`, and writes `CROUTER_CLIENT_ID` and `CROUTER_ISSUER` to `local/app.env`. | No |
20
- | `npx @north-light/sdk apps <leaf> [--app <client id>] [--issuer <url>] [leaf flags] [--json]` | Call one app-management leaf with the login and print its answer as JSON. Leaves: `mine`, `get`, `update`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys list\|add\|retire\|delete\|set-jwks-uri\|remove-jwks-uri`, `testers list\|add\|remove`. `--app` defaults to `CROUTER_CLIENT_ID` in `local/app.env`; `keys add` takes `--key-file <path>` and sends only the public half. Deleting an app, its marketplace listing, spend cap, sharing and provider role are console-only. | No |
22
+ | `npx @north-light/sdk apps <leaf> [--app <client id>] [--issuer <url>] [leaf flags] [--json]` | Call one app-management leaf with the login and print its answer as JSON. Leaves: `mine`, `get`, `update`, `set-logo --file <path>`, `set-brand-color --color <#rrggbb|none>`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys list\|add\|retire\|delete\|set-jwks-uri\|remove-jwks-uri`, `testers list\|add\|remove`. `--app` defaults to `CROUTER_CLIENT_ID` in `local/app.env`; `keys add` takes `--key-file <path>` and sends only the public half. Deleting an app, its marketplace listing, monthly spend cap, sharing and provider role are console-only: those leaves exit 1 and name the console page, with no request. | No |
21
23
 
22
24
  `keygen` and `env` are the only commands that can call `crtr`, and only with `--app`. The documented path on this site never uses `--app`, so you do not need crtr. `login`, `logout`, `whoami`, `register` and `apps` call only the Northlight directory.
23
25
 
@@ -33,11 +35,23 @@ It never overwrites a file. On a refused step it exits non-zero and names the fi
33
35
 
34
36
  ### `login`, `logout`, `whoami`
35
37
 
36
- `login` prints a link and a code and waits while you approve in the browser, then saves a revocable login to `~/.config/northlight/credentials.json`. `--no-browser` does not open the link; `--new` discards a pending code and starts a fresh one. `whoami` shows the account and when the login expires, and exits 1 when this computer is not logged in. `logout` revokes the login on the server and deletes the local file. The login can manage your apps; it cannot delete one (not yet checked on production).
38
+ `login` prints a link and a code and waits while you approve in the browser, then saves a revocable login to `~/.config/northlight/credentials.json`. `--no-browser` does not open the link; `--new` discards a pending code and starts a fresh one.
39
+
40
+ What `login` does when it runs again:
41
+
42
+ - **A code is still pending** (the earlier run was killed, timed out or lost its connection): it prints "Still waiting for the login started at HH:MM." with the same link and code, and keeps polling. Do not pass `--new`.
43
+ - **This computer is already logged in**: it prints "Already logged in as … Nothing to do." and exits 0, without starting another login. `--new` makes a new login and revokes the old one once the new one is approved.
44
+ - **The saved login was revoked or has expired**: it says so and starts a new login.
45
+ - **The directory cannot be reached while it waits**: a failed poll is retried for up to 3 minutes. If it still cannot reach the directory it exits 1 and says the code is still saved, until when, and to run `login` again. Nothing is lost.
46
+ - **The code expired** (10 minutes) **or was declined**: it exits 1 and says which. Run `login` again for a new code.
47
+
48
+ It exits 0 only when the login is saved or was already there.
49
+
50
+ `whoami` shows the account and when the login expires, and exits 1 when this computer is not logged in. `logout` revokes the login on the server and removes it from the credentials file, which stays on disk as `{"version":1,"issuers":{}}` when no other login is in it. The login can manage your apps; it cannot delete one: `apps delete` exits 1 and names the console page, and a raw call to the directory's delete answers 403 `provider_refused`.
37
51
 
38
52
  ### `register`
39
53
 
40
- Run it in the project `init` made, after `login`. It sends the app's origin from `local/app.env`, the public half of `local/app-key.json` and the permissions you pass with `--scope`, and writes the client ID and issuer it gets back into `local/app.env`. `crtr:llm` and `crtr:act` are always requested (not yet checked on production).
54
+ Run it in the project `init` made, after `login`. It sends the app's origin from `local/app.env`, the public half of `local/app-key.json` and the permissions you pass with `--scope`, and writes the client ID and issuer it gets back into `local/app.env`. `crtr:llm` and `crtr:act` are always requested.
41
55
 
42
56
  ### `apps`
43
57
 
@@ -46,9 +46,11 @@ An agent that has no project yet can read the hosted copy:
46
46
 
47
47
  The agent runs `npx @north-light/sdk@latest init`, then `npx @north-light/sdk login`, which prints a link and a code. It shows you both. You open the link, check the page shows the same code, and choose **Allow**. The agent then runs `npx @north-light/sdk register --scope sms:send`, installs, starts the app, and asks you to open `/auth/login` and choose **Allow** on the consent page. You sign up for Northlight first. The hosted copy matches the SDK version the docs site was last built from.
48
48
 
49
- If the agent's shell call times out while you approve, it runs `login` again and the same code resumes. If it cannot reach `account.northlight.ai`, it falls back to asking you to register in the console.
49
+ If the agent's shell call times out or is interrupted while you approve, it runs `login` again and the same code resumes. If you approved already, `login` says "Already logged in" and exits 0: the agent goes on to `register`. A code left unapproved for 10 minutes expires; `login` then exits 1 saying the code expired, and the agent runs it again for a new code and asks you to approve that one. Only a message that says it could not reach `account.northlight.ai` means a network problem, and even then `login` says the code is still saved and can be resumed. An expired code or a failed run is not a reason to fall back to the console.
50
50
 
51
- Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory) before `login` existed: given only the skill URL plus "I want the quickstart app: sign in with Northlight, start a run, text me", it ran `init my-app`, read the generated `server.ts`, and stopped with the console steps and the public key for you to paste. Name what you want built in the prompt. The journey with `login` and `register` has not been run by an agent on production (not yet checked on production).
51
+ The skill tells the agent to read the whole message `login` printed and its exit code before it tells you what happened.
52
+
53
+ Checked on production with Claude Code (an interactive session, no crtr on `PATH`, an empty directory), given only the skill URL plus the quickstart request: a fresh session ran `init`, `login`, `npm i`, `register --scope sms:send` and `npm start` by itself. The person's part was choosing **Allow** on the login page and on the app's consent page, and pressing the button; the run replied `pong` and the text was sent (whether it reached the phone is not checked). When the first login code expired unapproved, that session said so and ran `login` again for a new code. In an earlier session on the same day, before this guidance, the agent misread a failed second `login` as a network timeout and fell back to the console steps. Name what you want built in the prompt.
52
54
 
53
55
  ## What the login can do
54
56
 
@@ -57,19 +59,19 @@ Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory) be
57
59
  | It can | It cannot |
58
60
  |---|---|
59
61
  | Register new apps (at most 10 a day) | Delete an app |
60
- | Change a name, origin, logo and description | List an app in the marketplace, or change its spend cap or sharing |
62
+ | Change a name, origin, logo and description, upload a logo image, set the brand colour (also on a verified app) | List an app in the marketplace, or change its spend cap or sharing |
61
63
  | Change what an app asks for and requires | Set the provider role |
62
64
  | Add and remove testers | See private keys, or use your runtime, your AI subscription, your texts or your data |
63
65
  | Change signing keys, redirect URIs and the run notice URL on an app in testing | Change those on a verified app: do it in the console |
64
66
  | | Approve another login |
65
67
 
66
- Deleting an app, its listing, spend cap and sharing stay in the console on purpose. The CLI refuses them without sending a request, and the directory refuses them if a call is made by hand.
68
+ Deleting an app, its listing, spend cap and sharing stay in the console on purpose. The CLI refuses them without sending a request, and the directory refuses them (403 `provider_refused`) if a call is made by hand. Checked on production for delete, the marketplace listing and the spend cap.
67
69
 
68
- The login lives in `~/.config/northlight/credentials.json` (`$XDG_CONFIG_HOME/northlight/credentials.json` when set), mode `0600`, outside your project. The CLI refuses a file that other users can read. It lasts 30 days, or until it has gone 14 days unused, and then you run `login` again. An account holds at most 10 logins; an eleventh replaces the oldest.
70
+ The login lives in `~/.config/northlight/credentials.json` (`$XDG_CONFIG_HOME/northlight/credentials.json` when set), mode `0600`, outside your project. The CLI refuses a file that other users can read. It lasts 30 days, or until it has gone 14 days unused, and then you run `login` again. An account holds at most 10 logins; an eleventh replaces the oldest. The Developer access page shows the date the login goes unused, "Expires Oct 31" for a login approved on Oct 17, not the 30-day date the CLI prints.
69
71
 
70
- Revoke it on your account's Developer access page (`https://account.northlight.ai/account/developer`), or run `npx @north-light/sdk logout`, which revokes it on the server and deletes the file. The next command then fails and asks for `login`.
72
+ Revoke it on your account's Developer access page (`https://account.northlight.ai/account/developer`), or run `npx @north-light/sdk logout`, which revokes it on the server and removes it from the file. The file is not deleted: it is rewritten as `{"version":1,"issuers":{}}`, with no secret in it. A revoked login stops working at once: `whoami` and every other command fail with "This login was revoked" and ask for `login`, and a token minted before the revoke is refused with 401 `credential_revoked` even before it expires. Checked on production: the Developer access page, Revoke, `logout`, and what the next command prints.
71
73
 
72
- Not yet checked on production: every statement in this section, including the limits and the page.
74
+ Not yet checked on production: the 10-login limit and the eleventh login replacing the oldest, and the 14-day and 30-day expiries.
73
75
 
74
76
  ## How it stays current
75
77
 
@@ -51,6 +51,7 @@ Until you retire the old key it is your rollback. After you retire it, a sign-in
51
51
  ## Profile
52
52
 
53
53
  - **How people see your app**: Name, Origin URL, Logo URL, and **Why your app needs access** (quoted on the consent page as your words). **Save details**. From the terminal: `apps update`.
54
+ - **Logo image and brand colour from the terminal.** `apps update --logo <url>` sets the logo URL. `apps set-logo --file <path>` uploads an image instead (PNG, JPEG, WebP or SVG, square, at least 256x256, at most 512 KB), and `apps set-brand-color --color <#rrggbb>` sets the colour (`--color none` clears it). Both work on testing and verified apps.
54
55
  - **Marketplace**: **Listed in the marketplace**, Price, **Sign-in start URL** and **Text welcome**. Agents can install a listed app for a person by text. A testing app stays out of the marketplace even when listed. The sign-in start URL is where Install sends people; your `/auth/login` route is a valid one, and a `localhost` URL is accepted. The text welcome is one or two plain sentences, at most 120 characters, with no links.
55
56
  - **Delete this app.** At the bottom of the tab; see [Delete an app](#delete-an-app).
56
57
 
@@ -89,7 +89,9 @@ and "Done. Go back to your terminal." in the browser. The name, id and dates are
89
89
 
90
90
  The login is saved outside your project, in `~/.config/northlight/credentials.json` (mode `0600`). Never commit, paste or print it.
91
91
 
92
- Not yet checked on production: this whole step, including the confirm page and its wording (not yet checked on production). The command, its output and its files are checked against a fake directory in the package's tests.
92
+ Checked on production: this whole step, including the confirm page and its wording. The confirm page reads "Let a coding agent manage your Northlight apps?", shows the code, the computer's name and platform and the address it asked from ("The computer named itself; Northlight did not check it"), and lists what the login can and can't do. After **Allow** the page reads "Done / Go back to your terminal." and the terminal prints the output above.
93
+
94
+ If you run `login` again, it does not start over. While a code is pending it prints "Still waiting for the login started at HH:MM." with the same link and code. Once you are logged in it prints "Already logged in as …" and exits 0. If the code expired unapproved it exits 1 and says so: run it again for a new code. See [the CLI reference](/docs/cli#login-logout-whoami).
93
95
 
94
96
  If `login` cannot reach `account.northlight.ai`, or you would rather use the browser, skip to [Register in the browser instead](#register-in-the-browser-instead).
95
97
 
@@ -99,7 +101,7 @@ If `login` cannot reach `account.northlight.ai`, or you would rather use the bro
99
101
  npx @north-light/sdk register --scope sms:send
100
102
  ```
101
103
 
102
- Run it in the folder `init` made. It registers the app with the public key from `local/app-key.json`, the name from `package.json`, the origin from `local/app.env` and the redirect URI `<origin>/auth/callback`, then adds `sms:send` to what the app asks for. A new app starts asking for `crtr:llm` and `crtr:act`. The server in this guide also asks for `sms:send`, so it needs the `--scope`. Take scope strings from `npx @north-light/sdk apps asks` or from `providers.tools`. Do not invent them. See [Scopes and grants](/docs/concepts/scopes-and-grants).
104
+ Run it in the folder `init` made. With no dependencies installed, `npx` downloads the newest published release of the SDK, not the version your `package.json` pins, so run `npm i` first if the pinned version should run. It registers the app with the public key from `local/app-key.json`, the name from `package.json`, the origin from `local/app.env` and the redirect URI `<origin>/auth/callback`, then adds `sms:send` to what the app asks for. A new app starts asking for `crtr:llm` and `crtr:act`. The server in this guide also asks for `sms:send`, so it needs the `--scope`. Take scope strings from `npx @north-light/sdk apps asks` or from `providers.tools`. Do not invent them. See [Scopes and grants](/docs/concepts/scopes-and-grants).
103
105
 
104
106
  You should see:
105
107
 
@@ -116,7 +118,7 @@ Next: npm i && npm start, then open http://localhost:3000/auth/login
116
118
 
117
119
  `local/app.env` now has all four settings. The name, client ID and key ID are examples. Change the defaults with `--name`, `--logo`, `--justification` and `--redirect-uri`; the default logo is a placeholder, `https://sdk.docs.northlight.ai/app-logo.png`. `register` refuses when `CROUTER_CLIENT_ID` is already set. A login can register at most 10 apps a day.
118
120
 
119
- Not yet checked on production: this step, including that the directory accepts the default logo (not yet checked on production). Its output and its effect on `local/app.env` are checked against a fake directory in the package's tests.
121
+ Checked on production: this step, including that the directory accepts the default logo.
120
122
 
121
123
  ## 5. Run it
122
124
 
package/docs/ci-runner.md CHANGED
@@ -108,7 +108,7 @@ The hourly `17 * * * *` schedule and the `promote`, `api_ref`, `env_docker_ref`
108
108
 
109
109
  | Event | ubuntu `checks` + `select` | ubuntu `linux` (container) | `macos` (laptop-only tests) |
110
110
  | --- | --- | --- | --- |
111
- | pull request push | yes | the selected files, sharded to ~4 min; none for a docs-only change | the selected files that carry `laptopOnly` tags, if any |
111
+ | pull request push | yes | the selected files (narrowed by Preflight's pick when there is one), sharded to ~4 min; none for a docs-only change | the selected files that carry `laptopOnly` tags, if any |
112
112
  | pull request with the `full-ci` label, or a change whose path forces it | yes | all of it | all of it |
113
113
  | merge queue run (`merge_group`; inert until a queue ruleset exists) | yes | all of it | all of it |
114
114
  | `workflow_dispatch` (`gh workflow run test.yml --ref <branch>`) | yes | all of it | all of it |
@@ -129,4 +129,5 @@ On a pull request push the selector turns the diff against the merge base into p
129
129
  - **Path rules** are in `classify()`; anything not named there under `src/` or `packages/` that is not TypeScript, and any unrecognized path, forces `full`. A no-test rule never beats evidence that a test reads the path: a file in some test's or the CLI's import graph selects the tests that reach it even under `scripts/` (e.g. `scripts/proof/stub-provider/*.ts`, reached through the plugin test fixture); a script whose path or file name a test mentions forces `full`; and a `bin/**` shim selects the CLI closure and its spawners plus every test that names the shim (`spawn-env.test.ts` runs `./bin/runtime-generation-manifest.mjs`).
130
130
  - **Shard sizing** uses `scripts/ci-select/timing.json`: per-file local work in seconds from one measured local run, times a per-lane factor that scales it to the Linux test-step totals, targeting 240 s per shard. A committed table, not a runtime measurement, because it must exist before the first selected run and cannot come from a run that is being skipped; a file missing from the table (a new test) costs its lane's median, which only skews shard balance. Selection never reads it.
131
131
  - `node scripts/ci-select.mjs --replay scripts/ci-select/replay-prs.json` replays the 44 merged PRs #1–#65 through the selector as shipped, path rules included: this is the projection to quote. Over those PRs it runs a Linux lane on 34/44 (full suite: 44/44), at 26.0 billed ubuntu minutes per PR (full suite 39), 3.0 macOS-equivalent cost including the 4 minutes of `checks` (full suite 4.3), 564 s of laptop-only local work on the minis (759), 9.6 hosted-fallback macOS minutes (13); 8 PRs are docs-only and select no lane.
132
+ - **Preflight's test pick can narrow a selected run.** Preflight's triage run (the `tests` block of `.github/preflight.yml`) publishes the check run `Preflight test pick` on the PR head commit, written by Preflight's GitHub App (app id 5351829, so a workflow cannot forge it). After selecting, and only on a `pull_request` in mode `selected` (no `full-ci` or `no-test-pick` label, not a fork), `ci-select.mjs` polls that check run for `github.event.pull_request.head.sha` (not the merge ref) with the job's `GITHUB_TOKEN` (the repo's default token permissions are the restricted set without `checks`, which answers HTTP 403, so the `select` job declares `contents: read` and `checks: read`, and publish.yml's `tests` job grants `checks: read` too because a called workflow cannot exceed its caller). It waits at most 120 s for a completed pick, gives up after 45 s when no such check run exists, and stops at once on `outcome: none`. A valid pick keeps `(selected ∩ pick) ∪ (selected test files the PR changed)`, re-plans the shards from them, and gives mode `none` when nothing is left. It never adds a file and never touches `checks`. Any HTTP error, bad JSON, unknown schema, wrong head SHA or timeout keeps the script's set, and the log says so (e.g. `no Preflight test pick for <sha> after 45 s; using the script's set`). Mode `full` stays `full`, a narrowed run stays `selected` (so it never records a tested tree and the release `gate` still needs a full-suite record), and merge_group, push, schedule, dispatch and Publish's call never consult it. Opt a PR out with `gh pr edit <n> --add-label no-test-pick`. Evidence: the `select` step summary opens with `Preflight test pick: used|not used`, and `selection.json` (artifact `selection`) has a `pick` object with `used`, `why`, `before` and `after` file counts per lane (linux and macOS), `billed` estimated minutes before and after, and `dropped`, the files the pick removed. The logic and its tests are `scripts/ci-test-pick-lib.mjs` and `scripts/ci-test-pick.test.mjs` (in `test:contract`).
132
133
  - `--static-only` is a **counterfactual**, not the selector: it drops every path rule, so a workflow, manifest, helper or unrecognized path selects nothing instead of `full`. It exists only to compare with the design's replay figures, which used the import-graph-plus-spawner model alone (30/44 PRs, 21.9 ubuntu minutes). Do not read its numbers as what the selector does.
@@ -7,6 +7,29 @@ description: Behavior changes in @north-light/sdk that an app must act on, newes
7
7
 
8
8
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
9
9
 
10
+ ## 0.3.488
11
+
12
+ ### Developer CLI: `--help` exits 0, `login` is safe to re-run (CLI only, no app change)
13
+
14
+ - `npx @north-light/sdk <command> --help` and `-h` print the command's usage and exit 0 for every command. A bad flag still exits 1.
15
+ - `login` run again while this computer is logged in prints `Already logged in as …` and exits 0 instead of starting a second login (`--new` still does). A failed poll (a dropped connection, a sleeping laptop) is retried for up to 3 minutes; if the directory is still unreachable, `login` exits 1 and says the code is still saved and to run `login` again. It no longer ends on the first failed poll with a console fallback message.
16
+ - `init` writes `"allowScripts": {"esbuild": false}` into a new app's `package.json`, so npm 11.20+ no longer warns that esbuild's postinstall script is not allowed.
17
+ - `apps set-logo --file <path>` uploads a logo image (PNG, JPEG, WebP or SVG, square, at least 256x256, at most 512 KB) and `apps set-brand-color --color <#rrggbb or none>` sets or clears the brand colour. Both work on testing and verified apps. The directory side must be live first: until it is, they answer with the directory's refusal.
18
+ - `apps set-listing`, `set-spend-cap` and `set-allows` refuse with the same plain wording as the docs. They still make no request.
19
+
20
+ ### `docs`: an app writes, reads and edits documents it owns (additive, no app change)
21
+
22
+ - `docs.write({name, body, preview, kind?, rules?, privacy?, summary?})` creates a document owned by your app and returns its canonical `name` (`<app>/<name>`). `kind: 'preference'` with `rules: [{on: 'boot', deliver: 'content'}]` puts the body into the context of every node at boot. A name already taken throws `ConflictError` (`name_taken`).
23
+ - `docs.get(name)` returns `{body, revision, base, rules, kind, owner, needsMerge, …}`; no such document throws `NotFoundError`.
24
+ - `docs.edit(name, {body | replace, rationale, base?, …})` changes it in place and returns the new `revision`. Pass the `base` from `docs.get`; a base that is no longer current throws `ConflictError` (`stale_revision`) and writes nothing.
25
+ - Served on the app listener, so `oauth.client(userId)` offers it. An app can write only as itself (`owner: 'app'`, the default), and needs no scope for documents it owns.
26
+
27
+ ## 0.3.487
28
+
29
+ ### Run notices and the app listener verify one call-token shape (additive, no app change)
30
+
31
+ `verifyRunNotice` and the runtime's app listener now verify bearers with `verifyCallToken` from `@north-light/identity`, which accepts the token shapes the directory mints today and the actor-chain shape it is moving to. A run notice still needs scope `crtr:run_notice`, your app as `grantee`, and a runtime as its actor, and is refused with 401 otherwise. Nothing an existing app sends or reads changes.
32
+
10
33
  ## 0.3.486
11
34
 
12
35
  ### `crtr:memory:read_private:user` and `oauth.permissionUrl` (additive, no app change)
@@ -28,6 +28,7 @@ Every namespace exported by the client. Namespaces are camelCase. Verbs are `cre
28
28
  | `models.config` | `update` | `PUT /v1/model-config` |
29
29
  | `customObjects` | `register`, `publish`, `unregister`, `ack`, `receive` | `/v1/custom-objects…` |
30
30
  | `memory` | `list`, `listAll`, `get` | `GET /v1/memory`, `GET /v1/memory/document` |
31
+ | `docs` | `write`, `edit`, `get` | `POST /v1/docs`, `PATCH /v1/docs/:ref`, `GET /v1/objects/:ref?fields=1` |
31
32
  | `runs` | `start`, `get`, `list`, `listAll`, `message`, `interrupt`, `cancel`, `delete`, `rename`, `trace`, `events`, `reply`, `stream`, `wait`, `parse` | `/v1/runs…` |
32
33
  | `uploads` | `create` | `POST /v1/uploads` |
33
34
  | `attachments` | `link` | `GET /v1/attachments/link` |
@@ -37,7 +38,7 @@ Every namespace exported by the client. Namespaces are camelCase. Verbs are `cre
37
38
  | `grant` | `get` (`wait`, `since`: hold until `version` changes) | `GET /v1/grant` |
38
39
  | `health` | `get` | `GET /v1/health` |
39
40
 
40
- `runs`, `uploads`, `attachments`, `shares`, `questions` and the app side of `grant` and `providers` are the application-facing API an app reaches through the app listener; `oauth.client(userId)` returns a client typed to exactly that set (`AppClient`); `nodes`, `bash`, `canvas`, `crons`, `human`, `models`, `customObjects`, `structuredRequests`, `system`, `auth`, `files.read/write/list` and `profiles.ensure/retrieve` are the owner's own daemon and are not served to an app. An app schedules work with its own timer calling `runs.start`, or a run's agent runs `crtr cron add` under `crtr:schedule`. See [Apps on Northlight](./guides/apps-on-northlight.md) and [Apps with many users](./guides/apps-with-many-users.md). `memory` is the person's `public` documents ([Memory](./memory.md)). `system.health` (`/healthz`) is the daemon's liveness check; `health.get` is the app listener's.
41
+ `runs`, `uploads`, `attachments`, `shares`, `questions`, `docs` and the app side of `grant` and `providers` are the application-facing API an app reaches through the app listener; `oauth.client(userId)` returns a client typed to exactly that set (`AppClient`); `nodes`, `bash`, `canvas`, `crons`, `human`, `models`, `customObjects`, `structuredRequests`, `system`, `auth`, `files.read/write/list` and `profiles.ensure/retrieve` are the owner's own daemon and are not served to an app. An app schedules work with its own timer calling `runs.start`, or a run's agent runs `crtr cron add` under `crtr:schedule`. See [Apps on Northlight](./guides/apps-on-northlight.md) and [Apps with many users](./guides/apps-with-many-users.md). `memory` is the person's `public` documents ([Memory](./memory.md)). `docs` writes, reads and edits documents the calling app owns: `docs.write({name, body, preview, kind?, rules?})` returns the canonical `name`; `docs.get(name)` returns `body`, `revision`, `rules` and the edit `base`; `docs.edit(name, {body, rationale, base?})` changes it in place and refuses a stale `base` with `ConflictError` (`stale_revision`). To keep one document current, `get` it (catch `NotFoundError` on first run to `write`), compare `body` to your text, and `edit` only when they differ. `system.health` (`/healthz`) is the daemon's liveness check; `health.get` is the app listener's.
41
42
 
42
43
  Action methods keep the product's literal name (`fork`, `revive`, `promote`, `yield`) rather than being renamed into a generic verb. Every request-capable method takes `RequestOptions` as its final argument after its path, body, or query arguments. `RequestOptions` is `{ headers?, signal?, timeout?, maxRetries? }`; stream event options exclude `timeout` and add `after`.
43
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.486",
3
+ "version": "0.3.488",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -99,7 +99,7 @@
99
99
  "test:runtime-packaging": "node --test scripts/runtime-smoke.test.mjs scripts/pack-args.test.mjs scripts/ensure-native-linux.test.mjs scripts/release-version.test.mjs",
100
100
  "contract:check": "node scripts/contract.mjs check",
101
101
  "contract:surface": "node scripts/contract.mjs generate",
102
- "test:contract": "node --conditions=crtr-src --import tsx/esm --test scripts/contract.test.mjs scripts/promote-sdk.test.mjs scripts/release-version.test.mjs scripts/sdk-docs-commands.test.mjs scripts/ci-image.test.mjs scripts/ci-miss-triage.test.mjs",
102
+ "test:contract": "node --conditions=crtr-src --import tsx/esm --test scripts/contract.test.mjs scripts/promote-sdk.test.mjs scripts/release-version.test.mjs scripts/sdk-docs-commands.test.mjs scripts/ci-image.test.mjs scripts/ci-miss-triage.test.mjs scripts/ci-test-pick.test.mjs",
103
103
  "test:build-output": "node scripts/build-concurrency-test.mjs",
104
104
  "check:cli-no-canvas": "node scripts/check-cli-no-canvas.mjs",
105
105
  "check:cli-cold-start": "node scripts/check-cli-cold-start.mjs",
@@ -137,7 +137,7 @@
137
137
  },
138
138
  "license": "UNLICENSED",
139
139
  "dependencies": {
140
- "@north-light/identity": "0.3.486",
140
+ "@north-light/identity": "0.3.488",
141
141
  "@earendil-works/pi-agent-core": "1.1.0",
142
142
  "@earendil-works/pi-ai": "1.1.0",
143
143
  "@earendil-works/pi-coding-agent": "1.1.0",