@anton.andrusenko/shopify-mcp-admin 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  clearFallbackContext,
4
+ correlationMiddleware,
5
+ createLogger,
4
6
  createMultiTenantContext,
5
7
  createShopifyClient,
8
+ flushLogs,
9
+ generateCorrelationId,
6
10
  getRequestContext,
7
11
  getShopifyClient,
8
12
  getStoreAlerts,
@@ -14,14 +18,17 @@ import {
14
18
  getStorePolicies,
15
19
  getStoreShipping,
16
20
  getStoreTaxes,
21
+ initSentry,
17
22
  isMultiTenantContext,
18
23
  registerAllTools,
19
24
  requestContextStorage,
20
25
  sanitizeErrorMessage,
26
+ sentryErrorHandler,
27
+ sentryRequestMiddleware,
21
28
  setCurrentContextKey,
22
29
  setFallbackContext,
23
30
  validateShopifyToken
24
- } from "./chunk-LMFNHULG.js";
31
+ } from "./chunk-RBXQOPVF.js";
25
32
  import {
26
33
  disconnectPrisma,
27
34
  getPrismaClient,
@@ -30,7 +37,7 @@ import {
30
37
  } from "./chunk-JU5IFCVJ.js";
31
38
  import {
32
39
  createJsonRpcError
33
- } from "./chunk-PQKNBYJN.js";
40
+ } from "./chunk-EQUN4XCH.js";
34
41
  import {
35
42
  getConfig,
36
43
  log,
@@ -49,172 +56,6 @@ import { readFileSync as readFileSync2 } from "fs";
49
56
  import { dirname as dirname2, join as join2 } from "path";
50
57
  import { fileURLToPath as fileURLToPath2 } from "url";
51
58
 
52
- // src/monitoring/sentry.ts
53
- import * as Sentry from "@sentry/node";
54
- function initSentry(options) {
55
- const { dsn, environment = "production", release } = options;
56
- if (!dsn) {
57
- return;
58
- }
59
- Sentry.init({
60
- dsn,
61
- environment,
62
- // Sample 10% of transactions for performance monitoring (AC-13.7.4)
63
- tracesSampleRate: 0.1,
64
- // Release tracking for deployment correlation
65
- release: release || "unknown",
66
- // Sanitize sensitive data before sending to Sentry
67
- beforeSend(event) {
68
- if (event.request?.headers) {
69
- const sensitiveHeaders = [
70
- "authorization",
71
- "x-api-key",
72
- "cookie",
73
- "x-shopify-access-token",
74
- "x-shopify-hmac-sha256"
75
- ];
76
- for (const header of sensitiveHeaders) {
77
- delete event.request.headers[header];
78
- }
79
- }
80
- if (event.request?.data) {
81
- const data = event.request.data;
82
- const sensitiveKeys = ["token", "password", "secret", "key", "access_token"];
83
- for (const key of Object.keys(data)) {
84
- if (sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive))) {
85
- data[key] = "[REDACTED]";
86
- }
87
- }
88
- }
89
- if (event.message) {
90
- event.message = sanitizeErrorMessage2(event.message);
91
- }
92
- if (event.exception?.values) {
93
- for (const exception of event.exception.values) {
94
- if (exception.value) {
95
- exception.value = sanitizeErrorMessage2(exception.value);
96
- }
97
- }
98
- }
99
- return event;
100
- },
101
- // Integrations
102
- integrations: [
103
- // Capture uncaught exceptions and unhandled rejections
104
- Sentry.onUncaughtExceptionIntegration(),
105
- Sentry.onUnhandledRejectionIntegration(),
106
- // Capture HTTP request data
107
- Sentry.requestDataIntegration(),
108
- // Deduplicate similar errors
109
- Sentry.dedupeIntegration()
110
- ]
111
- });
112
- }
113
- function sanitizeErrorMessage2(message) {
114
- const patterns = [
115
- /shpat_[a-zA-Z0-9]+/g,
116
- /shpua_[a-zA-Z0-9]+/g,
117
- /Bearer\s+[a-zA-Z0-9_-]+/g,
118
- /access_token[=:]\s*[a-zA-Z0-9_-]+/gi,
119
- /client_secret[=:]\s*[a-zA-Z0-9_-]+/gi,
120
- /sk_live_[a-zA-Z0-9_-]+/g
121
- ];
122
- let sanitized = message;
123
- for (const pattern of patterns) {
124
- sanitized = sanitized.replace(pattern, "[REDACTED]");
125
- }
126
- return sanitized;
127
- }
128
- function sentryRequestMiddleware(req, res, next) {
129
- const context = getRequestContext();
130
- if (context) {
131
- if (context.correlationId) {
132
- Sentry.setTag("correlationId", context.correlationId);
133
- Sentry.setContext("request", {
134
- correlationId: context.correlationId
135
- });
136
- }
137
- if (isMultiTenantContext(context) && context.tenantId) {
138
- Sentry.setTag("tenantId", context.tenantId);
139
- Sentry.setContext("tenant", {
140
- tenantId: context.tenantId
141
- });
142
- }
143
- if (context.shopDomain) {
144
- Sentry.setTag("shopDomain", context.shopDomain);
145
- }
146
- }
147
- Sentry.setContext("http", {
148
- method: req.method,
149
- url: req.url,
150
- path: req.path,
151
- query: req.query,
152
- userAgent: req.get("user-agent"),
153
- ip: req.ip
154
- });
155
- Sentry.addBreadcrumb({
156
- category: "http",
157
- message: `${req.method} ${req.path}`,
158
- level: "info",
159
- data: {
160
- method: req.method,
161
- path: req.path
162
- // statusCode will be updated when response finishes (see below)
163
- }
164
- });
165
- res.on("finish", () => {
166
- Sentry.addBreadcrumb({
167
- category: "http",
168
- message: `${req.method} ${req.path} - ${res.statusCode}`,
169
- level: res.statusCode >= 400 ? "error" : "info",
170
- data: {
171
- method: req.method,
172
- path: req.path,
173
- statusCode: res.statusCode
174
- }
175
- });
176
- });
177
- next();
178
- }
179
- function sentryErrorHandler(error, req, _res, next) {
180
- Sentry.captureException(error, {
181
- tags: {
182
- path: req.path,
183
- method: req.method
184
- },
185
- extra: {
186
- url: req.url,
187
- query: req.query,
188
- body: req.body
189
- }
190
- });
191
- next(error);
192
- }
193
- function captureError(error, context) {
194
- const requestContext = getRequestContext();
195
- Sentry.withScope((scope) => {
196
- if (requestContext?.correlationId) {
197
- scope.setTag("correlationId", requestContext.correlationId);
198
- scope.setContext("request", {
199
- correlationId: requestContext.correlationId
200
- });
201
- }
202
- if (requestContext && isMultiTenantContext(requestContext) && requestContext.tenantId) {
203
- scope.setTag("tenantId", requestContext.tenantId);
204
- scope.setContext("tenant", {
205
- tenantId: requestContext.tenantId
206
- });
207
- }
208
- if (requestContext?.shopDomain) {
209
- scope.setTag("shopDomain", requestContext.shopDomain);
210
- }
211
- if (context) {
212
- scope.setContext("additional", context);
213
- }
214
- Sentry.captureException(error);
215
- });
216
- }
217
-
218
59
  // src/server.ts
219
60
  import { createRequire } from "module";
220
61
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -790,7 +631,7 @@ function createStdioTransport() {
790
631
  }
791
632
 
792
633
  // src/transports/http.ts
793
- import { randomUUID as randomUUID3 } from "crypto";
634
+ import { randomUUID as randomUUID2 } from "crypto";
794
635
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
795
636
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
796
637
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -805,194 +646,6 @@ import { z } from "zod";
805
646
 
806
647
  // src/middleware/tenant-auth.ts
807
648
  import { TenantStatus } from "@prisma/client";
808
-
809
- // src/logging/structured-logger.ts
810
- import {
811
- pino as createPino,
812
- destination as pinoDestination,
813
- transport as pinoTransport
814
- } from "pino";
815
- var SANITIZATION_PATTERNS = [
816
- { pattern: /shpat_[a-zA-Z0-9]+/g, replacement: "[REDACTED]" },
817
- { pattern: /shpua_[a-zA-Z0-9]+/g, replacement: "[REDACTED]" },
818
- { pattern: /Bearer\s+[a-zA-Z0-9_-]+/g, replacement: "Bearer [REDACTED]" },
819
- { pattern: /access_token[=:]\s*[a-zA-Z0-9_-]+/gi, replacement: "access_token=[REDACTED]" },
820
- { pattern: /client_secret[=:]\s*[a-zA-Z0-9_-]+/gi, replacement: "client_secret=[REDACTED]" },
821
- { pattern: /sk_live_[a-zA-Z0-9_-]+/g, replacement: "[REDACTED]" }
822
- ];
823
- function sanitizeText(text) {
824
- let result = text;
825
- for (const { pattern, replacement } of SANITIZATION_PATTERNS) {
826
- result = result.replace(pattern, replacement);
827
- }
828
- return result;
829
- }
830
- function sanitizeObject(obj, seen = /* @__PURE__ */ new WeakSet()) {
831
- if (typeof obj === "string") {
832
- return sanitizeText(obj);
833
- }
834
- if (obj === null || typeof obj !== "object") {
835
- return obj;
836
- }
837
- if (seen.has(obj)) {
838
- return "[Circular]";
839
- }
840
- seen.add(obj);
841
- if (Array.isArray(obj)) {
842
- return obj.map((item) => sanitizeObject(item, seen));
843
- }
844
- const result = {};
845
- for (const [key, value] of Object.entries(obj)) {
846
- result[key] = sanitizeObject(value, seen);
847
- }
848
- return result;
849
- }
850
- function getInstanceId() {
851
- return process.env.INSTANCE_ID || process.env.HOSTNAME || "unknown";
852
- }
853
- function getLogLevel() {
854
- const level = process.env.LOG_LEVEL?.toLowerCase();
855
- if (level === "debug" || level === "info" || level === "warn" || level === "error") {
856
- return level;
857
- }
858
- return "info";
859
- }
860
- function isDevelopment() {
861
- return process.env.NODE_ENV === "development";
862
- }
863
- function createTransport() {
864
- if (isDevelopment()) {
865
- try {
866
- return {
867
- target: "pino-pretty",
868
- options: {
869
- destination: 2,
870
- // stderr (fd 2)
871
- colorize: true,
872
- translateTime: "HH:MM:ss.l",
873
- ignore: "pid,hostname"
874
- }
875
- };
876
- } catch {
877
- return void 0;
878
- }
879
- }
880
- return void 0;
881
- }
882
- function createBasePinoLogger() {
883
- const level = getLogLevel();
884
- const transport = createTransport();
885
- const instanceId = getInstanceId();
886
- const options = {
887
- level,
888
- // ISO 8601 timestamp format (AC-13.1.3)
889
- timestamp: () => `,"time":"${(/* @__PURE__ */ new Date()).toISOString()}"`,
890
- // Format level as string instead of number
891
- formatters: {
892
- level: (label) => ({ level: label })
893
- },
894
- // Base bindings (can be overridden by child loggers)
895
- // Story 13.5: Add instance ID for multi-instance deployment identification
896
- base: {
897
- instanceId
898
- // AC-13.5.1: Instance ID for load balancer distribution verification
899
- }
900
- };
901
- if (transport) {
902
- return createPino(options, pinoTransport(transport));
903
- }
904
- return createPino(options, pinoDestination({ dest: 2, sync: false }));
905
- }
906
- var basePinoLogger = createBasePinoLogger();
907
- function createLogger(module) {
908
- function getContextFields() {
909
- const context = getRequestContext();
910
- const fields = { module };
911
- if (context) {
912
- if (context.correlationId) {
913
- fields.correlationId = context.correlationId;
914
- }
915
- if (context.shopDomain) {
916
- fields.shopDomain = context.shopDomain;
917
- }
918
- if (isMultiTenantContext(context)) {
919
- fields.tenantId = context.tenantId;
920
- }
921
- }
922
- return fields;
923
- }
924
- function mergeData(data) {
925
- const contextFields = getContextFields();
926
- if (data) {
927
- const sanitizedData = sanitizeObject(data);
928
- return { ...contextFields, ...sanitizedData };
929
- }
930
- return contextFields;
931
- }
932
- function formatError(error) {
933
- return {
934
- error: {
935
- name: error.name,
936
- message: sanitizeText(error.message),
937
- stack: error.stack ? sanitizeText(error.stack) : void 0
938
- }
939
- };
940
- }
941
- const logger3 = {
942
- debug: (msg, data) => {
943
- basePinoLogger.debug(mergeData(data), sanitizeText(msg));
944
- },
945
- info: (msg, data) => {
946
- basePinoLogger.info(mergeData(data), sanitizeText(msg));
947
- },
948
- warn: (msg, data) => {
949
- basePinoLogger.warn(mergeData(data), sanitizeText(msg));
950
- },
951
- error: (msg, error, data) => {
952
- const merged = mergeData(data);
953
- if (error) {
954
- Object.assign(merged, formatError(error));
955
- captureError(error, data);
956
- }
957
- basePinoLogger.error(merged, sanitizeText(msg));
958
- },
959
- child: (bindings) => {
960
- const childModule = bindings.module || module;
961
- const childLogger = createLogger(childModule);
962
- const originalMergeData = mergeData;
963
- const enhancedMergeData = (data) => {
964
- const base = originalMergeData(data);
965
- const sanitizedBindings = sanitizeObject(bindings);
966
- return { ...base, ...sanitizedBindings };
967
- };
968
- return {
969
- debug: (msg, data) => {
970
- basePinoLogger.debug(enhancedMergeData(data), sanitizeText(msg));
971
- },
972
- info: (msg, data) => {
973
- basePinoLogger.info(enhancedMergeData(data), sanitizeText(msg));
974
- },
975
- warn: (msg, data) => {
976
- basePinoLogger.warn(enhancedMergeData(data), sanitizeText(msg));
977
- },
978
- error: (msg, error, data) => {
979
- const merged = enhancedMergeData(data);
980
- if (error) {
981
- Object.assign(merged, formatError(error));
982
- }
983
- basePinoLogger.error(merged, sanitizeText(msg));
984
- },
985
- child: childLogger.child
986
- };
987
- }
988
- };
989
- return logger3;
990
- }
991
- function flushLogs() {
992
- basePinoLogger.flush();
993
- }
994
-
995
- // src/middleware/tenant-auth.ts
996
649
  var logger = createLogger("middleware/tenant-auth");
997
650
  async function requireTenantAuth(req, res, next) {
998
651
  try {
@@ -2231,6 +1884,7 @@ function createShopsRouter(options) {
2231
1884
  // src/api/keys.ts
2232
1885
  import { Router as Router3 } from "express";
2233
1886
  import { z as z3 } from "zod";
1887
+ var logger2 = createLogger("api/keys");
2234
1888
  var DEFAULT_SCOPES = ["*"];
2235
1889
  var createKeySchema = z3.object({
2236
1890
  name: z3.string().min(1, "Name is required").max(100, "Name must be 100 characters or less"),
@@ -2329,6 +1983,114 @@ function createKeysRouter(options) {
2329
1983
  });
2330
1984
  }
2331
1985
  });
1986
+ router.get("/:keyId/metrics", async (req, res) => {
1987
+ try {
1988
+ const tenantId = req.tenantContext?.tenantId;
1989
+ if (!tenantId) {
1990
+ return res.status(401).json({
1991
+ error: "Unauthorized",
1992
+ message: "Tenant context not found"
1993
+ });
1994
+ }
1995
+ const { keyId } = req.params;
1996
+ const key = await prisma2.apiKey.findFirst({
1997
+ where: {
1998
+ id: keyId,
1999
+ tenantId,
2000
+ revokedAt: null
2001
+ },
2002
+ select: { id: true }
2003
+ });
2004
+ if (!key) {
2005
+ return res.status(404).json({
2006
+ error: "Not Found",
2007
+ message: "API key not found"
2008
+ });
2009
+ }
2010
+ const now = /* @__PURE__ */ new Date();
2011
+ const sevenDaysAgo = new Date(now);
2012
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
2013
+ const thirtyDaysAgo = new Date(now);
2014
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
2015
+ const baseWhere = {
2016
+ tenantId,
2017
+ apiKeyId: keyId
2018
+ };
2019
+ const queryStartTime = Date.now();
2020
+ const [calls7d, calls30d, totalCalls, successCount, avgDuration, lastUsed] = await Promise.all([
2021
+ // Calls in last 7 days
2022
+ prisma2.toolExecutionLog.count({
2023
+ where: {
2024
+ ...baseWhere,
2025
+ createdAt: { gte: sevenDaysAgo }
2026
+ }
2027
+ }),
2028
+ // Calls in last 30 days
2029
+ prisma2.toolExecutionLog.count({
2030
+ where: {
2031
+ ...baseWhere,
2032
+ createdAt: { gte: thirtyDaysAgo }
2033
+ }
2034
+ }),
2035
+ // Total calls (for success rate calculation)
2036
+ prisma2.toolExecutionLog.count({
2037
+ where: {
2038
+ ...baseWhere,
2039
+ createdAt: { gte: sevenDaysAgo }
2040
+ }
2041
+ }),
2042
+ // Success count (for success rate calculation)
2043
+ prisma2.toolExecutionLog.count({
2044
+ where: {
2045
+ ...baseWhere,
2046
+ createdAt: { gte: sevenDaysAgo },
2047
+ status: "SUCCESS"
2048
+ }
2049
+ }),
2050
+ // Average duration
2051
+ prisma2.toolExecutionLog.aggregate({
2052
+ where: {
2053
+ ...baseWhere,
2054
+ createdAt: { gte: sevenDaysAgo },
2055
+ durationMs: { not: null }
2056
+ },
2057
+ _avg: { durationMs: true }
2058
+ }),
2059
+ // Most recent execution
2060
+ prisma2.toolExecutionLog.findFirst({
2061
+ where: baseWhere,
2062
+ orderBy: { createdAt: "desc" },
2063
+ select: { createdAt: true }
2064
+ })
2065
+ ]);
2066
+ const successRate = totalCalls > 0 ? successCount / totalCalls * 100 : 0;
2067
+ const queryDuration = Date.now() - queryStartTime;
2068
+ if (queryDuration > 200) {
2069
+ logger2.warn("API key metrics query exceeded 200ms", {
2070
+ keyId,
2071
+ tenantId,
2072
+ durationMs: queryDuration
2073
+ });
2074
+ }
2075
+ const response = {
2076
+ calls7d,
2077
+ calls30d,
2078
+ successRate: Math.round(successRate * 10) / 10,
2079
+ // Round to 1 decimal place
2080
+ avgDurationMs: Math.round(avgDuration._avg.durationMs ?? 0),
2081
+ lastUsedAt: lastUsed?.createdAt.toISOString() ?? null
2082
+ };
2083
+ return res.json(response);
2084
+ } catch (error) {
2085
+ logger2.error("metrics endpoint error", error, {
2086
+ correlationId: req.headers["x-correlation-id"]
2087
+ });
2088
+ return res.status(500).json({
2089
+ error: "Internal Server Error",
2090
+ message: "Failed to compute API key metrics"
2091
+ });
2092
+ }
2093
+ });
2332
2094
  router.delete("/:keyId", async (req, res) => {
2333
2095
  try {
2334
2096
  const tenantId = req.tenantContext?.tenantId;
@@ -2467,13 +2229,14 @@ function createAuthRouter(_config) {
2467
2229
  }
2468
2230
  }
2469
2231
  }
2232
+ const sameSiteSetting = isProduction ? "none" : "lax";
2470
2233
  res.cookie("session_id", sessionId, {
2471
2234
  httpOnly: true,
2472
2235
  // Prevents JavaScript access (XSS protection)
2473
2236
  secure: isProduction,
2474
- // HTTPS only in production
2475
- sameSite: "lax",
2476
- // CSRF protection
2237
+ // HTTPS only in production (required for SameSite=None)
2238
+ sameSite: sameSiteSetting,
2239
+ // 'none' in production for Claude.ai OAuth iframe compatibility
2477
2240
  path: "/",
2478
2241
  // Explicitly set path
2479
2242
  domain: cookieDomain,
@@ -2527,7 +2290,7 @@ function createAuthRouter(_config) {
2527
2290
  res.clearCookie("session_id", {
2528
2291
  httpOnly: true,
2529
2292
  secure: isProduction,
2530
- sameSite: "lax",
2293
+ sameSite: isProduction ? "none" : "lax",
2531
2294
  path: "/",
2532
2295
  domain: cookieDomain
2533
2296
  });
@@ -2555,7 +2318,7 @@ function createAuthRouter(_config) {
2555
2318
  res.clearCookie("session_id", {
2556
2319
  httpOnly: true,
2557
2320
  secure: isProduction,
2558
- sameSite: "lax",
2321
+ sameSite: isProduction ? "none" : "lax",
2559
2322
  path: "/",
2560
2323
  domain: cookieDomain
2561
2324
  });
@@ -2575,9 +2338,36 @@ function createAuthRouter(_config) {
2575
2338
  // src/api/activity.ts
2576
2339
  import { Router as Router5 } from "express";
2577
2340
  import { z as z5 } from "zod";
2341
+ var logger3 = createLogger("api/activity");
2578
2342
  var usageSummaryQuerySchema = z5.object({
2579
2343
  months: z5.coerce.number().int().min(1).max(24).default(6)
2580
2344
  });
2345
+ var activityLogsQuerySchema = z5.object({
2346
+ page: z5.coerce.number().int().min(1).default(1),
2347
+ limit: z5.coerce.number().int().min(1).transform((val) => Math.min(val, 100)).default(50),
2348
+ toolName: z5.string().optional(),
2349
+ clientType: z5.enum(["api_key", "oauth_client"]).optional(),
2350
+ oauthClientId: z5.string().uuid("Invalid OAuth client ID format").optional(),
2351
+ apiKeyId: z5.string().uuid("Invalid API key ID format").optional(),
2352
+ shopId: z5.string().uuid("Invalid shop ID format").optional(),
2353
+ status: z5.enum(["SUCCESS", "ERROR", "VALIDATION_ERROR", "AUTH_ERROR", "TIMEOUT"]).optional(),
2354
+ startDate: z5.string().datetime().optional(),
2355
+ endDate: z5.string().datetime().optional()
2356
+ }).refine(
2357
+ (data) => {
2358
+ if (data.startDate && data.endDate) {
2359
+ return new Date(data.endDate) >= new Date(data.startDate);
2360
+ }
2361
+ return true;
2362
+ },
2363
+ {
2364
+ message: "endDate must be greater than or equal to startDate",
2365
+ path: ["endDate"]
2366
+ }
2367
+ );
2368
+ var activityStatsQuerySchema = z5.object({
2369
+ period: z5.enum(["7d", "30d", "90d"]).default("7d")
2370
+ });
2581
2371
  function startOfMonth(d) {
2582
2372
  const out = new Date(d);
2583
2373
  out.setDate(1);
@@ -2640,18 +2430,293 @@ function createActivityRouter() {
2640
2430
  };
2641
2431
  return res.json(response);
2642
2432
  } catch (error) {
2643
- console.error("[ACTIVITY API] usage-summary error:", error);
2433
+ logger3.error("usage-summary error", error);
2644
2434
  return res.status(500).json({
2645
2435
  error: "Internal Server Error",
2646
2436
  message: "Failed to compute usage summary"
2647
2437
  });
2648
2438
  }
2649
2439
  });
2440
+ router.get("/logs", async (req, res) => {
2441
+ try {
2442
+ const tenantId = req.tenantContext?.tenantId;
2443
+ if (!tenantId) {
2444
+ return res.status(401).json({
2445
+ error: "Unauthorized",
2446
+ message: "Tenant context not found"
2447
+ });
2448
+ }
2449
+ const parsed = activityLogsQuerySchema.safeParse(req.query);
2450
+ if (!parsed.success) {
2451
+ return res.status(400).json({
2452
+ error: "Validation Error",
2453
+ message: "Invalid query parameters",
2454
+ details: parsed.error.errors
2455
+ });
2456
+ }
2457
+ const {
2458
+ page,
2459
+ limit,
2460
+ toolName,
2461
+ clientType,
2462
+ oauthClientId,
2463
+ apiKeyId,
2464
+ shopId,
2465
+ status,
2466
+ startDate,
2467
+ endDate
2468
+ } = parsed.data;
2469
+ const where = {
2470
+ tenantId,
2471
+ ...toolName && { toolName },
2472
+ ...clientType && { clientType },
2473
+ ...oauthClientId && { oauthClientId },
2474
+ ...apiKeyId && { apiKeyId },
2475
+ ...shopId && { shopId },
2476
+ ...status && { status },
2477
+ ...(startDate || endDate) && {
2478
+ createdAt: {
2479
+ ...startDate && { gte: new Date(startDate) },
2480
+ ...endDate && { lte: new Date(endDate) }
2481
+ }
2482
+ }
2483
+ };
2484
+ const skip = (page - 1) * limit;
2485
+ const take = limit;
2486
+ const [logs, total] = await Promise.all([
2487
+ prisma2.toolExecutionLog.findMany({
2488
+ where,
2489
+ skip,
2490
+ take,
2491
+ orderBy: { createdAt: "desc" },
2492
+ select: {
2493
+ id: true,
2494
+ toolName: true,
2495
+ toolModule: true,
2496
+ status: true,
2497
+ clientType: true,
2498
+ oauthClientId: true,
2499
+ apiKeyId: true,
2500
+ shopDomain: true,
2501
+ durationMs: true,
2502
+ createdAt: true
2503
+ }
2504
+ }),
2505
+ prisma2.toolExecutionLog.count({ where })
2506
+ ]);
2507
+ const clientIds = /* @__PURE__ */ new Set();
2508
+ logs.forEach((log3) => {
2509
+ if (log3.oauthClientId) clientIds.add(log3.oauthClientId);
2510
+ if (log3.apiKeyId) clientIds.add(log3.apiKeyId);
2511
+ });
2512
+ const [oauthClients, apiKeys] = await Promise.all([
2513
+ clientIds.size > 0 && logs.some((log3) => log3.oauthClientId) ? prisma2.oAuthClient.findMany({
2514
+ where: {
2515
+ id: { in: Array.from(clientIds) }
2516
+ },
2517
+ select: { id: true, clientName: true }
2518
+ }) : Promise.resolve([]),
2519
+ clientIds.size > 0 && logs.some((log3) => log3.apiKeyId) ? prisma2.apiKey.findMany({
2520
+ where: {
2521
+ id: { in: Array.from(clientIds) },
2522
+ tenantId
2523
+ },
2524
+ select: { id: true, name: true }
2525
+ }) : Promise.resolve([])
2526
+ ]);
2527
+ const clientNameMap = /* @__PURE__ */ new Map();
2528
+ oauthClients.forEach((client) => {
2529
+ clientNameMap.set(client.id, client.clientName);
2530
+ });
2531
+ apiKeys.forEach((key) => {
2532
+ clientNameMap.set(key.id, key.name);
2533
+ });
2534
+ const responseLogs = logs.filter((log3) => log3.createdAt && !isNaN(new Date(log3.createdAt).getTime())).map((log3) => ({
2535
+ id: log3.id,
2536
+ toolName: log3.toolName,
2537
+ toolModule: log3.toolModule ?? void 0,
2538
+ status: log3.status,
2539
+ clientType: log3.clientType,
2540
+ clientName: log3.oauthClientId ? clientNameMap.get(log3.oauthClientId) : log3.apiKeyId ? clientNameMap.get(log3.apiKeyId) : void 0,
2541
+ shopDomain: log3.shopDomain ?? void 0,
2542
+ durationMs: log3.durationMs ?? void 0,
2543
+ createdAt: new Date(log3.createdAt).toISOString()
2544
+ }));
2545
+ const response = {
2546
+ logs: responseLogs,
2547
+ pagination: {
2548
+ page,
2549
+ limit,
2550
+ total,
2551
+ hasMore: skip + take < total
2552
+ }
2553
+ };
2554
+ return res.json(response);
2555
+ } catch (error) {
2556
+ logger3.error("logs endpoint error", error, {
2557
+ correlationId: req.headers["x-correlation-id"]
2558
+ });
2559
+ return res.status(500).json({
2560
+ error: "Internal Server Error",
2561
+ message: "Failed to fetch activity logs"
2562
+ });
2563
+ }
2564
+ });
2565
+ router.get("/stats", async (req, res) => {
2566
+ try {
2567
+ const tenantId = req.tenantContext?.tenantId;
2568
+ if (!tenantId) {
2569
+ return res.status(401).json({
2570
+ error: "Unauthorized",
2571
+ message: "Tenant context not found"
2572
+ });
2573
+ }
2574
+ const parsed = activityStatsQuerySchema.safeParse(req.query);
2575
+ if (!parsed.success) {
2576
+ return res.status(400).json({
2577
+ error: "Validation Error",
2578
+ message: "Invalid query parameters",
2579
+ details: parsed.error.errors
2580
+ });
2581
+ }
2582
+ const { period } = parsed.data;
2583
+ const now = /* @__PURE__ */ new Date();
2584
+ const days = period === "7d" ? 7 : period === "30d" ? 30 : 90;
2585
+ const startDate = new Date(now);
2586
+ startDate.setDate(startDate.getDate() - days);
2587
+ const baseWhere = {
2588
+ tenantId,
2589
+ createdAt: { gte: startDate }
2590
+ };
2591
+ const [totalCalls, successCount, avgDuration, topTools, byClientGroup, byShopGroup] = await Promise.all([
2592
+ // Total calls
2593
+ prisma2.toolExecutionLog.count({ where: baseWhere }),
2594
+ // Success count
2595
+ prisma2.toolExecutionLog.count({
2596
+ where: {
2597
+ ...baseWhere,
2598
+ status: "SUCCESS"
2599
+ }
2600
+ }),
2601
+ // Average duration
2602
+ prisma2.toolExecutionLog.aggregate({
2603
+ where: baseWhere,
2604
+ _avg: { durationMs: true }
2605
+ }),
2606
+ // Top 10 tools
2607
+ prisma2.toolExecutionLog.groupBy({
2608
+ by: ["toolName"],
2609
+ where: baseWhere,
2610
+ _count: { id: true },
2611
+ orderBy: { _count: { id: "desc" } },
2612
+ take: 10
2613
+ }),
2614
+ // By client
2615
+ prisma2.toolExecutionLog.groupBy({
2616
+ by: ["clientType", "oauthClientId", "apiKeyId"],
2617
+ where: baseWhere,
2618
+ _count: { id: true }
2619
+ }),
2620
+ // By shop
2621
+ prisma2.toolExecutionLog.groupBy({
2622
+ by: ["shopDomain"],
2623
+ where: {
2624
+ ...baseWhere,
2625
+ shopDomain: { not: null }
2626
+ },
2627
+ _count: { id: true }
2628
+ })
2629
+ ]);
2630
+ const byDayRaw = await prisma2.$queryRaw`
2631
+ SELECT
2632
+ date_trunc('day', created_at)::date AS date,
2633
+ COUNT(*)::bigint AS calls,
2634
+ COUNT(*) FILTER (WHERE status != 'SUCCESS')::bigint AS errors
2635
+ FROM tool_execution_logs
2636
+ WHERE tenant_id = ${tenantId}
2637
+ AND created_at >= ${startDate}
2638
+ GROUP BY 1
2639
+ ORDER BY 1 ASC
2640
+ `;
2641
+ const clientIds = /* @__PURE__ */ new Set();
2642
+ byClientGroup.forEach((group) => {
2643
+ if (group.oauthClientId) clientIds.add(group.oauthClientId);
2644
+ if (group.apiKeyId) clientIds.add(group.apiKeyId);
2645
+ });
2646
+ const [oauthClients, apiKeys] = await Promise.all([
2647
+ clientIds.size > 0 && byClientGroup.some((g) => g.oauthClientId) ? prisma2.oAuthClient.findMany({
2648
+ where: {
2649
+ id: { in: Array.from(clientIds) }
2650
+ },
2651
+ select: { id: true, clientName: true }
2652
+ }) : Promise.resolve([]),
2653
+ clientIds.size > 0 && byClientGroup.some((g) => g.apiKeyId) ? prisma2.apiKey.findMany({
2654
+ where: {
2655
+ id: { in: Array.from(clientIds) },
2656
+ tenantId
2657
+ },
2658
+ select: { id: true, name: true }
2659
+ }) : Promise.resolve([])
2660
+ ]);
2661
+ const clientNameMap = /* @__PURE__ */ new Map();
2662
+ oauthClients.forEach((client) => {
2663
+ clientNameMap.set(client.id, client.clientName);
2664
+ });
2665
+ apiKeys.forEach((key) => {
2666
+ clientNameMap.set(key.id, key.name);
2667
+ });
2668
+ const successRate = totalCalls > 0 ? successCount / totalCalls * 100 : 0;
2669
+ const avgDurationMs = avgDuration._avg.durationMs ?? 0;
2670
+ const topToolsFormatted = topTools.map((tool) => ({
2671
+ name: tool.toolName,
2672
+ calls: tool._count.id
2673
+ }));
2674
+ const byClientFormatted = byClientGroup.map((group) => ({
2675
+ clientId: group.oauthClientId ?? void 0,
2676
+ clientName: group.oauthClientId ? clientNameMap.get(group.oauthClientId) : void 0,
2677
+ apiKeyId: group.apiKeyId ?? void 0,
2678
+ keyName: group.apiKeyId ? clientNameMap.get(group.apiKeyId) : void 0,
2679
+ type: group.clientType === "oauth_client" ? "oauth" : "api_key",
2680
+ calls: group._count.id
2681
+ }));
2682
+ const byShopFormatted = byShopGroup.filter((group) => group.shopDomain).map((group) => ({
2683
+ shopDomain: group.shopDomain,
2684
+ calls: group._count.id
2685
+ }));
2686
+ const byDayFormatted = byDayRaw.filter((row) => row.date && !isNaN(new Date(row.date).getTime())).map((row) => ({
2687
+ date: new Date(row.date).toISOString().split("T")[0],
2688
+ // YYYY-MM-DD
2689
+ calls: Number(row.calls),
2690
+ errors: Number(row.errors)
2691
+ }));
2692
+ const response = {
2693
+ period,
2694
+ totalCalls,
2695
+ successRate: Math.round(successRate * 100) / 100,
2696
+ // Round to 2 decimal places
2697
+ avgDurationMs: Math.round(avgDurationMs),
2698
+ topTools: topToolsFormatted,
2699
+ byClient: byClientFormatted,
2700
+ byShop: byShopFormatted,
2701
+ byDay: byDayFormatted
2702
+ };
2703
+ return res.json(response);
2704
+ } catch (error) {
2705
+ logger3.error("stats endpoint error", error, {
2706
+ correlationId: req.headers["x-correlation-id"]
2707
+ });
2708
+ return res.status(500).json({
2709
+ error: "Internal Server Error",
2710
+ message: "Failed to compute activity statistics"
2711
+ });
2712
+ }
2713
+ });
2650
2714
  return router;
2651
2715
  }
2652
2716
 
2653
2717
  // src/api/oauth-clients.ts
2654
2718
  import { Router as Router6 } from "express";
2719
+ var logger4 = createLogger("api/oauth-clients");
2655
2720
  var OAUTH_CLIENT_REVOKE_ACTION = "oauth_client:revoke";
2656
2721
  function createOAuthClientsRouter(options) {
2657
2722
  const { auditLogger: auditLogger2 } = options;
@@ -2676,6 +2741,129 @@ function createOAuthClientsRouter(options) {
2676
2741
  });
2677
2742
  }
2678
2743
  });
2744
+ router.get("/:clientId/metrics", async (req, res) => {
2745
+ try {
2746
+ const tenantId = req.tenantContext?.tenantId;
2747
+ if (!tenantId) {
2748
+ return res.status(401).json({
2749
+ error: "Unauthorized",
2750
+ message: "Tenant context not found"
2751
+ });
2752
+ }
2753
+ const { clientId } = req.params;
2754
+ const clientExists = await prisma2.oAuthRefreshToken.findFirst({
2755
+ where: {
2756
+ clientId,
2757
+ tenantId,
2758
+ revokedAt: null
2759
+ }
2760
+ });
2761
+ if (!clientExists) {
2762
+ return res.status(404).json({
2763
+ error: "Not Found",
2764
+ message: "OAuth client not found or no active tokens"
2765
+ });
2766
+ }
2767
+ const now = /* @__PURE__ */ new Date();
2768
+ const sevenDaysAgo = new Date(now);
2769
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
2770
+ const thirtyDaysAgo = new Date(now);
2771
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
2772
+ const baseWhere = {
2773
+ tenantId,
2774
+ oauthClientId: clientId
2775
+ };
2776
+ const queryStartTime = Date.now();
2777
+ const [calls7d, calls30d, totalCalls, successCount, avgDuration, topTools, lastUsed] = await Promise.all([
2778
+ // Calls in last 7 days
2779
+ prisma2.toolExecutionLog.count({
2780
+ where: {
2781
+ ...baseWhere,
2782
+ createdAt: { gte: sevenDaysAgo }
2783
+ }
2784
+ }),
2785
+ // Calls in last 30 days
2786
+ prisma2.toolExecutionLog.count({
2787
+ where: {
2788
+ ...baseWhere,
2789
+ createdAt: { gte: thirtyDaysAgo }
2790
+ }
2791
+ }),
2792
+ // Total calls (for success rate calculation)
2793
+ prisma2.toolExecutionLog.count({
2794
+ where: {
2795
+ ...baseWhere,
2796
+ createdAt: { gte: sevenDaysAgo }
2797
+ }
2798
+ }),
2799
+ // Success count (for success rate calculation)
2800
+ prisma2.toolExecutionLog.count({
2801
+ where: {
2802
+ ...baseWhere,
2803
+ createdAt: { gte: sevenDaysAgo },
2804
+ status: "SUCCESS"
2805
+ }
2806
+ }),
2807
+ // Average duration
2808
+ prisma2.toolExecutionLog.aggregate({
2809
+ where: {
2810
+ ...baseWhere,
2811
+ createdAt: { gte: sevenDaysAgo },
2812
+ durationMs: { not: null }
2813
+ },
2814
+ _avg: { durationMs: true }
2815
+ }),
2816
+ // Top 3 tools by call count in last 7 days
2817
+ prisma2.toolExecutionLog.groupBy({
2818
+ by: ["toolName"],
2819
+ where: {
2820
+ ...baseWhere,
2821
+ createdAt: { gte: sevenDaysAgo }
2822
+ },
2823
+ _count: { id: true },
2824
+ orderBy: { _count: { id: "desc" } },
2825
+ take: 3
2826
+ }),
2827
+ // Most recent execution
2828
+ prisma2.toolExecutionLog.findFirst({
2829
+ where: baseWhere,
2830
+ orderBy: { createdAt: "desc" },
2831
+ select: { createdAt: true }
2832
+ })
2833
+ ]);
2834
+ const successRate = totalCalls > 0 ? successCount / totalCalls * 100 : 0;
2835
+ const topToolsFormatted = topTools.map((tool) => ({
2836
+ name: tool.toolName,
2837
+ calls: tool._count.id
2838
+ }));
2839
+ const queryDuration = Date.now() - queryStartTime;
2840
+ if (queryDuration > 200) {
2841
+ logger4.warn("OAuth client metrics query exceeded 200ms", {
2842
+ clientId,
2843
+ tenantId,
2844
+ durationMs: queryDuration
2845
+ });
2846
+ }
2847
+ const response = {
2848
+ calls7d,
2849
+ calls30d,
2850
+ successRate: Math.round(successRate * 10) / 10,
2851
+ // Round to 1 decimal place
2852
+ avgDurationMs: Math.round(avgDuration._avg.durationMs ?? 0),
2853
+ topTools: topToolsFormatted,
2854
+ lastUsedAt: lastUsed?.createdAt.toISOString() ?? null
2855
+ };
2856
+ return res.json(response);
2857
+ } catch (error) {
2858
+ logger4.error("metrics endpoint error", error, {
2859
+ correlationId: req.headers["x-correlation-id"]
2860
+ });
2861
+ return res.status(500).json({
2862
+ error: "Internal Server Error",
2863
+ message: "Failed to compute OAuth client metrics"
2864
+ });
2865
+ }
2866
+ });
2679
2867
  router.delete("/:clientId", async (req, res) => {
2680
2868
  try {
2681
2869
  const tenantId = req.tenantContext?.tenantId;
@@ -2826,7 +3014,7 @@ var oauthClientsRouter = createOAuthClientsRouter({
2826
3014
  });
2827
3015
 
2828
3016
  // src/lifecycle/shutdown.ts
2829
- var logger2 = createLogger("lifecycle/shutdown");
3017
+ var logger5 = createLogger("lifecycle/shutdown");
2830
3018
  var ShutdownManager = class {
2831
3019
  /** Current shutdown state */
2832
3020
  _state = "running";
@@ -2956,20 +3144,20 @@ var ShutdownManager = class {
2956
3144
  */
2957
3145
  registerSignalHandlers() {
2958
3146
  process.on("SIGTERM", () => {
2959
- logger2.info("Received SIGTERM signal");
3147
+ logger5.info("Received SIGTERM signal");
2960
3148
  this.initiateShutdown().catch((error) => {
2961
- logger2.error("Shutdown failed", error instanceof Error ? error : void 0);
3149
+ logger5.error("Shutdown failed", error instanceof Error ? error : void 0);
2962
3150
  process.exit(1);
2963
3151
  });
2964
3152
  });
2965
3153
  process.on("SIGINT", () => {
2966
- logger2.info("Received SIGINT signal");
3154
+ logger5.info("Received SIGINT signal");
2967
3155
  this.initiateShutdown().catch((error) => {
2968
- logger2.error("Shutdown failed", error instanceof Error ? error : void 0);
3156
+ logger5.error("Shutdown failed", error instanceof Error ? error : void 0);
2969
3157
  process.exit(1);
2970
3158
  });
2971
3159
  });
2972
- logger2.debug("Signal handlers registered (SIGTERM, SIGINT)");
3160
+ logger5.debug("Signal handlers registered (SIGTERM, SIGINT)");
2973
3161
  }
2974
3162
  /**
2975
3163
  * Initiate graceful shutdown
@@ -2987,13 +3175,13 @@ var ShutdownManager = class {
2987
3175
  */
2988
3176
  async initiateShutdown() {
2989
3177
  if (this.shutdownInProgress) {
2990
- logger2.debug("Shutdown already in progress, ignoring duplicate call");
3178
+ logger5.debug("Shutdown already in progress, ignoring duplicate call");
2991
3179
  return;
2992
3180
  }
2993
3181
  this.shutdownInProgress = true;
2994
3182
  this._state = "draining";
2995
3183
  this.drainingStartedAt = /* @__PURE__ */ new Date();
2996
- logger2.info("Shutdown initiated", {
3184
+ logger5.info("Shutdown initiated", {
2997
3185
  drainTimeoutMs: this.drainTimeoutMs,
2998
3186
  activeConnections: this.getRemainingConnections()
2999
3187
  });
@@ -3009,11 +3197,11 @@ var ShutdownManager = class {
3009
3197
  }
3010
3198
  if (this.mcpSessionsCleanup) {
3011
3199
  try {
3012
- logger2.debug("Closing MCP sessions");
3200
+ logger5.debug("Closing MCP sessions");
3013
3201
  await this.withTimeout(this.mcpSessionsCleanup(), 5e3, "MCP sessions cleanup");
3014
- logger2.debug("MCP sessions closed");
3202
+ logger5.debug("MCP sessions closed");
3015
3203
  } catch (error) {
3016
- logger2.error("Failed to close MCP sessions", error instanceof Error ? error : void 0);
3204
+ logger5.error("Failed to close MCP sessions", error instanceof Error ? error : void 0);
3017
3205
  }
3018
3206
  }
3019
3207
  const summary = await this.runCleanupCallbacks();
@@ -3031,14 +3219,14 @@ var ShutdownManager = class {
3031
3219
  resolve();
3032
3220
  return;
3033
3221
  }
3034
- logger2.debug("Stopping HTTP server from accepting new connections");
3222
+ logger5.debug("Stopping HTTP server from accepting new connections");
3035
3223
  this.httpServer.close((error) => {
3036
3224
  if (error) {
3037
3225
  if (error.code !== "ERR_SERVER_NOT_RUNNING") {
3038
- logger2.warn("HTTP server close error", { error: error.message });
3226
+ logger5.warn("HTTP server close error", { error: error.message });
3039
3227
  }
3040
3228
  }
3041
- logger2.debug("HTTP server stopped accepting connections");
3229
+ logger5.debug("HTTP server stopped accepting connections");
3042
3230
  resolve();
3043
3231
  });
3044
3232
  });
@@ -3051,7 +3239,7 @@ var ShutdownManager = class {
3051
3239
  while (this.getRemainingConnections() > 0) {
3052
3240
  await new Promise((resolve) => setTimeout(resolve, pollInterval));
3053
3241
  }
3054
- logger2.debug("All connections drained");
3242
+ logger5.debug("All connections drained");
3055
3243
  }
3056
3244
  /**
3057
3245
  * Create drain timeout promise
@@ -3059,7 +3247,7 @@ var ShutdownManager = class {
3059
3247
  createDrainTimeout() {
3060
3248
  return new Promise((resolve) => {
3061
3249
  this.drainTimeoutTimer = setTimeout(() => {
3062
- logger2.warn("Drain timeout reached, proceeding with shutdown", {
3250
+ logger5.warn("Drain timeout reached, proceeding with shutdown", {
3063
3251
  remainingConnections: this.getRemainingConnections(),
3064
3252
  drainTimeoutMs: this.drainTimeoutMs
3065
3253
  });
@@ -3074,7 +3262,7 @@ var ShutdownManager = class {
3074
3262
  async runCleanupCallbacks() {
3075
3263
  const drainTimeMs = this.drainingStartedAt ? Date.now() - this.drainingStartedAt.getTime() : 0;
3076
3264
  let cleanupErrors = 0;
3077
- logger2.debug("Running cleanup callbacks", {
3265
+ logger5.debug("Running cleanup callbacks", {
3078
3266
  count: this.cleanupCallbacks.length
3079
3267
  });
3080
3268
  for (let i = 0; i < this.cleanupCallbacks.length; i++) {
@@ -3087,7 +3275,7 @@ var ShutdownManager = class {
3087
3275
  );
3088
3276
  } catch (error) {
3089
3277
  cleanupErrors++;
3090
- logger2.error(
3278
+ logger5.error(
3091
3279
  `Cleanup callback ${i + 1} failed`,
3092
3280
  error instanceof Error ? error : void 0
3093
3281
  );
@@ -3121,7 +3309,7 @@ var ShutdownManager = class {
3121
3309
  * Log final shutdown summary
3122
3310
  */
3123
3311
  logShutdownSummary(summary) {
3124
- logger2.info("Shutdown complete", {
3312
+ logger5.info("Shutdown complete", {
3125
3313
  drainTimeMs: summary.drainTimeMs,
3126
3314
  cleanupCallbacksRun: summary.cleanupCallbacksRun,
3127
3315
  cleanupErrors: summary.cleanupErrors,
@@ -3208,36 +3396,6 @@ var ShutdownManager = class {
3208
3396
  };
3209
3397
  var shutdownManager = new ShutdownManager();
3210
3398
 
3211
- // src/logging/correlation.ts
3212
- import { randomUUID as randomUUID2 } from "crypto";
3213
- var CORRELATION_ID_HEADER = "X-Correlation-ID";
3214
- var UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
3215
- function generateCorrelationId() {
3216
- return randomUUID2();
3217
- }
3218
- function isValidCorrelationId(id) {
3219
- return UUID_V4_REGEX.test(id);
3220
- }
3221
- function correlationMiddleware(req, res, next) {
3222
- const headerValue = req.headers[CORRELATION_ID_HEADER.toLowerCase()];
3223
- let correlationId;
3224
- if (headerValue && isValidCorrelationId(headerValue)) {
3225
- correlationId = headerValue;
3226
- } else {
3227
- correlationId = generateCorrelationId();
3228
- }
3229
- res.locals.correlationId = correlationId;
3230
- if (typeof res.set === "function") {
3231
- res.set(
3232
- CORRELATION_ID_HEADER,
3233
- correlationId
3234
- );
3235
- } else {
3236
- res.setHeader(CORRELATION_ID_HEADER, correlationId);
3237
- }
3238
- next();
3239
- }
3240
-
3241
3399
  // src/metrics/registry.ts
3242
3400
  import { Counter, Gauge, Histogram, Registry, collectDefaultMetrics } from "prom-client";
3243
3401
  var METRICS_PREFIX = "shopify_mcp_";
@@ -3920,10 +4078,10 @@ var OAuthDiscoveryService = class {
3920
4078
  // Used by ChatGPT for automatic client registration
3921
4079
  registration_endpoint: `${this.baseUrl}/oauth/register`,
3922
4080
  // Supported authentication methods at token endpoint
3923
- // Client authentication is required for token exchange
3924
4081
  // "client_secret_post" - credentials in request body (client_id, client_secret)
3925
4082
  // "client_secret_basic" - credentials in Authorization header (Basic auth)
3926
- token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
4083
+ // "none" - public client with PKCE only (required for Claude.ai)
4084
+ token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic", "none"],
3927
4085
  // Supported grant types
3928
4086
  // authorization_code: Standard OAuth 2.0 flow with PKCE
3929
4087
  // refresh_token: For obtaining new access tokens
@@ -3931,6 +4089,9 @@ var OAuthDiscoveryService = class {
3931
4089
  // Response types for authorization endpoint
3932
4090
  // "code" for authorization code flow
3933
4091
  response_types_supported: ["code"],
4092
+ // Response modes for authorization endpoint
4093
+ // "query" returns code in URL query string (standard for auth code flow)
4094
+ response_modes_supported: ["query"],
3934
4095
  // PKCE code challenge methods supported (when PKCE is used)
3935
4096
  // S256 is the only method supported - plain is NOT allowed
3936
4097
  // Note: PKCE is optional for confidential clients (with client_secret)
@@ -4014,6 +4175,7 @@ var CLIENT_SECRET_BYTES = 16;
4014
4175
  var BCRYPT_COST_FACTOR2 = 12;
4015
4176
  var DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"];
4016
4177
  var DEFAULT_SCOPES3 = ["mcp:full"];
4178
+ var PUBLIC_CLIENT_MARKER = "PUBLIC_CLIENT:none";
4017
4179
  var OAuthRegistrationService = class {
4018
4180
  prisma;
4019
4181
  /**
@@ -4084,22 +4246,40 @@ var OAuthRegistrationService = class {
4084
4246
  * hashed secret in the database, and returns RFC 7591 compliant
4085
4247
  * response with the plaintext secret (one-time reveal).
4086
4248
  *
4249
+ * Supports both confidential clients (with client_secret) and
4250
+ * public clients (token_endpoint_auth_method: "none", uses PKCE).
4251
+ *
4087
4252
  * @param request - RFC 7591 client registration request
4088
4253
  * @returns RFC 7591 compliant registration response
4089
4254
  *
4090
4255
  * @throws Error if database operation fails
4091
4256
  *
4092
4257
  * @example
4258
+ * // Confidential client (default)
4093
4259
  * const response = await service.registerClient({
4094
4260
  * client_name: "ChatGPT MCP Client",
4095
4261
  * redirect_uris: ["https://chatgpt.com/aip/g/callback"]
4096
4262
  * });
4097
- * // response.client_secret is only returned once!
4263
+ *
4264
+ * @example
4265
+ * // Public client (no secret, uses PKCE) - required for Claude.ai
4266
+ * const response = await service.registerClient({
4267
+ * client_name: "Claude AI",
4268
+ * redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
4269
+ * token_endpoint_auth_method: "none"
4270
+ * });
4098
4271
  */
4099
4272
  async registerClient(request) {
4100
4273
  const clientId = this.generateClientId();
4101
- const clientSecret = this.generateClientSecret();
4102
- const clientSecretHash = await this.hashClientSecret(clientSecret);
4274
+ const isPublicClient = request.token_endpoint_auth_method === "none";
4275
+ let clientSecret;
4276
+ let clientSecretHash;
4277
+ if (isPublicClient) {
4278
+ clientSecretHash = PUBLIC_CLIENT_MARKER;
4279
+ } else {
4280
+ clientSecret = this.generateClientSecret();
4281
+ clientSecretHash = await this.hashClientSecret(clientSecret);
4282
+ }
4103
4283
  const grantTypes = request.grant_types?.length ? request.grant_types : DEFAULT_GRANT_TYPES;
4104
4284
  const client = await this.prisma.oAuthClient.create({
4105
4285
  data: {
@@ -4111,10 +4291,8 @@ var OAuthRegistrationService = class {
4111
4291
  scopes: DEFAULT_SCOPES3
4112
4292
  }
4113
4293
  });
4114
- return {
4294
+ const response = {
4115
4295
  client_id: clientId,
4116
- client_secret: clientSecret,
4117
- // One-time reveal!
4118
4296
  client_name: request.client_name,
4119
4297
  redirect_uris: request.redirect_uris,
4120
4298
  grant_types: grantTypes,
@@ -4122,6 +4300,10 @@ var OAuthRegistrationService = class {
4122
4300
  client_secret_expires_at: 0
4123
4301
  // Per RFC 7591 Section 3.2.1: 0 means no expiry
4124
4302
  };
4303
+ if (clientSecret) {
4304
+ response.client_secret = clientSecret;
4305
+ }
4306
+ return response;
4125
4307
  }
4126
4308
  /**
4127
4309
  * Find a client by client_id
@@ -4632,15 +4814,16 @@ var OAuthTokenService = class {
4632
4814
  /**
4633
4815
  * Authenticate a client from request
4634
4816
  *
4635
- * Supports two authentication methods:
4817
+ * Supports three authentication methods:
4636
4818
  * - client_secret_post: client_id and client_secret in request body
4637
4819
  * - client_secret_basic: HTTP Basic auth header
4820
+ * - none: public client (no secret), requires PKCE - used by Claude.ai
4638
4821
  *
4639
4822
  * Per RFC 6749 Section 2.3.1
4640
4823
  *
4641
4824
  * @param params - Token request parameters (may contain client_id/secret)
4642
4825
  * @param authorizationHeader - Authorization header value (if present)
4643
- * @returns Authenticated client entity
4826
+ * @returns Authenticated client entity with isPublicClient flag
4644
4827
  * @throws OAuthTokenError if authentication fails
4645
4828
  */
4646
4829
  async authenticateClient(params, authorizationHeader) {
@@ -4675,15 +4858,18 @@ var OAuthTokenService = class {
4675
4858
  has_client_secret: !!clientSecret,
4676
4859
  secret_length: clientSecret?.length || 0
4677
4860
  });
4678
- if (!clientId || !clientSecret) {
4679
- log2.warn("Client authentication failed: missing credentials", {
4680
- has_client_id: !!clientId,
4861
+ if (!clientId) {
4862
+ clientId = params.client_id;
4863
+ }
4864
+ if (!clientId) {
4865
+ log2.warn("Client authentication failed: missing client_id", {
4866
+ has_client_id: false,
4681
4867
  has_client_secret: !!clientSecret,
4682
4868
  auth_method: authMethod
4683
4869
  });
4684
4870
  throw new OAuthTokenError(
4685
4871
  "invalid_client",
4686
- "Client authentication required. Provide client_id and client_secret.",
4872
+ "Client authentication required. Provide client_id.",
4687
4873
  401
4688
4874
  );
4689
4875
  }
@@ -4696,6 +4882,31 @@ var OAuthTokenService = class {
4696
4882
  });
4697
4883
  throw new OAuthTokenError("invalid_client", "Unknown client_id", 401);
4698
4884
  }
4885
+ const isPublicClient = client.clientSecretHash === PUBLIC_CLIENT_MARKER;
4886
+ if (isPublicClient) {
4887
+ log2.info("Public client authenticated (no secret required, PKCE enforced)", {
4888
+ client_id: clientId,
4889
+ client_name: client.clientName,
4890
+ auth_method: "none"
4891
+ });
4892
+ return {
4893
+ clientId: client.clientId,
4894
+ clientSecretHash: client.clientSecretHash,
4895
+ clientName: client.clientName,
4896
+ isPublicClient: true
4897
+ };
4898
+ }
4899
+ if (!clientSecret) {
4900
+ log2.warn("Client authentication failed: missing client_secret for confidential client", {
4901
+ client_id: clientId,
4902
+ client_name: client.clientName
4903
+ });
4904
+ throw new OAuthTokenError(
4905
+ "invalid_client",
4906
+ "Client authentication required. Provide client_id and client_secret.",
4907
+ 401
4908
+ );
4909
+ }
4699
4910
  const isValidSecret = await bcrypt5.compare(clientSecret, client.clientSecretHash);
4700
4911
  if (!isValidSecret) {
4701
4912
  log2.warn("Client authentication failed: invalid client_secret", {
@@ -4712,7 +4923,8 @@ var OAuthTokenService = class {
4712
4923
  return {
4713
4924
  clientId: client.clientId,
4714
4925
  clientSecretHash: client.clientSecretHash,
4715
- clientName: client.clientName
4926
+ clientName: client.clientName,
4927
+ isPublicClient: false
4716
4928
  };
4717
4929
  }
4718
4930
  // ===========================================================================
@@ -4850,6 +5062,19 @@ var OAuthTokenService = class {
4850
5062
  });
4851
5063
  throw new OAuthTokenError("invalid_grant", "Authorization code has already been used");
4852
5064
  }
5065
+ const client = await this.prisma.oAuthClient.findUnique({
5066
+ where: { clientId }
5067
+ });
5068
+ const isPublicClient = client?.clientSecretHash === PUBLIC_CLIENT_MARKER;
5069
+ if (isPublicClient && !authCode.codeChallenge) {
5070
+ log2.warn("Token exchange failed: public client did not use PKCE", {
5071
+ client_id: clientId
5072
+ });
5073
+ throw new OAuthTokenError(
5074
+ "invalid_grant",
5075
+ "Public clients must use PKCE. No code_challenge was provided during authorization."
5076
+ );
5077
+ }
4853
5078
  if (authCode.codeChallenge) {
4854
5079
  if (!params.code_verifier) {
4855
5080
  throw new OAuthTokenError(
@@ -5072,7 +5297,10 @@ var clientRegistrationRequestSchema = z6.object({
5072
5297
  invalid_type_error: "redirect_uris must be an array"
5073
5298
  }).min(1, "redirect_uris cannot be empty"),
5074
5299
  grant_types: z6.array(z6.string()).optional(),
5075
- response_types: z6.array(z6.string()).optional()
5300
+ response_types: z6.array(z6.string()).optional(),
5301
+ // Token endpoint authentication method (RFC 7591)
5302
+ // 'none' = public client (no client_secret, uses PKCE) - required for Claude.ai
5303
+ token_endpoint_auth_method: z6.enum(["client_secret_post", "client_secret_basic", "none"]).optional()
5076
5304
  });
5077
5305
  function validateClientRegistrationRequest(data) {
5078
5306
  const result = clientRegistrationRequestSchema.safeParse(data);
@@ -5101,6 +5329,7 @@ var TokenRefreshService = class {
5101
5329
  cryptoService;
5102
5330
  config;
5103
5331
  refreshInterval = null;
5332
+ clientPool = null;
5104
5333
  constructor(prisma2, cryptoService, config) {
5105
5334
  this.prisma = prisma2;
5106
5335
  this.cryptoService = cryptoService;
@@ -5112,6 +5341,14 @@ var TokenRefreshService = class {
5112
5341
  };
5113
5342
  log.debug("TokenRefreshService initialized");
5114
5343
  }
5344
+ /**
5345
+ * Set the client pool for cache eviction after token refresh
5346
+ * Must be called after both services are initialized to avoid circular dependency
5347
+ */
5348
+ setClientPool(clientPool) {
5349
+ this.clientPool = clientPool;
5350
+ log.debug("TokenRefreshService: client pool reference set");
5351
+ }
5115
5352
  /**
5116
5353
  * Refresh an access token using the refresh token
5117
5354
  *
@@ -5200,6 +5437,12 @@ var TokenRefreshService = class {
5200
5437
  where: { id: shop.id },
5201
5438
  data: updateData
5202
5439
  });
5440
+ if (this.clientPool) {
5441
+ this.clientPool.evict(shop.tenantId, shop.shopDomain);
5442
+ log.debug(
5443
+ `Evicted cached client for ${shop.shopDomain.substring(0, 20)} after token refresh`
5444
+ );
5445
+ }
5203
5446
  log.info(
5204
5447
  `Token refreshed successfully for ${shop.shopDomain.substring(0, 20)} (expires in ${tokenData.expires_in}s)`
5205
5448
  );
@@ -6073,8 +6316,8 @@ async function createHttpTransport(server) {
6073
6316
  );
6074
6317
  log.info(`CORS middleware enabled with ${allowedOrigins.length} allowed origin(s)`);
6075
6318
  app.use((req, res, next) => {
6076
- const isOAuthPage = req.path.startsWith("/oauth/authorize") || req.path.startsWith("/app/oauth/");
6077
- const frameAncestors = isOAuthPage ? "frame-ancestors 'self' https://claude.ai https://*.anthropic.com" : "frame-ancestors 'none'";
6319
+ const isOAuthRelatedPage = req.path.startsWith("/oauth/authorize") || req.path.startsWith("/app/oauth/") || req.path.startsWith("/app/login") || req.path === "/app" || req.path === "/app/";
6320
+ const frameAncestors = isOAuthRelatedPage ? "frame-ancestors 'self' https://claude.ai https://*.anthropic.com https://*.claude.ai" : "frame-ancestors 'none'";
6078
6321
  res.setHeader(
6079
6322
  "Content-Security-Policy",
6080
6323
  [
@@ -6086,7 +6329,8 @@ async function createHttpTransport(server) {
6086
6329
  "connect-src 'self' https://*.myshopify.com",
6087
6330
  frameAncestors,
6088
6331
  "base-uri 'self'",
6089
- "form-action 'self' https://claude.ai"
6332
+ // Allow form submissions to Claude and Anthropic domains for OAuth redirects
6333
+ "form-action 'self' https://claude.ai https://*.claude.ai https://*.anthropic.com"
6090
6334
  ].join("; ")
6091
6335
  );
6092
6336
  next();
@@ -6111,6 +6355,9 @@ async function createHttpTransport(server) {
6111
6355
  clientId: config.SHOPIFY_CLIENT_ID,
6112
6356
  clientSecret: config.SHOPIFY_CLIENT_SECRET
6113
6357
  });
6358
+ if (clientPool) {
6359
+ tokenRefreshService.setClientPool(clientPool);
6360
+ }
6114
6361
  tokenRefreshService.startBackgroundRefresh();
6115
6362
  log.info("TokenRefreshService initialized (background refresh enabled)");
6116
6363
  } else {
@@ -6205,6 +6452,50 @@ async function createHttpTransport(server) {
6205
6452
  }
6206
6453
  next();
6207
6454
  });
6455
+ app.head("/mcp", async (req, res) => {
6456
+ const authHeader = req.headers.authorization;
6457
+ const bearerToken = typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : void 0;
6458
+ log.info("[mcp] HEAD /mcp probe request", {
6459
+ hasAuth: !!bearerToken,
6460
+ userAgent: req.headers["user-agent"]
6461
+ });
6462
+ if (!bearerToken) {
6463
+ const wwwAuth2 = oauthDiscoveryService.getWwwAuthenticateHeader(
6464
+ "invalid_token",
6465
+ "Authentication required"
6466
+ );
6467
+ res.setHeader("WWW-Authenticate", wwwAuth2);
6468
+ res.status(401).json({
6469
+ error: "unauthorized",
6470
+ message: "Authentication required. Initiate OAuth flow."
6471
+ });
6472
+ return;
6473
+ }
6474
+ if (apiKeyService) {
6475
+ try {
6476
+ const { validateMcpBearerToken } = await import("./mcp-auth-CWOWKID3.js");
6477
+ const authResult = await validateMcpBearerToken(
6478
+ `Bearer ${bearerToken}`,
6479
+ apiKeyService,
6480
+ getPrismaClient()
6481
+ );
6482
+ if (authResult.valid) {
6483
+ res.status(200).end();
6484
+ return;
6485
+ }
6486
+ } catch {
6487
+ }
6488
+ }
6489
+ const wwwAuth = oauthDiscoveryService.getWwwAuthenticateHeader(
6490
+ "invalid_token",
6491
+ "Invalid or expired token"
6492
+ );
6493
+ res.setHeader("WWW-Authenticate", wwwAuth);
6494
+ res.status(401).json({
6495
+ error: "unauthorized",
6496
+ message: "Invalid or expired token"
6497
+ });
6498
+ });
6208
6499
  app.post("/mcp", async (req, res) => {
6209
6500
  try {
6210
6501
  const authHeader = req.headers.authorization;
@@ -6252,7 +6543,7 @@ async function createHttpTransport(server) {
6252
6543
  }
6253
6544
  if (sessionContext && methodRequiresShop && isRemote && apiKeyService && clientPool && prisma2 && (!sessionContext.requestContext?.client || !sessionContext.requestContext?.shopDomain)) {
6254
6545
  log.debug(`[mcp] Lazy loading context for existing session: ${sessionPrefix}...`);
6255
- const { validateMcpBearerToken } = await import("./mcp-auth-F25V6FEY.js");
6546
+ const { validateMcpBearerToken } = await import("./mcp-auth-CWOWKID3.js");
6256
6547
  const authResult = await validateMcpBearerToken(
6257
6548
  req.headers.authorization,
6258
6549
  apiKeyService,
@@ -6356,7 +6647,7 @@ async function createHttpTransport(server) {
6356
6647
  let shopifyClient;
6357
6648
  let multiTenantContext;
6358
6649
  if (isRemote && apiKeyService && clientPool && prisma2) {
6359
- const { validateMcpBearerToken } = await import("./mcp-auth-F25V6FEY.js");
6650
+ const { validateMcpBearerToken } = await import("./mcp-auth-CWOWKID3.js");
6360
6651
  const authResult = await validateMcpBearerToken(
6361
6652
  req.headers.authorization,
6362
6653
  apiKeyService,
@@ -6447,7 +6738,7 @@ async function createHttpTransport(server) {
6447
6738
  `[mcp] Creating new transport: isInitReq=${isInitReq}, has_tenant=${!!mcpTenantContext}, has_client=${!!shopifyClient}`
6448
6739
  );
6449
6740
  transport = new StreamableHTTPServerTransport({
6450
- sessionIdGenerator: () => randomUUID3(),
6741
+ sessionIdGenerator: () => randomUUID2(),
6451
6742
  onsessioninitialized: (newSessionId) => {
6452
6743
  log.info(`[mcp] Session initialized: ${newSessionId.substring(0, 8)}...`);
6453
6744
  transports.set(newSessionId, transport);
@@ -6500,7 +6791,7 @@ async function createHttpTransport(server) {
6500
6791
  });
6501
6792
  await server.connect(transport);
6502
6793
  log.info("[mcp] Transport connected to server, handling request...");
6503
- const tempContextKey = multiTenantContext?.correlationId || randomUUID3();
6794
+ const tempContextKey = multiTenantContext?.correlationId || randomUUID2();
6504
6795
  try {
6505
6796
  if (multiTenantContext) {
6506
6797
  setFallbackContext(tempContextKey, multiTenantContext);
@@ -6639,7 +6930,7 @@ async function createHttpTransport(server) {
6639
6930
  let shopifyClient;
6640
6931
  let multiTenantContext;
6641
6932
  if (isRemote && apiKeyService && clientPool && prisma2) {
6642
- const { validateMcpBearerToken } = await import("./mcp-auth-F25V6FEY.js");
6933
+ const { validateMcpBearerToken } = await import("./mcp-auth-CWOWKID3.js");
6643
6934
  const authResult = await validateMcpBearerToken(
6644
6935
  req.headers.authorization,
6645
6936
  apiKeyService,
@@ -6804,7 +7095,7 @@ data: ${JSON.stringify({
6804
7095
  const isRemote = isRemoteMode(config);
6805
7096
  const prisma2 = isRemote ? getPrismaClient() : null;
6806
7097
  if (isRemote && apiKeyService && prisma2) {
6807
- const { validateMcpBearerToken } = await import("./mcp-auth-F25V6FEY.js");
7098
+ const { validateMcpBearerToken } = await import("./mcp-auth-CWOWKID3.js");
6808
7099
  const authResult = await validateMcpBearerToken(
6809
7100
  req.headers.authorization,
6810
7101
  apiKeyService,
@@ -6855,7 +7146,7 @@ data: ${JSON.stringify({
6855
7146
  return;
6856
7147
  }
6857
7148
  if (body.method === "tools/list") {
6858
- const { getRegisteredTools } = await import("./tools-HVUCP53D.js");
7149
+ const { getRegisteredTools } = await import("./tools-BCI3Z2AW.js");
6859
7150
  const tools = getRegisteredTools();
6860
7151
  const response = {
6861
7152
  jsonrpc: "2.0",
@@ -6893,7 +7184,7 @@ data: ${JSON.stringify({
6893
7184
  return;
6894
7185
  }
6895
7186
  if (body.method === "tools/call" && isRemote && apiKeyService && clientPool && prisma2) {
6896
- const { validateMcpBearerToken } = await import("./mcp-auth-F25V6FEY.js");
7187
+ const { validateMcpBearerToken } = await import("./mcp-auth-CWOWKID3.js");
6897
7188
  const authResult = await validateMcpBearerToken(
6898
7189
  req.headers.authorization,
6899
7190
  apiKeyService,
@@ -6950,7 +7241,7 @@ data: ${JSON.stringify({
6950
7241
  ),
6951
7242
  correlationId
6952
7243
  };
6953
- const { getToolByName } = await import("./tools-HVUCP53D.js");
7244
+ const { getToolByName } = await import("./tools-BCI3Z2AW.js");
6954
7245
  const params = body.params;
6955
7246
  const rawToolName = params?.name;
6956
7247
  const toolArgs = params?.arguments || {};
@@ -7143,6 +7434,31 @@ data: ${JSON.stringify({
7143
7434
  });
7144
7435
  }
7145
7436
  });
7437
+ const faviconPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKklEQVR42u3NQQkAAAgEsItnQutYzRQ+hMH+S0+dikAgEAgEAoFAIPgSLM8edFuULS3fAAAAAElFTkSuQmCC";
7438
+ app.get("/favicon.ico", (_req, res) => {
7439
+ const faviconBuffer = Buffer.from(faviconPngBase64, "base64");
7440
+ res.setHeader("Content-Type", "image/png");
7441
+ res.setHeader("Content-Length", faviconBuffer.length.toString());
7442
+ res.setHeader("Cache-Control", "public, max-age=86400");
7443
+ res.send(faviconBuffer);
7444
+ });
7445
+ app.get("/favicon.png", (_req, res) => {
7446
+ const faviconBuffer = Buffer.from(faviconPngBase64, "base64");
7447
+ res.setHeader("Content-Type", "image/png");
7448
+ res.setHeader("Content-Length", faviconBuffer.length.toString());
7449
+ res.setHeader("Cache-Control", "public, max-age=86400");
7450
+ res.send(faviconBuffer);
7451
+ });
7452
+ app.get("/favicon.svg", (_req, res) => {
7453
+ const svgFavicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
7454
+ <rect width="32" height="32" rx="6" fill="#96BF48"/>
7455
+ <path d="M10 8h12l2 6v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V14l2-6z" fill="white" stroke="white" stroke-width="1"/>
7456
+ <path d="M12 8v-2a4 4 0 0 1 8 0v2" fill="none" stroke="#96BF48" stroke-width="2" stroke-linecap="round"/>
7457
+ </svg>`;
7458
+ res.setHeader("Content-Type", "image/svg+xml");
7459
+ res.setHeader("Cache-Control", "public, max-age=86400");
7460
+ res.send(svgFavicon);
7461
+ });
7146
7462
  const oauthDiscoveryService = createOAuthDiscoveryService(discoveryBaseUrl);
7147
7463
  app.get("/.well-known/oauth-authorization-server", (_req, res) => {
7148
7464
  const metadata = oauthDiscoveryService.getAuthorizationServerMetadata();
@@ -7184,18 +7500,43 @@ data: ${JSON.stringify({
7184
7500
  const oauthAuthorizeService = createOAuthAuthorizeService(void 0, cryptoService || void 0);
7185
7501
  app.get("/oauth/authorize", async (req, res) => {
7186
7502
  try {
7503
+ const userAgent = req.headers["user-agent"] || "unknown";
7504
+ const origin = req.headers.origin || req.headers.referer || "none";
7505
+ const clientId = req.query.client_id;
7506
+ log.info("[OAuth] GET /oauth/authorize request", {
7507
+ client_id: clientId?.substring(0, 20),
7508
+ has_session_cookie: !!req.cookies?.session_id,
7509
+ origin: typeof origin === "string" ? origin.substring(0, 50) : "none",
7510
+ user_agent: userAgent.substring(0, 80),
7511
+ redirect_uri: req.query.redirect_uri?.substring(0, 80),
7512
+ // Check if this looks like a Claude request
7513
+ is_claude_origin: origin.includes("claude.ai") || origin.includes("anthropic.com")
7514
+ });
7187
7515
  const sessionId = req.cookies?.session_id;
7188
7516
  if (!sessionId) {
7517
+ log.info("[OAuth] No session cookie - redirecting to login", {
7518
+ client_id: clientId?.substring(0, 20),
7519
+ is_iframe: req.headers["sec-fetch-dest"] === "iframe"
7520
+ });
7189
7521
  const returnUrl = `/oauth/authorize?${new URLSearchParams(req.query).toString()}`;
7190
7522
  res.redirect(`/app/login?redirect=${encodeURIComponent(returnUrl)}`);
7191
7523
  return;
7192
7524
  }
7193
7525
  const session = await sessionStore.get(sessionId);
7194
7526
  if (!session || session.expiresAt < Date.now()) {
7527
+ log.info("[OAuth] Session expired or invalid - redirecting to login", {
7528
+ client_id: clientId?.substring(0, 20),
7529
+ session_exists: !!session,
7530
+ expired: session ? session.expiresAt < Date.now() : "N/A"
7531
+ });
7195
7532
  const returnUrl = `/oauth/authorize?${new URLSearchParams(req.query).toString()}`;
7196
7533
  res.redirect(`/app/login?redirect=${encodeURIComponent(returnUrl)}`);
7197
7534
  return;
7198
7535
  }
7536
+ log.info("[OAuth] Valid session found, proceeding with authorization", {
7537
+ client_id: clientId?.substring(0, 20),
7538
+ tenant_id: session.tenantId.substring(0, 8)
7539
+ });
7199
7540
  const validationResult = await oauthAuthorizeService.validateAuthorizationRequest(
7200
7541
  req.query
7201
7542
  );