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 atstart(), everysweepMs(default 30 000), after each delivery and onnudge(). 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.getonoauth.client(userId). A user whose last reading was delivered readsgrant.get({wait, since: version})(waitinteger 1–25, default 25): the runtime holds the read until the grant'sversionchanges orwaitseconds pass. With more users thanconcurrency(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 andprevious: null, so a restart delivers current state again. Delivery is at least once per(userId, version); make the handler idempotent.- If
onChangedthrows, 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 toonError; the next sweep decides again fromusers(). - For one person without the watcher,
runtime.grant.get({wait: 25, since: grant.version})holds a single read the same way.waitoutside 1–25 throwsRangeErrorbefore 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).
stateisnot_started(the person has not done their part),pending(the provider is working),readyorfailed.messageis the provider's own sentence forpendingandfailed. It is absent fornot_startedandready, so keep a short fallback.nextis the absolute URL of the directory's setup page for this provider and your app. Appendreturn_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_equivalentis user-paid spend at list price: model completions and provider receipts. Receipts have noby_modelrow, so it can exceed the sum ofby_model. A model with no list price reportsusd_equivalent: nullon its row.app_paidis usage your app pays for. It never counts towardspend_cap.- At the cap. Once
usd_equivalentreachesspend_cap, user-paid model and provider calls answer 402cap_exhausted(retryable: false,userAction: 'raise_cap', withresetAt) untilreset_at, the first instant of the next UTC month. A run whose model call is refused ends with outcome reasoncap_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: nullmeans 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.
Link to the app's settings on Northlight
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,verifyIdTokenandrevokeshare. - A failure is a
CrouterError(codecrouter_error) whose message names the fault, such as a discovery document that advertises noaccount_app_uri_template. A slow discovery read throwsDeadlineExceededErroraftertimeoutMs(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.