@lowdefy/client 6.1.0 → 7.0.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 (51) hide show
  1. package/dist/Client.js +54 -17
  2. package/dist/Context.js +30 -12
  3. package/dist/HtmlOverlay.js +127 -0
  4. package/dist/IconProvider.js +24 -0
  5. package/dist/MountEvents.js +3 -3
  6. package/dist/adapters/createRouter.js +9 -3
  7. package/dist/adapters/url.js +1 -7
  8. package/dist/auth/createAuthMethods.js +1034 -41
  9. package/dist/block/Block.js +6 -3
  10. package/dist/block/CategorySwitch.js +6 -0
  11. package/dist/block/Container.js +3 -0
  12. package/dist/block/InputContainer.js +8 -2
  13. package/dist/block/List.js +3 -0
  14. package/dist/block/LoadingBlock.js +6 -12
  15. package/dist/block/LoadingContainer.js +6 -1
  16. package/dist/block/LoadingList.js +6 -1
  17. package/dist/block/loadingBlockMethods.js +26 -0
  18. package/dist/claimCallerReload.js +38 -0
  19. package/dist/createAntdIconConfig.js +91 -0
  20. package/dist/createCallAPI.js +6 -2
  21. package/dist/createCallRequest.js +6 -2
  22. package/dist/createHandleError.js +6 -4
  23. package/dist/createIcon.js +138 -36
  24. package/dist/createPageLifecycleManager.js +172 -0
  25. package/dist/formatIconTitle.js +26 -0
  26. package/dist/getActiveLocale.js +27 -0
  27. package/dist/getContextKey.js +29 -0
  28. package/dist/getMessageIcon.js +43 -0
  29. package/dist/hasOtherRoles.js +30 -0
  30. package/dist/index.js +1 -0
  31. package/dist/initLowdefyContext.js +103 -4
  32. package/dist/isOtherCaller.js +29 -0
  33. package/dist/recordCallerStamp.js +31 -0
  34. package/dist/renderIconNodes.js +46 -0
  35. package/dist/request.js +72 -5
  36. package/dist/setLowdefyUser.js +32 -0
  37. package/dist/setupLink.js +19 -13
  38. package/dist/shouldReloadForAuthError.js +39 -0
  39. package/dist/shouldReloadForBuild.js +44 -0
  40. package/dist/shouldReloadForCaller.js +50 -0
  41. package/dist/storage/clearUserStorage.js +36 -0
  42. package/dist/storage/clearUserStorageOnOwnerChange.js +41 -0
  43. package/dist/storage/createStorage.js +48 -0
  44. package/dist/storage/listUserStorageKeys.js +36 -0
  45. package/dist/storage/readStorageItem.js +31 -0
  46. package/dist/storage/userStorageKey.js +24 -0
  47. package/dist/storage/userStoragePrefix.js +25 -0
  48. package/dist/useDarkMode.js +17 -4
  49. package/dist/useLocale.js +18 -5
  50. package/dist/websocket/createWebSocketClient.js +234 -98
  51. package/package.json +11 -10
@@ -12,59 +12,1052 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import { type, urlQuery as urlQueryFn } from '@lowdefy/helpers';
16
- function getCallbackUrl({ lowdefy, callbackUrl = {} }) {
17
- const { home, pageId, urlQuery, url } = callbackUrl;
18
- if ([
19
- !home,
20
- !pageId,
21
- !url
22
- ].filter((v)=>!v).length > 1) {
23
- throw new Error(`Invalid Link: To avoid ambiguity, only one of 'home', 'pageId' or 'url' can be defined.`);
24
- }
25
- const query = type.isNone(urlQuery) ? '' : `${urlQueryFn.stringify(urlQuery)}`;
26
- if (home === true) {
27
- return `/${lowdefy.home.configured ? '' : lowdefy.home.pageId}${query ? `?${query}` : ''}`;
28
- }
29
- if (type.isString(pageId)) {
30
- return `/${pageId}${query ? `?${query}` : ''}`;
31
- }
32
- if (type.isString(url)) {
33
- return `${url}${query ? `?${query}` : ''}`;
34
- }
35
- return undefined;
15
+ */ import { rememberTarget, resolveTarget, stopChain } from '@lowdefy/engine';
16
+ import { ConfigError, UserError } from '@lowdefy/errors';
17
+ import { type } from '@lowdefy/helpers';
18
+ import { createUrl } from '../adapters/url.js';
19
+ import setLowdefyUser from '../setLowdefyUser.js';
20
+ // Accepts only a path-absolute URL from the ?callbackUrl= query. A bare
21
+ // startsWith('/') is not enough: "//evil.com" and "/\evil.com" also start with
22
+ // "/" but are protocol-relative (browsers normalize the backslash to a slash),
23
+ // so a crafted sign-in link would navigate off-site with a fresh session. This
24
+ // value is the one target that never reaches BetterAuth on the email, phone and
25
+ // passkey paths - it goes straight into window.location.assign - so its
26
+ // server-side originCheck / trustedOrigins do not cover it.
27
+ function isAppRelativePath(value) {
28
+ return type.isString(value) && /^\/([^/\\]|$)/.test(value);
29
+ }
30
+ // The action's callbackUrl param wins; otherwise honor the callbackUrl query
31
+ // param set by the unauthenticated page redirect, so login returns to the
32
+ // page the user asked for; otherwise the app's home page. Only app-relative
33
+ // paths are accepted from the query to avoid open redirects. The query fallback
34
+ // is exclusive to the primary callbackUrl - a new-user or error destination has
35
+ // no equivalent query source, so reading it for them would misroute.
36
+ //
37
+ // Returns resolveTarget's typed, un-prefixed union (or undefined), never a
38
+ // string: the caller navigates it via the router or serializes it for a wire
39
+ // hop, and basePath is applied only at those single boundaries.
40
+ function resolveCallbackURL({ lowdefy, callbackUrl }) {
41
+ // An explicit refusal to navigate, above the ladder so a bounced sign-in
42
+ // honors it too. Absence means "go home" below, so "stay put" needs its own
43
+ // spelling - a login form in a modal or an embedded panel relies on the
44
+ // session store re-rendering the tree with the new user in place.
45
+ if (callbackUrl === false) {
46
+ return undefined;
47
+ }
48
+ // resolveTarget returns undefined for a non-object or empty target, so a bare
49
+ // string (the spelling the schemas wrongly declared before this change, still
50
+ // common in apps) falls through the ladder rather than failing the sign-in.
51
+ const explicit = resolveTarget({
52
+ lowdefy,
53
+ target: callbackUrl,
54
+ name: 'callbackUrl'
55
+ });
56
+ if (!type.isNone(explicit)) {
57
+ return explicit;
58
+ }
59
+ const window = lowdefy._internal?.globals?.window;
60
+ const fromQuery = new URLSearchParams(window?.location?.search ?? '').get('callbackUrl');
61
+ // The parser drops tabs and newlines the path test above cannot see, so
62
+ // "/\t/evil.com/x" reads as another host; only a same-origin result counts.
63
+ const parsed = isAppRelativePath(fromQuery) ? new URL(fromQuery, window.location.origin) : null;
64
+ if (parsed !== null && parsed.origin === window.location.origin) {
65
+ // The query value is a finished, basePath-prefixed URL string - both
66
+ // producers bake basePath in (renderPage.js, apiPage.js). This is the one
67
+ // boundary that parses it back to an un-prefixed page target: strip basePath
68
+ // conditionally so router.push does not re-apply it into /app/app/reports.
69
+ const basePath = lowdefy.basePath ?? '';
70
+ const pathname = basePath && parsed.pathname.startsWith(basePath) ? parsed.pathname.slice(basePath.length) : parsed.pathname;
71
+ return {
72
+ kind: 'page',
73
+ pathname,
74
+ query: parsed.search.replace(/^\?/, '')
75
+ };
76
+ }
77
+ // The bottom rung, below the query so a bounced sign-in still returns to the
78
+ // page the user asked for.
79
+ const home = resolveTarget({
80
+ lowdefy,
81
+ target: {
82
+ home: true
83
+ }
84
+ });
85
+ if (!type.isNone(home)) {
86
+ return home;
87
+ }
88
+ throw new ConfigError('Invalid callbackUrl: no destination resolved. The app has no resolvable home page - set homePageId, give an explicit callbackUrl, or use "callbackUrl: false" to stay on the page.');
89
+ }
90
+ // callbackUrl: false suppresses Lowdefy's own window.location.assign, so it is
91
+ // only honorable where that assign is the value's single consumer. The paths
92
+ // that hand callbackURL to BetterAuth make it the destination of a later hop -
93
+ // an emailed link, an OAuth return - which cannot be suppressed once the browser
94
+ // has left. Both alternatives there are worse than an error: omitting the param
95
+ // lands the user on BetterAuth's own "/" fallback, which resolves against the
96
+ // auth baseURL and so drops basePath entirely, and silently substituting the
97
+ // home default contradicts an explicit instruction.
98
+ function assertCallbackUrlNavigable({ callbackUrl, method }) {
99
+ if (callbackUrl === false) {
100
+ throw new ConfigError(`Invalid callbackUrl: "false" is not valid for ${method}, which redirects through an external hop. Give a destination.`);
101
+ }
102
+ }
103
+ // Maps the captchaToken action param onto the BetterAuth client's per-call
104
+ // fetch options as the x-captcha-response header - the header the captcha
105
+ // middleware reads. One helper so no auth method implements its own header
106
+ // plumbing; the token never rides in the request body. Tokens are single-use
107
+ // and short-lived: a failed submit consumes the token, so retry chains reset
108
+ // the Captcha block for a fresh one.
109
+ function captchaFetchOptions(captchaToken) {
110
+ if (type.isNone(captchaToken)) {
111
+ return {};
112
+ }
113
+ return {
114
+ fetchOptions: {
115
+ headers: {
116
+ 'x-captcha-response': captchaToken
117
+ }
118
+ }
119
+ };
120
+ }
121
+ // BetterAuth client calls resolve with { data, error } instead of throwing -
122
+ // rethrow so action onError chains fire on failed sign-in attempts.
123
+ //
124
+ // A 4xx is the auth server rejecting the attempt: a wrong password, an expired
125
+ // or already-used link, an invalid code, a stale OAuth authorization query.
126
+ // That is an expected outcome of the user's interaction, not a fault in the
127
+ // page config, so it is a UserError - the chain's catch actions still run and
128
+ // the message still displays, but it logs to the browser console only instead
129
+ // of being posted to /api/client-error and logged on the server as an
130
+ // ActionError against the action's config location. Anything else (a network
131
+ // failure, a 5xx) is thrown plain so the action runner wraps it as an
132
+ // ActionError and reports it. The auth server's code and status ride along as
133
+ // metaData so page config can branch on them.
134
+ async function unwrap(promise) {
135
+ const { data, error } = await promise;
136
+ if (error) {
137
+ const message = error.message ?? error.statusText ?? 'Authentication failed.';
138
+ if (type.isInt(error.status) && error.status >= 400 && error.status < 500) {
139
+ throw new UserError(message, {
140
+ metaData: {
141
+ code: error.code,
142
+ status: error.status
143
+ }
144
+ });
145
+ }
146
+ const authError = new Error(message);
147
+ authError.code = error.code;
148
+ authError.status = error.status;
149
+ throw authError;
150
+ }
151
+ return data;
152
+ }
153
+ // The BetterAuth passkey client runs the WebAuthn ceremony in the browser
154
+ // itself, so it rejects with a DOMException when the user dismisses or aborts
155
+ // the prompt, the authenticator is already enrolled, or the prompt times out.
156
+ // Those are expected outcomes of the user's interaction, not config or system
157
+ // faults, so they surface as UserError. Anything else propagates.
158
+ const WEB_AUTHN_USER_ERROR_NAMES = new Set([
159
+ 'AbortError',
160
+ 'InvalidStateError',
161
+ 'NotAllowedError',
162
+ 'TimeoutError'
163
+ ]);
164
+ async function runWebAuthnCeremony(promise) {
165
+ try {
166
+ return await promise;
167
+ } catch (error) {
168
+ if (WEB_AUTHN_USER_ERROR_NAMES.has(error?.name)) {
169
+ throw new UserError(error.message, {
170
+ cause: error,
171
+ metaData: {
172
+ code: error.name
173
+ }
174
+ });
175
+ }
176
+ throw error;
177
+ }
36
178
  }
37
179
  function createAuthMethods(lowdefy, auth) {
38
- // login and logout are Lowdefy function that handle action params
39
- // signIn and signOut are the next-auth methods
40
- function login({ authUrl, callbackUrl, providerId, ...rest } = {}) {
41
- if (type.isNone(providerId) && auth.authConfig?.providers.length === 1) {
42
- providerId = auth.authConfig.providers[0].id;
180
+ const router = lowdefy._internal.router;
181
+ // Every same-origin destination navigates through the router, which applies
182
+ // basePath once through createUrl and hard-reloads (forceReload) so the server
183
+ // re-runs the page auth fork - the new session's roles come from the active
184
+ // member row the client session does not carry, so a soft push would leave it
185
+ // half-applied. An external target leaves the app, so it cannot route through
186
+ // the in-app router. A resolved page target is un-prefixed; the router
187
+ // re-applies basePath, so nothing double-prefixes. A page target that names a
188
+ // page joins the path memory before the push, as a followed link does. The
189
+ // session provider's reloads on a session change are suppressed first: the
190
+ // full load renders the new session anyway, and a reload racing it would
191
+ // land back on the page being left.
192
+ function navigateToTarget(target) {
193
+ if (type.isNone(target)) {
194
+ return;
195
+ }
196
+ auth.suppressSessionReload();
197
+ if (target.kind === 'external') {
198
+ lowdefy._internal.globals.window.location.assign(target.href);
199
+ return;
200
+ }
201
+ rememberTarget({
202
+ lowdefy,
203
+ target
204
+ });
205
+ router.push({
206
+ pathname: target.pathname,
207
+ query: target.query,
208
+ forceReload: true
209
+ });
210
+ }
211
+ // The wire form of a target: a string BetterAuth performs a later redirect hop
212
+ // to (an emailed link, an OAuth return). A page target is baked to its
213
+ // basePath-prefixed URL through the single boundary; an external target is
214
+ // already a whole URL. Off-site safety for the resolved URL is BetterAuth's
215
+ // server-side originCheck / trustedOrigins, not here.
216
+ function serializeTarget(target) {
217
+ if (type.isNone(target)) {
218
+ return undefined;
219
+ }
220
+ if (target.kind === 'external') {
221
+ return target.href;
222
+ }
223
+ return createUrl({
224
+ basePath: lowdefy.basePath ?? '',
225
+ pathname: target.pathname,
226
+ query: target.query
227
+ });
228
+ }
229
+ // The target of an authPages role (verifyEmail, resetPassword), or undefined
230
+ // when the app has not set it.
231
+ function resolveAuthPageTarget(role) {
232
+ const page = auth.authConfig?.authPages?.[role];
233
+ if (!type.isString(page)) {
234
+ return undefined;
235
+ }
236
+ return resolveTarget({
237
+ lowdefy,
238
+ target: {
239
+ url: page
240
+ },
241
+ name: `authPages.${role}`
242
+ });
243
+ }
244
+ // Where an emailed verification link lands once BetterAuth has verified the
245
+ // address: the action's own callbackUrl, else the app's authPages.verifyEmail
246
+ // page. BetterAuth appends ?error= to it when the link is invalid or has
247
+ // expired, so that page can say so. Undefined when neither is set, leaving the
248
+ // caller's own destination.
249
+ function resolveVerifyEmailTarget({ callbackUrl }) {
250
+ const explicit = resolveTarget({
251
+ lowdefy,
252
+ target: callbackUrl,
253
+ name: 'callbackUrl'
254
+ });
255
+ if (!type.isNone(explicit)) {
256
+ return explicit;
43
257
  }
44
- return auth.signIn(providerId, {
258
+ return resolveAuthPageTarget('verifyEmail');
259
+ }
260
+ // The engine owns the two-factor challenge destination on every sign-in path,
261
+ // so every method that can receive a challenge instead of a session navigates
262
+ // through here. Leaving it to the login page makes correctness opt-in: a page
263
+ // that omits the branch leaves an enrolled user unable to sign in at all and
264
+ // with nothing to show for the click.
265
+ //
266
+ // The destination the user asked for rides along as ?callbackUrl= for the
267
+ // challenge page to spend once the challenge is done - a wire value stapled
268
+ // onto the page's own query rather than replacing it, so an authPages.twoFactor
269
+ // carrying a query of its own keeps it. Only an app-relative (kind:'page')
270
+ // return is stapled, and only onto an in-app (kind:'page') challenge page: the
271
+ // value was validated at the ladder's front door so no open-redirect guard is
272
+ // needed here, but an off-app challenge page must not carry the app's cargo.
273
+ //
274
+ // Returns false rather than throwing when there is no page to navigate to: the
275
+ // build check guarantees authPages.twoFactor exists whenever two-factor is
276
+ // enabled, and a throw here would turn that guarantee into a failed sign-in.
277
+ // The no-window case is legitimate too (server rendering, tests). The caller
278
+ // then falls through to returning the response without navigating.
279
+ function navigateToTwoFactorChallenge({ callbackTarget }) {
280
+ const twoFactorPage = auth.authConfig?.authPages?.twoFactor;
281
+ if (!type.isString(twoFactorPage)) {
282
+ return false;
283
+ }
284
+ const target = resolveTarget({
285
+ lowdefy,
286
+ target: {
287
+ url: twoFactorPage
288
+ },
289
+ name: 'authPages.twoFactor'
290
+ });
291
+ const window = lowdefy._internal?.globals?.window;
292
+ if (type.isNone(target) || !window) {
293
+ return false;
294
+ }
295
+ if (target.kind === 'page') {
296
+ const ownQuery = Object.fromEntries(new URLSearchParams(target.query));
297
+ const query = callbackTarget?.kind === 'page' ? {
298
+ ...ownQuery,
299
+ callbackUrl: serializeTarget(callbackTarget)
300
+ } : ownQuery;
301
+ navigateToTarget({
302
+ kind: 'page',
303
+ pathname: target.pathname,
304
+ query
305
+ });
306
+ return true;
307
+ }
308
+ navigateToTarget(target);
309
+ return true;
310
+ }
311
+ // login and logout are Lowdefy functions that handle action params;
312
+ // the auth object provides the BetterAuth client methods.
313
+ async function login({ callbackUrl, captchaToken, email, errorCallbackUrl, magicLink, newUserCallbackUrl, password, phoneNumber, providerId, ...rest } = {}) {
314
+ const callbackTarget = resolveCallbackURL({
315
+ lowdefy,
316
+ callbackUrl
317
+ });
318
+ // The wire form for the routes that hand the destination to BetterAuth for a
319
+ // later redirect hop; the direct-navigation routes use callbackTarget.
320
+ const callbackURL = serializeTarget(callbackTarget);
321
+ const captchaOptions = captchaFetchOptions(captchaToken);
322
+ const providers = auth.authConfig?.providers ?? [];
323
+ // The magic-link and social/OAuth routes accept these two extra callbacks;
324
+ // resolve each as a structured target, serialize to the wire string, and
325
+ // forward only the ones the app supplied, so BetterAuth's own
326
+ // default-to-callbackURL stands otherwise.
327
+ const routingCallbacks = {};
328
+ const newUserCallbackURL = serializeTarget(resolveTarget({
329
+ lowdefy,
330
+ target: newUserCallbackUrl,
331
+ name: 'newUserCallbackUrl'
332
+ }));
333
+ if (!type.isNone(newUserCallbackURL)) {
334
+ routingCallbacks.newUserCallbackURL = newUserCallbackURL;
335
+ }
336
+ const errorCallbackURL = serializeTarget(resolveTarget({
337
+ lowdefy,
338
+ target: errorCallbackUrl,
339
+ name: 'errorCallbackUrl'
340
+ }));
341
+ if (!type.isNone(errorCallbackURL)) {
342
+ routingCallbacks.errorCallbackURL = errorCallbackURL;
343
+ } else {
344
+ // No caller-supplied error destination: default to the app's declared
345
+ // auth-error page (authPages.error), serialized through the same single
346
+ // boundary so an app-relative page gains basePath and an absolute
347
+ // authPages.error passes through untouched. So a failed magic-link verify
348
+ // lands there with ?error= intact instead of on the success page. Mirrors
349
+ // the server-side onAPIError.errorURL default on the one path that default
350
+ // cannot structurally reach. Omitted when authPages.error is unset,
351
+ // letting BetterAuth's fallback stand.
352
+ const errorPage = auth.authConfig?.authPages?.error;
353
+ if (type.isString(errorPage)) {
354
+ routingCallbacks.errorCallbackURL = serializeTarget(resolveTarget({
355
+ lowdefy,
356
+ target: {
357
+ url: errorPage
358
+ },
359
+ name: 'errorCallbackUrl'
360
+ }));
361
+ }
362
+ }
363
+ if (type.isNone(providerId) && type.isNone(email) && type.isNone(phoneNumber) && providers.length === 1) {
364
+ providerId = providers[0].id;
365
+ }
366
+ if (!type.isNone(providerId)) {
367
+ assertCallbackUrlNavigable({
368
+ callbackUrl,
369
+ method: 'Login with a provider'
370
+ });
371
+ const provider = providers.find((configured)=>configured.id === providerId);
372
+ if (type.isNone(provider)) {
373
+ throw new ConfigError(`Login provider "${providerId}" is not a configured auth provider.`);
374
+ }
375
+ // BetterAuth registers a GenericOAuth provider as a social provider keyed
376
+ // by its providerId, the Lowdefy provider id; built-in ones are keyed by
377
+ // their lowercase type.
378
+ const socialProvider = provider.type === 'GenericOAuth' ? provider.id : provider.type.toLowerCase();
379
+ const data = await unwrap(auth.signInSocial({
380
+ provider: socialProvider,
381
+ callbackURL,
382
+ ...routingCallbacks,
383
+ ...rest,
384
+ ...captchaOptions
385
+ }));
386
+ // An idToken passed through rest signs in within this request, so a
387
+ // 2FA-enrolled user gets the challenge here, as on the password paths.
388
+ if (data?.twoFactorRedirect) {
389
+ return navigateToTwoFactorChallenge({
390
+ callbackTarget
391
+ }) ? stopChain(data) : data;
392
+ }
393
+ // The redirect branch leaves navigation to BetterAuth's redirect to the
394
+ // IdP; a completed id token sign-in answers redirect: false with no url,
395
+ // so navigate to the callback as the password paths do.
396
+ const window = lowdefy._internal?.globals?.window;
397
+ if (data?.redirect === false && callbackTarget && window) {
398
+ navigateToTarget(callbackTarget);
399
+ }
400
+ return data;
401
+ }
402
+ if (magicLink === true) {
403
+ assertCallbackUrlNavigable({
404
+ callbackUrl,
405
+ method: 'Login with magicLink'
406
+ });
407
+ if (!type.isString(email)) {
408
+ throw new Error('Login with magicLink requires an "email" param.');
409
+ }
410
+ return unwrap(auth.signInMagicLink({
411
+ email,
412
+ callbackURL,
413
+ ...routingCallbacks,
414
+ ...rest,
415
+ ...captchaOptions
416
+ }));
417
+ }
418
+ if (!type.isNone(phoneNumber)) {
419
+ if (!type.isString(password)) {
420
+ throw new Error('Login with phoneNumber requires a "password" param.');
421
+ }
422
+ const data = await unwrap(auth.signInPhoneNumber({
423
+ phoneNumber,
424
+ password,
425
+ ...rest,
426
+ ...captchaOptions
427
+ }));
428
+ if (data?.twoFactorRedirect) {
429
+ return navigateToTwoFactorChallenge({
430
+ callbackTarget
431
+ }) ? stopChain(data) : data;
432
+ }
433
+ const window = lowdefy._internal?.globals?.window;
434
+ if (callbackTarget && window) {
435
+ navigateToTarget(callbackTarget);
436
+ }
437
+ return data;
438
+ }
439
+ if (!type.isNone(email) || !type.isNone(password)) {
440
+ const data = await unwrap(auth.signInEmail({
441
+ email,
442
+ password,
443
+ ...rest,
444
+ ...captchaOptions
445
+ }));
446
+ // A 2FA-enrolled user gets a challenge, not a session - navigate to
447
+ // authPages.twoFactor instead of the callback URL, where TwoFactorVerify
448
+ // completes the session. The response still carries twoFactorRedirect and
449
+ // twoFactorMethods for an app that wants to read them, but the routing is
450
+ // not the app's job.
451
+ //
452
+ // The chain ends here: without that, the step the app put after Login
453
+ // races the challenge page load and re-renders the app with no session.
454
+ // When there was nothing to navigate to the chain continues as before, so
455
+ // a chain is never stranded by a navigation that did not happen.
456
+ if (data?.twoFactorRedirect) {
457
+ return navigateToTwoFactorChallenge({
458
+ callbackTarget
459
+ }) ? stopChain(data) : data;
460
+ }
461
+ const window = lowdefy._internal?.globals?.window;
462
+ if (callbackTarget && window) {
463
+ navigateToTarget(callbackTarget);
464
+ }
465
+ return data;
466
+ }
467
+ throw new Error('Login requires a "providerId", "email" and "password", "phoneNumber" and "password", or "magicLink: true" param.');
468
+ }
469
+ // Creates an email/password account (BetterAuth's one signup endpoint).
470
+ // Social, magic-link and passkey have no separate signup - the account is
471
+ // created on first sign-in via login - so SignUp is email/password only.
472
+ async function signUp({ callbackUrl, captchaToken, email, name, password, ...rest } = {}) {
473
+ // signUp is the case where the two consumers diverge: Lowdefy navigates on
474
+ // the session-bearing success, and BetterAuth puts the callbackURL in the
475
+ // verification email. An explicit callbackUrl serves both, so it cannot be
476
+ // suppressed for one of them alone. Without one, Lowdefy navigates down the
477
+ // callback ladder and the email lands on authPages.verifyEmail.
478
+ assertCallbackUrlNavigable({
479
+ callbackUrl,
480
+ method: 'SignUp'
481
+ });
482
+ const callbackTarget = resolveCallbackURL({
483
+ lowdefy,
484
+ callbackUrl
485
+ });
486
+ const verifyEmailTarget = resolveVerifyEmailTarget({
487
+ callbackUrl
488
+ }) ?? callbackTarget;
489
+ const data = await unwrap(auth.signUpEmail({
490
+ email,
491
+ password,
492
+ name,
493
+ callbackURL: serializeTarget(verifyEmailTarget),
45
494
  ...rest,
46
- callbackUrl: getCallbackUrl({
47
- lowdefy,
48
- callbackUrl
49
- })
50
- }, authUrl?.urlQuery);
495
+ ...captchaFetchOptions(captchaToken)
496
+ }));
497
+ // With requireEmailVerification the response carries no session - do not
498
+ // navigate; the page shows a "verify your email" message instead.
499
+ const window = lowdefy._internal?.globals?.window;
500
+ if (data?.token && callbackTarget && window) {
501
+ navigateToTarget(callbackTarget);
502
+ }
503
+ return data;
51
504
  }
52
- function logout({ callbackUrl, redirect } = {}) {
53
- return auth.signOut({
54
- callbackUrl: getCallbackUrl({
55
- lowdefy,
56
- callbackUrl
57
- }),
58
- redirect
505
+ // Deliberately the one method outside resolveCallbackURL's ladder: it reads
506
+ // neither the ?callbackUrl= query nor the home default. With no callback the
507
+ // session provider's post-sign-out reload takes over and the server re-applies
508
+ // the page auth fork, which is the right landing for a sign-out - sending a
509
+ // signed-out user to the home page could land them on a page they may no
510
+ // longer see.
511
+ async function logout({ callbackUrl } = {}) {
512
+ const callbackTarget = resolveTarget({
513
+ lowdefy,
514
+ target: callbackUrl
59
515
  });
516
+ const window = lowdefy._internal?.globals?.window;
517
+ const willNavigate = Boolean(callbackTarget && window);
518
+ if (willNavigate && auth.suppressSessionReload) {
519
+ // The sign-out reload in the session provider would race the callback
520
+ // navigation - suppress it for this sign-out.
521
+ auth.suppressSessionReload();
522
+ }
523
+ const data = await unwrap(auth.signOut());
524
+ // The next person on this browser must not read the signed-out person's data. Clearing
525
+ // before the navigation or the session provider's reload keeps it from any later page.
526
+ lowdefy._internal.storage.clearUserStorage({
527
+ keepUserId: null
528
+ });
529
+ if (willNavigate) {
530
+ navigateToTarget(callbackTarget);
531
+ }
532
+ return data;
533
+ }
534
+ // Switches the session's active organization - the session-scoped org
535
+ // switch. Roles and attributes resolve from the new active member row
536
+ // server-side; chain UpdateSession after to re-sync the client.
537
+ async function setActiveOrganization({ organizationId, organizationSlug } = {}) {
538
+ if (type.isNone(organizationId) && type.isNone(organizationSlug)) {
539
+ throw new Error('SetActiveOrganization requires an "organizationId" or "organizationSlug" param.');
540
+ }
541
+ return unwrap(auth.setActiveOrganization({
542
+ organizationId,
543
+ organizationSlug
544
+ }));
60
545
  }
546
+ // Refreshes the BetterAuth client session store through an awaited
547
+ // store refetch, bypassing the cookie cache (a live re-resolve) so role,
548
+ // attribute or session changes surface immediately instead of after
549
+ // cookieCache.maxAge. The store holds a fresh session user before this
550
+ // resolves. Roles and merged attributes come from the server-resolved
551
+ // caller - the active member row read the base session does not carry.
61
552
  async function updateSession() {
62
- const session = await auth.getSession();
63
- lowdefy.user = session?.user ?? null;
553
+ const session = await unwrap(auth.refreshSession({
554
+ disableCookieCache: true
555
+ }));
556
+ const { user } = await auth.getResolvedUser();
557
+ if (session && type.isNone(user)) {
558
+ // A session with no resolved caller is the admission wall rejecting a
559
+ // real session, not a logout - surface it instead of nulling the caller.
560
+ throw new Error('UpdateSession failed: a session is active but the server resolved no user.');
561
+ }
562
+ if (auth.updateResolvedUser) {
563
+ auth.updateResolvedUser(user ?? null);
564
+ }
565
+ setLowdefyUser({
566
+ lowdefy,
567
+ user
568
+ });
569
+ }
570
+ // Accepts an organization invitation. BetterAuth's endpoint enforces the
571
+ // session-email to invitation-email match - no re-check here.
572
+ async function acceptInvitation({ invitationId } = {}) {
573
+ if (!type.isString(invitationId)) {
574
+ throw new Error('AcceptInvitation requires an "invitationId" param.');
575
+ }
576
+ return unwrap(auth.acceptInvitation({
577
+ invitationId
578
+ }));
579
+ }
580
+ // Removes the caller's own membership from the active organization.
581
+ // BetterAuth's leave endpoint requires an explicit organizationId, so it
582
+ // is resolved from the session's active organization - the action can
583
+ // only ever leave the org the caller is acting in. Authorization is
584
+ // BetterAuth's own per-org access control, enforced server-side against
585
+ // the caller's member role in the active organization.
586
+ async function leaveOrganization() {
587
+ const session = await unwrap(auth.getSession());
588
+ const organizationId = session?.session?.activeOrganizationId;
589
+ if (type.isNone(organizationId)) {
590
+ throw new UserError('LeaveOrganization requires an active organization on the session.');
591
+ }
592
+ return unwrap(auth.leaveOrganization({
593
+ organizationId
594
+ }));
595
+ }
596
+ // The signed-in user's own memberships from BetterAuth's org plugin -
597
+ // keyed on the consent session alone, not the active organization, so a
598
+ // consent page can list every workspace the caller may connect. Returns
599
+ // the array of organization rows ({ id, name, slug, ... }). Under the
600
+ // pinned org policy the /organization/list route is disabled server-side
601
+ // and this rejects.
602
+ async function listOrganizations() {
603
+ return unwrap(auth.listOrganizations());
604
+ }
605
+ // Approves or denies the pending OAuth authorization request. The
606
+ // oauth-provider plugin redirected here with the signed authorization
607
+ // query in the page URL; none of it is posted back through params - the
608
+ // auth client's oauth-provider fetch plugin stamps it onto the request
609
+ // body as oauth_query from window.location.search, and the server trusts
610
+ // it only after verifying its signature. The consenting identity rides the
611
+ // session cookie. So the call must run while the page still holds the
612
+ // query it arrived with. Optional scope / claims params narrow the grant
613
+ // to a subset of what was requested; omitted, everything requested is
614
+ // granted. Resolves with { redirect: true, url } - url is the OAuth
615
+ // client's redirect URI, carrying an authorization code on accept and
616
+ // error=access_denied on deny. The auth client's built-in redirect fetch
617
+ // plugin navigates the browser there as the response lands; the url is
618
+ // still returned so page config owns any navigation it wants to do
619
+ // instead.
620
+ async function oauth2Consent({ accept, ...params } = {}) {
621
+ if (!type.isBoolean(accept)) {
622
+ throw new Error('OAuthConsent requires a boolean "accept" param.');
623
+ }
624
+ return unwrap(auth.oauth2Consent({
625
+ accept,
626
+ ...params
627
+ }));
628
+ }
629
+ // Resumes the pending authorization after the post-login organization
630
+ // choice. Same contract as oauth2Consent: the signed query rides from
631
+ // window.location.search via the auth client's oauth-provider fetch plugin,
632
+ // and the session cookie carries the identity - so the page must call this
633
+ // before navigating, after SetActiveOrganization has recorded the chosen
634
+ // organization on the session. Resolves with { redirect: true, url } - the
635
+ // consent page, or the OAuth client's redirect URI when consent for that
636
+ // organization already stands.
637
+ async function oauth2Continue() {
638
+ return unwrap(auth.oauth2Continue({
639
+ postLogin: true
640
+ }));
641
+ }
642
+ async function changePassword({ currentPassword, newPassword, revokeOtherSessions } = {}) {
643
+ if (!type.isString(currentPassword) || !type.isString(newPassword)) {
644
+ throw new Error('ChangePassword requires "currentPassword" and "newPassword" params.');
645
+ }
646
+ return unwrap(auth.changePassword({
647
+ currentPassword,
648
+ newPassword,
649
+ revokeOtherSessions
650
+ }));
651
+ }
652
+ async function passkeyDelete({ passkeyId } = {}) {
653
+ if (!type.isString(passkeyId)) {
654
+ throw new Error('PasskeyDelete requires a "passkeyId" param.');
655
+ }
656
+ return unwrap(auth.deletePasskey({
657
+ id: passkeyId
658
+ }));
659
+ }
660
+ // Renames a passkey. The action param passkeyId maps onto BetterAuth's id,
661
+ // matching passkeyDelete. Ownership is enforced server-side, so a caller can
662
+ // only rename their own passkey.
663
+ async function passkeyUpdate({ name, passkeyId } = {}) {
664
+ if (!type.isString(passkeyId)) {
665
+ throw new Error('PasskeyUpdate requires a "passkeyId" param.');
666
+ }
667
+ if (!type.isString(name)) {
668
+ throw new Error('PasskeyUpdate requires a "name" param.');
669
+ }
670
+ return unwrap(auth.updatePasskey({
671
+ id: passkeyId,
672
+ name
673
+ }));
674
+ }
675
+ // The BetterAuth client method runs the whole WebAuthn browser ceremony
676
+ // itself - it fetches the registration options, prompts the authenticator
677
+ // and verifies the result - so the ceremony runs inside the action.
678
+ async function passkeyRegister(params = {}) {
679
+ return runWebAuthnCeremony(unwrap(auth.addPasskey(params)));
680
+ }
681
+ // Passkey sign-in: the BetterAuth client method runs the whole WebAuthn
682
+ // assertion ceremony itself (options fetch, navigator.credentials.get(),
683
+ // verification), so the ceremony runs inside the action. verify-authentication
684
+ // creates the session, so on success navigate to callbackUrl like login's
685
+ // email path. A successful assertion is terminal - passkey never returns a
686
+ // two-factor challenge - so there is no twoFactorRedirect branch.
687
+ async function passkeySignIn({ callbackUrl } = {}) {
688
+ const callbackTarget = resolveCallbackURL({
689
+ lowdefy,
690
+ callbackUrl
691
+ });
692
+ const data = await runWebAuthnCeremony(unwrap(auth.signInPasskey()));
693
+ const window = lowdefy._internal?.globals?.window;
694
+ if (callbackTarget && window) {
695
+ navigateToTarget(callbackTarget);
696
+ }
697
+ return data;
698
+ }
699
+ // Dispatches by parameter, matching login: a phoneNumber param requests the
700
+ // reset code over SMS (the "phone.passwordReset.send" hook), otherwise
701
+ // email carries the reset link. BetterAuth sends that link to redirectTo
702
+ // with ?token= (or ?error=INVALID_TOKEN) appended, and without one the link
703
+ // fails, so it defaults to the app's authPages.resetPassword page.
704
+ async function requestPasswordReset({ captchaToken, email, phoneNumber, redirectTo, ...rest } = {}) {
705
+ const captchaOptions = captchaFetchOptions(captchaToken);
706
+ if (type.isString(phoneNumber)) {
707
+ return unwrap(auth.phoneNumberRequestPasswordReset({
708
+ phoneNumber,
709
+ ...rest,
710
+ ...captchaOptions
711
+ }));
712
+ }
713
+ if (!type.isString(email)) {
714
+ throw new Error('RequestPasswordReset requires an "email" or "phoneNumber" param.');
715
+ }
716
+ return unwrap(auth.requestPasswordReset({
717
+ email,
718
+ redirectTo: redirectTo ?? serializeTarget(resolveAuthPageTarget('resetPassword')),
719
+ ...rest,
720
+ ...captchaOptions
721
+ }));
722
+ }
723
+ // Dispatches by parameter: a phoneNumber param resets with the SMS otp,
724
+ // otherwise token carries the emailed reset link's token.
725
+ async function resetPassword({ newPassword, otp, phoneNumber, token, ...rest } = {}) {
726
+ if (!type.isString(newPassword)) {
727
+ throw new Error('ResetPassword requires a "newPassword" param.');
728
+ }
729
+ if (type.isString(phoneNumber)) {
730
+ if (!type.isString(otp)) {
731
+ throw new Error('ResetPassword with phoneNumber requires an "otp" param.');
732
+ }
733
+ return unwrap(auth.phoneNumberResetPassword({
734
+ phoneNumber,
735
+ otp,
736
+ newPassword,
737
+ ...rest
738
+ }));
739
+ }
740
+ return unwrap(auth.resetPassword({
741
+ newPassword,
742
+ token,
743
+ ...rest
744
+ }));
745
+ }
746
+ // Revokes every session except the current one - the only session revoke
747
+ // exposed to config; per-session revoke would put session tokens in
748
+ // config reach.
749
+ async function revokeOtherSessions() {
750
+ return unwrap(auth.revokeOtherSessions());
751
+ }
752
+ // Resends the verification email for an unverified account - an unverified
753
+ // user holds no session, so this is a public call. The callbackUrl is
754
+ // where the emailed verification link lands after verifying, matching the
755
+ // signUp param of the same name, and authPages.verifyEmail without one.
756
+ async function sendVerificationEmail({ callbackUrl, captchaToken, email, ...rest } = {}) {
757
+ if (!type.isString(email)) {
758
+ throw new Error('SendVerificationEmail requires an "email" param.');
759
+ }
760
+ assertCallbackUrlNavigable({
761
+ callbackUrl,
762
+ method: 'SendVerificationEmail'
763
+ });
764
+ const verifyEmailTarget = resolveVerifyEmailTarget({
765
+ callbackUrl
766
+ }) ?? resolveCallbackURL({
767
+ lowdefy,
768
+ callbackUrl
769
+ });
770
+ return unwrap(auth.sendVerificationEmail({
771
+ email,
772
+ callbackURL: serializeTarget(verifyEmailTarget),
773
+ ...rest,
774
+ ...captchaFetchOptions(captchaToken)
775
+ }));
776
+ }
777
+ // Sends a one-time sign-in code to the email address. The code-only entry
778
+ // point; when magicLink is also enabled the Login action with magicLink: true
779
+ // sends the link email that already carries the code, so an app offering both
780
+ // needs only the one send.
781
+ async function emailOtpSend({ captchaToken, email, ...rest } = {}) {
782
+ if (!type.isString(email)) {
783
+ throw new Error('EmailOtpSend requires an "email" param.');
784
+ }
785
+ // Only the sign-in code is wired server-side - the other OTP types
786
+ // BetterAuth can mint have no email flow behind them - so the type is not
787
+ // an app-facing parameter.
788
+ return unwrap(auth.emailOtpSend({
789
+ email,
790
+ type: 'sign-in',
791
+ ...rest,
792
+ ...captchaFetchOptions(captchaToken)
793
+ }));
794
+ }
795
+ // The code sign-in: on success BetterAuth sets the session cookie (creating
796
+ // the user on first sign-in unless disableSignUp), and the browser navigates
797
+ // on the resolved callbackURL like every other method that mints a session.
798
+ async function emailOtpVerify({ callbackUrl, email, otp, ...rest } = {}) {
799
+ if (!type.isString(email) || !type.isString(otp)) {
800
+ throw new Error('EmailOtpVerify requires "email" and "otp" params.');
801
+ }
802
+ // Resolved before the call, as login and phoneNumberVerify do, so a
803
+ // misconfigured destination throws before the code is consumed rather than
804
+ // after. No assertCallbackUrlNavigable: this is a JSON sign-in with no
805
+ // redirect hop, so the resolved value's single consumer is the navigation
806
+ // below and callbackUrl: false is honourable.
807
+ const callbackTarget = resolveCallbackURL({
808
+ lowdefy,
809
+ callbackUrl
810
+ });
811
+ const data = await unwrap(auth.emailOtpVerify({
812
+ email,
813
+ otp,
814
+ ...rest
815
+ }));
816
+ // An enrolled user signing in with a code gets a challenge instead of a
817
+ // session, in the same JSON shape the password paths return, so the
818
+ // navigation is the one Login performs. The halt keeps the app's remaining
819
+ // steps from re-rendering with no session while the challenge page load is
820
+ // still in flight.
821
+ if (data?.twoFactorRedirect) {
822
+ return navigateToTwoFactorChallenge({
823
+ callbackTarget
824
+ }) ? stopChain(data) : data;
825
+ }
826
+ const window = lowdefy._internal?.globals?.window;
827
+ if (data?.token && callbackTarget && window) {
828
+ navigateToTarget(callbackTarget);
829
+ }
830
+ return data;
831
+ }
832
+ // Sends a sign-in/verification OTP over SMS through the app's
833
+ // "phone.otp.send" hook binding.
834
+ async function phoneNumberSendOtp({ captchaToken, phoneNumber, ...rest } = {}) {
835
+ if (!type.isString(phoneNumber)) {
836
+ throw new Error('PhoneNumberSendOtp requires a "phoneNumber" param.');
837
+ }
838
+ return unwrap(auth.phoneNumberSendOtp({
839
+ phoneNumber,
840
+ ...rest,
841
+ ...captchaFetchOptions(captchaToken)
842
+ }));
843
+ }
844
+ // The OTP sign-in: on success BetterAuth sets the session cookie (and
845
+ // creates the account under signUpOnVerification), and the browser navigates
846
+ // on the resolved callbackURL like every other method that mints a session.
847
+ async function phoneNumberVerify({ callbackUrl, code, phoneNumber, ...rest } = {}) {
848
+ if (!type.isString(phoneNumber) || !type.isString(code)) {
849
+ throw new Error('PhoneNumberVerify requires "phoneNumber" and "code" params.');
850
+ }
851
+ // Resolved before the call, as login and signUp do, so a misconfigured
852
+ // destination throws before the OTP is consumed rather than after. No
853
+ // assertCallbackUrlNavigable: the resolved value has one consumer, the assign
854
+ // below, so callbackUrl: false is honorable here.
855
+ const callbackTarget = resolveCallbackURL({
856
+ lowdefy,
857
+ callbackUrl
858
+ });
859
+ const data = await unwrap(auth.phoneNumberVerify({
860
+ phoneNumber,
861
+ code,
862
+ ...rest
863
+ }));
864
+ // An enrolled user verifying by SMS gets a challenge instead of a session, in
865
+ // the same JSON shape the password paths return, so the navigation is the one
866
+ // Login performs and the destination rides along identically.
867
+ //
868
+ // The halt keeps the app's remaining steps from re-rendering with no session
869
+ // while the challenge page load is still in flight.
870
+ if (data?.twoFactorRedirect) {
871
+ return navigateToTwoFactorChallenge({
872
+ callbackTarget
873
+ }) ? stopChain(data) : data;
874
+ }
875
+ // The two modes that mint no usable arrival stay put: disableSession returns
876
+ // token: null, and updatePhoneNumber is a signed-in user confirming a new
877
+ // number - it returns their existing session token, so a token check alone
878
+ // would yank them out of what is usually a modal. The discriminator has to
879
+ // come from the request, and updatePhoneNumber is still in rest because
880
+ // BetterAuth needs it.
881
+ const window = lowdefy._internal?.globals?.window;
882
+ if (data?.token && rest.updatePhoneNumber !== true && callbackTarget && window) {
883
+ navigateToTarget(callbackTarget);
884
+ }
885
+ return data;
886
+ }
887
+ // The landing-page half of the magic-link flow. BetterAuth's verify endpoint
888
+ // consumes the token atomically on its first GET, and corporate mail security
889
+ // (Defender Safe Links, Proofpoint URL Defense, Mimecast) GETs every link in a
890
+ // delivered message - so when the email points at verify directly, the scanner
891
+ // spends the token and the person lands on INVALID_TOKEN. With
892
+ // authPages.magicLink set, the engine points the email at a Lowdefy page
893
+ // instead, which is inert to a fetch, and this method walks to verify on a real
894
+ // click.
895
+ //
896
+ // No BetterAuth client call: verify answers with a Set-Cookie and its own
897
+ // redirect, which only a whole-document navigation can follow. That is also why
898
+ // the chain stops here, like every other navigating method - a step after it
899
+ // would race the page being replaced.
900
+ //
901
+ // The engine copied the verify URL's whole query onto the landing URL, so the
902
+ // token and the three destinations are already on this page. The params exist
903
+ // to override them; each falls back to the query value, and errorCallbackURL
904
+ // falls back once more to the app's authPages.error, mirroring the default
905
+ // Login applies when it asks for the link.
906
+ function magicLinkVerify({ callbackUrl, errorCallbackUrl, newUserCallbackUrl, token } = {}) {
907
+ const window = lowdefy._internal?.globals?.window;
908
+ const pageQuery = new URLSearchParams(window?.location?.search ?? '');
909
+ const verifyToken = type.isString(token) ? token : pageQuery.get('token');
910
+ if (!type.isString(verifyToken) || verifyToken === '') {
911
+ throw new Error('MagicLinkVerify requires a "token" param, or a "token" URL query parameter on the landing page the sign-in email links to.');
912
+ }
913
+ // Every destination here is spent by BetterAuth as a later redirect hop, so
914
+ // "stay put" cannot be honored - the browser has already left.
915
+ assertCallbackUrlNavigable({
916
+ callbackUrl,
917
+ method: 'MagicLinkVerify'
918
+ });
919
+ const verifyQuery = new URLSearchParams({
920
+ token: verifyToken
921
+ });
922
+ const params = {
923
+ callbackURL: callbackUrl,
924
+ newUserCallbackURL: newUserCallbackUrl,
925
+ errorCallbackURL: errorCallbackUrl
926
+ };
927
+ Object.entries(params).forEach(([key, param])=>{
928
+ const explicit = serializeTarget(resolveTarget({
929
+ lowdefy,
930
+ target: param,
931
+ name: key
932
+ }));
933
+ const resolved = type.isNone(explicit) ? pageQuery.get(key) : explicit;
934
+ if (!type.isNone(resolved)) {
935
+ verifyQuery.set(key, resolved);
936
+ }
937
+ });
938
+ if (!verifyQuery.has('errorCallbackURL')) {
939
+ const errorPage = auth.authConfig?.authPages?.error;
940
+ const errorTarget = type.isString(errorPage) ? serializeTarget(resolveTarget({
941
+ lowdefy,
942
+ target: {
943
+ url: errorPage
944
+ },
945
+ name: 'errorCallbackUrl'
946
+ })) : undefined;
947
+ if (!type.isNone(errorTarget)) {
948
+ verifyQuery.set('errorCallbackURL', errorTarget);
949
+ }
950
+ }
951
+ // The same address the email used to carry: basePath, then BetterAuth's
952
+ // mount, which AuthConfigured gives the client as `${basePath}/api/auth`.
953
+ const url = `${lowdefy.basePath ?? ''}/api/auth/magic-link/verify?${verifyQuery.toString()}`;
954
+ lowdefy._internal.globals.window.location.assign(url);
955
+ return stopChain({
956
+ url
957
+ });
958
+ }
959
+ async function twoFactorDisable({ password, ...rest } = {}) {
960
+ if (!type.isString(password)) {
961
+ throw new Error('TwoFactorDisable requires a "password" param.');
962
+ }
963
+ return unwrap(auth.twoFactorDisable({
964
+ password,
965
+ ...rest
966
+ }));
967
+ }
968
+ // Returns the totpURI and backup codes the page must render once - the
969
+ // action response is the only carrier (readable via _actions in the same
970
+ // event chain); no side-channel state.
971
+ async function twoFactorEnable({ password, ...rest } = {}) {
972
+ if (!type.isString(password)) {
973
+ throw new Error('TwoFactorEnable requires a "password" param.');
974
+ }
975
+ return unwrap(auth.twoFactorEnable({
976
+ password,
977
+ ...rest
978
+ }));
979
+ }
980
+ // Rotates recovery codes only - the TOTP secret, the verified flag and
981
+ // user.twoFactorEnabled are untouched. Returns the new backup codes the page
982
+ // must render once (readable via _actions in the same event chain); no
983
+ // side-channel state.
984
+ async function twoFactorGenerateBackupCodes({ password, ...rest } = {}) {
985
+ if (!type.isString(password)) {
986
+ throw new Error('TwoFactorGenerateBackupCodes requires a "password" param.');
987
+ }
988
+ return unwrap(auth.twoFactorGenerateBackupCodes({
989
+ password,
990
+ ...rest
991
+ }));
992
+ }
993
+ // Serves both enrolment confirmation and the sign-in challenge, dispatching
994
+ // by parameter (matching login): a backupCode param verifies a backup code,
995
+ // otherwise code verifies TOTP.
996
+ //
997
+ // The engine owns both ends of the challenge hop, not only arrival: every
998
+ // sign-in path navigates to authPages.twoFactor carrying ?callbackUrl=, and
999
+ // this is where that value is spent. Leaving the last hop to the challenge
1000
+ // page is the same opt-in correctness the arrival navigation exists to avoid,
1001
+ // and the rung is awkward to consume from config besides - it already carries
1002
+ // basePath, so a Link's pageId double-prefixes it under a subpath. With the
1003
+ // engine finishing the hop, no app config reads the parameter at all.
1004
+ //
1005
+ // No stopChain: this navigation is the end of the flow rather than a departure
1006
+ // mid-chain, so an app may legitimately have a step after it.
1007
+ async function twoFactorVerify({ backupCode, callbackUrl, code, trustDevice, ...rest } = {}) {
1008
+ if (!type.isString(backupCode) && !type.isString(code)) {
1009
+ throw new Error('TwoFactorVerify requires a "code" or "backupCode" param.');
1010
+ }
1011
+ const callbackTarget = resolveCallbackURL({
1012
+ lowdefy,
1013
+ callbackUrl
1014
+ });
1015
+ const data = await unwrap(type.isString(backupCode) ? auth.twoFactorVerifyBackupCode({
1016
+ code: backupCode,
1017
+ trustDevice,
1018
+ ...rest
1019
+ }) : auth.twoFactorVerifyTotp({
1020
+ code,
1021
+ trustDevice,
1022
+ ...rest
1023
+ }));
1024
+ // A token is the whole guard here, unlike phoneNumberVerify's: verify has no
1025
+ // session-less mode, so a successful challenge always mints one and a failed
1026
+ // one throws to the action's catch.
1027
+ const window = lowdefy._internal?.globals?.window;
1028
+ if (data?.token && callbackTarget && window) {
1029
+ navigateToTarget(callbackTarget);
1030
+ }
1031
+ return data;
64
1032
  }
65
1033
  return {
1034
+ acceptInvitation,
1035
+ changePassword,
1036
+ emailOtpSend,
1037
+ emailOtpVerify,
1038
+ leaveOrganization,
1039
+ listOrganizations,
66
1040
  login,
67
1041
  logout,
1042
+ magicLinkVerify,
1043
+ oauth2Consent,
1044
+ oauth2Continue,
1045
+ passkeyDelete,
1046
+ passkeyRegister,
1047
+ passkeySignIn,
1048
+ passkeyUpdate,
1049
+ phoneNumberSendOtp,
1050
+ phoneNumberVerify,
1051
+ requestPasswordReset,
1052
+ resetPassword,
1053
+ revokeOtherSessions,
1054
+ sendVerificationEmail,
1055
+ setActiveOrganization,
1056
+ signUp,
1057
+ twoFactorDisable,
1058
+ twoFactorEnable,
1059
+ twoFactorGenerateBackupCodes,
1060
+ twoFactorVerify,
68
1061
  updateSession
69
1062
  };
70
1063
  }