@open-loyalty/mcp-server 1.0.3 → 1.3.1
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 +180 -177
- package/dist/auth/provider.js +2 -14
- package/dist/auth/storage.js +22 -0
- package/dist/client/http.d.ts +5 -0
- package/dist/client/http.js +62 -3
- package/dist/config.d.ts +6 -5
- package/dist/config.js +15 -11
- package/dist/http.js +170 -65
- package/dist/instructions.d.ts +5 -0
- package/dist/instructions.js +420 -0
- package/dist/prompts/fan-engagement-setup.d.ts +107 -0
- package/dist/prompts/fan-engagement-setup.js +492 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +68 -278
- package/dist/tools/achievement/handlers.d.ts +117 -0
- package/dist/tools/achievement/handlers.js +161 -0
- package/dist/tools/achievement/index.d.ts +479 -0
- package/dist/tools/achievement/index.js +74 -0
- package/dist/tools/achievement/schemas.d.ts +433 -0
- package/dist/tools/achievement/schemas.js +142 -0
- package/dist/tools/achievement.d.ts +155 -121
- package/dist/tools/achievement.js +82 -39
- package/dist/tools/admin.d.ts +18 -6
- package/dist/tools/admin.js +24 -12
- package/dist/tools/analytics.d.ts +29 -11
- package/dist/tools/analytics.js +58 -48
- package/dist/tools/apikey.d.ts +10 -3
- package/dist/tools/apikey.js +13 -6
- package/dist/tools/audit.d.ts +6 -2
- package/dist/tools/audit.js +8 -4
- package/dist/tools/badge.d.ts +14 -6
- package/dist/tools/badge.js +36 -27
- package/dist/tools/campaign/handlers.d.ts +42 -0
- package/dist/tools/campaign/handlers.js +223 -0
- package/dist/tools/campaign/index.d.ts +783 -0
- package/dist/tools/campaign/index.js +112 -0
- package/dist/tools/campaign/member-handlers.d.ts +60 -0
- package/dist/tools/campaign/member-handlers.js +159 -0
- package/dist/tools/campaign/schemas.d.ts +704 -0
- package/dist/tools/campaign/schemas.js +259 -0
- package/dist/tools/campaign/types.d.ts +161 -0
- package/dist/tools/campaign/types.js +2 -0
- package/dist/tools/campaign.d.ts +41 -16
- package/dist/tools/campaign.js +38 -25
- package/dist/tools/custom-event.d.ts +315 -0
- package/dist/tools/custom-event.js +270 -0
- package/dist/tools/export.d.ts +12 -4
- package/dist/tools/export.js +25 -20
- package/dist/tools/import.d.ts +9 -3
- package/dist/tools/import.js +33 -21
- package/dist/tools/index.d.ts +3 -11
- package/dist/tools/index.js +17 -475
- package/dist/tools/member/handlers.d.ts +111 -0
- package/dist/tools/member/handlers.js +206 -0
- package/dist/tools/member/index.d.ts +169 -0
- package/dist/tools/member/index.js +92 -0
- package/dist/tools/member/schemas.d.ts +89 -0
- package/dist/tools/member/schemas.js +65 -0
- package/dist/tools/member.d.ts +21 -0
- package/dist/tools/member.js +56 -62
- package/dist/tools/points.d.ts +19 -6
- package/dist/tools/points.js +51 -49
- package/dist/tools/referral/handlers.d.ts +47 -0
- package/dist/tools/referral/handlers.js +115 -0
- package/dist/tools/referral/index.d.ts +44 -0
- package/dist/tools/referral/index.js +44 -0
- package/dist/tools/referral/schemas.d.ts +34 -0
- package/dist/tools/referral/schemas.js +52 -0
- package/dist/tools/reward/handlers.d.ts +110 -0
- package/dist/tools/reward/handlers.js +289 -0
- package/dist/tools/reward/index.d.ts +177 -0
- package/dist/tools/reward/index.js +90 -0
- package/dist/tools/reward/schemas.d.ts +116 -0
- package/dist/tools/reward/schemas.js +91 -0
- package/dist/tools/reward.d.ts +18 -0
- package/dist/tools/reward.js +56 -66
- package/dist/tools/role.d.ts +26 -7
- package/dist/tools/role.js +25 -12
- package/dist/tools/segment/handlers.d.ts +87 -0
- package/dist/tools/segment/handlers.js +174 -0
- package/dist/tools/segment/index.d.ts +395 -0
- package/dist/tools/segment/index.js +87 -0
- package/dist/tools/segment/schemas.d.ts +337 -0
- package/dist/tools/segment/schemas.js +79 -0
- package/dist/tools/segment.d.ts +29 -10
- package/dist/tools/segment.js +84 -50
- package/dist/tools/store.d.ts +12 -4
- package/dist/tools/store.js +16 -8
- package/dist/tools/tierset.d.ts +19 -7
- package/dist/tools/tierset.js +44 -35
- package/dist/tools/transaction.d.ts +16 -8
- package/dist/tools/transaction.js +25 -21
- package/dist/tools/wallet-type.d.ts +7 -3
- package/dist/tools/wallet-type.js +14 -12
- package/dist/tools/webhook.d.ts +23 -10
- package/dist/tools/webhook.js +135 -33
- package/dist/types/schemas/achievement.d.ts +12 -309
- package/dist/types/schemas/achievement.js +0 -13
- package/dist/types/schemas/admin.d.ts +10 -97
- package/dist/types/schemas/admin.js +0 -38
- package/dist/types/schemas/badge.d.ts +0 -37
- package/dist/types/schemas/badge.js +0 -11
- package/dist/types/schemas/campaign.d.ts +64 -832
- package/dist/types/schemas/campaign.js +2 -25
- package/dist/types/schemas/common.d.ts +5 -0
- package/dist/types/schemas/common.js +5 -0
- package/dist/types/schemas/export.d.ts +0 -17
- package/dist/types/schemas/export.js +0 -7
- package/dist/types/schemas/member.d.ts +37 -176
- package/dist/types/schemas/member.js +0 -27
- package/dist/types/schemas/points.d.ts +0 -63
- package/dist/types/schemas/points.js +0 -22
- package/dist/types/schemas/reward.d.ts +71 -68
- package/dist/types/schemas/reward.js +8 -28
- package/dist/types/schemas/role.d.ts +0 -100
- package/dist/types/schemas/role.js +0 -29
- package/dist/types/schemas/segment.d.ts +0 -58
- package/dist/types/schemas/segment.js +0 -17
- package/dist/types/schemas/tierset.d.ts +0 -176
- package/dist/types/schemas/tierset.js +0 -27
- package/dist/types/schemas/transaction.d.ts +23 -254
- package/dist/types/schemas/transaction.js +0 -7
- package/dist/types/schemas/wallet-type.d.ts +8 -8
- package/dist/types/schemas/wallet-type.js +1 -1
- package/dist/types/schemas/webhook.d.ts +0 -58
- package/dist/types/schemas/webhook.js +0 -12
- package/dist/utils/errors.js +30 -3
- package/dist/utils/payload.d.ts +12 -0
- package/dist/utils/payload.js +14 -0
- package/dist/workflows/app-login-streak.d.ts +39 -0
- package/dist/workflows/app-login-streak.js +298 -0
- package/dist/workflows/early-arrival.d.ts +33 -0
- package/dist/workflows/early-arrival.js +148 -0
- package/dist/workflows/index.d.ts +101 -0
- package/dist/workflows/index.js +208 -0
- package/dist/workflows/match-attendance.d.ts +45 -0
- package/dist/workflows/match-attendance.js +308 -0
- package/dist/workflows/sportsbar-visit.d.ts +41 -0
- package/dist/workflows/sportsbar-visit.js +284 -0
- package/dist/workflows/vod-watching.d.ts +43 -0
- package/dist/workflows/vod-watching.js +326 -0
- package/package.json +10 -2
package/dist/auth/provider.js
CHANGED
|
@@ -272,11 +272,7 @@ function renderAuthorizationForm(params) {
|
|
|
272
272
|
outline: none;
|
|
273
273
|
border-color: #667eea;
|
|
274
274
|
}
|
|
275
|
-
.help-text {
|
|
276
|
-
color: #6b7280;
|
|
277
|
-
font-size: 12px;
|
|
278
|
-
margin-top: 4px;
|
|
279
|
-
}
|
|
275
|
+
.help-text { color: #6b7280; font-size: 12px; margin-top: 4px; }
|
|
280
276
|
button {
|
|
281
277
|
width: 100%;
|
|
282
278
|
padding: 14px 24px;
|
|
@@ -290,15 +286,7 @@ function renderAuthorizationForm(params) {
|
|
|
290
286
|
}
|
|
291
287
|
button:hover { opacity: 0.9; }
|
|
292
288
|
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
|
-
}
|
|
289
|
+
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 12px; border-radius: 8px; margin-bottom: 20px; display: none; }
|
|
302
290
|
.error.visible { display: block; }
|
|
303
291
|
</style>
|
|
304
292
|
</head>
|
package/dist/auth/storage.js
CHANGED
|
@@ -5,9 +5,24 @@
|
|
|
5
5
|
import { Redis } from "ioredis";
|
|
6
6
|
/**
|
|
7
7
|
* In-memory storage for local development
|
|
8
|
+
* Includes periodic cleanup to prevent memory leaks from expired entries
|
|
8
9
|
*/
|
|
9
10
|
class InMemoryStorage {
|
|
10
11
|
data = new Map();
|
|
12
|
+
cleanupInterval;
|
|
13
|
+
constructor() {
|
|
14
|
+
// Periodic cleanup every 5 minutes to remove expired entries
|
|
15
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
|
16
|
+
this.cleanupInterval.unref(); // Don't prevent process exit
|
|
17
|
+
}
|
|
18
|
+
cleanup() {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
for (const [key, entry] of this.data) {
|
|
21
|
+
if (entry.expiresAt && entry.expiresAt < now) {
|
|
22
|
+
this.data.delete(key);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
11
26
|
async get(key) {
|
|
12
27
|
const entry = this.data.get(key);
|
|
13
28
|
if (!entry)
|
|
@@ -27,6 +42,13 @@ class InMemoryStorage {
|
|
|
27
42
|
async delete(key) {
|
|
28
43
|
this.data.delete(key);
|
|
29
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Close the storage and clean up resources
|
|
47
|
+
*/
|
|
48
|
+
close() {
|
|
49
|
+
clearInterval(this.cleanupInterval);
|
|
50
|
+
this.data.clear();
|
|
51
|
+
}
|
|
30
52
|
}
|
|
31
53
|
/**
|
|
32
54
|
* Redis storage for production
|
package/dist/client/http.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { AxiosInstance } from "axios";
|
|
2
|
+
/**
|
|
3
|
+
* Redacts sensitive fields from data before logging.
|
|
4
|
+
* Replaces values of sensitive fields with "[REDACTED]".
|
|
5
|
+
*/
|
|
6
|
+
export declare function redactSensitiveData(data: unknown, depth?: number): unknown;
|
|
2
7
|
export declare function resetHttpClient(): void;
|
|
3
8
|
export declare function getAxiosInstance(): AxiosInstance;
|
|
4
9
|
export declare function apiGet<T>(url: string): Promise<T>;
|
package/dist/client/http.js
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
+
import https from "https";
|
|
2
3
|
import { getConfig } from "../config.js";
|
|
4
|
+
// Connection pool for HTTP keep-alive (50-80% latency improvement)
|
|
5
|
+
const httpsAgent = new https.Agent({
|
|
6
|
+
keepAlive: true,
|
|
7
|
+
maxSockets: 50,
|
|
8
|
+
maxFreeSockets: 10,
|
|
9
|
+
timeout: 60000,
|
|
10
|
+
keepAliveMsecs: 30000,
|
|
11
|
+
});
|
|
12
|
+
// Fields that may contain PII and should be redacted in logs
|
|
13
|
+
const SENSITIVE_FIELDS = new Set([
|
|
14
|
+
"email",
|
|
15
|
+
"phone",
|
|
16
|
+
"password",
|
|
17
|
+
"token",
|
|
18
|
+
"apiToken",
|
|
19
|
+
"apiKey",
|
|
20
|
+
"secret",
|
|
21
|
+
"address",
|
|
22
|
+
"street",
|
|
23
|
+
"city",
|
|
24
|
+
"postalCode",
|
|
25
|
+
"loyaltyCardNumber",
|
|
26
|
+
"firstName",
|
|
27
|
+
"lastName",
|
|
28
|
+
"birthDate",
|
|
29
|
+
"gender",
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Redacts sensitive fields from data before logging.
|
|
33
|
+
* Replaces values of sensitive fields with "[REDACTED]".
|
|
34
|
+
*/
|
|
35
|
+
export function redactSensitiveData(data, depth = 0) {
|
|
36
|
+
// Prevent infinite recursion on deeply nested objects
|
|
37
|
+
if (depth > 10)
|
|
38
|
+
return "[MAX_DEPTH]";
|
|
39
|
+
if (data === null || data === undefined)
|
|
40
|
+
return data;
|
|
41
|
+
if (Array.isArray(data)) {
|
|
42
|
+
return data.map((item) => redactSensitiveData(item, depth + 1));
|
|
43
|
+
}
|
|
44
|
+
if (typeof data === "object") {
|
|
45
|
+
const redacted = {};
|
|
46
|
+
for (const [key, value] of Object.entries(data)) {
|
|
47
|
+
if (SENSITIVE_FIELDS.has(key)) {
|
|
48
|
+
redacted[key] = "[REDACTED]";
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
redacted[key] = redactSensitiveData(value, depth + 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return redacted;
|
|
55
|
+
}
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
3
58
|
let client = null;
|
|
4
59
|
// For testing: reset the client singleton
|
|
5
60
|
export function resetHttpClient() {
|
|
@@ -13,16 +68,20 @@ function getClient() {
|
|
|
13
68
|
if (client) {
|
|
14
69
|
return client;
|
|
15
70
|
}
|
|
16
|
-
|
|
71
|
+
// Create client without baseURL - it will be set dynamically per request
|
|
72
|
+
// to support multi-tenant OAuth mode where each user has different API URLs
|
|
17
73
|
client = axios.create({
|
|
18
|
-
baseURL: config.apiUrl,
|
|
19
74
|
timeout: 30000,
|
|
75
|
+
httpsAgent,
|
|
20
76
|
headers: {
|
|
21
77
|
"Content-Type": "application/json",
|
|
22
78
|
},
|
|
23
79
|
});
|
|
24
80
|
client.interceptors.request.use((requestConfig) => {
|
|
81
|
+
// Get current config (supports request-scoped overrides in OAuth mode)
|
|
25
82
|
const cfg = getConfig();
|
|
83
|
+
// Set baseURL dynamically from current config (supports multi-tenant OAuth)
|
|
84
|
+
requestConfig.baseURL = cfg.apiUrl;
|
|
26
85
|
requestConfig.headers.set("X-AUTH-TOKEN", cfg.apiToken);
|
|
27
86
|
return requestConfig;
|
|
28
87
|
}, (error) => {
|
|
@@ -33,7 +92,7 @@ function getClient() {
|
|
|
33
92
|
if (error.response) {
|
|
34
93
|
const status = error.response.status;
|
|
35
94
|
const data = error.response.data;
|
|
36
|
-
console.error(`API Error [${status}]:`, JSON.stringify(data, null, 2));
|
|
95
|
+
console.error(`API Error [${status}]:`, JSON.stringify(redactSensitiveData(data), null, 2));
|
|
37
96
|
if (status === 401) {
|
|
38
97
|
throw new Error("Authentication failed (401): Invalid or expired API token. " +
|
|
39
98
|
"Please check your OPENLOYALTY_API_TOKEN environment variable.");
|
package/dist/config.d.ts
CHANGED
|
@@ -14,16 +14,17 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
14
14
|
}>;
|
|
15
15
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Runs a function with a request-scoped config override (OAuth mode).
|
|
18
|
+
* This is thread-safe - concurrent requests each have their own isolated config.
|
|
18
19
|
*/
|
|
19
|
-
export declare function
|
|
20
|
+
export declare function runWithConfig<T>(override: {
|
|
20
21
|
apiUrl: string;
|
|
21
22
|
apiToken: string;
|
|
22
23
|
storeCode: string;
|
|
23
|
-
}):
|
|
24
|
+
}, fn: () => T | Promise<T>): T | Promise<T>;
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
+
* Gets the store code, falling back to default from config if not provided.
|
|
26
27
|
*/
|
|
27
|
-
export declare function
|
|
28
|
+
export declare function getStoreCode(storeCode?: string): string;
|
|
28
29
|
export declare function getConfig(): Config;
|
|
29
30
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -1,32 +1,36 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
3
|
const ConfigSchema = z.object({
|
|
3
4
|
apiUrl: z.string().url(),
|
|
4
5
|
apiToken: z.string().min(1),
|
|
5
6
|
defaultStoreCode: z.string().min(1),
|
|
6
7
|
});
|
|
7
8
|
let config = null;
|
|
8
|
-
//
|
|
9
|
-
|
|
9
|
+
// Request-scoped config storage using AsyncLocalStorage (thread-safe for concurrent requests)
|
|
10
|
+
const configStorage = new AsyncLocalStorage();
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
+
* Runs a function with a request-scoped config override (OAuth mode).
|
|
13
|
+
* This is thread-safe - concurrent requests each have their own isolated config.
|
|
12
14
|
*/
|
|
13
|
-
export function
|
|
14
|
-
|
|
15
|
+
export function runWithConfig(override, fn) {
|
|
16
|
+
const requestConfig = {
|
|
15
17
|
apiUrl: override.apiUrl,
|
|
16
18
|
apiToken: override.apiToken,
|
|
17
19
|
defaultStoreCode: override.storeCode,
|
|
18
20
|
};
|
|
21
|
+
return configStorage.run(requestConfig, fn);
|
|
19
22
|
}
|
|
20
23
|
/**
|
|
21
|
-
*
|
|
24
|
+
* Gets the store code, falling back to default from config if not provided.
|
|
22
25
|
*/
|
|
23
|
-
export function
|
|
24
|
-
|
|
26
|
+
export function getStoreCode(storeCode) {
|
|
27
|
+
return storeCode || getConfig().defaultStoreCode;
|
|
25
28
|
}
|
|
26
29
|
export function getConfig() {
|
|
27
|
-
// Return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
// Return request-scoped config if set (OAuth mode)
|
|
31
|
+
const requestConfig = configStorage.getStore();
|
|
32
|
+
if (requestConfig) {
|
|
33
|
+
return requestConfig;
|
|
30
34
|
}
|
|
31
35
|
if (config) {
|
|
32
36
|
return config;
|
package/dist/http.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import "dotenv/config";
|
|
3
3
|
import express from "express";
|
|
4
4
|
import cors from "cors";
|
|
5
|
+
import helmet from "helmet";
|
|
6
|
+
import rateLimit from "express-rate-limit";
|
|
5
7
|
import { randomUUID } from "crypto";
|
|
6
8
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
9
|
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
8
10
|
import { createServer, SERVER_INSTRUCTIONS } from "./server.js";
|
|
9
|
-
import { getConfig,
|
|
11
|
+
import { getConfig, runWithConfig } from "./config.js";
|
|
10
12
|
import { createOAuthProvider, completeAuthorization, validateOpenLoyaltyCredentials, getClientConfig, } from "./auth/provider.js";
|
|
11
13
|
// Check if OAuth mode is enabled
|
|
12
14
|
const OAUTH_ENABLED = process.env.OAUTH_ENABLED === "true";
|
|
@@ -22,16 +24,100 @@ if (!OAUTH_ENABLED) {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
const app = express();
|
|
25
|
-
// CORS for
|
|
27
|
+
// CORS configuration - defaults to "*" for MCP clients, configurable for enterprise
|
|
28
|
+
const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
|
|
26
29
|
app.use(cors({
|
|
27
|
-
origin:
|
|
30
|
+
origin: CORS_ORIGIN,
|
|
28
31
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
29
32
|
allowedHeaders: ["Content-Type", "Authorization", "MCP-Session-Id", "MCP-Protocol-Version"],
|
|
30
33
|
exposedHeaders: ["MCP-Session-Id"],
|
|
31
34
|
}));
|
|
32
|
-
|
|
35
|
+
// Security headers
|
|
36
|
+
app.use(helmet({
|
|
37
|
+
contentSecurityPolicy: {
|
|
38
|
+
directives: {
|
|
39
|
+
defaultSrc: ["'self'"],
|
|
40
|
+
scriptSrc: ["'self'"],
|
|
41
|
+
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for OAuth form
|
|
42
|
+
imgSrc: ["'self'", "data:"],
|
|
43
|
+
connectSrc: ["'self'"],
|
|
44
|
+
fontSrc: ["'self'"],
|
|
45
|
+
objectSrc: ["'none'"],
|
|
46
|
+
frameAncestors: ["'none'"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
crossOriginEmbedderPolicy: false, // Disable for CORS compatibility
|
|
50
|
+
crossOriginResourcePolicy: { policy: "cross-origin" }, // Allow cross-origin for API
|
|
51
|
+
}));
|
|
52
|
+
// Rate limiting - global limit
|
|
53
|
+
const globalLimiter = rateLimit({
|
|
54
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
55
|
+
max: 100, // 100 requests per window
|
|
56
|
+
standardHeaders: true,
|
|
57
|
+
legacyHeaders: false,
|
|
58
|
+
message: { error: "Too many requests, please try again later." },
|
|
59
|
+
});
|
|
60
|
+
// Stricter rate limiting for auth endpoints (brute-force protection)
|
|
61
|
+
const authLimiter = rateLimit({
|
|
62
|
+
windowMs: 60 * 1000, // 1 minute
|
|
63
|
+
max: 10, // 10 requests per minute
|
|
64
|
+
standardHeaders: true,
|
|
65
|
+
legacyHeaders: false,
|
|
66
|
+
message: { error: "Too many authentication attempts, please try again later." },
|
|
67
|
+
});
|
|
68
|
+
app.use(globalLimiter);
|
|
69
|
+
// Body size limit to prevent DoS via large payloads (10MB generous for CSV imports)
|
|
70
|
+
const BODY_LIMIT = process.env.BODY_LIMIT || "10mb";
|
|
71
|
+
app.use(express.json({ limit: BODY_LIMIT }));
|
|
33
72
|
// Store transports by session ID for stateful connections
|
|
34
73
|
const transports = new Map();
|
|
74
|
+
// Session TTL management to prevent memory leaks
|
|
75
|
+
const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MS || String(30 * 60 * 1000), 10); // Default: 30 minutes
|
|
76
|
+
const SESSION_CLEANUP_INTERVAL_MS = parseInt(process.env.SESSION_CLEANUP_INTERVAL_MS || String(60 * 1000), 10); // Default: 1 minute
|
|
77
|
+
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10000", 10); // Default: 10,000 sessions
|
|
78
|
+
const sessionLastActivity = new Map();
|
|
79
|
+
/**
|
|
80
|
+
* Evict oldest sessions when limit is reached to prevent memory exhaustion
|
|
81
|
+
*/
|
|
82
|
+
function evictOldestSessions() {
|
|
83
|
+
if (transports.size < MAX_SESSIONS)
|
|
84
|
+
return;
|
|
85
|
+
// Evict oldest 10% of sessions
|
|
86
|
+
const evictCount = Math.max(1, Math.floor(MAX_SESSIONS * 0.1));
|
|
87
|
+
const sortedSessions = [...sessionLastActivity.entries()]
|
|
88
|
+
.sort((a, b) => a[1] - b[1])
|
|
89
|
+
.slice(0, evictCount);
|
|
90
|
+
for (const [sessionId] of sortedSessions) {
|
|
91
|
+
const transport = transports.get(sessionId);
|
|
92
|
+
if (transport) {
|
|
93
|
+
try {
|
|
94
|
+
transport.close();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Ignore close errors during eviction
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
transports.delete(sessionId);
|
|
101
|
+
sessionLastActivity.delete(sessionId);
|
|
102
|
+
}
|
|
103
|
+
console.warn(`Session limit reached (${MAX_SESSIONS}). Evicted ${sortedSessions.length} oldest sessions.`);
|
|
104
|
+
}
|
|
105
|
+
// Periodic cleanup of abandoned sessions
|
|
106
|
+
const cleanupInterval = setInterval(() => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
for (const [sessionId, lastActivity] of sessionLastActivity) {
|
|
109
|
+
if (now - lastActivity > SESSION_TTL_MS) {
|
|
110
|
+
const transport = transports.get(sessionId);
|
|
111
|
+
if (transport) {
|
|
112
|
+
transport.close();
|
|
113
|
+
transports.delete(sessionId);
|
|
114
|
+
}
|
|
115
|
+
sessionLastActivity.delete(sessionId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
119
|
+
// Prevent cleanup interval from keeping the process alive
|
|
120
|
+
cleanupInterval.unref();
|
|
35
121
|
// Health check endpoint
|
|
36
122
|
app.get("/health", (_req, res) => {
|
|
37
123
|
res.json({ status: "ok", server: "openloyalty-mcp", oauth: OAUTH_ENABLED });
|
|
@@ -39,6 +125,10 @@ app.get("/health", (_req, res) => {
|
|
|
39
125
|
// OAuth mode setup
|
|
40
126
|
if (OAUTH_ENABLED) {
|
|
41
127
|
const provider = createOAuthProvider(BASE_URL);
|
|
128
|
+
// Apply stricter rate limiting to auth endpoints
|
|
129
|
+
app.use("/authorize", authLimiter);
|
|
130
|
+
app.use("/token", authLimiter);
|
|
131
|
+
app.use("/register", authLimiter);
|
|
42
132
|
// Add MCP SDK auth router (handles /.well-known/*, /authorize, /token, /register)
|
|
43
133
|
app.use(mcpAuthRouter({
|
|
44
134
|
provider,
|
|
@@ -46,7 +136,7 @@ if (OAUTH_ENABLED) {
|
|
|
46
136
|
baseUrl: new URL(BASE_URL),
|
|
47
137
|
serviceDocumentationUrl: new URL("https://github.com/OpenLoyalty/openloyalty-mcp"),
|
|
48
138
|
}));
|
|
49
|
-
// Authorization form submission endpoint
|
|
139
|
+
// Authorization form submission endpoint (also rate limited via /authorize prefix)
|
|
50
140
|
app.post("/authorize/submit", async (req, res) => {
|
|
51
141
|
const { session_id, api_url, api_token, store_code } = req.body;
|
|
52
142
|
if (!session_id || !api_url || !api_token || !store_code) {
|
|
@@ -88,9 +178,9 @@ if (OAUTH_ENABLED) {
|
|
|
88
178
|
res.status(401).json({ error: "Open Loyalty not configured. Please re-authorize." });
|
|
89
179
|
return;
|
|
90
180
|
}
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
181
|
+
// Store config on request for use with runWithConfig() in handler
|
|
182
|
+
// This is thread-safe because each request has its own req object
|
|
183
|
+
req.oauthConfig = config;
|
|
94
184
|
req.clientId = authInfo.clientId;
|
|
95
185
|
next();
|
|
96
186
|
}
|
|
@@ -103,73 +193,88 @@ if (OAUTH_ENABLED) {
|
|
|
103
193
|
// Apply auth middleware to /mcp
|
|
104
194
|
app.use("/mcp", authMiddleware);
|
|
105
195
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
196
|
+
// Helper to handle MCP request processing
|
|
197
|
+
async function handleMcpRequest(req, res) {
|
|
108
198
|
const sessionId = req.headers["mcp-session-id"];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
res.status(400).json({ error: "Invalid or missing session ID for SSE stream" });
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const transport = transports.get(sessionId);
|
|
117
|
-
await transport.handleRequest(req, res);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
// Handle DELETE requests for session cleanup
|
|
121
|
-
if (req.method === "DELETE") {
|
|
122
|
-
if (sessionId && transports.has(sessionId)) {
|
|
123
|
-
const transport = transports.get(sessionId);
|
|
124
|
-
await transport.close();
|
|
125
|
-
transports.delete(sessionId);
|
|
126
|
-
res.status(204).send();
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
res.status(404).json({ error: "Session not found" });
|
|
130
|
-
}
|
|
199
|
+
// Handle GET requests for SSE streams
|
|
200
|
+
if (req.method === "GET") {
|
|
201
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
202
|
+
res.status(400).json({ error: "Invalid or missing session ID for SSE stream" });
|
|
131
203
|
return;
|
|
132
204
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// Create and connect server
|
|
143
|
-
const server = createServer();
|
|
144
|
-
await server.connect(transport);
|
|
145
|
-
// Store transport for future requests
|
|
146
|
-
transports.set(newSessionId, transport);
|
|
147
|
-
// Clean up on close
|
|
148
|
-
transport.onclose = () => {
|
|
149
|
-
transports.delete(newSessionId);
|
|
150
|
-
};
|
|
151
|
-
// Handle the request
|
|
152
|
-
await transport.handleRequest(req, res, req.body);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
// Existing session - route to stored transport
|
|
205
|
+
const transport = transports.get(sessionId);
|
|
206
|
+
// Update last activity for TTL tracking
|
|
207
|
+
sessionLastActivity.set(sessionId, Date.now());
|
|
208
|
+
await transport.handleRequest(req, res);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Handle DELETE requests for session cleanup
|
|
212
|
+
if (req.method === "DELETE") {
|
|
213
|
+
if (sessionId && transports.has(sessionId)) {
|
|
156
214
|
const transport = transports.get(sessionId);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
215
|
+
await transport.close();
|
|
216
|
+
transports.delete(sessionId);
|
|
217
|
+
sessionLastActivity.delete(sessionId);
|
|
218
|
+
res.status(204).send();
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
res.status(404).json({ error: "Session not found" });
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// Handle POST requests
|
|
226
|
+
if (req.method === "POST") {
|
|
227
|
+
// Check if this is an initialization request (no session ID)
|
|
228
|
+
if (!sessionId) {
|
|
229
|
+
// Evict oldest sessions if limit reached (DoS protection)
|
|
230
|
+
evictOldestSessions();
|
|
231
|
+
// Create new session
|
|
232
|
+
const newSessionId = randomUUID();
|
|
233
|
+
const transport = new StreamableHTTPServerTransport({
|
|
234
|
+
sessionIdGenerator: () => newSessionId,
|
|
235
|
+
});
|
|
236
|
+
// Create and connect server
|
|
237
|
+
const server = createServer();
|
|
238
|
+
await server.connect(transport);
|
|
239
|
+
// Store transport for future requests
|
|
240
|
+
transports.set(newSessionId, transport);
|
|
241
|
+
sessionLastActivity.set(newSessionId, Date.now());
|
|
242
|
+
// Clean up on close
|
|
243
|
+
transport.onclose = () => {
|
|
244
|
+
transports.delete(newSessionId);
|
|
245
|
+
sessionLastActivity.delete(newSessionId);
|
|
246
|
+
};
|
|
247
|
+
// Handle the request
|
|
161
248
|
await transport.handleRequest(req, res, req.body);
|
|
162
249
|
return;
|
|
163
250
|
}
|
|
164
|
-
//
|
|
165
|
-
|
|
251
|
+
// Existing session - route to stored transport
|
|
252
|
+
const transport = transports.get(sessionId);
|
|
253
|
+
if (!transport) {
|
|
254
|
+
res.status(404).json({ error: "Session not found. Initialize a new session first." });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Update last activity for TTL tracking
|
|
258
|
+
sessionLastActivity.set(sessionId, Date.now());
|
|
259
|
+
await transport.handleRequest(req, res, req.body);
|
|
260
|
+
return;
|
|
166
261
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
262
|
+
// Unsupported method
|
|
263
|
+
res.status(405).json({ error: "Method not allowed" });
|
|
264
|
+
}
|
|
265
|
+
// MCP endpoint - handles both initialization and messages
|
|
266
|
+
app.all("/mcp", async (req, res) => {
|
|
267
|
+
// In OAuth mode, wrap request handling with runWithConfig for thread-safe config
|
|
268
|
+
if (OAUTH_ENABLED) {
|
|
269
|
+
const oauthConfig = req.oauthConfig;
|
|
270
|
+
if (oauthConfig) {
|
|
271
|
+
// Use runWithConfig for thread-safe, request-scoped config
|
|
272
|
+
await runWithConfig(oauthConfig, () => handleMcpRequest(req, res));
|
|
273
|
+
return;
|
|
171
274
|
}
|
|
172
275
|
}
|
|
276
|
+
// Non-OAuth mode or no config - use environment config
|
|
277
|
+
await handleMcpRequest(req, res);
|
|
173
278
|
});
|
|
174
279
|
// Server info endpoint
|
|
175
280
|
app.get("/", (_req, res) => {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Instructions - provides context and guidance for AI agents
|
|
3
|
+
* using the Open Loyalty MCP server.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SERVER_INSTRUCTIONS = "\nOpen Loyalty MCP Server - Complete Loyalty Program Management\n\n## Domain Model\n\nMember (loyalty program participant)\n \u2514\u2500\u2500 Points (wallet balance, transfers)\n \u2514\u2500\u2500 Tier (current level: Bronze, Silver, Gold)\n \u2514\u2500\u2500 Transactions (purchase history)\n \u2514\u2500\u2500 Rewards (redeemed coupons)\n \u2514\u2500\u2500 Achievements (gamification progress)\n \u2514\u2500\u2500 Badges (visual rewards from achievements)\n\nTierSet (loyalty program structure)\n \u2514\u2500\u2500 Conditions (criteria: activeUnits, totalSpending)\n \u2514\u2500\u2500 Tiers (levels with thresholds)\n\nWalletType (points currency configuration)\n\nReward (redeemable items)\n \u2514\u2500\u2500 Categories (reward groupings)\n\nCampaign (automated points/rewards rules)\n \u2514\u2500\u2500 Type (earning, spending, custom, instant_reward)\n \u2514\u2500\u2500 Trigger (transaction, custom_event, points_transfer)\n \u2514\u2500\u2500 Rules (SKU, category, transaction amount conditions)\n \u2514\u2500\u2500 Effects (give_points, multiply_points, percentage_discount)\n \u2514\u2500\u2500 Targeting (segments, tiers for audience)\n\nSegment (member audience grouping)\n \u2514\u2500\u2500 Parts (groups of criteria - OR logic between parts)\n \u2514\u2500\u2500 Criteria (conditions - AND logic within part)\n Types: transaction_count (working; requires min+max). Other types may be rejected by the API.\n\nAchievement (gamification goals)\n \u2514\u2500\u2500 Trigger (transaction, custom_event, points_transfer, reward_redemption, referral, achievement, tier_change, profile_update)\n \u2514\u2500\u2500 CompleteRule (periodGoal, period type, unique counting)\n \u2514\u2500\u2500 Badge (visual reward when completed)\n\nBadge (visual recognition)\n \u2514\u2500\u2500 Linked to achievements via badgeTypeId\n \u2514\u2500\u2500 Auto-created when referenced by achievement\n\n## Key Workflows\n\n### 1. Create 3-tier Loyalty Program:\nwallet_type_list \u2192 tierset_create \u2192 tierset_get \u2192 tierset_update_tiers\n\n### 2. Register and Reward Member:\nmember_create \u2192 points_add (welcome bonus) \u2192 member_get (verify)\n\n### 3. Record Purchase and Earn Points:\ntransaction_create (with customerData) \u2192 triggers campaigns \u2192 points auto-added\n\n### 4. Redeem Reward:\nreward_list \u2192 reward_buy (deducts points) \u2192 reward_redeem (mark coupon used)\n\n### 5. Assign Unmatched Transaction:\ntransaction_list (matched=false) \u2192 transaction_assign_member \u2192 points earned\n\n### 6. Check Member Tier Progress:\nmember_get_tier_progress \u2192 shows current tier, next tier, progress %\n\n### 7. Double Points for VIP Members:\nsegment_create (with transaction_count criteria) \u2192 campaign_create (targeting that segment with give_points effect and pointsRule: \"transaction.grossValue * 2\")\n\n### 8. Purchase Achievement with Badge:\nbadge_list (find or note badgeTypeId) \u2192 achievement_create (type+trigger+aggregation+period required, badgeTypeId)\n\n### 9. Create Targeted Promotion:\nsegment_create (define audience) \u2192 campaign_create (type: earning, target: segment, segmentIds: [segmentId])\n\n### 10. Track Member Gamification:\nachievement_list_member_achievements \u2192 badge_get_member_badges \u2192 achievement_get_member_progress\n\n## Discovery Paths:\n- Members: member_list \u2192 member_get \u2192 points_get_balance\n- Tiers: tierset_list \u2192 tierset_get \u2192 tierset_get_tiers\n- Rewards: reward_category_list \u2192 reward_list \u2192 reward_get\n- Transactions: transaction_list \u2192 transaction_get\n- Campaigns: campaign_list \u2192 campaign_get \u2192 campaign_simulate\n- Segments: segment_list \u2192 segment_get \u2192 segment_get_members \u2192 campaign_create (audience targeting)\n- Achievements: achievement_list \u2192 badge_list \u2192 achievement_create (with badge)\n- Member Gamification: achievement_list_member_achievements \u2192 badge_get_member_badges\n\n## Important Patterns:\n- conditionId REQUIRED for tier thresholds - call tierset_get after tierset_create\n- Points operations: check balance with points_get_balance before spending\n- Reward flow: reward_buy returns couponCode, use reward_redeem to mark used\n- Transactions auto-match to members via customerData (email, phone, loyaltyCardNumber)\n\n### Pagination:\n- Traditional pagination: use page + perPage params\n- Cursor pagination: provide cursor from previous response for efficient deep pagination\n- Cursor-enabled tools: member_list, transaction_list, points_get_history\n- First scroll request: cursor=\"\" (empty string)\n- Subsequent requests: use cursor value from previous response\n- When cursor provided, page param is ignored\n\n### Campaign Patterns:\n- Campaign types: direct (standard), referral (referral programs)\n- Triggers: transaction (purchase), custom_event, time, achievement, etc.\n- Effects: give_points, give_reward, deduct_unit\n- Target campaigns to segments or tiers using audience.target: \"segment\" and audience.segments array\n- campaign_create requires activity.startsAt, rules[].name, and effects[].effect (use key effect, not type). pointsRule is a STRING expression.\n\n### Points Expression (pointsRule):\npointsRule is a STRING expression, not an object. Examples:\n- Fixed: \"100\"\n- Per dollar: \"transaction.grossValue * 10\"\n- Category-based: \"transaction.category('electronics').grossValue * 5\"\n- Capped: \"(transaction.grossValue * 2 >= 100) ? 100 : transaction.grossValue * 2\"\n- Rounded: \"round_up(0.1 * transaction.grossValue)\"\n\n### Segment Patterns:\n- Parts use OR logic (member matches ANY part)\n- Criteria within a part use AND logic (member must match ALL criteria in that part)\n- Common criteria: transaction_count (working; requires min+max). Other criteria may be rejected by the API.\n\n### Achievement Patterns:\n- Triggers: transaction, custom_event, points_transfer, reward_redemption, referral, achievement, tier_change, profile_update\n- periodGoal sets the target (e.g., 5 purchases, 1000 points spent)\n- period.type defines timeframe: day, week, month, year, last_day, calendarDays, calendarWeeks, calendarMonths, calendarYears\n- aggregation.type defines counting method: quantity (count events only)\n- Link to badge via badgeTypeId - badge auto-created if doesn't exist\n- uniqueAttribute for counting distinct values (e.g., unique products)\n\n### Achievement Period Types:\n- \"day\": Count all-time when consecutive=1\n- \"week\": Count per week (use consecutive=1 for all-time weekly tracking)\n- \"month\": Count per month\n- \"year\": Count per year\n- \"last_day\": Rolling window of N days (e.g., value: 7 for last 7 days)\n- \"calendarDays\": Reset daily at midnight\n- \"calendarWeeks\": Reset weekly (Monday)\n- \"calendarMonths\": Reset monthly (1st of month)\n- \"calendarYears\": Reset yearly (January 1st)\n\n### Streak Achievement Example (7-day login streak):\n{\n \"rules\": [{\n \"trigger\": \"custom_event\",\n \"event\": \"app_login\",\n \"type\": \"direct\",\n \"aggregation\": {\"type\": \"quantity\"},\n \"completeRule\": {\n \"periodGoal\": 1,\n \"period\": {\"type\": \"last_day\", \"value\": 7, \"consecutive\": 1}\n },\n \"limit\": {\n \"interval\": {\"type\": \"calendarDays\", \"value\": 1},\n \"value\": 1\n }\n }]\n}\n\n### Custom Event Workflows:\n\n#### Create Custom Event Campaign:\n1. custom_event_schema_list \u2192 Check if event type exists\n2. custom_event_schema_create (if needed) \u2192 Define event type and fields\n3. campaign_create with trigger: \"custom_event\", event: \"{event_type}\"\n4. Send events via custom_event_send to trigger campaign\n\n#### Create Achievement with Custom Event:\n1. custom_event_schema_create \u2192 Define event (e.g., \"location_visit\")\n2. achievement_create with:\n - rules[].trigger: \"custom_event\"\n - rules[].event: \"{event_type}\"\n - rules[].type: \"direct\"\n - rules[].aggregation.type: \"quantity\"\n - rules[].completeRule.periodGoal: {count}\n - rules[].completeRule.period: { type: \"day\", consecutive: 1 } (all-time)\n\n#### Create Campaign Triggered by Achievement:\n1. achievement_create \u2192 Get achievementId from response\n2. campaign_create with:\n - trigger: \"achievement\"\n - achievementId: \"{achievement_id}\"\n - rules with effects (give_points, give_reward)\n\n### Condition Operators (for campaigns/achievements):\n- is_equal, is_not_equal\n- gte, gt, lte, lt\n- is_between, is_not_between\n- is_time_between\n- is_day_of_week, is_day_of_month\n- contains, not_contains\n- starts_with, ends_with\n- one_of, not_one_of\n- expression (for custom logic)\n\n## Common Campaign Patterns (Industry-Agnostic)\n\nThese patterns work for any loyalty program - retail, QSR, aviation, fan engagement, hospitality, etc.\n\n### Pattern 1: Count Events Toward Goal (Achievements)\nUse case: Track member actions (visits, purchases, logins, check-ins) toward a milestone.\n1. custom_event_schema_create \u2192 Define your action type (e.g., \"store_visit\", \"app_login\")\n2. achievement_create with:\n - trigger: \"custom_event\"\n - event: \"{your_event_code}\"\n - aggregation: { type: \"quantity\" }\n - completeRule: { periodGoal: {count}, period: { type: \"day\", consecutive: 1 } }\n3. custom_event_send to log each action\n\n### Pattern 2: Points Per Action (Instant Rewards)\nUse case: Award instant points for each qualifying action.\n1. custom_event_schema_create (if not using transaction)\n2. campaign_create with:\n - trigger: \"custom_event\" (or \"transaction\")\n - event: \"{event_code}\" (for custom events)\n - rules: [{ effects: [{ effect: \"give_points\", pointsRule: \"50\" }] }]\n\n### Pattern 3: Conditional Rewards (Bonus Conditions)\nUse case: Award points only when a condition is met (e.g., early arrival, large purchase).\nUse ternary expressions in pointsRule instead of conditions array:\n- pointsRule: \"event.body.minutes_before >= 60 ? 25 : 0\"\n- pointsRule: \"transaction.grossValue >= 100 ? 50 : 0\"\n\n### Pattern 4: Execution Limits (Anti-Fraud)\nUse case: Limit rewards to prevent abuse (once per day, once per week).\nCampaign limits structure:\n{\n \"limits\": {\n \"executionsPerMember\": {\n \"value\": 1,\n \"interval\": { \"type\": \"calendarDays\", \"value\": 1 }\n }\n }\n}\nNote: Use \"calendarDays\" (not \"days\"), \"calendarWeeks\", \"calendarMonths\", \"calendarYears\".\nOmit interval entirely for lifetime/forever limit.\n\n### Pattern 5: Achievement-Triggered Bonus\nUse case: Award bonus points/rewards when member completes an achievement.\n1. achievement_create \u2192 Get achievementId from response\n2. campaign_create with:\n - trigger: \"achievement\"\n - achievementId: \"{achievement_id}\"\n - rules with give_points or give_reward effect\n\n### Pattern 6: Tiered Milestones\nUse case: Multiple achievement levels (bronze/silver/gold, 5/10/25 visits).\nCreate multiple achievements with increasing periodGoal values,\neach linked to a different bonus campaign with increasing rewards.\n\n### Pattern 7: Time-Limited Promotion\nUse case: Seasonal or promotional campaigns.\n{\n \"activity\": {\n \"startsAt\": \"2026-01-01 00:00+00:00\",\n \"endsAt\": \"2026-03-31 23:59+00:00\"\n }\n}\n\n### Pattern 8: Streak Tracking\nUse case: Reward consecutive daily/weekly actions (login streaks, workout streaks).\nAchievement with:\n- completeRule.period: { type: \"last_day\", value: 7 } (rolling 7-day window)\n- limit: { value: 1, interval: { type: \"calendarDays\", value: 1 } } (max 1 per day)\n\n## Phase 3: Analytics, Admin, Roles & Stores\n\n### Domain Model (Extended)\n\nAdmin (system user)\n \u2514\u2500\u2500 Roles (permission sets)\n \u2514\u2500\u2500 Permissions (resource + access level)\n \u2514\u2500\u2500 API Keys (programmatic access)\n\nStore (multi-tenant container)\n \u2514\u2500\u2500 Members, Campaigns, Rewards (isolated per store)\n\nAudit Log (compliance tracking)\n \u2514\u2500\u2500 eventType, entityType, entityId, username, timestamp\n\n### Analytics Workflows:\n\n#### 11. Get Program Overview:\nanalytics_dashboard \u2192 analytics_tiers \u2192 analytics_members\n\n#### 12. Analyze Points Economy:\nanalytics_points \u2192 analytics_units (per wallet) \u2192 points_get_histogram\n\n#### 13. Measure Campaign Performance:\nanalytics_campaigns \u2192 analytics_campaign_detail (specific campaign)\n\n#### 14. Track Transactions:\nanalytics_transactions \u2192 transaction_list (details)\n\n### Aggregation Queries (Top Spenders, Purchase Analysis):\n\nIMPORTANT: For queries like \"top 5 spenders in July 2025\", use this approach:\n\n#### 15. Find Top Spenders by Date Range:\n1. transaction_list(purchasedAtFrom, purchasedAtTo, perPage=50, cursor='') - returns customerId with each transaction\n2. Use cursor pagination to fetch ALL pages - even if there are 1000+ transactions\n3. Aggregate grossValue by customerId in your code\n4. Sort by total spent, take top N\n5. member_get for each top spender to get names/details\n\nCRITICAL - DO NOT TRY TO BE SMART OR OPTIMIZE:\n- ALWAYS iterate through ALL pages using cursor pagination - this is the ONLY correct approach\n- DO NOT skip pages or try to sample data - you will get inaccurate results\n- DO NOT use transaction_get individually - transaction_list already includes customerId\n- DO NOT try to find \"smarter\" analytics endpoints - they don't exist for per-customer aggregation\n- Large datasets (1000+ transactions) are normal - just keep paginating until cursor is empty\n- Start with cursor='' (empty string), use returned cursor for next request, repeat until done\n\nExample: Top 5 spenders July 2025\n- First request: transaction_list(purchasedAtFrom: \"2025-07-01\", purchasedAtTo: \"2025-07-31\", perPage: 50, cursor: \"\")\n- Each result includes: transactionId, grossValue, customerId, purchasedAt\n- Keep fetching with returned cursor until response has no cursor or empty transactions\n- Even if there are 1500 transactions (30 pages), iterate through ALL of them\n- Group by customerId, sum grossValue, sort descending, take 5\n\n### Admin & Security Workflows:\n\n#### 15. Create Admin with Limited Role:\nacl_get_resources \u2192 role_create \u2192 admin_create (with roleId)\n\n#### 16. Generate API Key for Integration:\nadmin_get \u2192 apikey_create \u2192 SAVE TOKEN IMMEDIATELY (shown once!)\n\n#### 17. Audit User Actions:\naudit_list (filter by username/entity) \u2192 audit_export (compliance report)\n\n### Store Multi-Tenancy:\n\n#### 18. Create New Store:\nstore_create \u2192 configure campaigns/tiers \u2192 member_create (with storeCode)\n\n### Analytics Query Patterns:\n- All analytics tools support dateFrom/dateTo ISO date filters\n- analytics_dashboard: high-level program metrics\n- analytics_units: wallet-specific metrics (requires walletTypeCode)\n- analytics_campaign_detail: detailed metrics for single campaign\n\n### Admin \u2192 Role \u2192 Permission Model:\n\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Admin \u2502\u2500\u2500\u2500\u2500\u25B6\u2502 Role \u2502\u2500\u2500\u2500\u2500\u25B6\u2502 Permission \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502 \u2502\n \u2502 resource + access\n \u2502 (VIEW, MODIFY, etc.)\n \u25BC\n\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 API Key \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n### Store Multi-Tenancy:\n- Each store is isolated: members, campaigns, rewards, transactions\n- storeCode parameter routes requests to correct tenant\n- Default store used when storeCode omitted\n- IMPORTANT: Do NOT pass storeCode parameter unless the user explicitly asks to work with a different store. The configured default store should always be used unless the user requests otherwise.\n\n## Phase 4: Webhooks, Import & Export\n\n### Domain Model (Extended)\n\nWebhook Subscription (event notification)\n \u2514\u2500\u2500 eventName (event to subscribe to)\n \u2514\u2500\u2500 url (callback endpoint)\n \u2514\u2500\u2500 headers (custom HTTP headers)\n\nImport (bulk data upload)\n \u2514\u2500\u2500 type: member, groupValue, segmentMembers, unitTransferAdding, etc.\n \u2514\u2500\u2500 status: pending, processing, succeed, failed\n \u2514\u2500\u2500 items (individual row results)\n\nExport (bulk data download)\n \u2514\u2500\u2500 type: campaignCode, member, memberTier, memberSegment, rewardFulfillment\n \u2514\u2500\u2500 status: pending, done, failed, error\n \u2514\u2500\u2500 CSV file (when status='done')\n\n### Webhook Workflows:\n\n#### 19. Subscribe to Member Events for CRM Sync:\nwebhook_events \u2192 webhook_create (eventName: 'member.created', url: 'https://crm.example.com/webhook')\n\n#### 20. List and Manage Subscriptions:\nwebhook_list \u2192 webhook_get \u2192 webhook_update or webhook_delete\n\n### Import Workflows:\n\n#### 21. Bulk Import Members from CSV:\nimport_create (type: 'member', fileContent: CSV data) \u2192 import_list \u2192 import_get (check status)\n\n#### 22. Bulk Add Points to Members:\nimport_create (type: 'unitTransferAdding', fileContent: CSV) \u2192 poll import_get until complete\n\n### Export Workflows:\n\n#### 23. Export Campaign Codes:\nexport_create (type: 'campaignCode', filters: { campaignId }) \u2192 poll export_get (until status='done') \u2192 export_download\n\n#### 24. Export Member Data:\nexport_create (type: 'member') \u2192 export_get \u2192 export_download (returns CSV)\n\n### Webhook Patterns:\n- Use webhook_events to discover available event types before subscribing\n- API uses wrapper: { webhookSubscription: { eventName, url, headers? } }\n- Common events: member.created, member.updated, transaction.created, reward.purchased\n\n### Import Patterns:\n- Import is async: create returns importId, poll status with import_get\n- CSV format required - provide plain text, not base64\n- Types: member, groupValue, segmentMembers, unitTransferAdding, unitTransferSpending, transaction, campaignCode, rewardCoupon\n\n### Export Patterns:\n- Export is async: create returns exportId, poll status until 'done'\n- API body wrapper varies by type: { campaignCode: { filters... } }\n- Only call export_download when status='done'\n- Types: campaignCode, member, memberTier, memberSegment, rewardFulfillment\n";
|