@north-light/cli 0.3.472 → 0.3.473
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.
- package/dist/api/dto/nodes.d.ts +1 -1
- package/dist/builtin-memory/crouter-sdk/README.md +1 -1
- package/dist/builtin-memory/crouter-sdk/errors.md +1 -1
- package/dist/builtin-memory/crouter-sdk/getting-started.md +1 -1
- package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +1 -1
- package/dist/builtin-memory/crouter-sdk/nodes.md +1 -1
- package/dist/builtin-memory/crouter-sdk/release-notes.md +20 -20
- package/dist/commands/node/create.js +2 -2
- package/docs/apps/cli.md +40 -0
- package/docs/apps/coding-agents.md +72 -0
- package/docs/apps/concepts/apps-and-sign-in.md +82 -0
- package/docs/apps/concepts/connections.md +106 -0
- package/docs/apps/concepts/providers.md +59 -0
- package/docs/apps/concepts/runs.md +94 -0
- package/docs/apps/concepts/scopes-and-grants.md +62 -0
- package/docs/apps/concepts/testing-mode.md +36 -0
- package/docs/apps/errors.md +117 -0
- package/docs/apps/guides/background-runs.md +78 -0
- package/docs/apps/guides/files-and-documents.md +59 -0
- package/docs/apps/guides/go-live.md +37 -0
- package/docs/apps/guides/grant-changes.md +98 -0
- package/docs/apps/guides/manage-your-app.md +75 -0
- package/docs/apps/guides/many-users.md +61 -0
- package/docs/apps/guides/stream-a-run.md +82 -0
- package/docs/apps/guides/test-your-app.md +191 -0
- package/docs/apps/guides/typed-results.md +72 -0
- package/docs/apps/how-it-fits.md +46 -0
- package/docs/apps/providers/README.md +26 -0
- package/docs/apps/providers/context.md +32 -0
- package/docs/apps/providers/device.md +26 -0
- package/docs/apps/providers/integrations.md +26 -0
- package/docs/apps/providers/search.md +29 -0
- package/docs/apps/providers/sms.md +37 -0
- package/docs/apps/quickstart.md +120 -2
- package/docs/sdk/README.md +1 -1
- package/docs/sdk/errors.md +1 -1
- package/docs/sdk/getting-started.md +1 -1
- package/docs/sdk/guides/apps-on-northlight.md +1 -1
- package/docs/sdk/nodes.md +1 -1
- package/docs/sdk/release-notes.md +20 -20
- package/package.json +2 -2
- package/packages/crouter-identity/package.json +1 -1
- package/runtime.lock.json +12 -12
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Go live
|
|
3
|
+
description: Move an app from your laptop to a server you choose, and ask Northlight to verify it so anyone can sign in.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Go live
|
|
7
|
+
|
|
8
|
+
Northlight does not host your app's server. Deploy it wherever you like. What changes between your laptop and production is a short list.
|
|
9
|
+
|
|
10
|
+
## Production checklist
|
|
11
|
+
|
|
12
|
+
- **A persistent connection store.** `MemoryConnectionStore` loses every sign-in on restart and works for one process only. Use a store that survives restarts and locks across processes, such as `PostgresConnectionStore` from `@north-light/sdk/stores/postgres`, and pass it as `store`. Two processes that refresh the same connection with different stores race, and the directory refuses the loser with `refresh_token_reused`. The store holds credentials: restrict database access and encrypt backups.
|
|
13
|
+
- **The key file in a secret manager.** The private key is the app's identity. Keep it out of git and out of images. Mount it where your host keeps secrets, and point `CROUTER_KEY_FILE` at that path (a container might read `/run/secrets/app_key`). The SDK reads no environment: your code loads the values and passes them to `OAuth2Client`.
|
|
14
|
+
- **A production redirect URI and origin.** In the console, add `https://<your domain>/auth/callback` under **Sign-in → Redirect URIs** and set the **Origin URL** on the Profile tab to your domain. Set `APP_ORIGIN` to it on the server. Keep `http://localhost:3000/auth/callback` if you still develop locally.
|
|
15
|
+
- **An exact SDK pin.** `npm i --save-exact @north-light/sdk@<version>`. A compatibility gate protects the wire between the SDK and Northlight's runtimes, not the TypeScript API, so a range or `latest` can pick up a type change on its next install. If a call fails with `426 runtime_version_unsupported`, the SDK is newer than the runtimes: install the previous version or wait. To move on purpose, run `npm view @north-light/sdk dist-tags` and read the [release notes](/docs/release-notes) for the versions in between.
|
|
16
|
+
- **One place for dead connections.** Pass `onRefusal` to `OAuth2Client`, and schedule `oauth.refreshIdleConnections` so offline people stay connected. See [Many users](/docs/guides/many-users).
|
|
17
|
+
- **Redact sign-in secrets from logs.** An authorization code and a refresh token in a log line is a credential. Keep them out of HTTP logs.
|
|
18
|
+
- **Listing, if you want it.** In **Profile → Marketplace**, set a sign-in start URL and a text welcome, and turn on **Listed in the marketplace**. A testing app stays out of the marketplace.
|
|
19
|
+
|
|
20
|
+
Four variables configure an app built on the SDK:
|
|
21
|
+
|
|
22
|
+
| Variable | Value |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `CROUTER_ISSUER` | `https://account.northlight.ai` |
|
|
25
|
+
| `CROUTER_CLIENT_ID` | The client ID the console shows |
|
|
26
|
+
| `APP_ORIGIN` | Your app's origin. Its redirect URI is `${APP_ORIGIN}/auth/callback` by convention |
|
|
27
|
+
| `CROUTER_KEY_FILE` | Path to the private JWK; `local/app-key.json` by default |
|
|
28
|
+
|
|
29
|
+
## Let people other than your testers sign in
|
|
30
|
+
|
|
31
|
+
A new app is in testing: only you and up to 100 testers can sign in. To open it to everyone, ask Northlight to verify it by email to support@northlight.ai. Include your app's name and client ID, where it runs, and what it asks for and why. Until then, anyone else is refused with `app_in_testing`. See [Testing mode](/docs/concepts/testing-mode).
|
|
32
|
+
|
|
33
|
+
This site does not state how long verification takes or what Northlight checks beyond that email address (not yet checked on production).
|
|
34
|
+
|
|
35
|
+
## Rotate the key later
|
|
36
|
+
|
|
37
|
+
Add a second key, deploy with it, verify a real sign-in, then retire the old one. Rotation has no downtime. The steps are in [Manage your app](/docs/guides/manage-your-app#rotate-a-key).
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Grant changes
|
|
3
|
+
description: Follow a person's grant, wait for a required provider, show their month's cost, and link to the app's settings.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Grant changes
|
|
7
|
+
|
|
8
|
+
What an app reads from `grant.get()` changes while nobody is holding a request: a required provider's setup turns `ready`, the person changes the app's scopes or spend cap, or the grant is revoked.
|
|
9
|
+
|
|
10
|
+
## Follow grants with `watchGrants`
|
|
11
|
+
|
|
12
|
+
`oauth.watchGrants(...)` returns a `GrantWatcher` that follows the grants of the people you name and hands each change to `onChanged`:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const watcher = oauth.watchGrants({
|
|
16
|
+
// Only people whose change the app will act on: here, owners still waiting on Context.
|
|
17
|
+
users: () => db.owners.findMany({where: {connected: true, drafted: false}}).then((rows) => rows.map((row) => row.userId)),
|
|
18
|
+
onChanged: async ({userId, grant}) => {
|
|
19
|
+
// Level-triggered: decide from the grant as it is now, never from "did it just change".
|
|
20
|
+
if (grant.dependencies['context']?.state !== 'ready') return;
|
|
21
|
+
// Idempotent: only a row still waiting moves, so a redelivery or a second instance starts nothing.
|
|
22
|
+
const claimed = await db.owners.updateMany({where: {userId, drafted: false}, data: {drafted: true}});
|
|
23
|
+
if (claimed.count === 1) await startDraft(userId);
|
|
24
|
+
},
|
|
25
|
+
onError: (error, userId) => log.warn({error, userId}, 'grant watcher'),
|
|
26
|
+
});
|
|
27
|
+
await watcher.start();
|
|
28
|
+
// After the app starts waiting on someone:
|
|
29
|
+
watcher.nudge();
|
|
30
|
+
// On shutdown:
|
|
31
|
+
await watcher.stop();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **`users()`** returns the Northlight user ids whose grant you follow, read from your own rows. It is called at `start()`, every `sweepMs` (default 30 000), after each delivery and on `nudge()`. A user it no longer returns is forgotten. Each followed person holds one request on their runtime per turn, so return only people whose change you will act on.
|
|
35
|
+
- **Each turn** is one `grant.get` on `oauth.client(userId)`. A user whose last reading was delivered reads `grant.get({wait, since: version})` (`wait` integer 1–25, default 25): the runtime holds the read until the grant's `version` changes or `wait` seconds pass. With more users than `concurrency` (default 50), every user still gets a turn in round-robin order.
|
|
36
|
+
- **`onChanged({userId, grant, previous})`** is level-triggered. The first event for a user in a process carries the current grant and `previous: null`, so a restart delivers current state again. Delivery is at least once per `(userId, version)`; make the handler idempotent.
|
|
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
|
+
- 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
|
+
|
|
40
|
+
## Wait on a provider your app requires
|
|
41
|
+
|
|
42
|
+
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 required provider, keyed by provider id, or `{}` when the app requires none. Read it on each request that needs the provider; do not store it.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import type {ProviderDependency} from '@north-light/sdk';
|
|
46
|
+
|
|
47
|
+
// ProviderDependency is { state: 'not_started' | 'pending' | 'ready' | 'failed'; message?: string; next: string }.
|
|
48
|
+
const {dependencies} = await runtime.grant.get();
|
|
49
|
+
const context: ProviderDependency | undefined = dependencies['context'];
|
|
50
|
+
if (context?.state !== 'ready') {
|
|
51
|
+
// Show the provider's own sentence and link its setup page; never hard-code either.
|
|
52
|
+
const setup = context && new URL(context.next);
|
|
53
|
+
setup?.searchParams.set('return_to', 'https://app.example.com'); // your registered redirect origin
|
|
54
|
+
throw new AppError('provider_setup_pending', context?.message ?? 'Finish setting up Context, then try again.', {
|
|
55
|
+
status: 403,
|
|
56
|
+
retryable: false,
|
|
57
|
+
details: {provider: 'context', state: context?.state ?? 'not_started', message: context?.message, next: setup?.href},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`AppError` stands for your app's own typed error ([Errors](/docs/errors)).
|
|
63
|
+
|
|
64
|
+
- `state` is `not_started` (the person has not done their part), `pending` (the provider is working), `ready` or `failed`.
|
|
65
|
+
- `message` is the provider's own sentence for `pending` and `failed`. It is absent for `not_started` and `ready`, so keep a short fallback.
|
|
66
|
+
- `next` is the absolute URL of the directory's setup page for this provider and your app. Append `return_to` (your app's registered redirect origin) so the person comes back to you.
|
|
67
|
+
- An entry is missing when the runtime has not synced the grant since the app began requiring the provider. Treat it as `not_started`.
|
|
68
|
+
|
|
69
|
+
Refuse the dependent operation with `provider_setup_pending` (403), carrying `state`, `message` and `next` in `details`. To act when the state turns `ready`, use `watchGrants` above instead of polling.
|
|
70
|
+
|
|
71
|
+
## Show what a person's month has cost
|
|
72
|
+
|
|
73
|
+
A person's grant caps what your app may spend of their AI subscription each UTC month (`grant.spend_cap`). `usage.get()` answers where the month stands:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const usage = await runtime.usage.get(); // or {month: '2026-09'}
|
|
77
|
+
// {grantee, month, usd_equivalent, by_model, app_paid, spend_cap, reset_at}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- **`usd_equivalent`** is user-paid spend at list price: model completions and provider receipts. Receipts have no `by_model` row, so it can exceed the sum of `by_model`. A model with no list price reports `usd_equivalent: null` on its row.
|
|
81
|
+
- **`app_paid`** is usage your app pays for. It never counts toward `spend_cap`.
|
|
82
|
+
- **At the cap.** Once `usd_equivalent` reaches `spend_cap`, user-paid model and provider calls answer 402 `cap_exhausted` (`retryable: false`, `userAction: 'raise_cap'`, with `resetAt`) until `reset_at`, the first instant of the next UTC month. A run whose model call is refused ends with outcome reason `cap_exhausted`. The check runs before each call, so the call that crosses the cap completes and the next one is refused. Do not retry; tell the person when the count resets.
|
|
83
|
+
- `spend_cap: null` means the grant is not capped.
|
|
84
|
+
|
|
85
|
+
You set the default cap for your app in the console's **What it asks for** tab (**Monthly spend cap**); the person can change it for themselves.
|
|
86
|
+
|
|
87
|
+
## Link to the app's settings on Northlight
|
|
88
|
+
|
|
89
|
+
Each person who uses your app has a page on their Northlight account where they change or remove what the app may do. Link to it with the URL the directory advertises. Do not compose the path yourself:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const href = await oauth.accountAppUrl(); // e.g. https://<directory>/account/apps/<your client id>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- It is the same URL for every person: the directory shows whoever is signed in their own page for your app, and sends someone without a directory session through sign-in first. Someone without an active grant sees that the app is not connected to their account.
|
|
96
|
+
- It needs no user and no connection, so it serves owners and visitors alike. Its only request is the discovery read that `authorizeUrl`, `verifyIdToken` and `revoke` share.
|
|
97
|
+
- A failure is a `CrouterError` (code `crouter_error`) whose message names the fault, such as a discovery document that advertises no `account_app_uri_template`. A slow discovery read throws `DeadlineExceededError` after `timeoutMs` (default 10 s). A failed read is not kept; a successful one is kept for the client's life.
|
|
98
|
+
- Warm it at startup with `void oauth.accountAppUrl().catch(log)`. A page that must not wait on the directory races it against a short timer and hides the link if the race is lost.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Manage your app
|
|
3
|
+
description: What each tab of the Northlight console does for an app you have registered, and the two things the console cannot do yet.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Manage your app
|
|
7
|
+
|
|
8
|
+
You manage an app in the console at `https://account.northlight.ai/console`. Choose your app in the list to open its page. The tabs are Sign-in, What it asks for, Profile, Sharing, Testers and Provider. (Only the first-party apps use Provider.)
|
|
9
|
+
|
|
10
|
+
Your **client ID** is shown on the app's page. Copy it into `CROUTER_CLIENT_ID`, or write it to `local/app.env` with `npx @north-light/sdk env --client-id <client id>`.
|
|
11
|
+
|
|
12
|
+
## Sign-in
|
|
13
|
+
|
|
14
|
+
- **Redirect URIs.** Sign-in returns only to a URI listed here, matched exactly: `localhost` and `127.0.0.1` are different URIs, and the port counts. Each sign-in names the URI to return to, so an app can hold several (for example your deployed callback and a localhost one). **Save redirect URIs** replaces the whole list, so keep the ones you still use.
|
|
15
|
+
- **Keys.** An app proves who it is with a signing key. There is no client secret. **Add a key** takes a public JWK; the form refuses a pasted private key. You can instead **Use a JWKS URL**: the directory reads the keys from that URL. An app holds at most 2 active keys. **Retire** stops a key signing in; a retired key can be **Deleted** so it can be added again.
|
|
16
|
+
|
|
17
|
+
### Rotate a key
|
|
18
|
+
|
|
19
|
+
Rotating has no downtime, because the directory accepts a sign-in signed by either active key.
|
|
20
|
+
|
|
21
|
+
1. Generate the new key beside the current one: `npx @north-light/sdk keygen --out local/app-key.next.json`. It prints the public JWK.
|
|
22
|
+
2. In **Sign-in → Keys**, choose **Add a key** and paste the JWK. Both keys are now active.
|
|
23
|
+
3. Point `CROUTER_KEY_FILE` (or your `privateKey`) at `local/app-key.next.json` and deploy.
|
|
24
|
+
4. Complete one real sign-in through the callback, or refresh a stored connection. Opening your login route only redirects to the directory and never reaches the token endpoint, so it proves nothing about the key.
|
|
25
|
+
5. **Retire** the old key, then delete the old key file.
|
|
26
|
+
|
|
27
|
+
Until you retire the old key it is your rollback. After you retire it, a sign-in signed with it fails with `unauthorized`, and the message names the key as retired. An app registered with a JWKS URL rotates the same way through the URL: publish the new key beside the old one, switch, verify, then remove the old one. The directory re-reads the URL within about a minute.
|
|
28
|
+
|
|
29
|
+
## What it asks for
|
|
30
|
+
|
|
31
|
+
- **What your app asks for.** The scopes people are asked to approve. **Add permissions** searches the catalog; take scope strings from it and from `providers.tools`, and do not invent them. `crtr:act` needs `crtr:llm`. See [Scopes and grants](/docs/concepts/scopes-and-grants).
|
|
32
|
+
- **What your app requires.** Providers the app cannot work without. A person is taken through that provider's setup the first time they sign in. See [Grant changes](/docs/guides/grant-changes#wait-on-a-provider-your-app-requires).
|
|
33
|
+
- **Monthly spend cap.** The most your app may spend of each person's AI subscription per month, in whole US dollars.
|
|
34
|
+
|
|
35
|
+
## Profile
|
|
36
|
+
|
|
37
|
+
- **How people see your app**: Name, Origin URL, Logo URL, and **Why your app needs access** (quoted on the consent page as your words). **Save details**.
|
|
38
|
+
- **Marketplace**: **Listed in the marketplace**, Price, **Sign-in start URL** and **Text welcome**. Agents can install a listed app for a person by text. A testing app stays out of the marketplace even when listed. The sign-in start URL is where Install sends people; your `/auth/login` route is a valid one, and a `localhost` URL is accepted. The text welcome is one or two plain sentences, at most 120 characters, with no links.
|
|
39
|
+
|
|
40
|
+
## Testers
|
|
41
|
+
|
|
42
|
+
A new app is in testing: only you and up to 100 testers can sign in. Add a tester by the email or phone number on their Northlight account. An email matches only while it is verified on that account. Nobody gets a message, so tell them. Removing a tester revokes their grant for the app. See [Testing mode](/docs/concepts/testing-mode).
|
|
43
|
+
|
|
44
|
+
## Sharing
|
|
45
|
+
|
|
46
|
+
**What other apps may use** lists parts of your app's space (memory, files, conversations) that other apps may ask for, and at what access (read or read/write). Add a folder to share a part of your app's files.
|
|
47
|
+
|
|
48
|
+
## Develop beside a live app
|
|
49
|
+
|
|
50
|
+
Once your app is live you can keep developing on your laptop against production while the live app keeps running. There are two ways.
|
|
51
|
+
|
|
52
|
+
**Add your localhost callback to the live app.** This is simplest and fine for most apps. In **Sign-in → Redirect URIs**, list both `https://app.example.com/auth/callback` and `http://localhost:3000/auth/callback`. Give your laptop its own signing key beside the deployed one (an app holds at most 2 active keys), so you never copy the production private key to your laptop. Both servers share the same people, grants, space and scopes. A scope you add while developing changes what the live app asks of everyone. The origin, sign-in start URL and run notice URL each hold one value, so they keep pointing at the deployed app.
|
|
53
|
+
|
|
54
|
+
**Register a separate development app.** Do this when you are changing what the app asks for, or need grants and data that live people never touch. Register another app in the console, for example "Texty (dev)", with a `localhost` origin and redirect URI and its own key. It starts in testing and stays there, so only you and your testers can sign in, and it never appears in the marketplace. Point your laptop's environment file at its client ID.
|
|
55
|
+
|
|
56
|
+
## Two things the console can't do yet
|
|
57
|
+
|
|
58
|
+
Deleting an app and setting its run notice URL are not in the console today. Both need `crtr`, the Northlight command line, on a machine joined to your Northlight account. Joining is a one-time setup described in [the crouter docs](https://crouter.docs.northlight.ai/docs/sdk/guides/apps-on-northlight#1-account-and-join): install `@north-light/cli`, then run `crtr sys join` with a token from `https://account.northlight.ai/account/machines`. Joining rewrites the local crtr home and has no unjoin, so join from a home you can throw away. These steps are the only part of building an app that needs crtr.
|
|
59
|
+
|
|
60
|
+
### Delete an app
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
crtr cloud apps delete --app-id <client id> --confirm <client id>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It answers `deleted: true` and `removed_grants`, the number of people's grants it removed. Each person's runtime deletes the app's runs, space and schedules, and the app's next refresh fails with `invalid_grant`. The app's key stops working, its page answers 404 and it disappears from your list. This cannot be undone. You must run it yourself in the terminal of the joined machine: an agent, including your chat agent, is refused with `provider_refused`. If you only want a clean slate for tests, changing the app's redirect URIs and keys is often simpler.
|
|
67
|
+
|
|
68
|
+
### Set the run notice URL
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
crtr cloud apps set-run-notice-url --app-id <client id> --url https://app.example.com/run-notices
|
|
72
|
+
crtr cloud apps set-run-notice-url --app-id <client id> --clear
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The URL must be https (plain http only on localhost in development) and at most 512 characters. What the app does with notices is in [Background runs](/docs/guides/background-runs#run-notices).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Many users
|
|
3
|
+
description: Keep each person's runtime connection separate when people interact in your app, and understand whose action a call records.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Many users
|
|
7
|
+
|
|
8
|
+
An app can bring people together without putting all of their agents or data on one runtime. **Each person's runtime holds that person's agents, memory, files, conversations, AI subscription, phone and device.** Your server holds its user table keyed by the Northlight `sub`, one stored `Connection` per person in a `ConnectionStore` (runtime URL, rotating refresh token, grant id), in-app roles and settings, and data shared between users. Northlight permissions decide what your app can do on each person's runtime. Your app decides what its users can do to each other.
|
|
9
|
+
|
|
10
|
+
Keep the connection and refresh token on the server, never in a visitor's browser. The server can call `providers.call` on a stored connection outside any run, even when the person is offline. Access tokens last 10 minutes; refresh tokens rotate on each use and expire after 90 days unused.
|
|
11
|
+
|
|
12
|
+
- Give `OAuth2Client` a persistent `ConnectionStore` with a cross-process lock as `store`, and reach each person's runtime with `await oauth.client(userId)`.
|
|
13
|
+
- Schedule `oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000})` on a timer. It refreshes connections last saved before the cutoff, through the store's lock, and returns `{refreshed, refused, failures}`. The SDK starts no timer itself.
|
|
14
|
+
- Act on a dead connection in one place, `onRefusal`. Delete a person's data only on `grant_removed` (`kind: 'removed'`).
|
|
15
|
+
- Learn when a background run ends with `oauth.watchRuns(...)`: see [Background runs](/docs/guides/background-runs).
|
|
16
|
+
|
|
17
|
+
## Signing in a visitor
|
|
18
|
+
|
|
19
|
+
A visitor who only needs to be recognized, not connected, signs in with identity only. Request `openid`, adding `email` for their email and `profile` for their name and photo, and no runtime scope. The directory shows one line ("Know who you are on Northlight"), writes no grant, and answers with an ID token and an access token usable only at `/userinfo`: no refresh token, grant or runtime URL. Finish that sign-in with `exchangeIdentity`, not `exchangeCode`:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'email', 'profile'], state: sessionState});
|
|
23
|
+
// Save codeVerifier, state and nonce in the visitor's session; redirect them to url.
|
|
24
|
+
// On the callback, read them back from the session and finish the sign-in:
|
|
25
|
+
const visitor = await oauth.exchangeIdentity({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
|
|
26
|
+
visitor.sub; // 'user:…', the same id a Connection.userId carries for this person
|
|
27
|
+
visitor.email; // present only when 'email' was requested and approved
|
|
28
|
+
visitor.emailVerified; // the directory's email_verified, when email was approved
|
|
29
|
+
visitor.name; // present only when 'profile' was approved and the person has a name (never a phone number)
|
|
30
|
+
visitor.picture; // an https URL, present only when 'profile' was approved and the account has a photo
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`exchangeIdentity` returns an `Identity`: `sub`, `email` and `emailVerified` when granted, `name`, `givenName` and `familyName` when `profile` was granted and the person has one, `picture` when `profile` was granted and the account has a photo (only an `https:` URL), and every verified claim in `claims`.
|
|
34
|
+
|
|
35
|
+
The SDK checks the ID token's ES256 signature against the directory's JWKS, its issuer, that `aud` is your client id, that it has not expired, and that its `nonce` equals the one `authorizeUrl` returned. `nonce` is required. Any failure throws `CrouterError` code `id_token_invalid`. If the directory's JWKS can't be fetched or is malformed, that error is thrown as is.
|
|
36
|
+
|
|
37
|
+
An `Identity` is not a connection. Nothing is stored in a `ConnectionStore` and nothing refreshes; your app keeps its own session for the visitor, and the visitor's runtime can't be called on their behalf. To act for the visitor later, send them through `authorizeUrl` again with the runtime scopes you need and finish with `exchangeCode`, which returns a `Connection`. Each call refuses the other's answer with code `oauth_exchange_mismatch`. If `exchangeIdentity` refuses an answer that carries a connection, the directory has already recorded that grant; it shows in the person's approved apps until they or your app revoke it.
|
|
38
|
+
|
|
39
|
+
## Whose action a call records
|
|
40
|
+
|
|
41
|
+
When visitor A causes your server to call person B's runtime using **B's connection**, the runtime records the app as `grantee` and B as `sub`. It does not know or record that A caused the call. Even a request outside a run, with nobody present, has this attribution. Keep your own record of the app user and action when you need it. Do not send an app-user identity as a runtime principal.
|
|
42
|
+
|
|
43
|
+
A visitor chatting with B's clone starts an app run on **B's runtime and B's AI subscription**, using the app's grant from B. Your app rate-limits visitors; B's per-app spend cap is the platform backstop. Northlight's developer terms allow spending B's connection because of another user's action **only for what B turned on in your app, limited per person**. B can control the app's spend cap or remove the app.
|
|
44
|
+
|
|
45
|
+
## The app's agent and documents
|
|
46
|
+
|
|
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
|
+
|
|
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.
|
|
50
|
+
|
|
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
|
+
|
|
53
|
+
**An isolated run can't see, or be seen by, the app's other runs.** Starting one isolated run per visitor conversation keeps visitors apart. Another run's nodes, documents, conversation, trace, events and node folder answer `not_found` to it, and messaging, interrupting, cancelling or deleting another run does too. Each node of an isolated run gets its own home folder, `/apps/<app>/nodes/<node-id>/home`, in place of the shared `/apps/<app>/home`. An isolated run still reads your app's memory and the person's public memory. Your app's server is not confined: `runs.list` and `runs.trace` cover every run, so your server can show the person each visitor's conversation. When a call is refused by scope, a missing-scope error (`scope_missing` or `forbidden`) comes before any `not_found`, so the refusal reveals nothing about the target.
|
|
54
|
+
|
|
55
|
+
## Messages to a running run
|
|
56
|
+
|
|
57
|
+
`runs.message(runId, message, {start_turn: false})` stores an app-attributed message on an existing run **without starting, reviving or reopening a turn**. The message is delivered at the start of the next turn, before that turn's input, and the trace marks it `custom` / `app_message`. The default `start_turn: true` sends an ordinary follow-up.
|
|
58
|
+
|
|
59
|
+
`runs.message` is safe to retry. The SDK sends an `Idempotency-Key` (one per call, reused across its own retries; pass `idempotencyKey` to choose it) and the runtime delivers a given key once for 24 hours. A replay returns the original result; the same key with different text, `start_turn` or run is refused with `409 idempotency_conflict`. An app that queues sends and may crash between the call and recording success should derive `idempotencyKey` from its own message id.
|
|
60
|
+
|
|
61
|
+
**A run's first prompt is always its first turn's input.** With the default `start_turn: true`, a message sent before the first turn has started queues behind it: the agent answers the `runs.start` prompt in turn one and your message in turn two. To answer a first message in the first turn, pass it with the prompt: `runs.start({prompt, message})` stores `message` with the run, so it joins the first turn as context delivered ahead of the prompt, and one reply covers both. Follow the reply with `runs.reply(runId, sent)`: see [Stream a run](/docs/guides/stream-a-run#follow-the-reply-to-one-message).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Stream a run
|
|
3
|
+
description: Forward a run's events to a browser, follow the reply to one message, and resume after a reload.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Stream a run
|
|
7
|
+
|
|
8
|
+
## Forward a run to a browser
|
|
9
|
+
|
|
10
|
+
Your server keeps the runtime token and connection. The browser connects only to your server. Call `forwardRunEvents(runtime, runId, {after: cursorFromRequest(request), signal: request.signal})` and return the Fetch `Response`. After a host cuts the browser connection, `EventSource` sends its last SSE id as `Last-Event-ID`; `cursorFromRequest` prefers it over the URL's `after` query parameter.
|
|
11
|
+
|
|
12
|
+
Every forwarded run event is an unnamed SSE record with its `sequence_number` as `id` and the complete event object as `data`. One handler receives all of them:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const source = new EventSource(`/api/runs/${runId}/events?after=${cursor}`);
|
|
16
|
+
source.onmessage = ({data}) => {
|
|
17
|
+
const event = JSON.parse(data);
|
|
18
|
+
if (event.type === 'stream.refused') { showRefusal(event.error); source.close(); return; }
|
|
19
|
+
if (event.type === 'stream.reconnecting') { showConnectionFailure(event.error); return; }
|
|
20
|
+
if (event.type === 'stream.reconnected') { clearConnectionFailure(); return; }
|
|
21
|
+
if (event.type === 'stream.idle') { source.close(); markIdle(event.cursor); return; } // reopen on your own trigger
|
|
22
|
+
showRunEvent(event);
|
|
23
|
+
};
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The four `stream.*` records are notices from the forwarder, not run events. They have no `sequence_number` or SSE id.
|
|
27
|
+
|
|
28
|
+
- **`stream.idle`** (`{type: 'stream.idle', cursor}`) means the runtime closed the stream after 60 s with no running turn and no new event. The helper writes it and ends the response. Close the `EventSource` on it and reopen with `after=cursor` on your own trigger: the person sends a message, or your server handles a run notice ([Background runs](/docs/guides/background-runs)). Without that, the browser would reconnect at once and keep a hosted runtime awake.
|
|
29
|
+
- **`stream.refused`** carries the full error envelope, including `stream_gap`'s `details.latest_sequence` and `details.earliest_sequence`. The helper ends the response after a refusal. The SDK does not recover a gap by skipping events.
|
|
30
|
+
|
|
31
|
+
## Resume after a reload or a gap
|
|
32
|
+
|
|
33
|
+
When the page reloads mid-reply, or you get `stream_gap`, read `runs.trace(runId)` through your server and show each node's `in_progress_message` (`{thinking, text}` or `null`) as that node's open message. Then resume `runs.events(runId, {after: trace.latest_sequence})`, or forward with that cursor. Do not use a cursor from a separate `runs.get`.
|
|
34
|
+
|
|
35
|
+
Keep appending new deltas to the open message, but replace its thinking or text with the complete content repeated by `node.thinking.done` or `node.output_text.done`. That shows the message from its beginning without duplicating content. `runs.get` and every `runs.list` entry carry `latest_sequence` and `last_activity_at`; a cursor equal to `latest_sequence` is not a gap even when older events have expired.
|
|
36
|
+
|
|
37
|
+
## Follow the reply to one message
|
|
38
|
+
|
|
39
|
+
A chat app sends a message and shows the answer to it. `runs.reply(runId, sent, {signal?})` takes what `runs.message` returned and follows only the turn that delivers that message:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const sent = await runtime.runs.message(runId, 'What did the report say?');
|
|
43
|
+
for await (const event of runtime.runs.reply(runId, sent)) {
|
|
44
|
+
if (event.type === 'text.delta') append(event.delta);
|
|
45
|
+
if (event.type === 'turn.completed' && event.error) showError(event.error); // {code: 'content_refused' | 'model_error', message}
|
|
46
|
+
}
|
|
47
|
+
// or collect it: text as it streams, then the whole text and how the turn ended
|
|
48
|
+
const {text, ended, error, outcome} = await runtime.runs.reply(runId, sent).collect({onDelta: (delta) => append(delta)});
|
|
49
|
+
// ended: 'completed' | 'error' (error set) | 'waiting_on_user' | 'settled' (outcome set)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Event | Fields | From |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `text.delta` | `delta` | `node.output_text.delta` |
|
|
55
|
+
| `message.done` | `text`, `stop_reason` | `node.output_text.done`, once per assistant message |
|
|
56
|
+
| `turn.completed` | `run_status`, `stop_reason`, `error?`, `message_ids` | `node.turn.completed`; always last |
|
|
57
|
+
| `run.settled` | `outcome` | `node.settled`, when the run settles before the turn completes; always last |
|
|
58
|
+
|
|
59
|
+
Every event also carries its `sequence_number`. The reply ends after `turn.completed` or `run.settled` and closes its stream. `.text()` resolves to the text of the `message.done` events joined with a blank line. A reply can be consumed once.
|
|
60
|
+
|
|
61
|
+
To get a reply to the run's first message, pass it as `runs.start({prompt, message})` and follow `runs.reply(run.run_id, run.message!)`. A message stored with the run always joins the first turn.
|
|
62
|
+
|
|
63
|
+
A refused or failed model call ends the turn with an empty `message.done` (`stop_reason: 'error'`) and `turn.completed.error`; the run stays usable. An interrupt, `runs.cancel` or a closed node ends the turn with `stop_reason: 'aborted'` and no error. An expired cursor throws `APIError` with code `stream_gap`; aborting `signal` rejects with `APIUserAbortError`.
|
|
64
|
+
|
|
65
|
+
## A quiet run
|
|
66
|
+
|
|
67
|
+
A runtime that sleeps when nothing holds a connection to it ends a run's stream after 60 s with no running turn and no new event. `RunStream` reports that and does not reconnect:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const stream = runtime.runs.events(runId, {after: cursor});
|
|
71
|
+
for await (const event of stream) handle(event);
|
|
72
|
+
const end = await stream.whenEnded(); // rejects on a transport failure or abort()
|
|
73
|
+
if (end.reason === 'idle') {
|
|
74
|
+
// the run is quiet and nothing was missed; reopen later with runtime.runs.events(runId, {after: end.cursor})
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A settled run ends with `reason: 'settled'`. `runs.reply` reopens through an idle close on its own.
|
|
79
|
+
|
|
80
|
+
## Typed events
|
|
81
|
+
|
|
82
|
+
`RunEvent` is the union of every run event, discriminated on `type`. `RunEventOf<'node.turn.completed'>` picks one, and `RunStream.on(type, listener)` passes the listener that event. An event type a newer runtime adds and your SDK version does not declare is still delivered as-is.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Test your app
|
|
3
|
+
description: FakeCloud from @north-light/sdk/testing, a fake directory and runtime your real OAuth2Client talks to through fetch.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Test your app
|
|
7
|
+
|
|
8
|
+
`@north-light/sdk/testing` exports `FakeCloud`, a fake Northlight: the directory (discovery, token refresh, JWKS) and each person's runtime (grant, runs, memory, providers, run notices). It answers through `fetch`. Your app's production path — `new OAuth2Client(...)`, `oauth.client(userId)`, `runs`, `memory`, `grant`, `providers`, `watchRuns`, `watchGrants`, `onRefusal`, `forwardRunEvents`, `verifyRunNotice` — runs against it unchanged. The only differences from production are the `fetch` and `issuer` you pass in.
|
|
9
|
+
|
|
10
|
+
There is no mock `Crouter`. An object typed as `Crouter` would re-implement `RunReply`, `RunStream`, the watchers, retries and error mapping, so your tests would check a copy of the SDK instead of the SDK. FakeCloud fakes only the wire, so every SDK behaviour under test is the one you ship.
|
|
11
|
+
|
|
12
|
+
The subpath is Node-only (it uses `node:crypto`), adds no dependency, and imports no test framework: it works with `node:test`, vitest, jest or anything else. Nothing listens on a port.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { OAuth2Client, MemoryConnectionStore } from '@north-light/sdk';
|
|
18
|
+
import { FakeCloud } from '@north-light/sdk/testing';
|
|
19
|
+
|
|
20
|
+
const cloud = new FakeCloud({ clientId: 'doppel' });
|
|
21
|
+
const store = new MemoryConnectionStore(); // or your own ConnectionStore
|
|
22
|
+
await cloud.connect(store, 'user:o1', { scopes: ['crtr:llm', 'crtr:act', 'sms:send'] });
|
|
23
|
+
|
|
24
|
+
const oauth = new OAuth2Client({
|
|
25
|
+
clientId: 'doppel', issuer: cloud.issuer, fetch: cloud.fetch,
|
|
26
|
+
privateKey, redirectUri: 'https://app.test/callback', store,
|
|
27
|
+
});
|
|
28
|
+
const client = await oauth.client('user:o1'); // first call refreshes through the fake directory
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`cloud.connect(store, userId, {scopes?, dependencies?})` makes the person's grant active (default scopes `crtr:llm`, `crtr:act`) and saves a `Connection` in your store with its access token already expired, so the first call refreshes through the directory exactly as in production.
|
|
32
|
+
|
|
33
|
+
`cloud.crouter(userId)` returns a real `Crouter` on that person's runtime with a fresh token and no refresh. Refusals and refreshes never reach it. Use it only for code whose production signature takes a `Crouter`. Code that goes through `OAuth2Client` in production should go through `oauth.client` in tests too.
|
|
34
|
+
|
|
35
|
+
Options: `new FakeCloud({clientId, issuer = 'https://directory.test', runtimeUrl = 'https://runtime.test' (or (userId) => url), runtimeId = 'runtime:test', runtimeClientId = 'runtime-client', runtimeVersion?, streamIdleMs = 60000, accountAppUriTemplate = '<issuer>/account/apps/{client_id}' (null omits it), runNotice?, isolatedRuns = 'available', clock = Date.now})`.
|
|
36
|
+
|
|
37
|
+
`clock` is `{now(): number}`; every timestamp the fake writes reads it (run `created_at` and `settled_at`, grant `exp`, memory `created_at`/`updated_at`, access-token `iat`/`exp`, `calls[].at`, `directoryCalls[].at`) and the idempotency window runs on it. Pass `manualClock()` (from `@north-light/sdk/testing`; `now()`, `advance(ms)`) to step time by hand. Stream idle, long-poll waits and run-notice retries stay on real timers.
|
|
38
|
+
|
|
39
|
+
`cloud.activate(userId, {scopes?, dependencies?})` makes the grant active with no stored connection, for an app that mints a per-user runtime token itself. The fake directory serves that mint at `POST <issuer>/token` with `grant_type=client_credentials&sub=<userId>` (the client assertion is not verified): 200 `{access_token, token_type: 'Bearer', expires_in: 600, runtimeUrl}` (the token's `exp` claim is 600 s after `iat`; the fake runtime does not enforce token expiry); a missing `sub` is 400 `unauthorized_client`; an unknown or revoked user is 400 `invalid_grant` (`no active grant`); `user.refuse('runtime_provisioning')` is 400 `invalid_grant` with `error_description: runtime_provisioning`. A mint is a `directory.token` call (`userId` is the `sub`), so `failNext('directory.token', …, {user})` applies to it.
|
|
40
|
+
|
|
41
|
+
### Isolated runs
|
|
42
|
+
|
|
43
|
+
The fake stands in for a Northlight guest, where the Linux root launcher always exists, so `runs.start({isolated: true})` starts a run by default and `run.params.isolated` is `true`. The checks `startRun` makes without the launcher are mirrored: `isolated` must be a boolean, `cwd` is accepted only with `isolated: true` and must be an absolute path inside the run's root folder, `/apps/<clientId>/nodes/<run id>` (else 400 `invalid_request`, `param: cwd`), and `worktree` is refused. Nothing in the fake enforces the sandbox itself (no network, no `files/` mount).
|
|
44
|
+
|
|
45
|
+
`isolatedRuns: 'unavailable'` models a runtime without the launcher, such as a runtime on a machine that cannot launch isolated runs: every isolated start answers 503 `isolated_unavailable` (`param: isolated`, retryable, so `runs.start` sends it three times before throwing). Use it to test your app's handling of that refusal.
|
|
46
|
+
|
|
47
|
+
## Driving runs
|
|
48
|
+
|
|
49
|
+
Nothing in the fake runs a model. A run stays `running` until your test drives it, either directly or through a responder:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
cloud.runs.respond(({ run }) => run.reply(`echo: ${run.pending.at(-1)?.text ?? run.params.prompt}`)); // every user
|
|
53
|
+
cloud.user('user:o1').runs.respond(null); // per-user override
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A responder gets `{run, kind: 'start' | 'message', messages}`. It is called after the fake answers a `run.start`, or a `run.message` with `start_turn` true, and never for an idempotent replay. A responder that throws is recorded in `cloud.responderErrors` and fails nothing else. An `async` responder can await your app's state mid-reply.
|
|
57
|
+
|
|
58
|
+
`cloud.runs` and `cloud.user(id).runs` have one shape, `{all: FakeRun[], respond(fn)}`: `all` lists the runs in start order (a deleted run stays, with `deleted` set), so `expect(user.runs.all).toEqual([])` works. Each entry is a `FakeRun`:
|
|
59
|
+
|
|
60
|
+
| Method | Effect |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `reply(text, {chunks = 2, messageIds?})` | One turn: `node.turn.started`, text deltas, `node.output_text.done`, `node.turn.completed`. Delivers `messageIds` (default: every pending message). The run goes `idle` (`waiting_on_user` with an open question). |
|
|
63
|
+
| `failTurn({code: 'model_error' \| 'content_refused', message})` | The same turn ending `stop_reason: 'error'`; `runs.get` shows `last_turn_error`; `runs.reply(...).collect()` ends `error`. |
|
|
64
|
+
| `setActivity(activity, {at?})` | Fake-only: status `running` with this `activity` until the next event. The only way to reach `writing` or a streaming tool call. |
|
|
65
|
+
| `settle(ending)` | `{result}`, `{declined: {reason, code, retryable}}`, `{failure: 'canceled' \| 'deadline_exceeded'}`, or `{runtimeError: reason}`. Emits `node.settled` with the outcome the runtime writes. |
|
|
66
|
+
| `idle()` | Goes idle without a new turn (stopped without submitting). |
|
|
67
|
+
| `ask({title, slots?})` | Opens a question (`waiting_on_user`); returns its id. Your app's `questions.answer` records the answer. |
|
|
68
|
+
| `delete()` | As if the runtime deleted it: reads 404, streams close, its idempotency keys are dropped. |
|
|
69
|
+
| `emit(type, fields, {nodeId?})` | Appends any run event; another `nodeId` is a child node's event. Status and activity re-derive from the log. |
|
|
70
|
+
| `closeIdle()`, `expireEvents(upTo)` | Idle-close open streams now; drop old events so `stream_gap` is reachable. |
|
|
71
|
+
|
|
72
|
+
Read-only members: `id`, `userId`, `params` (the `run.start` body, `isolated` and `cwd` included), `idempotencyKey`, `messages`, `pending`, `status`, `activity`, `outcome`, `result`, `lastTurnError`, `events`.
|
|
73
|
+
|
|
74
|
+
What a turn's input is: `reply` and `failTurn` deliver `messageIds` (default: every pending message), and the turn's `node.turn.started` input text is those messages' text. A turn that delivers none (`messageIds: []`, or nothing pending) uses the run's `prompt` as its input on the run's first turn only, and an empty input after that; it still emits a full root turn with the text you pass. So `reply('', {messageIds: []})` is not a turn that answers nothing. To script a child node that works and answers nothing, or any event outside a root turn, use `emit(type, fields, {nodeId: 'child-1'})`: for example `run.emit('node.turn.started', {input: {text: 'x', message_ids: []}, entry_id: 'c1'}, {nodeId: 'child-1'})` followed by `node.turn.completed` with the same `nodeId`. Events with another `nodeId` never complete the app's `runs.reply`, which waits for the root's turn.
|
|
75
|
+
|
|
76
|
+
Messages follow the runtime: `start_turn` true makes the run `running` at once and calls the responder; `start_turn` false only records it. A message to a run settled `canceled` or `deadline_exceeded` is refused 409 `run_settled`; to a run settled any other way it reopens the run. A deleted run answers 404.
|
|
77
|
+
|
|
78
|
+
Idempotency follows the runtime too: the same `Idempotency-Key` with the same body replays the first answer (no new run, no responder call); the same key with a different body, or reused between `run.start` and `run.message`, is 409 `idempotency_conflict`. After a run is deleted, its start key creates a new run. A key is live for 24 hours (`IDEMPOTENCY_WINDOW_MS`): at exactly the window it reads as absent, and the next accept replaces the record. Step the window with `clock`.
|
|
79
|
+
|
|
80
|
+
`run.openStreams` counts the run's open event streams. It rises when the SDK starts reading the response body, which is after `cloud.calls` records `run.events`, so poll `openStreams`, not `calls`, to know a stream is attached. It falls on idle, error, abort or revoke. Run notices are owed only while no stream is open.
|
|
81
|
+
|
|
82
|
+
## Grants and setup state
|
|
83
|
+
|
|
84
|
+
`cloud.user(id).grant`: `state` (the `GrantGetDTO`, or null), `set({dependencies?, resolved_scopes?, spend_cap?})` (a new `rev`), `setup(provider, state, {message?})` (a dependency change without a `rev` bump), `sync()` (unchanged content: no version change, no wake), `revoke(code = 'grant_revoked')`, `restore(patch?)`.
|
|
85
|
+
|
|
86
|
+
`grant.get` with `wait` and `since` is held until the version changes, exactly as the runtime holds it, so `oauth.watchGrants` works unmodified. `user.reads` lists the person's `grant.get` calls and `user.holding` counts reads held now.
|
|
87
|
+
|
|
88
|
+
## Refusals
|
|
89
|
+
|
|
90
|
+
- `user.refuse(code)` leaves the grant alone: the directory refuses each refresh with `code` (`grant_removed`, `grant_suspended`, `app_in_testing`, `refresh_token_expired`, `refresh_token_reused`, `grant_revoked`), and tokens issued before the call get 401, so the next call refreshes and is refused. `refuse('ok')` clears it.
|
|
91
|
+
- `grant.revoke('grant_removed')` deactivates the grant: calls answer 401 `grant_revoked`, held reads wake to it, open streams get an error frame, and the next refresh is refused with that code.
|
|
92
|
+
- `user.expireTokens()` forces a refresh without a refusal.
|
|
93
|
+
|
|
94
|
+
The real `OAuth2Client` handles all of it: your `onRefusal` runs with the refusal, the stored connection is removed, and the thrown error has `refusalOf(e)` set. `await cloud.refusalError(code)` returns that exact error for unit tests that need the value.
|
|
95
|
+
|
|
96
|
+
## Testing mode
|
|
97
|
+
|
|
98
|
+
An unverified third-party app is *in testing* on Northlight: only its admin and up to 100 named testers can sign in, and anyone else is refused. `cloud.testing` puts the fake's app in that state so your app can test the refusal.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
cloud.user('user:tess').setAccount({ email: 'tess@example.com' }); // the identifiers the account holds
|
|
102
|
+
cloud.testing.addTester({ email: 'Tess@Example.com' }); // or {phone: '+15551234567'}; false if already present
|
|
103
|
+
cloud.testing.enable({ admin: 'user:me' }); // in testing; revokes active grants of people not allowed
|
|
104
|
+
|
|
105
|
+
const back = cloud.authorize('user:stranger', { redirectUri, state }); // the redirect the browser follows back to your app
|
|
106
|
+
authorizationRefusalOf(back); // {code: 'app_in_testing', error: 'access_denied', state}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- `cloud.testing.enable({admin?})` / `disable()` / `active`. `enable` returns the user ids whose active grants it revoked.
|
|
110
|
+
- `addTester({email} | {phone})` stores the entry (email trimmed and lowercased, phone E.164). The 101st entry throws. `removeTester(ref)` returns `{removed, revoked}`: a removed tester's active grant is revoked, not deleted, and the fake keeps their runs.
|
|
111
|
+
- `cloud.testing.allowed(userId)`: not in testing, the admin, or a tester entry matches the account: an `email` entry needs `emailVerified` (default true when `email` is set), a `phone` entry matches `setAccount({phone})`.
|
|
112
|
+
- A refresh for an active grant whose person is not allowed answers `invalid_grant` `app_in_testing`, and so does a refresh after the grant was revoked this way. `oauth.client(id)` rejects with an `AuthenticationError` (`code: 'app_in_testing'`, `refusalOf(e).kind === 'paused'`) and `onRefusal` runs. Keep the person's data.
|
|
113
|
+
- `cloud.authorize(userId, {redirectUri, state, scopes?})` stands in for the browser round trip. An allowed person gets an active grant and a redirect with `code` and `state`; give `code` to `oauth.exchangeCode`. A person who is not allowed gets `error=access_denied`, `error_description=app_in_testing` and `state`, and nothing is written. A person revoked by `removeTester` who is allowed again gets access back by `authorize`.
|
|
114
|
+
- When a person's email or phone stops matching (`setAccount`), nothing is revoked; their next refresh and `authorize` are refused.
|
|
115
|
+
|
|
116
|
+
In your callback handler, check `authorizationRefusalOf(url)` before reading `code`.
|
|
117
|
+
|
|
118
|
+
## Memory and providers
|
|
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.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
cloud.providers.define('sms', { tools: { send: { group: 'send', handler: ({ userId, arguments: args }) => ({ sent: true }) } } });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A call is checked against the grant's `<provider>:<group>` scope (403 `scope_missing`). A handler that throws `FakeCloud.error({...})` answers that envelope; any other throw is 502 `provider_error`. `status: 'install_failed'` lists the provider with its `error` and answers calls 503 `provider_unavailable`.
|
|
127
|
+
|
|
128
|
+
## Failures, gates and the directory
|
|
129
|
+
|
|
130
|
+
`intercept(op, fn, {user?, times?})` runs `fn` before an operation; it may await, act, or return an answer. `failNext(op, answer, {user?, times = 1})` is the shorthand. `gate(op)` holds every matching request until you call the `release()` it returns. `op` is a runtime operation name (`run.start`, `grant.get`, `memory.list`, …), `'*'` for every runtime operation, or `directory.discovery`, `directory.token`, `directory.jwks`.
|
|
131
|
+
|
|
132
|
+
| Answer | Runtime operation | `directory.discovery` | `directory.token` | `directory.jwks` |
|
|
133
|
+
|---|---|---|---|---|
|
|
134
|
+
| `'unreachable'` | fetch rejects; the SDK throws `APIConnectionError` | fetch rejects | fetch rejects; no `onRefusal` | fetch rejects; `verifyRunNotice` throws `RunNoticeError` |
|
|
135
|
+
| a status number | that status with a runtime envelope | that status; discovery fails | that status; an `InternalServerError` for 5xx coded `invalid_request`, origin `directory`; connection kept | that status |
|
|
136
|
+
| `FakeCloud.error({status, code, message?, origin?, retryable?, details?, param?})` | that envelope | not allowed | not allowed (use `user.refuse`) | not allowed |
|
|
137
|
+
|
|
138
|
+
Retries are the SDK's, so a scripted failure the SDK retries is absorbed unless `times` covers every attempt. One SDK call makes these attempts (`maxRetries` defaults to 2; a call's `maxRetries` option changes it):
|
|
139
|
+
|
|
140
|
+
| What the fake answers | Operations | SDK behaviour | `times` to surface it |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| 401 (any code) | every runtime operation called through `oauth.client` | refreshes the token once, then repeats the call once | 2 |
|
|
143
|
+
| a status number ≥ 500, or 429 | GET and DELETE operations: `grant.get`, `run.get`, `run.list`, `run.delete`, `trace.get`, `question.get`, `memory.list`, `memory.get`, `provider.tools`, `profile.list` | retried `maxRetries` times with backoff (250 ms, 500 ms) | 3 |
|
|
144
|
+
| `'unreachable'` | the same GET and DELETE operations | retried `maxRetries` times | 3 |
|
|
145
|
+
| `FakeCloud.error({..., retryable: true})` (the default for a 503), or `'unreachable'` | `run.start`, `run.message` (keyed sends) | resent with the same `Idempotency-Key` `maxRetries` times, waiting 500 ms, 1 s (or the envelope's `retry_after_s`) | 3 |
|
|
146
|
+
| a status number, or an envelope with `retryable: false` | `run.start`, `run.message` | not retried (a status number answers `retryable: false`) | 1 |
|
|
147
|
+
| anything else | the other POSTs: `run.interrupt`, `run.cancel`, `run.rename`, `question.answer`, `provider.call` | never retried | 1 |
|
|
148
|
+
| anything | `run.events` (the SDK's `RunStream`) | a retryable error reconnects from the cursor with backoff; a non-retryable one, `stream_error` or `stream_dropped`, ends the stream | `Infinity` for a retryable one |
|
|
149
|
+
|
|
150
|
+
The rows compose: one 401 followed by three 500s on `grant.get` surfaces the 500 (the call repeated after the refresh has its own two retries), so a 401 surfaces only when the repeat gets one too. A test that wants an operation to fail no matter what scripts `times: Infinity` and removes it afterwards with `intercept`'s returned function, or calls `cloud.reset()`.
|
|
151
|
+
|
|
152
|
+
Discovery is memoized per `OAuth2Client`. A test that fails discovery should use a fresh `FakeCloud` and `OAuth2Client`.
|
|
153
|
+
|
|
154
|
+
A runtime outage on an ordinary call: `user.expireTokens(); cloud.failNext('directory.token', 503, {user})`. An unreachable runtime: `cloud.failNext('*', 'unreachable', {user, times: Infinity})`. With `runtimeVersion` older than the SDK's minimum, calls fail with `RuntimeVersionError` (426).
|
|
155
|
+
|
|
156
|
+
## Run notices and forwarding
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const cloud = new FakeCloud({ clientId: 'chat', runNotice: { url: 'https://app.test/run-notices', deliver: (request) => app.handleNotice(request) } });
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The fake sends notices by the runtime's rules: one is owed when an event is appended with no `run.events` stream open, or when the last stream closes with events unread; a burst coalesces into one; up to three attempts; a 401 re-mints the token once; a 5xx, a thrown `deliver`, or no answer within 10 s is retried; any other status ends the send. A run whose person has no active grant is owed nothing. The waits default to 0 so tests stay fast. Pass the runtime's values (`retryWaitsMs: [3000, 5000]`, `ackGapMs: 5000`, `failGapMs: 60000`) to test timing. Every attempt is recorded in `cloud.notices`.
|
|
163
|
+
|
|
164
|
+
In your route, pass the cloud's `fetch` to `verifyRunNotice`:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
await verifyRunNotice(request, { runNoticeUrl, issuer: cloud.issuer, grantee: 'app:chat', fetch: cloud.fetch });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Keys are cached per `(issuer, fetch)`, and each cloud has its own signing key, so several clouds on one issuer verify independently. `forwardRunEvents` needs nothing special: it reads the fake's event stream, idle close included (`streamIdleMs`).
|
|
171
|
+
|
|
172
|
+
## Two app processes
|
|
173
|
+
|
|
174
|
+
Build two app instances (two `OAuth2Client`s) on the same `cloud`. They see the same runs, grants and held reads. If they keep separate stores, the second refresh with a superseded token is refused `refresh_token_reused`, as it would be in production; a shared store with `lock` prevents it.
|
|
175
|
+
|
|
176
|
+
## Asserting
|
|
177
|
+
|
|
178
|
+
- `cloud.calls`: every runtime request as `{userId, op, runId, questionId, method, path, query, body, idempotencyKey, status, at}`.
|
|
179
|
+
- `userId` is the bearer token's `sub` whenever it names a person this cloud knows (created by `connect` or `user(id)`), whatever their grant or refresh-token state, so a call refused 401 still carries it. It is `null` only for a missing or undecodable bearer, or a `sub` the cloud has never seen.
|
|
180
|
+
- `runId` is the run the call addresses: the path's id for `run.get`, `run.message`, `run.events`, `run.interrupt`, `run.cancel`, `run.delete`, `run.rename` and `trace.get` (as sent, even when no such run exists), and for `question.get` and `question.answer` the run holding the question (null when none of the caller's runs does). `questionId` is the path's id on the two question operations. Both are `null` on every other operation.
|
|
181
|
+
- `body` is a parsed copy for your assertions; mutating it from an intercept is not a supported way to change what the fake handles.
|
|
182
|
+
- `cloud.directoryCalls` and `cloud.refreshes`: directory requests, with `answer` (`ok`, a refusal code, a status or `unreachable`).
|
|
183
|
+
- `run.params`, `run.messages`, `run.idempotencyKey`: what your app sent.
|
|
184
|
+
- `cloud.notices`: every notice attempt, with its decoded token claims and your route's status.
|
|
185
|
+
- `cloud.reset()` clears runs, calls, intercepts, responders, memory, idempotency keys and notices, and keeps grants and refresh tokens so stored connections stay valid.
|
|
186
|
+
|
|
187
|
+
## Fidelity
|
|
188
|
+
|
|
189
|
+
FakeCloud is checked against the real runtime on every crouter release: one set of conformance scenarios runs on the fake and on the real app listener (run start and idempotency, messages, reply streams, long-poll, every settle outcome, delete, the event stream's edges, grant holds, memory, providers, run notices). The keyed-write scenarios K1–K8 (equal-key replay, conflict, concurrent equal keys accepted once, the 24h window, replay after cancel, a key bound to its operation, keys scoped to the grantee, concurrent notes) are exported as `@north-light/sdk/testing/conformance` (`keyedWriteScenarios`, `IDEMPOTENCY_WINDOW_MS`) and run on FakeCloud and on the real daemon; an app test double for the runtime should run them against its own target. Two behaviours are fake-only because the runtime cannot be scripted that way: a successful provider call (the real side needs an installed provider), and `setActivity`. An isolated run that starts is checked only against `startRun`'s documented checks: the real listener in the suite runs without the Linux root launcher, so on it the `isolatedRuns: 'unavailable'` refusal is what is conformance-checked.
|
|
190
|
+
|
|
191
|
+
Not faked: sign-in (`authorizeUrl`, `exchangeCode`, ID tokens — start from `cloud.connect`), token revocation, the owner surface (`nodes`, `canvas`, `crons`, `files`, …), and several apps on one cloud. `trace.get`, `run.rename`, `run.interrupt` and `profile.list` answer the right shape only.
|