@arcote.tech/arc-host 0.1.11 → 0.3.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.
@@ -0,0 +1,115 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type {
3
+ ConnectedClient,
4
+ HostToClientMessage,
5
+ TokenPayload,
6
+ } from "./types";
7
+
8
+ /**
9
+ * Manages WebSocket connections for a host
10
+ */
11
+ export class ConnectionManager {
12
+ private clients = new Map<string, ConnectedClient>();
13
+ private clientIdCounter = 0;
14
+
15
+ /**
16
+ * Add a new client connection
17
+ */
18
+ addClient(
19
+ ws: ServerWebSocket<{ clientId: string }>,
20
+ token: TokenPayload | null,
21
+ rawToken: string | null,
22
+ ): ConnectedClient {
23
+ const clientId = `client_${++this.clientIdCounter}_${Date.now()}`;
24
+
25
+ const client: ConnectedClient = {
26
+ id: clientId,
27
+ token,
28
+ rawToken,
29
+ lastHostEventId: null,
30
+ ws,
31
+ };
32
+
33
+ this.clients.set(clientId, client);
34
+
35
+ // Store clientId in WebSocket data for later lookup
36
+ ws.data = { clientId };
37
+
38
+ return client;
39
+ }
40
+
41
+ /**
42
+ * Remove a client connection
43
+ */
44
+ removeClient(clientId: string): void {
45
+ this.clients.delete(clientId);
46
+ }
47
+
48
+ /**
49
+ * Get client by ID
50
+ */
51
+ getClient(clientId: string): ConnectedClient | undefined {
52
+ return this.clients.get(clientId);
53
+ }
54
+
55
+ /**
56
+ * Get client by WebSocket
57
+ */
58
+ getClientByWs(
59
+ ws: ServerWebSocket<{ clientId: string }>,
60
+ ): ConnectedClient | undefined {
61
+ const clientId = ws.data?.clientId;
62
+ if (!clientId) return undefined;
63
+ return this.clients.get(clientId);
64
+ }
65
+
66
+ /**
67
+ * Update client's last synced event ID
68
+ */
69
+ updateLastSyncedEventId(clientId: string, hostEventId: string): void {
70
+ const client = this.clients.get(clientId);
71
+ if (client) {
72
+ client.lastHostEventId = hostEventId;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get all connected clients
78
+ */
79
+ getAllClients(): ConnectedClient[] {
80
+ return Array.from(this.clients.values());
81
+ }
82
+
83
+ /**
84
+ * Send message to a specific client
85
+ */
86
+ sendToClient(clientId: string, message: HostToClientMessage): boolean {
87
+ const client = this.clients.get(clientId);
88
+ if (!client) return false;
89
+
90
+ try {
91
+ client.ws.send(JSON.stringify(message));
92
+ return true;
93
+ } catch (error) {
94
+ console.error(`Failed to send message to client ${clientId}:`, error);
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Broadcast message to all clients
101
+ */
102
+ broadcast(message: HostToClientMessage, excludeClientId?: string): void {
103
+ for (const client of this.clients.values()) {
104
+ if (client.id === excludeClientId) continue;
105
+ this.sendToClient(client.id, message);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get total number of connected clients
111
+ */
112
+ get clientCount(): number {
113
+ return this.clients.size;
114
+ }
115
+ }
@@ -0,0 +1,219 @@
1
+ import {
2
+ type ArcContextAny,
3
+ type ArcEventAny,
4
+ AuthAdapter,
5
+ type DatabaseAdapter,
6
+ LocalEventPublisher,
7
+ MasterDataStorage,
8
+ Model,
9
+ mutationExecutor,
10
+ } from "@arcote.tech/arc";
11
+ import { canTokenEmitEvent, filterEventsForToken } from "./event-auth";
12
+ import type { SyncableEvent, TokenPayload } from "./types";
13
+
14
+ /**
15
+ * Handles a single context on the host
16
+ */
17
+ export class ContextHandler {
18
+ private model: Model<ArcContextAny>;
19
+ private dataStorage: MasterDataStorage;
20
+ private eventPublisher: LocalEventPublisher;
21
+ private authAdapter: AuthAdapter;
22
+ private eventDefinitions = new Map<string, ArcEventAny>();
23
+ private hostEventIdCounter = 0;
24
+ private initialized = false;
25
+
26
+ constructor(
27
+ public readonly context: ArcContextAny,
28
+ dbAdapter: Promise<DatabaseAdapter>,
29
+ ) {
30
+ this.dataStorage = new MasterDataStorage(dbAdapter);
31
+ this.eventPublisher = new LocalEventPublisher(this.dataStorage);
32
+ this.authAdapter = new AuthAdapter();
33
+
34
+ this.model = new Model(context, {
35
+ adapters: {
36
+ dataStorage: this.dataStorage,
37
+ eventPublisher: this.eventPublisher,
38
+ authAdapter: this.authAdapter,
39
+ },
40
+ environment: "server",
41
+ });
42
+
43
+ // Build event definitions map
44
+ for (const element of context.elements) {
45
+ if ("tagFields" in element && "payload" in element) {
46
+ this.eventDefinitions.set(element.name, element as ArcEventAny);
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Initialize the context handler
53
+ */
54
+ async init(): Promise<void> {
55
+ if (this.initialized) return;
56
+ await this.model.init();
57
+ this.initialized = true;
58
+ }
59
+
60
+ /**
61
+ * Execute a command
62
+ */
63
+ async executeCommand(
64
+ commandName: string,
65
+ params: any,
66
+ rawToken: string | null,
67
+ ): Promise<any> {
68
+ const mutations = mutationExecutor(this.model);
69
+ const command = (mutations as any)[commandName];
70
+
71
+ if (!command) {
72
+ throw new Error(`Command '${commandName}' not found`);
73
+ }
74
+
75
+ // Set auth token on adapter so commands can access $auth.params
76
+ // AuthAdapter decodes the JWT internally
77
+ this.authAdapter.setToken(rawToken);
78
+
79
+ // TODO: Check command protection with token
80
+
81
+ return await command(params);
82
+ }
83
+
84
+ /**
85
+ * Persist events from a client and return with host IDs
86
+ */
87
+ async persistEvents(
88
+ events: Array<{
89
+ localId: string;
90
+ type: string;
91
+ payload: any;
92
+ createdAt: string;
93
+ }>,
94
+ clientId: string,
95
+ token: TokenPayload | null,
96
+ ): Promise<SyncableEvent[]> {
97
+ const persistedEvents: SyncableEvent[] = [];
98
+
99
+ for (const event of events) {
100
+ // Get event definition
101
+ const eventDef = this.eventDefinitions.get(event.type);
102
+ if (!eventDef) {
103
+ console.warn(`Unknown event type: ${event.type}`);
104
+ continue;
105
+ }
106
+
107
+ // Check if token can emit this event
108
+ if (!canTokenEmitEvent(token, eventDef, event.payload)) {
109
+ console.warn(`Token not authorized to emit event: ${event.type}`);
110
+ continue;
111
+ }
112
+
113
+ // Generate host ID
114
+ const hostId = `host_${++this.hostEventIdCounter}_${Date.now()}`;
115
+
116
+ const syncableEvent: SyncableEvent = {
117
+ localId: event.localId,
118
+ hostId,
119
+ type: event.type,
120
+ payload: event.payload,
121
+ createdAt: event.createdAt,
122
+ clientId,
123
+ };
124
+
125
+ // Persist to database via event publisher
126
+ await this.eventPublisher.publish({
127
+ id: hostId,
128
+ type: event.type,
129
+ payload: event.payload,
130
+ createdAt: new Date(event.createdAt),
131
+ });
132
+
133
+ persistedEvents.push(syncableEvent);
134
+ }
135
+
136
+ return persistedEvents;
137
+ }
138
+
139
+ /**
140
+ * Get events after a specific host ID, filtered by token
141
+ */
142
+ async getEventsSince(
143
+ lastHostEventId: string | null,
144
+ token: TokenPayload | null,
145
+ ): Promise<SyncableEvent[]> {
146
+ // Query events from database
147
+ const eventsStore = this.dataStorage.getStore<any>("events");
148
+ const allEvents: any[] = await eventsStore.find({});
149
+
150
+ let filteredEvents: any[];
151
+ if (lastHostEventId) {
152
+ // Extract timestamp from lastHostEventId (format: host_{counter}_{timestamp})
153
+ const parts = lastHostEventId.split("_");
154
+ const lastTimestamp = parts.length >= 3 ? parseInt(parts[2], 10) : 0;
155
+
156
+ // Filter events with timestamp > lastTimestamp
157
+ filteredEvents = allEvents.filter((e) => {
158
+ if (!e._id.startsWith("host_")) return false;
159
+ const eventParts = e._id.split("_");
160
+ const eventTimestamp =
161
+ eventParts.length >= 3 ? parseInt(eventParts[2], 10) : 0;
162
+ return eventTimestamp > lastTimestamp;
163
+ });
164
+ } else {
165
+ // Return all host events
166
+ filteredEvents = allEvents.filter((e) => e._id.startsWith("host_"));
167
+ }
168
+
169
+ // Sort by timestamp to ensure correct order
170
+ filteredEvents.sort((a, b) => {
171
+ const aTimestamp = parseInt(a._id.split("_")[2] || "0", 10);
172
+ const bTimestamp = parseInt(b._id.split("_")[2] || "0", 10);
173
+ return aTimestamp - bTimestamp;
174
+ });
175
+
176
+ // Convert to SyncableEvent format
177
+ const syncableEvents: SyncableEvent[] = filteredEvents.map((e) => ({
178
+ localId: e._id,
179
+ hostId: e._id,
180
+ type: e.type,
181
+ payload:
182
+ typeof e.payload === "string" ? JSON.parse(e.payload) : e.payload,
183
+ createdAt: e.createdAt,
184
+ clientId: "host",
185
+ }));
186
+
187
+ // Filter by token authorization
188
+ return filterEventsForToken(token, syncableEvents, this.eventDefinitions);
189
+ }
190
+
191
+ /**
192
+ * Get the model for advanced operations
193
+ */
194
+ getModel(): Model<ArcContextAny> {
195
+ return this.model;
196
+ }
197
+
198
+ /**
199
+ * Get data storage for queries
200
+ */
201
+ getDataStorage(): MasterDataStorage {
202
+ return this.dataStorage;
203
+ }
204
+
205
+ /**
206
+ * Get event definitions map
207
+ */
208
+ getEventDefinitions(): Map<string, ArcEventAny> {
209
+ return this.eventDefinitions;
210
+ }
211
+
212
+ /**
213
+ * Set auth token on the adapter
214
+ * Used to apply view protection for queries
215
+ */
216
+ setAuthToken(rawToken: string | null): void {
217
+ this.authAdapter.setToken(rawToken);
218
+ }
219
+ }
@@ -0,0 +1,127 @@
1
+ import type { ArcEventAny } from "@arcote.tech/arc";
2
+ import type { SyncableEvent, TokenPayload } from "./types";
3
+
4
+ /**
5
+ * Check if a token can receive an event based on explicit protectBy rules
6
+ *
7
+ * Security model:
8
+ * - Events WITHOUT protections: public, anyone can receive
9
+ * - Events WITH protections: MUST have matching read rule for token
10
+ */
11
+ export function canTokenReceiveEvent(
12
+ token: TokenPayload | null,
13
+ event: ArcEventAny,
14
+ eventInstance: SyncableEvent,
15
+ ): boolean {
16
+ // If event has no protections, it's public - allow everyone
17
+ if (!event.hasProtections) {
18
+ return true;
19
+ }
20
+
21
+ // Event has protections - require valid token
22
+ if (!token) {
23
+ return false;
24
+ }
25
+
26
+ // Check event protections for matching read rule
27
+ for (const protection of event.protections) {
28
+ // Check if this protection applies to the token type
29
+ if (protection.token.name !== token.tokenType) {
30
+ continue;
31
+ }
32
+
33
+ // Get protection rules for this token's params
34
+ const rules = protection.protectionFn(token.params);
35
+
36
+ // If read is explicitly false, deny access
37
+ if (rules.read === false) {
38
+ continue;
39
+ }
40
+
41
+ // Check read conditions against event payload
42
+ if (rules.read) {
43
+ const matches = checkConditionsMatch(rules.read, eventInstance.payload);
44
+ if (matches) {
45
+ return true;
46
+ }
47
+ }
48
+ }
49
+
50
+ // No matching protection rule found - deny access
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Check if a token can emit an event
56
+ */
57
+ export function canTokenEmitEvent(
58
+ token: TokenPayload | null,
59
+ event: ArcEventAny,
60
+ payload: any,
61
+ ): boolean {
62
+ // If no token, only allow unprotected events
63
+ if (!token) {
64
+ return !event.hasProtections;
65
+ }
66
+
67
+ // If event has no protections, allow all authenticated users
68
+ if (!event.hasProtections) {
69
+ return true;
70
+ }
71
+
72
+ // Check event protections
73
+ for (const protection of event.protections) {
74
+ // Check if this protection applies to the token type
75
+ if (protection.token.name !== token.tokenType) {
76
+ continue;
77
+ }
78
+
79
+ // Get protection rules for this token's params
80
+ const rules = protection.protectionFn(token.params);
81
+
82
+ // Check write rules against payload
83
+ if (rules.write) {
84
+ const matches = checkConditionsMatch(rules.write, payload);
85
+ if (matches) {
86
+ return true;
87
+ }
88
+ }
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * Check if conditions match against data
96
+ */
97
+ function checkConditionsMatch(
98
+ conditions: Record<string, any>,
99
+ data: Record<string, any>,
100
+ ): boolean {
101
+ for (const [key, expectedValue] of Object.entries(conditions)) {
102
+ const actualValue = data[key];
103
+
104
+ if (actualValue !== expectedValue) {
105
+ return false;
106
+ }
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * Filter events that a token can receive
113
+ */
114
+ export function filterEventsForToken(
115
+ token: TokenPayload | null,
116
+ events: SyncableEvent[],
117
+ eventDefinitions: Map<string, ArcEventAny>,
118
+ ): SyncableEvent[] {
119
+ return events.filter((eventInstance) => {
120
+ const eventDef = eventDefinitions.get(eventInstance.type);
121
+ if (!eventDef) {
122
+ // Unknown event type, don't send
123
+ return false;
124
+ }
125
+ return canTokenReceiveEvent(token, eventDef, eventInstance);
126
+ });
127
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Main exports
2
+ export { ArcHost } from "./arc-host";
3
+ export { ConnectionManager } from "./connection-manager";
4
+ export { ContextHandler } from "./context-handler";
5
+
6
+ // Types
7
+ export type {
8
+ ArcHostConfig,
9
+ ClientToHostMessage,
10
+ ConnectedClient,
11
+ HostToClientMessage,
12
+ SyncableEvent,
13
+ TokenPayload,
14
+ } from "./types";
15
+
16
+ // Auth utilities
17
+ export {
18
+ canTokenEmitEvent,
19
+ canTokenReceiveEvent,
20
+ filterEventsForToken,
21
+ } from "./event-auth";
22
+
23
+ // Re-export adapters
24
+ export { sqliteAdapterFactory } from "../sqliteAdapter";
package/src/types.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { ArcContextAny, DBAdapterFactory } from "@arcote.tech/arc";
2
+
3
+ /**
4
+ * Host configuration options
5
+ */
6
+ export interface ArcHostConfig {
7
+ /** Context to serve */
8
+ context: ArcContextAny;
9
+ /** Database adapter factory */
10
+ dbAdapterFactory: DBAdapterFactory;
11
+ /** Server port */
12
+ port?: number;
13
+ /** JWT secret for token verification */
14
+ jwtSecret?: string;
15
+ /** JWT expiration time */
16
+ jwtExpiresIn?: string;
17
+ }
18
+
19
+ /**
20
+ * Token payload from JWT
21
+ */
22
+ export interface TokenPayload {
23
+ /** Token type name (e.g., "user") */
24
+ tokenType: string;
25
+ /** Token params (e.g., { userId: "123" }) */
26
+ params: Record<string, any>;
27
+ /** Issued at timestamp */
28
+ iat?: number;
29
+ /** Expiration timestamp */
30
+ exp?: number;
31
+ }
32
+
33
+ /**
34
+ * Connected client info
35
+ */
36
+ export interface ConnectedClient {
37
+ /** Unique client ID */
38
+ id: string;
39
+ /** Token payload (decoded) */
40
+ token: TokenPayload | null;
41
+ /** Raw JWT token string */
42
+ rawToken: string | null;
43
+ /** Last synced host event ID */
44
+ lastHostEventId: string | null;
45
+ /** WebSocket instance */
46
+ ws: any;
47
+ }
48
+
49
+ /**
50
+ * Event with sync IDs
51
+ */
52
+ export interface SyncableEvent {
53
+ /** Local ID generated by client */
54
+ localId: string;
55
+ /** Host ID assigned when persisted */
56
+ hostId: string;
57
+ /** Event type */
58
+ type: string;
59
+ /** Event payload */
60
+ payload: any;
61
+ /** Creation timestamp */
62
+ createdAt: string;
63
+ /** Client ID that created this event */
64
+ clientId: string;
65
+ }
66
+
67
+ /**
68
+ * Messages from client to host
69
+ */
70
+ export type ClientToHostMessage =
71
+ | {
72
+ type: "sync-events";
73
+ events: Array<{
74
+ localId: string;
75
+ type: string;
76
+ payload: any;
77
+ createdAt: string;
78
+ }>;
79
+ }
80
+ | {
81
+ type: "request-sync";
82
+ lastHostEventId: string | null;
83
+ }
84
+ | {
85
+ type: "execute-command";
86
+ commandName: string;
87
+ params: any;
88
+ requestId: string;
89
+ };
90
+
91
+ /**
92
+ * Messages from host to client
93
+ */
94
+ export type HostToClientMessage =
95
+ | {
96
+ type: "events";
97
+ events: SyncableEvent[];
98
+ }
99
+ | {
100
+ type: "command-result";
101
+ requestId: string;
102
+ result?: any;
103
+ error?: string;
104
+ }
105
+ | {
106
+ type: "sync-complete";
107
+ lastHostEventId: string;
108
+ }
109
+ | {
110
+ type: "error";
111
+ message: string;
112
+ };
package/dist/host.d.ts DELETED
@@ -1,45 +0,0 @@
1
- import { type ArcContextAny, type DatabaseAdapter, type DataStorageChanges, type RealTimeCommunicationAdapter } from "@arcote.tech/arc";
2
- declare class RTCHost implements RealTimeCommunicationAdapter {
3
- private context;
4
- private server;
5
- private dataStore;
6
- private model;
7
- constructor(context: ArcContextAny, dbAdapter: Promise<DatabaseAdapter>);
8
- commitChanges(changes: DataStorageChanges[]): void;
9
- sync(progressCallback: ({ store, size, }: {
10
- store: string;
11
- size: number;
12
- }) => void): Promise<void>;
13
- private verifyAuthToken;
14
- /**
15
- * Convert JWT payload to AuthContext
16
- */
17
- private tokenToAuthContext;
18
- /**
19
- * Extract client IP address from request headers
20
- */
21
- private getClientIpAddress;
22
- /**
23
- * Get default auth context for anonymous users
24
- */
25
- private getDefaultAuthContext;
26
- /**
27
- * Parse FormData back to an object structure
28
- */
29
- private parseFormDataToObject;
30
- /**
31
- * Helper method to set nested properties from FormData keys like "user[profile][avatar]"
32
- */
33
- private setNestedProperty;
34
- private handleCommand;
35
- private handleQuery;
36
- private handleRoute;
37
- private setupServer;
38
- private isPublicEndpoint;
39
- private handleSync;
40
- private onMessage;
41
- private publishMessage;
42
- }
43
- export declare const rtcHostFactory: (context: ArcContextAny, dbAdapter: Promise<DatabaseAdapter>) => () => RTCHost;
44
- export {};
45
- //# sourceMappingURL=host.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../host.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,aAAa,EAElB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EAGvB,KAAK,4BAA4B,EAClC,MAAM,kBAAkB,CAAC;AAI1B,cAAM,OAAQ,YAAW,4BAA4B;IAMjD,OAAO,CAAC,OAAO;IALjB,OAAO,CAAC,MAAM,CAAU;IACxB,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,KAAK,CAAuB;gBAG1B,OAAO,EAAE,aAAa,EAC9B,SAAS,EAAE,OAAO,CAAC,eAAe,CAAC;IASrC,aAAa,CAAC,OAAO,EAAE,kBAAkB,EAAE,GAAG,IAAI;IAI5C,IAAI,CACR,gBAAgB,EAAE,CAAC,EACjB,KAAK,EACL,IAAI,GACL,EAAE;QACD,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,IAAI,GACT,OAAO,CAAC,IAAI,CAAC;YAIF,eAAe;IAS7B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAU1B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAuB1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAM7B;;OAEG;YACW,qBAAqB;IAWnC;;OAEG;IACH,OAAO,CAAC,iBAAiB;YAwCX,aAAa;YAoDb,WAAW;YAiEX,WAAW;IAgEzB,OAAO,CAAC,WAAW;IAoEnB,OAAO,CAAC,gBAAgB;YA4BV,UAAU;YA+CV,SAAS;IAevB,OAAO,CAAC,cAAc;CAGvB;AAED,eAAO,MAAM,cAAc,YACf,aAAa,aAAa,OAAO,CAAC,eAAe,CAAC,kBAE3D,CAAC"}
@@ -1,3 +0,0 @@
1
- import { type DBAdapterFactory } from "@arcote.tech/arc";
2
- export declare const postgreSQLAdapterFactory: (connectionString: string) => DBAdapterFactory;
3
- //# sourceMappingURL=postgresAdapter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"postgresAdapter.d.ts","sourceRoot":"","sources":["../postgresAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,kBAAkB,CAAC;AAqC1B,eAAO,MAAM,wBAAwB,qBACjB,MAAM,KACvB,gBAKF,CAAC"}