@cboxdk/id-js 0.17.0 → 0.19.0

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.
Files changed (40) hide show
  1. package/README.md +315 -3
  2. package/dist/{chunk-WYTGLCMF.js → chunk-2E25IPBA.js} +4 -3
  3. package/dist/chunk-2E25IPBA.js.map +1 -0
  4. package/dist/chunk-QQW63PGU.js +25 -0
  5. package/dist/chunk-QQW63PGU.js.map +1 -0
  6. package/dist/{chunk-ASOQN47M.js → chunk-SEUD4TE4.js} +3 -23
  7. package/dist/chunk-SEUD4TE4.js.map +1 -0
  8. package/dist/{chunk-G3CD7X4U.js → chunk-YGLMLIKL.js} +197 -9
  9. package/dist/chunk-YGLMLIKL.js.map +1 -0
  10. package/dist/{client-CdwdigzG.d.ts → client-BT-jOm0p.d.cts} +154 -2
  11. package/dist/{client-CEXddiCR.d.cts → client-xlEJalu8.d.ts} +154 -2
  12. package/dist/errors-D61xljZE.d.cts +87 -0
  13. package/dist/errors-D61xljZE.d.ts +87 -0
  14. package/dist/index.cjs +267 -17
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +84 -92
  17. package/dist/index.d.ts +84 -92
  18. package/dist/index.js +66 -13
  19. package/dist/index.js.map +1 -1
  20. package/dist/management.cjs +5092 -0
  21. package/dist/management.cjs.map +1 -0
  22. package/dist/management.d.cts +13454 -0
  23. package/dist/management.d.ts +13454 -0
  24. package/dist/management.js +5054 -0
  25. package/dist/management.js.map +1 -0
  26. package/dist/nextjs.cjs +136 -5
  27. package/dist/nextjs.cjs.map +1 -1
  28. package/dist/nextjs.d.cts +3 -2
  29. package/dist/nextjs.d.ts +3 -2
  30. package/dist/nextjs.js +4 -3
  31. package/dist/nextjs.js.map +1 -1
  32. package/dist/server.d.cts +1 -1
  33. package/dist/server.d.ts +1 -1
  34. package/dist/server.js +3 -2
  35. package/dist/{types-BYZldock.d.cts → types-CC1bz68x.d.cts} +30 -1
  36. package/dist/{types-BYZldock.d.ts → types-CC1bz68x.d.ts} +30 -1
  37. package/package.json +9 -2
  38. package/dist/chunk-ASOQN47M.js.map +0 -1
  39. package/dist/chunk-G3CD7X4U.js.map +0 -1
  40. package/dist/chunk-WYTGLCMF.js.map +0 -1
package/README.md CHANGED
@@ -196,6 +196,29 @@ if (organization(user)?.role === 'owner') showBilling();
196
196
  Matching is exact — `invoices:*` does not grant `invoices:delete`. An `org_role` this SDK
197
197
  version does not recognise reads as `null`, never as a tier it would have to guess.
198
198
 
199
+ ### Feature flags
200
+
201
+ Request the `feature_flags` scope (and give the app that scope on its Scopes tab) and the
202
+ tokens and UserInfo carry the keys of every flag that is on for the person, in the
203
+ organization they signed in to:
204
+
205
+ ```ts
206
+ import { CboxIdClient, FEATURE_FLAGS_SCOPE, hasFeature } from '@cboxdk/id-js';
207
+
208
+ const client = new CboxIdClient({ ...config, scopes: ['openid', 'profile', 'email', FEATURE_FLAGS_SCOPE] });
209
+
210
+ user.featureFlags; // ['acme-beta', 'new-dashboard'] | null
211
+ if (hasFeature(user, 'new-dashboard')) showNewDashboard();
212
+ hasFeature(accessTokenPayload, 'billing.v2'); // works on a verified claim set too
213
+ ```
214
+
215
+ `null` means the claim is absent — the scope was not requested — and `[]` means nothing is
216
+ on. Either way `hasFeature()` is `false`: a missing scope turns every feature off, never on.
217
+ A token carries the flags as they were when it was issued; the next refresh picks up a
218
+ change. To ask without a token in hand (a job, a webhook handler), use
219
+ `env.featureFlags.evaluate({ user_id, organization_id })` from the
220
+ [Management API](#management-api).
221
+
199
222
  ### Support sessions
200
223
 
201
224
  A staff member can act as one of your users for a limited time (at most an hour, no refresh
@@ -266,7 +289,10 @@ const frontend = new CboxIdFrontend({
266
289
  })
267
290
 
268
291
  const config = await frontend.config()
269
- // → endpoints, social buttons, and the customer's theme
292
+ // → endpoints, social buttons, which methods are on, and the customer's theme
293
+
294
+ // The buttons one organization's hosted page shows (by id or slug):
295
+ const acme = await frontend.config({ organization: 'acme' })
270
296
 
271
297
  const { user } = await frontend.session(accessToken)
272
298
  // → { id, email, name } or null
@@ -355,6 +381,22 @@ if (result.status === 'mfa_required' || result.status === 'otp_required') {
355
381
  }
356
382
  ```
357
383
 
384
+ `mfa_required` also lists the `factors` the person can answer with — `'totp'`, `'sms'`,
385
+ `'recovery_code'` (kinds only, never the number; absent from instances older than
386
+ laravel-id 1.24). For a texted code, send it when the person asks, then submit it as
387
+ `'sms'`:
388
+
389
+ ```ts
390
+ if (result.status === 'mfa_required' && result.factors?.includes('sms')) {
391
+ // On "Text me a code" — never on page load: each send spends one of the token's five attempts.
392
+ const sent = await frontend.sendSmsCode(result.mfaToken)
393
+ if (sent.status === 'sent') showCodeField(`We texted ${sent.to}`) // '+45 ******78'
394
+ if (sent.status === 'rate_limited') showRetry(sent.retryAfter)
395
+ // later:
396
+ const done = await frontend.submitSecondFactor(result.mfaToken, code, 'sms')
397
+ }
398
+ ```
399
+
358
400
  ### Passkeys
359
401
 
360
402
  ```ts
@@ -506,6 +548,26 @@ const ok = await verifyWebhook({
506
548
  });
507
549
  ```
508
550
 
551
+ ### Standard Webhooks
552
+
553
+ An endpoint registered with `signature_scheme: 'standard_webhooks'` (or switched with
554
+ `env.webhooks.signatureScheme.change(id, { signature_scheme: 'standard_webhooks' })`) is
555
+ signed the [Standard Webhooks](https://www.standardwebhooks.com/) way: `webhook-id`,
556
+ `webhook-timestamp` and `webhook-signature`. Verify it with `verifyStandardWebhook`:
557
+
558
+ ```ts
559
+ import { verifyStandardWebhook } from '@cboxdk/id-js';
560
+
561
+ const ok = await verifyStandardWebhook({
562
+ payload: rawBody, // the exact bytes received
563
+ headers: request.headers, // a Headers object or a plain record
564
+ secret: process.env.CBOX_ID_WEBHOOK_SECRET!, // whsec_…, or the endpoint's old 64-hex secret
565
+ });
566
+ ```
567
+
568
+ It accepts any valid `v1` signature among several (sent while a secret rotates), uses the same
569
+ 5-minute freshness window, and never throws.
570
+
509
571
  ## Token Vault
510
572
 
511
573
  Broker downstream credentials (API keys for OpenAI, GitHub, …) through the instance's
@@ -526,6 +588,33 @@ const lease = await agent.lease(secret.id, 'call openai');
526
588
 
527
589
  A lease with no live grant is refused — the vault is deny-by-default.
528
590
 
591
+ ### Pipes: a person's own GitHub, Google, Slack… token
592
+
593
+ When a person has connected their account at a provider, lease a fresh access token for it
594
+ with a `vault.lease` token. Cbox ID refreshes it first when it is about to expire:
595
+
596
+ ```ts
597
+ import { PipeNotConnectedError, PipeReauthorizationRequiredError, PipeTemporarilyUnavailableError } from '@cboxdk/id-js';
598
+
599
+ const vault = client.vault(await client.machineToken({ scopes: ['vault.lease'] }));
600
+
601
+ try {
602
+ const { accessToken } = await vault.leasePipeToken('github', { userId, purpose: 'list-repos' });
603
+ // call https://api.github.com/user/repos with it, then drop it
604
+ } catch (e) {
605
+ if (e instanceof PipeNotConnectedError || e instanceof PipeReauthorizationRequiredError) {
606
+ return redirect(e.connectUrlWith({ clientId, returnTo: 'https://app.acme.com/settings' }));
607
+ }
608
+ if (e instanceof PipeTemporarilyUnavailableError) return retryLater(e.retryAfter);
609
+ throw e; // PipeLeaseDeniedError: the app is not granted this pipe
610
+ }
611
+ ```
612
+
613
+ With a token issued for the person (they signed in to your app), leave `userId` out. To
614
+ send somebody to connect before any lease, `client.pipeConnectUrl('github', returnTo)` is
615
+ the hosted connect page, preselected to your app; they come back with
616
+ `?provider=github&status=connected` (or `cancelled`, `failed`).
617
+
529
618
  ## Roles & permissions (federated RBAC)
530
619
 
531
620
  Declare your app's authorization **roles** and **permissions** in code, then push them
@@ -600,6 +689,228 @@ the manifest to `{issuer}/api/v1/apps/manifest`. It is a server-side operation
600
689
  your `clientSecret` off the browser. The wire format matches the PHP SDK
601
690
  (`cboxdk/laravel-id-client`), so any SDK can publish the same catalog.
602
691
 
692
+ ## Management API
693
+
694
+ `@cboxdk/id-js/management` is a typed client for Cbox ID's management planes. It is
695
+ generated from the OpenAPI documents the server publishes, so every method, path, scope and
696
+ type matches the server you generated it from. Use it from server code only: every client
697
+ holds a management credential.
698
+
699
+ | Client | Plane | Credential | `baseUrl` |
700
+ | --- | --- | --- | --- |
701
+ | `EnvironmentClient` | One environment's tenancy: organizations, users, apps, roles, SSO, audit logs… | `cbid_env_…` key, or a delegated access token | The environment's own host, or the platform root with `environment` |
702
+ | `WorkspaceClient` | The workspace above its environments: projects, environments, team, keys | `cbid_ws_…` key, or a person's root token | `https://api.cboxid.com` (default) |
703
+ | `PlatformClient` | The deployment itself, for operators | Delegated operator token only | `https://api.cboxid.com` (default) |
704
+ | `AccountClient` | A person's own account | Delegated token only | The environment's own host |
705
+
706
+ Method names are the server's action names: `apps.secrets.rotate` is
707
+ `env.apps.secrets.rotate(...)`, and `sso.connections.require_sso` is
708
+ `env.sso.connections.requireSso(...)`. Path parameters come first, in path order, then the body
709
+ (or the query, for a read), then per-call options. Every call resolves to
710
+ `{ data, meta, body, status, replayed, idempotencyKey, requestId, headers }`.
711
+
712
+ ### Create an app and rotate its secret
713
+
714
+ ```ts
715
+ import { EnvironmentClient, CboxIdApiError, ApprovalDeniedError } from '@cboxdk/id-js/management';
716
+
717
+ const env = new EnvironmentClient({
718
+ baseUrl: 'https://acme.cboxid.com',
719
+ apiKey: process.env.CBOX_ID_ENV_KEY!, // cbid_env_…
720
+ onApprovalRequired: (approval, { action }) => {
721
+ console.log(`Approve ${action} on your device. Code: ${approval.binding_code}`);
722
+ },
723
+ });
724
+
725
+ const { data: app } = await env.apps.create({
726
+ name: 'Billing',
727
+ type: 'web',
728
+ redirect_uris: ['https://billing.acme.com/callback'],
729
+ });
730
+ // app.client_secret is in this response and in no other. Store it now.
731
+
732
+ try {
733
+ const { data: secret } = await env.apps.secrets.rotate(app.id, { grace_seconds: 3600 });
734
+ // secret.client_secret: the new secret, shown once.
735
+ } catch (e) {
736
+ if (e instanceof ApprovalDeniedError) console.log('Rotation was declined.');
737
+ else if (e instanceof CboxIdApiError && e.isValidationError) console.log(e.errors);
738
+ else throw e;
739
+ }
740
+ ```
741
+
742
+ When a key's policy holds an action for a person's approval, the server answers
743
+ `202 approval_required`. By default the client calls `onApprovalRequired` (show the
744
+ `binding_code` so the person can match it on their device), polls the approval, and repeats the
745
+ request with `Cbox-Approval: <id>` and the same `Idempotency-Key` once it is approved.
746
+ `ApprovalDeniedError` and `ApprovalExpiredError` are thrown when it is denied or expires. If you
747
+ do not want to wait in the same call, pass `{ approval: 'return' }`:
748
+
749
+ ```ts
750
+ const outcome = await env.apps.secrets.rotate(app.id, { grace_seconds: 0 }, { approval: 'return' });
751
+ if (outcome.pending) {
752
+ console.log(`Code: ${outcome.approval.binding_code}`);
753
+ const { data } = await outcome.resume(); // polls, then repeats the request
754
+ }
755
+ ```
756
+
757
+ ### Bootstrap an environment from a workspace key
758
+
759
+ ```ts
760
+ import { WorkspaceClient, EnvironmentClient } from '@cboxdk/id-js/management';
761
+
762
+ const workspace = new WorkspaceClient({ apiKey: process.env.CBOX_ID_WS_KEY! }); // cbid_ws_…
763
+
764
+ const { data: created } = await workspace.environments.create({
765
+ name: 'Staging',
766
+ type: 'sandbox',
767
+ initial_key: { name: 'bootstrap', scopes: ['apps:write', 'organizations:write'] },
768
+ });
769
+
770
+ // The first management key, returned once. On an idempotent replay it is null.
771
+ const env = new EnvironmentClient({ baseUrl: created!.issuer!, apiKey: created!.initial_key!.token! });
772
+ await env.organizations.create({ name: 'Acme', slug: 'acme' });
773
+ ```
774
+
775
+ ### Idempotency, retries and errors
776
+
777
+ - Every `POST`, `PUT`, `PATCH` and `DELETE` sends an `Idempotency-Key`, a fresh UUID unless you
778
+ pass `{ idempotencyKey }`. Network failures, `5xx`, `429` and `409 idempotency_in_progress` are
779
+ retried with the **same** key (`retry: { maxRetries, baseDelayMs, maxDelayMs }`; default 3
780
+ retries). `Retry-After` is respected; one longer than `maxDelayMs` is thrown instead of waited
781
+ out. `replayed` is `true` when the server returned the first request's stored answer
782
+ (`Idempotent-Replayed`). A secret in a replayed answer is `null`.
783
+ - A failed call throws `CboxIdApiError` with `status`, `error` (the stable code), `message`,
784
+ `errors` (field-keyed, on `validation_failed`), `requestId` (the envelope's `request_id`,
785
+ else the `X-Request-Id` header; quote it when reporting a problem) and `retryAfter`. A call that never
786
+ got an answer throws `ManagementNetworkError`, which carries the `idempotencyKey` so you can
787
+ repeat the request safely.
788
+ - The client never logs. Secrets in responses are returned as they arrive, and request bodies
789
+ are never put in an error.
790
+
791
+ ### One token for every environment
792
+
793
+ A person's access token issued at the platform root reaches the workspace, account and
794
+ operator planes there, and any environment of their workspace when the request names it with
795
+ `Cbox-Environment`. Pass `environment` (an id or slug) and the root host as `baseUrl`:
796
+
797
+ ```ts
798
+ const staging = new EnvironmentClient({
799
+ baseUrl: 'https://api.cboxid.com',
800
+ accessToken: () => tokens.current(), // the person's root token
801
+ environment: 'acme-staging', // sent as Cbox-Environment on every request
802
+ });
803
+ await staging.organizations.portalLinks.create(orgId, { intents: ['sso', 'dsync'] });
804
+ ```
805
+
806
+ What the token may do there is bounded by the person's role and the token's scopes. A
807
+ `cbid_env_…` key is bound to its own environment's host, so `environment` is refused with one.
808
+
809
+ ### Lists
810
+
811
+ Every paged list also has an `…All` variant that iterates every item, fetching pages as you
812
+ reach them. It follows `meta.next_cursor` on the environment plane and `meta.next_page` on the
813
+ workspace plane:
814
+
815
+ ```ts
816
+ for await (const org of env.organizations.listAll({ status: 'active' })) {
817
+ console.log(org.id, org.name);
818
+ }
819
+ ```
820
+
821
+ ### Fine-grained authorization
822
+
823
+ `env.fga.*` writes relationship tuples and asks checks. Every write answers with a
824
+ `consistency_token`; pass it to a check that must see the write:
825
+
826
+ ```ts
827
+ import { fgaTuple } from '@cboxdk/id-js/management';
828
+
829
+ const { data: written } = await env.fga.tuples.write({
830
+ tuples: [{ resource_type: 'document', resource_id: 'leave', relation: 'viewer', subject: { type: 'user', id: 'alice' } }],
831
+ });
832
+
833
+ const { data } = await env.fga.check({
834
+ resource_type: 'document', resource_id: 'leave', relation: 'viewer',
835
+ subject_type: 'user', subject_id: 'alice',
836
+ consistency_token: written.consistency_token,
837
+ });
838
+
839
+ // Up to 100 at once, in the tuple notation, answered in order:
840
+ await env.fga.checkBatch({ checks: ['document:leave#viewer@user:alice', 'document:readme#editor@user:alice'] });
841
+ ```
842
+
843
+ `env.fga.tuples.delete()`, `env.fga.resources.list()` (which documents can alice view),
844
+ `env.fga.subjects.list()` (who can view this one), and `env.fga.schema.get()` /
845
+ `.update()` / `.validate()` complete it. `fgaTuple()` writes the notation from the same
846
+ object `tuples.write` takes.
847
+
848
+ ### Audit Logs
849
+
850
+ Your app records what its users did, per organization (your customer), and Cbox ID keeps
851
+ each organization's events in a tamper-evident hash chain. `AuditLogger` buffers events and
852
+ sends them in batches of up to 100, each batch under its own `Idempotency-Key`. It sends a
853
+ batch when it is full, every `flushIntervalMs` (default 5 s), and on `flush()` / `close()`. A
854
+ batch that fails stays queued with the same key, so sending it again never records an event
855
+ twice:
856
+
857
+ ```ts
858
+ import { AuditLogger, exportAuditLogs, verifyAuditLogChain } from '@cboxdk/id-js/management';
859
+
860
+ const audit = new AuditLogger(env, { onError: (e) => console.error('audit flush failed', e) });
861
+
862
+ audit.record({
863
+ organization_id: org.id,
864
+ action: 'invoice.voided',
865
+ actor: { id: user.id, type: 'user', name: user.name },
866
+ targets: [{ id: invoice.id, type: 'invoice' }],
867
+ context: { location: request.ip, user_agent: request.headers['user-agent'] },
868
+ metadata: { reason: 'duplicate' },
869
+ }); // occurred_at defaults to now
870
+
871
+ await audit.close(); // on shutdown: stop the timer and send what is left
872
+ ```
873
+
874
+ Read events with `env.auditLogs.events.listAll({ organization_id, actions, range_start })`.
875
+ To get a CSV, `exportAuditLogs(env, filters)` starts an export and polls it until it is ready.
876
+ Its `url` is signed and short-lived. To check the chain yourself instead of trusting the
877
+ server's `env.auditLogs.verify()`, call `verifyAuditLogChain(env, { organization_id })` or
878
+ `verifyAuditChain(events)`. They recompute `sha256(prev_hash + canonical JSON)` exactly as
879
+ the server does: keys sorted at every depth, and slashes and Unicode unescaped. They report
880
+ `{ valid, reason, broken_at_sequence, … }`. Keep floats out of metadata you want to verify:
881
+ `1.0` and `1` arrive as the same JavaScript number.
882
+
883
+ ### Delegated tokens and DPoP
884
+
885
+ Pass `accessToken` (a string, or a function that is called before every request so it can
886
+ refresh) instead of `apiKey`. For a DPoP-bound token, pass a signer built from the key pair the
887
+ token was bound to:
888
+
889
+ ```ts
890
+ import { AccountClient, createDPoPSigner } from '@cboxdk/id-js/management';
891
+
892
+ const me = new AccountClient({
893
+ baseUrl: 'https://acme.cboxid.com',
894
+ accessToken: () => tokens.current(),
895
+ dpop: await createDPoPSigner(keyPair), // ES256, WebCrypto
896
+ });
897
+ await me.sessions.revokeOthers();
898
+ ```
899
+
900
+ ### Types, metadata and regenerating
901
+
902
+ Schema and operation types are exported per plane as namespaces (`EnvironmentApi.App`,
903
+ `WorkspaceApi.EnvironmentsCreateBody`). The operation tables (`environmentOperations`, …) list
904
+ each action's method, path, scope, danger and whether it can be held for approval, which is
905
+ useful for showing a confirmation before a `critical` action. `env.request(method, path, input)`
906
+ calls a route that is not generated.
907
+
908
+ The specs are vendored in `openapi/`. `npm run generate` (Node 22.6 or later) regenerates
909
+ `src/management/generated/` from them, and
910
+ `npm run generate -- --fetch environment=https://acme.cboxid.com` (or `workspace=`, `platform=`,
911
+ `account=`, `all=`) refreshes a vendored spec from a running server first. The test suite fails
912
+ when the generated code and the vendored specs disagree.
913
+
603
914
  ## Security & scope
604
915
 
605
916
  Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verification
@@ -607,8 +918,9 @@ Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verif
607
918
  freshness window. Keep `clientSecret` and webhook secrets server-side.
608
919
 
609
920
  This is a **client**. It authenticates users and calls a Cbox ID instance's standard
610
- endpoints; it does not configure SSO, run SCIM, or manage organizations — those are
611
- platform capabilities of [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
921
+ endpoints. The management planes (organizations, apps, SSO, keys…) are reached through
922
+ [`@cboxdk/id-js/management`](#management-api), which only calls the instance's published API;
923
+ the capabilities themselves live in [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
612
924
 
613
925
  Report vulnerabilities via this repo's GitHub **Private Vulnerability Reporting**.
614
926
 
@@ -1,4 +1,5 @@
1
- import { ConfigurationError, assertSecureIssuer, AuthenticationError, oauthError } from './chunk-ASOQN47M.js';
1
+ import { assertSecureIssuer } from './chunk-QQW63PGU.js';
2
+ import { ConfigurationError, AuthenticationError, oauthError } from './chunk-SEUD4TE4.js';
2
3
 
3
4
  // src/api-keys.ts
4
5
  var MAX_API_KEY_CACHE_TTL_MS = 6e4;
@@ -150,5 +151,5 @@ async function digest(key) {
150
151
  }
151
152
 
152
153
  export { ApiKeyVerifier, MAX_API_KEY_CACHE_TTL_MS };
153
- //# sourceMappingURL=chunk-WYTGLCMF.js.map
154
- //# sourceMappingURL=chunk-WYTGLCMF.js.map
154
+ //# sourceMappingURL=chunk-2E25IPBA.js.map
155
+ //# sourceMappingURL=chunk-2E25IPBA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/api-keys.ts"],"names":[],"mappings":"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","file":"chunk-2E25IPBA.js","sourcesContent":["import type { OrganizationRole } from './claims.js';\nimport { AuthenticationError, ConfigurationError, oauthError } from './errors.js';\nimport { assertSecureIssuer } from './issuer.js';\n\n/**\n * A customer API key Cbox ID vouches for: live, bound to YOUR app, and held by an active\n * member of `org`.\n *\n * The field names are the endpoint's own, so the claim helpers read it like a token —\n * `hasPermission(answer, 'invoices:create')`, `organization(answer)?.role`. (A type\n * alias rather than an interface for that reason: only an alias is assignable to the\n * helpers' record type.)\n */\nexport type ActiveApiKey = {\n active: true;\n /** The key's id — log this, never the key. */\n key_id: string;\n /** The person who holds the key (their subject id). */\n sub: string;\n /** The organization the key acts in. */\n org: string;\n /** The holder's membership tier in `org`; null for a tier this SDK does not know. */\n org_role: OrganizationRole | null;\n /**\n * What the key may do, already re-capped by Cbox ID: the key's own list intersected\n * with what the holder holds for your app right now. A demoted holder's key loses the\n * permission on the next verification, not at the next reissue.\n */\n permissions: string[];\n /** Your app's client id — always, or the answer is refused. */\n client_id: string;\n /** ISO 8601 UTC expiry, or null for a key that does not expire. */\n expires_at: string | null;\n};\n\n/**\n * Every refusal: unknown, malformed, revoked, expired, another app's key, a holder who\n * left. Cbox ID never says which, so the endpoint cannot be used to probe keys — and\n * neither does this.\n */\nexport interface InactiveApiKey {\n active: false;\n}\n\n/** What {@link ApiKeyVerifier.verifyApiKey} returns. Narrow on `active`. */\nexport type ApiKeyVerification = ActiveApiKey | InactiveApiKey;\n\n/** Configuration for an {@link ApiKeyVerifier}. */\nexport interface ApiKeyVerifierConfig {\n /** Base URL (issuer) of the Cbox ID instance. */\n issuer: string;\n /** Your app's OAuth client id — the app the keys were issued for. */\n clientId: string;\n /** Your app's client secret. Verification is a confidential-client call. */\n clientSecret: string;\n /** Timeout (ms) for the verification request. Defaults to `10000`. */\n timeoutMs?: number;\n /**\n * Cache an ACTIVE answer for this long (ms), so a burst of requests with one key costs\n * one round-trip. Off (`0`) by default and at most {@link MAX_API_KEY_CACHE_TTL_MS}:\n * while an answer is cached, a revoked key keeps working, and a demoted holder keeps\n * the permissions they lost. Never cached past the key's `expires_at`. Refusals are\n * never cached, so a key issued a moment ago works on its first request.\n */\n cacheTtlMs?: number;\n}\n\n/** The longest {@link ApiKeyVerifierConfig.cacheTtlMs} allowed: one minute. */\nexport const MAX_API_KEY_CACHE_TTL_MS = 60_000;\n\n/** Enough for a busy API's working set; oldest entries go first beyond it. */\nconst MAX_CACHE_ENTRIES = 1_000;\n\nconst ORGANIZATION_ROLES: ReadonlySet<string> = new Set<OrganizationRole>([\n 'owner',\n 'admin',\n 'developer',\n 'member',\n 'viewer',\n]);\n\n/**\n * Verifies customer API keys presented to your API: `POST {issuer}/oauth/api-keys/verify`,\n * authenticated with your app's own client credentials.\n *\n * SERVER-SIDE ONLY. It holds your client secret, so it lives in the\n * `@cboxdk/id-js/server` entry and the Next.js adapter, never in the main entry that a\n * browser bundle pulls in — and it refuses to be constructed in a browser at all.\n *\n * ```ts\n * import { ApiKeyVerifier } from '@cboxdk/id-js/server';\n * import { hasPermission } from '@cboxdk/id-js';\n *\n * const keys = new ApiKeyVerifier({ issuer, clientId, clientSecret, cacheTtlMs: 10_000 });\n *\n * const answer = await keys.verifyApiKey(request.headers.get('x-api-key') ?? '');\n * if (!answer.active) return unauthorized();\n * if (!hasPermission(answer, 'invoices:create')) return forbidden();\n * ```\n */\nexport class ApiKeyVerifier {\n private readonly endpoint: string;\n private readonly timeoutMs: number;\n private readonly cacheTtlMs: number;\n private readonly cache = new Map<string, { answer: ActiveApiKey; until: number }>();\n\n constructor(private readonly config: ApiKeyVerifierConfig) {\n // A heuristic, and enough of one: a page has a `document`, and no server runtime\n // this SDK supports (Node, Deno, Bun, edge workers) defines one.\n if (typeof (globalThis as { document?: unknown }).document !== 'undefined') {\n throw new ConfigurationError(\n 'API key verification uses your client secret and must run on your server, never in a browser.',\n );\n }\n if (!config.issuer || !config.clientId || !config.clientSecret) {\n throw new ConfigurationError('Verifying API keys needs issuer, clientId and clientSecret.');\n }\n assertSecureIssuer(config.issuer);\n\n const cacheTtlMs = config.cacheTtlMs ?? 0;\n if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0 || cacheTtlMs > MAX_API_KEY_CACHE_TTL_MS) {\n throw new ConfigurationError(\n `cacheTtlMs must be between 0 and ${MAX_API_KEY_CACHE_TTL_MS}: a cached answer keeps a revoked key working for that long.`,\n );\n }\n\n this.endpoint = `${config.issuer.replace(/\\/$/, '')}/oauth/api-keys/verify`;\n this.timeoutMs = config.timeoutMs ?? 10_000;\n this.cacheTtlMs = cacheTtlMs;\n }\n\n /**\n * Ask Cbox ID whether `key` is good for this app, and what it may do.\n *\n * Resolves `{ active: false }` for every kind of bad key, without a request for an empty\n * one. Throws `AuthenticationError` when the call itself fails (wrong client\n * credentials, an unreachable instance) or when the answer cannot be trusted — one that\n * names another app's `client_id`, or does not have the promised shape. Both mean \"do\n * not let this request in\"; the difference is that a throw is something to look at.\n */\n async verifyApiKey(key: string): Promise<ApiKeyVerification> {\n if (typeof key !== 'string' || key.trim() === '') {\n return { active: false };\n }\n\n const cacheKey = this.cacheTtlMs > 0 ? await digest(key) : null;\n if (cacheKey !== null) {\n const hit = this.cache.get(cacheKey);\n if (hit && hit.until > Date.now()) {\n return hit.answer;\n }\n this.cache.delete(cacheKey);\n }\n\n const answer = this.parse(await this.request(key));\n\n if (cacheKey !== null && answer.active) {\n this.remember(cacheKey, answer);\n }\n\n return answer;\n }\n\n private async request(key: string): Promise<unknown> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeoutMs);\n\n let response: Response;\n try {\n response = await fetch(this.endpoint, {\n method: 'POST',\n headers: {\n authorization: `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,\n accept: 'application/json',\n 'content-type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({ key }),\n signal: controller.signal,\n });\n } catch (error) {\n throw new AuthenticationError(`API key verification request failed: ${String(error)}`);\n } finally {\n clearTimeout(timer);\n }\n\n if (!response.ok) {\n throw await oauthError(response, 'API key verification failed');\n }\n\n return response.json().catch(() => null);\n }\n\n private parse(body: unknown): ApiKeyVerification {\n if (typeof body !== 'object' || body === null) {\n throw new AuthenticationError('API key verification returned a body that is not a JSON object.');\n }\n\n const record = body as Record<string, unknown>;\n\n // Only a literal `true` lets anybody in.\n if (record['active'] !== true) {\n return { active: false };\n }\n\n const keyId = record['key_id'];\n const sub = record['sub'];\n const org = record['org'];\n const clientId = record['client_id'];\n const expiresAt = record['expires_at'];\n const permissions = record['permissions'];\n\n if (\n !nonEmpty(keyId) ||\n !nonEmpty(sub) ||\n !nonEmpty(org) ||\n !nonEmpty(clientId) ||\n !Array.isArray(permissions) ||\n (expiresAt !== null && expiresAt !== undefined && typeof expiresAt !== 'string')\n ) {\n throw new AuthenticationError('API key verification returned an active answer without the promised fields.');\n }\n\n // THE BINDING, CHECKED AGAIN HERE. Cbox ID only ever finds a key bound to the calling\n // client, so this cannot fire against a correct instance — which is exactly when a\n // check that it holds is worth having: a proxy, a misrouted base URL, or a shared\n // secret between two apps would otherwise let one app's customers into another's API.\n if (clientId !== this.config.clientId) {\n throw new AuthenticationError(\n `API key verification answered for client ${clientId}, not this app (${this.config.clientId}); refusing it.`,\n );\n }\n\n // Past its expiry by our clock is expired, whatever the instance thought a moment ago.\n if (typeof expiresAt === 'string') {\n const expiry = Date.parse(expiresAt);\n if (Number.isNaN(expiry) || expiry <= Date.now()) {\n return { active: false };\n }\n }\n\n const role = record['org_role'];\n\n return {\n active: true,\n key_id: keyId,\n sub,\n org,\n org_role: typeof role === 'string' && ORGANIZATION_ROLES.has(role) ? (role as OrganizationRole) : null,\n permissions: permissions.filter((value): value is string => nonEmpty(value)),\n client_id: clientId,\n expires_at: typeof expiresAt === 'string' ? expiresAt : null,\n };\n }\n\n private remember(cacheKey: string, answer: ActiveApiKey): void {\n const expiry = answer.expires_at === null ? Number.POSITIVE_INFINITY : Date.parse(answer.expires_at);\n const until = Math.min(Date.now() + this.cacheTtlMs, expiry);\n\n if (this.cache.size >= MAX_CACHE_ENTRIES) {\n const oldest = this.cache.keys().next();\n if (!oldest.done) {\n this.cache.delete(oldest.value);\n }\n }\n\n this.cache.set(cacheKey, { answer, until });\n }\n}\n\nfunction nonEmpty(value: unknown): value is string {\n return typeof value === 'string' && value !== '';\n}\n\n/** The cache is keyed by a hash, so live keys are not sitting in memory as plaintext. */\nasync function digest(key: string): Promise<string> {\n const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));\n return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n"]}
@@ -0,0 +1,25 @@
1
+ import { ConfigurationError } from './chunk-SEUD4TE4.js';
2
+
3
+ // src/issuer.ts
4
+ function assertSecureIssuer(issuer) {
5
+ let url;
6
+ try {
7
+ url = new URL(issuer);
8
+ } catch {
9
+ throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
10
+ }
11
+ if (url.protocol === "https:") {
12
+ return;
13
+ }
14
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
15
+ if (url.protocol === "http:" && loopback) {
16
+ return;
17
+ }
18
+ throw new ConfigurationError(
19
+ `Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
20
+ );
21
+ }
22
+
23
+ export { assertSecureIssuer };
24
+ //# sourceMappingURL=chunk-QQW63PGU.js.map
25
+ //# sourceMappingURL=chunk-QQW63PGU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/issuer.ts"],"names":[],"mappings":";;;AAaO,SAAS,mBAAmB,MAAA,EAAsB;AACvD,EAAA,IAAI,GAAA;AAEJ,EAAA,IAAI;AACF,IAAA,GAAA,GAAM,IAAI,IAAI,MAAM,CAAA;AAAA,EACtB,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,mBAAmB,6CAA6C,CAAA;AAAA,EAC5E;AAEA,EAAA,IAAI,GAAA,CAAI,aAAa,QAAA,EAAU;AAC7B,IAAA;AAAA,EACF;AAEA,EAAA,MAAM,QAAA,GAAW,GAAA,CAAI,QAAA,KAAa,WAAA,IAAe,GAAA,CAAI,QAAA,KAAa,WAAA,IAAe,GAAA,CAAI,QAAA,KAAa,OAAA,IAAW,GAAA,CAAI,QAAA,KAAa,KAAA;AAE9H,EAAA,IAAI,GAAA,CAAI,QAAA,KAAa,OAAA,IAAW,QAAA,EAAU;AACxC,IAAA;AAAA,EACF;AAEA,EAAA,MAAM,IAAI,kBAAA;AAAA,IACR,CAAA,6CAAA,EAAgD,GAAA,CAAI,QAAQ,CAAA,EAAA,EAAK,IAAI,QAAQ,CAAA,EAAA;AAAA,GAC/E;AACF","file":"chunk-QQW63PGU.js","sourcesContent":["import { ConfigurationError } from './errors.js';\n\n/**\n * An issuer must be HTTPS.\n *\n * Everything this SDK does with it carries a credential: the authorization code, the PKCE\n * verifier, the client secret, the refresh token. Over `http://` a network attacker reads\n * all of them — and, worse, replaces the discovery document and JWKS, at which point a\n * forged id_token verifies cleanly and the whole verification chain proves nothing.\n *\n * Loopback is allowed because a native app's own callback listener is loopback by\n * definition (RFC 8252) and a dev instance runs there.\n */\nexport function assertSecureIssuer(issuer: string): void {\n let url: URL;\n\n try {\n url = new URL(issuer);\n } catch {\n throw new ConfigurationError('Cbox ID config `issuer` is not a valid URL.');\n }\n\n if (url.protocol === 'https:') {\n return;\n }\n\n const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1';\n\n if (url.protocol === 'http:' && loopback) {\n return;\n }\n\n throw new ConfigurationError(\n `Cbox ID config \\`issuer\\` must be https (got ${url.protocol}//${url.hostname}).`,\n );\n}\n"]}
@@ -56,26 +56,6 @@ var FrontendApiError = class extends CboxIdError {
56
56
  retryAfter;
57
57
  };
58
58
 
59
- // src/issuer.ts
60
- function assertSecureIssuer(issuer) {
61
- let url;
62
- try {
63
- url = new URL(issuer);
64
- } catch {
65
- throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
66
- }
67
- if (url.protocol === "https:") {
68
- return;
69
- }
70
- const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
71
- if (url.protocol === "http:" && loopback) {
72
- return;
73
- }
74
- throw new ConfigurationError(
75
- `Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
76
- );
77
- }
78
-
79
- export { AuthenticationError, CboxIdError, ConfigurationError, FrontendApiError, InvalidStateError, assertSecureIssuer, oauthError };
80
- //# sourceMappingURL=chunk-ASOQN47M.js.map
81
- //# sourceMappingURL=chunk-ASOQN47M.js.map
59
+ export { AuthenticationError, CboxIdError, ConfigurationError, FrontendApiError, InvalidStateError, oauthError };
60
+ //# sourceMappingURL=chunk-SEUD4TE4.js.map
61
+ //# sourceMappingURL=chunk-SEUD4TE4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts"],"names":[],"mappings":";AAIO,IAAM,WAAA,GAAN,cAA0B,KAAA,CAAM;AAAA,EACrC,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,OAAO,GAAA,CAAA,MAAA,CAAW,IAAA;AAAA,EACzB;AACF;AAGO,IAAM,kBAAA,GAAN,cAAiC,WAAA,CAAY;AAAC;AAM9C,IAAM,iBAAA,GAAN,cAAgC,WAAA,CAAY;AAAC;AAa7C,IAAM,mBAAA,GAAN,cAAkC,WAAA,CAAY;AAAA,EACnD,WAAA,CACE,OAAA,EAES,KAAA,EAEA,gBAAA,EAEA,QAUA,UAAA,EACT;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAhBJ,IAAA,IAAA,CAAA,KAAA,GAAA,KAAA;AAEA,IAAA,IAAA,CAAA,gBAAA,GAAA,gBAAA;AAEA,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AAUA,IAAA,IAAA,CAAA,UAAA,GAAA,UAAA;AAAA,EAGX;AAAA,EAjBW,KAAA;AAAA,EAEA,gBAAA;AAAA,EAEA,MAAA;AAAA,EAUA,UAAA;AAAA;AAAA,EAMX,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AACF;AAUA,eAAsB,UAAA,CAAW,UAAoB,QAAA,EAAgD;AACnG,EAAA,MAAM,OAAO,MAAM,QAAA,CAAS,MAAK,CAAE,KAAA,CAAM,MAAM,EAAE,CAAA;AAEjD,EAAA,IAAI,IAAA;AACJ,EAAA,IAAI,WAAA;AAEJ,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,GAAkB,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AAEvC,IAAA,IAAI,OAAO,MAAA,KAAW,QAAA,IAAY,MAAA,KAAW,IAAA,EAAM;AACjD,MAAA,MAAM,EAAE,KAAA,EAAO,iBAAA,EAAmB,gBAAA,EAAiB,GAAI,MAAA;AACvD,MAAA,IAAA,GAAO,OAAO,KAAA,KAAU,QAAA,GAAW,KAAA,GAAQ,KAAA,CAAA;AAC3C,MAAA,WAAA,GAAc,OAAO,gBAAA,KAAqB,QAAA,GAAW,gBAAA,GAAmB,KAAA,CAAA;AAAA,IAC1E;AAAA,EACF,CAAA,CAAA,MAAQ;AAAA,EAER;AAOA,EAAA,MAAM,MAAA,GAAS,IAAA,IAAQ,CAAA,KAAA,EAAQ,QAAA,CAAS,MAAM,CAAA,CAAA;AAM9C,EAAA,MAAM,gBAAA,GAAmB,QAAA,CAAS,OAAA,CAAQ,GAAA,CAAI,aAAa,CAAA;AAC3D,EAAA,MAAM,UAAA,GAAa,gBAAA,KAAqB,IAAA,IAAQ,OAAA,CAAQ,IAAA,CAAK,gBAAA,CAAiB,IAAA,EAAM,CAAA,GAChF,MAAA,CAAO,gBAAA,CAAiB,IAAA,EAAM,CAAA,GAC9B,MAAA;AAEJ,EAAA,OAAO,IAAI,mBAAA,CAAoB,CAAA,EAAG,QAAQ,CAAA,EAAA,EAAK,MAAM,CAAA,CAAA,EAAI,IAAA,EAAM,WAAA,EAAa,QAAA,CAAS,MAAA,EAAQ,UAAU,CAAA;AACzG;AAiBO,IAAM,gBAAA,GAAN,cAA+B,WAAA,CAAY;AAAA,EAChD,WAAA,CACE,OAAA,EACS,IAAA,EACA,MAAA,EACA,UAAA,EACT;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAJJ,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AACA,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AACA,IAAA,IAAA,CAAA,UAAA,GAAA,UAAA;AAAA,EAGX;AAAA,EALW,IAAA;AAAA,EACA,MAAA;AAAA,EACA,UAAA;AAIb","file":"chunk-SEUD4TE4.js","sourcesContent":["/**\n * Base class for every error this SDK throws, so callers can `catch (e) { if (e\n * instanceof CboxIdError) ... }` in one branch.\n */\nexport class CboxIdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = new.target.name;\n }\n}\n\n/** The client is misconfigured (a required option is missing or empty). */\nexport class ConfigurationError extends CboxIdError {}\n\n/**\n * The login `state` did not match what was stored — the callback is forged or\n * stale. Treat as a fresh start, not an error to surface to the user.\n */\nexport class InvalidStateError extends CboxIdError {}\n\n/**\n * Login could not be completed: the provider returned an error, the callback was\n * missing a code, or a token / id_token failed verification.\n *\n * `error` carries the RFC 6749 §5.2 code the authorization server sent, when it sent one.\n * It used to be discarded at every back-channel boundary, leaving a single message string\n * for outcomes that need opposite responses: `invalid_grant` on a refresh means the\n * session is over and the person has to sign in again, while `temporarily_unavailable`\n * means try the same token in a moment. A caller reduced to matching on prose either\n * retries what can never succeed, or signs out somebody who did not need to be.\n */\nexport class AuthenticationError extends CboxIdError {\n constructor(\n message: string,\n /** RFC 6749 §5.2 error code, when the server sent a parseable one. */\n readonly error?: string,\n /** The server's `error_description`, verbatim. Not end-user copy. */\n readonly errorDescription?: string,\n /** HTTP status, for the cases where the body says nothing useful. */\n readonly status?: number,\n /**\n * Seconds to wait, off the `Retry-After` header — set only on a 429.\n *\n * The distinction it carries is the one a caller most needs and could least infer:\n * a 429 is the ONLY back-channel failure where the same request will succeed\n * unchanged if you simply wait. Every other one needs a different request, or a new\n * sign-in. Without this the SDK reported \"token refresh failed\" and a caller with a\n * retry loop hammered a limiter that was already telling it exactly how long to stop.\n */\n readonly retryAfter?: number,\n ) {\n super(message);\n }\n\n /** Whether waiting `retryAfter` seconds and repeating the same request is worth it. */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n}\n\n/**\n * Read an OAuth error out of a failed token-endpoint response.\n *\n * Best-effort by design: a 502 from a proxy is HTML, a captive portal is worse, and the\n * caller still needs an error rather than a parse exception. What it must never do is\n * invent a code — an absent or unparseable `error` stays undefined, so\n * `e.error === 'invalid_grant'` is true only because the server said so.\n */\nexport async function oauthError(response: Response, fallback: string): Promise<AuthenticationError> {\n const body = await response.text().catch(() => '');\n\n let code: string | undefined;\n let description: string | undefined;\n\n try {\n const parsed: unknown = JSON.parse(body);\n\n if (typeof parsed === 'object' && parsed !== null) {\n const { error, error_description: errorDescription } = parsed as Record<string, unknown>;\n code = typeof error === 'string' ? error : undefined;\n description = typeof errorDescription === 'string' ? errorDescription : undefined;\n }\n } catch {\n // Not JSON. The status and the truncated body below are all there is to report.\n }\n\n // THE BODY IS NEVER ECHOED. These calls carry `client_secret`, `code_verifier` and\n // refresh tokens in their REQUEST, and a debug proxy, a captive portal or an upstream\n // error page that reflects what it received puts those straight into a thrown message\n // — which production apps log. Only the server's own OAuth error code travels, and\n // failing that, the status alone.\n const detail = code ?? `HTTP ${response.status}`;\n\n // `Retry-After` is seconds here — Laravel's throttler writes the integer form. The\n // HTTP-date form is legal too and is deliberately NOT parsed: guessing at a clock\n // skew is worse than saying nothing, and `status === 429` still tells the caller to\n // back off.\n const retryAfterHeader = response.headers.get('retry-after');\n const retryAfter = retryAfterHeader !== null && /^\\d+$/.test(retryAfterHeader.trim())\n ? Number(retryAfterHeader.trim())\n : undefined;\n\n return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);\n}\n\n/**\n * The Frontend API refused, or could not be reached.\n *\n * Carries a `code` because the four ways this goes wrong need four different responses\n * from the caller, and telling them apart from a message string is the kind of thing\n * that works until somebody rewords the message:\n *\n * - `origin_not_allowed` — the overwhelmingly likely cause of a refusal, and a\n * configuration problem the developer fixes once in the console.\n * - `rate_limited` — back off; `retryAfter` says for how long, when the server said.\n * - `unavailable` — the network, or a 5xx. Transient by assumption; already retried.\n * - `malformed` — a 200 whose body is not the document we expect, which means something\n * between the page and us is rewriting responses (a proxy, an extension, a captive\n * portal). Worth distinguishing, because retrying it never helps.\n */\nexport class FrontendApiError extends CboxIdError {\n constructor(\n message: string,\n readonly code: 'origin_not_allowed' | 'rate_limited' | 'unavailable' | 'malformed',\n readonly status?: number,\n readonly retryAfter?: number,\n ) {\n super(message);\n }\n}\n"]}