fareql 0.1.1 → 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 CHANGED
@@ -1,34 +1,46 @@
1
- # FareQL
1
+ <h1 align="center">FareQL</h1>
2
2
 
3
- [![npm](https://img.shields.io/npm/v/fareql)](https://www.npmjs.com/package/fareql)
4
- [![ci](https://github.com/Bruce039/fareql/actions/workflows/ci.yml/badge.svg)](https://github.com/Bruce039/fareql/actions/workflows/ci.yml)
5
- [![license](https://img.shields.io/npm/l/fareql)](https://github.com/Bruce039/fareql/blob/main/LICENSE)
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>
6
7
 
7
- Charge for GraphQL operations over [MPP](https://mpp.dev) (HTTP 402), priced by what each operation selects.
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>
8
14
 
9
- 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.
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>
10
22
 
11
- - **Schema-driven prices.** `@price(amount: "0.001")` on fields and types, multiplied through lists, Relay connections and `@listSize`.
12
- - **One Challenge per operation**, bound to the exact document and variables. A Credential paid for a cheap query can't be replayed on an expensive one; mppx enforces this through the Challenge's HMAC.
13
- - **Charged after validation, right before execution.** Persisted queries and depth limits are already applied when FareQL charges, so nobody pays for an operation the server then refuses.
14
- - **Fails closed.** If FareQL can't read or price an operation, the operation doesn't run.
15
- - **Ready for GraphQL clients.** The 402 keeps mppx's `WWW-Authenticate` Challenge and adds a GraphQL `errors` entry with the price and its breakdown.
16
- - Works with **GraphQL Yoga**, **Apollo Server 4/5** and **any Fetch-API handler**, on graphql 16 and 17.
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)
17
25
 
18
- ```bash
19
- npm install fareql mppx graphql
20
- ```
26
+ ## Why FareQL
21
27
 
22
- ## How it works
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.
23
29
 
24
- 1. You mark what costs money in your schema: `@price(amount: "0.001")`.
25
- 2. A client sends a query. FareQL prices it from what it selects. Free operations run as usual.
26
- 3. A priced operation gets `402 Payment Required` with an MPP Challenge for that exact operation, plus the price and its breakdown.
27
- 4. The client (an app, the `mppx` CLI, an AI agent) pays, for example in stablecoins on Tempo, and resends the query with the Credential. FareQL verifies it through mppx, runs the operation, and returns the data with a `Payment-Receipt`.
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 |
28
38
 
29
- Payments go straight to your own address. There is no account, API key or subscription for the client to set up.
39
+ ## Quick start
30
40
 
31
- ## GraphQL Yoga
41
+ ```bash
42
+ npm install fareql mppx graphql
43
+ ```
32
44
 
33
45
  ```ts
34
46
  import { createSchema, createYoga } from 'graphql-yoga'
@@ -60,11 +72,25 @@ const yoga = createYoga({
60
72
 
61
73
  `{ hello }` is free. `{ search(query: "mpp", first: 2) { snippet } }` costs `2 × (0.0005 + 0.0002) = 0.0014`.
62
74
 
63
- 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 (`useResponseCache`) or a plugin that short-circuits cannot serve a priced result for free.
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
64
84
 
65
- Yoga-only options: `getRequest(context)` supplies the HTTP request for transports that have none (graphql-ws); without it, priced operations over such transports are refused. `maxSubscriptionEvents` (default 100) is how many events one payment buys on a priced subscription; the stream then ends with `PAYMENT_EXHAUSTED`.
85
+ ### GraphQL Yoga
66
86
 
67
- ## Apollo Server
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
68
94
 
69
95
  ```ts
70
96
  import { ApolloServer } from '@apollo/server'
@@ -77,9 +103,9 @@ const server = new ApolloServer({
77
103
  })
78
104
  ```
79
105
 
80
- The plugin charges in `didResolveOperation`, after Apollo has parsed, validated and resolved the operation, APQ included, and before Apollo's response cache can answer. In an HTTP batch only one priced operation can be paid. Operations you run in-process with `server.executeOperation()` have no HTTP request and are not charged.
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.
81
107
 
82
- ## Any Fetch handler (Hono, Next.js, graphql-http, Cloudflare Workers)
108
+ ### Any Fetch handler (Hono, Next.js, graphql-http, Cloudflare Workers)
83
109
 
84
110
  ```ts
85
111
  import { withPayment } from 'fareql'
@@ -87,19 +113,19 @@ import { withPayment } from 'fareql'
87
113
  export const POST = withPayment((req) => handler(req), { schema, charge: (o) => mppx.charge(o) })
88
114
  ```
89
115
 
90
- `withPayment` reads the request itself, so it is strict about anything the wrapped handler could read differently:
116
+ `withPayment` reads the request itself, so it refuses anything the wrapped handler could read differently:
91
117
 
92
- - It accepts GET and `application/json` POST by default. Enable `application/graphql`, `application/graphql+json`, form-urlencoded or multipart uploads with `encodings: [...]`, and only if your handler accepts them, so a client is never charged for a body the handler then refuses.
93
- - It refuses the following with 4xx before anything is charged:
94
- - URL fragments (`#`), duplicate or unknown parameters, and GraphQL parameters in the URL of a POST;
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;
95
121
  - non-object `variables`, and `query` sent together with `documentId`;
96
122
  - bodies on other methods, and mutations over GET.
97
- - `maxBodyBytes` (default 1 MB), `maxQueryLength` (100 000), `maxTokens` (15 000) and `maxBatchSize` (10) bound the work.
98
- - `validationRules` adds your server's extra rules (depth limits and so on), so operations they reject are refused before payment.
99
- - It speaks APQ: a hash it hasn't seen gets `PersistedQueryNotFound`, the client resends the full query, and that query is priced. For trusted documents (`documentId`), pass `resolveDocument`.
100
- - Batches are refused unless you set `batching: true`; then the whole batch is charged in one payment, and `maxPrice` applies to the total.
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.
101
127
 
102
- For full control, `createPaymentGate(options)(request)` returns `free`, `payment-required` (with the 402 `response`), `paid` (with `withReceipt`) or `rejected` (with the response to send).
128
+ For full control, `createPaymentGate(options)(request)` returns one of `free`, `payment-required`, `paid` or `rejected`.
103
129
 
104
130
  ## Pricing rules
105
131
 
@@ -107,27 +133,31 @@ For full control, `createPaymentGate(options)(request)` returns `free`, `payment
107
133
  |---|---|
108
134
  | Field `@price` | Charged once per resolved instance of the field |
109
135
  | Type `@price` | Applies to every field returning that type, unless the field has its own |
110
- | Field selected several times | Charged once per response key, as the executor resolves it (fragments merged, repeated spreads ignored) |
111
- | Lists | Children × list size, read from `first`, `last`, `limit`, `take`, `top`, `pageSize` or `perPage` (also one level inside an input object, e.g. `page: { limit }`); otherwise `defaultListSize` (10). Capped at `maxListSize` (100). `null` or an unbounded number counts as `maxListSize`; a negative size is refused (`INVALID_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 |
112
138
  | Nested lists | `[[Cell]]`: each inner level multiplies by `defaultListSize` |
113
- | 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 off with `false` |
114
- | `@listSize` | As in the IBM GraphQL Cost Directives draft: `slicingArguments`, `sizedFields`, `assumedSize`, `requireOneSlicingArgument` (default true) |
115
- | Interfaces and unions | Each possible runtime type is priced and the most expensive one is charged. Branches are never added together. `@price` on an interface field or interface type applies to the types implementing it |
116
- | Code-first schemas | `extensions: { price: '0.001' }` or `extensions: { directives: { price: { amount: '0.001' } } }` work like the directive |
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' } } }` |
117
143
  | Fragments, `@skip`, `@include` | Followed and evaluated with the request variables |
118
144
  | `__typename`, introspection | Free |
119
- | `maxPrice` | Operations above it get `PRICE_LIMIT_EXCEEDED` before any Challenge is issued |
120
- | `maxFieldVisits`, `maxDepth` | Operations too complex to price get `TOO_COMPLEX` (defaults 10 000 selections, 100 levels) |
121
- | No price anywhere | A schema without any `@price` (lost directives, an introspection copy) is refused with `NO_PRICES` instead of making everything free |
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.
122
153
 
123
- Other options: `decimals` (default 6, which must match your payment method's token), `defaultFieldPrice`, `listSizeArguments`, `connections` (`false` or `{ sizedFields }`), and `fieldPrice(ctx)` to price fields in code. `priceOperation()` is exported for quotes and tests. The breakdown lists at most 100 items (`breakdownTruncated: true` when there are more).
154
+ `priceOperation()` is exported for quotes and tests.
124
155
 
125
156
  ## What clients get
126
157
 
127
158
  ```http
128
159
  HTTP/1.1 402 Payment Required
129
160
  WWW-Authenticate: Payment id="…", realm="api.example.com", method="tempo", intent="charge", request="…"
130
- Cache-Control: no-store
131
161
  Content-Type: application/json; charset=utf-8
132
162
 
133
163
  {
@@ -137,7 +167,6 @@ Content-Type: application/json; charset=utf-8
137
167
  "code": "PAYMENT_REQUIRED",
138
168
  "price": {
139
169
  "amount": "0.0014",
140
- "decimals": 6,
141
170
  "breakdown": [
142
171
  { "path": "search", "unitAmount": "0.0005", "count": "2", "amount": "0.001" },
143
172
  { "path": "search.snippet", "unitAmount": "0.0002", "count": "2", "amount": "0.0004" }
@@ -156,17 +185,20 @@ npx mppx https://api.example.com/graphql -X POST -H 'content-type: application/j
156
185
  -d '{"query":"{ search(query: \"mpp\", first: 2) { snippet } }"}'
157
186
  ```
158
187
 
159
- In code, `mppx/client` wraps `fetch` so 402s are paid and retried automatically, and MPP-aware agents do the same. The paid response carries `Payment-Receipt` and `Cache-Control: private` (or `private, no-store`), so no shared cache or CDN stores paid content. Set `exposePrice: false` to leave the price out of every error body.
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.
160
189
 
161
190
  ## Security model
162
191
 
163
- - **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. A payment for one operation never unlocks another.
164
- - **Price what runs.** Yoga and Apollo are charged after the server's own parsing, validation and persisted-document lookup. `withPayment` refuses requests a handler could parse differently.
165
- - **Fail closed.** Unreadable, unpriceable, over-limit or ambiguous requests are answered with an error and never executed. Batches can't hold more than one paid operation per Credential.
166
- - **Bounded work.** Pricing is linear in the document, like graphql-js field collection. Token, length, depth, selection, body and batch limits keep it cheap.
167
- - **Tested by attack.** [`test/attacks-*.test.ts`](https://github.com/Bruce039/fareql/tree/main/test) hold the red-team suites: pricing tricks, parser differentials, cache and plugin-order bypasses, scope collisions and DoS. Each test asserts the secure behaviour.
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.
168
197
 
169
- ## Shared options
198
+ ## Reference
199
+
200
+ <details>
201
+ <summary><b>Shared options</b></summary>
170
202
 
171
203
  | Option | |
172
204
  |---|---|
@@ -175,31 +207,28 @@ In code, `mppx/client` wraps `fetch` so 402s are paid and retried automatically,
175
207
  | `describe(op, price)` | Challenge description. Default: `GraphQL <operation name>` |
176
208
  | `exposePrice` | Include the price breakdown in 402 bodies. Default: `true` |
177
209
 
178
- ## Notes
210
+ </details>
211
+
212
+ <details>
213
+ <summary><b>Notes and limits</b></summary>
179
214
 
180
- - Payment settles before execution, so a resolver error after payment is not refunded. Keep priced resolvers reliable, or price them lower.
215
+ - Payment settles before execution, so a resolver error after payment is not refunded. Keep priced resolvers reliable.
181
216
  - Subscriptions are charged once, when they start, for up to `maxSubscriptionEvents` events. Metered streams over MPP sessions are planned.
182
- - Set `maxListSize` to your server's real page cap. A larger requested size is clamped, so a server that returns more items than `maxListSize` is under-charged.
183
- - With Yoga, priced operations can't share a batch, because one request carries one Credential. Free operations in a batch work as usual.
184
- - 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.
185
219
 
186
- ## Examples
220
+ </details>
187
221
 
188
- - [`examples/yoga-tempo`](https://github.com/Bruce039/fareql/blob/main/examples/yoga-tempo/server.ts): GraphQL Yoga on Tempo's Moderato testnet
189
- - [`examples/apollo-tempo`](https://github.com/Bruce039/fareql/blob/main/examples/apollo-tempo/server.ts): Apollo Server with a priced Relay-style connection
190
- - [`examples/playground`](https://github.com/Bruce039/fareql/blob/main/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
191
223
 
192
- ```bash
193
- MPP_SECRET_KEY=$(openssl rand -hex 32) RECIPIENT=0x… npx tsx examples/yoga-tempo/server.ts
194
- npx mppx account create && npx mppx account fund
195
- npx mppx http://localhost:4000/graphql -X POST -H 'content-type: application/json' \
196
- -d '{"query":"{ search(query: \"tempo\", first: 3) { title snippet } }"}'
197
- ```
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.
198
227
 
199
228
  ## Development
200
229
 
201
230
  ```bash
202
- npm test # vitest: pricing, gate, Yoga, Apollo, and real mppx client ↔ server loops
231
+ npm test # pricing, gate, Yoga, Apollo, attacks, real mppx loops
203
232
  npm run build && node scripts/smoke.mjs # the built package over real HTTP in plain Node
204
233
  ```
205
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 units;
50
+ let price;
51
51
  try {
52
- units = priceOperation({ schema: rc.schema, ...input }, options.pricing).units;
52
+ price = priceOperation({ schema: rc.schema, ...input }, options.pricing);
53
53
  }
54
54
  catch (e) {
55
- if (e instanceof PricingError)
56
- throw fail(e.message, pricingErrorBody(e, exposePrice).extensions, 400);
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): Promise<ChargeOutcome>;
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 = operations.map((o) => priceOperation({ schema, ...o }, options.pricing));
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
- const entries = new Map();
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.clear();
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 still works: FareQL keeps its own hash → query cache.
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
- async function readBody(request, maxBytes) {
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 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;
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 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');
169
+ const bytes = await readBytes(request, maxBytes);
146
170
  let form;
147
171
  try {
148
- form = await request.clone().formData();
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 = (await options.resolveDocument?.(params)) ?? (hash !== undefined ? apq.get(hash) : undefined);
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 units;
71
+ let price;
72
72
  try {
73
- units = priceOperation({ schema, ...input }, options.pricing).units;
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
- if (Array.isArray(requestParserResult) && requestParserResult.length > 1)
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.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",