fareql 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/dist/apollo.d.ts +19 -0
- package/dist/apollo.js +100 -0
- package/dist/core.d.ts +119 -0
- package/dist/core.js +211 -0
- package/dist/fetch.d.ts +81 -0
- package/dist/fetch.js +291 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/price.d.ts +103 -0
- package/dist/price.js +485 -0
- package/dist/yoga.d.ts +26 -0
- package/dist/yoga.js +205 -0
- package/package.json +83 -4
package/dist/core.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { GraphQLError as GraphQLErrorClass, parse, print, specifiedRules, validate, } from 'graphql';
|
|
2
|
+
import { PriceLimitError, PricingError, formatUnits, parseUnits, priceOperation, selectOperation, } from './price.js';
|
|
3
|
+
const MAX_VARIABLE_DEPTH = 64;
|
|
4
|
+
/**
|
|
5
|
+
* Canonical, unambiguous encoding of variable values: sorted keys, tagged files, no
|
|
6
|
+
* non-finite numbers, bounded depth. Equal inputs give equal strings and different
|
|
7
|
+
* inputs can never collide.
|
|
8
|
+
*/
|
|
9
|
+
function canonical(value, depth = 0) {
|
|
10
|
+
if (depth > MAX_VARIABLE_DEPTH)
|
|
11
|
+
throw new PricingError('Variables are nested too deeply', 'TOO_COMPLEX');
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return { $undefined: true };
|
|
14
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
15
|
+
return value;
|
|
16
|
+
if (typeof value === 'number') {
|
|
17
|
+
if (!Number.isFinite(value))
|
|
18
|
+
throw new PricingError('Variables must not contain non-finite numbers', 'INVALID_VARIABLES');
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'bigint')
|
|
22
|
+
return { $bigint: value.toString() };
|
|
23
|
+
if (Array.isArray(value))
|
|
24
|
+
return value.map((v) => canonical(v, depth + 1));
|
|
25
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) {
|
|
26
|
+
// Uploaded files: bound by name, type and size (the content does not change the price).
|
|
27
|
+
const f = value;
|
|
28
|
+
return { $blob: [f.name ?? '', f.type, f.size] };
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'object') {
|
|
31
|
+
const o = value;
|
|
32
|
+
return { $object: Object.keys(o).sort().map((k) => [k, canonical(o[k], depth + 1)]) };
|
|
33
|
+
}
|
|
34
|
+
throw new PricingError(`Unsupported variable value of type ${typeof value}`, 'INVALID_VARIABLES');
|
|
35
|
+
}
|
|
36
|
+
async function sha256Hex(text) {
|
|
37
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
38
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
39
|
+
}
|
|
40
|
+
export { sha256Hex };
|
|
41
|
+
/** Only the variables the operation declares can change what runs. */
|
|
42
|
+
function declaredVariables(o) {
|
|
43
|
+
const vars = o.variables ?? {};
|
|
44
|
+
const op = selectOperation(o.document, o.operationName);
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const d of op.variableDefinitions ?? []) {
|
|
47
|
+
const name = d.variable.name.value;
|
|
48
|
+
if (Object.hasOwn(vars, name))
|
|
49
|
+
out[name] = vars[name];
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Scope binding a Challenge to a set of operations: `graphql:<sha256 of document, operation name and variables>`.
|
|
55
|
+
* mppx stores it in the Challenge's HMAC-protected metadata and refuses Credentials whose scope differs.
|
|
56
|
+
*/
|
|
57
|
+
export async function operationScope(operations) {
|
|
58
|
+
const material = JSON.stringify(operations.map((o) => [print(o.document), o.operationName ?? null, canonical(declaredVariables(o))]));
|
|
59
|
+
return `graphql:${(await sha256Hex(material)).slice(0, 32)}`;
|
|
60
|
+
}
|
|
61
|
+
function sumPrices(prices, decimals) {
|
|
62
|
+
if (prices.length === 1)
|
|
63
|
+
return prices[0];
|
|
64
|
+
const units = prices.reduce((s, p) => s + p.units, 0n);
|
|
65
|
+
return {
|
|
66
|
+
amount: formatUnits(units, decimals),
|
|
67
|
+
units,
|
|
68
|
+
decimals,
|
|
69
|
+
breakdown: prices.flatMap((p, i) => p.breakdown.map((b) => ({ ...b, path: `${i}:${b.path}` }))).slice(0, 100),
|
|
70
|
+
...(prices.some((p) => p.breakdownTruncated) || prices.reduce((n, p) => n + p.breakdown.length, 0) > 100
|
|
71
|
+
? { breakdownTruncated: true }
|
|
72
|
+
: {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Prices one or more operations and charges for them through mppx. Every adapter (Fetch, Yoga,
|
|
77
|
+
* Apollo) goes through here. The price is recomputed from the retried request, so a Credential
|
|
78
|
+
* for a cheaper operation never pays for a more expensive one.
|
|
79
|
+
*/
|
|
80
|
+
export async function chargeOperations(request, schema, operations, options) {
|
|
81
|
+
const decimals = options.pricing?.decimals ?? 6;
|
|
82
|
+
let price;
|
|
83
|
+
let info;
|
|
84
|
+
try {
|
|
85
|
+
const prices = operations.map((o) => priceOperation({ schema, ...o }, options.pricing));
|
|
86
|
+
price = sumPrices(prices, decimals);
|
|
87
|
+
const max = options.pricing?.maxPrice;
|
|
88
|
+
if (max !== undefined && price.units > parseUnits(max, decimals))
|
|
89
|
+
throw new PriceLimitError(price.amount, max);
|
|
90
|
+
const first = operations[0];
|
|
91
|
+
const op = first ? selectOperation(first.document, first.operationName) : undefined;
|
|
92
|
+
info = { operationName: operations.length === 1 ? op?.name?.value : undefined, operationType: op?.operation ?? 'query' };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof PricingError)
|
|
96
|
+
return { status: 'rejected', error };
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
if (price.units === 0n)
|
|
100
|
+
return { status: 'free', price };
|
|
101
|
+
let scope;
|
|
102
|
+
try {
|
|
103
|
+
scope = await operationScope(operations);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof PricingError)
|
|
107
|
+
return { status: 'rejected', error };
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
const description = options.describe?.(info, price) ??
|
|
111
|
+
(operations.length > 1 ? `GraphQL batch of ${operations.length} operations` : `GraphQL ${info.operationName ?? info.operationType}`);
|
|
112
|
+
const result = await options.charge({ amount: price.amount, scope, description })(request);
|
|
113
|
+
if (result.status === 402)
|
|
114
|
+
return { status: 'payment-required', price, scope, challenge: result.challenge };
|
|
115
|
+
return { status: 'paid', price, scope, withReceipt: result.withReceipt };
|
|
116
|
+
}
|
|
117
|
+
export function priceExtension(price) {
|
|
118
|
+
return {
|
|
119
|
+
amount: price.amount,
|
|
120
|
+
decimals: price.decimals,
|
|
121
|
+
breakdown: price.breakdown,
|
|
122
|
+
...(price.breakdownTruncated ? { breakdownTruncated: true } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function paymentError(status, price, exposePrice) {
|
|
126
|
+
const code = status === 402 ? 'PAYMENT_REQUIRED' : status === 401 ? 'UNAUTHENTICATED' : 'FORBIDDEN';
|
|
127
|
+
return {
|
|
128
|
+
message: status === 402 ? `Payment required: this operation costs ${price.amount}` : 'Payment was not accepted for this operation',
|
|
129
|
+
extensions: exposePrice ? { code, price: priceExtension(price) } : { code },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function pricingErrorBody(error, exposePrice = true) {
|
|
133
|
+
if (!exposePrice && error.code === 'PRICE_LIMIT_EXCEEDED') {
|
|
134
|
+
return { message: 'Operation exceeds the maximum price', extensions: { code: error.code } };
|
|
135
|
+
}
|
|
136
|
+
return { message: error.message, extensions: { code: error.code } };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Turns mppx's 402 into a response GraphQL clients understand: same status and headers
|
|
140
|
+
* (WWW-Authenticate carries the Challenge), and a body with both the problem details mppx
|
|
141
|
+
* wrote and a GraphQL `errors` array. HTML paywalls are passed through untouched.
|
|
142
|
+
*/
|
|
143
|
+
export async function graphqlChallenge(challenge, price, exposePrice) {
|
|
144
|
+
const type = challenge.headers.get('content-type') ?? '';
|
|
145
|
+
if (type.includes('text/html') || challenge.status === 200)
|
|
146
|
+
return challenge;
|
|
147
|
+
let problem = {};
|
|
148
|
+
if (type.includes('json')) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = (await challenge.clone().json());
|
|
151
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
152
|
+
problem = parsed;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// keep an empty problem object
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const headers = new Headers(challenge.headers);
|
|
159
|
+
headers.set('content-type', 'application/json; charset=utf-8');
|
|
160
|
+
headers.delete('content-length');
|
|
161
|
+
const body = { ...problem, errors: [paymentError(challenge.status, price, exposePrice)] };
|
|
162
|
+
return new Response(JSON.stringify(body), { status: challenge.status, statusText: challenge.statusText, headers });
|
|
163
|
+
}
|
|
164
|
+
export function jsonResponse(status, errors, headers) {
|
|
165
|
+
const h = new Headers(headers);
|
|
166
|
+
h.set('content-type', 'application/json; charset=utf-8');
|
|
167
|
+
return new Response(JSON.stringify({ errors }), { status, headers: h });
|
|
168
|
+
}
|
|
169
|
+
/** Copies the headers mppx adds for a receipt onto an existing response. */
|
|
170
|
+
export function receiptHeaders(withReceipt) {
|
|
171
|
+
return withReceipt(new Response(null)).headers;
|
|
172
|
+
}
|
|
173
|
+
const CACHEABLE_QUERY_LENGTH = 32_768;
|
|
174
|
+
/** Small LRU of parse + validate results. Only short queries are cached, so memory stays bounded. */
|
|
175
|
+
export function createDocumentCache(limits = {}, max = 1000) {
|
|
176
|
+
const entries = new Map();
|
|
177
|
+
const maxQueryLength = limits.maxQueryLength ?? 100_000;
|
|
178
|
+
const maxTokens = limits.maxTokens ?? 15_000;
|
|
179
|
+
const rules = limits.validationRules ? [...specifiedRules, ...limits.validationRules] : undefined;
|
|
180
|
+
return {
|
|
181
|
+
get(schema, query) {
|
|
182
|
+
if (query.length > maxQueryLength) {
|
|
183
|
+
return { errors: [new GraphQLErrorClass(`Query is longer than ${maxQueryLength} characters`)] };
|
|
184
|
+
}
|
|
185
|
+
const hit = entries.get(query);
|
|
186
|
+
if (hit) {
|
|
187
|
+
entries.delete(query);
|
|
188
|
+
entries.set(query, hit);
|
|
189
|
+
return hit;
|
|
190
|
+
}
|
|
191
|
+
let result;
|
|
192
|
+
try {
|
|
193
|
+
const document = parse(query, { maxTokens });
|
|
194
|
+
const errors = validate(schema, document, rules);
|
|
195
|
+
result = errors.length > 0 ? { errors } : { document };
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
result = { errors: [error instanceof GraphQLErrorClass ? error : new GraphQLErrorClass(String(error.message))] };
|
|
199
|
+
}
|
|
200
|
+
if (query.length <= CACHEABLE_QUERY_LENGTH) {
|
|
201
|
+
entries.set(query, result);
|
|
202
|
+
if (entries.size > max)
|
|
203
|
+
entries.delete(entries.keys().next().value);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
},
|
|
207
|
+
clear() {
|
|
208
|
+
entries.clear();
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { GraphQLSchema } from 'graphql';
|
|
2
|
+
import { type DocumentLimits, type FareOptions } from './core.js';
|
|
3
|
+
import { type OperationPrice } from './price.js';
|
|
4
|
+
export type GraphQLParams = {
|
|
5
|
+
query?: string | null;
|
|
6
|
+
operationName?: string | null;
|
|
7
|
+
variables?: Record<string, unknown> | null;
|
|
8
|
+
extensions?: Record<string, unknown> | null;
|
|
9
|
+
/** GraphQL-over-HTTP persisted document identifier. */
|
|
10
|
+
documentId?: string | null;
|
|
11
|
+
};
|
|
12
|
+
/** Request bodies the gate reads besides `application/json`. Enable only what your handler accepts. */
|
|
13
|
+
export type Encoding = 'application/graphql+json' | 'application/graphql' | 'application/x-www-form-urlencoded' | 'multipart/form-data';
|
|
14
|
+
export type ReadOptions = {
|
|
15
|
+
/** Extra body encodings to accept. Default: none (GET and `application/json` only). */
|
|
16
|
+
encodings?: readonly Encoding[];
|
|
17
|
+
/** Largest request body read, in bytes. Default: 1 MB. */
|
|
18
|
+
maxBodyBytes?: number;
|
|
19
|
+
};
|
|
20
|
+
export declare class RequestError extends Error {
|
|
21
|
+
readonly status: number;
|
|
22
|
+
readonly code: string;
|
|
23
|
+
name: string;
|
|
24
|
+
constructor(message: string, status: number, code: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reads GraphQL-over-HTTP params without consuming the request body. It refuses anything that
|
|
28
|
+
* a handler could read differently (URL fragments, duplicate or unknown parameters, GraphQL
|
|
29
|
+
* parameters in the URL of a POST, bodies on other methods), so what is priced is what runs.
|
|
30
|
+
* Returns undefined when the request carries no GraphQL operation (e.g. a GraphiQL page).
|
|
31
|
+
* Throws {@link RequestError} for anything it will not price.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readGraphQLParams(request: Request, options?: ReadOptions): Promise<GraphQLParams | GraphQLParams[] | undefined>;
|
|
34
|
+
export type GateOptions = FareOptions & ReadOptions & DocumentLimits & {
|
|
35
|
+
schema: GraphQLSchema;
|
|
36
|
+
/** Accept JSON arrays of operations and charge their total in one payment. Default: false. */
|
|
37
|
+
batching?: boolean;
|
|
38
|
+
/** Most operations in one batch. Default: 10. */
|
|
39
|
+
maxBatchSize?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Looks up persisted documents (trusted documents, `documentId`, or APQ hashes your server
|
|
42
|
+
* stores itself). Without it, APQ still works: FareQL keeps its own hash → query cache.
|
|
43
|
+
*/
|
|
44
|
+
resolveDocument?: (params: GraphQLParams) => string | undefined | Promise<string | undefined>;
|
|
45
|
+
};
|
|
46
|
+
export type GateResult =
|
|
47
|
+
/** Nothing to charge (price 0, or not a GraphQL request). Run the handler. */
|
|
48
|
+
{
|
|
49
|
+
status: 'free';
|
|
50
|
+
price?: OperationPrice;
|
|
51
|
+
}
|
|
52
|
+
/** Return `response` (402 with the MPP Challenge). */
|
|
53
|
+
| {
|
|
54
|
+
status: 'payment-required';
|
|
55
|
+
response: Response;
|
|
56
|
+
price: OperationPrice;
|
|
57
|
+
}
|
|
58
|
+
/** Paid. Run the handler and pass its response through `withReceipt`. */
|
|
59
|
+
| {
|
|
60
|
+
status: 'paid';
|
|
61
|
+
price: OperationPrice;
|
|
62
|
+
withReceipt: (response: Response) => Response;
|
|
63
|
+
}
|
|
64
|
+
/** Must not run (unreadable, invalid, unpriceable or over the price limit). Return `response`. */
|
|
65
|
+
| {
|
|
66
|
+
status: 'rejected';
|
|
67
|
+
response: Response;
|
|
68
|
+
error: Error;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Creates a framework-agnostic payment gate. Call it with each request before executing
|
|
72
|
+
* GraphQL. It fails closed: anything it cannot read or price is answered, never executed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function createPaymentGate(options: GateOptions): (request: Request) => Promise<GateResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Wraps any Fetch-API GraphQL handler (graphql-http, Apollo's fetch integrations, a Hono or
|
|
77
|
+
* Next.js route). Free operations pass through; priced ones get a 402 Challenge, or run and
|
|
78
|
+
* receive a Payment-Receipt. For GraphQL Yoga and Apollo Server, prefer their plugins: they
|
|
79
|
+
* price after the server's own validation.
|
|
80
|
+
*/
|
|
81
|
+
export declare function withPayment(handler: (request: Request) => Response | Promise<Response>, options: GateOptions): (request: Request) => Promise<Response>;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { chargeOperations, createDocumentCache, graphqlChallenge, jsonResponse, pricingErrorBody, sha256Hex, } from './core.js';
|
|
2
|
+
import { PricingError, selectOperation } from './price.js';
|
|
3
|
+
export class RequestError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
name = 'RequestError';
|
|
7
|
+
constructor(message, status, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const PARAM_KEYS = ['query', 'operationName', 'variables', 'extensions', 'documentId'];
|
|
14
|
+
const bad = (message, status = 400, code = 'BAD_REQUEST') => new RequestError(message, status, code);
|
|
15
|
+
/** Content-type match as GraphQL Yoga does it: first entry of a list, case-sensitive, parameters allowed. */
|
|
16
|
+
function contentTypeIs(request, expected) {
|
|
17
|
+
const t = (request.headers.get('content-type') ?? '').split(',')[0]?.trim() ?? '';
|
|
18
|
+
return t === expected || t.startsWith(`${expected};`);
|
|
19
|
+
}
|
|
20
|
+
function parseJson(text, what) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw bad(`${what} is not valid JSON`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(typeof Blob !== 'undefined' && v instanceof Blob);
|
|
29
|
+
function hasGraphQLParams(p) {
|
|
30
|
+
return PARAM_KEYS.some((k) => p.has(k));
|
|
31
|
+
}
|
|
32
|
+
function fromSearchParams(p) {
|
|
33
|
+
if (!hasGraphQLParams(p))
|
|
34
|
+
return undefined;
|
|
35
|
+
for (const k of PARAM_KEYS)
|
|
36
|
+
if (p.getAll(k).length > 1)
|
|
37
|
+
throw bad(`Parameter "${k}" is given more than once`);
|
|
38
|
+
const variables = p.get('variables');
|
|
39
|
+
const extensions = p.get('extensions');
|
|
40
|
+
// Empty values count as absent, as in GraphQL Yoga.
|
|
41
|
+
return checkParams({
|
|
42
|
+
query: p.get('query') || null,
|
|
43
|
+
operationName: p.get('operationName') || null,
|
|
44
|
+
variables: variables ? parseJson(variables, 'variables') : null,
|
|
45
|
+
extensions: extensions ? parseJson(extensions, 'extensions') : null,
|
|
46
|
+
documentId: p.get('documentId') || null,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function setPath(target, path, value) {
|
|
50
|
+
const keys = path.split('.');
|
|
51
|
+
let cur = target;
|
|
52
|
+
for (const [i, key] of keys.entries()) {
|
|
53
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
|
|
54
|
+
return;
|
|
55
|
+
if (i === keys.length - 1)
|
|
56
|
+
cur[key] = value;
|
|
57
|
+
else {
|
|
58
|
+
if (typeof cur[key] !== 'object' || cur[key] === null)
|
|
59
|
+
cur[key] = {};
|
|
60
|
+
cur = cur[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Checks one set of params the way GraphQL Yoga does, so the gate never prices what the server would refuse. */
|
|
65
|
+
function checkParams(value) {
|
|
66
|
+
if (!isPlainObject(value))
|
|
67
|
+
throw bad('GraphQL params must be an object');
|
|
68
|
+
for (const key of Object.keys(value)) {
|
|
69
|
+
if (!PARAM_KEYS.includes(key))
|
|
70
|
+
throw bad(`Unexpected parameter "${key}"`);
|
|
71
|
+
}
|
|
72
|
+
const { query, operationName, variables, extensions, documentId } = value;
|
|
73
|
+
if (query != null && typeof query !== 'string')
|
|
74
|
+
throw bad('"query" must be a string');
|
|
75
|
+
if (operationName != null && (typeof operationName !== 'string' || operationName === '')) {
|
|
76
|
+
throw bad('"operationName" must be a non-empty string');
|
|
77
|
+
}
|
|
78
|
+
if (variables != null && !isPlainObject(variables))
|
|
79
|
+
throw bad('"variables" must be an object');
|
|
80
|
+
if (extensions != null && !isPlainObject(extensions))
|
|
81
|
+
throw bad('"extensions" must be an object');
|
|
82
|
+
if (documentId != null && typeof documentId !== 'string')
|
|
83
|
+
throw bad('"documentId" must be a string');
|
|
84
|
+
if (documentId != null && query != null)
|
|
85
|
+
throw bad('Send either "query" or "documentId", not both');
|
|
86
|
+
if (documentId != null && extensions?.persistedQuery !== undefined) {
|
|
87
|
+
throw bad('Send either "documentId" or extensions.persistedQuery, not both');
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function normalize(value) {
|
|
92
|
+
return Array.isArray(value) ? value.map(checkParams) : checkParams(value);
|
|
93
|
+
}
|
|
94
|
+
async function readBody(request, maxBytes) {
|
|
95
|
+
const declared = Number(request.headers.get('content-length') ?? 'NaN');
|
|
96
|
+
if (declared > maxBytes)
|
|
97
|
+
throw bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
98
|
+
const text = await request.clone().text();
|
|
99
|
+
if (text.length > maxBytes)
|
|
100
|
+
throw bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reads GraphQL-over-HTTP params without consuming the request body. It refuses anything that
|
|
105
|
+
* a handler could read differently (URL fragments, duplicate or unknown parameters, GraphQL
|
|
106
|
+
* parameters in the URL of a POST, bodies on other methods), so what is priced is what runs.
|
|
107
|
+
* Returns undefined when the request carries no GraphQL operation (e.g. a GraphiQL page).
|
|
108
|
+
* Throws {@link RequestError} for anything it will not price.
|
|
109
|
+
*/
|
|
110
|
+
export async function readGraphQLParams(request, options = {}) {
|
|
111
|
+
const encodings = options.encodings ?? [];
|
|
112
|
+
const maxBytes = options.maxBodyBytes ?? 1_000_000;
|
|
113
|
+
// URL parsers disagree about "#": URL drops it, GraphQL Yoga keeps what follows "?".
|
|
114
|
+
if (request.url.includes('#'))
|
|
115
|
+
throw bad('URL must not contain "#"');
|
|
116
|
+
const search = new URL(request.url).searchParams;
|
|
117
|
+
if (request.method === 'GET')
|
|
118
|
+
return fromSearchParams(search);
|
|
119
|
+
if (request.method === 'HEAD') {
|
|
120
|
+
if (hasGraphQLParams(search))
|
|
121
|
+
throw bad('Use GET or POST for GraphQL operations', 405, 'METHOD_NOT_ALLOWED');
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (request.method !== 'POST') {
|
|
125
|
+
if (request.body !== null || hasGraphQLParams(search))
|
|
126
|
+
throw bad(`Method ${request.method} is not allowed`, 405, 'METHOD_NOT_ALLOWED');
|
|
127
|
+
return undefined; // e.g. CORS preflight
|
|
128
|
+
}
|
|
129
|
+
if (hasGraphQLParams(search))
|
|
130
|
+
throw bad('A POST request must carry its GraphQL parameters in the body');
|
|
131
|
+
if (request.body === null)
|
|
132
|
+
throw bad('Missing request body');
|
|
133
|
+
const accepts = (type) => encodings.includes(type) && contentTypeIs(request, type);
|
|
134
|
+
if (contentTypeIs(request, 'application/json') || accepts('application/graphql+json')) {
|
|
135
|
+
return normalize(parseJson(await readBody(request, maxBytes), 'Request body'));
|
|
136
|
+
}
|
|
137
|
+
if (accepts('application/graphql'))
|
|
138
|
+
return { query: await readBody(request, maxBytes) };
|
|
139
|
+
if (accepts('application/x-www-form-urlencoded')) {
|
|
140
|
+
return fromSearchParams(new URLSearchParams(await readBody(request, maxBytes))) ?? checkParams({});
|
|
141
|
+
}
|
|
142
|
+
if (accepts('multipart/form-data')) {
|
|
143
|
+
const declared = Number(request.headers.get('content-length') ?? 'NaN');
|
|
144
|
+
if (declared > maxBytes)
|
|
145
|
+
throw bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
146
|
+
let form;
|
|
147
|
+
try {
|
|
148
|
+
form = await request.clone().formData();
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
throw bad(`Invalid multipart body: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
if (form.getAll('operations').length !== 1 || form.getAll('map').length > 1) {
|
|
154
|
+
throw bad('Multipart body needs exactly one "operations" field and at most one "map" field');
|
|
155
|
+
}
|
|
156
|
+
const ops = form.get('operations');
|
|
157
|
+
if (typeof ops !== 'string')
|
|
158
|
+
throw bad('Multipart field "operations" must be a string');
|
|
159
|
+
const operations = parseJson(ops, 'Multipart field "operations"');
|
|
160
|
+
const map = form.get('map');
|
|
161
|
+
if (typeof map === 'string') {
|
|
162
|
+
for (const [file, paths] of Object.entries(parseJson(map, 'Multipart field "map"'))) {
|
|
163
|
+
for (const path of Array.isArray(paths) ? paths : [])
|
|
164
|
+
if (typeof path === 'string')
|
|
165
|
+
setPath(operations, path, form.get(file));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return normalize(operations);
|
|
169
|
+
}
|
|
170
|
+
throw bad('Unsupported content type for this endpoint', 415, 'UNSUPPORTED_MEDIA_TYPE');
|
|
171
|
+
}
|
|
172
|
+
const reject = (status, errors, error, headers) => ({
|
|
173
|
+
status: 'rejected',
|
|
174
|
+
response: jsonResponse(status, errors, headers),
|
|
175
|
+
error,
|
|
176
|
+
});
|
|
177
|
+
const rejectWith = (status, code, message, headers) => reject(status, [{ message, extensions: { code } }], new RequestError(message, status, code), headers);
|
|
178
|
+
/** Hash of an Automatic Persisted Query, if the params carry one. */
|
|
179
|
+
function apqHash(params) {
|
|
180
|
+
const pq = params.extensions?.persistedQuery;
|
|
181
|
+
return typeof pq?.sha256Hash === 'string' ? pq.sha256Hash : undefined;
|
|
182
|
+
}
|
|
183
|
+
const APQ_CACHEABLE_LENGTH = 32_768;
|
|
184
|
+
/**
|
|
185
|
+
* Creates a framework-agnostic payment gate. Call it with each request before executing
|
|
186
|
+
* GraphQL. It fails closed: anything it cannot read or price is answered, never executed.
|
|
187
|
+
*/
|
|
188
|
+
export function createPaymentGate(options) {
|
|
189
|
+
const documents = createDocumentCache(options);
|
|
190
|
+
const apq = new Map();
|
|
191
|
+
const exposePrice = options.exposePrice ?? true;
|
|
192
|
+
const maxBatchSize = options.maxBatchSize ?? 10;
|
|
193
|
+
const queryOf = async (params) => {
|
|
194
|
+
const hash = apqHash(params);
|
|
195
|
+
if (typeof params.query === 'string' && params.query.length > 0) {
|
|
196
|
+
if (hash !== undefined) {
|
|
197
|
+
// Exact comparison, as Yoga and Apollo do: the hash names exactly this text.
|
|
198
|
+
if ((await sha256Hex(params.query)) !== hash) {
|
|
199
|
+
return rejectWith(400, 'PERSISTED_QUERY_HASH_MISMATCH', 'persistedQuery.sha256Hash does not match the query');
|
|
200
|
+
}
|
|
201
|
+
if (params.query.length <= APQ_CACHEABLE_LENGTH) {
|
|
202
|
+
apq.set(hash, params.query);
|
|
203
|
+
if (apq.size > 1000)
|
|
204
|
+
apq.delete(apq.keys().next().value);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return params.query;
|
|
208
|
+
}
|
|
209
|
+
const resolved = (await options.resolveDocument?.(params)) ?? (hash !== undefined ? apq.get(hash) : undefined);
|
|
210
|
+
if (resolved)
|
|
211
|
+
return resolved;
|
|
212
|
+
if (hash !== undefined) {
|
|
213
|
+
// Same reply as Apollo/Yoga: APQ clients resend the full query, which is then priced.
|
|
214
|
+
return reject(200, [{ message: 'PersistedQueryNotFound', extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' } }], new RequestError('PersistedQueryNotFound', 200, 'PERSISTED_QUERY_NOT_FOUND'));
|
|
215
|
+
}
|
|
216
|
+
if (params.documentId) {
|
|
217
|
+
return rejectWith(400, 'PERSISTED_DOCUMENT_NOT_FOUND', 'Unknown persisted document; pass resolveDocument to price persisted documents');
|
|
218
|
+
}
|
|
219
|
+
return rejectWith(400, 'BAD_REQUEST', 'Must provide a query string');
|
|
220
|
+
};
|
|
221
|
+
return async (request) => {
|
|
222
|
+
let read;
|
|
223
|
+
try {
|
|
224
|
+
read = await readGraphQLParams(request, options);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
const e = error instanceof RequestError ? error : new RequestError('Unreadable request', 400, 'BAD_REQUEST');
|
|
228
|
+
return reject(e.status, [{ message: e.message, extensions: { code: e.code } }], e, e.status === 405 ? { allow: 'GET, POST' } : undefined);
|
|
229
|
+
}
|
|
230
|
+
if (read === undefined)
|
|
231
|
+
return { status: 'free' };
|
|
232
|
+
if (Array.isArray(read) && !options.batching) {
|
|
233
|
+
return rejectWith(400, 'BATCHING_NOT_SUPPORTED', 'Batched requests are not accepted; send one operation per request');
|
|
234
|
+
}
|
|
235
|
+
const list = Array.isArray(read) ? read : [read];
|
|
236
|
+
if (list.length === 0)
|
|
237
|
+
return rejectWith(400, 'BAD_REQUEST', 'Empty batch');
|
|
238
|
+
if (list.length > maxBatchSize)
|
|
239
|
+
return rejectWith(400, 'BATCH_TOO_LARGE', `A batch may hold at most ${maxBatchSize} operations`);
|
|
240
|
+
const operations = [];
|
|
241
|
+
for (const params of list) {
|
|
242
|
+
const query = await queryOf(params);
|
|
243
|
+
if (typeof query !== 'string')
|
|
244
|
+
return query;
|
|
245
|
+
const parsed = documents.get(options.schema, query);
|
|
246
|
+
if (parsed.errors) {
|
|
247
|
+
return reject(400, parsed.errors.map((e) => ({ message: e.message, extensions: { code: 'GRAPHQL_VALIDATION_FAILED' } })), new RequestError(parsed.errors[0]?.message ?? 'Invalid document', 400, 'GRAPHQL_VALIDATION_FAILED'));
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const op = selectOperation(parsed.document, params.operationName);
|
|
251
|
+
if (request.method !== 'POST' && op.operation !== 'query') {
|
|
252
|
+
return rejectWith(405, 'METHOD_NOT_ALLOWED', `Can only perform a ${op.operation} operation from a POST request`, { allow: 'POST' });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof PricingError)
|
|
257
|
+
return reject(400, [pricingErrorBody(error, exposePrice)], error);
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
operations.push({ document: parsed.document, operationName: params.operationName, variables: params.variables });
|
|
261
|
+
}
|
|
262
|
+
const outcome = await chargeOperations(request, options.schema, operations, options);
|
|
263
|
+
switch (outcome.status) {
|
|
264
|
+
case 'free':
|
|
265
|
+
return { status: 'free', price: outcome.price };
|
|
266
|
+
case 'rejected':
|
|
267
|
+
return reject(400, [pricingErrorBody(outcome.error, exposePrice)], outcome.error);
|
|
268
|
+
case 'payment-required':
|
|
269
|
+
return { status: 'payment-required', price: outcome.price, response: await graphqlChallenge(outcome.challenge, outcome.price, exposePrice) };
|
|
270
|
+
case 'paid':
|
|
271
|
+
return { status: 'paid', price: outcome.price, withReceipt: outcome.withReceipt };
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Wraps any Fetch-API GraphQL handler (graphql-http, Apollo's fetch integrations, a Hono or
|
|
277
|
+
* Next.js route). Free operations pass through; priced ones get a 402 Challenge, or run and
|
|
278
|
+
* receive a Payment-Receipt. For GraphQL Yoga and Apollo Server, prefer their plugins: they
|
|
279
|
+
* price after the server's own validation.
|
|
280
|
+
*/
|
|
281
|
+
export function withPayment(handler, options) {
|
|
282
|
+
const gate = createPaymentGate(options);
|
|
283
|
+
return async (request) => {
|
|
284
|
+
const r = await gate(request);
|
|
285
|
+
if (r.status === 'free')
|
|
286
|
+
return handler(request);
|
|
287
|
+
if (r.status === 'paid')
|
|
288
|
+
return r.withReceipt(await handler(request));
|
|
289
|
+
return r.response;
|
|
290
|
+
};
|
|
291
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { priceOperation, selectOperation, parseUnits, formatUnits, fareDirectiveTypeDefs, PricingError, PriceLimitError, type PricingOptions, type PricingErrorCode, type OperationPrice, type PriceBreakdownItem, type FieldPriceContext, type PriceInput, } from './price.js';
|
|
2
|
+
export { chargeOperations, operationScope, type ChargeFactory, type ChargeOptions, type ChargeResult, type ChargeOutcome, type FareOptions, type OperationInfo, type OperationInput, } from './core.js';
|
|
3
|
+
export { createPaymentGate, withPayment, readGraphQLParams, RequestError, type GateOptions, type GateResult, type GraphQLParams, } from './fetch.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { priceOperation, selectOperation, parseUnits, formatUnits, fareDirectiveTypeDefs, PricingError, PriceLimitError, } from './price.js';
|
|
2
|
+
export { chargeOperations, operationScope, } from './core.js';
|
|
3
|
+
export { createPaymentGate, withPayment, readGraphQLParams, RequestError, } from './fetch.js';
|