@base44-preview/sdk 0.8.40-pr.242.b74cef0 → 0.8.41-pr.244.e7d6747
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/actor.d.ts +95 -0
- package/dist/actor.js +85 -0
- package/dist/client.js +47 -6
- package/dist/client.types.d.ts +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/modules/actors.d.ts +25 -0
- package/dist/modules/actors.js +135 -0
- package/dist/modules/actors.types.d.ts +95 -0
- package/dist/modules/actors.types.js +1 -0
- package/dist/modules/auth.js +25 -7
- package/dist/utils/session-handoff.d.ts +52 -0
- package/dist/utils/session-handoff.js +184 -0
- package/package.json +2 -1
package/dist/actor.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-only base class for Actors.
|
|
3
|
+
*
|
|
4
|
+
* Import and extend this in your actor files:
|
|
5
|
+
* import { Actor } from "@base44/sdk";
|
|
6
|
+
* export class MyActor extends Actor { ... }
|
|
7
|
+
*
|
|
8
|
+
* At deploy time the bundler replaces this import with the compiled
|
|
9
|
+
* Cloudflare Durable Object implementation — this file provides types only.
|
|
10
|
+
*/
|
|
11
|
+
import type { Base44Client } from "./client";
|
|
12
|
+
/**
|
|
13
|
+
* A single client connection. `Send` is the message type this connection accepts
|
|
14
|
+
* via {@link send} — the actor's *outgoing* (server→client) messages.
|
|
15
|
+
*/
|
|
16
|
+
export interface Conn<Send = unknown> {
|
|
17
|
+
/** Unique per-connection id (one per socket/tab), the same value the client
|
|
18
|
+
* receives from `subscribe()`. Identifies a distinct client, so multiple
|
|
19
|
+
* tabs are separate connections. */
|
|
20
|
+
id: string;
|
|
21
|
+
send(data: Send): void;
|
|
22
|
+
reject(code: number, reason: string): void;
|
|
23
|
+
}
|
|
24
|
+
export interface Storage {
|
|
25
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
26
|
+
put(key: string, value: unknown): Promise<void>;
|
|
27
|
+
delete(key: string): Promise<boolean>;
|
|
28
|
+
/** Wipe the room's entire persisted storage (match-end cleanup). Safe: a
|
|
29
|
+
* later rejoin re-bootstraps exactly like a brand-new room. */
|
|
30
|
+
deleteAll(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Base class for an Actor.
|
|
34
|
+
*
|
|
35
|
+
* @typeParam Incoming - messages this actor *receives* from clients
|
|
36
|
+
* (`handleMessage`'s `msg`) — the schema's `toServer` section.
|
|
37
|
+
* @typeParam Outgoing - messages this actor *sends* to clients
|
|
38
|
+
* (`conn.send`/`broadcast`) — the schema's `toClient` section.
|
|
39
|
+
*
|
|
40
|
+
* With a generated `schema.jsonc`, wire both from the registry so they can't drift
|
|
41
|
+
* from the client's types:
|
|
42
|
+
* ```ts
|
|
43
|
+
* type Reg = ActorRegistry["MyActor"];
|
|
44
|
+
* class MyActor extends Actor<Reg["toServer"], Reg["toClient"]> { ... }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare abstract class Actor<Incoming = unknown, Outgoing = unknown> {
|
|
48
|
+
abstract handleConnect(conn: Conn<Outgoing>): void | Promise<void>;
|
|
49
|
+
abstract handleMessage(conn: Conn<Outgoing>, msg: Incoming): void | Promise<void>;
|
|
50
|
+
abstract handleClose(conn: Conn<Outgoing>): void | Promise<void>;
|
|
51
|
+
abstract handleTick(): void | Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Optional wake hook: runs once when the instance starts, before any
|
|
54
|
+
* connection is handled — safe to load persisted state here.
|
|
55
|
+
*/
|
|
56
|
+
handleStart(): void | Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Optional handler for scheduled wakes. Runs when a timer armed via
|
|
59
|
+
* {@link schedule} comes due, receiving the same `key` that was scheduled.
|
|
60
|
+
* The schedule is one-shot: it fires once and is cleared before this runs.
|
|
61
|
+
*/
|
|
62
|
+
protected handleWake(_key: string): void | Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Arm a one-shot wake at `at` (epoch ms or a `Date`), identified by `key`.
|
|
65
|
+
* When it comes due the platform calls {@link handleWake} with this `key`.
|
|
66
|
+
* Scheduling the same `key` again reschedules it.
|
|
67
|
+
*/
|
|
68
|
+
protected schedule(_key: string, _at: number | Date): Promise<void>;
|
|
69
|
+
/** Cancel a pending wake previously armed with {@link schedule}. */
|
|
70
|
+
protected cancelSchedule(_key: string): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Managed ticker (opt-in). Override {@link shouldTick} and the platform runs
|
|
73
|
+
* {@link handleTick} on a timer of {@link tickIntervalMs} while it returns true,
|
|
74
|
+
* and stops (letting the Durable Object hibernate — no compute cost) when it
|
|
75
|
+
* returns false. The platform owns scheduling, rescheduling, self-heal, and
|
|
76
|
+
* error-safety.
|
|
77
|
+
*
|
|
78
|
+
* Re-evaluated after every connect/message/close and on every tick, so keep it
|
|
79
|
+
* cheap and pure (no async, no side effects). Example: `return this.players >= 2`.
|
|
80
|
+
*/
|
|
81
|
+
protected tickIntervalMs: number;
|
|
82
|
+
protected shouldTick?(): boolean;
|
|
83
|
+
protected broadcast(_data: Outgoing): void;
|
|
84
|
+
protected getConnections(): Conn<Outgoing>[];
|
|
85
|
+
protected get instanceId(): string;
|
|
86
|
+
protected get storage(): Storage;
|
|
87
|
+
/**
|
|
88
|
+
* Anonymous Base44 client scoped to this actor instance — no user or service
|
|
89
|
+
* auth, so entity access is RLS-gated (same as a logged-out visitor). Always
|
|
90
|
+
* operates on production data: an actor runs server-side with no per-connection
|
|
91
|
+
* identity, so a Test DB preview selected in the editor does not apply here.
|
|
92
|
+
* Example: `const rows = await this.client.entities.Score.list();`
|
|
93
|
+
*/
|
|
94
|
+
protected get client(): Base44Client;
|
|
95
|
+
}
|
package/dist/actor.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-only base class for Actors.
|
|
3
|
+
*
|
|
4
|
+
* Import and extend this in your actor files:
|
|
5
|
+
* import { Actor } from "@base44/sdk";
|
|
6
|
+
* export class MyActor extends Actor { ... }
|
|
7
|
+
*
|
|
8
|
+
* At deploy time the bundler replaces this import with the compiled
|
|
9
|
+
* Cloudflare Durable Object implementation — this file provides types only.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Base class for an Actor.
|
|
13
|
+
*
|
|
14
|
+
* @typeParam Incoming - messages this actor *receives* from clients
|
|
15
|
+
* (`handleMessage`'s `msg`) — the schema's `toServer` section.
|
|
16
|
+
* @typeParam Outgoing - messages this actor *sends* to clients
|
|
17
|
+
* (`conn.send`/`broadcast`) — the schema's `toClient` section.
|
|
18
|
+
*
|
|
19
|
+
* With a generated `schema.jsonc`, wire both from the registry so they can't drift
|
|
20
|
+
* from the client's types:
|
|
21
|
+
* ```ts
|
|
22
|
+
* type Reg = ActorRegistry["MyActor"];
|
|
23
|
+
* class MyActor extends Actor<Reg["toServer"], Reg["toClient"]> { ... }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export class Actor {
|
|
27
|
+
constructor() {
|
|
28
|
+
/**
|
|
29
|
+
* Managed ticker (opt-in). Override {@link shouldTick} and the platform runs
|
|
30
|
+
* {@link handleTick} on a timer of {@link tickIntervalMs} while it returns true,
|
|
31
|
+
* and stops (letting the Durable Object hibernate — no compute cost) when it
|
|
32
|
+
* returns false. The platform owns scheduling, rescheduling, self-heal, and
|
|
33
|
+
* error-safety.
|
|
34
|
+
*
|
|
35
|
+
* Re-evaluated after every connect/message/close and on every tick, so keep it
|
|
36
|
+
* cheap and pure (no async, no side effects). Example: `return this.players >= 2`.
|
|
37
|
+
*/
|
|
38
|
+
this.tickIntervalMs = 100;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Optional wake hook: runs once when the instance starts, before any
|
|
42
|
+
* connection is handled — safe to load persisted state here.
|
|
43
|
+
*/
|
|
44
|
+
handleStart() { }
|
|
45
|
+
/**
|
|
46
|
+
* Optional handler for scheduled wakes. Runs when a timer armed via
|
|
47
|
+
* {@link schedule} comes due, receiving the same `key` that was scheduled.
|
|
48
|
+
* The schedule is one-shot: it fires once and is cleared before this runs.
|
|
49
|
+
*/
|
|
50
|
+
handleWake(_key) { }
|
|
51
|
+
/**
|
|
52
|
+
* Arm a one-shot wake at `at` (epoch ms or a `Date`), identified by `key`.
|
|
53
|
+
* When it comes due the platform calls {@link handleWake} with this `key`.
|
|
54
|
+
* Scheduling the same `key` again reschedules it.
|
|
55
|
+
*/
|
|
56
|
+
schedule(_key, _at) {
|
|
57
|
+
throw new Error("Actor.schedule() is only available inside a deployed actor");
|
|
58
|
+
}
|
|
59
|
+
/** Cancel a pending wake previously armed with {@link schedule}. */
|
|
60
|
+
cancelSchedule(_key) {
|
|
61
|
+
throw new Error("Actor.cancelSchedule() is only available inside a deployed actor");
|
|
62
|
+
}
|
|
63
|
+
broadcast(_data) {
|
|
64
|
+
throw new Error("Actor.broadcast() is only available inside a deployed actor");
|
|
65
|
+
}
|
|
66
|
+
getConnections() {
|
|
67
|
+
throw new Error("Actor.getConnections() is only available inside a deployed actor");
|
|
68
|
+
}
|
|
69
|
+
get instanceId() {
|
|
70
|
+
throw new Error("Actor.instanceId is only available inside a deployed actor");
|
|
71
|
+
}
|
|
72
|
+
get storage() {
|
|
73
|
+
throw new Error("Actor.storage is only available inside a deployed actor");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Anonymous Base44 client scoped to this actor instance — no user or service
|
|
77
|
+
* auth, so entity access is RLS-gated (same as a logged-out visitor). Always
|
|
78
|
+
* operates on production data: an actor runs server-side with no per-connection
|
|
79
|
+
* identity, so a Test DB preview selected in the editor does not apply here.
|
|
80
|
+
* Example: `const rows = await this.client.entities.Score.list();`
|
|
81
|
+
*/
|
|
82
|
+
get client() {
|
|
83
|
+
throw new Error("Actor.client is only available inside a deployed actor");
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -5,6 +5,7 @@ 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";
|
|
8
9
|
import { createFunctionsModule } from "./modules/functions.js";
|
|
9
10
|
import { createAgentsModule } from "./modules/agents.js";
|
|
10
11
|
import { createAiGatewayModule } from "./modules/ai-gateway.js";
|
|
@@ -12,6 +13,7 @@ import { createAppLogsModule } from "./modules/app-logs.js";
|
|
|
12
13
|
import { createUsersModule } from "./modules/users.js";
|
|
13
14
|
import { RoomsSocket } from "./utils/socket-utils.js";
|
|
14
15
|
import { createAnalyticsModule } from "./modules/analytics.js";
|
|
16
|
+
import { createActorsModule, resolveActorsHost } from "./modules/actors.js";
|
|
15
17
|
/**
|
|
16
18
|
* Creates a Base44 client.
|
|
17
19
|
*
|
|
@@ -50,7 +52,7 @@ import { createAnalyticsModule } from "./modules/analytics.js";
|
|
|
50
52
|
* ```
|
|
51
53
|
*/
|
|
52
54
|
export function createClient(config) {
|
|
53
|
-
var _a, _b;
|
|
55
|
+
var _a, _b, _c;
|
|
54
56
|
const { serverUrl = "https://base44.app", appId, token, serviceToken, requiresAuth = false, appBaseUrl, options, functionsVersion, headers: optionalHeaders, } = config;
|
|
55
57
|
// Normalize appBaseUrl to always be a string (empty if not provided or invalid)
|
|
56
58
|
const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : "";
|
|
@@ -117,12 +119,42 @@ export function createClient(config) {
|
|
|
117
119
|
// requests during construction (notably analytics, which fires an init
|
|
118
120
|
// event whose flush calls auth.me()). Without this, the first User/me
|
|
119
121
|
// 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;
|
|
120
131
|
if (typeof window !== "undefined") {
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
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
|
+
}
|
|
124
148
|
}
|
|
125
149
|
}
|
|
150
|
+
const actorsModule = createActorsModule({
|
|
151
|
+
appId,
|
|
152
|
+
// serverUrl is often relative/empty (same-origin app); PartySocket needs an
|
|
153
|
+
// absolute host, so fall back to the page origin.
|
|
154
|
+
host: resolveActorsHost(serverUrl, typeof window !== "undefined" ? (_a = window.location) === null || _a === void 0 ? void 0 : _a.origin : undefined),
|
|
155
|
+
functionsVersion,
|
|
156
|
+
getAuthToken: () => token || getAccessToken(),
|
|
157
|
+
});
|
|
126
158
|
const userModules = {
|
|
127
159
|
entities: createEntitiesModule({
|
|
128
160
|
axios: axiosClient,
|
|
@@ -142,7 +174,7 @@ export function createClient(config) {
|
|
|
142
174
|
}
|
|
143
175
|
return headers;
|
|
144
176
|
},
|
|
145
|
-
baseURL: (
|
|
177
|
+
baseURL: (_b = functionsAxiosClient.defaults) === null || _b === void 0 ? void 0 : _b.baseURL,
|
|
146
178
|
}),
|
|
147
179
|
agents: createAgentsModule({
|
|
148
180
|
axios: axiosClient,
|
|
@@ -160,8 +192,10 @@ export function createClient(config) {
|
|
|
160
192
|
appId,
|
|
161
193
|
userAuthModule,
|
|
162
194
|
}),
|
|
195
|
+
actors: actorsModule.module,
|
|
163
196
|
cleanup: () => {
|
|
164
197
|
userModules.analytics.cleanup();
|
|
198
|
+
actorsModule.closeAll();
|
|
165
199
|
if (socket) {
|
|
166
200
|
socket.disconnect();
|
|
167
201
|
}
|
|
@@ -185,7 +219,7 @@ export function createClient(config) {
|
|
|
185
219
|
}
|
|
186
220
|
return headers;
|
|
187
221
|
},
|
|
188
|
-
baseURL: (
|
|
222
|
+
baseURL: (_c = serviceRoleFunctionsAxiosClient.defaults) === null || _c === void 0 ? void 0 : _c.baseURL,
|
|
189
223
|
}),
|
|
190
224
|
agents: createAgentsModule({
|
|
191
225
|
axios: serviceRoleAxiosClient,
|
|
@@ -207,6 +241,13 @@ export function createClient(config) {
|
|
|
207
241
|
// We perform this check asynchronously to not block client creation
|
|
208
242
|
setTimeout(async () => {
|
|
209
243
|
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
|
+
}
|
|
210
251
|
const isAuthenticated = await userModules.auth.isAuthenticated();
|
|
211
252
|
if (!isAuthenticated) {
|
|
212
253
|
userModules.auth.redirectToLogin(window.location.href);
|
package/dist/client.types.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { AgentsModule } from "./modules/agents.types.js";
|
|
|
8
8
|
import type { AiGatewayModule } from "./modules/ai-gateway.types.js";
|
|
9
9
|
import type { AppLogsModule } from "./modules/app-logs.types.js";
|
|
10
10
|
import type { AnalyticsModule } from "./modules/analytics.types.js";
|
|
11
|
+
import type { ActorsModule } from "./modules/actors.types.js";
|
|
11
12
|
/**
|
|
12
13
|
* Options for creating a Base44 client.
|
|
13
14
|
*/
|
|
@@ -88,6 +89,8 @@ export interface Base44Client {
|
|
|
88
89
|
analytics: AnalyticsModule;
|
|
89
90
|
/** {@link AppLogsModule | App logs module} for tracking app usage. */
|
|
90
91
|
appLogs: AppLogsModule;
|
|
92
|
+
/** {@link ActorsModule | Actors module} for subscribing to and sending messages via Cloudflare Durable Object-backed Actors. */
|
|
93
|
+
actors: ActorsModule;
|
|
91
94
|
/** {@link AuthModule | Auth module} for user authentication and management. */
|
|
92
95
|
auth: AuthModule;
|
|
93
96
|
/** {@link UserConnectorsModule | Connectors module} for app-user OAuth flows. */
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ export type { FunctionsModule, FunctionName, FunctionNameRegistry, } from "./mod
|
|
|
11
11
|
export type { AgentsModule, AgentName, AgentNameRegistry, AgentConversation, AgentMessage, AgentMessageReasoning, AgentMessageToolCall, AgentMessageUsage, AgentMessageCustomContext, AgentMessageMetadata, CreateConversationParams, } from "./modules/agents.types.js";
|
|
12
12
|
export type { AiGatewayModule, AiGatewayConnection, } from "./modules/ai-gateway.types.js";
|
|
13
13
|
export type { AppLogsModule } from "./modules/app-logs.types.js";
|
|
14
|
+
export type { ActorsModule, ActorClient, ActorRef, Connection, ActorSubscription, ActorConnectOptions, ActorNameRegistry, ActorRegistry, } from "./modules/actors.types.js";
|
|
14
15
|
export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js";
|
|
16
|
+
export { Actor, type Conn } from "./actor.js";
|
|
15
17
|
export type { ConnectorsModule, UserConnectorsModule, } from "./modules/connectors.types.js";
|
|
16
18
|
export type { CustomIntegrationsModule, CustomIntegrationCallParams, CustomIntegrationCallResponse, } from "./modules/custom-integrations.types.js";
|
|
17
19
|
export type { GetAccessTokenOptions, SaveAccessTokenOptions, RemoveAccessTokenOptions, GetLoginUrlOptions, } from "./utils/auth-utils.types.js";
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,4 @@ import { Base44Error } from "./utils/axios-client.js";
|
|
|
3
3
|
import { getAccessToken, saveAccessToken, removeAccessToken, getLoginUrl, } from "./utils/auth-utils.js";
|
|
4
4
|
export { createClient, createClientFromRequest, Base44Error, getAccessToken, saveAccessToken, removeAccessToken, getLoginUrl, };
|
|
5
5
|
export * from "./types.js";
|
|
6
|
+
export { Actor } from "./actor.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ActorRef } from "./actors.types.js";
|
|
2
|
+
interface ActorsConfig {
|
|
3
|
+
appId: string;
|
|
4
|
+
/** Current user access token, if authenticated. Rides the WS query so the
|
|
5
|
+
* platform proxy can authenticate the connection; anonymous connects omit it. */
|
|
6
|
+
getAuthToken(): string | null | undefined;
|
|
7
|
+
/** Same semantics as function calls: editors with a non-prod version get the
|
|
8
|
+
* draft actor script; everyone else gets the published one. */
|
|
9
|
+
functionsVersion?: string;
|
|
10
|
+
/** Absolute host PartySocket dials (it strips the scheme and connects wss, ws
|
|
11
|
+
* for localhost). Resolved by {@link resolveActorsHost}. */
|
|
12
|
+
host: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Absolute host for the actor WebSocket. PartySocket needs an absolute host and
|
|
16
|
+
* can't resolve a relative/empty `serverUrl` (same-origin apps use a relative
|
|
17
|
+
* `/api`, so `serverUrl` is often `""`), so fall back to the page origin.
|
|
18
|
+
* PartySocket handles the scheme (https→wss, ws for localhost).
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveActorsHost(serverUrl: string, browserOrigin?: string): string;
|
|
21
|
+
export declare function createActorsModule(config: ActorsConfig): {
|
|
22
|
+
module: Record<string, (instanceId: string) => ActorRef>;
|
|
23
|
+
closeAll: () => void;
|
|
24
|
+
};
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import PartySocket from "partysocket";
|
|
2
|
+
// Heartbeat / half-open detection: PartySocket only reconnects on a close/error
|
|
3
|
+
// event, so ping periodically and force a reconnect if nothing returns in DEAD_MS.
|
|
4
|
+
const PING_MS = 1000;
|
|
5
|
+
const DEAD_MS = 3000;
|
|
6
|
+
/**
|
|
7
|
+
* A live connection to an actor instance. Only obtainable from
|
|
8
|
+
* {@link ActorRef.connect}, so `subscribe`/`send` are always valid — the socket
|
|
9
|
+
* exists for this object's whole lifetime.
|
|
10
|
+
*/
|
|
11
|
+
class Connection {
|
|
12
|
+
constructor(actorName, instanceId, config, options, onClose) {
|
|
13
|
+
var _a;
|
|
14
|
+
this.onClose = onClose;
|
|
15
|
+
this.listeners = new Set();
|
|
16
|
+
this.heartbeat = null;
|
|
17
|
+
this.id = (_a = options === null || options === void 0 ? void 0 : options.id) !== null && _a !== void 0 ? _a : crypto.randomUUID();
|
|
18
|
+
const ws = new PartySocket({
|
|
19
|
+
host: config.host,
|
|
20
|
+
party: actorName,
|
|
21
|
+
room: instanceId,
|
|
22
|
+
id: this.id,
|
|
23
|
+
// Re-read on every (re)connect so a login/logout is picked up.
|
|
24
|
+
query: () => {
|
|
25
|
+
const token = config.getAuthToken();
|
|
26
|
+
return {
|
|
27
|
+
app_id: config.appId,
|
|
28
|
+
handler: actorName,
|
|
29
|
+
...(token ? { token } : {}),
|
|
30
|
+
...(config.functionsVersion ? { fv: config.functionsVersion } : {}),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
this.ws = ws;
|
|
35
|
+
let lastMsg = Date.now();
|
|
36
|
+
const bumpAlive = () => { lastMsg = Date.now(); };
|
|
37
|
+
ws.addEventListener("open", bumpAlive);
|
|
38
|
+
ws.addEventListener("message", (ev) => {
|
|
39
|
+
bumpAlive();
|
|
40
|
+
let data;
|
|
41
|
+
try {
|
|
42
|
+
data = JSON.parse(ev.data);
|
|
43
|
+
}
|
|
44
|
+
catch (_a) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const msgType = data && typeof data === "object" ? data.type : undefined;
|
|
48
|
+
if (msgType === "__pong")
|
|
49
|
+
return;
|
|
50
|
+
for (const listener of this.listeners)
|
|
51
|
+
listener(data);
|
|
52
|
+
});
|
|
53
|
+
this.heartbeat = setInterval(() => {
|
|
54
|
+
if (Date.now() - lastMsg > DEAD_MS) {
|
|
55
|
+
bumpAlive(); // avoid a reconnect storm while the new socket comes up
|
|
56
|
+
ws.reconnect();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
// The deployed shim echoes __ping → __pong (base44-userapp-bundler
|
|
61
|
+
// shim/actor.ts); without that, an idle room reconnects every DEAD_MS.
|
|
62
|
+
ws.send(JSON.stringify({ type: "__ping" }));
|
|
63
|
+
}
|
|
64
|
+
catch (_a) {
|
|
65
|
+
// not open; the watchdog above will reconnect
|
|
66
|
+
}
|
|
67
|
+
}, PING_MS);
|
|
68
|
+
}
|
|
69
|
+
subscribe(callback) {
|
|
70
|
+
this.listeners.add(callback);
|
|
71
|
+
return {
|
|
72
|
+
unsubscribe: () => { this.listeners.delete(callback); },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
send(data) {
|
|
76
|
+
this.ws.send(JSON.stringify(data));
|
|
77
|
+
}
|
|
78
|
+
close() {
|
|
79
|
+
if (this.heartbeat) {
|
|
80
|
+
clearInterval(this.heartbeat);
|
|
81
|
+
this.heartbeat = null;
|
|
82
|
+
}
|
|
83
|
+
this.listeners.clear();
|
|
84
|
+
this.ws.close();
|
|
85
|
+
this.onClose();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Handle for one actor instance: `connect()` opens the socket (idempotent). */
|
|
89
|
+
function makeActorRef(actorName, instanceId, config, connections) {
|
|
90
|
+
let conn = null;
|
|
91
|
+
return {
|
|
92
|
+
connect(options) {
|
|
93
|
+
if (conn)
|
|
94
|
+
return conn;
|
|
95
|
+
const c = new Connection(actorName, instanceId, config, options, () => {
|
|
96
|
+
connections.delete(c);
|
|
97
|
+
if (conn === c)
|
|
98
|
+
conn = null; // allow a fresh connect() after close
|
|
99
|
+
});
|
|
100
|
+
conn = c;
|
|
101
|
+
connections.add(c);
|
|
102
|
+
return c;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Absolute host for the actor WebSocket. PartySocket needs an absolute host and
|
|
108
|
+
* can't resolve a relative/empty `serverUrl` (same-origin apps use a relative
|
|
109
|
+
* `/api`, so `serverUrl` is often `""`), so fall back to the page origin.
|
|
110
|
+
* PartySocket handles the scheme (https→wss, ws for localhost).
|
|
111
|
+
*/
|
|
112
|
+
export function resolveActorsHost(serverUrl, browserOrigin) {
|
|
113
|
+
return serverUrl && !serverUrl.startsWith("/") ? serverUrl : browserOrigin !== null && browserOrigin !== void 0 ? browserOrigin : serverUrl;
|
|
114
|
+
}
|
|
115
|
+
export function createActorsModule(config) {
|
|
116
|
+
// Live connections this client opened, so client.cleanup() can reclaim any the
|
|
117
|
+
// app forgot to close() (each connection removes itself here on close).
|
|
118
|
+
const connections = new Set();
|
|
119
|
+
const module = new Proxy({}, {
|
|
120
|
+
get(_, key) {
|
|
121
|
+
// Symbols and `then` resolve to undefined (so the module isn't mistaken
|
|
122
|
+
// for a thenable when awaited); any string key is an actor name.
|
|
123
|
+
if (typeof key !== "string" || key === "then")
|
|
124
|
+
return undefined;
|
|
125
|
+
return (instanceId) => makeActorRef(key, instanceId, config, connections);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
module,
|
|
130
|
+
closeAll: () => {
|
|
131
|
+
for (const c of [...connections])
|
|
132
|
+
c.close();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extend this interface to add typed `subscribe` callbacks and `send` payloads
|
|
3
|
+
* for your deployed Actors.
|
|
4
|
+
*
|
|
5
|
+
* This is separate from {@link ActorNameRegistry} (which is auto-generated
|
|
6
|
+
* by `base44 types generate`), so there are no conflicts.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* declare module "@base44/sdk" {
|
|
11
|
+
* interface ActorRegistry {
|
|
12
|
+
* ChatRoom: {
|
|
13
|
+
* toClient: { type: "joined" | "left" | "message"; userId?: string; from?: string; text?: string };
|
|
14
|
+
* toServer: { type: "message"; text: string };
|
|
15
|
+
* };
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export interface ActorRegistry {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Auto-populated by `base44 types generate` with the names of your deployed actors.
|
|
24
|
+
* Do not edit this interface manually — use {@link ActorRegistry} for message types.
|
|
25
|
+
*/
|
|
26
|
+
export interface ActorNameRegistry {
|
|
27
|
+
}
|
|
28
|
+
type AllActorNames = keyof ActorRegistry | keyof ActorNameRegistry;
|
|
29
|
+
type ToClientFor<N extends string> = N extends keyof ActorRegistry ? ActorRegistry[N] extends {
|
|
30
|
+
toClient: infer I;
|
|
31
|
+
} ? I : unknown : unknown;
|
|
32
|
+
type ToServerFor<N extends string> = N extends keyof ActorRegistry ? ActorRegistry[N] extends {
|
|
33
|
+
toServer: infer O;
|
|
34
|
+
} ? O : unknown : unknown;
|
|
35
|
+
/** Options for {@link ActorRef.connect}. */
|
|
36
|
+
export interface ActorConnectOptions {
|
|
37
|
+
/**
|
|
38
|
+
* The connection id — becomes the actor's `conn.id`. Supply a stable value
|
|
39
|
+
* (e.g. persisted per tab) so a reconnect reuses the same server-side
|
|
40
|
+
* identity; omit for an auto-generated per-connection id.
|
|
41
|
+
*/
|
|
42
|
+
id?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Handle for one listener registered via {@link Connection.subscribe}. */
|
|
45
|
+
export interface ActorSubscription {
|
|
46
|
+
/** Remove this listener; other listeners and the socket stay live. */
|
|
47
|
+
unsubscribe(): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A live connection to an actor instance, returned by {@link ActorRef.connect}.
|
|
51
|
+
* `subscribe`/`send` are always valid — you only get a `Connection` once the
|
|
52
|
+
* socket has been opened, so there's no pre-connect state to guard against.
|
|
53
|
+
*/
|
|
54
|
+
export interface Connection<N extends string = string> {
|
|
55
|
+
/** The connection id (the value the actor sees as `conn.id`). */
|
|
56
|
+
readonly id: string;
|
|
57
|
+
/** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */
|
|
58
|
+
subscribe(callback: (data: ToClientFor<N>) => void): ActorSubscription;
|
|
59
|
+
/** Send a message. Buffered by the socket until it's open. */
|
|
60
|
+
send(data: ToServerFor<N>): void;
|
|
61
|
+
/** Tear down the socket, heartbeat, and all listeners. */
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A handle to one actor instance — `base44.actors.MyActor(id)`. Call
|
|
66
|
+
* {@link connect} to open the socket and get a {@link Connection}.
|
|
67
|
+
*/
|
|
68
|
+
export interface ActorRef<N extends string = string> {
|
|
69
|
+
/** Open the WebSocket and return the {@link Connection}. Idempotent. */
|
|
70
|
+
connect(options?: ActorConnectOptions): Connection<N>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Client for a single named Actor — call it with an instance id to get an
|
|
74
|
+
* {@link ActorRef}. Typed automatically when the actor is registered in
|
|
75
|
+
* {@link ActorRegistry}.
|
|
76
|
+
*/
|
|
77
|
+
export interface ActorClient<N extends string = string> {
|
|
78
|
+
(instanceId: string): ActorRef<N>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The actors module provides access to Cloudflare Durable Object-backed
|
|
82
|
+
* Actors deployed by the Base44 platform.
|
|
83
|
+
*
|
|
84
|
+
* ```typescript
|
|
85
|
+
* const conn = base44.actors.MyActor("room-1").connect();
|
|
86
|
+
* const sub = conn.subscribe((msg) => console.log(msg)); // typed via ActorRegistry
|
|
87
|
+
* conn.send({ type: "message", text: "hi" });
|
|
88
|
+
* sub.unsubscribe();
|
|
89
|
+
* conn.close();
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export type ActorsModule = {
|
|
93
|
+
[K in AllActorNames]: K extends keyof ActorRegistry ? ActorClient<string & K> : ActorClient;
|
|
94
|
+
} & Record<string, ActorClient>;
|
|
95
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/modules/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { prepareSessionHandoffKickoff } from "../utils/session-handoff.js";
|
|
2
2
|
function isInsideIframe() {
|
|
3
3
|
if (typeof window === "undefined")
|
|
4
4
|
return false;
|
|
@@ -83,11 +83,8 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
83
83
|
const redirectUrl = nextUrl
|
|
84
84
|
? new URL(nextUrl, window.location.origin).toString()
|
|
85
85
|
: window.location.href;
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
const loginUrl = options.appBaseUrl
|
|
89
|
-
? getLoginUrl(redirectUrl, { serverUrl: options.appBaseUrl, appId })
|
|
90
|
-
: `/login?from_url=${encodeURIComponent(redirectUrl)}`;
|
|
86
|
+
// Build the login URL
|
|
87
|
+
const loginUrl = `${options.appBaseUrl}/login?from_url=${encodeURIComponent(redirectUrl)}`;
|
|
91
88
|
// Redirect to the login page
|
|
92
89
|
window.location.href = loginUrl;
|
|
93
90
|
},
|
|
@@ -108,11 +105,32 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
108
105
|
}
|
|
109
106
|
const loginUrl = `${options.appBaseUrl}/api${authPath}?${queryParams}`;
|
|
110
107
|
// When running inside an iframe, use a popup to avoid OAuth providers
|
|
111
|
-
// blocking iframe navigation.
|
|
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).
|
|
112
111
|
if (isInsideIframe()) {
|
|
113
112
|
const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`;
|
|
114
113
|
return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin);
|
|
115
114
|
}
|
|
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
|
+
}
|
|
116
134
|
// Default: full-page redirect
|
|
117
135
|
window.location.href = loginUrl;
|
|
118
136
|
},
|
|
@@ -0,0 +1,52 @@
|
|
|
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;
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44-preview/sdk",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.41-pr.244.e7d6747",
|
|
4
4
|
"description": "JavaScript SDK for Base44 API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"axios": "^1.18.1",
|
|
30
|
+
"partysocket": "^0.0.23",
|
|
30
31
|
"socket.io-client": "^4.8.3",
|
|
31
32
|
"uuid": "^13.0.2"
|
|
32
33
|
},
|