@rekey.dev/node 2.0.0 → 2.2.0-rc.1

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/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @rekey.dev/node server SDK for Rekey.
2
+ * @rekey.dev/node, server SDK for Rekey.
3
3
  *
4
4
  * One client instance per Application. Construct with the Application's
5
5
  * secret key (`rp_live_…` or `rp_test_…`) and the URL of your Rekey
6
- * deployment. Never ship the secret key to the browser for browser code
6
+ * deployment. Never ship the secret key to the browser, for browser code
7
7
  * use `@rekey.dev/react` with the Application's public key instead.
8
8
  *
9
9
  * @example Smoke-test your credentials
@@ -21,24 +21,24 @@
21
21
  */
22
22
  // The canonical error class lives in shared-types; import it for internal use
23
23
  // and re-export below so @rekey.dev/node's public surface is unchanged. The
24
- // `/error` subpath is the zod-free module the class actually lives in same
24
+ // `/error` subpath is the zod-free module the class actually lives in, same
25
25
  // class object the barrel re-exports, so `instanceof` is identical.
26
26
  import { RekeyError } from '@rekey.dev/shared-types/error';
27
27
  /**
28
28
  * Default per-request deadline, in milliseconds. Matches the timeout the Rekey
29
29
  * API itself uses when it POSTs your outbound webhooks.
30
30
  *
31
- * Without a deadline the effective timeout is undici's `headersTimeout` five
32
- * minutes so a single unreachable Rekey deployment can pin one of your
31
+ * Without a deadline the effective timeout is undici's `headersTimeout`, five
32
+ * minutes, so a single unreachable Rekey deployment can pin one of your
33
33
  * request handlers for that long. Ten seconds is long enough for any endpoint
34
34
  * this SDK calls and short enough to fail a page instead of hanging it.
35
35
  */
36
36
  export const DEFAULT_TIMEOUT_MS = 10_000;
37
- // RekeyError is the shared class (imported above) re-exported so the public
37
+ // RekeyError is the shared class (imported above), re-exported so the public
38
38
  // API name is preserved and `instanceof` is consistent with @rekey.dev/react.
39
39
  export { RekeyError };
40
40
  /**
41
- * Outbound webhook event registry the events Rekey can POST to your app
41
+ * Outbound webhook event registry, the events Rekey can POST to your app
42
42
  * (verify them with `verifyWebhookSignature` below). `WEBHOOK_EVENTS` carries
43
43
  * `{ name, description }` pairs for introspection/autocomplete;
44
44
  * `KNOWN_WEBHOOK_EVENTS` is just the names. Mirrors the API's registry exactly.
@@ -47,7 +47,7 @@ export { RekeyError };
47
47
  * ```ts
48
48
  * import { WEBHOOK_EVENTS, isKnownWebhookEvent, type WebhookEventEnvelope } from '@rekey.dev/node';
49
49
  *
50
- * for (const e of WEBHOOK_EVENTS) console.log(`${e.name} ${e.description}`);
50
+ * for (const e of WEBHOOK_EVENTS) console.log(`${e.name}, ${e.description}`);
51
51
  *
52
52
  * const event = req.body as WebhookEventEnvelope; // after verifyWebhookSignature(...)
53
53
  * if (event.type === 'subscription.activated') unlockPlan(event.data);
@@ -59,7 +59,7 @@ export { WEBHOOK_EVENTS, KNOWN_WEBHOOK_EVENTS, isKnownWebhookEvent } from '@reke
59
59
  * this subscriber the rest of the period they paid for?
60
60
  *
61
61
  * Exported because a cancel confirmation has to say which outcome the customer
62
- * is about to get, and it has to say so BEFORE the call there is no response
62
+ * is about to get, and it has to say so BEFORE the call, there is no response
63
63
  * to read it off. It is the same function the API decides from, not a
64
64
  * description of it, so a UI built on it cannot promise a behaviour the server
65
65
  * does not have. See its docblock for the cases that still end immediately.
@@ -87,25 +87,29 @@ export class Rekey {
87
87
  config;
88
88
  /** Operations on the calling Application itself. */
89
89
  applications;
90
- /** Auth operations sign-in, sign-up, sessions, passkeys, magic-link. */
90
+ /** Auth operations, sign-in, sign-up, sessions, passkeys, magic-link. */
91
91
  auth;
92
- /** Billing operations plans, checkout, subscriptions, coupons. */
92
+ /** Billing operations, plans, checkout, subscriptions, coupons. */
93
93
  billing;
94
- /** End-user organizations create, invite, members, role changes. */
94
+ /** End-user organizations, create, invite, members, role changes. */
95
95
  organizations;
96
96
  /** License key verification + activation. */
97
97
  licenses;
98
- /** Usage metering record events, aggregate windows. */
98
+ /** Devices, list and release the machines an end-user signs in from. */
99
+ devices;
100
+ /** End-users, lookup by id or email, bulk import. */
101
+ users;
102
+ /** Usage metering, record events, aggregate windows. */
99
103
  usage;
100
- /** Prepaid credits balance reads, idempotent drawdown, ledger. */
104
+ /** Prepaid credits, balance reads, idempotent drawdown, ledger. */
101
105
  credits;
102
- /** MCP validate Rekey-issued MCP tokens from your own MCP server. */
106
+ /** MCP, validate Rekey-issued MCP tokens from your own MCP server. */
103
107
  mcp;
104
108
  constructor(config) {
105
109
  if (!config.apiUrl) {
106
110
  // Name the Cloud value outright. There is no default and there should
107
- // not be one a wrong default would silently point a self-hosted
108
- // deployment's traffic at somebody else's API but "requires apiUrl"
111
+ // not be one, a wrong default would silently point a self-hosted
112
+ // deployment's traffic at somebody else's API, but "requires apiUrl"
109
113
  // alone leaves a Rekey Cloud customer with no way to find out what to
110
114
  // put, because every example in the docs reads as a placeholder.
111
115
  throw new RekeyError({
@@ -132,12 +136,14 @@ export class Rekey {
132
136
  this.billing = new BillingClient(this);
133
137
  this.organizations = new OrganizationsClient(this);
134
138
  this.licenses = new LicensesClient(this);
139
+ this.devices = new DevicesClient(this);
140
+ this.users = new UsersClient(this);
135
141
  this.usage = new UsageClient(this);
136
142
  this.credits = new CreditsClient(this);
137
143
  this.mcp = new McpClient(this);
138
144
  }
139
145
  /**
140
- * A clone of this client with different call options the per-call knob for
146
+ * A clone of this client with different call options, the per-call knob for
141
147
  * every wrapped method.
142
148
  *
143
149
  * Each namespace method (`billing.getPlans()`, `auth.signIn()`, …) has a
@@ -157,7 +163,7 @@ export class Rekey {
157
163
  * });
158
164
  * ```
159
165
  *
160
- * Cheap it rebuilds the namespace objects, holds no connections, and
166
+ * Cheap, it rebuilds the namespace objects, holds no connections, and
161
167
  * shares the same `fetch`.
162
168
  */
163
169
  with(options) {
@@ -174,7 +180,7 @@ export class Rekey {
174
180
  * Call a Rekey endpoint this SDK does not wrap yet.
175
181
  *
176
182
  * This is a **supported** escape hatch, not an internal: when the API grows a
177
- * route before the SDK does, use this instead of hand-rolling `fetch` you
183
+ * route before the SDK does, use this instead of hand-rolling `fetch`, you
178
184
  * keep the auth header, the `{ success, data }` unwrapping, the `RekeyError`
179
185
  * mapping (including transport failures) and the deadline. It takes an
180
186
  * options object precisely so a future knob does not need a new overload.
@@ -204,6 +210,19 @@ export class Rekey {
204
210
  * surface (see `stripInternal` in tsconfig).
205
211
  */
206
212
  async send(method, path, body, extraHeaders, options) {
213
+ return (await this.sendWithStatus(method, path, body, extraHeaders, options)).data;
214
+ }
215
+ /**
216
+ * @internal Same request as {@link send}, but keeps the HTTP status.
217
+ *
218
+ * Almost every endpoint encodes its whole answer in the body, which is why
219
+ * `send` throws the status away. `POST /billing/subscribe` does not: it
220
+ * returns the same Subscription under 201 (it just activated the free tier)
221
+ * and under 200 (the caller already had it, nothing was written). Dropping
222
+ * the status there would make "you are now on the free tier" and "you already
223
+ * were" indistinguishable to the caller.
224
+ */
225
+ async sendWithStatus(method, path, body, extraHeaders, options) {
207
226
  const res = await this.fetchWithDeadline(`${this.apiUrl}${path}`, {
208
227
  method,
209
228
  headers: {
@@ -230,10 +249,10 @@ export class Rekey {
230
249
  ...(resolvedRequestId !== undefined && { requestId: resolvedRequestId }),
231
250
  });
232
251
  }
233
- return json.data;
252
+ return { data: json.data, status: res.status };
234
253
  }
235
254
  /**
236
- * @internal Raw request for the non-enveloped OAuth/MCP endpoints returns
255
+ * @internal Raw request for the non-enveloped OAuth/MCP endpoints, returns
237
256
  * the parsed JSON as-is (those endpoints emit standard OAuth shapes, not the
238
257
  * `{ success, data }` envelope). Throws `RekeyError` on non-2xx, mapping
239
258
  * the OAuth `{ error, error_description }` body when present.
@@ -260,7 +279,7 @@ export class Rekey {
260
279
  /**
261
280
  * @internal The one place `fetch` is called. Applies the deadline, composes
262
281
  * the caller's signals, and turns anything the transport throws into a
263
- * `RekeyError` without this, `ECONNREFUSED` escaped as a bare `TypeError`
282
+ * `RekeyError`, without this, `ECONNREFUSED` escaped as a bare `TypeError`
264
283
  * and slipped straight through the documented
265
284
  * `catch (e) { if (e instanceof RekeyError) … }` pattern.
266
285
  */
@@ -315,7 +334,7 @@ function transportError(cause, deadline, method, path) {
315
334
  return new RekeyError({
316
335
  code: 'REQUEST_ABORTED',
317
336
  message: `${where} was aborted by the caller's AbortSignal.`,
318
- fix: 'This is your own cancellation swallow it, or check the signal you passed to `signal` / `Rekey.with({ signal })`.',
337
+ fix: 'This is your own cancellation, swallow it, or check the signal you passed to `signal` / `Rekey.with({ signal })`.',
319
338
  cause,
320
339
  });
321
340
  }
@@ -336,7 +355,7 @@ function transportError(cause, deadline, method, path) {
336
355
  }
337
356
  /**
338
357
  * MCP helpers for customers running their OWN MCP server behind Rekey auth.
339
- * The hosted MCP server (account tools) is consumed by MCP clients directly
358
+ * The hosted MCP server (account tools) is consumed by MCP clients directly,
340
359
  * this client is for the "bring your own MCP server" path: validate incoming
341
360
  * Rekey-issued tokens, and read the OAuth metadata.
342
361
  */
@@ -380,7 +399,7 @@ class ApplicationsClient {
380
399
  }
381
400
  /**
382
401
  * Verify credentials and fetch the calling Application. Use this as your
383
- * SDK smoke test if it returns, your secret key is good and you're
402
+ * SDK smoke test, if it returns, your secret key is good and you're
384
403
  * pointed at the right Rekey deployment.
385
404
  *
386
405
  * @example
@@ -406,7 +425,7 @@ class AuthClient {
406
425
  * (e.g. `getCurrentUser(accessToken)`) and a `refreshToken` to renew it.
407
426
  *
408
427
  * Unless the Application turns `authConfig.sendVerificationEmailOnSignUp`
409
- * off, Rekey also emails the verification link best-effort, so it never
428
+ * off, Rekey also emails the verification link, best-effort, so it never
410
429
  * fails the sign-up, and `sendVerificationEmail` re-sends on demand.
411
430
  *
412
431
  * @example
@@ -415,7 +434,7 @@ class AuthClient {
415
434
  * email: 'alice@example.com',
416
435
  * password: 'correct-horse-battery-staple',
417
436
  * });
418
- * // store both in your session the access token expires in 15 minutes
437
+ * // store both in your session, the access token expires in 15 minutes
419
438
  * ```
420
439
  *
421
440
  * @throws {RekeyError} `EMAIL_ALREADY_EXISTS` (409) if the email is taken in this Application.
@@ -434,14 +453,14 @@ class AuthClient {
434
453
  * Prompt the user for their TOTP / backup code and call
435
454
  * `mfaVerify({ mfaChallengeToken, code })` to receive a real session.
436
455
  *
437
- * **Branch on `result.mfaRequired` before reading `accessToken`** the
456
+ * **Branch on `result.mfaRequired` before reading `accessToken`**, the
438
457
  * MFA-required branch has no session tokens.
439
458
  *
440
- * @throws {RekeyError} `INVALID_CREDENTIALS` (401) single code on purpose.
459
+ * @throws {RekeyError} `INVALID_CREDENTIALS` (401), single code on purpose.
441
460
  * Don't try to distinguish wrong-email from wrong-password from the SDK side either.
442
461
  * @throws {RekeyError} `EMAIL_NOT_VERIFIED` (403) when the Application sets
443
462
  * `authConfig.requireEmailVerification` and the user hasn't confirmed their
444
- * address. The password was correct prompt for the emailed link (or call
463
+ * address. The password was correct, prompt for the emailed link (or call
445
464
  * `sendVerificationEmail`), not for the password again.
446
465
  */
447
466
  signIn(input) {
@@ -471,7 +490,7 @@ class AuthClient {
471
490
  return this.client.send('POST', '/api/v1/auth/magic-link/request', input);
472
491
  }
473
492
  /**
474
- * Consume a magic-link token. Returns `SignInOutcome` branch on
493
+ * Consume a magic-link token. Returns `SignInOutcome`, branch on
475
494
  * `mfaRequired` before reading `accessToken`. For MFA-enrolled users
476
495
  * the response carries `mfaChallengeToken` and you must complete via
477
496
  * `mfaVerify(...)`.
@@ -482,7 +501,7 @@ class AuthClient {
482
501
  /**
483
502
  * Begin a passkey authentication ceremony. Returns the WebAuthn options
484
503
  * to forward to the browser (`navigator.credentials.get(...)`) along
485
- * with `expectedChallenge` bind the challenge to your session and
504
+ * with `expectedChallenge`, bind the challenge to your session and
486
505
  * pass both back via `verifyPasskeyAuthentication(...)`.
487
506
  */
488
507
  startPasskeyAuthentication(input) {
@@ -490,7 +509,7 @@ class AuthClient {
490
509
  }
491
510
  /**
492
511
  * Complete a passkey authentication. Returns the same `SignInOutcome`
493
- * shape as `signIn` but passkeys are themselves a strong factor, so
512
+ * shape as `signIn`, but passkeys are themselves a strong factor, so
494
513
  * `mfaRequired` will always be `false` in practice.
495
514
  */
496
515
  verifyPasskeyAuthentication(input) {
@@ -515,7 +534,7 @@ class AuthClient {
515
534
  /**
516
535
  * List the user's registered passkeys, newest first.
517
536
  *
518
- * Returns `{items, page}` `page.total` is the number of passkeys the user
537
+ * Returns `{items, page}`, `page.total` is the number of passkeys the user
519
538
  * has, independent of the window served.
520
539
  */
521
540
  listPasskeys(accessToken, page) {
@@ -528,7 +547,7 @@ class AuthClient {
528
547
  return this.client.send('DELETE', `/api/v1/auth/passkeys/${encodeURIComponent(credentialRowId)}`, undefined, { 'X-Rekey-User-Token': accessToken });
529
548
  }
530
549
  // End-user organization / team methods live on `rekey.organizations.*`
531
- // (OrganizationsClient) the canonical, fuller surface. The earlier
550
+ // (OrganizationsClient), the canonical, fuller surface. The earlier
532
551
  // duplicates here (createOrganization / listMyOrganizations /
533
552
  // inviteToOrganization / acceptOrganizationInvitation) were removed to
534
553
  // avoid two divergent copies of the same endpoints.
@@ -545,7 +564,7 @@ class AuthClient {
545
564
  });
546
565
  }
547
566
  /**
548
- * Update the end-user behind a presented access token their OWN record,
567
+ * Update the end-user behind a presented access token, their OWN record,
549
568
  * and only ever their own: the token identifies the subject, so there is no
550
569
  * user id to pass and no way to aim this at anyone else.
551
570
  *
@@ -578,21 +597,26 @@ class AuthClient {
578
597
  }
579
598
  /**
580
599
  * Exchange a refresh token for a fresh {access, refresh} pair. The presented
581
- * refresh is revoked atomically call this **once** and store the new
600
+ * refresh is revoked atomically, call this **once** and store the new
582
601
  * `refreshToken` from the response immediately.
583
602
  *
584
603
  * @throws {RekeyError} `REFRESH_TOKEN_REUSED` (401) if you replay an already-used token.
585
604
  * This is a strong signal the original was leaked; treat as compromise.
586
605
  * @throws {RekeyError} `REFRESH_TOKEN_EXPIRED` (401) after the 30-day refresh window.
587
606
  */
588
- refresh(refreshToken) {
589
- // /auth/refresh returns the same shape as /auth/mfa-verify always a
607
+ refresh(refreshToken, options = {}) {
608
+ // /auth/refresh returns the same shape as /auth/mfa-verify, always a
590
609
  // full session (refresh requires a prior MFA-verified session by
591
- // definition).
592
- return this.client.send('POST', '/api/v1/auth/refresh', { refreshToken });
610
+ // definition). `device` identifies the machine presenting the token: a
611
+ // chain bound at sign-in refuses a different fingerprint, and an unbound
612
+ // one becomes bound (docs/devices.md).
613
+ return this.client.send('POST', '/api/v1/auth/refresh', {
614
+ refreshToken,
615
+ ...(options.device && { device: options.device }),
616
+ });
593
617
  }
594
618
  /**
595
- * Revoke a refresh token. Idempotent no-op for unknown tokens. The
619
+ * Revoke a refresh token. Idempotent, no-op for unknown tokens. The
596
620
  * access token paired with this refresh remains valid until its short
597
621
  * (15 min) expiry; for true "log out everywhere" semantics, also clear
598
622
  * the access token from your client.
@@ -601,14 +625,14 @@ class AuthClient {
601
625
  return this.client.send('POST', '/api/v1/auth/sign-out', { refreshToken });
602
626
  }
603
627
  /**
604
- * Request a password reset for an email. Always succeeds never tells you
628
+ * Request a password reset for an email. Always succeeds, never tells you
605
629
  * whether the email exists.
606
630
  *
607
631
  * **Branch on the result.** When the Application has an email transport
608
632
  * (BYO Resend/SMTP, or a deployment-wide `RESEND_DEFAULT_API_KEY`) Rekey sends
609
633
  * the mail itself and `resetToken` is null. With no transport it falls back to
610
- * the original contract and hands the raw token to you a secret-key caller
611
- * only so you can deliver it with your own provider.
634
+ * the original contract and hands the raw token to you, a secret-key caller
635
+ * only, so you can deliver it with your own provider.
612
636
  *
613
637
  * @example
614
638
  * ```ts
@@ -634,7 +658,7 @@ class AuthClient {
634
658
  }
635
659
  /**
636
660
  * Authenticated password change. Pass the user's *current* access token.
637
- * On success, every refresh token for the user is revoked other devices
661
+ * On success, every refresh token for the user is revoked, other devices
638
662
  * are signed out.
639
663
  */
640
664
  changePassword(accessToken, input) {
@@ -644,8 +668,8 @@ class AuthClient {
644
668
  }
645
669
  /**
646
670
  * Revoke every refresh token for the calling user. "Sign out of all
647
- * devices." The caller's access token remains valid until 15-min expiry
648
- * clear it client-side for full logout.
671
+ * devices." The caller's access token remains valid until 15-min expiry,
672
+ * clear it client-side for full logout.
649
673
  */
650
674
  signOutEverywhere(accessToken) {
651
675
  return this.client.send('POST', '/api/v1/auth/sign-out-everywhere', undefined, { 'X-Rekey-User-Token': accessToken });
@@ -665,7 +689,7 @@ class AuthClient {
665
689
  });
666
690
  }
667
691
  /**
668
- * Re-send a verification link to an address, with **no session** the
692
+ * Re-send a verification link to an address, with **no session**, the
669
693
  * sessionless sibling of `sendVerificationEmail`.
670
694
  *
671
695
  * This is the route for a user locked out by
@@ -673,17 +697,17 @@ class AuthClient {
673
697
  * session `sendVerificationEmail` needs, so a user whose first mail never
674
698
  * arrived cannot ask for another. Takes the address instead of a token.
675
699
  *
676
- * **Branch on the result**, exactly as with `requestPasswordReset` the
700
+ * **Branch on the result**, exactly as with `requestPasswordReset`, the
677
701
  * contract is the same one. It never throws for an unknown address and never
678
702
  * discloses whether the address exists, is already verified, or was mailed:
679
703
  * a publishable-key caller gets one constant body whatever happened. A
680
- * secret-key caller this SDK gets the real outcome, and the raw
704
+ * secret-key caller, this SDK, gets the real outcome, and the raw
681
705
  * `verificationToken` when the Application has no email transport configured,
682
706
  * so you can deliver it with your own provider.
683
707
  *
684
708
  * Pass `verifyUrl` containing `{token}` to template the link target. Unlike
685
709
  * `sendVerificationEmail`, nothing is sent and no token is minted when no
686
- * link can be built at all pass `verifyUrl`, or set the Application URL
710
+ * link can be built at all, pass `verifyUrl`, or set the Application URL
687
711
  * (Panel → Application → Auth). Mailing a locked-out user a verification
688
712
  * message with no button in it helps nobody.
689
713
  *
@@ -719,7 +743,7 @@ class AuthClient {
719
743
  'X-Rekey-User-Token': accessToken,
720
744
  });
721
745
  }
722
- /** Revoke one session by id. Idempotent `{ revoked: false }` if it isn't this user's. */
746
+ /** Revoke one session by id. Idempotent, `{ revoked: false }` if it isn't this user's. */
723
747
  revokeSession(accessToken, sessionId) {
724
748
  return this.client.send('DELETE', `/api/v1/auth/sessions/${encodeURIComponent(sessionId)}`, undefined, { 'X-Rekey-User-Token': accessToken });
725
749
  }
@@ -727,7 +751,7 @@ class AuthClient {
727
751
  //
728
752
  // The login-step verification is `mfaVerify(...)` above. These manage the
729
753
  // user's own TOTP enrollment + step-up challenges. Gated by the
730
- // Application's `authConfig.mfa` policy calls return `MFA_NOT_ENABLED`
754
+ // Application's `authConfig.mfa` policy, calls return `MFA_NOT_ENABLED`
731
755
  // (403) when the policy is "off".
732
756
  /** MFA enrollment status for the current user, plus the Application's policy. */
733
757
  mfaStatus(accessToken) {
@@ -738,7 +762,7 @@ class AuthClient {
738
762
  /**
739
763
  * Begin TOTP enrollment: mints a secret (as an `otpauthUrl` for the QR) and
740
764
  * 10 single-show backup codes. **Not enrolled until `confirmMfaSetup(...)`.**
741
- * Only SHA-256 hashes of the backup codes are stored show them once.
765
+ * Only SHA-256 hashes of the backup codes are stored, show them once.
742
766
  */
743
767
  mfaSetup(accessToken) {
744
768
  return this.client.send('POST', '/api/v1/auth/mfa/setup', undefined, {
@@ -753,7 +777,7 @@ class AuthClient {
753
777
  }
754
778
  /**
755
779
  * Verify a TOTP or backup code as a step-up check (does NOT issue a session).
756
- * Backup codes are single-use consumed on success. Returns `{ ok }`.
780
+ * Backup codes are single-use, consumed on success. Returns `{ ok }`.
757
781
  */
758
782
  mfaChallenge(accessToken, code) {
759
783
  return this.client.send('POST', '/api/v1/auth/mfa/challenge', { code }, {
@@ -780,11 +804,11 @@ class AuthClient {
780
804
  }
781
805
  /**
782
806
  * Exchange the provider `code` for a Rekey session. Returns a
783
- * `SignInOutcome` branch on `mfaRequired` before reading `accessToken`.
807
+ * `SignInOutcome`, branch on `mfaRequired` before reading `accessToken`.
784
808
  * Verify the `state` CSRF value yourself before calling.
785
809
  */
786
- completeOAuth(provider, code) {
787
- return this.client.send('POST', `/api/v1/auth/oauth/${encodeURIComponent(provider)}/callback`, { code });
810
+ completeOAuth(provider, code, options) {
811
+ return this.client.send('POST', `/api/v1/auth/oauth/${encodeURIComponent(provider)}/callback`, { code, ...(options?.device && { device: options.device }) });
788
812
  }
789
813
  /** List the OAuth providers linked to the current user. */
790
814
  listOAuthIdentities(accessToken) {
@@ -797,7 +821,7 @@ class AuthClient {
797
821
  return this.client.send('POST', `/api/v1/auth/oauth/${encodeURIComponent(provider)}/link/start`, { state }, { 'X-Rekey-User-Token': accessToken });
798
822
  }
799
823
  /**
800
- * Complete an OAuth link attaches the provider identity to the current
824
+ * Complete an OAuth link, attaches the provider identity to the current
801
825
  * user. Refuses on unverified provider emails (account-takeover guard) or
802
826
  * when the provider account already belongs to a different user.
803
827
  */
@@ -834,6 +858,26 @@ class OrganizationsClient {
834
858
  'X-Rekey-User-Token': accessToken,
835
859
  });
836
860
  }
861
+ /**
862
+ * List the organization roles assignable in this Application.
863
+ *
864
+ * Returns the built-in OWNER / ADMIN / MEMBER plus any custom roles the
865
+ * operator defined (`editor`, `content-manager`, …). Call this to populate a
866
+ * role picker. An org OWNER/ADMIN assigns roles with their own session via
867
+ * `setMemberRole` / `invite`, so their UI needs to know which names exist.
868
+ *
869
+ * `baseRole` is the authority tier a name maps to. Gate your own features on
870
+ * that, never on the name: Rekey enforces the tier and treats the name as
871
+ * opaque, so a `content-manager` on tier MEMBER can do exactly what MEMBER
872
+ * can and nothing more.
873
+ *
874
+ * Bounded by construction, being an operator-curated catalog, so it is not paged.
875
+ */
876
+ listRoles(accessToken) {
877
+ return this.client.send('GET', '/api/v1/users/me/organizations/roles', undefined, {
878
+ 'X-Rekey-User-Token': accessToken,
879
+ });
880
+ }
837
881
  /**
838
882
  * List organizations the calling user belongs to, with their role.
839
883
  *
@@ -863,10 +907,15 @@ class OrganizationsClient {
863
907
  return this.client.send('GET', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/members${listQuery(page)}`, undefined, { 'X-Rekey-User-Token': accessToken });
864
908
  }
865
909
  /**
866
- * Invite a user. Returns the raw token ONCE surface via your own
910
+ * Invite a user. Returns the raw token ONCE, surface via your own
867
911
  * email/share channel. OWNER + ADMIN only.
868
912
  */
869
- invite(accessToken, organizationId, input) {
913
+ invite(accessToken, organizationId,
914
+ /**
915
+ * `role` is a catalog NAME from `listRoles()`, not a fixed tier. Omit it to
916
+ * use the Application's default organization role.
917
+ */
918
+ input) {
870
919
  return this.client.send('POST', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/invitations`, input, { 'X-Rekey-User-Token': accessToken });
871
920
  }
872
921
  /** Revoke a pending invitation. OWNER + ADMIN only. Idempotent. */
@@ -877,14 +926,16 @@ class OrganizationsClient {
877
926
  * Change a member's role. OWNER manages anyone; ADMIN manages MEMBER
878
927
  * only. Last-OWNER guard refuses demoting the only OWNER.
879
928
  */
880
- setMemberRole(accessToken, organizationId, targetEndUserId, input) {
929
+ setMemberRole(accessToken, organizationId, targetEndUserId,
930
+ /** `role` is a catalog NAME from `listRoles()`, not a fixed tier. */
931
+ input) {
881
932
  return this.client.send('PATCH', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(targetEndUserId)}`, input, { 'X-Rekey-User-Token': accessToken });
882
933
  }
883
934
  /**
884
935
  * Remove a member (or self). Refuses removing the last OWNER.
885
936
  *
886
937
  * Idempotent: `removed` is `false` when the target was not a member (e.g.
887
- * already removed) a no-op removal is not an error. Branch on `removed`
938
+ * already removed), a no-op removal is not an error. Branch on `removed`
888
939
  * rather than assuming it is always `true`.
889
940
  */
890
941
  removeMember(accessToken, organizationId, targetEndUserId) {
@@ -892,7 +943,7 @@ class OrganizationsClient {
892
943
  }
893
944
  /**
894
945
  * Self-leave. An OWNER cannot leave (payment + benefits are tied to the
895
- * owner `ORGANIZATION_OWNER_CANNOT_LEAVE`); transfer ownership via support
946
+ * owner, `ORGANIZATION_OWNER_CANNOT_LEAVE`); transfer ownership via support
896
947
  * first, or demote yourself to ADMIN if there is another OWNER.
897
948
  */
898
949
  leave(accessToken, organizationId) {
@@ -907,7 +958,7 @@ class OrganizationsClient {
907
958
  }
908
959
  /**
909
960
  * Make `organizationId` the active org for this session (member-only).
910
- * Returns a fresh {accessToken, refreshToken} pair carrying the active org
961
+ * Returns a fresh {accessToken, refreshToken} pair carrying the active org,
911
962
  * **store both**. Subsequent entitlement reads (`billing.getEntitlements`)
912
963
  * then default to this org's view + shared pool without passing
913
964
  * `organizationId` explicitly. The active org survives token refresh until
@@ -917,7 +968,7 @@ class OrganizationsClient {
917
968
  return this.client.send('POST', `/api/v1/users/me/organizations/${encodeURIComponent(organizationId)}/switch`, undefined, { 'X-Rekey-User-Token': accessToken });
918
969
  }
919
970
  /**
920
- * Clear the active org switch the session back to the personal pool.
971
+ * Clear the active org, switch the session back to the personal pool.
921
972
  * Returns a fresh token pair (no active org); **store both**.
922
973
  */
923
974
  clearActive(accessToken) {
@@ -932,7 +983,7 @@ class LicensesClient {
932
983
  /**
933
984
  * Verify a license key + record an activation for this machine. Call
934
985
  * once at app startup; you'll get a deterministic body (`ok=false` for
935
- * invalid licenses never an HTTP error so your software can loop
986
+ * invalid licenses, never an HTTP error, so your software can loop
936
987
  * on the result without try/catch noise).
937
988
  *
938
989
  * `machineFingerprint` should be a stable identifier you derive client-
@@ -952,6 +1003,144 @@ class LicensesClient {
952
1003
  verify(input) {
953
1004
  return this.client.send('POST', '/api/v1/licenses/verify', input);
954
1005
  }
1006
+ /**
1007
+ * Give back the seat this machine holds; call it before a re-image or on
1008
+ * uninstall so the next machine can verify. Same deterministic body as
1009
+ * `verify`; `released: false` means the machine held no seat.
1010
+ *
1011
+ * @example
1012
+ * ```ts
1013
+ * await rekey.licenses.deactivate({ key, machineFingerprint });
1014
+ * ```
1015
+ */
1016
+ deactivate(input) {
1017
+ return this.client.send('POST', '/api/v1/licenses/deactivate', input);
1018
+ }
1019
+ }
1020
+ /**
1021
+ * End-users' devices (docs/devices.md), both surfaces.
1022
+ *
1023
+ * `list` / `release` are the SERVER surface: secret key only, addressed by
1024
+ * end-user id, because they read and mutate OTHER users' devices.
1025
+ *
1026
+ * `listMine` / `releaseMine` are the END-USER surface, the one
1027
+ * `DEVICE_LIMIT_REACHED` tells you to offer. They take that user's own access
1028
+ * token and act only on their own devices, so they are what a "your signed-in
1029
+ * machines" screen calls, and what lets a user release a machine themselves
1030
+ * instead of contacting support.
1031
+ */
1032
+ class DevicesClient {
1033
+ client;
1034
+ constructor(client) {
1035
+ this.client = client;
1036
+ }
1037
+ /** An end-user's devices, newest activity first. Optional `status` filter. */
1038
+ list(endUserId, options = {}) {
1039
+ const q = new URLSearchParams({ endUserId });
1040
+ if (options.status)
1041
+ q.set('status', options.status);
1042
+ if (options.limit !== undefined)
1043
+ q.set('limit', String(options.limit));
1044
+ if (options.offset !== undefined)
1045
+ q.set('offset', String(options.offset));
1046
+ return this.client.send('GET', `/api/v1/devices?${q.toString()}`);
1047
+ }
1048
+ /** Release a device: gives its slot back and revokes every session on it. */
1049
+ release(deviceId, endUserId) {
1050
+ return this.client.send('POST', `/api/v1/devices/${encodeURIComponent(deviceId)}/release`, { endUserId });
1051
+ }
1052
+ /**
1053
+ * The calling end-user's OWN devices, newest activity first.
1054
+ *
1055
+ * `GET /api/v1/users/me/devices`, authorized by the user's access token
1056
+ * rather than by an end-user id. Operator notes (`blockedReason`) and IPs are
1057
+ * not on this surface, which is why it resolves to `EndUserDeviceDto`.
1058
+ *
1059
+ * The device the current session is bound to is the one whose `id` matches
1060
+ * the access token's `dev` claim (see {@link VerifiedAccessTokenClaims}), so
1061
+ * a "your devices" screen can mark "this device" without a second call.
1062
+ *
1063
+ * @example
1064
+ * ```ts
1065
+ * const { items } = await rekey.devices.listMine(accessToken, { status: 'ACTIVE' });
1066
+ * ```
1067
+ */
1068
+ listMine(accessToken, options = {}) {
1069
+ const q = new URLSearchParams();
1070
+ if (options.status)
1071
+ q.set('status', options.status);
1072
+ if (options.limit !== undefined)
1073
+ q.set('limit', String(options.limit));
1074
+ if (options.offset !== undefined)
1075
+ q.set('offset', String(options.offset));
1076
+ const query = q.toString();
1077
+ return this.client.send('GET', `/api/v1/users/me/devices/${query ? `?${query}` : ''}`, undefined, { 'X-Rekey-User-Token': accessToken });
1078
+ }
1079
+ /**
1080
+ * Release one of the calling end-user's own devices.
1081
+ *
1082
+ * This is the flow `DEVICE_LIMIT_REACHED` names: that refusal carries
1083
+ * `details.limit` and `details.devices` (typed as `DeviceLimitDetails`), so
1084
+ * you can show the user their machines and release one here rather than
1085
+ * leaving them at a dead end.
1086
+ *
1087
+ * Gives the slot back and revokes every session minted on that device,
1088
+ * INCLUDING the current one when it is the same device, so treat a release of
1089
+ * `claims.dev` as a sign-out. Idempotent for an already-released device; a
1090
+ * BLOCKED device refuses with `DEVICE_BLOCKED` (only an operator can unblock).
1091
+ *
1092
+ * @example
1093
+ * ```ts
1094
+ * try {
1095
+ * await rekey.auth.signIn({ email, password, device: { fingerprint } });
1096
+ * } catch (e) {
1097
+ * if (e instanceof RekeyError && e.code === 'DEVICE_LIMIT_REACHED') {
1098
+ * const { devices } = e.details as DeviceLimitDetails;
1099
+ * // …let the user pick one, then, with a token from a session that has one:
1100
+ * await rekey.devices.releaseMine(accessToken, devices[0]!.id);
1101
+ * }
1102
+ * }
1103
+ * ```
1104
+ */
1105
+ releaseMine(accessToken, deviceId) {
1106
+ return this.client.send('DELETE', `/api/v1/users/me/devices/${encodeURIComponent(deviceId)}`, undefined, { 'X-Rekey-User-Token': accessToken });
1107
+ }
1108
+ }
1109
+ /**
1110
+ * Server-side end-user lookup (secret key only). `/users/me` answers "who is
1111
+ * this token"; these answer "who is this id / email" for a backend that holds
1112
+ * no token.
1113
+ */
1114
+ class UsersClient {
1115
+ client;
1116
+ constructor(client) {
1117
+ this.client = client;
1118
+ }
1119
+ /** Exact, case-insensitive email match in the calling Application. Throws END_USER_NOT_FOUND. */
1120
+ getByEmail(email) {
1121
+ return this.client.send('GET', `/api/v1/users?email=${encodeURIComponent(email)}`);
1122
+ }
1123
+ /** By id, scoped to the calling Application. Throws END_USER_NOT_FOUND. */
1124
+ get(endUserId) {
1125
+ return this.client.send('GET', `/api/v1/users/${encodeURIComponent(endUserId)}`);
1126
+ }
1127
+ /**
1128
+ * Import up to 500 users from another auth system in one call. Password
1129
+ * hashes (argon2id or bcrypt) are stored as given and verified as-is at
1130
+ * sign-in; bcrypt is upgraded to argon2id on first success. Existing
1131
+ * addresses are skipped, never updated.
1132
+ *
1133
+ * @example
1134
+ * ```ts
1135
+ * const { created, skipped } = await rekey.users.import([
1136
+ * { email: 'a@example.com', passwordHash: '$2b$10$…', emailVerified: true },
1137
+ * { email: 'b@example.com', oauthIdentities: [{ provider: 'google', providerAccountId: '1234' }] },
1138
+ * ]);
1139
+ * ```
1140
+ */
1141
+ import(users) {
1142
+ return this.client.send('POST', '/api/v1/users/import', { users });
1143
+ }
955
1144
  }
956
1145
  class UsageClient {
957
1146
  client;
@@ -994,11 +1183,11 @@ function creditSubjectQuery(subject) {
994
1183
  return p;
995
1184
  }
996
1185
  /**
997
- * Prepaid credits the "lead pack" / pay-as-you-go drawdown model. The
1186
+ * Prepaid credits, the "lead pack" / pay-as-you-go drawdown model. The
998
1187
  * customer's backend grants credits (by selling a CREDIT-kind plan, which
999
1188
  * grants automatically on payment) and draws them down per unit consumed.
1000
1189
  *
1001
- * All calls are server-to-server (secret key) and scoped to a `CreditSubject` —
1190
+ * All calls are server-to-server (secret key) and scoped to a `CreditSubject`,
1002
1191
  * an end-user's personal balance, or an organization's shared pool.
1003
1192
  */
1004
1193
  class CreditsClient {
@@ -1015,7 +1204,7 @@ class CreditsClient {
1015
1204
  * `code: "CREDITS_INSUFFICIENT"` (HTTP 402) when the balance is too low.
1016
1205
  *
1017
1206
  * Pass `idempotencyKey` (e.g. the lead id) so a retried call never
1018
- * double-charges a repeat returns the original result with `applied: false`.
1207
+ * double-charges, a repeat returns the original result with `applied: false`.
1019
1208
  */
1020
1209
  consume(input) {
1021
1210
  return this.client.send('POST', '/api/v1/credits/consume', input);
@@ -1039,12 +1228,12 @@ class CreditsClient {
1039
1228
  *
1040
1229
  * This is deliberately `createRequire` and not a bare `require`. The package is
1041
1230
  * `"type": "module"` with ESM-only `exports`, so in the built `dist` a bare
1042
- * `require` is simply not defined `verifyWebhookSignature` and the RS256 path
1231
+ * `require` is simply not defined, `verifyWebhookSignature` and the RS256 path
1043
1232
  * of `verifyAccessToken` threw `ReferenceError: require is not defined` for
1044
1233
  * every consumer who installed from npm, in every published version.
1045
1234
  *
1046
1235
  * It went unnoticed because `node -e` defines `globalThis.require`, so the
1047
- * failure does not reproduce in a one-liner only in a real `.mjs`, `.cjs`, or
1236
+ * failure does not reproduce in a one-liner, only in a real `.mjs`, `.cjs`, or
1048
1237
  * `"type": "module"` package, which is to say only in real use. The tests
1049
1238
  * exercise the TypeScript source rather than the built ESM artifact, so they
1050
1239
  * never saw it either.
@@ -1055,7 +1244,7 @@ class CreditsClient {
1055
1244
  */
1056
1245
  function nodeCrypto() {
1057
1246
  // `process.getBuiltinModule` (Node 22.3+, and this package's floor is 22)
1058
- // resolves a builtin synchronously with NO static import which is the
1247
+ // resolves a builtin synchronously with NO static import, which is the
1059
1248
  // whole point. The previous fix used `createRequire`, correct for CJS
1060
1249
  // interop but imported from 'node:module' at module scope, so merely
1061
1250
  // IMPORTING the package failed on edge runtimes with
@@ -1078,7 +1267,7 @@ function nodeCrypto() {
1078
1267
  * default 300) AND (b) the signature matches a constant-time compare.
1079
1268
  *
1080
1269
  * Use against the `X-Rekey-Signature` header and the raw request body
1081
- * BYTES (not the parsed JSON any reserialization breaks the HMAC).
1270
+ * BYTES (not the parsed JSON, any reserialization breaks the HMAC).
1082
1271
  *
1083
1272
  * @example
1084
1273
  * ```ts
@@ -1123,7 +1312,7 @@ export function verifyWebhookSignature(args) {
1123
1312
  return timingSafeEqual(a, b);
1124
1313
  }
1125
1314
  const jwksCache = new Map();
1126
- /** @internal Test hook drop cached JWKS responses. */
1315
+ /** @internal Test hook, drop cached JWKS responses. */
1127
1316
  export function _clearJwksCacheForTests() {
1128
1317
  jwksCache.clear();
1129
1318
  }
@@ -1187,14 +1376,14 @@ async function loadJwks(options, forceRefetch) {
1187
1376
  return jwks;
1188
1377
  }
1189
1378
  /**
1190
- * Verify an end-user ACCESS token **offline** no round-trip to the Rekey
1379
+ * Verify an end-user ACCESS token **offline**, no round-trip to the Rekey
1191
1380
  * API. Works only for Applications that opted into RS256 tokens
1192
1381
  * (`authConfig.tokenAlg = "RS256"`, Panel → Application → Auth); the default
1193
1382
  * HS256 tokens are symmetric and can only be verified by the API itself
1194
1383
  * (use `rekey.auth.getCurrentUser(token)` for those).
1195
1384
  *
1196
1385
  * Checks performed (same posture as the API's verifier):
1197
- * - header `alg` must be `RS256` and `kid` must exist in the JWKS
1386
+ * - header `alg` must be `RS256` and `kid` must exist in the JWKS,
1198
1387
  * a strict allowlist, immune to alg-confusion;
1199
1388
  * - RSA-SHA256 signature against that public key;
1200
1389
  * - `exp` in the future, `typ === "eu_access"` (refresh/MFA/MCP tokens
@@ -1204,6 +1393,11 @@ async function loadJwks(options, forceRefetch) {
1204
1393
  * user deletion. The 15-minute access lifetime bounds both; for hard
1205
1394
  * revocation guarantees keep using `auth.getCurrentUser`.
1206
1395
  *
1396
+ * Nor can it see any server-side revocation: a locally verified token stays
1397
+ * valid until it expires, even after sign-out everywhere, a password change,
1398
+ * a session revoke or a device release. Call the API when immediate
1399
+ * revocation matters.
1400
+ *
1207
1401
  * Node-only (uses `node:crypto`). Returns the verified claims; throws
1208
1402
  * `RekeyError` on any failure.
1209
1403
  *
@@ -1226,10 +1420,10 @@ async function loadJwks(options, forceRefetch) {
1226
1420
  * it moved inside: the shortest correct path should not be the one nobody
1227
1421
  * takes.
1228
1422
  *
1229
- * @throws {RekeyError} `TOKEN_ALG_NOT_RS256` token is HS256 (app hasn't opted in) or another alg.
1230
- * @throws {RekeyError} `TOKEN_KID_UNKNOWN` `kid` not in the JWKS (forged, or key deleted).
1231
- * @throws {RekeyError} `USER_TOKEN_EXPIRED` `exp` passed; refresh the session.
1232
- * @throws {RekeyError} `USER_TOKEN_INVALID` malformed, bad signature, or wrong `typ`.
1423
+ * @throws {RekeyError} `TOKEN_ALG_NOT_RS256`, token is HS256 (app hasn't opted in) or another alg.
1424
+ * @throws {RekeyError} `TOKEN_KID_UNKNOWN`, `kid` not in the JWKS (forged, or key deleted).
1425
+ * @throws {RekeyError} `USER_TOKEN_EXPIRED`, `exp` passed; refresh the session.
1426
+ * @throws {RekeyError} `USER_TOKEN_INVALID`, malformed, bad signature, or wrong `typ`.
1233
1427
  */
1234
1428
  export async function verifyAccessToken(token, options) {
1235
1429
  const invalid = (message) => new RekeyError({
@@ -1251,7 +1445,7 @@ export async function verifyAccessToken(token, options) {
1251
1445
  catch {
1252
1446
  throw invalid('The token header/payload is not valid base64url JSON.');
1253
1447
  }
1254
- // Strict alg allowlist this helper verifies RS256 ONLY. HS256 tokens are
1448
+ // Strict alg allowlist, this helper verifies RS256 ONLY. HS256 tokens are
1255
1449
  // symmetric (the verifying key can also MINT tokens), so they are never
1256
1450
  // verified client-side.
1257
1451
  if (header.alg !== 'RS256') {
@@ -1281,10 +1475,10 @@ export async function verifyAccessToken(token, options) {
1281
1475
  statusCode: 401,
1282
1476
  });
1283
1477
  }
1284
- // Lazy-load node:crypto (same posture as verifyWebhookSignature) keeps
1478
+ // Lazy-load node:crypto (same posture as verifyWebhookSignature), keeps
1285
1479
  // the import graph clean for bundlers that tree-shake this helper away.
1286
1480
  const { createPublicKey, verify } = nodeCrypto();
1287
- let signatureOk = false;
1481
+ let signatureOk;
1288
1482
  try {
1289
1483
  const publicKey = createPublicKey({ key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, format: 'jwk' });
1290
1484
  signatureOk = verify('sha256', Buffer.from(`${parts[0]}.${parts[1]}`, 'utf8'), publicKey, Buffer.from(parts[2], 'base64url'));
@@ -1294,7 +1488,7 @@ export async function verifyAccessToken(token, options) {
1294
1488
  }
1295
1489
  if (!signatureOk)
1296
1490
  throw invalid('The token signature does not verify against the JWKS key.');
1297
- // Claims mirror the API's verifier: typ is load-bearing, exp is enforced.
1491
+ // Claims, mirror the API's verifier: typ is load-bearing, exp is enforced.
1298
1492
  if (payload.typ !== 'eu_access') {
1299
1493
  throw invalid(`Token typ is ${JSON.stringify(payload.typ)}, expected "eu_access".`);
1300
1494
  }
@@ -1303,7 +1497,7 @@ export async function verifyAccessToken(token, options) {
1303
1497
  }
1304
1498
  // Bind the token to ONE Application. The signing key is deployment-wide and
1305
1499
  // `eu_access` carries no `iss`/`aud`, so a token minted for a different
1306
- // Application on the same deployment is cryptographically valid here on a
1500
+ // Application on the same deployment is cryptographically valid here, on a
1307
1501
  // multi-app self-host that means accepting someone else's end-user as your
1308
1502
  // own. The API does compare this server-side; the SDK left it to the caller
1309
1503
  // and documented it as a follow-up step, which made the shortest correct
@@ -1328,11 +1522,11 @@ class BillingClient {
1328
1522
  this.client = client;
1329
1523
  }
1330
1524
  /**
1331
- * List the calling Application's active plans. Public pricing pages
1525
+ * List the calling Application's active plans. Public, pricing pages
1332
1526
  * typically render straight from this. Application API key only; no
1333
1527
  * user JWT needed.
1334
1528
  *
1335
- * `amount` is in the smallest currency unit (cents/paise/sen) never
1529
+ * `amount` is in the smallest currency unit (cents/paise/sen), never
1336
1530
  * a float. Format on display: `${amount / 100} ${currency}`.
1337
1531
  */
1338
1532
  getPlans(page) {
@@ -1345,7 +1539,7 @@ class BillingClient {
1345
1539
  * Pass the user's access token (the SDK puts it in `X-Rekey-User-Token`).
1346
1540
  *
1347
1541
  * `opts.includeEnded` falls back to the most recent CANCELED/EXPIRED
1348
- * subscription **only when the answer would otherwise be null** for a
1542
+ * subscription **only when the answer would otherwise be null**, for a
1349
1543
  * billing page that has to tell a former subscriber what they were on and
1350
1544
  * when it ended, rather than showing them the same blank state as somebody
1351
1545
  * who never subscribed. It can never replace a live subscription, so it is
@@ -1370,13 +1564,22 @@ class BillingClient {
1370
1564
  /**
1371
1565
  * Start a hosted-checkout session. Returns the URL to redirect the user
1372
1566
  * to and the local PENDING Subscription row. Subscription activation
1373
- * happens via the provider's webhook not synchronously here.
1567
+ * happens via the provider's webhook, not synchronously here.
1374
1568
  *
1375
1569
  * Pass `couponCode` to apply a discount. The whole checkout fails if the
1376
1570
  * coupon doesn't validate (typed `RekeyError` with the precise reason).
1377
1571
  *
1572
+ * A buyer who has already used their free trial is refused with
1573
+ * `BILLING_TRIAL_ALREADY_USED` (409). The escape hatch is
1574
+ * `allowWithoutTrial: true` AND a fresh `Idempotency-Key`, but send it only
1575
+ * after the buyer has been told they are paying today. Read
1576
+ * {@link getTrialEligibility} and render the paid price instead of retrying
1577
+ * blindly: a buyer who merely abandoned a trial checkout still reads
1578
+ * `eligible: true`, and acknowledging on their behalf charges them today for
1579
+ * the trial the next checkout was about to grant.
1580
+ *
1378
1581
  * If the Application's billing subject is **org** (Panel → Application →
1379
- * Billing → Subject), an individual can't hold a subscription you MUST
1582
+ * Billing → Subject), an individual can't hold a subscription, you MUST
1380
1583
  * pass `organizationId` of a team the user owns/admins. Omitting it throws
1381
1584
  * `RekeyError` `code: "BILLING_ORGANIZATION_REQUIRED"`.
1382
1585
  *
@@ -1396,6 +1599,83 @@ class BillingClient {
1396
1599
  'X-Rekey-User-Token': accessToken,
1397
1600
  });
1398
1601
  }
1602
+ /**
1603
+ * Put the calling end-user on the Application's free tier
1604
+ * (`billingConfig.defaultPlanSlug`). No payment provider is involved and none
1605
+ * needs to be configured: the plan costs nothing.
1606
+ *
1607
+ * This is how a freemium product hands a new signup their included credits,
1608
+ * licence or quota. `defaultPlanSlug` alone covers only the read-time half
1609
+ * (feature flags and included usage); CREDIT and LICENSE entitlements are
1610
+ * stateful and need a real subscription, which is what this creates.
1611
+ *
1612
+ * **Idempotent**, and the answer says which happened: `activated: true` is a
1613
+ * first activation (201, `subscription.activated` emitted), `activated:
1614
+ * false` means they were already entitled and nothing was written,
1615
+ * re-provisioned or re-announced.
1616
+ *
1617
+ * Pass `organizationId` on an org-billed Application; the caller must be an
1618
+ * OWNER or ADMIN of it. Omit it and the session's active organization is used.
1619
+ *
1620
+ * @throws {RekeyError} `BILLING_NO_FREE_PLAN` (404) when the Application
1621
+ * nominates no default plan; `BILLING_FREE_PLAN_NOT_FREE` (409) when that
1622
+ * plan charges money, use {@link createCheckout} instead;
1623
+ * `BILLING_FREE_TIER_ALREADY_CLAIMED` (409) when the plan grants credits or a
1624
+ * licence and this caller already claimed it for a different beneficiary.
1625
+ *
1626
+ * @example
1627
+ * ```ts
1628
+ * const { subscription, activated } = await rekey.billing.subscribe(accessToken);
1629
+ * if (activated) welcomeWithStarterCredits(subscription);
1630
+ * ```
1631
+ */
1632
+ async subscribe(accessToken, input = {}) {
1633
+ const { data, status } = await this.client.sendWithStatus('POST', '/api/v1/billing/subscribe', { ...(input.organizationId !== undefined && { organizationId: input.organizationId }) }, { 'X-Rekey-User-Token': accessToken });
1634
+ // 201 = activated now, 200 = already on it. The body is the same row in
1635
+ // both cases, so the status is the only place this distinction lives.
1636
+ return { subscription: data, activated: status === 201 };
1637
+ }
1638
+ /**
1639
+ * Whether THIS buyer may start each plan's free trial, under the
1640
+ * Application's `trialPolicy`.
1641
+ *
1642
+ * Read this before offering a trial: a buyer who is not eligible should be
1643
+ * shown the paid price, not a trial that checkout refuses with
1644
+ * `BILLING_TRIAL_ALREADY_USED`. Feed the result straight into
1645
+ * `<PricingTable trialEligibility={…}>` from `@rekey.dev/react`.
1646
+ *
1647
+ * **Advisory.** The authoritative decision is taken under a lock at checkout,
1648
+ * so two tabs can both read `eligible: true` and only one gets the trial.
1649
+ * Treat a 409 at checkout as normal, not as a contradiction.
1650
+ *
1651
+ * **Provider-dependent.** `PLAN_TRIAL_MISCONFIGURED` can be the resolved
1652
+ * provider's answer, so the response echoes `provider`; re-read this when the
1653
+ * buyer changes processor. Pass `country` (ISO 3166-1 alpha-2) to steer the
1654
+ * geo router the way {@link getProviders} does.
1655
+ *
1656
+ * @example
1657
+ * ```ts
1658
+ * const { items, policy } = await rekey.billing.getTrialEligibility(accessToken);
1659
+ * const pro = items.find((i) => i.planSlug === 'pro');
1660
+ * const label = pro?.eligible ? `Start ${pro.trialDays} days free` : 'Subscribe';
1661
+ * ```
1662
+ */
1663
+ getTrialEligibility(accessToken, opts) {
1664
+ const q = new URLSearchParams();
1665
+ if (opts?.organizationId)
1666
+ q.set('organizationId', opts.organizationId);
1667
+ if (opts?.planSlug)
1668
+ q.set('planSlug', opts.planSlug);
1669
+ if (opts?.limit !== undefined)
1670
+ q.set('limit', String(opts.limit));
1671
+ if (opts?.offset !== undefined)
1672
+ q.set('offset', String(opts.offset));
1673
+ const query = q.toString();
1674
+ return this.client.send('GET', `/api/v1/billing/trial-eligibility${query ? `?${query}` : ''}`, undefined, {
1675
+ 'X-Rekey-User-Token': accessToken,
1676
+ ...(opts?.country ? { 'x-country': opts.country.toUpperCase() } : {}),
1677
+ });
1678
+ }
1399
1679
  /**
1400
1680
  * Validate a coupon for the current user against a plan, *without*
1401
1681
  * applying it. Render "$50 off" on a pricing page before submit.
@@ -1413,7 +1693,7 @@ class BillingClient {
1413
1693
  /**
1414
1694
  * List the billing providers configured + enabled for this Application,
1415
1695
  * in the order the geo router would prefer them. Forward the end-user's
1416
- * `country` (ISO 3166-1 alpha-2) when you have it the panel/SDK will
1696
+ * `country` (ISO 3166-1 alpha-2) when you have it, the panel/SDK will
1417
1697
  * surface India-specific providers (Razorpay) for IN-country users, etc.
1418
1698
  *
1419
1699
  * Returns the resolved country (echoed back from the server's view of
@@ -1427,7 +1707,7 @@ class BillingClient {
1427
1707
  return this.client.send('GET', '/api/v1/billing/providers', undefined, headers);
1428
1708
  }
1429
1709
  /**
1430
- * Resolve the calling end-user's current entitlements feature flags +
1710
+ * Resolve the calling end-user's current entitlements, feature flags +
1431
1711
  * limits, the live credit balance, and the raw entitlement list, unioned
1432
1712
  * across their active subscriptions (and subscriptions of orgs they belong
1433
1713
  * to). Pass `{ organizationId }` (member-only) for that org's view + shared
@@ -1447,10 +1727,27 @@ class BillingClient {
1447
1727
  'X-Rekey-User-Token': accessToken,
1448
1728
  });
1449
1729
  }
1730
+ /**
1731
+ * The same union as `getEntitlements`, for an end-user you name rather than
1732
+ * one whose token you hold. Secret key only, for a licence server, a
1733
+ * support tool or a batch job.
1734
+ *
1735
+ * @example
1736
+ * ```ts
1737
+ * const { features } = await rekey.billing.getEntitlementsFor(endUserId);
1738
+ * if (features.max_devices !== undefined) capDevices(features.max_devices);
1739
+ * ```
1740
+ */
1741
+ getEntitlementsFor(endUserId, opts) {
1742
+ const q = new URLSearchParams({ endUserId });
1743
+ if (opts?.organizationId)
1744
+ q.set('organizationId', opts.organizationId);
1745
+ return this.client.send('GET', `/api/v1/billing/entitlements/for-user?${q.toString()}`);
1746
+ }
1450
1747
  /**
1451
1748
  * Cancel the calling end-user's current subscription.
1452
1749
  *
1453
- * Defaults to cancelling **at period end** the user keeps what they paid
1750
+ * Defaults to cancelling **at period end**, the user keeps what they paid
1454
1751
  * for until the period they already bought runs out. A provider-backed
1455
1752
  * subscription therefore stays ACTIVE with `cancelAt` set, and the provider
1456
1753
  * webhook is what eventually terminates it; read `cancelAt` on the returned
@@ -1458,7 +1755,7 @@ class BillingClient {
1458
1755
  * `{ atPeriodEnd: false }` to end it immediately, forfeiting the remainder.
1459
1756
  *
1460
1757
  * PENDING checkouts (and anything with no provider-side record) are
1461
- * cancelled locally straight away regardless of the flag there is nothing
1758
+ * cancelled locally straight away regardless of the flag, there is nothing
1462
1759
  * at the provider to schedule against.
1463
1760
  *
1464
1761
  * Pass `organizationId` when the subscription belongs to a team; the caller