- Search GEO with meaning
+ Search biology data with meaning
- Find genomics and transcriptomics datasets using natural language.
- No more keyword guessing—describe what you're looking for.
+ Semantic search over GEO and other biological databases.
+ No more keyword guessing, just describe what you're looking for.
diff --git a/web/src/components/search/SearchResults.tsx b/web/src/components/search/SearchResults.tsx
index 2ca6f48..b06a1a7 100644
--- a/web/src/components/search/SearchResults.tsx
+++ b/web/src/components/search/SearchResults.tsx
@@ -2,7 +2,7 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import type { SearchResponse, SearchHit as SearchHitType, Record } from '@/types';
-import { api } from '@/lib/api';
+import { osa } from '@/lib/sdk';
import { SearchHit } from './SearchHit';
import { RecordDetail } from '@/components/record/RecordDetail';
import { RecordComparison } from '@/components/record/RecordComparison';
@@ -98,7 +98,7 @@ export function SearchResults({ initialData }: SearchResultsProps) {
setLoading(true);
try {
- const response = await api.search(initialData.query, initialData.index, {
+ const response = await osa.search.query(initialData.query, initialData.index, {
offset: results.length,
});
setResults((prev) => [...prev, ...response.results]);
diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts
deleted file mode 100644
index c825dde..0000000
--- a/web/src/lib/api/index.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * API Factory
- * Exports the active API implementation based on environment configuration.
- */
-
-import type { ApiInterface } from './interface';
-import { MockAPI } from './mock';
-import { OSAApi } from './osa';
-import { API_MODE } from '@/lib/utils/constants';
-
-/**
- * Get the API implementation based on environment.
- */
-function createApi(): ApiInterface {
- if (API_MODE === 'mock') {
- return new MockAPI();
- }
- return new OSAApi();
-}
-
-/** Active API instance */
-export const api = createApi();
-
-// Re-export types for convenience
-export type { ApiInterface } from './interface';
diff --git a/web/src/lib/api/interface.ts b/web/src/lib/api/interface.ts
deleted file mode 100644
index de2722f..0000000
--- a/web/src/lib/api/interface.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * API Interface Contract
- * Defines the contract that both MockAPI and OSAApi must implement.
- */
-
-import type {
- SearchOptions,
- IndexListResponse,
- SearchResponse,
- RecordResponse,
-} from '@/types';
-
-/**
- * API interface that all implementations must satisfy.
- *
- * Implementations:
- * - MockAPI: In-memory implementation returning deterministic dummy data
- * - OSAApi: HTTP client communicating with the real OSA backend
- */
-export interface ApiInterface {
- /**
- * List available search indexes.
- * @returns List of index names (e.g., ["vector"])
- */
- listIndexes(): Promise;
-
- /**
- * Search records using semantic similarity.
- * @param query - Natural language search query
- * @param indexName - Index to search (default: "vector")
- * @param options - Search options (pagination, filters)
- * @returns Search results ranked by score
- */
- search(
- query: string,
- indexName?: string,
- options?: SearchOptions
- ): Promise;
-
- /**
- * Get a single record by SRN.
- * @param srn - Structured Resource Name
- * @returns Record details
- */
- getRecord(srn: string): Promise;
-}
diff --git a/web/src/lib/api/osa.ts b/web/src/lib/api/osa.ts
deleted file mode 100644
index 4a45c0d..0000000
--- a/web/src/lib/api/osa.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * OSAApi Implementation
- * HTTP client for the real OSA backend.
- */
-
-import type { ApiInterface } from './interface';
-import type {
- SearchOptions,
- IndexListResponse,
- SearchResponse,
- RecordResponse,
-} from '@/types';
-import { API_BASE_URL, DEFAULT_INDEX, DEFAULT_LIMIT } from '@/lib/utils/constants';
-
-/**
- * OSAApi - HTTP client for the Open Science Archive API.
- */
-export class OSAApi implements ApiInterface {
- private baseUrl: string;
-
- constructor(baseUrl: string = API_BASE_URL) {
- this.baseUrl = baseUrl;
- }
-
- async listIndexes(): Promise {
- const response = await fetch(`${this.baseUrl}/search/`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw error;
- }
-
- return response.json();
- }
-
- async search(
- query: string,
- indexName: string = DEFAULT_INDEX,
- options: SearchOptions = {}
- ): Promise {
- const { offset = 0, limit = DEFAULT_LIMIT, filters = {} } = options;
-
- // Build query parameters
- const params = new URLSearchParams({
- q: query,
- offset: offset.toString(),
- limit: limit.toString(),
- });
-
- // Add filter parameters
- for (const [key, value] of Object.entries(filters)) {
- params.append(`filter:${key}`, value);
- }
-
- const response = await fetch(
- `${this.baseUrl}/search/${indexName}?${params.toString()}`,
- {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- }
- );
-
- if (!response.ok) {
- const error = await response.json();
- throw error;
- }
-
- return response.json();
- }
-
- async getRecord(srn: string): Promise {
- const response = await fetch(
- `${this.baseUrl}/records/${encodeURIComponent(srn)}`,
- {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- }
- );
-
- if (!response.ok) {
- const error = await response.json();
- throw error;
- }
-
- return response.json();
- }
-}
diff --git a/web/src/lib/sdk/auth.ts b/web/src/lib/sdk/auth.ts
index 32f4dba..9d74ab3 100644
--- a/web/src/lib/sdk/auth.ts
+++ b/web/src/lib/sdk/auth.ts
@@ -1,7 +1,8 @@
/**
- * Authentication client for OSA SDK.
+ * Authentication namespace for the OSA SDK.
*/
+import type { HttpClient } from './http';
import type { TokenStorage } from './storage';
import type {
AuthCallbackParams,
@@ -9,19 +10,48 @@ import type {
TokenPair,
TokenResponse,
User,
+ UserResponse,
} from './types';
-/** Parse auth parameters from URL hash */
+// ---------------------------------------------------------------------------
+// Public interface
+// ---------------------------------------------------------------------------
+
+export interface AuthInterface {
+ /** Get the login URL to redirect users to. */
+ getLoginUrl(redirectUri?: string): string;
+
+ /** Parse an OAuth callback hash and store the resulting tokens. */
+ handleCallback(hash: string): { user: User; tokens: TokenPair } | null;
+
+ /** Refresh the access token using the stored refresh token. */
+ refreshToken(): Promise;
+
+ /** Logout the user (server + local). */
+ logout(): Promise;
+
+ /** Retrieve stored user + tokens (null if expired or missing). */
+ getStoredAuth(): { user: User; tokens: TokenPair } | null;
+
+ /** Check whether the user has a valid, non-expired session. */
+ isAuthenticated(): boolean;
+
+ /** Fetch the current user from the server. */
+ getUser(): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Standalone utility
+// ---------------------------------------------------------------------------
+
+/** Parse auth parameters from a URL hash fragment. */
export function parseAuthCallback(hash: string): AuthCallbackParams | null {
- // Remove leading # if present
const hashContent = hash.startsWith('#') ? hash.slice(1) : hash;
- // Check if it starts with "auth="
if (!hashContent.startsWith('auth=')) {
return null;
}
- // Remove "auth=" prefix
const paramsStr = hashContent.slice(5);
try {
@@ -53,8 +83,11 @@ export function parseAuthCallback(hash: string): AuthCallbackParams | null {
}
}
-/** Authentication client class */
-export class AuthClient {
+// ---------------------------------------------------------------------------
+// Internal auth client (encapsulated inside AuthNamespace)
+// ---------------------------------------------------------------------------
+
+class AuthClient {
private config: SDKConfig;
private storage: TokenStorage;
private refreshTimer: ReturnType | null = null;
@@ -64,9 +97,7 @@ export class AuthClient {
this.storage = storage;
}
- /** Get the login URL to redirect users to */
getLoginUrl(redirectUri?: string, provider: string = 'orcid'): string {
- // Handle both relative and absolute baseUrl
const base = this.config.baseUrl.startsWith('http')
? this.config.baseUrl
: `${window.location.origin}${this.config.baseUrl}`;
@@ -78,7 +109,6 @@ export class AuthClient {
return url.toString();
}
- /** Handle OAuth callback and store tokens */
handleCallback(params: AuthCallbackParams): { user: User; tokens: TokenPair } {
const tokens: TokenPair = {
accessToken: params.accessToken,
@@ -92,10 +122,8 @@ export class AuthClient {
externalId: params.externalId,
};
- // Store auth data
this.storage.set({ tokens, user });
- // Setup auto-refresh if enabled
if (this.config.autoRefresh !== false) {
this.setupAutoRefresh(tokens);
}
@@ -103,7 +131,6 @@ export class AuthClient {
return { user, tokens };
}
- /** Refresh the access token */
async refresh(): Promise {
const stored = this.storage.get();
if (!stored?.tokens?.refreshToken) {
@@ -112,16 +139,11 @@ export class AuthClient {
const response = await fetch(`${this.config.baseUrl}/auth/refresh`, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- refresh_token: stored.tokens.refreshToken,
- }),
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: stored.tokens.refreshToken }),
});
if (!response.ok) {
- // Clear storage on auth error
this.storage.clear();
throw new Error('Token refresh failed');
}
@@ -134,10 +156,8 @@ export class AuthClient {
expiresAt: Date.now() + data.expires_in * 1000,
};
- // Update storage with new tokens
this.storage.set({ tokens, user: stored.user });
- // Setup next auto-refresh
if (this.config.autoRefresh !== false) {
this.setupAutoRefresh(tokens);
}
@@ -145,50 +165,39 @@ export class AuthClient {
return tokens;
}
- /** Logout the user */
async logout(): Promise {
const stored = this.storage.get();
if (stored?.tokens?.refreshToken) {
try {
await fetch(`${this.config.baseUrl}/auth/logout`, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- refresh_token: stored.tokens.refreshToken,
- }),
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: stored.tokens.refreshToken }),
});
} catch {
- // Ignore logout errors - we're clearing local state anyway
+ // Ignore — we clear local state regardless
}
}
- // Clear local state
this.storage.clear();
this.cancelAutoRefresh();
}
- /** Get stored auth data */
getStoredAuth(): { user: User; tokens: TokenPair } | null {
const stored = this.storage.get();
if (!stored) return null;
- // Check if tokens are still valid
if (stored.tokens.expiresAt <= Date.now()) {
- // Token expired, but we might be able to refresh
- // For now, return null and let the caller handle refresh
return null;
}
return stored;
}
- /** Setup auto-refresh timer */
private setupAutoRefresh(tokens: TokenPair): void {
this.cancelAutoRefresh();
- const threshold = (this.config.refreshThreshold || 300) * 1000; // Default 5 minutes
+ const threshold = (this.config.refreshThreshold || 300) * 1000;
const timeUntilRefresh = tokens.expiresAt - Date.now() - threshold;
if (timeUntilRefresh > 0) {
@@ -196,13 +205,12 @@ export class AuthClient {
try {
await this.refresh();
} catch {
- // Refresh failed, user will need to re-authenticate
+ // Refresh failed — user must re-authenticate
}
}, timeUntilRefresh);
}
}
- /** Cancel auto-refresh timer */
private cancelAutoRefresh(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
@@ -210,3 +218,69 @@ export class AuthClient {
}
}
}
+
+// ---------------------------------------------------------------------------
+// AuthNamespace — public facade
+// ---------------------------------------------------------------------------
+
+export class AuthNamespace implements AuthInterface {
+ private client: AuthClient;
+ private http: HttpClient;
+
+ constructor(http: HttpClient, storage: TokenStorage, clientBaseUrl: string) {
+ this.http = http;
+ // Auth endpoints (login redirect, refresh, logout) are always browser-facing.
+ // Use the client-facing URL — never the internal Docker URL that
+ // HttpClient.baseUrl may resolve to during SSR.
+ this.client = new AuthClient(
+ { baseUrl: clientBaseUrl, autoRefresh: true, refreshThreshold: 300 },
+ storage,
+ );
+ }
+
+ /** Get the login URL to redirect users to. */
+ getLoginUrl(redirectUri?: string): string {
+ return this.client.getLoginUrl(redirectUri);
+ }
+
+ /** Parse an OAuth callback hash and store the resulting tokens. */
+ handleCallback(hash: string): { user: User; tokens: TokenPair } | null {
+ const params = parseAuthCallback(hash);
+ if (!params) return null;
+ return this.client.handleCallback(params);
+ }
+
+ /** Refresh the access token using the stored refresh token. */
+ async refreshToken(): Promise {
+ return this.client.refresh();
+ }
+
+ /** Logout the user (server + local). */
+ async logout(): Promise {
+ return this.client.logout();
+ }
+
+ /** Retrieve stored user + tokens (null if expired or missing). */
+ getStoredAuth(): { user: User; tokens: TokenPair } | null {
+ return this.client.getStoredAuth();
+ }
+
+ /** Check whether the user has a valid, non-expired session. */
+ isAuthenticated(): boolean {
+ const auth = this.getStoredAuth();
+ return auth !== null && auth.tokens.expiresAt > Date.now();
+ }
+
+ /** Fetch the current user from the server. */
+ async getUser(): Promise {
+ const response = await this.http.fetch('/auth/me');
+ if (!response.ok) return null;
+
+ const data = (await response.json()) as UserResponse;
+ return {
+ id: data.id,
+ displayName: data.display_name,
+ externalId: data.external_id,
+ };
+ }
+}
diff --git a/web/src/lib/sdk/client.ts b/web/src/lib/sdk/client.ts
deleted file mode 100644
index 32f7ec8..0000000
--- a/web/src/lib/sdk/client.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * OSA SDK client.
- */
-
-import { AuthClient, parseAuthCallback } from './auth';
-import { LocalTokenStorage, type TokenStorage } from './storage';
-import type { SDKConfig, TokenPair, User, UserResponse } from './types';
-
-/** Main OSA client class */
-export class OSAClient {
- private config: SDKConfig;
- private storage: TokenStorage;
- private authClient: AuthClient;
-
- constructor(options: {
- baseUrl: string;
- storage?: TokenStorage;
- autoRefresh?: boolean;
- refreshThreshold?: number;
- }) {
- this.config = {
- baseUrl: options.baseUrl,
- autoRefresh: options.autoRefresh ?? true,
- refreshThreshold: options.refreshThreshold ?? 300,
- };
-
- this.storage = options.storage ?? new LocalTokenStorage();
- this.authClient = new AuthClient(this.config, this.storage);
- }
-
- // === Auth Methods ===
-
- /** Get the login URL */
- getLoginUrl(redirectUri?: string): string {
- return this.authClient.getLoginUrl(redirectUri);
- }
-
- /** Handle OAuth callback from URL hash */
- handleAuthCallback(hash: string): { user: User; tokens: TokenPair } | null {
- const params = parseAuthCallback(hash);
- if (!params) return null;
- return this.authClient.handleCallback(params);
- }
-
- /** Refresh the access token */
- async refreshToken(): Promise {
- return this.authClient.refresh();
- }
-
- /** Logout the user */
- async logout(): Promise {
- return this.authClient.logout();
- }
-
- /** Get stored user and tokens */
- getStoredAuth(): { user: User; tokens: TokenPair } | null {
- return this.authClient.getStoredAuth();
- }
-
- /** Check if user is authenticated */
- isAuthenticated(): boolean {
- const auth = this.getStoredAuth();
- return auth !== null && auth.tokens.expiresAt > Date.now();
- }
-
- // === API Methods ===
-
- /** Make an authenticated fetch request */
- async fetch(path: string, options: RequestInit = {}): Promise {
- const stored = this.storage.get();
- const headers = new Headers(options.headers);
-
- if (stored?.tokens?.accessToken) {
- headers.set('Authorization', `Bearer ${stored.tokens.accessToken}`);
- }
-
- const response = await fetch(`${this.config.baseUrl}${path}`, {
- ...options,
- headers,
- });
-
- // If 401 and we have a refresh token, try to refresh and retry
- if (response.status === 401 && stored?.tokens?.refreshToken) {
- try {
- await this.refreshToken();
- const newStored = this.storage.get();
- if (newStored?.tokens?.accessToken) {
- headers.set('Authorization', `Bearer ${newStored.tokens.accessToken}`);
- return fetch(`${this.config.baseUrl}${path}`, {
- ...options,
- headers,
- });
- }
- } catch {
- // Refresh failed, return original 401 response
- }
- }
-
- return response;
- }
-
- /** Get current user info from server */
- async getUser(): Promise {
- const response = await this.fetch('/auth/me');
- if (!response.ok) {
- return null;
- }
-
- const data = (await response.json()) as UserResponse;
- return {
- id: data.id,
- displayName: data.display_name,
- externalId: data.external_id,
- };
- }
-}
diff --git a/web/src/lib/sdk/deposition.ts b/web/src/lib/sdk/deposition.ts
new file mode 100644
index 0000000..442d45f
--- /dev/null
+++ b/web/src/lib/sdk/deposition.ts
@@ -0,0 +1,137 @@
+/**
+ * Deposition namespace for the OSA SDK.
+ */
+
+import type { HttpClient } from './http';
+import type {
+ ConventionListResponse,
+ ConventionDetail,
+ CreateDepositionResponse,
+ Deposition,
+ SpreadsheetUploadResponse,
+ FileUploadResponse,
+} from '@/types';
+
+// ---------------------------------------------------------------------------
+// Public interface
+// ---------------------------------------------------------------------------
+
+export interface DepositionInterface {
+ /** List available conventions. */
+ listConventions(): Promise;
+
+ /** Get full convention details by SRN. */
+ getConvention(srn: string): Promise;
+
+ /** Create a new deposition against a convention. */
+ create(conventionSrn: string): Promise;
+
+ /** Get a deposition by SRN. */
+ get(srn: string): Promise;
+
+ /** Download the metadata template for a convention. */
+ downloadTemplate(conventionSrn: string): Promise;
+
+ /** Upload a metadata spreadsheet to a deposition. */
+ uploadSpreadsheet(depositionSrn: string, file: File): Promise;
+
+ /** Upload a data file to a deposition. */
+ uploadFile(depositionSrn: string, file: File): Promise;
+
+ /** Delete a file from a deposition. */
+ deleteFile(depositionSrn: string, filename: string): Promise;
+
+ /** Submit a deposition for validation. */
+ submit(depositionSrn: string): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Real HTTP implementation
+// ---------------------------------------------------------------------------
+
+export class DepositionNamespace implements DepositionInterface {
+ constructor(private http: HttpClient) {}
+
+ /** List available conventions. */
+ async listConventions(): Promise {
+ const res = await this.http.fetch('/conventions');
+ if (!res.ok) throw await res.json();
+ return res.json();
+ }
+
+ /** Get full convention details by SRN. */
+ async getConvention(srn: string): Promise {
+ const res = await this.http.fetch(`/conventions/${encodeURIComponent(srn)}`);
+ if (!res.ok) throw await res.json();
+ return res.json();
+ }
+
+ /** Create a new deposition against a convention. */
+ async create(conventionSrn: string): Promise {
+ const res = await this.http.fetch('/depositions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ convention_srn: conventionSrn }),
+ });
+ if (!res.ok) throw await res.json();
+ return res.json();
+ }
+
+ /** Get a deposition by SRN. */
+ async get(srn: string): Promise {
+ const res = await this.http.fetch(`/depositions/${encodeURIComponent(srn)}`);
+ if (!res.ok) throw await res.json();
+ return res.json();
+ }
+
+ /** Download the metadata template for a convention. */
+ async downloadTemplate(conventionSrn: string): Promise {
+ const res = await this.http.fetch(
+ `/conventions/${encodeURIComponent(conventionSrn)}/template`,
+ );
+ if (!res.ok) throw await res.json();
+ return res.blob();
+ }
+
+ /** Upload a metadata spreadsheet to a deposition. */
+ async uploadSpreadsheet(depositionSrn: string, file: File): Promise {
+ const form = new FormData();
+ form.append('file', file);
+ const res = await this.http.fetch(
+ `/depositions/${encodeURIComponent(depositionSrn)}/spreadsheet`,
+ { method: 'POST', body: form },
+ );
+ if (!res.ok) throw await res.json();
+ return res.json();
+ }
+
+ /** Upload a data file to a deposition. */
+ async uploadFile(depositionSrn: string, file: File): Promise {
+ const form = new FormData();
+ form.append('file', file);
+ const res = await this.http.fetch(
+ `/depositions/${encodeURIComponent(depositionSrn)}/files`,
+ { method: 'POST', body: form },
+ );
+ if (!res.ok) throw await res.json();
+ return res.json();
+ }
+
+ /** Delete a file from a deposition. */
+ async deleteFile(depositionSrn: string, filename: string): Promise {
+ const res = await this.http.fetch(
+ `/depositions/${encodeURIComponent(depositionSrn)}/files/${encodeURIComponent(filename)}`,
+ { method: 'DELETE' },
+ );
+ if (!res.ok) throw await res.json();
+ }
+
+ /** Submit a deposition for validation. */
+ async submit(depositionSrn: string): Promise {
+ const res = await this.http.fetch(
+ `/depositions/${encodeURIComponent(depositionSrn)}/submit`,
+ { method: 'POST' },
+ );
+ if (!res.ok) throw await res.json();
+ }
+}
diff --git a/web/src/lib/sdk/http.ts b/web/src/lib/sdk/http.ts
new file mode 100644
index 0000000..4113f6b
--- /dev/null
+++ b/web/src/lib/sdk/http.ts
@@ -0,0 +1,53 @@
+/**
+ * Shared HTTP client with automatic auth token injection and 401 retry.
+ */
+
+import type { TokenStorage } from './storage';
+
+export class HttpClient {
+ private refreshFn: (() => Promise) | null = null;
+
+ constructor(
+ readonly baseUrl: string,
+ private storage?: TokenStorage,
+ ) {}
+
+ /** Wire a token-refresh callback (set after construction to avoid circular deps). */
+ setRefreshFn(fn: () => Promise): void {
+ this.refreshFn = fn;
+ }
+
+ /** Make an HTTP request, attaching auth headers and retrying once on 401. */
+ async fetch(path: string, options: RequestInit = {}): Promise {
+ const stored = this.storage?.get();
+ const headers = new Headers(options.headers);
+
+ if (stored?.tokens?.accessToken) {
+ headers.set('Authorization', `Bearer ${stored.tokens.accessToken}`);
+ }
+
+ const response = await fetch(`${this.baseUrl}${path}`, {
+ ...options,
+ headers,
+ });
+
+ // If 401 and we have a refresh token, try to refresh and retry
+ if (response.status === 401 && stored?.tokens?.refreshToken && this.refreshFn) {
+ try {
+ await this.refreshFn();
+ const newStored = this.storage?.get();
+ if (newStored?.tokens?.accessToken) {
+ headers.set('Authorization', `Bearer ${newStored.tokens.accessToken}`);
+ return fetch(`${this.baseUrl}${path}`, {
+ ...options,
+ headers,
+ });
+ }
+ } catch {
+ // Refresh failed, return original 401 response
+ }
+ }
+
+ return response;
+ }
+}
diff --git a/web/src/lib/sdk/index.ts b/web/src/lib/sdk/index.ts
index f768fac..ec80b5d 100644
--- a/web/src/lib/sdk/index.ts
+++ b/web/src/lib/sdk/index.ts
@@ -1,43 +1,96 @@
/**
- * OSA SDK - TypeScript client for Open Science Archive API.
+ * OSA SDK — unified client for Open Science Archive.
*
* @example
* ```typescript
- * import { OSAClient } from '@/lib/sdk';
+ * import { osa } from '@/lib/sdk';
*
- * const client = new OSAClient({ baseUrl: '/api/v1' });
+ * // Auth
+ * osa.auth.getLoginUrl()
+ * osa.auth.isAuthenticated()
+ * const user = await osa.auth.getUser()
*
- * // Check if user is authenticated
- * if (client.isAuthenticated()) {
- * const user = client.getStoredAuth()?.user;
- * console.log(`Hello, ${user?.displayName}!`);
- * }
+ * // Search
+ * const results = await osa.search.query('alzheimer')
+ * const record = await osa.search.getRecord('urn:osa:...')
*
- * // Get login URL
- * const loginUrl = client.getLoginUrl();
- * window.location.href = loginUrl;
- *
- * // Handle callback (in callback page)
- * const auth = client.handleAuthCallback(window.location.hash);
- * if (auth) {
- * console.log('Logged in as:', auth.user);
- * }
- *
- * // Logout
- * await client.logout();
+ * // Deposition
+ * const conventions = await osa.deposition.listConventions()
+ * const { srn } = await osa.deposition.create(conventionSrn)
+ * await osa.deposition.uploadSpreadsheet(srn, file)
+ * await osa.deposition.submit(srn)
* ```
*/
-export { OSAClient } from './client';
-export { AuthClient, parseAuthCallback } from './auth';
-export { LocalTokenStorage, MemoryTokenStorage, type TokenStorage } from './storage';
-export type {
- AuthCallbackParams,
- AuthState,
- ErrorResponse,
- SDKConfig,
- TokenPair,
- TokenResponse,
- User,
- UserResponse,
-} from './types';
+import { HttpClient } from './http';
+import { AuthNamespace, type AuthInterface } from './auth';
+import { SearchNamespace, type SearchInterface } from './search';
+import { DepositionNamespace, type DepositionInterface } from './deposition';
+import { LocalTokenStorage, type TokenStorage } from './storage';
+import { MockSearchNamespace } from './mock/search';
+import { MockDepositionNamespace } from './mock/deposition';
+import { API_BASE_URL, CLIENT_API_URL, API_MODE } from '@/lib/utils/constants';
+
+// ---------------------------------------------------------------------------
+// OSA facade
+// ---------------------------------------------------------------------------
+
+export class OSA {
+ constructor(
+ readonly auth: AuthInterface,
+ readonly search: SearchInterface,
+ readonly deposition: DepositionInterface,
+ ) {}
+}
+
+// ---------------------------------------------------------------------------
+// Factory
+// ---------------------------------------------------------------------------
+
+export function createOSA(options?: {
+ baseUrl?: string;
+ storage?: TokenStorage;
+}): OSA {
+ const baseUrl = options?.baseUrl ?? API_BASE_URL;
+ const storage =
+ options?.storage ??
+ (typeof window !== 'undefined' ? new LocalTokenStorage() : undefined);
+
+ const http = new HttpClient(baseUrl, storage);
+
+ const auth = new AuthNamespace(http, storage ?? new LocalTokenStorage(), CLIENT_API_URL);
+ http.setRefreshFn(() => auth.refreshToken());
+
+ const search =
+ API_MODE === 'mock' ? new MockSearchNamespace() : new SearchNamespace(http);
+
+ const deposition =
+ API_MODE === 'mock'
+ ? new MockDepositionNamespace()
+ : new DepositionNamespace(http);
+
+ return new OSA(auth, search, deposition);
+}
+
+/** SDK singleton — ready to use everywhere. */
+export const osa = createOSA();
+
+// ---------------------------------------------------------------------------
+// Re-exports
+// ---------------------------------------------------------------------------
+
+// Standalone utility (used in auth callback page)
+export { parseAuthCallback } from './auth';
+
+// Namespace interfaces (for typing, mocking, testing)
+export type { AuthInterface } from './auth';
+export type { SearchInterface } from './search';
+export type { DepositionInterface } from './deposition';
+
+// Auth types needed by AuthProvider and consumers
+export type { AuthState, TokenPair, User, SDKConfig } from './types';
+export { type TokenStorage, LocalTokenStorage, MemoryTokenStorage } from './storage';
+
+// Mock implementations (for testing)
+export { MockSearchNamespace } from './mock/search';
+export { MockDepositionNamespace } from './mock/deposition';
diff --git a/web/src/lib/sdk/mock/deposition-data.ts b/web/src/lib/sdk/mock/deposition-data.ts
new file mode 100644
index 0000000..ebdd39a
--- /dev/null
+++ b/web/src/lib/sdk/mock/deposition-data.ts
@@ -0,0 +1,59 @@
+/**
+ * Mock convention data for development.
+ */
+
+import type { ConventionDetail } from '@/types';
+
+export const MOCK_CONVENTIONS: ConventionDetail[] = [
+ {
+ srn: 'urn:osa:localhost:conv:proteomics-ms',
+ title: 'Proteomics Mass Spectrometry',
+ description:
+ 'Mass spectrometry-based proteomics data including raw spectra, peptide identifications, and protein quantification tables.',
+ schema_srn: 'urn:osa:localhost:schema:proteomics-ms@1.4.0',
+ created_at: '2025-01-15T10:00:00Z',
+ file_requirements: {
+ accepted_types: ['.raw', '.mzML', '.mzXML', '.mgf', '.csv', '.tsv'],
+ min_count: 1,
+ max_count: 500,
+ max_file_size: 10_737_418_240,
+ },
+ validator_refs: [
+ { image: 'ghcr.io/osap/validators/proteomics-ms', digest: 'sha256:abc123' },
+ ],
+ },
+ {
+ srn: 'urn:osa:localhost:conv:bulk-rnaseq-timeseries',
+ title: 'Bulk RNA-Seq Time Series',
+ description:
+ 'Longitudinal gene expression profiling with time-point annotations, developmental stages, and differential expression across conditions.',
+ schema_srn: 'urn:osa:localhost:schema:bulk-rnaseq-timeseries@1.3.0',
+ created_at: '2025-02-01T12:00:00Z',
+ file_requirements: {
+ accepted_types: ['.fastq', '.fastq.gz', '.bam', '.csv', '.tsv'],
+ min_count: 1,
+ max_count: 1000,
+ max_file_size: 53_687_091_200,
+ },
+ validator_refs: [
+ { image: 'ghcr.io/osap/validators/bulk-rnaseq', digest: 'sha256:def456' },
+ ],
+ },
+ {
+ srn: 'urn:osa:localhost:conv:single-cell-rnaseq',
+ title: 'Single-Cell RNA-Seq',
+ description:
+ 'Single-cell resolution transcriptomics with cell type annotations, UMI counts, and spatial coordinates.',
+ schema_srn: 'urn:osa:localhost:schema:single-cell-rnaseq@1.2.0',
+ created_at: '2025-03-10T09:00:00Z',
+ file_requirements: {
+ accepted_types: ['.h5ad', '.h5', '.fastq.gz', '.bam', '.csv', '.mtx', '.tsv'],
+ min_count: 1,
+ max_count: 200,
+ max_file_size: 10_737_418_240,
+ },
+ validator_refs: [
+ { image: 'ghcr.io/osap/validators/scrna-seq', digest: 'sha256:ghi789' },
+ ],
+ },
+];
diff --git a/web/src/lib/sdk/mock/deposition.ts b/web/src/lib/sdk/mock/deposition.ts
new file mode 100644
index 0000000..e4fe91a
--- /dev/null
+++ b/web/src/lib/sdk/mock/deposition.ts
@@ -0,0 +1,152 @@
+/**
+ * Mock deposition namespace for development.
+ * Stateful within a session: tracks created depositions and uploaded files.
+ */
+
+import type { DepositionInterface } from '../deposition';
+import type {
+ ConventionListResponse,
+ ConventionDetail,
+ CreateDepositionResponse,
+ Deposition,
+ SpreadsheetUploadResponse,
+ FileUploadResponse,
+ DepositionFile,
+} from '@/types';
+import { MOCK_CONVENTIONS } from './deposition-data';
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+let depositionCounter = 0;
+
+function generateDepositionSrn(): string {
+ depositionCounter++;
+ const id = String(depositionCounter).padStart(26, '0');
+ return `urn:osa:localhost:dep:${id}`;
+}
+
+function simpleChecksum(name: string, size: number): string {
+ const raw = `${name}:${size}:${Date.now()}`;
+ let hash = 0;
+ for (let i = 0; i < raw.length; i++) {
+ hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
+ }
+ return `sha256:${Math.abs(hash).toString(16).padStart(16, '0')}`;
+}
+
+export class MockDepositionNamespace implements DepositionInterface {
+ private depositions = new Map();
+
+ async listConventions(): Promise {
+ await delay(200 + Math.random() * 300);
+ return {
+ items: MOCK_CONVENTIONS.map((c) => ({
+ srn: c.srn,
+ title: c.title,
+ description: c.description,
+ schema_srn: c.schema_srn,
+ created_at: c.created_at,
+ })),
+ };
+ }
+
+ async getConvention(srn: string): Promise {
+ await delay(150 + Math.random() * 200);
+ const conv = MOCK_CONVENTIONS.find((c) => c.srn === srn);
+ if (!conv) {
+ throw new Error(`Convention not found: ${srn}`);
+ }
+ return conv;
+ }
+
+ async create(conventionSrn: string): Promise {
+ await delay(200 + Math.random() * 300);
+ const now = new Date().toISOString();
+ const srn = generateDepositionSrn();
+ const deposition: Deposition = {
+ srn,
+ convention_srn: conventionSrn,
+ status: 'draft',
+ metadata: {},
+ files: [],
+ record_srn: null,
+ created_at: now,
+ updated_at: now,
+ };
+ this.depositions.set(srn, deposition);
+ return { srn };
+ }
+
+ async get(srn: string): Promise {
+ await delay(150 + Math.random() * 200);
+ const dep = this.depositions.get(srn);
+ if (!dep) {
+ throw new Error(`Deposition not found: ${srn}`);
+ }
+ return dep;
+ }
+
+ async downloadTemplate(conventionSrn: string): Promise {
+ await delay(300 + Math.random() * 400);
+ const conv = MOCK_CONVENTIONS.find((c) => c.srn === conventionSrn);
+ if (!conv) {
+ throw new Error(`Convention not found: ${conventionSrn}`);
+ }
+ const content = `template for ${conv.title}\n`;
+ return new Blob([content], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+ }
+
+ async uploadSpreadsheet(depositionSrn: string, file: File): Promise {
+ await delay(400 + Math.random() * 500);
+ const dep = this.depositions.get(depositionSrn);
+ if (dep) {
+ dep.updated_at = new Date().toISOString();
+ }
+ return {
+ parse_result: {
+ metadata: { title: 'Sample 1', organism: 'Homo sapiens' },
+ warnings: [`Column "batch_id" in ${file.name} is not in the schema — it will be ignored`],
+ errors: [],
+ },
+ };
+ }
+
+ async uploadFile(depositionSrn: string, file: File): Promise {
+ await delay(200 + Math.random() * 300);
+ const dep = this.depositions.get(depositionSrn);
+ const entry: DepositionFile = {
+ name: file.name,
+ size: file.size,
+ checksum: simpleChecksum(file.name, file.size),
+ content_type: file.type || null,
+ uploaded_at: new Date().toISOString(),
+ };
+ if (dep) {
+ dep.files.push(entry);
+ dep.updated_at = new Date().toISOString();
+ }
+ return { file: entry };
+ }
+
+ async deleteFile(depositionSrn: string, filename: string): Promise {
+ await delay(150 + Math.random() * 200);
+ const dep = this.depositions.get(depositionSrn);
+ if (dep) {
+ dep.files = dep.files.filter((f) => f.name !== filename);
+ dep.updated_at = new Date().toISOString();
+ }
+ }
+
+ async submit(depositionSrn: string): Promise {
+ await delay(300 + Math.random() * 400);
+ const dep = this.depositions.get(depositionSrn);
+ if (dep) {
+ dep.status = 'in_validation';
+ dep.updated_at = new Date().toISOString();
+ }
+ }
+}
diff --git a/web/src/lib/api/mock.ts b/web/src/lib/sdk/mock/search.ts
similarity index 92%
rename from web/src/lib/api/mock.ts
rename to web/src/lib/sdk/mock/search.ts
index 8b7fb2b..a782fd0 100644
--- a/web/src/lib/api/mock.ts
+++ b/web/src/lib/sdk/mock/search.ts
@@ -1,9 +1,9 @@
/**
- * MockAPI Implementation
- * In-memory implementation with realistic sample data for development.
+ * Mock search namespace for development.
+ * Returns deterministic sample data with simulated network delay.
*/
-import type { ApiInterface } from './interface';
+import type { SearchInterface } from '../search';
import type {
SearchOptions,
IndexListResponse,
@@ -13,7 +13,6 @@ import type {
} from '@/types';
import { DEFAULT_INDEX, DEFAULT_LIMIT } from '@/lib/utils/constants';
-// Sample records representing biological datasets
const MOCK_RECORDS: Array<{ srn: string; metadata: RecordMetadata }> = [
{
srn: 'urn:osa:localhost:dep:a1b2c3d4-e5f6-7890-abcd-ef1234567890',
@@ -173,9 +172,6 @@ const MOCK_RECORDS: Array<{ srn: string; metadata: RecordMetadata }> = [
},
];
-/**
- * Calculate a mock relevance score based on query term matching.
- */
function calculateMockScore(query: string, metadata: RecordMetadata): number {
const queryTerms = query.toLowerCase().split(/\s+/);
const searchText = [
@@ -195,48 +191,42 @@ function calculateMockScore(query: string, metadata: RecordMetadata): number {
}
}
- // Base score + bonus for matches
const matchRatio = matches / queryTerms.length;
return Math.min(0.95, 0.3 + matchRatio * 0.6 + Math.random() * 0.1);
}
-/**
- * MockAPI - In-memory implementation for development.
- */
-export class MockAPI implements ApiInterface {
+export class MockSearchNamespace implements SearchInterface {
private records = MOCK_RECORDS;
+ /** List available search indexes. */
async listIndexes(): Promise {
- // Simulate network delay
await this.delay(100);
return { indexes: ['vector'] };
}
- async search(
- query: string,
+ /** Search records by natural language query. */
+ async query(
+ text: string,
indexName: string = DEFAULT_INDEX,
- options: SearchOptions = {}
+ options: SearchOptions = {},
): Promise {
const { offset = 0, limit = DEFAULT_LIMIT } = options;
- // Simulate network delay
await this.delay(300 + Math.random() * 200);
- // Score and sort records
const scoredResults = this.records
.map((record) => ({
srn: record.srn,
- score: calculateMockScore(query, record.metadata),
+ score: calculateMockScore(text, record.metadata),
metadata: record.metadata,
}))
.sort((a, b) => b.score - a.score);
- // Apply pagination
const paginatedResults = scoredResults.slice(offset, offset + limit);
const hasMore = offset + limit < scoredResults.length;
return {
- query,
+ query: text,
index: indexName,
total: scoredResults.length,
has_more: hasMore,
@@ -244,8 +234,8 @@ export class MockAPI implements ApiInterface {
};
}
+ /** Fetch a single record by SRN. */
async getRecord(srn: string): Promise {
- // Simulate network delay
await this.delay(200);
const record = this.records.find((r) => r.srn === srn);
diff --git a/web/src/lib/sdk/search.ts b/web/src/lib/sdk/search.ts
new file mode 100644
index 0000000..0ee50cf
--- /dev/null
+++ b/web/src/lib/sdk/search.ts
@@ -0,0 +1,97 @@
+/**
+ * Search namespace for the OSA SDK.
+ */
+
+import type { HttpClient } from './http';
+import type {
+ SearchOptions,
+ IndexListResponse,
+ SearchResponse,
+ RecordResponse,
+} from '@/types';
+import { DEFAULT_INDEX, DEFAULT_LIMIT } from '@/lib/utils/constants';
+
+// ---------------------------------------------------------------------------
+// Public interface
+// ---------------------------------------------------------------------------
+
+export interface SearchInterface {
+ /** List available search indexes. */
+ listIndexes(): Promise;
+
+ /** Search records by natural language query. */
+ query(
+ text: string,
+ indexName?: string,
+ options?: SearchOptions,
+ ): Promise;
+
+ /** Fetch a single record by SRN. */
+ getRecord(srn: string): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Real HTTP implementation
+// ---------------------------------------------------------------------------
+
+export class SearchNamespace implements SearchInterface {
+ constructor(private http: HttpClient) {}
+
+ /** List available search indexes. */
+ async listIndexes(): Promise {
+ const response = await this.http.fetch('/search/', {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ if (!response.ok) {
+ throw await response.json();
+ }
+
+ return response.json();
+ }
+
+ /** Search records by natural language query. */
+ async query(
+ text: string,
+ indexName: string = DEFAULT_INDEX,
+ options: SearchOptions = {},
+ ): Promise {
+ const { offset = 0, limit = DEFAULT_LIMIT, filters = {} } = options;
+
+ const params = new URLSearchParams({
+ q: text,
+ offset: offset.toString(),
+ limit: limit.toString(),
+ });
+
+ for (const [key, value] of Object.entries(filters)) {
+ params.append(`filter:${key}`, value);
+ }
+
+ const response = await this.http.fetch(
+ `/search/${indexName}?${params.toString()}`,
+ { method: 'GET', headers: { 'Content-Type': 'application/json' } },
+ );
+
+ if (!response.ok) {
+ throw await response.json();
+ }
+
+ return response.json();
+ }
+
+ /** Fetch a single record by SRN. */
+ async getRecord(srn: string): Promise {
+ const response = await this.http.fetch(
+ `/records/${encodeURIComponent(srn)}`,
+ { method: 'GET', headers: { 'Content-Type': 'application/json' } },
+ );
+
+ if (!response.ok) {
+ throw await response.json();
+ }
+
+ return response.json();
+ }
+}
diff --git a/web/src/lib/utils/constants.ts b/web/src/lib/utils/constants.ts
index e75e802..9f84d2b 100644
--- a/web/src/lib/utils/constants.ts
+++ b/web/src/lib/utils/constants.ts
@@ -20,5 +20,16 @@ const isServer = typeof window === 'undefined';
const serverApiUrl = process.env.API_URL ? `${process.env.API_URL}/api/v1` : null;
export const API_BASE_URL = isServer && serverApiUrl ? serverApiUrl : '/api/v1';
+/**
+ * Client-facing API base URL (used for browser-initiated requests like auth).
+ * - Uses NEXT_PUBLIC_API_URL if set (e.g., http://127.0.0.1:8000/api/v1 for local dev)
+ * - Falls back to /api/v1 (proxied by Next.js rewrites)
+ *
+ * This differs from API_BASE_URL which may resolve to an internal Docker hostname
+ * for SSR. Auth endpoints (login redirect, refresh, logout) must always use a
+ * browser-reachable URL.
+ */
+export const CLIENT_API_URL = process.env.NEXT_PUBLIC_API_URL || '/api/v1';
+
/** API mode: 'mock' or 'live' */
export const API_MODE = process.env.NEXT_PUBLIC_API_MODE || 'live';
diff --git a/web/src/types/convention.ts b/web/src/types/convention.ts
new file mode 100644
index 0000000..f5f02df
--- /dev/null
+++ b/web/src/types/convention.ts
@@ -0,0 +1,32 @@
+/**
+ * Convention types matching backend DTOs.
+ */
+
+export interface Convention {
+ srn: string;
+ title: string;
+ description: string | null;
+ schema_srn: string;
+ created_at: string;
+}
+
+export interface ConventionDetail extends Convention {
+ file_requirements: FileRequirements;
+ validator_refs: ValidatorRef[];
+}
+
+export interface FileRequirements {
+ accepted_types: string[];
+ min_count: number;
+ max_count: number;
+ max_file_size: number;
+}
+
+export interface ValidatorRef {
+ image: string;
+ digest: string;
+}
+
+export interface ConventionListResponse {
+ items: Convention[];
+}
diff --git a/web/src/types/deposition.ts b/web/src/types/deposition.ts
new file mode 100644
index 0000000..2f488c7
--- /dev/null
+++ b/web/src/types/deposition.ts
@@ -0,0 +1,52 @@
+/**
+ * Deposition types matching backend DTOs.
+ */
+
+export type DepositionStatus =
+ | 'draft'
+ | 'in_validation'
+ | 'in_review'
+ | 'accepted'
+ | 'rejected';
+
+export interface DepositionFile {
+ name: string;
+ size: number;
+ checksum: string;
+ content_type: string | null;
+ uploaded_at: string;
+}
+
+export interface Deposition {
+ srn: string;
+ convention_srn: string;
+ status: DepositionStatus;
+ metadata: Record;
+ files: DepositionFile[];
+ record_srn: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface SpreadsheetError {
+ field: string;
+ message: string;
+}
+
+export interface SpreadsheetParseResult {
+ metadata: Record;
+ warnings: string[];
+ errors: SpreadsheetError[];
+}
+
+export interface CreateDepositionResponse {
+ srn: string;
+}
+
+export interface SpreadsheetUploadResponse {
+ parse_result: SpreadsheetParseResult;
+}
+
+export interface FileUploadResponse {
+ file: DepositionFile;
+}
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index 04856c0..eda7589 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -1,5 +1,5 @@
/**
- * Type exports for Lingual Bio Search
+ * Type exports for OSA
*/
export type {
@@ -18,3 +18,22 @@ export type {
} from './api';
export { isApiError } from './api';
+
+export type {
+ Convention,
+ ConventionDetail,
+ ConventionListResponse,
+ FileRequirements,
+ ValidatorRef,
+} from './convention';
+
+export type {
+ Deposition,
+ DepositionFile,
+ DepositionStatus,
+ SpreadsheetError,
+ SpreadsheetParseResult,
+ CreateDepositionResponse,
+ SpreadsheetUploadResponse,
+ FileUploadResponse,
+} from './deposition';
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
new file mode 100644
index 0000000..a49fa52
--- /dev/null
+++ b/web/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vitest/config';
+import path from 'path';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ test: {
+ globals: true,
+ },
+});