hive-intelligence 1.3.0 → 1.5.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 +2 -2
- 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-Y4GXW376.js} +1 -9
- package/build/browser-auth-F62BMD7W.js +20 -0
- package/build/{chunk-W2QL7LYI.js → chunk-AGM2CIRL.js} +971 -117
- package/build/{chunk-7GD2MFYI.js → chunk-BDO27NIP.js} +37 -17
- package/build/{upgrade-J4HIGCKE.js → chunk-GB4XMTDE.js} +3 -4
- package/build/{chunk-TNQ7GJ6Q.js → chunk-GWZIAAQ2.js} +58 -40
- package/build/{chunk-Q7VBYLX7.js → chunk-P7NLFSZQ.js} +0 -58
- package/build/{chunk-N32UIHBU.js → chunk-SFN772RL.js} +5 -5
- package/build/{chunk-AEU43ACS.js → chunk-TVBPCELA.js} +2 -2
- package/build/{chunk-RUHO24SX.js → chunk-WF34ZD5I.js} +7 -162
- package/build/{chunk-I5Z6FZRL.js → chunk-XQPEFCSY.js} +14 -12
- package/build/cli.js +36 -47
- package/build/{completion-7KBW243H.js → completion-C57PDJMY.js} +1 -1
- package/build/{doctor-ORDOCGLM.js → doctor-XG5IQCXK.js} +5 -5
- package/build/{init-UCTCUYFA.js → init-COHA3VZI.js} +3 -3
- package/build/{init-all-BVWW3JVK.js → init-all-YTQJLPLR.js} +43 -24
- package/build/{mcpServer-RUEOTTF7.js → mcpServer-XHIN5JER.js} +1 -1
- package/build/monitor-worker.js +2 -2
- package/build/{namespace-L5DGCY7K.js → namespace-YLCGHVRT.js} +1 -1
- package/build/release.json +4 -4
- package/build/{serve-DKLR2WQH.js → serve-PWMT5IRU.js} +1 -1
- package/build/server.js +1475 -452
- package/build/stdio.js +1 -1
- package/build/{tools-I7GLWTV6.js → tools-XNFJBN7Q.js} +2 -2
- package/build/{uninstall-SAOIPL6K.js → uninstall-TKDP6C4F.js} +7 -7
- package/build/upgrade-ODPKMIDC.js +14 -0
- package/build/{watch-WVR4CGCD.js → watch-MXJSNGL2.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-XQPEFCSY.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-AGM2CIRL.js";
|
|
97
99
|
import {
|
|
98
100
|
HIVE_AGENT_SKILLS
|
|
99
101
|
} from "./chunk-L326MQZP.js";
|
|
@@ -733,7 +735,16 @@ var require_ipaddr = __commonJS({
|
|
|
733
735
|
|
|
734
736
|
// src/server.ts
|
|
735
737
|
import dotenv from "dotenv";
|
|
736
|
-
|
|
738
|
+
|
|
739
|
+
// src/config/trustProxy.ts
|
|
740
|
+
var DEFAULT_TRUSTED_PROXY_ADDRESSES = "34.102.187.55";
|
|
741
|
+
function resolveTrustedProxies(raw = getEnvVar("TRUSTED_PROXY_ADDRESSES")) {
|
|
742
|
+
const addresses = (raw ?? DEFAULT_TRUSTED_PROXY_ADDRESSES).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
743
|
+
return [...addresses, "loopback", "uniquelocal", "169.254.0.0/16"];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// src/server.ts
|
|
747
|
+
import { randomUUID as randomUUID4, timingSafeEqual as timingSafeEqual6 } from "crypto";
|
|
737
748
|
import { resolve } from "path";
|
|
738
749
|
import { fileURLToPath } from "url";
|
|
739
750
|
import { createRequire as createNodeRequire } from "module";
|
|
@@ -741,7 +752,7 @@ import express2 from "express";
|
|
|
741
752
|
import compression from "compression";
|
|
742
753
|
import cors from "cors";
|
|
743
754
|
import helmet from "helmet";
|
|
744
|
-
import { z as
|
|
755
|
+
import { z as z5 } from "zod";
|
|
745
756
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
746
757
|
|
|
747
758
|
// src/middleware/rate-limit.ts
|
|
@@ -805,8 +816,43 @@ function ipRateLimitKeyFromRequest(req) {
|
|
|
805
816
|
}
|
|
806
817
|
|
|
807
818
|
// src/middleware/rate-limit.ts
|
|
808
|
-
var
|
|
809
|
-
|
|
819
|
+
var STORE_ERROR_LOG_INTERVAL_MS = 6e4;
|
|
820
|
+
var lastStoreErrorLoggedAt = 0;
|
|
821
|
+
var suppressedStoreErrors = 0;
|
|
822
|
+
function noteRateLimitStoreError(error) {
|
|
823
|
+
suppressedStoreErrors += 1;
|
|
824
|
+
const now = Date.now();
|
|
825
|
+
if (now - lastStoreErrorLoggedAt < STORE_ERROR_LOG_INTERVAL_MS) return;
|
|
826
|
+
lastStoreErrorLoggedAt = now;
|
|
827
|
+
const suppressed = suppressedStoreErrors;
|
|
828
|
+
suppressedStoreErrors = 0;
|
|
829
|
+
secureLogger.error(
|
|
830
|
+
"[rate-limit] Redis store error \u2014 requests are passing UNLIMITED until it recovers",
|
|
831
|
+
{
|
|
832
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
833
|
+
occurrencesSinceLastLog: suppressed
|
|
834
|
+
}
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
function isRateLimiterDegraded() {
|
|
838
|
+
return lastStoreErrorLoggedAt > 0 && Date.now() - lastStoreErrorLoggedAt < STORE_ERROR_LOG_INTERVAL_MS * 2;
|
|
839
|
+
}
|
|
840
|
+
function hasValidMonitorSecret(req) {
|
|
841
|
+
const secret = getEnvVar("MONITOR_SECRET");
|
|
842
|
+
const provided = req.headers["x-monitor-secret"];
|
|
843
|
+
if (!secret || typeof provided !== "string") return false;
|
|
844
|
+
const a = Buffer.from(provided);
|
|
845
|
+
const b = Buffer.from(secret);
|
|
846
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
847
|
+
}
|
|
848
|
+
function hasCredential(req) {
|
|
849
|
+
if (typeof req.headers["x-api-key"] === "string") return true;
|
|
850
|
+
const auth = req.headers.authorization;
|
|
851
|
+
return typeof auth === "string" && auth.startsWith("Bearer ");
|
|
852
|
+
}
|
|
853
|
+
var createIpRateLimiter = (opts = {}) => {
|
|
854
|
+
const lane = opts.lane ?? "anonymous";
|
|
855
|
+
const maxRequests = lane === "credentialed" ? getCredentialedRateLimitMax() : getRateLimitMax();
|
|
810
856
|
const redisClient = getRedisClient();
|
|
811
857
|
const isProduction2 = getNodeEnv() === "production";
|
|
812
858
|
if (isProduction2 && !redisClient) {
|
|
@@ -824,11 +870,11 @@ var createIpRateLimiter = () => {
|
|
|
824
870
|
}
|
|
825
871
|
const storeConfig = redisClient ? {
|
|
826
872
|
store: new RedisStore({
|
|
827
|
-
sendCommand: ((...args) => redisClient.call(
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
)),
|
|
831
|
-
prefix: "rl:ip:"
|
|
873
|
+
sendCommand: ((...args) => redisClient.call(args[0], ...args.slice(1)).catch((error) => {
|
|
874
|
+
noteRateLimitStoreError(error);
|
|
875
|
+
throw error;
|
|
876
|
+
})),
|
|
877
|
+
prefix: lane === "credentialed" ? "rl:ip:cred:" : "rl:ip:"
|
|
832
878
|
})
|
|
833
879
|
} : {};
|
|
834
880
|
return rateLimit({
|
|
@@ -837,14 +883,15 @@ var createIpRateLimiter = () => {
|
|
|
837
883
|
max: maxRequests,
|
|
838
884
|
standardHeaders: true,
|
|
839
885
|
legacyHeaders: false,
|
|
886
|
+
// Availability over enforcement. A Redis outage or an exhausted provider
|
|
887
|
+
// quota must degrade to unlimited traffic, never to a 500 on every route.
|
|
888
|
+
// On 2026-08-19 an Upstash quota exhaustion took the whole service down
|
|
889
|
+
// for ~16h because the store error propagated to Express.
|
|
890
|
+
passOnStoreError: true,
|
|
840
891
|
keyGenerator: (req) => ipRateLimitKeyFromRequest(req),
|
|
841
892
|
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);
|
|
893
|
+
if (hasValidMonitorSecret(req)) return true;
|
|
894
|
+
return lane === "credentialed" ? !hasCredential(req) : hasCredential(req);
|
|
848
895
|
},
|
|
849
896
|
handler: (_req, res) => {
|
|
850
897
|
res.setHeader("Retry-After", "60");
|
|
@@ -858,6 +905,24 @@ var createIpRateLimiter = () => {
|
|
|
858
905
|
}
|
|
859
906
|
});
|
|
860
907
|
};
|
|
908
|
+
var createLocalRateLimiter = (maxPerMinute = 120) => rateLimit({
|
|
909
|
+
windowMs: 60 * 1e3,
|
|
910
|
+
max: maxPerMinute,
|
|
911
|
+
standardHeaders: true,
|
|
912
|
+
legacyHeaders: false,
|
|
913
|
+
keyGenerator: (req) => ipRateLimitKeyFromRequest(req),
|
|
914
|
+
skip: hasValidMonitorSecret,
|
|
915
|
+
handler: (_req, res) => {
|
|
916
|
+
res.setHeader("Retry-After", "60");
|
|
917
|
+
res.status(429).json(
|
|
918
|
+
apiError(
|
|
919
|
+
"rate_limit_error",
|
|
920
|
+
"RATE_LIMITED",
|
|
921
|
+
`Rate limit exceeded. Maximum ${maxPerMinute} requests per minute.`
|
|
922
|
+
)
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
861
926
|
|
|
862
927
|
// src/health/index.ts
|
|
863
928
|
import { timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
@@ -1521,8 +1586,16 @@ var PUBLIC_STATUS_DIMENSIONS = [
|
|
|
1521
1586
|
"taskCanaries",
|
|
1522
1587
|
"workflowRouting",
|
|
1523
1588
|
"mcpProtocol",
|
|
1524
|
-
"catalogIntegrity"
|
|
1589
|
+
"catalogIntegrity",
|
|
1590
|
+
"rateLimiter"
|
|
1525
1591
|
];
|
|
1592
|
+
function readRateLimiterSignal() {
|
|
1593
|
+
return {
|
|
1594
|
+
configured: isRedisConfigured(),
|
|
1595
|
+
available: isRedisAvailable(),
|
|
1596
|
+
degraded: isRateLimiterDegraded()
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1526
1599
|
var CATALOG_CHECK_NAMES = [
|
|
1527
1600
|
"mcp_initialize",
|
|
1528
1601
|
"tools_list_root_surface",
|
|
@@ -1646,6 +1719,56 @@ function buildCatalogIntegrityDimension(history) {
|
|
|
1646
1719
|
}
|
|
1647
1720
|
};
|
|
1648
1721
|
}
|
|
1722
|
+
var RATE_LIMITER_SCOPE = "This instance's shared rate-limit store, upstream response cache, and idempotency keys.";
|
|
1723
|
+
function buildRateLimiterDimension(signal) {
|
|
1724
|
+
const observedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1725
|
+
if (!signal.configured) {
|
|
1726
|
+
return {
|
|
1727
|
+
status: "healthy",
|
|
1728
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1729
|
+
evidence: {
|
|
1730
|
+
state: "absent",
|
|
1731
|
+
source: "serving_instance_redis",
|
|
1732
|
+
observedAt,
|
|
1733
|
+
message: "No REDIS_URL on this instance, so limits, cache and idempotency are per-instance and in memory by design."
|
|
1734
|
+
}
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
if (!signal.available) {
|
|
1738
|
+
return {
|
|
1739
|
+
status: "unhealthy",
|
|
1740
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1741
|
+
evidence: {
|
|
1742
|
+
state: "storage_unavailable",
|
|
1743
|
+
source: "serving_instance_redis",
|
|
1744
|
+
observedAt,
|
|
1745
|
+
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."
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
if (signal.degraded) {
|
|
1750
|
+
return {
|
|
1751
|
+
status: "degraded",
|
|
1752
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1753
|
+
evidence: {
|
|
1754
|
+
state: "observed",
|
|
1755
|
+
source: "serving_instance_redis",
|
|
1756
|
+
observedAt,
|
|
1757
|
+
message: "The rate-limit store returned errors recently; those requests failed open and were not counted."
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
return {
|
|
1762
|
+
status: "healthy",
|
|
1763
|
+
scope: RATE_LIMITER_SCOPE,
|
|
1764
|
+
evidence: {
|
|
1765
|
+
state: "observed",
|
|
1766
|
+
source: "serving_instance_redis",
|
|
1767
|
+
observedAt,
|
|
1768
|
+
message: "This instance holds a live connection to the rate-limit store."
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1649
1772
|
function overallStatus(dimensions) {
|
|
1650
1773
|
const required = [
|
|
1651
1774
|
dimensions.providers,
|
|
@@ -1653,7 +1776,8 @@ function overallStatus(dimensions) {
|
|
|
1653
1776
|
dimensions.workflowRouting,
|
|
1654
1777
|
dimensions.mcpProtocol,
|
|
1655
1778
|
dimensions.oauthLifecycle,
|
|
1656
|
-
dimensions.catalogIntegrity
|
|
1779
|
+
dimensions.catalogIntegrity,
|
|
1780
|
+
dimensions.rateLimiter
|
|
1657
1781
|
];
|
|
1658
1782
|
if (required.some((dimension) => dimension.status === "unhealthy")) {
|
|
1659
1783
|
return "unhealthy";
|
|
@@ -1666,7 +1790,7 @@ function overallStatus(dimensions) {
|
|
|
1666
1790
|
}
|
|
1667
1791
|
return "healthy";
|
|
1668
1792
|
}
|
|
1669
|
-
function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, protocol, oauthLifecycle) {
|
|
1793
|
+
function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, protocol, oauthLifecycle, rateLimiter = readRateLimiterSignal()) {
|
|
1670
1794
|
const results = providers.latest ?? {
|
|
1671
1795
|
status: "unknown",
|
|
1672
1796
|
totalApis: 0,
|
|
@@ -1725,7 +1849,8 @@ function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, pro
|
|
|
1725
1849
|
},
|
|
1726
1850
|
mcpProtocol: buildMcpProtocolDimension(protocol),
|
|
1727
1851
|
oauthLifecycle: buildOAuthLifecycleDimension(oauthLifecycle),
|
|
1728
|
-
catalogIntegrity: buildCatalogIntegrityDimension(protocol)
|
|
1852
|
+
catalogIntegrity: buildCatalogIntegrityDimension(protocol),
|
|
1853
|
+
rateLimiter: buildRateLimiterDimension(rateLimiter)
|
|
1729
1854
|
};
|
|
1730
1855
|
return {
|
|
1731
1856
|
...results,
|
|
@@ -1749,7 +1874,8 @@ function buildPublicHealthSnapshot(providers, taskCanaries, workflowRouting, pro
|
|
|
1749
1874
|
"workflowRouting",
|
|
1750
1875
|
"mcpProtocol",
|
|
1751
1876
|
"oauthLifecycle",
|
|
1752
|
-
"catalogIntegrity"
|
|
1877
|
+
"catalogIntegrity",
|
|
1878
|
+
"rateLimiter"
|
|
1753
1879
|
],
|
|
1754
1880
|
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
1881
|
}
|
|
@@ -2353,8 +2479,91 @@ var getCorsConfig = () => {
|
|
|
2353
2479
|
|
|
2354
2480
|
// src/middleware/api-key.ts
|
|
2355
2481
|
import { createHash, randomUUID } from "crypto";
|
|
2482
|
+
|
|
2483
|
+
// src/services/user-rate-limit.ts
|
|
2484
|
+
var WINDOW_MS = 6e4;
|
|
2485
|
+
var WINDOW_SECONDS = 60;
|
|
2486
|
+
var PLAN_CACHE_TTL_MS = 6e4;
|
|
2487
|
+
var planCache = /* @__PURE__ */ new Map();
|
|
2488
|
+
var FALLBACK_LIMIT_PER_MIN = 100;
|
|
2489
|
+
var localCounters = /* @__PURE__ */ new Map();
|
|
2490
|
+
async function resolveUserRateLimit(userId) {
|
|
2491
|
+
const cached = planCache.get(userId);
|
|
2492
|
+
if (cached && cached.expiresAt > Date.now()) return cached.limit;
|
|
2493
|
+
let limit = FALLBACK_LIMIT_PER_MIN;
|
|
2494
|
+
try {
|
|
2495
|
+
const supabase = getSupabaseAdmin();
|
|
2496
|
+
const { data: profile } = await supabase.from("user_profiles").select("plan_id").eq("id", userId).single();
|
|
2497
|
+
const planId = profile?.plan_id ?? "demo";
|
|
2498
|
+
const { data: plan } = await supabase.from("plans").select("rate_limit_per_min").eq("id", planId).single();
|
|
2499
|
+
if (typeof plan?.rate_limit_per_min === "number") {
|
|
2500
|
+
limit = plan.rate_limit_per_min;
|
|
2501
|
+
}
|
|
2502
|
+
} catch (error) {
|
|
2503
|
+
secureLogger.warn("Plan rate lookup failed \u2014 using the free-tier rate", {
|
|
2504
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
planCache.set(userId, { limit, expiresAt: Date.now() + PLAN_CACHE_TTL_MS });
|
|
2508
|
+
return limit;
|
|
2509
|
+
}
|
|
2510
|
+
function consumeLocal(key, limit) {
|
|
2511
|
+
const now = Date.now();
|
|
2512
|
+
const existing = localCounters.get(key);
|
|
2513
|
+
const entry = existing && existing.expiresAt > now ? existing : { count: 0, expiresAt: now + WINDOW_MS };
|
|
2514
|
+
entry.count += 1;
|
|
2515
|
+
localCounters.set(key, entry);
|
|
2516
|
+
const resetSeconds = Math.max(1, Math.ceil((entry.expiresAt - now) / 1e3));
|
|
2517
|
+
return {
|
|
2518
|
+
allowed: entry.count <= limit,
|
|
2519
|
+
limit,
|
|
2520
|
+
remaining: Math.max(0, limit - entry.count),
|
|
2521
|
+
resetSeconds,
|
|
2522
|
+
degraded: false
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
async function consumeUserRateLimit(userId, limitPerMin) {
|
|
2526
|
+
const window = Math.floor(Date.now() / WINDOW_MS);
|
|
2527
|
+
const key = `rl:user:${userId}:${window}`;
|
|
2528
|
+
const redis = getRedisClient();
|
|
2529
|
+
if (!redis) return consumeLocal(key, limitPerMin);
|
|
2530
|
+
try {
|
|
2531
|
+
const count = await redis.incr(key);
|
|
2532
|
+
if (count === 1) await redis.expire(key, WINDOW_SECONDS);
|
|
2533
|
+
const resetSeconds = Math.max(
|
|
2534
|
+
1,
|
|
2535
|
+
WINDOW_SECONDS - Math.floor(Date.now() % WINDOW_MS / 1e3)
|
|
2536
|
+
);
|
|
2537
|
+
return {
|
|
2538
|
+
allowed: count <= limitPerMin,
|
|
2539
|
+
limit: limitPerMin,
|
|
2540
|
+
remaining: Math.max(0, limitPerMin - count),
|
|
2541
|
+
resetSeconds,
|
|
2542
|
+
degraded: false
|
|
2543
|
+
};
|
|
2544
|
+
} catch (error) {
|
|
2545
|
+
secureLogger.warn(
|
|
2546
|
+
"[user-rate-limit] store error \u2014 request passed UNLIMITED",
|
|
2547
|
+
{ errorMessage: error instanceof Error ? error.message : String(error) }
|
|
2548
|
+
);
|
|
2549
|
+
return {
|
|
2550
|
+
allowed: true,
|
|
2551
|
+
limit: limitPerMin,
|
|
2552
|
+
remaining: limitPerMin,
|
|
2553
|
+
resetSeconds: WINDOW_SECONDS,
|
|
2554
|
+
degraded: true
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
async function enforceUserRateLimit(userId) {
|
|
2559
|
+
const limit = await resolveUserRateLimit(userId);
|
|
2560
|
+
return consumeUserRateLimit(userId, limit);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// src/middleware/api-key.ts
|
|
2356
2564
|
var REQUEST_COST = 1;
|
|
2357
|
-
|
|
2565
|
+
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.";
|
|
2566
|
+
function attachVerifiedContext(req, res, key, result, quotaRemaining, rateDecision) {
|
|
2358
2567
|
const authReq = req;
|
|
2359
2568
|
authReq.apiKey = key;
|
|
2360
2569
|
authReq.apiKeyEnvironment = getKeyEnvironment(key);
|
|
@@ -2362,7 +2571,7 @@ function attachVerifiedContext(req, res, key, result, quotaRemaining) {
|
|
|
2362
2571
|
authReq.planId = result.planId;
|
|
2363
2572
|
authReq.unkeyKeyId = result.keyId;
|
|
2364
2573
|
authReq.quotaRemaining = quotaRemaining ?? void 0;
|
|
2365
|
-
authReq.ratelimitRemaining = result.ratelimit?.remaining;
|
|
2574
|
+
authReq.ratelimitRemaining = rateDecision?.remaining ?? result.ratelimit?.remaining;
|
|
2366
2575
|
authReq.unkeyResult = result;
|
|
2367
2576
|
if (quotaRemaining !== void 0) {
|
|
2368
2577
|
res.setHeader(
|
|
@@ -2370,9 +2579,11 @@ function attachVerifiedContext(req, res, key, result, quotaRemaining) {
|
|
|
2370
2579
|
quotaRemaining === null ? "unlimited" : String(quotaRemaining)
|
|
2371
2580
|
);
|
|
2372
2581
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2582
|
+
const limit = rateDecision?.limit ?? result.ratelimit?.limit;
|
|
2583
|
+
const remaining = rateDecision?.remaining ?? result.ratelimit?.remaining;
|
|
2584
|
+
if (limit !== void 0 && remaining !== void 0) {
|
|
2585
|
+
res.setHeader("X-RateLimit-Remaining", String(remaining));
|
|
2586
|
+
res.setHeader("X-RateLimit-Limit", String(limit));
|
|
2376
2587
|
}
|
|
2377
2588
|
}
|
|
2378
2589
|
function deferApiKeyCreditDebit(req, _res, next) {
|
|
@@ -2397,7 +2608,7 @@ async function debitApiKeyCreditForExecution(req, res) {
|
|
|
2397
2608
|
return {
|
|
2398
2609
|
type: "rate_limit_error",
|
|
2399
2610
|
code: "QUOTA_EXCEEDED",
|
|
2400
|
-
message:
|
|
2611
|
+
message: REST_QUOTA_EXHAUSTED_MESSAGE,
|
|
2401
2612
|
runtimeStatus: "rate_limited"
|
|
2402
2613
|
};
|
|
2403
2614
|
}
|
|
@@ -2529,8 +2740,25 @@ function requireApiKey(req, res, next) {
|
|
|
2529
2740
|
);
|
|
2530
2741
|
return;
|
|
2531
2742
|
}
|
|
2743
|
+
const rateDecision = await enforceUserRateLimit(result.userId);
|
|
2744
|
+
res.setHeader("RateLimit-Limit", String(rateDecision.limit));
|
|
2745
|
+
res.setHeader("RateLimit-Remaining", String(rateDecision.remaining));
|
|
2746
|
+
res.setHeader("RateLimit-Reset", String(rateDecision.resetSeconds));
|
|
2747
|
+
res.setHeader("RateLimit-Policy", `${rateDecision.limit};w=60`);
|
|
2748
|
+
if (!rateDecision.allowed) {
|
|
2749
|
+
res.setHeader("Retry-After", String(rateDecision.resetSeconds));
|
|
2750
|
+
res.status(429).json(
|
|
2751
|
+
apiError(
|
|
2752
|
+
"rate_limit_error",
|
|
2753
|
+
"RATE_LIMITED",
|
|
2754
|
+
`Rate limit exceeded: ${rateDecision.limit} requests per minute on your plan. This limit is shared across all of your API keys and connected clients.`,
|
|
2755
|
+
{ requestId: req.requestId }
|
|
2756
|
+
)
|
|
2757
|
+
);
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2532
2760
|
if (req.deferCreditDebit) {
|
|
2533
|
-
attachVerifiedContext(req, res, key, result);
|
|
2761
|
+
attachVerifiedContext(req, res, key, result, void 0, rateDecision);
|
|
2534
2762
|
next();
|
|
2535
2763
|
return;
|
|
2536
2764
|
}
|
|
@@ -2552,7 +2780,7 @@ function requireApiKey(req, res, next) {
|
|
|
2552
2780
|
apiError(
|
|
2553
2781
|
"rate_limit_error",
|
|
2554
2782
|
"QUOTA_EXCEEDED",
|
|
2555
|
-
|
|
2783
|
+
REST_QUOTA_EXHAUSTED_MESSAGE,
|
|
2556
2784
|
{ requestId: req.requestId }
|
|
2557
2785
|
)
|
|
2558
2786
|
);
|
|
@@ -2573,7 +2801,14 @@ function requireApiKey(req, res, next) {
|
|
|
2573
2801
|
);
|
|
2574
2802
|
return;
|
|
2575
2803
|
}
|
|
2576
|
-
attachVerifiedContext(
|
|
2804
|
+
attachVerifiedContext(
|
|
2805
|
+
req,
|
|
2806
|
+
res,
|
|
2807
|
+
key,
|
|
2808
|
+
result,
|
|
2809
|
+
consumeResult.balance,
|
|
2810
|
+
rateDecision
|
|
2811
|
+
);
|
|
2577
2812
|
next();
|
|
2578
2813
|
} catch (err) {
|
|
2579
2814
|
secureLogger.error("consume_credits threw \u2014 rejecting request", err, {
|
|
@@ -2666,9 +2901,6 @@ function requireApiKey(req, res, next) {
|
|
|
2666
2901
|
|
|
2667
2902
|
// src/routes/auth.ts
|
|
2668
2903
|
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
2904
|
|
|
2673
2905
|
// src/middleware/supabase-auth.ts
|
|
2674
2906
|
function requireSupabaseAuth(req, res, next) {
|
|
@@ -2728,136 +2960,6 @@ var asyncHandler = (fn) => (req, res, next) => {
|
|
|
2728
2960
|
|
|
2729
2961
|
// src/routes/auth.ts
|
|
2730
2962
|
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
2963
|
router2.get(
|
|
2862
2964
|
"/me",
|
|
2863
2965
|
requireSupabaseAuth,
|
|
@@ -2875,7 +2977,7 @@ var auth_default = router2;
|
|
|
2875
2977
|
|
|
2876
2978
|
// src/routes/keys.ts
|
|
2877
2979
|
import { Router as Router3 } from "express";
|
|
2878
|
-
import { z
|
|
2980
|
+
import { z } from "zod";
|
|
2879
2981
|
|
|
2880
2982
|
// src/middleware/idempotency.ts
|
|
2881
2983
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -3092,7 +3194,7 @@ function replayResponse(res, cached) {
|
|
|
3092
3194
|
error: {
|
|
3093
3195
|
type: "invalid_request_error",
|
|
3094
3196
|
code: "SECRET_ALREADY_ISSUED",
|
|
3095
|
-
message: "This request was already completed and its one-time secret was returned only in the original response. It cannot be retrieved again
|
|
3197
|
+
message: "This request was already completed and its one-time secret was returned only in the original response. It cannot be retrieved again; create a new key if you no longer have it."
|
|
3096
3198
|
}
|
|
3097
3199
|
});
|
|
3098
3200
|
return;
|
|
@@ -3208,9 +3310,9 @@ async function handleRedisLookup(key, requestFingerprint, req, res, next) {
|
|
|
3208
3310
|
|
|
3209
3311
|
// src/routes/keys.ts
|
|
3210
3312
|
var router3 = Router3();
|
|
3211
|
-
var CreateKeySchema =
|
|
3212
|
-
name:
|
|
3213
|
-
b2b_subjects_enabled:
|
|
3313
|
+
var CreateKeySchema = z.object({
|
|
3314
|
+
name: z.string().min(1).max(100).optional(),
|
|
3315
|
+
b2b_subjects_enabled: z.boolean().optional()
|
|
3214
3316
|
});
|
|
3215
3317
|
router3.post(
|
|
3216
3318
|
"/",
|
|
@@ -3248,7 +3350,7 @@ router3.post(
|
|
|
3248
3350
|
plan: planId,
|
|
3249
3351
|
b2b_subjects_enabled: b2b_subjects_enabled ?? false,
|
|
3250
3352
|
...result.subjectSigningSecret ? { subject_signing_secret: result.subjectSigningSecret } : {},
|
|
3251
|
-
warning: "Save this key
|
|
3353
|
+
warning: "Save this key; it will not be shown again."
|
|
3252
3354
|
}
|
|
3253
3355
|
});
|
|
3254
3356
|
} catch (err) {
|
|
@@ -3375,6 +3477,7 @@ router4.get(
|
|
|
3375
3477
|
const { data: plan } = await getSupabaseAdmin().from("plans").select("*").eq("id", resolvedPlanId).single();
|
|
3376
3478
|
await ensureWallet(userId, resolvedPlanId);
|
|
3377
3479
|
const wallet = await getWalletStatus(userId);
|
|
3480
|
+
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
3481
|
const keys = await listKeys(userId);
|
|
3379
3482
|
const keyEntries = keys.map((k) => ({
|
|
3380
3483
|
key_id: k.keyId,
|
|
@@ -3398,6 +3501,11 @@ router4.get(
|
|
|
3398
3501
|
cycle_start: wallet.cycleStart,
|
|
3399
3502
|
cycle_end: wallet.cycleEnd
|
|
3400
3503
|
} : null,
|
|
3504
|
+
subscription: subscription ? {
|
|
3505
|
+
status: subscription.status,
|
|
3506
|
+
current_period_start: subscription.current_period_start,
|
|
3507
|
+
current_period_end: subscription.current_period_end
|
|
3508
|
+
} : null,
|
|
3401
3509
|
keys: keyEntries,
|
|
3402
3510
|
total_keys: keys.length
|
|
3403
3511
|
}
|
|
@@ -3588,13 +3696,13 @@ var b2b_default = router5;
|
|
|
3588
3696
|
import {
|
|
3589
3697
|
Router as Router6
|
|
3590
3698
|
} from "express";
|
|
3591
|
-
import { z as
|
|
3699
|
+
import { z as z2 } from "zod";
|
|
3592
3700
|
|
|
3593
3701
|
// src/services/stripe-service.ts
|
|
3594
3702
|
import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
3595
3703
|
var STRIPE_API_BASE = "https://api.stripe.com";
|
|
3596
3704
|
var STRIPE_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
3597
|
-
var PAID_PLAN_ID = "
|
|
3705
|
+
var PAID_PLAN_ID = "analyst";
|
|
3598
3706
|
var FREE_PLAN_ID = "demo";
|
|
3599
3707
|
function getStripeSecretKey() {
|
|
3600
3708
|
return getEnvVar("STRIPE_SECRET_KEY");
|
|
@@ -3605,16 +3713,44 @@ function getStripeWebhookSecret() {
|
|
|
3605
3713
|
function isStripeCheckoutConfigured() {
|
|
3606
3714
|
return Boolean(getStripeSecretKey());
|
|
3607
3715
|
}
|
|
3608
|
-
|
|
3609
|
-
|
|
3716
|
+
var STRIPE_PRICE_ENV = {
|
|
3717
|
+
starter: {
|
|
3718
|
+
monthly: "STRIPE_PRICE_STARTER_MONTHLY",
|
|
3719
|
+
yearly: "STRIPE_PRICE_STARTER_YEARLY"
|
|
3720
|
+
},
|
|
3721
|
+
analyst: {
|
|
3722
|
+
monthly: "STRIPE_PRICE_PRO_MONTHLY",
|
|
3723
|
+
yearly: "STRIPE_PRICE_PRO_YEARLY"
|
|
3724
|
+
}
|
|
3725
|
+
};
|
|
3726
|
+
function priceIdForPlanCycle(planId, cycle) {
|
|
3727
|
+
const row = STRIPE_PRICE_ENV[planId];
|
|
3728
|
+
if (!row) return void 0;
|
|
3729
|
+
return getEnvVar(row[cycle]);
|
|
3610
3730
|
}
|
|
3611
3731
|
function planIdForPrice(priceId) {
|
|
3612
3732
|
if (!priceId) return null;
|
|
3613
|
-
|
|
3614
|
-
|
|
3733
|
+
for (const [planId, cycles] of Object.entries(STRIPE_PRICE_ENV)) {
|
|
3734
|
+
for (const envName of Object.values(cycles)) {
|
|
3735
|
+
if (priceId === getEnvVar(envName)) return planId;
|
|
3736
|
+
}
|
|
3615
3737
|
}
|
|
3616
3738
|
return null;
|
|
3617
3739
|
}
|
|
3740
|
+
var STRIPE_SUBSCRIPTION_STATUSES = [
|
|
3741
|
+
"incomplete",
|
|
3742
|
+
"incomplete_expired",
|
|
3743
|
+
"trialing",
|
|
3744
|
+
"active",
|
|
3745
|
+
"past_due",
|
|
3746
|
+
"canceled",
|
|
3747
|
+
"unpaid",
|
|
3748
|
+
"paused"
|
|
3749
|
+
];
|
|
3750
|
+
function registrySubscriptionStatus(status) {
|
|
3751
|
+
if (!status) return null;
|
|
3752
|
+
return STRIPE_SUBSCRIPTION_STATUSES.includes(status) ? status : null;
|
|
3753
|
+
}
|
|
3618
3754
|
function getBillingSuccessUrl() {
|
|
3619
3755
|
return getEnvVar("BILLING_SUCCESS_URL") ?? "https://www.hiveintelligence.xyz/dashboard?checkout=success";
|
|
3620
3756
|
}
|
|
@@ -3734,6 +3870,164 @@ async function createStripeCheckoutSession(args) {
|
|
|
3734
3870
|
return { id, url };
|
|
3735
3871
|
}
|
|
3736
3872
|
|
|
3873
|
+
// src/services/llamapay-service.ts
|
|
3874
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
3875
|
+
var LLAMAPAY_API_BASE = "https://api.llamapay.io";
|
|
3876
|
+
var LLAMAPAY_SIGNATURE_HEADER = "x-cc-webhook-signature";
|
|
3877
|
+
function getLlamaPayApiKey() {
|
|
3878
|
+
const key = process.env.LLAMAPAY_API_KEY?.trim();
|
|
3879
|
+
return key && key.length > 0 ? key : void 0;
|
|
3880
|
+
}
|
|
3881
|
+
function getLlamaPayWebhookSecret() {
|
|
3882
|
+
const secret = process.env.LLAMAPAY_WEBHOOK_SECRET?.trim();
|
|
3883
|
+
return secret && secret.length > 0 ? secret : void 0;
|
|
3884
|
+
}
|
|
3885
|
+
function isLlamaPayCheckoutConfigured() {
|
|
3886
|
+
return getLlamaPayApiKey() !== void 0;
|
|
3887
|
+
}
|
|
3888
|
+
var PLAN_PRICES_USD = {
|
|
3889
|
+
starter: { monthly: 49, yearly: 490 },
|
|
3890
|
+
analyst: { monthly: 149, yearly: 1490 }
|
|
3891
|
+
};
|
|
3892
|
+
var PLAN_TIER_ORDER = [
|
|
3893
|
+
"demo",
|
|
3894
|
+
"starter",
|
|
3895
|
+
"analyst",
|
|
3896
|
+
"enterprise"
|
|
3897
|
+
];
|
|
3898
|
+
function isSellablePlan(planId) {
|
|
3899
|
+
return Object.prototype.hasOwnProperty.call(PLAN_PRICES_USD, planId);
|
|
3900
|
+
}
|
|
3901
|
+
function priceForPlanCycle(planId, cycle) {
|
|
3902
|
+
const row = PLAN_PRICES_USD[planId];
|
|
3903
|
+
if (!row) return void 0;
|
|
3904
|
+
return cycle === "yearly" ? row.yearly : row.monthly;
|
|
3905
|
+
}
|
|
3906
|
+
function canUpgradeTo(current, target) {
|
|
3907
|
+
const currentIndex = PLAN_TIER_ORDER.indexOf(current ?? "demo");
|
|
3908
|
+
const targetIndex = PLAN_TIER_ORDER.indexOf(target);
|
|
3909
|
+
if (targetIndex < 0) return false;
|
|
3910
|
+
return targetIndex > (currentIndex < 0 ? 0 : currentIndex);
|
|
3911
|
+
}
|
|
3912
|
+
var RENEWAL_WINDOW_DAYS = 30;
|
|
3913
|
+
function canPurchasePlan(current, target, termEnd, now = /* @__PURE__ */ new Date()) {
|
|
3914
|
+
if (canUpgradeTo(current, target)) return true;
|
|
3915
|
+
if (current !== target) return false;
|
|
3916
|
+
if (!termEnd) return false;
|
|
3917
|
+
const end = termEnd instanceof Date ? termEnd : new Date(termEnd);
|
|
3918
|
+
if (Number.isNaN(end.getTime())) return false;
|
|
3919
|
+
const windowOpens = new Date(
|
|
3920
|
+
end.getTime() - RENEWAL_WINDOW_DAYS * 24 * 60 * 60 * 1e3
|
|
3921
|
+
);
|
|
3922
|
+
return now.getTime() >= windowOpens.getTime();
|
|
3923
|
+
}
|
|
3924
|
+
function pricingTypeFor(cycle) {
|
|
3925
|
+
return cycle === "yearly" ? "fixed_price" : "subscription";
|
|
3926
|
+
}
|
|
3927
|
+
function addUtcMonths(start, months) {
|
|
3928
|
+
const end = new Date(start.getTime());
|
|
3929
|
+
const day = end.getUTCDate();
|
|
3930
|
+
end.setUTCDate(1);
|
|
3931
|
+
end.setUTCMonth(end.getUTCMonth() + months);
|
|
3932
|
+
const daysInTargetMonth = new Date(
|
|
3933
|
+
Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1, 0)
|
|
3934
|
+
).getUTCDate();
|
|
3935
|
+
end.setUTCDate(Math.min(day, daysInTargetMonth));
|
|
3936
|
+
return end;
|
|
3937
|
+
}
|
|
3938
|
+
function creditCycleEndFrom(start) {
|
|
3939
|
+
return addUtcMonths(start, 1);
|
|
3940
|
+
}
|
|
3941
|
+
function termEndFrom(start, cycle) {
|
|
3942
|
+
return addUtcMonths(start, cycle === "yearly" ? 12 : 1);
|
|
3943
|
+
}
|
|
3944
|
+
var LlamaPayApiError = class extends Error {
|
|
3945
|
+
constructor(status, message) {
|
|
3946
|
+
super(message);
|
|
3947
|
+
this.status = status;
|
|
3948
|
+
this.name = "LlamaPayApiError";
|
|
3949
|
+
}
|
|
3950
|
+
status;
|
|
3951
|
+
};
|
|
3952
|
+
async function createLlamaPayCharge(args) {
|
|
3953
|
+
const apiKey = getLlamaPayApiKey();
|
|
3954
|
+
if (!apiKey) {
|
|
3955
|
+
throw new LlamaPayApiError(503, "LlamaPay API key is not configured");
|
|
3956
|
+
}
|
|
3957
|
+
let response;
|
|
3958
|
+
try {
|
|
3959
|
+
response = await fetch(`${LLAMAPAY_API_BASE}/charges`, {
|
|
3960
|
+
method: "POST",
|
|
3961
|
+
headers: {
|
|
3962
|
+
Authorization: apiKey,
|
|
3963
|
+
"Content-Type": "application/json"
|
|
3964
|
+
},
|
|
3965
|
+
body: JSON.stringify({
|
|
3966
|
+
pricing_type: pricingTypeFor(args.cycle),
|
|
3967
|
+
local_price: {
|
|
3968
|
+
amount: args.amountUsd.toFixed(2),
|
|
3969
|
+
currency: "USD"
|
|
3970
|
+
},
|
|
3971
|
+
metadata: {
|
|
3972
|
+
user_id: args.userId,
|
|
3973
|
+
plan_id: args.planId,
|
|
3974
|
+
billing_cycle: args.cycle,
|
|
3975
|
+
...args.email ? { email: args.email } : {}
|
|
3976
|
+
},
|
|
3977
|
+
redirect_url: getBillingSuccessUrl(),
|
|
3978
|
+
cancel_url: getBillingCancelUrl()
|
|
3979
|
+
})
|
|
3980
|
+
});
|
|
3981
|
+
} catch (err) {
|
|
3982
|
+
throw new LlamaPayApiError(
|
|
3983
|
+
502,
|
|
3984
|
+
`LlamaPay request failed: ${err instanceof Error ? err.message : "unknown"}`
|
|
3985
|
+
);
|
|
3986
|
+
}
|
|
3987
|
+
const text = await response.text();
|
|
3988
|
+
if (!response.ok) {
|
|
3989
|
+
throw new LlamaPayApiError(
|
|
3990
|
+
response.status,
|
|
3991
|
+
`LlamaPay charge creation failed (${response.status}): ${text.slice(0, 300)}`
|
|
3992
|
+
);
|
|
3993
|
+
}
|
|
3994
|
+
let payload;
|
|
3995
|
+
try {
|
|
3996
|
+
payload = JSON.parse(text);
|
|
3997
|
+
} catch {
|
|
3998
|
+
throw new LlamaPayApiError(502, "LlamaPay returned a non-JSON response");
|
|
3999
|
+
}
|
|
4000
|
+
const id = payload.data?.id;
|
|
4001
|
+
const hostedUrl = payload.data?.hosted_url;
|
|
4002
|
+
if (typeof id !== "string" || typeof hostedUrl !== "string" || !hostedUrl) {
|
|
4003
|
+
throw new LlamaPayApiError(
|
|
4004
|
+
502,
|
|
4005
|
+
"LlamaPay charge creation returned no hosted_url"
|
|
4006
|
+
);
|
|
4007
|
+
}
|
|
4008
|
+
return { id, hostedUrl };
|
|
4009
|
+
}
|
|
4010
|
+
function verifyLlamaPaySignature(args) {
|
|
4011
|
+
const header = args.header;
|
|
4012
|
+
if (typeof header !== "string" || header.length === 0) {
|
|
4013
|
+
return { valid: false, reason: "missing_header" };
|
|
4014
|
+
}
|
|
4015
|
+
const received = header.trim().toLowerCase();
|
|
4016
|
+
if (!/^[0-9a-f]+$/.test(received)) {
|
|
4017
|
+
return { valid: false, reason: "malformed_header" };
|
|
4018
|
+
}
|
|
4019
|
+
const expected = createHmac2("sha256", args.secret).update(args.payload).digest("hex");
|
|
4020
|
+
const receivedBuf = Buffer.from(received, "utf8");
|
|
4021
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
4022
|
+
if (receivedBuf.length !== expectedBuf.length) {
|
|
4023
|
+
return { valid: false, reason: "no_match" };
|
|
4024
|
+
}
|
|
4025
|
+
if (!timingSafeEqual4(receivedBuf, expectedBuf)) {
|
|
4026
|
+
return { valid: false, reason: "no_match" };
|
|
4027
|
+
}
|
|
4028
|
+
return { valid: true };
|
|
4029
|
+
}
|
|
4030
|
+
|
|
3737
4031
|
// src/routes/billing.ts
|
|
3738
4032
|
var BILLING_UNAVAILABLE = apiError(
|
|
3739
4033
|
"internal_error",
|
|
@@ -3788,46 +4082,187 @@ function requireBillingPrincipal(req, res, next) {
|
|
|
3788
4082
|
next();
|
|
3789
4083
|
});
|
|
3790
4084
|
}
|
|
3791
|
-
var CheckoutSchema =
|
|
3792
|
-
|
|
4085
|
+
var CheckoutSchema = z2.object({
|
|
4086
|
+
// Was absent, and the route hardcoded PAID_PLAN_ID. The crypto lane sold
|
|
4087
|
+
// Starter and this one did not, so the two processors sold different
|
|
4088
|
+
// catalogues -- with Starter the emphasised middle card on /pricing.
|
|
4089
|
+
plan_id: z2.string().min(1).default(PAID_PLAN_ID),
|
|
4090
|
+
billing_cycle: z2.enum(["monthly", "yearly"]).default("monthly")
|
|
3793
4091
|
});
|
|
3794
4092
|
var OCCUPYING_BILLING_STATUSES = /* @__PURE__ */ new Set(["active", "past_due", "unpaid"]);
|
|
3795
|
-
var
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
return res.status(400).json(
|
|
3810
|
-
apiError(
|
|
3811
|
-
"invalid_request_error",
|
|
3812
|
-
"VALIDATION_ERROR",
|
|
3813
|
-
'billing_cycle must be "monthly" or "yearly".'
|
|
3814
|
-
)
|
|
4093
|
+
var CHECKOUT_LOCK_TTL_MS = 6e4;
|
|
4094
|
+
var CHECKOUT_LOCK_PREFIX = "billing:checkout:";
|
|
4095
|
+
var localCheckoutLocks = /* @__PURE__ */ new Map();
|
|
4096
|
+
async function acquireCheckoutLock(userId) {
|
|
4097
|
+
const key = `${CHECKOUT_LOCK_PREFIX}${userId}`;
|
|
4098
|
+
const redis = isRedisAvailable() ? getRedisClient() : null;
|
|
4099
|
+
if (redis) {
|
|
4100
|
+
try {
|
|
4101
|
+
const claimed = await redis.set(
|
|
4102
|
+
key,
|
|
4103
|
+
"1",
|
|
4104
|
+
"PX",
|
|
4105
|
+
CHECKOUT_LOCK_TTL_MS,
|
|
4106
|
+
"NX"
|
|
3815
4107
|
);
|
|
4108
|
+
return claimed === "OK";
|
|
4109
|
+
} catch (err) {
|
|
4110
|
+
secureLogger.warn("Checkout lock fell back to process memory", {
|
|
4111
|
+
reason: err instanceof Error ? err.message : "unknown"
|
|
4112
|
+
});
|
|
3816
4113
|
}
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
4114
|
+
}
|
|
4115
|
+
const now = Date.now();
|
|
4116
|
+
const heldUntil = localCheckoutLocks.get(key);
|
|
4117
|
+
if (heldUntil !== void 0 && heldUntil > now) return false;
|
|
4118
|
+
localCheckoutLocks.set(key, now + CHECKOUT_LOCK_TTL_MS);
|
|
4119
|
+
return true;
|
|
4120
|
+
}
|
|
4121
|
+
async function releaseCheckoutLock(userId) {
|
|
4122
|
+
const key = `${CHECKOUT_LOCK_PREFIX}${userId}`;
|
|
4123
|
+
localCheckoutLocks.delete(key);
|
|
4124
|
+
const redis = isRedisAvailable() ? getRedisClient() : null;
|
|
4125
|
+
if (!redis) return;
|
|
4126
|
+
try {
|
|
4127
|
+
await redis.del(key);
|
|
4128
|
+
} catch {
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
var CHECKOUT_RACE_LOST = apiError(
|
|
4132
|
+
"invalid_request_error",
|
|
4133
|
+
"CHECKOUT_IN_PROGRESS",
|
|
4134
|
+
"Another checkout request for this account is still being created. Next action: wait a couple of seconds and retry; two simultaneous requests can only ever produce one charge."
|
|
4135
|
+
);
|
|
4136
|
+
function checkoutAlreadyOpen(chargeId) {
|
|
4137
|
+
return apiError(
|
|
4138
|
+
"invalid_request_error",
|
|
4139
|
+
"CHECKOUT_IN_PROGRESS",
|
|
4140
|
+
`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; otherwise it clears on its own ${OPEN_CHARGE_WINDOW_MINUTES} minutes after the page was opened.`
|
|
4141
|
+
);
|
|
4142
|
+
}
|
|
4143
|
+
function withCheckoutLock(req, res, next) {
|
|
4144
|
+
const userId = req.billingUserId;
|
|
4145
|
+
if (!userId) {
|
|
4146
|
+
next();
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
void acquireCheckoutLock(userId).then((claimed) => {
|
|
4150
|
+
if (!claimed) {
|
|
4151
|
+
res.status(409).json(CHECKOUT_RACE_LOST);
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
4154
|
+
const sendJson = res.json.bind(res);
|
|
4155
|
+
res.json = ((body) => {
|
|
4156
|
+
void releaseCheckoutLock(userId);
|
|
4157
|
+
return sendJson(body);
|
|
4158
|
+
});
|
|
4159
|
+
next();
|
|
4160
|
+
});
|
|
4161
|
+
}
|
|
4162
|
+
var OPEN_CHARGE_WINDOW_MINUTES = 15;
|
|
4163
|
+
var OPEN_CHARGE_WINDOW_MS = OPEN_CHARGE_WINDOW_MINUTES * 60 * 1e3;
|
|
4164
|
+
var ABANDONED_EVENT_TYPE = "checkout:abandoned";
|
|
4165
|
+
function openChargeCutoff() {
|
|
4166
|
+
return new Date(Date.now() - OPEN_CHARGE_WINDOW_MS).toISOString();
|
|
4167
|
+
}
|
|
4168
|
+
async function findOpenCharge(userId) {
|
|
4169
|
+
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);
|
|
4170
|
+
if (error) {
|
|
4171
|
+
secureLogger.warn("Open-charge lookup failed; allowing checkout", {
|
|
4172
|
+
userId,
|
|
4173
|
+
errorMessage: error.message
|
|
4174
|
+
});
|
|
4175
|
+
return null;
|
|
4176
|
+
}
|
|
4177
|
+
const row = Array.isArray(data) ? data[0] : void 0;
|
|
4178
|
+
return typeof row?.charge_id === "string" ? row.charge_id : null;
|
|
4179
|
+
}
|
|
4180
|
+
async function settleChargeRecord(chargeId, status, eventType) {
|
|
4181
|
+
if (!chargeId || !isSupabaseConfigured()) return;
|
|
4182
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4183
|
+
try {
|
|
4184
|
+
const { error } = await getSupabaseAdmin().from("billing_charges").update({
|
|
4185
|
+
status,
|
|
4186
|
+
settled_at: now,
|
|
4187
|
+
last_event_at: now,
|
|
4188
|
+
last_event_type: eventType
|
|
4189
|
+
}).eq("charge_id", chargeId);
|
|
4190
|
+
if (error) throw new Error(error.message);
|
|
4191
|
+
} catch (err) {
|
|
4192
|
+
secureLogger.error("Failed to settle billing charge record", err, {
|
|
4193
|
+
chargeId,
|
|
4194
|
+
eventType
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
async function recordChargeCreated(charge) {
|
|
4199
|
+
const { error } = await getSupabaseAdmin().from("billing_charges").insert({
|
|
4200
|
+
charge_id: charge.chargeId,
|
|
4201
|
+
processor: charge.processor,
|
|
4202
|
+
user_id: charge.userId,
|
|
4203
|
+
plan_id: charge.planId,
|
|
4204
|
+
billing_cycle: charge.cycle,
|
|
4205
|
+
amount_usd: charge.amountUsd,
|
|
4206
|
+
plan_id_before: charge.planIdBefore,
|
|
4207
|
+
status: "created"
|
|
4208
|
+
});
|
|
4209
|
+
if (error) {
|
|
4210
|
+
throw new Error(`failed to record billing charge: ${error.message}`);
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
var billingRouter = Router6();
|
|
4214
|
+
billingRouter.post(
|
|
4215
|
+
"/checkout-session",
|
|
4216
|
+
requireBillingPrincipal,
|
|
4217
|
+
withCheckoutLock,
|
|
4218
|
+
asyncHandler(async (req, res) => {
|
|
4219
|
+
const userId = req.billingUserId;
|
|
4220
|
+
if (!isStripeCheckoutConfigured()) {
|
|
4221
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4222
|
+
}
|
|
4223
|
+
if (!isSupabaseConfigured()) {
|
|
4224
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4225
|
+
}
|
|
4226
|
+
const parsed = CheckoutSchema.safeParse(req.body ?? {});
|
|
4227
|
+
if (!parsed.success) {
|
|
4228
|
+
return res.status(400).json(
|
|
4229
|
+
apiError(
|
|
4230
|
+
"invalid_request_error",
|
|
4231
|
+
"VALIDATION_ERROR",
|
|
4232
|
+
'billing_cycle must be "monthly" or "yearly".'
|
|
4233
|
+
)
|
|
3829
4234
|
);
|
|
3830
4235
|
}
|
|
4236
|
+
const { plan_id: planId, billing_cycle: cycle } = parsed.data;
|
|
4237
|
+
if (!isSellablePlan(planId)) {
|
|
4238
|
+
return res.status(400).json(
|
|
4239
|
+
apiError(
|
|
4240
|
+
"invalid_request_error",
|
|
4241
|
+
"PLAN_NOT_SELLABLE",
|
|
4242
|
+
`Plan "${planId}" is not sold self-serve. Next action: choose starter or ${PAID_PLAN_ID}, or contact support for Enterprise.`
|
|
4243
|
+
)
|
|
4244
|
+
);
|
|
4245
|
+
}
|
|
4246
|
+
const priceId = priceIdForPlanCycle(planId, cycle);
|
|
4247
|
+
if (!priceId) {
|
|
4248
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4249
|
+
}
|
|
4250
|
+
const { data: profile } = await getSupabaseAdmin().from("user_profiles").select(
|
|
4251
|
+
"plan_id, billing_customer_id, billing_subscription_id, billing_status"
|
|
4252
|
+
).eq("id", userId).maybeSingle();
|
|
4253
|
+
if (profile?.billing_subscription_id && typeof profile.billing_status === "string" && OCCUPYING_BILLING_STATUSES.has(profile.billing_status)) {
|
|
4254
|
+
return res.status(409).json(
|
|
4255
|
+
apiError(
|
|
4256
|
+
"invalid_request_error",
|
|
4257
|
+
"SUBSCRIPTION_EXISTS",
|
|
4258
|
+
"This account already has an active subscription. Next action: manage it from the dashboard billing tab instead of creating a new checkout."
|
|
4259
|
+
)
|
|
4260
|
+
);
|
|
4261
|
+
}
|
|
4262
|
+
const openChargeId = await findOpenCharge(userId);
|
|
4263
|
+
if (openChargeId) {
|
|
4264
|
+
return res.status(409).json(checkoutAlreadyOpen(openChargeId));
|
|
4265
|
+
}
|
|
3831
4266
|
try {
|
|
3832
4267
|
let customerId = profile?.billing_customer_id ?? null;
|
|
3833
4268
|
if (!customerId) {
|
|
@@ -3846,11 +4281,25 @@ billingRouter.post(
|
|
|
3846
4281
|
customerId,
|
|
3847
4282
|
priceId,
|
|
3848
4283
|
userId,
|
|
3849
|
-
planId
|
|
4284
|
+
planId
|
|
4285
|
+
});
|
|
4286
|
+
await recordChargeCreated({
|
|
4287
|
+
chargeId: session.id,
|
|
4288
|
+
processor: "stripe",
|
|
4289
|
+
userId,
|
|
4290
|
+
planId,
|
|
4291
|
+
cycle,
|
|
4292
|
+
amountUsd: priceForPlanCycle(planId, cycle) ?? 0,
|
|
4293
|
+
planIdBefore: asString(profile?.plan_id)
|
|
3850
4294
|
});
|
|
3851
4295
|
return res.json({
|
|
3852
4296
|
ok: true,
|
|
3853
|
-
data: {
|
|
4297
|
+
data: {
|
|
4298
|
+
url: session.url,
|
|
4299
|
+
session_id: session.id,
|
|
4300
|
+
plan_id: planId,
|
|
4301
|
+
billing_cycle: cycle
|
|
4302
|
+
}
|
|
3854
4303
|
});
|
|
3855
4304
|
} catch (err) {
|
|
3856
4305
|
if (err instanceof StripeApiError) {
|
|
@@ -3922,176 +4371,566 @@ function invoiceSubscriptionContext(invoice) {
|
|
|
3922
4371
|
...metadataOf(parentDetails.metadata),
|
|
3923
4372
|
...metadataOf(details.metadata)
|
|
3924
4373
|
}
|
|
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;
|
|
4374
|
+
};
|
|
4375
|
+
}
|
|
4376
|
+
function invoicePeriod(invoice) {
|
|
4377
|
+
const lines = metadataOf(invoice.lines);
|
|
4378
|
+
const data = Array.isArray(lines.data) ? lines.data : [];
|
|
4379
|
+
let best = null;
|
|
4380
|
+
let bestEnd = -Infinity;
|
|
4381
|
+
for (const raw of data) {
|
|
4382
|
+
const line = metadataOf(raw);
|
|
4383
|
+
const period2 = metadataOf(line.period);
|
|
4384
|
+
const end = typeof period2.end === "number" ? period2.end : null;
|
|
4385
|
+
if (end !== null && end > bestEnd) {
|
|
4386
|
+
bestEnd = end;
|
|
4387
|
+
best = line;
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
if (!best) return { cycleStart: null, cycleEnd: null, priceId: null };
|
|
4391
|
+
const period = metadataOf(best.period);
|
|
4392
|
+
const price = metadataOf(best.price);
|
|
4393
|
+
const pricing = metadataOf(best.pricing);
|
|
4394
|
+
const priceDetails = metadataOf(pricing.price_details);
|
|
4395
|
+
return {
|
|
4396
|
+
cycleStart: isoFromUnixSeconds(period.start),
|
|
4397
|
+
cycleEnd: isoFromUnixSeconds(period.end),
|
|
4398
|
+
priceId: asString(price.id) ?? asString(priceDetails.price)
|
|
4399
|
+
};
|
|
4400
|
+
}
|
|
4401
|
+
function cappedCreditCycleEnd(cycleStart, cycleEnd) {
|
|
4402
|
+
if (!cycleStart) return cycleEnd;
|
|
4403
|
+
const start = new Date(cycleStart);
|
|
4404
|
+
if (Number.isNaN(start.getTime())) return cycleEnd;
|
|
4405
|
+
const monthly = creditCycleEndFrom(start);
|
|
4406
|
+
if (!cycleEnd) return monthly.toISOString();
|
|
4407
|
+
const end = new Date(cycleEnd);
|
|
4408
|
+
if (Number.isNaN(end.getTime())) return monthly.toISOString();
|
|
4409
|
+
return (end < monthly ? end : monthly).toISOString();
|
|
4410
|
+
}
|
|
4411
|
+
async function decideWebhookAction(event) {
|
|
4412
|
+
const object = metadataOf(event.data?.object);
|
|
4413
|
+
switch (event.type) {
|
|
4414
|
+
case "checkout.session.completed": {
|
|
4415
|
+
const mode = asString(object.mode);
|
|
4416
|
+
if (mode !== null && mode !== "subscription") {
|
|
4417
|
+
return { kind: "ignore", note: "non-subscription checkout" };
|
|
4418
|
+
}
|
|
4419
|
+
const metadata = metadataOf(object.metadata);
|
|
4420
|
+
const userId = asString(metadata.user_id) ?? asString(object.client_reference_id);
|
|
4421
|
+
if (!userId) {
|
|
4422
|
+
return { kind: "ignore", note: "checkout session without user_id" };
|
|
4423
|
+
}
|
|
4424
|
+
return {
|
|
4425
|
+
kind: "apply",
|
|
4426
|
+
args: {
|
|
4427
|
+
eventId: event.id,
|
|
4428
|
+
eventType: event.type,
|
|
4429
|
+
userId,
|
|
4430
|
+
newPlanId: asString(metadata.plan_id) ?? PAID_PLAN_ID,
|
|
4431
|
+
grantCredits: true,
|
|
4432
|
+
billingStatus: "active",
|
|
4433
|
+
customerId: refId(object.customer),
|
|
4434
|
+
subscriptionId: refId(object.subscription),
|
|
4435
|
+
subscriptionStatus: "active",
|
|
4436
|
+
ledgerReason: "plan_change"
|
|
4437
|
+
}
|
|
4438
|
+
};
|
|
4439
|
+
}
|
|
4440
|
+
case "customer.subscription.updated":
|
|
4441
|
+
case "customer.subscription.deleted": {
|
|
4442
|
+
const metadata = metadataOf(object.metadata);
|
|
4443
|
+
const customerId = refId(object.customer);
|
|
4444
|
+
const userId = asString(metadata.user_id) ?? await resolveUserIdByCustomer(customerId);
|
|
4445
|
+
if (!userId) {
|
|
4446
|
+
return {
|
|
4447
|
+
kind: "unresolved_user",
|
|
4448
|
+
note: `no user binding for customer ${customerId ?? "<none>"}`
|
|
4449
|
+
};
|
|
4450
|
+
}
|
|
4451
|
+
if (event.type === "customer.subscription.deleted") {
|
|
4452
|
+
return {
|
|
4453
|
+
kind: "apply",
|
|
4454
|
+
args: {
|
|
4455
|
+
eventId: event.id,
|
|
4456
|
+
eventType: event.type,
|
|
4457
|
+
userId,
|
|
4458
|
+
newPlanId: FREE_PLAN_ID,
|
|
4459
|
+
grantCredits: true,
|
|
4460
|
+
billingStatus: "canceled",
|
|
4461
|
+
customerId,
|
|
4462
|
+
subscriptionId: refId(object.id),
|
|
4463
|
+
subscriptionStatus: "canceled",
|
|
4464
|
+
ledgerReason: "plan_change"
|
|
4465
|
+
}
|
|
4466
|
+
};
|
|
4467
|
+
}
|
|
4468
|
+
const status = asString(object.status);
|
|
4469
|
+
const items = metadataOf(object.items);
|
|
4470
|
+
const firstItem = metadataOf(
|
|
4471
|
+
Array.isArray(items.data) ? items.data[0] : void 0
|
|
4472
|
+
);
|
|
4473
|
+
const priceId = asString(metadataOf(firstItem.price).id);
|
|
4474
|
+
return {
|
|
4475
|
+
kind: "apply",
|
|
4476
|
+
args: {
|
|
4477
|
+
eventId: event.id,
|
|
4478
|
+
eventType: event.type,
|
|
4479
|
+
userId,
|
|
4480
|
+
// Tier/status sync only — credits ride the money event
|
|
4481
|
+
// (invoice.paid), which is the one wallet clock.
|
|
4482
|
+
newPlanId: planIdForPrice(priceId) ?? asString(metadata.plan_id) ?? null,
|
|
4483
|
+
grantCredits: false,
|
|
4484
|
+
billingStatus: mapSubscriptionStatusToBilling(status),
|
|
4485
|
+
customerId,
|
|
4486
|
+
subscriptionId: refId(object.id),
|
|
4487
|
+
// Whitelisted, never forwarded raw. stripe_subscriptions.status has
|
|
4488
|
+
// a CHECK over eight literals; a status Stripe adds later raises
|
|
4489
|
+
// 23514 *inside* apply_plan_change, which rolls back the dedupe
|
|
4490
|
+
// claim and the wallet grant with it, 500s, and leaves Stripe
|
|
4491
|
+
// retrying forever. Null here means "skip the registry upsert",
|
|
4492
|
+
// which still applies the tier and still acks 200.
|
|
4493
|
+
subscriptionStatus: registrySubscriptionStatus(status),
|
|
4494
|
+
priceId,
|
|
4495
|
+
ledgerReason: "plan_change"
|
|
4496
|
+
}
|
|
4497
|
+
};
|
|
4498
|
+
}
|
|
4499
|
+
case "invoice.paid":
|
|
4500
|
+
case "invoice.payment_failed": {
|
|
4501
|
+
const { subscriptionId, metadata } = invoiceSubscriptionContext(object);
|
|
4502
|
+
if (!subscriptionId) {
|
|
4503
|
+
return { kind: "ignore", note: "invoice without a subscription" };
|
|
4504
|
+
}
|
|
4505
|
+
const customerId = refId(object.customer);
|
|
4506
|
+
const userId = asString(metadata.user_id) ?? await resolveUserIdByCustomer(customerId);
|
|
4507
|
+
if (!userId) {
|
|
4508
|
+
return {
|
|
4509
|
+
kind: "unresolved_user",
|
|
4510
|
+
note: `no user binding for customer ${customerId ?? "<none>"}`
|
|
4511
|
+
};
|
|
4512
|
+
}
|
|
4513
|
+
if (event.type === "invoice.payment_failed") {
|
|
4514
|
+
return {
|
|
4515
|
+
kind: "apply",
|
|
4516
|
+
args: {
|
|
4517
|
+
eventId: event.id,
|
|
4518
|
+
eventType: event.type,
|
|
4519
|
+
userId,
|
|
4520
|
+
billingStatus: "past_due",
|
|
4521
|
+
customerId,
|
|
4522
|
+
subscriptionId,
|
|
4523
|
+
ledgerReason: "plan_change"
|
|
4524
|
+
}
|
|
4525
|
+
};
|
|
4526
|
+
}
|
|
4527
|
+
const period = invoicePeriod(object);
|
|
4528
|
+
const creditCycleEnd = cappedCreditCycleEnd(
|
|
4529
|
+
period.cycleStart,
|
|
4530
|
+
period.cycleEnd
|
|
4531
|
+
);
|
|
4532
|
+
return {
|
|
4533
|
+
kind: "apply",
|
|
4534
|
+
args: {
|
|
4535
|
+
eventId: event.id,
|
|
4536
|
+
eventType: event.type,
|
|
4537
|
+
userId,
|
|
4538
|
+
newPlanId: planIdForPrice(period.priceId) ?? asString(metadata.plan_id) ?? null,
|
|
4539
|
+
grantCredits: true,
|
|
4540
|
+
cycleStart: period.cycleStart,
|
|
4541
|
+
cycleEnd: creditCycleEnd,
|
|
4542
|
+
termEnd: period.cycleEnd,
|
|
4543
|
+
billingStatus: "active",
|
|
4544
|
+
customerId,
|
|
4545
|
+
subscriptionId,
|
|
4546
|
+
priceId: period.priceId,
|
|
4547
|
+
ledgerReason: "refill"
|
|
4548
|
+
}
|
|
4549
|
+
};
|
|
4550
|
+
}
|
|
4551
|
+
default:
|
|
4552
|
+
return { kind: "ignore", note: `unhandled event type ${event.type}` };
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
var stripeWebhookHandler = asyncHandler(
|
|
4556
|
+
async (req, res) => {
|
|
4557
|
+
const secret = getStripeWebhookSecret();
|
|
4558
|
+
if (!secret) {
|
|
4559
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4560
|
+
}
|
|
4561
|
+
const raw = req.body;
|
|
4562
|
+
if (!Buffer.isBuffer(raw)) {
|
|
4563
|
+
secureLogger.error(
|
|
4564
|
+
"Stripe webhook received a non-raw body \u2014 express.raw must mount before the JSON parser",
|
|
4565
|
+
void 0,
|
|
4566
|
+
{ path: req.path }
|
|
4567
|
+
);
|
|
4568
|
+
return res.status(500).json(
|
|
4569
|
+
apiError(
|
|
4570
|
+
"internal_error",
|
|
4571
|
+
"WEBHOOK_BODY_NOT_RAW",
|
|
4572
|
+
"Webhook raw body unavailable; server middleware misconfigured."
|
|
4573
|
+
)
|
|
4574
|
+
);
|
|
4575
|
+
}
|
|
4576
|
+
const verification = verifyStripeSignature({
|
|
4577
|
+
payload: raw,
|
|
4578
|
+
header: req.headers["stripe-signature"],
|
|
4579
|
+
secret
|
|
4580
|
+
});
|
|
4581
|
+
if (!verification.valid) {
|
|
4582
|
+
secureLogger.warn("Stripe webhook signature rejected", {
|
|
4583
|
+
reason: verification.reason
|
|
4584
|
+
});
|
|
4585
|
+
return res.status(400).json(
|
|
4586
|
+
apiError(
|
|
4587
|
+
"authentication_error",
|
|
4588
|
+
"INVALID_WEBHOOK_SIGNATURE",
|
|
4589
|
+
`Stripe signature verification failed (${verification.reason}).`
|
|
4590
|
+
)
|
|
4591
|
+
);
|
|
4592
|
+
}
|
|
4593
|
+
let event;
|
|
4594
|
+
try {
|
|
4595
|
+
event = JSON.parse(raw.toString("utf8"));
|
|
4596
|
+
} catch {
|
|
4597
|
+
return res.status(400).json(
|
|
4598
|
+
apiError(
|
|
4599
|
+
"invalid_request_error",
|
|
4600
|
+
"INVALID_JSON",
|
|
4601
|
+
"Webhook payload is not valid JSON."
|
|
4602
|
+
)
|
|
4603
|
+
);
|
|
4604
|
+
}
|
|
4605
|
+
if (!asString(event?.id) || !asString(event?.type)) {
|
|
4606
|
+
return res.status(400).json(
|
|
4607
|
+
apiError(
|
|
4608
|
+
"invalid_request_error",
|
|
4609
|
+
"INVALID_EVENT",
|
|
4610
|
+
"Webhook payload is missing event id or type."
|
|
4611
|
+
)
|
|
4612
|
+
);
|
|
4613
|
+
}
|
|
4614
|
+
try {
|
|
4615
|
+
const decision = await decideWebhookAction(event);
|
|
4616
|
+
if (decision.kind === "ignore") {
|
|
4617
|
+
return res.json({ received: true, handled: false });
|
|
4618
|
+
}
|
|
4619
|
+
if (decision.kind === "unresolved_user") {
|
|
4620
|
+
secureLogger.warn("Stripe webhook user unresolved \u2014 asking for retry", {
|
|
4621
|
+
eventType: event.type,
|
|
4622
|
+
note: decision.note
|
|
4623
|
+
});
|
|
4624
|
+
return res.status(500).json(
|
|
4625
|
+
apiError(
|
|
4626
|
+
"internal_error",
|
|
4627
|
+
"WEBHOOK_USER_UNRESOLVED",
|
|
4628
|
+
"Could not resolve the Hive user for this event yet; Stripe should retry."
|
|
4629
|
+
)
|
|
4630
|
+
);
|
|
4631
|
+
}
|
|
4632
|
+
const result = await applyPlanChange(decision.args);
|
|
4633
|
+
if (event.type === "checkout.session.completed") {
|
|
4634
|
+
await settleChargeRecord(
|
|
4635
|
+
asString(metadataOf(event.data?.object).id),
|
|
4636
|
+
"confirmed",
|
|
4637
|
+
event.type
|
|
4638
|
+
);
|
|
4639
|
+
}
|
|
4640
|
+
return res.json({
|
|
4641
|
+
received: true,
|
|
4642
|
+
handled: true,
|
|
4643
|
+
outcome: result.outcome,
|
|
4644
|
+
replayed: result.replayed
|
|
4645
|
+
});
|
|
4646
|
+
} catch (err) {
|
|
4647
|
+
secureLogger.error("Stripe webhook processing failed", err, {
|
|
4648
|
+
eventType: event.type
|
|
4649
|
+
});
|
|
4650
|
+
return res.status(500).json(
|
|
4651
|
+
apiError(
|
|
4652
|
+
"internal_error",
|
|
4653
|
+
"WEBHOOK_PROCESSING_FAILED",
|
|
4654
|
+
"Webhook processing failed; Stripe should retry. Processing is idempotent by event id."
|
|
4655
|
+
)
|
|
4656
|
+
);
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
);
|
|
4660
|
+
var LLAMAPAY_UNAVAILABLE = apiError(
|
|
4661
|
+
"internal_error",
|
|
4662
|
+
"BILLING_UNAVAILABLE",
|
|
4663
|
+
"Crypto checkout is not configured on this deployment. Next action: contact support@hiveintelligence.xyz to upgrade, or retry once billing is enabled."
|
|
4664
|
+
);
|
|
4665
|
+
var CryptoCheckoutSchema = z2.object({
|
|
4666
|
+
plan_id: z2.string().min(1).default(PAID_PLAN_ID),
|
|
4667
|
+
billing_cycle: z2.enum(["monthly", "yearly"]).default("monthly")
|
|
4668
|
+
});
|
|
4669
|
+
billingRouter.post(
|
|
4670
|
+
"/checkout",
|
|
4671
|
+
requireBillingPrincipal,
|
|
4672
|
+
withCheckoutLock,
|
|
4673
|
+
asyncHandler(async (req, res) => {
|
|
4674
|
+
const userId = req.billingUserId;
|
|
4675
|
+
if (!isLlamaPayCheckoutConfigured() || !isSupabaseConfigured()) {
|
|
4676
|
+
return res.status(503).json(LLAMAPAY_UNAVAILABLE);
|
|
4677
|
+
}
|
|
4678
|
+
const parsed = CryptoCheckoutSchema.safeParse(req.body ?? {});
|
|
4679
|
+
if (!parsed.success) {
|
|
4680
|
+
return res.status(400).json(
|
|
4681
|
+
apiError(
|
|
4682
|
+
"invalid_request_error",
|
|
4683
|
+
"VALIDATION_ERROR",
|
|
4684
|
+
'billing_cycle must be "monthly" or "yearly", and plan_id must be a sold plan.'
|
|
4685
|
+
)
|
|
4686
|
+
);
|
|
4687
|
+
}
|
|
4688
|
+
const { plan_id: planId, billing_cycle: cycle } = parsed.data;
|
|
4689
|
+
if (!isSellablePlan(planId)) {
|
|
4690
|
+
return res.status(400).json(
|
|
4691
|
+
apiError(
|
|
4692
|
+
"invalid_request_error",
|
|
4693
|
+
"PLAN_NOT_SELLABLE",
|
|
4694
|
+
`Plan "${planId}" is not sold self-serve. Next action: choose starter or ${PAID_PLAN_ID}, or contact support for Enterprise.`
|
|
4695
|
+
)
|
|
4696
|
+
);
|
|
4697
|
+
}
|
|
4698
|
+
const amountUsd = priceForPlanCycle(planId, cycle);
|
|
4699
|
+
if (amountUsd === void 0) {
|
|
4700
|
+
return res.status(503).json(LLAMAPAY_UNAVAILABLE);
|
|
4701
|
+
}
|
|
4702
|
+
const { data: profile } = await getSupabaseAdmin().from("user_profiles").select("plan_id, billing_subscription_id, billing_status").eq("id", userId).maybeSingle();
|
|
4703
|
+
const hasOccupyingSubscription = Boolean(profile?.billing_subscription_id) && typeof profile?.billing_status === "string" && OCCUPYING_BILLING_STATUSES.has(profile.billing_status);
|
|
4704
|
+
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();
|
|
4705
|
+
if (!canPurchasePlan(
|
|
4706
|
+
profile?.plan_id ?? null,
|
|
4707
|
+
planId,
|
|
4708
|
+
liveSubscription?.current_period_end ?? null
|
|
4709
|
+
)) {
|
|
4710
|
+
const sameTier = profile?.plan_id === planId;
|
|
4711
|
+
return res.status(409).json(
|
|
4712
|
+
apiError(
|
|
4713
|
+
"invalid_request_error",
|
|
4714
|
+
hasOccupyingSubscription ? "SUBSCRIPTION_EXISTS" : "PLAN_NOT_AN_UPGRADE",
|
|
4715
|
+
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.`
|
|
4716
|
+
)
|
|
4717
|
+
);
|
|
4718
|
+
}
|
|
4719
|
+
const openChargeId = await findOpenCharge(userId);
|
|
4720
|
+
if (openChargeId) {
|
|
4721
|
+
return res.status(409).json(checkoutAlreadyOpen(openChargeId));
|
|
4722
|
+
}
|
|
4723
|
+
try {
|
|
4724
|
+
const charge = await createLlamaPayCharge({
|
|
4725
|
+
userId,
|
|
4726
|
+
planId,
|
|
4727
|
+
cycle,
|
|
4728
|
+
amountUsd,
|
|
4729
|
+
email: req.billingUserEmail
|
|
4730
|
+
});
|
|
4731
|
+
await recordChargeCreated({
|
|
4732
|
+
chargeId: charge.id,
|
|
4733
|
+
processor: "llamapay",
|
|
4734
|
+
userId,
|
|
4735
|
+
planId,
|
|
4736
|
+
cycle,
|
|
4737
|
+
amountUsd,
|
|
4738
|
+
planIdBefore: asString(profile?.plan_id)
|
|
4739
|
+
});
|
|
4740
|
+
return res.json({
|
|
4741
|
+
ok: true,
|
|
4742
|
+
data: {
|
|
4743
|
+
url: charge.hostedUrl,
|
|
4744
|
+
charge_id: charge.id,
|
|
4745
|
+
plan_id: planId,
|
|
4746
|
+
billing_cycle: cycle,
|
|
4747
|
+
amount_usd: amountUsd,
|
|
4748
|
+
processor: "llamapay"
|
|
4749
|
+
}
|
|
4750
|
+
});
|
|
4751
|
+
} catch (err) {
|
|
4752
|
+
if (err instanceof LlamaPayApiError) {
|
|
4753
|
+
secureLogger.error("LlamaPay charge creation failed", err, {
|
|
4754
|
+
userId,
|
|
4755
|
+
status: err.status
|
|
4756
|
+
});
|
|
4757
|
+
return res.status(502).json(
|
|
4758
|
+
apiError(
|
|
4759
|
+
"provider_error",
|
|
4760
|
+
"BILLING_PROVIDER_ERROR",
|
|
4761
|
+
"LlamaPay rejected the checkout request. Next action: retry; if it persists, contact support@hiveintelligence.xyz."
|
|
4762
|
+
)
|
|
4763
|
+
);
|
|
4764
|
+
}
|
|
4765
|
+
throw err;
|
|
4766
|
+
}
|
|
4767
|
+
})
|
|
4768
|
+
);
|
|
4769
|
+
var AbandonSchema = z2.object({
|
|
4770
|
+
charge_id: z2.string().min(1).optional()
|
|
4771
|
+
});
|
|
4772
|
+
billingRouter.post(
|
|
4773
|
+
"/checkout/abandon",
|
|
4774
|
+
requireBillingPrincipal,
|
|
4775
|
+
asyncHandler(async (req, res) => {
|
|
4776
|
+
const userId = req.billingUserId;
|
|
4777
|
+
if (!isSupabaseConfigured()) {
|
|
4778
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4779
|
+
}
|
|
4780
|
+
const parsed = AbandonSchema.safeParse(req.body ?? {});
|
|
4781
|
+
if (!parsed.success) {
|
|
4782
|
+
return res.status(400).json(
|
|
4783
|
+
apiError(
|
|
4784
|
+
"invalid_request_error",
|
|
4785
|
+
"VALIDATION_ERROR",
|
|
4786
|
+
"charge_id must be a non-empty string when provided. Next action: omit it to release every checkout this account has open."
|
|
4787
|
+
)
|
|
4788
|
+
);
|
|
4789
|
+
}
|
|
4790
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4791
|
+
let query = getSupabaseAdmin().from("billing_charges").update({
|
|
4792
|
+
status: "canceled",
|
|
4793
|
+
last_event_at: now,
|
|
4794
|
+
last_event_type: ABANDONED_EVENT_TYPE
|
|
4795
|
+
}).eq("user_id", userId).eq("status", "created").is("settled_at", null).gte("created_at", openChargeCutoff());
|
|
4796
|
+
if (parsed.data.charge_id) {
|
|
4797
|
+
query = query.eq("charge_id", parsed.data.charge_id);
|
|
4798
|
+
}
|
|
4799
|
+
const { data, error } = await query.select("charge_id");
|
|
4800
|
+
if (error) {
|
|
4801
|
+
secureLogger.error("Failed to mark checkout abandoned", error, {
|
|
4802
|
+
userId
|
|
4803
|
+
});
|
|
4804
|
+
return res.status(500).json(
|
|
4805
|
+
apiError(
|
|
4806
|
+
"internal_error",
|
|
4807
|
+
"CHECKOUT_ABANDON_FAILED",
|
|
4808
|
+
`Could not release the open checkout. Next action: retry, or wait ${OPEN_CHARGE_WINDOW_MINUTES} minutes for it to clear on its own.`
|
|
4809
|
+
)
|
|
4810
|
+
);
|
|
4811
|
+
}
|
|
4812
|
+
const abandoned = (Array.isArray(data) ? data : []).map((row) => row.charge_id).filter((id) => typeof id === "string");
|
|
4813
|
+
return res.json({ ok: true, data: { abandoned } });
|
|
4814
|
+
})
|
|
4815
|
+
);
|
|
4816
|
+
billingRouter.get(
|
|
4817
|
+
"/charges",
|
|
4818
|
+
requireBillingPrincipal,
|
|
4819
|
+
asyncHandler(async (req, res) => {
|
|
4820
|
+
const userId = req.billingUserId;
|
|
4821
|
+
if (!isSupabaseConfigured()) {
|
|
4822
|
+
return res.status(503).json(BILLING_UNAVAILABLE);
|
|
4823
|
+
}
|
|
4824
|
+
const { data, error } = await getSupabaseAdmin().from("billing_charges").select(
|
|
4825
|
+
"charge_id, processor, plan_id, billing_cycle, amount_usd, status, created_at, settled_at"
|
|
4826
|
+
).eq("user_id", userId).order("created_at", { ascending: false }).limit(50);
|
|
4827
|
+
if (error) {
|
|
4828
|
+
secureLogger.error("Failed to read billing charges", error, { userId });
|
|
4829
|
+
return res.status(500).json(
|
|
4830
|
+
apiError(
|
|
4831
|
+
"internal_error",
|
|
4832
|
+
"BILLING_HISTORY_UNAVAILABLE",
|
|
4833
|
+
"Could not load your billing history. Next action: retry shortly, or contact support@hiveintelligence.xyz with the date of the charge."
|
|
4834
|
+
)
|
|
4835
|
+
);
|
|
3939
4836
|
}
|
|
4837
|
+
return res.json({ ok: true, data: { charges: data ?? [] } });
|
|
4838
|
+
})
|
|
4839
|
+
);
|
|
4840
|
+
var LLAMAPAY_EVENT_TYPES = [
|
|
4841
|
+
"charge:pending",
|
|
4842
|
+
"charge:confirmed",
|
|
4843
|
+
"subscription:expired"
|
|
4844
|
+
];
|
|
4845
|
+
function localChargeAmountUsd(pricing) {
|
|
4846
|
+
const local = metadataOf(metadataOf(pricing).local);
|
|
4847
|
+
const currency = asString(local.currency);
|
|
4848
|
+
if (currency !== null && currency.toUpperCase() !== "USD") {
|
|
4849
|
+
return Number.NaN;
|
|
4850
|
+
}
|
|
4851
|
+
const raw = local.amount;
|
|
4852
|
+
const amount = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseFloat(raw) : void 0;
|
|
4853
|
+
if (amount === void 0 || !Number.isFinite(amount)) return void 0;
|
|
4854
|
+
return amount;
|
|
4855
|
+
}
|
|
4856
|
+
function decideLlamaPayAction(event) {
|
|
4857
|
+
const data = metadataOf(event.data);
|
|
4858
|
+
const metadata = metadataOf(data.metadata);
|
|
4859
|
+
const userId = asString(metadata.user_id);
|
|
4860
|
+
const chargeId = asString(data.id);
|
|
4861
|
+
if (!userId) {
|
|
4862
|
+
return { kind: "ignore", note: "charge without user_id metadata" };
|
|
3940
4863
|
}
|
|
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
4864
|
switch (event.type) {
|
|
3955
|
-
case "
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
const
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
4865
|
+
case "charge:pending":
|
|
4866
|
+
case "charge:confirmed": {
|
|
4867
|
+
const planId = asString(metadata.plan_id) ?? PAID_PLAN_ID;
|
|
4868
|
+
const cycle = metadata.billing_cycle === "yearly" ? "yearly" : "monthly";
|
|
4869
|
+
const expected = priceForPlanCycle(planId, cycle);
|
|
4870
|
+
const paid = localChargeAmountUsd(data.pricing);
|
|
4871
|
+
if (expected !== void 0 && paid !== void 0 && paid !== expected) {
|
|
4872
|
+
return {
|
|
4873
|
+
kind: "ignore",
|
|
4874
|
+
note: `charge ${chargeId ?? "<no id>"} paid ${Number.isNaN(paid) ? "a non-USD amount" : paid} against ${expected} for ${planId}/${cycle} \u2014 entitlement withheld`
|
|
4875
|
+
};
|
|
3964
4876
|
}
|
|
4877
|
+
const start = event.created_at ? new Date(event.created_at) : /* @__PURE__ */ new Date();
|
|
4878
|
+
const cycleStart = Number.isNaN(start.getTime()) ? /* @__PURE__ */ new Date() : start;
|
|
3965
4879
|
return {
|
|
3966
4880
|
kind: "apply",
|
|
3967
4881
|
args: {
|
|
3968
4882
|
eventId: event.id,
|
|
3969
4883
|
eventType: event.type,
|
|
3970
4884
|
userId,
|
|
3971
|
-
newPlanId:
|
|
4885
|
+
newPlanId: planId,
|
|
3972
4886
|
grantCredits: true,
|
|
4887
|
+
cycleStart: cycleStart.toISOString(),
|
|
4888
|
+
// Two clocks, deliberately. Credits are sold monthly on every term,
|
|
4889
|
+
// so the wallet cycle is always a month; the term is what the charge
|
|
4890
|
+
// actually bought. Passing the annual date as the credit cycle gave
|
|
4891
|
+
// a $1,490 buyer one month's credits to last a year.
|
|
4892
|
+
cycleEnd: creditCycleEndFrom(cycleStart).toISOString(),
|
|
4893
|
+
termEnd: termEndFrom(cycleStart, cycle).toISOString(),
|
|
3973
4894
|
billingStatus: "active",
|
|
3974
|
-
|
|
3975
|
-
subscriptionId: refId(object.subscription),
|
|
4895
|
+
subscriptionId: chargeId,
|
|
3976
4896
|
subscriptionStatus: "active",
|
|
3977
4897
|
ledgerReason: "plan_change"
|
|
3978
4898
|
}
|
|
3979
4899
|
};
|
|
3980
4900
|
}
|
|
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);
|
|
4901
|
+
case "subscription:expired": {
|
|
4015
4902
|
return {
|
|
4016
4903
|
kind: "apply",
|
|
4017
4904
|
args: {
|
|
4018
4905
|
eventId: event.id,
|
|
4019
4906
|
eventType: event.type,
|
|
4020
4907
|
userId,
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
customerId,
|
|
4027
|
-
subscriptionId: refId(object.id),
|
|
4028
|
-
subscriptionStatus: status,
|
|
4029
|
-
priceId,
|
|
4908
|
+
newPlanId: FREE_PLAN_ID,
|
|
4909
|
+
grantCredits: true,
|
|
4910
|
+
billingStatus: "canceled",
|
|
4911
|
+
subscriptionId: chargeId,
|
|
4912
|
+
subscriptionStatus: "canceled",
|
|
4030
4913
|
ledgerReason: "plan_change"
|
|
4031
4914
|
}
|
|
4032
4915
|
};
|
|
4033
4916
|
}
|
|
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);
|
|
4917
|
+
default:
|
|
4063
4918
|
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
|
-
}
|
|
4919
|
+
kind: "ignore",
|
|
4920
|
+
note: `unhandled event type ${event.type} (known: ${LLAMAPAY_EVENT_TYPES.join(", ")})`
|
|
4079
4921
|
};
|
|
4080
|
-
}
|
|
4081
|
-
default:
|
|
4082
|
-
return { kind: "ignore", note: `unhandled event type ${event.type}` };
|
|
4083
4922
|
}
|
|
4084
4923
|
}
|
|
4085
|
-
var
|
|
4924
|
+
var llamapayWebhookHandler = asyncHandler(
|
|
4086
4925
|
async (req, res) => {
|
|
4087
|
-
const secret =
|
|
4926
|
+
const secret = getLlamaPayWebhookSecret();
|
|
4088
4927
|
if (!secret) {
|
|
4089
|
-
return res.status(503).json(
|
|
4928
|
+
return res.status(503).json(LLAMAPAY_UNAVAILABLE);
|
|
4090
4929
|
}
|
|
4091
4930
|
const raw = req.body;
|
|
4092
4931
|
if (!Buffer.isBuffer(raw)) {
|
|
4093
4932
|
secureLogger.error(
|
|
4094
|
-
"
|
|
4933
|
+
"LlamaPay webhook received a non-raw body \u2014 express.raw must mount before the JSON parser",
|
|
4095
4934
|
void 0,
|
|
4096
4935
|
{ path: req.path }
|
|
4097
4936
|
);
|
|
@@ -4103,26 +4942,26 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4103
4942
|
)
|
|
4104
4943
|
);
|
|
4105
4944
|
}
|
|
4106
|
-
const verification =
|
|
4945
|
+
const verification = verifyLlamaPaySignature({
|
|
4107
4946
|
payload: raw,
|
|
4108
|
-
header: req.headers[
|
|
4947
|
+
header: req.headers[LLAMAPAY_SIGNATURE_HEADER],
|
|
4109
4948
|
secret
|
|
4110
4949
|
});
|
|
4111
4950
|
if (!verification.valid) {
|
|
4112
|
-
secureLogger.warn("
|
|
4951
|
+
secureLogger.warn("LlamaPay webhook signature rejected", {
|
|
4113
4952
|
reason: verification.reason
|
|
4114
4953
|
});
|
|
4115
4954
|
return res.status(400).json(
|
|
4116
4955
|
apiError(
|
|
4117
4956
|
"authentication_error",
|
|
4118
4957
|
"INVALID_WEBHOOK_SIGNATURE",
|
|
4119
|
-
`
|
|
4958
|
+
`LlamaPay signature verification failed (${verification.reason}).`
|
|
4120
4959
|
)
|
|
4121
4960
|
);
|
|
4122
4961
|
}
|
|
4123
|
-
let
|
|
4962
|
+
let envelope;
|
|
4124
4963
|
try {
|
|
4125
|
-
|
|
4964
|
+
envelope = JSON.parse(raw.toString("utf8"));
|
|
4126
4965
|
} catch {
|
|
4127
4966
|
return res.status(400).json(
|
|
4128
4967
|
apiError(
|
|
@@ -4132,6 +4971,7 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4132
4971
|
)
|
|
4133
4972
|
);
|
|
4134
4973
|
}
|
|
4974
|
+
const event = envelope.event ?? envelope;
|
|
4135
4975
|
if (!asString(event?.id) || !asString(event?.type)) {
|
|
4136
4976
|
return res.status(400).json(
|
|
4137
4977
|
apiError(
|
|
@@ -4142,20 +4982,20 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4142
4982
|
);
|
|
4143
4983
|
}
|
|
4144
4984
|
try {
|
|
4145
|
-
const decision =
|
|
4985
|
+
const decision = decideLlamaPayAction(event);
|
|
4146
4986
|
if (decision.kind === "ignore") {
|
|
4147
|
-
|
|
4148
|
-
}
|
|
4149
|
-
if (decision.kind === "unresolved_user") {
|
|
4150
|
-
secureLogger.warn("Stripe webhook user unresolved \u2014 asking for retry", {
|
|
4987
|
+
secureLogger.warn("LlamaPay webhook ignored", {
|
|
4151
4988
|
eventType: event.type,
|
|
4152
4989
|
note: decision.note
|
|
4153
4990
|
});
|
|
4991
|
+
return res.json({ received: true, handled: false });
|
|
4992
|
+
}
|
|
4993
|
+
if (decision.kind === "unresolved_user") {
|
|
4154
4994
|
return res.status(500).json(
|
|
4155
4995
|
apiError(
|
|
4156
4996
|
"internal_error",
|
|
4157
4997
|
"WEBHOOK_USER_UNRESOLVED",
|
|
4158
|
-
"Could not resolve the Hive user for this event yet;
|
|
4998
|
+
"Could not resolve the Hive user for this event yet; LlamaPay should retry."
|
|
4159
4999
|
)
|
|
4160
5000
|
);
|
|
4161
5001
|
}
|
|
@@ -4167,14 +5007,14 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4167
5007
|
replayed: result.replayed
|
|
4168
5008
|
});
|
|
4169
5009
|
} catch (err) {
|
|
4170
|
-
secureLogger.error("
|
|
5010
|
+
secureLogger.error("LlamaPay webhook processing failed", err, {
|
|
4171
5011
|
eventType: event.type
|
|
4172
5012
|
});
|
|
4173
5013
|
return res.status(500).json(
|
|
4174
5014
|
apiError(
|
|
4175
5015
|
"internal_error",
|
|
4176
5016
|
"WEBHOOK_PROCESSING_FAILED",
|
|
4177
|
-
"Webhook processing failed;
|
|
5017
|
+
"Webhook processing failed; LlamaPay should retry. Processing is idempotent by event id."
|
|
4178
5018
|
)
|
|
4179
5019
|
);
|
|
4180
5020
|
}
|
|
@@ -4183,20 +5023,39 @@ var stripeWebhookHandler = asyncHandler(
|
|
|
4183
5023
|
|
|
4184
5024
|
// src/middleware/request-id.ts
|
|
4185
5025
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
5026
|
+
function isUnstampedApiError(body) {
|
|
5027
|
+
if (typeof body !== "object" || body === null) return false;
|
|
5028
|
+
const candidate = body;
|
|
5029
|
+
if (candidate.ok !== false) return false;
|
|
5030
|
+
if (typeof candidate.error !== "object" || candidate.error === null) {
|
|
5031
|
+
return false;
|
|
5032
|
+
}
|
|
5033
|
+
return !("request_id" in candidate.error);
|
|
5034
|
+
}
|
|
4186
5035
|
function requestIdMiddleware(req, res, next) {
|
|
4187
5036
|
const id = req.headers["x-request-id"] || `req_${randomUUID2().replace(/-/g, "")}`;
|
|
4188
5037
|
req.requestId = id;
|
|
4189
5038
|
res.setHeader("x-request-id", id);
|
|
5039
|
+
const json = res.json.bind(res);
|
|
5040
|
+
res.json = (body) => {
|
|
5041
|
+
if (isUnstampedApiError(body)) {
|
|
5042
|
+
body.error.request_id = id;
|
|
5043
|
+
}
|
|
5044
|
+
return json(body);
|
|
5045
|
+
};
|
|
4190
5046
|
next();
|
|
4191
5047
|
}
|
|
4192
5048
|
|
|
4193
5049
|
// src/middleware/rate-limit-headers.ts
|
|
4194
5050
|
function rateLimitInfoHeaders(opts) {
|
|
4195
5051
|
const windowSec = Math.round(opts.windowMs / 1e3);
|
|
4196
|
-
return (
|
|
5052
|
+
return (req, res, next) => {
|
|
4197
5053
|
if (!res.getHeader("RateLimit-Limit")) {
|
|
4198
|
-
|
|
4199
|
-
|
|
5054
|
+
const headers = req.headers ?? {};
|
|
5055
|
+
const hasCredential2 = typeof headers["x-api-key"] === "string" || typeof headers.authorization === "string" && headers.authorization.startsWith("Bearer ");
|
|
5056
|
+
const limit = hasCredential2 && opts.credentialedLimit !== void 0 ? opts.credentialedLimit : opts.limit;
|
|
5057
|
+
res.setHeader("RateLimit-Limit", String(limit));
|
|
5058
|
+
res.setHeader("RateLimit-Policy", `${limit};w=${windowSec}`);
|
|
4200
5059
|
}
|
|
4201
5060
|
next();
|
|
4202
5061
|
};
|
|
@@ -4600,12 +5459,13 @@ import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
|
|
|
4600
5459
|
|
|
4601
5460
|
// src/services/anon-quota.ts
|
|
4602
5461
|
import { createHash as createHash4 } from "crypto";
|
|
4603
|
-
import { z as
|
|
4604
|
-
var ANON_QUOTA_EXHAUSTED_MESSAGE = "Anonymous free quota reached (resets 00:00 UTC). For continued access set HIVE_API_KEY
|
|
4605
|
-
var
|
|
5462
|
+
import { z as z3 } from "zod";
|
|
5463
|
+
var ANON_QUOTA_EXHAUSTED_MESSAGE = "Anonymous free quota reached (resets 00:00 UTC). For continued access set HIVE_API_KEY (create one free at https://hiveintelligence.xyz/dashboard/keys) or sign in via OAuth.";
|
|
5464
|
+
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 (create one free at https://hiveintelligence.xyz/dashboard/keys) or sign in via OAuth.";
|
|
5465
|
+
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 (create one free at https://hiveintelligence.xyz/dashboard/keys) for keyed access now.";
|
|
4606
5466
|
var ANON_IP_HASH_DOMAIN = "hive-anon-lane-v1";
|
|
4607
|
-
var capSchema =
|
|
4608
|
-
var globalCapSchema =
|
|
5467
|
+
var capSchema = z3.coerce.number().int().min(1).max(1e4);
|
|
5468
|
+
var globalCapSchema = z3.coerce.number().int().min(1).max(1e6);
|
|
4609
5469
|
function isAnonLaneEnabled() {
|
|
4610
5470
|
return getEnvVar("ANON_LANE_ENABLED")?.toLowerCase() !== "false";
|
|
4611
5471
|
}
|
|
@@ -4620,6 +5480,27 @@ function getAnonGlobalDailyCap() {
|
|
|
4620
5480
|
function anonymousIpHash(ipKey) {
|
|
4621
5481
|
return createHash4("sha256").update(`${ANON_IP_HASH_DOMAIN}\0${ipKey}`).digest("hex");
|
|
4622
5482
|
}
|
|
5483
|
+
var GLOBAL_CAP_LOG_INTERVAL_MS = 6e4;
|
|
5484
|
+
var lastGlobalCapLoggedAt = 0;
|
|
5485
|
+
var globalCapRejections = 0;
|
|
5486
|
+
var globalCapRejectionsAtLastLog = 0;
|
|
5487
|
+
function noteGlobalCapTripped(limit, resetSeconds) {
|
|
5488
|
+
globalCapRejections += 1;
|
|
5489
|
+
const now = Date.now();
|
|
5490
|
+
if (now - lastGlobalCapLoggedAt < GLOBAL_CAP_LOG_INTERVAL_MS) return;
|
|
5491
|
+
lastGlobalCapLoggedAt = now;
|
|
5492
|
+
const rejected = globalCapRejections - globalCapRejectionsAtLastLog;
|
|
5493
|
+
globalCapRejectionsAtLastLog = globalCapRejections;
|
|
5494
|
+
secureLogger.error(
|
|
5495
|
+
"[anon-lane] GLOBAL daily breaker tripped \u2014 every anonymous caller is blocked until the UTC day rolls",
|
|
5496
|
+
void 0,
|
|
5497
|
+
{
|
|
5498
|
+
globalDailyCap: limit,
|
|
5499
|
+
resetSeconds,
|
|
5500
|
+
rejectionsSinceLastLog: rejected
|
|
5501
|
+
}
|
|
5502
|
+
);
|
|
5503
|
+
}
|
|
4623
5504
|
async function defaultConsume(args) {
|
|
4624
5505
|
const { data, error } = await getSupabaseAdmin().rpc(
|
|
4625
5506
|
"consume_anon_daily_quota",
|
|
@@ -4660,11 +5541,12 @@ async function consumeAnonMaterialQuota(ipHash, overrides = {}) {
|
|
|
4660
5541
|
resetSeconds: Math.max(1, row.reset_seconds ?? 1)
|
|
4661
5542
|
};
|
|
4662
5543
|
}
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
}
|
|
5544
|
+
const resetSeconds = Math.max(1, row.reset_seconds ?? 1);
|
|
5545
|
+
if (row.scope === "global") {
|
|
5546
|
+
noteGlobalCapTripped(getAnonGlobalDailyCap(), resetSeconds);
|
|
5547
|
+
return { allowed: false, reason: "global_cap", resetSeconds };
|
|
5548
|
+
}
|
|
5549
|
+
return { allowed: false, reason: "ip_cap", resetSeconds };
|
|
4668
5550
|
} catch (err) {
|
|
4669
5551
|
secureLogger.warn("Anonymous quota backend unavailable; failing closed", {
|
|
4670
5552
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -4693,7 +5575,7 @@ function buildServerCard() {
|
|
|
4693
5575
|
email: "support@hiveintelligence.xyz"
|
|
4694
5576
|
},
|
|
4695
5577
|
homepage: "https://www.hiveintelligence.xyz",
|
|
4696
|
-
documentation: "https://
|
|
5578
|
+
documentation: "https://docs.hiveintelligence.xyz/concepts/how-it-works",
|
|
4697
5579
|
repository: {
|
|
4698
5580
|
type: "git",
|
|
4699
5581
|
url: "https://github.com/hive-intel/hive-sdk"
|
|
@@ -4809,7 +5691,7 @@ function buildServerCard() {
|
|
|
4809
5691
|
freeTier: {
|
|
4810
5692
|
monthlyCredits: 1e4,
|
|
4811
5693
|
requestsPerMinute: 30,
|
|
4812
|
-
description: "Demo tier
|
|
5694
|
+
description: "Demo tier, no credit card required"
|
|
4813
5695
|
}
|
|
4814
5696
|
},
|
|
4815
5697
|
authCompatibility,
|
|
@@ -4926,33 +5808,33 @@ function buildServerCard() {
|
|
|
4926
5808
|
}
|
|
4927
5809
|
|
|
4928
5810
|
// src/auth/oauth/router.ts
|
|
4929
|
-
import { timingSafeEqual as
|
|
5811
|
+
import { timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
4930
5812
|
import express from "express";
|
|
4931
|
-
import
|
|
4932
|
-
import
|
|
4933
|
-
import { z as
|
|
5813
|
+
import rateLimit2 from "express-rate-limit";
|
|
5814
|
+
import RedisStore2 from "rate-limit-redis";
|
|
5815
|
+
import { z as z4 } from "zod";
|
|
4934
5816
|
import {
|
|
4935
5817
|
createOAuthMetadata,
|
|
4936
5818
|
getOAuthProtectedResourceMetadataUrl,
|
|
4937
5819
|
mcpAuthRouter
|
|
4938
5820
|
} from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
4939
5821
|
import { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
4940
|
-
var consentDecisionSchema =
|
|
4941
|
-
approved:
|
|
4942
|
-
scopes:
|
|
5822
|
+
var consentDecisionSchema = z4.object({
|
|
5823
|
+
approved: z4.boolean(),
|
|
5824
|
+
scopes: z4.array(z4.enum(OAUTH_SCOPES)).max(OAUTH_SCOPES.length).optional()
|
|
4943
5825
|
}).strict();
|
|
4944
|
-
var consentRequestIdSchema =
|
|
4945
|
-
var connectedAppDisconnectSchema =
|
|
4946
|
-
client_id:
|
|
5826
|
+
var consentRequestIdSchema = z4.string().min(40).max(100).regex(/^hvoq_[A-Za-z0-9_-]+$/);
|
|
5827
|
+
var connectedAppDisconnectSchema = z4.object({
|
|
5828
|
+
client_id: z4.string().min(1).max(2048).refine((value) => value.trim() === value, {
|
|
4947
5829
|
message: "client_id must not contain leading or trailing whitespace."
|
|
4948
5830
|
})
|
|
4949
5831
|
}).strict();
|
|
4950
|
-
var deploymentCanaryClientIdSchema =
|
|
4951
|
-
var deploymentCanaryConsentSchema =
|
|
5832
|
+
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);
|
|
5833
|
+
var deploymentCanaryConsentSchema = z4.object({
|
|
4952
5834
|
request_id: consentRequestIdSchema,
|
|
4953
5835
|
client_id: deploymentCanaryClientIdSchema
|
|
4954
5836
|
}).strict();
|
|
4955
|
-
var deploymentCanaryDisconnectSchema =
|
|
5837
|
+
var deploymentCanaryDisconnectSchema = z4.object({ client_id: deploymentCanaryClientIdSchema }).strict();
|
|
4956
5838
|
var OAUTH_RATE_LIMITS = {
|
|
4957
5839
|
authorize: { windowMs: 15 * 60 * 1e3, max: 300 },
|
|
4958
5840
|
token: { windowMs: 15 * 60 * 1e3, max: 3e3 },
|
|
@@ -4971,7 +5853,7 @@ function oauthRateLimitOptions(prefix) {
|
|
|
4971
5853
|
const redis = getRedisClient();
|
|
4972
5854
|
return {
|
|
4973
5855
|
...redis ? {
|
|
4974
|
-
store: new
|
|
5856
|
+
store: new RedisStore2({
|
|
4975
5857
|
sendCommand: ((...args) => redis.call(
|
|
4976
5858
|
args[0],
|
|
4977
5859
|
...args.slice(1)
|
|
@@ -4988,11 +5870,11 @@ function monitorSecretAuthorized(req) {
|
|
|
4988
5870
|
if (!expected || typeof supplied !== "string") return false;
|
|
4989
5871
|
const left = Buffer.from(supplied);
|
|
4990
5872
|
const right = Buffer.from(expected);
|
|
4991
|
-
return left.length === right.length &&
|
|
5873
|
+
return left.length === right.length && timingSafeEqual5(left, right);
|
|
4992
5874
|
}
|
|
4993
5875
|
function deploymentCanaryUserId() {
|
|
4994
5876
|
const userId = getEnvVar("HIVE_CANARY_USER_ID");
|
|
4995
|
-
return userId &&
|
|
5877
|
+
return userId && z4.string().uuid().safeParse(userId).success ? userId : void 0;
|
|
4996
5878
|
}
|
|
4997
5879
|
async function verifyDeploymentCanaryUser(userId) {
|
|
4998
5880
|
const { data, error } = await getSupabaseAdmin().auth.admin.getUserById(userId);
|
|
@@ -5010,7 +5892,7 @@ function createMcpOAuthRuntime(options = {}) {
|
|
|
5010
5892
|
{ fetcher: options.fetcher }
|
|
5011
5893
|
);
|
|
5012
5894
|
const serviceDocumentationUrl = new URL(
|
|
5013
|
-
"https://
|
|
5895
|
+
"https://docs.hiveintelligence.xyz/concepts/how-it-works"
|
|
5014
5896
|
);
|
|
5015
5897
|
const oauthMetadata = {
|
|
5016
5898
|
...createOAuthMetadata({
|
|
@@ -5081,7 +5963,7 @@ function createMcpOAuthRuntime(options = {}) {
|
|
|
5081
5963
|
res.json(resourceMetadata);
|
|
5082
5964
|
}
|
|
5083
5965
|
);
|
|
5084
|
-
const consentRateLimit =
|
|
5966
|
+
const consentRateLimit = rateLimit2({
|
|
5085
5967
|
...oauthRateLimitOptions("rl:oauth:consent:"),
|
|
5086
5968
|
...OAUTH_RATE_LIMITS.account,
|
|
5087
5969
|
standardHeaders: true,
|
|
@@ -5107,7 +5989,7 @@ function createMcpOAuthRuntime(options = {}) {
|
|
|
5107
5989
|
).href;
|
|
5108
5990
|
const deploymentCanaryJsonParser = express.json({ limit: "8kb" });
|
|
5109
5991
|
const deploymentCanaryUserVerifier = options.deploymentCanaryUserVerifier ?? verifyDeploymentCanaryUser;
|
|
5110
|
-
const deploymentCanaryRateLimit =
|
|
5992
|
+
const deploymentCanaryRateLimit = rateLimit2({
|
|
5111
5993
|
...oauthRateLimitOptions("rl:oauth:deployment-canary:"),
|
|
5112
5994
|
...OAUTH_RATE_LIMITS.deploymentCanary,
|
|
5113
5995
|
standardHeaders: true,
|
|
@@ -5723,7 +6605,7 @@ var port = getPort();
|
|
|
5723
6605
|
function createHttpApp() {
|
|
5724
6606
|
return app;
|
|
5725
6607
|
}
|
|
5726
|
-
app.set("trust proxy",
|
|
6608
|
+
app.set("trust proxy", resolveTrustedProxies());
|
|
5727
6609
|
app.use(requestIdMiddleware);
|
|
5728
6610
|
app.use((_req, res, next) => {
|
|
5729
6611
|
if (isShuttingDown) {
|
|
@@ -5739,10 +6621,12 @@ app.use((_req, res, next) => {
|
|
|
5739
6621
|
return next();
|
|
5740
6622
|
});
|
|
5741
6623
|
var RATE_LIMIT_MAX = getRateLimitMax();
|
|
6624
|
+
var CREDENTIALED_RATE_LIMIT_MAX = getCredentialedRateLimitMax();
|
|
5742
6625
|
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
5743
6626
|
app.use(
|
|
5744
6627
|
rateLimitInfoHeaders({
|
|
5745
6628
|
limit: RATE_LIMIT_MAX,
|
|
6629
|
+
credentialedLimit: CREDENTIALED_RATE_LIMIT_MAX,
|
|
5746
6630
|
windowMs: RATE_LIMIT_WINDOW_MS
|
|
5747
6631
|
})
|
|
5748
6632
|
);
|
|
@@ -5829,6 +6713,11 @@ app.post(
|
|
|
5829
6713
|
express2.raw({ type: "application/json", limit: "1mb" }),
|
|
5830
6714
|
stripeWebhookHandler
|
|
5831
6715
|
);
|
|
6716
|
+
app.post(
|
|
6717
|
+
"/api/v1/billing/webhook/llamapay",
|
|
6718
|
+
express2.raw({ type: "application/json", limit: "1mb" }),
|
|
6719
|
+
llamapayWebhookHandler
|
|
6720
|
+
);
|
|
5832
6721
|
app.use(
|
|
5833
6722
|
express2.json({
|
|
5834
6723
|
limit: "1mb",
|
|
@@ -5982,10 +6871,20 @@ function pingHandler(_req, res) {
|
|
|
5982
6871
|
}
|
|
5983
6872
|
app.get("/ping", pingHandler);
|
|
5984
6873
|
app.get("/health", pingHandler);
|
|
5985
|
-
var mcpIpRateLimiter =
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
6874
|
+
var mcpIpRateLimiter = [
|
|
6875
|
+
createIpRateLimiter({ lane: "anonymous" }),
|
|
6876
|
+
createIpRateLimiter({ lane: "credentialed" })
|
|
6877
|
+
];
|
|
6878
|
+
var restIpRateLimiter = [
|
|
6879
|
+
createIpRateLimiter({ lane: "anonymous" }),
|
|
6880
|
+
createIpRateLimiter({ lane: "credentialed" })
|
|
6881
|
+
];
|
|
6882
|
+
var authIpRateLimiter = [
|
|
6883
|
+
createIpRateLimiter({ lane: "anonymous" }),
|
|
6884
|
+
createIpRateLimiter({ lane: "credentialed" })
|
|
6885
|
+
];
|
|
6886
|
+
var healthRateLimiter = createLocalRateLimiter();
|
|
6887
|
+
app.use(["/status", "/api/health"], healthRateLimiter);
|
|
5989
6888
|
app.use(health_default);
|
|
5990
6889
|
app.use("/auth", authIpRateLimiter);
|
|
5991
6890
|
app.use("/api/v1", restIpRateLimiter);
|
|
@@ -5994,7 +6893,7 @@ app.use("/api/v1/keys", keys_default);
|
|
|
5994
6893
|
app.use("/api/v1/usage", usage_default);
|
|
5995
6894
|
app.use("/api/v1/b2b", b2b_default);
|
|
5996
6895
|
app.use("/api/v1/billing", billingRouter);
|
|
5997
|
-
var mcpMiddlewares = [mcpIpRateLimiter];
|
|
6896
|
+
var mcpMiddlewares = [...mcpIpRateLimiter];
|
|
5998
6897
|
function withTimeout(p, ms) {
|
|
5999
6898
|
return new Promise((resolve2, reject) => {
|
|
6000
6899
|
const timer = setTimeout(() => {
|
|
@@ -6012,6 +6911,16 @@ function withTimeout(p, ms) {
|
|
|
6012
6911
|
);
|
|
6013
6912
|
});
|
|
6014
6913
|
}
|
|
6914
|
+
var PRICING_URL = "https://www.hiveintelligence.xyz/pricing";
|
|
6915
|
+
function buildMcpQuotaExhaustedCopy(args) {
|
|
6916
|
+
const plan = args.planId ? `${args.planId} plan` : "current plan";
|
|
6917
|
+
const reset = args.cycleEnd ? `Credits refill at ${args.cycleEnd}.` : "Credits refill at the start of the next billing cycle.";
|
|
6918
|
+
return {
|
|
6919
|
+
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.`,
|
|
6920
|
+
cause: `The ${plan} has no monthly credits left, so this material call cannot be debited.`,
|
|
6921
|
+
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.`
|
|
6922
|
+
};
|
|
6923
|
+
}
|
|
6015
6924
|
async function debitMcpCredits(args) {
|
|
6016
6925
|
try {
|
|
6017
6926
|
const debit = await consumeCredits({
|
|
@@ -6025,13 +6934,19 @@ async function debitMcpCredits(args) {
|
|
|
6025
6934
|
return {
|
|
6026
6935
|
ok: false,
|
|
6027
6936
|
noWallet: true,
|
|
6028
|
-
error: "This account has no credit wallet provisioned. This is an account setup issue, not a quota limit
|
|
6937
|
+
error: "This account has no credit wallet provisioned. This is an account setup issue, not a quota limit. Visit https://hiveintelligence.xyz/dashboard to initialize billing or contact support."
|
|
6029
6938
|
};
|
|
6030
6939
|
}
|
|
6031
6940
|
if (debit.code === "insufficient") {
|
|
6941
|
+
const copy = buildMcpQuotaExhaustedCopy({
|
|
6942
|
+
planId: args.planId,
|
|
6943
|
+
cycleEnd: debit.cycleEnd
|
|
6944
|
+
});
|
|
6032
6945
|
return {
|
|
6033
6946
|
ok: false,
|
|
6034
|
-
error:
|
|
6947
|
+
error: copy.message,
|
|
6948
|
+
cause: copy.cause,
|
|
6949
|
+
next_action: copy.next_action,
|
|
6035
6950
|
rateLimited: true
|
|
6036
6951
|
};
|
|
6037
6952
|
}
|
|
@@ -6250,6 +7165,16 @@ function getMcpHiveToolName(body) {
|
|
|
6250
7165
|
}
|
|
6251
7166
|
return toolName.startsWith("hive_") ? toolName : void 0;
|
|
6252
7167
|
}
|
|
7168
|
+
function resolveMcpClientName(req, body) {
|
|
7169
|
+
const fromBody = body?.params?.clientInfo?.name;
|
|
7170
|
+
if (typeof fromBody === "string" && fromBody.trim()) return fromBody.trim();
|
|
7171
|
+
const header = req.headers["x-hive-client"];
|
|
7172
|
+
if (typeof header === "string" && header.trim()) return header.trim();
|
|
7173
|
+
const userAgent = req.headers["user-agent"];
|
|
7174
|
+
if (typeof userAgent !== "string") return void 0;
|
|
7175
|
+
const product = userAgent.trim().split(/[\s\/]/, 1)[0];
|
|
7176
|
+
return product || void 0;
|
|
7177
|
+
}
|
|
6253
7178
|
function mcpCredentialPresented(req) {
|
|
6254
7179
|
return typeof req.headers["x-api-key"] === "string" || Boolean(req.headers.authorization?.startsWith("Bearer "));
|
|
6255
7180
|
}
|
|
@@ -6288,7 +7213,11 @@ async function authorizeAnonymousMcpRequest(req) {
|
|
|
6288
7213
|
};
|
|
6289
7214
|
}
|
|
6290
7215
|
if (quota.reason === "unavailable") return { kind: "quota_unavailable" };
|
|
6291
|
-
return {
|
|
7216
|
+
return {
|
|
7217
|
+
kind: "quota_exhausted",
|
|
7218
|
+
scope: quota.reason === "global_cap" ? "global" : "ip",
|
|
7219
|
+
resetSeconds: quota.resetSeconds
|
|
7220
|
+
};
|
|
6292
7221
|
}
|
|
6293
7222
|
function isMonitorSecretRequest(req) {
|
|
6294
7223
|
const secret = getEnvVar("MONITOR_SECRET");
|
|
@@ -6296,7 +7225,7 @@ function isMonitorSecretRequest(req) {
|
|
|
6296
7225
|
if (!secret || typeof provided !== "string") return false;
|
|
6297
7226
|
const a = Buffer.from(provided);
|
|
6298
7227
|
const b = Buffer.from(secret);
|
|
6299
|
-
return a.length === b.length &&
|
|
7228
|
+
return a.length === b.length && timingSafeEqual6(a, b);
|
|
6300
7229
|
}
|
|
6301
7230
|
function getValidatedMcpHiveToolName(scope, body) {
|
|
6302
7231
|
if (body?.method !== "tools/call") return void 0;
|
|
@@ -6482,6 +7411,7 @@ function createStatelessMcpHandler(scope) {
|
|
|
6482
7411
|
}
|
|
6483
7412
|
} else if (decision.kind === "quota_exhausted") {
|
|
6484
7413
|
res.setHeader("Retry-After", String(decision.resetSeconds));
|
|
7414
|
+
const exhaustedMessage = decision.scope === "global" ? ANON_GLOBAL_CAP_MESSAGE : ANON_QUOTA_EXHAUSTED_MESSAGE;
|
|
6485
7415
|
if (mcpOAuth.status === "ready") {
|
|
6486
7416
|
const requiredScopes = getRequiredMcpScopes(req.body);
|
|
6487
7417
|
res.setHeader(
|
|
@@ -6493,7 +7423,7 @@ function createStatelessMcpHandler(scope) {
|
|
|
6493
7423
|
);
|
|
6494
7424
|
res.status(401).json({
|
|
6495
7425
|
error: "authorization_required",
|
|
6496
|
-
error_description:
|
|
7426
|
+
error_description: exhaustedMessage
|
|
6497
7427
|
});
|
|
6498
7428
|
return;
|
|
6499
7429
|
}
|
|
@@ -6501,9 +7431,9 @@ function createStatelessMcpHandler(scope) {
|
|
|
6501
7431
|
jsonrpc: "2.0",
|
|
6502
7432
|
error: {
|
|
6503
7433
|
code: -32001,
|
|
6504
|
-
message:
|
|
7434
|
+
message: exhaustedMessage,
|
|
6505
7435
|
data: {
|
|
6506
|
-
code: "ANON_QUOTA_EXCEEDED",
|
|
7436
|
+
code: decision.scope === "global" ? "ANON_GLOBAL_CAP_EXCEEDED" : "ANON_QUOTA_EXCEEDED",
|
|
6507
7437
|
retryable: true,
|
|
6508
7438
|
retry_after: decision.resetSeconds
|
|
6509
7439
|
}
|
|
@@ -6601,6 +7531,30 @@ function createStatelessMcpHandler(scope) {
|
|
|
6601
7531
|
return;
|
|
6602
7532
|
}
|
|
6603
7533
|
}
|
|
7534
|
+
if (keyedAuth?.userId) {
|
|
7535
|
+
const decision = await enforceUserRateLimit(keyedAuth.userId);
|
|
7536
|
+
res.setHeader("RateLimit-Limit", String(decision.limit));
|
|
7537
|
+
res.setHeader("RateLimit-Remaining", String(decision.remaining));
|
|
7538
|
+
res.setHeader("RateLimit-Reset", String(decision.resetSeconds));
|
|
7539
|
+
res.setHeader("RateLimit-Policy", `${decision.limit};w=60`);
|
|
7540
|
+
if (!decision.allowed) {
|
|
7541
|
+
res.setHeader("Retry-After", String(decision.resetSeconds));
|
|
7542
|
+
res.status(200).json({
|
|
7543
|
+
jsonrpc: "2.0",
|
|
7544
|
+
error: {
|
|
7545
|
+
code: -32001,
|
|
7546
|
+
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.`,
|
|
7547
|
+
data: {
|
|
7548
|
+
code: "RATE_LIMITED",
|
|
7549
|
+
retryable: true,
|
|
7550
|
+
retry_after: decision.resetSeconds
|
|
7551
|
+
}
|
|
7552
|
+
},
|
|
7553
|
+
id: req.body?.id ?? null
|
|
7554
|
+
});
|
|
7555
|
+
return;
|
|
7556
|
+
}
|
|
7557
|
+
}
|
|
6604
7558
|
try {
|
|
6605
7559
|
const body = req.body;
|
|
6606
7560
|
const rawHiveToolName = getMcpHiveToolName(body);
|
|
@@ -6651,11 +7605,10 @@ function createStatelessMcpHandler(scope) {
|
|
|
6651
7605
|
subjectContext = subject.data;
|
|
6652
7606
|
}
|
|
6653
7607
|
sanitizeMcpAuthContext(body);
|
|
6654
|
-
const clientInfoName = body?.params?.clientInfo?.name;
|
|
6655
7608
|
const analytics = {
|
|
6656
7609
|
principalRaw: keyedAuth?.userId ?? anonymousLane?.principalRaw,
|
|
6657
7610
|
authLane: keyedAuth ? keyedAuth.authMethod : "anonymous",
|
|
6658
|
-
clientName:
|
|
7611
|
+
clientName: resolveMcpClientName(req, body),
|
|
6659
7612
|
isInternal: isMonitorSecretRequest(req)
|
|
6660
7613
|
};
|
|
6661
7614
|
const server = createHiveMcpServer(scope, {
|
|
@@ -6719,7 +7672,8 @@ function createStatelessMcpHandler(scope) {
|
|
|
6719
7672
|
const debit = await debitMcpCredits({
|
|
6720
7673
|
userId: keyedAuth.userId,
|
|
6721
7674
|
keyId: keyedAuth.keyId,
|
|
6722
|
-
requestId: req.requestId
|
|
7675
|
+
requestId: req.requestId,
|
|
7676
|
+
planId: typeof keyedAuth.keyMeta?.plan === "string" ? keyedAuth.keyMeta.plan : void 0
|
|
6723
7677
|
});
|
|
6724
7678
|
if (!debit.ok) {
|
|
6725
7679
|
if (debit.noWallet) {
|
|
@@ -6737,7 +7691,9 @@ function createStatelessMcpHandler(scope) {
|
|
|
6737
7691
|
type: debit.rateLimited ? "rate_limit_error" : "internal_error",
|
|
6738
7692
|
code: debit.rateLimited ? "QUOTA_EXCEEDED" : "QUOTA_SERVICE_UNAVAILABLE",
|
|
6739
7693
|
message: debit.error,
|
|
6740
|
-
runtimeStatus: debit.rateLimited ? "rate_limited" : "failing"
|
|
7694
|
+
runtimeStatus: debit.rateLimited ? "rate_limited" : "failing",
|
|
7695
|
+
...debit.cause ? { cause: debit.cause } : {},
|
|
7696
|
+
...debit.next_action ? { next_action: debit.next_action } : {}
|
|
6741
7697
|
};
|
|
6742
7698
|
}
|
|
6743
7699
|
res.setHeader(
|
|
@@ -6756,16 +7712,18 @@ function createStatelessMcpHandler(scope) {
|
|
|
6756
7712
|
};
|
|
6757
7713
|
}
|
|
6758
7714
|
}
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
7715
|
+
const auditEvent = buildStatefulWriteAuditEvent({
|
|
7716
|
+
isStatefulWrite: isHiveStatefulWriteTool(executedToolName),
|
|
7717
|
+
transport: "mcp",
|
|
7718
|
+
toolName: executedToolName,
|
|
7719
|
+
userId: keyedAuth.userId,
|
|
7720
|
+
subjectContext,
|
|
7721
|
+
keyId: keyedAuth.keyId,
|
|
7722
|
+
authMethod: keyedAuth.authMethod,
|
|
7723
|
+
oauthClientId: keyedAuth.oauthClientId,
|
|
7724
|
+
requestId: req.requestId
|
|
7725
|
+
});
|
|
7726
|
+
if (auditEvent) recordSubjectAuditEvent(auditEvent);
|
|
6769
7727
|
return void 0;
|
|
6770
7728
|
} : anonymousLane ? async (executedToolName) => {
|
|
6771
7729
|
const creditCost = getMcpCreditCost(executedToolName);
|
|
@@ -6775,7 +7733,7 @@ function createStatelessMcpHandler(scope) {
|
|
|
6775
7733
|
return {
|
|
6776
7734
|
type: "authentication_error",
|
|
6777
7735
|
code: "ANON_AUTH_REQUIRED",
|
|
6778
|
-
message: "This tool requires authentication. Set HIVE_API_KEY
|
|
7736
|
+
message: "This tool requires authentication. Set HIVE_API_KEY (create one free at https://hiveintelligence.xyz/dashboard/keys) or sign in via OAuth.",
|
|
6779
7737
|
runtimeStatus: "failing"
|
|
6780
7738
|
};
|
|
6781
7739
|
}
|
|
@@ -6868,7 +7826,7 @@ var requireMcpTransportCredential = async (req, res, next) => {
|
|
|
6868
7826
|
};
|
|
6869
7827
|
app.get(
|
|
6870
7828
|
mcpPaths,
|
|
6871
|
-
mcpIpRateLimiter,
|
|
7829
|
+
...mcpIpRateLimiter,
|
|
6872
7830
|
requireMcpTransportCredential,
|
|
6873
7831
|
(_req, res) => {
|
|
6874
7832
|
res.writeHead(405, { Allow: "POST" }).end();
|
|
@@ -6876,7 +7834,7 @@ app.get(
|
|
|
6876
7834
|
);
|
|
6877
7835
|
app.delete(
|
|
6878
7836
|
mcpPaths,
|
|
6879
|
-
mcpIpRateLimiter,
|
|
7837
|
+
...mcpIpRateLimiter,
|
|
6880
7838
|
requireMcpTransportCredential,
|
|
6881
7839
|
(_req, res) => {
|
|
6882
7840
|
res.writeHead(405, { Allow: "POST" }).end();
|
|
@@ -6884,7 +7842,7 @@ app.delete(
|
|
|
6884
7842
|
);
|
|
6885
7843
|
app.post(
|
|
6886
7844
|
"/api/run-tests",
|
|
6887
|
-
restIpRateLimiter,
|
|
7845
|
+
...restIpRateLimiter,
|
|
6888
7846
|
asyncHandler(async (req, res) => {
|
|
6889
7847
|
const authToken = req.headers["x-monitor-secret"];
|
|
6890
7848
|
const monitorSecret = getEnvVar("MONITOR_SECRET");
|
|
@@ -6892,7 +7850,7 @@ app.post(
|
|
|
6892
7850
|
if (monitorSecret && typeof authToken === "string") {
|
|
6893
7851
|
const a = Buffer.from(authToken);
|
|
6894
7852
|
const b = Buffer.from(monitorSecret);
|
|
6895
|
-
isAuthorized = a.length === b.length &&
|
|
7853
|
+
isAuthorized = a.length === b.length && timingSafeEqual6(a, b);
|
|
6896
7854
|
}
|
|
6897
7855
|
secureLogger.authEvent("monitor-tests-access", isAuthorized, {
|
|
6898
7856
|
hasToken: !!authToken,
|
|
@@ -7041,15 +7999,50 @@ function getRestHeroEndpoint(toolName) {
|
|
|
7041
7999
|
}
|
|
7042
8000
|
return restHeroEndpointsByName.get(toolName);
|
|
7043
8001
|
}
|
|
8002
|
+
var knownRestToolNames = null;
|
|
8003
|
+
function isKnownRestToolName(toolName) {
|
|
8004
|
+
if (!knownRestToolNames) {
|
|
8005
|
+
knownRestToolNames = new Set(
|
|
8006
|
+
getAllWrappedEndpoints().map((endpoint) => endpoint.tool.name)
|
|
8007
|
+
);
|
|
8008
|
+
}
|
|
8009
|
+
return knownRestToolNames.has(toolName);
|
|
8010
|
+
}
|
|
7044
8011
|
var anonymousHeroExecuteMiddleware = asyncHandler(
|
|
7045
8012
|
async (req, res, next) => {
|
|
8013
|
+
const rawBody = req.body;
|
|
8014
|
+
if (rawBody && typeof rawBody === "object" && rawBody.args === void 0 && rawBody.arguments !== void 0) {
|
|
8015
|
+
rawBody.args = rawBody.arguments;
|
|
8016
|
+
}
|
|
7046
8017
|
if (mcpCredentialPresented(req)) {
|
|
7047
8018
|
next();
|
|
7048
8019
|
return;
|
|
7049
8020
|
}
|
|
7050
|
-
const body =
|
|
8021
|
+
const body = rawBody;
|
|
7051
8022
|
const toolName = typeof body?.tool === "string" ? body.tool : void 0;
|
|
7052
|
-
if (!toolName
|
|
8023
|
+
if (!toolName) {
|
|
8024
|
+
res.status(400).json({
|
|
8025
|
+
ok: false,
|
|
8026
|
+
error: {
|
|
8027
|
+
type: "invalid_request",
|
|
8028
|
+
code: "TOOL_REQUIRED",
|
|
8029
|
+
message: `Missing required field "tool". Send {"tool":"<name>","args":{...}}. The keyless lane serves ${[...HERO_TOOL_NAMES].join(", ")}; every other tool needs an API key.`
|
|
8030
|
+
}
|
|
8031
|
+
});
|
|
8032
|
+
return;
|
|
8033
|
+
}
|
|
8034
|
+
if (!HERO_TOOL_NAMES.has(toolName) && !isKnownRestToolName(toolName)) {
|
|
8035
|
+
res.status(404).json({
|
|
8036
|
+
ok: false,
|
|
8037
|
+
error: {
|
|
8038
|
+
type: "invalid_request",
|
|
8039
|
+
code: "TOOL_NOT_FOUND",
|
|
8040
|
+
message: `Unknown tool "${toolName}". Browse the catalog at /api/v1/tools, or call search_tools over MCP.`
|
|
8041
|
+
}
|
|
8042
|
+
});
|
|
8043
|
+
return;
|
|
8044
|
+
}
|
|
8045
|
+
if (!HERO_TOOL_NAMES.has(toolName)) {
|
|
7053
8046
|
next();
|
|
7054
8047
|
return;
|
|
7055
8048
|
}
|
|
@@ -7077,12 +8070,13 @@ var anonymousHeroExecuteMiddleware = asyncHandler(
|
|
|
7077
8070
|
);
|
|
7078
8071
|
return;
|
|
7079
8072
|
}
|
|
8073
|
+
const globalCap = quota.reason === "global_cap";
|
|
7080
8074
|
res.setHeader("Retry-After", String(quota.resetSeconds));
|
|
7081
8075
|
res.status(429).json(
|
|
7082
8076
|
apiError(
|
|
7083
8077
|
"rate_limit_error",
|
|
7084
|
-
"ANON_QUOTA_EXCEEDED",
|
|
7085
|
-
ANON_QUOTA_EXHAUSTED_MESSAGE,
|
|
8078
|
+
globalCap ? "ANON_GLOBAL_CAP_EXCEEDED" : "ANON_QUOTA_EXCEEDED",
|
|
8079
|
+
globalCap ? ANON_GLOBAL_CAP_MESSAGE : ANON_QUOTA_EXHAUSTED_MESSAGE,
|
|
7086
8080
|
{ requestId: req.requestId }
|
|
7087
8081
|
)
|
|
7088
8082
|
);
|
|
@@ -7096,6 +8090,7 @@ var anonymousHeroExecuteMiddleware = asyncHandler(
|
|
|
7096
8090
|
analytics: {
|
|
7097
8091
|
principalRaw: `anon:${ipHash}`,
|
|
7098
8092
|
authLane: "anonymous",
|
|
8093
|
+
clientName: resolveMcpClientName(req, body),
|
|
7099
8094
|
isInternal: isMonitorSecretRequest(req)
|
|
7100
8095
|
}
|
|
7101
8096
|
});
|
|
@@ -7168,16 +8163,18 @@ app.post(
|
|
|
7168
8163
|
};
|
|
7169
8164
|
}
|
|
7170
8165
|
}
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
8166
|
+
const restAuditEvent = buildStatefulWriteAuditEvent({
|
|
8167
|
+
isStatefulWrite: isHiveStatefulWriteTool(endpoint.tool.name),
|
|
8168
|
+
transport: "rest",
|
|
8169
|
+
toolName: endpoint.tool.name,
|
|
8170
|
+
userId: authReq.userId,
|
|
8171
|
+
subjectContext: authReq.hiveSubjectContext,
|
|
8172
|
+
keyId: authReq.unkeyKeyId,
|
|
8173
|
+
// REST is the API-key transport; OAuth tokens are MCP-only.
|
|
8174
|
+
authMethod: "api_key",
|
|
8175
|
+
requestId: req.requestId
|
|
8176
|
+
});
|
|
8177
|
+
if (restAuditEvent) recordSubjectAuditEvent(restAuditEvent);
|
|
7181
8178
|
return void 0;
|
|
7182
8179
|
};
|
|
7183
8180
|
const heroEndpoint = HERO_TOOL_NAMES.has(executionToolName) ? getRestHeroEndpoint(executionToolName) : void 0;
|
|
@@ -7229,13 +8226,13 @@ app.post(
|
|
|
7229
8226
|
});
|
|
7230
8227
|
})
|
|
7231
8228
|
);
|
|
7232
|
-
var cliTelemetrySchema =
|
|
7233
|
-
command:
|
|
7234
|
-
durationMs:
|
|
7235
|
-
errorCode:
|
|
7236
|
-
cliVersion:
|
|
7237
|
-
os:
|
|
7238
|
-
nodeVersion:
|
|
8229
|
+
var cliTelemetrySchema = z5.object({
|
|
8230
|
+
command: z5.string().trim().min(1).max(80).regex(/^[a-zA-Z0-9:_-]+$/),
|
|
8231
|
+
durationMs: z5.number().int().min(0).max(6e5),
|
|
8232
|
+
errorCode: z5.string().trim().min(1).max(80).regex(/^[a-zA-Z0-9:_-]+$/).optional(),
|
|
8233
|
+
cliVersion: z5.string().trim().min(1).max(40),
|
|
8234
|
+
os: z5.string().trim().min(1).max(120),
|
|
8235
|
+
nodeVersion: z5.string().trim().min(1).max(40)
|
|
7239
8236
|
}).strict();
|
|
7240
8237
|
app.post("/api/v1/telemetry", (req, res) => {
|
|
7241
8238
|
const parsed = cliTelemetrySchema.safeParse(req.body);
|
|
@@ -7324,6 +8321,10 @@ async function setupServer() {
|
|
|
7324
8321
|
`OAuth configuration is incomplete: ${mcpOAuth.config.issues.join(" ")}`
|
|
7325
8322
|
);
|
|
7326
8323
|
}
|
|
8324
|
+
const redisReady = await waitForRedisAvailability(3e3);
|
|
8325
|
+
secureLogger.info("[startup] Redis readiness settled", {
|
|
8326
|
+
ready: redisReady
|
|
8327
|
+
});
|
|
7327
8328
|
secureLogger.info("Setting up Hive MCP server...", {
|
|
7328
8329
|
server: SERVER_NAME,
|
|
7329
8330
|
version: SERVER_VERSION,
|
|
@@ -7420,21 +8421,41 @@ async function startHttpServer() {
|
|
|
7420
8421
|
});
|
|
7421
8422
|
httpServer.keepAliveTimeout = 65e3;
|
|
7422
8423
|
httpServer.headersTimeout = 7e4;
|
|
8424
|
+
const ANALYTICS_FLUSH_TIMEOUT_MS = 2e3;
|
|
8425
|
+
const flushAnalytics = async () => {
|
|
8426
|
+
const writer = getAnalyticsWriter();
|
|
8427
|
+
await Promise.race([
|
|
8428
|
+
writer.flush(),
|
|
8429
|
+
new Promise((resolve2) => {
|
|
8430
|
+
setTimeout(resolve2, ANALYTICS_FLUSH_TIMEOUT_MS).unref();
|
|
8431
|
+
})
|
|
8432
|
+
]);
|
|
8433
|
+
const stats = writer.getStats();
|
|
8434
|
+
secureLogger.info("Analytics queue flushed on shutdown", {
|
|
8435
|
+
stillQueued: stats.queued,
|
|
8436
|
+
droppedTotal: stats.dropped
|
|
8437
|
+
});
|
|
8438
|
+
};
|
|
8439
|
+
const exitAfterAnalyticsFlush = (code) => {
|
|
8440
|
+
getAnalyticsWriter().stop();
|
|
8441
|
+
void flushAnalytics().finally(() => process.exit(code));
|
|
8442
|
+
};
|
|
7423
8443
|
const shutdown = (signal) => {
|
|
7424
8444
|
if (isShuttingDown) return;
|
|
7425
8445
|
isShuttingDown = true;
|
|
7426
8446
|
secureLogger.info(
|
|
7427
8447
|
`${signal} received \u2014 draining ${activeRequests} active requests`
|
|
7428
8448
|
);
|
|
8449
|
+
void flushAnalytics();
|
|
7429
8450
|
httpServer.close(() => {
|
|
7430
8451
|
secureLogger.info("HTTP server closed", { activeRequests });
|
|
7431
|
-
|
|
8452
|
+
exitAfterAnalyticsFlush(0);
|
|
7432
8453
|
});
|
|
7433
8454
|
const drainInterval = setInterval(() => {
|
|
7434
8455
|
if (activeRequests <= 0) {
|
|
7435
8456
|
clearInterval(drainInterval);
|
|
7436
8457
|
secureLogger.info("All requests drained, closing");
|
|
7437
|
-
httpServer.close(() =>
|
|
8458
|
+
httpServer.close(() => exitAfterAnalyticsFlush(0));
|
|
7438
8459
|
}
|
|
7439
8460
|
}, 500);
|
|
7440
8461
|
drainInterval.unref();
|
|
@@ -7443,7 +8464,7 @@ async function startHttpServer() {
|
|
|
7443
8464
|
secureLogger.warn("Force shutdown after 10s timeout", {
|
|
7444
8465
|
activeRequests
|
|
7445
8466
|
});
|
|
7446
|
-
|
|
8467
|
+
exitAfterAnalyticsFlush(1);
|
|
7447
8468
|
}, 1e4);
|
|
7448
8469
|
forceTimer.unref();
|
|
7449
8470
|
};
|
|
@@ -7466,9 +8487,11 @@ if (isMainModule()) {
|
|
|
7466
8487
|
}
|
|
7467
8488
|
var server_default = app;
|
|
7468
8489
|
export {
|
|
8490
|
+
buildMcpQuotaExhaustedCopy,
|
|
7469
8491
|
classifyAnonymousMcpRequest,
|
|
7470
8492
|
createHttpApp,
|
|
7471
8493
|
server_default as default,
|
|
7472
8494
|
getRequiredMcpScopes,
|
|
8495
|
+
resolveMcpClientName,
|
|
7473
8496
|
startHttpServer
|
|
7474
8497
|
};
|