@marianmeres/ownsuite 1.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @module domains/profile
3
+ *
4
+ * ProfileManager — a singleton (one-row) reactive container for the
5
+ * authenticated subject's `/me` data: email, roles, verification flag,
6
+ * whether the account has a password, and the list of linked OAuth
7
+ * connections.
8
+ *
9
+ * Deliberately NOT an OwnedCollectionManager. The underlying `/me` endpoint
10
+ * is a single-record resource: no list, no optimistic create/delete, and
11
+ * update returns the full profile. Shoehorning it into the collection
12
+ * manager would leak CRUD semantics that don't apply and complicate
13
+ * ownership semantics.
14
+ *
15
+ * This manager mutates the companion `SessionManager`'s subject in place
16
+ * when the profile changes (e.g. email edited) so consumers reading from
17
+ * `suite.session` see the update without a second fetch.
18
+ */
19
+ import { createStore } from "@marianmeres/store";
20
+ import { createPubSub } from "@marianmeres/pubsub";
21
+ const EMPTY = {
22
+ profile: null,
23
+ loading: false,
24
+ error: null,
25
+ };
26
+ /**
27
+ * Profile manager — singleton state for `/me`.
28
+ */
29
+ export class ProfileManager {
30
+ #store;
31
+ #pubsub;
32
+ #adapter;
33
+ #session;
34
+ #context;
35
+ /** Currently-active read controller, for abort-supersede semantics. */
36
+ #readController = null;
37
+ constructor(options) {
38
+ this.#adapter = options.adapter;
39
+ this.#session = options.session;
40
+ this.#pubsub = options.pubsub ?? createPubSub();
41
+ this.#context = options.context ?? {};
42
+ this.#store = createStore({ ...EMPTY });
43
+ }
44
+ get subscribe() {
45
+ return this.#store.subscribe;
46
+ }
47
+ get() {
48
+ return this.#store.get();
49
+ }
50
+ setContext(ctx) {
51
+ this.#context = { ...this.#context, ...ctx };
52
+ }
53
+ replaceContext(ctx) {
54
+ this.#context = { ...ctx };
55
+ }
56
+ /** Build a per-op context with the current JWT from the session. */
57
+ #ctxFor(signal) {
58
+ const jwt = this.#session.getJwt();
59
+ return {
60
+ ...this.#context,
61
+ ...(jwt ? { jwt } : {}),
62
+ signal,
63
+ };
64
+ }
65
+ #abortActiveRead(reason = "superseded") {
66
+ if (this.#readController) {
67
+ try {
68
+ this.#readController.abort(reason);
69
+ }
70
+ catch {
71
+ // ignore
72
+ }
73
+ this.#readController = null;
74
+ }
75
+ }
76
+ /** Fetch `/me`. Supersedes any in-flight fetch. */
77
+ async fetch() {
78
+ this.#abortActiveRead();
79
+ const ctrl = new AbortController();
80
+ this.#readController = ctrl;
81
+ this.#store.update((s) => ({ ...s, loading: true }));
82
+ try {
83
+ const profile = await this.#adapter.get(this.#ctxFor(ctrl.signal));
84
+ if (ctrl.signal.aborted) {
85
+ // A newer request already took over; don't overwrite its data.
86
+ throw new Error("aborted");
87
+ }
88
+ this.#store.set({
89
+ profile,
90
+ loading: false,
91
+ error: null,
92
+ });
93
+ // Keep the session's subject in sync with /me so consumers reading
94
+ // from session don't need to also subscribe to profile.
95
+ this.#session.patchSubject({
96
+ email: profile.email,
97
+ roles: profile.roles,
98
+ isVerified: profile.isVerified,
99
+ hasPassword: profile.hasPassword,
100
+ });
101
+ return profile;
102
+ }
103
+ catch (e) {
104
+ if (!ctrl.signal.aborted) {
105
+ const err = e instanceof Error ? e : new Error(String(e));
106
+ this.#store.update((s) => ({ ...s, loading: false, error: err }));
107
+ }
108
+ throw e;
109
+ }
110
+ finally {
111
+ if (this.#readController === ctrl)
112
+ this.#readController = null;
113
+ }
114
+ }
115
+ /** Update profile (currently: email). Triggers a re-verification email
116
+ * server-side when the gate is on. Returns the refreshed profile. */
117
+ async update(input) {
118
+ const ctrl = new AbortController();
119
+ this.#store.update((s) => ({ ...s, loading: true }));
120
+ try {
121
+ const profile = await this.#adapter.update(input, this.#ctxFor(ctrl.signal));
122
+ this.#store.set({ profile, loading: false, error: null });
123
+ this.#session.patchSubject({
124
+ email: profile.email,
125
+ roles: profile.roles,
126
+ isVerified: profile.isVerified,
127
+ hasPassword: profile.hasPassword,
128
+ });
129
+ this.#pubsub.publish("profile:updated", {
130
+ type: "profile:updated",
131
+ timestamp: Date.now(),
132
+ email: profile.email,
133
+ });
134
+ return profile;
135
+ }
136
+ catch (e) {
137
+ const err = e instanceof Error ? e : new Error(String(e));
138
+ this.#store.update((s) => ({ ...s, loading: false, error: err }));
139
+ throw e;
140
+ }
141
+ }
142
+ async listOAuth() {
143
+ const ctrl = new AbortController();
144
+ return await this.#adapter.listOAuth(this.#ctxFor(ctrl.signal));
145
+ }
146
+ async unlinkOAuth(provider) {
147
+ const ctrl = new AbortController();
148
+ await this.#adapter.unlinkOAuth(provider, this.#ctxFor(ctrl.signal));
149
+ this.#pubsub.publish("oauth:unlinked", {
150
+ type: "oauth:unlinked",
151
+ timestamp: Date.now(),
152
+ provider,
153
+ });
154
+ // Refresh so the profile reflects the new connection list.
155
+ try {
156
+ await this.fetch();
157
+ }
158
+ catch {
159
+ // swallow — caller already got a successful unlink
160
+ }
161
+ }
162
+ reset() {
163
+ this.#abortActiveRead("reset");
164
+ this.#store.set({ ...EMPTY });
165
+ }
166
+ destroy() {
167
+ this.#abortActiveRead("destroyed");
168
+ this.#store.set({ ...EMPTY });
169
+ }
170
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module domains/session
3
+ *
4
+ * SessionManager — source of truth for the JWT and the authenticated subject.
5
+ *
6
+ * This is NOT an `OwnedCollectionManager` — there's no list-of-rows and no
7
+ * remote adapter. The session is a single reactive record, persisted via a
8
+ * pluggable `SessionStorage`, that drives every other manager (its JWT and
9
+ * subjectId end up on `OwnsuiteContext`).
10
+ *
11
+ * Consumers subscribe directly (`suite.session.subscribe(fn)`) or read the
12
+ * current snapshot (`suite.session.get()`). Writes go through the high-
13
+ * level `AuthManager` methods (`login`, `logout`, etc.) which call into
14
+ * this manager.
15
+ */
16
+ import { type StoreLike } from "@marianmeres/store";
17
+ import { type PubSub } from "@marianmeres/pubsub";
18
+ import type { SessionState, SessionStorage, SessionStorageType, SessionSubject } from "../types/auth.js";
19
+ /** In-memory session storage — survives the current JS realm only. */
20
+ export declare function createMemorySessionStorage(): SessionStorage;
21
+ /** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
22
+ * Gracefully falls back to memory storage when running in an environment
23
+ * without Web Storage (SSR, worker without storage, etc). */
24
+ export declare function resolveSessionStorage(type?: SessionStorageType): SessionStorage;
25
+ export interface SessionManagerOptions {
26
+ /** Storage backend for session persistence. Default: "local". */
27
+ storage?: SessionStorageType;
28
+ /** Key used in the storage backend. Default: "ownsuite:session". */
29
+ storageKey?: string;
30
+ /** Shared pubsub for event emission. When omitted, a private one is
31
+ * created — useful for standalone testing of the manager. */
32
+ pubsub?: PubSub;
33
+ }
34
+ /**
35
+ * Session manager — pure reactive state + persistence, no HTTP.
36
+ *
37
+ * Writes are driven by the AuthManager. On construction it hydrates from
38
+ * storage; if the stored JWT has expired, it transitions to anonymous and
39
+ * clears the storage.
40
+ */
41
+ export declare class SessionManager {
42
+ #private;
43
+ constructor(options?: SessionManagerOptions);
44
+ /** Svelte-compatible subscribe. */
45
+ get subscribe(): StoreLike<SessionState>["subscribe"];
46
+ /** Current session state snapshot. */
47
+ get(): SessionState;
48
+ get isAuthenticated(): boolean;
49
+ get isUnverified(): boolean;
50
+ get isAnonymous(): boolean;
51
+ /** JWT for adapter `Authorization` headers, or null when anonymous. */
52
+ getJwt(): string | null;
53
+ /** Transition to authenticated. Called after login/register/OAuth succeed
54
+ * and the subject has been loaded. */
55
+ setAuthenticated(opts: {
56
+ jwt: string;
57
+ subject: SessionSubject;
58
+ expiresAt?: number | null;
59
+ }): void;
60
+ /** Server confirmed credentials but refuses login until email is
61
+ * verified. Expose the email so the UI can prompt "check your inbox"
62
+ * without a second server call. No JWT in this state. */
63
+ setUnverified(email: string): void;
64
+ /** Drop the session. Storage is cleared; downstream domains should be
65
+ * reset by the suite orchestrator. */
66
+ clear(): void;
67
+ /** Patch the subject in place without touching the JWT. Used when the
68
+ * profile manager changes email / linked providers / etc. */
69
+ patchSubject(patch: Partial<SessionSubject>): void;
70
+ /** Test / reset hook — clears storage AND in-memory state without
71
+ * emitting. Used by teardown. */
72
+ destroy(): void;
73
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @module domains/session
3
+ *
4
+ * SessionManager — source of truth for the JWT and the authenticated subject.
5
+ *
6
+ * This is NOT an `OwnedCollectionManager` — there's no list-of-rows and no
7
+ * remote adapter. The session is a single reactive record, persisted via a
8
+ * pluggable `SessionStorage`, that drives every other manager (its JWT and
9
+ * subjectId end up on `OwnsuiteContext`).
10
+ *
11
+ * Consumers subscribe directly (`suite.session.subscribe(fn)`) or read the
12
+ * current snapshot (`suite.session.get()`). Writes go through the high-
13
+ * level `AuthManager` methods (`login`, `logout`, etc.) which call into
14
+ * this manager.
15
+ */
16
+ import { createStore } from "@marianmeres/store";
17
+ import { createPubSub } from "@marianmeres/pubsub";
18
+ const DEFAULT_STORAGE_KEY = "ownsuite:session";
19
+ const EMPTY = {
20
+ status: "anonymous",
21
+ subject: null,
22
+ jwt: null,
23
+ expiresAt: null,
24
+ };
25
+ /** In-memory session storage — survives the current JS realm only. */
26
+ export function createMemorySessionStorage() {
27
+ const map = new Map();
28
+ return {
29
+ get(k) {
30
+ return map.has(k) ? map.get(k) : null;
31
+ },
32
+ set(k, v) {
33
+ map.set(k, v);
34
+ },
35
+ del(k) {
36
+ map.delete(k);
37
+ },
38
+ };
39
+ }
40
+ /** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
41
+ * Gracefully falls back to memory storage when running in an environment
42
+ * without Web Storage (SSR, worker without storage, etc). */
43
+ export function resolveSessionStorage(type = "local") {
44
+ if (typeof type === "object" && type !== null)
45
+ return type;
46
+ if (type === "memory")
47
+ return createMemorySessionStorage();
48
+ const backend = type === "session"
49
+ ? "sessionStorage"
50
+ : "localStorage";
51
+ const g = globalThis;
52
+ const store = g[backend];
53
+ if (!store)
54
+ return createMemorySessionStorage();
55
+ return {
56
+ get(k) {
57
+ try {
58
+ return store.getItem(k);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ },
64
+ set(k, v) {
65
+ try {
66
+ store.setItem(k, v);
67
+ }
68
+ catch {
69
+ // quota exceeded / disabled — degrade silently
70
+ }
71
+ },
72
+ del(k) {
73
+ try {
74
+ store.removeItem(k);
75
+ }
76
+ catch {
77
+ // ignore
78
+ }
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * Session manager — pure reactive state + persistence, no HTTP.
84
+ *
85
+ * Writes are driven by the AuthManager. On construction it hydrates from
86
+ * storage; if the stored JWT has expired, it transitions to anonymous and
87
+ * clears the storage.
88
+ */
89
+ export class SessionManager {
90
+ #store;
91
+ #pubsub;
92
+ #storage;
93
+ #storageKey;
94
+ constructor(options = {}) {
95
+ this.#pubsub = options.pubsub ?? createPubSub();
96
+ this.#storage = resolveSessionStorage(options.storage ?? "local");
97
+ this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
98
+ this.#store = createStore({ ...EMPTY });
99
+ this.#hydrate();
100
+ }
101
+ /** Read from storage and populate the store. Expired sessions are wiped. */
102
+ #hydrate() {
103
+ const raw = this.#storage.get(this.#storageKey);
104
+ if (!raw)
105
+ return;
106
+ try {
107
+ const parsed = JSON.parse(raw);
108
+ // Basic shape check.
109
+ if (typeof parsed !== "object" ||
110
+ parsed === null ||
111
+ typeof parsed.status !== "string") {
112
+ this.#storage.del(this.#storageKey);
113
+ return;
114
+ }
115
+ // Expiry check (only meaningful when expiresAt is set).
116
+ if (parsed.expiresAt !== null &&
117
+ parsed.expiresAt !== undefined &&
118
+ parsed.expiresAt * 1000 <= Date.now()) {
119
+ this.#storage.del(this.#storageKey);
120
+ return;
121
+ }
122
+ this.#store.set(parsed);
123
+ }
124
+ catch {
125
+ this.#storage.del(this.#storageKey);
126
+ }
127
+ }
128
+ #persist() {
129
+ const s = this.#store.get();
130
+ if (s.status === "anonymous") {
131
+ this.#storage.del(this.#storageKey);
132
+ }
133
+ else {
134
+ this.#storage.set(this.#storageKey, JSON.stringify(s));
135
+ }
136
+ }
137
+ #emitChange() {
138
+ this.#pubsub.publish("auth:session:changed", {
139
+ type: "auth:session:changed",
140
+ timestamp: Date.now(),
141
+ session: this.#store.get(),
142
+ });
143
+ }
144
+ /** Svelte-compatible subscribe. */
145
+ get subscribe() {
146
+ return this.#store.subscribe;
147
+ }
148
+ /** Current session state snapshot. */
149
+ get() {
150
+ return this.#store.get();
151
+ }
152
+ get isAuthenticated() {
153
+ return this.#store.get().status === "authenticated";
154
+ }
155
+ get isUnverified() {
156
+ return this.#store.get().status === "unverified";
157
+ }
158
+ get isAnonymous() {
159
+ return this.#store.get().status === "anonymous";
160
+ }
161
+ /** JWT for adapter `Authorization` headers, or null when anonymous. */
162
+ getJwt() {
163
+ return this.#store.get().jwt;
164
+ }
165
+ /** Transition to authenticated. Called after login/register/OAuth succeed
166
+ * and the subject has been loaded. */
167
+ setAuthenticated(opts) {
168
+ const { jwt, subject, expiresAt = null } = opts;
169
+ this.#store.set({
170
+ status: "authenticated",
171
+ subject,
172
+ jwt,
173
+ expiresAt,
174
+ });
175
+ this.#persist();
176
+ this.#emitChange();
177
+ }
178
+ /** Server confirmed credentials but refuses login until email is
179
+ * verified. Expose the email so the UI can prompt "check your inbox"
180
+ * without a second server call. No JWT in this state. */
181
+ setUnverified(email) {
182
+ this.#store.set({
183
+ status: "unverified",
184
+ subject: {
185
+ id: "",
186
+ email,
187
+ roles: [],
188
+ isVerified: false,
189
+ hasPassword: false,
190
+ },
191
+ jwt: null,
192
+ expiresAt: null,
193
+ });
194
+ this.#persist();
195
+ this.#emitChange();
196
+ }
197
+ /** Drop the session. Storage is cleared; downstream domains should be
198
+ * reset by the suite orchestrator. */
199
+ clear() {
200
+ if (this.#store.get().status === "anonymous")
201
+ return;
202
+ this.#store.set({ ...EMPTY });
203
+ this.#persist();
204
+ this.#emitChange();
205
+ }
206
+ /** Patch the subject in place without touching the JWT. Used when the
207
+ * profile manager changes email / linked providers / etc. */
208
+ patchSubject(patch) {
209
+ const s = this.#store.get();
210
+ if (!s.subject)
211
+ return;
212
+ const next = {
213
+ ...s,
214
+ subject: { ...s.subject, ...patch },
215
+ };
216
+ this.#store.set(next);
217
+ this.#persist();
218
+ this.#emitChange();
219
+ }
220
+ /** Test / reset hook — clears storage AND in-memory state without
221
+ * emitting. Used by teardown. */
222
+ destroy() {
223
+ this.#storage.del(this.#storageKey);
224
+ this.#store.set({ ...EMPTY });
225
+ }
226
+ }
package/dist/mod.d.ts CHANGED
@@ -29,3 +29,4 @@ export * from "./ownsuite.js";
29
29
  export * from "./domains/mod.js";
30
30
  export * from "./types/mod.js";
31
31
  export * from "./adapters/mod.js";
32
+ export * from "./oauth/popup.js";
package/dist/mod.js CHANGED
@@ -29,3 +29,4 @@ export * from "./ownsuite.js";
29
29
  export * from "./domains/mod.js";
30
30
  export * from "./types/mod.js";
31
31
  export * from "./adapters/mod.js";
32
+ export * from "./oauth/popup.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @module oauth/popup
3
+ *
4
+ * `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
5
+ * resolves with the postMessage the server's callback page posts back.
6
+ *
7
+ * Server shape (stack-account): the OAuth callback page renders a `<script>`
8
+ * that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
9
+ * We listen for those messages, validate the `type` field, and resolve.
10
+ *
11
+ * For tests, an injectable window-like interface lets us shim `postMessage`
12
+ * via a MessageChannel without a real browser.
13
+ */
14
+ export interface OAuthPopupLoginMessage {
15
+ type: "oauth_login_success";
16
+ jwt: string;
17
+ email: string;
18
+ roles?: string[];
19
+ isNewAccount?: boolean;
20
+ redirectUrl?: string;
21
+ }
22
+ export interface OAuthPopupLinkMessage {
23
+ type: "oauth_link_success";
24
+ provider: string;
25
+ }
26
+ export interface OAuthPopupErrorMessage {
27
+ type: "oauth_error";
28
+ error: string;
29
+ }
30
+ export type OAuthPopupMessage = OAuthPopupLoginMessage | OAuthPopupLinkMessage;
31
+ /**
32
+ * Minimal window-like surface we need. Real `Window` satisfies this; tests
33
+ * supply a shim.
34
+ */
35
+ export interface PopupWindowHost {
36
+ open(url: string, target: string, features?: string): PopupWindowHandle | null;
37
+ addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
38
+ removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
39
+ }
40
+ export interface PopupWindowHandle {
41
+ closed: boolean;
42
+ focus?(): void;
43
+ close?(): void;
44
+ }
45
+ export interface OpenOAuthPopupOptions {
46
+ /** Host window — defaults to `globalThis` when running in the browser. */
47
+ host?: PopupWindowHost;
48
+ /** How often to poll for popup-closed-without-message. Default 500ms. */
49
+ closedPollMs?: number;
50
+ /** Hard timeout in ms; 0 disables. Default 0. */
51
+ timeoutMs?: number;
52
+ /** Restrict accepted message origins. Default: no origin check (the
53
+ * server's postMessage uses "*" intentionally; origin validation is the
54
+ * caller's responsibility if needed). */
55
+ expectedOrigin?: string;
56
+ /** Popup window features string. */
57
+ features?: string;
58
+ }
59
+ /**
60
+ * Open the OAuth popup at `url` and await a success message from it.
61
+ * Rejects if the popup is closed before a message arrives, if the received
62
+ * message is an error, or if the optional timeout fires.
63
+ */
64
+ export declare function openOAuthPopup(url: string, options?: OpenOAuthPopupOptions): Promise<OAuthPopupMessage>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @module oauth/popup
3
+ *
4
+ * `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
5
+ * resolves with the postMessage the server's callback page posts back.
6
+ *
7
+ * Server shape (stack-account): the OAuth callback page renders a `<script>`
8
+ * that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
9
+ * We listen for those messages, validate the `type` field, and resolve.
10
+ *
11
+ * For tests, an injectable window-like interface lets us shim `postMessage`
12
+ * via a MessageChannel without a real browser.
13
+ */
14
+ const DEFAULT_FEATURES = "width=500,height=700,noopener=no,noreferrer=no";
15
+ /**
16
+ * Open the OAuth popup at `url` and await a success message from it.
17
+ * Rejects if the popup is closed before a message arrives, if the received
18
+ * message is an error, or if the optional timeout fires.
19
+ */
20
+ export function openOAuthPopup(url, options = {}) {
21
+ const host = (options.host ?? globalThis);
22
+ if (typeof host.open !== "function" || typeof host.addEventListener !== "function") {
23
+ return Promise.reject(new Error("openOAuthPopup: host window does not support popups"));
24
+ }
25
+ const features = options.features ?? DEFAULT_FEATURES;
26
+ const closedPollMs = options.closedPollMs ?? 500;
27
+ const popup = host.open(url, "oauth_popup", features);
28
+ if (!popup) {
29
+ return Promise.reject(new Error("OAUTH_POPUP_BLOCKED"));
30
+ }
31
+ return new Promise((resolve, reject) => {
32
+ let settled = false;
33
+ let pollTimer;
34
+ let timeoutTimer;
35
+ const cleanup = () => {
36
+ settled = true;
37
+ host.removeEventListener("message", onMessage);
38
+ if (pollTimer !== undefined)
39
+ clearInterval(pollTimer);
40
+ if (timeoutTimer !== undefined)
41
+ clearTimeout(timeoutTimer);
42
+ };
43
+ const onMessage = (event) => {
44
+ if (settled)
45
+ return;
46
+ if (options.expectedOrigin !== undefined &&
47
+ event.origin !== options.expectedOrigin) {
48
+ return; // silently drop mismatched origin messages
49
+ }
50
+ const data = event.data;
51
+ if (!data || typeof data !== "object" || typeof data.type !== "string") {
52
+ return;
53
+ }
54
+ if (data.type === "oauth_login_success" ||
55
+ data.type === "oauth_link_success") {
56
+ cleanup();
57
+ try {
58
+ popup.close?.();
59
+ }
60
+ catch {
61
+ // ignore
62
+ }
63
+ resolve(data);
64
+ return;
65
+ }
66
+ if (data.type === "oauth_error") {
67
+ cleanup();
68
+ try {
69
+ popup.close?.();
70
+ }
71
+ catch {
72
+ // ignore
73
+ }
74
+ reject(new Error(data.error));
75
+ return;
76
+ }
77
+ };
78
+ host.addEventListener("message", onMessage);
79
+ if (closedPollMs > 0) {
80
+ pollTimer = setInterval(() => {
81
+ if (settled)
82
+ return;
83
+ if (popup.closed) {
84
+ cleanup();
85
+ reject(new Error("OAUTH_POPUP_CLOSED"));
86
+ }
87
+ }, closedPollMs);
88
+ }
89
+ if (options.timeoutMs && options.timeoutMs > 0) {
90
+ timeoutTimer = setTimeout(() => {
91
+ if (settled)
92
+ return;
93
+ cleanup();
94
+ try {
95
+ popup.close?.();
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ reject(new Error("OAUTH_POPUP_TIMEOUT"));
101
+ }, options.timeoutMs);
102
+ }
103
+ });
104
+ }