@north-light/cli 0.3.491 → 0.3.493

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 (33) hide show
  1. package/dist/api/command-manifest/index.d.ts +1 -0
  2. package/dist/api/command-manifest/index.js +1 -1
  3. package/dist/api/command-manifest/object-kinds.d.ts +20 -0
  4. package/dist/api/command-manifest/object-kinds.js +1 -0
  5. package/dist/api/contract.d.ts +1 -1
  6. package/dist/api/contract.js +1 -1
  7. package/dist/api/dto/lifecycle.d.ts +8 -0
  8. package/dist/builtin-memory/crouter-plugin/deploying.md +22 -0
  9. package/dist/builtin-memory/crouter-sdk/client.md +1 -1
  10. package/dist/builtin-memory/crouter-sdk/release-notes.md +35 -1
  11. package/dist/builtin-memory/crouter-sdk/resources.md +2 -2
  12. package/dist/builtin-memory/northlight-apps/concepts/connections.md +17 -0
  13. package/dist/builtin-memory/northlight-apps/errors.md +2 -0
  14. package/dist/clients/attach/viewer.js +429 -429
  15. package/dist/commands/node-lifecycle-revive.js +1 -1
  16. package/dist/core/graph/access.d.ts +3 -0
  17. package/dist/core/graph/access.js +3 -3
  18. package/dist/core/plugin-objects-manifest.d.ts +2 -16
  19. package/dist/core/plugin-objects-manifest.js +1 -1
  20. package/dist/core/runtime/view-socket.d.ts +3 -4
  21. package/dist/core/runtime/view-socket.js +1 -1
  22. package/dist/daemon/api/handlers/nodes.js +1 -1
  23. package/dist/daemon/api/map.d.ts +1 -1
  24. package/dist/daemon/api/map.js +1 -1
  25. package/docs/apps/concepts/connections.md +17 -0
  26. package/docs/apps/errors.md +2 -0
  27. package/docs/plugin/deploying.md +22 -0
  28. package/docs/sdk/client.md +1 -1
  29. package/docs/sdk/release-notes.md +35 -1
  30. package/docs/sdk/resources.md +2 -2
  31. package/package.json +2 -2
  32. package/packages/crouter-identity/package.json +1 -1
  33. package/runtime.lock.json +12 -12
@@ -1,3 +1,4 @@
1
1
  export * from './schema.js';
2
2
  export * from './manifest.js';
3
3
  export * from './result.js';
4
+ export * from './object-kinds.js';
@@ -1 +1 @@
1
- export*from"./schema.js";export*from"./manifest.js";export*from"./result.js";
1
+ export*from"./schema.js";export*from"./manifest.js";export*from"./result.js";export*from"./object-kinds.js";
@@ -0,0 +1,20 @@
1
+ export declare const KIND: RegExp;
2
+ export declare const LOCAL: RegExp;
3
+ /** A kind's first segment may not be one of the built-in object types. */
4
+ export declare const RESERVED_KIND_ROOTS: readonly string[];
5
+ export declare const MAX_KIND_EVENTS = 64;
6
+ export declare const MIN_EVENT_LEVEL = 1;
7
+ export declare const MAX_EVENT_LEVEL = 10;
8
+ /** Reasons a catalog (`events` map) is invalid; shared with `registerCustomObject`. */
9
+ export declare function eventCatalogReasons(events: unknown, where: string): string[];
10
+ /** The JSON shape of both blocks of a raw manifest record, independent of the plugin's name and commands. */
11
+ export declare function objectBlocksShapeReasons(record: Record<string, unknown>): string[];
12
+ /**
13
+ * Reasons a kind's `read.command` does not name a non-streaming leaf of plugin `name`. `leafPaths` holds the space-joined path of
14
+ * every leaf below the plugin's root command; `streamingLeafPaths` holds those declared streaming.
15
+ */
16
+ export declare function objectKindReadReasons(kind: string, decl: {
17
+ read?: {
18
+ command: readonly string[];
19
+ };
20
+ }, name: string, leafPaths: ReadonlySet<string>, streamingLeafPaths: ReadonlySet<string>): string[];
@@ -0,0 +1 @@
1
+ var u=Object.defineProperty;var f=(t,e)=>u(t,"name",{value:e,configurable:!0});const m=/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/,E=/^[a-z][a-z0-9-]*$/,b=["report","notice","node","job","document","person","app","repo","profile","action"],_=64,N=1,k=10,l=new Set(["description","events","read"]),y=new Set(["level","supersedes"]);function d(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}f(d,"isRecord");function $(t,e){if(!d(t))return[`${e} events must be an object`];const o=[],c=Object.entries(t);c.length>64&&o.push(`${e} declares more than 64 events`);for(const[r,s]of c){const n=`${e} event ${JSON.stringify(r)}`;if(E.test(r)||o.push(`${n} is not a valid event name`),!d(s)){o.push(`${n} must be an object { level, supersedes? }`);continue}for(const a of Object.keys(s))y.has(a)||o.push(`${n} has unknown key ${JSON.stringify(a)}`);const i=s.level;(typeof i!="number"||!Number.isInteger(i)||i<1||i>10)&&o.push(`${n} level must be an integer 1-10`),s.supersedes!==void 0&&typeof s.supersedes!="boolean"&&o.push(`${n} supersedes must be a boolean`)}return o}f($,"eventCatalogReasons");function j(t){const e=[],o=t.object_kinds;if(o!==void 0)if(!d(o))e.push("object_kinds must be an object keyed by kind name");else for(const[r,s]of Object.entries(o)){const n=`object_kinds ${JSON.stringify(r)}`;if(m.test(r)?b.includes(r.split(".")[0])&&e.push(`${n} uses a reserved first segment`):e.push(`${n} is not a valid kind name (<plugin>.<name>, lowercase)`),!d(s)){e.push(`${n} must be an object { description, events, read? }`);continue}for(const a of Object.keys(s))l.has(a)||e.push(`${n} has unknown key ${JSON.stringify(a)}`);(typeof s.description!="string"||s.description.trim()==="")&&e.push(`${n} description must be non-empty text`),e.push(...$(s.events,n));const i=s.read;if(i!==void 0){const a=d(i)?i.command:void 0;!d(i)||Object.keys(i).some(p=>p!=="command")?e.push(`${n} read must be { command: [leaf path] }`):(!Array.isArray(a)||a.length===0||a.some(p=>typeof p!="string"||p===""))&&e.push(`${n} read.command must be a non-empty array of command names`)}}const c=t.scopes;return c!==void 0&&(!Array.isArray(c)||c.some(r=>typeof r!="string"))&&e.push("scopes must be an array of scope strings"),e}f(j,"objectBlocksShapeReasons");function g(t,e,o,c,r){const s=e.read?.command;if(s===void 0)return[];const n=s.join(" ");return c.has(n)?r.has(n)?[`object_kinds ${JSON.stringify(t)} read.command "${n}" streams; a read is one {markdown} answer`]:[]:[`object_kinds ${JSON.stringify(t)} read.command "${n}" is not a leaf of plugin "${o}"`]}f(g,"objectKindReadReasons");export{m as KIND,E as LOCAL,k as MAX_EVENT_LEVEL,_ as MAX_KIND_EVENTS,N as MIN_EVENT_LEVEL,b as RESERVED_KIND_ROOTS,$ as eventCatalogReasons,j as objectBlocksShapeReasons,g as objectKindReadReasons};
@@ -1,5 +1,5 @@
1
1
  /** The contract level this build serves and requires. Written by `scripts/contract.mjs release`; never edit by hand. */
2
- export declare const CONTRACT_LEVEL = "1.12";
2
+ export declare const CONTRACT_LEVEL = "1.14";
3
3
  /** Request header naming the one contract level the caller needs, e.g. `1.3`. Optional: absent means no check. */
4
4
  export declare const REQUIRED_CONTRACT_HEADER = "Crouter-Required-Contract";
5
5
  /** Response header listing the contract levels the server serves, comma-separated, one per major, e.g. `1.3`. */
@@ -1 +1 @@
1
- var c=Object.defineProperty;var e=(r,t)=>c(r,"name",{value:t,configurable:!0});const f="1.12",i="Crouter-Required-Contract",p="Crouter-Contract";function l(r){const t=/^(\d+)\.(\d+)$/.exec(r.trim());return t===null?null:{major:Number(t[1]),minor:Number(t[2])}}e(l,"parseContractLevel");function a(r){return r==null?[]:r.split(",").map(t=>l(t)).filter(t=>t!==null)}e(a,"parseServedContracts");function s(r,t){const n=l(r);return n===null?!1:(typeof t=="string"||t===null||t===void 0?a(t):t).some(u=>u.major===n.major&&u.minor>=n.minor)}e(s,"contractSatisfies");function C(r,t,n={}){if(r===void 0)return null;if(l(r)===null)return{status:400,code:"invalid_request",param:i,message:`${i} must be a contract level like 1.0.`};if(s(r,t))return null;const o=r.trim();return{status:426,code:"runtime_version_unsupported",message:t===null||a(t).length===0?`The runtime did not name its contract level; the caller requires contract ${o} or later. Roll the runtime forward.`:`The runtime serves contract ${t}; the caller requires contract ${o} or later within the same major. Roll the runtime forward, or pin an SDK whose crouterContract the runtime serves.`,details:{sdk_version:(n.legacyMinimum??"").trim(),runtime_version:n.runtimeVersion??null,required_contract:o,runtime_contract:t}}}e(C,"contractRefusal");export{p as CONTRACT_HEADER,f as CONTRACT_LEVEL,i as REQUIRED_CONTRACT_HEADER,C as contractRefusal,s as contractSatisfies,l as parseContractLevel,a as parseServedContracts};
1
+ var c=Object.defineProperty;var e=(r,t)=>c(r,"name",{value:t,configurable:!0});const f="1.14",i="Crouter-Required-Contract",p="Crouter-Contract";function l(r){const t=/^(\d+)\.(\d+)$/.exec(r.trim());return t===null?null:{major:Number(t[1]),minor:Number(t[2])}}e(l,"parseContractLevel");function a(r){return r==null?[]:r.split(",").map(t=>l(t)).filter(t=>t!==null)}e(a,"parseServedContracts");function s(r,t){const n=l(r);return n===null?!1:(typeof t=="string"||t===null||t===void 0?a(t):t).some(u=>u.major===n.major&&u.minor>=n.minor)}e(s,"contractSatisfies");function C(r,t,n={}){if(r===void 0)return null;if(l(r)===null)return{status:400,code:"invalid_request",param:i,message:`${i} must be a contract level like 1.0.`};if(s(r,t))return null;const o=r.trim();return{status:426,code:"runtime_version_unsupported",message:t===null||a(t).length===0?`The runtime did not name its contract level; the caller requires contract ${o} or later. Roll the runtime forward.`:`The runtime serves contract ${t}; the caller requires contract ${o} or later within the same major. Roll the runtime forward, or pin an SDK whose crouterContract the runtime serves.`,details:{sdk_version:(n.legacyMinimum??"").trim(),runtime_version:n.runtimeVersion??null,required_contract:o,runtime_contract:t}}}e(C,"contractRefusal");export{p as CONTRACT_HEADER,f as CONTRACT_LEVEL,i as REQUIRED_CONTRACT_HEADER,C as contractRefusal,s as contractSatisfies,l as parseContractLevel,a as parseServedContracts};
@@ -25,6 +25,14 @@ export interface ReviveResultDTO {
25
25
  * already running, `frozen` found no free broker slot and left the row
26
26
  * waiting for one. */
27
27
  outcome: 'launched' | 'already-live' | 'frozen';
28
+ /** Whether the node's `view.sock` accepted a connection, checked by the
29
+ * daemon in its own namespace (a caller's sandbox cannot see another node's
30
+ * socket) for up to 10 s, ending early if the launched broker exits. `true`
31
+ * means the node is attachable; nothing more — it does not show the task was
32
+ * delivered or the model is making progress. `false` means no connection
33
+ * yet: a slow start, an exited broker, or (`frozen`) no broker launched.
34
+ * Absent from a daemon that predates the field: readiness is then unknown. */
35
+ ready?: boolean;
28
36
  }
29
37
  /** `POST /v1/nodes/{id}/relaunch-root` result. A null id means the target
30
38
  * was not a relaunchable live root, or its replacement failed to launch. */
@@ -118,6 +118,28 @@ Crtr-Caller: node=<id>; machine=<id>; profile=<id>; run=<id>
118
118
 
119
119
  The fields appear in that order. A field with no value is left out, and a call with none omits the header. A service uses `node` to link the calling node to an object it owns. The value comes from crtrd's view of the caller; a manifest cannot set `Crtr-Caller`, `Crtr-Signature` or `Crtr-Request-Id`.
120
120
 
121
+ ### Object kinds and the calling node
122
+
123
+ A plugin written with the kit declares canvas object kinds in `definePlugin({objectKinds})` instead of writing `object_kinds` by hand. Each key is the kind's name relative to the plugin (`repo`), lowercase letters, digits and `-`, with no `.`:
124
+
125
+ ```ts
126
+ definePlugin({
127
+ name: 'github',
128
+ objectKinds: {
129
+ repo: {
130
+ description: 'A GitHub repository',
131
+ events: { 'pr-opened': { level: 5 }, 'ci-failed': { level: 8, supersedes: true } },
132
+ read: { command: ['repo', 'read'] },
133
+ },
134
+ },
135
+ commands: { /* … */ },
136
+ });
137
+ ```
138
+
139
+ The served `bundle.json` carries `object_kinds` with each key prefixed `<providerId>.<name>`: `providerId` is the `auth.providerId` the handler was given, else the plugin name, the same value used in scope names. `definePlugin` throws `PluginDefinitionError` for a name with a `.` or capitals, a blank description, more than 64 events, a level outside 1-10, or a `read.command` that is not a non-streaming leaf of the plugin. Building the bundle throws when the prefixed kind is one crouter would refuse (a provider id with capitals or `_`, or a reserved first segment such as `repo` or `report`).
140
+
141
+ Every leaf's `ctx.caller.crtr` carries the calling node, parsed from `Crtr-Caller` by `parseCrtrCaller(header)` (also exported): `{ node?, machine?, profile?, run? }`, each field present only when the header carried it, and `crtr` itself absent when it carried none. **The header is asserted by the calling runtime, not verified:** the bearer token does not cover it. It tells you which of the verified `sub`'s nodes made the call, for example to link that node to an object. Never authorize on it; authorize on `ctx.caller.sub`.
142
+
121
143
  ### `Crtr-Signature`
122
144
 
123
145
  crtrd signs a call to a local plugin when the plugin holds a token and the request goes to the origin of its installed endpoint. Any other URL gets `Crtr-Caller` and no signature.
@@ -56,7 +56,7 @@ There is one request path. The client issues every request through the Web `fetc
56
56
 
57
57
  ## Autostart
58
58
 
59
- With `autostart` on (the default for a socket), a request that finds a cold socket runs `crtr sys daemon start`, waits for the daemon to serve, and retries the request once. This is what makes `npm i -g crouter` followed by `new Crouter()` work on a machine that has never run the daemon.
59
+ With `autostart` on (the default for a socket), a request that finds a cold socket runs `crtr sys daemon start`, waits for the daemon to serve, and retries the request once. This is what makes `npm i -g @north-light/cli` followed by `new Crouter()` work on a machine that has never run the daemon.
60
60
 
61
61
  Autostart is Node-only and applies only to the socket transport. A `baseURL` client cannot start a daemon it may not even share a machine with, so `autostart` defaults to `false` there; setting it to `true` on a `baseURL` client throws `CrouterError` (`autostart is only valid for the local socket transport`).
62
62
 
@@ -9,6 +9,40 @@ when-and-why-to-read: When you need Release notes, this knowledge should be read
9
9
 
10
10
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
11
11
 
12
+ ## 0.3.493
13
+
14
+ An app can act on a person's hosted runtime with no sign-in of theirs, and `@north-light/plugin` can declare canvas object kinds. Everything below is additive: no request or response an existing app sends or reads changes.
15
+
16
+ ### `oauth.appClient(userId)`: the app acts on a person's hosted runtime (additive)
17
+
18
+ `await oauth.appClient('user:<id>')` returns a client on that person's hosted runtime without a stored connection or refresh token. The SDK signs a `client_credentials` request with the `OAuth2Client`'s key (`private_key_jwt`) for `sub=user:<id>`, and the directory answers a token and the person's `runtimeUrl`. The app must be registered with the `app_token_for_user` operation, and the person must hold an active grant for it. Tokens are cached per person until a minute before they expire, and one mint is in flight per person however many callers ask. The client mints again on expiry and once after a `401`. A `userId` that is not `user:<id>` throws `CrouterError`.
19
+
20
+ - `NoHostedRuntimeError` (`no_hosted_runtime`, status 409, not retryable): the person has no hosted runtime to act on.
21
+ - `NotGrantedError` (`not_granted`, status 409, not retryable): the person has no active grant for the app, so they have not signed in to it.
22
+ - A runtime still provisioning (503), a directory 5xx and a 429 throw an error with `retryable: true`. `invalid_client` is a `401` `unauthorized` that names the signing key.
23
+
24
+ `no_hosted_runtime` and `not_granted` are appended to `sdkErrorCodes`, so the codes already in the list keep their positions. `NoHostedRuntimeError` and `NotGrantedError` are exported from `@north-light/sdk`. See [Errors](/docs/errors) and [Connections](/docs/concepts/connections#acting-without-a-sign-in).
25
+
26
+ ### `customObjects` and `canvas` on `AppClient` (additive, type change)
27
+
28
+ `AppClient` (the type of `oauth.client(userId)` and `oauth.appClient(userId)`) now offers two namespaces the app listener already served to an app grant token:
29
+
30
+ - `customObjects.register`, `publish` and `unregister` act on the custom objects your app owns. `receive` and `ack` stay owner-daemon only.
31
+ - `canvas.watch` and `canvas.unwatch` link a node to an object your app owns, under `crtr:objects:link:node`. Pass `watcher` (a node id) and, for a node of another of the account's machines, `watcher_machine`. `unwatch` takes the same `watcher` and `watcher_machine` to remove that node's watch; without `watcher` it removes the app's own watch.
32
+
33
+ Every other `canvas` method and `customObjects.receive`/`ack` remain absent from the type and throw `CrouterError` if reached. `Crouter` is still assignable to `AppClient`.
34
+
35
+ ### `@north-light/plugin`: `objectKinds` and `LeafCaller.crtr` (additive)
36
+
37
+ - `definePlugin({objectKinds})` declares canvas object kinds by name relative to the plugin (`repo`), each `{ description, events: { <name>: { level: 1-10, supersedes? } }, read?: { command: [...] } }`. The bundle the kit serves carries them in `bundle.json` `object_kinds`, keyed `<providerId>.<name>`: `providerId` is the `auth.providerId` the handler was given, else the plugin name. `definePlugin` throws `PluginDefinitionError` for a relative name that is not lowercase letters, digits and `-`, a blank description, more than 64 events, a level outside 1-10, and a `read.command` that is not a non-streaming leaf of the plugin. Building the bundle throws if the prefixed kind is not one crouter accepts (a provider id with capitals or `_`, or one of crouter's reserved roots such as `repo` or `report`).
38
+ - `ctx.caller.crtr` carries the calling node: `{ node?, machine?, profile?, run? }`, parsed by the exported `parseCrtrCaller(header)` from the request's `Crtr-Caller`. It is absent when the header names no field. The runtime asserts that header and the bearer token does not cover it, so use it to tell which of the verified `sub`'s nodes called, never to authorize; authorize on `sub`.
39
+
40
+ See [Local plugins](https://crouter.docs.northlight.ai/docs/plugin/deploying#local-plugins) and [Object kinds and the calling node](https://crouter.docs.northlight.ai/docs/plugin/deploying#object-kinds-and-the-calling-node).
41
+
42
+ ### `nodes.revive` reports `ready` (additive)
43
+
44
+ `ReviveResultDTO` gains an optional `ready`. The daemon checks, in its own namespace, whether the revived node's `view.sock` accepts a connection (up to 10 s, ending early if the launched broker exits) and returns the answer. `true` means the node is attachable; it does not show the task was delivered or the model is making progress. `false` means no connection yet, including `outcome: 'frozen'`. A daemon that predates the field sends none, so treat an absent `ready` as unknown.
45
+
12
46
  ## 0.3.488
13
47
 
14
48
  ### Developer CLI: `--help` exits 0, `login` is safe to re-run (CLI only, no app change)
@@ -60,7 +94,7 @@ Plugins can now declare canvas object kinds and act on the canvas, and an app to
60
94
  - `app:<x>`: nodes and custom objects of another app `x`. An app never needs a scope for its own objects, and a plugin may not declare its own `app:<plugin>` space.
61
95
  - `node`: any node, including the stub of a node on another of the account's machines.
62
96
 
63
- `read` lets the holder read objects of the target, `write` change them, and `link` (below) make a node watch an object the holder owns. `watch` lets a custom object that watches an object of the target read it. Holding a scope never lifts run confinement inside the owning app, and a revoked grant holds nothing. A public custom object is readable by any app that may use the owning app's tools, with no scope. The app listener now serves the canvas operations (`objects.*`, `custom-objects.*`, `docs.*`) to an app grant token under its resolved scopes; those rows answered `unknown operation` there before.
97
+ `read` lets the holder read objects of the target, `write` change them, and `link` (below) make a node watch an object the holder owns. `watch` lets a custom object that watches an object of the target read it. Holding a scope never lifts run confinement inside the owning app, and a revoked grant holds nothing. A public custom object is readable by any app that may use the owning app's tools, with no scope. A `friends` or `private` instance is readable, with no read scope, only by a run (a node, or a node of another machine) that holds one of the owning app's provider-group scopes `<app>:<group>`, the scopes a call to that app's provider commands needs; another app's server reads it only through a held `crtr:objects:read:app:<app>` scope. The app listener now serves the canvas operations (`objects.*`, `custom-objects.*`, `docs.*`) to an app grant token under its resolved scopes; those rows answered `unknown operation` there before.
64
98
 
65
99
  ### The `crtr_app_` credential and its socket fetch (additive)
66
100
 
@@ -40,13 +40,13 @@ Every namespace exported by the client. Namespaces are camelCase. Verbs are `cre
40
40
  | `grant` | `get` (`wait`, `since`: hold until `version` changes) | `GET /v1/grant` |
41
41
  | `health` | `get` | `GET /v1/health` |
42
42
 
43
- `runs`, `uploads`, `attachments`, `shares`, `questions`, `docs` and the app side of `grant` and `providers` are the application-facing API an app reaches through the app listener; `oauth.client(userId)` returns a client typed to exactly that set (`AppClient`); `nodes`, `bash`, `canvas`, `crons`, `human`, `models`, `customObjects`, `structuredRequests`, `system`, `auth`, `files.read/write/list` and `profiles.ensure/retrieve` are the owner's own daemon and are not served to an app. An app schedules work with its own timer calling `runs.start`, or a run's agent runs `crtr cron add` under `crtr:schedule`. See [[crouter-sdk/guides/apps-on-northlight]] and [[crouter-sdk/guides/apps-with-many-users]]. `memory` is the person's `public` documents ([[crouter-sdk/memory]]). `docs` writes, reads and edits documents the calling app owns: `docs.write({name, body, preview, kind?, rules?})` returns the canonical `name`; `docs.get(name)` returns `body`, `revision`, `rules` and the edit `base`; `docs.edit(name, {body, rationale, base?})` changes it in place and refuses a stale `base` with `ConflictError` (`stale_revision`). To keep one document current, `get` it (catch `NotFoundError` on first run to `write`), compare `body` to your text, and `edit` only when they differ. `system.health` (`/healthz`) is the daemon's liveness check; `health.get` is the app listener's.
43
+ `runs`, `uploads`, `attachments`, `shares`, `questions`, `docs` and the app side of `grant` and `providers` are the application-facing API an app reaches through the app listener; `oauth.client(userId)` returns a client typed to exactly that set (`AppClient`); `customObjects` (`register`, `publish`, `unregister`) and `canvas` (`watch`, `unwatch`) are served too, for the custom objects your app owns; `oauth.appClient(userId)` returns the same `AppClient` type without a stored sign-in ([[northlight-apps/concepts/connections]]). `nodes`, `bash`, `crons`, `human`, `models`, `structuredRequests`, `system`, `auth`, the rest of `canvas` and `customObjects` (`receive`, `ack`), `files.read/write/list` and `profiles.ensure/retrieve` are the owner's own daemon and are not served to an app. An app schedules work with its own timer calling `runs.start`, or a run's agent runs `crtr cron add` under `crtr:schedule`. See [[crouter-sdk/guides/apps-on-northlight]] and [[crouter-sdk/guides/apps-with-many-users]]. `memory` is the person's `public` documents ([[crouter-sdk/memory]]). `docs` writes, reads and edits documents the calling app owns: `docs.write({name, body, preview, kind?, rules?})` returns the canonical `name`; `docs.get(name)` returns `body`, `revision`, `rules` and the edit `base`; `docs.edit(name, {body, rationale, base?})` changes it in place and refuses a stale `base` with `ConflictError` (`stale_revision`). To keep one document current, `get` it (catch `NotFoundError` on first run to `write`), compare `body` to your text, and `edit` only when they differ. `system.health` (`/healthz`) is the daemon's liveness check; `health.get` is the app listener's.
44
44
 
45
45
  Action methods keep the product's literal name (`fork`, `revive`, `promote`, `yield`) rather than being renamed into a generic verb. Every request-capable method takes `RequestOptions` as its final argument after its path, body, or query arguments. `RequestOptions` is `{ headers?, signal?, timeout?, maxRetries? }`; stream event options exclude `timeout` and add `after`.
46
46
 
47
47
  ## Custom objects
48
48
 
49
- `client.customObjects` registers an external service or script as an object on the canvas, with a declared event catalog and a queue, URL or argv receiver, and publishes its events to the objects watching it. `register(body)` returns the object and, once, its publish token; `publish(ref, body)` sends one declared event (a custom object may publish with that token alone); `receive(ref, { after?, signal? })` is an async iterator over a queue receiver's deliveries and replays every unacknowledged delivery on each connect; `ack(ref, deliveryIds)` acknowledges them; `unregister(ref)` deletes the object. The same objects are managed from a terminal with `crtr custom-object` and watched with `crtr canvas watch`.
49
+ `client.customObjects` registers an external service or script as an object on the canvas, with a declared event catalog and a queue, URL or argv receiver, and publishes its events to the objects watching it. `register(body)` returns the object and, once, its publish token; `publish(ref, body)` sends one declared event (a custom object may publish with that token alone); `receive(ref, { after?, signal? })` is an async iterator over a queue receiver's deliveries and replays every unacknowledged delivery on each connect; `ack(ref, deliveryIds)` acknowledges them; `unregister(ref)` deletes the object. An app reaches `register`, `publish` and `unregister` on its own objects through `oauth.client(userId)` or `oauth.appClient(userId)`, and links a node to one with `client.canvas.watch(ref, { watcher, watcher_machine? })` and `client.canvas.unwatch(ref, { watcher, watcher_machine? })` under `crtr:objects:link:node` (without `watcher`, `unwatch` removes the app's own watch). The same objects are managed from a terminal with `crtr custom-object` and watched with `crtr canvas watch`.
50
50
 
51
51
  ## Files and bash
52
52
 
@@ -100,6 +100,23 @@ await oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000});
100
100
 
101
101
  It refreshes every connection last saved before the cutoff, through the store's lock, and returns `{refreshed, refused, failures}`. A refused connection goes through `onRefusal` and is removed. `failures` holds connections that could not be refreshed and are still stored; the next run retries them. The SDK starts no timer. The store needs `listStale(before)`, which both `MemoryConnectionStore` and `PostgresConnectionStore` have.
102
102
 
103
+ ## Acting without a sign-in
104
+
105
+ A server that acts for a person on their hosted runtime, for example to publish events to the objects they watch, uses `oauth.appClient(userId)` instead of `oauth.client(userId)`. It stores no connection and refreshes nothing: the SDK signs a `client_credentials` request with your app's key for `sub=user:<id>`, and the directory answers a token and the person's `runtimeUrl`.
106
+
107
+ ```ts
108
+ const runtime = await oauth.appClient('user:o1');
109
+ await runtime.customObjects.publish(ref, {event: 'changed', body: '…'});
110
+ ```
111
+
112
+ It needs the app registered with the `app_token_for_user` operation, and an active grant from the person. The token is cached per person until a minute before it expires, the client mints a new one on expiry and once after a `401`, and concurrent callers share one mint. It returns the same `AppClient` as `oauth.client`, so `customObjects` (`register`, `publish`, `unregister`) and `canvas` (`watch`, `unwatch`) are available and the owner-daemon namespaces are not.
113
+
114
+ | Error | Code | Means |
115
+ |---|---|---|
116
+ | `NoHostedRuntimeError` | `no_hosted_runtime` (409) | The person has no hosted runtime. Not retryable. |
117
+ | `NotGrantedError` | `not_granted` (409) | The person has no active grant for your app, so they have not signed in to it. Not retryable. |
118
+ | any error with `retryable: true` | `runtime_provisioning` (503), a directory 5xx or a 429 | Try again later. |
119
+
103
120
  ## Signing in a visitor
104
121
 
105
122
  A person who only needs to be recognized, not connected, signs in with identity only: request `openid`, optionally with `email` and `profile`, and no runtime scope. Finish with `exchangeIdentity`, not `exchangeCode`. An `Identity` is not a connection: no runtime URL, grant or refresh token, nothing goes in the store, and you keep your own session. To act for that person later, send them through `authorizeUrl` again with the runtime scopes you need.
@@ -22,6 +22,8 @@ Codes the SDK raises itself are listed in `sdkErrorCodes`, and `CrouterError.cod
22
22
  | `oauth_nonce_missing` | `exchangeIdentity` was called without the `nonce` from `authorizeUrl`. | Save the nonce in the visitor's session and pass it back. |
23
23
  | `oauth_state_mismatch` | The callback's `state` was missing or differed from the saved one. | Refuse the callback. |
24
24
  | `not_connected` | `oauth.client(userId)` or a refresh found no stored connection for that person. | Show "sign in again". |
25
+ | `no_hosted_runtime` (409, `NoHostedRuntimeError`) | `oauth.appClient(userId)`: the person has no hosted runtime to act on. Not retryable. | Skip them, or ask them to set up a hosted runtime. |
26
+ | `not_granted` (409, `NotGrantedError`) | `oauth.appClient(userId)`: the person has no active grant for your app. Not retryable. | Send them through sign-in ([[northlight-apps/concepts/connections]]). |
25
27
  | `refusal_handler_reentry` | An `onRefusal` handler called `oauth.client` (or a refresh) for the person whose connection it is handling. | Don't call the runtime for `refusal.userId` inside the handler. |
26
28
  | `unauthorized` (401, `AuthenticationError`) | The directory refused your app's client assertion (`invalid_client`) on `exchangeCode`, a refresh or `revoke`, including a refresh inside a client from `oauth.client(userId)`. The message names the key id (`kid`) you signed with and asks whether that key's public JWK is registered. `details` is `{oauth_error: 'invalid_client', kid}`, or `key_thumbprint` instead of `kid` for a key with no `kid`. | Check that the key's public JWK is on the app's **Sign-in** tab ([[northlight-apps/guides/manage-your-app]]). |
27
29
  | `run_idle`, `waiting_on_user` | `runs.parse`: the run stopped without submitting, or waits on a question and you passed no `onQuestion`. | See [[northlight-apps/guides/typed-results]]. |