@north-light/sdk 0.3.521 → 0.3.522

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.
@@ -10,16 +10,26 @@ export interface AppToken {
10
10
  runtimeUrl: string;
11
11
  expiresAt: number;
12
12
  }
13
+ /** The app's own directory API token: what its `client_credentials` request with no `sub` answers. */
14
+ export interface DirectoryApiToken {
15
+ accessToken: string;
16
+ expiresAt: number;
17
+ }
13
18
  /** Reads the directory's answer into an `AppToken`; `expiresAt` is the token's own `exp`, else `expires_in` from now. */
14
19
  export declare function appTokenFrom(response: AppTokenResponse, now?: number): AppToken;
20
+ /** Reads the directory's answer to a `client_credentials` request with no `sub` into a `DirectoryApiToken`. */
21
+ export declare function directoryApiTokenFrom(response: AppTokenResponse, now?: number): DirectoryApiToken;
15
22
  /** A token stays usable until this long before its expiry. */
16
23
  export declare const APP_TOKEN_SKEW_MS = 60000;
17
- /** One minted app token per person, shared by every client of that person and by concurrent callers (one mint in flight per person). */
18
- export declare class AppTokenCache {
24
+ /** One minted token per key (a person's user id; the directory API token has one key), shared by every caller of that key (one mint in flight per key). */
25
+ export declare class AppTokenCache<T extends {
26
+ accessToken: string;
27
+ expiresAt: number;
28
+ } = AppToken> {
19
29
  private readonly mint;
20
30
  private readonly tokens;
21
31
  private readonly minting;
22
- constructor(mint: (userId: string) => Promise<AppToken>);
23
- /** The cached token while it has more than a minute left, else a fresh mint. `rejected` is the token a runtime just answered 401 to: it is never served again. */
24
- get(userId: string, rejected?: string): Promise<AppToken>;
32
+ constructor(mint: (userId: string) => Promise<T>);
33
+ /** The cached token while it has more than a minute left, else a fresh mint. `rejected` is the token a server just answered 401 to: it is never served again. */
34
+ get(userId: string, rejected?: string): Promise<T>;
25
35
  }
@@ -5,17 +5,25 @@ export function appTokenFrom(response, now = Date.now()) {
5
5
  if (typeof response.access_token !== 'string' || !response.access_token || typeof response.runtimeUrl !== 'string' || !response.runtimeUrl) {
6
6
  throw new CrouterError('Directory returned an incomplete app token (access_token and runtimeUrl are required)');
7
7
  }
8
- const exp = decodeJwt(response.access_token).exp;
9
- const expiresAt = typeof exp === 'number' ? exp * 1_000
10
- : typeof response.expires_in === 'number' ? now + response.expires_in * 1_000
11
- : undefined;
8
+ return { accessToken: response.access_token, runtimeUrl: response.runtimeUrl, expiresAt: expiryOf(response.access_token, response.expires_in, now) };
9
+ }
10
+ /** Reads the directory's answer to a `client_credentials` request with no `sub` into a `DirectoryApiToken`. */
11
+ export function directoryApiTokenFrom(response, now = Date.now()) {
12
+ if (typeof response.access_token !== 'string' || !response.access_token) {
13
+ throw new CrouterError('Directory returned an incomplete directory API token (access_token is required)');
14
+ }
15
+ return { accessToken: response.access_token, expiresAt: expiryOf(response.access_token, response.expires_in, now) };
16
+ }
17
+ function expiryOf(accessToken, expiresIn, now) {
18
+ const exp = decodeJwt(accessToken).exp;
19
+ const expiresAt = typeof exp === 'number' ? exp * 1_000 : typeof expiresIn === 'number' ? now + expiresIn * 1_000 : undefined;
12
20
  if (expiresAt === undefined)
13
- throw new CrouterError('Directory returned an app token with no expiry');
14
- return { accessToken: response.access_token, runtimeUrl: response.runtimeUrl, expiresAt };
21
+ throw new CrouterError('Directory returned a token with no expiry');
22
+ return expiresAt;
15
23
  }
16
24
  /** A token stays usable until this long before its expiry. */
17
25
  export const APP_TOKEN_SKEW_MS = 60_000;
18
- /** One minted app token per person, shared by every client of that person and by concurrent callers (one mint in flight per person). */
26
+ /** One minted token per key (a person's user id; the directory API token has one key), shared by every caller of that key (one mint in flight per key). */
19
27
  export class AppTokenCache {
20
28
  mint;
21
29
  tokens = new Map();
@@ -23,7 +31,7 @@ export class AppTokenCache {
23
31
  constructor(mint) {
24
32
  this.mint = mint;
25
33
  }
26
- /** The cached token while it has more than a minute left, else a fresh mint. `rejected` is the token a runtime just answered 401 to: it is never served again. */
34
+ /** The cached token while it has more than a minute left, else a fresh mint. `rejected` is the token a server just answered 401 to: it is never served again. */
27
35
  get(userId, rejected) {
28
36
  const cached = this.tokens.get(userId);
29
37
  if (cached && cached.accessToken !== rejected && cached.expiresAt - Date.now() > APP_TOKEN_SKEW_MS)
@@ -128,6 +128,7 @@ export declare class OAuth2Client {
128
128
  private readonly timeoutMs;
129
129
  private readonly store;
130
130
  private readonly appTokens;
131
+ private readonly directoryApiTokens;
131
132
  private jwks?;
132
133
  private discoveryPromise?;
133
134
  /** One refresh per (user, refresh token) in this process; a client holding a newer token never joins an older refusal. */
@@ -242,6 +243,18 @@ export declare class OAuth2Client {
242
243
  */
243
244
  appClient(userId: string): Promise<ConnectedCrouter>;
244
245
  private mintAppToken;
246
+ private mintDirectoryApiToken;
247
+ /**
248
+ * The app's own directory API token (`sub=app:<clientId>`, audience `<issuer>/v1`): the bearer for the directory's `/v1` routes
249
+ * called as the app itself, not for a person. The SDK signs a `client_credentials` request with the `OAuth2Client`'s key
250
+ * (`private_key_jwt`) and no `sub` (a `sub` would mint a person's runtime token, see `appClient`). The token is cached until a
251
+ * minute before it expires, and concurrent callers share one mint.
252
+ *
253
+ * Pass `rejected`, the token the directory just answered 401 to, to get a different one: the cached copy is dropped and the
254
+ * SDK mints again, so `const t = await oauth.directoryToken(); … 401 … oauth.directoryToken(t)` re-mints once. Errors are the
255
+ * token request's: `invalid_client` is a 401 `unauthorized` naming the key, a directory 5xx or a 429 has `retryable` true.
256
+ */
257
+ directoryToken(rejected?: string): Promise<string>;
245
258
  /**
246
259
  * Follow the app's background runs until each comes to rest, and hand each ending to `onEnded` (see
247
260
  * `RunWatchOptions`). Runs are opened with `client(userId)`, so refusals reach `onRefusal`. Call `start()`.
@@ -6,7 +6,7 @@ import { Crouter } from '../client.js';
6
6
  import { withinDeadline } from '../resources/request.js';
7
7
  import { RunWatcher } from '../resources/run-watcher.js';
8
8
  import { GrantWatcher } from '../resources/grant-watcher.js';
9
- import { AppTokenCache, appTokenFrom } from './app-token.js';
9
+ import { AppTokenCache, appTokenFrom, directoryApiTokenFrom } from './app-token.js';
10
10
  import { attachRefusal, isRefusalCode, refusalKindOf } from './refusal.js';
11
11
  export { authorizationRefusalOf, refusalOf } from './refusal.js';
12
12
  /** A connection store for one process (local development and tests): its lock does not reach other processes. */
@@ -113,6 +113,7 @@ export class OAuth2Client {
113
113
  timeoutMs;
114
114
  store;
115
115
  appTokens = new AppTokenCache((userId) => this.mintAppToken(userId));
116
+ directoryApiTokens = new AppTokenCache(() => this.mintDirectoryApiToken());
116
117
  jwks;
117
118
  discoveryPromise;
118
119
  /** One refresh per (user, refresh token) in this process; a client holding a newer token never joins an older refusal. */
@@ -453,6 +454,22 @@ export class OAuth2Client {
453
454
  async mintAppToken(userId) {
454
455
  return appTokenFrom(await this.tokenResponse({ grant_type: 'client_credentials', sub: userId }));
455
456
  }
457
+ async mintDirectoryApiToken() {
458
+ return directoryApiTokenFrom(await this.tokenResponse({ grant_type: 'client_credentials' }));
459
+ }
460
+ /**
461
+ * The app's own directory API token (`sub=app:<clientId>`, audience `<issuer>/v1`): the bearer for the directory's `/v1` routes
462
+ * called as the app itself, not for a person. The SDK signs a `client_credentials` request with the `OAuth2Client`'s key
463
+ * (`private_key_jwt`) and no `sub` (a `sub` would mint a person's runtime token, see `appClient`). The token is cached until a
464
+ * minute before it expires, and concurrent callers share one mint.
465
+ *
466
+ * Pass `rejected`, the token the directory just answered 401 to, to get a different one: the cached copy is dropped and the
467
+ * SDK mints again, so `const t = await oauth.directoryToken(); … 401 … oauth.directoryToken(t)` re-mints once. Errors are the
468
+ * token request's: `invalid_client` is a 401 `unauthorized` naming the key, a directory 5xx or a 429 has `retryable` true.
469
+ */
470
+ directoryToken(rejected) {
471
+ return this.directoryApiTokens.get('directory', rejected).then((token) => token.accessToken);
472
+ }
456
473
  /**
457
474
  * Follow the app's background runs until each comes to rest, and hand each ending to `onEnded` (see
458
475
  * `RunWatchOptions`). Runs are opened with `client(userId)`, so refusals reach `onRefusal`. Call `start()`.
@@ -2,7 +2,7 @@
2
2
  name: northlight
3
3
  description: "Use when building, debugging or shipping an app that signs people in with Northlight, starts runs on their Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk."
4
4
  metadata:
5
- sdk-version: "0.3.521"
5
+ sdk-version: "0.3.522"
6
6
  ---
7
7
 
8
8
  # Building a Northlight app
@@ -11,7 +11,7 @@ An app on Northlight signs people in with their Northlight account, then starts
11
11
 
12
12
  ## First: check this skill matches the SDK
13
13
 
14
- This skill is for SDK version `0.3.521`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. That command needs no account and no crtr. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
14
+ This skill is for SDK version `0.3.522`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. That command needs no account and no crtr. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
15
15
 
16
16
  ## Where things run
17
17
 
@@ -279,7 +279,7 @@ export declare class FakeCloud {
279
279
  private route;
280
280
  private runIntercepts;
281
281
  private directory;
282
- /** `grant_type=client_credentials` + `sub`: the directory's per-user runtime-token mint. The client assertion is not verified. */
282
+ /** `grant_type=client_credentials`: with `sub`, the directory's per-user runtime-token mint; without, the app's own directory API token. The client assertion is not verified. */
283
283
  private mint;
284
284
  private refresh;
285
285
  /** `grant_type=authorization_code`: the code `authorize` issued, once. The client assertion and PKCE verifier are not verified. */
@@ -520,12 +520,14 @@ export class FakeCloud {
520
520
  return this.exchange(params, call);
521
521
  return credentials ? this.mint(params, call) : this.refresh(params, presented, call);
522
522
  }
523
- /** `grant_type=client_credentials` + `sub`: the directory's per-user runtime-token mint. The client assertion is not verified. */
523
+ /** `grant_type=client_credentials`: with `sub`, the directory's per-user runtime-token mint; without, the app's own directory API token. The client assertion is not verified. */
524
524
  mint(params, call) {
525
525
  const sub = params.get('sub');
526
526
  if (!sub) {
527
- call.answer = 400;
528
- return Response.json({ error: 'unauthorized_client' }, { status: 400 });
527
+ const now = Math.floor(this.clock.now() / 1000);
528
+ const clientId = params.get('client_id') ?? '';
529
+ const access_token = unsignedJwt({ sub: `app:${clientId}`, client_id: clientId, aud: `${this.issuer}/v1`, exp: now + 600, iat: now, serial: ++this.tokenSerial });
530
+ return Response.json({ access_token, token_type: 'Bearer', expires_in: 600 });
529
531
  }
530
532
  const user = this.users.get(sub);
531
533
  const refused = (description, answer) => { call.answer = answer; return Response.json({ error: 'invalid_grant', error_description: description }, { status: 400 }); };
package/dist/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
2
- export declare const SDK_VERSION = "0.3.521";
2
+ export declare const SDK_VERSION = "0.3.522";
3
3
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
4
4
  * contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
5
5
  export declare const LEGACY_FLOOR = "0.3.423";
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
2
2
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
3
- export const SDK_VERSION = '0.3.521';
3
+ export const SDK_VERSION = '0.3.522';
4
4
  // LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
5
5
  // (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
6
6
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.521",
3
+ "version": "0.3.522",
4
4
  "crouterContract": "1.25",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
@@ -44,8 +44,8 @@
44
44
  "prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
45
45
  },
46
46
  "dependencies": {
47
- "@north-light/api": "0.3.521",
48
- "@north-light/identity": "0.3.521",
47
+ "@north-light/api": "0.3.522",
48
+ "@north-light/identity": "0.3.522",
49
49
  "jose": "^6.2.1"
50
50
  },
51
51
  "peerDependencies": {