@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,64 @@
|
|
|
1
|
+
import type { XRPCBlobBodyParam, XRPCLexBodyParam } from '@atcute/lexicons/validations';
|
|
2
|
+
|
|
3
|
+
import type { Result } from '../../types/misc.js';
|
|
4
|
+
|
|
5
|
+
const jsonMimeValidator = (() => {
|
|
6
|
+
const JSON_RE = /^\s*application\/json\s*(?:$|;)/;
|
|
7
|
+
|
|
8
|
+
return (request: Request): Result<void, string> => {
|
|
9
|
+
const type = request.headers.get('content-type');
|
|
10
|
+
if (type === null) {
|
|
11
|
+
return { ok: false, error: `missing input content type (expected application/json)` };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!JSON_RE.test(type)) {
|
|
15
|
+
return { ok: false, error: `invalid input content type (expected application/json)` };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { ok: true, value: undefined };
|
|
19
|
+
};
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
export const constructMimeValidator = (param: XRPCLexBodyParam | XRPCBlobBodyParam) => {
|
|
23
|
+
if (param.type === 'lex') {
|
|
24
|
+
return jsonMimeValidator;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const mimes = param.encoding;
|
|
28
|
+
if (mimes === undefined || mimes.length === 0) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pattern = new RegExp(`^\\s*(?:${mimes.map(escapeRegexp).join('|')})\\s*(?:$|;)`);
|
|
33
|
+
|
|
34
|
+
return (request: Request): Result<void, string> => {
|
|
35
|
+
const type = request.headers.get('content-type');
|
|
36
|
+
if (type === null) {
|
|
37
|
+
return { ok: false, error: `missing input content type (expected ${separatedList(mimes, 'or')})` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!pattern.test(type)) {
|
|
41
|
+
return { ok: false, error: `invalid input content type (expected ${separatedList(mimes, 'or')})` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { ok: true, value: undefined };
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const separatedList = (list: string[], sep: 'or' | 'and'): string => {
|
|
49
|
+
switch (list.length) {
|
|
50
|
+
case 0: {
|
|
51
|
+
return `nothing`;
|
|
52
|
+
}
|
|
53
|
+
case 1: {
|
|
54
|
+
return list[0];
|
|
55
|
+
}
|
|
56
|
+
default: {
|
|
57
|
+
return `${list.slice(0, -1).join(', ')} ${sep} ${list[list.length - 1]}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const escapeRegexp = (input: string): string => {
|
|
63
|
+
return input.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
|
64
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
safeParse,
|
|
3
|
+
type ArraySchema,
|
|
4
|
+
type BaseSchema,
|
|
5
|
+
type ObjectSchema,
|
|
6
|
+
type OptionalSchema,
|
|
7
|
+
type ValidationResult,
|
|
8
|
+
} from '@atcute/lexicons/validations';
|
|
9
|
+
|
|
10
|
+
import type { Literal } from '../../types/misc.js';
|
|
11
|
+
|
|
12
|
+
type MaybeArray<T> = T | T[];
|
|
13
|
+
|
|
14
|
+
const isArraySchema = (schema: BaseSchema): schema is ArraySchema => {
|
|
15
|
+
return schema.type === 'array';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const isOptionalSchema = (schema: BaseSchema): schema is OptionalSchema => {
|
|
19
|
+
return schema.type === 'optional';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const unwrapOptional = (schema: BaseSchema): BaseSchema => {
|
|
23
|
+
return isOptionalSchema(schema) ? schema.wrapped : schema;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const unwrapArray = (schema: BaseSchema): BaseSchema => {
|
|
27
|
+
return isArraySchema(schema) ? schema.item : schema;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const coerceBoolean = (str: string): boolean | null => {
|
|
31
|
+
switch (str) {
|
|
32
|
+
case 'true':
|
|
33
|
+
return true;
|
|
34
|
+
case 'false':
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const coerceInteger = (str: string): number => {
|
|
42
|
+
return Number(str);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const constructParamsHandler = <TSchema extends ObjectSchema>(schema: TSchema) => {
|
|
46
|
+
const entries = Object.entries(schema.shape).map(([key, schema]) => {
|
|
47
|
+
const nonnullable = unwrapOptional(schema);
|
|
48
|
+
const singular = unwrapArray(nonnullable);
|
|
49
|
+
|
|
50
|
+
let coerce: ((x: string) => Literal | null) | undefined;
|
|
51
|
+
switch (singular.type) {
|
|
52
|
+
case 'boolean': {
|
|
53
|
+
coerce = coerceBoolean;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'integer': {
|
|
57
|
+
coerce = coerceInteger;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
key: key,
|
|
64
|
+
coerce: coerce,
|
|
65
|
+
multiple: isArraySchema(nonnullable),
|
|
66
|
+
optional: isOptionalSchema(schema) && schema.default === undefined,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const len = entries.length;
|
|
71
|
+
|
|
72
|
+
return (searchParams: URLSearchParams): ValidationResult<Record<string, MaybeArray<Literal>>> => {
|
|
73
|
+
const input: Record<string, MaybeArray<Literal | null>> = {};
|
|
74
|
+
|
|
75
|
+
for (let idx = 0; idx < len; idx++) {
|
|
76
|
+
const entry = entries[idx];
|
|
77
|
+
const key = entry.key;
|
|
78
|
+
const coerce = entry.coerce;
|
|
79
|
+
|
|
80
|
+
const raw = searchParams.getAll(key);
|
|
81
|
+
const count = raw.length;
|
|
82
|
+
|
|
83
|
+
let value: MaybeArray<Literal | null>;
|
|
84
|
+
|
|
85
|
+
if (entry.multiple || count > 1) {
|
|
86
|
+
value = coerce !== undefined ? raw.map(coerce) : raw;
|
|
87
|
+
} else {
|
|
88
|
+
if (count === 0) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
value = coerce !== undefined ? coerce(raw[0]) : raw[0];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/*#__INLINE__*/ set(input, key, value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return safeParse(schema, input);
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const set = <K extends PropertyKey, V>(obj: Record<K, V>, key: NoInfer<K>, value: NoInfer<V>): void => {
|
|
103
|
+
if (key === '__proto__') {
|
|
104
|
+
Object.defineProperty(obj, key, { value });
|
|
105
|
+
} else {
|
|
106
|
+
obj[key] = value;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Err } from '@atcute/lexicons/validations';
|
|
2
|
+
|
|
3
|
+
export const invalidRequest = (message: string) => {
|
|
4
|
+
return Response.json({ error: 'InvalidRequest', message }, { status: 400 });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const validationError = (kind: 'params' | 'input', err: Err): Response => {
|
|
8
|
+
const message = `invalid ${kind}: ${err.message}`;
|
|
9
|
+
|
|
10
|
+
return Response.json(
|
|
11
|
+
{ error: 'InvalidRequest', message: message, 'net.kelinci.atcute.issues': err.issues },
|
|
12
|
+
{ status: 400 },
|
|
13
|
+
);
|
|
14
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface XRPCErrorOptions {
|
|
2
|
+
status: number;
|
|
3
|
+
error: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class XRPCError extends Error {
|
|
8
|
+
/** response status */
|
|
9
|
+
readonly status: number;
|
|
10
|
+
|
|
11
|
+
/** error name */
|
|
12
|
+
readonly error: string;
|
|
13
|
+
/** error message */
|
|
14
|
+
readonly description?: string;
|
|
15
|
+
|
|
16
|
+
constructor({ status, error, description }: XRPCErrorOptions) {
|
|
17
|
+
super(`${error} > ${description ?? '(unspecified description)'}`);
|
|
18
|
+
|
|
19
|
+
this.status = status;
|
|
20
|
+
|
|
21
|
+
this.error = error;
|
|
22
|
+
this.description = description;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toResponse(): Response {
|
|
26
|
+
return Response.json({ error: this.error, message: this.description }, { status: this.status });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class InvalidRequestError extends XRPCError {
|
|
31
|
+
constructor({ status = 400, error = 'InvalidRequest', description }: Partial<XRPCErrorOptions> = {}) {
|
|
32
|
+
super({ status, error, description });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class AuthRequiredError extends XRPCError {
|
|
37
|
+
constructor({
|
|
38
|
+
status = 401,
|
|
39
|
+
error = 'AuthenticationRequired',
|
|
40
|
+
description,
|
|
41
|
+
}: Partial<XRPCErrorOptions> = {}) {
|
|
42
|
+
super({ status, error, description });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ForbiddenError extends XRPCError {
|
|
47
|
+
constructor({ status = 403, error = 'Forbidden', description }: Partial<XRPCErrorOptions> = {}) {
|
|
48
|
+
super({ status, error, description });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class RateLimitExceededError extends XRPCError {
|
|
53
|
+
constructor({ status = 429, error = 'RateLimitExceeded', description }: Partial<XRPCErrorOptions> = {}) {
|
|
54
|
+
super({ status, error, description });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class InternalServerError extends XRPCError {
|
|
59
|
+
constructor({ status = 500, error = 'InternalServerError', description }: Partial<XRPCErrorOptions> = {}) {
|
|
60
|
+
super({ status, error, description });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class UpstreamFailureError extends XRPCError {
|
|
65
|
+
constructor({ status = 502, error = 'UpstreamFailure', description }: Partial<XRPCErrorOptions> = {}) {
|
|
66
|
+
super({ status, error, description });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class NotEnoughResourcesError extends XRPCError {
|
|
71
|
+
constructor({ status = 503, error = 'NotEnoughResources', description }: Partial<XRPCErrorOptions> = {}) {
|
|
72
|
+
super({ status, error, description });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class UpstreamTimeoutError extends XRPCError {
|
|
77
|
+
constructor({ status = 504, error = 'UpstreamTimeout', description }: Partial<XRPCErrorOptions> = {}) {
|
|
78
|
+
super({ status, error, description });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { FetchMiddleware } from '../main/router.js';
|
|
2
|
+
|
|
3
|
+
export interface CORSOptions {
|
|
4
|
+
/** Additional headers to expose to the client */
|
|
5
|
+
exposedHeaders?: string[];
|
|
6
|
+
/** Additional headers to allow */
|
|
7
|
+
allowedHeaders?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_EXPOSED_HEADERS = [
|
|
11
|
+
'dpop-nonce',
|
|
12
|
+
'www-authenticate',
|
|
13
|
+
|
|
14
|
+
'ratelimit-limit',
|
|
15
|
+
'ratelimit-policy',
|
|
16
|
+
'ratelimit-remaining',
|
|
17
|
+
'ratelimit-reset',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const DEFAULT_ALLOWED_HEADERS = [
|
|
21
|
+
'content-type',
|
|
22
|
+
|
|
23
|
+
'authorization',
|
|
24
|
+
'dpop',
|
|
25
|
+
|
|
26
|
+
'atproto-accept-labelers',
|
|
27
|
+
'atproto-proxy',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const cors = (options: CORSOptions = {}): FetchMiddleware => {
|
|
31
|
+
const exposedHeaders = Array.from(
|
|
32
|
+
new Set([...DEFAULT_EXPOSED_HEADERS, ...(options.exposedHeaders?.map((h) => h.toLowerCase()) || [])]),
|
|
33
|
+
).sort();
|
|
34
|
+
|
|
35
|
+
const allowedHeaders = Array.from(
|
|
36
|
+
new Set([...DEFAULT_ALLOWED_HEADERS, ...(options.allowedHeaders?.map((h) => h.toLowerCase()) || [])]),
|
|
37
|
+
)
|
|
38
|
+
.sort()
|
|
39
|
+
.join(',');
|
|
40
|
+
|
|
41
|
+
return async (request, next) => {
|
|
42
|
+
const origin = request.headers.get('origin') || '*';
|
|
43
|
+
|
|
44
|
+
// Handle preflight requests
|
|
45
|
+
if (request.method === 'OPTIONS') {
|
|
46
|
+
const headers = new Headers();
|
|
47
|
+
headers.set('access-control-max-age', '86400');
|
|
48
|
+
headers.set('access-control-allow-origin', origin);
|
|
49
|
+
|
|
50
|
+
if (allowedHeaders) {
|
|
51
|
+
headers.set('access-control-allow-headers', allowedHeaders);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return new Response(null, { status: 204, headers: headers });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const response = await next(request);
|
|
58
|
+
|
|
59
|
+
const expose = exposedHeaders.filter((h) => response.headers.has(h)).join(',');
|
|
60
|
+
|
|
61
|
+
response.headers.set('access-control-allow-origin', origin);
|
|
62
|
+
if (expose.length > 0) {
|
|
63
|
+
response.headers.append('access-control-expose-headers', expose);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return response;
|
|
67
|
+
};
|
|
68
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@atcute/xrpc-server",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "xrpc server",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/mary-ext/atcute",
|
|
9
|
+
"directory": "packages/servers/xrpc-server"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/",
|
|
13
|
+
"lib/",
|
|
14
|
+
"!lib/**/*.bench.ts",
|
|
15
|
+
"!lib/**/*.test.ts"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/main/index.js",
|
|
19
|
+
"./auth": "./dist/auth/index.js",
|
|
20
|
+
"./middlewares/cors": "./dist/middlewares/cors.js"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@badrap/valita": "^0.4.4",
|
|
24
|
+
"nanoid": "^5.1.5",
|
|
25
|
+
"@atcute/crypto": "^2.2.3",
|
|
26
|
+
"@atcute/identity": "^1.0.2",
|
|
27
|
+
"@atcute/identity-resolver": "^1.1.1",
|
|
28
|
+
"@atcute/lexicons": "^1.0.4",
|
|
29
|
+
"@atcute/multibase": "^1.1.4",
|
|
30
|
+
"@atcute/uint8array": "^1.0.3"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@atcute/xrpc-server": "file:",
|
|
34
|
+
"@vitest/coverage-v8": "^3.0.4",
|
|
35
|
+
"vitest": "^3.0.4",
|
|
36
|
+
"@atcute/atproto": "^3.0.3",
|
|
37
|
+
"@atcute/bluesky": "^3.0.4"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc --project tsconfig.build.json",
|
|
41
|
+
"test": "vitest --coverage",
|
|
42
|
+
"prepublish": "rm -rf dist; pnpm run build"
|
|
43
|
+
}
|
|
44
|
+
}
|