fareql 0.1.0 → 0.1.2
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 +110 -60
- package/dist/apollo.js +8 -6
- package/dist/core.d.ts +3 -1
- package/dist/core.js +14 -4
- package/dist/fetch.d.ts +2 -1
- package/dist/fetch.js +38 -19
- package/dist/yoga.js +6 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,22 +1,47 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">FareQL</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<b>Every GraphQL query pays its own fare.</b><br>
|
|
5
|
+
Price each operation by what it selects and charge for it over <a href="https://mpp.dev">MPP</a> (HTTP 402).
|
|
6
|
+
</p>
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://www.npmjs.com/package/fareql"><img src="https://img.shields.io/npm/v/fareql?color=ffcb3d&labelColor=24211b" alt="npm"></a>
|
|
10
|
+
<a href="https://github.com/Bruce039/fareql/actions/workflows/ci.yml"><img src="https://github.com/Bruce039/fareql/actions/workflows/ci.yml/badge.svg" alt="ci"></a>
|
|
11
|
+
<a href="https://github.com/Bruce039/fareql/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/fareql?color=1f9d74&labelColor=24211b" alt="license"></a>
|
|
12
|
+
<img src="https://img.shields.io/badge/graphql-16%20%7C%2017-e535ab?labelColor=24211b" alt="graphql 16 | 17">
|
|
13
|
+
</p>
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://fareql.pages.dev"><b>Live demo</b></a> ·
|
|
17
|
+
<a href="#quick-start">Quick start</a> ·
|
|
18
|
+
<a href="#how-it-works">How it works</a> ·
|
|
19
|
+
<a href="#pricing-rules">Pricing rules</a> ·
|
|
20
|
+
<a href="#security-model">Security</a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
> **Try it in the browser at [fareql.pages.dev](https://fareql.pages.dev).** Type a query and the meter quotes it live. Pay with a demo method and you get the data and a receipt. No money moves.
|
|
24
|
+
> Shortcuts: [pay for a search](https://fareql.pages.dev/#ride=2&pay) · [slide a page size](https://fareql.pages.dev/#ride=7) · [try to ride for free](https://fareql.pages.dev/#dodge=0)
|
|
25
|
+
|
|
26
|
+
## Why FareQL
|
|
27
|
+
|
|
28
|
+
A GraphQL API is one endpoint, so a flat price per route doesn't fit it: `{ hello }` and `{ users(first: 100) { edges { node { email } } } }` hit the same URL. FareQL works out the fare of every operation from your schema and charges it through [`mppx`](https://github.com/wevm/mppx) with Tempo, Stripe or any other MPP method. Clients (apps, scripts, AI agents) pay per question. They don't need an account, an API key or a plan, and the money goes straight to your address.
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
|---|---|
|
|
32
|
+
| **Schema-driven prices** | `@price(amount: "0.001")` on fields and types, multiplied through lists, Relay connections and `@listSize` |
|
|
33
|
+
| **One Challenge per operation** | Bound to the exact document and variables. A Credential for a cheap query can't pay for an expensive one |
|
|
34
|
+
| **Charged after validation** | Persisted queries and depth limits run first, so nobody pays for an operation the server refuses |
|
|
35
|
+
| **Fails closed** | If FareQL can't read or price an operation, the operation doesn't run |
|
|
36
|
+
| **Red-teamed** | About 100 attack tests: parser differentials, cache bypasses, fragment bombs, scope collisions |
|
|
37
|
+
| **Works where you are** | GraphQL Yoga, Apollo Server 4/5 and any Fetch handler (Hono, Next.js, Workers), on graphql 16 and 17 |
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
13
40
|
|
|
14
41
|
```bash
|
|
15
42
|
npm install fareql mppx graphql
|
|
16
43
|
```
|
|
17
44
|
|
|
18
|
-
## GraphQL Yoga
|
|
19
|
-
|
|
20
45
|
```ts
|
|
21
46
|
import { createSchema, createYoga } from 'graphql-yoga'
|
|
22
47
|
import { Mppx, tempo } from 'mppx/server'
|
|
@@ -47,11 +72,25 @@ const yoga = createYoga({
|
|
|
47
72
|
|
|
48
73
|
`{ hello }` is free. `{ search(query: "mpp", first: 2) { snippet } }` costs `2 × (0.0005 + 0.0002) = 0.0014`.
|
|
49
74
|
|
|
50
|
-
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
1. **You price your schema.** Put `@price(amount: "0.001")` on whatever costs money.
|
|
78
|
+
2. **A client asks.** FareQL prices the operation from what it selects. Free operations run as usual.
|
|
79
|
+
3. **It gets a quote.** A priced operation answers `402 Payment Required` with an MPP Challenge for that exact operation, plus the price and its breakdown.
|
|
80
|
+
4. **It pays and asks again.** The client (an app, the `mppx` CLI, an AI agent) pays, for example in stablecoins on Tempo, and resends the query with the Credential.
|
|
81
|
+
5. **It gets the answer.** FareQL verifies the Credential through mppx, runs the operation and returns the data with a `Payment-Receipt`.
|
|
82
|
+
|
|
83
|
+
## Use it with your server
|
|
51
84
|
|
|
52
|
-
|
|
85
|
+
### GraphQL Yoga
|
|
53
86
|
|
|
54
|
-
|
|
87
|
+
Shown in [Quick start](#quick-start). Register `useFareql` after plugins that change what runs (APQ, persisted operations, validation rules). It hooks into execution, so it always prices the final document. A backstop before each response charges results that never reached execution, so a response cache or a plugin that short-circuits can't serve a priced result for free.
|
|
88
|
+
|
|
89
|
+
Yoga-only options:
|
|
90
|
+
- `getRequest(context)` supplies the HTTP request for transports that have none, such as graphql-ws. Without it, priced operations over those transports are refused.
|
|
91
|
+
- `maxSubscriptionEvents` (default 100) is how many events one payment buys on a priced subscription.
|
|
92
|
+
|
|
93
|
+
### Apollo Server
|
|
55
94
|
|
|
56
95
|
```ts
|
|
57
96
|
import { ApolloServer } from '@apollo/server'
|
|
@@ -64,9 +103,9 @@ const server = new ApolloServer({
|
|
|
64
103
|
})
|
|
65
104
|
```
|
|
66
105
|
|
|
67
|
-
The plugin charges in `didResolveOperation
|
|
106
|
+
The plugin charges in `didResolveOperation`. By then Apollo has parsed, validated and resolved the operation (APQ included), and its response cache hasn't answered yet. In an HTTP batch only one priced operation can be paid. Operations run in-process with `server.executeOperation()` have no HTTP request and are not charged.
|
|
68
107
|
|
|
69
|
-
|
|
108
|
+
### Any Fetch handler (Hono, Next.js, graphql-http, Cloudflare Workers)
|
|
70
109
|
|
|
71
110
|
```ts
|
|
72
111
|
import { withPayment } from 'fareql'
|
|
@@ -74,19 +113,19 @@ import { withPayment } from 'fareql'
|
|
|
74
113
|
export const POST = withPayment((req) => handler(req), { schema, charge: (o) => mppx.charge(o) })
|
|
75
114
|
```
|
|
76
115
|
|
|
77
|
-
`withPayment` reads the request itself, so it
|
|
116
|
+
`withPayment` reads the request itself, so it refuses anything the wrapped handler could read differently:
|
|
78
117
|
|
|
79
|
-
- It accepts GET and `application/json` POST by default. Enable `application/graphql`, `application/graphql+json`, form-urlencoded or multipart uploads with `encodings: [...]`,
|
|
80
|
-
- It refuses the following with 4xx before anything is charged:
|
|
81
|
-
- URL fragments
|
|
118
|
+
- It accepts GET and `application/json` POST by default. Enable `application/graphql`, `application/graphql+json`, form-urlencoded or multipart uploads with `encodings: [...]`, but only if your handler accepts them too.
|
|
119
|
+
- It refuses the following with a 4xx before anything is charged:
|
|
120
|
+
- URL fragments, duplicate or unknown parameters, and GraphQL parameters in a POST URL;
|
|
82
121
|
- non-object `variables`, and `query` sent together with `documentId`;
|
|
83
122
|
- bodies on other methods, and mutations over GET.
|
|
84
|
-
- `maxBodyBytes` (
|
|
85
|
-
- `validationRules` adds your server's extra rules
|
|
86
|
-
-
|
|
87
|
-
- Batches are refused unless you set `batching: true
|
|
123
|
+
- `maxBodyBytes` (1 MB), `maxQueryLength` (100 000), `maxTokens` (15 000) and `maxBatchSize` (10) bound the work.
|
|
124
|
+
- `validationRules` adds your server's extra rules, so operations they would reject are refused before payment.
|
|
125
|
+
- APQ works without a hash store in the gate: a hash-only request gets `PersistedQueryNotFound` and the full query the client resends is priced, so nobody pays for a hash the handler doesn't know. To price hashes or trusted documents (`documentId`) from your own store, pass `resolveDocument`.
|
|
126
|
+
- Batches are refused unless you set `batching: true`. With it, the whole batch is charged in one payment and `maxPrice` applies to the total.
|
|
88
127
|
|
|
89
|
-
For full control, `createPaymentGate(options)(request)` returns `free`, `payment-required
|
|
128
|
+
For full control, `createPaymentGate(options)(request)` returns one of `free`, `payment-required`, `paid` or `rejected`.
|
|
90
129
|
|
|
91
130
|
## Pricing rules
|
|
92
131
|
|
|
@@ -94,27 +133,31 @@ For full control, `createPaymentGate(options)(request)` returns `free`, `payment
|
|
|
94
133
|
|---|---|
|
|
95
134
|
| Field `@price` | Charged once per resolved instance of the field |
|
|
96
135
|
| Type `@price` | Applies to every field returning that type, unless the field has its own |
|
|
97
|
-
| Field selected several times | Charged once per response key, as the executor resolves it
|
|
98
|
-
| Lists | Children × list size
|
|
136
|
+
| Field selected several times | Charged once per response key, as the executor resolves it |
|
|
137
|
+
| Lists | Children × list size from `first`, `last`, `limit`, `take`, `top`, `pageSize` or `perPage` (also inside an input object, e.g. `page: { limit }`), otherwise `defaultListSize` (10). Capped at `maxListSize` (100). `null` or unbounded counts as `maxListSize`; negative sizes are refused |
|
|
99
138
|
| Nested lists | `[[Cell]]`: each inner level multiplies by `defaultListSize` |
|
|
100
|
-
| Connections and page wrappers | A non-list field given a size (`users(first: 50)`) passes it to the lists it selects (`edges`, `nodes`, `items`…). Restrict with `connections: { sizedFields }
|
|
101
|
-
| `@listSize` | As in the IBM GraphQL Cost Directives draft: `slicingArguments`, `sizedFields`, `assumedSize`, `requireOneSlicingArgument`
|
|
102
|
-
| Interfaces and unions |
|
|
103
|
-
| Code-first schemas | `extensions: { price: '0.001' }` or `extensions: { directives: { price: { amount: '0.001' } } }`
|
|
139
|
+
| Connections and page wrappers | A non-list field given a size (`users(first: 50)`) passes it to the lists it selects (`edges`, `nodes`, `items`…). Restrict with `connections: { sizedFields }`, or turn it off with `false` |
|
|
140
|
+
| `@listSize` | As in the IBM GraphQL Cost Directives draft: `slicingArguments`, `sizedFields`, `assumedSize`, `requireOneSlicingArgument` |
|
|
141
|
+
| Interfaces and unions | Every possible runtime type is priced and the most expensive one is charged. A `@price` on an interface field or type applies to its implementations |
|
|
142
|
+
| Code-first schemas | `extensions: { price: '0.001' }` or `extensions: { directives: { price: { amount: '0.001' } } }` |
|
|
104
143
|
| Fragments, `@skip`, `@include` | Followed and evaluated with the request variables |
|
|
105
144
|
| `__typename`, introspection | Free |
|
|
106
|
-
| `maxPrice` | Operations above it get `PRICE_LIMIT_EXCEEDED` before any Challenge
|
|
107
|
-
| `maxFieldVisits`, `maxDepth` | Operations too complex to price get `TOO_COMPLEX` (
|
|
108
|
-
| No price anywhere | A schema
|
|
145
|
+
| `maxPrice` | Operations above it get `PRICE_LIMIT_EXCEEDED` before any Challenge |
|
|
146
|
+
| `maxFieldVisits`, `maxDepth` | Operations too complex to price get `TOO_COMPLEX` (10 000 selections, 100 levels) |
|
|
147
|
+
| No price anywhere | A schema with no `@price` at all (e.g. one that lost its directives) is refused with `NO_PRICES` instead of making everything free |
|
|
148
|
+
|
|
149
|
+
Other options:
|
|
150
|
+
- `decimals` (default 6) must match your payment method's token.
|
|
151
|
+
- `defaultFieldPrice`, `listSizeArguments`, `connections`.
|
|
152
|
+
- `fieldPrice(ctx)` prices fields in code.
|
|
109
153
|
|
|
110
|
-
|
|
154
|
+
`priceOperation()` is exported for quotes and tests.
|
|
111
155
|
|
|
112
156
|
## What clients get
|
|
113
157
|
|
|
114
158
|
```http
|
|
115
159
|
HTTP/1.1 402 Payment Required
|
|
116
160
|
WWW-Authenticate: Payment id="…", realm="api.example.com", method="tempo", intent="charge", request="…"
|
|
117
|
-
Cache-Control: no-store
|
|
118
161
|
Content-Type: application/json; charset=utf-8
|
|
119
162
|
|
|
120
163
|
{
|
|
@@ -124,7 +167,6 @@ Content-Type: application/json; charset=utf-8
|
|
|
124
167
|
"code": "PAYMENT_REQUIRED",
|
|
125
168
|
"price": {
|
|
126
169
|
"amount": "0.0014",
|
|
127
|
-
"decimals": 6,
|
|
128
170
|
"breakdown": [
|
|
129
171
|
{ "path": "search", "unitAmount": "0.0005", "count": "2", "amount": "0.001" },
|
|
130
172
|
{ "path": "search.snippet", "unitAmount": "0.0002", "count": "2", "amount": "0.0004" }
|
|
@@ -135,17 +177,28 @@ Content-Type: application/json; charset=utf-8
|
|
|
135
177
|
}
|
|
136
178
|
```
|
|
137
179
|
|
|
138
|
-
|
|
180
|
+
Clients need nothing from FareQL, only an MPP client. With the mppx CLI and a Tempo testnet wallet:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npx mppx account create && npx mppx account fund
|
|
184
|
+
npx mppx https://api.example.com/graphql -X POST -H 'content-type: application/json' \
|
|
185
|
+
-d '{"query":"{ search(query: \"mpp\", first: 2) { snippet } }"}'
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
In code, `mppx/client` wraps `fetch`, so 402s are paid and retried automatically. The paid response carries a `Payment-Receipt` and `Cache-Control: private`, so shared caches never store paid content. Set `exposePrice: false` to keep prices out of error bodies.
|
|
139
189
|
|
|
140
190
|
## Security model
|
|
141
191
|
|
|
142
|
-
- **Binding.** Each Challenge's `scope` is a hash of the printed document, the operation name and the declared variables. mppx keeps it in the HMAC-protected Challenge and refuses a Credential whose scope differs.
|
|
143
|
-
- **Price what runs.** Yoga and Apollo
|
|
144
|
-
- **Fail closed.** Unreadable, unpriceable, over-limit or ambiguous requests are answered with an error and never
|
|
145
|
-
- **Bounded work.** Pricing is linear in the document, like graphql-js field collection. Token, length, depth,
|
|
146
|
-
- **Tested by attack.** `test/attacks-*.test.ts` hold the red-team suites
|
|
192
|
+
- **Binding.** Each Challenge's `scope` is a hash of the printed document, the operation name and the declared variables. mppx keeps it in the HMAC-protected Challenge and refuses a Credential whose scope differs.
|
|
193
|
+
- **Price what runs.** Yoga and Apollo charge after the server's own parsing, validation and persisted-document lookup. `withPayment` refuses requests a handler could parse differently.
|
|
194
|
+
- **Fail closed.** Unreadable, unpriceable, over-limit or ambiguous requests are answered with an error and never run.
|
|
195
|
+
- **Bounded work.** Pricing is linear in the document, like graphql-js field collection. Token, length, depth, body and batch limits keep it cheap.
|
|
196
|
+
- **Tested by attack.** [`test/attacks-*.test.ts`](https://github.com/Bruce039/fareql/tree/main/test) hold the red-team suites. Each test asserts the secure behaviour.
|
|
197
|
+
|
|
198
|
+
## Reference
|
|
147
199
|
|
|
148
|
-
|
|
200
|
+
<details>
|
|
201
|
+
<summary><b>Shared options</b></summary>
|
|
149
202
|
|
|
150
203
|
| Option | |
|
|
151
204
|
|---|---|
|
|
@@ -154,31 +207,28 @@ Any MPP client (`mppx`'s fetch wrapper, `npx mppx`, agents) pays and retries. Th
|
|
|
154
207
|
| `describe(op, price)` | Challenge description. Default: `GraphQL <operation name>` |
|
|
155
208
|
| `exposePrice` | Include the price breakdown in 402 bodies. Default: `true` |
|
|
156
209
|
|
|
157
|
-
|
|
210
|
+
</details>
|
|
158
211
|
|
|
159
|
-
|
|
212
|
+
<details>
|
|
213
|
+
<summary><b>Notes and limits</b></summary>
|
|
214
|
+
|
|
215
|
+
- Payment settles before execution, so a resolver error after payment is not refunded. Keep priced resolvers reliable.
|
|
160
216
|
- Subscriptions are charged once, when they start, for up to `maxSubscriptionEvents` events. Metered streams over MPP sessions are planned.
|
|
161
|
-
- Set `maxListSize` to your server's real page cap.
|
|
162
|
-
-
|
|
163
|
-
- In production, give `tempo.charge()` a shared `store` so a Credential can't be replayed across instances. This is an mppx setting.
|
|
217
|
+
- Set `maxListSize` to your server's real page cap. If the server returns more items than `maxListSize`, the client is under-charged, because larger requested sizes are clamped.
|
|
218
|
+
- In production, give `tempo.charge()` a shared `store` so a Credential can't be replayed across instances.
|
|
164
219
|
|
|
165
|
-
|
|
220
|
+
</details>
|
|
166
221
|
|
|
167
|
-
|
|
168
|
-
- [`examples/apollo-tempo`](examples/apollo-tempo/server.ts): Apollo Server with a priced Relay-style connection
|
|
169
|
-
- [`examples/playground`](examples/playground/server.ts): a local page that walks through quote → Challenge → Credential → Receipt. It uses a settlement-free demo method by default (`npx tsx examples/playground/server.ts`, then open http://localhost:4000), and real Moderato Challenges when `RECIPIENT` and `MPP_SECRET_KEY` are set
|
|
222
|
+
## Examples
|
|
170
223
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
npx mppx http://localhost:4000/graphql -X POST -H 'content-type: application/json' \
|
|
175
|
-
-d '{"query":"{ search(query: \"tempo\", first: 3) { title snippet } }"}'
|
|
176
|
-
```
|
|
224
|
+
- [`examples/playground`](https://github.com/Bruce039/fareql/tree/main/examples/playground): the [live demo](https://fareql.pages.dev). Run it locally with `npx tsx examples/playground/server.ts`; set `RECIPIENT` and `MPP_SECRET_KEY` for real Moderato testnet Challenges.
|
|
225
|
+
- [`examples/yoga-tempo`](https://github.com/Bruce039/fareql/blob/main/examples/yoga-tempo/server.ts): a minimal GraphQL Yoga server on Moderato.
|
|
226
|
+
- [`examples/apollo-tempo`](https://github.com/Bruce039/fareql/blob/main/examples/apollo-tempo/server.ts): Apollo Server with a priced Relay-style connection.
|
|
177
227
|
|
|
178
228
|
## Development
|
|
179
229
|
|
|
180
230
|
```bash
|
|
181
|
-
npm test
|
|
231
|
+
npm test # pricing, gate, Yoga, Apollo, attacks, real mppx loops
|
|
182
232
|
npm run build && node scripts/smoke.mjs # the built package over real HTTP in plain Node
|
|
183
233
|
```
|
|
184
234
|
|
package/dist/apollo.js
CHANGED
|
@@ -47,16 +47,18 @@ export function fareqlApolloPlugin(options) {
|
|
|
47
47
|
if (!http)
|
|
48
48
|
return;
|
|
49
49
|
const input = { document: rc.document, operationName: rc.operationName ?? rc.request.operationName, variables: rc.request.variables };
|
|
50
|
-
let
|
|
50
|
+
let price;
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
price = priceOperation({ schema: rc.schema, ...input }, options.pricing);
|
|
53
53
|
}
|
|
54
54
|
catch (e) {
|
|
55
|
-
if (e instanceof PricingError)
|
|
56
|
-
|
|
55
|
+
if (e instanceof PricingError) {
|
|
56
|
+
const body = pricingErrorBody(e, exposePrice);
|
|
57
|
+
throw fail(body.message, body.extensions, 400);
|
|
58
|
+
}
|
|
57
59
|
throw e;
|
|
58
60
|
}
|
|
59
|
-
if (units === 0n)
|
|
61
|
+
if (price.units === 0n)
|
|
60
62
|
return;
|
|
61
63
|
// Counted synchronously, before any await, so parallel batch pieces cannot race past it.
|
|
62
64
|
const n = (pricedPieces.get(http.headers) ?? 0) + 1;
|
|
@@ -64,7 +66,7 @@ export function fareqlApolloPlugin(options) {
|
|
|
64
66
|
if (n > 1) {
|
|
65
67
|
throw fail('Priced operations cannot be batched; send them one per request', { code: 'BATCHING_NOT_SUPPORTED' }, 400);
|
|
66
68
|
}
|
|
67
|
-
const outcome = await chargeOperations(toFetchRequest(http, options.url), rc.schema, [input], options);
|
|
69
|
+
const outcome = await chargeOperations(toFetchRequest(http, options.url), rc.schema, [input], options, [price]);
|
|
68
70
|
switch (outcome.status) {
|
|
69
71
|
case 'free':
|
|
70
72
|
return;
|
package/dist/core.d.ts
CHANGED
|
@@ -75,7 +75,9 @@ export declare function operationScope(operations: readonly OperationInput[]): P
|
|
|
75
75
|
* Apollo) goes through here. The price is recomputed from the retried request, so a Credential
|
|
76
76
|
* for a cheaper operation never pays for a more expensive one.
|
|
77
77
|
*/
|
|
78
|
-
export declare function chargeOperations(request: Request, schema: GraphQLSchema, operations: readonly OperationInput[], options: FareOptions
|
|
78
|
+
export declare function chargeOperations(request: Request, schema: GraphQLSchema, operations: readonly OperationInput[], options: FareOptions,
|
|
79
|
+
/** Prices already computed for `operations` (same order), to avoid pricing them twice. */
|
|
80
|
+
precomputed?: readonly OperationPrice[]): Promise<ChargeOutcome>;
|
|
79
81
|
export type GraphQLErrorBody = {
|
|
80
82
|
message: string;
|
|
81
83
|
extensions: Record<string, unknown>;
|
package/dist/core.js
CHANGED
|
@@ -77,12 +77,16 @@ function sumPrices(prices, decimals) {
|
|
|
77
77
|
* Apollo) goes through here. The price is recomputed from the retried request, so a Credential
|
|
78
78
|
* for a cheaper operation never pays for a more expensive one.
|
|
79
79
|
*/
|
|
80
|
-
export async function chargeOperations(request, schema, operations, options
|
|
80
|
+
export async function chargeOperations(request, schema, operations, options,
|
|
81
|
+
/** Prices already computed for `operations` (same order), to avoid pricing them twice. */
|
|
82
|
+
precomputed) {
|
|
81
83
|
const decimals = options.pricing?.decimals ?? 6;
|
|
82
84
|
let price;
|
|
83
85
|
let info;
|
|
84
86
|
try {
|
|
85
|
-
const prices =
|
|
87
|
+
const prices = precomputed && precomputed.length === operations.length
|
|
88
|
+
? precomputed
|
|
89
|
+
: operations.map((o) => priceOperation({ schema, ...o }, options.pricing));
|
|
86
90
|
price = sumPrices(prices, decimals);
|
|
87
91
|
const max = options.pricing?.maxPrice;
|
|
88
92
|
if (max !== undefined && price.units > parseUnits(max, decimals))
|
|
@@ -173,7 +177,8 @@ export function receiptHeaders(withReceipt) {
|
|
|
173
177
|
const CACHEABLE_QUERY_LENGTH = 32_768;
|
|
174
178
|
/** Small LRU of parse + validate results. Only short queries are cached, so memory stays bounded. */
|
|
175
179
|
export function createDocumentCache(limits = {}, max = 1000) {
|
|
176
|
-
|
|
180
|
+
// Validation depends on the schema, so results are cached per schema (Yoga can swap schemas).
|
|
181
|
+
const bySchema = new WeakMap();
|
|
177
182
|
const maxQueryLength = limits.maxQueryLength ?? 100_000;
|
|
178
183
|
const maxTokens = limits.maxTokens ?? 15_000;
|
|
179
184
|
const rules = limits.validationRules ? [...specifiedRules, ...limits.validationRules] : undefined;
|
|
@@ -182,6 +187,11 @@ export function createDocumentCache(limits = {}, max = 1000) {
|
|
|
182
187
|
if (query.length > maxQueryLength) {
|
|
183
188
|
return { errors: [new GraphQLErrorClass(`Query is longer than ${maxQueryLength} characters`)] };
|
|
184
189
|
}
|
|
190
|
+
let entries = bySchema.get(schema);
|
|
191
|
+
if (!entries) {
|
|
192
|
+
entries = new Map();
|
|
193
|
+
bySchema.set(schema, entries);
|
|
194
|
+
}
|
|
185
195
|
const hit = entries.get(query);
|
|
186
196
|
if (hit) {
|
|
187
197
|
entries.delete(query);
|
|
@@ -205,7 +215,7 @@ export function createDocumentCache(limits = {}, max = 1000) {
|
|
|
205
215
|
return result;
|
|
206
216
|
},
|
|
207
217
|
clear() {
|
|
208
|
-
entries.
|
|
218
|
+
// WeakMap entries go away with their schema; nothing to do eagerly.
|
|
209
219
|
},
|
|
210
220
|
};
|
|
211
221
|
}
|
package/dist/fetch.d.ts
CHANGED
|
@@ -39,7 +39,8 @@ export type GateOptions = FareOptions & ReadOptions & DocumentLimits & {
|
|
|
39
39
|
maxBatchSize?: number;
|
|
40
40
|
/**
|
|
41
41
|
* Looks up persisted documents (trusted documents, `documentId`, or APQ hashes your server
|
|
42
|
-
* stores itself). Without it, APQ
|
|
42
|
+
* stores itself). Without it, hash-only APQ requests get PersistedQueryNotFound and the client
|
|
43
|
+
* resends the full query, which is then priced.
|
|
43
44
|
*/
|
|
44
45
|
resolveDocument?: (params: GraphQLParams) => string | undefined | Promise<string | undefined>;
|
|
45
46
|
};
|
package/dist/fetch.js
CHANGED
|
@@ -91,14 +91,40 @@ function checkParams(value) {
|
|
|
91
91
|
function normalize(value) {
|
|
92
92
|
return Array.isArray(value) ? value.map(checkParams) : checkParams(value);
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
const tooLarge = (maxBytes) => bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
95
|
+
/** Reads a clone of the body as bytes, stopping as soon as it passes `maxBytes` (chunked bodies included). */
|
|
96
|
+
async function readBytes(request, maxBytes) {
|
|
95
97
|
const declared = Number(request.headers.get('content-length') ?? 'NaN');
|
|
96
98
|
if (declared > maxBytes)
|
|
97
|
-
throw
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
throw tooLarge(maxBytes);
|
|
100
|
+
const body = request.clone().body;
|
|
101
|
+
if (!body)
|
|
102
|
+
return new Uint8Array(new ArrayBuffer(0));
|
|
103
|
+
const reader = body.getReader();
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let size = 0;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const { done, value } = await reader.read();
|
|
108
|
+
if (done)
|
|
109
|
+
break;
|
|
110
|
+
size += value.byteLength;
|
|
111
|
+
if (size > maxBytes) {
|
|
112
|
+
// Not awaited: cancelling one branch of a teed body can wait on the other branch.
|
|
113
|
+
reader.cancel().catch(() => { });
|
|
114
|
+
throw tooLarge(maxBytes);
|
|
115
|
+
}
|
|
116
|
+
chunks.push(value);
|
|
117
|
+
}
|
|
118
|
+
const out = new Uint8Array(new ArrayBuffer(size));
|
|
119
|
+
let offset = 0;
|
|
120
|
+
for (const c of chunks) {
|
|
121
|
+
out.set(c, offset);
|
|
122
|
+
offset += c.byteLength;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
async function readBody(request, maxBytes) {
|
|
127
|
+
return new TextDecoder().decode(await readBytes(request, maxBytes));
|
|
102
128
|
}
|
|
103
129
|
/**
|
|
104
130
|
* Reads GraphQL-over-HTTP params without consuming the request body. It refuses anything that
|
|
@@ -140,12 +166,10 @@ export async function readGraphQLParams(request, options = {}) {
|
|
|
140
166
|
return fromSearchParams(new URLSearchParams(await readBody(request, maxBytes))) ?? checkParams({});
|
|
141
167
|
}
|
|
142
168
|
if (accepts('multipart/form-data')) {
|
|
143
|
-
const
|
|
144
|
-
if (declared > maxBytes)
|
|
145
|
-
throw bad(`Request body is larger than ${maxBytes} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
169
|
+
const bytes = await readBytes(request, maxBytes);
|
|
146
170
|
let form;
|
|
147
171
|
try {
|
|
148
|
-
form = await request.
|
|
172
|
+
form = await new Response(bytes, { headers: { 'content-type': request.headers.get('content-type') ?? '' } }).formData();
|
|
149
173
|
}
|
|
150
174
|
catch (e) {
|
|
151
175
|
throw bad(`Invalid multipart body: ${e.message}`);
|
|
@@ -180,14 +204,12 @@ function apqHash(params) {
|
|
|
180
204
|
const pq = params.extensions?.persistedQuery;
|
|
181
205
|
return typeof pq?.sha256Hash === 'string' ? pq.sha256Hash : undefined;
|
|
182
206
|
}
|
|
183
|
-
const APQ_CACHEABLE_LENGTH = 32_768;
|
|
184
207
|
/**
|
|
185
208
|
* Creates a framework-agnostic payment gate. Call it with each request before executing
|
|
186
209
|
* GraphQL. It fails closed: anything it cannot read or price is answered, never executed.
|
|
187
210
|
*/
|
|
188
211
|
export function createPaymentGate(options) {
|
|
189
212
|
const documents = createDocumentCache(options);
|
|
190
|
-
const apq = new Map();
|
|
191
213
|
const exposePrice = options.exposePrice ?? true;
|
|
192
214
|
const maxBatchSize = options.maxBatchSize ?? 10;
|
|
193
215
|
const queryOf = async (params) => {
|
|
@@ -198,19 +220,16 @@ export function createPaymentGate(options) {
|
|
|
198
220
|
if ((await sha256Hex(params.query)) !== hash) {
|
|
199
221
|
return rejectWith(400, 'PERSISTED_QUERY_HASH_MISMATCH', 'persistedQuery.sha256Hash does not match the query');
|
|
200
222
|
}
|
|
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
223
|
}
|
|
207
224
|
return params.query;
|
|
208
225
|
}
|
|
209
|
-
const resolved =
|
|
226
|
+
const resolved = await options.resolveDocument?.(params);
|
|
210
227
|
if (resolved)
|
|
211
228
|
return resolved;
|
|
212
229
|
if (hash !== undefined) {
|
|
213
|
-
// Same reply as Apollo/Yoga: APQ clients resend the full query, which is then priced.
|
|
230
|
+
// Same reply as Apollo/Yoga: APQ clients resend the full query, which is then priced. The gate
|
|
231
|
+
// keeps no hash store of its own: the wrapped handler might not know the hash, and the client
|
|
232
|
+
// would pay for an operation that never runs.
|
|
214
233
|
return reject(200, [{ message: 'PersistedQueryNotFound', extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' } }], new RequestError('PersistedQueryNotFound', 200, 'PERSISTED_QUERY_NOT_FOUND'));
|
|
215
234
|
}
|
|
216
235
|
if (params.documentId) {
|
package/dist/yoga.js
CHANGED
|
@@ -68,15 +68,16 @@ export function useFareql(options) {
|
|
|
68
68
|
const schema = options.schema ?? args.schema;
|
|
69
69
|
const input = { document: args.document, operationName: args.operationName, variables: args.variableValues };
|
|
70
70
|
const request = options.getRequest?.(args.contextValue) ?? args.contextValue.request;
|
|
71
|
-
let
|
|
71
|
+
let price;
|
|
72
72
|
try {
|
|
73
|
-
|
|
73
|
+
price = priceOperation({ schema, ...input }, options.pricing);
|
|
74
74
|
}
|
|
75
75
|
catch (e) {
|
|
76
76
|
if (e instanceof PricingError)
|
|
77
77
|
return { stop: reject(e), paid: false };
|
|
78
78
|
throw e;
|
|
79
79
|
}
|
|
80
|
+
const units = price.units;
|
|
80
81
|
if (!request) {
|
|
81
82
|
// No HTTP request to carry a Credential (e.g. graphql-ws): fail closed for priced operations.
|
|
82
83
|
if (units === 0n)
|
|
@@ -94,7 +95,7 @@ export function useFareql(options) {
|
|
|
94
95
|
// One request carries one Credential, so priced operations cannot share a batch.
|
|
95
96
|
return { stop: failure('Priced operations cannot be batched; send them one per request', { code: 'BATCHING_NOT_SUPPORTED' }, 400), paid: false };
|
|
96
97
|
}
|
|
97
|
-
const outcome = await chargeOperations(forPayment(request), schema, [input], options);
|
|
98
|
+
const outcome = await chargeOperations(forPayment(request), schema, [input], options, [price]);
|
|
98
99
|
const stop = settle(request, outcome);
|
|
99
100
|
if (!stop)
|
|
100
101
|
allowed.set(request, (allowed.get(request) ?? 0) + 1);
|
|
@@ -125,7 +126,8 @@ export function useFareql(options) {
|
|
|
125
126
|
onRequestParse({ request }) {
|
|
126
127
|
return {
|
|
127
128
|
onRequestParseDone({ requestParserResult }) {
|
|
128
|
-
|
|
129
|
+
// Any array is a batch, even with one operation: Yoga answers it with an array.
|
|
130
|
+
if (Array.isArray(requestParserResult))
|
|
129
131
|
batches.add(request);
|
|
130
132
|
},
|
|
131
133
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fareql",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Charge for GraphQL operations over MPP (HTTP 402), priced by what each operation selects. Plugins for GraphQL Yoga and Apollo Server, plus a wrapper for any Fetch handler.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|