fareql 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +186 -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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruce0X
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,187 @@
|
|
|
1
|
-
#
|
|
1
|
+
# FareQL
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Charge for GraphQL operations over [MPP](https://mpp.dev) (HTTP 402), priced by what each operation selects.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- **Schema-driven prices.** `@price(amount: "0.001")` on fields and types, multiplied through lists, Relay connections and `@listSize`.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **Fails closed.** If FareQL can't read or price an operation, the operation doesn't run.
|
|
11
|
+
- **Ready for GraphQL clients.** The 402 keeps mppx's `WWW-Authenticate` Challenge and adds a GraphQL `errors` entry with the price and its breakdown.
|
|
12
|
+
- Works with **GraphQL Yoga**, **Apollo Server 4/5** and **any Fetch-API handler**, on graphql 16 and 17.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install fareql mppx graphql
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## GraphQL Yoga
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createSchema, createYoga } from 'graphql-yoga'
|
|
22
|
+
import { Mppx, tempo } from 'mppx/server'
|
|
23
|
+
import { fareDirectiveTypeDefs } from 'fareql'
|
|
24
|
+
import { useFareql } from 'fareql/yoga'
|
|
25
|
+
|
|
26
|
+
const mppx = Mppx.create({
|
|
27
|
+
methods: [tempo.charge({ currency: '0x…' /* TIP-20 token */, recipient: '0x…' })],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const yoga = createYoga({
|
|
31
|
+
schema: createSchema({
|
|
32
|
+
typeDefs: /* GraphQL */ `
|
|
33
|
+
${fareDirectiveTypeDefs}
|
|
34
|
+
type Query {
|
|
35
|
+
hello: String
|
|
36
|
+
weather(city: String!): Forecast @price(amount: "0.001")
|
|
37
|
+
search(query: String!, first: Int = 5): [Hit!]! @price(amount: "0.0005")
|
|
38
|
+
}
|
|
39
|
+
type Forecast { temperature: Float }
|
|
40
|
+
type Hit { title: String, snippet: String @price(amount: "0.0002") }
|
|
41
|
+
`,
|
|
42
|
+
resolvers,
|
|
43
|
+
}),
|
|
44
|
+
plugins: [useFareql({ charge: (o) => mppx.charge(o), pricing: { maxPrice: '1' } })],
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`{ hello }` is free. `{ search(query: "mpp", first: 2) { snippet } }` costs `2 × (0.0005 + 0.0002) = 0.0014`.
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
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`.
|
|
53
|
+
|
|
54
|
+
## Apollo Server
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { ApolloServer } from '@apollo/server'
|
|
58
|
+
import { fareqlApolloPlugin } from 'fareql/apollo'
|
|
59
|
+
|
|
60
|
+
const server = new ApolloServer({
|
|
61
|
+
typeDefs,
|
|
62
|
+
resolvers,
|
|
63
|
+
plugins: [fareqlApolloPlugin({ charge: (o) => mppx.charge(o) })],
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
## Any Fetch handler (Hono, Next.js, graphql-http, Cloudflare Workers)
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { withPayment } from 'fareql'
|
|
73
|
+
|
|
74
|
+
export const POST = withPayment((req) => handler(req), { schema, charge: (o) => mppx.charge(o) })
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`withPayment` reads the request itself, so it is strict about anything the wrapped handler could read differently:
|
|
78
|
+
|
|
79
|
+
- 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.
|
|
80
|
+
- It refuses the following with 4xx before anything is charged:
|
|
81
|
+
- URL fragments (`#`), duplicate or unknown parameters, and GraphQL parameters in the URL of a POST;
|
|
82
|
+
- non-object `variables`, and `query` sent together with `documentId`;
|
|
83
|
+
- bodies on other methods, and mutations over GET.
|
|
84
|
+
- `maxBodyBytes` (default 1 MB), `maxQueryLength` (100 000), `maxTokens` (15 000) and `maxBatchSize` (10) bound the work.
|
|
85
|
+
- `validationRules` adds your server's extra rules (depth limits and so on), so operations they reject are refused before payment.
|
|
86
|
+
- 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`.
|
|
87
|
+
- Batches are refused unless you set `batching: true`; then the whole batch is charged in one payment, and `maxPrice` applies to the total.
|
|
88
|
+
|
|
89
|
+
For full control, `createPaymentGate(options)(request)` returns `free`, `payment-required` (with the 402 `response`), `paid` (with `withReceipt`) or `rejected` (with the response to send).
|
|
90
|
+
|
|
91
|
+
## Pricing rules
|
|
92
|
+
|
|
93
|
+
| Rule | Behaviour |
|
|
94
|
+
|---|---|
|
|
95
|
+
| Field `@price` | Charged once per resolved instance of the field |
|
|
96
|
+
| 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 (fragments merged, repeated spreads ignored) |
|
|
98
|
+
| 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`) |
|
|
99
|
+
| 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 }` or turn off with `false` |
|
|
101
|
+
| `@listSize` | As in the IBM GraphQL Cost Directives draft: `slicingArguments`, `sizedFields`, `assumedSize`, `requireOneSlicingArgument` (default true) |
|
|
102
|
+
| 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 |
|
|
103
|
+
| Code-first schemas | `extensions: { price: '0.001' }` or `extensions: { directives: { price: { amount: '0.001' } } }` work like the directive |
|
|
104
|
+
| Fragments, `@skip`, `@include` | Followed and evaluated with the request variables |
|
|
105
|
+
| `__typename`, introspection | Free |
|
|
106
|
+
| `maxPrice` | Operations above it get `PRICE_LIMIT_EXCEEDED` before any Challenge is issued |
|
|
107
|
+
| `maxFieldVisits`, `maxDepth` | Operations too complex to price get `TOO_COMPLEX` (defaults 10 000 selections, 100 levels) |
|
|
108
|
+
| No price anywhere | A schema without any `@price` (lost directives, an introspection copy) is refused with `NO_PRICES` instead of making everything free |
|
|
109
|
+
|
|
110
|
+
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).
|
|
111
|
+
|
|
112
|
+
## What clients get
|
|
113
|
+
|
|
114
|
+
```http
|
|
115
|
+
HTTP/1.1 402 Payment Required
|
|
116
|
+
WWW-Authenticate: Payment id="…", realm="api.example.com", method="tempo", intent="charge", request="…"
|
|
117
|
+
Cache-Control: no-store
|
|
118
|
+
Content-Type: application/json; charset=utf-8
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
"errors": [{
|
|
122
|
+
"message": "Payment required: this operation costs 0.0014",
|
|
123
|
+
"extensions": {
|
|
124
|
+
"code": "PAYMENT_REQUIRED",
|
|
125
|
+
"price": {
|
|
126
|
+
"amount": "0.0014",
|
|
127
|
+
"decimals": 6,
|
|
128
|
+
"breakdown": [
|
|
129
|
+
{ "path": "search", "unitAmount": "0.0005", "count": "2", "amount": "0.001" },
|
|
130
|
+
{ "path": "search.snippet", "unitAmount": "0.0002", "count": "2", "amount": "0.0004" }
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}]
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Any MPP client (`mppx`'s fetch wrapper, `npx mppx`, agents) pays and retries. 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.
|
|
139
|
+
|
|
140
|
+
## Security model
|
|
141
|
+
|
|
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. A payment for one operation never unlocks another.
|
|
143
|
+
- **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.
|
|
144
|
+
- **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.
|
|
145
|
+
- **Bounded work.** Pricing is linear in the document, like graphql-js field collection. Token, length, depth, selection, body and batch limits keep it cheap.
|
|
146
|
+
- **Tested by attack.** `test/attacks-*.test.ts` hold the red-team suites: pricing tricks, parser differentials, cache and plugin-order bypasses, scope collisions and DoS. Each test asserts the secure behaviour.
|
|
147
|
+
|
|
148
|
+
## Shared options
|
|
149
|
+
|
|
150
|
+
| Option | |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `charge` | `(o) => mppx.charge(o)`, or `(o) => mppx.tempo.charge(o)` when several methods are configured. Receives `{ amount, scope, description }` |
|
|
153
|
+
| `pricing` | See [Pricing rules](#pricing-rules) |
|
|
154
|
+
| `describe(op, price)` | Challenge description. Default: `GraphQL <operation name>` |
|
|
155
|
+
| `exposePrice` | Include the price breakdown in 402 bodies. Default: `true` |
|
|
156
|
+
|
|
157
|
+
## Notes
|
|
158
|
+
|
|
159
|
+
- Payment settles before execution, so a resolver error after payment is not refunded. Keep priced resolvers reliable, or price them lower.
|
|
160
|
+
- 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. A larger requested size is clamped, so a server that returns more items than `maxListSize` is under-charged.
|
|
162
|
+
- With Yoga, priced operations can't share a batch, because one request carries one Credential. Free operations in a batch work as usual.
|
|
163
|
+
- In production, give `tempo.charge()` a shared `store` so a Credential can't be replayed across instances. This is an mppx setting.
|
|
164
|
+
|
|
165
|
+
## Examples
|
|
166
|
+
|
|
167
|
+
- [`examples/yoga-tempo`](examples/yoga-tempo/server.ts): GraphQL Yoga on Tempo's Moderato testnet
|
|
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
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
MPP_SECRET_KEY=$(openssl rand -hex 32) RECIPIENT=0x… npx tsx examples/yoga-tempo/server.ts
|
|
173
|
+
npx mppx account create && npx mppx account fund
|
|
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
|
+
```
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npm test # vitest: pricing, gate, Yoga, Apollo, and real mppx client ↔ server loops
|
|
182
|
+
npm run build && node scripts/smoke.mjs # the built package over real HTTP in plain Node
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
package/dist/apollo.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ApolloServerPlugin, type BaseContext, type HTTPGraphQLRequest } from '@apollo/server';
|
|
2
|
+
import { type FareOptions } from './core.js';
|
|
3
|
+
export type FareqlApolloOptions = FareOptions & {
|
|
4
|
+
/**
|
|
5
|
+
* URL of the GraphQL endpoint, used to build the Fetch `Request` mppx sees. Apollo does not
|
|
6
|
+
* expose the path. Default: `http://<Host header>/graphql<search>`.
|
|
7
|
+
*/
|
|
8
|
+
url?: string | ((http: HTTPGraphQLRequest) => string);
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Apollo Server 4/5 plugin. Prices each operation after Apollo has parsed, validated and
|
|
12
|
+
* resolved it (persisted queries included), before execution. Priced operations answer with the
|
|
13
|
+
* MPP Challenge (402 + `WWW-Authenticate`); paid ones carry `Payment-Receipt`.
|
|
14
|
+
*
|
|
15
|
+
* In an HTTP batch only the first priced operation can be paid (one request carries one
|
|
16
|
+
* Credential); further priced operations in the same batch are refused.
|
|
17
|
+
* Operations run in-process with `server.executeOperation()` have no HTTP request and are not charged.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fareqlApolloPlugin<TContext extends BaseContext = BaseContext>(options: FareqlApolloOptions): ApolloServerPlugin<TContext>;
|
package/dist/apollo.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { HeaderMap } from '@apollo/server';
|
|
2
|
+
import { GraphQLError } from 'graphql';
|
|
3
|
+
import { chargeOperations, paymentError, pricingErrorBody, receiptHeaders } from './core.js';
|
|
4
|
+
import { PricingError, priceOperation } from './price.js';
|
|
5
|
+
function toFetchRequest(http, url) {
|
|
6
|
+
const headers = new Headers();
|
|
7
|
+
for (const [key, value] of http.headers)
|
|
8
|
+
headers.set(key, value);
|
|
9
|
+
const href = typeof url === 'function'
|
|
10
|
+
? url(http)
|
|
11
|
+
: url
|
|
12
|
+
? `${url}${http.search}`
|
|
13
|
+
: `http://${http.headers.get('host') ?? 'localhost'}/graphql${http.search}`;
|
|
14
|
+
try {
|
|
15
|
+
return new Request(href, { method: http.method, headers });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// A Host header that is not a valid authority: the Challenge realm falls back to localhost
|
|
19
|
+
// (configure `realm` in Mppx.create to make it independent of the Host header).
|
|
20
|
+
return new Request(`http://localhost/graphql${http.search.startsWith('?') ? http.search : ''}`, { method: http.method, headers });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const fail = (message, extensions, status, headers) => {
|
|
24
|
+
// Typed loosely: GraphQL Yoga augments GraphQLErrorExtensions with its own `http` shape.
|
|
25
|
+
const ext = { ...extensions, http: { status, headers: headers ?? new HeaderMap() } };
|
|
26
|
+
return new GraphQLError(message, { extensions: ext });
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Apollo Server 4/5 plugin. Prices each operation after Apollo has parsed, validated and
|
|
30
|
+
* resolved it (persisted queries included), before execution. Priced operations answer with the
|
|
31
|
+
* MPP Challenge (402 + `WWW-Authenticate`); paid ones carry `Payment-Receipt`.
|
|
32
|
+
*
|
|
33
|
+
* In an HTTP batch only the first priced operation can be paid (one request carries one
|
|
34
|
+
* Credential); further priced operations in the same batch are refused.
|
|
35
|
+
* Operations run in-process with `server.executeOperation()` have no HTTP request and are not charged.
|
|
36
|
+
*/
|
|
37
|
+
export function fareqlApolloPlugin(options) {
|
|
38
|
+
const exposePrice = options.exposePrice ?? true;
|
|
39
|
+
// Pieces of one HTTP batch share the same headers object; count the priced ones per request.
|
|
40
|
+
const pricedPieces = new WeakMap();
|
|
41
|
+
return {
|
|
42
|
+
async requestDidStart() {
|
|
43
|
+
let receipt;
|
|
44
|
+
return {
|
|
45
|
+
async didResolveOperation(rc) {
|
|
46
|
+
const http = rc.request.http;
|
|
47
|
+
if (!http)
|
|
48
|
+
return;
|
|
49
|
+
const input = { document: rc.document, operationName: rc.operationName ?? rc.request.operationName, variables: rc.request.variables };
|
|
50
|
+
let units;
|
|
51
|
+
try {
|
|
52
|
+
units = priceOperation({ schema: rc.schema, ...input }, options.pricing).units;
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (e instanceof PricingError)
|
|
56
|
+
throw fail(e.message, pricingErrorBody(e, exposePrice).extensions, 400);
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
if (units === 0n)
|
|
60
|
+
return;
|
|
61
|
+
// Counted synchronously, before any await, so parallel batch pieces cannot race past it.
|
|
62
|
+
const n = (pricedPieces.get(http.headers) ?? 0) + 1;
|
|
63
|
+
pricedPieces.set(http.headers, n);
|
|
64
|
+
if (n > 1) {
|
|
65
|
+
throw fail('Priced operations cannot be batched; send them one per request', { code: 'BATCHING_NOT_SUPPORTED' }, 400);
|
|
66
|
+
}
|
|
67
|
+
const outcome = await chargeOperations(toFetchRequest(http, options.url), rc.schema, [input], options);
|
|
68
|
+
switch (outcome.status) {
|
|
69
|
+
case 'free':
|
|
70
|
+
return;
|
|
71
|
+
case 'rejected': {
|
|
72
|
+
const body = pricingErrorBody(outcome.error, exposePrice);
|
|
73
|
+
throw fail(body.message, body.extensions, 400);
|
|
74
|
+
}
|
|
75
|
+
case 'payment-required': {
|
|
76
|
+
const headers = new HeaderMap();
|
|
77
|
+
outcome.challenge.headers.forEach((value, key) => {
|
|
78
|
+
if (key !== 'content-type' && key !== 'content-length')
|
|
79
|
+
headers.set(key, value);
|
|
80
|
+
});
|
|
81
|
+
const body = paymentError(outcome.challenge.status, outcome.price, exposePrice);
|
|
82
|
+
throw fail(body.message, body.extensions, outcome.challenge.status, headers);
|
|
83
|
+
}
|
|
84
|
+
case 'paid':
|
|
85
|
+
receipt = receiptHeaders(outcome.withReceipt);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async willSendResponse(rc) {
|
|
89
|
+
if (!receipt)
|
|
90
|
+
return;
|
|
91
|
+
receipt.forEach((value, key) => rc.response.http.headers.set(key, value));
|
|
92
|
+
// Paid content belongs to the payer: never let a shared cache or CDN store it.
|
|
93
|
+
const cc = rc.response.http.headers.get('cache-control') ?? '';
|
|
94
|
+
if (!/\b(private|no-store)\b/i.test(cc))
|
|
95
|
+
rc.response.http.headers.set('cache-control', 'private, no-store');
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type DocumentNode, type GraphQLError, type GraphQLSchema, type OperationTypeNode, type ValidationRule } from 'graphql';
|
|
2
|
+
import { type OperationPrice, type PricingOptions, PricingError } from './price.js';
|
|
3
|
+
/** Options passed to the charge factory; maps 1:1 onto `mppx.charge({ ... })`. */
|
|
4
|
+
export type ChargeOptions = {
|
|
5
|
+
/** Price in token units, e.g. "0.0014". */
|
|
6
|
+
amount: string;
|
|
7
|
+
/** Binds the Challenge to this exact operation, so its Credential cannot pay for another one. */
|
|
8
|
+
scope: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
};
|
|
11
|
+
/** What an mppx route handler returns (`await mppx.charge(options)(request)`). */
|
|
12
|
+
export type ChargeResult = {
|
|
13
|
+
status: 402;
|
|
14
|
+
challenge: Response;
|
|
15
|
+
} | {
|
|
16
|
+
status: 200;
|
|
17
|
+
withReceipt: (response: Response) => Response;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Creates a charge handler for one priced operation. With mppx this is
|
|
21
|
+
* `(o) => mppx.charge(o)`, or `(o) => mppx.tempo.charge(o)` with several methods.
|
|
22
|
+
*/
|
|
23
|
+
export type ChargeFactory = (options: ChargeOptions) => (request: Request) => Promise<ChargeResult>;
|
|
24
|
+
export type OperationInput = {
|
|
25
|
+
document: DocumentNode;
|
|
26
|
+
operationName?: string | null;
|
|
27
|
+
variables?: Record<string, unknown> | null;
|
|
28
|
+
};
|
|
29
|
+
export type OperationInfo = {
|
|
30
|
+
operationName: string | undefined;
|
|
31
|
+
operationType: OperationTypeNode;
|
|
32
|
+
};
|
|
33
|
+
export type FareOptions = {
|
|
34
|
+
charge: ChargeFactory;
|
|
35
|
+
pricing?: PricingOptions;
|
|
36
|
+
/** Challenge description. Default: `GraphQL <operation name or type>`. */
|
|
37
|
+
describe?: (operation: OperationInfo, price: OperationPrice) => string | undefined;
|
|
38
|
+
/** Include the price and its breakdown in 402 bodies. Default: true. */
|
|
39
|
+
exposePrice?: boolean;
|
|
40
|
+
};
|
|
41
|
+
export type ChargeOutcome =
|
|
42
|
+
/** Nothing to charge. Execute normally. */
|
|
43
|
+
{
|
|
44
|
+
status: 'free';
|
|
45
|
+
price: OperationPrice;
|
|
46
|
+
}
|
|
47
|
+
/** Send `challenge` (an MPP 402) to the client. */
|
|
48
|
+
| {
|
|
49
|
+
status: 'payment-required';
|
|
50
|
+
price: OperationPrice;
|
|
51
|
+
scope: string;
|
|
52
|
+
challenge: Response;
|
|
53
|
+
}
|
|
54
|
+
/** Paid. Execute, then pass the response through `withReceipt`. */
|
|
55
|
+
| {
|
|
56
|
+
status: 'paid';
|
|
57
|
+
price: OperationPrice;
|
|
58
|
+
scope: string;
|
|
59
|
+
withReceipt: (response: Response) => Response;
|
|
60
|
+
}
|
|
61
|
+
/** The operation could not be priced; it must not run. */
|
|
62
|
+
| {
|
|
63
|
+
status: 'rejected';
|
|
64
|
+
error: PricingError;
|
|
65
|
+
};
|
|
66
|
+
declare function sha256Hex(text: string): Promise<string>;
|
|
67
|
+
export { sha256Hex };
|
|
68
|
+
/**
|
|
69
|
+
* Scope binding a Challenge to a set of operations: `graphql:<sha256 of document, operation name and variables>`.
|
|
70
|
+
* mppx stores it in the Challenge's HMAC-protected metadata and refuses Credentials whose scope differs.
|
|
71
|
+
*/
|
|
72
|
+
export declare function operationScope(operations: readonly OperationInput[]): Promise<string>;
|
|
73
|
+
/**
|
|
74
|
+
* Prices one or more operations and charges for them through mppx. Every adapter (Fetch, Yoga,
|
|
75
|
+
* Apollo) goes through here. The price is recomputed from the retried request, so a Credential
|
|
76
|
+
* for a cheaper operation never pays for a more expensive one.
|
|
77
|
+
*/
|
|
78
|
+
export declare function chargeOperations(request: Request, schema: GraphQLSchema, operations: readonly OperationInput[], options: FareOptions): Promise<ChargeOutcome>;
|
|
79
|
+
export type GraphQLErrorBody = {
|
|
80
|
+
message: string;
|
|
81
|
+
extensions: Record<string, unknown>;
|
|
82
|
+
};
|
|
83
|
+
export declare function priceExtension(price: OperationPrice): {
|
|
84
|
+
breakdownTruncated?: boolean | undefined;
|
|
85
|
+
amount: string;
|
|
86
|
+
decimals: number;
|
|
87
|
+
breakdown: import("./price.js").PriceBreakdownItem[];
|
|
88
|
+
};
|
|
89
|
+
export declare function paymentError(status: number, price: OperationPrice, exposePrice: boolean): GraphQLErrorBody;
|
|
90
|
+
export declare function pricingErrorBody(error: PricingError, exposePrice?: boolean): GraphQLErrorBody;
|
|
91
|
+
/**
|
|
92
|
+
* Turns mppx's 402 into a response GraphQL clients understand: same status and headers
|
|
93
|
+
* (WWW-Authenticate carries the Challenge), and a body with both the problem details mppx
|
|
94
|
+
* wrote and a GraphQL `errors` array. HTML paywalls are passed through untouched.
|
|
95
|
+
*/
|
|
96
|
+
export declare function graphqlChallenge(challenge: Response, price: OperationPrice, exposePrice: boolean): Promise<Response>;
|
|
97
|
+
export declare function jsonResponse(status: number, errors: readonly GraphQLErrorBody[], headers?: HeadersInit): Response;
|
|
98
|
+
/** Copies the headers mppx adds for a receipt onto an existing response. */
|
|
99
|
+
export declare function receiptHeaders(withReceipt: (response: Response) => Response): Headers;
|
|
100
|
+
export type ParsedDocument = {
|
|
101
|
+
document: DocumentNode;
|
|
102
|
+
errors?: undefined;
|
|
103
|
+
} | {
|
|
104
|
+
document?: undefined;
|
|
105
|
+
errors: readonly GraphQLError[];
|
|
106
|
+
};
|
|
107
|
+
export type DocumentLimits = {
|
|
108
|
+
/** Longest query text accepted, in characters. Default: 100000. */
|
|
109
|
+
maxQueryLength?: number;
|
|
110
|
+
/** Most lexical tokens a document may have (graphql-js `maxTokens`). Default: 15000. */
|
|
111
|
+
maxTokens?: number;
|
|
112
|
+
/** Extra validation rules your server applies, so their failures are refused before payment. */
|
|
113
|
+
validationRules?: readonly ValidationRule[];
|
|
114
|
+
};
|
|
115
|
+
/** Small LRU of parse + validate results. Only short queries are cached, so memory stays bounded. */
|
|
116
|
+
export declare function createDocumentCache(limits?: DocumentLimits, max?: number): {
|
|
117
|
+
get(schema: GraphQLSchema, query: string): ParsedDocument;
|
|
118
|
+
clear(): void;
|
|
119
|
+
};
|
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
|
+
}
|