@atproto/xrpc-server 0.0.1 → 0.2.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/dist/auth.d.ts +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +40116 -29848
- package/dist/index.js.map +4 -4
- package/dist/server.d.ts +9 -3
- package/dist/src/index.d.ts +2 -0
- package/dist/src/logger.d.ts +2 -0
- package/dist/src/server.d.ts +19 -0
- package/dist/src/types.d.ts +115 -0
- package/dist/src/util.d.ts +10 -0
- package/dist/stream/frames.d.ts +25 -0
- package/dist/stream/index.d.ts +5 -0
- package/dist/stream/logger.d.ts +2 -0
- package/dist/stream/server.d.ts +11 -0
- package/dist/stream/stream.d.ts +5 -0
- package/dist/stream/subscription.d.ts +24 -0
- package/dist/stream/types.d.ts +64 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/util.d.ts +3 -2
- package/package.json +14 -2
- package/src/auth.ts +111 -0
- package/src/index.ts +2 -0
- package/src/server.ts +148 -10
- package/src/stream/frames.ts +95 -0
- package/src/stream/index.ts +5 -0
- package/src/stream/logger.ts +5 -0
- package/src/stream/server.ts +65 -0
- package/src/stream/stream.ts +26 -0
- package/src/stream/subscription.ts +175 -0
- package/src/stream/types.ts +43 -0
- package/src/types.ts +27 -2
- package/src/util.ts +38 -7
- package/tests/_util.ts +36 -1
- package/tests/auth.test.ts +15 -36
- package/tests/bodies.test.ts +50 -9
- package/tests/errors.test.ts +38 -11
- package/tests/frames.test.ts +137 -0
- package/tests/ipld.test.ts +96 -0
- package/tests/parameters.test.ts +13 -45
- package/tests/procedures.test.ts +7 -3
- package/tests/queries.test.ts +7 -3
- package/tests/stream.test.ts +169 -0
- package/tests/subscriptions.test.ts +347 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { wait } from '@atproto/common'
|
|
2
|
+
import { WebSocket, ClientOptions } from 'ws'
|
|
3
|
+
import { byMessage } from './stream'
|
|
4
|
+
import { CloseCode, DisconnectError } from './types'
|
|
5
|
+
|
|
6
|
+
export class Subscription<T = unknown> {
|
|
7
|
+
constructor(
|
|
8
|
+
public opts: ClientOptions & {
|
|
9
|
+
service: string
|
|
10
|
+
method: string
|
|
11
|
+
maxReconnectSeconds?: number
|
|
12
|
+
signal?: AbortSignal
|
|
13
|
+
validate: (obj: unknown) => T | undefined
|
|
14
|
+
onReconnectError?: (
|
|
15
|
+
error: unknown,
|
|
16
|
+
n: number,
|
|
17
|
+
initialSetup: boolean,
|
|
18
|
+
) => void
|
|
19
|
+
getParams?: () =>
|
|
20
|
+
| Record<string, unknown>
|
|
21
|
+
| Promise<Record<string, unknown> | undefined>
|
|
22
|
+
| undefined
|
|
23
|
+
},
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
|
|
27
|
+
let initialSetup = true
|
|
28
|
+
let reconnects: number | null = null
|
|
29
|
+
const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64)
|
|
30
|
+
while (true) {
|
|
31
|
+
if (reconnects !== null) {
|
|
32
|
+
const duration = initialSetup
|
|
33
|
+
? Math.min(1000, maxReconnectMs)
|
|
34
|
+
: backoffMs(reconnects++, maxReconnectMs)
|
|
35
|
+
await wait(duration)
|
|
36
|
+
}
|
|
37
|
+
const ws = await this.getSocket()
|
|
38
|
+
const ac = new AbortController()
|
|
39
|
+
if (this.opts.signal) {
|
|
40
|
+
forwardSignal(this.opts.signal, ac)
|
|
41
|
+
}
|
|
42
|
+
ws.once('open', () => {
|
|
43
|
+
initialSetup = false
|
|
44
|
+
reconnects = 0
|
|
45
|
+
})
|
|
46
|
+
ws.once('close', (code, reason) => {
|
|
47
|
+
if (code === CloseCode.Abnormal) {
|
|
48
|
+
// Forward into an error to distinguish from a clean close
|
|
49
|
+
ac.abort(
|
|
50
|
+
new AbnormalCloseError(`Abnormal ws close: ${reason.toString()}`),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
try {
|
|
55
|
+
const cancelable = { signal: ac.signal }
|
|
56
|
+
for await (const message of byMessage(ws, cancelable)) {
|
|
57
|
+
const t = message.header.t
|
|
58
|
+
const clone =
|
|
59
|
+
message.body !== undefined ? { ...message.body } : undefined
|
|
60
|
+
if (clone !== undefined && t !== undefined) {
|
|
61
|
+
clone['$type'] = t.startsWith('#') ? this.opts.method + t : t
|
|
62
|
+
}
|
|
63
|
+
const result = this.opts.validate(clone)
|
|
64
|
+
if (result !== undefined) {
|
|
65
|
+
yield result
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (_err) {
|
|
69
|
+
const err = _err?.['code'] === 'ABORT_ERR' ? _err['cause'] : _err
|
|
70
|
+
if (err instanceof DisconnectError) {
|
|
71
|
+
// We cleanly end the connection
|
|
72
|
+
ws.close(err.wsCode)
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
ws.close() // No-ops if already closed or closing
|
|
76
|
+
if (isReconnectable(err)) {
|
|
77
|
+
reconnects ??= 0 // Never reconnect with a null
|
|
78
|
+
this.opts.onReconnectError?.(err, reconnects, initialSetup)
|
|
79
|
+
continue
|
|
80
|
+
} else {
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
break // Other side cleanly ended stream and disconnected
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async getSocket() {
|
|
89
|
+
const params = (await this.opts.getParams?.()) ?? {}
|
|
90
|
+
const query = encodeQueryParams(params)
|
|
91
|
+
const url = `${this.opts.service}/xrpc/${this.opts.method}?${query}`
|
|
92
|
+
return new WebSocket(url, this.opts)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default Subscription
|
|
97
|
+
|
|
98
|
+
class AbnormalCloseError extends Error {
|
|
99
|
+
code = 'EWSABNORMALCLOSE'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isReconnectable(err: unknown): boolean {
|
|
103
|
+
// Network errors are reconnectable.
|
|
104
|
+
// AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.
|
|
105
|
+
// @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving
|
|
106
|
+
// an invalid message is not current reconnectable, but the user can decide to skip them.
|
|
107
|
+
if (!err || typeof err['code'] !== 'string') return false
|
|
108
|
+
return networkErrorCodes.includes(err['code'])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const networkErrorCodes = [
|
|
112
|
+
'EWSABNORMALCLOSE',
|
|
113
|
+
'ECONNRESET',
|
|
114
|
+
'ECONNREFUSED',
|
|
115
|
+
'ECONNABORTED',
|
|
116
|
+
'EPIPE',
|
|
117
|
+
'ETIMEDOUT',
|
|
118
|
+
'ECANCELED',
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
function backoffMs(n: number, maxMs: number) {
|
|
122
|
+
const baseSec = Math.pow(2, n) // 1, 2, 4, ...
|
|
123
|
+
const randSec = Math.random() - 0.5 // Random jitter between -.5 and .5 seconds
|
|
124
|
+
const ms = 1000 * (baseSec + randSec)
|
|
125
|
+
return Math.min(ms, maxMs)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function encodeQueryParams(obj: Record<string, unknown>): string {
|
|
129
|
+
const params = new URLSearchParams()
|
|
130
|
+
Object.entries(obj).forEach(([key, value]) => {
|
|
131
|
+
const encoded = encodeQueryParam(value)
|
|
132
|
+
if (Array.isArray(encoded)) {
|
|
133
|
+
encoded.forEach((enc) => params.append(key, enc))
|
|
134
|
+
} else {
|
|
135
|
+
params.set(key, encoded)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
return params.toString()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Adapted from xrpc, but without any lex-specific knowledge
|
|
142
|
+
function encodeQueryParam(value: unknown): string | string[] {
|
|
143
|
+
if (typeof value === 'string') {
|
|
144
|
+
return value
|
|
145
|
+
}
|
|
146
|
+
if (typeof value === 'number') {
|
|
147
|
+
return value.toString()
|
|
148
|
+
}
|
|
149
|
+
if (typeof value === 'boolean') {
|
|
150
|
+
return value ? 'true' : 'false'
|
|
151
|
+
}
|
|
152
|
+
if (typeof value === 'undefined') {
|
|
153
|
+
return ''
|
|
154
|
+
}
|
|
155
|
+
if (typeof value === 'object') {
|
|
156
|
+
if (value instanceof Date) {
|
|
157
|
+
return value.toISOString()
|
|
158
|
+
} else if (Array.isArray(value)) {
|
|
159
|
+
return value.flatMap(encodeQueryParam)
|
|
160
|
+
} else if (!value) {
|
|
161
|
+
return ''
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Cannot encode ${typeof value}s into query params`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function forwardSignal(signal: AbortSignal, ac: AbortController) {
|
|
168
|
+
if (signal.aborted) {
|
|
169
|
+
return ac.abort(signal.reason)
|
|
170
|
+
} else {
|
|
171
|
+
signal.addEventListener('abort', () => ac.abort(signal.reason), {
|
|
172
|
+
signal: ac.signal,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export enum FrameType {
|
|
4
|
+
Message = 1,
|
|
5
|
+
Error = -1,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const messageFrameHeader = z.object({
|
|
9
|
+
op: z.literal(FrameType.Message), // Frame op
|
|
10
|
+
t: z.string().optional(), // Message body type discriminator
|
|
11
|
+
})
|
|
12
|
+
export type MessageFrameHeader = z.infer<typeof messageFrameHeader>
|
|
13
|
+
|
|
14
|
+
export const errorFrameHeader = z.object({
|
|
15
|
+
op: z.literal(FrameType.Error),
|
|
16
|
+
})
|
|
17
|
+
export const errorFrameBody = z.object({
|
|
18
|
+
error: z.string(), // Error code
|
|
19
|
+
message: z.string().optional(), // Error message
|
|
20
|
+
})
|
|
21
|
+
export type ErrorFrameHeader = z.infer<typeof errorFrameHeader>
|
|
22
|
+
export type ErrorFrameBody<T extends string = string> = { error: T } & z.infer<
|
|
23
|
+
typeof errorFrameBody
|
|
24
|
+
>
|
|
25
|
+
|
|
26
|
+
export const frameHeader = z.union([messageFrameHeader, errorFrameHeader])
|
|
27
|
+
export type FrameHeader = z.infer<typeof frameHeader>
|
|
28
|
+
|
|
29
|
+
export class DisconnectError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
public wsCode: CloseCode = CloseCode.Policy,
|
|
32
|
+
public xrpcCode?: string,
|
|
33
|
+
) {
|
|
34
|
+
super()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
|
|
39
|
+
export enum CloseCode {
|
|
40
|
+
Normal = 1000,
|
|
41
|
+
Abnormal = 1006,
|
|
42
|
+
Policy = 1008,
|
|
43
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { IncomingMessage } from 'http'
|
|
1
2
|
import express from 'express'
|
|
2
3
|
import { isHttpError } from 'http-errors'
|
|
3
4
|
import zod from 'zod'
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ResponseType,
|
|
7
|
+
ResponseTypeStrings,
|
|
8
|
+
ResponseTypeNames,
|
|
9
|
+
} from '@atproto/xrpc'
|
|
5
10
|
|
|
6
11
|
export type Options = {
|
|
7
12
|
validateResponse?: boolean
|
|
@@ -52,6 +57,13 @@ export type XRPCHandler = (ctx: {
|
|
|
52
57
|
res: express.Response
|
|
53
58
|
}) => Promise<HandlerOutput> | HandlerOutput | undefined
|
|
54
59
|
|
|
60
|
+
export type XRPCStreamHandler = (ctx: {
|
|
61
|
+
auth: HandlerAuth | undefined
|
|
62
|
+
params: Params
|
|
63
|
+
req: IncomingMessage
|
|
64
|
+
signal: AbortSignal
|
|
65
|
+
}) => AsyncIterable<unknown>
|
|
66
|
+
|
|
55
67
|
export type AuthOutput = HandlerAuth | HandlerError
|
|
56
68
|
|
|
57
69
|
export type AuthVerifier = (ctx: {
|
|
@@ -59,11 +71,20 @@ export type AuthVerifier = (ctx: {
|
|
|
59
71
|
res: express.Response
|
|
60
72
|
}) => Promise<AuthOutput> | AuthOutput
|
|
61
73
|
|
|
74
|
+
export type StreamAuthVerifier = (ctx: {
|
|
75
|
+
req: IncomingMessage
|
|
76
|
+
}) => Promise<AuthOutput> | AuthOutput
|
|
77
|
+
|
|
62
78
|
export type XRPCHandlerConfig = {
|
|
63
79
|
auth?: AuthVerifier
|
|
64
80
|
handler: XRPCHandler
|
|
65
81
|
}
|
|
66
82
|
|
|
83
|
+
export type XRPCStreamHandlerConfig = {
|
|
84
|
+
auth?: StreamAuthVerifier
|
|
85
|
+
handler: XRPCStreamHandler
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
export class XRPCError extends Error {
|
|
68
89
|
constructor(
|
|
69
90
|
public type: ResponseType,
|
|
@@ -75,7 +96,7 @@ export class XRPCError extends Error {
|
|
|
75
96
|
|
|
76
97
|
get payload() {
|
|
77
98
|
return {
|
|
78
|
-
error: this.customErrorName,
|
|
99
|
+
error: this.customErrorName ?? this.typeName,
|
|
79
100
|
message:
|
|
80
101
|
this.type === ResponseType.InternalServerError
|
|
81
102
|
? this.typeStr // Do not respond with error details for 500s
|
|
@@ -83,6 +104,10 @@ export class XRPCError extends Error {
|
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
106
|
|
|
107
|
+
get typeName(): string | undefined {
|
|
108
|
+
return ResponseTypeNames[this.type]
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
get typeStr(): string | undefined {
|
|
87
112
|
return ResponseTypeStrings[this.type]
|
|
88
113
|
}
|
package/src/util.ts
CHANGED
|
@@ -2,7 +2,13 @@ import { Readable, Transform } from 'stream'
|
|
|
2
2
|
import { createDeflate, createGunzip } from 'zlib'
|
|
3
3
|
import express from 'express'
|
|
4
4
|
import mime from 'mime-types'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
jsonToLex,
|
|
7
|
+
Lexicons,
|
|
8
|
+
LexXrpcProcedure,
|
|
9
|
+
LexXrpcQuery,
|
|
10
|
+
LexXrpcSubscription,
|
|
11
|
+
} from '@atproto/lexicon'
|
|
6
12
|
import { forwardStreamErrors, MaxSizeChecker } from '@atproto/common'
|
|
7
13
|
import {
|
|
8
14
|
UndecodedParams,
|
|
@@ -17,7 +23,7 @@ import {
|
|
|
17
23
|
} from './types'
|
|
18
24
|
|
|
19
25
|
export function decodeQueryParams(
|
|
20
|
-
def: LexXrpcProcedure | LexXrpcQuery,
|
|
26
|
+
def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription,
|
|
21
27
|
params: UndecodedParams,
|
|
22
28
|
): Params {
|
|
23
29
|
const decoded: Params = {}
|
|
@@ -50,7 +56,7 @@ export function decodeQueryParam(
|
|
|
50
56
|
if (type === 'string' || type === 'datetime') {
|
|
51
57
|
return String(value)
|
|
52
58
|
}
|
|
53
|
-
if (type === '
|
|
59
|
+
if (type === 'float') {
|
|
54
60
|
return Number(String(value))
|
|
55
61
|
} else if (type === 'integer') {
|
|
56
62
|
return Number(String(value)) | 0
|
|
@@ -59,6 +65,18 @@ export function decodeQueryParam(
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
export function getQueryParams(url = ''): Record<string, string | string[]> {
|
|
69
|
+
const { searchParams } = new URL(url ?? '', 'http://x')
|
|
70
|
+
const result: Record<string, string | string[]> = {}
|
|
71
|
+
for (const key of searchParams.keys()) {
|
|
72
|
+
result[key] = searchParams.getAll(key)
|
|
73
|
+
if (result[key].length === 1) {
|
|
74
|
+
result[key] = result[key][0]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
export function validateInput(
|
|
63
81
|
nsid: string,
|
|
64
82
|
def: LexXrpcProcedure | LexXrpcQuery,
|
|
@@ -88,7 +106,15 @@ export function validateInput(
|
|
|
88
106
|
def.input?.encoding &&
|
|
89
107
|
(!inputEncoding || !isValidEncoding(def.input?.encoding, inputEncoding))
|
|
90
108
|
) {
|
|
91
|
-
|
|
109
|
+
if (!inputEncoding) {
|
|
110
|
+
throw new InvalidRequestError(
|
|
111
|
+
`Request encoding (Content-Type) required but not provided`,
|
|
112
|
+
)
|
|
113
|
+
} else {
|
|
114
|
+
throw new InvalidRequestError(
|
|
115
|
+
`Wrong request encoding (Content-Type): ${inputEncoding}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
if (!inputEncoding) {
|
|
@@ -99,7 +125,8 @@ export function validateInput(
|
|
|
99
125
|
// if input schema, validate
|
|
100
126
|
if (def.input?.schema) {
|
|
101
127
|
try {
|
|
102
|
-
|
|
128
|
+
const lexBody = req.body ? jsonToLex(req.body) : req.body
|
|
129
|
+
req.body = lexicons.assertValidXrpcInput(nsid, lexBody)
|
|
103
130
|
} catch (e) {
|
|
104
131
|
throw new InvalidRequestError(e instanceof Error ? e.message : String(e))
|
|
105
132
|
}
|
|
@@ -157,7 +184,10 @@ export function validateOutput(
|
|
|
157
184
|
// output schema
|
|
158
185
|
if (def.output?.schema) {
|
|
159
186
|
try {
|
|
160
|
-
lexicons.assertValidXrpcOutput(nsid, output?.body)
|
|
187
|
+
const result = lexicons.assertValidXrpcOutput(nsid, output?.body)
|
|
188
|
+
if (output) {
|
|
189
|
+
output.body = result
|
|
190
|
+
}
|
|
161
191
|
} catch (e) {
|
|
162
192
|
throw new InternalServerError(e instanceof Error ? e.message : String(e))
|
|
163
193
|
}
|
|
@@ -167,8 +197,9 @@ export function validateOutput(
|
|
|
167
197
|
}
|
|
168
198
|
|
|
169
199
|
export function normalizeMime(v: string) {
|
|
170
|
-
const fullType = mime.contentType(v)
|
|
171
200
|
if (!v) return false
|
|
201
|
+
const fullType = mime.contentType(v)
|
|
202
|
+
if (!fullType) return false
|
|
172
203
|
const shortType = fullType.split(';')[0]
|
|
173
204
|
if (!shortType) return false
|
|
174
205
|
return shortType
|
package/tests/_util.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as http from 'http'
|
|
2
2
|
import express from 'express'
|
|
3
|
-
import * as xrpc from '../src
|
|
3
|
+
import * as xrpc from '../src'
|
|
4
|
+
import { AuthRequiredError } from '../src'
|
|
4
5
|
|
|
5
6
|
export async function createServer(
|
|
6
7
|
port: number,
|
|
@@ -18,3 +19,37 @@ export async function closeServer(httpServer: http.Server) {
|
|
|
18
19
|
httpServer.close(() => r(undefined))
|
|
19
20
|
})
|
|
20
21
|
}
|
|
22
|
+
|
|
23
|
+
export function createBasicAuth(allowed: {
|
|
24
|
+
username: string
|
|
25
|
+
password: string
|
|
26
|
+
}) {
|
|
27
|
+
return function (ctx: { req: http.IncomingMessage }) {
|
|
28
|
+
const header = ctx.req.headers.authorization ?? ''
|
|
29
|
+
if (!header.startsWith('Basic ')) {
|
|
30
|
+
throw new AuthRequiredError()
|
|
31
|
+
}
|
|
32
|
+
const original = header.replace('Basic ', '')
|
|
33
|
+
const [username, password] = Buffer.from(original, 'base64')
|
|
34
|
+
.toString()
|
|
35
|
+
.split(':')
|
|
36
|
+
if (username !== allowed.username || password !== allowed.password) {
|
|
37
|
+
throw new AuthRequiredError()
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
credentials: { username },
|
|
41
|
+
artifacts: { original },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function basicAuthHeaders(creds: {
|
|
47
|
+
username: string
|
|
48
|
+
password: string
|
|
49
|
+
}) {
|
|
50
|
+
return {
|
|
51
|
+
authorization:
|
|
52
|
+
'Basic ' +
|
|
53
|
+
Buffer.from(`${creds.username}:${creds.password}`).toString('base64'),
|
|
54
|
+
}
|
|
55
|
+
}
|
package/tests/auth.test.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import * as http from 'http'
|
|
2
|
-
import
|
|
3
|
-
import xrpc, { XRPCError } from '@atproto/xrpc'
|
|
4
|
-
import { createServer, closeServer } from './_util'
|
|
2
|
+
import getPort from 'get-port'
|
|
3
|
+
import xrpc, { ServiceClient, XRPCError } from '@atproto/xrpc'
|
|
5
4
|
import * as xrpcServer from '../src'
|
|
6
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createServer,
|
|
7
|
+
closeServer,
|
|
8
|
+
createBasicAuth,
|
|
9
|
+
basicAuthHeaders,
|
|
10
|
+
} from './_util'
|
|
7
11
|
|
|
8
12
|
const LEXICONS = [
|
|
9
13
|
{
|
|
@@ -51,10 +55,13 @@ describe('Auth', () => {
|
|
|
51
55
|
}
|
|
52
56
|
},
|
|
53
57
|
})
|
|
54
|
-
const client = xrpc.service(`http://localhost:8894`)
|
|
55
58
|
xrpc.addLexicons(LEXICONS)
|
|
59
|
+
|
|
60
|
+
let client: ServiceClient
|
|
56
61
|
beforeAll(async () => {
|
|
57
|
-
|
|
62
|
+
const port = await getPort()
|
|
63
|
+
s = await createServer(port, server)
|
|
64
|
+
client = xrpc.service(`http://localhost:${port}`)
|
|
58
65
|
})
|
|
59
66
|
afterAll(async () => {
|
|
60
67
|
await closeServer(s)
|
|
@@ -75,7 +82,7 @@ describe('Auth', () => {
|
|
|
75
82
|
)
|
|
76
83
|
throw new Error('Didnt throw')
|
|
77
84
|
} catch (e: any) {
|
|
78
|
-
expect(e
|
|
85
|
+
expect(e).toBeInstanceOf(XRPCError)
|
|
79
86
|
expect(e.success).toBeFalsy()
|
|
80
87
|
expect(e.error).toBe('AuthenticationRequired')
|
|
81
88
|
expect(e.message).toBe('Authentication Required')
|
|
@@ -98,7 +105,7 @@ describe('Auth', () => {
|
|
|
98
105
|
)
|
|
99
106
|
throw new Error('Didnt throw')
|
|
100
107
|
} catch (e: any) {
|
|
101
|
-
expect(e
|
|
108
|
+
expect(e).toBeInstanceOf(XRPCError)
|
|
102
109
|
expect(e.success).toBeFalsy()
|
|
103
110
|
expect(e.error).toBe('InvalidRequest')
|
|
104
111
|
expect(e.message).toBe('Input/present must be true')
|
|
@@ -125,31 +132,3 @@ describe('Auth', () => {
|
|
|
125
132
|
})
|
|
126
133
|
})
|
|
127
134
|
})
|
|
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
|
-
}
|
package/tests/bodies.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as http from 'http'
|
|
2
2
|
import { Readable } from 'stream'
|
|
3
3
|
import { gzipSync } from 'zlib'
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import getPort from 'get-port'
|
|
5
|
+
import xrpc, { ServiceClient } from '@atproto/xrpc'
|
|
6
|
+
import { bytesToStream, cidForCbor } from '@atproto/common'
|
|
6
7
|
import { randomBytes } from '@atproto/crypto'
|
|
7
8
|
import { createServer, closeServer } from './_util'
|
|
8
9
|
import * as xrpcServer from '../src'
|
|
@@ -22,7 +23,7 @@ const LEXICONS = [
|
|
|
22
23
|
required: ['foo'],
|
|
23
24
|
properties: {
|
|
24
25
|
foo: { type: 'string' },
|
|
25
|
-
bar: { type: '
|
|
26
|
+
bar: { type: 'integer' },
|
|
26
27
|
},
|
|
27
28
|
},
|
|
28
29
|
},
|
|
@@ -33,7 +34,7 @@ const LEXICONS = [
|
|
|
33
34
|
required: ['foo'],
|
|
34
35
|
properties: {
|
|
35
36
|
foo: { type: 'string' },
|
|
36
|
-
bar: { type: '
|
|
37
|
+
bar: { type: 'integer' },
|
|
37
38
|
},
|
|
38
39
|
},
|
|
39
40
|
},
|
|
@@ -53,7 +54,7 @@ const LEXICONS = [
|
|
|
53
54
|
required: ['foo'],
|
|
54
55
|
properties: {
|
|
55
56
|
foo: { type: 'string' },
|
|
56
|
-
bar: { type: '
|
|
57
|
+
bar: { type: 'integer' },
|
|
57
58
|
},
|
|
58
59
|
},
|
|
59
60
|
},
|
|
@@ -113,17 +114,22 @@ describe('Bodies', () => {
|
|
|
113
114
|
for await (const data of ctx.input.body) {
|
|
114
115
|
buffers.push(data)
|
|
115
116
|
}
|
|
116
|
-
const cid = await
|
|
117
|
+
const cid = await cidForCbor(Buffer.concat(buffers))
|
|
117
118
|
return {
|
|
118
119
|
encoding: 'json',
|
|
119
120
|
body: { cid: cid.toString() },
|
|
120
121
|
}
|
|
121
122
|
},
|
|
122
123
|
)
|
|
123
|
-
const client = xrpc.service(`http://localhost:8892`)
|
|
124
124
|
xrpc.addLexicons(LEXICONS)
|
|
125
|
+
|
|
126
|
+
let client: ServiceClient
|
|
127
|
+
let url: string
|
|
125
128
|
beforeAll(async () => {
|
|
126
|
-
|
|
129
|
+
const port = await getPort()
|
|
130
|
+
s = await createServer(port, server)
|
|
131
|
+
url = `http://localhost:${port}`
|
|
132
|
+
client = xrpc.service(url)
|
|
127
133
|
})
|
|
128
134
|
afterAll(async () => {
|
|
129
135
|
await closeServer(s)
|
|
@@ -151,6 +157,14 @@ describe('Bodies', () => {
|
|
|
151
157
|
await expect(
|
|
152
158
|
client.call('io.example.validationTest', {}, { foo: 123 }),
|
|
153
159
|
).rejects.toThrow(`Input/foo must be a string`)
|
|
160
|
+
await expect(
|
|
161
|
+
client.call(
|
|
162
|
+
'io.example.validationTest',
|
|
163
|
+
{},
|
|
164
|
+
{ foo: 'hello', bar: 123 },
|
|
165
|
+
{ encoding: 'image/jpeg' },
|
|
166
|
+
),
|
|
167
|
+
).rejects.toThrow(`Wrong request encoding (Content-Type): image/jpeg`)
|
|
154
168
|
|
|
155
169
|
// 500 responses don't include details, so we nab details from the logger.
|
|
156
170
|
let error: string | undefined
|
|
@@ -169,7 +183,7 @@ describe('Bodies', () => {
|
|
|
169
183
|
|
|
170
184
|
it('supports blobs and compression', async () => {
|
|
171
185
|
const bytes = randomBytes(1024)
|
|
172
|
-
const expectedCid = await
|
|
186
|
+
const expectedCid = await cidForCbor(bytes)
|
|
173
187
|
|
|
174
188
|
const { data: uncompressed } = await client.call(
|
|
175
189
|
'io.example.blobTest',
|
|
@@ -237,4 +251,31 @@ describe('Bodies', () => {
|
|
|
237
251
|
|
|
238
252
|
await expect(promise).rejects.toThrow('request entity too large')
|
|
239
253
|
})
|
|
254
|
+
|
|
255
|
+
it('requires any parsable Content-Type for blob uploads', async () => {
|
|
256
|
+
// not a real mimetype, but correct syntax
|
|
257
|
+
await client.call('io.example.blobTest', {}, randomBytes(BLOB_LIMIT), {
|
|
258
|
+
encoding: 'some/thing',
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// @TODO: figure out why this is failing dependent on the prev test being run
|
|
263
|
+
// https://github.com/bluesky-social/atproto/pull/550/files#r1106400413
|
|
264
|
+
it.skip('errors on an empty Content-type on blob upload', async () => {
|
|
265
|
+
// empty mimetype, but correct syntax
|
|
266
|
+
const res = await fetch(`${url}/xrpc/io.example.blobTest`, {
|
|
267
|
+
method: 'post',
|
|
268
|
+
headers: { 'Content-Type': '' },
|
|
269
|
+
body: randomBytes(BLOB_LIMIT),
|
|
270
|
+
// @ts-ignore see note in @atproto/xrpc/client.ts
|
|
271
|
+
duplex: 'half',
|
|
272
|
+
})
|
|
273
|
+
const resBody = await res.json()
|
|
274
|
+
const status = res.status
|
|
275
|
+
expect(status).toBe(400)
|
|
276
|
+
expect(resBody.error).toBe('InvalidRequest')
|
|
277
|
+
expect(resBody.message).toBe(
|
|
278
|
+
'Request encoding (Content-Type) required but not provided',
|
|
279
|
+
)
|
|
280
|
+
})
|
|
240
281
|
})
|