@arcote.tech/arc-host 0.1.3 → 0.1.4

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,13 +48,9 @@ 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
56
  console.error("Token verification failed:", error);
@@ -56,6 +58,83 @@ class RTCHost implements RealTimeCommunicationAdapter {
56
58
  }
57
59
  }
58
60
 
61
+ /**
62
+ * Convert JWT payload to AuthContext
63
+ */
64
+ private tokenToAuthContext(payload: TokenPayload): AuthContext {
65
+ return {
66
+ userId: payload.userId,
67
+ roles: [], // Default to no roles, you may want to extend TokenPayload to include roles
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Get default auth context for anonymous users
73
+ */
74
+ private getDefaultAuthContext(): AuthContext {
75
+ return {
76
+ userId: "anonymous",
77
+ roles: [],
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Parse FormData back to an object structure
83
+ */
84
+ private async parseFormDataToObject(formData: FormData): Promise<any> {
85
+ const obj: any = {};
86
+
87
+ // Use forEach instead of entries() for better TypeScript compatibility
88
+ formData.forEach((value, key) => {
89
+ this.setNestedProperty(obj, key, value);
90
+ });
91
+
92
+ return obj;
93
+ }
94
+
95
+ /**
96
+ * Helper method to set nested properties from FormData keys like "user[profile][avatar]"
97
+ */
98
+ private setNestedProperty(obj: any, path: string, value: any): void {
99
+ // Handle array notation like "items[0]" or nested object notation like "user[profile][name]"
100
+ const keys = path.split(/[\[\]]+/).filter((key) => key !== "");
101
+ let current = obj;
102
+
103
+ for (let i = 0; i < keys.length - 1; i++) {
104
+ const key = keys[i];
105
+ const nextKey = keys[i + 1];
106
+
107
+ // Check if next key is a number (array index)
108
+ if (!isNaN(Number(nextKey))) {
109
+ if (!current[key]) {
110
+ current[key] = [];
111
+ }
112
+ if (!current[key][Number(nextKey)]) {
113
+ current[key][Number(nextKey)] = {};
114
+ }
115
+ current = current[key][Number(nextKey)];
116
+ i++; // Skip the array index
117
+ } else {
118
+ if (!current[key]) {
119
+ current[key] = {};
120
+ }
121
+ current = current[key];
122
+ }
123
+ }
124
+
125
+ const finalKey = keys[keys.length - 1];
126
+ if (!isNaN(Number(finalKey))) {
127
+ // This is an array index at the root level
128
+ const parentKey = keys[keys.length - 2];
129
+ if (!current[parentKey]) {
130
+ current[parentKey] = [];
131
+ }
132
+ current[parentKey][Number(finalKey)] = value;
133
+ } else {
134
+ current[finalKey] = value;
135
+ }
136
+ }
137
+
59
138
  private async handleCommand(req: Request) {
60
139
  const url = new URL(req.url);
61
140
  const commandName = url.pathname.split("/command/")[1];
@@ -63,19 +142,38 @@ class RTCHost implements RealTimeCommunicationAdapter {
63
142
  if (!commandName) {
64
143
  return new Response("Command not specified", { status: 400 });
65
144
  }
145
+
66
146
  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
- );
147
+ // Extract token from Authorization header
148
+ const authHeader = req.headers.get("Authorization");
149
+ const token = authHeader?.replace("Bearer ", "");
150
+
151
+ let authContext: AuthContext;
152
+ if (token && !this.isPublicEndpoint(url.pathname)) {
153
+ const payload = await this.verifyAuthToken(token);
154
+ if (!payload) {
155
+ return new Response("Invalid or expired token", { status: 401 });
156
+ }
157
+ authContext = this.tokenToAuthContext(payload);
158
+ } else {
159
+ authContext = this.getDefaultAuthContext();
160
+ }
161
+
162
+ let argument: any;
163
+ const contentType = req.headers.get("Content-Type");
164
+
165
+ // Handle different content types
166
+ if (contentType && contentType.includes("multipart/form-data")) {
167
+ // Parse FormData
168
+ const formData = await req.formData();
169
+ argument = await this.parseFormDataToObject(formData);
170
+ } else {
171
+ // Parse JSON (default behavior)
172
+ argument = await req.json();
173
+ }
174
+
175
+ // Use the model's commands() method with auth context
176
+ const commands = this.model.commands(authContext);
79
177
 
80
178
  const result = await (commands as any)[commandName](argument);
81
179
  return new Response(JSON.stringify(result), {
@@ -90,6 +188,21 @@ class RTCHost implements RealTimeCommunicationAdapter {
90
188
 
91
189
  private async handleQuery(req: Request) {
92
190
  try {
191
+ // Extract token from Authorization header
192
+ const authHeader = req.headers.get("Authorization");
193
+ const token = authHeader?.replace("Bearer ", "");
194
+
195
+ let authContext: AuthContext;
196
+ if (token) {
197
+ const payload = await this.verifyAuthToken(token);
198
+ if (!payload) {
199
+ return new Response("Invalid or expired token", { status: 401 });
200
+ }
201
+ authContext = this.tokenToAuthContext(payload);
202
+ } else {
203
+ return new Response("Authorization token required", { status: 401 });
204
+ }
205
+
93
206
  const body = await req.json();
94
207
  const { query } = body;
95
208
 
@@ -99,9 +212,10 @@ class RTCHost implements RealTimeCommunicationAdapter {
99
212
 
100
213
  const { element, queryType, params } = query;
101
214
 
102
- // Access the model and run the query
215
+ // Access the model and run the query with auth context
103
216
  const queryBuilder = this.context.queryBuilder(
104
217
  new QueryBuilderContext(new QueryCache(), this.dataStore),
218
+ authContext,
105
219
  ) as any;
106
220
 
107
221
  // Get the appropriate element from the queryBuilder
@@ -141,20 +255,23 @@ class RTCHost implements RealTimeCommunicationAdapter {
141
255
  fetch: async (req, server) => {
142
256
  const url = new URL(req.url);
143
257
 
144
- // const authHeader = req.headers.get("Authorization");
145
- // const token =
146
- // authHeader?.replace("Bearer ", "") || url.searchParams.get("token");
147
- // console.log(url);
258
+ // Extract token from Authorization header or query parameter
259
+ const authHeader = req.headers.get("Authorization");
260
+ const token =
261
+ authHeader?.replace("Bearer ", "") || url.searchParams.get("token");
148
262
 
149
- // if (!token) {
150
- // return new Response("Unauthorized", { status: 401 });
151
- // }
263
+ // Verify token for protected endpoints
264
+ let payload = null;
265
+ if (token) {
266
+ payload = await this.verifyAuthToken(token);
152
267
 
153
- // const payload = await this.verifyToken(token);
154
- // if (!payload) {
155
- // return new Response("Invalid token", { status: 401 });
156
- // }
157
- const payload = null;
268
+ // For protected endpoints, require valid token
269
+ if (!payload && !this.isPublicEndpoint(url.pathname)) {
270
+ return new Response("Invalid or expired token", { status: 401 });
271
+ }
272
+ } else if (!this.isPublicEndpoint(url.pathname)) {
273
+ return new Response("Authorization token required", { status: 401 });
274
+ }
158
275
 
159
276
  // Handle different endpoints
160
277
  if (
@@ -174,7 +291,6 @@ class RTCHost implements RealTimeCommunicationAdapter {
174
291
  if (url.pathname.startsWith("/command/") && req.method === "POST") {
175
292
  return await this.handleCommand(req);
176
293
  }
177
- console.log(url.pathname);
178
294
 
179
295
  if (url.pathname === "/query" && req.method === "POST") {
180
296
  return await this.handleQuery(req);
@@ -195,6 +311,13 @@ class RTCHost implements RealTimeCommunicationAdapter {
195
311
  });
196
312
  }
197
313
 
314
+ private isPublicEndpoint(pathname: string): boolean {
315
+ // Define which endpoints don't require authentication
316
+ const publicEndpoints = ["/command/signin", "/command/register"];
317
+
318
+ return publicEndpoints.some((endpoint) => pathname === endpoint);
319
+ }
320
+
198
321
  private async handleSync(lastDate: string | null) {
199
322
  const syncDate = new Date();
200
323
  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.4",
8
8
  "private": false,
9
9
  "author": "Przemysław Krasiński [arcote.tech]",
10
10
  "dependencies": {