@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/auth/jwt.ts +42 -0
- package/dist/auth/jwt.d.ts +15 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/host.d.ts +23 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/index.js +2539 -2261
- package/host.ts +204 -34
- package/package.json +1 -1
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
|
|
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
|
|
51
|
+
private async verifyAuthToken(token: string) {
|
|
46
52
|
try {
|
|
47
|
-
const
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
295
|
+
// Verify token for protected endpoints
|
|
296
|
+
let payload = null;
|
|
297
|
+
if (token) {
|
|
298
|
+
payload = await this.verifyAuthToken(token);
|
|
152
299
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|