lemma-sdk 0.2.2 → 0.2.4

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/README.md CHANGED
@@ -39,6 +39,35 @@ const supportAssistant = await client.assistants.get("support_assistant");
39
39
  - `client.request(method, path, options)` escape hatch for endpoints not yet modeled.
40
40
  - `client.resources` for generic file resource APIs (`conversation`, `assistant`, `task`, etc.).
41
41
  - Ergonomic type aliases exported at top level: `Agent`, `Assistant`, `Conversation`, `Task`, `TaskMessage`, `CreateAgentInput`, `CreateAssistantInput`, etc.
42
+ - `client.withPod(podId)` returns a pod-scoped client that shares auth state with the parent client.
43
+
44
+ ## Auth Helpers
45
+
46
+ ```ts
47
+ import { LemmaClient, buildAuthUrl, resolveSafeRedirectUri } from "lemma-sdk";
48
+
49
+ const client = new LemmaClient({
50
+ apiUrl: "https://api-next.asur.work",
51
+ authUrl: "https://auth.asur.work/auth",
52
+ });
53
+
54
+ // Build auth URLs (server/client)
55
+ const loginUrl = buildAuthUrl(client.authUrl, { redirectUri: "https://app.asur.work/" });
56
+ const signupUrl = buildAuthUrl(client.authUrl, { mode: "signup", redirectUri: "https://app.asur.work/" });
57
+
58
+ // Redirect safety helper for auth route handlers
59
+ const safeRedirect = resolveSafeRedirectUri("/pod/123", {
60
+ siteOrigin: "https://app.asur.work",
61
+ fallback: "/",
62
+ });
63
+
64
+ // Browser helpers
65
+ await client.auth.checkAuth();
66
+ await client.auth.signOut();
67
+ const token = await client.auth.getAccessToken();
68
+ const refreshed = await client.auth.refreshAccessToken();
69
+ client.auth.redirectToAuth({ mode: "signup", redirectUri: safeRedirect });
70
+ ```
42
71
 
43
72
  ## Assistants + Agent Runs
44
73
 
package/dist/auth.d.ts CHANGED
@@ -28,6 +28,27 @@ export interface AuthState {
28
28
  user: UserInfo | null;
29
29
  }
30
30
  export type AuthListener = (state: AuthState) => void;
31
+ export type AuthRedirectMode = "login" | "signup";
32
+ export interface BuildAuthUrlOptions {
33
+ /** Optional auth path segment relative to authUrl pathname, e.g. "callback" -> /auth/callback. */
34
+ path?: string;
35
+ /** Adds signup mode query, preserving existing params. */
36
+ mode?: AuthRedirectMode;
37
+ /** Redirect URI passed to auth service. */
38
+ redirectUri?: string;
39
+ /** Additional query parameters appended to auth URL. */
40
+ params?: Record<string, string | number | boolean | Array<string | number | boolean> | null | undefined>;
41
+ }
42
+ export interface ResolveSafeRedirectUriOptions {
43
+ /** Origin for resolving relative paths. */
44
+ siteOrigin: string;
45
+ /** Fallback path or URL when input is empty/invalid/blocked. Defaults to "/". */
46
+ fallback?: string;
47
+ /** Local paths blocked as redirect targets to avoid auth loops. */
48
+ blockedPaths?: string[];
49
+ }
50
+ export declare function buildAuthUrl(authUrl: string, options?: BuildAuthUrlOptions): string;
51
+ export declare function resolveSafeRedirectUri(rawValue: string | null | undefined, options: ResolveSafeRedirectUriOptions): string;
31
52
  export declare class AuthManager {
32
53
  private readonly apiUrl;
33
54
  private readonly authUrl;
@@ -47,6 +68,23 @@ export declare class AuthManager {
47
68
  subscribe(listener: AuthListener): () => void;
48
69
  private notify;
49
70
  private setState;
71
+ private assertBrowserContext;
72
+ private getCookie;
73
+ private clearInjectedToken;
74
+ private rawSignOutViaBackend;
75
+ /**
76
+ * Check whether a cookie-backed session is active without mutating auth state.
77
+ */
78
+ isAuthenticatedViaCookie(): Promise<boolean>;
79
+ /**
80
+ * Return a browser access token from the session layer.
81
+ * Throws if no token is available.
82
+ */
83
+ getAccessToken(): Promise<string>;
84
+ /**
85
+ * Force a refresh-token flow and return the new access token.
86
+ */
87
+ refreshAccessToken(): Promise<string>;
50
88
  /**
51
89
  * Build request headers for an API call.
52
90
  * Uses Bearer token if one was injected, otherwise omits Authorization
@@ -63,10 +101,21 @@ export declare class AuthManager {
63
101
  * Does NOT redirect — call redirectToAuth() explicitly if desired.
64
102
  */
65
103
  markUnauthenticated(): void;
104
+ /**
105
+ * Sign out the current user session.
106
+ * Returns true when the session is no longer active.
107
+ */
108
+ signOut(): Promise<boolean>;
109
+ /**
110
+ * Build auth URL for login/signup/custom auth sub-path.
111
+ */
112
+ getAuthUrl(options?: BuildAuthUrlOptions): string;
66
113
  /**
67
114
  * Redirect to the auth service, passing the current URL as redirect_uri.
68
115
  * After the user authenticates, the auth service should redirect back to
69
116
  * the original URL and set the session cookie.
70
117
  */
71
- redirectToAuth(): void;
118
+ redirectToAuth(options?: Omit<BuildAuthUrlOptions, "redirectUri"> & {
119
+ redirectUri?: string;
120
+ }): void;
72
121
  }
package/dist/auth.js CHANGED
@@ -16,7 +16,9 @@
16
16
  * Auth state is determined by calling GET /users/me (user.current.get).
17
17
  * 401 → unauthenticated. 200 → authenticated.
18
18
  */
19
+ import Session from "supertokens-web-js/recipe/session";
19
20
  import { ensureCookieSessionSupport } from "./supertokens.js";
21
+ const DEFAULT_BLOCKED_REDIRECT_PATHS = ["/login", "/signup", "/auth"];
20
22
  const LOCALSTORAGE_TOKEN_KEY = "lemma_token";
21
23
  const QUERY_PARAM_TOKEN_KEY = "lemma_token";
22
24
  function detectInjectedToken() {
@@ -51,6 +53,79 @@ function detectInjectedToken() {
51
53
  catch { /* ignore */ }
52
54
  return null;
53
55
  }
56
+ function normalizePath(path) {
57
+ const trimmed = path.trim();
58
+ if (!trimmed)
59
+ return "/";
60
+ if (trimmed === "/")
61
+ return "/";
62
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
63
+ return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
64
+ }
65
+ function resolveAuthPath(basePath, path) {
66
+ const normalizedBase = normalizePath(basePath);
67
+ if (!path || !path.trim()) {
68
+ return normalizedBase;
69
+ }
70
+ const segment = path.trim().replace(/^\/+/, "");
71
+ if (!segment) {
72
+ return normalizedBase;
73
+ }
74
+ return `${normalizedBase}/${segment}`.replace(/\/{2,}/g, "/");
75
+ }
76
+ function isBlockedLocalPath(pathname, blockedPaths) {
77
+ const normalizedPathname = normalizePath(pathname);
78
+ return blockedPaths.some((rawBlockedPath) => {
79
+ const blockedPath = normalizePath(rawBlockedPath);
80
+ return normalizedPathname === blockedPath || normalizedPathname.startsWith(`${blockedPath}/`);
81
+ });
82
+ }
83
+ function normalizeOrigin(rawOrigin) {
84
+ const parsed = new URL(rawOrigin);
85
+ return parsed.origin;
86
+ }
87
+ export function buildAuthUrl(authUrl, options = {}) {
88
+ const url = new URL(authUrl);
89
+ url.pathname = resolveAuthPath(url.pathname, options.path);
90
+ for (const [key, value] of Object.entries(options.params ?? {})) {
91
+ if (value === null || value === undefined)
92
+ continue;
93
+ if (Array.isArray(value)) {
94
+ url.searchParams.delete(key);
95
+ for (const item of value) {
96
+ url.searchParams.append(key, String(item));
97
+ }
98
+ continue;
99
+ }
100
+ url.searchParams.set(key, String(value));
101
+ }
102
+ if (options.mode === "signup") {
103
+ url.searchParams.set("show", "signup");
104
+ }
105
+ if (options.redirectUri && options.redirectUri.trim()) {
106
+ url.searchParams.set("redirect_uri", options.redirectUri);
107
+ }
108
+ return url.toString();
109
+ }
110
+ export function resolveSafeRedirectUri(rawValue, options) {
111
+ const siteOrigin = normalizeOrigin(options.siteOrigin);
112
+ const blockedPaths = options.blockedPaths ?? DEFAULT_BLOCKED_REDIRECT_PATHS;
113
+ const fallbackTarget = options.fallback ?? "/";
114
+ const fallback = new URL(fallbackTarget, siteOrigin).toString();
115
+ if (!rawValue || !rawValue.trim()) {
116
+ return fallback;
117
+ }
118
+ try {
119
+ const parsed = new URL(rawValue, siteOrigin);
120
+ if (parsed.origin === siteOrigin && isBlockedLocalPath(parsed.pathname, blockedPaths)) {
121
+ return fallback;
122
+ }
123
+ return parsed.toString();
124
+ }
125
+ catch {
126
+ return fallback;
127
+ }
128
+ }
54
129
  export class AuthManager {
55
130
  apiUrl;
56
131
  authUrl;
@@ -93,6 +168,109 @@ export class AuthManager {
93
168
  this.state = state;
94
169
  this.notify();
95
170
  }
171
+ assertBrowserContext() {
172
+ if (typeof window === "undefined") {
173
+ throw new Error("This auth method is only available in browser environments.");
174
+ }
175
+ }
176
+ getCookie(name) {
177
+ if (typeof document === "undefined")
178
+ return undefined;
179
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
180
+ const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
181
+ return match ? decodeURIComponent(match[1]) : undefined;
182
+ }
183
+ clearInjectedToken() {
184
+ this.injectedToken = null;
185
+ if (typeof window === "undefined")
186
+ return;
187
+ try {
188
+ sessionStorage.removeItem(LOCALSTORAGE_TOKEN_KEY);
189
+ }
190
+ catch {
191
+ // ignore storage errors
192
+ }
193
+ try {
194
+ localStorage.removeItem(LOCALSTORAGE_TOKEN_KEY);
195
+ }
196
+ catch {
197
+ // ignore storage errors
198
+ }
199
+ }
200
+ async rawSignOutViaBackend() {
201
+ const antiCsrf = this.getCookie("sAntiCsrf");
202
+ const headers = {
203
+ Accept: "application/json",
204
+ "Content-Type": "application/json",
205
+ rid: "anti-csrf",
206
+ "fdi-version": "4.2",
207
+ "st-auth-mode": "cookie",
208
+ };
209
+ if (antiCsrf) {
210
+ headers["anti-csrf"] = antiCsrf;
211
+ }
212
+ const separator = this.apiUrl.includes("?") ? "&" : "?";
213
+ const signOutUrl = `${this.apiUrl.replace(/\/$/, "")}/st/auth/signout${separator}superTokensDoNotDoInterception=true`;
214
+ await fetch(signOutUrl, {
215
+ method: "POST",
216
+ credentials: "include",
217
+ headers,
218
+ });
219
+ }
220
+ /**
221
+ * Check whether a cookie-backed session is active without mutating auth state.
222
+ */
223
+ async isAuthenticatedViaCookie() {
224
+ if (this.injectedToken) {
225
+ return this.isAuthenticated();
226
+ }
227
+ try {
228
+ const response = await fetch(`${this.apiUrl}/users/me`, {
229
+ method: "GET",
230
+ credentials: "include",
231
+ headers: { Accept: "application/json" },
232
+ });
233
+ return response.status !== 401;
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ }
239
+ /**
240
+ * Return a browser access token from the session layer.
241
+ * Throws if no token is available.
242
+ */
243
+ async getAccessToken() {
244
+ if (this.injectedToken) {
245
+ return this.injectedToken;
246
+ }
247
+ this.assertBrowserContext();
248
+ ensureCookieSessionSupport(this.apiUrl, () => this.markUnauthenticated());
249
+ const token = await Session.getAccessToken();
250
+ if (!token) {
251
+ throw new Error("Token unavailable");
252
+ }
253
+ return token;
254
+ }
255
+ /**
256
+ * Force a refresh-token flow and return the new access token.
257
+ */
258
+ async refreshAccessToken() {
259
+ if (this.injectedToken) {
260
+ return this.injectedToken;
261
+ }
262
+ this.assertBrowserContext();
263
+ ensureCookieSessionSupport(this.apiUrl, () => this.markUnauthenticated());
264
+ const refreshed = await Session.attemptRefreshingSession();
265
+ if (!refreshed) {
266
+ throw new Error("Session refresh failed");
267
+ }
268
+ const token = await Session.getAccessToken();
269
+ if (!token) {
270
+ throw new Error("Token unavailable");
271
+ }
272
+ return token;
273
+ }
96
274
  /**
97
275
  * Build request headers for an API call.
98
276
  * Uses Bearer token if one was injected, otherwise omits Authorization
@@ -151,16 +329,54 @@ export class AuthManager {
151
329
  markUnauthenticated() {
152
330
  this.setState({ status: "unauthenticated", user: null });
153
331
  }
332
+ /**
333
+ * Sign out the current user session.
334
+ * Returns true when the session is no longer active.
335
+ */
336
+ async signOut() {
337
+ if (this.injectedToken) {
338
+ this.clearInjectedToken();
339
+ this.markUnauthenticated();
340
+ return true;
341
+ }
342
+ this.assertBrowserContext();
343
+ ensureCookieSessionSupport(this.apiUrl, () => this.markUnauthenticated());
344
+ try {
345
+ await Session.signOut();
346
+ }
347
+ catch {
348
+ // continue with raw fallback
349
+ }
350
+ if (await this.isAuthenticatedViaCookie()) {
351
+ try {
352
+ await this.rawSignOutViaBackend();
353
+ }
354
+ catch {
355
+ // best effort fallback only
356
+ }
357
+ }
358
+ const isAuthenticated = await this.isAuthenticatedViaCookie();
359
+ if (!isAuthenticated) {
360
+ this.markUnauthenticated();
361
+ }
362
+ return !isAuthenticated;
363
+ }
364
+ /**
365
+ * Build auth URL for login/signup/custom auth sub-path.
366
+ */
367
+ getAuthUrl(options = {}) {
368
+ return buildAuthUrl(this.authUrl, options);
369
+ }
154
370
  /**
155
371
  * Redirect to the auth service, passing the current URL as redirect_uri.
156
372
  * After the user authenticates, the auth service should redirect back to
157
373
  * the original URL and set the session cookie.
158
374
  */
159
- redirectToAuth() {
375
+ redirectToAuth(options = {}) {
160
376
  if (typeof window === "undefined") {
161
377
  return;
162
378
  }
163
- const redirectUri = encodeURIComponent(window.location.href);
164
- window.location.href = `${this.authUrl}?redirect_uri=${redirectUri}`;
379
+ const redirectUri = options.redirectUri ?? window.location.href;
380
+ window.location.href = this.getAuthUrl({ ...options, redirectUri });
165
381
  }
166
382
  }