NorthlightSDK
Guides

Grant changes

Follow a person's grant, wait for a required provider, show their month's cost, and link to the app's settings.

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.

Follow grants with watchGrants

oauth.watchGrants(...) returns a GrantWatcher that follows the grants of the people you name and hands each change to onChanged:

const watcher = oauth.watchGrants({
  // Only people whose change the app will act on: here, owners still waiting on Context.
  users: () => db.owners.findMany({where: {connected: true, drafted: false}}).then((rows) => rows.map((row) => row.userId)),
  onChanged: async ({userId, grant}) => {
    // Level-triggered: decide from the grant as it is now, never from "did it just change".
    if (grant.dependencies['context']?.state !== 'ready') return;
    // Idempotent: only a row still waiting moves, so a redelivery or a second instance starts nothing.
    const claimed = await db.owners.updateMany({where: {userId, drafted: false}, data: {drafted: true}});
    if (claimed.count === 1) await startDraft(userId);
  },
  onError: (error, userId) => log.warn({error, userId}, 'grant watcher'),
});
await watcher.start();
// After the app starts waiting on someone:
watcher.nudge();
// On shutdown:
await watcher.stop();
  • 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.
  • 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.
  • 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.
  • 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().
  • 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.

Wait on a provider your app requires

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.

import type {ProviderDependency} from '@north-light/sdk';

// ProviderDependency is { state: 'not_started' | 'pending' | 'ready' | 'failed'; message?: string; next: string }.
const {dependencies} = await runtime.grant.get();
const context: ProviderDependency | undefined = dependencies['context'];
if (context?.state !== 'ready') {
  // Show the provider's own sentence and link its setup page; never hard-code either.
  const setup = context && new URL(context.next);
  setup?.searchParams.set('return_to', 'https://app.example.com'); // your registered redirect origin
  throw new AppError('provider_setup_pending', context?.message ?? 'Finish setting up Context, then try again.', {
    status: 403,
    retryable: false,
    details: {provider: 'context', state: context?.state ?? 'not_started', message: context?.message, next: setup?.href},
  });
}

AppError stands for your app's own typed error (Errors).

  • state is not_started (the person has not done their part), pending (the provider is working), ready or failed.
  • message is the provider's own sentence for pending and failed. It is absent for not_started and ready, so keep a short fallback.
  • 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.
  • An entry is missing when the runtime has not synced the grant since the app began requiring the provider. Treat it as not_started.

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.

Show what a person's month has cost

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:

const usage = await runtime.usage.get(); // or {month: '2026-09'}
// {grantee, month, usd_equivalent, by_model, app_paid, spend_cap, reset_at}
  • 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.
  • app_paid is usage your app pays for. It never counts toward spend_cap.
  • 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.
  • spend_cap: null means the grant is not capped.

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.

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:

const href = await oauth.accountAppUrl(); // e.g. https://<directory>/account/apps/<your client id>
  • 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.
  • 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.
  • 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.
  • 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.

On this page