@arcote.tech/arc-host 0.1.3 → 0.1.5

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/host.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  ArcCollection,
3
3
  MasterDataStorage,
4
+ Model,
4
5
  QueryBuilderContext,
5
6
  QueryCache,
6
7
  type ArcCollectionAny,
7
8
  type ArcContextAny,
9
+ type AuthContext,
8
10
  type DatabaseAdapter,
9
11
  type DataStorageChanges,
10
12
  type MessageClientToHost,
@@ -12,17 +14,21 @@ import {
12
14
  type RealTimeCommunicationAdapter,
13
15
  } from "@arcote.tech/arc";
14
16
  import type { Server, ServerWebSocket } from "bun";
15
- import jwt from "jsonwebtoken";
17
+ import { verifyToken, type TokenPayload } from "./auth/jwt";
16
18
 
17
19
  class RTCHost implements RealTimeCommunicationAdapter {
18
20
  private server!: Server;
19
21
  private dataStore: MasterDataStorage;
22
+ private model: Model<ArcContextAny>;
20
23
 
21
24
  constructor(
22
25
  private context: ArcContextAny,
23
26
  dbAdapter: Promise<DatabaseAdapter>,
24
27
  ) {
25
28
  this.dataStore = new MasterDataStorage(dbAdapter, () => this, context);
29
+ this.model = new Model(context, this.dataStore, (error) =>
30
+ console.error("Command error:", error),
31
+ );
26
32
  this.setupServer();
27
33
  }
28
34
 
@@ -42,20 +48,123 @@ class RTCHost implements RealTimeCommunicationAdapter {
42
48
  // throw new Error("Method not implemented.");
43
49
  }
44
50
 
45
- private async verifyToken(token: string) {
51
+ private async verifyAuthToken(token: string) {
46
52
  try {
47
- const secret = process.env.AUTH_SECRET as string;
48
- if (!secret) {
49
- throw new Error("AUTH_SECRET is not set");
50
- }
51
- const payload = jwt.verify(token, secret);
53
+ const payload = verifyToken(token);
52
54
  return payload;
53
55
  } catch (error) {
54
- console.error("Token verification failed:", error);
55
56
  return null;
56
57
  }
57
58
  }
58
59
 
60
+ /**
61
+ * Convert JWT payload to AuthContext
62
+ */
63
+ private tokenToAuthContext(
64
+ payload: TokenPayload,
65
+ ipAddress?: string,
66
+ ): AuthContext {
67
+ return {
68
+ userId: payload.userId,
69
+ roles: [], // Default to no roles, you may want to extend TokenPayload to include roles
70
+ ipAddress,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Extract client IP address from request headers
76
+ */
77
+ private getClientIpAddress(req: Request): string | undefined {
78
+ // Check common headers for client IP
79
+ const xForwardedFor = req.headers.get("x-forwarded-for");
80
+ const xRealIp = req.headers.get("x-real-ip");
81
+ const cfConnectingIp = req.headers.get("cf-connecting-ip");
82
+
83
+ if (xForwardedFor) {
84
+ // x-forwarded-for can contain multiple IPs, take the first one
85
+ return xForwardedFor.split(",")[0].trim();
86
+ }
87
+
88
+ if (xRealIp) {
89
+ return xRealIp;
90
+ }
91
+
92
+ if (cfConnectingIp) {
93
+ return cfConnectingIp;
94
+ }
95
+
96
+ // Fallback - this might not work in all environments
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * Get default auth context for anonymous users
102
+ */
103
+ private getDefaultAuthContext(ipAddress?: string): AuthContext {
104
+ return {
105
+ userId: "anonymous",
106
+ roles: [],
107
+ ipAddress,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Parse FormData back to an object structure
113
+ */
114
+ private async parseFormDataToObject(formData: FormData): Promise<any> {
115
+ const obj: any = {};
116
+
117
+ // Use forEach instead of entries() for better TypeScript compatibility
118
+ formData.forEach((value, key) => {
119
+ this.setNestedProperty(obj, key, value);
120
+ });
121
+
122
+ return obj;
123
+ }
124
+
125
+ /**
126
+ * Helper method to set nested properties from FormData keys like "user[profile][avatar]"
127
+ */
128
+ private setNestedProperty(obj: any, path: string, value: any): void {
129
+ // Handle array notation like "items[0]" or nested object notation like "user[profile][name]"
130
+ const keys = path.split(/[\[\]]+/).filter((key) => key !== "");
131
+ let current = obj;
132
+
133
+ for (let i = 0; i < keys.length - 1; i++) {
134
+ const key = keys[i];
135
+ const nextKey = keys[i + 1];
136
+
137
+ // Check if next key is a number (array index)
138
+ if (!isNaN(Number(nextKey))) {
139
+ if (!current[key]) {
140
+ current[key] = [];
141
+ }
142
+ if (!current[key][Number(nextKey)]) {
143
+ current[key][Number(nextKey)] = {};
144
+ }
145
+ current = current[key][Number(nextKey)];
146
+ i++; // Skip the array index
147
+ } else {
148
+ if (!current[key]) {
149
+ current[key] = {};
150
+ }
151
+ current = current[key];
152
+ }
153
+ }
154
+
155
+ const finalKey = keys[keys.length - 1];
156
+ if (!isNaN(Number(finalKey))) {
157
+ // This is an array index at the root level
158
+ const parentKey = keys[keys.length - 2];
159
+ if (!current[parentKey]) {
160
+ current[parentKey] = [];
161
+ }
162
+ current[parentKey][Number(finalKey)] = value;
163
+ } else {
164
+ current[finalKey] = value;
165
+ }
166
+ }
167
+
59
168
  private async handleCommand(req: Request) {
60
169
  const url = new URL(req.url);
61
170
  const commandName = url.pathname.split("/command/")[1];
@@ -63,19 +172,39 @@ class RTCHost implements RealTimeCommunicationAdapter {
63
172
  if (!commandName) {
64
173
  return new Response("Command not specified", { status: 400 });
65
174
  }
175
+
66
176
  try {
67
- const argument = await req.json();
68
- // Create command context and execute command
69
- const queryContext = new QueryBuilderContext(
70
- new QueryCache(),
71
- this.dataStore,
72
- );
73
- const commands = this.context.commandsClient(
74
- "server",
75
- queryContext,
76
- this.dataStore,
77
- (error) => console.error("Command error:", error),
78
- );
177
+ // Extract token from Authorization header
178
+ const authHeader = req.headers.get("Authorization");
179
+ const token = authHeader?.replace("Bearer ", "");
180
+
181
+ const clientIp = this.getClientIpAddress(req);
182
+ let authContext: AuthContext;
183
+ if (token && !this.isPublicEndpoint(url.pathname)) {
184
+ const payload = await this.verifyAuthToken(token);
185
+ if (!payload) {
186
+ return new Response("Invalid or expired token", { status: 401 });
187
+ }
188
+ authContext = this.tokenToAuthContext(payload, clientIp);
189
+ } else {
190
+ authContext = this.getDefaultAuthContext(clientIp);
191
+ }
192
+
193
+ let argument: any;
194
+ const contentType = req.headers.get("Content-Type");
195
+
196
+ // Handle different content types
197
+ if (contentType && contentType.includes("multipart/form-data")) {
198
+ // Parse FormData
199
+ const formData = await req.formData();
200
+ argument = await this.parseFormDataToObject(formData);
201
+ } else {
202
+ // Parse JSON (default behavior)
203
+ argument = await req.json();
204
+ }
205
+
206
+ // Use the model's commands() method with auth context
207
+ const commands = this.model.commands(authContext);
79
208
 
80
209
  const result = await (commands as any)[commandName](argument);
81
210
  return new Response(JSON.stringify(result), {
@@ -90,6 +219,22 @@ class RTCHost implements RealTimeCommunicationAdapter {
90
219
 
91
220
  private async handleQuery(req: Request) {
92
221
  try {
222
+ // Extract token from Authorization header
223
+ const authHeader = req.headers.get("Authorization");
224
+ const token = authHeader?.replace("Bearer ", "");
225
+
226
+ const clientIp = this.getClientIpAddress(req);
227
+ let authContext: AuthContext;
228
+ if (token) {
229
+ const payload = await this.verifyAuthToken(token);
230
+ if (!payload) {
231
+ return new Response("Invalid or expired token", { status: 401 });
232
+ }
233
+ authContext = this.tokenToAuthContext(payload, clientIp);
234
+ } else {
235
+ return new Response("Authorization token required", { status: 401 });
236
+ }
237
+
93
238
  const body = await req.json();
94
239
  const { query } = body;
95
240
 
@@ -99,9 +244,10 @@ class RTCHost implements RealTimeCommunicationAdapter {
99
244
 
100
245
  const { element, queryType, params } = query;
101
246
 
102
- // Access the model and run the query
247
+ // Access the model and run the query with auth context
103
248
  const queryBuilder = this.context.queryBuilder(
104
249
  new QueryBuilderContext(new QueryCache(), this.dataStore),
250
+ authContext,
105
251
  ) as any;
106
252
 
107
253
  // Get the appropriate element from the queryBuilder
@@ -141,20 +287,23 @@ class RTCHost implements RealTimeCommunicationAdapter {
141
287
  fetch: async (req, server) => {
142
288
  const url = new URL(req.url);
143
289
 
144
- // const authHeader = req.headers.get("Authorization");
145
- // const token =
146
- // authHeader?.replace("Bearer ", "") || url.searchParams.get("token");
147
- // console.log(url);
290
+ // Extract token from Authorization header or query parameter
291
+ const authHeader = req.headers.get("Authorization");
292
+ const token =
293
+ authHeader?.replace("Bearer ", "") || url.searchParams.get("token");
148
294
 
149
- // if (!token) {
150
- // return new Response("Unauthorized", { status: 401 });
151
- // }
295
+ // Verify token for protected endpoints
296
+ let payload = null;
297
+ if (token) {
298
+ payload = await this.verifyAuthToken(token);
152
299
 
153
- // const payload = await this.verifyToken(token);
154
- // if (!payload) {
155
- // return new Response("Invalid token", { status: 401 });
156
- // }
157
- const payload = null;
300
+ // For protected endpoints, require valid token
301
+ if (!payload && !this.isPublicEndpoint(url.pathname)) {
302
+ return new Response("Invalid or expired token", { status: 401 });
303
+ }
304
+ } else if (!this.isPublicEndpoint(url.pathname)) {
305
+ return new Response("Authorization token required", { status: 401 });
306
+ }
158
307
 
159
308
  // Handle different endpoints
160
309
  if (
@@ -174,7 +323,6 @@ class RTCHost implements RealTimeCommunicationAdapter {
174
323
  if (url.pathname.startsWith("/command/") && req.method === "POST") {
175
324
  return await this.handleCommand(req);
176
325
  }
177
- console.log(url.pathname);
178
326
 
179
327
  if (url.pathname === "/query" && req.method === "POST") {
180
328
  return await this.handleQuery(req);
@@ -195,6 +343,28 @@ class RTCHost implements RealTimeCommunicationAdapter {
195
343
  });
196
344
  }
197
345
 
346
+ private isPublicEndpoint(pathname: string): boolean {
347
+ // Extract command name from pathname
348
+ if (!pathname.startsWith("/command/")) {
349
+ return false;
350
+ }
351
+
352
+ const commandName = pathname.split("/command/")[1];
353
+ if (!commandName) {
354
+ return false;
355
+ }
356
+
357
+ // Get the command from the context and check if it's marked as public
358
+ const contextElement = this.context.elements.find(
359
+ (element: any) => element.name === commandName,
360
+ );
361
+
362
+ // Check if it's an ArcCommand instance and if it's marked as public
363
+ return contextElement && "isPublic" in contextElement
364
+ ? contextElement.isPublic === true
365
+ : false;
366
+ }
367
+
198
368
  private async handleSync(lastDate: string | null) {
199
369
  const syncDate = new Date();
200
370
  const where = lastDate
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
7
- "version": "0.1.3",
7
+ "version": "0.1.5",
8
8
  "private": false,
9
9
  "author": "Przemysław Krasiński [arcote.tech]",
10
10
  "dependencies": {