@grafserv/persisted 0.0.0-1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +243 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +201 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces.d.ts +70 -0
- package/dist/interfaces.d.ts.map +1 -0
- package/dist/interfaces.js +3 -0
- package/dist/interfaces.js.map +1 -0
- package/package.json +64 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @grafserv/persisted
|
|
2
|
+
|
|
3
|
+
## 0.0.0-1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#297](https://github.com/benjie/postgraphile-private/pull/297)
|
|
8
|
+
[`5a7396e2d`](https://github.com/benjie/postgraphile-private/commit/5a7396e2d1675f49b1d54cf6e748f2bc35aefe8a)
|
|
9
|
+
Thanks [@benjie](https://github.com/benjie)! - New @grafserv/persisted plugin
|
|
10
|
+
to add persisted operations support to Grafserv 🎉
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
[[`90ed0cb7a`](https://github.com/benjie/postgraphile-private/commit/90ed0cb7a78479b85115cd1ce045ac253107b3eb),
|
|
13
|
+
[`56be761c2`](https://github.com/benjie/postgraphile-private/commit/56be761c29343e28ba4980c62c955b5adaef25c0),
|
|
14
|
+
[`8d270ead3`](https://github.com/benjie/postgraphile-private/commit/8d270ead3fa8331e28974aae052bd48ad537d1bf),
|
|
15
|
+
[`1a012bdd7`](https://github.com/benjie/postgraphile-private/commit/1a012bdd7d3748ac9a4ca9b1f771876654988f25),
|
|
16
|
+
[`b4eaf89f4`](https://github.com/benjie/postgraphile-private/commit/b4eaf89f401ca207de08770361d07903f6bb9cb0)]:
|
|
17
|
+
- grafserv@0.0.1-1.3
|
|
18
|
+
- grafast@0.0.1-1.3
|
|
19
|
+
- graphile-config@0.0.1-1.2
|
package/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# @grafserv/persisted
|
|
2
|
+
|
|
3
|
+
<span class="badge-patreon"><a href="https://patreon.com/benjie" title="Support Graphile development on Patreon"><img src="https://img.shields.io/badge/sponsor-via%20Patreon-orange.svg" alt="Patreon sponsor button" /></a></span>
|
|
4
|
+
[](http://discord.gg/graphile)
|
|
5
|
+
[](https://www.npmjs.com/package/@grafserv/persisted)
|
|
6
|
+

|
|
7
|
+
[](https://twitter.com/GraphileHQ)
|
|
8
|
+
|
|
9
|
+
Persisted operations (aka "persisted queries", "query allowlist", "persisted
|
|
10
|
+
documents") support for [Grafserv](https://grafast.org/grafserv). Applies to
|
|
11
|
+
both standard GET and POST requests and to websocket connections. Works for all
|
|
12
|
+
operation types (queries, mutations and subscriptions).
|
|
13
|
+
|
|
14
|
+
We recommend that all GraphQL servers (PostGraphile or otherwise) that only
|
|
15
|
+
intend first party clients to use the GraphQL schema should use persisted
|
|
16
|
+
operations to mitigate attacks against the GraphQL API and to help track the
|
|
17
|
+
fields that have been used. This package is our solution for Grafserv, for other
|
|
18
|
+
servers you will need different software.
|
|
19
|
+
|
|
20
|
+
<!-- SPONSORS_BEGIN -->
|
|
21
|
+
|
|
22
|
+
## Crowd-funded open-source software
|
|
23
|
+
|
|
24
|
+
To help us develop this software sustainably under the MIT license, we ask all
|
|
25
|
+
individuals and businesses that use it to help support its ongoing maintenance
|
|
26
|
+
and development via sponsorship.
|
|
27
|
+
|
|
28
|
+
### [Click here to find out more about sponsors and sponsorship.](https://www.graphile.org/sponsor/)
|
|
29
|
+
|
|
30
|
+
And please give some love to our featured sponsors 🤩:
|
|
31
|
+
|
|
32
|
+
<table><tr>
|
|
33
|
+
<td align="center"><a href="https://surge.io/"><img src="https://graphile.org/images/sponsors/surge.png" width="90" height="90" alt="Surge" /><br />Surge</a> *</td>
|
|
34
|
+
<td align="center"><a href="https://www.netflix.com/"><img src="https://graphile.org/images/sponsors/Netflix.png" width="90" height="90" alt="Netflix" /><br />Netflix</a> *</td>
|
|
35
|
+
<td align="center"><a href="https://www.the-guild.dev/"><img src="https://graphile.org/images/sponsors/theguild.png" width="90" height="90" alt="The Guild" /><br />The Guild</a> *</td>
|
|
36
|
+
<td align="center"><a href="https://qwick.com/"><img src="https://graphile.org/images/sponsors/qwick.png" width="90" height="90" alt="Qwick" /><br />Qwick</a> *</td>
|
|
37
|
+
</tr><tr>
|
|
38
|
+
<td align="center"><a href="http://chads.website"><img src="https://graphile.org/images/sponsors/chadf.png" width="90" height="90" alt="Chad Furman" /><br />Chad Furman</a> *</td>
|
|
39
|
+
<td align="center"><a href="https://dovetailapp.com/"><img src="https://graphile.org/images/sponsors/dovetail.png" width="90" height="90" alt="Dovetail" /><br />Dovetail</a> *</td>
|
|
40
|
+
<td align="center"><a href="https://www.enzuzo.com/"><img src="https://graphile.org/images/sponsors/enzuzo.png" width="90" height="90" alt="Enzuzo" /><br />Enzuzo</a> *</td>
|
|
41
|
+
<td align="center"><a href="https://stellate.co/"><img src="https://graphile.org/images/sponsors/Stellate.png" width="90" height="90" alt="Stellate" /><br />Stellate</a> *</td>
|
|
42
|
+
</tr></table>
|
|
43
|
+
|
|
44
|
+
<em>\* Sponsors the entire Graphile suite</em>
|
|
45
|
+
|
|
46
|
+
<!-- SPONSORS_END -->
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
yarn add @grafserv/persisted
|
|
52
|
+
# or: npm install --save @grafserv/persisted
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
Import `PersistedPlugin` from `@grafserv/persisted` and then add it to the
|
|
58
|
+
`plugins` list in your `graphile.config.ts` (or equivalent) file:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import "graphile-config";
|
|
62
|
+
import PersistedPlugin from "@grafserv/persisted";
|
|
63
|
+
|
|
64
|
+
const preset: GraphileConfig.Preset = {
|
|
65
|
+
plugins: [PersistedPlugin],
|
|
66
|
+
grafserv: {
|
|
67
|
+
/* add configuration options here, e.g. */
|
|
68
|
+
persistedOperationsDirectory: `${process.cwd()}/.persisted_operations`,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default preset;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Options
|
|
76
|
+
|
|
77
|
+
This plugin adds the following options to the Grafserv options:
|
|
78
|
+
|
|
79
|
+
````ts
|
|
80
|
+
/**
|
|
81
|
+
* This function will be passed a GraphQL request object (normally
|
|
82
|
+
* `{query: string, variables?: any, operationName?: string, extensions?: any}`,
|
|
83
|
+
* but in the case of persisted operations it likely won't have a `query`
|
|
84
|
+
* property), and must extract the hash to use to identify the persisted
|
|
85
|
+
* operation. For Apollo Client, this might be something like:
|
|
86
|
+
* `request?.extensions?.persistedQuery?.sha256Hash`; for Relay something
|
|
87
|
+
* like: `request?.documentId`.
|
|
88
|
+
*/
|
|
89
|
+
hashFromPayload?(request: ParsedGraphQLBody): string | undefined;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* We can read persisted operations from a folder (they must be named
|
|
93
|
+
* `<hash>.graphql`). When used in this way, the first request for a hash
|
|
94
|
+
* will read the file, and then the result will be cached such that the
|
|
95
|
+
* **filesystem read** will only impact the first use of that hash. We
|
|
96
|
+
* periodically scan the folder for new files, requests for hashes that
|
|
97
|
+
* were not present in our last scan of the folder will be rejected to
|
|
98
|
+
* mitigate denial of service attacks asking for non-existent hashes.
|
|
99
|
+
*/
|
|
100
|
+
persistedOperationsDirectory?: string;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* An optional string-string key-value object defining the persisted
|
|
104
|
+
* operations, where the keys are the hashes, and the values are the
|
|
105
|
+
* operation document strings to use.
|
|
106
|
+
*/
|
|
107
|
+
persistedOperations?: { [hash: string]: string };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* If your known persisted operations may change over time, or you'd rather
|
|
111
|
+
* load them on demand, you may supply this function. Note this function is
|
|
112
|
+
* **performance critical** so you should use caching to improve
|
|
113
|
+
* performance of any follow-up requests for the same hash.
|
|
114
|
+
*/
|
|
115
|
+
persistedOperationsGetter?: PersistedOperationGetter;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* There are situations where you may want to allow arbitrary operations
|
|
119
|
+
* (for example using GraphiQL in development, or allowing an admin to
|
|
120
|
+
* make arbitrary requests in production) whilst enforcing Persisted
|
|
121
|
+
* Operations for the application and non-admin users. This function
|
|
122
|
+
* allows you to determine under which circumstances persisted operations
|
|
123
|
+
* may be bypassed.
|
|
124
|
+
*
|
|
125
|
+
* IMPORTANT: this function must not throw!
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
*
|
|
129
|
+
* ```
|
|
130
|
+
* app.use(postgraphile(DATABASE_URL, SCHEMAS, {
|
|
131
|
+
* allowUnpersistedOperation(event) {
|
|
132
|
+
* return process.env.NODE_ENV === "development" && event.request?.getHeader('referer')?.endsWith("/graphiql");
|
|
133
|
+
* }
|
|
134
|
+
* });
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
allowUnpersistedOperation?:
|
|
138
|
+
| boolean
|
|
139
|
+
| ((event: ProcessGraphQLRequestBodyEvent) => boolean);
|
|
140
|
+
````
|
|
141
|
+
|
|
142
|
+
All these options are optional; but you should specify exactly one of
|
|
143
|
+
`persistedOperationsDirectory`, `persistedOperations` or
|
|
144
|
+
`persistedOperationsGetter` for this plugin to be useful.
|
|
145
|
+
|
|
146
|
+
## Generating Persisted Operations
|
|
147
|
+
|
|
148
|
+
### Relay
|
|
149
|
+
|
|
150
|
+
Relay has built-in support for persisted operations:
|
|
151
|
+
|
|
152
|
+
https://relay.dev/docs/guides/persisted-queries/
|
|
153
|
+
|
|
154
|
+
Pass `--persist-output ./path/to/server.json` to `relay-compiler` along with
|
|
155
|
+
your normal options to have it generate the persisted operations file. You can
|
|
156
|
+
then use the `addToPersistedOperations.js` script below to split this operations
|
|
157
|
+
JSON file into one file per query that can be passed to
|
|
158
|
+
`--persisted-operations-directory`.
|
|
159
|
+
|
|
160
|
+
Then in your network config (`fetchQuery` function) you need to change the
|
|
161
|
+
`query: operation.text` to `documentId: operation.id`
|
|
162
|
+
[as shown in the relay docs](https://relay.dev/docs/guides/persisted-queries/#network-layer-changes).
|
|
163
|
+
|
|
164
|
+
### GraphQL-Code-Generator
|
|
165
|
+
|
|
166
|
+
For everything except Relay, we recommend that you generate persisted operations
|
|
167
|
+
when you build your clients using the awesome
|
|
168
|
+
[graphql-code-generator](https://github.com/dotansimha/graphql-code-generator)
|
|
169
|
+
project and the
|
|
170
|
+
[graphql-codegen-persisted-query-ids](https://www.npmjs.com/package/graphql-codegen-persisted-query-ids)
|
|
171
|
+
(tested with v0.1.2) plugin. A config might look like:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
schema: "schema.graphql"
|
|
175
|
+
documents: "src/**/*.graphql"
|
|
176
|
+
hooks:
|
|
177
|
+
afterAllFileWrite:
|
|
178
|
+
- node addToPersistedOperations.js
|
|
179
|
+
generates:
|
|
180
|
+
client.json:
|
|
181
|
+
plugins:
|
|
182
|
+
- graphql-codegen-persisted-query-ids:
|
|
183
|
+
output: client
|
|
184
|
+
algorithm: sha256
|
|
185
|
+
server.json:
|
|
186
|
+
plugins:
|
|
187
|
+
- graphql-codegen-persisted-query-ids:
|
|
188
|
+
output: server
|
|
189
|
+
algorithm: sha256
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
We also recommend using the file `addToPersistedOperations.js` to write the
|
|
193
|
+
`server.json` file contents out to separate GraphQL files every time the code is
|
|
194
|
+
built for easier version control:
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
// addToPersistedOperations.js
|
|
198
|
+
const map = require("./server.json");
|
|
199
|
+
const { promises: fsp } = require("fs");
|
|
200
|
+
|
|
201
|
+
async function main() {
|
|
202
|
+
await Promise.all(
|
|
203
|
+
Object.entries(map).map(([hash, query]) =>
|
|
204
|
+
fsp.writeFile(
|
|
205
|
+
`${__dirname}/.persisted_operations/${hash}.graphql`,
|
|
206
|
+
query,
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
main().catch((e) => {
|
|
213
|
+
console.error(e);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Then you pass the `.persisted_operations` folder via the
|
|
219
|
+
`persistedOperationsDirectory` option as shown in the usage example.
|
|
220
|
+
|
|
221
|
+
#### Apollo Client
|
|
222
|
+
|
|
223
|
+
You can configure Apollo Client to send the persisted operations generated by
|
|
224
|
+
`graphql-codegen` with `apollo-link-persisted-queries`:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { createPersistedQueryLink } from "apollo-link-persisted-queries";
|
|
228
|
+
import { usePregeneratedHashes as withPregeneratedHashes } from "graphql-codegen-persisted-query-ids/lib/apollo";
|
|
229
|
+
import { hashes } from "./path/to/client.json";
|
|
230
|
+
|
|
231
|
+
const persistedLink = createPersistedQueryLink({
|
|
232
|
+
useGETForHashedQueries: false,
|
|
233
|
+
generateHash: withPregeneratedHashes(hashes),
|
|
234
|
+
disable: () => false,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ...
|
|
238
|
+
|
|
239
|
+
const client = new ApolloClient({
|
|
240
|
+
link: ApolloLink.from([persistedLink, httpLink]),
|
|
241
|
+
// ...
|
|
242
|
+
});
|
|
243
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAIhE,QAAA,MAAM,eAAe,EAAE,cAAc,CAAC,MA8CrC,CAAC;AAEF,eAAe,eAAe,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const grafast_1 = require("grafast");
|
|
5
|
+
const promises_1 = tslib_1.__importDefault(require("node:fs/promises"));
|
|
6
|
+
const version = require("../package.json").version;
|
|
7
|
+
const PersistedPlugin = {
|
|
8
|
+
name: "PersistedPlugin",
|
|
9
|
+
description: "Enables persisted operations in Grafserv",
|
|
10
|
+
version,
|
|
11
|
+
grafserv: {
|
|
12
|
+
hooks: {
|
|
13
|
+
processGraphQLRequestBody(info, event) {
|
|
14
|
+
const { body } = event;
|
|
15
|
+
const options = info.resolvedPreset.grafserv;
|
|
16
|
+
if (!options) {
|
|
17
|
+
throw new grafast_1.SafeError("Persisted operations misconfigured; rejecting requests.", { statusCode: 500 });
|
|
18
|
+
}
|
|
19
|
+
const realQuery = persistedOperationFromPayload(body, options, shouldAllowUnpersistedOperation(options, event));
|
|
20
|
+
// Always overwrite
|
|
21
|
+
if (realQuery != null && typeof realQuery !== "string") {
|
|
22
|
+
return realQuery.then((q) => {
|
|
23
|
+
if (typeof q === "string") {
|
|
24
|
+
body.query = q;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
throw new grafast_1.SafeError("Persisted operations are enabled on this server, please provide an approved document id.", { statusCode: 400 });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
if (typeof realQuery === "string") {
|
|
33
|
+
body.query = realQuery;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
throw new grafast_1.SafeError("Persisted operations are enabled on this server, please provide an approved document id.", { statusCode: 400 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
exports.default = PersistedPlugin;
|
|
44
|
+
/**
|
|
45
|
+
* This fallback hashFromPayload method is compatible with Apollo Client and
|
|
46
|
+
* Relay.
|
|
47
|
+
*/
|
|
48
|
+
function defaultHashFromPayload(payload) {
|
|
49
|
+
return (
|
|
50
|
+
// https://github.com/apollographql/apollo-link-persisted-queries#protocol
|
|
51
|
+
payload?.extensions?.persistedQuery?.sha256Hash ||
|
|
52
|
+
// https://relay.dev/docs/en/persisted-queries#network-layer-changes
|
|
53
|
+
payload?.documentId ||
|
|
54
|
+
// Benjie's memory
|
|
55
|
+
payload?.id);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Given a cache object, returns a persisted operation getter that looks up the
|
|
59
|
+
* given hash in said cache object.
|
|
60
|
+
*/
|
|
61
|
+
function persistedOperationGetterForCache(cache) {
|
|
62
|
+
return (key) => cache[key];
|
|
63
|
+
}
|
|
64
|
+
function makeGetterForDirectory(directory) {
|
|
65
|
+
// NOTE: We periodically scan the folder to see what files it contains so
|
|
66
|
+
// that we can reject requests to non-existent files to avoid DOS attacks
|
|
67
|
+
// having us make lots of requests to the filesystem.
|
|
68
|
+
let files = null;
|
|
69
|
+
/**
|
|
70
|
+
* This function must never reject.
|
|
71
|
+
*/
|
|
72
|
+
async function scanDirectory() {
|
|
73
|
+
try {
|
|
74
|
+
const allFiles = await promises_1.default.readdir(directory);
|
|
75
|
+
files = allFiles.filter((name) => name.endsWith(".graphql"));
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
console.error(`Error occurred whilst scanning '${directory}'`);
|
|
79
|
+
console.error(e);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
// TODO: This interval should be configurable.
|
|
83
|
+
// TODO: This might not be needed in production, and if so should be able to be disabled.
|
|
84
|
+
// TODO: In dev watching the folder might be a better experience.
|
|
85
|
+
// We don't know how long the scanning takes, so rather than setting an
|
|
86
|
+
// interval, we wait 5 seconds between scans before kicking off the next
|
|
87
|
+
// one.
|
|
88
|
+
setTimeout(scanDirectory, 5000);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
scanDirectory();
|
|
92
|
+
const operationFromHash = new Map();
|
|
93
|
+
function getOperationFromHash(hash) {
|
|
94
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(hash)) {
|
|
95
|
+
throw new Error("Invalid hash");
|
|
96
|
+
}
|
|
97
|
+
let operation = operationFromHash.get(hash);
|
|
98
|
+
if (!operation) {
|
|
99
|
+
const filename = `${hash}.graphql`;
|
|
100
|
+
if (files && !files.includes(filename)) {
|
|
101
|
+
throw new Error(`Could not find file for hash '${hash}'`);
|
|
102
|
+
}
|
|
103
|
+
operation = promises_1.default
|
|
104
|
+
.readFile(`${directory}/${filename}`, "utf8")
|
|
105
|
+
.catch(() => null);
|
|
106
|
+
operationFromHash.set(hash, operation);
|
|
107
|
+
// Once resolved, replace reference to string to avoid unnecessary ticks
|
|
108
|
+
operation.then((operationText) => {
|
|
109
|
+
operationFromHash.set(hash, operationText);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return operation;
|
|
113
|
+
}
|
|
114
|
+
return getOperationFromHash;
|
|
115
|
+
}
|
|
116
|
+
const directoryGetterByDirectory = new Map();
|
|
117
|
+
/**
|
|
118
|
+
* Given a directory, get or make the persisted operations getter.
|
|
119
|
+
*/
|
|
120
|
+
function getterForDirectory(directory) {
|
|
121
|
+
let getter = directoryGetterByDirectory.get(directory);
|
|
122
|
+
if (!getter) {
|
|
123
|
+
getter = makeGetterForDirectory(directory);
|
|
124
|
+
directoryGetterByDirectory.set(directory, getter);
|
|
125
|
+
}
|
|
126
|
+
return getter;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Extracts or creates a persisted operation getter function from the
|
|
130
|
+
* PostGraphile options.
|
|
131
|
+
*/
|
|
132
|
+
function getterFromOptionsCore(options) {
|
|
133
|
+
const optionsSpecified = Object.keys(options).filter((key) => [
|
|
134
|
+
"persistedOperationsGetter",
|
|
135
|
+
"persistedOperationsDirectory",
|
|
136
|
+
"persistedOperations",
|
|
137
|
+
].includes(key));
|
|
138
|
+
if (optionsSpecified.length > 1) {
|
|
139
|
+
// If you'd like support for more than one of these options; send a PR!
|
|
140
|
+
throw new Error(`'${optionsSpecified.join("' and '")}' were specified, at most one of these operations can be specified.`);
|
|
141
|
+
}
|
|
142
|
+
if (options.persistedOperationsGetter) {
|
|
143
|
+
return options.persistedOperationsGetter;
|
|
144
|
+
}
|
|
145
|
+
else if (options.persistedOperations) {
|
|
146
|
+
return persistedOperationGetterForCache(options.persistedOperations);
|
|
147
|
+
}
|
|
148
|
+
else if (options.persistedOperationsDirectory) {
|
|
149
|
+
return getterForDirectory(options.persistedOperationsDirectory);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
throw new Error("Server misconfiguration issue: persisted operations (operation allowlist) is in place, but the server has not been told how to fetch the allowed operations. Please provide one of the persisted operations configuration options.");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// TODO: use an LRU? For users using lots of new options objects this will
|
|
156
|
+
// cause a memory leak. But LRUs have a performance cost... Maybe switch to LRU
|
|
157
|
+
// once the size has grown?
|
|
158
|
+
const getterFromOptionsCache = new Map();
|
|
159
|
+
/**
|
|
160
|
+
* Returns a cached getter for performance reasons.
|
|
161
|
+
*/
|
|
162
|
+
function getterFromOptions(options) {
|
|
163
|
+
let getter = getterFromOptionsCache.get(options);
|
|
164
|
+
if (!getter) {
|
|
165
|
+
getter = getterFromOptionsCore(options);
|
|
166
|
+
getterFromOptionsCache.set(options, getter);
|
|
167
|
+
}
|
|
168
|
+
return getter;
|
|
169
|
+
}
|
|
170
|
+
function shouldAllowUnpersistedOperation(options, event) {
|
|
171
|
+
const { allowUnpersistedOperation } = options;
|
|
172
|
+
if (typeof allowUnpersistedOperation === "function") {
|
|
173
|
+
return allowUnpersistedOperation(event);
|
|
174
|
+
}
|
|
175
|
+
return !!allowUnpersistedOperation;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Given a payload, this method returns the GraphQL operation document
|
|
179
|
+
* (string), or null on failure. It **never throws**.
|
|
180
|
+
*/
|
|
181
|
+
function persistedOperationFromPayload(payload, options, allowUnpersistedOperation) {
|
|
182
|
+
try {
|
|
183
|
+
const hashFromPayload = options.hashFromPayload || defaultHashFromPayload;
|
|
184
|
+
const hash = hashFromPayload(payload);
|
|
185
|
+
if (typeof hash !== "string") {
|
|
186
|
+
if (allowUnpersistedOperation && typeof payload?.query === "string") {
|
|
187
|
+
return payload.query;
|
|
188
|
+
}
|
|
189
|
+
throw new Error("We could not find a persisted operation hash string in the request.");
|
|
190
|
+
}
|
|
191
|
+
const getter = getterFromOptions(options);
|
|
192
|
+
return getter(hash);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
console.error("Failed to get persisted operation from payload", payload, e);
|
|
196
|
+
// We must not throw, instead just overwrite the query with null (the error
|
|
197
|
+
// will be thrown elsewhere).
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { PromiseOrDirect } from "grafast";
|
|
2
|
+
import type { ParsedGraphQLBody, ProcessGraphQLRequestBodyEvent } from "grafserv";
|
|
3
|
+
/**
|
|
4
|
+
* Given a persisted operation hash, return the associated GraphQL operation
|
|
5
|
+
* document.
|
|
6
|
+
*/
|
|
7
|
+
export type PersistedOperationGetter = (hash: string) => PromiseOrDirect<string>;
|
|
8
|
+
declare global {
|
|
9
|
+
namespace GraphileConfig {
|
|
10
|
+
interface GrafservOptions {
|
|
11
|
+
/**
|
|
12
|
+
* This function will be passed a GraphQL request object (normally
|
|
13
|
+
* `{query: string, variables?: any, operationName?: string, extensions?: any}`,
|
|
14
|
+
* but in the case of persisted operations it likely won't have a `query`
|
|
15
|
+
* property), and must extract the hash to use to identify the persisted
|
|
16
|
+
* operation. For Apollo Client, this might be something like:
|
|
17
|
+
* `request?.extensions?.persistedQuery?.sha256Hash`; for Relay something
|
|
18
|
+
* like: `request?.documentId`.
|
|
19
|
+
*/
|
|
20
|
+
hashFromPayload?(request: ParsedGraphQLBody): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* We can read persisted operations from a folder (they must be named
|
|
23
|
+
* `<hash>.graphql`). When used in this way, the first request for a hash
|
|
24
|
+
* will read the file, and then the result will be cached such that the
|
|
25
|
+
* **filesystem read** will only impact the first use of that hash. We
|
|
26
|
+
* periodically scan the folder for new files, requests for hashes that
|
|
27
|
+
* were not present in our last scan of the folder will be rejected to
|
|
28
|
+
* mitigate denial of service attacks asking for non-existent hashes.
|
|
29
|
+
*/
|
|
30
|
+
persistedOperationsDirectory?: string;
|
|
31
|
+
/**
|
|
32
|
+
* An optional string-string key-value object defining the persisted
|
|
33
|
+
* operations, where the keys are the hashes, and the values are the
|
|
34
|
+
* operation document strings to use.
|
|
35
|
+
*/
|
|
36
|
+
persistedOperations?: {
|
|
37
|
+
[hash: string]: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* If your known persisted operations may change over time, or you'd rather
|
|
41
|
+
* load them on demand, you may supply this function. Note this function is
|
|
42
|
+
* **performance critical** so you should use caching to improve
|
|
43
|
+
* performance of any follow-up requests for the same hash.
|
|
44
|
+
*/
|
|
45
|
+
persistedOperationsGetter?: PersistedOperationGetter;
|
|
46
|
+
/**
|
|
47
|
+
* There are situations where you may want to allow arbitrary operations
|
|
48
|
+
* (for example using GraphiQL in development, or allowing an admin to
|
|
49
|
+
* make arbitrary requests in production) whilst enforcing Persisted
|
|
50
|
+
* Operations for the application and non-admin users. This function
|
|
51
|
+
* allows you to determine under which circumstances persisted operations
|
|
52
|
+
* may be bypassed.
|
|
53
|
+
*
|
|
54
|
+
* IMPORTANT: this function must not throw!
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
*
|
|
58
|
+
* ```
|
|
59
|
+
* app.use(postgraphile(DATABASE_URL, SCHEMAS, {
|
|
60
|
+
* allowUnpersistedOperation(event) {
|
|
61
|
+
* return process.env.NODE_ENV === "development" && event.request?.getHeader('referer')?.endsWith("/graphiql");
|
|
62
|
+
* }
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
allowUnpersistedOperation?: boolean | ((event: ProcessGraphQLRequestBodyEvent) => boolean);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=interfaces.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interfaces.d.ts","sourceRoot":"","sources":["../src/interfaces.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,KAAK,EACV,iBAAiB,EACjB,8BAA8B,EAC/B,MAAM,UAAU,CAAC;AAGlB;;;GAGG;AACH,MAAM,MAAM,wBAAwB,GAAG,CACrC,IAAI,EAAE,MAAM,KACT,eAAe,CAAC,MAAM,CAAC,CAAC;AAE7B,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,cAAc,CAAC;QACvB,UAAU,eAAe;YACvB;;;;;;;;eAQG;YACH,eAAe,CAAC,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,GAAG,SAAS,CAAC;YAEjE;;;;;;;;eAQG;YACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;YAEtC;;;;eAIG;YACH,mBAAmB,CAAC,EAAE;gBAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;aAAE,CAAC;YAEjD;;;;;eAKG;YACH,yBAAyB,CAAC,EAAE,wBAAwB,CAAC;YAErD;;;;;;;;;;;;;;;;;;;eAmBG;YACH,yBAAyB,CAAC,EACtB,OAAO,GACP,CAAC,CAAC,KAAK,EAAE,8BAA8B,KAAK,OAAO,CAAC,CAAC;SAC1D;KACF;CACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interfaces.js","sourceRoot":"","sources":["../src/interfaces.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grafserv/persisted",
|
|
3
|
+
"version": "0.0.0-1.1",
|
|
4
|
+
"description": "Persisted operations support for Grafserv",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepack": "tsc -b",
|
|
16
|
+
"test": "true"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/graphile/heart.git"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"persisted",
|
|
24
|
+
"queries",
|
|
25
|
+
"operations",
|
|
26
|
+
"documents",
|
|
27
|
+
"graphql",
|
|
28
|
+
"grafserv",
|
|
29
|
+
"server",
|
|
30
|
+
"grafast",
|
|
31
|
+
"graphile",
|
|
32
|
+
"fast",
|
|
33
|
+
"graphite"
|
|
34
|
+
],
|
|
35
|
+
"author": "Benjie Gillam <code@benjiegillam.com>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/graphile/heart/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/graphile/heart/tree/main/grafast/grafserv-persisted",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"graphile-config": "^0.0.1-1.2",
|
|
43
|
+
"tslib": "^2.4.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=16.10"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"grafast": "^0.0.1-1.3",
|
|
50
|
+
"grafserv": "^0.0.1-1.3"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"grafast": "^0.0.1-1.3",
|
|
54
|
+
"grafserv": "^0.0.1-1.3",
|
|
55
|
+
"jest": "^28.1.3",
|
|
56
|
+
"jest-serializer-graphql-schema": "^5.0.0-1.1",
|
|
57
|
+
"ts-node": "^10.9.1",
|
|
58
|
+
"typescript": "^5.0.0-beta"
|
|
59
|
+
},
|
|
60
|
+
"files": [
|
|
61
|
+
"dist",
|
|
62
|
+
"index.js"
|
|
63
|
+
]
|
|
64
|
+
}
|