@atcute/xrpc-server 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 +17 -0
- package/README.md +177 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/jwt-creator.d.ts +11 -0
- package/dist/auth/jwt-creator.js +30 -0
- package/dist/auth/jwt-creator.js.map +1 -0
- package/dist/auth/jwt-verifier.d.ts +23 -0
- package/dist/auth/jwt-verifier.js +173 -0
- package/dist/auth/jwt-verifier.js.map +1 -0
- package/dist/auth/jwt.d.ts +27 -0
- package/dist/auth/jwt.js +96 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/types.d.ts +4 -0
- package/dist/auth/types.js +2 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/main/index.d.ts +3 -0
- package/dist/main/index.js +4 -0
- package/dist/main/index.js.map +1 -0
- package/dist/main/response.d.ts +8 -0
- package/dist/main/response.js +4 -0
- package/dist/main/response.js.map +1 -0
- package/dist/main/router.d.ts +21 -0
- package/dist/main/router.js +158 -0
- package/dist/main/router.js.map +1 -0
- package/dist/main/types/operation.d.ts +36 -0
- package/dist/main/types/operation.js +2 -0
- package/dist/main/types/operation.js.map +1 -0
- package/dist/main/utils/middlewares.d.ts +2 -0
- package/dist/main/utils/middlewares.js +5 -0
- package/dist/main/utils/middlewares.js.map +1 -0
- package/dist/main/utils/request-input.d.ts +3 -0
- package/dist/main/utils/request-input.js +50 -0
- package/dist/main/utils/request-input.js.map +1 -0
- package/dist/main/utils/request-params.d.ts +5 -0
- package/dist/main/utils/request-params.js +80 -0
- package/dist/main/utils/request-params.js.map +1 -0
- package/dist/main/utils/response.d.ts +3 -0
- package/dist/main/utils/response.js +8 -0
- package/dist/main/utils/response.js.map +1 -0
- package/dist/main/xrpc-error.d.ts +39 -0
- package/dist/main/xrpc-error.js +58 -0
- package/dist/main/xrpc-error.js.map +1 -0
- package/dist/middlewares/cors.d.ts +8 -0
- package/dist/middlewares/cors.js +42 -0
- package/dist/middlewares/cors.js.map +1 -0
- package/dist/types/misc.d.ts +9 -0
- package/dist/types/misc.js +2 -0
- package/dist/types/misc.js.map +1 -0
- package/lib/auth/index.ts +2 -0
- package/lib/auth/jwt-creator.ts +51 -0
- package/lib/auth/jwt-verifier.ts +215 -0
- package/lib/auth/jwt.ts +124 -0
- package/lib/auth/types.ts +4 -0
- package/lib/main/index.ts +3 -0
- package/lib/main/response.ts +9 -0
- package/lib/main/router.ts +237 -0
- package/lib/main/types/operation.ts +87 -0
- package/lib/main/utils/middlewares.ts +13 -0
- package/lib/main/utils/request-input.ts +64 -0
- package/lib/main/utils/request-params.ts +108 -0
- package/lib/main/utils/response.ts +14 -0
- package/lib/main/xrpc-error.ts +80 -0
- package/lib/middlewares/cors.ts +68 -0
- package/lib/types/misc.ts +4 -0
- package/package.json +44 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { getPublicKeyFromDidController, verifySig, type FoundPublicKey } from '@atcute/crypto';
|
|
2
|
+
import { getAtprotoVerificationMaterial, type DidDocument } from '@atcute/identity';
|
|
3
|
+
import { type DidDocumentResolver } from '@atcute/identity-resolver';
|
|
4
|
+
import type { Did, Nsid } from '@atcute/lexicons';
|
|
5
|
+
import * as uint8arrays from '@atcute/uint8array';
|
|
6
|
+
|
|
7
|
+
import type { Result } from '../types/misc.js';
|
|
8
|
+
|
|
9
|
+
import { parseJwt, type ParsedJwt } from './jwt.js';
|
|
10
|
+
import type { AuthError } from './types.js';
|
|
11
|
+
|
|
12
|
+
export interface ServiceJwtVerifierOptions {
|
|
13
|
+
serviceDid: Did | null;
|
|
14
|
+
resolver: DidDocumentResolver;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface VerifyJwtOptions {
|
|
18
|
+
lxm: Nsid | Nsid[] | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VerifiedJwt {
|
|
22
|
+
issuer: Did;
|
|
23
|
+
audience: Did;
|
|
24
|
+
lxm: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ServiceJwtVerifier {
|
|
28
|
+
didDocResolver: DidDocumentResolver;
|
|
29
|
+
serviceDid: Did | null;
|
|
30
|
+
|
|
31
|
+
constructor(options: ServiceJwtVerifierOptions) {
|
|
32
|
+
this.didDocResolver = options.resolver;
|
|
33
|
+
this.serviceDid = options.serviceDid;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async #getSigningKey(issuer: Did, noCache: boolean): Promise<Result<FoundPublicKey, AuthError>> {
|
|
37
|
+
let didDocument: DidDocument;
|
|
38
|
+
let key: FoundPublicKey;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
didDocument = await this.didDocResolver.resolve(issuer, { noCache });
|
|
42
|
+
} catch {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: {
|
|
46
|
+
error: 'UnresolvedDidDocument',
|
|
47
|
+
description: `failed to retrieve did document for ${issuer}`,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const controller = getAtprotoVerificationMaterial(didDocument);
|
|
53
|
+
if (!controller) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: {
|
|
57
|
+
error: 'BadJwtIssuer',
|
|
58
|
+
description: `${issuer} does not have an atproto verification material`,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
key = getPublicKeyFromDidController(controller);
|
|
65
|
+
} catch {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: {
|
|
69
|
+
error: 'BadJwtIssuer',
|
|
70
|
+
description: `${issuer} has invalid atproto verification material`,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ok: true, value: key };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async #verifySignature(key: FoundPublicKey, jwt: ParsedJwt): Promise<Result<boolean, AuthError>> {
|
|
79
|
+
try {
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
value: await verifySig(key, jwt.signature, jwt.message, { allowMalleableSig: true }),
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: {
|
|
88
|
+
error: 'BadJwtSignature',
|
|
89
|
+
description: `could not verify jwt signature`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async verify(jwtString: string, options?: VerifyJwtOptions): Promise<Result<VerifiedJwt, AuthError>> {
|
|
96
|
+
const parsed = parseJwt(jwtString);
|
|
97
|
+
if (!parsed.ok) {
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { header, payload } = parsed.value;
|
|
102
|
+
|
|
103
|
+
switch (header.typ) {
|
|
104
|
+
case 'at+jwt':
|
|
105
|
+
case 'refresh+jwt':
|
|
106
|
+
case 'dpop+jwt': {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: {
|
|
110
|
+
error: 'BadJwtType',
|
|
111
|
+
description: `invalid jwt type`,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (Date.now() / 1_000 > payload.exp) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: {
|
|
121
|
+
error: 'JwtExpired',
|
|
122
|
+
description: `jwt is expired`,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (this.serviceDid !== undefined && this.serviceDid !== payload.aud) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: {
|
|
131
|
+
error: 'BadJwtAudience',
|
|
132
|
+
description: `jwt audience does not match (expected ${this.serviceDid})`,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
options?.lxm != null &&
|
|
139
|
+
(typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm!))
|
|
140
|
+
) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: {
|
|
144
|
+
error: `BadJwtLexiconMethod`,
|
|
145
|
+
description: `jwt lexicon method does not match (expected ${options.lxm})`,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const key = await this.#getSigningKey(payload.iss, false);
|
|
151
|
+
if (!key.ok) {
|
|
152
|
+
return key;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let isValid = false;
|
|
156
|
+
|
|
157
|
+
if (key.value.jwtAlg === header.alg) {
|
|
158
|
+
const result = await this.#verifySignature(key.value, parsed.value);
|
|
159
|
+
if (!result.ok) {
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
isValid = result.value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!isValid) {
|
|
167
|
+
// try again, uncached
|
|
168
|
+
const freshKey = await this.#getSigningKey(payload.iss, true);
|
|
169
|
+
if (!freshKey.ok) {
|
|
170
|
+
return freshKey;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// at this point we can't ignore the jwt alg difference
|
|
174
|
+
if (freshKey.value.jwtAlg !== header.alg) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: {
|
|
178
|
+
error: 'BadJwtIssuer',
|
|
179
|
+
description: `mismatching cryptographic key format (jwt is ${header.alg})`,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// only revalidate if it's a different key
|
|
185
|
+
if (!uint8arrays.equals(freshKey.value.publicKeyBytes, key.value.publicKeyBytes)) {
|
|
186
|
+
const result = await this.#verifySignature(key.value, parsed.value);
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
isValid = result.value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!isValid) {
|
|
196
|
+
// too bad
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: {
|
|
200
|
+
error: 'BadJwtSignature',
|
|
201
|
+
description: `invalid jwt signature`,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
value: {
|
|
209
|
+
issuer: payload.iss,
|
|
210
|
+
audience: payload.aud,
|
|
211
|
+
lxm: payload.lxm,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
package/lib/auth/jwt.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as v from '@badrap/valita';
|
|
2
|
+
|
|
3
|
+
import { isDid, isNsid } from '@atcute/lexicons/syntax';
|
|
4
|
+
import { fromBase64Url } from '@atcute/multibase';
|
|
5
|
+
import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array';
|
|
6
|
+
|
|
7
|
+
import type { Result } from '../types/misc.js';
|
|
8
|
+
|
|
9
|
+
import type { AuthError } from './types.js';
|
|
10
|
+
|
|
11
|
+
const didString = v.string().assert(isDid, `must be a did`);
|
|
12
|
+
const nsidString = v.string().assert(isNsid, `must be an nsid`);
|
|
13
|
+
|
|
14
|
+
const integer = v.number().assert((input) => input >= 0 && Number.isSafeInteger(input), `must be an integer`);
|
|
15
|
+
|
|
16
|
+
const jwtHeader = v.object({
|
|
17
|
+
typ: v.string().optional(),
|
|
18
|
+
alg: v.string(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export interface JwtHeader extends v.Infer<typeof jwtHeader> {}
|
|
22
|
+
|
|
23
|
+
const jwtPayload = v
|
|
24
|
+
.object({
|
|
25
|
+
/** issuer */
|
|
26
|
+
iss: didString,
|
|
27
|
+
/** target audience */
|
|
28
|
+
aud: didString,
|
|
29
|
+
/** expiration time */
|
|
30
|
+
exp: integer,
|
|
31
|
+
/** creation time */
|
|
32
|
+
iat: integer.optional(),
|
|
33
|
+
/** xrpc operation being invoked */
|
|
34
|
+
lxm: nsidString.optional(),
|
|
35
|
+
/** unique identifier */
|
|
36
|
+
jti: v.string().optional(),
|
|
37
|
+
})
|
|
38
|
+
.assert(({ iat, exp }) => iat === undefined || exp > iat, {
|
|
39
|
+
message: `expiry time must be greater than issued time`,
|
|
40
|
+
path: ['exp'],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export interface JwtPayload extends v.Infer<typeof jwtPayload> {}
|
|
44
|
+
|
|
45
|
+
export interface ParsedJwt {
|
|
46
|
+
header: JwtHeader;
|
|
47
|
+
payload: JwtPayload;
|
|
48
|
+
message: Uint8Array;
|
|
49
|
+
signature: Uint8Array;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const readJwtPortion = <T>(schema: v.Type<T>, input: string): Result<T, AuthError> => {
|
|
53
|
+
try {
|
|
54
|
+
const raw = decodeUtf8From(fromBase64Url(input));
|
|
55
|
+
const json = JSON.parse(raw);
|
|
56
|
+
|
|
57
|
+
const result = schema.try(json);
|
|
58
|
+
if (result.ok) {
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: {
|
|
66
|
+
error: `MalformedJwt`,
|
|
67
|
+
description: `jwt is malformed`,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const readJwtSignature = (input: string): Result<Uint8Array, AuthError> => {
|
|
73
|
+
try {
|
|
74
|
+
return { ok: true, value: fromBase64Url(input) };
|
|
75
|
+
} catch {}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: {
|
|
80
|
+
error: `MalformedJwt`,
|
|
81
|
+
description: `jwt is malformed`,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const parseJwt = (jwtString: string): Result<ParsedJwt, AuthError> => {
|
|
87
|
+
const parts = jwtString.split('.');
|
|
88
|
+
if (parts.length !== 3) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: {
|
|
92
|
+
error: `MalformedJwt`,
|
|
93
|
+
description: `jwt is malformed`,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [headerString, payloadString, signatureString] = parts;
|
|
99
|
+
|
|
100
|
+
const header = readJwtPortion(jwtHeader, headerString);
|
|
101
|
+
if (!header.ok) {
|
|
102
|
+
return header;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const payload = readJwtPortion(jwtPayload, payloadString);
|
|
106
|
+
if (!payload.ok) {
|
|
107
|
+
return payload;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const signature = readJwtSignature(signatureString);
|
|
111
|
+
if (!signature.ok) {
|
|
112
|
+
return signature;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
value: {
|
|
118
|
+
header: header.value,
|
|
119
|
+
payload: payload.value,
|
|
120
|
+
message: encodeUtf8(`${headerString}.${payloadString}`),
|
|
121
|
+
signature: signature.value,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
declare const kJson: unique symbol;
|
|
2
|
+
|
|
3
|
+
export type JSONResponse<TData> = Response & { [kJson]: TData };
|
|
4
|
+
|
|
5
|
+
export const json: {
|
|
6
|
+
<TData>(data: NoInfer<TData>, init?: ResponseInit): JSONResponse<TData>;
|
|
7
|
+
} = (data: any, init?: ResponseInit): any => {
|
|
8
|
+
return Response.json(data, init);
|
|
9
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { safeParse, type XRPCProcedureMetadata, type XRPCQueryMetadata } from '@atcute/lexicons/validations';
|
|
2
|
+
|
|
3
|
+
import type { Literal, Promisable } from '../types/misc.js';
|
|
4
|
+
|
|
5
|
+
import type { ProcedureConfig, QueryConfig, UnknownOperationContext } from './types/operation.js';
|
|
6
|
+
import { createAsyncMiddlewareRunner, type Middleware } from './utils/middlewares.js';
|
|
7
|
+
import { constructMimeValidator } from './utils/request-input.js';
|
|
8
|
+
import { constructParamsHandler } from './utils/request-params.js';
|
|
9
|
+
import { invalidRequest, validationError } from './utils/response.js';
|
|
10
|
+
|
|
11
|
+
import { XRPCError } from './xrpc-error.js';
|
|
12
|
+
|
|
13
|
+
type InternalRequestContext = {
|
|
14
|
+
url: URL;
|
|
15
|
+
request: Request;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type InternalRequestHandler = (context: InternalRequestContext) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
type InternalRouteData = {
|
|
21
|
+
method: 'GET' | 'POST';
|
|
22
|
+
handler: InternalRequestHandler;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FetchMiddleware = Middleware<[request: Request], Promise<Response>>;
|
|
26
|
+
|
|
27
|
+
export type NotFoundHandler = (request: Request) => Promisable<Response>;
|
|
28
|
+
export type ExceptionHandler = (error: unknown, request: Request) => Promisable<Response>;
|
|
29
|
+
|
|
30
|
+
export const defaultExceptionHandler: ExceptionHandler = (error: unknown) => {
|
|
31
|
+
if (error instanceof XRPCError) {
|
|
32
|
+
return error.toResponse();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (error instanceof Response) {
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Response.json(
|
|
40
|
+
{ error: 'InternalServerError', message: `an exception happened whilst processing this request` },
|
|
41
|
+
{ status: 500 },
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const defaultNotFoundHandler: NotFoundHandler = () => {
|
|
46
|
+
return new Response('Not Found', { status: 404 });
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface XRPCRouterOptions {
|
|
50
|
+
middlewares?: FetchMiddleware[];
|
|
51
|
+
handleNotFound?: NotFoundHandler;
|
|
52
|
+
handleException?: ExceptionHandler;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class XRPCRouter {
|
|
56
|
+
#handlers: Record<string, InternalRouteData> = {};
|
|
57
|
+
#handleNotFound: NotFoundHandler;
|
|
58
|
+
#handleException: ExceptionHandler;
|
|
59
|
+
|
|
60
|
+
fetch: (request: Request) => Promise<Response>;
|
|
61
|
+
|
|
62
|
+
constructor({
|
|
63
|
+
middlewares = [],
|
|
64
|
+
handleException = defaultExceptionHandler,
|
|
65
|
+
handleNotFound = defaultNotFoundHandler,
|
|
66
|
+
}: XRPCRouterOptions = {}) {
|
|
67
|
+
this.fetch = createAsyncMiddlewareRunner([...middlewares, (request) => this.#dispatch(request)]);
|
|
68
|
+
this.#handleException = handleException;
|
|
69
|
+
this.#handleNotFound = handleNotFound;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async #dispatch(request: Request): Promise<Response> {
|
|
73
|
+
const url = new URL(request.url);
|
|
74
|
+
const pathname = url.pathname;
|
|
75
|
+
|
|
76
|
+
if (!pathname.startsWith('/xrpc/')) {
|
|
77
|
+
return this.#handleNotFound(request);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const nsid = pathname.slice('/xrpc/'.length);
|
|
81
|
+
const route = this.#handlers[nsid];
|
|
82
|
+
|
|
83
|
+
if (route === undefined) {
|
|
84
|
+
return this.#handleNotFound(request);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (request.method !== route.method) {
|
|
88
|
+
return Response.json(
|
|
89
|
+
{ error: 'InvalidHttpMethod', message: `invalid http method (expected ${route.method})` },
|
|
90
|
+
{ status: 405, headers: { allow: `${route.method}` } },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await route.handler({
|
|
96
|
+
request: request,
|
|
97
|
+
url: url,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return response;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return this.#handleException(err, request);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
add<TQuery extends XRPCQueryMetadata>(query: TQuery, config: QueryConfig<TQuery>): void;
|
|
107
|
+
add<TProcedure extends XRPCProcedureMetadata>(
|
|
108
|
+
procedure: TProcedure,
|
|
109
|
+
config: ProcedureConfig<TProcedure>,
|
|
110
|
+
): void;
|
|
111
|
+
add(operation: XRPCQueryMetadata | XRPCProcedureMetadata, config: any): void {
|
|
112
|
+
switch (operation.type) {
|
|
113
|
+
case 'xrpc_query': {
|
|
114
|
+
return this.#addQuery(operation, config);
|
|
115
|
+
}
|
|
116
|
+
case 'xrpc_procedure': {
|
|
117
|
+
return this.#addProcedure(operation, config);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#addQuery<TQuery extends XRPCQueryMetadata>(query: TQuery, config: QueryConfig<TQuery>): void {
|
|
123
|
+
const handleParams = query.params ? constructParamsHandler(query.params) : null;
|
|
124
|
+
|
|
125
|
+
const handler = config.handler;
|
|
126
|
+
|
|
127
|
+
this.#handlers[query.nsid] = {
|
|
128
|
+
method: 'GET',
|
|
129
|
+
handler: async ({ request, url }) => {
|
|
130
|
+
let params: Record<string, Literal | Literal[]>;
|
|
131
|
+
|
|
132
|
+
if (handleParams !== null) {
|
|
133
|
+
const result = handleParams(url.searchParams);
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
return validationError('params', result);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
params = result.value;
|
|
139
|
+
} else {
|
|
140
|
+
params = {};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const context: UnknownOperationContext = {
|
|
144
|
+
request: request,
|
|
145
|
+
params: params,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const output = await handler(context as any);
|
|
149
|
+
|
|
150
|
+
if (output instanceof Response) {
|
|
151
|
+
return output;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return new Response(null);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#addProcedure<TProcedure extends XRPCProcedureMetadata>(
|
|
160
|
+
procedure: TProcedure,
|
|
161
|
+
config: ProcedureConfig<TProcedure>,
|
|
162
|
+
): void {
|
|
163
|
+
const handleParams = procedure.params ? constructParamsHandler(procedure.params) : null;
|
|
164
|
+
const validateInputType = procedure.input ? constructMimeValidator(procedure.input) : null;
|
|
165
|
+
|
|
166
|
+
const requiresInput = procedure.input !== null;
|
|
167
|
+
const inputSchema = procedure.input?.type === 'lex' ? procedure.input.schema : null;
|
|
168
|
+
|
|
169
|
+
const handler = config.handler;
|
|
170
|
+
|
|
171
|
+
this.#handlers[procedure.nsid] = {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
handler: async ({ request, url }) => {
|
|
174
|
+
let params: Record<string, Literal | Literal[]>;
|
|
175
|
+
let input: Record<string, unknown> | undefined;
|
|
176
|
+
|
|
177
|
+
if (handleParams !== null) {
|
|
178
|
+
const result = handleParams(url.searchParams);
|
|
179
|
+
if (!result.ok) {
|
|
180
|
+
return validationError('params', result);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
params = result.value;
|
|
184
|
+
} else {
|
|
185
|
+
params = {};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (requiresInput) {
|
|
189
|
+
if (request.body === null) {
|
|
190
|
+
return invalidRequest(`request body is expected but none was provided`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (validateInputType !== null) {
|
|
194
|
+
const result = validateInputType(request);
|
|
195
|
+
if (!result.ok) {
|
|
196
|
+
return invalidRequest(result.error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (inputSchema !== null) {
|
|
201
|
+
let raw: any;
|
|
202
|
+
try {
|
|
203
|
+
raw = await request.json();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return invalidRequest(`invalid request body (failed to parse json)`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = safeParse(inputSchema, raw);
|
|
209
|
+
if (!result.ok) {
|
|
210
|
+
return validationError('input', result);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
input = result.value;
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
if (request.body !== null) {
|
|
217
|
+
return invalidRequest(`request body is provided when none was expected`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const context: UnknownOperationContext = {
|
|
222
|
+
request: request,
|
|
223
|
+
params: params,
|
|
224
|
+
input: input,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const output = await handler(context as any);
|
|
228
|
+
|
|
229
|
+
if (output instanceof Response) {
|
|
230
|
+
return output;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return new Response(null);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InferOutput,
|
|
3
|
+
ObjectSchema,
|
|
4
|
+
XRPCBlobBodyParam,
|
|
5
|
+
XRPCLexBodyParam,
|
|
6
|
+
XRPCProcedureMetadata,
|
|
7
|
+
XRPCQueryMetadata,
|
|
8
|
+
} from '@atcute/lexicons/validations';
|
|
9
|
+
|
|
10
|
+
import type { Literal, Promisable } from '../../types/misc.js';
|
|
11
|
+
|
|
12
|
+
import type { JSONResponse } from '../response.js';
|
|
13
|
+
|
|
14
|
+
export type UnknownOperationContext = {
|
|
15
|
+
request: Request;
|
|
16
|
+
params: Record<string, Literal | Literal[]>;
|
|
17
|
+
input?: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// #region Query
|
|
21
|
+
|
|
22
|
+
export type QueryContext<TQuery extends XRPCQueryMetadata> = {
|
|
23
|
+
request: Request;
|
|
24
|
+
} & (TQuery['params'] extends ObjectSchema
|
|
25
|
+
? {
|
|
26
|
+
params: InferOutput<TQuery['params']>;
|
|
27
|
+
}
|
|
28
|
+
: {
|
|
29
|
+
// params
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type QueryHandler<TQuery extends XRPCQueryMetadata> = (
|
|
33
|
+
context: QueryContext<TQuery>,
|
|
34
|
+
) => Promisable<
|
|
35
|
+
TQuery['output'] extends null
|
|
36
|
+
? Response | void
|
|
37
|
+
: TQuery['output'] extends XRPCLexBodyParam
|
|
38
|
+
? Response | JSONResponse<InferOutput<TQuery['output']['schema']>>
|
|
39
|
+
: Response
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
export type QueryConfig<TQuery extends XRPCQueryMetadata = XRPCQueryMetadata> = {
|
|
43
|
+
handler: QueryHandler<TQuery>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// #region Procedure
|
|
47
|
+
|
|
48
|
+
export type ProcedureContext<TProcedure extends XRPCProcedureMetadata> =
|
|
49
|
+
(TProcedure['input'] extends XRPCBlobBodyParam
|
|
50
|
+
? {
|
|
51
|
+
request: Request & { readonly body: ReadableStream<Uint8Array> };
|
|
52
|
+
}
|
|
53
|
+
: TProcedure['input'] extends XRPCLexBodyParam
|
|
54
|
+
? {
|
|
55
|
+
request: Request & { readonly body: null };
|
|
56
|
+
}
|
|
57
|
+
: {
|
|
58
|
+
request: Request;
|
|
59
|
+
}) &
|
|
60
|
+
(TProcedure['params'] extends ObjectSchema
|
|
61
|
+
? {
|
|
62
|
+
params: InferOutput<TProcedure['params']>;
|
|
63
|
+
}
|
|
64
|
+
: {
|
|
65
|
+
// params
|
|
66
|
+
}) &
|
|
67
|
+
(TProcedure['input'] extends XRPCLexBodyParam
|
|
68
|
+
? {
|
|
69
|
+
input: InferOutput<TProcedure['input']['schema']>;
|
|
70
|
+
}
|
|
71
|
+
: {
|
|
72
|
+
// input
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export type ProcedureHandler<TProcedure extends XRPCProcedureMetadata> = (
|
|
76
|
+
context: ProcedureContext<TProcedure>,
|
|
77
|
+
) => Promisable<
|
|
78
|
+
TProcedure['output'] extends null
|
|
79
|
+
? Response | void
|
|
80
|
+
: TProcedure['output'] extends XRPCLexBodyParam
|
|
81
|
+
? Response | JSONResponse<InferOutput<TProcedure['output']['schema']>>
|
|
82
|
+
: Response
|
|
83
|
+
>;
|
|
84
|
+
|
|
85
|
+
export type ProcedureConfig<TProcedure extends XRPCProcedureMetadata = XRPCProcedureMetadata> = {
|
|
86
|
+
handler: ProcedureHandler<TProcedure>;
|
|
87
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Middleware<TParams extends any[], TReturn> = (
|
|
2
|
+
...params: [...TParams, next: (...params: TParams) => TReturn]
|
|
3
|
+
) => TReturn;
|
|
4
|
+
|
|
5
|
+
export const createAsyncMiddlewareRunner = <TParams extends any[], TReturn>(
|
|
6
|
+
middlewares: [...Middleware<TParams, Promise<TReturn>>[], Middleware<TParams, Promise<TReturn>>],
|
|
7
|
+
) => {
|
|
8
|
+
// prettier-ignore
|
|
9
|
+
return middlewares.reduceRight<(...params: TParams) => Promise<TReturn>>(
|
|
10
|
+
(next, run) => (...args) => run(...args, next),
|
|
11
|
+
() => Promise.reject(new Error(`middleware chain exhausted`)),
|
|
12
|
+
);
|
|
13
|
+
};
|