@open-loyalty/mcp-server 1.0.0 → 1.0.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,6 +1,6 @@
1
1
  # Open Loyalty MCP Server
2
2
 
3
- MCP (Model Context Protocol) server for interacting with Open Loyalty API. This server enables AI agents like Claude to manage loyalty programs, members, points, rewards, and transactions.
3
+ MCP (Model Context Protocol) server for interacting with Open Loyalty API. This server enables AI agents like Claude and ChatGPT to manage loyalty programs, members, points, rewards, and transactions.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -12,20 +12,20 @@ MCP (Model Context Protocol) server for interacting with Open Loyalty API. This
12
12
  ### Via npm (Recommended)
13
13
 
14
14
  ```bash
15
- npm install -g @openloyalty/mcp-server
15
+ npm install -g @open-loyalty/mcp-server
16
16
  ```
17
17
 
18
18
  Or use directly with npx (no installation required):
19
19
 
20
20
  ```bash
21
- npx @openloyalty/mcp-server
21
+ npx @open-loyalty/mcp-server
22
22
  ```
23
23
 
24
24
  ### From Source
25
25
 
26
26
  ```bash
27
- git clone https://github.com/openloyalty/mcp-server.git
28
- cd mcp-server
27
+ git clone https://github.com/OpenLoyalty/openloyalty-mcp.git
28
+ cd openloyalty-mcp/openloyalty-mcp
29
29
  npm install
30
30
  npm run build
31
31
  ```
@@ -46,20 +46,37 @@ For local development, create a `.env` file based on `.env.example`:
46
46
  cp .env.example .env
47
47
  ```
48
48
 
49
- ## Claude Desktop Configuration
49
+ ## Transport Modes
50
+
51
+ The server supports two transport modes for different use cases:
52
+
53
+ | Mode | Binary | Use Case |
54
+ |------|--------|----------|
55
+ | **stdio** | `openloyalty-mcp` | Local usage with Claude Desktop, Claude Code, Cursor |
56
+ | **HTTP** | `openloyalty-mcp-http` | Remote hosting, ChatGPT Actions, web integrations |
57
+
58
+ Both modes provide identical functionality - only the transport layer differs.
59
+
60
+ ---
61
+
62
+ ## Stdio Mode (Local)
63
+
64
+ Use stdio mode when running the server locally with MCP clients like Claude Desktop.
65
+
66
+ ### Claude Desktop Configuration
50
67
 
51
68
  Add this to your Claude Desktop configuration file:
52
69
  - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
53
70
  - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
54
71
 
55
- ### Using npx (Recommended)
72
+ #### Using npx (Recommended)
56
73
 
57
74
  ```json
58
75
  {
59
76
  "mcpServers": {
60
77
  "openloyalty": {
61
78
  "command": "npx",
62
- "args": ["-y", "@openloyalty/mcp-server"],
79
+ "args": ["-y", "@open-loyalty/mcp-server"],
63
80
  "env": {
64
81
  "OPENLOYALTY_API_URL": "https://your-instance.openloyalty.io",
65
82
  "OPENLOYALTY_API_TOKEN": "your-api-token",
@@ -70,7 +87,7 @@ Add this to your Claude Desktop configuration file:
70
87
  }
71
88
  ```
72
89
 
73
- ### Using Global Installation
90
+ #### Using Global Installation
74
91
 
75
92
  ```json
76
93
  {
@@ -87,14 +104,14 @@ Add this to your Claude Desktop configuration file:
87
104
  }
88
105
  ```
89
106
 
90
- ### Using Local Build
107
+ #### Using Local Build
91
108
 
92
109
  ```json
93
110
  {
94
111
  "mcpServers": {
95
112
  "openloyalty": {
96
113
  "command": "node",
97
- "args": ["/path/to/mcp-server/dist/index.js"],
114
+ "args": ["/path/to/openloyalty-mcp/dist/index.js"],
98
115
  "env": {
99
116
  "OPENLOYALTY_API_URL": "https://your-instance.openloyalty.io",
100
117
  "OPENLOYALTY_API_TOKEN": "your-api-token",
@@ -105,12 +122,105 @@ Add this to your Claude Desktop configuration file:
105
122
  }
106
123
  ```
107
124
 
125
+ ---
126
+
127
+ ## HTTP Mode (Remote Hosting)
128
+
129
+ Use HTTP mode when hosting the server remotely for web-based MCP clients or ChatGPT Actions.
130
+
131
+ ### Running HTTP Server
132
+
133
+ ```bash
134
+ # Using the binary
135
+ openloyalty-mcp-http
136
+
137
+ # Using npm scripts
138
+ npm run start:http
139
+
140
+ # Development mode
141
+ npm run dev:http
142
+ ```
143
+
144
+ ### HTTP Environment Variables
145
+
146
+ | Variable | Required | Description |
147
+ |----------|----------|-------------|
148
+ | `OPENLOYALTY_API_URL` | Yes* | Open Loyalty API URL |
149
+ | `OPENLOYALTY_API_TOKEN` | Yes* | API authentication token |
150
+ | `OPENLOYALTY_DEFAULT_STORE_CODE` | Yes* | Default store code |
151
+ | `PORT` or `MCP_HTTP_PORT` | No | Server port (default: 3000) |
152
+ | `OAUTH_ENABLED` | No | Enable multi-tenant OAuth mode |
153
+ | `BASE_URL` | OAuth | Public URL for OAuth callbacks |
154
+ | `REDIS_URL` | OAuth | Redis URL for token storage |
155
+
156
+ *Required when `OAUTH_ENABLED` is not set or `false`
157
+
158
+ ### HTTP Endpoints
159
+
160
+ | Endpoint | Method | Description |
161
+ |----------|--------|-------------|
162
+ | `/` | GET | Server info |
163
+ | `/health` | GET | Health check |
164
+ | `/mcp` | POST | MCP message endpoint |
165
+ | `/mcp` | GET | SSE stream (with session) |
166
+ | `/mcp` | DELETE | Close session |
167
+
168
+ ### Single-Tenant Mode
169
+
170
+ For dedicated deployments with fixed credentials:
171
+
172
+ ```bash
173
+ export OPENLOYALTY_API_URL="https://api.openloyalty.io"
174
+ export OPENLOYALTY_API_TOKEN="your-token"
175
+ export OPENLOYALTY_DEFAULT_STORE_CODE="default"
176
+ openloyalty-mcp-http
177
+ ```
178
+
179
+ ### Multi-Tenant OAuth Mode
180
+
181
+ For shared deployments where each user provides their own credentials:
182
+
183
+ ```bash
184
+ export OAUTH_ENABLED=true
185
+ export BASE_URL="https://your-server.com"
186
+ export REDIS_URL="redis://localhost:6379"
187
+ openloyalty-mcp-http
188
+ ```
189
+
190
+ With OAuth enabled, additional endpoints are available:
191
+
192
+ | Endpoint | Description |
193
+ |----------|-------------|
194
+ | `/authorize` | OAuth authorization form |
195
+ | `/token` | Token exchange |
196
+ | `/register` | Dynamic client registration |
197
+ | `/.well-known/oauth-authorization-server` | OAuth metadata |
198
+
199
+ ### ChatGPT Actions Integration
200
+
201
+ To use with ChatGPT:
202
+
203
+ 1. Host the HTTP server with OAuth enabled
204
+ 2. In GPT Editor → Configure → Actions:
205
+ - Authentication: OAuth
206
+ - Authorization URL: `https://your-server.com/authorize`
207
+ - Token URL: `https://your-server.com/token`
208
+ - Scope: `mcp`
209
+ 3. Add your MCP endpoint: `https://your-server.com/mcp`
210
+
211
+ Users will be prompted to enter their Open Loyalty credentials during the OAuth flow.
212
+
213
+ ---
214
+
108
215
  ## Development
109
216
 
110
217
  ```bash
111
- # Run in development mode
218
+ # Run stdio server in development mode
112
219
  npm run dev
113
220
 
221
+ # Run HTTP server in development mode
222
+ npm run dev:http
223
+
114
224
  # Build for production
115
225
  npm run build
116
226
 
@@ -121,6 +231,8 @@ npm test
121
231
  npm run typecheck
122
232
  ```
123
233
 
234
+ ---
235
+
124
236
  ## Available Tools (112 total)
125
237
 
126
238
  ### Wallet Types (2 tools)
@@ -273,6 +385,8 @@ npm run typecheck
273
385
  - `openloyalty_export_get` - Get export status and details
274
386
  - `openloyalty_export_download` - Download export CSV (when status='done')
275
387
 
388
+ ---
389
+
276
390
  ## Example Workflows
277
391
 
278
392
  ### 1. Create 3-Tier Loyalty Program
@@ -649,6 +763,14 @@ openloyalty_import_create({
649
763
  openloyalty_import_get({ importId: "..." })
650
764
  ```
651
765
 
766
+ ---
767
+
768
+ ## Links
769
+
770
+ - **GitHub**: [OpenLoyalty/openloyalty-mcp](https://github.com/OpenLoyalty/openloyalty-mcp)
771
+ - **Open Loyalty**: [openloyalty.io](https://openloyalty.io)
772
+ - **MCP Protocol**: [modelcontextprotocol.io](https://modelcontextprotocol.io)
773
+
652
774
  ## License
653
775
 
654
776
  MIT
@@ -0,0 +1,33 @@
1
+ import type { OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js";
2
+ /**
3
+ * Open Loyalty API credentials stored per-client
4
+ */
5
+ export interface OpenLoyaltyConfig {
6
+ apiUrl: string;
7
+ apiToken: string;
8
+ storeCode: string;
9
+ }
10
+ /**
11
+ * Creates the OAuth server provider
12
+ */
13
+ export declare function createOAuthProvider(issuerUrl: string): OAuthServerProvider;
14
+ /**
15
+ * Completes authorization after form submission
16
+ */
17
+ export declare function completeAuthorization(sessionId: string, config: OpenLoyaltyConfig): Promise<{
18
+ redirectUrl: string;
19
+ } | {
20
+ error: string;
21
+ }>;
22
+ /**
23
+ * Gets the Open Loyalty config for a client
24
+ */
25
+ export declare function getClientConfig(clientId: string): Promise<OpenLoyaltyConfig | undefined>;
26
+ /**
27
+ * Validates Open Loyalty credentials
28
+ * Uses the member list endpoint to validate both API token and store code
29
+ */
30
+ export declare function validateOpenLoyaltyCredentials(config: OpenLoyaltyConfig): Promise<{
31
+ valid: boolean;
32
+ error?: string;
33
+ }>;
@@ -0,0 +1,395 @@
1
+ import crypto from "crypto";
2
+ import { getStorage, KEYS } from "./storage.js";
3
+ // Expiration times
4
+ const AUTH_CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
5
+ const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
6
+ const CLIENT_TTL_MS = 365 * 24 * 60 * 60 * 1000; // 1 year
7
+ const CONFIG_TTL_MS = 365 * 24 * 60 * 60 * 1000; // 1 year
8
+ /**
9
+ * Storage-backed OAuth clients store
10
+ */
11
+ class StorageClientsStore {
12
+ async getClient(clientId) {
13
+ const storage = getStorage();
14
+ const client = await storage.get(KEYS.client(clientId));
15
+ return client ?? undefined;
16
+ }
17
+ async registerClient(client) {
18
+ const storage = getStorage();
19
+ const clientId = crypto.randomBytes(16).toString("hex");
20
+ const clientIdIssuedAt = Math.floor(Date.now() / 1000);
21
+ const fullClient = {
22
+ ...client,
23
+ client_id: clientId,
24
+ client_id_issued_at: clientIdIssuedAt,
25
+ };
26
+ await storage.set(KEYS.client(clientId), fullClient, CLIENT_TTL_MS);
27
+ return fullClient;
28
+ }
29
+ }
30
+ /**
31
+ * Creates the OAuth server provider
32
+ */
33
+ export function createOAuthProvider(issuerUrl) {
34
+ const clientsStore = new StorageClientsStore();
35
+ return {
36
+ get clientsStore() {
37
+ return clientsStore;
38
+ },
39
+ /**
40
+ * Handles authorization by showing a configuration form
41
+ */
42
+ async authorize(client, params, res) {
43
+ const storage = getStorage();
44
+ // Generate session ID to track this authorization flow
45
+ const sessionId = crypto.randomBytes(16).toString("hex");
46
+ // Store the pending authorization
47
+ const sessionData = {
48
+ clientId: client.client_id,
49
+ redirectUri: params.redirectUri,
50
+ codeChallenge: params.codeChallenge,
51
+ state: params.state,
52
+ scope: params.scopes?.join(" "),
53
+ expiresAt: Date.now() + AUTH_CODE_TTL_MS,
54
+ };
55
+ await storage.set(KEYS.session(sessionId), sessionData, AUTH_CODE_TTL_MS);
56
+ // Render the configuration form
57
+ const html = renderAuthorizationForm({
58
+ sessionId,
59
+ state: params.state,
60
+ clientName: client.client_name || "ChatGPT",
61
+ issuerUrl,
62
+ });
63
+ res.setHeader("Content-Type", "text/html");
64
+ res.send(html);
65
+ },
66
+ /**
67
+ * Returns the code challenge for a given authorization code
68
+ */
69
+ async challengeForAuthorizationCode(_client, authorizationCode) {
70
+ const storage = getStorage();
71
+ const codeData = await storage.get(KEYS.authCode(authorizationCode));
72
+ if (!codeData || codeData.expiresAt < Date.now()) {
73
+ await storage.delete(KEYS.authCode(authorizationCode));
74
+ throw new Error("Authorization code not found or expired");
75
+ }
76
+ return codeData.codeChallenge;
77
+ },
78
+ /**
79
+ * Exchanges authorization code for tokens
80
+ */
81
+ async exchangeAuthorizationCode(client, authorizationCode) {
82
+ const storage = getStorage();
83
+ const codeData = await storage.get(KEYS.authCode(authorizationCode));
84
+ if (!codeData || codeData.expiresAt < Date.now()) {
85
+ await storage.delete(KEYS.authCode(authorizationCode));
86
+ throw new Error("Authorization code not found or expired");
87
+ }
88
+ if (codeData.clientId !== client.client_id) {
89
+ throw new Error("Authorization code was not issued to this client");
90
+ }
91
+ // Delete the code (one-time use)
92
+ await storage.delete(KEYS.authCode(authorizationCode));
93
+ // Store the client config if provided
94
+ if (codeData.pendingConfig) {
95
+ await storage.set(KEYS.config(client.client_id), codeData.pendingConfig, CONFIG_TTL_MS);
96
+ }
97
+ // Generate access token
98
+ const accessToken = crypto.randomBytes(32).toString("hex");
99
+ const expiresAt = Date.now() + ACCESS_TOKEN_TTL_MS;
100
+ const tokenData = {
101
+ clientId: client.client_id,
102
+ scope: codeData.scope,
103
+ expiresAt,
104
+ };
105
+ await storage.set(KEYS.token(accessToken), tokenData, ACCESS_TOKEN_TTL_MS);
106
+ return {
107
+ access_token: accessToken,
108
+ token_type: "Bearer",
109
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
110
+ scope: codeData.scope,
111
+ };
112
+ },
113
+ /**
114
+ * Exchanges refresh token (not supported)
115
+ */
116
+ async exchangeRefreshToken() {
117
+ throw new Error("Refresh tokens are not supported");
118
+ },
119
+ /**
120
+ * Verifies an access token
121
+ */
122
+ async verifyAccessToken(token) {
123
+ const storage = getStorage();
124
+ const tokenData = await storage.get(KEYS.token(token));
125
+ if (!tokenData) {
126
+ throw new Error("Invalid access token");
127
+ }
128
+ if (tokenData.expiresAt < Date.now()) {
129
+ await storage.delete(KEYS.token(token));
130
+ throw new Error("Access token has expired");
131
+ }
132
+ return {
133
+ token,
134
+ clientId: tokenData.clientId,
135
+ scopes: tokenData.scope ? tokenData.scope.split(" ") : [],
136
+ expiresAt: Math.floor(tokenData.expiresAt / 1000),
137
+ };
138
+ },
139
+ };
140
+ }
141
+ /**
142
+ * Completes authorization after form submission
143
+ */
144
+ export async function completeAuthorization(sessionId, config) {
145
+ const storage = getStorage();
146
+ const sessionData = await storage.get(KEYS.session(sessionId));
147
+ if (!sessionData || sessionData.expiresAt < Date.now()) {
148
+ await storage.delete(KEYS.session(sessionId));
149
+ return { error: "Session expired. Please start the authorization process again." };
150
+ }
151
+ // Delete session
152
+ await storage.delete(KEYS.session(sessionId));
153
+ // Generate authorization code
154
+ const authorizationCode = crypto.randomBytes(32).toString("hex");
155
+ // Store with pending config
156
+ const codeData = {
157
+ ...sessionData,
158
+ pendingConfig: config,
159
+ expiresAt: Date.now() + AUTH_CODE_TTL_MS,
160
+ };
161
+ await storage.set(KEYS.authCode(authorizationCode), codeData, AUTH_CODE_TTL_MS);
162
+ // Build redirect URL
163
+ const redirectUrl = new URL(sessionData.redirectUri);
164
+ redirectUrl.searchParams.set("code", authorizationCode);
165
+ if (sessionData.state) {
166
+ redirectUrl.searchParams.set("state", sessionData.state);
167
+ }
168
+ return { redirectUrl: redirectUrl.toString() };
169
+ }
170
+ /**
171
+ * Gets the Open Loyalty config for a client
172
+ */
173
+ export async function getClientConfig(clientId) {
174
+ const storage = getStorage();
175
+ const config = await storage.get(KEYS.config(clientId));
176
+ return config ?? undefined;
177
+ }
178
+ /**
179
+ * Validates Open Loyalty credentials
180
+ * Uses the member list endpoint to validate both API token and store code
181
+ */
182
+ export async function validateOpenLoyaltyCredentials(config) {
183
+ try {
184
+ // Use member list endpoint with limit=1 to validate credentials
185
+ // This validates both the API token and the store code existence
186
+ const response = await fetch(`${config.apiUrl}/${config.storeCode}/member?_itemsOnPage=1`, {
187
+ method: "GET",
188
+ headers: {
189
+ "Content-Type": "application/json",
190
+ "X-AUTH-TOKEN": config.apiToken,
191
+ },
192
+ });
193
+ if (response.status === 401) {
194
+ return { valid: false, error: "Invalid API token" };
195
+ }
196
+ if (response.status === 403) {
197
+ return { valid: false, error: "API token does not have required permissions" };
198
+ }
199
+ if (response.status === 404) {
200
+ return { valid: false, error: "Store code not found or invalid API URL" };
201
+ }
202
+ if (!response.ok) {
203
+ return { valid: false, error: `API returned status ${response.status}` };
204
+ }
205
+ return { valid: true };
206
+ }
207
+ catch (error) {
208
+ const message = error instanceof Error ? error.message : "Unknown error";
209
+ return { valid: false, error: `Failed to connect: ${message}` };
210
+ }
211
+ }
212
+ /**
213
+ * Renders the authorization form HTML
214
+ */
215
+ function renderAuthorizationForm(params) {
216
+ const { sessionId, state, clientName, issuerUrl } = params;
217
+ return `<!DOCTYPE html>
218
+ <html lang="en">
219
+ <head>
220
+ <meta charset="UTF-8">
221
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
222
+ <title>Connect to Open Loyalty</title>
223
+ <style>
224
+ * { box-sizing: border-box; }
225
+ body {
226
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
227
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
228
+ min-height: 100vh;
229
+ margin: 0;
230
+ padding: 20px;
231
+ display: flex;
232
+ justify-content: center;
233
+ align-items: center;
234
+ }
235
+ .container {
236
+ background: white;
237
+ border-radius: 16px;
238
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
239
+ padding: 40px;
240
+ width: 100%;
241
+ max-width: 420px;
242
+ }
243
+ h1 {
244
+ color: #1a1a2e;
245
+ font-size: 24px;
246
+ text-align: center;
247
+ margin: 0 0 8px 0;
248
+ }
249
+ .subtitle {
250
+ color: #6b7280;
251
+ text-align: center;
252
+ font-size: 14px;
253
+ margin-bottom: 32px;
254
+ }
255
+ .client-name { color: #667eea; font-weight: 500; }
256
+ .form-group { margin-bottom: 20px; }
257
+ label {
258
+ display: block;
259
+ color: #374151;
260
+ font-size: 14px;
261
+ font-weight: 500;
262
+ margin-bottom: 6px;
263
+ }
264
+ input {
265
+ width: 100%;
266
+ padding: 12px 16px;
267
+ border: 2px solid #e5e7eb;
268
+ border-radius: 8px;
269
+ font-size: 14px;
270
+ }
271
+ input:focus {
272
+ outline: none;
273
+ border-color: #667eea;
274
+ }
275
+ .help-text {
276
+ color: #6b7280;
277
+ font-size: 12px;
278
+ margin-top: 4px;
279
+ }
280
+ button {
281
+ width: 100%;
282
+ padding: 14px 24px;
283
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
284
+ color: white;
285
+ border: none;
286
+ border-radius: 8px;
287
+ font-size: 16px;
288
+ font-weight: 600;
289
+ cursor: pointer;
290
+ }
291
+ button:hover { opacity: 0.9; }
292
+ button:disabled { opacity: 0.7; cursor: not-allowed; }
293
+ .error {
294
+ background: #fef2f2;
295
+ border: 1px solid #fecaca;
296
+ color: #dc2626;
297
+ padding: 12px;
298
+ border-radius: 8px;
299
+ margin-bottom: 20px;
300
+ display: none;
301
+ }
302
+ .error.visible { display: block; }
303
+ </style>
304
+ </head>
305
+ <body>
306
+ <div class="container">
307
+ <h1>Connect to Open Loyalty</h1>
308
+ <p class="subtitle">
309
+ <span class="client-name">${escapeHtml(clientName)}</span> wants to access your Open Loyalty account
310
+ </p>
311
+
312
+ <div id="error" class="error"></div>
313
+
314
+ <form id="authForm">
315
+ <input type="hidden" name="session_id" value="${escapeHtml(sessionId)}">
316
+ ${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ""}
317
+
318
+ <div class="form-group">
319
+ <label for="apiUrl">API URL</label>
320
+ <input type="url" id="apiUrl" name="api_url" placeholder="https://api.openloyalty.io" required>
321
+ <p class="help-text">Your Open Loyalty API endpoint</p>
322
+ </div>
323
+
324
+ <div class="form-group">
325
+ <label for="apiToken">API Token</label>
326
+ <input type="password" id="apiToken" name="api_token" required>
327
+ <p class="help-text">From your Open Loyalty admin panel</p>
328
+ </div>
329
+
330
+ <div class="form-group">
331
+ <label for="storeCode">Store Code</label>
332
+ <input type="text" id="storeCode" name="store_code" value="default" required>
333
+ <p class="help-text">Usually "default"</p>
334
+ </div>
335
+
336
+ <button type="submit" id="submitBtn">Connect Account</button>
337
+ </form>
338
+ </div>
339
+
340
+ <script>
341
+ const form = document.getElementById('authForm');
342
+ const errorEl = document.getElementById('error');
343
+ const submitBtn = document.getElementById('submitBtn');
344
+
345
+ form.addEventListener('submit', async (e) => {
346
+ e.preventDefault();
347
+ errorEl.classList.remove('visible');
348
+ submitBtn.disabled = true;
349
+ submitBtn.textContent = 'Connecting...';
350
+
351
+ try {
352
+ const formData = new FormData(form);
353
+ const response = await fetch('${issuerUrl}/authorize/submit', {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({
357
+ session_id: formData.get('session_id'),
358
+ state: formData.get('state'),
359
+ api_url: formData.get('api_url'),
360
+ api_token: formData.get('api_token'),
361
+ store_code: formData.get('store_code'),
362
+ }),
363
+ });
364
+
365
+ const result = await response.json();
366
+
367
+ if (result.redirect_url) {
368
+ window.location.href = result.redirect_url;
369
+ } else if (result.error) {
370
+ errorEl.textContent = result.error;
371
+ errorEl.classList.add('visible');
372
+ submitBtn.disabled = false;
373
+ submitBtn.textContent = 'Connect Account';
374
+ }
375
+ } catch (err) {
376
+ errorEl.textContent = 'Connection failed. Please try again.';
377
+ errorEl.classList.add('visible');
378
+ submitBtn.disabled = false;
379
+ submitBtn.textContent = 'Connect Account';
380
+ }
381
+ });
382
+ </script>
383
+ </body>
384
+ </html>`;
385
+ }
386
+ function escapeHtml(text) {
387
+ const escapes = {
388
+ "&": "&amp;",
389
+ "<": "&lt;",
390
+ ">": "&gt;",
391
+ '"': "&quot;",
392
+ "'": "&#39;",
393
+ };
394
+ return text.replace(/[&<>"']/g, (c) => escapes[c]);
395
+ }
@@ -0,0 +1,16 @@
1
+ export interface StorageBackend {
2
+ get<T>(key: string): Promise<T | null>;
3
+ set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
4
+ delete(key: string): Promise<void>;
5
+ }
6
+ /**
7
+ * Get the storage backend (Redis if available, otherwise in-memory)
8
+ */
9
+ export declare function getStorage(): StorageBackend;
10
+ export declare const KEYS: {
11
+ client: (id: string) => string;
12
+ authCode: (code: string) => string;
13
+ session: (id: string) => string;
14
+ token: (token: string) => string;
15
+ config: (clientId: string) => string;
16
+ };