@arinova-ai/spaces-sdk 0.1.1 → 0.1.2

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
@@ -1,109 +1,91 @@
1
- # @arinova-ai/spaces-sdk
1
+ # @arinova/spaces-sdk
2
2
 
3
- Official SDK for integrating external games with the Arinova platform.
3
+ OAuth PKCE SDK for Arinova Spaces authenticate users without exposing secrets.
4
4
 
5
- ## Install
5
+ ## Installation
6
6
 
7
7
  ```bash
8
- npm install @arinova-ai/spaces-sdk
8
+ npm install @arinova/spaces-sdk
9
9
  ```
10
10
 
11
11
  ## Quick Start
12
12
 
13
- ### 1. Initialize
13
+ ```js
14
+ import { Arinova } from "@arinova/spaces-sdk";
14
15
 
15
- ```typescript
16
- import { Arinova } from "@arinova-ai/spaces-sdk";
16
+ const arinova = new Arinova({ appId: "your-client-id" });
17
17
 
18
- Arinova.init({
19
- appId: "your-client-id",
20
- baseUrl: "https://api.arinova.ai", // optional, defaults to production
21
- });
18
+ // Trigger login (opens popup)
19
+ const token = await arinova.login();
20
+ console.log(token.user.name); // "Alice"
21
+ console.log(token.access_token); // Bearer token for API calls
22
22
  ```
23
23
 
24
- ### 2a. Connect (Recommended)
24
+ ## Setup
25
25
 
26
- The easiest way to authenticate. Works automatically in both contexts:
26
+ 1. Register your app: `arinova-cli app create --name "My App" --redirect-uri "https://myapp.com"`
27
+ 2. Copy the `Client ID` from the output
28
+ 3. No `client_secret` needed — all apps use PKCE
27
29
 
28
- - **Embedded in Arinova Chat iframe**: receives auth via `postMessage` from the parent window (no redirect needed).
29
- - **Standalone (outside iframe)**: falls back to the OAuth login flow.
30
+ ## API
30
31
 
31
- ```typescript
32
- const { user, accessToken, agents } = await Arinova.connect({ timeout: 5000 });
33
- // user: { id, name, email, image }
34
- // accessToken: session token for API calls
35
- // agents: user's connected agents (may be empty)
36
- ```
32
+ ### `new Arinova(config)`
37
33
 
38
- ### 2b. Login (Manual OAuth Flow)
34
+ | Option | Type | Default | Description |
35
+ |--------|------|---------|-------------|
36
+ | `appId` | `string` | *required* | Your OAuth app client_id |
37
+ | `endpoint` | `string` | `https://chat.arinova.ai` | Arinova server URL |
38
+ | `redirectUri` | `string` | `{origin}/callback` | OAuth callback URL |
39
+ | `scope` | `string` | `"profile"` | OAuth scope |
39
40
 
40
- ```typescript
41
- // Redirect to Arinova login
42
- Arinova.login({ scope: ["profile", "agents"] });
41
+ ### `arinova.login(): Promise<TokenResponse>`
43
42
 
44
- // Handle callback (on your redirect page)
45
- const { user, accessToken } = await Arinova.handleCallback({
46
- code: urlParams.get("code"),
47
- clientId: "your-client-id",
48
- clientSecret: "your-client-secret",
49
- redirectUri: window.location.origin + "/callback",
50
- });
43
+ Opens a popup for user authorization (PKCE flow). Returns:
44
+
45
+ ```ts
46
+ {
47
+ access_token: string;
48
+ token_type: "Bearer";
49
+ expires_in: 604800; // 7 days
50
+ scope: "profile";
51
+ user: { id, name, email, image };
52
+ }
51
53
  ```
52
54
 
53
- ### 3. Use Agent API
55
+ ### `arinova.handleCallback(): Promise<TokenResponse>`
54
56
 
55
- ```typescript
56
- // Get user's agents
57
- const agents = await Arinova.user.agents(accessToken);
57
+ Call on your redirect_uri page to complete the flow (for redirect mode instead of popup).
58
58
 
59
- // Chat with agent (sync)
60
- const { response } = await Arinova.agent.chat({
61
- agentId: agents[0].id,
62
- prompt: "Your board state...",
63
- accessToken,
64
- });
59
+ ## PKCE Flow
65
60
 
66
- // Chat with agent (streaming)
67
- const result = await Arinova.agent.chatStream({
68
- agentId: agents[0].id,
69
- prompt: "Your move?",
70
- accessToken,
71
- onChunk: (chunk) => console.log("Streaming:", chunk),
72
- });
73
- ```
61
+ 1. SDK generates `code_verifier` (random) and `code_challenge = BASE64URL(SHA256(code_verifier))`
62
+ 2. User is redirected to Arinova with `code_challenge`
63
+ 3. After authorization, Arinova redirects back with `code`
64
+ 4. SDK exchanges `code` + `code_verifier` for `access_token` (no secret needed)
65
+
66
+ ## redirect_uri Rules
67
+
68
+ - Origin match: scheme + host + port must match your registered URI
69
+ - Path can differ (SDK uses `window.location.origin + /callback` by default)
70
+ - Must use HTTPS in production
71
+ - `http://localhost` is allowed for development
72
+
73
+ ## Example: Redirect Mode
74
74
 
75
- ### 4. Economy (Server-to-Server)
75
+ If popups are blocked, use redirect mode:
76
76
 
77
- ```typescript
78
- // Charge coins
79
- const { newBalance } = await Arinova.economy.charge({
80
- userId: "user-id",
81
- amount: 10,
82
- description: "Game entry fee",
83
- clientId: "your-client-id",
84
- clientSecret: "your-client-secret",
77
+ ```js
78
+ // On login page:
79
+ const arinova = new Arinova({
80
+ appId: "your-client-id",
81
+ redirectUri: "https://myapp.com/auth/callback",
85
82
  });
83
+ arinova.login(); // Will redirect if popup is blocked
86
84
 
87
- // Award coins
88
- const { newBalance, platformFee } = await Arinova.economy.award({
89
- userId: "user-id",
90
- amount: 20,
91
- description: "Game prize",
92
- clientId: "your-client-id",
93
- clientSecret: "your-client-secret",
85
+ // On callback page (/auth/callback):
86
+ const arinova = new Arinova({
87
+ appId: "your-client-id",
88
+ redirectUri: "https://myapp.com/auth/callback",
94
89
  });
90
+ const token = await arinova.handleCallback();
95
91
  ```
96
-
97
- ## API Reference
98
-
99
- ### `Arinova.init(config)`
100
- ### `Arinova.connect(options?)`
101
- ### `Arinova.login(options?)`
102
- ### `Arinova.handleCallback(params)`
103
- ### `Arinova.user.profile(accessToken)`
104
- ### `Arinova.user.agents(accessToken)`
105
- ### `Arinova.agent.chat(options)`
106
- ### `Arinova.agent.chatStream(options)`
107
- ### `Arinova.economy.charge(options)`
108
- ### `Arinova.economy.award(options)`
109
- ### `Arinova.economy.balance(accessToken)`
package/dist/index.d.ts CHANGED
@@ -1,80 +1,52 @@
1
- import type { ArinovaConfig, LoginOptions, LoginResult, ConnectOptions, ConnectResult, ArinovaUser, AgentInfo, AgentChatOptions, AgentChatResponse, AgentChatStreamOptions, AgentChatStreamResponse, ChargeOptions, ChargeResponse, AwardOptions, AwardResponse, BalanceResponse } from "./types.js";
2
- export type * from "./types.js";
3
- export declare const Arinova: {
4
- /**
5
- * Initialize the SDK with your app configuration.
6
- */
7
- init(config: ArinovaConfig & {
8
- clientId?: string;
9
- clientSecret?: string;
10
- }): void;
11
- /**
12
- * Connect to Arinova works seamlessly in both iframe and standalone contexts.
13
- *
14
- * - **Inside an iframe** (embedded in Arinova Chat): receives auth via postMessage from the parent window.
15
- * - **Outside an iframe** (standalone): falls back to the OAuth login() flow.
16
- *
17
- * @param options.timeout - How long to wait for postMessage in iframe mode (default: 5000ms)
18
- * @returns Promise resolving with user, accessToken, and agents
19
- */
20
- connect(options?: ConnectOptions): Promise<ConnectResult>;
1
+ /**
2
+ * Arinova Spaces SDK
3
+ *
4
+ * Public client OAuth with PKCE — no client_secret needed.
5
+ *
6
+ * Usage:
7
+ * const arinova = new Arinova({ appId: "my-app-abc123" });
8
+ * const token = await arinova.login();
9
+ * // token.access_token is ready to use
10
+ */
11
+ export interface ArinovaConfig {
12
+ /** Your OAuth app's client_id (from Developer Console) */
13
+ appId: string;
14
+ /** Arinova server URL (default: https://chat.arinova.ai) */
15
+ endpoint?: string;
16
+ /** OAuth redirect URI (default: current page origin + /callback) */
17
+ redirectUri?: string;
18
+ /** OAuth scope (default: "profile") */
19
+ scope?: string;
20
+ }
21
+ export interface TokenResponse {
22
+ access_token: string;
23
+ token_type: string;
24
+ expires_in: number;
25
+ scope: string;
26
+ user: {
27
+ id: string;
28
+ name: string;
29
+ email: string;
30
+ image: string | null;
31
+ };
32
+ }
33
+ export declare class Arinova {
34
+ private appId;
35
+ private endpoint;
36
+ private redirectUri;
37
+ private scope;
38
+ constructor(config: ArinovaConfig);
21
39
  /**
22
- * Redirect to Arinova OAuth login page.
23
- * Call this from your game's frontend.
40
+ * Start the OAuth PKCE login flow.
41
+ * Opens a popup window for authorization.
42
+ * Returns the token response on success.
24
43
  */
25
- login(options?: LoginOptions): void;
44
+ login(): Promise<TokenResponse>;
26
45
  /**
27
- * Handle the OAuth callback. Call this on your redirect page.
28
- * Exchanges the authorization code for an access token.
46
+ * Handle the OAuth callback (call this on your redirect_uri page).
47
+ * Reads code and state from URL, exchanges for token.
29
48
  */
30
- handleCallback(params: {
31
- code: string;
32
- clientId: string;
33
- clientSecret: string;
34
- redirectUri: string;
35
- }): Promise<LoginResult>;
36
- user: {
37
- /**
38
- * Get the authenticated user's profile.
39
- */
40
- profile(accessToken: string): Promise<ArinovaUser>;
41
- /**
42
- * Get the authenticated user's agents.
43
- * Requires "agents" scope.
44
- */
45
- agents(accessToken: string): Promise<AgentInfo[]>;
46
- };
47
- agent: {
48
- /**
49
- * Send a prompt to a user's agent and get a complete response.
50
- */
51
- chat(options: AgentChatOptions): Promise<AgentChatResponse>;
52
- /**
53
- * Send a prompt to a user's agent and receive a streaming response via SSE.
54
- */
55
- chatStream(options: AgentChatStreamOptions): Promise<AgentChatStreamResponse>;
56
- };
57
- economy: {
58
- /**
59
- * Charge coins from a user's balance (server-to-server).
60
- * Requires clientId and clientSecret.
61
- */
62
- charge(options: ChargeOptions & {
63
- clientId: string;
64
- clientSecret: string;
65
- }): Promise<ChargeResponse>;
66
- /**
67
- * Award coins to a user (server-to-server).
68
- * Requires clientId and clientSecret.
69
- */
70
- award(options: AwardOptions & {
71
- clientId: string;
72
- clientSecret: string;
73
- }): Promise<AwardResponse>;
74
- /**
75
- * Get a user's coin balance (uses OAuth access token).
76
- */
77
- balance(accessToken: string): Promise<BalanceResponse>;
78
- };
79
- };
80
- //# sourceMappingURL=index.d.ts.map
49
+ handleCallback(): Promise<TokenResponse>;
50
+ private exchangeCode;
51
+ }
52
+ export default Arinova;
package/dist/index.js CHANGED
@@ -1,284 +1,153 @@
1
- let _config = null;
2
- let _oauthClientInfo = null;
3
- function getBaseUrl() {
4
- if (!_config)
5
- throw new Error("Arinova SDK not initialized. Call Arinova.init() first.");
6
- return _config.baseUrl || "https://api.arinova.ai";
7
- }
8
- function getConfig() {
9
- if (!_config)
10
- throw new Error("Arinova SDK not initialized. Call Arinova.init() first.");
11
- return _config;
12
- }
13
- export const Arinova = {
14
- /**
15
- * Initialize the SDK with your app configuration.
16
- */
17
- init(config) {
18
- _config = config;
19
- if (config.clientId && config.clientSecret) {
20
- _oauthClientInfo = { clientId: config.clientId, clientSecret: config.clientSecret };
21
- }
22
- },
1
+ /**
2
+ * Arinova Spaces SDK
3
+ *
4
+ * Public client OAuth with PKCE — no client_secret needed.
5
+ *
6
+ * Usage:
7
+ * const arinova = new Arinova({ appId: "my-app-abc123" });
8
+ * const token = await arinova.login();
9
+ * // token.access_token is ready to use
10
+ */
11
+ export class Arinova {
12
+ constructor(config) {
13
+ this.appId = config.appId;
14
+ this.endpoint = (config.endpoint ?? "https://chat.arinova.ai").replace(/\/+$/, "");
15
+ this.redirectUri =
16
+ config.redirectUri ?? `${window.location.origin}/callback`;
17
+ this.scope = config.scope ?? "profile";
18
+ }
23
19
  /**
24
- * Connect to Arinova works seamlessly in both iframe and standalone contexts.
25
- *
26
- * - **Inside an iframe** (embedded in Arinova Chat): receives auth via postMessage from the parent window.
27
- * - **Outside an iframe** (standalone): falls back to the OAuth login() flow.
28
- *
29
- * @param options.timeout - How long to wait for postMessage in iframe mode (default: 5000ms)
30
- * @returns Promise resolving with user, accessToken, and agents
20
+ * Start the OAuth PKCE login flow.
21
+ * Opens a popup window for authorization.
22
+ * Returns the token response on success.
31
23
  */
32
- async connect(options) {
33
- const timeout = options?.timeout ?? 5000;
34
- const inIframe = typeof window !== "undefined" && window.self !== window.top;
35
- if (!inIframe) {
36
- // Not in iframe — fall back to OAuth login flow
37
- this.login();
38
- // login() redirects, so this promise never resolves in practice
39
- return new Promise(() => { });
40
- }
41
- // In iframe — listen for postMessage auth from parent
24
+ async login() {
25
+ const { verifier, challenge } = await generatePKCE();
26
+ const state = generateRandom(32);
27
+ // Store state + verifier for callback validation
28
+ sessionStorage.setItem("arinova_pkce_verifier", verifier);
29
+ sessionStorage.setItem("arinova_pkce_state", state);
30
+ const params = new URLSearchParams({
31
+ client_id: this.appId,
32
+ redirect_uri: this.redirectUri,
33
+ scope: this.scope,
34
+ state,
35
+ code_challenge: challenge,
36
+ code_challenge_method: "S256",
37
+ response_type: "code",
38
+ });
39
+ const authUrl = `${this.endpoint}/oauth/authorize?${params}`;
42
40
  return new Promise((resolve, reject) => {
43
- let settled = false;
44
- const timer = setTimeout(() => {
45
- if (!settled) {
46
- settled = true;
47
- window.removeEventListener("message", handler);
48
- reject(new Error("Arinova connect timeout: no auth message received from parent window within " + timeout + "ms"));
49
- }
50
- }, timeout);
51
- function handler(event) {
52
- if (event.data?.type !== "arinova:auth")
53
- return;
54
- if (settled)
55
- return;
56
- settled = true;
57
- clearTimeout(timer);
58
- window.removeEventListener("message", handler);
59
- const { user, accessToken, agents } = event.data.payload;
60
- resolve({ user, accessToken, agents: agents ?? [] });
41
+ const popup = window.open(authUrl, "arinova_auth", "width=500,height=600");
42
+ if (!popup) {
43
+ // Fallback: redirect instead of popup
44
+ window.location.href = authUrl;
45
+ reject(new Error("Popup blocked — redirecting instead"));
46
+ return;
61
47
  }
62
- window.addEventListener("message", handler);
63
- });
64
- },
65
- /**
66
- * Redirect to Arinova OAuth login page.
67
- * Call this from your game's frontend.
68
- */
69
- login(options) {
70
- const config = getConfig();
71
- const baseUrl = getBaseUrl();
72
- const scope = options?.scope?.join(" ") || "profile";
73
- // Store current URL for callback
74
- const currentUrl = typeof window !== "undefined" ? window.location.href : "";
75
- const params = new URLSearchParams({
76
- client_id: config.appId,
77
- redirect_uri: currentUrl,
78
- scope,
79
- state: crypto.randomUUID(),
48
+ const interval = setInterval(() => {
49
+ try {
50
+ if (popup.closed) {
51
+ clearInterval(interval);
52
+ reject(new Error("Login cancelled"));
53
+ }
54
+ const popupUrl = popup.location.href;
55
+ if (popupUrl.startsWith(this.redirectUri)) {
56
+ clearInterval(interval);
57
+ popup.close();
58
+ const url = new URL(popupUrl);
59
+ const code = url.searchParams.get("code");
60
+ const returnedState = url.searchParams.get("state");
61
+ if (returnedState !== state) {
62
+ reject(new Error("State mismatch — possible CSRF attack"));
63
+ return;
64
+ }
65
+ if (!code) {
66
+ reject(new Error(url.searchParams.get("error_description") ?? "No code received"));
67
+ return;
68
+ }
69
+ this.exchangeCode(code, verifier).then(resolve).catch(reject);
70
+ }
71
+ }
72
+ catch {
73
+ // Cross-origin — popup is on a different domain, ignore
74
+ }
75
+ }, 200);
76
+ // Timeout after 5 minutes
77
+ setTimeout(() => {
78
+ clearInterval(interval);
79
+ try {
80
+ popup.close();
81
+ }
82
+ catch { /* ignore */ }
83
+ reject(new Error("Login timed out"));
84
+ }, 300000);
80
85
  });
81
- window.location.href = `${baseUrl}/oauth/authorize?${params}`;
82
- },
86
+ }
83
87
  /**
84
- * Handle the OAuth callback. Call this on your redirect page.
85
- * Exchanges the authorization code for an access token.
88
+ * Handle the OAuth callback (call this on your redirect_uri page).
89
+ * Reads code and state from URL, exchanges for token.
86
90
  */
87
- async handleCallback(params) {
88
- const baseUrl = getBaseUrl();
89
- const res = await fetch(`${baseUrl}/oauth/token`, {
91
+ async handleCallback() {
92
+ const url = new URL(window.location.href);
93
+ const code = url.searchParams.get("code");
94
+ const state = url.searchParams.get("state");
95
+ const verifier = sessionStorage.getItem("arinova_pkce_verifier");
96
+ const expectedState = sessionStorage.getItem("arinova_pkce_state");
97
+ sessionStorage.removeItem("arinova_pkce_verifier");
98
+ sessionStorage.removeItem("arinova_pkce_state");
99
+ if (!code) {
100
+ throw new Error(url.searchParams.get("error_description") ?? "No authorization code");
101
+ }
102
+ if (state !== expectedState) {
103
+ throw new Error("State mismatch");
104
+ }
105
+ if (!verifier) {
106
+ throw new Error("No PKCE verifier found — did you start login()?");
107
+ }
108
+ return this.exchangeCode(code, verifier);
109
+ }
110
+ async exchangeCode(code, codeVerifier) {
111
+ const res = await fetch(`${this.endpoint}/oauth/token`, {
90
112
  method: "POST",
91
113
  headers: { "Content-Type": "application/json" },
92
114
  body: JSON.stringify({
93
115
  grant_type: "authorization_code",
94
- code: params.code,
95
- client_id: params.clientId,
96
- client_secret: params.clientSecret,
97
- redirect_uri: params.redirectUri,
116
+ client_id: this.appId,
117
+ code,
118
+ redirect_uri: this.redirectUri,
119
+ code_verifier: codeVerifier,
98
120
  }),
99
121
  });
100
122
  if (!res.ok) {
101
- const error = await res.json().catch(() => ({ error: "token_exchange_failed" }));
102
- throw new Error(error.error || "Token exchange failed");
123
+ const body = await res.json().catch(() => ({}));
124
+ throw new Error(body.error_description ??
125
+ body.error ??
126
+ `Token exchange failed (${res.status})`);
103
127
  }
104
- const data = await res.json();
105
- return {
106
- user: data.user,
107
- accessToken: data.access_token,
108
- };
109
- },
110
- user: {
111
- /**
112
- * Get the authenticated user's profile.
113
- */
114
- async profile(accessToken) {
115
- const baseUrl = getBaseUrl();
116
- const res = await fetch(`${baseUrl}/api/v1/user/profile`, {
117
- headers: { Authorization: `Bearer ${accessToken}` },
118
- });
119
- if (!res.ok)
120
- throw new Error("Failed to get user profile");
121
- return res.json();
122
- },
123
- /**
124
- * Get the authenticated user's agents.
125
- * Requires "agents" scope.
126
- */
127
- async agents(accessToken) {
128
- const baseUrl = getBaseUrl();
129
- const res = await fetch(`${baseUrl}/api/v1/user/agents`, {
130
- headers: { Authorization: `Bearer ${accessToken}` },
131
- });
132
- if (!res.ok)
133
- throw new Error("Failed to get user agents");
134
- const data = await res.json();
135
- return data.agents;
136
- },
137
- },
138
- agent: {
139
- /**
140
- * Send a prompt to a user's agent and get a complete response.
141
- */
142
- async chat(options) {
143
- const baseUrl = getBaseUrl();
144
- const res = await fetch(`${baseUrl}/api/v1/agent/chat`, {
145
- method: "POST",
146
- headers: {
147
- "Content-Type": "application/json",
148
- Authorization: `Bearer ${options.accessToken}`,
149
- },
150
- body: JSON.stringify({
151
- agentId: options.agentId,
152
- prompt: options.prompt,
153
- systemPrompt: options.systemPrompt,
154
- }),
155
- });
156
- if (!res.ok) {
157
- const error = await res.json().catch(() => ({ error: "agent_chat_failed" }));
158
- throw new Error(error.error || "Agent chat failed");
159
- }
160
- return res.json();
161
- },
162
- /**
163
- * Send a prompt to a user's agent and receive a streaming response via SSE.
164
- */
165
- async chatStream(options) {
166
- const baseUrl = getBaseUrl();
167
- const res = await fetch(`${baseUrl}/api/v1/agent/chat/stream`, {
168
- method: "POST",
169
- headers: {
170
- "Content-Type": "application/json",
171
- Authorization: `Bearer ${options.accessToken}`,
172
- },
173
- body: JSON.stringify({
174
- agentId: options.agentId,
175
- prompt: options.prompt,
176
- systemPrompt: options.systemPrompt,
177
- }),
178
- });
179
- if (!res.ok) {
180
- const error = await res.json().catch(() => ({ error: "agent_stream_failed" }));
181
- throw new Error(error.error || "Agent stream failed");
182
- }
183
- const reader = res.body.getReader();
184
- const decoder = new TextDecoder();
185
- let fullContent = "";
186
- let buffer = "";
187
- while (true) {
188
- const { done, value } = await reader.read();
189
- if (done)
190
- break;
191
- buffer += decoder.decode(value, { stream: true });
192
- const lines = buffer.split("\n");
193
- buffer = lines.pop() || "";
194
- for (const line of lines) {
195
- if (!line.startsWith("data: "))
196
- continue;
197
- try {
198
- const event = JSON.parse(line.slice(6));
199
- if (event.type === "chunk") {
200
- options.onChunk(event.content);
201
- fullContent = event.content;
202
- }
203
- else if (event.type === "done") {
204
- fullContent = event.content;
205
- }
206
- else if (event.type === "error") {
207
- throw new Error(event.error);
208
- }
209
- }
210
- catch (e) {
211
- if (e instanceof Error && e.message !== "Unexpected end of JSON input")
212
- throw e;
213
- }
214
- }
215
- }
216
- return { content: fullContent, agentId: options.agentId };
217
- },
218
- },
219
- economy: {
220
- /**
221
- * Charge coins from a user's balance (server-to-server).
222
- * Requires clientId and clientSecret.
223
- */
224
- async charge(options) {
225
- const baseUrl = getBaseUrl();
226
- const res = await fetch(`${baseUrl}/api/v1/economy/charge`, {
227
- method: "POST",
228
- headers: {
229
- "Content-Type": "application/json",
230
- "X-Client-Id": options.clientId,
231
- "X-App-Secret": options.clientSecret,
232
- },
233
- body: JSON.stringify({
234
- userId: options.userId,
235
- amount: options.amount,
236
- description: options.description,
237
- }),
238
- });
239
- if (!res.ok) {
240
- const error = await res.json().catch(() => ({ error: "charge_failed" }));
241
- throw new Error(error.error || "Charge failed");
242
- }
243
- return res.json();
244
- },
245
- /**
246
- * Award coins to a user (server-to-server).
247
- * Requires clientId and clientSecret.
248
- */
249
- async award(options) {
250
- const baseUrl = getBaseUrl();
251
- const res = await fetch(`${baseUrl}/api/v1/economy/award`, {
252
- method: "POST",
253
- headers: {
254
- "Content-Type": "application/json",
255
- "X-Client-Id": options.clientId,
256
- "X-App-Secret": options.clientSecret,
257
- },
258
- body: JSON.stringify({
259
- userId: options.userId,
260
- amount: options.amount,
261
- description: options.description,
262
- }),
263
- });
264
- if (!res.ok) {
265
- const error = await res.json().catch(() => ({ error: "award_failed" }));
266
- throw new Error(error.error || "Award failed");
267
- }
268
- return res.json();
269
- },
270
- /**
271
- * Get a user's coin balance (uses OAuth access token).
272
- */
273
- async balance(accessToken) {
274
- const baseUrl = getBaseUrl();
275
- const res = await fetch(`${baseUrl}/api/v1/economy/balance`, {
276
- headers: { Authorization: `Bearer ${accessToken}` },
277
- });
278
- if (!res.ok)
279
- throw new Error("Failed to get balance");
280
- return res.json();
281
- },
282
- },
283
- };
284
- //# sourceMappingURL=index.js.map
128
+ return res.json();
129
+ }
130
+ }
131
+ // ── PKCE Helpers ──────────────────────────────────────────────
132
+ function generateRandom(length) {
133
+ const array = new Uint8Array(length);
134
+ crypto.getRandomValues(array);
135
+ return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
136
+ }
137
+ async function generatePKCE() {
138
+ const verifier = generateRandom(32); // 64 hex chars
139
+ const encoder = new TextEncoder();
140
+ const data = encoder.encode(verifier);
141
+ const hash = await crypto.subtle.digest("SHA-256", data);
142
+ const challenge = base64UrlEncode(new Uint8Array(hash));
143
+ return { verifier, challenge };
144
+ }
145
+ function base64UrlEncode(buffer) {
146
+ let binary = "";
147
+ for (const byte of buffer) {
148
+ binary += String.fromCharCode(byte);
149
+ }
150
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
151
+ }
152
+ // Default export for convenience
153
+ export default Arinova;
package/dist/types.d.ts CHANGED
@@ -81,4 +81,3 @@ export interface SSEErrorEvent {
81
81
  error: string;
82
82
  }
83
83
  export type SSEEvent = SSEChunkEvent | SSEDoneEvent | SSEErrorEvent;
84
- //# sourceMappingURL=types.d.ts.map
package/dist/types.js CHANGED
@@ -1,2 +1 @@
1
1
  export {};
2
- //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,20 +1,15 @@
1
1
  {
2
2
  "name": "@arinova-ai/spaces-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
+ "description": "Arinova Spaces SDK — OAuth PKCE login for third-party apps",
4
5
  "type": "module",
5
- "main": "./dist/index.js",
6
- "types": "./dist/index.d.ts",
7
- "exports": {
8
- ".": {
9
- "import": "./dist/index.js",
10
- "types": "./dist/index.d.ts"
11
- }
12
- },
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": ["dist"],
13
9
  "scripts": {
14
- "build": "tsc",
15
- "dev": "tsc --watch"
10
+ "build": "tsc"
16
11
  },
17
12
  "devDependencies": {
18
- "typescript": "^5.6.0"
13
+ "typescript": "^5.4.0"
19
14
  }
20
15
  }
@@ -1,4 +0,0 @@
1
-
2
- > @arinova-ai/spaces-sdk@0.0.2 build /Users/ripple/.openclaw/workspace/projects/arinova-chat/packages/spaces-sdk
3
- > tsc
4
-
package/src/index.ts DELETED
@@ -1,336 +0,0 @@
1
- import type {
2
- ArinovaConfig,
3
- LoginOptions,
4
- LoginResult,
5
- ConnectOptions,
6
- ConnectResult,
7
- ArinovaUser,
8
- AgentInfo,
9
- AgentChatOptions,
10
- AgentChatResponse,
11
- AgentChatStreamOptions,
12
- AgentChatStreamResponse,
13
- ChargeOptions,
14
- ChargeResponse,
15
- AwardOptions,
16
- AwardResponse,
17
- BalanceResponse,
18
- SSEEvent,
19
- } from "./types.js";
20
-
21
- export type * from "./types.js";
22
-
23
- let _config: ArinovaConfig | null = null;
24
- let _oauthClientInfo: { clientId: string; clientSecret: string } | null = null;
25
-
26
- function getBaseUrl(): string {
27
- if (!_config) throw new Error("Arinova SDK not initialized. Call Arinova.init() first.");
28
- return _config.baseUrl || "https://api.arinova.ai";
29
- }
30
-
31
- function getConfig(): ArinovaConfig {
32
- if (!_config) throw new Error("Arinova SDK not initialized. Call Arinova.init() first.");
33
- return _config;
34
- }
35
-
36
- export const Arinova = {
37
- /**
38
- * Initialize the SDK with your app configuration.
39
- */
40
- init(config: ArinovaConfig & { clientId?: string; clientSecret?: string }) {
41
- _config = config;
42
- if (config.clientId && config.clientSecret) {
43
- _oauthClientInfo = { clientId: config.clientId, clientSecret: config.clientSecret };
44
- }
45
- },
46
-
47
- /**
48
- * Connect to Arinova — works seamlessly in both iframe and standalone contexts.
49
- *
50
- * - **Inside an iframe** (embedded in Arinova Chat): receives auth via postMessage from the parent window.
51
- * - **Outside an iframe** (standalone): falls back to the OAuth login() flow.
52
- *
53
- * @param options.timeout - How long to wait for postMessage in iframe mode (default: 5000ms)
54
- * @returns Promise resolving with user, accessToken, and agents
55
- */
56
- async connect(options?: ConnectOptions): Promise<ConnectResult> {
57
- const timeout = options?.timeout ?? 5000;
58
-
59
- const inIframe = typeof window !== "undefined" && window.self !== window.top;
60
-
61
- if (!inIframe) {
62
- // Not in iframe — fall back to OAuth login flow
63
- this.login();
64
- // login() redirects, so this promise never resolves in practice
65
- return new Promise(() => {});
66
- }
67
-
68
- // In iframe — listen for postMessage auth from parent
69
- return new Promise<ConnectResult>((resolve, reject) => {
70
- let settled = false;
71
-
72
- const timer = setTimeout(() => {
73
- if (!settled) {
74
- settled = true;
75
- window.removeEventListener("message", handler);
76
- reject(new Error("Arinova connect timeout: no auth message received from parent window within " + timeout + "ms"));
77
- }
78
- }, timeout);
79
-
80
- function handler(event: MessageEvent) {
81
- if (event.data?.type !== "arinova:auth") return;
82
- if (settled) return;
83
- settled = true;
84
- clearTimeout(timer);
85
- window.removeEventListener("message", handler);
86
-
87
- const { user, accessToken, agents } = event.data.payload as ConnectResult;
88
- resolve({ user, accessToken, agents: agents ?? [] });
89
- }
90
-
91
- window.addEventListener("message", handler);
92
- });
93
- },
94
-
95
- /**
96
- * Redirect to Arinova OAuth login page.
97
- * Call this from your game's frontend.
98
- */
99
- login(options?: LoginOptions): void {
100
- const config = getConfig();
101
- const baseUrl = getBaseUrl();
102
- const scope = options?.scope?.join(" ") || "profile";
103
-
104
- // Store current URL for callback
105
- const currentUrl = typeof window !== "undefined" ? window.location.href : "";
106
-
107
- const params = new URLSearchParams({
108
- client_id: config.appId,
109
- redirect_uri: currentUrl,
110
- scope,
111
- state: crypto.randomUUID(),
112
- });
113
-
114
- window.location.href = `${baseUrl}/oauth/authorize?${params}`;
115
- },
116
-
117
- /**
118
- * Handle the OAuth callback. Call this on your redirect page.
119
- * Exchanges the authorization code for an access token.
120
- */
121
- async handleCallback(params: {
122
- code: string;
123
- clientId: string;
124
- clientSecret: string;
125
- redirectUri: string;
126
- }): Promise<LoginResult> {
127
- const baseUrl = getBaseUrl();
128
-
129
- const res = await fetch(`${baseUrl}/oauth/token`, {
130
- method: "POST",
131
- headers: { "Content-Type": "application/json" },
132
- body: JSON.stringify({
133
- grant_type: "authorization_code",
134
- code: params.code,
135
- client_id: params.clientId,
136
- client_secret: params.clientSecret,
137
- redirect_uri: params.redirectUri,
138
- }),
139
- });
140
-
141
- if (!res.ok) {
142
- const error = await res.json().catch(() => ({ error: "token_exchange_failed" }));
143
- throw new Error(error.error || "Token exchange failed");
144
- }
145
-
146
- const data = await res.json();
147
- return {
148
- user: data.user,
149
- accessToken: data.access_token,
150
- };
151
- },
152
-
153
- user: {
154
- /**
155
- * Get the authenticated user's profile.
156
- */
157
- async profile(accessToken: string): Promise<ArinovaUser> {
158
- const baseUrl = getBaseUrl();
159
- const res = await fetch(`${baseUrl}/api/v1/user/profile`, {
160
- headers: { Authorization: `Bearer ${accessToken}` },
161
- });
162
- if (!res.ok) throw new Error("Failed to get user profile");
163
- return res.json();
164
- },
165
-
166
- /**
167
- * Get the authenticated user's agents.
168
- * Requires "agents" scope.
169
- */
170
- async agents(accessToken: string): Promise<AgentInfo[]> {
171
- const baseUrl = getBaseUrl();
172
- const res = await fetch(`${baseUrl}/api/v1/user/agents`, {
173
- headers: { Authorization: `Bearer ${accessToken}` },
174
- });
175
- if (!res.ok) throw new Error("Failed to get user agents");
176
- const data = await res.json();
177
- return data.agents;
178
- },
179
- },
180
-
181
- agent: {
182
- /**
183
- * Send a prompt to a user's agent and get a complete response.
184
- */
185
- async chat(options: AgentChatOptions): Promise<AgentChatResponse> {
186
- const baseUrl = getBaseUrl();
187
- const res = await fetch(`${baseUrl}/api/v1/agent/chat`, {
188
- method: "POST",
189
- headers: {
190
- "Content-Type": "application/json",
191
- Authorization: `Bearer ${options.accessToken}`,
192
- },
193
- body: JSON.stringify({
194
- agentId: options.agentId,
195
- prompt: options.prompt,
196
- systemPrompt: options.systemPrompt,
197
- }),
198
- });
199
-
200
- if (!res.ok) {
201
- const error = await res.json().catch(() => ({ error: "agent_chat_failed" }));
202
- throw new Error(error.error || "Agent chat failed");
203
- }
204
-
205
- return res.json();
206
- },
207
-
208
- /**
209
- * Send a prompt to a user's agent and receive a streaming response via SSE.
210
- */
211
- async chatStream(options: AgentChatStreamOptions): Promise<AgentChatStreamResponse> {
212
- const baseUrl = getBaseUrl();
213
- const res = await fetch(`${baseUrl}/api/v1/agent/chat/stream`, {
214
- method: "POST",
215
- headers: {
216
- "Content-Type": "application/json",
217
- Authorization: `Bearer ${options.accessToken}`,
218
- },
219
- body: JSON.stringify({
220
- agentId: options.agentId,
221
- prompt: options.prompt,
222
- systemPrompt: options.systemPrompt,
223
- }),
224
- });
225
-
226
- if (!res.ok) {
227
- const error = await res.json().catch(() => ({ error: "agent_stream_failed" }));
228
- throw new Error(error.error || "Agent stream failed");
229
- }
230
-
231
- const reader = res.body!.getReader();
232
- const decoder = new TextDecoder();
233
- let fullContent = "";
234
- let buffer = "";
235
-
236
- while (true) {
237
- const { done, value } = await reader.read();
238
- if (done) break;
239
-
240
- buffer += decoder.decode(value, { stream: true });
241
- const lines = buffer.split("\n");
242
- buffer = lines.pop() || "";
243
-
244
- for (const line of lines) {
245
- if (!line.startsWith("data: ")) continue;
246
- try {
247
- const event: SSEEvent = JSON.parse(line.slice(6));
248
- if (event.type === "chunk") {
249
- options.onChunk(event.content);
250
- fullContent = event.content;
251
- } else if (event.type === "done") {
252
- fullContent = event.content;
253
- } else if (event.type === "error") {
254
- throw new Error(event.error);
255
- }
256
- } catch (e) {
257
- if (e instanceof Error && e.message !== "Unexpected end of JSON input") throw e;
258
- }
259
- }
260
- }
261
-
262
- return { content: fullContent, agentId: options.agentId };
263
- },
264
- },
265
-
266
- economy: {
267
- /**
268
- * Charge coins from a user's balance (server-to-server).
269
- * Requires clientId and clientSecret.
270
- */
271
- async charge(options: ChargeOptions & { clientId: string; clientSecret: string }): Promise<ChargeResponse> {
272
- const baseUrl = getBaseUrl();
273
- const res = await fetch(`${baseUrl}/api/v1/economy/charge`, {
274
- method: "POST",
275
- headers: {
276
- "Content-Type": "application/json",
277
- "X-Client-Id": options.clientId,
278
- "X-App-Secret": options.clientSecret,
279
- },
280
- body: JSON.stringify({
281
- userId: options.userId,
282
- amount: options.amount,
283
- description: options.description,
284
- }),
285
- });
286
-
287
- if (!res.ok) {
288
- const error = await res.json().catch(() => ({ error: "charge_failed" }));
289
- throw new Error(error.error || "Charge failed");
290
- }
291
-
292
- return res.json();
293
- },
294
-
295
- /**
296
- * Award coins to a user (server-to-server).
297
- * Requires clientId and clientSecret.
298
- */
299
- async award(options: AwardOptions & { clientId: string; clientSecret: string }): Promise<AwardResponse> {
300
- const baseUrl = getBaseUrl();
301
- const res = await fetch(`${baseUrl}/api/v1/economy/award`, {
302
- method: "POST",
303
- headers: {
304
- "Content-Type": "application/json",
305
- "X-Client-Id": options.clientId,
306
- "X-App-Secret": options.clientSecret,
307
- },
308
- body: JSON.stringify({
309
- userId: options.userId,
310
- amount: options.amount,
311
- description: options.description,
312
- }),
313
- });
314
-
315
- if (!res.ok) {
316
- const error = await res.json().catch(() => ({ error: "award_failed" }));
317
- throw new Error(error.error || "Award failed");
318
- }
319
-
320
- return res.json();
321
- },
322
-
323
- /**
324
- * Get a user's coin balance (uses OAuth access token).
325
- */
326
- async balance(accessToken: string): Promise<BalanceResponse> {
327
- const baseUrl = getBaseUrl();
328
- const res = await fetch(`${baseUrl}/api/v1/economy/balance`, {
329
- headers: { Authorization: `Bearer ${accessToken}` },
330
- });
331
-
332
- if (!res.ok) throw new Error("Failed to get balance");
333
- return res.json();
334
- },
335
- },
336
- };
package/src/types.ts DELETED
@@ -1,107 +0,0 @@
1
- // ===== Config =====
2
- export interface ArinovaConfig {
3
- appId: string;
4
- baseUrl?: string; // defaults to "https://api.arinova.ai"
5
- }
6
-
7
- // ===== Auth =====
8
- export interface LoginOptions {
9
- scope?: string[]; // defaults to ["profile"]
10
- }
11
-
12
- export interface LoginResult {
13
- user: ArinovaUser;
14
- accessToken: string;
15
- }
16
-
17
- export interface ConnectOptions {
18
- timeout?: number; // milliseconds, defaults to 5000
19
- }
20
-
21
- export interface ConnectResult {
22
- user: ArinovaUser;
23
- accessToken: string;
24
- agents: AgentInfo[];
25
- }
26
-
27
- export interface ArinovaUser {
28
- id: string;
29
- name: string;
30
- email: string;
31
- image: string | null;
32
- }
33
-
34
- // ===== Agent =====
35
- export interface AgentInfo {
36
- id: string;
37
- name: string;
38
- description: string | null;
39
- avatarUrl: string | null;
40
- }
41
-
42
- export interface AgentChatOptions {
43
- agentId: string;
44
- prompt: string;
45
- systemPrompt?: string;
46
- accessToken: string;
47
- }
48
-
49
- export interface AgentChatResponse {
50
- response: string;
51
- agentId: string;
52
- }
53
-
54
- export interface AgentChatStreamOptions extends AgentChatOptions {
55
- onChunk: (chunk: string) => void;
56
- }
57
-
58
- export interface AgentChatStreamResponse {
59
- content: string;
60
- agentId: string;
61
- }
62
-
63
- // ===== Economy =====
64
- export interface ChargeOptions {
65
- userId: string;
66
- amount: number;
67
- description?: string;
68
- }
69
-
70
- export interface ChargeResponse {
71
- transactionId: string;
72
- newBalance: number;
73
- }
74
-
75
- export interface AwardOptions {
76
- userId: string;
77
- amount: number;
78
- description?: string;
79
- }
80
-
81
- export interface AwardResponse {
82
- transactionId: string;
83
- newBalance: number;
84
- platformFee: number;
85
- }
86
-
87
- export interface BalanceResponse {
88
- balance: number;
89
- }
90
-
91
- // ===== SSE Event =====
92
- export interface SSEChunkEvent {
93
- type: "chunk";
94
- content: string;
95
- }
96
-
97
- export interface SSEDoneEvent {
98
- type: "done";
99
- content: string;
100
- }
101
-
102
- export interface SSEErrorEvent {
103
- type: "error";
104
- error: string;
105
- }
106
-
107
- export type SSEEvent = SSEChunkEvent | SSEDoneEvent | SSEErrorEvent;
package/tsconfig.json DELETED
@@ -1,19 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "lib": ["ES2022", "DOM"],
7
- "outDir": "./dist",
8
- "rootDir": "./src",
9
- "declaration": true,
10
- "declarationMap": true,
11
- "sourceMap": true,
12
- "strict": true,
13
- "esModuleInterop": true,
14
- "skipLibCheck": true,
15
- "forceConsistentCasingInFileNames": true
16
- },
17
- "include": ["src/**/*"],
18
- "exclude": ["node_modules", "dist"]
19
- }