@solvapay/server 1.0.9-preview.1 → 1.0.9
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 +80 -2
- package/dist/edge.d.ts +516 -49
- package/dist/edge.js +691 -197
- package/dist/fetch/index.cjs +3073 -0
- package/dist/fetch/index.d.cts +112 -0
- package/dist/fetch/index.d.ts +112 -0
- package/dist/fetch/index.js +3019 -0
- package/dist/index.cjs +731 -4739
- package/dist/index.d.cts +534 -112
- package/dist/index.d.ts +534 -112
- package/dist/index.js +688 -416
- package/package.json +19 -13
- package/dist/chunk-MLKGABMK.js +0 -9
- package/dist/webapi-K5XBCEO6.js +0 -3775
package/dist/edge.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import "./chunk-MLKGABMK.js";
|
|
2
|
-
|
|
3
1
|
// src/edge.ts
|
|
4
2
|
import { SolvaPayError as SolvaPayError5 } from "@solvapay/core";
|
|
5
3
|
|
|
@@ -63,7 +61,27 @@ function createSolvaPayClient(opts) {
|
|
|
63
61
|
throw new SolvaPayError(`Create customer failed (${res.status}): ${error}`);
|
|
64
62
|
}
|
|
65
63
|
const result = await res.json();
|
|
66
|
-
return
|
|
64
|
+
return {
|
|
65
|
+
customerRef: result.reference || result.customerRef
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
// PATCH: /v1/sdk/customers/{customerRef}
|
|
69
|
+
async updateCustomer(customerRef, params) {
|
|
70
|
+
const url = `${base}/v1/sdk/customers/${encodeURIComponent(customerRef)}`;
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
method: "PATCH",
|
|
73
|
+
headers,
|
|
74
|
+
body: JSON.stringify(params)
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const error = await res.text();
|
|
78
|
+
log(`\u274C API Error: ${res.status} - ${error}`);
|
|
79
|
+
throw new SolvaPayError(`Update customer failed (${res.status}): ${error}`);
|
|
80
|
+
}
|
|
81
|
+
const result = await res.json();
|
|
82
|
+
return {
|
|
83
|
+
customerRef: result.reference || result.customerRef || customerRef
|
|
84
|
+
};
|
|
67
85
|
},
|
|
68
86
|
// GET: /v1/sdk/customers/{reference} or /v1/sdk/customers?externalRef={externalRef}|email={email}
|
|
69
87
|
async getCustomer(params) {
|
|
@@ -109,6 +127,50 @@ function createSolvaPayClient(opts) {
|
|
|
109
127
|
purchases: customer.purchases || []
|
|
110
128
|
};
|
|
111
129
|
},
|
|
130
|
+
// GET: /v1/sdk/merchant
|
|
131
|
+
async getMerchant() {
|
|
132
|
+
const url = `${base}/v1/sdk/merchant`;
|
|
133
|
+
const res = await fetch(url, {
|
|
134
|
+
method: "GET",
|
|
135
|
+
headers
|
|
136
|
+
});
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
const error = await res.text();
|
|
139
|
+
log(`\u274C API Error: ${res.status} - ${error}`);
|
|
140
|
+
throw new SolvaPayError(`Get merchant failed (${res.status}): ${error}`);
|
|
141
|
+
}
|
|
142
|
+
return res.json();
|
|
143
|
+
},
|
|
144
|
+
// GET: /v1/sdk/platform-config
|
|
145
|
+
async getPlatformConfig() {
|
|
146
|
+
const url = `${base}/v1/sdk/platform-config`;
|
|
147
|
+
const res = await fetch(url, {
|
|
148
|
+
method: "GET",
|
|
149
|
+
headers
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const error = await res.text();
|
|
153
|
+
log(`\u274C API Error: ${res.status} - ${error}`);
|
|
154
|
+
throw new SolvaPayError(`Get platform config failed (${res.status}): ${error}`);
|
|
155
|
+
}
|
|
156
|
+
return res.json();
|
|
157
|
+
},
|
|
158
|
+
// GET: /v1/sdk/products/{productRef}
|
|
159
|
+
async getProduct(productRef) {
|
|
160
|
+
const url = `${base}/v1/sdk/products/${encodeURIComponent(productRef)}`;
|
|
161
|
+
const res = await fetch(url, {
|
|
162
|
+
method: "GET",
|
|
163
|
+
headers
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
const error = await res.text();
|
|
167
|
+
log(`\u274C API Error: ${res.status} - ${error}`);
|
|
168
|
+
throw new SolvaPayError(`Get product failed (${res.status}): ${error}`);
|
|
169
|
+
}
|
|
170
|
+
const result = await res.json();
|
|
171
|
+
const data = result.data || {};
|
|
172
|
+
return { ...data, ...result };
|
|
173
|
+
},
|
|
112
174
|
// Product management methods (primarily for integration tests)
|
|
113
175
|
// GET: /v1/sdk/products
|
|
114
176
|
async listProducts() {
|
|
@@ -519,10 +581,67 @@ function createSolvaPayClient(opts) {
|
|
|
519
581
|
throw new SolvaPayError(`Activate plan failed (${res.status}): ${error}`);
|
|
520
582
|
}
|
|
521
583
|
return await res.json();
|
|
584
|
+
},
|
|
585
|
+
async getPaymentMethod(params) {
|
|
586
|
+
const url = new URL(`${base}/v1/sdk/payment-method`);
|
|
587
|
+
url.searchParams.set("customerRef", params.customerRef);
|
|
588
|
+
const res = await fetch(url.toString(), { method: "GET", headers });
|
|
589
|
+
if (!res.ok) {
|
|
590
|
+
const error = await res.text();
|
|
591
|
+
log(`\u274C API Error: ${res.status} - ${error}`);
|
|
592
|
+
throw new SolvaPayError(`Get payment method failed (${res.status}): ${error}`);
|
|
593
|
+
}
|
|
594
|
+
return await res.json();
|
|
522
595
|
}
|
|
523
596
|
};
|
|
524
597
|
}
|
|
525
598
|
|
|
599
|
+
// src/paywall-state.ts
|
|
600
|
+
function classifyPaywallState(limits) {
|
|
601
|
+
if (!limits) return { kind: "upgrade_required" };
|
|
602
|
+
if (limits.activationRequired === true) {
|
|
603
|
+
return { kind: "activation_required" };
|
|
604
|
+
}
|
|
605
|
+
const activePlan = limits.plans?.find((p) => p.reference === limits.plan);
|
|
606
|
+
const isUsageBased = activePlan?.type === "usage-based" || limits.balance !== void 0;
|
|
607
|
+
const creditBalance = limits.balance?.creditBalance ?? limits.creditBalance;
|
|
608
|
+
if (isUsageBased) {
|
|
609
|
+
if (creditBalance === 0) return { kind: "topup_required" };
|
|
610
|
+
if (creditBalance === void 0 && limits.remaining === 0) {
|
|
611
|
+
return { kind: "topup_required" };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return { kind: "upgrade_required" };
|
|
615
|
+
}
|
|
616
|
+
function buildGateMessage(state, gate) {
|
|
617
|
+
const url = gate.checkoutUrl && gate.checkoutUrl.length > 0 ? gate.checkoutUrl : null;
|
|
618
|
+
const openClause = url ? `, or open ${url} in a browser` : "";
|
|
619
|
+
switch (state.kind) {
|
|
620
|
+
case "activation_required":
|
|
621
|
+
return `Your plan needs activation before you can use this tool. Call the \`activate_plan\` tool to activate it${openClause}.`;
|
|
622
|
+
case "topup_required":
|
|
623
|
+
return `You're out of credits. Call the \`topup\` tool to add more${openClause}.`;
|
|
624
|
+
case "upgrade_required":
|
|
625
|
+
return `You don't have an active plan for this tool. Call the \`upgrade\` tool to pick a plan${openClause}.`;
|
|
626
|
+
case "reactivation_required":
|
|
627
|
+
return `Your previous plan is no longer active. Call the \`manage_account\` tool to reactivate it, or the \`upgrade\` tool to pick a new plan.`;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function buildNudgeMessage(state, limits) {
|
|
631
|
+
const url = limits?.checkoutUrl && limits.checkoutUrl.length > 0 ? limits.checkoutUrl : null;
|
|
632
|
+
const visitClause = url ? `, or visit ${url}` : "";
|
|
633
|
+
switch (state.kind) {
|
|
634
|
+
case "topup_required":
|
|
635
|
+
return `Heads up \u2014 running low on credits. Call the \`topup\` tool to add more${visitClause}.`;
|
|
636
|
+
case "upgrade_required":
|
|
637
|
+
return `Heads up \u2014 approaching your plan's limit this period. Call the \`upgrade\` tool for more headroom${visitClause}.`;
|
|
638
|
+
case "activation_required":
|
|
639
|
+
return `Heads up \u2014 this plan still needs activation. Call the \`activate_plan\` tool${visitClause}.`;
|
|
640
|
+
case "reactivation_required":
|
|
641
|
+
return `Heads up \u2014 your plan is no longer active. Call the \`manage_account\` tool to reactivate it${visitClause}.`;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
526
645
|
// src/utils.ts
|
|
527
646
|
async function withRetry(fn, options = {}) {
|
|
528
647
|
const {
|
|
@@ -574,7 +693,8 @@ function createRequestDeduplicator(options = {}) {
|
|
|
574
693
|
const inFlightRequests = /* @__PURE__ */ new Map();
|
|
575
694
|
const resultCache = /* @__PURE__ */ new Map();
|
|
576
695
|
let _cleanupInterval = null;
|
|
577
|
-
|
|
696
|
+
function ensureCleanupInterval() {
|
|
697
|
+
if (_cleanupInterval || cacheTTL <= 0) return;
|
|
578
698
|
_cleanupInterval = setInterval(
|
|
579
699
|
() => {
|
|
580
700
|
const now = Date.now();
|
|
@@ -601,6 +721,7 @@ function createRequestDeduplicator(options = {}) {
|
|
|
601
721
|
);
|
|
602
722
|
}
|
|
603
723
|
const deduplicate = async (key, fn) => {
|
|
724
|
+
ensureCleanupInterval();
|
|
604
725
|
if (cacheTTL > 0) {
|
|
605
726
|
const cached = resultCache.get(key);
|
|
606
727
|
if (cached && Date.now() - cached.timestamp < cacheTTL) {
|
|
@@ -689,6 +810,10 @@ function paywallErrorToClientPayload(error) {
|
|
|
689
810
|
if (sc.balance !== void 0) base.balance = sc.balance;
|
|
690
811
|
if (sc.productDetails !== void 0) base.productDetails = sc.productDetails;
|
|
691
812
|
if (sc.confirmationUrl !== void 0) base.confirmationUrl = sc.confirmationUrl;
|
|
813
|
+
} else {
|
|
814
|
+
base.kind = "payment_required";
|
|
815
|
+
if (sc.balance !== void 0) base.balance = sc.balance;
|
|
816
|
+
if (sc.productDetails !== void 0) base.productDetails = sc.productDetails;
|
|
692
817
|
}
|
|
693
818
|
return base;
|
|
694
819
|
}
|
|
@@ -700,6 +825,7 @@ var sharedCustomerLookupDeduplicator = createRequestDeduplicator({
|
|
|
700
825
|
cacheErrors: false
|
|
701
826
|
// Don't cache errors - retry on next request
|
|
702
827
|
});
|
|
828
|
+
var EXTRA_FORWARD_KEY = "__solvapayExtra";
|
|
703
829
|
var SolvaPayPaywall = class {
|
|
704
830
|
constructor(apiClient, options = {}) {
|
|
705
831
|
this.apiClient = apiClient;
|
|
@@ -725,136 +851,216 @@ var SolvaPayPaywall = class {
|
|
|
725
851
|
return `solvapay_${timestamp}_${random}`;
|
|
726
852
|
}
|
|
727
853
|
/**
|
|
728
|
-
*
|
|
854
|
+
* Pure decision routine — performs customer resolution, limits cache
|
|
855
|
+
* lookup / fresh `checkLimits` fetch, and returns a `PaywallDecision`
|
|
856
|
+
* describing whether the handler should run.
|
|
857
|
+
*
|
|
858
|
+
* Side effects kept in lockstep with the legacy `protect()` path:
|
|
859
|
+
* - creates the backend customer on first use (`ensureCustomer`),
|
|
860
|
+
* - updates the limits cache (consume-one-unit bookkeeping), and
|
|
861
|
+
* - emits a `paywall` usage event on gate outcomes.
|
|
862
|
+
*
|
|
863
|
+
* `trackUsage` for the `success` / `fail` outcome is emitted by the
|
|
864
|
+
* caller (adapter or `protect()`) once it has actually invoked the
|
|
865
|
+
* handler — `decide()` never counts handler execution as usage.
|
|
866
|
+
*
|
|
867
|
+
* @since 1.1.0
|
|
729
868
|
*/
|
|
730
|
-
|
|
731
|
-
async protect(handler, metadata = {}, getCustomerRef) {
|
|
869
|
+
async decide(args, metadata = {}, getCustomerRef) {
|
|
732
870
|
const product = this.resolveProduct(metadata);
|
|
733
871
|
const usageType = metadata.usageType || "requests";
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
872
|
+
const requestId = this.generateRequestId();
|
|
873
|
+
const startTime = Date.now();
|
|
874
|
+
const inputCustomerRef = getCustomerRef ? getCustomerRef(args) : args.auth?.customer_ref || "anonymous";
|
|
875
|
+
let backendCustomerRef;
|
|
876
|
+
if (inputCustomerRef.startsWith("cus_")) {
|
|
877
|
+
backendCustomerRef = inputCustomerRef;
|
|
878
|
+
} else {
|
|
879
|
+
backendCustomerRef = await this.ensureCustomer(inputCustomerRef, inputCustomerRef);
|
|
880
|
+
}
|
|
881
|
+
const limitsCacheKey = `${backendCustomerRef}:${product}:${usageType}`;
|
|
882
|
+
const cachedLimits = this.limitsCache.get(limitsCacheKey);
|
|
883
|
+
const now = Date.now();
|
|
884
|
+
let withinLimits;
|
|
885
|
+
let remaining;
|
|
886
|
+
let checkoutUrl;
|
|
887
|
+
let resolvedMeterName;
|
|
888
|
+
let lastLimitsCheck;
|
|
889
|
+
const hasFreshCachedLimits = cachedLimits && now - cachedLimits.timestamp < this.limitsCacheTTL;
|
|
890
|
+
if (hasFreshCachedLimits) {
|
|
891
|
+
checkoutUrl = cachedLimits.checkoutUrl;
|
|
892
|
+
resolvedMeterName = cachedLimits.meterName;
|
|
893
|
+
lastLimitsCheck = cachedLimits.limits;
|
|
894
|
+
if (cachedLimits.remaining > 0) {
|
|
895
|
+
cachedLimits.remaining--;
|
|
896
|
+
if (cachedLimits.remaining <= 0) {
|
|
897
|
+
this.limitsCache.delete(limitsCacheKey);
|
|
898
|
+
}
|
|
899
|
+
withinLimits = true;
|
|
900
|
+
remaining = cachedLimits.remaining;
|
|
901
|
+
} else {
|
|
902
|
+
withinLimits = false;
|
|
903
|
+
remaining = 0;
|
|
904
|
+
this.limitsCache.delete(limitsCacheKey);
|
|
905
|
+
}
|
|
906
|
+
} else {
|
|
907
|
+
if (cachedLimits) {
|
|
908
|
+
this.limitsCache.delete(limitsCacheKey);
|
|
909
|
+
}
|
|
910
|
+
const limitsCheck = await this.apiClient.checkLimits({
|
|
911
|
+
customerRef: backendCustomerRef,
|
|
912
|
+
productRef: product,
|
|
913
|
+
meterName: usageType
|
|
914
|
+
});
|
|
915
|
+
lastLimitsCheck = limitsCheck;
|
|
916
|
+
withinLimits = limitsCheck.withinLimits;
|
|
917
|
+
remaining = limitsCheck.remaining;
|
|
918
|
+
checkoutUrl = limitsCheck.checkoutUrl;
|
|
919
|
+
resolvedMeterName = limitsCheck.meterName;
|
|
920
|
+
const consumedAllowance = withinLimits && remaining > 0;
|
|
921
|
+
if (consumedAllowance) {
|
|
922
|
+
remaining = Math.max(0, remaining - 1);
|
|
923
|
+
}
|
|
924
|
+
if (consumedAllowance) {
|
|
925
|
+
this.limitsCache.set(limitsCacheKey, {
|
|
926
|
+
remaining,
|
|
927
|
+
checkoutUrl,
|
|
928
|
+
meterName: resolvedMeterName,
|
|
929
|
+
timestamp: now,
|
|
930
|
+
limits: limitsCheck
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (!withinLimits) {
|
|
935
|
+
const latencyMs = Date.now() - startTime;
|
|
936
|
+
this.trackUsage(
|
|
937
|
+
backendCustomerRef,
|
|
938
|
+
product,
|
|
939
|
+
resolvedMeterName || usageType,
|
|
940
|
+
"paywall",
|
|
941
|
+
requestId,
|
|
942
|
+
latencyMs
|
|
943
|
+
);
|
|
944
|
+
const state = classifyPaywallState(lastLimitsCheck ?? null);
|
|
945
|
+
const preMessageGate = lastLimitsCheck?.activationRequired ? {
|
|
946
|
+
kind: "activation_required",
|
|
947
|
+
product,
|
|
948
|
+
message: "",
|
|
949
|
+
checkoutUrl: lastLimitsCheck.confirmationUrl || lastLimitsCheck.checkoutUrl || checkoutUrl || "",
|
|
950
|
+
...lastLimitsCheck.confirmationUrl !== void 0 ? { confirmationUrl: lastLimitsCheck.confirmationUrl } : {},
|
|
951
|
+
...lastLimitsCheck.plans !== void 0 ? { plans: lastLimitsCheck.plans } : {},
|
|
952
|
+
...lastLimitsCheck.balance !== void 0 ? { balance: lastLimitsCheck.balance } : {},
|
|
953
|
+
...lastLimitsCheck.product !== void 0 ? { productDetails: lastLimitsCheck.product } : {}
|
|
954
|
+
} : {
|
|
955
|
+
kind: "payment_required",
|
|
956
|
+
product,
|
|
957
|
+
checkoutUrl: checkoutUrl || "",
|
|
958
|
+
message: "",
|
|
959
|
+
...lastLimitsCheck?.balance !== void 0 ? { balance: lastLimitsCheck.balance } : {},
|
|
960
|
+
...lastLimitsCheck?.product !== void 0 ? { productDetails: lastLimitsCheck.product } : {}
|
|
961
|
+
};
|
|
962
|
+
const gate = {
|
|
963
|
+
...preMessageGate,
|
|
964
|
+
message: buildGateMessage(state, preMessageGate)
|
|
965
|
+
};
|
|
966
|
+
return {
|
|
967
|
+
outcome: "gate",
|
|
968
|
+
gate,
|
|
969
|
+
limits: lastLimitsCheck ?? null,
|
|
970
|
+
customerRef: backendCustomerRef
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
outcome: "allow",
|
|
975
|
+
args,
|
|
976
|
+
limits: lastLimitsCheck,
|
|
977
|
+
customerRef: backendCustomerRef
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Execute the handler for an already-obtained `allow` decision and
|
|
982
|
+
* emit the post-handler `trackUsage('success' | 'fail', ...)` event.
|
|
983
|
+
*
|
|
984
|
+
* Exposed for adapter integration — the adapter layer drives the
|
|
985
|
+
* paywall through `decide()` + `runAllow()` so `formatGate` can own
|
|
986
|
+
* gate outcomes without routing through `PaywallError`. `protect()`
|
|
987
|
+
* continues to offer the self-contained throw-based surface for
|
|
988
|
+
* legacy consumers.
|
|
989
|
+
*
|
|
990
|
+
* `runAllow` intentionally does NOT re-throw `PaywallError` — if a
|
|
991
|
+
* handler calls `ctx.gate(reason)` and throws from deep code, the
|
|
992
|
+
* adapter catches that at the `formatGate` boundary instead.
|
|
993
|
+
*
|
|
994
|
+
* @since 1.1.0
|
|
995
|
+
*/
|
|
996
|
+
async runAllow(decision, handler, metadata, args) {
|
|
997
|
+
const product = this.resolveProduct(metadata);
|
|
998
|
+
const usageType = metadata.usageType || "requests";
|
|
999
|
+
const requestId = this.generateRequestId();
|
|
1000
|
+
const startTime = Date.now();
|
|
1001
|
+
const forwardedExtra = args[EXTRA_FORWARD_KEY];
|
|
1002
|
+
const handlerContext = {
|
|
1003
|
+
customerRef: decision.customerRef,
|
|
1004
|
+
limits: decision.limits,
|
|
1005
|
+
...forwardedExtra !== void 0 ? { extra: forwardedExtra } : {}
|
|
1006
|
+
};
|
|
1007
|
+
try {
|
|
1008
|
+
const result = await handler(args, handlerContext);
|
|
1009
|
+
const latencyMs = Date.now() - startTime;
|
|
1010
|
+
this.trackUsage(
|
|
1011
|
+
decision.customerRef,
|
|
1012
|
+
product,
|
|
1013
|
+
decision.limits.meterName || usageType,
|
|
1014
|
+
"success",
|
|
1015
|
+
requestId,
|
|
1016
|
+
latencyMs
|
|
1017
|
+
);
|
|
1018
|
+
return result;
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
if (error instanceof Error) {
|
|
1021
|
+
const errorType = error instanceof PaywallError ? "PaywallError" : "API Error";
|
|
1022
|
+
this.log(`\u274C Error in paywall [${errorType}]: ${error.message}`);
|
|
741
1023
|
} else {
|
|
742
|
-
|
|
1024
|
+
this.log(`\u274C Error in paywall:`, error);
|
|
743
1025
|
}
|
|
744
|
-
|
|
745
|
-
try {
|
|
746
|
-
const limitsCacheKey = `${backendCustomerRef}:${product}:${usageType}`;
|
|
747
|
-
const cachedLimits = this.limitsCache.get(limitsCacheKey);
|
|
748
|
-
const now = Date.now();
|
|
749
|
-
let withinLimits;
|
|
750
|
-
let remaining;
|
|
751
|
-
let checkoutUrl;
|
|
752
|
-
let lastLimitsCheck;
|
|
753
|
-
const hasFreshCachedLimits = cachedLimits && now - cachedLimits.timestamp < this.limitsCacheTTL;
|
|
754
|
-
if (hasFreshCachedLimits) {
|
|
755
|
-
checkoutUrl = cachedLimits.checkoutUrl;
|
|
756
|
-
resolvedMeterName = cachedLimits.meterName;
|
|
757
|
-
if (cachedLimits.remaining > 0) {
|
|
758
|
-
cachedLimits.remaining--;
|
|
759
|
-
if (cachedLimits.remaining <= 0) {
|
|
760
|
-
this.limitsCache.delete(limitsCacheKey);
|
|
761
|
-
}
|
|
762
|
-
withinLimits = true;
|
|
763
|
-
remaining = cachedLimits.remaining;
|
|
764
|
-
} else {
|
|
765
|
-
withinLimits = false;
|
|
766
|
-
remaining = 0;
|
|
767
|
-
this.limitsCache.delete(limitsCacheKey);
|
|
768
|
-
}
|
|
769
|
-
} else {
|
|
770
|
-
if (cachedLimits) {
|
|
771
|
-
this.limitsCache.delete(limitsCacheKey);
|
|
772
|
-
}
|
|
773
|
-
const limitsCheck = await this.apiClient.checkLimits({
|
|
774
|
-
customerRef: backendCustomerRef,
|
|
775
|
-
productRef: product,
|
|
776
|
-
meterName: usageType
|
|
777
|
-
});
|
|
778
|
-
lastLimitsCheck = limitsCheck;
|
|
779
|
-
withinLimits = limitsCheck.withinLimits;
|
|
780
|
-
remaining = limitsCheck.remaining;
|
|
781
|
-
checkoutUrl = limitsCheck.checkoutUrl;
|
|
782
|
-
resolvedMeterName = limitsCheck.meterName;
|
|
783
|
-
const consumedAllowance = withinLimits && remaining > 0;
|
|
784
|
-
if (consumedAllowance) {
|
|
785
|
-
remaining = Math.max(0, remaining - 1);
|
|
786
|
-
}
|
|
787
|
-
if (consumedAllowance) {
|
|
788
|
-
this.limitsCache.set(limitsCacheKey, {
|
|
789
|
-
remaining,
|
|
790
|
-
checkoutUrl,
|
|
791
|
-
meterName: resolvedMeterName,
|
|
792
|
-
timestamp: now
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
if (!withinLimits) {
|
|
797
|
-
const latencyMs2 = Date.now() - startTime;
|
|
798
|
-
this.trackUsage(
|
|
799
|
-
backendCustomerRef,
|
|
800
|
-
product,
|
|
801
|
-
resolvedMeterName || usageType,
|
|
802
|
-
"paywall",
|
|
803
|
-
requestId,
|
|
804
|
-
latencyMs2
|
|
805
|
-
);
|
|
806
|
-
if (lastLimitsCheck?.activationRequired) {
|
|
807
|
-
const confirmationUrl = lastLimitsCheck.confirmationUrl;
|
|
808
|
-
const payCheckoutUrl = confirmationUrl || lastLimitsCheck.checkoutUrl || checkoutUrl || "";
|
|
809
|
-
throw new PaywallError("Activation required", {
|
|
810
|
-
kind: "activation_required",
|
|
811
|
-
product,
|
|
812
|
-
message: "Product activation is required before this tool can be used.",
|
|
813
|
-
checkoutUrl: payCheckoutUrl,
|
|
814
|
-
confirmationUrl,
|
|
815
|
-
plans: lastLimitsCheck.plans,
|
|
816
|
-
balance: lastLimitsCheck.balance,
|
|
817
|
-
productDetails: lastLimitsCheck.product
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
throw new PaywallError("Payment required", {
|
|
821
|
-
kind: "payment_required",
|
|
822
|
-
product,
|
|
823
|
-
checkoutUrl: checkoutUrl || "",
|
|
824
|
-
message: `Purchase required. Remaining: ${remaining}`
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
const result = await handler(args);
|
|
1026
|
+
if (!(error instanceof PaywallError)) {
|
|
828
1027
|
const latencyMs = Date.now() - startTime;
|
|
829
1028
|
this.trackUsage(
|
|
830
|
-
|
|
1029
|
+
decision.customerRef,
|
|
831
1030
|
product,
|
|
832
|
-
|
|
833
|
-
"
|
|
1031
|
+
decision.limits.meterName || usageType,
|
|
1032
|
+
"fail",
|
|
834
1033
|
requestId,
|
|
835
1034
|
latencyMs
|
|
836
1035
|
);
|
|
837
|
-
return result;
|
|
838
|
-
} catch (error) {
|
|
839
|
-
if (error instanceof Error) {
|
|
840
|
-
const errorType = error instanceof PaywallError ? "PaywallError" : "API Error";
|
|
841
|
-
this.log(`\u274C Error in paywall [${errorType}]: ${error.message}`);
|
|
842
|
-
} else {
|
|
843
|
-
this.log(`\u274C Error in paywall:`, error);
|
|
844
|
-
}
|
|
845
|
-
if (!(error instanceof PaywallError)) {
|
|
846
|
-
const latencyMs = Date.now() - startTime;
|
|
847
|
-
this.trackUsage(
|
|
848
|
-
backendCustomerRef,
|
|
849
|
-
product,
|
|
850
|
-
resolvedMeterName || usageType,
|
|
851
|
-
"fail",
|
|
852
|
-
requestId,
|
|
853
|
-
latencyMs
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
throw error;
|
|
857
1036
|
}
|
|
1037
|
+
throw error;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Core protection method - works for both MCP and HTTP
|
|
1042
|
+
*
|
|
1043
|
+
* The `handler` may optionally declare a second positional argument
|
|
1044
|
+
* of type `ProtectHandlerContext` to receive the resolved customer
|
|
1045
|
+
* ref, the pre-check `LimitResponseWithPlan`, and an opaque `extra`
|
|
1046
|
+
* bag threaded through from the adapter layer. One-arg handlers
|
|
1047
|
+
* ignore the second argument and continue to work unchanged.
|
|
1048
|
+
*
|
|
1049
|
+
* Implemented on top of `decide()`: pre-check runs through the same
|
|
1050
|
+
* decision routine, and gate outcomes are raised as a `PaywallError`
|
|
1051
|
+
* to preserve the legacy throw-based signal for consumers that
|
|
1052
|
+
* haven't migrated to adapter-level `formatGate` routing.
|
|
1053
|
+
*/
|
|
1054
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1055
|
+
async protect(handler, metadata = {}, getCustomerRef) {
|
|
1056
|
+
return async (args) => {
|
|
1057
|
+
const decision = await this.decide(args, metadata, getCustomerRef);
|
|
1058
|
+
if (decision.outcome === "gate") {
|
|
1059
|
+
const message = decision.gate.kind === "activation_required" ? "Activation required" : "Payment required";
|
|
1060
|
+
this.log(`\u274C Error in paywall [PaywallError]: ${message}`);
|
|
1061
|
+
throw new PaywallError(message, decision.gate);
|
|
1062
|
+
}
|
|
1063
|
+
return this.runAllow(decision, handler, metadata, args);
|
|
858
1064
|
};
|
|
859
1065
|
}
|
|
860
1066
|
/**
|
|
@@ -957,6 +1163,16 @@ var SolvaPayPaywall = class {
|
|
|
957
1163
|
this.log(
|
|
958
1164
|
`\u26A0\uFE0F Resolved customer ${customerRef} by email after conflict; using existing customer ${byEmail.customerRef}`
|
|
959
1165
|
);
|
|
1166
|
+
if (!byEmail.externalRef && this.apiClient.updateCustomer) {
|
|
1167
|
+
try {
|
|
1168
|
+
await this.apiClient.updateCustomer(byEmail.customerRef, { externalRef });
|
|
1169
|
+
} catch (backfillError) {
|
|
1170
|
+
this.log(
|
|
1171
|
+
`\u26A0\uFE0F Failed to backfill externalRef on ${byEmail.customerRef}:`,
|
|
1172
|
+
backfillError instanceof Error ? backfillError.message : backfillError
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
960
1176
|
return byEmail.customerRef;
|
|
961
1177
|
}
|
|
962
1178
|
} catch (emailLookupError) {
|
|
@@ -1033,6 +1249,7 @@ var SolvaPayPaywall = class {
|
|
|
1033
1249
|
};
|
|
1034
1250
|
|
|
1035
1251
|
// src/adapters/base.ts
|
|
1252
|
+
var EXTRA_FORWARD_KEY2 = "__solvapayExtra";
|
|
1036
1253
|
var AdapterUtils = class {
|
|
1037
1254
|
/**
|
|
1038
1255
|
* Ensure customer reference is properly formatted
|
|
@@ -1048,7 +1265,7 @@ var AdapterUtils = class {
|
|
|
1048
1265
|
*/
|
|
1049
1266
|
static async extractFromJWT(token, options) {
|
|
1050
1267
|
try {
|
|
1051
|
-
const { jwtVerify } = await import("
|
|
1268
|
+
const { jwtVerify } = await import("jose");
|
|
1052
1269
|
const jwtSecret = new TextEncoder().encode(
|
|
1053
1270
|
options?.secret || process.env.OAUTH_JWKS_SECRET || "test-jwt-secret"
|
|
1054
1271
|
);
|
|
@@ -1064,23 +1281,45 @@ var AdapterUtils = class {
|
|
|
1064
1281
|
};
|
|
1065
1282
|
async function createAdapterHandler(adapter, paywall, metadata, businessLogic) {
|
|
1066
1283
|
const backendRefCache = /* @__PURE__ */ new Map();
|
|
1067
|
-
const getCustomerRef = (args) => args.auth?.customer_ref || "anonymous";
|
|
1068
|
-
const protectedHandler = await paywall.protect(businessLogic, metadata, getCustomerRef);
|
|
1069
1284
|
return async (context, extra) => {
|
|
1285
|
+
let args;
|
|
1286
|
+
let customerRef;
|
|
1070
1287
|
try {
|
|
1071
|
-
|
|
1072
|
-
|
|
1288
|
+
args = await adapter.extractArgs(context);
|
|
1289
|
+
customerRef = await adapter.getCustomerRef(context, extra);
|
|
1073
1290
|
let backendRef = backendRefCache.get(customerRef);
|
|
1074
1291
|
if (!backendRef) {
|
|
1075
1292
|
backendRef = await paywall.ensureCustomer(customerRef, customerRef);
|
|
1076
1293
|
backendRefCache.set(customerRef, backendRef);
|
|
1077
1294
|
}
|
|
1078
1295
|
args.auth = { customer_ref: backendRef };
|
|
1079
|
-
const result = await protectedHandler(args);
|
|
1080
|
-
return adapter.formatResponse(result, context);
|
|
1081
1296
|
} catch (error) {
|
|
1082
1297
|
return adapter.formatError(error, context);
|
|
1083
1298
|
}
|
|
1299
|
+
const decideGetCustomerRef = (args2) => args2.auth?.customer_ref || "anonymous";
|
|
1300
|
+
try {
|
|
1301
|
+
const decision = await paywall.decide(args, metadata, decideGetCustomerRef);
|
|
1302
|
+
if (decision.outcome === "gate") {
|
|
1303
|
+
return adapter.formatGate(decision.gate, context);
|
|
1304
|
+
}
|
|
1305
|
+
if (extra !== void 0) {
|
|
1306
|
+
;
|
|
1307
|
+
args[EXTRA_FORWARD_KEY2] = extra;
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
const result = await paywall.runAllow(decision, businessLogic, metadata, args);
|
|
1311
|
+
return adapter.formatResponse(result, context);
|
|
1312
|
+
} finally {
|
|
1313
|
+
if (EXTRA_FORWARD_KEY2 in args) {
|
|
1314
|
+
delete args[EXTRA_FORWARD_KEY2];
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (error instanceof PaywallError) {
|
|
1319
|
+
return adapter.formatGate(error.structuredContent, context);
|
|
1320
|
+
}
|
|
1321
|
+
return adapter.formatError(error, context);
|
|
1322
|
+
}
|
|
1084
1323
|
};
|
|
1085
1324
|
}
|
|
1086
1325
|
|
|
@@ -1128,24 +1367,25 @@ var HttpAdapter = class {
|
|
|
1128
1367
|
}
|
|
1129
1368
|
return result;
|
|
1130
1369
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
}
|
|
1147
|
-
return errorResponse2;
|
|
1370
|
+
/**
|
|
1371
|
+
* Emit a 402 Payment Required response with the same JSON body shape
|
|
1372
|
+
* REST consumers have always received (`{success:false, error, product,
|
|
1373
|
+
* checkoutUrl, message, ...}`). The shape is reused via
|
|
1374
|
+
* `paywallErrorToClientPayload` so HTTP / Next / hosted-proxy clients
|
|
1375
|
+
* don't have to branch on an SDK version.
|
|
1376
|
+
*/
|
|
1377
|
+
formatGate(gate, [_req, reply]) {
|
|
1378
|
+
const errorResponse = paywallErrorToClientPayload(new PaywallError(gate.message, gate));
|
|
1379
|
+
if (reply && reply.status && typeof reply.json === "function") {
|
|
1380
|
+
reply.status(402).json(errorResponse);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (reply && reply.code) {
|
|
1384
|
+
reply.code(402);
|
|
1148
1385
|
}
|
|
1386
|
+
return errorResponse;
|
|
1387
|
+
}
|
|
1388
|
+
formatError(error, [_req, reply]) {
|
|
1149
1389
|
const errorResponse = {
|
|
1150
1390
|
success: false,
|
|
1151
1391
|
error: error instanceof Error ? error.message : "Internal server error"
|
|
@@ -1223,22 +1463,22 @@ var NextAdapter = class {
|
|
|
1223
1463
|
headers: { "Content-Type": "application/json" }
|
|
1224
1464
|
});
|
|
1225
1465
|
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Emit a 402 Payment Required `Response` with the same JSON body
|
|
1468
|
+
* REST consumers have always received. Reuses
|
|
1469
|
+
* `paywallErrorToClientPayload` so HTTP / Next / hosted-proxy
|
|
1470
|
+
* clients don't have to branch on an SDK version.
|
|
1471
|
+
*/
|
|
1472
|
+
formatGate(gate, _context) {
|
|
1473
|
+
return new Response(
|
|
1474
|
+
JSON.stringify(paywallErrorToClientPayload(new PaywallError(gate.message, gate))),
|
|
1475
|
+
{
|
|
1476
|
+
status: 402,
|
|
1477
|
+
headers: { "Content-Type": "application/json" }
|
|
1478
|
+
}
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1226
1481
|
formatError(error, _context) {
|
|
1227
|
-
if (error instanceof PaywallError) {
|
|
1228
|
-
return new Response(
|
|
1229
|
-
JSON.stringify({
|
|
1230
|
-
success: false,
|
|
1231
|
-
error: "Payment required",
|
|
1232
|
-
product: error.structuredContent.product,
|
|
1233
|
-
checkoutUrl: error.structuredContent.checkoutUrl,
|
|
1234
|
-
message: error.structuredContent.message
|
|
1235
|
-
}),
|
|
1236
|
-
{
|
|
1237
|
-
status: 402,
|
|
1238
|
-
headers: { "Content-Type": "application/json" }
|
|
1239
|
-
}
|
|
1240
|
-
);
|
|
1241
|
-
}
|
|
1242
1482
|
return new Response(
|
|
1243
1483
|
JSON.stringify({
|
|
1244
1484
|
success: false,
|
|
@@ -1286,19 +1526,27 @@ var McpAdapter = class {
|
|
|
1286
1526
|
}
|
|
1287
1527
|
return response;
|
|
1288
1528
|
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Emit a plain-narration paywall response — `content[0].text` carries
|
|
1531
|
+
* the gate's human message (LLM-actionable), `structuredContent`
|
|
1532
|
+
* carries the machine-readable gate payload, and `isError` stays
|
|
1533
|
+
* `false` per the MCP spec's own `isError` definition (paywall is
|
|
1534
|
+
* not a self-correctable tool execution error; it is a user-facing
|
|
1535
|
+
* control transfer to the UI).
|
|
1536
|
+
*
|
|
1537
|
+
* Hosts that read widget metadata from `tools/list` or tool-result
|
|
1538
|
+
* `_meta.ui` open the paywall iframe on top of this response;
|
|
1539
|
+
* `buildPayableHandler` stamps the `_meta.ui.resourceUri` envelope
|
|
1540
|
+
* before returning.
|
|
1541
|
+
*/
|
|
1542
|
+
formatGate(gate, _context) {
|
|
1543
|
+
return {
|
|
1544
|
+
content: [{ type: "text", text: gate.message }],
|
|
1545
|
+
isError: false,
|
|
1546
|
+
structuredContent: gate
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1289
1549
|
formatError(error, _context) {
|
|
1290
|
-
if (error instanceof PaywallError) {
|
|
1291
|
-
return {
|
|
1292
|
-
content: [
|
|
1293
|
-
{
|
|
1294
|
-
type: "text",
|
|
1295
|
-
text: JSON.stringify(paywallErrorToClientPayload(error), null, 2)
|
|
1296
|
-
}
|
|
1297
|
-
],
|
|
1298
|
-
isError: true,
|
|
1299
|
-
structuredContent: error.structuredContent
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
1550
|
return {
|
|
1303
1551
|
content: [
|
|
1304
1552
|
{
|
|
@@ -1703,6 +1951,15 @@ function createSolvaPay(config) {
|
|
|
1703
1951
|
};
|
|
1704
1952
|
}
|
|
1705
1953
|
|
|
1954
|
+
// src/types/paywall.ts
|
|
1955
|
+
function isPaywallStructuredContent(value) {
|
|
1956
|
+
if (typeof value !== "object" || value === null || !("kind" in value)) {
|
|
1957
|
+
return false;
|
|
1958
|
+
}
|
|
1959
|
+
const kind = value.kind;
|
|
1960
|
+
return kind === "payment_required" || kind === "activation_required";
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1706
1963
|
// src/helpers/error.ts
|
|
1707
1964
|
import { SolvaPayError as SolvaPayError3 } from "@solvapay/core";
|
|
1708
1965
|
function isErrorResult(result) {
|
|
@@ -1728,26 +1985,128 @@ function handleRouteError(error, operationName, defaultMessage) {
|
|
|
1728
1985
|
}
|
|
1729
1986
|
|
|
1730
1987
|
// src/helpers/auth.ts
|
|
1988
|
+
function base64UrlDecode(input) {
|
|
1989
|
+
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
1990
|
+
const padding = padded.length % 4 === 0 ? "" : "=".repeat(4 - padded.length % 4);
|
|
1991
|
+
const base64 = padded + padding;
|
|
1992
|
+
if (typeof atob === "function") {
|
|
1993
|
+
const binary = atob(base64);
|
|
1994
|
+
let result = "";
|
|
1995
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1996
|
+
result += String.fromCharCode(binary.charCodeAt(i));
|
|
1997
|
+
}
|
|
1998
|
+
try {
|
|
1999
|
+
return decodeURIComponent(escape(result));
|
|
2000
|
+
} catch {
|
|
2001
|
+
return result;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
const BufferCtor = globalThis.Buffer;
|
|
2005
|
+
if (BufferCtor) {
|
|
2006
|
+
return BufferCtor.from(base64, "base64").toString("utf-8");
|
|
2007
|
+
}
|
|
2008
|
+
throw new Error("No base64 decoder available in this runtime");
|
|
2009
|
+
}
|
|
2010
|
+
function decodeJwtUnverified(token) {
|
|
2011
|
+
const parts = token.split(".");
|
|
2012
|
+
if (parts.length !== 3) return null;
|
|
2013
|
+
try {
|
|
2014
|
+
const json = base64UrlDecode(parts[1]);
|
|
2015
|
+
const payload = JSON.parse(json);
|
|
2016
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
2017
|
+
return payload;
|
|
2018
|
+
} catch {
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
function extractBearerToken(request) {
|
|
2023
|
+
const authHeader = request.headers.get("authorization");
|
|
2024
|
+
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) return null;
|
|
2025
|
+
const token = authHeader.slice(7).trim();
|
|
2026
|
+
return token.length > 0 ? token : null;
|
|
2027
|
+
}
|
|
2028
|
+
function readEnv(name) {
|
|
2029
|
+
const proc = globalThis.process;
|
|
2030
|
+
return proc?.env?.[name];
|
|
2031
|
+
}
|
|
2032
|
+
function isStrictMode() {
|
|
2033
|
+
return readEnv("SOLVAPAY_AUTH_STRICT") === "true";
|
|
2034
|
+
}
|
|
2035
|
+
function getConfiguredSecret() {
|
|
2036
|
+
return readEnv("SOLVAPAY_JWT_SECRET") || readEnv("SUPABASE_JWT_SECRET");
|
|
2037
|
+
}
|
|
2038
|
+
async function verifyHs256(token, secret) {
|
|
2039
|
+
try {
|
|
2040
|
+
const { jwtVerify } = await import("jose");
|
|
2041
|
+
const key = new TextEncoder().encode(secret);
|
|
2042
|
+
const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
|
|
2043
|
+
return payload;
|
|
2044
|
+
} catch {
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
function pickName(payload) {
|
|
2049
|
+
const metadataFullName = typeof payload.user_metadata?.full_name === "string" ? payload.user_metadata.full_name : null;
|
|
2050
|
+
const metadataName = typeof payload.user_metadata?.name === "string" ? payload.user_metadata.name : null;
|
|
2051
|
+
const claimName = typeof payload.name === "string" ? payload.name : null;
|
|
2052
|
+
return metadataFullName || metadataName || claimName || null;
|
|
2053
|
+
}
|
|
2054
|
+
function pickEmail(payload) {
|
|
2055
|
+
return typeof payload.email === "string" ? payload.email : null;
|
|
2056
|
+
}
|
|
2057
|
+
function unauthorized(details) {
|
|
2058
|
+
return { error: "Unauthorized", status: 401, details };
|
|
2059
|
+
}
|
|
1731
2060
|
async function getAuthenticatedUserCore(request, options = {}) {
|
|
1732
2061
|
try {
|
|
1733
|
-
const
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2062
|
+
const includeEmail = options.includeEmail !== false;
|
|
2063
|
+
const includeName = options.includeName !== false;
|
|
2064
|
+
const headerUserId = request.headers.get("x-user-id");
|
|
2065
|
+
if (headerUserId) {
|
|
2066
|
+
let email = null;
|
|
2067
|
+
let name = null;
|
|
2068
|
+
if (includeEmail || includeName) {
|
|
2069
|
+
const token2 = extractBearerToken(request);
|
|
2070
|
+
if (token2) {
|
|
2071
|
+
const secret2 = getConfiguredSecret();
|
|
2072
|
+
const payload2 = secret2 ? await verifyHs256(token2, secret2) : isStrictMode() ? null : decodeJwtUnverified(token2);
|
|
2073
|
+
if (payload2) {
|
|
2074
|
+
if (includeEmail) email = pickEmail(payload2);
|
|
2075
|
+
if (includeName) name = pickName(payload2);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
return { userId: headerUserId, email, name };
|
|
2080
|
+
}
|
|
2081
|
+
const token = extractBearerToken(request);
|
|
2082
|
+
if (!token) {
|
|
2083
|
+
return unauthorized("User ID not found. Ensure middleware is configured.");
|
|
2084
|
+
}
|
|
2085
|
+
const secret = getConfiguredSecret();
|
|
2086
|
+
let payload = null;
|
|
2087
|
+
if (secret) {
|
|
2088
|
+
payload = await verifyHs256(token, secret);
|
|
2089
|
+
if (!payload) {
|
|
2090
|
+
return unauthorized("Invalid or expired authentication token");
|
|
2091
|
+
}
|
|
2092
|
+
} else if (isStrictMode()) {
|
|
2093
|
+
return unauthorized(
|
|
2094
|
+
"Strict auth mode is enabled but no JWT secret is configured. Set SOLVAPAY_JWT_SECRET or SUPABASE_JWT_SECRET."
|
|
2095
|
+
);
|
|
2096
|
+
} else {
|
|
2097
|
+
payload = decodeJwtUnverified(token);
|
|
2098
|
+
if (!payload) {
|
|
2099
|
+
return unauthorized("Malformed authentication token");
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
const userId = typeof payload.sub === "string" ? payload.sub : null;
|
|
2103
|
+
if (!userId) {
|
|
2104
|
+
return unauthorized("Authentication token missing subject (sub) claim");
|
|
1743
2105
|
}
|
|
1744
|
-
const userId = userIdOrError;
|
|
1745
|
-
const email = options.includeEmail !== false ? await getUserEmailFromRequest(request) : null;
|
|
1746
|
-
const name = options.includeName !== false ? await getUserNameFromRequest(request) : null;
|
|
1747
2106
|
return {
|
|
1748
2107
|
userId,
|
|
1749
|
-
email,
|
|
1750
|
-
name
|
|
2108
|
+
email: includeEmail ? pickEmail(payload) : null,
|
|
2109
|
+
name: includeName ? pickName(payload) : null
|
|
1751
2110
|
};
|
|
1752
2111
|
} catch (error) {
|
|
1753
2112
|
return handleRouteError(error, "Get authenticated user", "Authentication failed");
|
|
@@ -2140,9 +2499,34 @@ async function activatePlanCore(request, body, options = {}) {
|
|
|
2140
2499
|
}
|
|
2141
2500
|
}
|
|
2142
2501
|
|
|
2502
|
+
// src/helpers/payment-method.ts
|
|
2503
|
+
async function getPaymentMethodCore(request, options = {}) {
|
|
2504
|
+
try {
|
|
2505
|
+
const customerResult = await syncCustomerCore(request, {
|
|
2506
|
+
solvaPay: options.solvaPay,
|
|
2507
|
+
includeEmail: options.includeEmail,
|
|
2508
|
+
includeName: options.includeName
|
|
2509
|
+
});
|
|
2510
|
+
if (isErrorResult(customerResult)) {
|
|
2511
|
+
return customerResult;
|
|
2512
|
+
}
|
|
2513
|
+
const customerRef = customerResult;
|
|
2514
|
+
const solvaPay = options.solvaPay || createSolvaPay();
|
|
2515
|
+
if (!solvaPay.apiClient.getPaymentMethod) {
|
|
2516
|
+
return {
|
|
2517
|
+
error: "getPaymentMethod is not implemented on this API client",
|
|
2518
|
+
status: 500
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
return await solvaPay.apiClient.getPaymentMethod({ customerRef });
|
|
2522
|
+
} catch (error) {
|
|
2523
|
+
return handleRouteError(error, "Get payment method", "Failed to load payment method");
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2143
2527
|
// src/helpers/plans.ts
|
|
2144
2528
|
import { getSolvaPayConfig as getSolvaPayConfig2 } from "@solvapay/core";
|
|
2145
|
-
async function listPlansCore(request) {
|
|
2529
|
+
async function listPlansCore(request, options = {}) {
|
|
2146
2530
|
try {
|
|
2147
2531
|
const url = new URL(request.url);
|
|
2148
2532
|
const productRef = url.searchParams.get("productRef");
|
|
@@ -2152,19 +2536,20 @@ async function listPlansCore(request) {
|
|
|
2152
2536
|
status: 400
|
|
2153
2537
|
};
|
|
2154
2538
|
}
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2539
|
+
const apiClient = options.solvaPay?.apiClient ?? (() => {
|
|
2540
|
+
const config = getSolvaPayConfig2();
|
|
2541
|
+
if (!config.apiKey) return null;
|
|
2542
|
+
return createSolvaPayClient({
|
|
2543
|
+
apiKey: config.apiKey,
|
|
2544
|
+
apiBaseUrl: config.apiBaseUrl
|
|
2545
|
+
});
|
|
2546
|
+
})();
|
|
2547
|
+
if (!apiClient) {
|
|
2159
2548
|
return {
|
|
2160
2549
|
error: "Server configuration error: SolvaPay secret key not configured",
|
|
2161
2550
|
status: 500
|
|
2162
2551
|
};
|
|
2163
2552
|
}
|
|
2164
|
-
const apiClient = createSolvaPayClient({
|
|
2165
|
-
apiKey: solvapaySecretKey,
|
|
2166
|
-
apiBaseUrl: solvapayApiBaseUrl
|
|
2167
|
-
});
|
|
2168
2553
|
if (!apiClient.listPlans) {
|
|
2169
2554
|
return {
|
|
2170
2555
|
error: "List plans method not available",
|
|
@@ -2181,6 +2566,76 @@ async function listPlansCore(request) {
|
|
|
2181
2566
|
}
|
|
2182
2567
|
}
|
|
2183
2568
|
|
|
2569
|
+
// src/helpers/merchant.ts
|
|
2570
|
+
import { getSolvaPayConfig as getSolvaPayConfig3 } from "@solvapay/core";
|
|
2571
|
+
async function getMerchantCore(_request, options = {}) {
|
|
2572
|
+
try {
|
|
2573
|
+
const apiClient = options.solvaPay?.apiClient ?? (() => {
|
|
2574
|
+
const config = getSolvaPayConfig3();
|
|
2575
|
+
if (!config.apiKey) return null;
|
|
2576
|
+
return createSolvaPayClient({
|
|
2577
|
+
apiKey: config.apiKey,
|
|
2578
|
+
apiBaseUrl: config.apiBaseUrl
|
|
2579
|
+
});
|
|
2580
|
+
})();
|
|
2581
|
+
if (!apiClient) {
|
|
2582
|
+
return {
|
|
2583
|
+
error: "Server configuration error: SolvaPay secret key not configured",
|
|
2584
|
+
status: 500
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
if (!apiClient.getMerchant) {
|
|
2588
|
+
return {
|
|
2589
|
+
error: "Get merchant method not available",
|
|
2590
|
+
status: 500
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
const merchant = await apiClient.getMerchant();
|
|
2594
|
+
return merchant;
|
|
2595
|
+
} catch (error) {
|
|
2596
|
+
return handleRouteError(error, "Get merchant", "Failed to fetch merchant");
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
// src/helpers/product.ts
|
|
2601
|
+
import { getSolvaPayConfig as getSolvaPayConfig4 } from "@solvapay/core";
|
|
2602
|
+
async function getProductCore(request, options = {}) {
|
|
2603
|
+
try {
|
|
2604
|
+
const url = new URL(request.url);
|
|
2605
|
+
const productRef = url.searchParams.get("productRef");
|
|
2606
|
+
if (!productRef) {
|
|
2607
|
+
return {
|
|
2608
|
+
error: "Missing required parameter: productRef",
|
|
2609
|
+
status: 400
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
const apiClient = options.solvaPay?.apiClient ?? (() => {
|
|
2613
|
+
const config = getSolvaPayConfig4();
|
|
2614
|
+
if (!config.apiKey) return null;
|
|
2615
|
+
return createSolvaPayClient({
|
|
2616
|
+
apiKey: config.apiKey,
|
|
2617
|
+
apiBaseUrl: config.apiBaseUrl
|
|
2618
|
+
});
|
|
2619
|
+
})();
|
|
2620
|
+
if (!apiClient) {
|
|
2621
|
+
return {
|
|
2622
|
+
error: "Server configuration error: SolvaPay secret key not configured",
|
|
2623
|
+
status: 500
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
if (!apiClient.getProduct) {
|
|
2627
|
+
return {
|
|
2628
|
+
error: "Get product method not available",
|
|
2629
|
+
status: 500
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
const product = await apiClient.getProduct(productRef);
|
|
2633
|
+
return product;
|
|
2634
|
+
} catch (error) {
|
|
2635
|
+
return handleRouteError(error, "Get product", "Failed to fetch product");
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2184
2639
|
// src/helpers/purchase.ts
|
|
2185
2640
|
async function checkPurchaseCore(request, options = {}) {
|
|
2186
2641
|
try {
|
|
@@ -2238,6 +2693,37 @@ async function checkPurchaseCore(request, options = {}) {
|
|
|
2238
2693
|
}
|
|
2239
2694
|
|
|
2240
2695
|
// src/helpers/usage.ts
|
|
2696
|
+
async function getUsageCore(request, options = {}) {
|
|
2697
|
+
const purchaseResult = await checkPurchaseCore(request, options);
|
|
2698
|
+
if (isErrorResult(purchaseResult)) return purchaseResult;
|
|
2699
|
+
const activePurchase = (purchaseResult.purchases ?? []).find((p) => p.status === "active");
|
|
2700
|
+
if (!activePurchase) {
|
|
2701
|
+
return {
|
|
2702
|
+
meterRef: null,
|
|
2703
|
+
total: null,
|
|
2704
|
+
used: 0,
|
|
2705
|
+
remaining: null,
|
|
2706
|
+
percentUsed: null
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
const snap = activePurchase.planSnapshot;
|
|
2710
|
+
const usage = activePurchase.usage;
|
|
2711
|
+
const meterRef = snap?.meterRef ?? snap?.meterId ?? null;
|
|
2712
|
+
const total = typeof snap?.limit === "number" ? snap.limit : null;
|
|
2713
|
+
const used = typeof usage?.used === "number" ? usage.used : 0;
|
|
2714
|
+
const remaining = total !== null ? Math.max(0, total - used) : null;
|
|
2715
|
+
const percentUsed = total !== null && total > 0 ? Math.min(100, Math.round(used / total * 1e4) / 100) : null;
|
|
2716
|
+
return {
|
|
2717
|
+
meterRef,
|
|
2718
|
+
total,
|
|
2719
|
+
used,
|
|
2720
|
+
remaining,
|
|
2721
|
+
percentUsed,
|
|
2722
|
+
...usage?.periodStart ? { periodStart: usage.periodStart } : {},
|
|
2723
|
+
...usage?.periodEnd ? { periodEnd: usage.periodEnd } : {},
|
|
2724
|
+
purchaseRef: activePurchase.reference
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2241
2727
|
async function trackUsageCore(request, body, options = {}) {
|
|
2242
2728
|
try {
|
|
2243
2729
|
const userResult = await getAuthenticatedUserCore(request);
|
|
@@ -2319,8 +2805,11 @@ async function verifyWebhook({
|
|
|
2319
2805
|
export {
|
|
2320
2806
|
PaywallError,
|
|
2321
2807
|
activatePlanCore,
|
|
2808
|
+
buildGateMessage,
|
|
2809
|
+
buildNudgeMessage,
|
|
2322
2810
|
cancelPurchaseCore,
|
|
2323
2811
|
checkPurchaseCore,
|
|
2812
|
+
classifyPaywallState,
|
|
2324
2813
|
createCheckoutSessionCore,
|
|
2325
2814
|
createCustomerSessionCore,
|
|
2326
2815
|
createPaymentIntentCore,
|
|
@@ -2329,8 +2818,13 @@ export {
|
|
|
2329
2818
|
createTopupPaymentIntentCore,
|
|
2330
2819
|
getAuthenticatedUserCore,
|
|
2331
2820
|
getCustomerBalanceCore,
|
|
2821
|
+
getMerchantCore,
|
|
2822
|
+
getPaymentMethodCore,
|
|
2823
|
+
getProductCore,
|
|
2824
|
+
getUsageCore,
|
|
2332
2825
|
handleRouteError,
|
|
2333
2826
|
isErrorResult,
|
|
2827
|
+
isPaywallStructuredContent,
|
|
2334
2828
|
listPlansCore,
|
|
2335
2829
|
paywallErrorToClientPayload,
|
|
2336
2830
|
processPaymentIntentCore,
|