@atproto/xrpc-server 0.0.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/src/util.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { Readable, Transform } from 'stream'
2
+ import { createDeflate, createGunzip } from 'zlib'
3
+ import express from 'express'
4
+ import mime from 'mime-types'
5
+ import { Lexicons, LexXrpcProcedure, LexXrpcQuery } from '@atproto/lexicon'
6
+ import { forwardStreamErrors, MaxSizeChecker } from '@atproto/common'
7
+ import {
8
+ UndecodedParams,
9
+ Params,
10
+ HandlerInput,
11
+ HandlerSuccess,
12
+ handlerSuccess,
13
+ InvalidRequestError,
14
+ InternalServerError,
15
+ Options,
16
+ XRPCError,
17
+ } from './types'
18
+
19
+ export function decodeQueryParams(
20
+ def: LexXrpcProcedure | LexXrpcQuery,
21
+ params: UndecodedParams,
22
+ ): Params {
23
+ const decoded: Params = {}
24
+ for (const k in params) {
25
+ const val = params[k]
26
+ const property = def.parameters?.properties?.[k]
27
+ if (property) {
28
+ if (property.type === 'array') {
29
+ const vals: typeof val[] = []
30
+ decoded[k] = val
31
+ ? vals
32
+ .concat(val) // Cast to array
33
+ .flatMap((v) => decodeQueryParam(property.items.type, v) ?? [])
34
+ : undefined
35
+ } else {
36
+ decoded[k] = decodeQueryParam(property.type, val)
37
+ }
38
+ }
39
+ }
40
+ return decoded
41
+ }
42
+
43
+ export function decodeQueryParam(
44
+ type: string,
45
+ value: unknown,
46
+ ): string | number | boolean | undefined {
47
+ if (!value) {
48
+ return undefined
49
+ }
50
+ if (type === 'string' || type === 'datetime') {
51
+ return String(value)
52
+ }
53
+ if (type === 'number') {
54
+ return Number(String(value))
55
+ } else if (type === 'integer') {
56
+ return Number(String(value)) | 0
57
+ } else if (type === 'boolean') {
58
+ return value === 'true'
59
+ }
60
+ }
61
+
62
+ export function validateInput(
63
+ nsid: string,
64
+ def: LexXrpcProcedure | LexXrpcQuery,
65
+ req: express.Request,
66
+ opts: Options,
67
+ lexicons: Lexicons,
68
+ ): HandlerInput | undefined {
69
+ // request expectation
70
+ const reqHasBody = hasBody(req)
71
+ if (reqHasBody && (def.type !== 'procedure' || !def.input)) {
72
+ throw new InvalidRequestError(
73
+ `A request body was provided when none was expected`,
74
+ )
75
+ }
76
+ if (def.type === 'query') {
77
+ return
78
+ }
79
+ if (!reqHasBody && def.input) {
80
+ throw new InvalidRequestError(
81
+ `A request body is expected but none was provided`,
82
+ )
83
+ }
84
+
85
+ // mimetype
86
+ const inputEncoding = normalizeMime(req.headers['content-type'] || '')
87
+ if (
88
+ def.input?.encoding &&
89
+ (!inputEncoding || !isValidEncoding(def.input?.encoding, inputEncoding))
90
+ ) {
91
+ throw new InvalidRequestError(`Invalid request encoding: ${inputEncoding}`)
92
+ }
93
+
94
+ if (!inputEncoding) {
95
+ // no input body
96
+ return undefined
97
+ }
98
+
99
+ // if input schema, validate
100
+ if (def.input?.schema) {
101
+ try {
102
+ lexicons.assertValidXrpcInput(nsid, req.body)
103
+ } catch (e) {
104
+ throw new InvalidRequestError(e instanceof Error ? e.message : String(e))
105
+ }
106
+ }
107
+
108
+ // if middleware already got the body, we pass that along as input
109
+ // otherwise, we pass along a decoded readable stream
110
+ let body
111
+ if (req.readableEnded) {
112
+ body = req.body
113
+ } else {
114
+ body = decodeBodyStream(req, opts.payload?.blobLimit)
115
+ }
116
+
117
+ return {
118
+ encoding: inputEncoding,
119
+ body,
120
+ }
121
+ }
122
+
123
+ export function validateOutput(
124
+ nsid: string,
125
+ def: LexXrpcProcedure | LexXrpcQuery,
126
+ output: HandlerSuccess | undefined,
127
+ lexicons: Lexicons,
128
+ ): HandlerSuccess | undefined {
129
+ // initial validation
130
+ if (output) {
131
+ handlerSuccess.parse(output)
132
+ }
133
+
134
+ // response expectation
135
+ if (output?.body && !def.output) {
136
+ throw new InternalServerError(
137
+ `A response body was provided when none was expected`,
138
+ )
139
+ }
140
+ if (!output?.body && def.output) {
141
+ throw new InternalServerError(
142
+ `A response body is expected but none was provided`,
143
+ )
144
+ }
145
+
146
+ // mimetype
147
+ if (
148
+ def.output?.encoding &&
149
+ (!output?.encoding ||
150
+ !isValidEncoding(def.output?.encoding, output?.encoding))
151
+ ) {
152
+ throw new InternalServerError(
153
+ `Invalid response encoding: ${output?.encoding}`,
154
+ )
155
+ }
156
+
157
+ // output schema
158
+ if (def.output?.schema) {
159
+ try {
160
+ lexicons.assertValidXrpcOutput(nsid, output?.body)
161
+ } catch (e) {
162
+ throw new InternalServerError(e instanceof Error ? e.message : String(e))
163
+ }
164
+ }
165
+
166
+ return output
167
+ }
168
+
169
+ export function normalizeMime(v: string) {
170
+ const fullType = mime.contentType(v)
171
+ if (!v) return false
172
+ const shortType = fullType.split(';')[0]
173
+ if (!shortType) return false
174
+ return shortType
175
+ }
176
+
177
+ function isValidEncoding(possibleStr: string, value: string) {
178
+ const possible = possibleStr.split(',').map((v) => v.trim())
179
+ const normalized = normalizeMime(value)
180
+ if (!normalized) return false
181
+ if (possible.includes('*/*')) return true
182
+ return possible.includes(normalized)
183
+ }
184
+
185
+ export function hasBody(req: express.Request) {
186
+ const contentLength = req.headers['content-length']
187
+ const transferEncoding = req.headers['transfer-encoding']
188
+ return (contentLength && parseInt(contentLength, 10) > 0) || transferEncoding
189
+ }
190
+
191
+ export function processBodyAsBytes(req: express.Request): Promise<Uint8Array> {
192
+ return new Promise((resolve) => {
193
+ const chunks: Buffer[] = []
194
+ req.on('data', (chunk) => chunks.push(chunk))
195
+ req.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks))))
196
+ })
197
+ }
198
+
199
+ function decodeBodyStream(
200
+ req: express.Request,
201
+ maxSize: number | undefined,
202
+ ): Readable {
203
+ let stream: Readable = req
204
+ const contentEncoding = req.headers['content-encoding']
205
+ const contentLength = req.headers['content-length']
206
+
207
+ if (
208
+ maxSize !== undefined &&
209
+ contentLength &&
210
+ parseInt(contentLength, 10) > maxSize
211
+ ) {
212
+ throw new XRPCError(413, 'request entity too large')
213
+ }
214
+
215
+ let decoder: Transform | undefined
216
+ if (contentEncoding === 'gzip') {
217
+ decoder = createGunzip()
218
+ } else if (contentEncoding === 'deflate') {
219
+ decoder = createDeflate()
220
+ }
221
+
222
+ if (decoder) {
223
+ forwardStreamErrors(stream, decoder)
224
+ stream = stream.pipe(decoder)
225
+ }
226
+
227
+ if (maxSize !== undefined) {
228
+ const maxSizeChecker = new MaxSizeChecker(
229
+ maxSize,
230
+ () => new XRPCError(413, 'request entity too large'),
231
+ )
232
+ forwardStreamErrors(stream, maxSizeChecker)
233
+ stream = stream.pipe(maxSizeChecker)
234
+ }
235
+
236
+ return stream
237
+ }
package/tests/_util.ts ADDED
@@ -0,0 +1,20 @@
1
+ import * as http from 'http'
2
+ import express from 'express'
3
+ import * as xrpc from '../src/index'
4
+
5
+ export async function createServer(
6
+ port: number,
7
+ server: xrpc.Server,
8
+ ): Promise<http.Server> {
9
+ const app = express()
10
+ app.use(server.router)
11
+ const httpServer = app.listen(port)
12
+ await new Promise((r) => httpServer.on('listening', r))
13
+ return httpServer
14
+ }
15
+
16
+ export async function closeServer(httpServer: http.Server) {
17
+ await new Promise((r) => {
18
+ httpServer.close(() => r(undefined))
19
+ })
20
+ }
@@ -0,0 +1,155 @@
1
+ import * as http from 'http'
2
+ import express from 'express'
3
+ import xrpc, { XRPCError } from '@atproto/xrpc'
4
+ import { createServer, closeServer } from './_util'
5
+ import * as xrpcServer from '../src'
6
+ import { AuthRequiredError } from '../src'
7
+
8
+ const LEXICONS = [
9
+ {
10
+ lexicon: 1,
11
+ id: 'io.example.authTest',
12
+ defs: {
13
+ main: {
14
+ type: 'procedure',
15
+ input: {
16
+ encoding: 'application/json',
17
+ schema: {
18
+ type: 'object',
19
+ properties: {
20
+ present: { type: 'boolean', const: true },
21
+ },
22
+ },
23
+ },
24
+ output: {
25
+ encoding: 'application/json',
26
+ schema: {
27
+ type: 'object',
28
+ properties: {
29
+ username: { type: 'string' },
30
+ original: { type: 'string' },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ },
36
+ },
37
+ ]
38
+
39
+ describe('Auth', () => {
40
+ let s: http.Server
41
+ const server = xrpcServer.createServer(LEXICONS)
42
+ server.method('io.example.authTest', {
43
+ auth: createBasicAuth({ username: 'admin', password: 'password' }),
44
+ handler: ({ auth }) => {
45
+ return {
46
+ encoding: 'application/json',
47
+ body: {
48
+ username: auth?.credentials?.username,
49
+ original: auth?.artifacts?.original,
50
+ },
51
+ }
52
+ },
53
+ })
54
+ const client = xrpc.service(`http://localhost:8894`)
55
+ xrpc.addLexicons(LEXICONS)
56
+ beforeAll(async () => {
57
+ s = await createServer(8894, server)
58
+ })
59
+ afterAll(async () => {
60
+ await closeServer(s)
61
+ })
62
+
63
+ it('fails on bad auth before invalid request payload.', async () => {
64
+ try {
65
+ await client.call(
66
+ 'io.example.authTest',
67
+ {},
68
+ { present: false },
69
+ {
70
+ headers: basicAuthHeaders({
71
+ username: 'admin',
72
+ password: 'wrong',
73
+ }),
74
+ },
75
+ )
76
+ throw new Error('Didnt throw')
77
+ } catch (e: any) {
78
+ expect(e instanceof XRPCError).toBeTruthy()
79
+ expect(e.success).toBeFalsy()
80
+ expect(e.error).toBe('AuthenticationRequired')
81
+ expect(e.message).toBe('Authentication Required')
82
+ expect(e.status).toBe(401)
83
+ }
84
+ })
85
+
86
+ it('fails on invalid request payload after good auth.', async () => {
87
+ try {
88
+ await client.call(
89
+ 'io.example.authTest',
90
+ {},
91
+ { present: false },
92
+ {
93
+ headers: basicAuthHeaders({
94
+ username: 'admin',
95
+ password: 'password',
96
+ }),
97
+ },
98
+ )
99
+ throw new Error('Didnt throw')
100
+ } catch (e: any) {
101
+ expect(e instanceof XRPCError).toBeTruthy()
102
+ expect(e.success).toBeFalsy()
103
+ expect(e.error).toBe('InvalidRequest')
104
+ expect(e.message).toBe('Input/present must be true')
105
+ expect(e.status).toBe(400)
106
+ }
107
+ })
108
+
109
+ it('succeeds on good auth and payload.', async () => {
110
+ const res = await client.call(
111
+ 'io.example.authTest',
112
+ {},
113
+ { present: true },
114
+ {
115
+ headers: basicAuthHeaders({
116
+ username: 'admin',
117
+ password: 'password',
118
+ }),
119
+ },
120
+ )
121
+ expect(res.success).toBe(true)
122
+ expect(res.data).toEqual({
123
+ username: 'admin',
124
+ original: 'YWRtaW46cGFzc3dvcmQ=',
125
+ })
126
+ })
127
+ })
128
+
129
+ function createBasicAuth(allowed: { username: string; password: string }) {
130
+ return function (ctx: { req: express.Request }) {
131
+ const header = ctx.req.headers.authorization ?? ''
132
+ if (!header.startsWith('Basic ')) {
133
+ throw new AuthRequiredError()
134
+ }
135
+ const original = header.replace('Basic ', '')
136
+ const [username, password] = Buffer.from(original, 'base64')
137
+ .toString()
138
+ .split(':')
139
+ if (username !== allowed.username || password !== allowed.password) {
140
+ throw new AuthRequiredError()
141
+ }
142
+ return {
143
+ credentials: { username },
144
+ artifacts: { original },
145
+ }
146
+ }
147
+ }
148
+
149
+ function basicAuthHeaders(creds: { username: string; password: string }) {
150
+ return {
151
+ authorization:
152
+ 'Basic ' +
153
+ Buffer.from(`${creds.username}:${creds.password}`).toString('base64'),
154
+ }
155
+ }
@@ -0,0 +1,240 @@
1
+ import * as http from 'http'
2
+ import { Readable } from 'stream'
3
+ import { gzipSync } from 'zlib'
4
+ import xrpc from '@atproto/xrpc'
5
+ import { bytesToStream, cidForData } from '@atproto/common'
6
+ import { randomBytes } from '@atproto/crypto'
7
+ import { createServer, closeServer } from './_util'
8
+ import * as xrpcServer from '../src'
9
+ import logger from '../src/logger'
10
+
11
+ const LEXICONS = [
12
+ {
13
+ lexicon: 1,
14
+ id: 'io.example.validationTest',
15
+ defs: {
16
+ main: {
17
+ type: 'procedure',
18
+ input: {
19
+ encoding: 'application/json',
20
+ schema: {
21
+ type: 'object',
22
+ required: ['foo'],
23
+ properties: {
24
+ foo: { type: 'string' },
25
+ bar: { type: 'number' },
26
+ },
27
+ },
28
+ },
29
+ output: {
30
+ encoding: 'application/json',
31
+ schema: {
32
+ type: 'object',
33
+ required: ['foo'],
34
+ properties: {
35
+ foo: { type: 'string' },
36
+ bar: { type: 'number' },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ {
44
+ lexicon: 1,
45
+ id: 'io.example.validationTest2',
46
+ defs: {
47
+ main: {
48
+ type: 'query',
49
+ output: {
50
+ encoding: 'application/json',
51
+ schema: {
52
+ type: 'object',
53
+ required: ['foo'],
54
+ properties: {
55
+ foo: { type: 'string' },
56
+ bar: { type: 'number' },
57
+ },
58
+ },
59
+ },
60
+ },
61
+ },
62
+ },
63
+ {
64
+ lexicon: 1,
65
+ id: 'io.example.blobTest',
66
+ defs: {
67
+ main: {
68
+ type: 'procedure',
69
+ input: {
70
+ encoding: '*/*',
71
+ },
72
+ output: {
73
+ encoding: 'application/json',
74
+ schema: {
75
+ type: 'object',
76
+ required: ['cid'],
77
+ properties: {
78
+ cid: { type: 'string' },
79
+ },
80
+ },
81
+ },
82
+ },
83
+ },
84
+ },
85
+ ]
86
+
87
+ const BLOB_LIMIT = 5000
88
+
89
+ describe('Bodies', () => {
90
+ let s: http.Server
91
+ const server = xrpcServer.createServer(LEXICONS, {
92
+ payload: {
93
+ blobLimit: BLOB_LIMIT,
94
+ },
95
+ })
96
+ server.method(
97
+ 'io.example.validationTest',
98
+ (ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => ({
99
+ encoding: 'json',
100
+ body: ctx.input?.body,
101
+ }),
102
+ )
103
+ server.method('io.example.validationTest2', () => ({
104
+ encoding: 'json',
105
+ body: { wrong: 'data' },
106
+ }))
107
+ server.method(
108
+ 'io.example.blobTest',
109
+ async (ctx: { input?: xrpcServer.HandlerInput }) => {
110
+ if (!(ctx.input?.body instanceof Readable))
111
+ throw new Error('Input not readable')
112
+ const buffers: Buffer[] = []
113
+ for await (const data of ctx.input.body) {
114
+ buffers.push(data)
115
+ }
116
+ const cid = await cidForData(Buffer.concat(buffers))
117
+ return {
118
+ encoding: 'json',
119
+ body: { cid: cid.toString() },
120
+ }
121
+ },
122
+ )
123
+ const client = xrpc.service(`http://localhost:8892`)
124
+ xrpc.addLexicons(LEXICONS)
125
+ beforeAll(async () => {
126
+ s = await createServer(8892, server)
127
+ })
128
+ afterAll(async () => {
129
+ await closeServer(s)
130
+ })
131
+
132
+ it('validates input and output bodies', async () => {
133
+ const res1 = await client.call(
134
+ 'io.example.validationTest',
135
+ {},
136
+ {
137
+ foo: 'hello',
138
+ bar: 123,
139
+ },
140
+ )
141
+ expect(res1.success).toBeTruthy()
142
+ expect(res1.data.foo).toBe('hello')
143
+ expect(res1.data.bar).toBe(123)
144
+
145
+ await expect(client.call('io.example.validationTest', {})).rejects.toThrow(
146
+ `A request body is expected but none was provided`,
147
+ )
148
+ await expect(
149
+ client.call('io.example.validationTest', {}, {}),
150
+ ).rejects.toThrow(`Input must have the property "foo"`)
151
+ await expect(
152
+ client.call('io.example.validationTest', {}, { foo: 123 }),
153
+ ).rejects.toThrow(`Input/foo must be a string`)
154
+
155
+ // 500 responses don't include details, so we nab details from the logger.
156
+ let error: string | undefined
157
+ const origError = logger.error
158
+ logger.error = (obj, ...args) => {
159
+ error = obj.message
160
+ logger.error = origError
161
+ return logger.error(obj, ...args)
162
+ }
163
+
164
+ await expect(client.call('io.example.validationTest2')).rejects.toThrow(
165
+ 'Internal Server Error',
166
+ )
167
+ expect(error).toEqual(`Output must have the property "foo"`)
168
+ })
169
+
170
+ it('supports blobs and compression', async () => {
171
+ const bytes = randomBytes(1024)
172
+ const expectedCid = await cidForData(bytes)
173
+
174
+ const { data: uncompressed } = await client.call(
175
+ 'io.example.blobTest',
176
+ {},
177
+ bytes,
178
+ {
179
+ encoding: 'application/octet-stream',
180
+ },
181
+ )
182
+ expect(uncompressed.cid).toEqual(expectedCid.toString())
183
+
184
+ const { data: compressed } = await client.call(
185
+ 'io.example.blobTest',
186
+ {},
187
+ gzipSync(bytes),
188
+ {
189
+ encoding: 'application/octet-stream',
190
+ headers: {
191
+ 'content-encoding': 'gzip',
192
+ },
193
+ },
194
+ )
195
+ expect(compressed.cid).toEqual(expectedCid.toString())
196
+ })
197
+
198
+ it('supports max blob size (based on content-length)', async () => {
199
+ const bytes = randomBytes(BLOB_LIMIT + 1)
200
+
201
+ // Exactly the number of allowed bytes
202
+ await client.call('io.example.blobTest', {}, bytes.slice(0, BLOB_LIMIT), {
203
+ encoding: 'application/octet-stream',
204
+ })
205
+
206
+ // Over the number of allowed bytes
207
+ const promise = client.call('io.example.blobTest', {}, bytes, {
208
+ encoding: 'application/octet-stream',
209
+ })
210
+
211
+ await expect(promise).rejects.toThrow('request entity too large')
212
+ })
213
+
214
+ it('supports max blob size (missing content-length)', async () => {
215
+ // We stream bytes in these tests so that content-length isn't included.
216
+ const bytes = randomBytes(BLOB_LIMIT + 1)
217
+
218
+ // Exactly the number of allowed bytes
219
+ await client.call(
220
+ 'io.example.blobTest',
221
+ {},
222
+ bytesToStream(bytes.slice(0, BLOB_LIMIT)),
223
+ {
224
+ encoding: 'application/octet-stream',
225
+ },
226
+ )
227
+
228
+ // Over the number of allowed bytes.
229
+ const promise = client.call(
230
+ 'io.example.blobTest',
231
+ {},
232
+ bytesToStream(bytes),
233
+ {
234
+ encoding: 'application/octet-stream',
235
+ },
236
+ )
237
+
238
+ await expect(promise).rejects.toThrow('request entity too large')
239
+ })
240
+ })