@enactprotocol/api 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Authentication functionality (v2)
3
+ * Handles OAuth-based authentication for the Enact registry
4
+ */
5
+
6
+ import type { EnactApiClient } from "./client";
7
+ import type {
8
+ CurrentUser,
9
+ OAuthCallbackRequest,
10
+ OAuthLoginRequest,
11
+ OAuthLoginResponse,
12
+ OAuthProvider,
13
+ OAuthTokenResponse,
14
+ RefreshTokenRequest,
15
+ RefreshTokenResponse,
16
+ } from "./types";
17
+
18
+ /**
19
+ * Authentication result
20
+ */
21
+ export interface AuthResult {
22
+ /** Whether authentication succeeded */
23
+ success: boolean;
24
+ /** Authentication token (if successful) */
25
+ token?: string | undefined;
26
+ /** Current user info (if successful) */
27
+ user?: AuthUser | undefined;
28
+ /** Error message (if failed) */
29
+ error?: string | undefined;
30
+ }
31
+
32
+ /**
33
+ * Authenticated user info
34
+ */
35
+ export interface AuthUser {
36
+ /** Username */
37
+ username: string;
38
+ /** Email address */
39
+ email: string;
40
+ /** Namespaces owned */
41
+ namespaces: string[];
42
+ }
43
+
44
+ /**
45
+ * Authentication status
46
+ */
47
+ export interface AuthStatus {
48
+ /** Whether currently authenticated */
49
+ authenticated: boolean;
50
+ /** Current user (if authenticated) */
51
+ user?: AuthUser | undefined;
52
+ /** Token expiration time (if available) */
53
+ expiresAt?: Date | undefined;
54
+ }
55
+
56
+ /**
57
+ * Initiate OAuth login (v2)
58
+ *
59
+ * @param client - API client instance
60
+ * @param provider - OAuth provider (github, google, microsoft)
61
+ * @param redirectUri - Callback URL (usually http://localhost:PORT/callback)
62
+ * @returns Authorization URL to redirect user to
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const result = await initiateLogin(client, "github", "http://localhost:9876/callback");
67
+ * console.log(`Visit: ${result.authUrl}`);
68
+ * ```
69
+ */
70
+ export async function initiateLogin(
71
+ client: EnactApiClient,
72
+ provider: OAuthProvider,
73
+ redirectUri: string
74
+ ): Promise<OAuthLoginResponse> {
75
+ const response = await client.post<OAuthLoginResponse>("/auth/login", {
76
+ provider,
77
+ redirect_uri: redirectUri,
78
+ } as OAuthLoginRequest);
79
+
80
+ return response.data;
81
+ }
82
+
83
+ /**
84
+ * Exchange OAuth code for tokens (v2)
85
+ *
86
+ * @param client - API client instance
87
+ * @param provider - OAuth provider used
88
+ * @param code - Authorization code from OAuth callback
89
+ * @returns Token response with access token, refresh token, and user info
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * const tokens = await exchangeCodeForToken(client, "github", "auth_code_123");
94
+ * client.setAuthToken(tokens.access_token);
95
+ * console.log(`Logged in as ${tokens.user.username}`);
96
+ * ```
97
+ */
98
+ export async function exchangeCodeForToken(
99
+ client: EnactApiClient,
100
+ provider: OAuthProvider,
101
+ code: string
102
+ ): Promise<OAuthTokenResponse> {
103
+ const response = await client.post<OAuthTokenResponse>("/auth/callback", {
104
+ provider,
105
+ code,
106
+ } as OAuthCallbackRequest);
107
+
108
+ return response.data;
109
+ }
110
+
111
+ /**
112
+ * Refresh an expired access token (v2)
113
+ *
114
+ * @param client - API client instance
115
+ * @param refreshToken - Refresh token obtained during login
116
+ * @returns New access token and expiration
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * const newToken = await refreshAccessToken(client, storedRefreshToken);
121
+ * client.setAuthToken(newToken.access_token);
122
+ * ```
123
+ */
124
+ export async function refreshAccessToken(
125
+ client: EnactApiClient,
126
+ refreshToken: string
127
+ ): Promise<RefreshTokenResponse> {
128
+ const response = await client.post<RefreshTokenResponse>("/auth/refresh", {
129
+ refresh_token: refreshToken,
130
+ } as RefreshTokenRequest);
131
+
132
+ return response.data;
133
+ }
134
+
135
+ /**
136
+ * Authenticate with the Enact registry (v2 OAuth flow)
137
+ *
138
+ * This is a convenience wrapper that initiates an OAuth flow:
139
+ * 1. Opens a browser for authentication
140
+ * 2. User logs in via their provider (GitHub, Google, etc.)
141
+ * 3. Receives a token from the registry
142
+ *
143
+ * Note: The actual OAuth callback handling requires a local HTTP server,
144
+ * which should be implemented in the CLI package.
145
+ *
146
+ * @param client - API client instance
147
+ * @returns Authentication result
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * const result = await authenticate(client);
152
+ * if (result.success) {
153
+ * client.setAuthToken(result.token);
154
+ * console.log(`Logged in as ${result.user.username}`);
155
+ * }
156
+ * ```
157
+ */
158
+ export async function authenticate(_client: EnactApiClient): Promise<AuthResult> {
159
+ // This is a placeholder for the full OAuth flow
160
+ // The actual implementation should be in the CLI package
161
+ // which can start a local server and open a browser
162
+ //
163
+ // Typical flow:
164
+ // 1. const loginResponse = await initiateLogin(client, "github", redirectUri);
165
+ // 2. Open browser to loginResponse.auth_url
166
+ // 3. Start local server on redirectUri to receive callback
167
+ // 4. Extract code from callback
168
+ // 5. const tokens = await exchangeCodeForToken(client, "github", code);
169
+ // 6. Return { success: true, token: tokens.access_token, user: {...} }
170
+
171
+ throw new Error(
172
+ "authenticate() must be implemented in the CLI package. " +
173
+ "Use initiateLogin() and exchangeCodeForToken() for OAuth flow."
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Log out by clearing the authentication token
179
+ *
180
+ * @param client - API client instance
181
+ */
182
+ export function logout(client: EnactApiClient): void {
183
+ client.setAuthToken(undefined);
184
+ }
185
+
186
+ /**
187
+ * Get current user info (v2)
188
+ *
189
+ * @param client - API client instance (must be authenticated)
190
+ * @returns Current user info
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * const user = await getCurrentUser(client);
195
+ * console.log(`Logged in as ${user.username}`);
196
+ * ```
197
+ */
198
+ export async function getCurrentUser(client: EnactApiClient): Promise<CurrentUser> {
199
+ const response = await client.get<CurrentUser>("/auth/me");
200
+ return response.data;
201
+ }
202
+
203
+ /**
204
+ * Get current authentication status (v2)
205
+ *
206
+ * @param client - API client instance
207
+ * @returns Current auth status
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * const status = await getAuthStatus(client);
212
+ * if (status.authenticated) {
213
+ * console.log(`Logged in as ${status.user.username}`);
214
+ * }
215
+ * ```
216
+ */
217
+ export async function getAuthStatus(client: EnactApiClient): Promise<AuthStatus> {
218
+ if (!client.isAuthenticated()) {
219
+ return { authenticated: false };
220
+ }
221
+
222
+ try {
223
+ const user = await getCurrentUser(client);
224
+ return {
225
+ authenticated: true,
226
+ user: {
227
+ username: user.username,
228
+ email: user.email,
229
+ namespaces: user.namespaces,
230
+ },
231
+ };
232
+ } catch {
233
+ // Token might be invalid/expired
234
+ return { authenticated: false };
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Get user profile by username (v2)
240
+ *
241
+ * @param client - API client instance
242
+ * @param username - Username to look up
243
+ * @returns User profile info
244
+ */
245
+ export async function getUserProfile(
246
+ client: EnactApiClient,
247
+ username: string
248
+ ): Promise<{
249
+ username: string;
250
+ displayName?: string | undefined;
251
+ avatarUrl?: string | undefined;
252
+ createdAt: Date;
253
+ toolsCount?: number | undefined;
254
+ }> {
255
+ const response = await client.get<{
256
+ username: string;
257
+ display_name?: string | undefined;
258
+ avatar_url?: string | undefined;
259
+ created_at: string;
260
+ tools_count?: number | undefined;
261
+ }>(`/users/${username}`);
262
+
263
+ return {
264
+ username: response.data.username,
265
+ displayName: response.data.display_name,
266
+ avatarUrl: response.data.avatar_url,
267
+ createdAt: new Date(response.data.created_at),
268
+ toolsCount: response.data.tools_count,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Submit feedback for a tool
274
+ *
275
+ * @param client - API client instance (must be authenticated)
276
+ * @param name - Tool name
277
+ * @param rating - Rating (1-5)
278
+ * @param version - Version being rated
279
+ * @param comment - Optional comment
280
+ */
281
+ export async function submitFeedback(
282
+ client: EnactApiClient,
283
+ name: string,
284
+ rating: number,
285
+ version: string,
286
+ comment?: string
287
+ ): Promise<void> {
288
+ await client.post(`/tools/${name}/feedback`, {
289
+ rating,
290
+ version,
291
+ comment,
292
+ });
293
+ }
package/src/client.ts ADDED
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Enact Registry API Client
3
+ * Core HTTP client for interacting with the Enact registry
4
+ */
5
+
6
+ import type { ApiError, RateLimitInfo } from "./types";
7
+
8
+ /**
9
+ * Default registry URL
10
+ */
11
+ export const DEFAULT_REGISTRY_URL = "https://siikwkfgsmouioodghho.supabase.co/functions/v1";
12
+
13
+ /**
14
+ * API client configuration options
15
+ */
16
+ export interface ApiClientOptions {
17
+ /** Registry base URL (default: https://siikwkfgsmouioodghho.supabase.co/functions/v1) */
18
+ baseUrl?: string | undefined;
19
+ /** Authentication token */
20
+ authToken?: string | undefined;
21
+ /** Request timeout in milliseconds (default: 30000) */
22
+ timeout?: number | undefined;
23
+ /** Number of retry attempts for failed requests (default: 3) */
24
+ retries?: number | undefined;
25
+ /** User agent string */
26
+ userAgent?: string | undefined;
27
+ }
28
+
29
+ /**
30
+ * API response wrapper
31
+ */
32
+ export interface ApiResponse<T> {
33
+ /** Response data */
34
+ data: T;
35
+ /** HTTP status code */
36
+ status: number;
37
+ /** Rate limit information */
38
+ rateLimit?: RateLimitInfo | undefined;
39
+ }
40
+
41
+ /**
42
+ * API request error
43
+ */
44
+ export class ApiRequestError extends Error {
45
+ /** HTTP status code */
46
+ readonly status: number;
47
+ /** API error code */
48
+ readonly code: string;
49
+ /** Original error response */
50
+ readonly response?: ApiError | undefined;
51
+
52
+ constructor(message: string, status: number, code: string, response?: ApiError) {
53
+ super(message);
54
+ this.name = "ApiRequestError";
55
+ this.status = status;
56
+ this.code = code;
57
+ this.response = response;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Parse rate limit headers from response
63
+ */
64
+ function parseRateLimitHeaders(headers: Headers): RateLimitInfo | undefined {
65
+ const limit = headers.get("X-RateLimit-Limit");
66
+ const remaining = headers.get("X-RateLimit-Remaining");
67
+ const reset = headers.get("X-RateLimit-Reset");
68
+
69
+ if (limit && remaining && reset) {
70
+ return {
71
+ limit: Number.parseInt(limit, 10),
72
+ remaining: Number.parseInt(remaining, 10),
73
+ reset: Number.parseInt(reset, 10),
74
+ };
75
+ }
76
+
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Enact Registry API Client
82
+ */
83
+ export class EnactApiClient {
84
+ private readonly baseUrl: string;
85
+ private readonly timeout: number;
86
+ private readonly maxRetries: number;
87
+ private readonly userAgent: string;
88
+ private authToken: string | undefined;
89
+
90
+ constructor(options: ApiClientOptions = {}) {
91
+ this.baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL;
92
+ this.timeout = options.timeout ?? 30000;
93
+ this.maxRetries = options.retries ?? 3;
94
+ this.userAgent = options.userAgent ?? "enact-cli/0.1.0";
95
+ this.authToken = options.authToken;
96
+ }
97
+
98
+ /**
99
+ * Set authentication token
100
+ */
101
+ setAuthToken(token: string | undefined): void {
102
+ this.authToken = token;
103
+ }
104
+
105
+ /**
106
+ * Get current authentication token
107
+ */
108
+ getAuthToken(): string | undefined {
109
+ return this.authToken;
110
+ }
111
+
112
+ /**
113
+ * Get the base URL for the registry
114
+ */
115
+ getBaseUrl(): string {
116
+ return this.baseUrl;
117
+ }
118
+
119
+ /**
120
+ * Get the user agent string
121
+ */
122
+ getUserAgent(): string {
123
+ return this.userAgent;
124
+ }
125
+
126
+ /**
127
+ * Check if client is authenticated
128
+ */
129
+ isAuthenticated(): boolean {
130
+ return this.authToken !== undefined;
131
+ }
132
+
133
+ /**
134
+ * Build headers for a request
135
+ */
136
+ private buildHeaders(contentType?: string): Headers {
137
+ const headers = new Headers();
138
+ headers.set("User-Agent", this.userAgent);
139
+ headers.set("Accept", "application/json");
140
+
141
+ if (contentType) {
142
+ headers.set("Content-Type", contentType);
143
+ }
144
+
145
+ if (this.authToken) {
146
+ headers.set("Authorization", `Bearer ${this.authToken}`);
147
+ // Supabase Edge Functions also need the apikey header
148
+ headers.set("apikey", this.authToken);
149
+ }
150
+
151
+ return headers;
152
+ }
153
+
154
+ /**
155
+ * Make an HTTP request with retry logic
156
+ */
157
+ private async request<T>(
158
+ method: string,
159
+ path: string,
160
+ options: {
161
+ body?: unknown;
162
+ contentType?: string;
163
+ retryCount?: number;
164
+ } = {}
165
+ ): Promise<ApiResponse<T>> {
166
+ const url = `${this.baseUrl}${path}`;
167
+ const retryCount = options.retryCount ?? 0;
168
+
169
+ const controller = new AbortController();
170
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
171
+
172
+ try {
173
+ const response = await fetch(url, {
174
+ method,
175
+ headers: this.buildHeaders(options.contentType),
176
+ body: options.body ? JSON.stringify(options.body) : undefined,
177
+ signal: controller.signal,
178
+ });
179
+
180
+ clearTimeout(timeoutId);
181
+
182
+ const rateLimit = parseRateLimitHeaders(response.headers);
183
+
184
+ // Handle rate limiting with retry
185
+ if (response.status === 429 && retryCount < this.maxRetries) {
186
+ const retryAfter = response.headers.get("Retry-After");
187
+ const delay = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : 1000 * (retryCount + 1);
188
+ await new Promise((resolve) => setTimeout(resolve, delay));
189
+ return this.request<T>(method, path, { ...options, retryCount: retryCount + 1 });
190
+ }
191
+
192
+ // Handle error responses
193
+ if (!response.ok) {
194
+ let errorData: ApiError | undefined;
195
+ try {
196
+ errorData = (await response.json()) as ApiError;
197
+ } catch {
198
+ // Response body might not be JSON
199
+ }
200
+
201
+ const code = errorData?.error?.code ?? "unknown";
202
+ const message = errorData?.error?.message ?? `HTTP ${response.status}`;
203
+
204
+ throw new ApiRequestError(message, response.status, code, errorData);
205
+ }
206
+
207
+ // Handle 204 No Content
208
+ if (response.status === 204) {
209
+ return {
210
+ data: undefined as T,
211
+ status: response.status,
212
+ rateLimit,
213
+ };
214
+ }
215
+
216
+ // Parse JSON response
217
+ let data: T;
218
+ try {
219
+ const text = await response.text();
220
+ if (!text || text.trim() === "") {
221
+ throw new ApiRequestError(
222
+ "Server returned empty response",
223
+ response.status,
224
+ "empty_response"
225
+ );
226
+ }
227
+ data = JSON.parse(text) as T;
228
+ } catch (parseError) {
229
+ if (parseError instanceof ApiRequestError) {
230
+ throw parseError;
231
+ }
232
+ throw new ApiRequestError(
233
+ "Server returned invalid JSON response",
234
+ response.status,
235
+ "invalid_json"
236
+ );
237
+ }
238
+
239
+ return { data, status: response.status, rateLimit };
240
+ } catch (error) {
241
+ clearTimeout(timeoutId);
242
+
243
+ // Handle network errors with retry
244
+ if (error instanceof Error && error.name === "AbortError") {
245
+ if (retryCount < this.maxRetries) {
246
+ const delay = 1000 * (retryCount + 1);
247
+ await new Promise((resolve) => setTimeout(resolve, delay));
248
+ return this.request<T>(method, path, { ...options, retryCount: retryCount + 1 });
249
+ }
250
+ throw new ApiRequestError("Request timeout", 0, "timeout");
251
+ }
252
+
253
+ // Re-throw ApiRequestErrors
254
+ if (error instanceof ApiRequestError) {
255
+ throw error;
256
+ }
257
+
258
+ // Wrap other errors
259
+ throw new ApiRequestError(
260
+ error instanceof Error ? error.message : "Unknown error",
261
+ 0,
262
+ "network_error"
263
+ );
264
+ }
265
+ }
266
+
267
+ /**
268
+ * GET request
269
+ */
270
+ async get<T>(path: string): Promise<ApiResponse<T>> {
271
+ return this.request<T>("GET", path);
272
+ }
273
+
274
+ /**
275
+ * POST request
276
+ */
277
+ async post<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
278
+ return this.request<T>("POST", path, {
279
+ body,
280
+ contentType: "application/json",
281
+ });
282
+ }
283
+
284
+ /**
285
+ * PUT request
286
+ */
287
+ async put<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
288
+ return this.request<T>("PUT", path, {
289
+ body,
290
+ contentType: "application/json",
291
+ });
292
+ }
293
+
294
+ /**
295
+ * DELETE request
296
+ */
297
+ async delete<T>(path: string): Promise<ApiResponse<T>> {
298
+ return this.request<T>("DELETE", path);
299
+ }
300
+
301
+ /**
302
+ * Download a file (returns raw response for streaming)
303
+ */
304
+ async download(path: string): Promise<Response> {
305
+ const url = `${this.baseUrl}${path}`;
306
+
307
+ const controller = new AbortController();
308
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout * 10); // Longer timeout for downloads
309
+
310
+ try {
311
+ const response = await fetch(url, {
312
+ method: "GET",
313
+ headers: this.buildHeaders(),
314
+ signal: controller.signal,
315
+ });
316
+
317
+ clearTimeout(timeoutId);
318
+
319
+ if (!response.ok) {
320
+ throw new ApiRequestError(
321
+ `Download failed: HTTP ${response.status}`,
322
+ response.status,
323
+ "download_error"
324
+ );
325
+ }
326
+
327
+ return response;
328
+ } catch (error) {
329
+ clearTimeout(timeoutId);
330
+
331
+ if (error instanceof ApiRequestError) {
332
+ throw error;
333
+ }
334
+
335
+ throw new ApiRequestError(
336
+ error instanceof Error ? error.message : "Download failed",
337
+ 0,
338
+ "download_error"
339
+ );
340
+ }
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Create a new API client instance
346
+ */
347
+ export function createApiClient(options?: ApiClientOptions): EnactApiClient {
348
+ return new EnactApiClient(options);
349
+ }