cogsbox-sync 0.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.
@@ -0,0 +1,1860 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { sign, verify } from '@tsndr/cloudflare-worker-jwt';
3
+ import { UserPresenceDO } from './UserObject';
4
+ import { Container } from '@cloudflare/containers';
5
+ import { buildShadowNode } from './utility';
6
+ import { AuthService, AuthError } from './auth';
7
+ // These types should match the ones in cogsbox-shape for clarity
8
+ type SerializableFieldMetadata = {
9
+ type: 'field';
10
+ sql: any; // The SQLType object
11
+ };
12
+ type SerializableRelationMetadata = {
13
+ type: 'relation';
14
+ relationType: 'hasMany' | 'hasOne' | 'manyToMany';
15
+ fromKey: string;
16
+ toKey: string;
17
+ schema: SerializableSchemaMetadata;
18
+ };
19
+ type SerializableSchemaMetadata = {
20
+ _tableName: string;
21
+ primaryKey: string | null;
22
+ fields: Record<string, SerializableFieldMetadata>;
23
+ relations: Record<string, SerializableRelationMetadata>;
24
+ };
25
+
26
+ type DataRoutes = {
27
+ fetch: string;
28
+ update?: string;
29
+ log?: string;
30
+ };
31
+
32
+ type SyncTokenPayload = {
33
+ clientId: string;
34
+ serviceId: number;
35
+ boundaryId: string;
36
+ scopes: string[];
37
+ exp: number;
38
+ iat: number;
39
+ dataRoutes?: DataRoutes;
40
+ appApiKey: string;
41
+ tenantId: number;
42
+ context: Record<string, any>;
43
+ metadataTemplate?: Record<string, any>;
44
+ };
45
+
46
+ type AuthResult = {
47
+ success: boolean;
48
+ valid: boolean;
49
+ tenantId: number;
50
+ serviceId: number;
51
+ scopes: string[];
52
+ };
53
+
54
+ export class SchemaProcessorContainer extends Container<Env> {
55
+ defaultPort = 8080;
56
+ sleepAfter = '30m';
57
+
58
+ async fetch(request: Request): Promise<Response> {
59
+ const url = new URL(request.url);
60
+ let requestToForward = request;
61
+ if (url.pathname === '/process-notification') {
62
+ const tenantId = url.searchParams.get('tenantId');
63
+ if (!tenantId) return new Response('Missing tenantId', { status: 400 });
64
+
65
+ const bundlePath = `schemas/tenant-${tenantId}/bundle.js`;
66
+ const r2Object = await this.env.SCHEMA_STORAGE.get(bundlePath);
67
+ if (!r2Object) return new Response('Bundle not found', { status: 404 });
68
+ const bundledCode = await r2Object.text();
69
+
70
+ const requestBody = (await request.json()) as Record<string, any>;
71
+
72
+ // Create a new request object with the bundled code injected into the body.
73
+ requestToForward = new Request(request.url, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ bundledCode, ...requestBody }),
77
+ });
78
+ }
79
+
80
+ // STEP 2: UNIFIED ROUTER for all allowed paths.
81
+ // It will forward either the original request or the modified one.
82
+ if (
83
+ url.pathname.startsWith('/health') ||
84
+ url.pathname.startsWith('/load-schema') ||
85
+ url.pathname.startsWith('/validate') ||
86
+ url.pathname.startsWith('/process-notification') ||
87
+ url.pathname.startsWith('/get-api-routes')
88
+ ) {
89
+ // Always forward the final `requestToForward` object.
90
+ return this.containerFetch(requestToForward);
91
+ }
92
+
93
+ // Return 404 for unknown paths instead of infinite forwarding
94
+ return new Response('Not Found', { status: 404 });
95
+ }
96
+ }
97
+ // Sync Engine Shadow Types
98
+ export type ValidationStatus = 'NOT_VALIDATED' | 'VALIDATING' | 'VALID' | 'INVALID';
99
+ export type ValidationError = {
100
+ path: (string | number)[];
101
+ message: string;
102
+ code?: string;
103
+ };
104
+
105
+ export type ValidationState = {
106
+ status: ValidationStatus;
107
+ errors: ValidationError[];
108
+ lastValidated?: number;
109
+ validatedValue?: any;
110
+ };
111
+
112
+ export type TypeInfo = {
113
+ type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'date' | 'unknown';
114
+ schema: any; // The Zod schema object
115
+ source: 'sync' | 'zod' | 'runtime' | 'default';
116
+ default: any;
117
+ nullable?: boolean;
118
+ optional?: boolean;
119
+ };
120
+
121
+ export type SyncEngineShadowMetadata = {
122
+ arrayKeys?: string[];
123
+ value?: any;
124
+ isDirty?: boolean;
125
+ lastSyncTimestamp?: number;
126
+ version?: number;
127
+
128
+ typeInfo?: TypeInfo;
129
+ validation?: ValidationState;
130
+ };
131
+ export type SyncEngineShadowNode = {
132
+ _meta?: SyncEngineShadowMetadata;
133
+ [key: string]: SyncEngineShadowNode | SyncEngineShadowMetadata | undefined;
134
+ };
135
+
136
+ type VersionRecord = {
137
+ value: number;
138
+ };
139
+
140
+ type MetadataTemplate = Record<string, any>;
141
+
142
+ class WebSocketSyncEngine extends DurableObject<Env, unknown> {
143
+ private readonly DEBOUNCE_DELAY = 2000; // 2 seconds
144
+ private readonly MAX_SAVE_INTERVAL = 10000; // 10 seconds
145
+ private syncKey: string = '';
146
+ private expectedBoundaryId: string = '';
147
+ private schemaKey: string = '';
148
+ private stateId: string = '';
149
+ private params: Record<string, any> | undefined;
150
+ private instanceId: string = '';
151
+ private operationCounter: number = 0;
152
+ private clientActivities: Map<string, any> = new Map();
153
+
154
+ constructor(
155
+ readonly ctx: DurableObjectState,
156
+ readonly env: Env,
157
+ ) {
158
+ super(ctx, env);
159
+
160
+ this.ctx.blockConcurrencyWhile(async () => {
161
+ this.syncKey = (await this.ctx.storage.get<string>('syncKey')) || '';
162
+ this.expectedBoundaryId = (await this.ctx.storage.get<string>('boundaryId')) || '';
163
+
164
+ const storedData = await this.ctx.storage.get(['instanceId', 'operationCounter']);
165
+ this.instanceId = (storedData.get('instanceId') as string) || `inst_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
166
+ this.operationCounter = (storedData.get('operationCounter') as number) || 0;
167
+
168
+ if (!storedData.get('instanceId')) {
169
+ await this.ctx.storage.put({ instanceId: this.instanceId, operationCounter: 0 });
170
+ console.log(`[DO] New instance created: ${this.instanceId}`);
171
+ }
172
+
173
+ if (this.syncKey) {
174
+ this.parseSyncKey();
175
+ }
176
+ });
177
+ }
178
+ async alarm() {
179
+ await this.processUpdateQueue();
180
+ this.rebuildCachedState();
181
+ }
182
+ private async ensureContainerReady(): Promise<boolean> {
183
+ try {
184
+ const tenantId = await this.ctx.storage.get<number>('tenantId');
185
+ if (!tenantId) {
186
+ console.log('[DO] No tenantId found, skipping container readiness check.');
187
+ return false;
188
+ }
189
+
190
+ const containerStub = this.env.SCHEMA_PROCESSOR.get(this.env.SCHEMA_PROCESSOR.idFromName(`tenant:${tenantId}`));
191
+
192
+ // 1. CHECK if the container has the schema cached already.
193
+ const checkRequest = new Request(`https://container/load-schema?tenantId=${tenantId}`);
194
+ const checkResponse = await this.fetchWithReadyCheck(containerStub, checkRequest);
195
+
196
+ if (checkResponse.ok) {
197
+ const { hasSchema } = await checkResponse.json<{ hasSchema: boolean }>();
198
+ if (hasSchema) {
199
+ console.log(`[DO] Container for tenant ${tenantId} confirmed schema is ready.`);
200
+ return true; // It's ready, we're done.
201
+ }
202
+ }
203
+
204
+ // 2. If not, LOAD it from R2 and send it to the container.
205
+ console.log(`[DO] Schema not cached in container for tenant ${tenantId}. Loading from R2...`);
206
+ const bundlePath = `schemas/tenant-${tenantId}/bundle.js`;
207
+ const r2Object = await this.env.SCHEMA_STORAGE.get(bundlePath);
208
+ if (!r2Object) {
209
+ console.error(`[DO] CRITICAL: Schema bundle not found in R2 at ${bundlePath}`);
210
+ return false;
211
+ }
212
+ const bundledCode = await r2Object.text();
213
+
214
+ // 3. Use the POST /load-schema endpoint you already built.
215
+ const loadRequest = new Request(`https://container/load-schema?tenantId=${tenantId}`, {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ bundledCode }),
219
+ });
220
+
221
+ const loadResponse = await this.fetchWithReadyCheck(containerStub, loadRequest);
222
+ if (loadResponse.ok) {
223
+ console.log(`[DO] Successfully sent schema to container for tenant ${tenantId}.`);
224
+ return true;
225
+ } else {
226
+ console.error(`[DO] Failed to load schema into container for tenant ${tenantId}.`);
227
+ return false;
228
+ }
229
+ } catch (error) {
230
+ console.error('[DO] The ensureContainerReady check failed:', error);
231
+ return false;
232
+ }
233
+ }
234
+
235
+ private async rebuildCachedState(): Promise<void> {
236
+ try {
237
+ const { state: freshState, shadow: freshShadow } = await this.reconstructObjectFromStorage(this.ctx.storage, 'shadow');
238
+ if (freshState) {
239
+ const masterVersion = await this.ctx.storage.get<VersionRecord>('version');
240
+ await this.ctx.storage.put({
241
+ state_cache: freshState,
242
+ cache_version: masterVersion,
243
+ });
244
+ }
245
+ } catch (error) {
246
+ console.error('[DO Alarm] Failed to rebuild state_cache:', error);
247
+ }
248
+ }
249
+ private parseSyncKey() {
250
+ const bracketStart = this.syncKey.indexOf('[');
251
+ const bracketEnd = this.syncKey.indexOf(']');
252
+
253
+ if (bracketStart > -1 && bracketEnd > bracketStart) {
254
+ this.schemaKey = this.syncKey.substring(0, bracketStart);
255
+ const paramsString = this.syncKey.substring(bracketStart + 1, bracketEnd);
256
+
257
+ // Parse "userId:1" into {userId: 1}
258
+ const [key, value] = paramsString.split(':');
259
+ this.params = { [key]: isNaN(Number(value)) ? value : Number(value) };
260
+ } else {
261
+ this.schemaKey = this.syncKey.split('::')[0];
262
+ this.params = undefined;
263
+ }
264
+
265
+ const stateIdStart = this.syncKey.indexOf('::');
266
+ this.stateId = stateIdStart > -1 ? this.syncKey.substring(stateIdStart + 2) : '';
267
+ }
268
+ private async sendCurrentState(ws: WebSocket) {
269
+ // 1. Get the most up-to-date state by rebuilding it from storage.
270
+ const { shadow: currentShadow } = await this.reconstructObjectFromStorage(this.ctx.storage, 'shadow');
271
+
272
+ if (currentShadow) {
273
+ // 2. Build the message with the correct, full version string.
274
+ const message = JSON.stringify({
275
+ type: 'updateInitialState',
276
+ syncKey: this.syncKey,
277
+ data: currentShadow,
278
+ version: `${this.instanceId}:${this.operationCounter}`,
279
+ });
280
+
281
+ // 3. Send it.
282
+ ws.send(message);
283
+ } else {
284
+ // If there's no state at all, tell the client to provide it (for a "cold start").
285
+ // This handles the case where the DO is totally empty.
286
+ ws.send(JSON.stringify({ type: 'requestInitialState', syncKey: this.syncKey }));
287
+ }
288
+ }
289
+ async fetch(request: Request) {
290
+ const url = new URL(request.url);
291
+ const sessionId = url.searchParams.get('sessionId');
292
+ if (!sessionId) {
293
+ return new Response('Missing sessionId', { status: 400 });
294
+ }
295
+ const pathParts = url.pathname.split('/');
296
+ let pathSyncKey = '';
297
+ if (pathParts.length >= 3 && pathParts[1] === 'sync') {
298
+ pathSyncKey = pathParts[2];
299
+ }
300
+
301
+ if (!this.syncKey && pathSyncKey) {
302
+ this.syncKey = pathSyncKey;
303
+ await this.ctx.storage.put('syncKey', pathSyncKey);
304
+ this.parseSyncKey();
305
+ }
306
+
307
+ if (request.headers.get('Upgrade') !== 'websocket') {
308
+ return new Response('Expected WebSocket', { status: 400 });
309
+ }
310
+
311
+ const token = url.searchParams.get('token');
312
+ if (!token) {
313
+ return new Response('Missing token', { status: 401 });
314
+ }
315
+
316
+ let payload: SyncTokenPayload;
317
+ let isExpired = false;
318
+ try {
319
+ const isValid = await verify(token, this.env.JWT_SECRET);
320
+ if (!isValid) throw new Error('Invalid token');
321
+
322
+ payload = JSON.parse(atob(token.split('.')[1]));
323
+ isExpired = payload.exp < Math.floor(Date.now() / 1000);
324
+
325
+ const storedtenantId = await this.ctx.storage.get<number>('tenantId');
326
+ if (!storedtenantId) {
327
+ if (!payload.tenantId) {
328
+ throw new Error("JWT is missing required 'tenantId' claim.");
329
+ }
330
+ await this.ctx.storage.put('tenantId', payload.tenantId);
331
+ }
332
+
333
+ if (payload.context) {
334
+ await this.ctx.storage.put('clientContext', payload.context);
335
+ }
336
+ const appApiKey = payload.appApiKey;
337
+ if (!appApiKey) {
338
+ throw new Error('appApiKey not found in token payload. The /sync-token endpoint might be misconfigured.');
339
+ }
340
+
341
+ const existingKey = await this.ctx.storage.get<string>('app_api_key');
342
+ if (!existingKey) {
343
+ await this.ctx.storage.put('app_api_key', appApiKey);
344
+ }
345
+ } catch (error) {
346
+ return new Response('Invalid token', { status: 401 });
347
+ }
348
+
349
+ if (!this.expectedBoundaryId) {
350
+ this.expectedBoundaryId = payload.boundaryId;
351
+ await this.ctx.storage.put('boundaryId', payload.boundaryId);
352
+ } else if (this.expectedBoundaryId !== payload.boundaryId) {
353
+ console.error(`SECURITY VIOLATION: Boundary mismatch. Expected: ${this.expectedBoundaryId}, Got: ${payload.boundaryId}`);
354
+ return new Response('Access denied: boundary violation', { status: 403 });
355
+ }
356
+ this.ensureContainerReady();
357
+ const pair = new WebSocketPair();
358
+ const [client, server] = Object.values(pair);
359
+
360
+ this.ctx.acceptWebSocket(server);
361
+ if (payload.metadataTemplate) {
362
+ const existingTemplate = await this.ctx.storage.get<MetadataTemplate>('metadataTemplate');
363
+ if (!existingTemplate) {
364
+ await this.ctx.storage.put('metadataTemplate', payload.metadataTemplate);
365
+ }
366
+ }
367
+ server.serializeAttachment({
368
+ serviceId: payload.serviceId,
369
+ clientId: payload.clientId,
370
+ sessionId: sessionId,
371
+ syncKey: this.syncKey,
372
+ boundaryId: payload.boundaryId,
373
+ context: payload.context,
374
+ });
375
+
376
+ if (isExpired) {
377
+ server.send(
378
+ JSON.stringify({
379
+ type: 'refreshToken',
380
+ syncKey: this.syncKey,
381
+ }),
382
+ );
383
+ }
384
+
385
+ this.broadcastSubscribers();
386
+
387
+ return new Response(null, { status: 101, webSocket: client });
388
+ }
389
+
390
+ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
391
+ const attachment = ws.deserializeAttachment();
392
+ const data = typeof message === 'string' ? JSON.parse(message) : JSON.parse(new TextDecoder().decode(message));
393
+ const messageId = Math.random().toString(36).substring(7);
394
+ console.log(`[MSG-${messageId}] Received message:`, message);
395
+ switch (data.type) {
396
+ case 'initialSend':
397
+ await this.handleInitialSetup(ws, data);
398
+ break;
399
+
400
+ case 'providingInitialState':
401
+ const currentState = await this.ctx.storage.get('state');
402
+ console.log('currentState', currentState);
403
+ if (!currentState) {
404
+ await this.ctx.storage.put('state', data.state);
405
+ // Broadcast the newly adopted state to everyone
406
+ const updateMessage = JSON.stringify({
407
+ type: 'updateInitialState',
408
+ syncKey: this.syncKey,
409
+ data: data.state,
410
+ });
411
+ this.ctx.getWebSockets().forEach((socket) => socket.send(updateMessage));
412
+ }
413
+ break;
414
+
415
+ case 'updateContext':
416
+ await this.ctx.storage.put('clientContext', data.ctx);
417
+ // You might want to broadcast this change to other clients if needed
418
+ break;
419
+ case 'requestState': {
420
+ await this.sendCurrentState(ws);
421
+ break;
422
+ }
423
+ case 'initialSyncState':
424
+ if (data.data && !data.data.error) {
425
+ await this.ctx.storage.put('state', data.data);
426
+ ws.send(
427
+ JSON.stringify({
428
+ type: 'syncReady',
429
+ syncKey: this.syncKey, // Normal sync key
430
+ }),
431
+ );
432
+ }
433
+ break;
434
+
435
+ case 'clientStatus':
436
+ this.handleClientActivity(ws, data.status);
437
+ break;
438
+
439
+ case 'queueUpdate':
440
+ console.log(`[MSG-${messageId}] Processing queueUpdate`);
441
+ await this.handleStateUpdate(ws, data.data, attachment);
442
+ console.log(`[MSG-${messageId}] Completed queueUpdate`);
443
+ break;
444
+
445
+ case 'getSubscribers':
446
+ this.sendSubscribers(ws);
447
+ break;
448
+
449
+ case 'clearStorage':
450
+ await this.ctx.storage.delete('state');
451
+ await this.ctx.storage.delete('schema');
452
+ ws.send(
453
+ JSON.stringify({
454
+ type: 'storageCleared',
455
+ syncKey: this.syncKey, // Normal sync key
456
+ }),
457
+ );
458
+ break;
459
+ }
460
+ }
461
+ private async evaluateMetadataTemplate(
462
+ template: MetadataTemplate,
463
+ receivingClientContext: any,
464
+ updatingClientContext: any,
465
+ ): Promise<Record<string, any>> {
466
+ const result: Record<string, any> = {};
467
+
468
+ for (const [key, value] of Object.entries(template)) {
469
+ if (typeof value === 'string' && value.startsWith('__ctx_') && value.endsWith('__')) {
470
+ // Extract the field name from the marker
471
+ const fieldName = value.slice(6, -2); // Remove __ctx_ prefix and __ suffix
472
+
473
+ // Get the value from the updating client's context
474
+ result[key] = updatingClientContext[fieldName];
475
+ } else if (typeof value === 'string' && value.startsWith('__currentCtx_') && value.endsWith('__')) {
476
+ // Handle references to the receiving/current client's context
477
+ const fieldName = value.slice(13, -2); // Remove __currentCtx_ prefix and __ suffix
478
+
479
+ // Get the value from the receiving client's context
480
+ result[key] = receivingClientContext[fieldName];
481
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
482
+ // Recursively handle nested objects
483
+ result[key] = await this.evaluateMetadataTemplate(value, receivingClientContext, updatingClientContext);
484
+ } else {
485
+ // IMPORTANT: It's a literal value - preserve it exactly as is!
486
+ // This includes strings, numbers, booleans, arrays, null, etc.
487
+ result[key] = value;
488
+ }
489
+ }
490
+
491
+ return result;
492
+ }
493
+ private async getFilteredMetadata(receivingClientAttachment: any, updatingClientContext: any): Promise<Record<string, any> | null> {
494
+ try {
495
+ // Get the metadata template stored during token creation
496
+ const metadataTemplate = await this.ctx.storage.get<MetadataTemplate>('metadataTemplate');
497
+
498
+ if (!metadataTemplate) {
499
+ // No metadata template defined for this sync schema
500
+ return null;
501
+ }
502
+
503
+ // Get the receiving client's context from their attachment or stored data
504
+ const receivingClientContext =
505
+ receivingClientAttachment?.context || (await this.ctx.storage.get<any>(`clientContext:${receivingClientAttachment?.clientId}`));
506
+
507
+ if (!receivingClientContext) {
508
+ console.warn(`[DO] No context found for receiving client ${receivingClientAttachment?.clientId}`);
509
+ return null;
510
+ }
511
+
512
+ // Evaluate the template with both contexts
513
+ const filteredMetadata = await this.evaluateMetadataTemplate(metadataTemplate, receivingClientContext, updatingClientContext);
514
+
515
+ return filteredMetadata;
516
+ } catch (error) {
517
+ console.error('[DO] Error filtering metadata:', error);
518
+ return null;
519
+ }
520
+ }
521
+ async webSocketClose(ws: WebSocket) {
522
+ this.broadcastSubscribers(ws);
523
+ const attachment = ws.deserializeAttachment();
524
+ if (attachment && attachment.sessionId) {
525
+ this.clientActivities.delete(attachment.sessionId);
526
+
527
+ // Optional: Broadcast that the user has left
528
+ const clientContext = await this.ctx.storage.get<any>('clientContext');
529
+
530
+ // Broadcast that the user has left WITH METADATA
531
+ const leftMessage = JSON.stringify({
532
+ type: 'clientActivity',
533
+ syncKey: this.syncKey,
534
+ activity: {
535
+ clientId: attachment.clientId,
536
+ event: { type: 'disconnect' },
537
+ metaData: clientContext || {}, // ADD METADATA HERE
538
+ },
539
+ });
540
+ this.ctx.getWebSockets().forEach((socket) => socket.send(leftMessage));
541
+ }
542
+ }
543
+ private async getApiRoutes(params?: Record<string, any>): Promise<{
544
+ queryData?: { url: string; method: 'GET' | 'POST'; data?: any };
545
+ mutateData?: { url: string; method: 'GET' | 'POST'; data?: any };
546
+ } | null> {
547
+ const tenantId = await this.ctx.storage.get<number>('tenantId');
548
+ const schemaKey = this.schemaKey;
549
+
550
+ if (!params) {
551
+ try {
552
+ const routesPath = `schemas/tenant-${tenantId}/routes/${schemaKey}.json`;
553
+ const r2Routes = await this.env.SCHEMA_STORAGE.get(routesPath);
554
+
555
+ if (r2Routes) {
556
+ return await r2Routes.json();
557
+ }
558
+ } catch (error) {
559
+ console.error('[DO] Failed to get routes from R2:', error);
560
+ }
561
+ }
562
+
563
+ try {
564
+ const bundlePath = `schemas/tenant-${tenantId}/bundle.js`;
565
+ const r2Object = await this.env.SCHEMA_STORAGE.get(bundlePath);
566
+ if (!r2Object) {
567
+ console.error(`[DO] Schema bundle not found in R2 at ${bundlePath} while trying to get API routes.`);
568
+ return null;
569
+ }
570
+ const bundledCode = await r2Object.text();
571
+
572
+ const containerStub = this.env.SCHEMA_PROCESSOR.get(this.env.SCHEMA_PROCESSOR.idFromName(`tenant:${tenantId}`));
573
+
574
+ const request = new Request(`https://container/get-api-routes`, {
575
+ method: 'POST',
576
+ headers: { 'Content-Type': 'application/json' },
577
+ body: JSON.stringify({
578
+ tenantId,
579
+ schemaKey,
580
+ bundledCode,
581
+ params,
582
+ }),
583
+ });
584
+
585
+ const response = await this.fetchWithReadyCheck(containerStub, request);
586
+ console.log('response', response);
587
+ if (!response.ok) {
588
+ const errorText = await response.text();
589
+ console.error(`[DO] Failed to get API routes from container for ${schemaKey}: ${response.status} - ${errorText}`);
590
+ return null;
591
+ }
592
+
593
+ const apiRoutes = await response.json<{
594
+ queryData?: { url: string; method: 'GET' | 'POST'; data?: any };
595
+ mutateData?: { url: string; method: 'GET' | 'POST'; data?: any };
596
+ }>();
597
+
598
+ if (!params) {
599
+ try {
600
+ await this.env.SCHEMA_STORAGE.put(`schemas/tenant-${tenantId}/routes/${schemaKey}.json`, JSON.stringify(apiRoutes), {
601
+ httpMetadata: { contentType: 'application/json' },
602
+ });
603
+ } catch (error) {
604
+ console.error('[DO] Failed to cache routes in R2:', error);
605
+ }
606
+ }
607
+
608
+ return apiRoutes;
609
+ } catch (error) {
610
+ console.error('[DO] Error fetching API routes from container:', error);
611
+ return null;
612
+ }
613
+ }
614
+ private async handleInitialSetup(ws: WebSocket, data: any) {
615
+ try {
616
+ const { isArray, inMemoryState } = data;
617
+ await this.ctx.storage.put('isArray', isArray);
618
+ if (inMemoryState) {
619
+ await this.ctx.storage.put('inMemoryState', true);
620
+ }
621
+ if (!inMemoryState) {
622
+ this.ensureContainerReady(); // Fire and forget
623
+ }
624
+
625
+ const isInitialized = await this.ctx.storage.get('shadow');
626
+
627
+ if (isInitialized) {
628
+ await this.sendCurrentState(ws);
629
+ return;
630
+ }
631
+
632
+ if (inMemoryState) {
633
+ ws.send(JSON.stringify({ type: 'requestInitialState', syncKey: this.syncKey }));
634
+ } else {
635
+ await this.fetchInitialState(ws);
636
+ }
637
+ } catch (error: any) {
638
+ console.error(`[DO] Initial setup failed for syncKey ${this.syncKey}:`, error);
639
+ ws.close(1011, 'Initial setup failed');
640
+ }
641
+ }
642
+ private async fetchWithReadyCheck(
643
+ containerStub: DurableObjectStub,
644
+ request: Request, // The actual request we WANT to send
645
+ options: { retries?: number; delay?: number } = {},
646
+ ): Promise<Response> {
647
+ const { retries = 5, delay = 400 } = options;
648
+
649
+ for (let i = 0; i < retries; i++) {
650
+ try {
651
+ if (i > 0) {
652
+ // Only log retries
653
+ }
654
+ return await containerStub.fetch(request.clone());
655
+ } catch (error: any) {
656
+ if (error.message.includes('Connection refused') || error.message.includes('container port not found')) {
657
+ await new Promise((resolve) => setTimeout(resolve, delay));
658
+ } else {
659
+ console.error(`[DO] Unrecoverable fetch error:`, error);
660
+ throw error;
661
+ }
662
+ }
663
+ }
664
+
665
+ throw new Error(`Container did not respond after ${retries} attempts.`);
666
+ }
667
+ private async handleClientActivity(ws: WebSocket, status: any) {
668
+ const attachment = ws.deserializeAttachment();
669
+ if (!attachment || !attachment.clientId || !attachment.sessionId) return;
670
+
671
+ // 1. Store the latest activity for this client
672
+ this.clientActivities.set(attachment.sessionId, {
673
+ clientId: attachment.clientId,
674
+ event: status,
675
+ });
676
+ const clientContext = await this.ctx.storage.get<any>('clientContext');
677
+
678
+ // 3. Prepare the broadcast message WITH METADATA
679
+ const message = JSON.stringify({
680
+ type: 'clientActivity',
681
+ syncKey: this.syncKey,
682
+ activity: {
683
+ clientId: attachment.clientId,
684
+ event: status.event,
685
+ metaData: clientContext || {}, // ADD METADATA HERE
686
+ },
687
+ });
688
+
689
+ // 3. Broadcast to all OTHER clients
690
+ this.ctx.getWebSockets().forEach((socket) => {
691
+ const socketAttachment = socket.deserializeAttachment();
692
+ if (socketAttachment?.sessionId !== attachment.sessionId) {
693
+ try {
694
+ socket.send(message);
695
+ } catch (e) {
696
+ console.error('[DO] Failed to send activity to socket:', e);
697
+ }
698
+ }
699
+ });
700
+ }
701
+ private async processUpdateQueue() {
702
+ const isInMemory = await this.ctx.storage.get<boolean>('inMemoryState');
703
+ if (isInMemory) {
704
+ await this.ctx.storage.delete('firstDirtyTimestamp');
705
+ await this.ctx.storage.deleteAlarm();
706
+ return;
707
+ }
708
+
709
+ await this.ctx.storage.transaction(async (txn) => {
710
+ const firstDirtyTimestamp = await txn.get<number>('firstDirtyTimestamp');
711
+ if (!firstDirtyTimestamp) {
712
+ return;
713
+ }
714
+
715
+ await txn.delete('firstDirtyTimestamp');
716
+
717
+ const stateToPersist = await txn.get<any>('state');
718
+ if (stateToPersist === undefined || stateToPersist === null) {
719
+ return;
720
+ }
721
+
722
+ try {
723
+ // Get the API routes configuration
724
+
725
+ const apiRoutes = await this.getApiRoutes(this.params);
726
+ const mutateConfig = apiRoutes?.mutateData;
727
+
728
+ if (!mutateConfig || !mutateConfig.url) {
729
+ console.warn(`[DO] Persistence skipped: 'mutateData' configuration not found for syncKey ${this.syncKey}.`);
730
+ await txn.put('firstDirtyTimestamp', firstDirtyTimestamp);
731
+ return;
732
+ }
733
+
734
+ const authToken = await this.getAuthToken();
735
+ let response: Response;
736
+
737
+ if (mutateConfig.method === 'GET') {
738
+ // GET request for mutations (unusual but supported)
739
+ const finalUrl = new URL(mutateConfig.url);
740
+ finalUrl.searchParams.append('action', `${this.schemaKey}.update`); // Use this.schemaKey
741
+ finalUrl.searchParams.append('stateId', this.stateId); // Use this.stateId
742
+
743
+ response = await fetch(finalUrl.href, {
744
+ method: 'GET',
745
+ headers: {
746
+ 'Content-Type': 'application/json',
747
+ Authorization: `Bearer ${authToken}`,
748
+ },
749
+ });
750
+ } else {
751
+ // POST request for mutations (standard)
752
+ const bodyData = {
753
+ action: `${this.schemaKey}.update`, // Use this.schemaKey
754
+ stateId: this.stateId, // Use this.stateId
755
+ payload: stateToPersist,
756
+ ...(mutateConfig.data || {}),
757
+ };
758
+
759
+ response = await fetch(mutateConfig.url, {
760
+ method: 'POST',
761
+ headers: {
762
+ 'Content-Type': 'application/json',
763
+ Authorization: `Bearer ${authToken}`,
764
+ },
765
+ body: JSON.stringify(bodyData),
766
+ });
767
+ }
768
+
769
+ if (!response.ok) {
770
+ const errorText = await response.text();
771
+ throw new Error(`DB sync failed: ${response.status} ${errorText}`);
772
+ }
773
+ } catch (error) {
774
+ console.error('Processing update queue failed, will retry:', error);
775
+ await txn.put('firstDirtyTimestamp', firstDirtyTimestamp);
776
+ const currentAlarm = await this.ctx.storage.getAlarm();
777
+ if (!currentAlarm || currentAlarm < Date.now()) {
778
+ await this.ctx.storage.setAlarm(Date.now() + this.DEBOUNCE_DELAY);
779
+ }
780
+ }
781
+ });
782
+ }
783
+
784
+ private async checkNotifications(operation: any, fullState: any) {
785
+ const stateKey = this.schemaKey;
786
+ const tenantId = await this.ctx.storage.get<number>('tenantId');
787
+ const boundaryId = await this.ctx.storage.get<string>('boundaryId');
788
+
789
+ if (!tenantId || !boundaryId) return;
790
+
791
+ const allUserContexts = this.ctx
792
+ .getWebSockets()
793
+ .map((ws) => {
794
+ const attachment = ws.deserializeAttachment();
795
+ return {
796
+ userId: attachment?.clientId,
797
+ tenantId,
798
+ boundaryId,
799
+ };
800
+ })
801
+ .filter((ctx) => ctx.userId);
802
+
803
+ if (allUserContexts.length === 0) {
804
+ return;
805
+ }
806
+
807
+ try {
808
+ const bundlePath = `schemas/tenant-${tenantId}/bundle.js`;
809
+ const r2Object = await this.env.SCHEMA_STORAGE.get(bundlePath);
810
+ if (!r2Object) {
811
+ console.error(`[DO] Schema bundle not found for notifications at ${bundlePath}`);
812
+ return;
813
+ }
814
+ const bundledCode = await r2Object.text();
815
+
816
+ const containerStub = this.env.SCHEMA_PROCESSOR.get(this.env.SCHEMA_PROCESSOR.idFromName(`tenant:${tenantId}`));
817
+
818
+ const requestUrl = `https://container/process-notifications-batch?tenantId=${tenantId}`;
819
+
820
+ const response = await this.fetchWithReadyCheck(
821
+ containerStub,
822
+ new Request(requestUrl, {
823
+ method: 'POST',
824
+ headers: { 'Content-Type': 'application/json' },
825
+ body: JSON.stringify({
826
+ bundledCode,
827
+ fullState: { [stateKey]: fullState },
828
+ operation,
829
+ contexts: allUserContexts,
830
+ }),
831
+ }),
832
+ );
833
+
834
+ if (!response.ok) {
835
+ const errorBody = await response.text();
836
+ console.error(`[DO] Batch notification processing failed with status: ${response.status}. Body: ${errorBody}`);
837
+ return;
838
+ }
839
+
840
+ const notificationsByUserId = await response.json<Record<string, any[]>>();
841
+
842
+ for (const userId in notificationsByUserId) {
843
+ const notifications = notificationsByUserId[userId];
844
+ if (notifications && notifications.length > 0) {
845
+ await this.sendNotificationToUser(userId, notifications, boundaryId);
846
+ }
847
+ }
848
+ } catch (error) {
849
+ console.error(`[DO] Failed to process notification batch:`, error);
850
+ }
851
+ }
852
+
853
+ private async sendNotificationToUser(userId: string, notifications: any[], boundaryId: string) {
854
+ try {
855
+ if (!notifications || notifications.length === 0) {
856
+ return;
857
+ }
858
+
859
+ const doName = `${boundaryId}:user:${userId}`;
860
+ const id = this.env.USER_PRESENCE.idFromName(doName);
861
+ const stub = this.env.USER_PRESENCE.get(id);
862
+
863
+ const batchMessage = {
864
+ type: 'notifications_batch',
865
+ payload: notifications,
866
+ timestamp: Date.now(),
867
+ };
868
+
869
+ console.log(`[WebSocketSyncEngine] Forwarding EFFICIENT batch notification:`, JSON.stringify(batchMessage));
870
+
871
+ await stub.forwardNotification(batchMessage);
872
+ } catch (error) {
873
+ console.error(`[DO] Failed to send notification batch to user ${userId}:`, error);
874
+ }
875
+ }
876
+
877
+ private async resolvePathToKey(txn: DurableObjectTransaction, path: (string | number)[]): Promise<string> {
878
+ let currentKey = 'shadow';
879
+ for (const segment of path) {
880
+ if (typeof segment === 'number') {
881
+ const parentMeta = await txn.get<any>(currentKey);
882
+ if (!parentMeta?.arrayKeys || parentMeta.arrayKeys.length <= segment) {
883
+ throw new Error(`Could not resolve path. Invalid index ${segment} at key ${currentKey}`);
884
+ }
885
+ const canonicalId = parentMeta.arrayKeys[segment];
886
+ currentKey = `${currentKey}:${canonicalId}`;
887
+ } else {
888
+ currentKey = `${currentKey}:${segment}`;
889
+ }
890
+ }
891
+ return currentKey;
892
+ }
893
+
894
+ private async reconstructObjectFromStorage(
895
+ storage: DurableObjectStorage | DurableObjectTransaction,
896
+ startKey: string,
897
+ ): Promise<{ state: any; shadow: any }> {
898
+ const entries = await storage.list<any>({ prefix: `${startKey}:` });
899
+ const rootMeta = await storage.get<any>(startKey);
900
+
901
+ if (!rootMeta) return { state: undefined, shadow: undefined };
902
+
903
+ if (rootMeta.hasOwnProperty('value')) {
904
+ const shadow = { _meta: rootMeta };
905
+ return {
906
+ state: rootMeta.value,
907
+ shadow: shadow,
908
+ };
909
+ }
910
+
911
+ if (rootMeta.arrayKeys) {
912
+ const array: any[] = [];
913
+ const shadow: any = { _meta: rootMeta };
914
+
915
+ for (const itemKey of rootMeta.arrayKeys) {
916
+ const result = await this.reconstructObjectFromStorage(storage, `${startKey}:${itemKey}`);
917
+ array.push(result.state);
918
+ shadow[itemKey] = result.shadow;
919
+ }
920
+
921
+ return { state: array, shadow };
922
+ }
923
+
924
+ const obj: { [key: string]: any } = {};
925
+ const shadow: any = { _meta: rootMeta };
926
+ const keyPrefix = `${startKey}:`;
927
+
928
+ const childKeys = new Set<string>();
929
+ for (const key of entries.keys()) {
930
+ const propName = key.substring(keyPrefix.length).split(':')[0];
931
+ childKeys.add(propName);
932
+ }
933
+
934
+ for (const propName of childKeys) {
935
+ const result = await this.reconstructObjectFromStorage(storage, `${keyPrefix}${propName}`);
936
+ obj[propName] = result.state;
937
+ shadow[propName] = result.shadow;
938
+ }
939
+
940
+ return { state: obj, shadow };
941
+ }
942
+
943
+ private async handleStateUpdate(ws: WebSocket, updateData: any, attachment: any) {
944
+ const stateKey = this.schemaKey;
945
+ let validationErrors: { errors: any[]; sessionId: string | null } | null = null;
946
+ console.log('new updateData', updateData);
947
+ if (!updateData.operation) return;
948
+ await this.ctx.blockConcurrencyWhile(async () => {
949
+ await this.ctx.storage.transaction(async (txn) => {
950
+ try {
951
+ const { operation } = updateData;
952
+
953
+ const isInMemory = await txn.get<boolean>('inMemoryState');
954
+
955
+ console.log('new operation 22222');
956
+ const newVersion = Date.now();
957
+ await txn.put('version', { value: newVersion });
958
+
959
+ if (Array.isArray(operation)) {
960
+ for (const patch of operation) {
961
+ if (patch.op === 'replace') {
962
+ const pathSegments = patch.path
963
+ .split('/')
964
+ .slice(1)
965
+ .map((p: any) => (isNaN(Number(p)) ? p : Number(p)));
966
+ const targetKey = await this.resolvePathToKey(txn, pathSegments);
967
+ const oldMeta = (await txn.get<any>(targetKey)) || {};
968
+ await txn.put(targetKey, { ...oldMeta, value: patch.value, isDirty: true });
969
+ }
970
+ }
971
+ } else {
972
+ console.log('new operation 33333');
973
+
974
+ switch (operation.updateType) {
975
+ case 'update': {
976
+ const targetKey = await this.resolvePathToKey(txn, operation.path);
977
+ const oldMeta = (await txn.get<any>(targetKey)) || {};
978
+ await txn.put(targetKey, { ...oldMeta, value: operation.newValue, isDirty: true });
979
+ break;
980
+ }
981
+ case 'insert': {
982
+ const { itemId, insertAfterId, newValue } = operation;
983
+ console.log(`[DO] Insert operation: adding item ${itemId} to array at path [${operation.path.join(', ')}]`);
984
+ if (!itemId || !itemId.startsWith('id:')) {
985
+ throw new Error(`Invalid insert operation: missing or invalid item ID`);
986
+ }
987
+
988
+ let arrayKey: string;
989
+ if (operation.path.length === 0) {
990
+ arrayKey = 'shadow';
991
+ } else {
992
+ arrayKey = await this.resolvePathToKey(txn, operation.path);
993
+ }
994
+
995
+ let arrayMeta = await txn.get<any>(arrayKey);
996
+ if (!arrayMeta) {
997
+ arrayMeta = { arrayKeys: [] };
998
+ }
999
+ if (!arrayMeta.arrayKeys) {
1000
+ arrayMeta.arrayKeys = [];
1001
+ }
1002
+
1003
+ let insertIndex = 0;
1004
+ if (insertAfterId && arrayMeta.arrayKeys.includes(insertAfterId)) {
1005
+ insertIndex = arrayMeta.arrayKeys.indexOf(insertAfterId) + 1;
1006
+ } else if (insertAfterId === null) {
1007
+ insertIndex = 0;
1008
+ } else {
1009
+ insertIndex = arrayMeta.arrayKeys.length;
1010
+ }
1011
+
1012
+ arrayMeta.arrayKeys.splice(insertIndex, 0, itemId);
1013
+
1014
+ arrayMeta.isDirty = true;
1015
+ await txn.put(arrayKey, arrayMeta);
1016
+
1017
+ const newItemKey = `${arrayKey}:${itemId}`;
1018
+
1019
+ const storeNode = async (value: any, storageKey: string) => {
1020
+ if (value === null || value === undefined || typeof value !== 'object') {
1021
+ await txn.put(storageKey, { value, isDirty: true });
1022
+ return;
1023
+ }
1024
+
1025
+ if (Array.isArray(value)) {
1026
+ const subArrayKeys: string[] = [];
1027
+ for (let i = 0; i < value.length; i++) {
1028
+ const subItemId = `id:${this.instanceId}_${Math.random().toString(36).substring(2, 9)}`;
1029
+ subArrayKeys.push(subItemId);
1030
+ await storeNode(value[i], `${storageKey}:${subItemId}`);
1031
+ }
1032
+ await txn.put(storageKey, { arrayKeys: subArrayKeys, isDirty: true });
1033
+ } else {
1034
+ await txn.put(storageKey, { isDirty: true });
1035
+ for (const [key, val] of Object.entries(value)) {
1036
+ await storeNode(val, `${storageKey}:${key}`);
1037
+ }
1038
+ }
1039
+ };
1040
+
1041
+ await storeNode(operation.newValue, newItemKey);
1042
+
1043
+ console.log(
1044
+ `[DO] Insert operation: added item ${itemId} ${
1045
+ insertAfterId ? `after ${insertAfterId}` : 'at beginning'
1046
+ } to array at path [${operation.path.join(', ')}]`,
1047
+ );
1048
+ break;
1049
+ }
1050
+ case 'cut': {
1051
+ const itemIdToCut = operation.path[operation.path.length - 1];
1052
+ const arrayPath = operation.path.slice(0, -1);
1053
+
1054
+ if (!itemIdToCut || !itemIdToCut.startsWith('id:')) {
1055
+ throw new Error(`Invalid cut operation: missing or invalid item ID in path: ${operation.path.join('.')}`);
1056
+ }
1057
+
1058
+ let arrayKey: string;
1059
+ if (arrayPath.length === 0) {
1060
+ arrayKey = 'shadow';
1061
+ } else {
1062
+ arrayKey = await this.resolvePathToKey(txn, arrayPath);
1063
+ }
1064
+
1065
+ const arrayMeta = await txn.get<any>(arrayKey);
1066
+ console.log('arrayMeta', arrayMeta);
1067
+ if (!arrayMeta?.arrayKeys) {
1068
+ console.error(`[DO] Cut operation failed: array not found at path [${arrayPath.join(', ')}]`);
1069
+ return;
1070
+ }
1071
+
1072
+ const indexToRemove = arrayMeta.arrayKeys.indexOf(itemIdToCut);
1073
+ if (indexToRemove === -1) {
1074
+ console.error(`[DO] Cut operation failed: item ${itemIdToCut} not found in array at path [${arrayPath.join(', ')}]`);
1075
+ return;
1076
+ }
1077
+
1078
+ arrayMeta.arrayKeys.splice(indexToRemove, 1);
1079
+ arrayMeta.isDirty = true;
1080
+ await txn.put(arrayKey, arrayMeta);
1081
+
1082
+ const deleteRecursive = async (keyToDelete: string) => {
1083
+ const entries = await txn.list({ prefix: `${keyToDelete}:` });
1084
+ for (const key of entries.keys()) {
1085
+ await txn.delete(key);
1086
+ }
1087
+ await txn.delete(keyToDelete);
1088
+ };
1089
+
1090
+ const itemKey = `${arrayKey}:${itemIdToCut}`;
1091
+ await deleteRecursive(itemKey);
1092
+
1093
+ console.log(`[DO] Cut operation: removed item ${itemIdToCut} from array at path [${arrayPath.join(', ')}]`);
1094
+ break;
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ if (!Array.isArray(operation) && !isInMemory) {
1100
+ const { state: fullStateForNotifications, shadow: fullShadowForNotifications } = await this.reconstructObjectFromStorage(
1101
+ txn,
1102
+ 'shadow',
1103
+ );
1104
+ if (fullStateForNotifications) {
1105
+ await this.checkNotifications(operation, fullStateForNotifications);
1106
+ } else {
1107
+ console.warn(`[DO] Could not reconstruct state for notification check on syncKey: ${this.syncKey}`);
1108
+ }
1109
+ }
1110
+ if (!isInMemory) {
1111
+ const firstDirtyTimestamp = await txn.get<number>('firstDirtyTimestamp');
1112
+ if (!firstDirtyTimestamp) {
1113
+ await txn.put('firstDirtyTimestamp', Date.now());
1114
+ await this.ctx.storage.setAlarm(Date.now() + this.MAX_SAVE_INTERVAL);
1115
+ }
1116
+ const currentAlarm = await this.ctx.storage.getAlarm();
1117
+ const timeForDebounceAlarm = Date.now() + this.DEBOUNCE_DELAY;
1118
+ if (!currentAlarm || timeForDebounceAlarm < currentAlarm) {
1119
+ await this.ctx.storage.setAlarm(timeForDebounceAlarm);
1120
+ }
1121
+ }
1122
+
1123
+ const clientOperation = updateData.operation;
1124
+
1125
+ this.operationCounter++;
1126
+
1127
+ const allSockets = this.ctx.getWebSockets();
1128
+ const updatingClientAttachment = ws.deserializeAttachment();
1129
+ const updatingClientContext = updatingClientAttachment?.context;
1130
+
1131
+ for (const socket of allSockets) {
1132
+ try {
1133
+ const socketAttachment = socket.deserializeAttachment();
1134
+ const receivingClientContext = socketAttachment?.context;
1135
+
1136
+ const messagePayload: any = {
1137
+ type: 'applyPatch',
1138
+ syncKey: this.syncKey,
1139
+ data: { ...clientOperation },
1140
+ sessionId: updatingClientAttachment?.sessionId,
1141
+ version: `${this.instanceId}:${this.operationCounter}`,
1142
+ };
1143
+
1144
+ if (updatingClientContext && receivingClientContext) {
1145
+ const metadataTemplate = await this.ctx.storage.get<MetadataTemplate>('metadataTemplate');
1146
+
1147
+ if (metadataTemplate) {
1148
+ const filteredMetadata = await this.evaluateMetadataTemplate(
1149
+ metadataTemplate,
1150
+ receivingClientContext,
1151
+ updatingClientContext,
1152
+ );
1153
+
1154
+ if (filteredMetadata) {
1155
+ messagePayload.data.metaData = filteredMetadata;
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ socket.send(JSON.stringify(messagePayload));
1161
+ } catch (e) {
1162
+ console.error('[DO] Failed to send update to socket:', e);
1163
+ }
1164
+ }
1165
+
1166
+ await txn.put('operationCounter', this.operationCounter);
1167
+ } catch (error) {
1168
+ console.error('[DO] Fatal error in handleStateUpdate:', error);
1169
+ ws.send(JSON.stringify({ type: 'error', message: 'An unexpected error occurred while processing the update.' }));
1170
+ }
1171
+ });
1172
+ });
1173
+ }
1174
+
1175
+ private async getAuthToken(): Promise<string> {
1176
+ let token = await this.ctx.storage.get<string>('s2s_auth_token');
1177
+ if (token) {
1178
+ const payload = JSON.parse(atob(token.split('.')[1]));
1179
+ if (payload.exp > Math.floor(Date.now() / 1000)) {
1180
+ return token;
1181
+ }
1182
+ }
1183
+
1184
+ const boundaryId = await this.ctx.storage.get<string>('boundaryId');
1185
+ if (!boundaryId) {
1186
+ throw new Error('BUG: boundaryId was not found in storage. Cannot create auth token.');
1187
+ }
1188
+
1189
+ const payload = {
1190
+ iss: 'cogs-sync-engine',
1191
+ sub: `do:${this.syncKey}`,
1192
+ boundaryId: boundaryId,
1193
+ iat: Math.floor(Date.now() / 1000),
1194
+ exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
1195
+ };
1196
+ const appApiKey = await this.ctx.storage.get<string>('app_api_key');
1197
+ if (!appApiKey) {
1198
+ throw new Error('BUG: app_api_key was not found in storage. The fetch() handler should have stored it.');
1199
+ }
1200
+ const newToken = await sign(payload, appApiKey);
1201
+ await this.ctx.storage.put('s2s_auth_token', newToken);
1202
+ return newToken;
1203
+ }
1204
+
1205
+ private async initializeShadowState(state: any, schema?: any): Promise<any> {
1206
+ const writes: { [key: string]: object } = {};
1207
+ const rootKey = 'shadow';
1208
+
1209
+ function traverse(node: SyncEngineShadowNode, path: string) {
1210
+ if (node._meta) {
1211
+ writes[path] = node._meta;
1212
+ }
1213
+ for (const key in node) {
1214
+ if (key === '_meta') continue;
1215
+ const childNode = node[key] as SyncEngineShadowNode;
1216
+ traverse(childNode, `${path}:${key}`);
1217
+ }
1218
+ }
1219
+
1220
+ const shadowTree = buildShadowNode(state, schema);
1221
+ traverse(shadowTree, rootKey);
1222
+
1223
+ const currentVersion = Date.now();
1224
+ const versionRecord: VersionRecord = { value: currentVersion };
1225
+
1226
+ writes['state_cache'] = state;
1227
+ writes['version'] = versionRecord;
1228
+ writes['cache_version'] = versionRecord;
1229
+
1230
+ await this.ctx.storage.put(writes);
1231
+ return writes;
1232
+ }
1233
+ private async fetchInitialState(ws: WebSocket) {
1234
+ const existingState = await this.ctx.storage.get('shadow');
1235
+ if (existingState) {
1236
+ console.warn(`[DO] ABORTING fetchInitialState: State already exists. This prevents overwriting live data.`);
1237
+
1238
+ await this.sendCurrentState(ws);
1239
+
1240
+ return;
1241
+ }
1242
+
1243
+ try {
1244
+ const apiRoutes = await this.getApiRoutes(this.params);
1245
+ const queryConfig = apiRoutes?.queryData;
1246
+
1247
+ if (!queryConfig || !queryConfig.url) {
1248
+ console.error(
1249
+ `[fetchInitialState] 2. ERROR: 'queryData' configuration could not be resolved for syncKey "${this.syncKey}". Aborting.`,
1250
+ );
1251
+ return;
1252
+ }
1253
+
1254
+ let url = queryConfig.url;
1255
+
1256
+ if (url.includes('localhost') || url.includes('127.0.0.1')) {
1257
+ url = url.replace(/localhost|127\.0\.0\.1/, '172.17.32.1');
1258
+ } else {
1259
+ }
1260
+
1261
+ const authToken = await this.getAuthToken();
1262
+ let response: Response;
1263
+
1264
+ if (queryConfig.method === 'GET') {
1265
+ const finalUrl = new URL(url);
1266
+ finalUrl.searchParams.append('action', `${this.schemaKey}.fetch`);
1267
+ finalUrl.searchParams.append('stateId', this.stateId);
1268
+
1269
+ response = await fetch(finalUrl.href, {
1270
+ method: 'GET',
1271
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
1272
+ });
1273
+ } else {
1274
+ const bodyData = {
1275
+ action: `${this.schemaKey}.fetch`,
1276
+ stateId: this.stateId,
1277
+ ...(queryConfig.data || {}),
1278
+ };
1279
+
1280
+ response = await fetch(url, {
1281
+ method: 'POST',
1282
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
1283
+ body: JSON.stringify(bodyData),
1284
+ });
1285
+ }
1286
+
1287
+ if (!response.ok) {
1288
+ const errorText = await response.text();
1289
+ throw new Error(`Fetch failed with status ${response.status}: ${errorText}`);
1290
+ }
1291
+ const responseData = (await response.json()) as any;
1292
+ console.log('responseData', responseData);
1293
+ const initialState = responseData?.data || responseData;
1294
+
1295
+ if (initialState) {
1296
+ await this.initializeShadowState(initialState);
1297
+ console.log(`[DO] Successfully fetched and initialized state for syncKey: ${this.syncKey}`);
1298
+ } else {
1299
+ throw new Error('Fetched initial state is null or undefined.');
1300
+ }
1301
+
1302
+ await this.sendCurrentState(ws);
1303
+ ws.send(JSON.stringify({ type: 'syncReady', syncKey: this.syncKey }));
1304
+ } catch (error) {
1305
+ console.error('[fetchInitialState] FATAL ERROR during fetch:', error);
1306
+ ws.send(JSON.stringify({ type: 'error', message: 'Failed to fetch initial state.' }));
1307
+ }
1308
+ }
1309
+
1310
+ private broadcastSubscribers(closingSocket?: WebSocket) {
1311
+ let allSockets = this.ctx.getWebSockets();
1312
+
1313
+ if (closingSocket) {
1314
+ const closingSessionId = closingSocket.deserializeAttachment()?.sessionId;
1315
+
1316
+ allSockets = allSockets.filter((socket) => {
1317
+ return socket.deserializeAttachment()?.sessionId !== closingSessionId;
1318
+ });
1319
+ }
1320
+
1321
+ const subscribers = allSockets.map((ws) => {
1322
+ const attachment = ws.deserializeAttachment();
1323
+ return {
1324
+ clientId: attachment?.clientId || 'unknown',
1325
+ sessionId: attachment?.sessionId || 'unknown',
1326
+ syncKey: attachment?.syncKey || 'unknown',
1327
+ };
1328
+ });
1329
+
1330
+ const message = JSON.stringify({
1331
+ type: 'subscribers',
1332
+ syncKey: this.syncKey,
1333
+ subscribers: subscribers,
1334
+ });
1335
+
1336
+ allSockets.forEach((ws) => {
1337
+ try {
1338
+ ws.send(message);
1339
+ } catch (e) {}
1340
+ });
1341
+ }
1342
+
1343
+ private sendSubscribers(ws: WebSocket) {
1344
+ const sockets = this.ctx.getWebSockets();
1345
+ const clientIds = new Set(
1346
+ sockets.map((socket) => {
1347
+ const attachment = socket.deserializeAttachment();
1348
+
1349
+ return attachment?.clientId || 'unknown';
1350
+ }),
1351
+ );
1352
+
1353
+ ws.send(
1354
+ JSON.stringify({
1355
+ type: 'subscribers',
1356
+ syncKey: this.syncKey,
1357
+ subscribers: clientIds,
1358
+ }),
1359
+ );
1360
+ }
1361
+ }
1362
+
1363
+ export { WebSocketSyncEngine, UserPresenceDO };
1364
+
1365
+ // CHANGED: now takes auth param
1366
+ async function handleSchemaUpload(request: Request, env: Env, auth: AuthService): Promise<Response> {
1367
+ // CHANGED: replaced entire auth block with AuthService
1368
+ let tenantId: number;
1369
+ try {
1370
+ const apiKey = auth.extractBearerToken(request);
1371
+ const authResult = await auth.validateApiKey(apiKey);
1372
+
1373
+ if (!authResult.success || !authResult.valid) {
1374
+ return new Response(JSON.stringify({ success: false, message: 'Authentication failed: Invalid API key' }), {
1375
+ status: 401,
1376
+ headers: { 'Content-Type': 'application/json' },
1377
+ });
1378
+ }
1379
+ tenantId = authResult.tenantId;
1380
+ } catch (error) {
1381
+ if (error instanceof AuthError) {
1382
+ return new Response(JSON.stringify({ success: false, message: error.message }), {
1383
+ status: error.status,
1384
+ headers: { 'Content-Type': 'application/json' },
1385
+ });
1386
+ }
1387
+ console.error('Auth failed:', error);
1388
+ return new Response(JSON.stringify({ success: false, message: 'Internal server error during authentication' }), {
1389
+ status: 500,
1390
+ headers: { 'Content-Type': 'application/json' },
1391
+ });
1392
+ }
1393
+ // CHANGED: end of auth replacement
1394
+
1395
+ let payload: { bundledSchema: string; timestamp: number; schemaPath: string };
1396
+
1397
+ try {
1398
+ payload = await request.json();
1399
+ } catch (e: any) {
1400
+ console.error('Failed to parse request body', e);
1401
+ return new Response(
1402
+ JSON.stringify({
1403
+ success: false,
1404
+ message: 'Failed to parse request body as JSON.',
1405
+ error: e.message,
1406
+ }),
1407
+ {
1408
+ status: 400,
1409
+ headers: { 'Content-Type': 'application/json' },
1410
+ },
1411
+ );
1412
+ }
1413
+
1414
+ if (!payload.bundledSchema) {
1415
+ return new Response(JSON.stringify({ success: false, message: 'bundledSchema is required.' }), {
1416
+ status: 400,
1417
+ headers: { 'Content-Type': 'application/json' },
1418
+ });
1419
+ }
1420
+
1421
+ const bundleStoragePath = `schemas/tenant-${tenantId}/bundle.js`;
1422
+ try {
1423
+ await env.SCHEMA_STORAGE.put(bundleStoragePath, payload.bundledSchema, {
1424
+ httpMetadata: { contentType: 'application/javascript; charset=utf-8' },
1425
+ });
1426
+ } catch (error: any) {
1427
+ console.error(`R2 upload failed for tenant ${tenantId}:`, error);
1428
+ return new Response(JSON.stringify({ success: false, message: 'Failed to store schema bundle' }), {
1429
+ status: 500,
1430
+ headers: { 'Content-Type': 'application/json' },
1431
+ });
1432
+ }
1433
+ const routesPrefix = `schemas/tenant-${tenantId}/routes/`;
1434
+ const listed = await env.SCHEMA_STORAGE.list({ prefix: routesPrefix });
1435
+
1436
+ for (const key of listed.objects) {
1437
+ await env.SCHEMA_STORAGE.delete(key.key);
1438
+ }
1439
+ return new Response(
1440
+ JSON.stringify({
1441
+ success: true,
1442
+ message: `Schema bundle for tenant ${tenantId} uploaded successfully.`,
1443
+ }),
1444
+ {
1445
+ status: 200,
1446
+ headers: { 'Content-Type': 'application/json' },
1447
+ },
1448
+ );
1449
+ }
1450
+ async function handleUpdateContext(request: Request, env: Env) {
1451
+ try {
1452
+ const authHeader = request.headers.get('Authorization');
1453
+
1454
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
1455
+ return new Response(
1456
+ JSON.stringify({
1457
+ success: false,
1458
+ message: 'Missing or invalid authorization header',
1459
+ }),
1460
+ {
1461
+ status: 401,
1462
+ headers: { 'Content-Type': 'application/json' },
1463
+ },
1464
+ );
1465
+ }
1466
+
1467
+ const apiKey = authHeader.substring(7);
1468
+
1469
+ const body = (await request.json()) as {
1470
+ syncKey: string;
1471
+ ctx: Record<string, any>;
1472
+ };
1473
+
1474
+ const { syncKey, ctx } = body;
1475
+
1476
+ if (!syncKey) {
1477
+ return new Response(
1478
+ JSON.stringify({
1479
+ success: false,
1480
+ message: 'syncKey is required',
1481
+ }),
1482
+ {
1483
+ status: 400,
1484
+ headers: { 'Content-Type': 'application/json' },
1485
+ },
1486
+ );
1487
+ }
1488
+
1489
+ const boundaryId = syncKey.split(':')[0];
1490
+ const boundaryDOKey = `${boundaryId}:${syncKey}`;
1491
+ const id = env.WEBSOCKET_SYNC_ENGINE.idFromName(boundaryDOKey);
1492
+ const stub = env.WEBSOCKET_SYNC_ENGINE.get(id);
1493
+
1494
+ return stub.fetch(
1495
+ new Request(request.url, {
1496
+ method: 'POST',
1497
+ headers: request.headers,
1498
+ body: JSON.stringify({ type: 'updateContext', ctx }),
1499
+ }),
1500
+ );
1501
+ } catch (error: any) {
1502
+ console.error('Error updating context:', error);
1503
+ return new Response(
1504
+ JSON.stringify({
1505
+ success: false,
1506
+ message: error.message || 'Internal server error',
1507
+ }),
1508
+ {
1509
+ status: 500,
1510
+ headers: { 'Content-Type': 'application/json' },
1511
+ },
1512
+ );
1513
+ }
1514
+ }
1515
+ export default {
1516
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
1517
+ const url = new URL(request.url);
1518
+
1519
+ // Handle CORS
1520
+ if (request.method === 'OPTIONS') {
1521
+ return new Response(null, {
1522
+ headers: {
1523
+ 'Access-Control-Allow-Origin': '*',
1524
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
1525
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1526
+ 'Access-Control-Max-Age': '86400',
1527
+ },
1528
+ });
1529
+ }
1530
+
1531
+ // CHANGED: create auth service once
1532
+ const auth = new AuthService(env.JWT_SECRET, env.AUTH_SECRET);
1533
+
1534
+ if (url.pathname === '/sync-token' && request.method === 'POST') {
1535
+ return handleSyncToken(request, env, auth);
1536
+ }
1537
+ if (url.pathname === '/refresh-token' && request.method === 'POST') {
1538
+ return handleRefreshToken(request, env, auth);
1539
+ }
1540
+ if (url.pathname === '/upload-schema' && request.method === 'POST') {
1541
+ return handleSchemaUpload(request, env, auth); // CHANGED: pass auth
1542
+ }
1543
+ if (url.pathname === '/update-context' && request.method === 'POST') {
1544
+ return handleUpdateContext(request, env);
1545
+ }
1546
+ if (url.pathname.startsWith('/sync/') && request.headers.get('Upgrade') === 'websocket') {
1547
+ const syncKey = url.pathname.split('/')[2];
1548
+ if (!syncKey) {
1549
+ return new Response('Missing sync key', { status: 400 });
1550
+ }
1551
+
1552
+ const token = url.searchParams.get('token');
1553
+ if (!token) {
1554
+ return new Response('Missing token', {
1555
+ status: 401,
1556
+ headers: { 'X-Error-Reason': 'token_missing' },
1557
+ });
1558
+ }
1559
+
1560
+ try {
1561
+ const isValid = await verify(token, env.JWT_SECRET);
1562
+ if (!isValid) throw new Error('Invalid token signature');
1563
+
1564
+ const payload = JSON.parse(atob(token.split('.')[1]));
1565
+
1566
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
1567
+ throw new Error('TokenExpired');
1568
+ }
1569
+
1570
+ const boundaryDOKey = `${payload.boundaryId}:${syncKey}`;
1571
+ const id = env.WEBSOCKET_SYNC_ENGINE.idFromName(boundaryDOKey);
1572
+ const stub = env.WEBSOCKET_SYNC_ENGINE.get(id);
1573
+ return stub.fetch(request);
1574
+ } catch (e: any) {
1575
+ const errorReason = e.message || 'unknown_auth_error';
1576
+
1577
+ const pair = new WebSocketPair();
1578
+ const [client, server] = Object.values(pair);
1579
+
1580
+ server.accept();
1581
+
1582
+ server.send(
1583
+ JSON.stringify({
1584
+ type: 'auth_failed',
1585
+ reason: errorReason,
1586
+ message: `Authentication failed: ${errorReason.replace(/_/g, ' ')}.`,
1587
+ }),
1588
+ );
1589
+
1590
+ server.close(4001, errorReason);
1591
+
1592
+ return new Response(null, { status: 101, webSocket: client });
1593
+ }
1594
+ }
1595
+ if (url.pathname.startsWith('/user-presence/') && request.headers.get('Upgrade') === 'websocket') {
1596
+ const token = url.searchParams.get('token');
1597
+ if (!token) return new Response('Missing token', { status: 401 });
1598
+
1599
+ try {
1600
+ const isValid = await verify(token, env.JWT_SECRET);
1601
+ if (!isValid) throw new Error('Invalid token');
1602
+ const payload = JSON.parse(atob(token.split('.')[1]));
1603
+
1604
+ const boundaryId = payload.boundaryId;
1605
+ const userId = payload.clientId;
1606
+
1607
+ if (!boundaryId || !userId) {
1608
+ return new Response('Token missing required claims', { status: 400 });
1609
+ }
1610
+
1611
+ const doName = `${boundaryId}:user:${userId}`;
1612
+ const id = env.USER_PRESENCE.idFromName(doName);
1613
+ const stub = env.USER_PRESENCE.get(id);
1614
+
1615
+ return stub.fetch(request);
1616
+ } catch (e) {
1617
+ return new Response('Invalid token', { status: 401 });
1618
+ }
1619
+ }
1620
+ return new Response(`<html><body><h1>Sync Engine</h1><p>Status: Online</p></body></html>`, {
1621
+ headers: { 'Content-Type': 'text/html' },
1622
+ });
1623
+ },
1624
+ };
1625
+
1626
+ // CHANGED: now takes auth param, replaced external fetch with auth.validateApiKey
1627
+ async function handleSyncToken(request: Request, env: Env, auth: AuthService) {
1628
+ try {
1629
+ // CHANGED: use auth service to extract and validate
1630
+ const apiKey = auth.extractBearerToken(request);
1631
+
1632
+ const body = (await request.json()) as {
1633
+ clientId: string;
1634
+ boundaryId: string;
1635
+ ctx: Record<string, any>;
1636
+ metadataTemplate?: Record<string, any>;
1637
+ };
1638
+
1639
+ const { clientId: realClientId, boundaryId, ctx, metadataTemplate } = body;
1640
+
1641
+ if (!realClientId) {
1642
+ return new Response(
1643
+ JSON.stringify({
1644
+ success: false,
1645
+ message: 'clientKey is required',
1646
+ }),
1647
+ {
1648
+ status: 400,
1649
+ headers: { 'Content-Type': 'application/json' },
1650
+ },
1651
+ );
1652
+ }
1653
+
1654
+ // CHANGED: replaced external fetch('https://goot.co.uk:60002/...') with auth service
1655
+ const authResult = await auth.validateApiKey(apiKey);
1656
+
1657
+ if (!authResult.success || !authResult.valid) {
1658
+ return new Response(
1659
+ JSON.stringify({
1660
+ success: false,
1661
+ message: 'Authentication failed: Invalid API key',
1662
+ }),
1663
+ {
1664
+ status: 401,
1665
+ headers: { 'Content-Type': 'application/json' },
1666
+ },
1667
+ );
1668
+ }
1669
+ // CHANGED: end of auth replacement
1670
+
1671
+ const sessionClientId = `session_${realClientId}_${Math.random().toString(36).substring(2, 9)}`;
1672
+ const payload: SyncTokenPayload = {
1673
+ clientId: sessionClientId,
1674
+ serviceId: authResult.serviceId,
1675
+ boundaryId,
1676
+ scopes: authResult.scopes,
1677
+ tenantId: authResult.tenantId,
1678
+ exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
1679
+ iat: Math.floor(Date.now() / 1000),
1680
+ appApiKey: apiKey,
1681
+ context: ctx,
1682
+ metadataTemplate: metadataTemplate,
1683
+ };
1684
+ const refreshPayload = {
1685
+ clientId: sessionClientId,
1686
+ boundaryId,
1687
+ tenantId: authResult.tenantId,
1688
+ type: 'refresh',
1689
+ exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, // 30 days
1690
+ iat: Math.floor(Date.now() / 1000),
1691
+ };
1692
+ const token = await sign(payload, env.JWT_SECRET);
1693
+
1694
+ const refreshToken = await sign(refreshPayload, env.JWT_SECRET);
1695
+ return new Response(
1696
+ JSON.stringify({
1697
+ success: true,
1698
+ sessionToken: token,
1699
+ refreshToken,
1700
+ expiresIn: 3600,
1701
+ clientId: sessionClientId,
1702
+ }),
1703
+ {
1704
+ status: 200,
1705
+ headers: {
1706
+ 'Content-Type': 'application/json',
1707
+ 'Cache-Control': 'no-store',
1708
+ 'Access-Control-Allow-Origin': '*',
1709
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
1710
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1711
+ },
1712
+ },
1713
+ );
1714
+ } catch (error: any) {
1715
+ // CHANGED: handle AuthError specifically
1716
+ if (error instanceof AuthError) {
1717
+ return new Response(JSON.stringify({ success: false, message: error.message }), {
1718
+ status: error.status,
1719
+ headers: { 'Content-Type': 'application/json' },
1720
+ });
1721
+ }
1722
+ console.error('Error generating sync token:', error);
1723
+ return new Response(
1724
+ JSON.stringify({
1725
+ success: false,
1726
+ message: error.message || 'Internal server error',
1727
+ }),
1728
+ {
1729
+ status: 500,
1730
+ headers: { 'Content-Type': 'application/json' },
1731
+ },
1732
+ );
1733
+ }
1734
+ }
1735
+
1736
+ // CHANGED: now takes auth param, replaced external fetch with auth.validateApiKey
1737
+ async function handleRefreshToken(request: Request, env: Env, auth: AuthService) {
1738
+ try {
1739
+ const body = (await request.json()) as {
1740
+ refreshToken: string;
1741
+ clientId: string;
1742
+ ctx: Record<string, any>;
1743
+ };
1744
+
1745
+ const { refreshToken, clientId } = body;
1746
+
1747
+ if (!refreshToken || !clientId) {
1748
+ return new Response(
1749
+ JSON.stringify({
1750
+ success: false,
1751
+ message: 'refreshToken and clientId are required',
1752
+ }),
1753
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
1754
+ );
1755
+ }
1756
+
1757
+ // Verify the refresh token
1758
+ const isValid = await verify(refreshToken, env.JWT_SECRET);
1759
+ if (!isValid) {
1760
+ return new Response(
1761
+ JSON.stringify({
1762
+ success: false,
1763
+ message: 'Invalid refresh token',
1764
+ }),
1765
+ { status: 401, headers: { 'Content-Type': 'application/json' } },
1766
+ );
1767
+ }
1768
+
1769
+ const payload = JSON.parse(atob(refreshToken.split('.')[1]));
1770
+
1771
+ // Check if it's a refresh token and not expired
1772
+ if (payload.type !== 'refresh' || payload.exp < Math.floor(Date.now() / 1000)) {
1773
+ return new Response(
1774
+ JSON.stringify({
1775
+ success: false,
1776
+ message: 'Invalid or expired refresh token',
1777
+ }),
1778
+ { status: 401, headers: { 'Content-Type': 'application/json' } },
1779
+ );
1780
+ }
1781
+
1782
+ // Verify clientId matches
1783
+ if (payload.clientId !== clientId) {
1784
+ return new Response(
1785
+ JSON.stringify({
1786
+ success: false,
1787
+ message: 'Client ID mismatch',
1788
+ }),
1789
+ { status: 401, headers: { 'Content-Type': 'application/json' } },
1790
+ );
1791
+ }
1792
+
1793
+ // CHANGED: use auth service instead of manual header extraction
1794
+ const apiKey = auth.extractBearerToken(request);
1795
+
1796
+ // CHANGED: replaced external fetch('https://goot.co.uk:60002/...') with auth service
1797
+ const authResult = await auth.validateApiKey(apiKey);
1798
+
1799
+ if (!authResult.success || !authResult.valid) {
1800
+ return new Response(
1801
+ JSON.stringify({
1802
+ success: false,
1803
+ message: 'Failed to validate API key',
1804
+ }),
1805
+ { status: 401, headers: { 'Content-Type': 'application/json' } },
1806
+ );
1807
+ }
1808
+ // CHANGED: end of auth replacement
1809
+
1810
+ // Generate new access token
1811
+ const newAccessPayload: SyncTokenPayload = {
1812
+ clientId: payload.clientId,
1813
+ serviceId: authResult.serviceId,
1814
+ boundaryId: payload.boundaryId,
1815
+ scopes: authResult.scopes,
1816
+ tenantId: payload.tenantId,
1817
+ exp: Math.floor(Date.now() / 1000) + 3600, // CHANGED: was 5 seconds, fixed to 1 hour
1818
+ iat: Math.floor(Date.now() / 1000),
1819
+ appApiKey: apiKey,
1820
+ context: payload.context,
1821
+ };
1822
+
1823
+ const newAccessToken = await sign(newAccessPayload, env.JWT_SECRET);
1824
+
1825
+ return new Response(
1826
+ JSON.stringify({
1827
+ success: true,
1828
+ sessionToken: newAccessToken,
1829
+ expiresIn: 3600,
1830
+ }),
1831
+ {
1832
+ status: 200,
1833
+ headers: {
1834
+ 'Content-Type': 'application/json',
1835
+ 'Cache-Control': 'no-store',
1836
+ 'Access-Control-Allow-Origin': '*',
1837
+ },
1838
+ },
1839
+ );
1840
+ } catch (error: any) {
1841
+ // CHANGED: handle AuthError specifically
1842
+ if (error instanceof AuthError) {
1843
+ return new Response(JSON.stringify({ success: false, message: error.message }), {
1844
+ status: error.status,
1845
+ headers: { 'Content-Type': 'application/json' },
1846
+ });
1847
+ }
1848
+ console.error('Error refreshing token:', error);
1849
+ return new Response(
1850
+ JSON.stringify({
1851
+ success: false,
1852
+ message: error.message || 'Internal server error',
1853
+ }),
1854
+ {
1855
+ status: 500,
1856
+ headers: { 'Content-Type': 'application/json' },
1857
+ },
1858
+ );
1859
+ }
1860
+ }