hive-intelligence 1.3.0 → 1.4.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/README.md +1 -1
- package/agent-skills/SKILL.md +1 -1
- package/agent-skills/packs/hive-build/SKILL.md +1 -1
- package/agent-skills/packs/hive-build-onboarding/SKILL.md +7 -7
- package/agent-skills/packs/hive-cli/SKILL.md +2 -2
- package/agent-skills/packs/hive-defi-research/SKILL.md +1 -1
- package/agent-skills/packs/hive-dex-pool-analysis/SKILL.md +1 -1
- package/agent-skills/packs/hive-market-research/SKILL.md +1 -1
- package/agent-skills/packs/hive-mcp/SKILL.md +1 -1
- package/agent-skills/packs/hive-network-infrastructure/SKILL.md +1 -1
- package/agent-skills/packs/hive-nft-research/SKILL.md +1 -1
- package/agent-skills/packs/hive-prediction-markets/SKILL.md +1 -1
- package/agent-skills/packs/hive-query/SKILL.md +1 -1
- package/agent-skills/packs/hive-security-risk/SKILL.md +1 -1
- package/agent-skills/packs/hive-solana-analysis/SKILL.md +1 -1
- package/agent-skills/packs/hive-stateful-monitoring/SKILL.md +1 -1
- package/agent-skills/packs/hive-token-diligence/SKILL.md +1 -1
- package/agent-skills/packs/hive-tool-discovery/SKILL.md +1 -1
- package/agent-skills/packs/hive-wallet-investigation/SKILL.md +1 -1
- package/build/{api-client-FTVDFW5V.js → api-client-WMWFWXC7.js} +3 -15
- package/build/{auth-EZFJ5RYB.js → auth-HRFQWQMZ.js} +1 -9
- package/build/browser-auth-KXIO5BHM.js +20 -0
- package/build/{chunk-I5Z6FZRL.js → chunk-3OZFBWFK.js} +14 -12
- package/build/{chunk-N32UIHBU.js → chunk-7DYHWY4Y.js} +1 -1
- package/build/{chunk-7GD2MFYI.js → chunk-AQQAPAER.js} +35 -15
- package/build/{upgrade-J4HIGCKE.js → chunk-GHCYCBA3.js} +1 -2
- package/build/{chunk-Q7VBYLX7.js → chunk-P7NLFSZQ.js} +0 -58
- package/build/{chunk-RUHO24SX.js → chunk-TVQJY47C.js} +2 -157
- package/build/{chunk-W2QL7LYI.js → chunk-UFTNBCSA.js} +885 -101
- package/build/cli.js +28 -39
- package/build/{doctor-ORDOCGLM.js → doctor-GHUAUFS2.js} +3 -3
- package/build/{init-all-BVWW3JVK.js → init-all-RG542NWG.js} +32 -13
- package/build/{mcpServer-RUEOTTF7.js → mcpServer-D7OXUDU5.js} +1 -1
- package/build/monitor-worker.js +2 -2
- package/build/release.json +4 -4
- package/build/{serve-DKLR2WQH.js → serve-K3IT7DS4.js} +1 -1
- package/build/server.js +1429 -415
- package/build/stdio.js +1 -1
- package/build/{tools-I7GLWTV6.js → tools-D72ZH6P5.js} +1 -1
- package/build/upgrade-4S5XT5JG.js +14 -0
- package/build/{watch-WVR4CGCD.js → watch-IOSRSCFE.js} +2 -2
- package/package.json +5 -3
- package/build/browser-auth-OJSUNFBI.js +0 -8
package/build/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
ensureWallet,
|
|
7
7
|
executeToolByName,
|
|
8
8
|
getWalletStatus
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-3OZFBWFK.js";
|
|
10
10
|
import {
|
|
11
11
|
CategoryEndpoints,
|
|
12
12
|
HERO_TOOL_NAMES,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
TASK_TOOLSETS,
|
|
27
27
|
ToolRegistry,
|
|
28
28
|
buildAuthCompatibility,
|
|
29
|
+
buildStatefulWriteAuditEvent,
|
|
29
30
|
buildSubjectBodyDigest,
|
|
30
31
|
buildToolCatalogEntry,
|
|
31
32
|
clientProfileFromName,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
getAnalyticsWriter,
|
|
39
40
|
getAvailableApis,
|
|
40
41
|
getCorsAllowedOrigins,
|
|
42
|
+
getCredentialedRateLimitMax,
|
|
41
43
|
getCsvEnv,
|
|
42
44
|
getEnvVar,
|
|
43
45
|
getMcpCreditCost,
|
|
@@ -53,12 +55,10 @@ import {
|
|
|
53
55
|
getRootVisibleToolCount,
|
|
54
56
|
getSharedClients,
|
|
55
57
|
getSupabaseAdmin,
|
|
56
|
-
getSupabaseAuthClient,
|
|
57
58
|
getTaskCanaryCatalog,
|
|
58
59
|
getTaskCanaryHistory,
|
|
59
60
|
getWorkflowRegressionCatalog,
|
|
60
61
|
getWorkflowRegressionHistory,
|
|
61
|
-
hasEnvVars,
|
|
62
62
|
hasSubjectSigningSecret,
|
|
63
63
|
heroTools,
|
|
64
64
|
isB2BSubjectsEnabled,
|
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
isHiveOAuthAccessToken,
|
|
67
67
|
isProduction,
|
|
68
68
|
isRedisAvailable,
|
|
69
|
+
isRedisConfigured,
|
|
69
70
|
isSupabaseConfigured,
|
|
70
71
|
isUnkeyConfigured,
|
|
71
72
|
isUnkeyRequired,
|
|
@@ -92,8 +93,9 @@ import {
|
|
|
92
93
|
trackOAuthFirstMaterialReceipt,
|
|
93
94
|
trackServerEvent,
|
|
94
95
|
validateEndpointArgs,
|
|
95
|
-
verifyApiKey
|
|
96
|
-
|
|
96
|
+
verifyApiKey,
|
|
97
|
+
waitForRedisAvailability
|
|
98
|
+
} from "./chunk-UFTNBCSA.js";
|
|
97
99
|
import {
|
|
98
100
|
HIVE_AGENT_SKILLS
|
|
99
101
|
} from "./chunk-L326MQZP.js";
|
|
@@ -733,7 +735,7 @@ var require_ipaddr = __commonJS({
|
|
|
733
735
|
|
|
734
736
|
// src/server.ts
|
|
735
737
|
import dotenv from "dotenv";
|
|
736
|
-
import { randomUUID as randomUUID4, timingSafeEqual as
|
|
738
|
+
import { randomUUID as randomUUID4, timingSafeEqual as timingSafeEqual6 } from "crypto";
|
|
737
739
|
import { resolve } from "path";
|
|
738
740
|
import { fileURLToPath } from "url";
|
|
739
741
|
import { createRequire as createNodeRequire } from "module";
|
|
@@ -741,7 +743,7 @@ import express2 from "express";
|
|
|
741
743
|
import compression from "compression";
|
|
742
744
|
import cors from "cors";
|
|
743
745
|
import helmet from "helmet";
|
|
744
|
-
import { z as
|
|
746
|
+
import { z as z5 } from "zod";
|
|
745
747
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
746
748
|
|
|
747
749
|
// src/middleware/rate-limit.ts
|
|
@@ -805,8 +807,43 @@ function ipRateLimitKeyFromRequest(req) {
|
|
|
805
807
|
}
|
|
806
808
|
|
|
807
809
|
// src/middleware/rate-limit.ts
|
|
808
|
-
var
|
|
809
|
-
|
|
810
|
+
var STORE_ERROR_LOG_INTERVAL_MS = 6e4;
|
|
811
|
+
var lastStoreErrorLoggedAt = 0;
|
|
812
|
+
var suppressedStoreErrors = 0;
|
|
813
|
+
function noteRateLimitStoreError(error) {
|
|
814
|
+
suppressedStoreErrors += 1;
|
|
815
|
+
const now = Date.now();
|
|
816
|
+
if (now - lastStoreErrorLoggedAt < STORE_ERROR_LOG_INTERVAL_MS) return;
|
|
817
|
+
lastStoreErrorLoggedAt = now;
|
|
818
|
+
const suppressed = suppressedStoreErrors;
|
|
819
|
+
suppressedStoreErrors = 0;
|
|
820
|
+
secureLogger.error(
|
|
821
|
+
"[rate-limit] Redis store error \u2014 requests are passing UNLIMITED until it recovers",
|
|
822
|
+
{
|
|
823
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
824
|
+
occurrencesSinceLastLog: suppressed
|
|
825
|
+
}
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
function isRateLimiterDegraded() {
|
|
829
|
+
return lastStoreErrorLoggedAt > 0 && Date.now() - lastStoreErrorLoggedAt < STORE_ERROR_LOG_INTERVAL_MS * 2;
|
|
830
|
+
}
|
|
831
|
+
function hasValidMonitorSecret(req) {
|
|
832
|
+
const secret = getEnvVar("MONITOR_SECRET");
|
|
833
|
+
const provided = req.headers["x-monitor-secret"];
|
|
834
|
+
if (!secret || typeof provided !== "string") return false;
|
|
835
|
+
const a = Buffer.from(provided);
|
|
836
|
+
const b = Buffer.from(secret);
|
|
837
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
838
|
+
}
|
|
839
|
+
function hasCredential(req) {
|
|
840
|
+
if (typeof req.headers["x-api-key"] === "string") return true;
|
|
841
|
+
const auth = req.headers.authorization;
|
|
842
|
+
return typeof auth === "string" && auth.startsWith("Bearer ");
|
|
843
|
+
}
|
|
844
|
+
var createIpRateLimiter = (opts = {}) => {
|
|
845
|
+
const lane = opts.lane ?? "anonymous";
|
|
846
|
+
const maxRequests = lane === "credentialed" ? getCredentialedRateLimitMax() : getRateLimitMax();
|
|
810
847
|
const redisClient = getRedisClient();
|
|
811
848
|
const isProduction2 = getNodeEnv() === "production";
|
|
812
849
|
if (isProduction2 && !redisClient) {
|
|
@@ -824,11 +861,11 @@ var createIpRateLimiter = () => {
|
|
|
824
861
|
}
|
|
825
862
|
const storeConfig = redisClient ? {
|
|
826
863
|
store: new RedisStore({
|
|
827
|
-
sendCommand: ((...args) => redisClient.call(
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
)),
|
|
831
|
-
prefix: "rl:ip:"
|
|
864
|
+
sendCommand: ((...args) => redisClient.call(args[0], ...args.slice(1)).catch((error) => {
|
|
865
|
+
noteRateLimitStoreError(error);
|
|
866
|
+
throw error;
|
|
867
|
+
})),
|
|
868
|
+
prefix: lane === "credentialed" ? "rl:ip:cred:" : "rl:ip:"
|
|
832
869
|
})
|
|
833
870
|
} : {};
|
|
834
871
|
return rateLimit({
|
|
@@ -837,14 +874,15 @@ var createIpRateLimiter = () => {
|
|
|
837
874
|
max: maxRequests,
|
|
838
875
|
standardHeaders: true,
|
|
839
876
|
legacyHeaders: false,
|
|
877
|
+
// Availability over enforcement. A Redis outage or an exhausted provider
|
|
878
|
+
// quota must degrade to unlimited traffic, never to a 500 on every route.
|
|
879
|
+
// On 2026-08-19 an Upstash quota exhaustion took the whole service down
|
|
880
|
+
// for ~16h because the store error propagated to Express.
|
|
881
|
+
passOnStoreError: true,
|
|
840
882
|
keyGenerator: (req) => ipRateLimitKeyFromRequest(req),
|
|
841
883
|
skip: (req) => {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
if (!secret || typeof provided !== "string") return false;
|
|
845
|
-
const a = Buffer.from(provided);
|
|
846
|
-
const b = Buffer.from(secret);
|
|
847
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
884
|
+
if (hasValidMonitorSecret(req)) return true;
|
|
885
|
+
return lane === "credentialed" ? !hasCredential(req) : hasCredential(req);
|
|
848
886
|
},
|
|
849
887
|
handler: (_req, res) => {
|
|
850
888
|
res.setHeader("Retry-After", "60");
|
|
@@ -858,6 +896,24 @@ var createIpRateLimiter = () => {
|
|
|
858
896
|
}
|
|
859
897
|
});
|
|
860
898
|
};
|
|
899
|
+
var createLocalRateLimiter = (maxPerMinute = 120) => rateLimit({
|
|
900
|
+
windowMs: 60 * 1e3,
|
|
901
|
+
max: maxPerMinute,
|
|
902
|
+
standardHeaders: true,
|
|
903
|
+
legacyHeaders: false,
|
|
904
|
+
keyGenerator: (req) => ipRateLimitKeyFromRequest(req),
|
|
905
|
+
skip: hasValidMonitorSecret,
|
|
906
|
+
handler: (_req, res) => {
|
|
907
|
+
res.setHeader("Retry-After", "60");
|
|
908
|
+
res.status(429).json(
|
|
909
|
+
apiError(
|
|
910
|
+
"rate_limit_error",
|
|
911
|
+
"RATE_LIMITED",
|
|
912
|
+
`Rate limit exceeded. Maximum ${maxPerMinute} requests per minute.`
|
|
913
|
+
)
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
861
917
|
|
|
862
918
|
// src/health/index.ts
|
|
863
919
|
import { timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
@@ -1521,8 +1577,16 @@ var PUBLIC_STATUS_DIMENSIONS = [
|
|
|
1521
1577
|
"taskCanaries",
|
|
1522
1578
|
"workflowRouting",
|
|
1523
1579
|
"mcpProtocol",
|
|
1524
|
-
"catalogIntegrity"
|
|
1580
|
+
"catalogIntegrity",
|
|
1581
|
+
"rateLimiter"
|
|
1525
1582
|
];
|
|
1583
|
+
function readRateLimiterSignal() {
|
|
1584
|
+
return {
|
|
1585
|
+
configured: isRedisConfigured(),
|
|
1586
|
+
available: isRedisAvailable(),
|
|
1587
|
+
degraded: isRateLimiterDegraded()
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1526
1590
|
var CATALOG_CHECK_NAMES = [
|
|
1527
1591
|
"mcp_initialize",
|
|
1528
1592
|
"tools_list_root_surface",
|
|
@@ -1646,6 +1710,56 @@ function buildCatalogIntegrityDimension(history) {
|
|
|
1646
1710
|
}
|
|
1647
1711
|
};
|
|
1648
1712
|
}
|
|
1713
|
+
var RATE_LIMITER_SCOPE = "This instance's shared rate-limit store, upstream response cache, and idempotency keys.";
|
|
1714
|
+
function buildRateLimiterDimension(signal) {
|
|
1715
|
+
const observedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1716
|
+
if (!signal.configured) {
|
|
1717
|
+
return {
|
|
1718
|
+
status: "healthy",
|
|
1719
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1720
|
+
evidence: {
|
|
1721
|
+
state: "absent",
|
|
1722
|
+
source: "serving_instance_redis",
|
|
1723
|
+
observedAt,
|
|
1724
|
+
message: "No REDIS_URL on this instance, so limits, cache and idempotency are per-instance and in memory by design."
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
if (!signal.available) {
|
|
1729
|
+
return {
|
|
1730
|
+
status: "unhealthy",
|
|
1731
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1732
|
+
evidence: {
|
|
1733
|
+
state: "storage_unavailable",
|
|
1734
|
+
source: "serving_instance_redis",
|
|
1735
|
+
observedAt,
|
|
1736
|
+
message: "Redis is configured but this instance has no live connection: IP and per-user rate limits are passing UNLIMITED, and the upstream cache and idempotency store are bypassed."
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
if (signal.degraded) {
|
|
1741
|
+
return {
|
|
1742
|
+
status: "degraded",
|
|
1743
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1744
|
+
evidence: {
|
|
1745
|
+
state: "observed",
|
|
1746
|
+
source: "serving_instance_redis",
|
|
1747
|
+
observedAt,
|
|
1748
|
+
message: "The rate-limit store returned errors recently; those requests failed open and were not counted."
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
return {
|
|
1753
|
+
status: "healthy",
|
|
1754
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1755
|
+
evidence: {
|
|
1756
|
+
state: "observed",
|
|
1757
|
+
source: "serving_instance_redis",
|
|
1758
|
+
observedAt,
|
|
1759
|
+
message: "This instance holds a live connection to the rate-limit store."
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1649
1763
|
function overallStatus(dimensions) {
|
|
1650
1764
|
const required = [
|
|
1651
1765
|
dimensions.providers,
|
|
@@ -1653,7 +1767,8 @@ function overallStatus(dimensions) {
|
|
|
1653
1767
|
dimensions.workflowRouting,
|
|
1654
1768
|
dimensions.mcpProtocol,
|
|
1655
1769
|
dimensions.oauthLifecycle,
|
|
1656
|
-
dimensions.catalogIntegrity
|
|
1770
|
+
dimensions.catalogIntegrity,
|
|
1771
|
+
dimensions.rateLimiter
|
|
1657
1772
|
];
|
|
1658
1773
|
if (required.some((dimension) => dimension.status === "unhealthy")) {
|
|
1659
1774
|
return "unhealthy";
|
|
@@ -1666,7 +1781,7 @@ function overallStatus(dimensions) {
|
|
|
1666
1781
|
}
|
|
1667
1782
|
return "healthy";
|
|
1668
1783
|
}
|
|
1669
|
-
function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, protocol, oauthLifecycle) {
|
|
1784
|
+
function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, protocol, oauthLifecycle, rateLimiter = readRateLimiterSignal()) {
|
|
1670
1785
|
const results = providers.latest ?? {
|
|
1671
1786
|
status: "unknown",
|
|
1672
1787
|
totalApis: 0,
|
|
@@ -1725,7 +1840,8 @@ function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, pro
|
|
|
1725
1840
|
},
|
|
1726
1841
|
mcpProtocol: buildMcpProtocolDimension(protocol),
|
|
1727
1842
|
oauthLifecycle: buildOAuthLifecycleDimension(oauthLifecycle),
|
|
1728
|
-
catalogIntegrity: buildCatalogIntegrityDimension(protocol)
|
|
1843
|
+
catalogIntegrity: buildCatalogIntegrityDimension(protocol),
|
|
1844
|
+
rateLimiter: buildRateLimiterDimension(rateLimiter)
|
|
1729
1845
|
};
|
|
1730
1846
|
return {
|
|
1731
1847
|
...results,
|
|
@@ -1749,7 +1865,8 @@ function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, pro
|
|
|
1749
1865
|
"workflowRouting",
|
|
1750
1866
|
"mcpProtocol",
|
|
1751
1867
|
"oauthLifecycle",
|
|
1752
|
-
"catalogIntegrity"
|
|
1868
|
+
"catalogIntegrity",
|
|
1869
|
+
"rateLimiter"
|
|
1753
1870
|
],
|
|
1754
1871
|
rule: "Any unhealthy dimension makes overall status unhealthy; otherwise degraded wins, missing evidence remains unknown, and healthy requires every required dimension to be healthy."
|
|
1755
1872
|
}
|
|
@@ -2353,8 +2470,91 @@ var getCorsConfig = () => {
|
|
|
2353
2470
|
|
|
2354
2471
|
// src/middleware/api-key.ts
|
|
2355
2472
|
import { createHash, randomUUID } from "crypto";
|
|
2473
|
+
|
|
2474
|
+
// src/services/user-rate-limit.ts
|
|
2475
|
+
var WINDOW_MS = 6e4;
|
|
2476
|
+
var WINDOW_SECONDS = 60;
|
|
2477
|
+
var PLAN_CACHE_TTL_MS = 6e4;
|
|
2478
|
+
var planCache = /* @__PURE__ */ new Map();
|
|
2479
|
+
var FALLBACK_LIMIT_PER_MIN = 100;
|
|
2480
|
+
var localCounters = /* @__PURE__ */ new Map();
|
|
2481
|
+
async function resolveUserRateLimit(userId) {
|
|
2482
|
+
const cached = planCache.get(userId);
|
|
2483
|
+
if (cached && cached.expiresAt > Date.now()) return cached.limit;
|
|
2484
|
+
let limit = FALLBACK_LIMIT_PER_MIN;
|
|
2485
|
+
try {
|
|
2486
|
+
const supabase = getSupabaseAdmin();
|
|
2487
|
+
const { data: profile } = await supabase.from("user_profiles").select("plan_id").eq("id", userId).single();
|
|
2488
|
+
const planId = profile?.plan_id ?? "demo";
|
|
2489
|
+
const { data: plan } = await supabase.from("plans").select("rate_limit_per_min").eq("id", planId).single();
|
|
2490
|
+
if (typeof plan?.rate_limit_per_min === "number") {
|
|
2491
|
+
limit = plan.rate_limit_per_min;
|
|
2492
|
+
}
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
secureLogger.warn("Plan rate lookup failed \u2014 using the free-tier rate", {
|
|
2495
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
planCache.set(userId, { limit, expiresAt: Date.now() + PLAN_CACHE_TTL_MS });
|
|
2499
|
+
return limit;
|
|
2500
|
+
}
|
|
2501
|
+
function consumeLocal(key, limit) {
|
|
2502
|
+
const now = Date.now();
|
|
2503
|
+
const existing = localCounters.get(key);
|
|
2504
|
+
const entry = existing && existing.expiresAt > now ? existing : { count: 0, expiresAt: now + WINDOW_MS };
|
|
2505
|
+
entry.count += 1;
|
|
2506
|
+
localCounters.set(key, entry);
|
|
2507
|
+
const resetSeconds = Math.max(1, Math.ceil((entry.expiresAt - now) / 1e3));
|
|
2508
|
+
return {
|
|
2509
|
+
allowed: entry.count <= limit,
|
|
2510
|
+
limit,
|
|
2511
|
+
remaining: Math.max(0, limit - entry.count),
|
|
2512
|
+
resetSeconds,
|
|
2513
|
+
degraded: false
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
async function consumeUserRateLimit(userId, limitPerMin) {
|
|
2517
|
+
const window = Math.floor(Date.now() / WINDOW_MS);
|
|
2518
|
+
const key = `rl:user:${userId}:${window}`;
|
|
2519
|
+
const redis = getRedisClient();
|
|
2520
|
+
if (!redis) return consumeLocal(key, limitPerMin);
|
|
2521
|
+
try {
|
|
2522
|
+
const count = await redis.incr(key);
|
|
2523
|
+
if (count === 1) await redis.expire(key, WINDOW_SECONDS);
|
|
2524
|
+
const resetSeconds = Math.max(
|
|
2525
|
+
1,
|
|
2526
|
+
WINDOW_SECONDS - Math.floor(Date.now() % WINDOW_MS / 1e3)
|
|
2527
|
+
);
|
|
2528
|
+
return {
|
|
2529
|
+
allowed: count <= limitPerMin,
|
|
2530
|
+
limit: limitPerMin,
|
|
2531
|
+
remaining: Math.max(0, limitPerMin - count),
|
|
2532
|
+
resetSeconds,
|
|
2533
|
+
degraded: false
|
|
2534
|
+
};
|
|
2535
|
+
} catch (error) {
|
|
2536
|
+
secureLogger.warn(
|
|
2537
|
+
"[user-rate-limit] store error \u2014 request passed UNLIMITED",
|
|
2538
|
+
{ errorMessage: error instanceof Error ? error.message : String(error) }
|
|
2539
|
+
);
|
|
2540
|
+
return {
|
|
2541
|
+
allowed: true,
|
|
2542
|
+
limit: limitPerMin,
|
|
2543
|
+
remaining: limitPerMin,
|
|
2544
|
+
resetSeconds: WINDOW_SECONDS,
|
|
2545
|
+
degraded: true
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
async function enforceUserRateLimit(userId) {
|
|
2550
|
+
const limit = await resolveUserRateLimit(userId);
|
|
2551
|
+
return consumeUserRateLimit(userId, limit);
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// src/middleware/api-key.ts
|
|
2356
2555
|
var REQUEST_COST = 1;
|
|
2357
|
-
|
|
2556
|
+
var REST_QUOTA_EXHAUSTED_MESSAGE = "Monthly credit quota exhausted. Upgrade at https://www.hiveintelligence.xyz/pricing to raise the limit now, or wait for the next cycle.";
|
|
2557
|
+
function attachVerifiedContext(req, res, key, result, quotaRemaining, rateDecision) {
|
|
2358
2558
|
const authReq = req;
|
|
2359
2559
|
authReq.apiKey = key;
|
|
2360
2560
|
authReq.apiKeyEnvironment = getKeyEnvironment(key);
|
|
@@ -2362,7 +2562,7 @@ function attachVerifiedContext(req, res, key, result, quotaRemaining) {
|
|
|
2362
2562
|
authReq.planId = result.planId;
|
|
2363
2563
|
authReq.unkeyKeyId = result.keyId;
|
|
2364
2564
|
authReq.quotaRemaining = quotaRemaining ?? void 0;
|
|
2365
|
-
authReq.ratelimitRemaining = result.ratelimit?.remaining;
|
|
2565
|
+
authReq.ratelimitRemaining = rateDecision?.remaining ?? result.ratelimit?.remaining;
|
|
2366
2566
|
authReq.unkeyResult = result;
|
|
2367
2567
|
if (quotaRemaining !== void 0) {
|
|
2368
2568
|
res.setHeader(
|
|
@@ -2370,9 +2570,11 @@ function attachVerifiedContext(req, res, key, result, quotaRemaining) {
|
|
|
2370
2570
|
quotaRemaining === null ? "unlimited" : String(quotaRemaining)
|
|
2371
2571
|
);
|
|
2372
2572
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2573
|
+
const limit = rateDecision?.limit ?? result.ratelimit?.limit;
|
|
2574
|
+
const remaining = rateDecision?.remaining ?? result.ratelimit?.remaining;
|
|
2575
|
+
if (limit !== void 0 && remaining !== void 0) {
|
|
2576
|
+
res.setHeader("X-RateLimit-Remaining", String(remaining));
|
|
2577
|
+
res.setHeader("X-RateLimit-Limit", String(limit));
|
|
2376
2578
|
}
|
|
2377
2579
|
}
|
|
2378
2580
|
function deferApiKeyCreditDebit(req, _res, next) {
|
|
@@ -2397,7 +2599,7 @@ async function debitApiKeyCreditForExecution(req, res) {
|
|
|
2397
2599
|
return {
|
|
2398
2600
|
type: "rate_limit_error",
|
|
2399
2601
|
code: "QUOTA_EXCEEDED",
|
|
2400
|
-
message:
|
|
2602
|
+
message: REST_QUOTA_EXHAUSTED_MESSAGE,
|
|
2401
2603
|
runtimeStatus: "rate_limited"
|
|
2402
2604
|
};
|
|
2403
2605
|
}
|
|
@@ -2529,8 +2731,25 @@ function requireApiKey(req, res, next) {
|
|
|
2529
2731
|
);
|
|
2530
2732
|
return;
|
|
2531
2733
|
}
|
|
2734
|
+
const rateDecision = await enforceUserRateLimit(result.userId);
|
|
2735
|
+
res.setHeader("RateLimit-Limit", String(rateDecision.limit));
|
|
2736
|
+
res.setHeader("RateLimit-Remaining", String(rateDecision.remaining));
|
|
2737
|
+
res.setHeader("RateLimit-Reset", String(rateDecision.resetSeconds));
|
|
2738
|
+
res.setHeader("RateLimit-Policy", `${rateDecision.limit};w=60`);
|
|
2739
|
+
if (!rateDecision.allowed) {
|
|
2740
|
+
res.setHeader("Retry-After", String(rateDecision.resetSeconds));
|
|
2741
|
+
res.status(429).json(
|
|
2742
|
+
apiError(
|
|
2743
|
+
"rate_limit_error",
|
|
2744
|
+
"RATE_LIMITED",
|
|
2745
|
+
`Rate limit exceeded: ${rateDecision.limit} requests per minute on your plan. This limit is shared across all of your API keys and connected clients.`,
|
|
2746
|
+
{ requestId: req.requestId }
|
|
2747
|
+
)
|
|
2748
|
+
);
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2532
2751
|
if (req.deferCreditDebit) {
|
|
2533
|
-
attachVerifiedContext(req, res, key, result);
|
|
2752
|
+
attachVerifiedContext(req, res, key, result, void 0, rateDecision);
|
|
2534
2753
|
next();
|
|
2535
2754
|
return;
|
|
2536
2755
|
}
|
|
@@ -2552,7 +2771,7 @@ function requireApiKey(req, res, next) {
|
|
|
2552
2771
|
apiError(
|
|
2553
2772
|
"rate_limit_error",
|
|
2554
2773
|
"QUOTA_EXCEEDED",
|
|
2555
|
-
|
|
2774
|
+
REST_QUOTA_EXHAUSTED_MESSAGE,
|
|
2556
2775
|
{ requestId: req.requestId }
|
|
2557
2776
|
)
|
|
2558
2777
|
);
|
|
@@ -2573,7 +2792,14 @@ function requireApiKey(req, res, next) {
|
|
|
2573
2792
|
);
|
|
2574
2793
|
return;
|
|
2575
2794
|
}
|
|
2576
|
-
attachVerifiedContext(
|
|
2795
|
+
attachVerifiedContext(
|
|
2796
|
+
req,
|
|
2797
|
+
res,
|
|
2798
|
+
key,
|
|
2799
|
+
result,
|
|
2800
|
+
consumeResult.balance,
|
|
2801
|
+
rateDecision
|
|
2802
|
+
);
|
|
2577
2803
|
next();
|
|
2578
2804
|
} catch (err) {
|
|
2579
2805
|
secureLogger.error("consume_credits threw \u2014 rejecting request", err, {
|
|
@@ -2666,9 +2892,6 @@ function requireApiKey(req, res, next) {
|
|
|
2666
2892
|
|
|
2667
2893
|
// src/routes/auth.ts
|
|
2668
2894
|
import { Router as Router2 } from "express";
|
|
2669
|
-
import rateLimit2 from "express-rate-limit";
|
|
2670
|
-
import RedisStore2 from "rate-limit-redis";
|
|
2671
|
-
import { z } from "zod";
|
|
2672
2895
|
|
|
2673
2896
|
// src/middleware/supabase-auth.ts
|
|
2674
2897
|
function requireSupabaseAuth(req, res, next) {
|
|
@@ -2728,136 +2951,6 @@ var asyncHandler = (fn) => (req, res, next) => {
|
|
|
2728
2951
|
|
|
2729
2952
|
// src/routes/auth.ts
|
|
2730
2953
|
var router2 = Router2();
|
|
2731
|
-
function buildAuthRateLimiter(maxPerHour, prefix) {
|
|
2732
|
-
const redisClient = getRedisClient();
|
|
2733
|
-
const storeConfig = redisClient ? {
|
|
2734
|
-
store: new RedisStore2({
|
|
2735
|
-
sendCommand: ((...args) => redisClient.call(
|
|
2736
|
-
args[0],
|
|
2737
|
-
...args.slice(1)
|
|
2738
|
-
)),
|
|
2739
|
-
prefix
|
|
2740
|
-
})
|
|
2741
|
-
} : {};
|
|
2742
|
-
return rateLimit2({
|
|
2743
|
-
...storeConfig,
|
|
2744
|
-
windowMs: 60 * 60 * 1e3,
|
|
2745
|
-
max: maxPerHour,
|
|
2746
|
-
standardHeaders: true,
|
|
2747
|
-
legacyHeaders: false,
|
|
2748
|
-
keyGenerator: (req) => ipRateLimitKeyFromRequest(req),
|
|
2749
|
-
handler: (_req, res) => {
|
|
2750
|
-
res.setHeader("Retry-After", "3600");
|
|
2751
|
-
res.status(429).json(
|
|
2752
|
-
apiError(
|
|
2753
|
-
"rate_limit_error",
|
|
2754
|
-
"RATE_LIMITED",
|
|
2755
|
-
"Too many auth attempts. Try again in an hour."
|
|
2756
|
-
)
|
|
2757
|
-
);
|
|
2758
|
-
}
|
|
2759
|
-
});
|
|
2760
|
-
}
|
|
2761
|
-
var signupRateLimit = buildAuthRateLimiter(10, "rl:auth:signup:");
|
|
2762
|
-
var loginRateLimit = buildAuthRateLimiter(30, "rl:auth:login:");
|
|
2763
|
-
var AuthSchema = z.object({
|
|
2764
|
-
email: z.string().email("Invalid email format"),
|
|
2765
|
-
password: z.string().min(8, "Password must be at least 8 characters")
|
|
2766
|
-
});
|
|
2767
|
-
router2.post(
|
|
2768
|
-
"/signup",
|
|
2769
|
-
signupRateLimit,
|
|
2770
|
-
asyncHandler(async (req, res) => {
|
|
2771
|
-
if (!isSupabaseConfigured() || !hasEnvVars("SUPABASE_ANON_KEY")) {
|
|
2772
|
-
return res.status(503).json(
|
|
2773
|
-
apiError(
|
|
2774
|
-
"internal_error",
|
|
2775
|
-
"AUTH_NOT_CONFIGURED",
|
|
2776
|
-
"Auth not configured."
|
|
2777
|
-
)
|
|
2778
|
-
);
|
|
2779
|
-
}
|
|
2780
|
-
const parseResult = AuthSchema.safeParse(req.body);
|
|
2781
|
-
if (!parseResult.success) {
|
|
2782
|
-
return res.status(400).json(
|
|
2783
|
-
apiError(
|
|
2784
|
-
"invalid_request_error",
|
|
2785
|
-
"VALIDATION_ERROR",
|
|
2786
|
-
parseResult.error.issues.map((e) => e.message).join("; ")
|
|
2787
|
-
)
|
|
2788
|
-
);
|
|
2789
|
-
}
|
|
2790
|
-
const { email, password } = parseResult.data;
|
|
2791
|
-
const { data, error } = await getSupabaseAuthClient().auth.signUp({
|
|
2792
|
-
email,
|
|
2793
|
-
password
|
|
2794
|
-
});
|
|
2795
|
-
if (error || !data.user) {
|
|
2796
|
-
return res.status(400).json(
|
|
2797
|
-
apiError(
|
|
2798
|
-
"authentication_error",
|
|
2799
|
-
"SIGNUP_FAILED",
|
|
2800
|
-
"Signup failed. Please try again or use a different email."
|
|
2801
|
-
)
|
|
2802
|
-
);
|
|
2803
|
-
}
|
|
2804
|
-
return res.status(201).json({
|
|
2805
|
-
ok: true,
|
|
2806
|
-
user: { id: data.user.id, email: data.user.email },
|
|
2807
|
-
requires_email_confirmation: true,
|
|
2808
|
-
session: null,
|
|
2809
|
-
api_key: null
|
|
2810
|
-
});
|
|
2811
|
-
})
|
|
2812
|
-
);
|
|
2813
|
-
router2.post(
|
|
2814
|
-
"/login",
|
|
2815
|
-
loginRateLimit,
|
|
2816
|
-
asyncHandler(async (req, res) => {
|
|
2817
|
-
if (!isSupabaseConfigured() || !hasEnvVars("SUPABASE_ANON_KEY")) {
|
|
2818
|
-
return res.status(503).json(
|
|
2819
|
-
apiError(
|
|
2820
|
-
"internal_error",
|
|
2821
|
-
"AUTH_NOT_CONFIGURED",
|
|
2822
|
-
"Auth not configured."
|
|
2823
|
-
)
|
|
2824
|
-
);
|
|
2825
|
-
}
|
|
2826
|
-
const parseResult = AuthSchema.safeParse(req.body);
|
|
2827
|
-
if (!parseResult.success) {
|
|
2828
|
-
return res.status(400).json(
|
|
2829
|
-
apiError(
|
|
2830
|
-
"invalid_request_error",
|
|
2831
|
-
"VALIDATION_ERROR",
|
|
2832
|
-
parseResult.error.issues.map((e) => e.message).join("; ")
|
|
2833
|
-
)
|
|
2834
|
-
);
|
|
2835
|
-
}
|
|
2836
|
-
const { email, password } = parseResult.data;
|
|
2837
|
-
const { data, error } = await getSupabaseAuthClient().auth.signInWithPassword({
|
|
2838
|
-
email,
|
|
2839
|
-
password
|
|
2840
|
-
});
|
|
2841
|
-
if (error || !data.session) {
|
|
2842
|
-
return res.status(401).json(
|
|
2843
|
-
apiError(
|
|
2844
|
-
"authentication_error",
|
|
2845
|
-
"LOGIN_FAILED",
|
|
2846
|
-
error?.message ?? "Invalid credentials."
|
|
2847
|
-
)
|
|
2848
|
-
);
|
|
2849
|
-
}
|
|
2850
|
-
return res.json({
|
|
2851
|
-
ok: true,
|
|
2852
|
-
user: { id: data.user.id, email: data.user.email },
|
|
2853
|
-
session: {
|
|
2854
|
-
access_token: data.session.access_token,
|
|
2855
|
-
refresh_token: data.session.refresh_token,
|
|
2856
|
-
expires_at: data.session.expires_at
|
|
2857
|
-
}
|
|
2858
|
-
});
|
|
2859
|
-
})
|
|
2860
|
-
);
|
|
2861
2954
|
router2.get(
|
|
2862
2955
|
"/me",
|
|
2863
2956
|
requireSupabaseAuth,
|
|
@@ -2875,7 +2968,7 @@ var auth_default = router2;
|
|
|
2875
2968
|
|
|
2876
2969
|
// src/routes/keys.ts
|
|
2877
2970
|
import { Router as Router3 } from "express";
|
|
2878
|
-
import { z
|
|
2971
|
+
import { z } from "zod";
|
|
2879
2972
|
|
|
2880
2973
|
// src/middleware/idempotency.ts
|
|
2881
2974
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -3208,9 +3301,9 @@ async function handleRedisLookup(key, requestFingerprint, req, res, next) {
|
|
|
3208
3301
|
|
|
3209
3302
|
// src/routes/keys.ts
|
|
3210
3303
|
var router3 = Router3();
|
|
3211
|
-
var CreateKeySchema =
|
|
3212
|
-
name:
|
|
3213
|
-
b2b_subjects_enabled:
|
|
3304
|
+
var CreateKeySchema = z.object({
|
|
3305
|
+
name: z.string().min(1).max(100).optional(),
|
|
3306
|
+
b2b_subjects_enabled: z.boolean().optional()
|
|
3214
3307
|
});
|
|
3215
3308
|
router3.post(
|
|
3216
3309
|
"/",
|
|
@@ -3375,6 +3468,7 @@ router4.get(
|
|
|
3375
3468
|
const { data: plan } = await getSupabaseAdmin().from("plans").select("*").eq("id", resolvedPlanId).single();
|
|
3376
3469
|
await ensureWallet(userId, resolvedPlanId);
|
|
3377
3470
|
const wallet = await getWalletStatus(userId);
|
|
3471
|
+
const { data: subscription } = await getSupabaseAdmin().from("stripe_subscriptions").select("status, current_period_start, current_period_end").eq("user_id", userId).in("status", ["trialing", "active", "past_due", "unpaid"]).order("current_period_end", { ascending: false }).limit(1).maybeSingle();
|
|
3378
3472
|
const keys = await listKeys(userId);
|
|
3379
3473
|
const keyEntries = keys.map((k) => ({
|
|
3380
3474
|
key_id: k.keyId,
|
|
@@ -3398,6 +3492,11 @@ router4.get(
|
|
|
3398
3492
|
cycle_start: wallet.cycleStart,
|
|
3399
3493
|
cycle_end: wallet.cycleEnd
|
|
3400
3494
|
} : null,
|
|
3495
|
+
subscription: subscription ? {
|
|
3496
|
+
status: subscription.status,
|
|
3497
|
+
current_period_start: subscription.current_period_start,
|
|
3498
|
+
current_period_end: subscription.current_period_end
|
|
3499
|
+
} : null,
|
|
3401
3500
|
keys: keyEntries,
|
|
3402
3501
|
total_keys: keys.length
|
|
3403
3502
|
}
|
|
@@ -3588,13 +3687,13 @@ var b2b_default = router5;
|
|
|
3588
3687
|
import {
|
|
3589
3688
|
Router as Router6
|
|
3590
3689
|
} from "express";
|
|
3591
|
-
import { z as
|
|
3690
|
+
import { z as z2 } from "zod";
|
|
3592
3691
|
|
|
3593
3692
|
// src/services/stripe-service.ts
|
|
3594
3693
|
import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
3595
3694
|
var STRIPE_API_BASE = "https://api.stripe.com";
|
|
3596
3695
|
var STRIPE_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
3597
|
-
var PAID_PLAN_ID = "
|
|
3696
|
+
var PAID_PLAN_ID = "analyst";
|
|
3598
3697
|
var FREE_PLAN_ID = "demo";
|
|
3599
3698
|
function getStripeSecretKey() {
|
|
3600
3699
|
return getEnvVar("STRIPE_SECRET_KEY");
|
|
@@ -3605,16 +3704,44 @@ function getStripeWebhookSecret() {
|
|
|
3605
3704
|
function isStripeCheckoutConfigured() {
|
|
3606
3705
|
return Boolean(getStripeSecretKey());
|
|
3607
3706
|
}
|
|
3608
|
-
|
|
3609
|
-
|
|
3707
|
+
var STRIPE_PRICE_ENV = {
|
|
3708
|
+
starter: {
|
|
3709
|
+
monthly: "STRIPE_PRICE_STARTER_MONTHLY",
|
|
3710
|
+
yearly: "STRIPE_PRICE_STARTER_YEARLY"
|
|
3711
|
+
},
|
|
3712
|
+
analyst: {
|
|
3713
|
+
monthly: "STRIPE_PRICE_PRO_MONTHLY",
|
|
3714
|
+
yearly: "STRIPE_PRICE_PRO_YEARLY"
|
|
3715
|
+
}
|
|
3716
|
+
};
|
|
3717
|
+
function priceIdForPlanCycle(planId, cycle) {
|
|
3718
|
+
const row = STRIPE_PRICE_ENV[planId];
|
|
3719
|
+
if (!row) return void 0;
|
|
3720
|
+
return getEnvVar(row[cycle]);
|
|
3610
3721
|
}
|
|
3611
3722
|
function planIdForPrice(priceId) {
|
|
3612
3723
|
if (!priceId) return null;
|
|
3613
|
-
|
|
3614
|
-
|
|
3724
|
+
for (const [planId, cycles] of Object.entries(STRIPE_PRICE_ENV)) {
|
|
3725
|
+
for (const envName of Object.values(cycles)) {
|
|
3726
|
+
if (priceId === getEnvVar(envName)) return planId;
|
|
3727
|
+
}
|
|
3615
3728
|
}
|
|
3616
3729
|
return null;
|
|
3617
3730
|
}
|
|
3731
|
+
var STRIPE_SUBSCRIPTION_STATUSES = [
|
|
3732
|
+
"incomplete",
|
|
3733
|
+
"incomplete_expired",
|
|
3734
|
+
"trialing",
|
|
3735
|
+
"active",
|
|
3736
|
+
"past_due",
|
|
3737
|
+
"canceled",
|
|
3738
|
+
"unpaid",
|
|
3739
|
+
"paused"
|
|
3740
|
+
];
|
|
3741
|
+
function registrySubscriptionStatus(status) {
|
|
3742
|
+
if (!status) return null;
|
|
3743
|
+
return STRIPE_SUBSCRIPTION_STATUSES.includes(status) ? status : null;
|
|
3744
|
+
}
|
|
3618
3745
|
function getBillingSuccessUrl() {
|
|
3619
3746
|
return getEnvVar("BILLING_SUCCESS_URL") ?? "https://www.hiveintelligence.xyz/dashboard?checkout=success";
|
|
3620
3747
|
}
|
|
@@ -3734,6 +3861,164 @@ async function createStripeCheckoutSession(args) {
|
|
|
3734
3861
|
return { id, url };
|
|
3735
3862
|
}
|
|
3736
3863
|
|
|
3864
|
+
// src/services/llamapay-service.ts
|
|
3865
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
3866
|
+
var LLAMAPAY_API_BASE = "https://api.llamapay.io";
|
|
3867
|
+
var LLAMAPAY_SIGNATURE_HEADER = "x-cc-webhook-signature";
|
|
3868
|
+
function getLlamaPayApiKey() {
|
|
3869
|
+
const key = process.env.LLAMAPAY_API_KEY?.trim();
|
|
3870
|
+
return key && key.length > 0 ? key : void 0;
|
|
3871
|
+
}
|
|
3872
|
+
function getLlamaPayWebhookSecret() {
|
|
3873
|
+
const secret = process.env.LLAMAPAY_WEBHOOK_SECRET?.trim();
|
|
3874
|
+
return secret && secret.length > 0 ? secret : void 0;
|
|
3875
|
+
}
|
|
3876
|
+
function isLlamaPayCheckoutConfigured() {
|
|
3877
|
+
return getLlamaPayApiKey() !== void 0;
|
|
3878
|
+
}
|
|
3879
|
+
var PLAN_PRICES_USD = {
|
|
3880
|
+
starter: { monthly: 49, yearly: 490 },
|
|
3881
|
+
analyst: { monthly: 149, yearly: 1490 }
|
|
3882
|
+
};
|
|
3883
|
+
var PLAN_TIER_ORDER = [
|
|
3884
|
+
"demo",
|
|
3885
|
+
"starter",
|
|
3886
|
+
"analyst",
|
|
3887
|
+
"enterprise"
|
|
3888
|
+
];
|
|
3889
|
+
function isSellablePlan(planId) {
|
|
3890
|
+
return Object.prototype.hasOwnProperty.call(PLAN_PRICES_USD, planId);
|
|
3891
|
+
}
|
|
3892
|
+
function priceForPlanCycle(planId, cycle) {
|
|
3893
|
+
const row = PLAN_PRICES_USD[planId];
|
|
3894
|
+
if (!row) return void 0;
|
|
3895
|
+
return cycle === "yearly" ? row.yearly : row.monthly;
|
|
3896
|
+
}
|
|
3897
|
+
function canUpgradeTo(current, target) {
|
|
3898
|
+
const currentIndex = PLAN_TIER_ORDER.indexOf(current ?? "demo");
|
|
3899
|
+
const targetIndex = PLAN_TIER_ORDER.indexOf(target);
|
|
3900
|
+
if (targetIndex < 0) return false;
|
|
3901
|
+
return targetIndex > (currentIndex < 0 ? 0 : currentIndex);
|
|
3902
|
+
}
|
|
3903
|
+
var RENEWAL_WINDOW_DAYS = 30;
|
|
3904
|
+
function canPurchasePlan(current, target, termEnd, now = /* @__PURE__ */ new Date()) {
|
|
3905
|
+
if (canUpgradeTo(current, target)) return true;
|
|
3906
|
+
if (current !== target) return false;
|
|
3907
|
+
if (!termEnd) return false;
|
|
3908
|
+
const end = termEnd instanceof Date ? termEnd : new Date(termEnd);
|
|
3909
|
+
if (Number.isNaN(end.getTime())) return false;
|
|
3910
|
+
const windowOpens = new Date(
|
|
3911
|
+
end.getTime() - RENEWAL_WINDOW_DAYS * 24 * 60 * 60 * 1e3
|
|
3912
|
+
);
|
|
3913
|
+
return now.getTime() >= windowOpens.getTime();
|
|
3914
|
+
}
|
|
3915
|
+
function pricingTypeFor(cycle) {
|
|
3916
|
+
return cycle === "yearly" ? "fixed_price" : "subscription";
|
|
3917
|
+
}
|
|
3918
|
+
function addUtcMonths(start, months) {
|
|
3919
|
+
const end = new Date(start.getTime());
|
|
3920
|
+
const day = end.getUTCDate();
|
|
3921
|
+
end.setUTCDate(1);
|
|
3922
|
+
end.setUTCMonth(end.getUTCMonth() + months);
|
|
3923
|
+
const daysInTargetMonth = new Date(
|
|
3924
|
+
Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1, 0)
|
|
3925
|
+
).getUTCDate();
|
|
3926
|
+
end.setUTCDate(Math.min(day, daysInTargetMonth));
|
|
3927
|
+
return end;
|
|
3928
|
+
}
|
|
3929
|
+
function creditCycleEndFrom(start) {
|
|
3930
|
+
return addUtcMonths(start, 1);
|
|
3931
|
+
}
|
|
3932
|
+
function termEndFrom(start, cycle) {
|
|
3933
|
+
return addUtcMonths(start, cycle === "yearly" ? 12 : 1);
|
|
3934
|
+
}
|
|
3935
|
+
var LlamaPayApiError = class extends Error {
|
|
3936
|
+
constructor(status, message) {
|
|
3937
|
+
super(message);
|
|
3938
|
+
this.status = status;
|
|
3939
|
+
this.name = "LlamaPayApiError";
|
|
3940
|
+
}
|
|
3941
|
+
status;
|
|
3942
|
+
};
|
|
3943
|
+
async function createLlamaPayCharge(args) {
|
|
3944
|
+
const apiKey = getLlamaPayApiKey();
|
|
3945
|
+
if (!apiKey) {
|
|
3946
|
+
throw new LlamaPayApiError(503, "LlamaPay API key is not configured");
|
|
3947
|
+
}
|
|
3948
|
+
let response;
|
|
3949
|
+
try {
|
|
3950
|
+
response = await fetch(`${LLAMAPAY_API_BASE}/charges`, {
|
|
3951
|
+
method: "POST",
|
|
3952
|
+
headers: {
|
|
3953
|
+
Authorization: apiKey,
|
|
3954
|
+
"Content-Type": "application/json"
|
|
3955
|
+
},
|
|
3956
|
+
body: JSON.stringify({
|
|
3957
|
+
pricing_type: pricingTypeFor(args.cycle),
|
|
3958
|
+
local_price: {
|
|
3959
|
+
amount: args.amountUsd.toFixed(2),
|
|
3960
|
+
currency: "USD"
|
|
3961
|
+
},
|
|
3962
|
+
metadata: {
|
|
3963
|
+
user_id: args.userId,
|
|
3964
|
+
plan_id: args.planId,
|
|
3965
|
+
billing_cycle: args.cycle,
|
|
3966
|
+
...args.email ? { email: args.email } : {}
|
|
3967
|
+
},
|
|
3968
|
+
redirect_url: getBillingSuccessUrl(),
|
|
3969
|
+
cancel_url: getBillingCancelUrl()
|
|
3970
|
+
})
|
|
3971
|
+
});
|
|
3972
|
+
} catch (err) {
|
|
3973
|
+
throw new LlamaPayApiError(
|
|
3974
|
+
502,
|
|
3975
|
+
`LlamaPay request failed: ${err instanceof Error ? err.message : "unknown"}`
|
|
3976
|
+
);
|
|
3977
|
+
}
|
|
3978
|
+
const text = await response.text();
|
|
3979
|
+
if (!response.ok) {
|
|
3980
|
+
throw new LlamaPayApiError(
|
|
3981
|
+
response.status,
|
|
3982
|
+
`LlamaPay charge creation failed (${response.status}): ${text.slice(0, 300)}`
|
|
3983
|
+
);
|
|
3984
|
+
}
|
|
3985
|
+
let payload;
|
|
3986
|
+
try {
|
|
3987
|
+
payload = JSON.parse(text);
|
|
3988
|
+
} catch {
|
|
3989
|
+
throw new LlamaPayApiError(502, "LlamaPay returned a non-JSON response");
|
|
3990
|
+
}
|
|
3991
|
+
const id = payload.data?.id;
|
|
3992
|
+
const hostedUrl = payload.data?.hosted_url;
|
|
3993
|
+
if (typeof id !== "string" || typeof hostedUrl !== "string" || !hostedUrl) {
|
|
3994
|
+
throw new LlamaPayApiError(
|
|
3995
|
+
502,
|
|
3996
|
+
"LlamaPay charge creation returned no hosted_url"
|
|
3997
|
+
);
|
|
3998
|
+
}
|
|
3999
|
+
return { id, hostedUrl };
|
|
4000
|
+
}
|
|
4001
|
+
function verifyLlamaPaySignature(args) {
|
|
4002
|
+
const header = args.header;
|
|
4003
|
+
if (typeof header !== "string" || header.length === 0) {
|
|
4004
|
+
return { valid: false, reason: "missing_header" };
|
|
4005
|
+
}
|
|
4006
|
+
const received = header.trim().toLowerCase();
|
|
4007
|
+
if (!/^[0-9a-f]+$/.test(received)) {
|
|
4008
|
+
return { valid: false, reason: "malformed_header" };
|
|
4009
|
+
}
|
|
4010
|
+
const expected = createHmac2("sha256", args.secret).update(args.payload).digest("hex");
|
|
4011
|
+
const receivedBuf = Buffer.from(received, "utf8");
|
|
4012
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
4013
|
+
if (receivedBuf.length !== expectedBuf.length) {
|
|
4014
|
+
return { valid: false, reason: "no_match" };
|
|
4015
|
+
}
|
|
4016
|
+
if (!timingSafeEqual4(receivedBuf, expectedBuf)) {
|
|
4017
|
+
return { valid: false, reason: "no_match" };
|
|
4018
|
+
}
|
|
4019
|
+
return { valid: true };
|
|
4020
|
+
}
|
|
4021
|
+
|
|
3737
4022
|
// src/routes/billing.ts
|
|
3738
4023
|
var BILLING_UNAVAILABLE = apiError(
|
|
3739
4024
|
"internal_error",
|
|
@@ -3788,14 +4073,139 @@ function requireBillingPrincipal(req, res, next) {
|
|
|
3788
4073
|
next();
|
|
3789
4074
|
});
|
|
3790
4075
|
}
|
|
3791
|
-
var CheckoutSchema =
|
|
3792
|
-
|
|
4076
|
+
var CheckoutSchema = z2.object({
|
|
4077
|
+
// Was absent, and the route hardcoded PAID_PLAN_ID. The crypto lane sold
|
|
4078
|
+
// Starter and this one did not, so the two processors sold different
|
|
4079
|
+
// catalogues -- with Starter the emphasised middle card on /pricing.
|
|
4080
|
+
plan_id: z2.string().min(1).default(PAID_PLAN_ID),
|
|
4081
|
+
billing_cycle: z2.enum(["monthly", "yearly"]).default("monthly")
|
|
3793
4082
|
});
|
|
3794
4083
|
var OCCUPYING_BILLING_STATUSES = /* @__PURE__ */ new Set(["active", "past_due", "unpaid"]);
|
|
4084
|
+
var CHECKOUT_LOCK_TTL_MS = 6e4;
|
|
4085
|
+
var CHECKOUT_LOCK_PREFIX = "billing:checkout:";
|
|
4086
|
+
var localCheckoutLocks = /* @__PURE__ */ new Map();
|
|
4087
|
+
async function acquireCheckoutLock(userId) {
|
|
4088
|
+
const key = `${CHECKOUT_LOCK_PREFIX}${userId}`;
|
|
4089
|
+
const redis = isRedisAvailable() ? getRedisClient() : null;
|
|
4090
|
+
if (redis) {
|
|
4091
|
+
try {
|
|
4092
|
+
const claimed = await redis.set(
|
|
4093
|
+
key,
|
|
4094
|
+
"1",
|
|
4095
|
+
"PX",
|
|
4096
|
+
CHECKOUT_LOCK_TTL_MS,
|
|
4097
|
+
"NX"
|
|
4098
|
+
);
|
|
4099
|
+
return claimed === "OK";
|
|
4100
|
+
} catch (err) {
|
|
4101
|
+
secureLogger.warn("Checkout lock fell back to process memory", {
|
|
4102
|
+
reason: err instanceof Error ? err.message : "unknown"
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
const now = Date.now();
|
|
4107
|
+
const heldUntil = localCheckoutLocks.get(key);
|
|
4108
|
+
if (heldUntil !== void 0 && heldUntil > now) return false;
|
|
4109
|
+
localCheckoutLocks.set(key, now + CHECKOUT_LOCK_TTL_MS);
|
|
4110
|
+
return true;
|
|
4111
|
+
}
|
|
4112
|
+
async function releaseCheckoutLock(userId) {
|
|
4113
|
+
const key = `${CHECKOUT_LOCK_PREFIX}${userId}`;
|
|
4114
|
+
localCheckoutLocks.delete(key);
|
|
4115
|
+
const redis = isRedisAvailable() ? getRedisClient() : null;
|
|
4116
|
+
if (!redis) return;
|
|
4117
|
+
try {
|
|
4118
|
+
await redis.del(key);
|
|
4119
|
+
} catch {
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
var CHECKOUT_RACE_LOST = apiError(
|
|
4123
|
+
"invalid_request_error",
|
|
4124
|
+
"CHECKOUT_IN_PROGRESS",
|
|
4125
|
+
"Another checkout request for this account is still being created. Next action: wait a couple of seconds and retry \u2014 two simultaneous requests can only ever produce one charge."
|
|
4126
|
+
);
|
|
4127
|
+
function checkoutAlreadyOpen(chargeId) {
|
|
4128
|
+
return apiError(
|
|
4129
|
+
"invalid_request_error",
|
|
4130
|
+
"CHECKOUT_IN_PROGRESS",
|
|
4131
|
+
`A payment page for this account is already open${chargeId ? ` (charge ${chargeId})` : ""}. Next action: finish that payment, or release the hold with POST /api/v1/billing/checkout/abandon if that page was abandoned \u2014 otherwise it clears on its own ${OPEN_CHARGE_WINDOW_MINUTES} minutes after the page was opened.`
|
|
4132
|
+
);
|
|
4133
|
+
}
|
|
4134
|
+
function withCheckoutLock(req, res, next) {
|
|
4135
|
+
const userId = req.billingUserId;
|
|
4136
|
+
if (!userId) {
|
|
4137
|
+
next();
|
|
4138
|
+
return;
|
|
4139
|
+
}
|
|
4140
|
+
void acquireCheckoutLock(userId).then((claimed) => {
|
|
4141
|
+
if (!claimed) {
|
|
4142
|
+
res.status(409).json(CHECKOUT_RACE_LOST);
|
|
4143
|
+
return;
|
|
4144
|
+
}
|
|
4145
|
+
const sendJson = res.json.bind(res);
|
|
4146
|
+
res.json = ((body) => {
|
|
4147
|
+
void releaseCheckoutLock(userId);
|
|
4148
|
+
return sendJson(body);
|
|
4149
|
+
});
|
|
4150
|
+
next();
|
|
4151
|
+
});
|
|
4152
|
+
}
|
|
4153
|
+
var OPEN_CHARGE_WINDOW_MINUTES = 15;
|
|
4154
|
+
var OPEN_CHARGE_WINDOW_MS = OPEN_CHARGE_WINDOW_MINUTES * 60 * 1e3;
|
|
4155
|
+
var ABANDONED_EVENT_TYPE = "checkout:abandoned";
|
|
4156
|
+
function openChargeCutoff() {
|
|
4157
|
+
return new Date(Date.now() - OPEN_CHARGE_WINDOW_MS).toISOString();
|
|
4158
|
+
}
|
|
4159
|
+
async function findOpenCharge(userId) {
|
|
4160
|
+
const { data, error } = await getSupabaseAdmin().from("billing_charges").select("charge_id").eq("user_id", userId).is("settled_at", null).neq("status", "canceled").gte("created_at", openChargeCutoff()).limit(1);
|
|
4161
|
+
if (error) {
|
|
4162
|
+
secureLogger.warn("Open-charge lookup failed; allowing checkout", {
|
|
4163
|
+
userId,
|
|
4164
|
+
errorMessage: error.message
|
|
4165
|
+
});
|
|
4166
|
+
return null;
|
|
4167
|
+
}
|
|
4168
|
+
const row = Array.isArray(data) ? data[0] : void 0;
|
|
4169
|
+
return typeof row?.charge_id === "string" ? row.charge_id : null;
|
|
4170
|
+
}
|
|
4171
|
+
async function settleChargeRecord(chargeId, status, eventType) {
|
|
4172
|
+
if (!chargeId || !isSupabaseConfigured()) return;
|
|
4173
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4174
|
+
try {
|
|
4175
|
+
const { error } = await getSupabaseAdmin().from("billing_charges").update({
|
|
4176
|
+
status,
|
|
4177
|
+
settled_at: now,
|
|
4178
|
+
last_event_at: now,
|
|
4179
|
+
last_event_type: eventType
|
|
4180
|
+
}).eq("charge_id", chargeId);
|
|
4181
|
+
if (error) throw new Error(error.message);
|
|
4182
|
+
} catch (err) {
|
|
4183
|
+
secureLogger.error("Failed to settle billing charge record", err, {
|
|
4184
|
+
chargeId,
|
|
4185
|
+
eventType
|
|
4186
|
+
});
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
async function recordChargeCreated(charge) {
|
|
4190
|
+
const { error } = await getSupabaseAdmin().from("billing_charges").insert({
|
|
4191
|
+
charge_id: charge.chargeId,
|
|
4192
|
+
processor: charge.processor,
|
|
4193
|
+
user_id: charge.userId,
|
|
4194
|
+
plan_id: charge.planId,
|
|
4195
|
+
billing_cycle: charge.cycle,
|
|
4196
|
+
amount_usd: charge.amountUsd,
|
|
4197
|
+
plan_id_before: charge.planIdBefore,
|
|
4198
|
+
status: "created"
|
|
4199
|
+
});
|
|
4200
|
+
if (error) {
|
|
4201
|
+
throw new Error(`failed to record billing charge: ${error.message}`);
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
3795
4204
|
var billingRouter = Router6();
|
|
3796
4205
|
billingRouter.post(
|
|
3797
4206
|
"/checkout-session",
|
|
3798
4207
|
requireBillingPrincipal,
|
|
4208
|
+
withCheckoutLock,
|
|
3799
4209
|
asyncHandler(async (req, res) => {
|
|
3800
4210
|
const userId = req.billingUserId;
|
|
3801
4211
|
if (!isStripeCheckoutConfigured()) {
|
|
@@ -3814,11 +4224,23 @@ billingRouter.post(
|
|
|
3814
4224
|
)
|
|
3815
4225
|
);
|
|
3816
4226
|
}
|
|
3817
|
-
const
|
|
3818
|
-
if (!
|
|
4227
|
+
const { plan_id: planId, billing_cycle: cycle } = parsed.data;
|
|
4228
|
+
if (!isSellablePlan(planId)) {
|
|
4229
|
+
return res.status(400).json(
|
|
4230
|
+
apiError(
|
|
4231
|
+
"invalid_request_error",
|
|
4232
|
+
"PLAN_NOT_SELLABLE",
|
|
4233
|
+
`Plan "${planId}" is not sold self-serve. Next action: choose starter or ${PAID_PLAN_ID}, or contact support for Enterprise.`
|
|
4234
|
+
)
|
|
4235
|
+
);
|
|
4236
|
+
}
|
|
4237
|
+
const priceId = priceIdForPlanCycle(planId, cycle);
|
|
4238
|
+
if (!priceId) {
|
|
3819
4239
|
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
3820
4240
|
}
|
|
3821
|
-
const { data: profile } = await getSupabaseAdmin().from("user_profiles").select(
|
|
4241
|
+
const { data: profile } = await getSupabaseAdmin().from("user_profiles").select(
|
|
4242
|
+
"plan_id, billing_customer_id, billing_subscription_id, billing_status"
|
|
4243
|
+
).eq("id", userId).maybeSingle();
|
|
3822
4244
|
if (profile?.billing_subscription_id && typeof profile.billing_status === "string" && OCCUPYING_BILLING_STATUSES.has(profile.billing_status)) {
|
|
3823
4245
|
return res.status(409).json(
|
|
3824
4246
|
apiError(
|
|
@@ -3828,6 +4250,10 @@ billingRouter.post(
|
|
|
3828
4250
|
)
|
|
3829
4251
|
);
|
|
3830
4252
|
}
|
|
4253
|
+
const openChargeId = await findOpenCharge(userId);
|
|
4254
|
+
if (openChargeId) {
|
|
4255
|
+
return res.status(409).json(checkoutAlreadyOpen(openChargeId));
|
|
4256
|
+
}
|
|
3831
4257
|
try {
|
|
3832
4258
|
let customerId = profile?.billing_customer_id ?? null;
|
|
3833
4259
|
if (!customerId) {
|
|
@@ -3846,11 +4272,25 @@ billingRouter.post(
|
|
|
3846
4272
|
customerId,
|
|
3847
4273
|
priceId,
|
|
3848
4274
|
userId,
|
|
3849
|
-
planId
|
|
4275
|
+
planId
|
|
4276
|
+
});
|
|
4277
|
+
await recordChargeCreated({
|
|
4278
|
+
chargeId: session.id,
|
|
4279
|
+
processor: "stripe",
|
|
4280
|
+
userId,
|
|
4281
|
+
planId,
|
|
4282
|
+
cycle,
|
|
4283
|
+
amountUsd: priceForPlanCycle(planId, cycle) ?? 0,
|
|
4284
|
+
planIdBefore: asString(profile?.plan_id)
|
|
3850
4285
|
});
|
|
3851
4286
|
return res.json({
|
|
3852
4287
|
ok: true,
|
|
3853
|
-
data: {
|
|
4288
|
+
data: {
|
|
4289
|
+
url: session.url,
|
|
4290
|
+
session_id: session.id,
|
|
4291
|
+
plan_id: planId,
|
|
4292
|
+
billing_cycle: cycle
|
|
4293
|
+
}
|
|
3854
4294
|
});
|
|
3855
4295
|
} catch (err) {
|
|
3856
4296
|
if (err instanceof StripeApiError) {
|
|
@@ -3922,176 +4362,566 @@ function invoiceSubscriptionContext(invoice) {
|
|
|
3922
4362
|
...metadataOf(parentDetails.metadata),
|
|
3923
4363
|
...metadataOf(details.metadata)
|
|
3924
4364
|
}
|
|
3925
|
-
};
|
|
3926
|
-
}
|
|
3927
|
-
function invoicePeriod(invoice) {
|
|
3928
|
-
const lines = metadataOf(invoice.lines);
|
|
3929
|
-
const data = Array.isArray(lines.data) ? lines.data : [];
|
|
3930
|
-
let best = null;
|
|
3931
|
-
let bestEnd = -Infinity;
|
|
3932
|
-
for (const raw of data) {
|
|
3933
|
-
const line = metadataOf(raw);
|
|
3934
|
-
const period2 = metadataOf(line.period);
|
|
3935
|
-
const end = typeof period2.end === "number" ? period2.end : null;
|
|
3936
|
-
if (end !== null && end > bestEnd) {
|
|
3937
|
-
bestEnd = end;
|
|
3938
|
-
best = line;
|
|
4365
|
+
};
|
|
4366
|
+
}
|
|
4367
|
+
function invoicePeriod(invoice) {
|
|
4368
|
+
const lines = metadataOf(invoice.lines);
|
|
4369
|
+
const data = Array.isArray(lines.data) ? lines.data : [];
|
|
4370
|
+
let best = null;
|
|
4371
|
+
let bestEnd = -Infinity;
|
|
4372
|
+
for (const raw of data) {
|
|
4373
|
+
const line = metadataOf(raw);
|
|
4374
|
+
const period2 = metadataOf(line.period);
|
|
4375
|
+
const end = typeof period2.end === "number" ? period2.end : null;
|
|
4376
|
+
if (end !== null && end > bestEnd) {
|
|
4377
|
+
bestEnd = end;
|
|
4378
|
+
best = line;
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
if (!best) return { cycleStart: null, cycleEnd: null, priceId: null };
|
|
4382
|
+
const period = metadataOf(best.period);
|
|
4383
|
+
const price = metadataOf(best.price);
|
|
4384
|
+
const pricing = metadataOf(best.pricing);
|
|
4385
|
+
const priceDetails = metadataOf(pricing.price_details);
|
|
4386
|
+
return {
|
|
4387
|
+
cycleStart: isoFromUnixSeconds(period.start),
|
|
4388
|
+
cycleEnd: isoFromUnixSeconds(period.end),
|
|
4389
|
+
priceId: asString(price.id) ?? asString(priceDetails.price)
|
|
4390
|
+
};
|
|
4391
|
+
}
|
|
4392
|
+
function cappedCreditCycleEnd(cycleStart, cycleEnd) {
|
|
4393
|
+
if (!cycleStart) return cycleEnd;
|
|
4394
|
+
const start = new Date(cycleStart);
|
|
4395
|
+
if (Number.isNaN(start.getTime())) return cycleEnd;
|
|
4396
|
+
const monthly = creditCycleEndFrom(start);
|
|
4397
|
+
if (!cycleEnd) return monthly.toISOString();
|
|
4398
|
+
const end = new Date(cycleEnd);
|
|
4399
|
+
if (Number.isNaN(end.getTime())) return monthly.toISOString();
|
|
4400
|
+
return (end < monthly ? end : monthly).toISOString();
|
|
4401
|
+
}
|
|
4402
|
+
async function decideWebhookAction(event) {
|
|
4403
|
+
const object = metadataOf(event.data?.object);
|
|
4404
|
+
switch (event.type) {
|
|
4405
|
+
case "checkout.session.completed": {
|
|
4406
|
+
const mode = asString(object.mode);
|
|
4407
|
+
if (mode !== null && mode !== "subscription") {
|
|
4408
|
+
return { kind: "ignore", note: "non-subscription checkout" };
|
|
4409
|
+
}
|
|
4410
|
+
const metadata = metadataOf(object.metadata);
|
|
4411
|
+
const userId = asString(metadata.user_id) ?? asString(object.client_reference_id);
|
|
4412
|
+
if (!userId) {
|
|
4413
|
+
return { kind: "ignore", note: "checkout session without user_id" };
|
|
4414
|
+
}
|
|
4415
|
+
return {
|
|
4416
|
+
kind: "apply",
|
|
4417
|
+
args: {
|
|
4418
|
+
eventId: event.id,
|
|
4419
|
+
eventType: event.type,
|
|
4420
|
+
userId,
|
|
4421
|
+
newPlanId: asString(metadata.plan_id) ?? PAID_PLAN_ID,
|
|
4422
|
+
grantCredits: true,
|
|
4423
|
+
billingStatus: "active",
|
|
4424
|
+
customerId: refId(object.customer),
|
|
4425
|
+
subscriptionId: refId(object.subscription),
|
|
4426
|
+
subscriptionStatus: "active",
|
|
4427
|
+
ledgerReason: "plan_change"
|
|
4428
|
+
}
|
|
4429
|
+
};
|
|
4430
|
+
}
|
|
4431
|
+
case "customer.subscription.updated":
|
|
4432
|
+
case "customer.subscription.deleted": {
|
|
4433
|
+
const metadata = metadataOf(object.metadata);
|
|
4434
|
+
const customerId = refId(object.customer);
|
|
4435
|
+
const userId = asString(metadata.user_id) ?? await resolveUserIdByCustomer(customerId);
|
|
4436
|
+
if (!userId) {
|
|
4437
|
+
return {
|
|
4438
|
+
kind: "unresolved_user",
|
|
4439
|
+
note: `no user binding for customer ${customerId ?? "<none>"}`
|
|
4440
|
+
};
|
|
4441
|
+
}
|
|
4442
|
+
if (event.type === "customer.subscription.deleted") {
|
|
4443
|
+
return {
|
|
4444
|
+
kind: "apply",
|
|
4445
|
+
args: {
|
|
4446
|
+
eventId: event.id,
|
|
4447
|
+
eventType: event.type,
|
|
4448
|
+
userId,
|
|
4449
|
+
newPlanId: FREE_PLAN_ID,
|
|
4450
|
+
grantCredits: true,
|
|
4451
|
+
billingStatus: "canceled",
|
|
4452
|
+
customerId,
|
|
4453
|
+
subscriptionId: refId(object.id),
|
|
4454
|
+
subscriptionStatus: "canceled",
|
|
4455
|
+
ledgerReason: "plan_change"
|
|
4456
|
+
}
|
|
4457
|
+
};
|
|
4458
|
+
}
|
|
4459
|
+
const status = asString(object.status);
|
|
4460
|
+
const items = metadataOf(object.items);
|
|
4461
|
+
const firstItem = metadataOf(
|
|
4462
|
+
Array.isArray(items.data) ? items.data[0] : void 0
|
|
4463
|
+
);
|
|
4464
|
+
const priceId = asString(metadataOf(firstItem.price).id);
|
|
4465
|
+
return {
|
|
4466
|
+
kind: "apply",
|
|
4467
|
+
args: {
|
|
4468
|
+
eventId: event.id,
|
|
4469
|
+
eventType: event.type,
|
|
4470
|
+
userId,
|
|
4471
|
+
// Tier/status sync only — credits ride the money event
|
|
4472
|
+
// (invoice.paid), which is the one wallet clock.
|
|
4473
|
+
newPlanId: planIdForPrice(priceId) ?? asString(metadata.plan_id) ?? null,
|
|
4474
|
+
grantCredits: false,
|
|
4475
|
+
billingStatus: mapSubscriptionStatusToBilling(status),
|
|
4476
|
+
customerId,
|
|
4477
|
+
subscriptionId: refId(object.id),
|
|
4478
|
+
// Whitelisted, never forwarded raw. stripe_subscriptions.status has
|
|
4479
|
+
// a CHECK over eight literals; a status Stripe adds later raises
|
|
4480
|
+
// 23514 *inside* apply_plan_change, which rolls back the dedupe
|
|
4481
|
+
// claim and the wallet grant with it, 500s, and leaves Stripe
|
|
4482
|
+
// retrying forever. Null here means "skip the registry upsert",
|
|
4483
|
+
// which still applies the tier and still acks 200.
|
|
4484
|
+
subscriptionStatus: registrySubscriptionStatus(status),
|
|
4485
|
+
priceId,
|
|
4486
|
+
ledgerReason: "plan_change"
|
|
4487
|
+
}
|
|
4488
|
+
};
|
|
4489
|
+
}
|
|
4490
|
+
case "invoice.paid":
|
|
4491
|
+
case "invoice.payment_failed": {
|
|
4492
|
+
const { subscriptionId, metadata } = invoiceSubscriptionContext(object);
|
|
4493
|
+
if (!subscriptionId) {
|
|
4494
|
+
return { kind: "ignore", note: "invoice without a subscription" };
|
|
4495
|
+
}
|
|
4496
|
+
const customerId = refId(object.customer);
|
|
4497
|
+
const userId = asString(metadata.user_id) ?? await resolveUserIdByCustomer(customerId);
|
|
4498
|
+
if (!userId) {
|
|
4499
|
+
return {
|
|
4500
|
+
kind: "unresolved_user",
|
|
4501
|
+
note: `no user binding for customer ${customerId ?? "<none>"}`
|
|
4502
|
+
};
|
|
4503
|
+
}
|
|
4504
|
+
if (event.type === "invoice.payment_failed") {
|
|
4505
|
+
return {
|
|
4506
|
+
kind: "apply",
|
|
4507
|
+
args: {
|
|
4508
|
+
eventId: event.id,
|
|
4509
|
+
eventType: event.type,
|
|
4510
|
+
userId,
|
|
4511
|
+
billingStatus: "past_due",
|
|
4512
|
+
customerId,
|
|
4513
|
+
subscriptionId,
|
|
4514
|
+
ledgerReason: "plan_change"
|
|
4515
|
+
}
|
|
4516
|
+
};
|
|
4517
|
+
}
|
|
4518
|
+
const period = invoicePeriod(object);
|
|
4519
|
+
const creditCycleEnd = cappedCreditCycleEnd(
|
|
4520
|
+
period.cycleStart,
|
|
4521
|
+
period.cycleEnd
|
|
4522
|
+
);
|
|
4523
|
+
return {
|
|
4524
|
+
kind: "apply",
|
|
4525
|
+
args: {
|
|
4526
|
+
eventId: event.id,
|
|
4527
|
+
eventType: event.type,
|
|
4528
|
+
userId,
|
|
4529
|
+
newPlanId: planIdForPrice(period.priceId) ?? asString(metadata.plan_id) ?? null,
|
|
4530
|
+
grantCredits: true,
|
|
4531
|
+
cycleStart: period.cycleStart,
|
|
4532
|
+
cycleEnd: creditCycleEnd,
|
|
4533
|
+
termEnd: period.cycleEnd,
|
|
4534
|
+
billingStatus: "active",
|
|
4535
|
+
customerId,
|
|
4536
|
+
subscriptionId,
|
|
4537
|
+
priceId: period.priceId,
|
|
4538
|
+
ledgerReason: "refill"
|
|
4539
|
+
}
|
|
4540
|
+
};
|
|
4541
|
+
}
|
|
4542
|
+
default:
|
|
4543
|
+
return { kind: "ignore", note: `unhandled event type ${event.type}` };
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
var stripeWebhookHandler = asyncHandler(
|
|
4547
|
+
async (req, res) => {
|
|
4548
|
+
const secret = getStripeWebhookSecret();
|
|
4549
|
+
if (!secret) {
|
|
4550
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4551
|
+
}
|
|
4552
|
+
const raw = req.body;
|
|
4553
|
+
if (!Buffer.isBuffer(raw)) {
|
|
4554
|
+
secureLogger.error(
|
|
4555
|
+
"Stripe webhook received a non-raw body \u2014 express.raw must mount before the JSON parser",
|
|
4556
|
+
void 0,
|
|
4557
|
+
{ path: req.path }
|
|
4558
|
+
);
|
|
4559
|
+
return res.status(500).json(
|
|
4560
|
+
apiError(
|
|
4561
|
+
"internal_error",
|
|
4562
|
+
"WEBHOOK_BODY_NOT_RAW",
|
|
4563
|
+
"Webhook raw body unavailable; server middleware misconfigured."
|
|
4564
|
+
)
|
|
4565
|
+
);
|
|
4566
|
+
}
|
|
4567
|
+
const verification = verifyStripeSignature({
|
|
4568
|
+
payload: raw,
|
|
4569
|
+
header: req.headers["stripe-signature"],
|
|
4570
|
+
secret
|
|
4571
|
+
});
|
|
4572
|
+
if (!verification.valid) {
|
|
4573
|
+
secureLogger.warn("Stripe webhook signature rejected", {
|
|
4574
|
+
reason: verification.reason
|
|
4575
|
+
});
|
|
4576
|
+
return res.status(400).json(
|
|
4577
|
+
apiError(
|
|
4578
|
+
"authentication_error",
|
|
4579
|
+
"INVALID_WEBHOOK_SIGNATURE",
|
|
4580
|
+
`Stripe signature verification failed (${verification.reason}).`
|
|
4581
|
+
)
|
|
4582
|
+
);
|
|
4583
|
+
}
|
|
4584
|
+
let event;
|
|
4585
|
+
try {
|
|
4586
|
+
event = JSON.parse(raw.toString("utf8"));
|
|
4587
|
+
} catch {
|
|
4588
|
+
return res.status(400).json(
|
|
4589
|
+
apiError(
|
|
4590
|
+
"invalid_request_error",
|
|
4591
|
+
"INVALID_JSON",
|
|
4592
|
+
"Webhook payload is not valid JSON."
|
|
4593
|
+
)
|
|
4594
|
+
);
|
|
4595
|
+
}
|
|
4596
|
+
if (!asString(event?.id) || !asString(event?.type)) {
|
|
4597
|
+
return res.status(400).json(
|
|
4598
|
+
apiError(
|
|
4599
|
+
"invalid_request_error",
|
|
4600
|
+
"INVALID_EVENT",
|
|
4601
|
+
"Webhook payload is missing event id or type."
|
|
4602
|
+
)
|
|
4603
|
+
);
|
|
4604
|
+
}
|
|
4605
|
+
try {
|
|
4606
|
+
const decision = await decideWebhookAction(event);
|
|
4607
|
+
if (decision.kind === "ignore") {
|
|
4608
|
+
return res.json({ received: true, handled: false });
|
|
4609
|
+
}
|
|
4610
|
+
if (decision.kind === "unresolved_user") {
|
|
4611
|
+
secureLogger.warn("Stripe webhook user unresolved \u2014 asking for retry", {
|
|
4612
|
+
eventType: event.type,
|
|
4613
|
+
note: decision.note
|
|
4614
|
+
});
|
|
4615
|
+
return res.status(500).json(
|
|
4616
|
+
apiError(
|
|
4617
|
+
"internal_error",
|
|
4618
|
+
"WEBHOOK_USER_UNRESOLVED",
|
|
4619
|
+
"Could not resolve the Hive user for this event yet; Stripe should retry."
|
|
4620
|
+
)
|
|
4621
|
+
);
|
|
4622
|
+
}
|
|
4623
|
+
const result = await applyPlanChange(decision.args);
|
|
4624
|
+
if (event.type === "checkout.session.completed") {
|
|
4625
|
+
await settleChargeRecord(
|
|
4626
|
+
asString(metadataOf(event.data?.object).id),
|
|
4627
|
+
"confirmed",
|
|
4628
|
+
event.type
|
|
4629
|
+
);
|
|
4630
|
+
}
|
|
4631
|
+
return res.json({
|
|
4632
|
+
received: true,
|
|
4633
|
+
handled: true,
|
|
4634
|
+
outcome: result.outcome,
|
|
4635
|
+
replayed: result.replayed
|
|
4636
|
+
});
|
|
4637
|
+
} catch (err) {
|
|
4638
|
+
secureLogger.error("Stripe webhook processing failed", err, {
|
|
4639
|
+
eventType: event.type
|
|
4640
|
+
});
|
|
4641
|
+
return res.status(500).json(
|
|
4642
|
+
apiError(
|
|
4643
|
+
"internal_error",
|
|
4644
|
+
"WEBHOOK_PROCESSING_FAILED",
|
|
4645
|
+
"Webhook processing failed; Stripe should retry. Processing is idempotent by event id."
|
|
4646
|
+
)
|
|
4647
|
+
);
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
);
|
|
4651
|
+
var LLAMAPAY_UNAVAILABLE = apiError(
|
|
4652
|
+
"internal_error",
|
|
4653
|
+
"BILLING_UNAVAILABLE",
|
|
4654
|
+
"Crypto checkout is not configured on this deployment. Next action: contact support@hiveintelligence.xyz to upgrade, or retry once billing is enabled."
|
|
4655
|
+
);
|
|
4656
|
+
var CryptoCheckoutSchema = z2.object({
|
|
4657
|
+
plan_id: z2.string().min(1).default(PAID_PLAN_ID),
|
|
4658
|
+
billing_cycle: z2.enum(["monthly", "yearly"]).default("monthly")
|
|
4659
|
+
});
|
|
4660
|
+
billingRouter.post(
|
|
4661
|
+
"/checkout",
|
|
4662
|
+
requireBillingPrincipal,
|
|
4663
|
+
withCheckoutLock,
|
|
4664
|
+
asyncHandler(async (req, res) => {
|
|
4665
|
+
const userId = req.billingUserId;
|
|
4666
|
+
if (!isLlamaPayCheckoutConfigured() || !isSupabaseConfigured()) {
|
|
4667
|
+
return res.status(503).json(LLAMAPAY_UNAVAILABLE);
|
|
4668
|
+
}
|
|
4669
|
+
const parsed = CryptoCheckoutSchema.safeParse(req.body ?? {});
|
|
4670
|
+
if (!parsed.success) {
|
|
4671
|
+
return res.status(400).json(
|
|
4672
|
+
apiError(
|
|
4673
|
+
"invalid_request_error",
|
|
4674
|
+
"VALIDATION_ERROR",
|
|
4675
|
+
'billing_cycle must be "monthly" or "yearly", and plan_id must be a sold plan.'
|
|
4676
|
+
)
|
|
4677
|
+
);
|
|
4678
|
+
}
|
|
4679
|
+
const { plan_id: planId, billing_cycle: cycle } = parsed.data;
|
|
4680
|
+
if (!isSellablePlan(planId)) {
|
|
4681
|
+
return res.status(400).json(
|
|
4682
|
+
apiError(
|
|
4683
|
+
"invalid_request_error",
|
|
4684
|
+
"PLAN_NOT_SELLABLE",
|
|
4685
|
+
`Plan "${planId}" is not sold self-serve. Next action: choose starter or ${PAID_PLAN_ID}, or contact support for Enterprise.`
|
|
4686
|
+
)
|
|
4687
|
+
);
|
|
4688
|
+
}
|
|
4689
|
+
const amountUsd = priceForPlanCycle(planId, cycle);
|
|
4690
|
+
if (amountUsd === void 0) {
|
|
4691
|
+
return res.status(503).json(LLAMAPAY_UNAVAILABLE);
|
|
4692
|
+
}
|
|
4693
|
+
const { data: profile } = await getSupabaseAdmin().from("user_profiles").select("plan_id, billing_subscription_id, billing_status").eq("id", userId).maybeSingle();
|
|
4694
|
+
const hasOccupyingSubscription = Boolean(profile?.billing_subscription_id) && typeof profile?.billing_status === "string" && OCCUPYING_BILLING_STATUSES.has(profile.billing_status);
|
|
4695
|
+
const { data: liveSubscription } = await getSupabaseAdmin().from("stripe_subscriptions").select("current_period_end").eq("user_id", userId).in("status", [...OCCUPYING_BILLING_STATUSES]).order("current_period_end", { ascending: false }).limit(1).maybeSingle();
|
|
4696
|
+
if (!canPurchasePlan(
|
|
4697
|
+
profile?.plan_id ?? null,
|
|
4698
|
+
planId,
|
|
4699
|
+
liveSubscription?.current_period_end ?? null
|
|
4700
|
+
)) {
|
|
4701
|
+
const sameTier = profile?.plan_id === planId;
|
|
4702
|
+
return res.status(409).json(
|
|
4703
|
+
apiError(
|
|
4704
|
+
"invalid_request_error",
|
|
4705
|
+
hasOccupyingSubscription ? "SUBSCRIPTION_EXISTS" : "PLAN_NOT_AN_UPGRADE",
|
|
4706
|
+
sameTier ? `This account is already on ${planId} and the current term still has more than ${RENEWAL_WINDOW_DAYS} days left, so renewing now would replace time you have already paid for. Next action: renew inside the last ${RENEWAL_WINDOW_DAYS} days, pick a higher tier to upgrade immediately, or message us at https://t.me/HiveIntelligence.` : `This account is already on ${profile?.plan_id ?? "a paid plan"}. Next action: pick a higher tier to upgrade now, or message us at https://t.me/HiveIntelligence to change or cancel the current plan.`
|
|
4707
|
+
)
|
|
4708
|
+
);
|
|
4709
|
+
}
|
|
4710
|
+
const openChargeId = await findOpenCharge(userId);
|
|
4711
|
+
if (openChargeId) {
|
|
4712
|
+
return res.status(409).json(checkoutAlreadyOpen(openChargeId));
|
|
4713
|
+
}
|
|
4714
|
+
try {
|
|
4715
|
+
const charge = await createLlamaPayCharge({
|
|
4716
|
+
userId,
|
|
4717
|
+
planId,
|
|
4718
|
+
cycle,
|
|
4719
|
+
amountUsd,
|
|
4720
|
+
email: req.billingUserEmail
|
|
4721
|
+
});
|
|
4722
|
+
await recordChargeCreated({
|
|
4723
|
+
chargeId: charge.id,
|
|
4724
|
+
processor: "llamapay",
|
|
4725
|
+
userId,
|
|
4726
|
+
planId,
|
|
4727
|
+
cycle,
|
|
4728
|
+
amountUsd,
|
|
4729
|
+
planIdBefore: asString(profile?.plan_id)
|
|
4730
|
+
});
|
|
4731
|
+
return res.json({
|
|
4732
|
+
ok: true,
|
|
4733
|
+
data: {
|
|
4734
|
+
url: charge.hostedUrl,
|
|
4735
|
+
charge_id: charge.id,
|
|
4736
|
+
plan_id: planId,
|
|
4737
|
+
billing_cycle: cycle,
|
|
4738
|
+
amount_usd: amountUsd,
|
|
4739
|
+
processor: "llamapay"
|
|
4740
|
+
}
|
|
4741
|
+
});
|
|
4742
|
+
} catch (err) {
|
|
4743
|
+
if (err instanceof LlamaPayApiError) {
|
|
4744
|
+
secureLogger.error("LlamaPay charge creation failed", err, {
|
|
4745
|
+
userId,
|
|
4746
|
+
status: err.status
|
|
4747
|
+
});
|
|
4748
|
+
return res.status(502).json(
|
|
4749
|
+
apiError(
|
|
4750
|
+
"provider_error",
|
|
4751
|
+
"BILLING_PROVIDER_ERROR",
|
|
4752
|
+
"LlamaPay rejected the checkout request. Next action: retry; if it persists, contact support@hiveintelligence.xyz."
|
|
4753
|
+
)
|
|
4754
|
+
);
|
|
4755
|
+
}
|
|
4756
|
+
throw err;
|
|
4757
|
+
}
|
|
4758
|
+
})
|
|
4759
|
+
);
|
|
4760
|
+
var AbandonSchema = z2.object({
|
|
4761
|
+
charge_id: z2.string().min(1).optional()
|
|
4762
|
+
});
|
|
4763
|
+
billingRouter.post(
|
|
4764
|
+
"/checkout/abandon",
|
|
4765
|
+
requireBillingPrincipal,
|
|
4766
|
+
asyncHandler(async (req, res) => {
|
|
4767
|
+
const userId = req.billingUserId;
|
|
4768
|
+
if (!isSupabaseConfigured()) {
|
|
4769
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4770
|
+
}
|
|
4771
|
+
const parsed = AbandonSchema.safeParse(req.body ?? {});
|
|
4772
|
+
if (!parsed.success) {
|
|
4773
|
+
return res.status(400).json(
|
|
4774
|
+
apiError(
|
|
4775
|
+
"invalid_request_error",
|
|
4776
|
+
"VALIDATION_ERROR",
|
|
4777
|
+
"charge_id must be a non-empty string when provided. Next action: omit it to release every checkout this account has open."
|
|
4778
|
+
)
|
|
4779
|
+
);
|
|
4780
|
+
}
|
|
4781
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4782
|
+
let query = getSupabaseAdmin().from("billing_charges").update({
|
|
4783
|
+
status: "canceled",
|
|
4784
|
+
last_event_at: now,
|
|
4785
|
+
last_event_type: ABANDONED_EVENT_TYPE
|
|
4786
|
+
}).eq("user_id", userId).eq("status", "created").is("settled_at", null).gte("created_at", openChargeCutoff());
|
|
4787
|
+
if (parsed.data.charge_id) {
|
|
4788
|
+
query = query.eq("charge_id", parsed.data.charge_id);
|
|
4789
|
+
}
|
|
4790
|
+
const { data, error } = await query.select("charge_id");
|
|
4791
|
+
if (error) {
|
|
4792
|
+
secureLogger.error("Failed to mark checkout abandoned", error, {
|
|
4793
|
+
userId
|
|
4794
|
+
});
|
|
4795
|
+
return res.status(500).json(
|
|
4796
|
+
apiError(
|
|
4797
|
+
"internal_error",
|
|
4798
|
+
"CHECKOUT_ABANDON_FAILED",
|
|
4799
|
+
`Could not release the open checkout. Next action: retry, or wait ${OPEN_CHARGE_WINDOW_MINUTES} minutes for it to clear on its own.`
|
|
4800
|
+
)
|
|
4801
|
+
);
|
|
4802
|
+
}
|
|
4803
|
+
const abandoned = (Array.isArray(data) ? data : []).map((row) => row.charge_id).filter((id) => typeof id === "string");
|
|
4804
|
+
return res.json({ ok: true, data: { abandoned } });
|
|
4805
|
+
})
|
|
4806
|
+
);
|
|
4807
|
+
billingRouter.get(
|
|
4808
|
+
"/charges",
|
|
4809
|
+
requireBillingPrincipal,
|
|
4810
|
+
asyncHandler(async (req, res) => {
|
|
4811
|
+
const userId = req.billingUserId;
|
|
4812
|
+
if (!isSupabaseConfigured()) {
|
|
4813
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4814
|
+
}
|
|
4815
|
+
const { data, error } = await getSupabaseAdmin().from("billing_charges").select(
|
|
4816
|
+
"charge_id, processor, plan_id, billing_cycle, amount_usd, status, created_at, settled_at"
|
|
4817
|
+
).eq("user_id", userId).order("created_at", { ascending: false }).limit(50);
|
|
4818
|
+
if (error) {
|
|
4819
|
+
secureLogger.error("Failed to read billing charges", error, { userId });
|
|
4820
|
+
return res.status(500).json(
|
|
4821
|
+
apiError(
|
|
4822
|
+
"internal_error",
|
|
4823
|
+
"BILLING_HISTORY_UNAVAILABLE",
|
|
4824
|
+
"Could not load your billing history. Next action: retry shortly, or contact support@hiveintelligence.xyz with the date of the charge."
|
|
4825
|
+
)
|
|
4826
|
+
);
|
|
3939
4827
|
}
|
|
4828
|
+
return res.json({ ok: true, data: { charges: data ?? [] } });
|
|
4829
|
+
})
|
|
4830
|
+
);
|
|
4831
|
+
var LLAMAPAY_EVENT_TYPES = [
|
|
4832
|
+
"charge:pending",
|
|
4833
|
+
"charge:confirmed",
|
|
4834
|
+
"subscription:expired"
|
|
4835
|
+
];
|
|
4836
|
+
function localChargeAmountUsd(pricing) {
|
|
4837
|
+
const local = metadataOf(metadataOf(pricing).local);
|
|
4838
|
+
const currency = asString(local.currency);
|
|
4839
|
+
if (currency !== null && currency.toUpperCase() !== "USD") {
|
|
4840
|
+
return Number.NaN;
|
|
4841
|
+
}
|
|
4842
|
+
const raw = local.amount;
|
|
4843
|
+
const amount = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseFloat(raw) : void 0;
|
|
4844
|
+
if (amount === void 0 || !Number.isFinite(amount)) return void 0;
|
|
4845
|
+
return amount;
|
|
4846
|
+
}
|
|
4847
|
+
function decideLlamaPayAction(event) {
|
|
4848
|
+
const data = metadataOf(event.data);
|
|
4849
|
+
const metadata = metadataOf(data.metadata);
|
|
4850
|
+
const userId = asString(metadata.user_id);
|
|
4851
|
+
const chargeId = asString(data.id);
|
|
4852
|
+
if (!userId) {
|
|
4853
|
+
return { kind: "ignore", note: "charge without user_id metadata" };
|
|
3940
4854
|
}
|
|
3941
|
-
if (!best) return { cycleStart: null, cycleEnd: null, priceId: null };
|
|
3942
|
-
const period = metadataOf(best.period);
|
|
3943
|
-
const price = metadataOf(best.price);
|
|
3944
|
-
const pricing = metadataOf(best.pricing);
|
|
3945
|
-
const priceDetails = metadataOf(pricing.price_details);
|
|
3946
|
-
return {
|
|
3947
|
-
cycleStart: isoFromUnixSeconds(period.start),
|
|
3948
|
-
cycleEnd: isoFromUnixSeconds(period.end),
|
|
3949
|
-
priceId: asString(price.id) ?? asString(priceDetails.price)
|
|
3950
|
-
};
|
|
3951
|
-
}
|
|
3952
|
-
async function decideWebhookAction(event) {
|
|
3953
|
-
const object = metadataOf(event.data?.object);
|
|
3954
4855
|
switch (event.type) {
|
|
3955
|
-
case "
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
const
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
4856
|
+
case "charge:pending":
|
|
4857
|
+
case "charge:confirmed": {
|
|
4858
|
+
const planId = asString(metadata.plan_id) ?? PAID_PLAN_ID;
|
|
4859
|
+
const cycle = metadata.billing_cycle === "yearly" ? "yearly" : "monthly";
|
|
4860
|
+
const expected = priceForPlanCycle(planId, cycle);
|
|
4861
|
+
const paid = localChargeAmountUsd(data.pricing);
|
|
4862
|
+
if (expected !== void 0 && paid !== void 0 && paid !== expected) {
|
|
4863
|
+
return {
|
|
4864
|
+
kind: "ignore",
|
|
4865
|
+
note: `charge ${chargeId ?? "<no id>"} paid ${Number.isNaN(paid) ? "a non-USD amount" : paid} against ${expected} for ${planId}/${cycle} \u2014 entitlement withheld`
|
|
4866
|
+
};
|
|
3964
4867
|
}
|
|
4868
|
+
const start = event.created_at ? new Date(event.created_at) : /* @__PURE__ */ new Date();
|
|
4869
|
+
const cycleStart = Number.isNaN(start.getTime()) ? /* @__PURE__ */ new Date() : start;
|
|
3965
4870
|
return {
|
|
3966
4871
|
kind: "apply",
|
|
3967
4872
|
args: {
|
|
3968
4873
|
eventId: event.id,
|
|
3969
4874
|
eventType: event.type,
|
|
3970
4875
|
userId,
|
|
3971
|
-
newPlanId:
|
|
4876
|
+
newPlanId: planId,
|
|
3972
4877
|
grantCredits: true,
|
|
4878
|
+
cycleStart: cycleStart.toISOString(),
|
|
4879
|
+
// Two clocks, deliberately. Credits are sold monthly on every term,
|
|
4880
|
+
// so the wallet cycle is always a month; the term is what the charge
|
|
4881
|
+
// actually bought. Passing the annual date as the credit cycle gave
|
|
4882
|
+
// a $1,490 buyer one month's credits to last a year.
|
|
4883
|
+
cycleEnd: creditCycleEndFrom(cycleStart).toISOString(),
|
|
4884
|
+
termEnd: termEndFrom(cycleStart, cycle).toISOString(),
|
|
3973
4885
|
billingStatus: "active",
|
|
3974
|
-
|
|
3975
|
-
subscriptionId: refId(object.subscription),
|
|
4886
|
+
subscriptionId: chargeId,
|
|
3976
4887
|
subscriptionStatus: "active",
|
|
3977
4888
|
ledgerReason: "plan_change"
|
|
3978
4889
|
}
|
|
3979
4890
|
};
|
|
3980
4891
|
}
|
|
3981
|
-
case "
|
|
3982
|
-
case "customer.subscription.deleted": {
|
|
3983
|
-
const metadata = metadataOf(object.metadata);
|
|
3984
|
-
const customerId = refId(object.customer);
|
|
3985
|
-
const userId = asString(metadata.user_id) ?? await resolveUserIdByCustomer(customerId);
|
|
3986
|
-
if (!userId) {
|
|
3987
|
-
return {
|
|
3988
|
-
kind: "unresolved_user",
|
|
3989
|
-
note: `no user binding for customer ${customerId ?? "<none>"}`
|
|
3990
|
-
};
|
|
3991
|
-
}
|
|
3992
|
-
if (event.type === "customer.subscription.deleted") {
|
|
3993
|
-
return {
|
|
3994
|
-
kind: "apply",
|
|
3995
|
-
args: {
|
|
3996
|
-
eventId: event.id,
|
|
3997
|
-
eventType: event.type,
|
|
3998
|
-
userId,
|
|
3999
|
-
newPlanId: FREE_PLAN_ID,
|
|
4000
|
-
grantCredits: true,
|
|
4001
|
-
billingStatus: "canceled",
|
|
4002
|
-
customerId,
|
|
4003
|
-
subscriptionId: refId(object.id),
|
|
4004
|
-
subscriptionStatus: "canceled",
|
|
4005
|
-
ledgerReason: "plan_change"
|
|
4006
|
-
}
|
|
4007
|
-
};
|
|
4008
|
-
}
|
|
4009
|
-
const status = asString(object.status);
|
|
4010
|
-
const items = metadataOf(object.items);
|
|
4011
|
-
const firstItem = metadataOf(
|
|
4012
|
-
Array.isArray(items.data) ? items.data[0] : void 0
|
|
4013
|
-
);
|
|
4014
|
-
const priceId = asString(metadataOf(firstItem.price).id);
|
|
4892
|
+
case "subscription:expired": {
|
|
4015
4893
|
return {
|
|
4016
4894
|
kind: "apply",
|
|
4017
4895
|
args: {
|
|
4018
4896
|
eventId: event.id,
|
|
4019
4897
|
eventType: event.type,
|
|
4020
4898
|
userId,
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
customerId,
|
|
4027
|
-
subscriptionId: refId(object.id),
|
|
4028
|
-
subscriptionStatus: status,
|
|
4029
|
-
priceId,
|
|
4899
|
+
newPlanId: FREE_PLAN_ID,
|
|
4900
|
+
grantCredits: true,
|
|
4901
|
+
billingStatus: "canceled",
|
|
4902
|
+
subscriptionId: chargeId,
|
|
4903
|
+
subscriptionStatus: "canceled",
|
|
4030
4904
|
ledgerReason: "plan_change"
|
|
4031
4905
|
}
|
|
4032
4906
|
};
|
|
4033
4907
|
}
|
|
4034
|
-
|
|
4035
|
-
case "invoice.payment_failed": {
|
|
4036
|
-
const { subscriptionId, metadata } = invoiceSubscriptionContext(object);
|
|
4037
|
-
if (!subscriptionId) {
|
|
4038
|
-
return { kind: "ignore", note: "invoice without a subscription" };
|
|
4039
|
-
}
|
|
4040
|
-
const customerId = refId(object.customer);
|
|
4041
|
-
const userId = asString(metadata.user_id) ?? await resolveUserIdByCustomer(customerId);
|
|
4042
|
-
if (!userId) {
|
|
4043
|
-
return {
|
|
4044
|
-
kind: "unresolved_user",
|
|
4045
|
-
note: `no user binding for customer ${customerId ?? "<none>"}`
|
|
4046
|
-
};
|
|
4047
|
-
}
|
|
4048
|
-
if (event.type === "invoice.payment_failed") {
|
|
4049
|
-
return {
|
|
4050
|
-
kind: "apply",
|
|
4051
|
-
args: {
|
|
4052
|
-
eventId: event.id,
|
|
4053
|
-
eventType: event.type,
|
|
4054
|
-
userId,
|
|
4055
|
-
billingStatus: "past_due",
|
|
4056
|
-
customerId,
|
|
4057
|
-
subscriptionId,
|
|
4058
|
-
ledgerReason: "plan_change"
|
|
4059
|
-
}
|
|
4060
|
-
};
|
|
4061
|
-
}
|
|
4062
|
-
const period = invoicePeriod(object);
|
|
4908
|
+
default:
|
|
4063
4909
|
return {
|
|
4064
|
-
kind: "
|
|
4065
|
-
|
|
4066
|
-
eventId: event.id,
|
|
4067
|
-
eventType: event.type,
|
|
4068
|
-
userId,
|
|
4069
|
-
newPlanId: planIdForPrice(period.priceId) ?? asString(metadata.plan_id) ?? null,
|
|
4070
|
-
grantCredits: true,
|
|
4071
|
-
cycleStart: period.cycleStart,
|
|
4072
|
-
cycleEnd: period.cycleEnd,
|
|
4073
|
-
billingStatus: "active",
|
|
4074
|
-
customerId,
|
|
4075
|
-
subscriptionId,
|
|
4076
|
-
priceId: period.priceId,
|
|
4077
|
-
ledgerReason: "refill"
|
|
4078
|
-
}
|
|
4910
|
+
kind: "ignore",
|
|
4911
|
+
note: `unhandled event type ${event.type} (known: ${LLAMAPAY_EVENT_TYPES.join(", ")})`
|
|
4079
4912
|
};
|
|
4080
|
-
}
|
|
4081
|
-
default:
|
|
4082
|
-
return { kind: "ignore", note: `unhandled event type ${event.type}` };
|
|
4083
4913
|
}
|
|
4084
4914
|
}
|
|
4085
|
-
var
|
|
4915
|
+
var llamapayWebhookHandler = asyncHandler(
|
|
4086
4916
|
async (req, res) => {
|
|
4087
|
-
const secret =
|
|
4917
|
+
const secret = getLlamaPayWebhookSecret();
|
|
4088
4918
|
if (!secret) {
|
|
4089
|
-
return res.status(503).json(
|
|
4919
|
+
return res.status(503).json(LLAMAPAY_UNAVAILABLE);
|
|
4090
4920
|
}
|
|
4091
4921
|
const raw = req.body;
|
|
4092
4922
|
if (!Buffer.isBuffer(raw)) {
|
|
4093
4923
|
secureLogger.error(
|
|
4094
|
-
"
|
|
4924
|
+
"LlamaPay webhook received a non-raw body \u2014 express.raw must mount before the JSON parser",
|
|
4095
4925
|
void 0,
|
|
4096
4926
|
{ path: req.path }
|
|
4097
4927
|
);
|
|
@@ -4103,26 +4933,26 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4103
4933
|
)
|
|
4104
4934
|
);
|
|
4105
4935
|
}
|
|
4106
|
-
const verification =
|
|
4936
|
+
const verification = verifyLlamaPaySignature({
|
|
4107
4937
|
payload: raw,
|
|
4108
|
-
header: req.headers[
|
|
4938
|
+
header: req.headers[LLAMAPAY_SIGNATURE_HEADER],
|
|
4109
4939
|
secret
|
|
4110
4940
|
});
|
|
4111
4941
|
if (!verification.valid) {
|
|
4112
|
-
secureLogger.warn("
|
|
4942
|
+
secureLogger.warn("LlamaPay webhook signature rejected", {
|
|
4113
4943
|
reason: verification.reason
|
|
4114
4944
|
});
|
|
4115
4945
|
return res.status(400).json(
|
|
4116
4946
|
apiError(
|
|
4117
4947
|
"authentication_error",
|
|
4118
4948
|
"INVALID_WEBHOOK_SIGNATURE",
|
|
4119
|
-
`
|
|
4949
|
+
`LlamaPay signature verification failed (${verification.reason}).`
|
|
4120
4950
|
)
|
|
4121
4951
|
);
|
|
4122
4952
|
}
|
|
4123
|
-
let
|
|
4953
|
+
let envelope;
|
|
4124
4954
|
try {
|
|
4125
|
-
|
|
4955
|
+
envelope = JSON.parse(raw.toString("utf8"));
|
|
4126
4956
|
} catch {
|
|
4127
4957
|
return res.status(400).json(
|
|
4128
4958
|
apiError(
|
|
@@ -4132,6 +4962,7 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4132
4962
|
)
|
|
4133
4963
|
);
|
|
4134
4964
|
}
|
|
4965
|
+
const event = envelope.event ?? envelope;
|
|
4135
4966
|
if (!asString(event?.id) || !asString(event?.type)) {
|
|
4136
4967
|
return res.status(400).json(
|
|
4137
4968
|
apiError(
|
|
@@ -4142,20 +4973,20 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4142
4973
|
);
|
|
4143
4974
|
}
|
|
4144
4975
|
try {
|
|
4145
|
-
const decision =
|
|
4976
|
+
const decision = decideLlamaPayAction(event);
|
|
4146
4977
|
if (decision.kind === "ignore") {
|
|
4147
|
-
|
|
4148
|
-
}
|
|
4149
|
-
if (decision.kind === "unresolved_user") {
|
|
4150
|
-
secureLogger.warn("Stripe webhook user unresolved \u2014 asking for retry", {
|
|
4978
|
+
secureLogger.warn("LlamaPay webhook ignored", {
|
|
4151
4979
|
eventType: event.type,
|
|
4152
4980
|
note: decision.note
|
|
4153
4981
|
});
|
|
4982
|
+
return res.json({ received: true, handled: false });
|
|
4983
|
+
}
|
|
4984
|
+
if (decision.kind === "unresolved_user") {
|
|
4154
4985
|
return res.status(500).json(
|
|
4155
4986
|
apiError(
|
|
4156
4987
|
"internal_error",
|
|
4157
4988
|
"WEBHOOK_USER_UNRESOLVED",
|
|
4158
|
-
"Could not resolve the Hive user for this event yet;
|
|
4989
|
+
"Could not resolve the Hive user for this event yet; LlamaPay should retry."
|
|
4159
4990
|
)
|
|
4160
4991
|
);
|
|
4161
4992
|
}
|
|
@@ -4167,14 +4998,14 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4167
4998
|
replayed: result.replayed
|
|
4168
4999
|
});
|
|
4169
5000
|
} catch (err) {
|
|
4170
|
-
secureLogger.error("
|
|
5001
|
+
secureLogger.error("LlamaPay webhook processing failed", err, {
|
|
4171
5002
|
eventType: event.type
|
|
4172
5003
|
});
|
|
4173
5004
|
return res.status(500).json(
|
|
4174
5005
|
apiError(
|
|
4175
5006
|
"internal_error",
|
|
4176
5007
|
"WEBHOOK_PROCESSING_FAILED",
|
|
4177
|
-
"Webhook processing failed;
|
|
5008
|
+
"Webhook processing failed; LlamaPay should retry. Processing is idempotent by event id."
|
|
4178
5009
|
)
|
|
4179
5010
|
);
|
|
4180
5011
|
}
|
|
@@ -4183,20 +5014,39 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4183
5014
|
|
|
4184
5015
|
// src/middleware/request-id.ts
|
|
4185
5016
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
5017
|
+
function isUnstampedApiError(body) {
|
|
5018
|
+
if (typeof body !== "object" || body === null) return false;
|
|
5019
|
+
const candidate = body;
|
|
5020
|
+
if (candidate.ok !== false) return false;
|
|
5021
|
+
if (typeof candidate.error !== "object" || candidate.error === null) {
|
|
5022
|
+
return false;
|
|
5023
|
+
}
|
|
5024
|
+
return !("request_id" in candidate.error);
|
|
5025
|
+
}
|
|
4186
5026
|
function requestIdMiddleware(req, res, next) {
|
|
4187
5027
|
const id = req.headers["x-request-id"] || `req_${randomUUID2().replace(/-/g, "")}`;
|
|
4188
5028
|
req.requestId = id;
|
|
4189
5029
|
res.setHeader("x-request-id", id);
|
|
5030
|
+
const json = res.json.bind(res);
|
|
5031
|
+
res.json = (body) => {
|
|
5032
|
+
if (isUnstampedApiError(body)) {
|
|
5033
|
+
body.error.request_id = id;
|
|
5034
|
+
}
|
|
5035
|
+
return json(body);
|
|
5036
|
+
};
|
|
4190
5037
|
next();
|
|
4191
5038
|
}
|
|
4192
5039
|
|
|
4193
5040
|
// src/middleware/rate-limit-headers.ts
|
|
4194
5041
|
function rateLimitInfoHeaders(opts) {
|
|
4195
5042
|
const windowSec = Math.round(opts.windowMs / 1e3);
|
|
4196
|
-
return (
|
|
5043
|
+
return (req, res, next) => {
|
|
4197
5044
|
if (!res.getHeader("RateLimit-Limit")) {
|
|
4198
|
-
|
|
4199
|
-
|
|
5045
|
+
const headers = req.headers ?? {};
|
|
5046
|
+
const hasCredential2 = typeof headers["x-api-key"] === "string" || typeof headers.authorization === "string" && headers.authorization.startsWith("Bearer ");
|
|
5047
|
+
const limit = hasCredential2 && opts.credentialedLimit !== void 0 ? opts.credentialedLimit : opts.limit;
|
|
5048
|
+
res.setHeader("RateLimit-Limit", String(limit));
|
|
5049
|
+
res.setHeader("RateLimit-Policy", `${limit};w=${windowSec}`);
|
|
4200
5050
|
}
|
|
4201
5051
|
next();
|
|
4202
5052
|
};
|
|
@@ -4600,12 +5450,13 @@ import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
|
|
|
4600
5450
|
|
|
4601
5451
|
// src/services/anon-quota.ts
|
|
4602
5452
|
import { createHash as createHash4 } from "crypto";
|
|
4603
|
-
import { z as
|
|
5453
|
+
import { z as z3 } from "zod";
|
|
4604
5454
|
var ANON_QUOTA_EXHAUSTED_MESSAGE = "Anonymous free quota reached (resets 00:00 UTC). For continued access set HIVE_API_KEY \u2014 create one free at https://hiveintelligence.xyz/dashboard/keys \u2014 or sign in via OAuth.";
|
|
5455
|
+
var ANON_GLOBAL_CAP_MESSAGE = "Hive's shared anonymous allowance for today is spent (resets 00:00 UTC). This is a service-wide cap, not your personal one, and a key is not rate-limited by it: set HIVE_API_KEY \u2014 create one free at https://hiveintelligence.xyz/dashboard/keys \u2014 or sign in via OAuth.";
|
|
4605
5456
|
var ANON_QUOTA_UNAVAILABLE_MESSAGE = "Anonymous access is temporarily unavailable because the quota backend is unreachable. Retry in a moment, or set HIVE_API_KEY \u2014 create one free at https://hiveintelligence.xyz/dashboard/keys \u2014 for keyed access now.";
|
|
4606
5457
|
var ANON_IP_HASH_DOMAIN = "hive-anon-lane-v1";
|
|
4607
|
-
var capSchema =
|
|
4608
|
-
var globalCapSchema =
|
|
5458
|
+
var capSchema = z3.coerce.number().int().min(1).max(1e4);
|
|
5459
|
+
var globalCapSchema = z3.coerce.number().int().min(1).max(1e6);
|
|
4609
5460
|
function isAnonLaneEnabled() {
|
|
4610
5461
|
return getEnvVar("ANON_LANE_ENABLED")?.toLowerCase() !== "false";
|
|
4611
5462
|
}
|
|
@@ -4620,6 +5471,27 @@ function getAnonGlobalDailyCap() {
|
|
|
4620
5471
|
function anonymousIpHash(ipKey) {
|
|
4621
5472
|
return createHash4("sha256").update(`${ANON_IP_HASH_DOMAIN}\0${ipKey}`).digest("hex");
|
|
4622
5473
|
}
|
|
5474
|
+
var GLOBAL_CAP_LOG_INTERVAL_MS = 6e4;
|
|
5475
|
+
var lastGlobalCapLoggedAt = 0;
|
|
5476
|
+
var globalCapRejections = 0;
|
|
5477
|
+
var globalCapRejectionsAtLastLog = 0;
|
|
5478
|
+
function noteGlobalCapTripped(limit, resetSeconds) {
|
|
5479
|
+
globalCapRejections += 1;
|
|
5480
|
+
const now = Date.now();
|
|
5481
|
+
if (now - lastGlobalCapLoggedAt < GLOBAL_CAP_LOG_INTERVAL_MS) return;
|
|
5482
|
+
lastGlobalCapLoggedAt = now;
|
|
5483
|
+
const rejected = globalCapRejections - globalCapRejectionsAtLastLog;
|
|
5484
|
+
globalCapRejectionsAtLastLog = globalCapRejections;
|
|
5485
|
+
secureLogger.error(
|
|
5486
|
+
"[anon-lane] GLOBAL daily breaker tripped \u2014 every anonymous caller is blocked until the UTC day rolls",
|
|
5487
|
+
void 0,
|
|
5488
|
+
{
|
|
5489
|
+
globalDailyCap: limit,
|
|
5490
|
+
resetSeconds,
|
|
5491
|
+
rejectionsSinceLastLog: rejected
|
|
5492
|
+
}
|
|
5493
|
+
);
|
|
5494
|
+
}
|
|
4623
5495
|
async function defaultConsume(args) {
|
|
4624
5496
|
const { data, error } = await getSupabaseAdmin().rpc(
|
|
4625
5497
|
"consume_anon_daily_quota",
|
|
@@ -4660,11 +5532,12 @@ async function consumeAnonMaterialQuota(ipHash, overrides = {}) {
|
|
|
4660
5532
|
resetSeconds: Math.max(1, row.reset_seconds ?? 1)
|
|
4661
5533
|
};
|
|
4662
5534
|
}
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
}
|
|
5535
|
+
const resetSeconds = Math.max(1, row.reset_seconds ?? 1);
|
|
5536
|
+
if (row.scope === "global") {
|
|
5537
|
+
noteGlobalCapTripped(getAnonGlobalDailyCap(), resetSeconds);
|
|
5538
|
+
return { allowed: false, reason: "global_cap", resetSeconds };
|
|
5539
|
+
}
|
|
5540
|
+
return { allowed: false, reason: "ip_cap", resetSeconds };
|
|
4668
5541
|
} catch (err) {
|
|
4669
5542
|
secureLogger.warn("Anonymous quota backend unavailable; failing closed", {
|
|
4670
5543
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -4693,7 +5566,7 @@ function buildServerCard() {
|
|
|
4693
5566
|
email: "support@hiveintelligence.xyz"
|
|
4694
5567
|
},
|
|
4695
5568
|
homepage: "https://www.hiveintelligence.xyz",
|
|
4696
|
-
documentation: "https://
|
|
5569
|
+
documentation: "https://docs.hiveintelligence.xyz/concepts/how-it-works",
|
|
4697
5570
|
repository: {
|
|
4698
5571
|
type: "git",
|
|
4699
5572
|
url: "https://github.com/hive-intel/hive-sdk"
|
|
@@ -4926,33 +5799,33 @@ function buildServerCard() {
|
|
|
4926
5799
|
}
|
|
4927
5800
|
|
|
4928
5801
|
// src/auth/oauth/router.ts
|
|
4929
|
-
import { timingSafeEqual as
|
|
5802
|
+
import { timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
4930
5803
|
import express from "express";
|
|
4931
|
-
import
|
|
4932
|
-
import
|
|
4933
|
-
import { z as
|
|
5804
|
+
import rateLimit2 from "express-rate-limit";
|
|
5805
|
+
import RedisStore2 from "rate-limit-redis";
|
|
5806
|
+
import { z as z4 } from "zod";
|
|
4934
5807
|
import {
|
|
4935
5808
|
createOAuthMetadata,
|
|
4936
5809
|
getOAuthProtectedResourceMetadataUrl,
|
|
4937
5810
|
mcpAuthRouter
|
|
4938
5811
|
} from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
4939
5812
|
import { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
4940
|
-
var consentDecisionSchema =
|
|
4941
|
-
approved:
|
|
4942
|
-
scopes:
|
|
5813
|
+
var consentDecisionSchema = z4.object({
|
|
5814
|
+
approved: z4.boolean(),
|
|
5815
|
+
scopes: z4.array(z4.enum(OAUTH_SCOPES)).max(OAUTH_SCOPES.length).optional()
|
|
4943
5816
|
}).strict();
|
|
4944
|
-
var consentRequestIdSchema =
|
|
4945
|
-
var connectedAppDisconnectSchema =
|
|
4946
|
-
client_id:
|
|
5817
|
+
var consentRequestIdSchema = z4.string().min(40).max(100).regex(/^hvoq_[A-Za-z0-9_-]+$/);
|
|
5818
|
+
var connectedAppDisconnectSchema = z4.object({
|
|
5819
|
+
client_id: z4.string().min(1).max(2048).refine((value) => value.trim() === value, {
|
|
4947
5820
|
message: "client_id must not contain leading or trailing whitespace."
|
|
4948
5821
|
})
|
|
4949
5822
|
}).strict();
|
|
4950
|
-
var deploymentCanaryClientIdSchema =
|
|
4951
|
-
var deploymentCanaryConsentSchema =
|
|
5823
|
+
var deploymentCanaryClientIdSchema = z4.string().regex(/^hive_oauth_client_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
5824
|
+
var deploymentCanaryConsentSchema = z4.object({
|
|
4952
5825
|
request_id: consentRequestIdSchema,
|
|
4953
5826
|
client_id: deploymentCanaryClientIdSchema
|
|
4954
5827
|
}).strict();
|
|
4955
|
-
var deploymentCanaryDisconnectSchema =
|
|
5828
|
+
var deploymentCanaryDisconnectSchema = z4.object({ client_id: deploymentCanaryClientIdSchema }).strict();
|
|
4956
5829
|
var OAUTH_RATE_LIMITS = {
|
|
4957
5830
|
authorize: { windowMs: 15 * 60 * 1e3, max: 300 },
|
|
4958
5831
|
token: { windowMs: 15 * 60 * 1e3, max: 3e3 },
|
|
@@ -4971,7 +5844,7 @@ function oauthRateLimitOptions(prefix) {
|
|
|
4971
5844
|
const redis = getRedisClient();
|
|
4972
5845
|
return {
|
|
4973
5846
|
...redis ? {
|
|
4974
|
-
store: new
|
|
5847
|
+
store: new RedisStore2({
|
|
4975
5848
|
sendCommand: ((...args) => redis.call(
|
|
4976
5849
|
args[0],
|
|
4977
5850
|
...args.slice(1)
|
|
@@ -4988,11 +5861,11 @@ function monitorSecretAuthorized(req) {
|
|
|
4988
5861
|
if (!expected || typeof supplied !== "string") return false;
|
|
4989
5862
|
const left = Buffer.from(supplied);
|
|
4990
5863
|
const right = Buffer.from(expected);
|
|
4991
|
-
return left.length === right.length &&
|
|
5864
|
+
return left.length === right.length && timingSafeEqual5(left, right);
|
|
4992
5865
|
}
|
|
4993
5866
|
function deploymentCanaryUserId() {
|
|
4994
5867
|
const userId = getEnvVar("HIVE_CANARY_USER_ID");
|
|
4995
|
-
return userId &&
|
|
5868
|
+
return userId && z4.string().uuid().safeParse(userId).success ? userId : void 0;
|
|
4996
5869
|
}
|
|
4997
5870
|
async function verifyDeploymentCanaryUser(userId) {
|
|
4998
5871
|
const { data, error } = await getSupabaseAdmin().auth.admin.getUserById(userId);
|
|
@@ -5010,7 +5883,7 @@ function createMcpOAuthRuntime(options = {}) {
|
|
|
5010
5883
|
{ fetcher: options.fetcher }
|
|
5011
5884
|
);
|
|
5012
5885
|
const serviceDocumentationUrl = new URL(
|
|
5013
|
-
"https://
|
|
5886
|
+
"https://docs.hiveintelligence.xyz/concepts/how-it-works"
|
|
5014
5887
|
);
|
|
5015
5888
|
const oauthMetadata = {
|
|
5016
5889
|
...createOAuthMetadata({
|
|
@@ -5081,7 +5954,7 @@ function createMcpOAuthRuntime(options = {}) {
|
|
|
5081
5954
|
res.json(resourceMetadata);
|
|
5082
5955
|
}
|
|
5083
5956
|
);
|
|
5084
|
-
const consentRateLimit =
|
|
5957
|
+
const consentRateLimit = rateLimit2({
|
|
5085
5958
|
...oauthRateLimitOptions("rl:oauth:consent:"),
|
|
5086
5959
|
...OAUTH_RATE_LIMITS.account,
|
|
5087
5960
|
standardHeaders: true,
|
|
@@ -5107,7 +5980,7 @@ function createMcpOAuthRuntime(options = {}) {
|
|
|
5107
5980
|
).href;
|
|
5108
5981
|
const deploymentCanaryJsonParser = express.json({ limit: "8kb" });
|
|
5109
5982
|
const deploymentCanaryUserVerifier = options.deploymentCanaryUserVerifier ?? verifyDeploymentCanaryUser;
|
|
5110
|
-
const deploymentCanaryRateLimit =
|
|
5983
|
+
const deploymentCanaryRateLimit = rateLimit2({
|
|
5111
5984
|
...oauthRateLimitOptions("rl:oauth:deployment-canary:"),
|
|
5112
5985
|
...OAUTH_RATE_LIMITS.deploymentCanary,
|
|
5113
5986
|
standardHeaders: true,
|
|
@@ -5739,10 +6612,12 @@ app.use((_req, res, next) => {
|
|
|
5739
6612
|
return next();
|
|
5740
6613
|
});
|
|
5741
6614
|
var RATE_LIMIT_MAX = getRateLimitMax();
|
|
6615
|
+
var CREDENTIALED_RATE_LIMIT_MAX = getCredentialedRateLimitMax();
|
|
5742
6616
|
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
5743
6617
|
app.use(
|
|
5744
6618
|
rateLimitInfoHeaders({
|
|
5745
6619
|
limit: RATE_LIMIT_MAX,
|
|
6620
|
+
credentialedLimit: CREDENTIALED_RATE_LIMIT_MAX,
|
|
5746
6621
|
windowMs: RATE_LIMIT_WINDOW_MS
|
|
5747
6622
|
})
|
|
5748
6623
|
);
|
|
@@ -5829,6 +6704,11 @@ app.post(
|
|
|
5829
6704
|
express2.raw({ type: "application/json", limit: "1mb" }),
|
|
5830
6705
|
stripeWebhookHandler
|
|
5831
6706
|
);
|
|
6707
|
+
app.post(
|
|
6708
|
+
"/api/v1/billing/webhook/llamapay",
|
|
6709
|
+
express2.raw({ type: "application/json", limit: "1mb" }),
|
|
6710
|
+
llamapayWebhookHandler
|
|
6711
|
+
);
|
|
5832
6712
|
app.use(
|
|
5833
6713
|
express2.json({
|
|
5834
6714
|
limit: "1mb",
|
|
@@ -5982,10 +6862,20 @@ function pingHandler(_req, res) {
|
|
|
5982
6862
|
}
|
|
5983
6863
|
app.get("/ping", pingHandler);
|
|
5984
6864
|
app.get("/health", pingHandler);
|
|
5985
|
-
var mcpIpRateLimiter =
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
6865
|
+
var mcpIpRateLimiter = [
|
|
6866
|
+
createIpRateLimiter({ lane: "anonymous" }),
|
|
6867
|
+
createIpRateLimiter({ lane: "credentialed" })
|
|
6868
|
+
];
|
|
6869
|
+
var restIpRateLimiter = [
|
|
6870
|
+
createIpRateLimiter({ lane: "anonymous" }),
|
|
6871
|
+
createIpRateLimiter({ lane: "credentialed" })
|
|
6872
|
+
];
|
|
6873
|
+
var authIpRateLimiter = [
|
|
6874
|
+
createIpRateLimiter({ lane: "anonymous" }),
|
|
6875
|
+
createIpRateLimiter({ lane: "credentialed" })
|
|
6876
|
+
];
|
|
6877
|
+
var healthRateLimiter = createLocalRateLimiter();
|
|
6878
|
+
app.use(["/status", "/api/health"], healthRateLimiter);
|
|
5989
6879
|
app.use(health_default);
|
|
5990
6880
|
app.use("/auth", authIpRateLimiter);
|
|
5991
6881
|
app.use("/api/v1", restIpRateLimiter);
|
|
@@ -5994,7 +6884,7 @@ app.use("/api/v1/keys", keys_default);
|
|
|
5994
6884
|
app.use("/api/v1/usage", usage_default);
|
|
5995
6885
|
app.use("/api/v1/b2b", b2b_default);
|
|
5996
6886
|
app.use("/api/v1/billing", billingRouter);
|
|
5997
|
-
var mcpMiddlewares = [mcpIpRateLimiter];
|
|
6887
|
+
var mcpMiddlewares = [...mcpIpRateLimiter];
|
|
5998
6888
|
function withTimeout(p, ms) {
|
|
5999
6889
|
return new Promise((resolve2, reject) => {
|
|
6000
6890
|
const timer = setTimeout(() => {
|
|
@@ -6012,6 +6902,16 @@ function withTimeout(p, ms) {
|
|
|
6012
6902
|
);
|
|
6013
6903
|
});
|
|
6014
6904
|
}
|
|
6905
|
+
var PRICING_URL = "https://www.hiveintelligence.xyz/pricing";
|
|
6906
|
+
function buildMcpQuotaExhaustedCopy(args) {
|
|
6907
|
+
const plan = args.planId ? `${args.planId} plan` : "current plan";
|
|
6908
|
+
const reset = args.cycleEnd ? `Credits refill at ${args.cycleEnd}.` : "Credits refill at the start of the next billing cycle.";
|
|
6909
|
+
return {
|
|
6910
|
+
message: `Monthly credit quota exhausted on the ${plan}. ${reset} Upgrade at ${PRICING_URL} to raise the limit now, or wait for the cycle to reset.`,
|
|
6911
|
+
cause: `The ${plan} has no monthly credits left, so this material call cannot be debited.`,
|
|
6912
|
+
next_action: `Upgrade at ${PRICING_URL} to raise the monthly allowance, or retry after the cycle resets${args.cycleEnd ? ` at ${args.cycleEnd}` : ""}. Discovery calls (tools/list, search_tools, schema lookup) cost no credits and still work.`
|
|
6913
|
+
};
|
|
6914
|
+
}
|
|
6015
6915
|
async function debitMcpCredits(args) {
|
|
6016
6916
|
try {
|
|
6017
6917
|
const debit = await consumeCredits({
|
|
@@ -6029,9 +6929,15 @@ async function debitMcpCredits(args) {
|
|
|
6029
6929
|
};
|
|
6030
6930
|
}
|
|
6031
6931
|
if (debit.code === "insufficient") {
|
|
6932
|
+
const copy = buildMcpQuotaExhaustedCopy({
|
|
6933
|
+
planId: args.planId,
|
|
6934
|
+
cycleEnd: debit.cycleEnd
|
|
6935
|
+
});
|
|
6032
6936
|
return {
|
|
6033
6937
|
ok: false,
|
|
6034
|
-
error:
|
|
6938
|
+
error: copy.message,
|
|
6939
|
+
cause: copy.cause,
|
|
6940
|
+
next_action: copy.next_action,
|
|
6035
6941
|
rateLimited: true
|
|
6036
6942
|
};
|
|
6037
6943
|
}
|
|
@@ -6250,6 +7156,16 @@ function getMcpHiveToolName(body) {
|
|
|
6250
7156
|
}
|
|
6251
7157
|
return toolName.startsWith("hive_") ? toolName : void 0;
|
|
6252
7158
|
}
|
|
7159
|
+
function resolveMcpClientName(req, body) {
|
|
7160
|
+
const fromBody = body?.params?.clientInfo?.name;
|
|
7161
|
+
if (typeof fromBody === "string" && fromBody.trim()) return fromBody.trim();
|
|
7162
|
+
const header = req.headers["x-hive-client"];
|
|
7163
|
+
if (typeof header === "string" && header.trim()) return header.trim();
|
|
7164
|
+
const userAgent = req.headers["user-agent"];
|
|
7165
|
+
if (typeof userAgent !== "string") return void 0;
|
|
7166
|
+
const product = userAgent.trim().split(/[\s\/]/, 1)[0];
|
|
7167
|
+
return product || void 0;
|
|
7168
|
+
}
|
|
6253
7169
|
function mcpCredentialPresented(req) {
|
|
6254
7170
|
return typeof req.headers["x-api-key"] === "string" || Boolean(req.headers.authorization?.startsWith("Bearer "));
|
|
6255
7171
|
}
|
|
@@ -6288,7 +7204,11 @@ async function authorizeAnonymousMcpRequest(req) {
|
|
|
6288
7204
|
};
|
|
6289
7205
|
}
|
|
6290
7206
|
if (quota.reason === "unavailable") return { kind: "quota_unavailable" };
|
|
6291
|
-
return {
|
|
7207
|
+
return {
|
|
7208
|
+
kind: "quota_exhausted",
|
|
7209
|
+
scope: quota.reason === "global_cap" ? "global" : "ip",
|
|
7210
|
+
resetSeconds: quota.resetSeconds
|
|
7211
|
+
};
|
|
6292
7212
|
}
|
|
6293
7213
|
function isMonitorSecretRequest(req) {
|
|
6294
7214
|
const secret = getEnvVar("MONITOR_SECRET");
|
|
@@ -6296,7 +7216,7 @@ function isMonitorSecretRequest(req) {
|
|
|
6296
7216
|
if (!secret || typeof provided !== "string") return false;
|
|
6297
7217
|
const a = Buffer.from(provided);
|
|
6298
7218
|
const b = Buffer.from(secret);
|
|
6299
|
-
return a.length === b.length &&
|
|
7219
|
+
return a.length === b.length && timingSafeEqual6(a, b);
|
|
6300
7220
|
}
|
|
6301
7221
|
function getValidatedMcpHiveToolName(scope, body) {
|
|
6302
7222
|
if (body?.method !== "tools/call") return void 0;
|
|
@@ -6482,6 +7402,7 @@ function createStatelessMcpHandler(scope) {
|
|
|
6482
7402
|
}
|
|
6483
7403
|
} else if (decision.kind === "quota_exhausted") {
|
|
6484
7404
|
res.setHeader("Retry-After", String(decision.resetSeconds));
|
|
7405
|
+
const exhaustedMessage = decision.scope === "global" ? ANON_GLOBAL_CAP_MESSAGE : ANON_QUOTA_EXHAUSTED_MESSAGE;
|
|
6485
7406
|
if (mcpOAuth.status === "ready") {
|
|
6486
7407
|
const requiredScopes = getRequiredMcpScopes(req.body);
|
|
6487
7408
|
res.setHeader(
|
|
@@ -6493,7 +7414,7 @@ function createStatelessMcpHandler(scope) {
|
|
|
6493
7414
|
);
|
|
6494
7415
|
res.status(401).json({
|
|
6495
7416
|
error: "authorization_required",
|
|
6496
|
-
error_description:
|
|
7417
|
+
error_description: exhaustedMessage
|
|
6497
7418
|
});
|
|
6498
7419
|
return;
|
|
6499
7420
|
}
|
|
@@ -6501,9 +7422,9 @@ function createStatelessMcpHandler(scope) {
|
|
|
6501
7422
|
jsonrpc: "2.0",
|
|
6502
7423
|
error: {
|
|
6503
7424
|
code: -32001,
|
|
6504
|
-
message:
|
|
7425
|
+
message: exhaustedMessage,
|
|
6505
7426
|
data: {
|
|
6506
|
-
code: "ANON_QUOTA_EXCEEDED",
|
|
7427
|
+
code: decision.scope === "global" ? "ANON_GLOBAL_CAP_EXCEEDED" : "ANON_QUOTA_EXCEEDED",
|
|
6507
7428
|
retryable: true,
|
|
6508
7429
|
retry_after: decision.resetSeconds
|
|
6509
7430
|
}
|
|
@@ -6601,6 +7522,30 @@ function createStatelessMcpHandler(scope) {
|
|
|
6601
7522
|
return;
|
|
6602
7523
|
}
|
|
6603
7524
|
}
|
|
7525
|
+
if (keyedAuth?.userId) {
|
|
7526
|
+
const decision = await enforceUserRateLimit(keyedAuth.userId);
|
|
7527
|
+
res.setHeader("RateLimit-Limit", String(decision.limit));
|
|
7528
|
+
res.setHeader("RateLimit-Remaining", String(decision.remaining));
|
|
7529
|
+
res.setHeader("RateLimit-Reset", String(decision.resetSeconds));
|
|
7530
|
+
res.setHeader("RateLimit-Policy", `${decision.limit};w=60`);
|
|
7531
|
+
if (!decision.allowed) {
|
|
7532
|
+
res.setHeader("Retry-After", String(decision.resetSeconds));
|
|
7533
|
+
res.status(200).json({
|
|
7534
|
+
jsonrpc: "2.0",
|
|
7535
|
+
error: {
|
|
7536
|
+
code: -32001,
|
|
7537
|
+
message: `Rate limit exceeded: ${decision.limit} requests per minute on your plan. This limit is shared across all of your API keys and connected clients.`,
|
|
7538
|
+
data: {
|
|
7539
|
+
code: "RATE_LIMITED",
|
|
7540
|
+
retryable: true,
|
|
7541
|
+
retry_after: decision.resetSeconds
|
|
7542
|
+
}
|
|
7543
|
+
},
|
|
7544
|
+
id: req.body?.id ?? null
|
|
7545
|
+
});
|
|
7546
|
+
return;
|
|
7547
|
+
}
|
|
7548
|
+
}
|
|
6604
7549
|
try {
|
|
6605
7550
|
const body = req.body;
|
|
6606
7551
|
const rawHiveToolName = getMcpHiveToolName(body);
|
|
@@ -6651,11 +7596,10 @@ function createStatelessMcpHandler(scope) {
|
|
|
6651
7596
|
subjectContext = subject.data;
|
|
6652
7597
|
}
|
|
6653
7598
|
sanitizeMcpAuthContext(body);
|
|
6654
|
-
const clientInfoName = body?.params?.clientInfo?.name;
|
|
6655
7599
|
const analytics = {
|
|
6656
7600
|
principalRaw: keyedAuth?.userId ?? anonymousLane?.principalRaw,
|
|
6657
7601
|
authLane: keyedAuth ? keyedAuth.authMethod : "anonymous",
|
|
6658
|
-
clientName:
|
|
7602
|
+
clientName: resolveMcpClientName(req, body),
|
|
6659
7603
|
isInternal: isMonitorSecretRequest(req)
|
|
6660
7604
|
};
|
|
6661
7605
|
const server = createHiveMcpServer(scope, {
|
|
@@ -6719,7 +7663,8 @@ function createStatelessMcpHandler(scope) {
|
|
|
6719
7663
|
const debit = await debitMcpCredits({
|
|
6720
7664
|
userId: keyedAuth.userId,
|
|
6721
7665
|
keyId: keyedAuth.keyId,
|
|
6722
|
-
requestId: req.requestId
|
|
7666
|
+
requestId: req.requestId,
|
|
7667
|
+
planId: typeof keyedAuth.keyMeta?.plan === "string" ? keyedAuth.keyMeta.plan : void 0
|
|
6723
7668
|
});
|
|
6724
7669
|
if (!debit.ok) {
|
|
6725
7670
|
if (debit.noWallet) {
|
|
@@ -6737,7 +7682,9 @@ function createStatelessMcpHandler(scope) {
|
|
|
6737
7682
|
type: debit.rateLimited ? "rate_limit_error" : "internal_error",
|
|
6738
7683
|
code: debit.rateLimited ? "QUOTA_EXCEEDED" : "QUOTA_SERVICE_UNAVAILABLE",
|
|
6739
7684
|
message: debit.error,
|
|
6740
|
-
runtimeStatus: debit.rateLimited ? "rate_limited" : "failing"
|
|
7685
|
+
runtimeStatus: debit.rateLimited ? "rate_limited" : "failing",
|
|
7686
|
+
...debit.cause ? { cause: debit.cause } : {},
|
|
7687
|
+
...debit.next_action ? { next_action: debit.next_action } : {}
|
|
6741
7688
|
};
|
|
6742
7689
|
}
|
|
6743
7690
|
res.setHeader(
|
|
@@ -6756,16 +7703,18 @@ function createStatelessMcpHandler(scope) {
|
|
|
6756
7703
|
};
|
|
6757
7704
|
}
|
|
6758
7705
|
}
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
7706
|
+
const auditEvent = buildStatefulWriteAuditEvent({
|
|
7707
|
+
isStatefulWrite: isHiveStatefulWriteTool(executedToolName),
|
|
7708
|
+
transport: "mcp",
|
|
7709
|
+
toolName: executedToolName,
|
|
7710
|
+
userId: keyedAuth.userId,
|
|
7711
|
+
subjectContext,
|
|
7712
|
+
keyId: keyedAuth.keyId,
|
|
7713
|
+
authMethod: keyedAuth.authMethod,
|
|
7714
|
+
oauthClientId: keyedAuth.oauthClientId,
|
|
7715
|
+
requestId: req.requestId
|
|
7716
|
+
});
|
|
7717
|
+
if (auditEvent) recordSubjectAuditEvent(auditEvent);
|
|
6769
7718
|
return void 0;
|
|
6770
7719
|
} : anonymousLane ? async (executedToolName) => {
|
|
6771
7720
|
const creditCost = getMcpCreditCost(executedToolName);
|
|
@@ -6868,7 +7817,7 @@ var requireMcpTransportCredential = async (req, res, next) => {
|
|
|
6868
7817
|
};
|
|
6869
7818
|
app.get(
|
|
6870
7819
|
mcpPaths,
|
|
6871
|
-
mcpIpRateLimiter,
|
|
7820
|
+
...mcpIpRateLimiter,
|
|
6872
7821
|
requireMcpTransportCredential,
|
|
6873
7822
|
(_req, res) => {
|
|
6874
7823
|
res.writeHead(405, { Allow: "POST" }).end();
|
|
@@ -6876,7 +7825,7 @@ app.get(
|
|
|
6876
7825
|
);
|
|
6877
7826
|
app.delete(
|
|
6878
7827
|
mcpPaths,
|
|
6879
|
-
mcpIpRateLimiter,
|
|
7828
|
+
...mcpIpRateLimiter,
|
|
6880
7829
|
requireMcpTransportCredential,
|
|
6881
7830
|
(_req, res) => {
|
|
6882
7831
|
res.writeHead(405, { Allow: "POST" }).end();
|
|
@@ -6884,7 +7833,7 @@ app.delete(
|
|
|
6884
7833
|
);
|
|
6885
7834
|
app.post(
|
|
6886
7835
|
"/api/run-tests",
|
|
6887
|
-
restIpRateLimiter,
|
|
7836
|
+
...restIpRateLimiter,
|
|
6888
7837
|
asyncHandler(async (req, res) => {
|
|
6889
7838
|
const authToken = req.headers["x-monitor-secret"];
|
|
6890
7839
|
const monitorSecret = getEnvVar("MONITOR_SECRET");
|
|
@@ -6892,7 +7841,7 @@ app.post(
|
|
|
6892
7841
|
if (monitorSecret && typeof authToken === "string") {
|
|
6893
7842
|
const a = Buffer.from(authToken);
|
|
6894
7843
|
const b = Buffer.from(monitorSecret);
|
|
6895
|
-
isAuthorized = a.length === b.length &&
|
|
7844
|
+
isAuthorized = a.length === b.length && timingSafeEqual6(a, b);
|
|
6896
7845
|
}
|
|
6897
7846
|
secureLogger.authEvent("monitor-tests-access", isAuthorized, {
|
|
6898
7847
|
hasToken: !!authToken,
|
|
@@ -7041,15 +7990,50 @@ function getRestHeroEndpoint(toolName) {
|
|
|
7041
7990
|
}
|
|
7042
7991
|
return restHeroEndpointsByName.get(toolName);
|
|
7043
7992
|
}
|
|
7993
|
+
var knownRestToolNames = null;
|
|
7994
|
+
function isKnownRestToolName(toolName) {
|
|
7995
|
+
if (!knownRestToolNames) {
|
|
7996
|
+
knownRestToolNames = new Set(
|
|
7997
|
+
getAllWrappedEndpoints().map((endpoint) => endpoint.tool.name)
|
|
7998
|
+
);
|
|
7999
|
+
}
|
|
8000
|
+
return knownRestToolNames.has(toolName);
|
|
8001
|
+
}
|
|
7044
8002
|
var anonymousHeroExecuteMiddleware = asyncHandler(
|
|
7045
8003
|
async (req, res, next) => {
|
|
8004
|
+
const rawBody = req.body;
|
|
8005
|
+
if (rawBody && typeof rawBody === "object" && rawBody.args === void 0 && rawBody.arguments !== void 0) {
|
|
8006
|
+
rawBody.args = rawBody.arguments;
|
|
8007
|
+
}
|
|
7046
8008
|
if (mcpCredentialPresented(req)) {
|
|
7047
8009
|
next();
|
|
7048
8010
|
return;
|
|
7049
8011
|
}
|
|
7050
|
-
const body =
|
|
8012
|
+
const body = rawBody;
|
|
7051
8013
|
const toolName = typeof body?.tool === "string" ? body.tool : void 0;
|
|
7052
|
-
if (!toolName
|
|
8014
|
+
if (!toolName) {
|
|
8015
|
+
res.status(400).json({
|
|
8016
|
+
ok: false,
|
|
8017
|
+
error: {
|
|
8018
|
+
type: "invalid_request",
|
|
8019
|
+
code: "TOOL_REQUIRED",
|
|
8020
|
+
message: `Missing required field "tool". Send {"tool":"<name>","args":{...}}. The keyless lane serves ${[...HERO_TOOL_NAMES].join(", ")}; every other tool needs an API key.`
|
|
8021
|
+
}
|
|
8022
|
+
});
|
|
8023
|
+
return;
|
|
8024
|
+
}
|
|
8025
|
+
if (!HERO_TOOL_NAMES.has(toolName) && !isKnownRestToolName(toolName)) {
|
|
8026
|
+
res.status(404).json({
|
|
8027
|
+
ok: false,
|
|
8028
|
+
error: {
|
|
8029
|
+
type: "invalid_request",
|
|
8030
|
+
code: "TOOL_NOT_FOUND",
|
|
8031
|
+
message: `Unknown tool "${toolName}". Browse the catalog at /api/v1/tools, or call search_tools over MCP.`
|
|
8032
|
+
}
|
|
8033
|
+
});
|
|
8034
|
+
return;
|
|
8035
|
+
}
|
|
8036
|
+
if (!HERO_TOOL_NAMES.has(toolName)) {
|
|
7053
8037
|
next();
|
|
7054
8038
|
return;
|
|
7055
8039
|
}
|
|
@@ -7077,12 +8061,13 @@ var anonymousHeroExecuteMiddleware = asyncHandler(
|
|
|
7077
8061
|
);
|
|
7078
8062
|
return;
|
|
7079
8063
|
}
|
|
8064
|
+
const globalCap = quota.reason === "global_cap";
|
|
7080
8065
|
res.setHeader("Retry-After", String(quota.resetSeconds));
|
|
7081
8066
|
res.status(429).json(
|
|
7082
8067
|
apiError(
|
|
7083
8068
|
"rate_limit_error",
|
|
7084
|
-
"ANON_QUOTA_EXCEEDED",
|
|
7085
|
-
ANON_QUOTA_EXHAUSTED_MESSAGE,
|
|
8069
|
+
globalCap ? "ANON_GLOBAL_CAP_EXCEEDED" : "ANON_QUOTA_EXCEEDED",
|
|
8070
|
+
globalCap ? ANON_GLOBAL_CAP_MESSAGE : ANON_QUOTA_EXHAUSTED_MESSAGE,
|
|
7086
8071
|
{ requestId: req.requestId }
|
|
7087
8072
|
)
|
|
7088
8073
|
);
|
|
@@ -7096,6 +8081,7 @@ var anonymousHeroExecuteMiddleware = asyncHandler(
|
|
|
7096
8081
|
analytics: {
|
|
7097
8082
|
principalRaw: `anon:${ipHash}`,
|
|
7098
8083
|
authLane: "anonymous",
|
|
8084
|
+
clientName: resolveMcpClientName(req, body),
|
|
7099
8085
|
isInternal: isMonitorSecretRequest(req)
|
|
7100
8086
|
}
|
|
7101
8087
|
});
|
|
@@ -7168,16 +8154,18 @@ app.post(
|
|
|
7168
8154
|
};
|
|
7169
8155
|
}
|
|
7170
8156
|
}
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
8157
|
+
const restAuditEvent = buildStatefulWriteAuditEvent({
|
|
8158
|
+
isStatefulWrite: isHiveStatefulWriteTool(endpoint.tool.name),
|
|
8159
|
+
transport: "rest",
|
|
8160
|
+
toolName: endpoint.tool.name,
|
|
8161
|
+
userId: authReq.userId,
|
|
8162
|
+
subjectContext: authReq.hiveSubjectContext,
|
|
8163
|
+
keyId: authReq.unkeyKeyId,
|
|
8164
|
+
// REST is the API-key transport; OAuth tokens are MCP-only.
|
|
8165
|
+
authMethod: "api_key",
|
|
8166
|
+
requestId: req.requestId
|
|
8167
|
+
});
|
|
8168
|
+
if (restAuditEvent) recordSubjectAuditEvent(restAuditEvent);
|
|
7181
8169
|
return void 0;
|
|
7182
8170
|
};
|
|
7183
8171
|
const heroEndpoint = HERO_TOOL_NAMES.has(executionToolName) ? getRestHeroEndpoint(executionToolName) : void 0;
|
|
@@ -7229,13 +8217,13 @@ app.post(
|
|
|
7229
8217
|
});
|
|
7230
8218
|
})
|
|
7231
8219
|
);
|
|
7232
|
-
var cliTelemetrySchema =
|
|
7233
|
-
command:
|
|
7234
|
-
durationMs:
|
|
7235
|
-
errorCode:
|
|
7236
|
-
cliVersion:
|
|
7237
|
-
os:
|
|
7238
|
-
nodeVersion:
|
|
8220
|
+
var cliTelemetrySchema = z5.object({
|
|
8221
|
+
command: z5.string().trim().min(1).max(80).regex(/^[a-zA-Z0-9:_-]+$/),
|
|
8222
|
+
durationMs: z5.number().int().min(0).max(6e5),
|
|
8223
|
+
errorCode: z5.string().trim().min(1).max(80).regex(/^[a-zA-Z0-9:_-]+$/).optional(),
|
|
8224
|
+
cliVersion: z5.string().trim().min(1).max(40),
|
|
8225
|
+
os: z5.string().trim().min(1).max(120),
|
|
8226
|
+
nodeVersion: z5.string().trim().min(1).max(40)
|
|
7239
8227
|
}).strict();
|
|
7240
8228
|
app.post("/api/v1/telemetry", (req, res) => {
|
|
7241
8229
|
const parsed = cliTelemetrySchema.safeParse(req.body);
|
|
@@ -7324,6 +8312,10 @@ async function setupServer() {
|
|
|
7324
8312
|
`OAuth configuration is incomplete: ${mcpOAuth.config.issues.join(" ")}`
|
|
7325
8313
|
);
|
|
7326
8314
|
}
|
|
8315
|
+
const redisReady = await waitForRedisAvailability(3e3);
|
|
8316
|
+
secureLogger.info("[startup] Redis readiness settled", {
|
|
8317
|
+
ready: redisReady
|
|
8318
|
+
});
|
|
7327
8319
|
secureLogger.info("Setting up Hive MCP server...", {
|
|
7328
8320
|
server: SERVER_NAME,
|
|
7329
8321
|
version: SERVER_VERSION,
|
|
@@ -7420,21 +8412,41 @@ async function startHttpServer() {
|
|
|
7420
8412
|
});
|
|
7421
8413
|
httpServer.keepAliveTimeout = 65e3;
|
|
7422
8414
|
httpServer.headersTimeout = 7e4;
|
|
8415
|
+
const ANALYTICS_FLUSH_TIMEOUT_MS = 2e3;
|
|
8416
|
+
const flushAnalytics = async () => {
|
|
8417
|
+
const writer = getAnalyticsWriter();
|
|
8418
|
+
await Promise.race([
|
|
8419
|
+
writer.flush(),
|
|
8420
|
+
new Promise((resolve2) => {
|
|
8421
|
+
setTimeout(resolve2, ANALYTICS_FLUSH_TIMEOUT_MS).unref();
|
|
8422
|
+
})
|
|
8423
|
+
]);
|
|
8424
|
+
const stats = writer.getStats();
|
|
8425
|
+
secureLogger.info("Analytics queue flushed on shutdown", {
|
|
8426
|
+
stillQueued: stats.queued,
|
|
8427
|
+
droppedTotal: stats.dropped
|
|
8428
|
+
});
|
|
8429
|
+
};
|
|
8430
|
+
const exitAfterAnalyticsFlush = (code) => {
|
|
8431
|
+
getAnalyticsWriter().stop();
|
|
8432
|
+
void flushAnalytics().finally(() => process.exit(code));
|
|
8433
|
+
};
|
|
7423
8434
|
const shutdown = (signal) => {
|
|
7424
8435
|
if (isShuttingDown) return;
|
|
7425
8436
|
isShuttingDown = true;
|
|
7426
8437
|
secureLogger.info(
|
|
7427
8438
|
`${signal} received \u2014 draining ${activeRequests} active requests`
|
|
7428
8439
|
);
|
|
8440
|
+
void flushAnalytics();
|
|
7429
8441
|
httpServer.close(() => {
|
|
7430
8442
|
secureLogger.info("HTTP server closed", { activeRequests });
|
|
7431
|
-
|
|
8443
|
+
exitAfterAnalyticsFlush(0);
|
|
7432
8444
|
});
|
|
7433
8445
|
const drainInterval = setInterval(() => {
|
|
7434
8446
|
if (activeRequests <= 0) {
|
|
7435
8447
|
clearInterval(drainInterval);
|
|
7436
8448
|
secureLogger.info("All requests drained, closing");
|
|
7437
|
-
httpServer.close(() =>
|
|
8449
|
+
httpServer.close(() => exitAfterAnalyticsFlush(0));
|
|
7438
8450
|
}
|
|
7439
8451
|
}, 500);
|
|
7440
8452
|
drainInterval.unref();
|
|
@@ -7443,7 +8455,7 @@ async function startHttpServer() {
|
|
|
7443
8455
|
secureLogger.warn("Force shutdown after 10s timeout", {
|
|
7444
8456
|
activeRequests
|
|
7445
8457
|
});
|
|
7446
|
-
|
|
8458
|
+
exitAfterAnalyticsFlush(1);
|
|
7447
8459
|
}, 1e4);
|
|
7448
8460
|
forceTimer.unref();
|
|
7449
8461
|
};
|
|
@@ -7466,9 +8478,11 @@ if (isMainModule()) {
|
|
|
7466
8478
|
}
|
|
7467
8479
|
var server_default = app;
|
|
7468
8480
|
export {
|
|
8481
|
+
buildMcpQuotaExhaustedCopy,
|
|
7469
8482
|
classifyAnonymousMcpRequest,
|
|
7470
8483
|
createHttpApp,
|
|
7471
8484
|
server_default as default,
|
|
7472
8485
|
getRequiredMcpScopes,
|
|
8486
|
+
resolveMcpClientName,
|
|
7473
8487
|
startHttpServer
|
|
7474
8488
|
};
|