@north-light/cli 0.3.417 → 0.3.419

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.
@@ -42,6 +42,21 @@ const client = await oauth.client(connection.userId);
42
42
 
43
43
  `state: callbackState` comes from the redirect query, while `expectedState` is the value saved in the app's session when `authorizeUrl()` ran. `exchangeCode` and `exchangeIdentity` refuse a missing or different value with `CrouterError` code `oauth_state_mismatch` **before** sending the code to the token endpoint. Persist the expected value across redirects and tie it to the user's session; never use the callback's `state` for both arguments.
44
44
 
45
+ ### When sign-in is refused: apps in testing
46
+
47
+ A new app is in testing until Northlight verifies it. When someone who is not the app's admin or a named tester signs in, the directory redirects to your `redirectUri` with `error=access_denied`, `error_description=app_in_testing` and your `state`, and issues no code. `authorizationRefusalOf` reads it from the callback's query (a `URL`, `URLSearchParams`, query string, or parsed object) and returns `{code: 'app_in_testing', error: 'access_denied', state}`, else `null`:
48
+
49
+ ```ts
50
+ import {authorizationRefusalOf} from '@north-light/sdk';
51
+
52
+ const refused = authorizationRefusalOf(new URL(request.url));
53
+ if (refused) {
54
+ // check refused.state against the saved state, then show "this app is in testing; ask its developer to add you"
55
+ }
56
+ ```
57
+
58
+ A person who pressed Cancel (`access_denied` with no reason) returns `null`. Test the path with [[crouter-sdk/testing]].
59
+
45
60
  ### Keep sign-in secrets out of HTTP logs
46
61
 
47
62
  If your app uses `nestjs-pino` / `pino-http`, its default serializers can log the full `/auth/callback?code=…&state=…` URL and query, plus response headers including the session `set-cookie` and a redirect `location` containing OAuth `state`. Redacting only authorization and cookie **request** headers does not protect these values. Use bounded serializers for *all* routes, not just the callback: log only request method, path without query string, and id; log only response status. For example:
@@ -107,6 +122,7 @@ A `Refusal` is `{kind, code, userId, grantId, error}`. `kind` reads this table;
107
122
  |---|---|---|---|
108
123
  | `grant_removed` | `removed` | The person removed your app, deleted their account, or a provider-connected registration disconnected them. The runtime has deleted your app's space, runs, schedules and shares. | You may delete what you keep for that person. Do not call `runs.delete`: the runs are already gone. If they connect again, they see the consent page and start fresh. |
109
124
  | `grant_suspended` | `paused` | Your app's access for that person is paused. Nothing was deleted; your app's sandboxes are stopped. | Keep everything you store for that person, stop calling for them, and show that the app is paused. If they connect again, the same grant comes back with every run, file and schedule. |
125
+ | `app_in_testing` | `paused` | Your app is still in testing (not verified by Northlight) and this person is not its admin or a named tester: they were removed as a tester, or their email or phone stopped matching one. Their grant is revoked, not removed; nothing was deleted and your app's sandboxes are stopped. | Keep everything you store for that person, stop calling for them, and tell them the app is in testing and the developer must add them. If the developer adds them back and they sign in again, the same space comes back. |
110
126
  | `refresh_token_expired` | `reconnect` | The refresh token went 90 days without use. The grant is still active. | Keep everything. Run the connect flow again; the consent page is skipped. Prevent it with `refreshIdleConnections` (below). |
111
127
  | `refresh_token_reused` | `reconnect` | Two of your app's processes raced on one refresh token, and the directory ended the grant's refresh tokens. The grant is still active. | Keep everything. Fix the store's `lock`, then reconnect (consent is skipped); without the fix the race recurs. |
112
128
  | `grant_revoked` | `reconnect` | The directory refused without naming a reason: a refresh token it never issued to your app, one your app revoked through `revoke`, or one whose record was purged. The runtime also answers `grant_revoked` when the grant is not active in its last sync; the connected client then refreshes once, so you normally receive the directory's specific code instead. | Keep everything and reconnect. Never delete data on `grant_revoked`: it does not say anything was removed. |
@@ -9,6 +9,16 @@ 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.419
13
+
14
+ ### Testing mode: `app_in_testing` (additive)
15
+
16
+ Not breaking: no type or wire shape an app reads is removed or changed.
17
+
18
+ - An app Northlight has not verified is in testing: only its admin and named testers can sign in. A refused sign-in redirects with `error=access_denied`, `error_description=app_in_testing`; a refused refresh answers `invalid_grant` `app_in_testing`. The refresh refusal is a new `Refusal` with `code: 'app_in_testing'` and `kind: 'paused'`, like `grant_suspended`: keep the person's data. If you `switch` exhaustively on `refusal.code`, add the case. See [[crouter-sdk/client]].
19
+ - New `authorizationRefusalOf(callback)` reads the sign-in refusal from the callback's query and returns `AuthorizationRefusal` (`{code: 'app_in_testing', error: 'access_denied', state}`), else `null`. See [[crouter-sdk/client]].
20
+ - `@north-light/sdk/testing`: `cloud.testing` (`enable`, `disable`, `active`, `addTester`, `removeTester`, `testers`, `allowed`), `cloud.authorize(userId, {redirectUri, state})`, `user.setAccount({email, emailVerified, phone})`, and `'app_in_testing'` in `FakeRefusalCode`. The fake directory now serves `grant_type=authorization_code` for codes `cloud.authorize` issued. See [[crouter-sdk/testing]].
21
+
12
22
  ## 0.3.415
13
23
 
14
24
  ### Monthly spend cap enforced; `usage.get` (additive; one behavior change)
@@ -89,12 +89,34 @@ Idempotency follows the runtime too: the same `Idempotency-Key` with the same bo
89
89
 
90
90
  ## Refusals
91
91
 
92
- - `user.refuse(code)` leaves the grant alone: the directory refuses each refresh with `code` (`grant_removed`, `grant_suspended`, `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.
92
+ - `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.
93
93
  - `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.
94
94
  - `user.expireTokens()` forces a refresh without a refusal.
95
95
 
96
96
  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.
97
97
 
98
+ ## Testing mode
99
+
100
+ An unverified third-party app is *in testing* on crouter cloud: 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.
101
+
102
+ ```ts
103
+ cloud.user('user:tess').setAccount({ email: 'tess@example.com' }); // the identifiers the account holds
104
+ cloud.testing.addTester({ email: 'Tess@Example.com' }); // or {phone: '+15551234567'}; false if already present
105
+ cloud.testing.enable({ admin: 'user:me' }); // in testing; revokes active grants of people not allowed
106
+
107
+ const back = cloud.authorize('user:stranger', { redirectUri, state }); // the redirect the browser follows back to your app
108
+ authorizationRefusalOf(back); // {code: 'app_in_testing', error: 'access_denied', state}
109
+ ```
110
+
111
+ - `cloud.testing.enable({admin?})` / `disable()` / `active`. `enable` returns the user ids whose active grants it revoked.
112
+ - `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.
113
+ - `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})`.
114
+ - 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.
115
+ - `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`.
116
+ - When a person's email or phone stops matching (`setAccount`), nothing is revoked; their next refresh and `authorize` are refused.
117
+
118
+ In your callback handler, check `authorizationRefusalOf(url)` before reading `code`.
119
+
98
120
  ## Memory and providers
99
121
 
100
122
  `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.
@@ -40,6 +40,21 @@ const client = await oauth.client(connection.userId);
40
40
 
41
41
  `state: callbackState` comes from the redirect query, while `expectedState` is the value saved in the app's session when `authorizeUrl()` ran. `exchangeCode` and `exchangeIdentity` refuse a missing or different value with `CrouterError` code `oauth_state_mismatch` **before** sending the code to the token endpoint. Persist the expected value across redirects and tie it to the user's session; never use the callback's `state` for both arguments.
42
42
 
43
+ ### When sign-in is refused: apps in testing
44
+
45
+ A new app is in testing until Northlight verifies it. When someone who is not the app's admin or a named tester signs in, the directory redirects to your `redirectUri` with `error=access_denied`, `error_description=app_in_testing` and your `state`, and issues no code. `authorizationRefusalOf` reads it from the callback's query (a `URL`, `URLSearchParams`, query string, or parsed object) and returns `{code: 'app_in_testing', error: 'access_denied', state}`, else `null`:
46
+
47
+ ```ts
48
+ import {authorizationRefusalOf} from '@north-light/sdk';
49
+
50
+ const refused = authorizationRefusalOf(new URL(request.url));
51
+ if (refused) {
52
+ // check refused.state against the saved state, then show "this app is in testing; ask its developer to add you"
53
+ }
54
+ ```
55
+
56
+ A person who pressed Cancel (`access_denied` with no reason) returns `null`. Test the path with [`cloud.testing`](./testing.md#testing-mode).
57
+
43
58
  ### Keep sign-in secrets out of HTTP logs
44
59
 
45
60
  If your app uses `nestjs-pino` / `pino-http`, its default serializers can log the full `/auth/callback?code=…&state=…` URL and query, plus response headers including the session `set-cookie` and a redirect `location` containing OAuth `state`. Redacting only authorization and cookie **request** headers does not protect these values. Use bounded serializers for *all* routes, not just the callback: log only request method, path without query string, and id; log only response status. For example:
@@ -105,6 +120,7 @@ A `Refusal` is `{kind, code, userId, grantId, error}`. `kind` reads this table;
105
120
  |---|---|---|---|
106
121
  | `grant_removed` | `removed` | The person removed your app, deleted their account, or a provider-connected registration disconnected them. The runtime has deleted your app's space, runs, schedules and shares. | You may delete what you keep for that person. Do not call `runs.delete`: the runs are already gone. If they connect again, they see the consent page and start fresh. |
107
122
  | `grant_suspended` | `paused` | Your app's access for that person is paused. Nothing was deleted; your app's sandboxes are stopped. | Keep everything you store for that person, stop calling for them, and show that the app is paused. If they connect again, the same grant comes back with every run, file and schedule. |
123
+ | `app_in_testing` | `paused` | Your app is still in testing (not verified by Northlight) and this person is not its admin or a named tester: they were removed as a tester, or their email or phone stopped matching one. Their grant is revoked, not removed; nothing was deleted and your app's sandboxes are stopped. | Keep everything you store for that person, stop calling for them, and tell them the app is in testing and the developer must add them. If the developer adds them back and they sign in again, the same space comes back. |
108
124
  | `refresh_token_expired` | `reconnect` | The refresh token went 90 days without use. The grant is still active. | Keep everything. Run the connect flow again; the consent page is skipped. Prevent it with `refreshIdleConnections` (below). |
109
125
  | `refresh_token_reused` | `reconnect` | Two of your app's processes raced on one refresh token, and the directory ended the grant's refresh tokens. The grant is still active. | Keep everything. Fix the store's `lock`, then reconnect (consent is skipped); without the fix the race recurs. |
110
126
  | `grant_revoked` | `reconnect` | The directory refused without naming a reason: a refresh token it never issued to your app, one your app revoked through `revoke`, or one whose record was purged. The runtime also answers `grant_revoked` when the grant is not active in its last sync; the connected client then refreshes once, so you normally receive the directory's specific code instead. | Keep everything and reconnect. Never delete data on `grant_revoked`: it does not say anything was removed. |
@@ -7,6 +7,16 @@ description: Behavior changes in @north-light/sdk that an app must act on, newes
7
7
 
8
8
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
9
9
 
10
+ ## 0.3.419
11
+
12
+ ### Testing mode: `app_in_testing` (additive)
13
+
14
+ Not breaking: no type or wire shape an app reads is removed or changed.
15
+
16
+ - An app Northlight has not verified is in testing: only its admin and named testers can sign in. A refused sign-in redirects with `error=access_denied`, `error_description=app_in_testing`; a refused refresh answers `invalid_grant` `app_in_testing`. The refresh refusal is a new `Refusal` with `code: 'app_in_testing'` and `kind: 'paused'`, like `grant_suspended`: keep the person's data. If you `switch` exhaustively on `refusal.code`, add the case. See [When a refresh is refused](./client.md#when-a-refresh-is-refused).
17
+ - New `authorizationRefusalOf(callback)` reads the sign-in refusal from the callback's query and returns `AuthorizationRefusal` (`{code: 'app_in_testing', error: 'access_denied', state}`), else `null`. See [When sign-in is refused](./client.md#when-sign-in-is-refused-apps-in-testing).
18
+ - `@north-light/sdk/testing`: `cloud.testing` (`enable`, `disable`, `active`, `addTester`, `removeTester`, `testers`, `allowed`), `cloud.authorize(userId, {redirectUri, state})`, `user.setAccount({email, emailVerified, phone})`, and `'app_in_testing'` in `FakeRefusalCode`. The fake directory now serves `grant_type=authorization_code` for codes `cloud.authorize` issued. See [Testing mode](./testing.md#testing-mode).
19
+
10
20
  ## 0.3.415
11
21
 
12
22
  ### Monthly spend cap enforced; `usage.get` (additive; one behavior change)
@@ -87,12 +87,34 @@ Idempotency follows the runtime too: the same `Idempotency-Key` with the same bo
87
87
 
88
88
  ## Refusals
89
89
 
90
- - `user.refuse(code)` leaves the grant alone: the directory refuses each refresh with `code` (`grant_removed`, `grant_suspended`, `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.
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
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
92
  - `user.expireTokens()` forces a refresh without a refusal.
93
93
 
94
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
95
 
96
+ ## Testing mode
97
+
98
+ An unverified third-party app is *in testing* on crouter cloud: 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
+
96
118
  ## Memory and providers
97
119
 
98
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.417",
3
+ "version": "0.3.419",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -134,7 +134,7 @@
134
134
  },
135
135
  "license": "UNLICENSED",
136
136
  "dependencies": {
137
- "@north-light/identity": "0.3.417",
137
+ "@north-light/identity": "0.3.419",
138
138
  "@earendil-works/pi-agent-core": "1.1.0",
139
139
  "@earendil-works/pi-ai": "1.1.0",
140
140
  "@earendil-works/pi-coding-agent": "1.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/identity",
3
- "version": "0.3.417",
3
+ "version": "0.3.419",
4
4
  "description": "crouter cloud access-token verification and scope rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.417",
3
+ "version": "0.3.419",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.417",
9
+ "version": "0.3.419",
10
10
  "hasInstallScript": true,
11
11
  "license": "UNLICENSED",
12
12
  "workspaces": [
@@ -18,7 +18,7 @@
18
18
  "@earendil-works/pi-ai": "1.1.0",
19
19
  "@earendil-works/pi-coding-agent": "1.1.0",
20
20
  "@earendil-works/pi-tui": "1.1.0",
21
- "@north-light/identity": "0.3.417",
21
+ "@north-light/identity": "0.3.419",
22
22
  "cron-parser": "^5.6.0",
23
23
  "esbuild": "^0.27.7",
24
24
  "jose": "^6.2.12",
@@ -12095,17 +12095,17 @@
12095
12095
  },
12096
12096
  "packages/crouter-api": {
12097
12097
  "name": "@north-light/api",
12098
- "version": "0.3.417",
12098
+ "version": "0.3.419",
12099
12099
  "license": "UNLICENSED"
12100
12100
  },
12101
12101
  "packages/crouter-env-docker": {
12102
12102
  "name": "@north-light/env-docker",
12103
- "version": "0.3.417",
12103
+ "version": "0.3.419",
12104
12104
  "license": "UNLICENSED"
12105
12105
  },
12106
12106
  "packages/crouter-identity": {
12107
12107
  "name": "@north-light/identity",
12108
- "version": "0.3.417",
12108
+ "version": "0.3.419",
12109
12109
  "license": "UNLICENSED",
12110
12110
  "dependencies": {
12111
12111
  "jose": "^6.2.1"
@@ -12113,20 +12113,20 @@
12113
12113
  },
12114
12114
  "packages/crouter-plugin": {
12115
12115
  "name": "@north-light/plugin",
12116
- "version": "0.3.417",
12116
+ "version": "0.3.419",
12117
12117
  "license": "UNLICENSED",
12118
12118
  "dependencies": {
12119
- "@north-light/api": "0.3.417",
12120
- "@north-light/identity": "0.3.417"
12119
+ "@north-light/api": "0.3.419",
12120
+ "@north-light/identity": "0.3.419"
12121
12121
  }
12122
12122
  },
12123
12123
  "packages/crouter-sdk": {
12124
12124
  "name": "@north-light/sdk",
12125
- "version": "0.3.417",
12125
+ "version": "0.3.419",
12126
12126
  "license": "UNLICENSED",
12127
12127
  "dependencies": {
12128
- "@north-light/api": "0.3.417",
12129
- "@north-light/identity": "0.3.417",
12128
+ "@north-light/api": "0.3.419",
12129
+ "@north-light/identity": "0.3.419",
12130
12130
  "jose": "^6.2.1"
12131
12131
  },
12132
12132
  "bin": {