@atlasauth/js 0.5.0 → 0.7.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,188 @@
1
+ # @atlasauth/js changelog
2
+
3
+ ## 0.7.0
4
+ - **Popup OAuth completes when the opener page sends `Cross-Origin-Opener-Policy: same-origin`.** That
5
+ header puts the provider popup in another browsing-context group: `popup.closed` reads `true` at once and
6
+ the popup has no `window.opener`, so the result could never be posted back and the flow settled as a
7
+ cancel while the popup stayed open. Now the popup lands back on your page with the result in the URL
8
+ fragment (`#atlas_popup_result=…`, never the query string), the SDK there hands it to the opener over
9
+ `BroadcastChannel('atlas:oauth:<nonce>')` (a `localStorage` `storage` event where BroadcastChannel is
10
+ missing) and closes the popup.
11
+ - **Changed `startOauthPopup` cancel semantics.** `popup.closed` turning true within 1.5 s of opening means
12
+ the opener link was severed, not that the user closed it: the flow keeps waiting, probing `GET /v1/client`
13
+ with back-off, for up to 5 minutes, and settles as `popup_closed` only on that timeout, an explicit error
14
+ result, `signal` aborting, or a newer popup flow starting. A close after 1.5 s is a cancel after one final
15
+ session probe (as before).
16
+ - **Changed `startOauthPopup` request.** It now sends `popup_nonce` and `opener_origin`
17
+ (`window.location.origin`) with `mode: 'popup'`, and opens the popup with the window name `atlas:oauth`.
18
+ - **Added** to `startOauthPopup`: `input.signal?: AbortSignal`, `handlers.onSevered?: () => void`,
19
+ `PopupStartResult.severed?: boolean`; new `OAuthPopupInput` type; `POPUP_SEVERED_WINDOW_MS` (1500) and
20
+ `POPUP_SEVERED_TIMEOUT_MS` (300000); `mapOAuthPopupPayload(data)` (the payload half of
21
+ `parseOAuthPopupMessage`).
22
+ - **Added popup channel helpers:** `completeOAuthPopupLanding({ win?, ackTimeoutMs? })` →
23
+ `PopupLanding | null` (`{ payload, delivered }`; reads and scrubs the fragment, relays the result and
24
+ closes once the opener acknowledges — call it on startup), `closeOAuthPopupAfterSignIn({ win?, ackTimeoutMs? })`
25
+ (a page that finished a sign-in inside the Atlas popup closes itself when an opener is waiting),
26
+ `listenForPopupResult(nonce, { onResult, onSignedIn? })`, `readPopupLanding(hash)`, `popupChannelName(nonce)`,
27
+ `createPopupNonce()`, `PopupRelayMessage`, and the constants `OAUTH_POPUP_WINDOW_NAME`
28
+ (`'atlas:oauth'`), `POPUP_RESULT_FRAGMENT` (`'atlas_popup_result'`), `POPUP_SIGNAL_CHANNEL`.
29
+ - **Sign-out tombstones are shared by every tab.** `SessionTombstones` moved from `sessionStorage` to
30
+ `localStorage`, so after a failed revoke a newly opened tab no longer re-adopts the session. Entries are
31
+ `{ id, at }`, at most 20, kept 30 days, pruned on every read, and keyed per Frontend API origin
32
+ (`atlas:signed-out-sessions:<origin>`). `sessionStorage` is used only when `localStorage` throws; tombstones
33
+ an earlier version kept in `sessionStorage` are carried over.
34
+ - **Changed** `new SessionTombstones(options?)`: accepts `{ frontendApi?, storageKey?, now? }`
35
+ (`SessionTombstonesOptions`); a bare storage-key string is still accepted. **Added** `remove(...ids)` (forget
36
+ a tombstone once a later revoke is confirmed) and `subscribe(listener)` (another tab's changes, via the
37
+ `storage` event). `has()`/`list()` re-read storage, so other tabs' changes are seen.
38
+
39
+ ## 0.6.0
40
+ - **Per-flow captcha widget rendering**, shared by the prebuilt sign-in / sign-up UIs:
41
+ `renderCaptchaWidget(flow, container)` → `{ token: Promise<string>, remove() }`,
42
+ `getCaptchaToken(flow, container)`, `loadCaptchaProvider(provider, siteKey, win)`,
43
+ `isRenderableCaptcha(provider)`, `flowNeedsCaptcha(flow)`, `isCaptchaError(error)` and
44
+ `CAPTCHA_ERROR_CODES`. Renders Turnstile, hCaptcha, reCAPTCHA v2, v3 and Enterprise, honouring the
45
+ `invisible` / `managed` / `visible` widget mode. A failed script load is retried on the next render.
46
+ - **Added types** `FlowCaptcha` (`{ enabled, provider?, siteKey?, widget? }` — one entry of
47
+ `/v1/appearance` `captchaFlows`), `CaptchaFlowKey`, `CaptchaWidgetMode`, `CaptchaWidgetHandle`.
48
+ - **Framework-agnostic captcha lifecycle** for the prebuilt forms of every framework SDK:
49
+ `createCaptchaController(options?)` → `CaptchaController` (`needed`, `configure(options)`,
50
+ `attach(el)`, `getToken()`, `reset()`, `destroy()`), with the new `CaptchaControllerOptions` type
51
+ (`{ flow, active, eager?, onSolved? }`); `localizeCaptchaErrors(errors, t)`.
52
+ - **Sign-in / sign-up submit helpers:** `signInCaptchaOptions(flow, state, onChallengeSolved)`,
53
+ `advanceWithCaptcha(client, state, values, { captcha?, identifier?, t })` → `{ state, identifier }`
54
+ (adds the captcha token on the identifier / `needs_captcha` step, reports a rejected token as
55
+ `CAPTCHA_FAILED` and draws a fresh widget), and `createSignUp(client, values, { consent, captcha?, t })`
56
+ (posts `/v1/client/sign_ups` with the sign-up captcha's token).
57
+ - **Instance settings:** `fetchInstanceSettings(client)` (`GET /v1/appearance`, null on failure),
58
+ `instanceSettingsFrom(body)`, `toSignUpInput(values, consent, captchaToken?)`, and the types
59
+ `InstanceSettings` (`{ captchaFlows, signUpFields, legal }`) and `PublicSignUpField` (moved here from
60
+ `@atlasauth/react`, which re-exports them unchanged).
61
+
62
+ - **New: `resolveFrontendApi(value, { component?, allowSameOrigin? })`** — validates a provider's
63
+ `frontendApi` and returns the request base URL. Throws an **`AtlasConfigError`** (new, exported;
64
+ `code: AtlasConfigErrorCode` = `'FRONTEND_API_REQUIRED' | 'FRONTEND_API_INVALID'`) when the value is
65
+ missing, non-string, blank, not an absolute `https:` URL (`http:` only for localhost / local development hosts), or
66
+ carries credentials, a query or a fragment. `"same-origin"` (new constant `SAME_ORIGIN_FRONTEND_API`)
67
+ resolves to `""` and a root-relative path such as `"/atlas"` is kept, for reverse proxies; both are refused
68
+ with `allowSameOrigin: false` (native apps). An explicit `""` is the **deprecated** same-origin spelling: it
69
+ still resolves to `""` (one-time console warning pointing at `"same-origin"`), and is refused with
70
+ `allowSameOrigin: false`. Every framework SDK now uses it instead of `frontendApi ?? ''`.
71
+ - **New: `revokeSession(request, sessionId, { retries = 1 })`** → `RevokeSessionOutcome`
72
+ (`{ revoked, status, response?, activeSessionId? }`). Retries once on a network failure, 5xx or 429; a 404
73
+ counts as revoked; `activeSessionId` is the parked session the server promoted.
74
+ - **New: `SessionTombstones`** — remembers (memory + `sessionStorage`, bounded to 20) the session ids a tab
75
+ signed out of, so a re-boot from a still-valid refresh cookie never re-adopts them.
76
+ - **New types:** `SignOutResult` (`{ revoked: boolean; activeSessionId?: string }`), `SignOutError`
77
+ (`code: 'SIGN_OUT_REVOKE_FAILED'`, `sessionId`, `status`), `ResolveFrontendApiOptions`, `RevokeSessionOutcome`.
78
+ - **`SignOutOptions.strict?: boolean`** added (reject with `SignOutError` when the revoke fails; honoured by
79
+ `@atlasauth/react`).
80
+ - **Popup OAuth:** when the popup closes without posting a result, `startOauthPopup()` checks
81
+ `GET /v1/client` once; if a session now exists it resolves `{ error: 'popup_closed', sessionDetected: true }`
82
+ (new `PopupStartResult.sessionDetected?: boolean`) so the caller re-boots instead of treating it as a cancel.
83
+ - **End-user API key self-service.** New `listApiKeys(client, { organizationId? })`,
84
+ `createApiKey(client, input)`, `updateApiKey(client, id, { name })` and
85
+ `revokeApiKey(client, id)` drive `/v1/client/me/api_keys` through a `FapiClient`.
86
+ Added types: `UserApiKey` (`id`, `subject_type`, `subject_id`, `name`, `prefix`, `claims`,
87
+ `scopes`, optional `limits`, `last_used_at`, `expires_at`, `revoked_at`, `created_at`),
88
+ `CreatedUserApiKey` (adds the one-time `secret`, `note`), `UserApiKeyList` (`data`,
89
+ `allowedScopes`, `allowedClaims`, `maxActiveKeys`, `activeCount`) and `CreateUserApiKeyInput`
90
+ (`name`, `expiresAt`, `expiresInSeconds`, `scopes`, `claims`, `organizationId`). A refused call
91
+ throws the new `ApiKeyRequestError` (`code`, `status`, `param`, `needsReverification`).
92
+
93
+ ## 0.5.0
94
+ - **OIDC Front-Channel Logout helpers.** `runFrontChannelLogout(urls, { timeoutMs })` loads each
95
+ front-channel logout URL in a hidden, sandboxed iframe and resolves once they have all loaded or
96
+ after the timeout (default 5 s); it never throws, only loads `https:` URLs (and `http://localhost`
97
+ for development), and is a no-op without a DOM (SSR). `runFrontChannelLogoutFromResponse(res)`
98
+ reads `frontchannel_logout_urls` from a Frontend API sign-out response. The framework SDKs'
99
+ `signOut()` uses them; the new `SignOutOptions` type (`{ frontchannel?: boolean }`) is the option
100
+ they accept.
101
+
102
+
103
+ ## 0.4.0
104
+ - `AttemptView` now carries `id` (`string | null`; `null` only on the pre-attempt captcha
105
+ challenge), plus optional `object`, `identifier` and `expires_at`, matching what the server
106
+ returns, so consumers can read `attempt.id` without a cast.
107
+
108
+ ## 0.3.0
109
+ - **Captcha for any action.** New `captcha(client)` returns `config(action?)` (fetch the
110
+ centrally managed public provider + site key), `verify(token, action?)` (server-side check)
111
+ and `protect(element, action?)` (load the provider's widget script, render it, resolve the
112
+ solved token) — so an app can protect any action without hardcoding provider keys.
113
+ - New `CaptchaWidgetConfig` and `CaptchaVerification` types, plus `loadCaptchaScript()` /
114
+ `resetCaptchaScripts()`.
115
+
116
+ ## 0.2.0
117
+ - **Native-session helpers** for apps without a cookie jar (React Native, Electron, extension
118
+ service workers): `exchangeForSession()` trades a first-party OAuth access token for an Atlas
119
+ session, and `refreshNativeSession()` rotates it without cookies. Both return `null` instead
120
+ of throwing on failure.
121
+ - `NativeSessionManager` holds the current session, hands out a fresh session JWT and fires
122
+ `onChange` on every rotation so the new refresh token can be persisted.
123
+ `serializeNativeSession()` / `parseNativeSession()` help store it. New `NativeSession` type.
124
+
125
+ ## 0.1.9
126
+ - Fixed: the invisible Google overlay now uses a near-zero opacity instead of exactly `0`.
127
+ Google's button treats an element at `opacity: 0` as not visible and silently ignores the
128
+ click; the overlay stays invisible to users but now receives the click.
129
+
130
+ ## 0.1.8
131
+ - Fixed: the native Google overlay now stretches Google's button iframe, which Google injects
132
+ asynchronously, to cover the whole wrapper (re-applied via a `MutationObserver`), so clicks
133
+ near the edges no longer miss.
134
+
135
+ ## 0.1.7
136
+ - Added: `renderGoogleOverlayButton` accepts `visible` — `false` (default) keeps Google's button
137
+ as an invisible click target over your own styled button; `true` shows Google's own button.
138
+ - Added: `diagnoseProvider()` reports a provider's resolved mode, client id, overlay geometry and
139
+ whether a credential was minted this session, for debugging a dead Google button. New `Rect`,
140
+ `OverlayGeometry` and `ProviderDiagnosis` types; `resetGoogleCredentialFlag()`.
141
+ - Changed: the invisible Google overlay is stretched to fill its wrapper, and in development it
142
+ warns in the console when it is misaligned or undersized.
143
+
144
+ ## 0.1.6
145
+ - Added: `reportProviderUnavailable(client, provider, reason)` sends a best-effort beacon when a
146
+ native provider (Google) can't be used, so the failure shows up in the dashboard. New
147
+ `ProviderUnavailableReason` type (`'script_blocked' | 'not_rendered'`).
148
+ - Changed: device telemetry honours Do-Not-Track — when it is on, no persistent device id is
149
+ stored. New `doNotTrackEnabled()`.
150
+
151
+ ## 0.1.5
152
+ - Added: popup-mode social / OIDC sign-in. `startOauthPopup(client, { provider, redirectUrl })`
153
+ opens the provider in a popup, accepts the result only from the Frontend API origin, and falls
154
+ back to a full-page redirect when the popup is blocked. New `parseOAuthPopupMessage()`,
155
+ `PopupOutcome`, `PopupStartResult` and `OAuthPopupHandlers`.
156
+ - Added: `FapiClient.frontendApiOrigin`.
157
+
158
+ ## 0.1.4
159
+ - Added: `startOauthRedirect(client, { provider, redirectUrl })` starts a redirect OAuth sign-in
160
+ and returns the provider's authorization URL.
161
+
162
+ ## 0.1.3
163
+ - Added: `renderGoogleOverlayButton` accepts `oneTap` to also show Google One Tap (off by
164
+ default).
165
+
166
+ ## 0.1.2
167
+ - Added: native Google sign-in with replay protection. `mintNativeNonce(client, provider)` gets a
168
+ single-use nonce from Atlas and `nativeSignIn(client, { provider, idToken, nonce })` signs in
169
+ with a provider id token, returning the same `FlowState` as the other sign-in flows.
170
+
171
+ ## 0.1.1
172
+ - First published release.
173
+ - Sign-in / sign-up flow driver over the Frontend API (`FapiClient`, `initialFlow`, `advance`,
174
+ `startSignUp`, `prepareFactor`, `pollAttempt`) with attempt helpers (`nextStep`, `fieldErrors`,
175
+ `formErrors`).
176
+ - Session token cache with refresh scheduling (`TokenCache`) and cross-tab refresh election
177
+ (`winsElection`, `bestChannel`).
178
+ - Google Identity Services helpers (`loadGoogleIdentityServices`, `renderGoogleButton`,
179
+ `renderGoogleOverlayButton`), passkeys (`createPasskey`, `getPasskeyAssertion`,
180
+ `passkeysSupported`), Telegram and Sign-In with Ethereum request builders.
181
+ - Redirect handling (`readRedirectResult`, `stripRedirectParams`), password reset
182
+ (`startPasswordReset`, `advancePasswordReset`), step-up re-authentication
183
+ (`reauthenticate`), connecting an extra provider (`connectExternalAccount`) and provider
184
+ tokens (`getProviderToken`).
185
+ - Silent cross-site session check (`checkSession`, `handleSilentCallback`) and device
186
+ telemetry (`sendTelemetry`).
187
+ - Authorization helpers re-exported from `@atlasauth/authz` (`hasFromClaims`,
188
+ `protectFromClaims`, `evaluate`, `ForbiddenError`, …).
@@ -0,0 +1,84 @@
1
+ import type { FapiClient } from './fapi';
2
+ /**
3
+ * End-user API key self-service — a signed-in user manages their OWN `ak_` keys
4
+ * (or, when the application allows it, their active organization's) over
5
+ * `/v1/client/me/api_keys`. Cookie-authenticated through the {@link FapiClient};
6
+ * the feature is off until the application enables it.
7
+ *
8
+ * Creating a key needs a RECENT re-authentication: a stale session gets an
9
+ * {@link ApiKeyRequestError} whose `code` is `STEP_UP_REQUIRED`. Re-authenticate
10
+ * (`reauthenticate()`), then call `createApiKey` again.
11
+ */
12
+ /** An end-user API key as its owner sees it. The secret is never part of it. */
13
+ export interface UserApiKey {
14
+ object: 'api_key';
15
+ id: string;
16
+ subject_type: 'user' | 'organization';
17
+ subject_id: string;
18
+ name: string | null;
19
+ /** The public `ak_…` head — recognises a key, cannot be used as one. */
20
+ prefix: string;
21
+ claims: Record<string, unknown>;
22
+ scopes: string[] | null;
23
+ /** Usage limits on the key, when the application sets them. */
24
+ limits?: Record<string, unknown> | null;
25
+ last_used_at: number | null;
26
+ expires_at: number | null;
27
+ revoked_at: number | null;
28
+ created_at: number;
29
+ }
30
+ /** The create response: the key plus its one-time `secret`. */
31
+ export interface CreatedUserApiKey extends UserApiKey {
32
+ /** The plaintext `ak_…` key. Shown exactly once — store it now. */
33
+ secret: string;
34
+ note?: string;
35
+ }
36
+ export interface UserApiKeyList {
37
+ data: UserApiKey[];
38
+ /** The scopes a new key may carry. */
39
+ allowedScopes: string[];
40
+ /** The claim names a new key may set. */
41
+ allowedClaims: string[];
42
+ /** The most active keys this user (or organization) may hold. */
43
+ maxActiveKeys: number;
44
+ /** Active (unrevoked, unexpired) keys held now. */
45
+ activeCount: number;
46
+ }
47
+ export interface CreateUserApiKeyInput {
48
+ name?: string;
49
+ /** Absolute expiry, epoch milliseconds. Not with `expiresInSeconds`. */
50
+ expiresAt?: number;
51
+ /** Relative lifetime in seconds. Not with `expiresAt`. */
52
+ expiresInSeconds?: number;
53
+ /** Only scopes the application allows (`allowedScopes`). */
54
+ scopes?: string[];
55
+ /** Only claim names the application allows (`allowedClaims`). */
56
+ claims?: Record<string, unknown>;
57
+ /** Create the key for this (active) organization instead of the user. */
58
+ organizationId?: string;
59
+ }
60
+ /** A refused request. `code` is the server's stable error code. */
61
+ export declare class ApiKeyRequestError extends Error {
62
+ readonly code: string;
63
+ readonly status: number;
64
+ readonly param?: string;
65
+ constructor(status: number, code: string, message: string, param?: string);
66
+ /** True when the action needs a fresh re-authentication first. */
67
+ get needsReverification(): boolean;
68
+ }
69
+ /** List the user's keys (or the active organization's, with `organizationId`). */
70
+ export declare function listApiKeys(client: FapiClient, options?: {
71
+ organizationId?: string;
72
+ }): Promise<UserApiKeyList>;
73
+ /** Create a key. The returned `secret` is shown exactly once. */
74
+ export declare function createApiKey(client: FapiClient, input?: CreateUserApiKeyInput): Promise<CreatedUserApiKey>;
75
+ /** Rename a key (`null` clears the name). */
76
+ export declare function updateApiKey(client: FapiClient, id: string, input: {
77
+ name: string | null;
78
+ }): Promise<UserApiKey>;
79
+ /** Revoke a key. Immediate and permanent. */
80
+ export declare function revokeApiKey(client: FapiClient, id: string): Promise<{
81
+ object: 'api_key';
82
+ id: string;
83
+ revoked: true;
84
+ }>;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApiKeyRequestError = void 0;
4
+ exports.listApiKeys = listApiKeys;
5
+ exports.createApiKey = createApiKey;
6
+ exports.updateApiKey = updateApiKey;
7
+ exports.revokeApiKey = revokeApiKey;
8
+ /** A refused request. `code` is the server's stable error code. */
9
+ class ApiKeyRequestError extends Error {
10
+ code;
11
+ status;
12
+ param;
13
+ constructor(status, code, message, param) {
14
+ super(message);
15
+ this.name = 'ApiKeyRequestError';
16
+ this.status = status;
17
+ this.code = code;
18
+ this.param = param;
19
+ }
20
+ /** True when the action needs a fresh re-authentication first. */
21
+ get needsReverification() {
22
+ return this.code === 'STEP_UP_REQUIRED';
23
+ }
24
+ }
25
+ exports.ApiKeyRequestError = ApiKeyRequestError;
26
+ const BASE = '/v1/client/me/api_keys';
27
+ async function call(client, path, init = {}) {
28
+ const res = await client.request(path, init);
29
+ if (!res.ok || !res.data) {
30
+ const first = res.errors[0];
31
+ throw new ApiKeyRequestError(res.status, first?.code ?? 'UNKNOWN', first?.message ?? 'The request failed.', first?.param);
32
+ }
33
+ return res.data;
34
+ }
35
+ /** List the user's keys (or the active organization's, with `organizationId`). */
36
+ async function listApiKeys(client, options = {}) {
37
+ const query = options.organizationId
38
+ ? `?organization_id=${encodeURIComponent(options.organizationId)}`
39
+ : '';
40
+ const body = await call(client, `${BASE}${query}`, { method: 'GET' });
41
+ return {
42
+ data: body.data ?? [],
43
+ allowedScopes: body.allowed_scopes ?? [],
44
+ allowedClaims: body.allowed_claims ?? [],
45
+ maxActiveKeys: body.max_active_keys ?? 0,
46
+ activeCount: body.active_count ?? 0,
47
+ };
48
+ }
49
+ /** Create a key. The returned `secret` is shown exactly once. */
50
+ async function createApiKey(client, input = {}) {
51
+ const body = {};
52
+ if (input.name !== undefined)
53
+ body.name = input.name;
54
+ if (input.expiresAt !== undefined)
55
+ body.expires_at = input.expiresAt;
56
+ if (input.expiresInSeconds !== undefined)
57
+ body.expires_in_seconds = input.expiresInSeconds;
58
+ if (input.scopes !== undefined)
59
+ body.scopes = input.scopes;
60
+ if (input.claims !== undefined)
61
+ body.claims = input.claims;
62
+ if (input.organizationId !== undefined)
63
+ body.organization_id = input.organizationId;
64
+ return call(client, BASE, { method: 'POST', body: JSON.stringify(body) });
65
+ }
66
+ /** Rename a key (`null` clears the name). */
67
+ async function updateApiKey(client, id, input) {
68
+ return call(client, `${BASE}/${encodeURIComponent(id)}`, {
69
+ method: 'PATCH',
70
+ body: JSON.stringify({ name: input.name }),
71
+ });
72
+ }
73
+ /** Revoke a key. Immediate and permanent. */
74
+ async function revokeApiKey(client, id) {
75
+ return call(client, `${BASE}/${encodeURIComponent(id)}/revoke`, { method: 'POST' });
76
+ }
77
+ //# sourceMappingURL=api-keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-keys.js","sourceRoot":"","sources":["../src/api-keys.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,95 @@
1
+ import { type FieldError } from './attempt';
2
+ import { type FapiClient, type FlowState } from './fapi';
3
+ import { type FlowCaptcha } from './captcha-widget';
4
+ /**
5
+ * The captcha half of a prebuilt sign-in / sign-up form, without a framework.
6
+ *
7
+ * The Vue, Svelte, Angular, Solid and Elements bindings each hold one of these
8
+ * per form; React's `useCaptcha` hook is the same lifecycle. It is driven by the
9
+ * instance's settings (`captchaFlows` from `/v1/appearance`), so switching
10
+ * captcha on in the dashboard makes the forms render it with no code change.
11
+ *
12
+ * Lifecycle: a visible/managed widget is drawn as soon as its slot is attached,
13
+ * so the user can solve it while filling in the form; an invisible one is
14
+ * executed only when the form is submitted ({@link CaptchaController.getToken}).
15
+ * Tokens are single-use, so after a submit spent one the caller
16
+ * {@link CaptchaController.reset}s, which draws a fresh widget.
17
+ */
18
+ export interface CaptchaControllerOptions {
19
+ /** The flow's captcha settings (`captchaFlows.signUp`, …). */
20
+ flow: FlowCaptcha | null | undefined;
21
+ /** Render at all — the caller's "this flow requires a token here" decision. */
22
+ active: boolean;
23
+ /** Draw the widget as soon as the slot is attached. Default: unless invisible. */
24
+ eager?: boolean;
25
+ /** Called with the token when an eagerly drawn widget is solved. */
26
+ onSolved?: (token: string) => void;
27
+ }
28
+ export interface CaptchaController {
29
+ /** Whether the current settings render a widget at all. */
30
+ readonly needed: boolean;
31
+ /** Replace the settings; redraws an attached widget only when they changed. */
32
+ configure(options: CaptchaControllerOptions): void;
33
+ /** Attach the element the widget draws into (null detaches and removes it). */
34
+ attach(el: HTMLElement | null): void;
35
+ /**
36
+ * The solved token, rendering/executing the widget first if needed. Null when
37
+ * no captcha is needed, or the provider failed (the server then refuses with
38
+ * CAPTCHA_REQUIRED, which the caller turns into a message).
39
+ */
40
+ getToken(): Promise<string | null>;
41
+ /** Drop the (spent) widget and, for an eager one, draw a fresh one. */
42
+ reset(): void;
43
+ /** Remove the widget for good (component teardown). */
44
+ destroy(): void;
45
+ }
46
+ export declare function createCaptchaController(initial?: CaptchaControllerOptions): CaptchaController;
47
+ /**
48
+ * Swap the server's captcha refusals (CAPTCHA_REQUIRED / CAPTCHA_FAILED, which
49
+ * the server ties to the `captcha_token` param) for a localized, form-level
50
+ * message — there is no `captcha_token` input to hang a field error on. `t`
51
+ * resolves the `captcha.required` / `captcha.failed` catalog keys.
52
+ */
53
+ export declare function localizeCaptchaErrors(errors: FieldError[], t: (key: string) => string): {
54
+ errors: FieldError[];
55
+ captchaRejected: boolean;
56
+ };
57
+ /**
58
+ * The sign-in captcha settings for the flow's current step: drawn on the
59
+ * identifier step when the instance ALWAYS requires one for sign-in (so the
60
+ * first POST already carries the token), and on the `needs_captcha` challenge
61
+ * step the server answers with when it withheld the attempt (always-on, or
62
+ * risk-triggered) — where it is drawn at once (even an invisible one) and
63
+ * solving it calls `onChallengeSolved`, which should resubmit.
64
+ */
65
+ export declare function signInCaptchaOptions(flow: FlowCaptcha | null | undefined, state: FlowState, onChallengeSolved: () => void): CaptchaControllerOptions;
66
+ /**
67
+ * {@link advance} with the sign-in captcha folded in — what a prebuilt sign-in
68
+ * form calls on submit. On the identifier / `needs_captcha` step it adds the
69
+ * widget's token (re-issuing the identifier POST the challenge marker withheld);
70
+ * a token the provider rejected comes back as the same challenge, so it reports
71
+ * CAPTCHA_FAILED and draws a fresh widget (tokens are single-use). Captcha
72
+ * refusals are localized through `t`.
73
+ *
74
+ * `identifier` is the last identifier the user typed (the challenge marker
75
+ * carries none to re-send); the returned one is what to keep for next time.
76
+ */
77
+ export declare function advanceWithCaptcha(client: FapiClient, state: FlowState, values: Record<string, string>, opts: {
78
+ captcha?: CaptchaController | null;
79
+ identifier?: string;
80
+ t: (key: string) => string;
81
+ }): Promise<{
82
+ state: FlowState;
83
+ identifier: string;
84
+ }>;
85
+ /**
86
+ * Create the account from a prebuilt create-account form: takes the sign-up
87
+ * captcha's token (when one is needed), POSTs `/v1/client/sign_ups` via
88
+ * {@link startSignUp}, localizes captcha refusals and, when the create was
89
+ * refused, draws a fresh widget (the token was spent either way).
90
+ */
91
+ export declare function createSignUp(client: FapiClient, values: Record<string, string>, opts: {
92
+ consent: boolean;
93
+ captcha?: CaptchaController | null;
94
+ t: (key: string) => string;
95
+ }): Promise<FlowState>;
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCaptchaController = createCaptchaController;
4
+ exports.localizeCaptchaErrors = localizeCaptchaErrors;
5
+ exports.signInCaptchaOptions = signInCaptchaOptions;
6
+ exports.advanceWithCaptcha = advanceWithCaptcha;
7
+ exports.createSignUp = createSignUp;
8
+ const attempt_1 = require("./attempt");
9
+ const fapi_1 = require("./fapi");
10
+ const instance_settings_1 = require("./instance-settings");
11
+ const captcha_widget_1 = require("./captcha-widget");
12
+ function createCaptchaController(initial) {
13
+ let options = initial ?? { flow: undefined, active: false };
14
+ let container = null;
15
+ let handle = null;
16
+ // The handle whose token a submit already took: the next getToken() draws a
17
+ // fresh widget instead of replaying it.
18
+ let spent = null;
19
+ // The settings the current widget was drawn with, so a redraw happens only
20
+ // when they actually change.
21
+ let drawnKey = null;
22
+ const needed = () => options.active && !!options.flow && (0, captcha_widget_1.isRenderableCaptcha)(options.flow.provider);
23
+ const eager = () => options.eager ?? options.flow?.widget !== 'invisible';
24
+ const key = () => {
25
+ const f = options.flow;
26
+ return `${needed()}|${f?.provider ?? ''}|${f?.siteKey ?? ''}|${f?.widget ?? ''}|${eager()}`;
27
+ };
28
+ const remove = () => {
29
+ handle?.remove();
30
+ handle = null;
31
+ drawnKey = null;
32
+ };
33
+ const mount = () => {
34
+ remove();
35
+ const flow = options.flow;
36
+ if (!needed() || !flow || !container)
37
+ return null;
38
+ const h = (0, captcha_widget_1.renderCaptchaWidget)(flow, container);
39
+ handle = h;
40
+ drawnKey = key();
41
+ // Swallow here so a removed / failed widget is never an unhandled
42
+ // rejection; getToken() reads the same promise and reports it.
43
+ h.token.then((token) => {
44
+ if (handle === h)
45
+ options.onSolved?.(token);
46
+ }, () => undefined);
47
+ return h;
48
+ };
49
+ const reset = () => {
50
+ remove();
51
+ if (needed() && eager())
52
+ mount();
53
+ };
54
+ return {
55
+ get needed() {
56
+ return needed();
57
+ },
58
+ configure(next) {
59
+ options = next;
60
+ if (!container)
61
+ return;
62
+ if (!needed() || !eager())
63
+ remove();
64
+ else if (drawnKey !== key())
65
+ mount();
66
+ },
67
+ attach(el) {
68
+ if (el === container)
69
+ return;
70
+ remove();
71
+ container = el;
72
+ if (el && needed() && eager())
73
+ mount();
74
+ },
75
+ async getToken() {
76
+ if (!needed())
77
+ return null;
78
+ let h = handle;
79
+ if (!h || spent === h)
80
+ h = mount();
81
+ if (!h)
82
+ return null;
83
+ try {
84
+ const token = await h.token;
85
+ spent = h;
86
+ return token;
87
+ }
88
+ catch {
89
+ // The provider errored / timed out: start over with a fresh widget.
90
+ if (handle === h)
91
+ reset();
92
+ return null;
93
+ }
94
+ },
95
+ reset,
96
+ destroy() {
97
+ remove();
98
+ container = null;
99
+ },
100
+ };
101
+ }
102
+ /**
103
+ * Swap the server's captcha refusals (CAPTCHA_REQUIRED / CAPTCHA_FAILED, which
104
+ * the server ties to the `captcha_token` param) for a localized, form-level
105
+ * message — there is no `captcha_token` input to hang a field error on. `t`
106
+ * resolves the `captcha.required` / `captcha.failed` catalog keys.
107
+ */
108
+ function localizeCaptchaErrors(errors, t) {
109
+ let captchaRejected = false;
110
+ const out = errors.map((error) => {
111
+ if (!(0, captcha_widget_1.isCaptchaError)(error))
112
+ return error;
113
+ captchaRejected = true;
114
+ return {
115
+ code: error.code,
116
+ message: t(error.code === 'CAPTCHA_REQUIRED' ? 'captcha.required' : 'captcha.failed'),
117
+ };
118
+ });
119
+ return { errors: out, captchaRejected };
120
+ }
121
+ /**
122
+ * The sign-in captcha settings for the flow's current step: drawn on the
123
+ * identifier step when the instance ALWAYS requires one for sign-in (so the
124
+ * first POST already carries the token), and on the `needs_captcha` challenge
125
+ * step the server answers with when it withheld the attempt (always-on, or
126
+ * risk-triggered) — where it is drawn at once (even an invisible one) and
127
+ * solving it calls `onChallengeSolved`, which should resubmit.
128
+ */
129
+ function signInCaptchaOptions(flow, state, onChallengeSolved) {
130
+ const kind = state.attempt ? (0, attempt_1.nextStep)(state.attempt).kind : 'collect_identifier';
131
+ const challenged = kind === 'collect_captcha';
132
+ return {
133
+ flow,
134
+ active: challenged || (kind === 'collect_identifier' && (0, captcha_widget_1.flowNeedsCaptcha)(flow)),
135
+ ...(challenged ? { eager: true, onSolved: () => onChallengeSolved() } : {}),
136
+ };
137
+ }
138
+ /**
139
+ * {@link advance} with the sign-in captcha folded in — what a prebuilt sign-in
140
+ * form calls on submit. On the identifier / `needs_captcha` step it adds the
141
+ * widget's token (re-issuing the identifier POST the challenge marker withheld);
142
+ * a token the provider rejected comes back as the same challenge, so it reports
143
+ * CAPTCHA_FAILED and draws a fresh widget (tokens are single-use). Captcha
144
+ * refusals are localized through `t`.
145
+ *
146
+ * `identifier` is the last identifier the user typed (the challenge marker
147
+ * carries none to re-send); the returned one is what to keep for next time.
148
+ */
149
+ async function advanceWithCaptcha(client, state, values, opts) {
150
+ const { captcha, t } = opts;
151
+ const kind = state.attempt ? (0, attempt_1.nextStep)(state.attempt).kind : 'collect_identifier';
152
+ let identifier = opts.identifier ?? '';
153
+ let input = values;
154
+ let sentToken = false;
155
+ if (kind === 'collect_identifier' || kind === 'collect_captcha') {
156
+ if (values.identifier)
157
+ identifier = values.identifier;
158
+ if (captcha?.needed) {
159
+ const token = await captcha.getToken();
160
+ if (token) {
161
+ input = {
162
+ ...values,
163
+ identifier: identifier || (state.attempt?.identifier ?? ''),
164
+ captcha_token: token,
165
+ };
166
+ sentToken = true;
167
+ }
168
+ else if (kind === 'collect_captcha') {
169
+ // Nothing to send until the widget yields a token.
170
+ return {
171
+ state: {
172
+ ...state,
173
+ busy: false,
174
+ errors: [{ code: 'CAPTCHA_REQUIRED', message: t('captcha.required') }],
175
+ },
176
+ identifier,
177
+ };
178
+ }
179
+ }
180
+ }
181
+ let next = await (0, fapi_1.advance)(client, state, input);
182
+ const rechallenged = !!next.attempt && (0, attempt_1.nextStep)(next.attempt).kind === 'collect_captcha';
183
+ if (sentToken && rechallenged) {
184
+ next = { ...next, errors: [{ code: 'CAPTCHA_FAILED', message: t('captcha.failed') }] };
185
+ }
186
+ next = { ...next, errors: localizeCaptchaErrors(next.errors, t).errors };
187
+ if (sentToken && (rechallenged || next.errors.length > 0))
188
+ captcha?.reset();
189
+ return { state: next, identifier };
190
+ }
191
+ /**
192
+ * Create the account from a prebuilt create-account form: takes the sign-up
193
+ * captcha's token (when one is needed), POSTs `/v1/client/sign_ups` via
194
+ * {@link startSignUp}, localizes captcha refusals and, when the create was
195
+ * refused, draws a fresh widget (the token was spent either way).
196
+ */
197
+ async function createSignUp(client, values, opts) {
198
+ const token = opts.captcha ? await opts.captcha.getToken() : null;
199
+ let next = await (0, fapi_1.startSignUp)(client, (0, instance_settings_1.toSignUpInput)(values, opts.consent, token));
200
+ next = { ...next, errors: localizeCaptchaErrors(next.errors, opts.t).errors };
201
+ if (next.errors.length > 0 && opts.captcha?.needed)
202
+ opts.captcha.reset();
203
+ return next;
204
+ }
205
+ //# sourceMappingURL=captcha-controller.js.map