@meistrari/auth-nuxt 3.18.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/auth-nuxt",
3
3
  "configKey": "telaAuth",
4
- "version": "3.18.0",
4
+ "version": "3.19.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -1,8 +1,9 @@
1
- import type { AdminApplicationRecord, AdminApplicationEntitlementRecord, AdminOrganization, AdminOrganizationTeamRecord, CreateAdminApplicationEntitlementOptions, CreateAdminApplicationOptions, CreateAdminOrganizationOptions, CreateAdminOrganizationTeamOptions, GetAdminOrganizationOptions, ListAdminApplicationsOptions, ListAdminApplicationsResult, ListAdminOrganizationMembersOptions, ListAdminOrganizationMembersResult, ListAdminOrganizationsOptions, ListAdminOrganizationsResult, ListAdminOrganizationTeamsOptions, ListAdminOrganizationTeamsResult, ListAdminUsersOptions, ListAdminUsersResult, UpdateAdminOrganizationOptions } from '@meistrari/auth-core';
1
+ import type { AdminApplicationRecord, AdminApplicationEntitlementRecord, AdminOrganization, AdminOrganizationTeamRecord, AdminUserAccess, CreateAdminApplicationEntitlementOptions, CreateAdminApplicationOptions, CreateAdminOrganizationOptions, CreateAdminOrganizationTeamOptions, GetAdminOrganizationOptions, InviteAdminOrganizationUsersOptions, InviteAdminOrganizationUsersResult, ListAdminApplicationsOptions, ListAdminApplicationsResult, ListAdminOrganizationMembersOptions, ListAdminOrganizationMembersResult, ListAdminOrganizationsOptions, ListAdminOrganizationsResult, ListAdminOrganizationTeamsOptions, ListAdminOrganizationTeamsResult, ListAdminUsersOptions, ListAdminUsersResult, AdminMember, RemoveAdminMemberOptions, ResendAdminInviteOptions, SetAdminUserApplicationRoleOptions, SetAdminUserTeamOptions, UpdateAdminMemberRoleOptions, UpdateAdminOrganizationOptions } from '@meistrari/auth-core';
2
2
  export interface UseTelaAdminReturn {
3
3
  /**
4
- * Lists users from the Better Auth `user` table as a global admin.
5
- * @param options - Pagination, search, filter, and ordering options
4
+ * Lists users as a global admin, optionally with organization previews
5
+ * and totals when a preview size is requested.
6
+ * @param options - Pagination, ordering, search, and preview options
6
7
  * @returns The paginated user listing
7
8
  */
8
9
  listUsers: (options?: ListAdminUsersOptions) => Promise<ListAdminUsersResult>;
@@ -64,6 +65,14 @@ export interface UseTelaAdminReturn {
64
65
  * @returns The created organization team
65
66
  */
66
67
  createOrganizationTeam: (options: CreateAdminOrganizationTeamOptions) => Promise<AdminOrganizationTeamRecord>;
68
+ /**
69
+ * Fetches the per-organization access report for a single user as a global
70
+ * admin, including organization roles, teams, and entitled applications with
71
+ * the user's effective application role.
72
+ * @param userId - ID of the user to fetch access for
73
+ * @returns The user access report
74
+ */
75
+ getUserAccess: (userId: string) => Promise<AdminUserAccess>;
67
76
  /**
68
77
  * Creates an organization as a global admin without joining it.
69
78
  * @param options - Organization name and optional slug, logo, metadata, and settings
@@ -76,6 +85,62 @@ export interface UseTelaAdminReturn {
76
85
  * @returns The updated organization
77
86
  */
78
87
  updateOrganization: (options: UpdateAdminOrganizationOptions) => Promise<AdminOrganization>;
88
+ /**
89
+ * Updates an organization member's role as a global admin.
90
+ * @param options - Membership ID and the new role
91
+ * @returns The updated membership
92
+ */
93
+ updateMemberRole: (options: UpdateAdminMemberRoleOptions) => Promise<AdminMember>;
94
+ /**
95
+ * Removes an organization member as a global admin.
96
+ * @param options - Membership ID to remove
97
+ * @returns The removed membership
98
+ */
99
+ removeMember: (options: RemoveAdminMemberOptions) => Promise<AdminMember>;
100
+ /**
101
+ * Sets a user's effective application role within an organization as a
102
+ * global admin. A non-null role grants access, a null role denies access
103
+ * (leave application), and clear removes the user-specific override.
104
+ * @param options - User, organization, application, and the role or clear flag
105
+ */
106
+ setUserApplicationRole: (options: SetAdminUserApplicationRoleOptions) => Promise<void>;
107
+ /**
108
+ * Reconciles a user's team memberships within an organization to a given set
109
+ * as a global admin.
110
+ * @param options - User, organization, and the desired teams (empty to clear)
111
+ */
112
+ setUserTeam: (options: SetAdminUserTeamOptions) => Promise<void>;
113
+ /**
114
+ * Bans a user from the auth system and invalidates all their sessions.
115
+ * @param userId - ID of the user to ban
116
+ */
117
+ banUser: (userId: string) => Promise<void>;
118
+ /**
119
+ * Lifts a user's ban.
120
+ * @param userId - ID of the user to unban
121
+ */
122
+ unbanUser: (userId: string) => Promise<void>;
123
+ /**
124
+ * Permanently removes a user from the auth system.
125
+ * @param userId - ID of the user to remove
126
+ */
127
+ removeUser: (userId: string) => Promise<void>;
128
+ /**
129
+ * Cancels a pending invitation as a global admin.
130
+ * @param invitationId - ID of the invitation to cancel
131
+ */
132
+ cancelInvite: (invitationId: string) => Promise<void>;
133
+ /**
134
+ * Re-sends an organization invitation email as a global admin.
135
+ * @param options - Organization, email, role, and optional application
136
+ */
137
+ resendInvite: (options: ResendAdminInviteOptions) => Promise<void>;
138
+ /**
139
+ * Invites several users to an organization in one call, isolating failures
140
+ * per email in the results payload.
141
+ * @param options - Organization, emails, role, and optional teams/application
142
+ */
143
+ inviteOrganizationUsers: (options: InviteAdminOrganizationUsersOptions) => Promise<InviteAdminOrganizationUsersResult>;
79
144
  }
80
145
  /**
81
146
  * Composable for global admin operations.
@@ -136,12 +136,45 @@ export function useTelaAdmin() {
136
136
  async function createOrganizationTeam(options) {
137
137
  return await authClient.admin.createOrganizationTeam(options);
138
138
  }
139
+ async function getUserAccess(userId) {
140
+ return await authClient.admin.getUserAccess(userId);
141
+ }
139
142
  async function createOrganization(options) {
140
143
  return await authClient.admin.createOrganization(options);
141
144
  }
142
145
  async function updateOrganization(options) {
143
146
  return await authClient.admin.updateOrganization(options);
144
147
  }
148
+ async function updateMemberRole(options) {
149
+ return await authClient.admin.updateMemberRole(options);
150
+ }
151
+ async function removeMember(options) {
152
+ return await authClient.admin.removeMember(options);
153
+ }
154
+ async function setUserApplicationRole(options) {
155
+ return await authClient.admin.setUserApplicationRole(options);
156
+ }
157
+ async function setUserTeam(options) {
158
+ return await authClient.admin.setUserTeam(options);
159
+ }
160
+ async function banUser(userId) {
161
+ return await authClient.admin.banUser(userId);
162
+ }
163
+ async function unbanUser(userId) {
164
+ return await authClient.admin.unbanUser(userId);
165
+ }
166
+ async function removeUser(userId) {
167
+ return await authClient.admin.removeUser(userId);
168
+ }
169
+ async function cancelInvite(invitationId) {
170
+ return await authClient.admin.cancelInvite(invitationId);
171
+ }
172
+ async function resendInvite(options) {
173
+ return await authClient.admin.resendInvite(options);
174
+ }
175
+ async function inviteOrganizationUsers(options) {
176
+ return await authClient.admin.inviteOrganizationUsers(options);
177
+ }
145
178
  return {
146
179
  listUsers,
147
180
  listApplications,
@@ -153,7 +186,18 @@ export function useTelaAdmin() {
153
186
  listOrganizationMembers,
154
187
  listOrganizationTeams,
155
188
  createOrganizationTeam,
189
+ getUserAccess,
156
190
  createOrganization,
157
- updateOrganization
191
+ updateOrganization,
192
+ updateMemberRole,
193
+ removeMember,
194
+ setUserApplicationRole,
195
+ setUserTeam,
196
+ banUser,
197
+ unbanUser,
198
+ removeUser,
199
+ cancelInvite,
200
+ resendInvite,
201
+ inviteOrganizationUsers
158
202
  };
159
203
  }
@@ -60,15 +60,27 @@ export interface UseTelaApplicationAuthReturn {
60
60
  * for new access and refresh tokens. The server updates the cookies and returns
61
61
  * the user and organization data.
62
62
  *
63
- * @throws {RefreshTokenExpiredError} If no refresh token is available or refresh fails
63
+ * Concurrent calls (including the SDK plugin's background refresh) share
64
+ * one in-flight request, and transient failures are retried with backoff
65
+ * before this rejects.
66
+ *
67
+ * @throws {RefreshTokenExpiredError} If the refresh token is definitively expired or revoked (401/404)
68
+ * @throws {ApplicationError} If the refresh failed transiently (network failure, outage, rate limit) —
69
+ * the session is still valid and the call can be retried. Check `error.status` for the HTTP status,
70
+ * `undefined` meaning no HTTP response was received.
64
71
  */
65
72
  refreshToken: () => Promise<void>;
66
73
  /**
67
74
  * Retrieves the current access token.
68
75
  * If the token is expired, or close to expiry, it will be refreshed automatically.
69
76
  *
77
+ * Stale-while-revalidate: when a refresh fails transiently (network
78
+ * failure, outage) but the current token is still valid, the current
79
+ * token is returned instead of throwing.
80
+ *
70
81
  * @returns The current access token
71
- * @throws {RefreshTokenExpiredError} If the token is expired and cannot be refreshed
82
+ * @throws {RefreshTokenExpiredError} If the refresh token is definitively expired or revoked (401/404)
83
+ * @throws {ApplicationError} If the refresh failed transiently and the current token is hard-expired
72
84
  */
73
85
  getToken: () => Promise<string | null | undefined>;
74
86
  }
@@ -1,7 +1,9 @@
1
1
  import { navigateTo, useCookie, useRuntimeConfig } from "#app";
2
- import { AuthorizationFlowError, isTokenExpired, RefreshTokenExpiredError, UserNotLoggedInError } from "@meistrari/auth-core";
2
+ import { ApplicationError, AuthorizationFlowError, isTokenExpired, RefreshTokenExpiredError, UserNotLoggedInError } from "@meistrari/auth-core";
3
3
  import { useApplicationSessionState } from "../state.js";
4
- import { willTokenExpireIn } from "../../helpers/token.js";
4
+ import { ACCESS_TOKEN_COOKIE, readClientCookie } from "../../helpers/client-cookies.js";
5
+ import { useRefreshOrchestrator } from "../../helpers/refresh-deps.js";
6
+ import { extractHttpStatus } from "../../helpers/refresh-policy.js";
5
7
  import { useTelaOrganization } from "./organization.js";
6
8
  const FIFTEEN_MINUTES = 60 * 15;
7
9
  const ONE_MINUTE = 60 * 1e3;
@@ -52,18 +54,24 @@ export function useTelaApplicationAuth() {
52
54
  state.sessionAssurance.value = null;
53
55
  await $fetch("/auth/logout", { method: "POST" });
54
56
  }
57
+ const orchestrator = useRefreshOrchestrator(state);
58
+ function readAccessToken() {
59
+ return readClientCookie(ACCESS_TOKEN_COOKIE) ?? accessTokenCookie.value ?? null;
60
+ }
55
61
  async function refreshToken() {
56
- try {
57
- const result = await $fetch("/auth/refresh", {
58
- method: "POST"
59
- });
60
- state.user.value = result.user;
61
- state.activeOrganization.value = result.organization;
62
- state.sessionAssurance.value = result.assurance;
63
- } catch (error) {
64
- console.error("[Auth Refresh] Failed to refresh token:", error);
65
- throw new RefreshTokenExpiredError();
62
+ const outcome = await orchestrator.ensureFreshToken({ force: true });
63
+ if (outcome.status === "ok") {
64
+ return;
65
+ }
66
+ if (outcome.status === "auth-failed") {
67
+ console.error("[Auth Refresh] Refresh token expired or revoked:", outcome.error);
68
+ throw new RefreshTokenExpiredError({ cause: outcome.error, status: extractHttpStatus(outcome.error) });
66
69
  }
70
+ console.error("[Auth Refresh] Transient token refresh failure:", "error" in outcome ? outcome.error : "offline");
71
+ throw new ApplicationError("Failed to refresh access token", {
72
+ cause: "error" in outcome ? outcome.error : void 0,
73
+ status: "error" in outcome ? extractHttpStatus(outcome.error) : void 0
74
+ });
67
75
  }
68
76
  async function initSession() {
69
77
  if (!accessTokenCookie.value) {
@@ -81,11 +89,22 @@ export function useTelaApplicationAuth() {
81
89
  await useTelaOrganization().setActiveOrganization(organizationId);
82
90
  }
83
91
  async function getToken() {
84
- const shouldRefresh = accessTokenCookie.value ? willTokenExpireIn(accessTokenCookie.value, ONE_MINUTE * 2) : true;
85
- if (shouldRefresh) {
86
- await refreshToken();
92
+ const outcome = await orchestrator.ensureFreshToken();
93
+ if (outcome.status === "ok") {
94
+ return readAccessToken();
95
+ }
96
+ if (outcome.status === "auth-failed") {
97
+ console.error("[Auth Refresh] Refresh token expired or revoked:", outcome.error);
98
+ throw new RefreshTokenExpiredError({ cause: outcome.error, status: extractHttpStatus(outcome.error) });
99
+ }
100
+ const currentToken = readAccessToken();
101
+ if (currentToken && !isTokenExpired(currentToken)) {
102
+ return currentToken;
87
103
  }
88
- return accessTokenCookie.value;
104
+ throw new ApplicationError("Failed to refresh access token", {
105
+ cause: "error" in outcome ? outcome.error : void 0,
106
+ status: "error" in outcome ? extractHttpStatus(outcome.error) : void 0
107
+ });
89
108
  }
90
109
  return {
91
110
  user: state.user,
@@ -0,0 +1,23 @@
1
+ /** Cookie holding the application access token (readable JWT) */
2
+ export declare const ACCESS_TOKEN_COOKIE = "tela-access-token";
3
+ /** Cookie holding the application refresh token (httpOnly — invisible to JS) */
4
+ export declare const REFRESH_TOKEN_COOKIE = "tela-refresh-token";
5
+ /**
6
+ * Reads a cookie value straight from `document.cookie`
7
+ *
8
+ * This is the source of truth for all token freshness math: `useCookie`
9
+ * refs captured at setup time can go stale after the server rotates
10
+ * cookies via `Set-Cookie`, which is exactly when the scheduling math
11
+ * must not be wrong
12
+ *
13
+ * @returns The decoded cookie value, or `null` when absent or on the server
14
+ */
15
+ export declare function readClientCookie(name: string): string | null;
16
+ /**
17
+ * Re-syncs the `useCookie` refs for both auth cookies after the server
18
+ * rotated them, so reactive app code observes the new values.
19
+ *
20
+ * Reactive sugar only — freshness logic never depends on it, it always
21
+ * reads `document.cookie` via {@link readClientCookie}
22
+ */
23
+ export declare function syncAuthCookieRefs(): void;
@@ -0,0 +1,28 @@
1
+ import { refreshCookie } from "#app";
2
+ export const ACCESS_TOKEN_COOKIE = "tela-access-token";
3
+ export const REFRESH_TOKEN_COOKIE = "tela-refresh-token";
4
+ export function readClientCookie(name) {
5
+ if (typeof document === "undefined") {
6
+ return null;
7
+ }
8
+ const prefix = `${name}=`;
9
+ for (const part of document.cookie.split(";")) {
10
+ const trimmed = part.trim();
11
+ if (trimmed.startsWith(prefix)) {
12
+ const value = trimmed.slice(prefix.length);
13
+ try {
14
+ return decodeURIComponent(value);
15
+ } catch {
16
+ return value;
17
+ }
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ export function syncAuthCookieRefs() {
23
+ if (import.meta.server) {
24
+ return;
25
+ }
26
+ refreshCookie(ACCESS_TOKEN_COOKIE);
27
+ refreshCookie(REFRESH_TOKEN_COOKIE);
28
+ }
@@ -0,0 +1,37 @@
1
+ import type { Ref } from 'vue';
2
+ import type { FullOrganization, JWTPayload, User } from '@meistrari/auth-core';
3
+ import type { RefreshOrchestrator, RefreshOrchestratorDeps } from './refresh-orchestrator.js';
4
+ /** Success body of the `/auth/refresh` Nitro route */
5
+ export interface RefreshResponseBody {
6
+ success: boolean;
7
+ user: User;
8
+ organization: FullOrganization;
9
+ assurance: JWTPayload['assurance'];
10
+ /** Epoch ms expiry of the freshly issued access token */
11
+ accessTokenExpiresAt: number | null;
12
+ }
13
+ interface ApplicationSessionState {
14
+ user: Ref<User | null>;
15
+ activeOrganization: Ref<FullOrganization | null>;
16
+ sessionAssurance: Ref<JWTPayload['assurance'] | null>;
17
+ }
18
+ /**
19
+ * Builds the orchestrator dependencies used by both the token-refresh
20
+ * plugin and `useTelaApplicationAuth`, so the two call sites can't drift
21
+ *
22
+ * `performRefresh` hits the Nitro `/auth/refresh` route with a bounded
23
+ * timeout (so a held Web Lock always releases), then updates session
24
+ * state and re-syncs the reactive cookie refs. Cookies themselves are
25
+ * rotated by the route via `Set-Cookie`
26
+ */
27
+ export declare function buildRefreshDeps(state: ApplicationSessionState): RefreshOrchestratorDeps;
28
+ /**
29
+ * Returns the refresh orchestrator for the current environment.
30
+ *
31
+ * On the client this is a singleton shared by the plugin and the
32
+ * composable, so their refreshes dedupe into one in-flight request.
33
+ * On the server each caller gets a throwaway instance — no state may
34
+ * leak across requests
35
+ */
36
+ export declare function useRefreshOrchestrator(state: ApplicationSessionState): RefreshOrchestrator;
37
+ export {};
@@ -0,0 +1,35 @@
1
+ import { useCookie } from "#app";
2
+ import { ACCESS_TOKEN_COOKIE, readClientCookie, syncAuthCookieRefs } from "./client-cookies.js";
3
+ import { createRefreshOrchestrator } from "./refresh-orchestrator.js";
4
+ import { DEFAULT_REFRESH_POLICY } from "./refresh-policy.js";
5
+ import { parseTokenExpiry } from "./token.js";
6
+ export function buildRefreshDeps(state) {
7
+ const accessTokenCookie = useCookie(ACCESS_TOKEN_COOKIE);
8
+ return {
9
+ performRefresh: async () => {
10
+ const result = await $fetch("/auth/refresh", {
11
+ method: "POST",
12
+ timeout: 15e3
13
+ });
14
+ state.user.value = result.user;
15
+ state.activeOrganization.value = result.organization;
16
+ state.sessionAssurance.value = result.assurance;
17
+ syncAuthCookieRefs();
18
+ },
19
+ // document.cookie is the live source of truth on the client; the
20
+ // useCookie ref covers SSR, where document is unavailable.
21
+ readAccessToken: () => readClientCookie(ACCESS_TOKEN_COOKIE) ?? accessTokenCookie.value ?? null,
22
+ parseTokenExpiry,
23
+ policy: DEFAULT_REFRESH_POLICY
24
+ };
25
+ }
26
+ let sharedOrchestrator = null;
27
+ export function useRefreshOrchestrator(state) {
28
+ if (import.meta.server) {
29
+ return createRefreshOrchestrator(buildRefreshDeps(state));
30
+ }
31
+ if (!sharedOrchestrator) {
32
+ sharedOrchestrator = createRefreshOrchestrator(buildRefreshDeps(state));
33
+ }
34
+ return sharedOrchestrator;
35
+ }
@@ -0,0 +1,64 @@
1
+ import type { RefreshPolicy } from './refresh-policy.js';
2
+ /**
3
+ * Result of an {@link RefreshOrchestrator.ensureFreshToken} call
4
+ *
5
+ * Always a concrete outcome — the orchestrator never throws and never
6
+ * resolves to `undefined`, so callers (the scheduler in particular)
7
+ * can't mistake a concurrent call for a failure
8
+ */
9
+ export type RefreshOutcome = {
10
+ status: 'ok';
11
+ refreshed: boolean;
12
+ } | {
13
+ status: 'skipped-offline';
14
+ } | {
15
+ status: 'auth-failed';
16
+ error: unknown;
17
+ } | {
18
+ status: 'transient-failed';
19
+ error: unknown;
20
+ };
21
+ /** Minimal Web Locks surface used by the orchestrator. */
22
+ export interface RefreshLockManager {
23
+ request: <T>(name: string, callback: () => Promise<T>) => Promise<T>;
24
+ }
25
+ export interface RefreshOrchestratorDeps {
26
+ /**
27
+ * Performs one actual refresh: the network call plus any state/cookie
28
+ * updates. Must reject on failure; the rejection is classified via
29
+ * `extractHttpStatus`. Must be bounded (pass a fetch timeout) so a
30
+ * held Web Lock always releases
31
+ */
32
+ performRefresh: () => Promise<void>;
33
+ /** Reads the current access token from the live cookie source of truth */
34
+ readAccessToken: () => string | null;
35
+ /** Parses a JWT expiry to epoch ms, `null` when undecodable */
36
+ parseTokenExpiry: (token: string) => number | null;
37
+ policy: RefreshPolicy;
38
+ now?: () => number;
39
+ sleep?: (ms: number) => Promise<void>;
40
+ random?: () => number;
41
+ /** Returns `false` only when the browser is known to be offline */
42
+ isOnline?: () => boolean;
43
+ /** Web Locks manager; omit/`null` where unavailable (older browsers, SSR) */
44
+ locks?: RefreshLockManager | null;
45
+ lockName?: string;
46
+ }
47
+ export interface RefreshOrchestrator {
48
+ /**
49
+ * Ensures the access token is fresh, refreshing it if needed
50
+ *
51
+ * Concurrent callers share one in-flight promise (a single network
52
+ * request). Cross-tab callers are serialized by a Web Lock, and a
53
+ * caller that waited on the lock re-reads the cookie afterwards so it
54
+ * rides a sibling tab's rotation without its own network call.
55
+ * Transient failures back off and retry up to the policy cap;
56
+ * only a definitive 401/404 yields `auth-failed`
57
+ *
58
+ * @param options.force - Skip freshness checks and always hit the network
59
+ */
60
+ ensureFreshToken: (options?: {
61
+ force?: boolean;
62
+ }) => Promise<RefreshOutcome>;
63
+ }
64
+ export declare function createRefreshOrchestrator(deps: RefreshOrchestratorDeps): RefreshOrchestrator;
@@ -0,0 +1,85 @@
1
+ import { classifyRefreshStatus, computeBackoffDelay, extractHttpStatus } from "./refresh-policy.js";
2
+ const DEFAULT_LOCK_NAME = "tela-auth-token-refresh";
3
+ export function createRefreshOrchestrator(deps) {
4
+ const {
5
+ performRefresh,
6
+ readAccessToken,
7
+ parseTokenExpiry,
8
+ policy,
9
+ now = () => Date.now(),
10
+ sleep = async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)),
11
+ random = Math.random,
12
+ isOnline = () => typeof navigator === "undefined" || navigator.onLine !== false,
13
+ locks = typeof navigator !== "undefined" && "locks" in navigator ? navigator.locks : null,
14
+ lockName = DEFAULT_LOCK_NAME
15
+ } = deps;
16
+ let inFlight = null;
17
+ function isTokenFresh() {
18
+ const token = readAccessToken();
19
+ if (!token) {
20
+ return false;
21
+ }
22
+ const expiry = parseTokenExpiry(token);
23
+ if (expiry === null) {
24
+ return false;
25
+ }
26
+ return expiry - now() > policy.refreshThresholdMs;
27
+ }
28
+ async function attemptWithinLock(force) {
29
+ if (!force && isTokenFresh()) {
30
+ return { kind: "ok", refreshed: false };
31
+ }
32
+ try {
33
+ await performRefresh();
34
+ return { kind: "ok", refreshed: true };
35
+ } catch (error) {
36
+ return { kind: "error", error };
37
+ }
38
+ }
39
+ async function attempt(force) {
40
+ if (locks) {
41
+ try {
42
+ return await locks.request(lockName, async () => await attemptWithinLock(force));
43
+ } catch {
44
+ }
45
+ }
46
+ return await attemptWithinLock(force);
47
+ }
48
+ async function run(force) {
49
+ try {
50
+ for (let retry = 0; ; retry++) {
51
+ if (!isOnline()) {
52
+ return { status: "skipped-offline" };
53
+ }
54
+ const result = await attempt(force);
55
+ if (result.kind === "ok") {
56
+ return { status: "ok", refreshed: result.refreshed };
57
+ }
58
+ const kind = classifyRefreshStatus(extractHttpStatus(result.error));
59
+ if (kind === "auth") {
60
+ return { status: "auth-failed", error: result.error };
61
+ }
62
+ if (retry >= policy.maxImmediateRetries) {
63
+ return { status: "transient-failed", error: result.error };
64
+ }
65
+ await sleep(computeBackoffDelay(retry, policy, random));
66
+ }
67
+ } catch (error) {
68
+ return { status: "transient-failed", error };
69
+ }
70
+ }
71
+ async function ensureFreshToken(options = {}) {
72
+ if (inFlight) {
73
+ return await inFlight;
74
+ }
75
+ const force = options.force ?? false;
76
+ if (!force && isTokenFresh()) {
77
+ return { status: "ok", refreshed: false };
78
+ }
79
+ inFlight = run(force).finally(() => {
80
+ inFlight = null;
81
+ });
82
+ return await inFlight;
83
+ }
84
+ return { ensureFreshToken };
85
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure refresh policy: failure classification, status extraction,
3
+ * backoff math, and schedule math
4
+ */
5
+ /** How a refresh failure should be handled */
6
+ export type RefreshFailureKind = 'auth' | 'transient';
7
+ /** Timing knobs shared by the orchestrator and the scheduler */
8
+ export interface RefreshPolicy {
9
+ /** Refresh this long before the access token expires (ms) */
10
+ refreshThresholdMs: number;
11
+ /** How many immediate in-place retries a single refresh attempt gets */
12
+ maxImmediateRetries: number;
13
+ /** Base delay for exponential backoff between retries (ms) */
14
+ retryBaseDelayMs: number;
15
+ /** Upper bound for any backoff delay (ms) */
16
+ retryMaxDelayMs: number;
17
+ /** Hard floor between two scheduled refreshes (ms) */
18
+ minRefreshIntervalMs: number;
19
+ }
20
+ /** Default timing policy */
21
+ export declare const DEFAULT_REFRESH_POLICY: RefreshPolicy;
22
+ /**
23
+ * Classifies a refresh failure by HTTP status
24
+ *
25
+ * Only a definitive 401/404 means the session is gone. Everything else —
26
+ * 429, 5xx, other 4xx, or no status at all (network failure) — is
27
+ * transient and must never log the user out
28
+ */
29
+ export declare function classifyRefreshStatus(status: number | undefined): RefreshFailureKind;
30
+ /**
31
+ * Extracts an HTTP status code from an unknown error shape
32
+ *
33
+ * Checks, in order: `status`, `statusCode`, `response.status`, then the
34
+ * same fields on `cause` (recursively, so wrapped SDK errors still yield
35
+ * the upstream status). Returns `undefined` for pure transport failures
36
+ */
37
+ export declare function extractHttpStatus(error: unknown, depth?: number): number | undefined;
38
+ /**
39
+ * Computes a full-jitter exponential backoff delay
40
+ *
41
+ * @param attempt - Zero-based retry attempt number
42
+ * @param policy - Timing policy providing base and max delays
43
+ * @param random - Random source in [0, 1); injectable for deterministic tests
44
+ */
45
+ export declare function computeBackoffDelay(attempt: number, policy: Pick<RefreshPolicy, 'retryBaseDelayMs' | 'retryMaxDelayMs'>, random?: () => number): number;
46
+ /**
47
+ * Computes the delay until the next scheduled refresh from the access
48
+ * token's expiry, clamped to a hard minimum interval
49
+ *
50
+ * A `null` expiry (missing or undecodable token) and an already-passed
51
+ * expiry both return `minRefreshIntervalMs` — never 0 — so a stale
52
+ * cookie read can never spin the scheduler into a hot loop
53
+ */
54
+ export declare function computeNextRefreshDelay(expiryMs: number | null, nowMs: number, policy: Pick<RefreshPolicy, 'refreshThresholdMs' | 'minRefreshIntervalMs'>): number;
@@ -0,0 +1,32 @@
1
+ export const DEFAULT_REFRESH_POLICY = {
2
+ refreshThresholdMs: 2 * 60 * 1e3,
3
+ maxImmediateRetries: 3,
4
+ retryBaseDelayMs: 1e3,
5
+ retryMaxDelayMs: 3e4,
6
+ minRefreshIntervalMs: 5e3
7
+ };
8
+ export function classifyRefreshStatus(status) {
9
+ return status === 401 || status === 404 ? "auth" : "transient";
10
+ }
11
+ export function extractHttpStatus(error, depth = 0) {
12
+ if (!error || typeof error !== "object" || depth > 4) {
13
+ return void 0;
14
+ }
15
+ const candidate = error;
16
+ for (const value of [candidate.status, candidate.statusCode, candidate.response?.status]) {
17
+ if (typeof value === "number" && Number.isFinite(value)) {
18
+ return value;
19
+ }
20
+ }
21
+ return extractHttpStatus(candidate.cause, depth + 1);
22
+ }
23
+ export function computeBackoffDelay(attempt, policy, random = Math.random) {
24
+ const exponential = Math.min(policy.retryMaxDelayMs, policy.retryBaseDelayMs * 2 ** Math.max(attempt, 0));
25
+ return Math.floor(random() * exponential);
26
+ }
27
+ export function computeNextRefreshDelay(expiryMs, nowMs, policy) {
28
+ if (expiryMs === null) {
29
+ return policy.minRefreshIntervalMs;
30
+ }
31
+ return Math.max(expiryMs - policy.refreshThresholdMs - nowMs, policy.minRefreshIntervalMs);
32
+ }