@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 +134 -12
- package/dist/auth/provider.d.ts +33 -0
- package/dist/auth/provider.js +395 -0
- package/dist/auth/storage.d.ts +16 -0
- package/dist/auth/storage.js +98 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +22 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +214 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/package.json +11 -10
- package/dist/tools/member.test.d.ts +0 -1
- package/dist/tools/member.test.js +0 -213
- package/dist/tools/points.test.d.ts +0 -1
- package/dist/tools/points.test.js +0 -292
- package/dist/tools/reward.test.d.ts +0 -1
- package/dist/tools/reward.test.js +0 -240
- package/dist/tools/transaction.test.d.ts +0 -1
- package/dist/tools/transaction.test.js +0 -235
- package/dist/utils/cursor.d.ts +0 -84
- package/dist/utils/cursor.js +0 -117
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 @
|
|
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 @
|
|
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
|
|
28
|
-
cd mcp-
|
|
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
|
-
##
|
|
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
|
-
|
|
72
|
+
#### Using npx (Recommended)
|
|
56
73
|
|
|
57
74
|
```json
|
|
58
75
|
{
|
|
59
76
|
"mcpServers": {
|
|
60
77
|
"openloyalty": {
|
|
61
78
|
"command": "npx",
|
|
62
|
-
"args": ["-y", "@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
"&": "&",
|
|
389
|
+
"<": "<",
|
|
390
|
+
">": ">",
|
|
391
|
+
'"': """,
|
|
392
|
+
"'": "'",
|
|
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
|
+
};
|