@rakomi/node 0.0.0 → 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -1
  3. package/SECURITY.md +206 -0
  4. package/dist/agents.d.ts +90 -0
  5. package/dist/agents.js +203 -0
  6. package/dist/anonymous.d.ts +50 -0
  7. package/dist/anonymous.js +105 -0
  8. package/dist/ciba.d.ts +97 -0
  9. package/dist/ciba.js +282 -0
  10. package/dist/client.d.ts +93 -0
  11. package/dist/client.js +202 -0
  12. package/dist/credentials.d.ts +87 -0
  13. package/dist/credentials.js +104 -0
  14. package/dist/device.d.ts +76 -0
  15. package/dist/device.js +244 -0
  16. package/dist/doctor.d.ts +11 -0
  17. package/dist/doctor.js +135 -0
  18. package/dist/dpop-session.d.ts +90 -0
  19. package/dist/dpop-session.js +127 -0
  20. package/dist/dpop.d.ts +24 -0
  21. package/dist/dpop.js +51 -0
  22. package/dist/env-detect.d.ts +11 -0
  23. package/dist/env-detect.js +26 -0
  24. package/dist/errors.d.ts +307 -0
  25. package/dist/errors.js +385 -0
  26. package/dist/eudi.d.ts +23 -0
  27. package/dist/eudi.js +27 -0
  28. package/dist/flags.d.ts +50 -0
  29. package/dist/flags.js +173 -0
  30. package/dist/guards.d.ts +16 -0
  31. package/dist/guards.js +104 -0
  32. package/dist/index.d.ts +30 -0
  33. package/dist/index.js +18 -0
  34. package/dist/internal/canonical-url.d.ts +13 -0
  35. package/dist/internal/canonical-url.js +52 -0
  36. package/dist/internal/shared-constants.d.ts +3 -0
  37. package/dist/internal/shared-constants.js +3 -0
  38. package/dist/jwks-cache.d.ts +31 -0
  39. package/dist/jwks-cache.js +135 -0
  40. package/dist/link.d.ts +73 -0
  41. package/dist/link.js +262 -0
  42. package/dist/middleware.d.ts +21 -0
  43. package/dist/middleware.js +84 -0
  44. package/dist/oauth.d.ts +46 -0
  45. package/dist/oauth.js +457 -0
  46. package/dist/rbac.d.ts +12 -0
  47. package/dist/rbac.js +20 -0
  48. package/dist/token-exchange.d.ts +65 -0
  49. package/dist/token-exchange.js +163 -0
  50. package/dist/types.d.ts +436 -0
  51. package/dist/types.js +1 -0
  52. package/dist/verify-publisher-webhook.d.ts +25 -0
  53. package/dist/verify-publisher-webhook.js +47 -0
  54. package/dist/verify-token.d.ts +3 -0
  55. package/dist/verify-token.js +148 -0
  56. package/dist/verify-webhook.d.ts +7 -0
  57. package/dist/verify-webhook.js +101 -0
  58. package/package.json +61 -5
  59. package/sbom.cdx.json +52 -0
package/dist/errors.js ADDED
@@ -0,0 +1,385 @@
1
+ const DOCS_BASE = 'https://docs.rakomi.dev/sdk/errors';
2
+ /**
3
+ * Error class thrown by Rakomi constructor for configuration errors.
4
+ * Extends Error so `instanceof Error` works and stack traces are available.
5
+ */
6
+ export class RakomiError extends Error {
7
+ code;
8
+ suggestion;
9
+ docs_url;
10
+ fix_command;
11
+ constructor(sdkError) {
12
+ super(sdkError.message);
13
+ this.name = 'RakomiError';
14
+ this.code = sdkError.code;
15
+ this.suggestion = sdkError.suggestion;
16
+ this.docs_url = sdkError.docs_url;
17
+ this.fix_command = sdkError.fix_command;
18
+ }
19
+ }
20
+ function createError(code, message, suggestion, fix_command) {
21
+ return {
22
+ code,
23
+ message,
24
+ suggestion,
25
+ docs_url: `${DOCS_BASE}#${code.replace('/', '-')}`,
26
+ fix_command,
27
+ };
28
+ }
29
+ export const ERROR_CODES = {
30
+ TOKEN_REVOKED: 'token/revoked',
31
+ TOKEN_EXPIRED: 'token/expired',
32
+ TOKEN_INVALID_SIGNATURE: 'token/invalid_signature',
33
+ TOKEN_MALFORMED: 'token/malformed',
34
+ TOKEN_INVALID_ALGORITHM: 'token/invalid_algorithm',
35
+ TOKEN_MISSING_CLAIMS: 'token/missing_claims',
36
+ TOKEN_INVALID_ISSUER: 'token/invalid_issuer',
37
+ TOKEN_INVALID_AUDIENCE: 'token/invalid_audience',
38
+ TOKEN_NOT_YET_VALID: 'token/not_yet_valid',
39
+ AUTH_ENVIRONMENT_MISMATCH: 'auth/environment_mismatch',
40
+ AUTH_DPOP_PROVER_UNAVAILABLE: 'auth/dpop_prover_unavailable',
41
+ AUTH_INVALID_DPOP_PROOF: 'auth/invalid_dpop_proof',
42
+ AUTH_INVALID_REFRESH_TOKEN: 'auth/invalid_refresh_token',
43
+ AUTH_DPOP_ROTATION_NOOP: 'auth/dpop_rotation_noop',
44
+ AUTH_DPOP_ROTATION_DID_NOT_TAKE: 'auth/dpop_rotation_did_not_take',
45
+ AUTH_REFRESH_SUPERSEDED_BY_ROTATION: 'auth/refresh_superseded_by_rotation',
46
+ JWKS_FETCH_FAILED: 'jwks/fetch_failed',
47
+ JWKS_NO_MATCHING_KEY: 'jwks/no_matching_key',
48
+ JWKS_INVALID_RESPONSE: 'jwks/invalid_response',
49
+ WEBHOOK_TIMESTAMP_TOO_OLD: 'webhook/timestamp_too_old',
50
+ WEBHOOK_TIMESTAMP_TOO_NEW: 'webhook/timestamp_too_new',
51
+ WEBHOOK_INVALID_SIGNATURE: 'webhook/invalid_signature',
52
+ WEBHOOK_INVALID_SECRET: 'webhook/invalid_secret',
53
+ WEBHOOK_MISSING_HEADER: 'webhook/missing_header',
54
+ WEBHOOK_INVALID_BODY: 'webhook/invalid_body',
55
+ };
56
+ export const TOKEN_REVOKED = () => createError('token/revoked', 'Token was issued before emergency revocation', 'Re-authenticate the user. All sessions were invalidated by platform operator.');
57
+ export const TOKEN_EXPIRED = () => createError('token/expired', 'Token has expired', 'Request a new access token via refresh token endpoint');
58
+ export const TOKEN_INVALID_SIGNATURE = () => createError('token/invalid_signature', 'Token signature verification failed', 'Ensure the token was issued by Rakomi and has not been tampered with');
59
+ export const TOKEN_MALFORMED = () => createError('token/malformed', 'Token is not a valid JWT format', 'Ensure you are passing a complete JWT string (header.payload.signature)');
60
+ export const TOKEN_INVALID_ALGORITHM = () => createError('token/invalid_algorithm', 'Unsupported algorithm. Only RS256 is allowed', 'Rakomi tokens use RS256. Do not attempt to use HS256 or other algorithms');
61
+ export const TOKEN_MISSING_CLAIMS = () => createError('token/missing_claims', 'Required claims missing (sub, tenant_id, email, sid, iss, aud, exp, iat, jti)', 'Ensure the token was issued by Rakomi login/refresh endpoints');
62
+ export const TOKEN_INVALID_ISSUER = () => createError('token/invalid_issuer', 'Token issuer mismatch', 'Token must be issued by rakomi.com. Verify you are using the correct environment');
63
+ export const TOKEN_INVALID_AUDIENCE = () => createError('token/invalid_audience', 'Token audience mismatch', 'Ensure the token was issued for this Rakomi deployment. Tokens from other Rakomi instances cannot be reused here.');
64
+ export const TOKEN_NOT_YET_VALID = () => createError('token/not_yet_valid', 'Token nbf (not before) is in the future', 'Check system clock synchronization or increase clockTolerance in SDK config', 'new Rakomi({ apiKey: "...", clockTolerance: 60 })');
65
+ export const AUTH_ENVIRONMENT_MISMATCH = () => createError('auth/environment_mismatch', 'Token environment does not match SDK environment. A test token cannot be verified with a live API key (and vice versa).', 'Ensure the API key and token are from the same environment (both live or both test).');
66
+ /**
67
+ * Client-side: this SDK/platform could not produce a DPoP proof here (no native
68
+ * prover, or the signer threw / returned falsy). NOT a security event — the SDK
69
+ * deliberately refused to send an empty/malformed `DPoP` header rather than
70
+ * silently downgrade to Bearer. App action: report a bug / re-login.
71
+ */
72
+ export const AUTH_DPOP_PROVER_UNAVAILABLE = (detail) => createError('auth/dpop_prover_unavailable', detail || 'No DPoP prover is available to sign the refresh proof on this platform', 'Report this as a bug or re-authenticate the user. The SDK refused to send a malformed or empty DPoP proof — it did NOT silently downgrade to Bearer.');
73
+ /**
74
+ * Server rejected the DPoP proof on the refresh request (signature / `htu` /
75
+ * `htm` / replayed `jti` / cold-start key mismatch). App action: re-attach a
76
+ * fresh proof, check device clock skew, or re-authenticate to re-bind the key.
77
+ */
78
+ export const AUTH_INVALID_DPOP_PROOF = (detail) => createError('auth/invalid_dpop_proof', detail || 'The server rejected the DPoP proof on the refresh request', 'Re-attach a fresh proof and check device clock skew. After an app/process restart the in-memory key is lost — re-authenticate to re-bind a fresh session key.');
79
+ /**
80
+ * The refresh token itself is invalid, expired, or has been revoked (the
81
+ * genuine end-of-session class — RFC 6749 `invalid_grant` on the refresh
82
+ * operation). App action: start a full re-authentication (login) flow.
83
+ */
84
+ export const AUTH_INVALID_REFRESH_TOKEN = (detail) => createError('auth/invalid_refresh_token', detail || 'The refresh token is invalid, expired, or has been revoked', 'The session has ended. Start a full re-authentication (login) flow.');
85
+ /**
86
+ * Client-side rotation bug: the SDK attempted to rotate to a key the server
87
+ * already has bound (server `400 invalid_request` reason `rotation_noop`). NOT a
88
+ * security event and NO re-login — the session is unchanged. A fresh keypair
89
+ * makes this practically unreachable from the SDK; it surfaces only if a caller
90
+ * forces the new key to equal the old. App action: drop the rotation, keep using
91
+ * the current session.
92
+ */
93
+ export const AUTH_DPOP_ROTATION_NOOP = (detail) => createError('auth/dpop_rotation_noop', detail || 'Rotation to the currently-bound key is a no-op', 'Do not rotate to the current key. The session binding is unchanged — keep using the existing session.');
94
+ /**
95
+ * The rotation did not take AND no usable refreshed token was obtained. This is the
96
+ * FAILURE arm — distinct from the common rotation-unaware-server case where the
97
+ * refresh itself succeeds (that returns `{ ok: true, data: { ...tokens, rotated: false } }` — a
98
+ * {@link RotationTokenResponse} the caller MUST persist). This error fires
99
+ * only when there is nothing to persist:
100
+ * - a rotation that lost the token-keyed gate to a concurrent ordinary refresh of
101
+ * the same one-time-use token (fail-SAFE, NO network call, OLD key still bound);
102
+ * - the unreachable-by-construction local pre-swap invariant refusing a swap the
103
+ * server DID confirm (a new-key-bound token the SDK has no prover for).
104
+ * The OLD key is STILL bound (never a half-swap). App action: retry the rotation, or
105
+ * re-authenticate if it keeps not taking.
106
+ */
107
+ export const AUTH_DPOP_ROTATION_DID_NOT_TAKE = (detail) => createError('auth/dpop_rotation_did_not_take', detail || 'The server did not apply the key rotation (the old key is still bound)', 'The rotation did not take — the old key is still bound (no half-swap). Retry the rotation, or re-authenticate if it keeps failing.');
108
+ /**
109
+ * A refresh and a key-rotation were attempted concurrently on the SAME
110
+ * refresh_token. The server one-time-uses + rotates the refresh token on
111
+ * every success, so the two operations can never both spend the same token value
112
+ * without tripping server refresh-reuse detection (which revokes ALL session
113
+ * tokens). The SDK serializes every refresh_token-consuming operation through one
114
+ * token-keyed choke point: a key rotation already owns this token, so THIS
115
+ * ordinary refresh was NOT sent (fail-SAFE — no network call, no double-spend).
116
+ * The session/binding are unchanged. App action: use the rotation's result (it
117
+ * also delivers fresh tokens), or retry the refresh with the rotated
118
+ * refresh_token once the rotation settles.
119
+ */
120
+ export const AUTH_REFRESH_SUPERSEDED_BY_ROTATION = (detail) => createError('auth/refresh_superseded_by_rotation', detail || 'A concurrent key rotation is consuming this refresh token; the refresh was not sent', 'A key rotation is consuming this refresh token. Use the rotation result, or retry the refresh with the rotated refresh token after the rotation settles.');
121
+ export const JWKS_FETCH_FAILED = (detail) => createError('jwks/fetch_failed', `Failed to fetch JWKS${detail ? `: ${detail}` : ''}`, 'Check network connectivity and that baseUrl is correct', 'curl https://api.rakomi.com/.well-known/jwks.json');
122
+ export const JWKS_NO_MATCHING_KEY = () => createError('jwks/no_matching_key', 'No key in JWKS matches token kid', 'The signing key may have been rotated. This is transient during key rotation — retry in a few seconds');
123
+ export const JWKS_INVALID_RESPONSE = () => createError('jwks/invalid_response', 'JWKS response is not a valid JSON Web Key Set', 'Ensure baseUrl points to a valid Rakomi instance');
124
+ export const WEBHOOK_TIMESTAMP_TOO_OLD = (tolerance) => createError('webhook/timestamp_too_old', `Webhook timestamp is too old — exceeds ${tolerance}s tolerance`, 'Ensure your server clock is synchronized. The webhook may be a replay attack');
125
+ export const WEBHOOK_TIMESTAMP_TOO_NEW = (tolerance) => createError('webhook/timestamp_too_new', `Webhook timestamp is too far in the future — exceeds ${tolerance}s tolerance`, 'Check your server clock synchronization. Clock drift detected');
126
+ /** @deprecated Use WEBHOOK_TIMESTAMP_TOO_OLD or WEBHOOK_TIMESTAMP_TOO_NEW */
127
+ export const WEBHOOK_TIMESTAMP_EXPIRED = () => createError('webhook/timestamp_expired', 'Webhook timestamp is outside tolerance window', 'Ensure your server clock is synchronized');
128
+ export const WEBHOOK_INVALID_SIGNATURE = () => createError('webhook/invalid_signature', 'Webhook HMAC signature verification failed. Are you passing the raw request body? Use express.raw() or request.text(), not parsed JSON.', 'Verify the webhook secret matches the one in your Rakomi dashboard');
129
+ export const WEBHOOK_INVALID_SECRET = () => createError('webhook/invalid_secret', 'Webhook signing secret is invalid or corrupted — key must decode to exactly 32 bytes', 'Re-copy the signing secret from the Rakomi dashboard or rotate the key');
130
+ export const WEBHOOK_MISSING_HEADER = () => createError('webhook/missing_header', 'Required webhook headers missing (webhook-signature, webhook-timestamp, webhook-id)', 'Ensure you are passing the raw request headers to verifyWebhook()');
131
+ export const WEBHOOK_INVALID_BODY = () => createError('webhook/invalid_body', 'Webhook body is not valid JSON', 'Use express.raw() or express.text() middleware to preserve the raw body for webhook routes');
132
+ export const CONFIG_MISSING_API_KEY = () => createError('config/missing_api_key', 'apiKey is required', 'Pass your API key when creating the client', 'new Rakomi({ apiKey: "ca_live_xxx" })');
133
+ export const CONFIG_INVALID_BASE_URL = () => createError('config/invalid_base_url', 'baseUrl must be a valid HTTPS URL', 'Use a full URL including protocol, e.g., https://api.rakomi.com');
134
+ export const CONFIG_MISSING_WEBHOOK_SECRET = () => createError('config/missing_webhook_secret', 'webhookSecret is required for webhook verification', 'Pass your webhook signing key in config', 'new Rakomi({ apiKey: "...", webhookSecret: "rksec_xxx" })');
135
+ export const OAUTH_INVALID_GRANT = (detail) => createError('oauth/invalid_grant', detail || 'Authorization code is invalid, expired, or already used', 'Request a new authorization code. Codes expire after 10 minutes and can only be used once');
136
+ export const OAUTH_INVALID_CLIENT = (detail) => createError('oauth/invalid_client', detail || 'Client authentication failed — invalid client_id or client_secret', 'Verify your OAuth client credentials in the Rakomi dashboard');
137
+ export const OAUTH_INVALID_REQUEST = (detail) => createError('oauth/invalid_request', detail || 'Invalid or missing request parameters', 'Check that all required parameters are provided and correctly formatted');
138
+ export const OAUTH_UNSUPPORTED_GRANT_TYPE = (detail) => createError('oauth/unsupported_grant_type', detail || 'The grant type is not supported', 'Use grant_type=authorization_code or grant_type=refresh_token');
139
+ export const OAUTH_NETWORK_ERROR = (detail) => createError('oauth/network_error', `OAuth token request failed${detail ? `: ${detail}` : ''}`, 'Check network connectivity and that the Rakomi API is reachable');
140
+ export const OAUTH_MISSING_CLIENT_ID = () => createError('oauth/missing_client_id', 'clientId is required for OAuth token operations', 'Pass clientId when creating the Rakomi client or in the options', 'new Rakomi({ apiKey: "...", clientId: "your_client_id" })');
141
+ export const OAUTH_MISSING_CLIENT_SECRET = () => createError('oauth/missing_client_secret', 'clientSecret is required for OAuth token operations', 'Pass clientSecret when creating the Rakomi client or in the options', 'new Rakomi({ apiKey: "...", clientSecret: "your_client_secret" })');
142
+ export const DEVICE_AUTHORIZATION_PENDING = (detail) => createError('device/authorization_pending', detail || 'The user has not yet completed authorization', 'Continue polling the token endpoint at the suggested interval until success, denial, or expiry');
143
+ export const DEVICE_AUTHORIZATION_SLOW_DOWN = (detail) => createError('device/slow_down', detail || 'Polling interval too short — increase by 5 seconds', 'Honor the slow_down server signal: add 5s to your current interval before the next poll (RFC 8628 §3.5)');
144
+ export const DEVICE_AUTHORIZATION_DENIED = (detail) => createError('device/access_denied', detail || 'The user denied the authorization request', 'Restart the device flow if the user wants to retry');
145
+ export const DEVICE_AUTHORIZATION_EXPIRED = (detail) => createError('device/expired_token', detail || 'The device_code has expired before user authorization completed', 'Restart the device flow to obtain a fresh device_code + user_code');
146
+ export const DEVICE_AUTHORIZATION_TIMEOUT = (detail) => createError('device/timeout', detail || 'awaitDeviceTokens reached its client-side timeout before the user completed authorization', 'Pass a longer signal/timeout, or use poll() directly so you control the wait');
147
+ export const DEVICE_AUTHORIZATION_RATE_LIMITED = (detail) => createError('device/rate_limited', detail || 'Too many active device codes — server returned slow_down on issuance', 'Wait for older device codes to expire, or reduce concurrent device-flow initiations');
148
+ export const ANONYMOUS_DISABLED = () => createError('anonymous/disabled', 'Anonymous sign-ins are not enabled for this tenant', 'Enable anonymous sign-ins in the Rakomi dashboard → Settings → Authentication → Anonymous');
149
+ export const ANONYMOUS_RATE_LIMITED = (retryAfterSeconds) => createError('anonymous/rate_limited', retryAfterSeconds
150
+ ? `Too many anonymous sign-in requests. Retry after ${retryAfterSeconds}s.`
151
+ : 'Too many anonymous sign-in requests', 'Back off and retry. See https://docs.rakomi.dev/reference/auth/anonymous#rate-limits');
152
+ export const ANONYMOUS_MAU_EXHAUSTED = () => createError('anonymous/mau_exhausted', 'Tenant MAU cap reached — new anonymous users cannot be created until the billing period resets or the plan is upgraded.', 'Upgrade the tenant plan in the Rakomi dashboard, or wait for the next billing period.');
153
+ export const ANONYMOUS_NETWORK_ERROR = (detail) => createError('anonymous/network_error', `Anonymous sign-in request failed${detail ? `: ${detail}` : ''}`, 'Check network connectivity and that the Rakomi API is reachable');
154
+ /**
155
+ * Thrown by the auto-refresh path when a refresh returns 401 AND the prior
156
+ * access token was marked `is_anonymous: true`. Distinguishes "the tenant
157
+ * purged your anon user" from generic auth failures so apps can prompt a
158
+ * fresh `client.anonymous` rather than sending the user to a login screen.
159
+ *
160
+ * Carries `suggestedAction: 'call_anonymous'` as a stable hint for DX-level
161
+ * UX routing (DX thread).
162
+ */
163
+ export class AnonymousSessionExpiredError extends Error {
164
+ code = 'anonymous/session_expired';
165
+ suggestedAction = 'call_anonymous()';
166
+ suggestion;
167
+ docs_url;
168
+ constructor(message = 'Anonymous session expired — the tenant purged the underlying anonymous user.') {
169
+ super(message);
170
+ this.name = 'AnonymousSessionExpiredError';
171
+ this.suggestion = 'Call client.anonymous() to mint a fresh guest session.';
172
+ this.docs_url = `${DOCS_BASE}#anonymous-session_expired`;
173
+ }
174
+ }
175
+ export const ACCOUNT_LINKING_NETWORK_ERROR = (detail) => createError('account_linking/network_error', `Account linking request failed${detail ? `: ${detail}` : ''}`, 'Check network connectivity and that the Rakomi API is reachable');
176
+ export const ACCOUNT_LINKING_RATE_LIMITED = (retryAfterSeconds) => createError('account_linking/rate_limited', retryAfterSeconds
177
+ ? `Too many account-linking requests. Retry after ${retryAfterSeconds}s.`
178
+ : 'Too many account-linking requests', 'Back off and retry. Account-linking endpoints enforce a per-user rate limit.');
179
+ export const ACCOUNT_LINKING_IDENTITY_NOT_FOUND = () => createError('account_linking/identity_not_found', 'No such linked identity for the caller', 'The identity may already have been unlinked. Re-read GET /v1/users/me/link to confirm.');
180
+ /**
181
+ * Thrown when POST /v1/users/me/link/{provider} returns 403 —
182
+ * tenant has disabled explicit self-service linking.
183
+ */
184
+ export class AccountLinkingDisabledError extends Error {
185
+ code = 'account_linking/disabled_for_tenant';
186
+ suggestion;
187
+ docs_url;
188
+ retryAfterSeconds;
189
+ constructor(message = 'Explicit account linking is disabled for this tenant.') {
190
+ super(message);
191
+ this.name = 'AccountLinkingDisabledError';
192
+ this.suggestion = 'Contact the tenant administrator. The explicit_account_linking_enabled setting is off.';
193
+ this.docs_url = `${DOCS_BASE}#account_linking-disabled_for_tenant`;
194
+ }
195
+ }
196
+ /**
197
+ * Thrown when a link attempt finds the identity already owned by a different user.
198
+ * Never include details about the other user — anti-enumeration invariant.
199
+ */
200
+ export class IdentityOwnedByOtherUserError extends Error {
201
+ code = 'account_linking/identity_owned_by_other_user';
202
+ suggestion;
203
+ docs_url;
204
+ constructor(message = 'The requested identity is already linked to a different account.') {
205
+ super(message);
206
+ this.name = 'IdentityOwnedByOtherUserError';
207
+ this.suggestion = 'Ask the user to sign in with a different provider account, or contact support.';
208
+ this.docs_url = `${DOCS_BASE}#account_linking-identity_owned_by_other_user`;
209
+ }
210
+ }
211
+ /**
212
+ * Thrown when DELETE /v1/users/me/link/{provider} would remove the user's last
213
+ * sign-in method. Includes the remaining method kinds to inform UX.
214
+ */
215
+ export class CannotUnlinkLastMethodError extends Error {
216
+ code = 'account_linking/cannot_unlink_last_method';
217
+ suggestion;
218
+ docs_url;
219
+ remaining_methods;
220
+ constructor(remainingMethods = [], message = 'Cannot unlink the user\'s last sign-in method.') {
221
+ super(message);
222
+ this.name = 'CannotUnlinkLastMethodError';
223
+ this.suggestion = 'Add another sign-in method (password, passkey, or another social provider) before unlinking.';
224
+ this.docs_url = `${DOCS_BASE}#account_linking-cannot_unlink_last_method`;
225
+ this.remaining_methods = remainingMethods;
226
+ }
227
+ }
228
+ /**
229
+ * Thrown when a high-risk operation is attempted within the 1-hour post-link
230
+ * cooldown window. Semantically distinct from rate-limit
231
+ * 429s: clients should surface a localized "try again at {time}" hint rather
232
+ * than "too many requests".
233
+ *
234
+ * Exposes both `unlockAt: Date` and `unlockAtIso: string` so consumers can
235
+ * format the timestamp without re-parsing.
236
+ */
237
+ export class CooldownActiveError extends Error {
238
+ code = 'account_linking/cooldown_active';
239
+ suggestion;
240
+ docs_url;
241
+ unlockAt;
242
+ unlockAtIso;
243
+ /**
244
+ * User-facing discriminator so consumer UX can render a distinct copy path
245
+ * ("you recently linked an account") vs. the generic rate-limit message.
246
+ * Populated from the server-side `details.reason` when present.
247
+ */
248
+ reason;
249
+ constructor(unlockAtIso, reason = 'account_recently_linked', message = 'High-risk operation temporarily locked after account linking.') {
250
+ super(message);
251
+ this.name = 'CooldownActiveError';
252
+ this.unlockAtIso = unlockAtIso;
253
+ this.unlockAt = new Date(unlockAtIso);
254
+ this.reason = reason;
255
+ this.suggestion = `Wait until ${unlockAtIso} before retrying. For immediate access, users can unlink the newly-linked provider.`;
256
+ this.docs_url = `${DOCS_BASE}#account_linking-cooldown_active`;
257
+ }
258
+ }
259
+ let mfaChallengeTokenWarningEmitted = false;
260
+ function emitMfaChallengeTokenDeprecationWarning() {
261
+ if (mfaChallengeTokenWarningEmitted)
262
+ return;
263
+ mfaChallengeTokenWarningEmitted = true;
264
+ console.warn('[@rakomi/node] DEPRECATED: MfaStepUpRequiredError.mfa_challenge_token is a transitional dual-emit field and will be removed in 0.12.0. Switch to `next_action === "verify_mfa"` for MFA step-up branching. See https://docs.rakomi.dev/sdk/errors#account_linking-mfa_required');
265
+ }
266
+ /**
267
+ * Thrown when POST /v1/users/me/link/{provider} returns 401 with
268
+ * `code: 'account_linking/mfa_required'`.
269
+ *
270
+ * @see https://datatracker.ietf.org/doc/html/rfc9470 (Step-Up Authentication
271
+ * Challenge Protocol — conceptual reference; the JSON-body discriminator here
272
+ * mirrors RFC 9470's Bearer `WWW-Authenticate` step-up signal for cookie-session
273
+ * APIs.)
274
+ *
275
+ * In SDK 0.11.0 the canonical post-401 discriminator is `next_action: 'verify_mfa'`
276
+ * (mirrors Stripe `payment_intent.next_action` / AWS Cognito `ChallengeName`).
277
+ * The `mfa_challenge_token` getter is RETAINED for backward compatibility with
278
+ * 0.10.x consumers but will be removed in 0.12.0 — switch to `next_action`.
279
+ *
280
+ * Calling code: guide the user through `POST /v1/auth/step-up/password`, then
281
+ * retry the link initiate with the resulting `X-Step-Up-Token` header.
282
+ *
283
+ * Note: this error is ONLY thrown for users who CAN satisfy step-up (i.e. have
284
+ * a password set). Passwordless users (magic-link / OTP / passkey-only) get the
285
+ * complementary {@link MfaStepUpUnavailableError} instead.
286
+ */
287
+ export class MfaStepUpRequiredError extends Error {
288
+ code = 'account_linking/mfa_required';
289
+ suggestion;
290
+ docs_url;
291
+ /**
292
+ * Canonical post-401 discriminator. Always equals the literal `'verify_mfa'`
293
+ * in SDK 0.11.x. Reserved future literals: `'verify_passkey'`,
294
+ * `'verify_magic_link'`, `'verify_otp'`, `'verify_eudi_wallet'`.
295
+ */
296
+ next_action = 'verify_mfa';
297
+ /**
298
+ * The set of step-up issuance routes the current user can satisfy. The
299
+ * client SHOULD pick one and call the corresponding `/v1/auth/step-up/*`
300
+ * route. Order is a HINT, not a guarantee — the server may personalize
301
+ * ordering by recent success rate or admin policy.
302
+ *
303
+ * Open union: backend-additive values (e.g. `ciba_push`, `eudi_wallet`)
304
+ * MUST NOT trigger a SemVer-major bump for SDK consumers — the
305
+ * `(string & {})` escape hatch keeps the literal-aware autocomplete
306
+ * experience while accepting unknown strings at runtime.
307
+ *
308
+ * Optional: older API responses (servers older than 2026-04-26)
309
+ * lack this field; consumers MUST handle `undefined` by falling through to
310
+ * the legacy `next_action === 'verify_mfa'` flow.
311
+ */
312
+ availableMethods;
313
+ _mfaChallengeToken;
314
+ /**
315
+ * @deprecated Will be removed in 0.12.0+ once the dual-emit window expires
316
+ * (2026-07-24). Switch to `next_action === 'verify_mfa'`. In 0.10.x this field carried the
317
+ * literal string `'mfa_step_up_required'` (a routing marker, never a nonce);
318
+ * the value is preserved here so existing consumers continue working through
319
+ * the dual-emit window.
320
+ */
321
+ get mfa_challenge_token() {
322
+ emitMfaChallengeTokenDeprecationWarning();
323
+ return this._mfaChallengeToken;
324
+ }
325
+ constructor(mfaChallengeToken = 'mfa_step_up_required', message = 'MFA verification required before linking a new identity.', availableMethods) {
326
+ super(message);
327
+ this.name = 'MfaStepUpRequiredError';
328
+ this._mfaChallengeToken = mfaChallengeToken;
329
+ this.availableMethods = availableMethods;
330
+ this.suggestion = availableMethods && availableMethods.length > 0
331
+ ? `Direct the user through one of the available step-up methods (${availableMethods.join(', ')}) and retry the link initiate with the resulting X-Step-Up-Token header.`
332
+ : 'Direct the user through a fresh MFA challenge, then retry the link initiate with the resulting X-Step-Up-Token header.';
333
+ this.docs_url = `${DOCS_BASE}#account_linking-mfa_required`;
334
+ }
335
+ }
336
+ /**
337
+ * Thrown when POST /v1/users/me/link/{provider} returns 401 with
338
+ * `code: 'account_linking/mfa_step_up_unavailable'` — a passwordless user
339
+ * (magic-link / email-OTP / passkey-only) has MFA enabled but no step-up
340
+ * issuance route exists for their authenticator class today (only
341
+ * `POST /v1/auth/step-up/password` ships in 0.11.x).
342
+ *
343
+ * @see https://datatracker.ietf.org/doc/html/rfc9470
344
+ *
345
+ * The complement of {@link MfaStepUpRequiredError}: together the two classes
346
+ * partition the 401-with-MFA-enabled space. UX should surface a "complete
347
+ * account setup (set a password)" affordance — NOT a promise of a passwordless
348
+ * step-up flow (none exists in 0.11.x).
349
+ */
350
+ export class MfaStepUpUnavailableError extends Error {
351
+ code = 'account_linking/mfa_step_up_unavailable';
352
+ suggestion;
353
+ docs_url;
354
+ /**
355
+ * Extensible enum — additional reasons reserved for future passwordless
356
+ * step-up flows. The recognised reasons include
357
+ * `'no_step_up_authenticator_available'`, emitted when the server finds
358
+ * zero satisfiable methods (defensive tripwire).
359
+ */
360
+ reason;
361
+ constructor(reason = 'passwordless_user_no_step_up_route', message = 'No step-up authenticator is available for this account.') {
362
+ super(message);
363
+ this.name = 'MfaStepUpUnavailableError';
364
+ this.reason = reason;
365
+ this.suggestion = 'Reserved for the (now-rare) case where the user has no satisfiable step-up authenticator. Passwordless users with email or passkey receive MfaStepUpRequiredError with availableMethods populated instead.';
366
+ this.docs_url = `${DOCS_BASE}#account_linking-mfa_step_up_unavailable`;
367
+ }
368
+ }
369
+ /**
370
+ * Thrown when an OAuth callback rejects a state row that expired or was
371
+ * already used. The SDK typically surfaces this via the
372
+ * link-list read (consumers infer the failure from a `?error=link_state_expired`
373
+ * query string). Direct API consumers get the typed class.
374
+ */
375
+ export class LinkStateExpiredError extends Error {
376
+ code = 'account_linking/link_state_expired_or_missing';
377
+ suggestion;
378
+ docs_url;
379
+ constructor(message = 'The link request has expired or was already used.') {
380
+ super(message);
381
+ this.name = 'LinkStateExpiredError';
382
+ this.suggestion = 'Restart the link flow from the Connected Accounts dashboard.';
383
+ this.docs_url = `${DOCS_BASE}#account_linking-link_state_expired_or_missing`;
384
+ }
385
+ }
package/dist/eudi.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * EUDI Wallet (eIDAS) accessors.
3
+ *
4
+ * Offline, claim-derived helpers (NOT a live API call) for inspecting whether a
5
+ * verified token was issued via an EU Digital Identity Wallet and at what eIDAS
6
+ * level of assurance.
7
+ */
8
+ import type { TokenPayload } from './types.js';
9
+ /** eIDAS levels of assurance (Reg. EU 910/2014 Art. 8), low < substantial < high. */
10
+ export type EidasLevel = 'low' | 'substantial' | 'high';
11
+ /**
12
+ * True when the token represents an EU Digital Identity Wallet authentication —
13
+ * either `acr` is one of the eIDAS levels (`eidas_low` / `eidas_substantial` /
14
+ * `eidas_high` — the verifier emits the level it actually verified) OR `amr`
15
+ * includes `'eudi_wallet'`. Derived from the already-verified payload; do NOT
16
+ * trust an unverified token.
17
+ */
18
+ export declare function isEudiVerified(payload: Pick<TokenPayload, 'acr' | 'amr'>): boolean;
19
+ /**
20
+ * The VERIFIED eIDAS level of assurance from the token's `assurance_level` claim,
21
+ * or `undefined` when the token did not carry one (non-EUDI login).
22
+ */
23
+ export declare function eidasLevel(payload: Pick<TokenPayload, 'assurance_level'>): EidasLevel | undefined;
package/dist/eudi.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * EUDI Wallet (eIDAS) accessors.
3
+ *
4
+ * Offline, claim-derived helpers (NOT a live API call) for inspecting whether a
5
+ * verified token was issued via an EU Digital Identity Wallet and at what eIDAS
6
+ * level of assurance.
7
+ */
8
+ /**
9
+ * True when the token represents an EU Digital Identity Wallet authentication —
10
+ * either `acr` is one of the eIDAS levels (`eidas_low` / `eidas_substantial` /
11
+ * `eidas_high` — the verifier emits the level it actually verified) OR `amr`
12
+ * includes `'eudi_wallet'`. Derived from the already-verified payload; do NOT
13
+ * trust an unverified token.
14
+ */
15
+ export function isEudiVerified(payload) {
16
+ return (payload.acr === 'eidas_high' ||
17
+ payload.acr === 'eidas_substantial' ||
18
+ payload.acr === 'eidas_low' ||
19
+ (payload.amr?.includes('eudi_wallet') ?? false));
20
+ }
21
+ /**
22
+ * The VERIFIED eIDAS level of assurance from the token's `assurance_level` claim,
23
+ * or `undefined` when the token did not carry one (non-EUDI login).
24
+ */
25
+ export function eidasLevel(payload) {
26
+ return payload.assurance_level;
27
+ }
@@ -0,0 +1,50 @@
1
+ export type CircuitState = 'closed' | 'open' | 'half-open';
2
+ export interface UserContext {
3
+ userId?: string;
4
+ userMetadata?: Record<string, unknown>;
5
+ keys?: string[];
6
+ }
7
+ export interface FlagsOptions {
8
+ ttl?: number;
9
+ skipCache?: boolean;
10
+ }
11
+ export type FlagResult<T = unknown> = {
12
+ ok: true;
13
+ value: T;
14
+ variant?: string;
15
+ reason?: string;
16
+ } | {
17
+ ok: false;
18
+ error: {
19
+ code: string;
20
+ message: string;
21
+ };
22
+ };
23
+ export type FlagsAllResult = {
24
+ ok: true;
25
+ flags: Record<string, unknown>;
26
+ } | {
27
+ ok: false;
28
+ error: {
29
+ code: string;
30
+ message: string;
31
+ };
32
+ };
33
+ export declare class FlagsClient {
34
+ private readonly baseUrl;
35
+ private readonly apiKey;
36
+ private readonly cache;
37
+ private readonly circuit;
38
+ constructor(baseUrl: string, apiKey: string);
39
+ get circuitStatus(): CircuitState;
40
+ private buildCacheKey;
41
+ private isCacheValid;
42
+ private setCacheEntry;
43
+ private fetchFlags;
44
+ get<T = unknown>(key: string, ctx?: UserContext, opts?: FlagsOptions): Promise<FlagResult<T>>;
45
+ getAll(ctx?: UserContext, opts?: FlagsOptions): Promise<FlagsAllResult>;
46
+ startPolling(ctx?: UserContext, opts?: FlagsOptions): {
47
+ stop: () => void;
48
+ };
49
+ flush(cacheKey?: string): void;
50
+ }