@base44-preview/sdk 0.8.41-pr.244.e7d6747 → 0.8.42-pr.254.6a358ec

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/client.js CHANGED
@@ -5,7 +5,6 @@ import { createAuthModule } from "./modules/auth.js";
5
5
  import { createSsoModule } from "./modules/sso.js";
6
6
  import { createConnectorsModule, createUserConnectorsModule, } from "./modules/connectors.js";
7
7
  import { getAccessToken } from "./utils/auth-utils.js";
8
- import { redeemSessionHandoffCode } from "./utils/session-handoff.js";
9
8
  import { createFunctionsModule } from "./modules/functions.js";
10
9
  import { createAgentsModule } from "./modules/agents.js";
11
10
  import { createAiGatewayModule } from "./modules/ai-gateway.js";
@@ -119,32 +118,10 @@ export function createClient(config) {
119
118
  // requests during construction (notably analytics, which fires an init
120
119
  // event whose flush calls auth.me()). Without this, the first User/me
121
120
  // request is built before setToken runs and goes out unauthenticated.
122
- //
123
- // Precedence: an explicit config token wins (legacy behavior); then a PKCE
124
- // session-code handoff in the URL (base44-dev/apper#17216 §5.2) — the user
125
- // just completed a login, so it outranks any stored token, mirroring how
126
- // getAccessToken prefers a URL access_token over localStorage; then the
127
- // legacy capture. When the server spoke legacy — including after a backend
128
- // rollback — redeemSessionHandoffCode() returns null synchronously and the
129
- // legacy capture below runs unchanged.
130
- let tokenBootstrap = null;
131
121
  if (typeof window !== "undefined") {
132
- const sessionExchange = token ? null : redeemSessionHandoffCode();
133
- if (sessionExchange) {
134
- tokenBootstrap = sessionExchange.then((exchangedToken) => {
135
- // On exchange failure, fall back to any stored token rather than
136
- // leaving auth state empty (same fallback getAccessToken applies).
137
- const accessToken = exchangedToken || getAccessToken();
138
- if (accessToken) {
139
- userAuthModule.setToken(accessToken);
140
- }
141
- });
142
- }
143
- else {
144
- const accessToken = token || getAccessToken();
145
- if (accessToken) {
146
- userAuthModule.setToken(accessToken);
147
- }
122
+ const accessToken = token || getAccessToken();
123
+ if (accessToken) {
124
+ userAuthModule.setToken(accessToken);
148
125
  }
149
126
  }
150
127
  const actorsModule = createActorsModule({
@@ -241,13 +218,6 @@ export function createClient(config) {
241
218
  // We perform this check asynchronously to not block client creation
242
219
  setTimeout(async () => {
243
220
  try {
244
- // A pending session-code exchange must settle before the auth probe.
245
- // Probing early would see no token, redirect to login, and abandon
246
- // the in-flight exchange — minting a fresh code on every round, i.e.
247
- // a login loop (the exact BUG-787 failure shape).
248
- if (tokenBootstrap) {
249
- await tokenBootstrap;
250
- }
251
221
  const isAuthenticated = await userModules.auth.isAuthenticated();
252
222
  if (!isAuthenticated) {
253
223
  userModules.auth.redirectToLogin(window.location.href);
@@ -16,5 +16,16 @@ export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, us
16
16
  track: (params: TrackEventParams) => void;
17
17
  cleanup: () => void;
18
18
  };
19
+ /**
20
+ * Clears the memoized analytics session context.
21
+ *
22
+ * The context holds the `user_id` resolved by `auth.me()` and is reused for the
23
+ * lifetime of the session, so it has to be dropped whenever the identity
24
+ * changes. Without this, a visitor who loads a page anonymously and then logs in
25
+ * keeps reporting `user_id: null` on every subsequent event.
26
+ *
27
+ * @internal
28
+ */
29
+ export declare function resetAnalyticsSessionContext(): void;
19
30
  export declare function getAnalyticsConfigFromUrlParams(): AnalyticsModuleOptions | undefined;
20
31
  export declare function getAnalyticsSessionId(): string;
@@ -165,7 +165,12 @@ async function startAnalyticsProcessor(handleTrack, options) {
165
165
  }
166
166
  function startHeartBeatProcessor(track) {
167
167
  var _a;
168
- if (analyticsSharedState.isHeartBeatProcessing ||
168
+ // Browser-only, like the other automatic events here (initialization, session
169
+ // duration, visibility). Outside a browser this timer fired a `me()` every
170
+ // interval for the lifetime of a long-lived server-side client, and kept the
171
+ // Node event loop alive. Explicit `analytics.track()` calls still work.
172
+ if (typeof window === "undefined" ||
173
+ analyticsSharedState.isHeartBeatProcessing ||
169
174
  ((_a = analyticsSharedState.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
170
175
  return () => { };
171
176
  }
@@ -226,6 +231,20 @@ function transformEventDataToApiRequestData(sessionContext) {
226
231
  });
227
232
  }
228
233
  let sessionContextPromise = null;
234
+ /**
235
+ * Clears the memoized analytics session context.
236
+ *
237
+ * The context holds the `user_id` resolved by `auth.me()` and is reused for the
238
+ * lifetime of the session, so it has to be dropped whenever the identity
239
+ * changes. Without this, a visitor who loads a page anonymously and then logs in
240
+ * keeps reporting `user_id: null` on every subsequent event.
241
+ *
242
+ * @internal
243
+ */
244
+ export function resetAnalyticsSessionContext() {
245
+ analyticsSharedState.sessionContext = null;
246
+ sessionContextPromise = null;
247
+ }
229
248
  async function getSessionContext(userAuthModule) {
230
249
  if (!analyticsSharedState.sessionContext) {
231
250
  if (!sessionContextPromise) {
@@ -241,7 +260,18 @@ async function getSessionContext(userAuthModule) {
241
260
  session_id: sessionId,
242
261
  }));
243
262
  }
244
- analyticsSharedState.sessionContext = await sessionContextPromise;
263
+ const pending = sessionContextPromise;
264
+ const context = await pending;
265
+ // Publish only if this lookup is still the current one. A reset that lands
266
+ // while the request is in flight nulls `sessionContextPromise`, and an
267
+ // unconditional write here would put the pre-reset identity back and pin it
268
+ // for the rest of the session. The awaited value is still returned: these
269
+ // events were queued before the identity changed, so that is who they
270
+ // belong to.
271
+ if (sessionContextPromise === pending) {
272
+ analyticsSharedState.sessionContext = context;
273
+ }
274
+ return context;
245
275
  }
246
276
  return analyticsSharedState.sessionContext;
247
277
  }
@@ -1,4 +1,4 @@
1
- import { prepareSessionHandoffKickoff } from "../utils/session-handoff.js";
1
+ import { resetAnalyticsSessionContext } from "./analytics.js";
2
2
  function isInsideIframe() {
3
3
  if (typeof window === "undefined")
4
4
  return false;
@@ -64,10 +64,33 @@ function loginViaPopup(url, redirectUrl, expectedOrigin) {
64
64
  * @internal
65
65
  */
66
66
  export function createAuthModule(axios, functionsAxiosClient, appId, options) {
67
+ // In-flight `me()` request, shared by concurrent callers. The analytics
68
+ // module resolves its session context through `me()` at client construction,
69
+ // at the same moment most apps issue their own `me()`. Browsers serialize the
70
+ // two identical GETs, so the second pays the first's full latency on every
71
+ // cold load.
72
+ //
73
+ // This shares the pending promise only — it is cleared as soon as the request
74
+ // settles, so no resolved user is ever retained. Caching the user across
75
+ // requests would leave the app rendering a stale identity after logout or a
76
+ // session swap.
77
+ let pendingMe = null;
78
+ const clearPendingMe = () => {
79
+ pendingMe = null;
80
+ };
67
81
  return {
68
82
  // Get current user information
69
83
  async me() {
70
- return axios.get(`/apps/${appId}/entities/User/me`);
84
+ const request = pendingMe !== null && pendingMe !== void 0 ? pendingMe : axios.get(`/apps/${appId}/entities/User/me`).finally(() => {
85
+ // Only retire this request if it is still the shared one. An identity
86
+ // change mid-flight clears `pendingMe` and the next caller starts a
87
+ // fresh request; an unconditional clear here would retire that newer
88
+ // request instead, so a third caller would issue a duplicate.
89
+ if (pendingMe === request)
90
+ pendingMe = null;
91
+ });
92
+ pendingMe = request;
93
+ return request;
71
94
  },
72
95
  // Update current user data
73
96
  async updateMe(data) {
@@ -105,32 +128,11 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
105
128
  }
106
129
  const loginUrl = `${options.appBaseUrl}/api${authPath}?${queryParams}`;
107
130
  // When running inside an iframe, use a popup to avoid OAuth providers
108
- // blocking iframe navigation. Popups stay on the exact legacy kickoff:
109
- // they deliver the token via postMessage and never redeem a code (the
110
- // backend skips the code mint when popup_origin is present).
131
+ // blocking iframe navigation.
111
132
  if (isInsideIframe()) {
112
133
  const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`;
113
134
  return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin);
114
135
  }
115
- // Full-page SSO redirect: offer the PKCE session-code handoff
116
- // (base44-dev/apper#17216 §5.2). The backend decides per request whether
117
- // to use it; a server that answers with the legacy ?access_token= —
118
- // including after a backend rollback — is honored unchanged at
119
- // redemption. If PKCE can't be prepared locally, kick off with the
120
- // unmodified legacy URL (version=2 without a valid challenge is a 400
121
- // at /login, so it's all-or-nothing).
122
- if (provider === "sso") {
123
- prepareSessionHandoffKickoff()
124
- .then((pkceQuery) => {
125
- window.location.href = pkceQuery
126
- ? `${loginUrl}${pkceQuery}`
127
- : loginUrl;
128
- })
129
- .catch(() => {
130
- window.location.href = loginUrl;
131
- });
132
- return;
133
- }
134
136
  // Default: full-page redirect
135
137
  window.location.href = loginUrl;
136
138
  },
@@ -138,6 +140,10 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
138
140
  logout(redirectUrl) {
139
141
  // Remove token from axios headers (always do this)
140
142
  delete axios.defaults.headers.common["Authorization"];
143
+ // Drop identity resolved under the previous session: a `me()` already in
144
+ // flight would otherwise resolve into callers that run after the logout.
145
+ clearPendingMe();
146
+ resetAnalyticsSessionContext();
141
147
  // Only do the rest if in a browser environment
142
148
  if (typeof window !== "undefined") {
143
149
  // Remove token from localStorage
@@ -162,6 +168,10 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
162
168
  setToken(token, saveToStorage = true) {
163
169
  if (!token)
164
170
  return;
171
+ // Same reasoning as in `logout`: the identity changes here, so anything
172
+ // resolved for the previous one must not be handed to later callers.
173
+ clearPendingMe();
174
+ resetAnalyticsSessionContext();
165
175
  // handle token change for axios clients
166
176
  axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
167
177
  functionsAxiosClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
@@ -53,11 +53,14 @@ export interface AppUserConnectorConnectionResponse {
53
53
  *
54
54
  * ## Shared connectors
55
55
  *
56
- * All app users share a single OAuth token. Use this for shared accounts. For example, posting to a company Slack channel or reading from a shared Google Calendar. To use a shared connector:
56
+ * All app users share a single OAuth token. Use this for shared accounts. For example, posting to a company Slack channel or reading from a shared Google Calendar.
57
57
  *
58
- * 1. Connect the external service account in the app's Integration settings or using the [`connectors push`](/developers/references/cli/commands/connectors-push) CLI command.
59
- * 2. In a backend function, call {@linkcode getConnection | getConnection()} using the service role client (`base44.asServiceRole.connectors`) with an [integration type](#available-connectors) string to retrieve the shared OAuth token.
60
- * 3. Use the returned `accessToken` to call the external service's API directly. Some connectors also return a `connectionConfig` with additional values such as a subdomain for building the API URL.
58
+ * Shared connectors come in two forms, depending on how the connector is set up. Both return the same app-wide token, so they differ only in how you identify the connector in code:
59
+ *
60
+ * - **Platform connectors** are connected from the app's Integration settings or with the [`connectors push`](/developers/references/cli/commands/connectors-push) CLI command, and are identified by an [integration type](#available-connectors) string. Retrieve them with {@linkcode getConnection | getConnection()}.
61
+ * - **Workspace-registered connectors** are backed by your own OAuth app, registered once in Workspace Settings and consented to by the app builder. They are identified by a connector ID instead of an integration type. Retrieve them with {@linkcode getWorkspaceConnection | getWorkspaceConnection()}. Connectors whose OAuth app is specific to your own account, such as Databricks and Snowflake, work this way.
62
+ *
63
+ * To use a shared connector, call the matching method on the service role client `base44.asServiceRole.connectors` from a backend function, then use the returned `accessToken` to call the external service's API directly. Some connectors also return a `connectionConfig` with additional values, such as a subdomain, that you need to build the API URL.
61
64
  *
62
65
  * ## App user connectors
63
66
  *
@@ -70,7 +73,7 @@ export interface AppUserConnectorConnectionResponse {
70
73
  *
71
74
  * ## Available connectors
72
75
  *
73
- * All connectors listed below support both shared and app user connections. For shared connectors, pass the integration type string to {@linkcode getConnection | getConnection()}. For app user connectors, register the connector in Workspace Settings and use the connector ID with {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()}.
76
+ * The connectors below can be used as shared connectors or as app user connectors. For a shared platform connector, pass the integration type string to {@linkcode getConnection | getConnection()}. For a connector you register in Workspace Settings with your own OAuth app, use the connector ID with {@linkcode getWorkspaceConnection | getWorkspaceConnection()} for a shared token, or with {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()} for a per-user token.
74
77
  *
75
78
  * | Service | Type identifier |
76
79
  * |---|---|
@@ -204,6 +207,8 @@ export interface ConnectorsModule {
204
207
  *
205
208
  * Use this when a single shared account is connected and all app users access the same token. For per-user tokens, use [`getCurrentAppUserConnection()`](#getcurrentappuserconnection) instead.
206
209
  *
210
+ * This form is for platform connectors identified by an integration type. Connectors backed by your own OAuth app registered in Workspace Settings, such as Databricks and Snowflake, are retrieved by connector ID with {@linkcode getWorkspaceConnection | getWorkspaceConnection()} instead.
211
+ *
207
212
  * Some connectors require connection-specific parameters to build API calls.
208
213
  * In such cases, the returned `connectionConfig` is an object with the additional parameters. If there are no extra parameters needed for the connection, the `connectionConfig` is `null`.
209
214
  *
@@ -257,28 +262,27 @@ export interface ConnectorsModule {
257
262
  */
258
263
  getConnection(integrationType: ConnectorIntegrationType): Promise<ConnectorConnectionResponse>;
259
264
  /**
260
- * Retrieves the OAuth access token and connection configuration for a **workspace-registered** connector
261
- * (a connector backed by an OAuth app registered in the workspace, consented to once by the app builder).
265
+ * Retrieves the shared OAuth access token and connection configuration for a [workspace-registered connector](#shared-connectors).
262
266
  *
263
- * Use this method when the app's backend function needs to use a connector identified by its
264
- * workspace-connector ID rather than a platform integration type. The token returned represents
265
- * the app builder's consent against the workspace's OAuth app and is shared across all app users
266
- * of the app — identical semantics to the platform-shared {@link getConnection} form,
267
- * differing only in which OAuth app was used to produce the token.
267
+ * Use this for a connector backed by your own OAuth app that you register in Workspace Settings, such as Databricks or Snowflake. The app builder consents to the connector once, and the returned token is shared across all app users of the app. This is the shared-token counterpart to {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()}, which returns a per-user token for the same kind of connector. The semantics match {@linkcode getConnection | getConnection()}, except that you identify the connector by ID rather than by integration type.
268
268
  *
269
- * @param connectorId - The ID of the workspace connector (the `OrganizationConnector` database ID) as surfaced in the builder chat context.
269
+ * Some connectors require connection-specific parameters to build API calls. In such cases, the returned `connectionConfig` is an object with those parameters, such as the account subdomain used to construct the API URL. When no extra parameters are needed, `connectionConfig` is `null`.
270
+ *
271
+ * @param connectorId - The ID of the workspace connector, not the integration type string. You can find it on the connector's settings page in Workspace Settings.
270
272
  * @returns Promise resolving to a {@link ConnectorConnectionResponse} with `accessToken` and `connectionConfig`.
271
273
  *
272
274
  * @example
273
275
  * ```typescript
274
- * // Get the connection for a workspace-registered connector
275
- * const { accessToken, connectionConfig } = await base44.asServiceRole.connectors.getWorkspaceConnection(
276
- * 'abc123def',
276
+ * // Snowflake connection
277
+ * // Retrieve the shared token and run a statement against the account
278
+ * const { accessToken, connectionConfig } = await base44.asServiceRole.connectors.getWorkspaceConnection('abc123def');
279
+ *
280
+ * const response = await fetch(
281
+ * `https://${connectionConfig?.subdomain}.snowflakecomputing.com/api/v2/statements`,
282
+ * { headers: { Authorization: `Bearer ${accessToken}` } }
277
283
  * );
278
284
  *
279
- * const response = await fetch(`https://${connectionConfig?.subdomain}.snowflakecomputing.com/api/v2/statements`, {
280
- * headers: { Authorization: `Bearer ${accessToken}` },
281
- * });
285
+ * const data = await response.json();
282
286
  * ```
283
287
  */
284
288
  getWorkspaceConnection(connectorId: string): Promise<ConnectorConnectionResponse>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.41-pr.244.e7d6747",
3
+ "version": "0.8.42-pr.254.6a358ec",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,52 +0,0 @@
1
- /**
2
- * PKCE-bound one-time session-code handoff for SSO logins
3
- * (base44-dev/apper#17216 §5.2).
4
- *
5
- * The SDK OFFERS the handoff at login kickoff (`version=2` + S256
6
- * `code_challenge`) and the backend DECIDES per request: it only emits a
7
- * `session_code` when every server-side gate holds (verified custom domain,
8
- * non-private app, feature flag ON). In every other case — older backends,
9
- * excluded apps, and critically a BACKEND ROLLBACK — the server keeps
10
- * delivering the legacy `?access_token=` URL param, which the SDK digests
11
- * exactly as before. This is a negotiation, never a deprecation: the legacy
12
- * path must keep working here forever.
13
- *
14
- * Fail-open rule for kickoff: never send `version=2` unless this browser can
15
- * actually complete the exchange (WebCrypto, sessionStorage that persists,
16
- * fetch). The server 400s a `version=2` login without a valid challenge, and
17
- * an opted-in login whose verifier is lost can never redeem its code — both
18
- * are avoided by simply not opting in and letting the legacy path run.
19
- */
20
- /** sessionStorage key for the PKCE verifier. Per-tab by design (RFC 7636: the
21
- * verifier never leaves the browser); a login that completes in a different
22
- * tab loses it — a named, expected failure mode, see redeemSessionHandoffCode. */
23
- export declare const PKCE_VERIFIER_STORAGE_KEY = "base44_pkce_verifier";
24
- /**
25
- * Prepares the PKCE opt-in for an SSO login kickoff.
26
- *
27
- * Generates a verifier, persists it in sessionStorage (verified by read-back —
28
- * a write that doesn't stick means the exchange could never succeed), and
29
- * returns the query-string suffix to append to the login URL:
30
- * `&version=2&code_challenge=<S256>&code_challenge_method=S256`.
31
- *
32
- * Returns `null` on ANY failure or missing capability, in which case the
33
- * caller must use the unmodified legacy login URL. Never throws.
34
- *
35
- * @internal
36
- */
37
- export declare function prepareSessionHandoffKickoff(): Promise<string | null>;
38
- /**
39
- * Redeems a PKCE session-code handoff from the current URL, if one is present.
40
- *
41
- * Returns `null` synchronously when the URL carries no handoff — including
42
- * when it carries a legacy `?access_token=` (the legacy capture wins outright;
43
- * this is what makes a backend rollback safe). Otherwise strips the handoff
44
- * params from the URL immediately and returns a promise resolving to the
45
- * exchanged access token, or `null` when the exchange fails. Never rejects,
46
- * never redirects: a failed exchange leaves the app unauthenticated and lets
47
- * its normal login flow take over (each retry mints a fresh code, so this
48
- * self-heals rather than looping).
49
- *
50
- * @internal
51
- */
52
- export declare function redeemSessionHandoffCode(): Promise<string | null> | null;
@@ -1,184 +0,0 @@
1
- /**
2
- * PKCE-bound one-time session-code handoff for SSO logins
3
- * (base44-dev/apper#17216 §5.2).
4
- *
5
- * The SDK OFFERS the handoff at login kickoff (`version=2` + S256
6
- * `code_challenge`) and the backend DECIDES per request: it only emits a
7
- * `session_code` when every server-side gate holds (verified custom domain,
8
- * non-private app, feature flag ON). In every other case — older backends,
9
- * excluded apps, and critically a BACKEND ROLLBACK — the server keeps
10
- * delivering the legacy `?access_token=` URL param, which the SDK digests
11
- * exactly as before. This is a negotiation, never a deprecation: the legacy
12
- * path must keep working here forever.
13
- *
14
- * Fail-open rule for kickoff: never send `version=2` unless this browser can
15
- * actually complete the exchange (WebCrypto, sessionStorage that persists,
16
- * fetch). The server 400s a `version=2` login without a valid challenge, and
17
- * an opted-in login whose verifier is lost can never redeem its code — both
18
- * are avoided by simply not opting in and letting the legacy path run.
19
- */
20
- /** sessionStorage key for the PKCE verifier. Per-tab by design (RFC 7636: the
21
- * verifier never leaves the browser); a login that completes in a different
22
- * tab loses it — a named, expected failure mode, see redeemSessionHandoffCode. */
23
- export const PKCE_VERIFIER_STORAGE_KEY = "base44_pkce_verifier";
24
- /** Server-side format for challenge/verifier: base64url of 32 bytes, 43 chars. */
25
- const BASE64URL_43 = /^[A-Za-z0-9_-]{43}$/;
26
- function base64UrlEncode(bytes) {
27
- let binary = "";
28
- for (const byte of bytes) {
29
- binary += String.fromCharCode(byte);
30
- }
31
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
32
- }
33
- /**
34
- * Prepares the PKCE opt-in for an SSO login kickoff.
35
- *
36
- * Generates a verifier, persists it in sessionStorage (verified by read-back —
37
- * a write that doesn't stick means the exchange could never succeed), and
38
- * returns the query-string suffix to append to the login URL:
39
- * `&version=2&code_challenge=<S256>&code_challenge_method=S256`.
40
- *
41
- * Returns `null` on ANY failure or missing capability, in which case the
42
- * caller must use the unmodified legacy login URL. Never throws.
43
- *
44
- * @internal
45
- */
46
- export async function prepareSessionHandoffKickoff() {
47
- var _a;
48
- try {
49
- if (typeof window === "undefined")
50
- return null;
51
- const crypto = globalThis.crypto;
52
- if (!(crypto === null || crypto === void 0 ? void 0 : crypto.getRandomValues) || !((_a = crypto.subtle) === null || _a === void 0 ? void 0 : _a.digest))
53
- return null;
54
- // The exchange at redemption time needs fetch; don't opt in without it.
55
- if (typeof fetch !== "function")
56
- return null;
57
- const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
58
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
59
- const challenge = base64UrlEncode(new Uint8Array(digest));
60
- // The server rejects a malformed opt-in with a 400 at /login; a malformed
61
- // challenge here must therefore mean "don't opt in", never "send anyway".
62
- if (!BASE64URL_43.test(challenge))
63
- return null;
64
- // Store last, after everything else succeeded, and verify the write took
65
- // (sandboxed iframes and lockdown modes can throw OR silently drop it).
66
- window.sessionStorage.setItem(PKCE_VERIFIER_STORAGE_KEY, verifier);
67
- if (window.sessionStorage.getItem(PKCE_VERIFIER_STORAGE_KEY) !== verifier) {
68
- return null;
69
- }
70
- return `&version=2&code_challenge=${challenge}&code_challenge_method=S256`;
71
- }
72
- catch (_b) {
73
- return null;
74
- }
75
- }
76
- /**
77
- * Redeems a PKCE session-code handoff from the current URL, if one is present.
78
- *
79
- * Returns `null` synchronously when the URL carries no handoff — including
80
- * when it carries a legacy `?access_token=` (the legacy capture wins outright;
81
- * this is what makes a backend rollback safe). Otherwise strips the handoff
82
- * params from the URL immediately and returns a promise resolving to the
83
- * exchanged access token, or `null` when the exchange fails. Never rejects,
84
- * never redirects: a failed exchange leaves the app unauthenticated and lets
85
- * its normal login flow take over (each retry mints a fresh code, so this
86
- * self-heals rather than looping).
87
- *
88
- * @internal
89
- */
90
- export function redeemSessionHandoffCode() {
91
- if (typeof window === "undefined" || !window.location)
92
- return null;
93
- let code = null;
94
- let exchangePath = null;
95
- let urlParams;
96
- try {
97
- urlParams = new URLSearchParams(window.location.search);
98
- code = urlParams.get("session_code");
99
- exchangePath = urlParams.get("session_exchange_path");
100
- if (!code || !exchangePath)
101
- return null;
102
- // A server speaking legacy is authoritative: if an access_token is in the
103
- // URL (the two are never both sent by a real backend), take the legacy
104
- // path and ignore the code entirely.
105
- if (urlParams.get("access_token"))
106
- return null;
107
- // Strip the one-time params right away so the code doesn't linger in the
108
- // URL/history or get re-submitted on reload. `is_new_user` stays in the
109
- // URL exactly as the legacy flow leaves it.
110
- urlParams.delete("session_code");
111
- urlParams.delete("session_exchange_path");
112
- const newUrl = `${window.location.pathname}${urlParams.toString() ? `?${urlParams.toString()}` : ""}${window.location.hash}`;
113
- window.history.replaceState({}, typeof document !== "undefined" ? document.title : "", newUrl);
114
- }
115
- catch (e) {
116
- console.error("Error reading session handoff params from URL:", e);
117
- return null;
118
- }
119
- return exchangeSessionHandoffCode(code, exchangePath);
120
- }
121
- async function exchangeSessionHandoffCode(code, exchangePath) {
122
- // The verifier is one-shot: take it out of storage no matter how the
123
- // exchange ends (a failed PKCE check doesn't burn the code server-side,
124
- // but a stale verifier can never match a future login's challenge).
125
- let verifier = null;
126
- try {
127
- verifier = window.sessionStorage.getItem(PKCE_VERIFIER_STORAGE_KEY);
128
- if (verifier !== null) {
129
- window.sessionStorage.removeItem(PKCE_VERIFIER_STORAGE_KEY);
130
- }
131
- }
132
- catch (_a) {
133
- verifier = null;
134
- }
135
- // SECURITY: the exchange path arrives via the URL, so treat it as tainted.
136
- // POSTing the code + verifier to an attacker-chosen origin would hand over
137
- // both halves of the PKCE proof — enforce same-origin, path-only semantics.
138
- let exchangeUrl;
139
- try {
140
- exchangeUrl = new URL(exchangePath, window.location.origin);
141
- }
142
- catch (_b) {
143
- console.error("Invalid session_exchange_path; skipping token exchange.");
144
- return null;
145
- }
146
- if (exchangeUrl.origin !== window.location.origin) {
147
- console.error("Cross-origin session_exchange_path rejected; skipping token exchange.");
148
- return null;
149
- }
150
- if (typeof fetch !== "function")
151
- return null;
152
- try {
153
- const response = await fetch(exchangeUrl.toString(), {
154
- method: "POST",
155
- headers: { "Content-Type": "application/json" },
156
- body: JSON.stringify({
157
- code,
158
- ...(verifier ? { code_verifier: verifier } : {}),
159
- }),
160
- });
161
- if (!response.ok) {
162
- if (!verifier) {
163
- // Named failure mode (base44-dev/apper#17216): the login completed in
164
- // a different tab/window than it started in, so the per-tab PKCE
165
- // verifier is gone and the server fails closed. Logging in again from
166
- // this tab works.
167
- console.warn("Base44 SDK: SSO login could not be completed because it finished " +
168
- "in a different browser tab than it started in (missing PKCE " +
169
- "verifier). Please log in again.");
170
- }
171
- else {
172
- console.error(`Base44 SDK: SSO session-code exchange failed (HTTP ${response.status}).`);
173
- }
174
- return null;
175
- }
176
- const data = await response.json();
177
- const accessToken = data === null || data === void 0 ? void 0 : data.access_token;
178
- return typeof accessToken === "string" && accessToken ? accessToken : null;
179
- }
180
- catch (e) {
181
- console.error("Base44 SDK: SSO session-code exchange failed:", e);
182
- return null;
183
- }
184
- }