@signe/room 1.4.2 → 2.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/jwt.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * JWT Authentication Class for Cloudflare Workers
3
+ * Uses Web Crypto API for JWT signing and verification
4
+ */
5
+
6
+ interface JWTOptions {
7
+ expiresIn?: string | number;
8
+ }
9
+
10
+ interface JWTHeader {
11
+ alg: string;
12
+ typ: string;
13
+ }
14
+
15
+ interface JWTPayload {
16
+ [key: string]: unknown;
17
+ iat?: number;
18
+ exp?: number;
19
+ }
20
+
21
+ export class JWTAuth {
22
+ private secret: string;
23
+ private encoder: TextEncoder;
24
+ private decoder: TextDecoder;
25
+
26
+ /**
27
+ * Constructor for the JWTAuth class
28
+ * @param {string} secret - The secret key used for signing and verifying tokens
29
+ */
30
+ constructor(secret: string) {
31
+ if (!secret || typeof secret !== 'string') {
32
+ throw new Error('Secret is required and must be a string');
33
+ }
34
+ this.secret = secret;
35
+ this.encoder = new TextEncoder();
36
+ this.decoder = new TextDecoder();
37
+ }
38
+
39
+ /**
40
+ * Convert the secret to a CryptoKey for HMAC operations
41
+ * @returns {Promise<CryptoKey>} - The CryptoKey for HMAC operations
42
+ */
43
+ private async getSecretKey(): Promise<CryptoKey> {
44
+ const keyData: Uint8Array = this.encoder.encode(this.secret);
45
+ return await crypto.subtle.importKey(
46
+ 'raw', // format
47
+ keyData, // key data
48
+ {
49
+ name: 'HMAC',
50
+ hash: { name: 'SHA-256' },
51
+ },
52
+ false, // extractable
53
+ ['sign', 'verify'] // key usages
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Base64Url encode a buffer
59
+ * @param {ArrayBuffer} buffer - The buffer to encode
60
+ * @returns {string} - The base64url encoded string
61
+ */
62
+ private base64UrlEncode(buffer: ArrayBuffer): string {
63
+ const base64: string = btoa(String.fromCharCode(...new Uint8Array(buffer)));
64
+ return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
65
+ }
66
+
67
+ /**
68
+ * Base64Url decode a string
69
+ * @param {string} base64Url - The base64url encoded string
70
+ * @returns {ArrayBuffer} - The decoded buffer
71
+ */
72
+ private base64UrlDecode(base64Url: string) {
73
+ const padding: string = '='.repeat((4 - (base64Url.length % 4)) % 4);
74
+ const base64: string = base64Url.replace(/-/g, '+').replace(/_/g, '/') + padding;
75
+ const rawData: string = atob(base64);
76
+ const buffer: Uint8Array = new Uint8Array(rawData.length);
77
+
78
+ for (let i = 0; i < rawData.length; i++) {
79
+ buffer[i] = rawData.charCodeAt(i);
80
+ }
81
+
82
+ return buffer.buffer;
83
+ }
84
+
85
+ /**
86
+ * Sign a payload and create a JWT token
87
+ * @param {JWTPayload} payload - The payload to include in the token
88
+ * @param {JWTOptions} [options={}] - Options for the token
89
+ * @param {string | number} [options.expiresIn='1h'] - Token expiration time
90
+ * @returns {Promise<string>} - The JWT token
91
+ */
92
+ public async sign(payload: JWTPayload, options: JWTOptions = {}): Promise<string> {
93
+ if (!payload || typeof payload !== 'object') {
94
+ throw new Error('Payload must be an object');
95
+ }
96
+
97
+ // Set default expiration if not provided
98
+ const expiresIn: string | number = options.expiresIn || '1h';
99
+
100
+ // Calculate expiration time
101
+ let exp: number | undefined;
102
+ if (typeof expiresIn === 'number') {
103
+ exp = Math.floor(Date.now() / 1000) + expiresIn;
104
+ } else if (typeof expiresIn === 'string') {
105
+ const match: RegExpMatchArray | null = expiresIn.match(/^(\d+)([smhd])$/);
106
+ if (match) {
107
+ const value: number = parseInt(match[1]);
108
+ const unit: string = match[2];
109
+ const seconds: number = {
110
+ 's': value,
111
+ 'm': value * 60,
112
+ 'h': value * 60 * 60,
113
+ 'd': value * 60 * 60 * 24
114
+ }[unit]!;
115
+ exp = Math.floor(Date.now() / 1000) + seconds;
116
+ } else {
117
+ throw new Error('Invalid expiresIn format. Use a number (seconds) or a string like "1h", "30m", etc.');
118
+ }
119
+ }
120
+
121
+ // Create full payload with claims
122
+ const fullPayload: JWTPayload = {
123
+ ...payload,
124
+ iat: Math.floor(Date.now() / 1000),
125
+ exp
126
+ };
127
+
128
+ // Create header
129
+ const header: JWTHeader = {
130
+ alg: 'HS256',
131
+ typ: 'JWT'
132
+ };
133
+
134
+ // Encode header and payload
135
+ // @ts-expect-error - TS doesn't have a built-in TextEncoder
136
+ const encodedHeader: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(header)));
137
+ // @ts-expect-error - TS doesn't have a built-in TextEncoder
138
+ const encodedPayload: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(fullPayload)));
139
+
140
+ // Create signature base
141
+ const signatureBase: string = `${encodedHeader}.${encodedPayload}`;
142
+
143
+ // Get key and sign
144
+ const key: CryptoKey = await this.getSecretKey();
145
+ const signature: ArrayBuffer = await crypto.subtle.sign(
146
+ { name: 'HMAC' },
147
+ key,
148
+ this.encoder.encode(signatureBase)
149
+ );
150
+
151
+ // Encode signature and create token
152
+ const encodedSignature: string = this.base64UrlEncode(signature);
153
+ return `${signatureBase}.${encodedSignature}`;
154
+ }
155
+
156
+ /**
157
+ * Verify a JWT token and return the decoded payload
158
+ * @param {string} token - The JWT token to verify
159
+ * @returns {Promise<JWTPayload>} - The decoded payload if verification succeeds
160
+ * @throws {Error} - If verification fails
161
+ */
162
+ public async verify(token: string): Promise<JWTPayload> {
163
+ if (!token || typeof token !== 'string') {
164
+ throw new Error('Token is required and must be a string');
165
+ }
166
+
167
+ // Split token into parts
168
+ const parts: string[] = token.split('.');
169
+ if (parts.length !== 3) {
170
+ throw new Error('Invalid token format');
171
+ }
172
+
173
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
174
+
175
+ // Decode header and payload
176
+ try {
177
+ // @ts-expect-error - TS doesn't have a built-in TextDecoder
178
+ const header: JWTHeader = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedHeader)));
179
+ // @ts-expect-error - TS doesn't have a built-in TextDecoder
180
+ const payload: JWTPayload = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedPayload)));
181
+
182
+ // Check algorithm
183
+ if (header.alg !== 'HS256') {
184
+ throw new Error(`Unsupported algorithm: ${header.alg}`);
185
+ }
186
+
187
+ // Check expiration
188
+ const now: number = Math.floor(Date.now() / 1000);
189
+ if (payload.exp && payload.exp < now) {
190
+ throw new Error('Token has expired');
191
+ }
192
+
193
+ // Verify signature
194
+ const key: CryptoKey = await this.getSecretKey();
195
+ const signatureBase: string = `${encodedHeader}.${encodedPayload}`;
196
+ const signature: ArrayBuffer = this.base64UrlDecode(encodedSignature) as ArrayBuffer;
197
+
198
+ const isValid: boolean = await crypto.subtle.verify(
199
+ { name: 'HMAC' },
200
+ key,
201
+ signature,
202
+ this.encoder.encode(signatureBase)
203
+ );
204
+
205
+ if (!isValid) {
206
+ throw new Error('Invalid signature');
207
+ }
208
+
209
+ return payload;
210
+ } catch (error) {
211
+ if (error instanceof Error) {
212
+ throw new Error(`Token verification failed: ${error.message}`);
213
+ }
214
+ throw new Error('Token verification failed: Unknown error');
215
+ }
216
+ }
217
+ }
package/src/mock.ts CHANGED
@@ -28,18 +28,54 @@ export class MockPartyClient {
28
28
  }
29
29
  }
30
30
 
31
+ class MockLobby {
32
+ constructor(public server: Server) {}
33
+
34
+ socket() {
35
+ return new MockPartyClient(this.server)
36
+ }
37
+ }
38
+
39
+ class MockContext {
40
+ parties: {
41
+ main: Map<string, any>
42
+ } = {
43
+ main: new Map()
44
+ }
45
+
46
+ constructor(public room: MockPartyRoom, options: any = {}) {
47
+ const parties = options.parties || {}
48
+ for (let lobbyId in parties) {
49
+ this.parties.main.set(lobbyId, new MockLobby(parties[lobbyId](room)))
50
+ }
51
+ }
52
+ }
53
+
54
+
31
55
  class MockPartyRoom {
32
56
  clients: Map<string, MockPartyClient> = new Map();
33
57
  storage = new Storage();
58
+ context: MockContext;
34
59
  env = {}
35
60
 
36
- constructor(public id?: string) {
61
+ constructor(public id?: string, options: any = {}) {
37
62
  this.id = id || generateShortUUID()
63
+ this.context = new MockContext(this, {
64
+ parties: options.parties || {}
65
+ })
66
+ this.env = options.env || {}
38
67
  }
39
68
 
40
69
  async connection(server: Server) {
41
70
  const socket = new MockPartyClient(server);
42
- await server.onConnect(socket.conn as any, { request: {} } as any);
71
+ const url = new URL('http://localhost')
72
+ const request = new Request(url.toString(), {
73
+ method: 'GET',
74
+ headers: {
75
+ 'Content-Type': 'application/json'
76
+ }
77
+ })
78
+ await server.onConnect(socket.conn as any, { request } as any);
43
79
  this.clients.set(socket.id, socket);
44
80
  return socket
45
81
  }
@@ -55,7 +91,7 @@ class MockPartyRoom {
55
91
  }
56
92
 
57
93
  getConnections() {
58
- return this.clients;
94
+ return Array.from(this.clients.values()).map((client) => client.conn);
59
95
  }
60
96
 
61
97
  clear() {
@@ -0,0 +1,54 @@
1
+ export function cors(res: Response, options: CorsOptions = {}) {
2
+ const newHeaders = new Headers(res.headers);
3
+
4
+ // Set default CORS headers
5
+ newHeaders.set('Access-Control-Allow-Origin', options.origin || '*');
6
+
7
+ if (options.credentials) {
8
+ newHeaders.set('Access-Control-Allow-Credentials', 'true');
9
+ }
10
+
11
+ if (options.exposedHeaders && options.exposedHeaders.length) {
12
+ newHeaders.set('Access-Control-Expose-Headers', options.exposedHeaders.join(', '));
13
+ }
14
+
15
+ // Handle preflight requests
16
+ if (options.methods && options.methods.length) {
17
+ newHeaders.set('Access-Control-Allow-Methods', options.methods.join(', '));
18
+ } else {
19
+ newHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
20
+ }
21
+
22
+ if (options.allowedHeaders && options.allowedHeaders.length) {
23
+ newHeaders.set('Access-Control-Allow-Headers', options.allowedHeaders.join(', '));
24
+ } else {
25
+ newHeaders.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
26
+ }
27
+
28
+ if (options.maxAge) {
29
+ newHeaders.set('Access-Control-Max-Age', options.maxAge.toString());
30
+ }
31
+
32
+ return new Response(res.body, {
33
+ status: res.status,
34
+ headers: newHeaders
35
+ });
36
+ }
37
+
38
+ export interface CorsOptions {
39
+ origin?: string;
40
+ methods?: string[];
41
+ allowedHeaders?: string[];
42
+ exposedHeaders?: string[];
43
+ credentials?: boolean;
44
+ maxAge?: number;
45
+ }
46
+
47
+ /**
48
+ * Creates a CORS interceptor with the specified options
49
+ * @param options CORS configuration options
50
+ * @returns An interceptor function that can be used with ServerResponse
51
+ */
52
+ export function createCorsInterceptor(options: CorsOptions = {}): (res: Response) => Response {
53
+ return (res: Response) => cors(res, options);
54
+ }
@@ -0,0 +1,228 @@
1
+ export class ServerResponse {
2
+ private interceptors: ((res: Response) => Promise<Response> | Response)[];
3
+ private statusCode: number = 200;
4
+ private responseBody: any = {};
5
+ private responseHeaders: Record<string, string> = {
6
+ 'Content-Type': 'application/json'
7
+ };
8
+
9
+ /**
10
+ * Creates a new ServerResponse instance
11
+ * @param interceptors Array of interceptor functions that can modify the response
12
+ */
13
+ constructor(interceptors: ((res: Response) => Promise<Response> | Response)[] = []) {
14
+ this.interceptors = interceptors;
15
+ }
16
+
17
+ /**
18
+ * Sets the status code for the response
19
+ * @param code HTTP status code
20
+ * @returns this instance for chaining
21
+ */
22
+ status(code: number): ServerResponse {
23
+ this.statusCode = code;
24
+ return this;
25
+ }
26
+
27
+ /**
28
+ * Sets the response body and returns this instance (chainable method)
29
+ *
30
+ * @param body Response body
31
+ * @returns this instance for chaining
32
+ */
33
+ body(body: any): ServerResponse {
34
+ this.responseBody = body;
35
+ return this;
36
+ }
37
+
38
+ /**
39
+ * Adds a header to the response
40
+ * @param name Header name
41
+ * @param value Header value
42
+ * @returns this instance for chaining
43
+ */
44
+ header(name: string, value: string): ServerResponse {
45
+ this.responseHeaders[name] = value;
46
+ return this;
47
+ }
48
+
49
+ /**
50
+ * Adds multiple headers to the response
51
+ * @param headers Object containing headers
52
+ * @returns this instance for chaining
53
+ */
54
+ setHeaders(headers: Record<string, string>): ServerResponse {
55
+ this.responseHeaders = { ...this.responseHeaders, ...headers };
56
+ return this;
57
+ }
58
+
59
+ /**
60
+ * Add an interceptor to the chain
61
+ * @param interceptor Function that takes a Response and returns a modified Response
62
+ * @returns this instance for chaining
63
+ */
64
+ use(interceptor: (res: Response) => Promise<Response> | Response): ServerResponse {
65
+ this.interceptors.push(interceptor);
66
+ return this;
67
+ }
68
+
69
+ /**
70
+ * Builds and returns the Response object after applying all interceptors
71
+ * @returns Promise<Response> The final Response object
72
+ * @private Internal method used by terminal methods
73
+ */
74
+ private async buildResponse(): Promise<Response> {
75
+ // Create initial response
76
+ let response = new Response(JSON.stringify(this.responseBody), {
77
+ status: this.statusCode,
78
+ headers: this.responseHeaders
79
+ });
80
+
81
+ // Apply all interceptors sequentially
82
+ for (const interceptor of this.interceptors) {
83
+ try {
84
+ const interceptedResponse = interceptor(response);
85
+ if (interceptedResponse instanceof Promise) {
86
+ response = await interceptedResponse;
87
+ } else {
88
+ response = interceptedResponse;
89
+ }
90
+ } catch (error) {
91
+ console.error('Error in interceptor:', error);
92
+ // Continue with the current response if an interceptor fails
93
+ }
94
+ }
95
+
96
+ return response;
97
+ }
98
+
99
+ /**
100
+ * Sets the response body to the JSON-stringified version of the provided value
101
+ * and sends the response (terminal method)
102
+ *
103
+ * @param body Response body to be JSON stringified
104
+ * @returns Promise<Response> The final Response object
105
+ */
106
+ async json(body: any): Promise<Response> {
107
+ this.responseBody = body;
108
+ this.responseHeaders['Content-Type'] = 'application/json';
109
+ return this.buildResponse();
110
+ }
111
+
112
+ /**
113
+ * Sends the response with the current configuration (terminal method)
114
+ *
115
+ * @param body Optional body to set before sending
116
+ * @returns Promise<Response> The final Response object
117
+ */
118
+ async send(body?: any): Promise<Response> {
119
+ if (body !== undefined) {
120
+ this.responseBody = body;
121
+ }
122
+ return this.buildResponse();
123
+ }
124
+
125
+ /**
126
+ * Sends a plain text response (terminal method)
127
+ *
128
+ * @param text Text to send
129
+ * @returns Promise<Response> The final Response object
130
+ */
131
+ async text(text: string): Promise<Response> {
132
+ this.responseBody = text;
133
+ this.responseHeaders['Content-Type'] = 'text/plain';
134
+
135
+ // Create a text response without JSON stringifying the body
136
+ let response = new Response(text, {
137
+ status: this.statusCode,
138
+ headers: this.responseHeaders
139
+ });
140
+
141
+ // Apply interceptors
142
+ for (const interceptor of this.interceptors) {
143
+ try {
144
+ const interceptedResponse = interceptor(response);
145
+ if (interceptedResponse instanceof Promise) {
146
+ response = await interceptedResponse;
147
+ } else {
148
+ response = interceptedResponse;
149
+ }
150
+ } catch (error) {
151
+ console.error('Error in interceptor:', error);
152
+ }
153
+ }
154
+
155
+ return response;
156
+ }
157
+
158
+ /**
159
+ * Redirects to the specified URL (terminal method)
160
+ *
161
+ * @param url URL to redirect to
162
+ * @param statusCode HTTP status code (default: 302)
163
+ * @returns Promise<Response> The final Response object
164
+ */
165
+ async redirect(url: string, statusCode: number = 302): Promise<Response> {
166
+ this.statusCode = statusCode;
167
+ this.responseHeaders['Location'] = url;
168
+ return this.buildResponse();
169
+ }
170
+
171
+ /**
172
+ * Creates a success response with status 200
173
+ * @param body Response body
174
+ * @returns Promise<Response> The final Response object
175
+ */
176
+ async success(body: any = {}): Promise<Response> {
177
+ return this.status(200).json(body);
178
+ }
179
+
180
+ /**
181
+ * Creates an error response with status 400
182
+ * @param message Error message
183
+ * @param details Additional error details
184
+ * @returns Promise<Response> The final Response object
185
+ */
186
+ async badRequest(message: string, details: any = {}): Promise<Response> {
187
+ return this.status(400).json({
188
+ error: message,
189
+ ...details
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Creates an error response with status 403
195
+ * @param message Error message
196
+ * @returns Promise<Response> The final Response object
197
+ */
198
+ async notPermitted(message: string = "Not permitted"): Promise<Response> {
199
+ return this.status(403).json({ error: message });
200
+ }
201
+
202
+ /**
203
+ * Creates an error response with status 401
204
+ * @param message Error message
205
+ * @returns Promise<Response> The final Response object
206
+ */
207
+ async unauthorized(message: string = "Unauthorized"): Promise<Response> {
208
+ return this.status(401).json({ error: message });
209
+ }
210
+
211
+ /**
212
+ * Creates an error response with status 404
213
+ * @param message Error message
214
+ * @returns Promise<Response> The final Response object
215
+ */
216
+ async notFound(message: string = "Not found"): Promise<Response> {
217
+ return this.status(404).json({ error: message });
218
+ }
219
+
220
+ /**
221
+ * Creates an error response with status 500
222
+ * @param message Error message
223
+ * @returns Promise<Response> The final Response object
224
+ */
225
+ async serverError(message: string = "Internal Server Error"): Promise<Response> {
226
+ return this.status(500).json({ error: message });
227
+ }
228
+ }