@north-light/cli 0.3.485 → 0.3.487

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 (35) hide show
  1. package/dist/api/contract.d.ts +1 -1
  2. package/dist/api/contract.js +1 -1
  3. package/dist/builtin-memory/crouter-concepts/scopes-and-trust.md +2 -1
  4. package/dist/builtin-memory/crouter-sdk/getting-started.md +1 -1
  5. package/dist/builtin-memory/crouter-sdk/release-notes.md +15 -0
  6. package/dist/builtin-memory/northlight-apps/guides/files-and-documents.md +12 -3
  7. package/dist/builtin-memory/northlight-apps/guides/grant-changes.md +16 -0
  8. package/dist/builtin-memory/northlight-apps/guides/many-users.md +1 -1
  9. package/dist/builtin-memory/northlight-apps/guides/test-your-app.md +1 -1
  10. package/dist/clients/attach/viewer.js +180 -180
  11. package/dist/core/grants/cover.d.ts +14 -0
  12. package/dist/core/grants/cover.js +1 -0
  13. package/dist/core/grants/mirror.d.ts +3 -1
  14. package/dist/core/grants/mirror.js +2 -2
  15. package/dist/core/graph/spaces/person.js +3 -3
  16. package/dist/daemon/api/app-listener.js +13 -13
  17. package/dist/daemon/api/principal.d.ts +10 -18
  18. package/dist/daemon/api/principal.js +1 -1
  19. package/dist/daemon/peer/authority.d.ts +27 -0
  20. package/dist/daemon/peer/authority.js +1 -0
  21. package/dist/daemon/peer/ingress.d.ts +17 -15
  22. package/dist/daemon/peer/ingress.js +1 -1
  23. package/dist/daemon/peer/relay-service.js +1 -1
  24. package/docs/apps/guides/files-and-documents.md +12 -3
  25. package/docs/apps/guides/grant-changes.md +16 -0
  26. package/docs/apps/guides/many-users.md +1 -1
  27. package/docs/apps/guides/test-your-app.md +1 -1
  28. package/docs/concepts/scopes-and-trust.md +2 -1
  29. package/docs/sdk/getting-started.md +1 -1
  30. package/docs/sdk/release-notes.md +15 -0
  31. package/package.json +2 -2
  32. package/packages/crouter-identity/dist/index.d.ts +56 -1
  33. package/packages/crouter-identity/dist/index.js +120 -0
  34. package/packages/crouter-identity/package.json +1 -1
  35. package/runtime.lock.json +12 -12
@@ -0,0 +1,14 @@
1
+ import { type ActiveGrant } from './mirror.js';
2
+ /** Pulls the directory's grants into the mirror; `fresh` demands a pull that starts after the call. */
3
+ export type GrantSync = (options?: {
4
+ fresh?: boolean;
5
+ }) => Promise<void>;
6
+ /** Make the mirror cover grant `grantId` at `rev`, pulling once when it is behind. Throws `RunError` 503
7
+ * `service_unavailable` (failed pull, or a pull that stayed behind) or 401 `grant_revoked` (absent after a pull). */
8
+ export declare function ensureCovers(grantId: string, rev: number, syncGrants: GrantSync): Promise<void>;
9
+ /** The mirrored grant a caller names, read now, when it is still the caller's: active, the same `sub` and
10
+ * `grantee`, and not past `expires_at`. Otherwise 401 `grant_revoked`. */
11
+ export declare function callerGrantOf(grantId: string, sub: string, grantee: string): ActiveGrant;
12
+ /** The grant a token's app link names: the mirror made to cover `rev`, then the grant itself checked against
13
+ * `sub` and `grantee`. Errors are `ensureCovers`' and `callerGrantOf`'s `RunError`s. */
14
+ export declare function coverGrant(grantId: string, rev: number, sub: string, grantee: string, syncGrants: GrantSync): Promise<ActiveGrant>;
@@ -0,0 +1 @@
1
+ var l=Object.defineProperty;var r=(e,n)=>l(e,"name",{value:n,configurable:!0});import{emitEvent as w}from"../events/emit.js";import{RunError as a}from"../runs/operations.js";import{activeGrant as h,grantExpired as m,grantSyncState as s,grantsGeneration as g}from"./mirror.js";let c=new Map,f=-1;function u(e){const n=g();return n!==f&&(c=new Map,f=n),c.get(e)}r(u,"behindRev");const v=r((e,n)=>e!==null&&e.rev>=n,"covered");async function p(e,n,o){if(v(s(e),n))return;const t=u(e);if(t===void 0||n>t){try{await o({fresh:!0})}catch{throw new a(503,"service_unavailable","grant sync failed")}const i=s(e);if(v(i,n))return;u(e),c.set(e,Math.max(n,c.get(e)??0)),w({level:"error",event:"daemon.grants.behind_token",fields:{grant:e,token_rev:n,mirror_rev:i?.rev??0}})}throw s(e)?new a(503,"service_unavailable","the directory answered grant sync below the access token's grant revision"):new a(401,"grant_revoked","grant is not active")}r(p,"ensureCovers");function b(e,n,o){const t=h(e);if(!t||t.sub!==n||t.grantee!==o||m(t))throw new a(401,"grant_revoked","grant is not active");return t}r(b,"callerGrantOf");async function x(e,n,o,t,i){return await p(e,n,i),b(e,o,t)}r(x,"coverGrant");export{b as callerGrantOf,x as coverGrant,p as ensureCovers};
@@ -44,7 +44,9 @@ export declare function mirrorOnly(grant: Pick<GrantRecord, 'grantee'>): boolean
44
44
  export declare function localGrants(): GrantRecord[];
45
45
  /** Every mirrored grant, whatever its status. */
46
46
  export declare function allGrants(): GrantRecord[];
47
- /** The active form of a grant record read earlier, or null when it is not active or unparsable. */
47
+ /** Whether a grant's `expires_at` has passed. An unparsable time reads as expired (fail closed). */
48
+ export declare function grantExpired(grant: Pick<GrantRecord, 'expires_at'>): boolean;
49
+ /** The active form of a grant record read earlier, or null when it is not active, is past `expires_at`, or is unparsable. */
48
50
  export declare function activeRecord(grant: GrantRecord): ActiveGrant | null;
49
51
  /** `grant.get`'s `version`: an opaque string that changes exactly when what an app
50
52
  * reads from its grant changes — `rev`, `resolved_scopes` (order kept), `spend_cap`,
@@ -1,4 +1,4 @@
1
- var y=Object.defineProperty;var t=(e,r)=>y(e,"name",{value:r,configurable:!0});import{createHash as _}from"node:crypto";import{prepared as s}from"../sqlite-statements.js";import{parseScope as S}from"@north-light/identity";import{openDb as i,withCanvasWrite as v}from"../canvas/db.js";import{isUnsandboxedMachine as T}from"../runtime/unsandboxed.js";const m=["not_started","pending","ready","failed","unavailable"];function c(e){if(!Array.isArray(e))throw new TypeError("grant scopes must be a list");return e.map(r=>{if(!r||typeof r!="object"||typeof r.scope!="string"||!["always","ask"].includes(r.mode))throw new TypeError("invalid grant scope entry");return{scope:r.scope,mode:r.mode}})}t(c,"entries");function O(e){if(!e||typeof e!="object"||Array.isArray(e))return null;const{state:r,message:a,next:u,step:n}=e;return typeof r!="string"||!m.includes(r)||a!==void 0&&typeof a!="string"||n!==void 0&&typeof n!="string"||typeof u!="string"||!u?null:{state:r,...a===void 0?{}:{message:a},next:u,...n===void 0?{}:{step:n}}}t(O,"dependencyOf");function E(e){if(!e||typeof e!="object"||Array.isArray(e))return{};const r={};for(const[a,u]of Object.entries(e)){const n=O(u);n&&(r[a]=n)}return r}t(E,"dependenciesOf");function l(e){return{id:e.id,runtime:e.runtime,sub:e.sub,grantee:e.grantee,rev:e.rev,scopes:c(JSON.parse(e.scopes_json)),resolved_scopes:c(JSON.parse(e.resolved_scopes_json)),spend_cap:e.spend_cap,expires_at:e.expires_at,status:e.status,revoked_at:e.revoked_at,approved_at:e.approved_at,payer:e.payer,dependencies:E(JSON.parse(e.dependencies_json)),run_notice_url:e.run_notice_url??null,synced_at:e.synced_at}}t(l,"record");function p(e){if(!e||e.status!=="active")return null;try{return x(l(e))}catch(r){if(r instanceof TypeError||r instanceof SyntaxError)return null;throw r}}t(p,"active");function d(e){return e.grantee!=="app:terminal"&&T()}t(d,"mirrorOnly");function M(){return R().filter(e=>!d(e))}t(M,"localGrants");function R(){return s(i(),"SELECT * FROM grants").all().flatMap(e=>{try{return[l(e)]}catch(r){if(r instanceof TypeError||r instanceof SyntaxError)return[];throw r}})}t(R,"allGrants");function x(e){if(e.status!=="active")return null;try{const r=e.resolved_scopes.map(({scope:a})=>S(a));return{...e,resolved:e.resolved_scopes.flatMap(({mode:a},u)=>a==="always"?[r[u]]:[])}}catch(r){if(r instanceof TypeError||r instanceof SyntaxError)return null;throw r}}t(x,"activeRecord");function f(e){return Array.isArray(e)?`[${e.map(f).join(",")}]`:e&&typeof e=="object"?`{${Object.keys(e).sort().filter(r=>e[r]!==void 0).map(r=>`${JSON.stringify(r)}:${f(e[r])}`).join(",")}}`:JSON.stringify(e)??"null"}t(f,"canonicalJson");function b(e){const r={rev:e.rev,resolved_scopes:e.resolved_scopes,spend_cap:e.spend_cap,payer:e.payer,dependencies:e.dependencies??{}};return _("sha256").update(f(r)).digest("base64url").slice(0,22)}t(b,"grantVersion");function k(e){return!!s(i(),"SELECT 1 FROM grants WHERE id = ?").get(e)}t(k,"hasGrant");function H(e){return s(i(),"SELECT rev, status FROM grants WHERE id = ?").get(e)??null}t(H,"grantSyncState");function W(e,r){return s(i(),"SELECT rev, status FROM grants WHERE grantee = ? AND sub = ?").get(e,r)??null}t(W,"grantSyncStateFor");function j(e){const r=s(i(),"SELECT * FROM grants WHERE id = ?").get(e);if(!r)return null;try{return l(r)}catch(a){if(a instanceof TypeError||a instanceof SyntaxError)return null;throw a}}t(j,"grantRecord");function D(e){return p(s(i(),"SELECT * FROM grants WHERE id = ?").get(e))}t(D,"activeGrant");function G(e,r){return p(s(i(),"SELECT * FROM grants WHERE grantee = ? AND sub = ?").get(e,r))}t(G,"grantFor");function I(e){if(d({grantee:e}))return null;const r=s(i(),"SELECT * FROM grants WHERE grantee = ? AND status = 'active'").all(e);if(r.length>1)throw new Error(`multiple active grants for ${e}`);return p(r[0])}t(I,"activeGrantForApp");function J(e){const r=s(i(),"SELECT run_notice_url AS url, sub, grantee FROM grants WHERE id = ? AND status = 'active'").get(e);return r&&r.url&&!d(r)?{url:r.url,sub:r.sub,grantee:r.grantee}:null}t(J,"runNoticeTarget");function U(){const e=s(i(),"SELECT * FROM grants WHERE grantee = 'app:terminal' AND status = 'active'").all();if(e.length>1)throw new Error("multiple active terminal grants");return p(e[0])}t(U,"terminalGrant");function w(){return s(i(),"SELECT value FROM runtime_state WHERE key = 'grants_source'").get()?.value??null}t(w,"grantsSource");function $(e){return s(i(),"SELECT value FROM runtime_state WHERE key = 'grants_source'").get()?.value===e}t($,"hasSyncedGrants");function V(e){return s(e,"SELECT value FROM runtime_state WHERE key = 'grants_source'").get()?.value!=="local"?0:(s(e,"DELETE FROM runtime_state WHERE key = 'grants_source'").run(),Number(s(e,"DELETE FROM grants").run().changes))}t(V,"dropLocalGrantMirror");function P(e,r,a){const u=new Set;for(const n of e){if(u.has(n.id)||!n.id||!n.runtime||!n.sub||!n.grantee||!Number.isInteger(n.rev)||!["active","revoked","removed"].includes(n.status))throw new TypeError("invalid grant sync response");u.add(n.id),c(n.scopes),c(n.resolved_scopes)}v(n=>{n.exec("DELETE FROM grants");const g=s(n,`INSERT INTO grants
1
+ var _=Object.defineProperty;var t=(e,r)=>_(e,"name",{value:r,configurable:!0});import{createHash as y}from"node:crypto";import{prepared as s}from"../sqlite-statements.js";import{parseScope as S}from"@north-light/identity";import{openDb as i,withCanvasWrite as v}from"../canvas/db.js";import{isUnsandboxedMachine as T}from"../runtime/unsandboxed.js";const m=["not_started","pending","ready","failed","unavailable"];function c(e){if(!Array.isArray(e))throw new TypeError("grant scopes must be a list");return e.map(r=>{if(!r||typeof r!="object"||typeof r.scope!="string"||!["always","ask"].includes(r.mode))throw new TypeError("invalid grant scope entry");return{scope:r.scope,mode:r.mode}})}t(c,"entries");function O(e){if(!e||typeof e!="object"||Array.isArray(e))return null;const{state:r,message:a,next:u,step:n}=e;return typeof r!="string"||!m.includes(r)||a!==void 0&&typeof a!="string"||n!==void 0&&typeof n!="string"||typeof u!="string"||!u?null:{state:r,...a===void 0?{}:{message:a},next:u,...n===void 0?{}:{step:n}}}t(O,"dependencyOf");function E(e){if(!e||typeof e!="object"||Array.isArray(e))return{};const r={};for(const[a,u]of Object.entries(e)){const n=O(u);n&&(r[a]=n)}return r}t(E,"dependenciesOf");function l(e){return{id:e.id,runtime:e.runtime,sub:e.sub,grantee:e.grantee,rev:e.rev,scopes:c(JSON.parse(e.scopes_json)),resolved_scopes:c(JSON.parse(e.resolved_scopes_json)),spend_cap:e.spend_cap,expires_at:e.expires_at,status:e.status,revoked_at:e.revoked_at,approved_at:e.approved_at,payer:e.payer,dependencies:E(JSON.parse(e.dependencies_json)),run_notice_url:e.run_notice_url??null,synced_at:e.synced_at}}t(l,"record");function p(e){if(!e||e.status!=="active")return null;try{return N(l(e))}catch(r){if(r instanceof TypeError||r instanceof SyntaxError)return null;throw r}}t(p,"active");function d(e){return e.grantee!=="app:terminal"&&T()}t(d,"mirrorOnly");function b(){return R().filter(e=>!d(e))}t(b,"localGrants");function R(){return s(i(),"SELECT * FROM grants").all().flatMap(e=>{try{return[l(e)]}catch(r){if(r instanceof TypeError||r instanceof SyntaxError)return[];throw r}})}t(R,"allGrants");function x(e){return e.expires_at!==null&&!(Date.parse(e.expires_at)>Date.now())}t(x,"grantExpired");function N(e){if(e.status!=="active"||x(e))return null;try{const r=e.resolved_scopes.map(({scope:a})=>S(a));return{...e,resolved:e.resolved_scopes.flatMap(({mode:a},u)=>a==="always"?[r[u]]:[])}}catch(r){if(r instanceof TypeError||r instanceof SyntaxError)return null;throw r}}t(N,"activeRecord");function f(e){return Array.isArray(e)?`[${e.map(f).join(",")}]`:e&&typeof e=="object"?`{${Object.keys(e).sort().filter(r=>e[r]!==void 0).map(r=>`${JSON.stringify(r)}:${f(e[r])}`).join(",")}}`:JSON.stringify(e)??"null"}t(f,"canonicalJson");function k(e){const r={rev:e.rev,resolved_scopes:e.resolved_scopes,spend_cap:e.spend_cap,payer:e.payer,dependencies:e.dependencies??{}};return y("sha256").update(f(r)).digest("base64url").slice(0,22)}t(k,"grantVersion");function D(e){return!!s(i(),"SELECT 1 FROM grants WHERE id = ?").get(e)}t(D,"hasGrant");function H(e){return s(i(),"SELECT rev, status FROM grants WHERE id = ?").get(e)??null}t(H,"grantSyncState");function W(e,r){return s(i(),"SELECT rev, status FROM grants WHERE grantee = ? AND sub = ?").get(e,r)??null}t(W,"grantSyncStateFor");function j(e){const r=s(i(),"SELECT * FROM grants WHERE id = ?").get(e);if(!r)return null;try{return l(r)}catch(a){if(a instanceof TypeError||a instanceof SyntaxError)return null;throw a}}t(j,"grantRecord");function G(e){return p(s(i(),"SELECT * FROM grants WHERE id = ?").get(e))}t(G,"activeGrant");function I(e,r){return p(s(i(),"SELECT * FROM grants WHERE grantee = ? AND sub = ?").get(e,r))}t(I,"grantFor");function J(e){if(d({grantee:e}))return null;const r=s(i(),"SELECT * FROM grants WHERE grantee = ? AND status = 'active'").all(e);if(r.length>1)throw new Error(`multiple active grants for ${e}`);return p(r[0])}t(J,"activeGrantForApp");function U(e){const r=s(i(),"SELECT run_notice_url AS url, sub, grantee FROM grants WHERE id = ? AND status = 'active'").get(e);return r&&r.url&&!d(r)?{url:r.url,sub:r.sub,grantee:r.grantee}:null}t(U,"runNoticeTarget");function w(){const e=s(i(),"SELECT * FROM grants WHERE grantee = 'app:terminal' AND status = 'active'").all();if(e.length>1)throw new Error("multiple active terminal grants");return p(e[0])}t(w,"terminalGrant");function $(){return s(i(),"SELECT value FROM runtime_state WHERE key = 'grants_source'").get()?.value??null}t($,"grantsSource");function V(e){return s(i(),"SELECT value FROM runtime_state WHERE key = 'grants_source'").get()?.value===e}t(V,"hasSyncedGrants");function P(e){return s(e,"SELECT value FROM runtime_state WHERE key = 'grants_source'").get()?.value!=="local"?0:(s(e,"DELETE FROM runtime_state WHERE key = 'grants_source'").run(),Number(s(e,"DELETE FROM grants").run().changes))}t(P,"dropLocalGrantMirror");function X(e,r,a){const u=new Set;for(const n of e){if(u.has(n.id)||!n.id||!n.runtime||!n.sub||!n.grantee||!Number.isInteger(n.rev)||!["active","revoked","removed"].includes(n.status))throw new TypeError("invalid grant sync response");u.add(n.id),c(n.scopes),c(n.resolved_scopes)}v(n=>{n.exec("DELETE FROM grants");const g=s(n,`INSERT INTO grants
2
2
  (id, runtime, sub, grantee, rev, scopes_json, resolved_scopes_json, spend_cap, expires_at,
3
3
  status, revoked_at, approved_at, payer, dependencies_json, run_notice_url, synced_at)
4
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);for(const o of e)g.run(o.id,o.runtime,o.sub,o.grantee,o.rev,JSON.stringify(o.scopes),JSON.stringify(o.resolved_scopes),o.spend_cap,o.expires_at,o.status,o.revoked_at,o.approved_at,o.payer,JSON.stringify(E(o.dependencies)),o.run_notice_url??null,o.synced_at);s(n,"INSERT INTO runtime_state (key, value) VALUES ('grants_source', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(a),s(n,"INSERT INTO runtime_state (key, value) VALUES ('grants_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)").run()})}t(P,"replaceGrants");function X(){const e=s(i(),"SELECT value FROM runtime_state WHERE key = 'grants_generation'").get();return e?Number(e.value):0}t(X,"grantsGeneration");export{D as activeGrant,I as activeGrantForApp,x as activeRecord,R as allGrants,E as dependenciesOf,V as dropLocalGrantMirror,G as grantFor,j as grantRecord,H as grantSyncState,W as grantSyncStateFor,b as grantVersion,X as grantsGeneration,w as grantsSource,k as hasGrant,$ as hasSyncedGrants,M as localGrants,d as mirrorOnly,P as replaceGrants,J as runNoticeTarget,U as terminalGrant};
4
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);for(const o of e)g.run(o.id,o.runtime,o.sub,o.grantee,o.rev,JSON.stringify(o.scopes),JSON.stringify(o.resolved_scopes),o.spend_cap,o.expires_at,o.status,o.revoked_at,o.approved_at,o.payer,JSON.stringify(E(o.dependencies)),o.run_notice_url??null,o.synced_at);s(n,"INSERT INTO runtime_state (key, value) VALUES ('grants_source', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(a),s(n,"INSERT INTO runtime_state (key, value) VALUES ('grants_generation', '1') ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)").run()})}t(X,"replaceGrants");function q(){const e=s(i(),"SELECT value FROM runtime_state WHERE key = 'grants_generation'").get();return e?Number(e.value):0}t(q,"grantsGeneration");export{G as activeGrant,J as activeGrantForApp,N as activeRecord,R as allGrants,E as dependenciesOf,P as dropLocalGrantMirror,x as grantExpired,I as grantFor,j as grantRecord,H as grantSyncState,W as grantSyncStateFor,k as grantVersion,q as grantsGeneration,$ as grantsSource,D as hasGrant,V as hasSyncedGrants,b as localGrants,d as mirrorOnly,X as replaceGrants,U as runNoticeTarget,w as terminalGrant};
@@ -1,3 +1,3 @@
1
- import{prepared as p}from"../../sqlite-statements.js";import{covers as s}from"@north-light/identity";import{identityOf as f}from"./index.js";const d={identity:f,sync:"runtime",canRead(i,r,o,t,u){return r.class==="person"||r.class==="object"&&r.owner.rowid===o.rowid?!0:r.scopes===null?!1:r.app.rowid===o.rowid||t.length===0?!0:t[0].type!=="document"?!1:r.scopes.some(c=>s(c,"crtr:memory:manage:user"))?!0:u.privacy==="public"},canChange(i,r,o,t,u){if(r.class==="person"||r.class==="object"&&r.owner.rowid===o.rowid||r.app.rowid===o.rowid||r.scopes?.some(e=>s(e,"crtr:memory:manage:user")))return!0;if(t.length===0)return r.scopes?.some(e=>s(e,"crtr:memory:rw:user"))??!1;if(t[0].type!=="document")return!1;const c=`crtr:memory:rw:user:${u.name}`;if(r.scopes===null||!r.scopes.some(e=>s(e,c)))return!1;const n=p(i,`SELECT author.type, author.owner AS app FROM revisions r JOIN objects author ON author.rowid = r.author
2
- WHERE r.document = ? ORDER BY r.number, r.created_at LIMIT 1`).get(t[0].rowid);return n?n.type==="person"||n.app===r.app.rowid?!0:p(i,`SELECT g.resolved_scopes_json FROM grants g JOIN identities i ON i.external_id = g.grantee
3
- WHERE i.object = ? AND i.scheme = 'app'`).all(n.app).some(({resolved_scopes_json:e})=>JSON.parse(e).some(({scope:a,mode:m})=>m==="always"&&s(a,"crtr:memory:manage:user"))):!1}};export{d as personSpace};
1
+ import{prepared as p}from"../../sqlite-statements.js";import{covers as o}from"@north-light/identity";import{identityOf as f}from"./index.js";const d={identity:f,sync:"runtime",canRead(c,r,s,t,i){return r.class==="person"||r.class==="object"&&r.owner.rowid===s.rowid?!0:r.scopes===null?!1:r.app.rowid===s.rowid||t.length===0?!0:t[0].type!=="document"?!1:r.scopes.some(n=>o(n,"crtr:memory:manage:user"))||i.privacy==="public"?!0:i.privacy==="private"&&r.scopes.some(n=>o(n,"crtr:memory:read_private:user"))},canChange(c,r,s,t,i){if(r.class==="person"||r.class==="object"&&r.owner.rowid===s.rowid||r.app.rowid===s.rowid||r.scopes?.some(e=>o(e,"crtr:memory:manage:user")))return!0;if(t.length===0)return r.scopes?.some(e=>o(e,"crtr:memory:rw:user"))??!1;if(t[0].type!=="document")return!1;const n=`crtr:memory:rw:user:${i.name}`;if(r.scopes===null||!r.scopes.some(e=>o(e,n)))return!1;const u=p(c,`SELECT author.type, author.owner AS app FROM revisions r JOIN objects author ON author.rowid = r.author
2
+ WHERE r.document = ? ORDER BY r.number, r.created_at LIMIT 1`).get(t[0].rowid);return u?u.type==="person"||u.app===r.app.rowid?!0:p(c,`SELECT g.resolved_scopes_json FROM grants g JOIN identities i ON i.external_id = g.grantee
3
+ WHERE i.object = ? AND i.scheme = 'app'`).all(u.app).some(({resolved_scopes_json:e})=>JSON.parse(e).some(({scope:a,mode:m})=>m==="always"&&o(a,"crtr:memory:manage:user"))):!1}};export{d as personSpace};
@@ -1,24 +1,24 @@
1
- var $=Object.defineProperty;var s=(e,n)=>$(e,"name",{value:n,configurable:!0});import{randomUUID as F}from"node:crypto";import{createServer as G}from"node:http";import{Router as J}from"./router.js";import{toErrorBody as Y}from"./map.js";import{objectRoutes as q}from"./handlers/objects.js";import{customObjectRoutes as z}from"./handlers/custom-objects.js";import{docRoutes as Q}from"./handlers/docs.js";import{OPERATIONS as W}from"./operations.js";import{errorCodes as L}from"../../api/error-codes.js";import{MINIMUM_RUNTIME_VERSION_HEADER as K,RUNTIME_VERSION_HEADER as Z,minimumRuntimeRefusal as ee}from"../../api/runtime-version.js";import{CONTRACT_HEADER as te,CONTRACT_LEVEL as U,REQUIRED_CONTRACT_HEADER as re,contractRefusal as ne}from"../../api/contract.js";import{crtrVersion as H}from"../../shared/crtr-version.js";import{decodeJwt as ae}from"jose";import{AppPrincipalResolver as oe}from"./principal.js";import{runtimeOwner as se,servePeer as ie}from"../peer/ingress.js";import{applySyncNotice as ue}from"./sync-notice.js";import{ownMachineId as ce}from"../../core/peer/directory.js";import{handleRunOperation as de,integer as le}from"./handlers/runs.js";import{parseLlmRequest as fe,runLlm as me}from"../../core/llm/run.js";import{parseUsageMonth as pe,usageFor as he}from"../../core/usage/ledger.js";import{capFields as we}from"./handlers/llm.js";import{CONTROL_BODY_MAX_BYTES as ye,MODEL_CONTEXT_BODY_MAX_BYTES as _e}from"../../core/llm/body-limits.js";import{RunError as f,runIsRunning as ge}from"../../core/runs/operations.js";import{requireRun as k}from"./router.js";import{deferHead as ve}from"./deferred-head.js";import{readRunEvents as V,registerGrantStream as Re,subscribeRunEvents as Oe}from"../../core/runs/events.js";import{activeGrant as Ee}from"../../core/grants/mirror.js";import{releaseGrantWaiters as be}from"../../core/grants/watch.js";import{runNotices as Se}from"../run-notices.js";import{handleVendorSignIn as Ae,isVendorSignInPath as Te}from"./vendor-signin.js";import{listAppProfiles as Ne}from"./handlers/app-profiles.js";import{handleTransferDownload as Ce,handleTransferOperation as Pe,TRANSFER_OPERATIONS as Ie}from"./handlers/transfer.js";import{handleQuestionOperation as Me,QUESTION_OPERATIONS as He}from"./handlers/questions.js";import{vendorStatus as ke}from"./handlers/vendor-status.js";import{handleMemoryOperation as De,MEMORY_OPERATIONS as xe}from"./handlers/memory.js";import{abortOnClose as Be,callerFromApp as je,handleProviderOperation as Je,PROVIDER_OPERATIONS as Le,providerFailure as Ue}from"./handlers/providers.js";function Ve(e){return Object.prototype.hasOwnProperty.call(L,e)?L[e]:void 0}s(Ve,"fixedRow");function Xe(e){const n=Ve(e);return n?n.type:e==="stream_gap"||e==="provider_error"||e==="runtime_version_unsupported"?"invalid_request_error":"server_error"}s(Xe,"errorType");function $e(e){if(!e||typeof e!="object"||Array.isArray(e)||!("rate_limit"in e))return e;const{rate_limit:n,...o}=e;return o}s($e,"appDetails");function M(e,n,o){const l=$e(e.details);return{error:{code:e.code,message:e.message,...l===void 0?{}:{details:l},type:Xe(e.code),...e.param?{param:e.param}:{},origin:"runtime",retryable:e.retryable||e.code==="stream_dropped",request_id:n,...o?{run_id:o}:{},...e.scope?{scope:e.scope}:{},...e.code==="unauthorized"?{user_action:"reconnect"}:{},...we(e),...e.code==="all_routes_exhausted"?{retry_after_s:e.details.retry_after_s}:{}}}}s(M,"envelope");function Fe(e,n,o,l){const i=n instanceof f?n:new f(500,"server_failure","runtime request failed");if(n instanceof f||console.error("[app-listener]",n),e.headersSent&&!String(e.getHeader("Content-Type")??"").startsWith("text/event-stream")){e.destroy();return}if(e.headersSent){e.end(`event: error
2
- data: ${JSON.stringify(M(i,o,l))}
1
+ var z=Object.defineProperty;var s=(e,r)=>z(e,"name",{value:r,configurable:!0});import{randomUUID as F}from"node:crypto";import{createServer as G}from"node:http";import{Router as J}from"./router.js";import{toErrorBody as Y}from"./map.js";import{objectRoutes as q}from"./handlers/objects.js";import{customObjectRoutes as Q}from"./handlers/custom-objects.js";import{docRoutes as W}from"./handlers/docs.js";import{OPERATIONS as K}from"./operations.js";import{errorCodes as L}from"../../api/error-codes.js";import{MINIMUM_RUNTIME_VERSION_HEADER as Z,RUNTIME_VERSION_HEADER as ee,minimumRuntimeRefusal as te}from"../../api/runtime-version.js";import{CONTRACT_HEADER as ne,CONTRACT_LEVEL as U,REQUIRED_CONTRACT_HEADER as re,contractRefusal as ae}from"../../api/contract.js";import{crtrVersion as x}from"../../shared/crtr-version.js";import{AppPrincipalResolver as oe}from"./principal.js";import{isDaemonRoute as se,runtimeOwner as ie,servePeer as V}from"../peer/ingress.js";import{applySyncNotice as ue}from"./sync-notice.js";import{ownMachineId as de}from"../../core/peer/directory.js";import{handleRunOperation as ce,integer as le}from"./handlers/runs.js";import{parseLlmRequest as fe,runLlm as me}from"../../core/llm/run.js";import{parseUsageMonth as pe,usageFor as he}from"../../core/usage/ledger.js";import{capFields as we}from"./handlers/llm.js";import{CONTROL_BODY_MAX_BYTES as ye,MODEL_CONTEXT_BODY_MAX_BYTES as _e}from"../../core/llm/body-limits.js";import{RunError as m,runIsRunning as ve}from"../../core/runs/operations.js";import{requireRun as j}from"./router.js";import{deferHead as ge}from"./deferred-head.js";import{readRunEvents as X,registerGrantStream as Re,subscribeRunEvents as Oe}from"../../core/runs/events.js";import{activeGrant as Ee}from"../../core/grants/mirror.js";import{releaseGrantWaiters as be}from"../../core/grants/watch.js";import{runNotices as Se}from"../run-notices.js";import{handleVendorSignIn as Te,isVendorSignInPath as Ae}from"./vendor-signin.js";import{listAppProfiles as Ne}from"./handlers/app-profiles.js";import{handleTransferDownload as Ce,handleTransferOperation as Ie,TRANSFER_OPERATIONS as Pe}from"./handlers/transfer.js";import{handleQuestionOperation as ke,QUESTION_OPERATIONS as He}from"./handlers/questions.js";import{vendorStatus as Me}from"./handlers/vendor-status.js";import{handleMemoryOperation as De,MEMORY_OPERATIONS as xe}from"./handlers/memory.js";import{abortOnClose as je,callerFromApp as Be,handleProviderOperation as Je,PROVIDER_OPERATIONS as Le,providerFailure as Ue}from"./handlers/providers.js";function Ve(e){return Object.prototype.hasOwnProperty.call(L,e)?L[e]:void 0}s(Ve,"fixedRow");function Xe(e){const r=Ve(e);return r?r.type:e==="stream_gap"||e==="provider_error"||e==="runtime_version_unsupported"?"invalid_request_error":"server_error"}s(Xe,"errorType");function $e(e){if(!e||typeof e!="object"||Array.isArray(e)||!("rate_limit"in e))return e;const{rate_limit:r,...o}=e;return o}s($e,"appDetails");function D(e,r,o){const c=$e(e.details);return{error:{code:e.code,message:e.message,...c===void 0?{}:{details:c},type:Xe(e.code),...e.param?{param:e.param}:{},origin:"runtime",retryable:e.retryable||e.code==="stream_dropped",request_id:r,...o?{run_id:o}:{},...e.scope?{scope:e.scope}:{},...e.code==="unauthorized"?{user_action:"reconnect"}:{},...we(e),...e.code==="all_routes_exhausted"?{retry_after_s:e.details.retry_after_s}:{}}}}s(D,"envelope");function ze(e,r,o,c){const u=r instanceof m?r:new m(500,"server_failure","runtime request failed");if(r instanceof m||console.error("[app-listener]",r),e.headersSent&&!String(e.getHeader("Content-Type")??"").startsWith("text/event-stream")){e.destroy();return}if(e.headersSent){e.end(`event: error
2
+ data: ${JSON.stringify(D(u,o,c))}
3
3
 
4
- `);return}e.writeHead(i.status,{"Content-Type":"application/json; charset=utf-8","Request-Id":o,"X-Content-Type-Options":"nosniff",...i.code==="all_routes_exhausted"?{"Retry-After":String(i.details.retry_after_s)}:{}}),e.end(JSON.stringify(M(i,o,l)))}s(Fe,"fail");function X(e){return Array.isArray(e)?e[0]:e}s(X,"headerValue");function Ge(e){if(!e?.startsWith("Bearer ")||e.length<=7)return!1;try{return typeof ae(e.slice(7)).machine_id=="string"}catch{return!1}}s(Ge,"isMachineBearer");function O(e,n,o){e.writeHead(n,{"Content-Type":"application/json; charset=utf-8"}),e.end(JSON.stringify(o))}s(O,"json");async function P(e,n=ye){const o=[];let l=0;for await(const i of e){if(l+=i.length,l>n)throw new f(400,"invalid_request","request body too large");o.push(i)}if(l)try{return JSON.parse(Buffer.concat(o).toString("utf8"))}catch{throw new f(400,"invalid_request","invalid JSON body")}}s(P,"bodyOf");function Ye(e,n){const o=e.headers["last-event-id"]??n.get("after");if(Array.isArray(o))throw new f(400,"invalid_request","invalid cursor","after");return le(o??null,"after",0,Number.MAX_SAFE_INTEGER,0)}s(Ye,"cursor");function qe(e){return`id: ${e.sequence_number}
4
+ `);return}e.writeHead(u.status,{"Content-Type":"application/json; charset=utf-8","Request-Id":o,"X-Content-Type-Options":"nosniff",...u.code==="all_routes_exhausted"?{"Retry-After":String(u.details.retry_after_s)}:{}}),e.end(JSON.stringify(D(u,o,c)))}s(ze,"fail");function $(e){return Array.isArray(e)?e[0]:e}s($,"headerValue");function O(e,r,o){e.writeHead(r,{"Content-Type":"application/json; charset=utf-8"}),e.end(JSON.stringify(o))}s(O,"json");async function I(e,r=ye){const o=[];let c=0;for await(const u of e){if(c+=u.length,c>r)throw new m(400,"invalid_request","request body too large");o.push(u)}if(c)try{return JSON.parse(Buffer.concat(o).toString("utf8"))}catch{throw new m(400,"invalid_request","invalid JSON body")}}s(I,"bodyOf");function Fe(e,r){const o=e.headers["last-event-id"]??r.get("after");if(Array.isArray(o))throw new m(400,"invalid_request","invalid cursor","after");return le(o??null,"after",0,Number.MAX_SAFE_INTEGER,0)}s(Fe,"cursor");function Ge(e){return`id: ${e.sequence_number}
5
5
  event: ${e.type}
6
6
  data: ${JSON.stringify(e)}
7
7
 
8
- `}s(qe,"frame");const ze=6e4;function Qe(e,n,o,l,i,g,E,A={}){k(l,o);let _=V(o,i);if(i>_.latest_sequence)throw new f(400,"invalid_request","cursor is beyond the run","after");if(i<_.latest_sequence&&(_.earliest_sequence===null||i<_.earliest_sequence-1))throw new f(409,"stream_gap","events have expired",void 0,{earliest_sequence:_.earliest_sequence,latest_sequence:_.latest_sequence});if(n.destroyed||e.destroyed)return n.destroy(),Promise.resolve();n.writeHead(200,{"Content-Type":E==="sse"?"text/event-stream":"application/x-ndjson","Cache-Control":"no-cache","X-Accel-Buffering":"no"}),E==="sse"?n.write(`: connected
8
+ `}s(Ge,"frame");const Ye=6e4;function qe(e,r,o,c,u,_,E,A={}){j(c,o);let v=X(o,u);if(u>v.latest_sequence)throw new m(400,"invalid_request","cursor is beyond the run","after");if(u<v.latest_sequence&&(v.earliest_sequence===null||u<v.earliest_sequence-1))throw new m(409,"stream_gap","events have expired",void 0,{earliest_sequence:v.earliest_sequence,latest_sequence:v.latest_sequence});if(r.destroyed||e.destroyed)return r.destroy(),Promise.resolve();r.writeHead(200,{"Content-Type":E==="sse"?"text/event-stream":"application/x-ndjson","Cache-Control":"no-cache","X-Accel-Buffering":"no"}),E==="sse"?r.write(`: connected
9
9
 
10
- `):n.flushHeaders();let w=i;const D=Se().streamOpened(o);let r=!1,t=!1,p=!1,b;const T=new Promise(h=>{b=h}),y=s(()=>{r||(r=!0,D(w),clearInterval(d),clearInterval(B),u(),S(),n.off("close",y),n.off("drain",m),n.end(),b())},"end"),m=s(()=>{p=!1,a()},"onDrain"),v=s(h=>{r||n.write(E==="sse"?`event: error
11
- data: ${JSON.stringify(M(h,g,o))}
10
+ `):r.flushHeaders();let w=u;const B=Se().streamOpened(o);let n=!1,t=!1,h=!1,b;const N=new Promise(f=>{b=f}),y=s(()=>{n||(n=!0,B(w),clearInterval(l),clearInterval(R),P(),H(),r.off("close",y),r.off("drain",p),r.end(),b())},"end"),p=s(()=>{h=!1,a()},"onDrain"),g=s(f=>{n||r.write(E==="sse"?`event: error
11
+ data: ${JSON.stringify(D(f,_,o))}
12
12
 
13
- `:`${JSON.stringify({type:"error",...M(h,g,o)})}
14
- `),y()},"error"),a=s(()=>{if(!(r||t)){t=!0;try{if(!I()){c();return}if(k(l,o),_=V(o,w),_.backlog_count>1e3||_.backlog_bytes>32*1024*1024){v(new f(503,"stream_dropped","stream reader fell behind",void 0,{last_sequence:w}));return}for(const h of _.events){if(p)break;p=!n.write(E==="sse"?qe(h):`${JSON.stringify(h)}
15
- `),w=h.sequence_number}}catch(h){v(h instanceof f?h:new f(500,"server_failure","stream failed"))}finally{t=!1}}},"flush"),c=s(()=>v(new f(401,"grant_revoked","grant is not active")),"revoked"),I=s(()=>"class"in l||Ee(l.grant)!==null,"grantActive"),u=Oe(o,a),S="class"in l?()=>{}:Re(l.grant,c),d=setInterval(()=>{!r&&!p&&E==="sse"&&n.write(`: keepalive
13
+ `:`${JSON.stringify({type:"error",...D(f,_,o)})}
14
+ `),y()},"error"),a=s(()=>{if(!(n||t)){t=!0;try{if(!T()){k();return}if(j(c,o),v=X(o,w),v.backlog_count>1e3||v.backlog_bytes>32*1024*1024){g(new m(503,"stream_dropped","stream reader fell behind",void 0,{last_sequence:w}));return}for(const f of v.events){if(h)break;h=!r.write(E==="sse"?Ge(f):`${JSON.stringify(f)}
15
+ `),w=f.sequence_number}}catch(f){g(f instanceof m?f:new m(500,"server_failure","stream failed"))}finally{t=!1}}},"flush"),k=s(()=>g(new m(401,"grant_revoked","grant is not active")),"revoked"),T=s(()=>"class"in c||Ee(c.grant)!==null,"grantActive"),P=Oe(o,a),H="class"in c?()=>{}:Re(c.grant,k),l=setInterval(()=>{!n&&!h&&E==="sse"&&r.write(`: keepalive
16
16
 
17
- `)},15e3),R=A.idleMs??ze,N=A.isRunning??ge;let C=Date.now(),x=w;const B=setInterval(()=>{if(r||E!=="sse")return;const h=Date.now();let j=!0;try{j=N(o)}catch{}if(j||w!==x){C=h,x=w;return}h-C<R||p||(n.write(`event: idle
17
+ `)},15e3),M=A.idleMs??Ye,i=A.isRunning??ve;let S=Date.now(),d=w;const R=setInterval(()=>{if(n||E!=="sse")return;const f=Date.now();let C=!0;try{C=i(o)}catch{}if(C||w!==d){S=f,d=w;return}f-S<M||h||(r.write(`event: idle
18
18
  data: {}
19
19
 
20
- `),y())},A.idleCheckMs??1e3);return B.unref(),n.on("drain",m),n.on("close",y),a(),T}s(Qe,"streamRunEvents");function kt(e,n,o,l={}){const i=e.listener;if(!e.issuer||!e.audience||!i?.address||!Number.isInteger(i.port)||i.port<1||i.port>65535)throw new Error("runtime issuer, audience and listener address/port are required");if(["0.0.0.0","::","[::]"].includes(i.address))throw new Error("runtime listener must bind a specific address");const g=new oe(e.issuer,e.audience,o,e.jwksUrl),E=new J,A=new Map;for(const r of W.filter(t=>t.stage!==void 0)){const t=s(()=>({status:500}),"handler");E.register({method:r.method,pattern:r.path,handler:t}),A.set(t,r)}const _=new J().registerAll(q).registerAll(z).registerAll(Q),w=G((r,t)=>{(async()=>{ve(r,t,l.deferHead);const p=F();t.setHeader("Request-Id",p),t.setHeader(Z,H()),t.setHeader(te,U);let b;try{const T=X(r.headers[K.toLowerCase()]),y=ee(T,H())??ne(X(r.headers[re.toLowerCase()]),U,{legacyMinimum:T,runtimeVersion:H()});if(y!==null)throw y.status===400?new f(400,y.code,y.message,y.param):new f(426,y.code,y.message,void 0,y.details);const m=new URL(r.url??"/","http://runtime.local");if(Te(m.pathname)){await Ae(r,t,m,g);return}if(Ge(r.headers.authorization)){await ie(r,t,{issuer:e.issuer,jwks:g.jwks,audience:s(()=>e.audience,"audience"),owner:se,self:ce,ready:s(()=>g.isReady()&&n(),"ready")});return}if((r.method!=="GET"||m.pathname!=="/v1/health")&&(!g.isReady()||!n()))throw new f(503,"runtime_starting","runtime_starting");const v=E.match(r.method??"GET",m.pathname);if(!v)throw new f(404,"not_found","unknown operation");const a=A.get(v.handler);if(!a)throw new f(404,"not_found","unknown operation");if(b=v.params.run_id,a.name==="health"){O(t,200,{status:"ok",api_version:"2026-09-23"});return}if(a.name==="runtime.sync-notice"){await ue(await g.verifySyncNotice(r.headers.authorization),o,p),t.writeHead(204),t.end();return}if(a.name==="runtime.vendor-status"){await g.verifyDirectoryRead(r.headers.authorization,"vendor_status"),O(t,200,ke());return}const c=await g.resolve(r.headers.authorization,a.scope);if(t.setHeader("Request-Id",p),b&&k(c,b,["run.message","run.interrupt","run.cancel","run.rename","run.delete"].includes(a.name),a.name==="run.delete"),a.stage==="objects"){const u=_.match(a.method,m.pathname);if(!u)throw new f(404,"not_found","unknown operation");const S={method:a.method,path:m.pathname,params:u.params,query:m.searchParams,body:await P(r,u.maxBodyBytes),ceiling:c.resolved,principal:{class:"app",app:c.grantee,scopes:c.resolved},req:r,res:t};let d;try{d=await u.handler(S)}catch(R){const{status:N,body:C}=Y(R);throw N>=500&&console.error("[app-listener]",R),new f(N,C.error.code,C.error.message,void 0,C.error.details)}if(t.headersSent)return;if(d.rawBody!==void 0||d.status===204||d.body===void 0){t.writeHead(d.status,d.headers??{}),t.end(d.rawBody);return}t.writeHead(d.status,{"Content-Type":"application/json; charset=utf-8",...d.headers??{}}),t.end(JSON.stringify(d.body));return}if(a.stream==="bytes"){await Ce(r,t,c,m.searchParams);return}if(a.stream){Qe(r,t,b,c,Ye(r,m.searchParams),p,"sse",l.stream);return}if(a.name==="llm"){const u=fe(await P(r,_e)),S=new AbortController,d=s(()=>S.abort(),"abort");t.on("close",d);try{const R=await me(u,S.signal,u.stream?N=>{t.headersSent||t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache","X-Accel-Buffering":"no"}),t.write(`data: ${JSON.stringify(N)}
20
+ `),y())},A.idleCheckMs??1e3);return R.unref(),r.on("drain",p),r.on("close",y),a(),N}s(qe,"streamRunEvents");function kt(e,r,o,c={}){const u=e.listener;if(!e.issuer||!e.audience||!u?.address||!Number.isInteger(u.port)||u.port<1||u.port>65535)throw new Error("runtime issuer, audience and listener address/port are required");if(["0.0.0.0","::","[::]"].includes(u.address))throw new Error("runtime listener must bind a specific address");const _=new oe(e.issuer,e.audience,o,e.jwksUrl),E=new J,A=new Map;for(const n of K.filter(t=>t.stage!==void 0)){const t=s(()=>({status:500}),"handler");E.register({method:n.method,pattern:n.path,handler:t}),A.set(t,n)}const v=new J().registerAll(q).registerAll(Q).registerAll(W),w=G((n,t)=>{(async()=>{ge(n,t,c.deferHead);const h=F();t.setHeader("Request-Id",h),t.setHeader(ee,x()),t.setHeader(ne,U);let b;try{const N=$(n.headers[Z.toLowerCase()]),y=te(N,x())??ae($(n.headers[re.toLowerCase()]),U,{legacyMinimum:N,runtimeVersion:x()});if(y!==null)throw y.status===400?new m(400,y.code,y.message,y.param):new m(426,y.code,y.message,void 0,y.details);const p=new URL(n.url??"/","http://runtime.local");if(Ae(p.pathname)){await Te(n,t,p,_);return}const g=E.match(n.method??"GET",p.pathname),a=g?A.get(g.handler):void 0,k=a?.name==="runtime.sync-notice"||a?.name==="runtime.vendor-status";let T,P;if(!k&&n.headers.authorization!==void 0)try{T=await _.verifyCall(n.headers.authorization)}catch(i){P=i}const H={issuer:e.issuer,jwks:_.jwks,audience:s(()=>e.audience,"audience"),owner:ie,self:de,syncGrants:o,ready:s(()=>_.isReady()&&r(),"ready")};if(T?.chain[0]?.sub.startsWith("runtime:")){await V(n,t,H,T);return}if(T===void 0&&a===void 0&&await se(n.method??"GET",p.pathname)){await V(n,t,H,{status:401,code:"unauthorized",message:P===void 0?"a machine token is required":"invalid machine token"});return}if((n.method!=="GET"||p.pathname!=="/v1/health")&&(!_.isReady()||!r()))throw new m(503,"runtime_starting","runtime_starting");if(!g||!a)throw new m(404,"not_found","unknown operation");if(b=g.params.run_id,a.name==="health"){O(t,200,{status:"ok",api_version:"2026-09-23"});return}if(a.name==="runtime.sync-notice"){await ue(await _.verifySyncNotice(n.headers.authorization),o,h),t.writeHead(204),t.end();return}if(a.name==="runtime.vendor-status"){await _.verifyDirectoryRead(n.headers.authorization,"vendor_status"),O(t,200,Me());return}if(T===void 0)throw P??new m(401,"unauthorized","a directory access token is required");const l=await _.resolveVerified(T,a.scope);if(t.setHeader("Request-Id",h),b&&j(l,b,["run.message","run.interrupt","run.cancel","run.rename","run.delete"].includes(a.name),a.name==="run.delete"),a.stage==="objects"){const i=v.match(a.method,p.pathname);if(!i)throw new m(404,"not_found","unknown operation");const S={method:a.method,path:p.pathname,params:i.params,query:p.searchParams,body:await I(n,i.maxBodyBytes),ceiling:l.resolved,principal:{class:"app",app:l.grantee,scopes:l.resolved},req:n,res:t};let d;try{d=await i.handler(S)}catch(R){const{status:f,body:C}=Y(R);throw f>=500&&console.error("[app-listener]",R),new m(f,C.error.code,C.error.message,void 0,C.error.details)}if(t.headersSent)return;if(d.rawBody!==void 0||d.status===204||d.body===void 0){t.writeHead(d.status,d.headers??{}),t.end(d.rawBody);return}t.writeHead(d.status,{"Content-Type":"application/json; charset=utf-8",...d.headers??{}}),t.end(JSON.stringify(d.body));return}if(a.stream==="bytes"){await Ce(n,t,l,p.searchParams);return}if(a.stream){qe(n,t,b,l,Fe(n,p.searchParams),h,"sse",c.stream);return}if(a.name==="llm"){const i=fe(await I(n,_e)),S=new AbortController,d=s(()=>S.abort(),"abort");t.on("close",d);try{const R=await me(i,S.signal,i.stream?f=>{t.headersSent||t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache","X-Accel-Buffering":"no"}),t.write(`data: ${JSON.stringify(f)}
21
21
 
22
- `)}:void 0,void 0,{grantee:c.grantee,spend_cap:c.spend_cap});u.stream?(t.headersSent||t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache","X-Accel-Buffering":"no"}),t.end(`data: [DONE]
22
+ `)}:void 0,void 0,{grantee:l.grantee,spend_cap:l.spend_cap});i.stream?(t.headersSent||t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache","X-Accel-Buffering":"no"}),t.end(`data: [DONE]
23
23
 
24
- `)):O(t,200,R.completion)}finally{t.off("close",d)}return}if(a.name==="usage.get"){O(t,200,he(c.grantee,c.spend_cap,pe(m.searchParams.get("month"))));return}if(a.name==="profile.list"){O(t,200,Ne(c));return}if(He.has(a.name)){const u=await Me(a.name,c,v.params.question_id,a.method==="POST"?await P(r):void 0);O(t,u.status,u.body);return}if(Le.has(a.name)){const{signal:u,release:S}=Be(t);try{const d=await Je(a.name,je(c),{params:v.params,body:a.method==="POST"?await P(r):void 0,query:m.searchParams},{requestId:p,signal:u});O(t,d.status,d.body)}catch(d){const R=Ue(d,p,!0);t.writeHead(R.status,{"Content-Type":"application/json; charset=utf-8","X-Content-Type-Options":"nosniff",...R.headers}),t.end(JSON.stringify(R.body))}finally{S()}return}if(xe.has(a.name)){const u=De(a.name,{class:"app",app:c.grantee,scopes:c.resolved},m.searchParams);O(t,u.status,u.body);return}if(Ie.has(a.name)){const u=await Pe(a,c,v.params,m.searchParams,await P(r));O(t,u.status,u.body);return}const I=await de(a,c,b,m.searchParams,await P(r),r.headers["idempotency-key"]);O(t,I.status,I.body)}catch(T){Fe(t,T,p,b)}})()});return{ready:new Promise((r,t)=>{w.once("error",t),w.listen(i.port,i.address,()=>{w.off("error",t),r()})}),close:s(async()=>{g.close(),be(),w.closeAllConnections(),await new Promise((r,t)=>w.close(p=>p?t(p):r()))},"close")}}s(kt,"createAppListener");export{ze as RUN_STREAM_IDLE_MS,kt as createAppListener,Ye as cursor,Qe as streamRunEvents};
24
+ `)):O(t,200,R.completion)}finally{t.off("close",d)}return}if(a.name==="usage.get"){O(t,200,he(l.grantee,l.spend_cap,pe(p.searchParams.get("month"))));return}if(a.name==="profile.list"){O(t,200,Ne(l));return}if(He.has(a.name)){const i=await ke(a.name,l,g.params.question_id,a.method==="POST"?await I(n):void 0);O(t,i.status,i.body);return}if(Le.has(a.name)){const{signal:i,release:S}=je(t);try{const d=await Je(a.name,Be(l),{params:g.params,body:a.method==="POST"?await I(n):void 0,query:p.searchParams},{requestId:h,signal:i});O(t,d.status,d.body)}catch(d){const R=Ue(d,h,!0);t.writeHead(R.status,{"Content-Type":"application/json; charset=utf-8","X-Content-Type-Options":"nosniff",...R.headers}),t.end(JSON.stringify(R.body))}finally{S()}return}if(xe.has(a.name)){const i=De(a.name,{class:"app",app:l.grantee,scopes:l.resolved},p.searchParams);O(t,i.status,i.body);return}if(Pe.has(a.name)){const i=await Ie(a,l,g.params,p.searchParams,await I(n));O(t,i.status,i.body);return}const M=await ce(a,l,b,p.searchParams,await I(n),n.headers["idempotency-key"]);O(t,M.status,M.body)}catch(N){ze(t,N,h,b)}})()});return{ready:new Promise((n,t)=>{w.once("error",t),w.listen(u.port,u.address,()=>{w.off("error",t),n()})}),close:s(async()=>{_.close(),be(),w.closeAllConnections(),await new Promise((n,t)=>w.close(h=>h?t(h):n()))},"close")}}s(kt,"createAppListener");export{Ye as RUN_STREAM_IDLE_MS,kt as createAppListener,Fe as cursor,qe as streamRunEvents};
@@ -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};
@@ -19,7 +19,7 @@ A non-2xx answer is thrown as an `APIError`, never returned as bytes. A bad `ran
19
19
 
20
20
  ## Read the person's public documents
21
21
 
22
- `runtime.memory` is a read-only view of the `knowledge` and `preference` documents the person owns (`memory` and the `/v1/memory` routes are the API's names for this). An app reads the person's `public` documents with `memory.list` and `memory.get`, and needs no scope to do it. It never sees a `friends` or `private` document and cannot tell one from a document that does not exist: both answer `404 not_found`. The list is what the app's agents can read about the person, so an app can show the person exactly which of their documents it draws on.
22
+ `runtime.memory` is a read-only view of the `knowledge` and `preference` documents the person owns (`memory` and the `/v1/memory` routes are the API's names for this). An app reads the person's `public` documents with `memory.list` and `memory.get`, and needs no scope to do it. Without more, it never sees a `friends` or `private` document and cannot tell one from a document that does not exist: both answer `404 not_found`. The optional scope [`crtr:memory:read_private:user`](#private-memory) adds `private`. The list is what the app's agents can read about the person, so an app can show the person exactly which of their documents it draws on.
23
23
 
24
24
  ```ts
25
25
  const page = await runtime.memory.list({store: 'user'});
@@ -29,7 +29,7 @@ const doc = await runtime.memory.get({store: 'user', name: page.items[0].name});
29
29
  console.log(doc.body);
30
30
  ```
31
31
 
32
- `store` is `'user'` in this release; any other value answers `400 invalid_request` with `param: "store"`. An app whose grant holds `crtr:memory:manage:user` (a first-party app) sees every privacy level. Reading records nothing: no watch, no `read` event, no audit row. Both methods are `GET` requests and follow the SDK's retry rule for safe requests.
32
+ `store` is `'user'` in this release; any other value answers `400 invalid_request` with `param: "store"`. An app whose grant holds `crtr:memory:manage:user` (a first-party app) sees every privacy level; one that holds `crtr:memory:read_private:user` sees `public` and `private`. Reading records nothing: no watch, no `read` event, no audit row. Both methods are `GET` requests and follow the SDK's retry rule for safe requests.
33
33
 
34
34
  ### List
35
35
 
@@ -56,4 +56,13 @@ for await (const item of runtime.memory.listAll({store: 'user', path: 'work'}))
56
56
 
57
57
  - `body` is the current markdown as stored; `[[links]]` are not resolved. A body over 1 MiB is cut to its longest whole-character prefix of at most 1 MiB, with `truncated: true`; `size` is always the full length in UTF-8 bytes.
58
58
  - `grantee` is `app:<id>` of the app whose agent wrote the first revision, or `null` when the person wrote it.
59
- - An absent, deleted, `friends` or `private` name rejects with `NotFoundError` (`code: 'not_found'`); a malformed field rejects with `BadRequestError`.
59
+ - An absent or deleted name, a `friends` name, and a `private` name without `crtr:memory:read_private:user` reject with `NotFoundError` (`code: 'not_found'`); a malformed field rejects with `BadRequestError`.
60
+
61
+ ## Private memory
62
+
63
+ `crtr:memory:read_private:user` is an [optional permission](/docs/guides/grant-changes#optional-permissions) that lets your app read the person's `private` documents. Declare it, then let the person switch it on from your app (sign-in request or `permissionUrl`).
64
+
65
+ - **Read-only, and never `friends`.** With the scope `memory.list` and `memory.get` return `public` and `private` documents; a `friends` document still reads as absent, and the scope writes nothing. Each private document arrives with `privacy: 'private'`.
66
+ - **Both server reads and runs.** Your server's `memory` calls see private documents while the grant holds the scope. A run sees them only if it was started with the scope: `runs.start` `scopes` must cover `crtr:memory:read_private:user` (name it, or name `crtr:memory:manage:user` if the grant holds that) (a run with no `scopes` list gets what the grant holds), and the start is refused `403 scope_missing` while the grant lacks it. Add the scope to the runs that need it only while `resolved_scopes` holds it.
67
+ - **The person can turn it off at any time.** Reads then stop returning private documents at once, including from a run that is still live. That run keeps what it already read in its context, so restart the runs that used it when the permission turns off (`watchGrants` tells you).
68
+ - **Never show a private detail to anyone but the owner**, unless the owner asked your app to. A visitor, a guest or another user must not see a fact, quote or summary taken from a private document.
@@ -37,6 +37,22 @@ await watcher.stop();
37
37
  - **If `onChanged` throws**, the same version is delivered again after 5 s, doubling to 5 min. A transient runtime error backs off the same way. A missing or refused connection drops that user and reports to `onError`; the next sweep decides again from `users()`.
38
38
  - For one person without the watcher, `runtime.grant.get({wait: 25, since: grant.version})` holds a single read the same way. `wait` outside 1–25 throws `RangeError` before sending.
39
39
 
40
+ ## Optional permissions
41
+
42
+ An optional permission is a scope your app can use but does not need to work. The person turns it on or off for your app, on the consent page and later on the app's Northlight account page, and your app keeps working when it is off.
43
+
44
+ - **Declare it** in the app's registration: `crtr cloud apps set-optional-scopes` (or the console's **What it asks for** tab). The first one is `crtr:memory:read_private:user`: see [Private memory](/docs/guides/files-and-documents).
45
+ - **Ask at sign-in only when the person chose to.** Add the scope to `authorizeUrl`'s `scopes` only after the person said they want it in your app. A person who declines an optional row on the consent page sees the page again on every sign-in request that names it, so naming it for everyone nags everyone. Leave the sign-in request unchanged for everyone else.
46
+ - **Or link to the switch** with `permissionUrl`. It opens the account page with that scope's switch first, and `returnTo` (an absolute URL on one of your registered redirect origins; anything else is ignored) puts a "Back to your app" button on it once the person turns it on:
47
+
48
+ ```ts
49
+ const href = await oauth.permissionUrl('crtr:memory:read_private:user', {returnTo: 'https://app.example.com/settings'});
50
+ ```
51
+
52
+ It needs no user or connection, like `accountAppUrl`, and throws `CrouterError` when the scope is not a valid scope string.
53
+ - **Read the state from the grant.** The permission is on when the scope is in `grant.resolved_scopes` and off when it is not. Never keep your own copy of the person's choice.
54
+ - **Follow changes with `watchGrants`** (above). A switch changes the grant's `version`, so `onChanged` delivers it. Decide from the grant as it is now: start the work that needs the scope when it appears, and stop or restart it when it disappears.
55
+
40
56
  ## Wait on a provider your app requires
41
57
 
42
58
  An app whose registration `requires` a provider (the console's **What your app requires**) needs that provider set up for the person before the operations that depend on it work. The runtime does not check this for you. `grant.get()` returns `dependencies`: one entry per setup id the app requires or lists as optional (plus any the directory implies, such as `device` for `context.deep`), keyed by setup id, or `{}` when it has none. Read it on each request that needs the provider; do not store it.
@@ -46,7 +46,7 @@ A visitor chatting with B's clone starts an app run on **B's runtime and B's AI
46
46
 
47
47
  A run your app starts is **your app's agent**, not a continuation of another app's agent. It starts in `/apps/<app>/files`, uses the person's AI under `crtr:llm`, and reaches your app's own documents (owned by `app:<your app>`) without a `crtr:memory` scope. Other people's or apps' data is not shared merely because the app has many users. The grant's resolved permissions govern access to the person's files (`crtr:files:<access>:user:<dir>`), the person's documents (`crtr:memory:<access>:user`), another app's space (only where that app allows it), and another app's conversations (`crtr:conversations:read:app:<B>`).
48
48
 
49
- The exception is the person's **public** documents (owned by the person, read through `memory`; see [Files and documents](/docs/guides/files-and-documents)): an app with an active grant can read these even without a user-memory scope. A third-party app cannot see `friends` or `private` documents, even with `crtr:memory:read:user`. They look absent from lists, searches, reads and history. Only a holder of `crtr:memory:manage:user` (a first-party app), or the person, reads all three levels. A document without a privacy field reads as `private`. Public read access is not write access.
49
+ The exception is the person's **public** documents (owned by the person, read through `memory`; see [Files and documents](/docs/guides/files-and-documents)): an app with an active grant can read these even without a user-memory scope. A third-party app cannot see `friends` documents, and sees `private` ones only with the optional `crtr:memory:read_private:user` the person switched on; `crtr:memory:read:user` reveals neither. They look absent from lists, searches, reads and history. Only a holder of `crtr:memory:manage:user` (a first-party app), or the person, reads all three levels. A document without a privacy field reads as `private`. Public read access is not write access.
50
50
 
51
51
  Every run is sandboxed: its shell runs as the app's OS user and sees only the app's space and the folders its permissions name. Normal runs have network access. Start a run with `isolated: true` when the agent should have **no network** and **no `/apps/<app>/files` mount**: its cwd is its own node folder, and all children inherit the restriction. The shell, stored conversation and model calls still work, and the run's `scopes` can narrow its commands. An isolated run cannot turn isolation off in a child or schedule, or use an outside cwd or worktree.
52
52
 
@@ -117,7 +117,7 @@ In your callback handler, check `authorizationRefusalOf(url)` before reading `co
117
117
 
118
118
  ## Memory and providers
119
119
 
120
- `user.memory.put({name, body, privacy = 'public', kind = 'knowledge', ...})` and `remove(name)`. Without `crtr:memory:manage:user` in the grant, only `public` documents are listed and others read as 404, as on the runtime. List items carry the full `MemoryListItem` fields.
120
+ `user.memory.put({name, body, privacy = 'public', kind = 'knowledge', ...})` and `remove(name)`. Without `crtr:memory:manage:user` in the grant, only `public` documents are listed and others read as 404, as on the runtime; `crtr:memory:read_private:user` adds `private` (never `friends`). FakeCloud applies the user's whole grant (`user.grant.set`), not the `scopes` a run was started with; test narrowed runs on the real runtime. List items carry the full `MemoryListItem` fields.
121
121
 
122
122
  ```ts
123
123
  cloud.providers.define('sms', { tools: { send: { group: 'send', handler: ({ userId, arguments: args }) => ({ sent: true }) } } });
@@ -15,6 +15,7 @@ Scope strings are prefixed with `crtr:`. These are the ones the daemon and CLI c
15
15
  | `crtr:act` | Creating, forking, reviving, closing and messaging nodes; `bash` and `files`; creating a human request or review (the `crtr human` commands are hidden from a run without it) |
16
16
  | `crtr:schedule` | Creating, pausing, resuming, running and deleting crons (the `crtr cron` commands are hidden without it) |
17
17
  | `crtr:memory:read:user[:path]`, `crtr:memory:rw:user[:path]`, `crtr:memory:manage:user` | The person's documents: reading, writing (`crtr doc`), and `manage` for every privacy level and every document |
18
+ | `crtr:memory:read_private:user` | Read-only access to the person's `public` and `private` documents, never `friends`; no path suffix; an optional permission the person switches on; `manage:user` covers it, `read:user` and `rw:user` do not |
18
19
  | `crtr:memory:read:app:<id>`, `crtr:memory:rw:app:<id>` | An app's documents; `…:profile:<profile>` narrows to one of its profiles. A run with no `scopes` list, and an app's own server, reads and writes its own app's space freely; a run started with a `scopes` list needs these for its own app too |
19
20
  | `crtr:files:read\|rw:user:<dir>`, `crtr:files:read\|rw:app:<id>:<dir>`, `crtr:files:share` | Host files the run may mount, and sharing files beyond the app's own space |
20
21
  | `crtr:conversations:read:app:<id>`, `crtr:objects:read:app:<id>` | Reading another app's conversations or node objects |
@@ -22,7 +23,7 @@ Scope strings are prefixed with `crtr:`. These are the ones the daemon and CLI c
22
23
 
23
24
  The `memory` in `crtr:memory:*` is the scope family's name for access to **documents** (see [Documents](./documents.md)); there is no separate memory store. The older short names (`ask`, `act`, `schedule`, `memory:read`, `memory:write`, `llm`) are not accepted by `crtr sys connect --scopes`. A node's own documents, and an app's documents for its own nodes, need no `crtr:memory` scope.
24
25
 
25
- This is an allow-list, not a claim that code will behave. The daemon checks the scope at the route that performs the action and rejects an unavailable one. The SDK’s [`nodes.create`](../sdk/nodes.md) `scopes` field narrows a run: a run's list must stay inside what its creator and grant hold, and `crtr:act` requires `crtr:llm`. `client.memory` is not scope-gated: it lists and reads the person's `knowledge` and `preference` documents, and the answer is filtered by privacy — an app sees `public` documents, and a grant holding `crtr:memory:manage:user` also sees `friends` and `private` ones. Who may read any document is the owner-and-privacy model, not a per-call scope. The exception is a run started with a `scopes` list: it reads its own app's documents (including automatic delivery of them at boot) only with `crtr:memory:read:app:<id>` and writes or edits them only with `crtr:memory:rw:app:<id>`; a refused write answers `scope_missing` naming the scope. Such a run still publishes its own reports with `crtr push` and its promoted node still gets a roadmap, but cannot edit any other document through that path, and a child never regains a scope its parent's list dropped.
26
+ This is an allow-list, not a claim that code will behave. The daemon checks the scope at the route that performs the action and rejects an unavailable one. The SDK’s [`nodes.create`](../sdk/nodes.md) `scopes` field narrows a run: a run's list must stay inside what its creator and grant hold, and `crtr:act` requires `crtr:llm`. `client.memory` is not scope-gated: it lists and reads the person's `knowledge` and `preference` documents, and the answer is filtered by privacy — an app sees `public` documents, a grant holding `crtr:memory:read_private:user` also sees `private` ones, and a grant holding `crtr:memory:manage:user` sees `friends` and `private` ones. Who may read any document is the owner-and-privacy model, not a per-call scope. The exception is a run started with a `scopes` list: it reads its own app's documents (including automatic delivery of them at boot) only with `crtr:memory:read:app:<id>` and writes or edits them only with `crtr:memory:rw:app:<id>`; a refused write answers `scope_missing` naming the scope. Such a run still publishes its own reports with `crtr push` and its promoted node still gets a roadmap, but cannot edit any other document through that path, and a child never regains a scope its parent's list dropped.
26
27
 
27
28
  ```mermaid
28
29
  flowchart LR
@@ -78,7 +78,7 @@ crtr sys connect --app my-app --scopes crtr:llm,crtr:act,crtr:memory:read:user
78
78
 
79
79
  `--scopes` requires `--app` (the app id, without the `app:` prefix) and accepts only `crtr:llm`, `crtr:act`, `crtr:schedule`, `crtr:memory:read:user`, `crtr:memory:read:app`, `crtr:memory:rw:app`, `crtr:files:read:user:.` and `crtr:files:rw:user:.`. This mints a new token for that app whose scope list is a ceiling, appends it to the same secrets store, prints it in place of the owner token, and always hands the daemon over (it reads tokens once, at boot). The ceiling is checked on every request that carries the token:
80
80
 
81
- - A scope-gated operation the ceiling lacks answers `403 scope_denied`. Creating, reviving, forking, messaging, or yielding a node needs `crtr:act`, as do `bash`, `files`, and creating a human request or review (there is no separate scope for human requests); arming or running a cron needs `crtr:schedule`; `/v1/llm` needs `crtr:llm`. `client.memory` has no scope of its own: it reads the person's documents filtered by privacy, and a grant holding `crtr:memory:manage:user` sees every level.
81
+ - A scope-gated operation the ceiling lacks answers `403 scope_denied`. Creating, reviving, forking, messaging, or yielding a node needs `crtr:act`, as do `bash`, `files`, and creating a human request or review (there is no separate scope for human requests); arming or running a cron needs `crtr:schedule`; `/v1/llm` needs `crtr:llm`. `client.memory` has no scope of its own: it reads the person's documents filtered by privacy, and a grant holding `crtr:memory:read_private:user` also sees `private` documents, and one holding `crtr:memory:manage:user` sees every level.
82
82
  - `nodes.create` with `scopes` outside the ceiling answers `403 scope_denied`; `details.scopes` lists the offending scopes. Omit `scopes` and the run gets the ceiling.
83
83
  - Owner-only operations — daemon restart, the attach viewer, broker internals, canvas prune, profile pause/resume/delete, model credential install — answer `403 owner_only` to any scoped token.
84
84
 
@@ -7,6 +7,21 @@ 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.487
11
+
12
+ ### Run notices and the app listener verify one call-token shape (additive, no app change)
13
+
14
+ `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.
15
+
16
+ ## 0.3.486
17
+
18
+ ### `crtr:memory:read_private:user` and `oauth.permissionUrl` (additive, no app change)
19
+
20
+ - The scope `crtr:memory:read_private:user` lets an app read the person's `private` memory as well as `public`, read-only. `friends` documents are never included, and the scope grants no write. `crtr:memory:manage:user` covers it; `read:user` and `rw:user` do not. It takes no path suffix. With it, `memory.list` and `memory.get` (and the memory a run reads) return `private` documents marked `privacy: 'private'`; without it they read as absent. A run started with a `scopes` list sees `private` only if that list covers it (names it, or names `crtr:memory:manage:user`). The grant can lose the scope while a run is live: the run's next read stops returning private documents, but what it already read stays in its context, so restart runs when access turns off.
21
+ - New `await oauth.permissionUrl(scope, {returnTo?})` returns `accountAppUrl()` with `permission=<scope>` and, when given, `return_to=<returnTo>`, so the person lands on the account page with that optional permission's switch first. It throws `CrouterError` when `scope` is not a valid scope string.
22
+ - `FakeCloud` memory visibility honors `read_private` (public and private, never friends). It follows the user's whole grant, not a run's narrowed `scopes`.
23
+ - A runtime older than this release cannot parse the scope and marks a grant that holds it inactive, so a directory change that offers it waits until hosted runtimes are on this release.
24
+
10
25
  ## 0.3.484
11
26
 
12
27
  Plugins can now declare canvas object kinds and act on the canvas, and an app token can be granted canvas scopes. Everything below is additive: no request or response an existing app sends or reads changes, with one runtime-side exception under [Unknown bearers](#unknown-bearers-are-refused).