@affectively/aeon-pages 1.3.0

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.
Files changed (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * User Context Extractor
3
+ *
4
+ * Middleware that extracts UserContext from HTTP requests.
5
+ * Gathers signals from headers, cookies, and request properties.
6
+ */
7
+
8
+ import type {
9
+ ConnectionType,
10
+ EmotionState,
11
+ UserContext,
12
+ UserTier,
13
+ Viewport,
14
+ } from './types';
15
+
16
+ // ============================================================================
17
+ // Cookie Helpers
18
+ // ============================================================================
19
+
20
+ function parseCookies(cookieHeader: string | null): Record<string, string> {
21
+ if (!cookieHeader) return {};
22
+
23
+ return cookieHeader.split(';').reduce(
24
+ (acc, cookie) => {
25
+ const [key, value] = cookie.trim().split('=');
26
+ if (key && value) {
27
+ acc[key] = decodeURIComponent(value);
28
+ }
29
+ return acc;
30
+ },
31
+ {} as Record<string, string>,
32
+ );
33
+ }
34
+
35
+ function parseJSON<T>(value: string | undefined, fallback: T): T {
36
+ if (!value) return fallback;
37
+ try {
38
+ return JSON.parse(value) as T;
39
+ } catch {
40
+ return fallback;
41
+ }
42
+ }
43
+
44
+ // ============================================================================
45
+ // Header Extraction
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Extract viewport from client hints or fallback headers
50
+ */
51
+ function extractViewport(request: Request): Viewport {
52
+ const headers = request.headers;
53
+
54
+ // Client Hints (if available)
55
+ const viewportWidth = headers.get('sec-ch-viewport-width');
56
+ const viewportHeight = headers.get('sec-ch-viewport-height');
57
+ const dpr = headers.get('sec-ch-dpr');
58
+
59
+ if (viewportWidth && viewportHeight) {
60
+ return {
61
+ width: parseInt(viewportWidth, 10),
62
+ height: parseInt(viewportHeight, 10),
63
+ devicePixelRatio: dpr ? parseFloat(dpr) : undefined,
64
+ };
65
+ }
66
+
67
+ // Fallback to custom headers (set by client-side JS)
68
+ const xViewport = headers.get('x-viewport');
69
+ if (xViewport) {
70
+ const [width, height, devicePixelRatio] = xViewport.split(',').map(Number);
71
+ return { width: width || 1920, height: height || 1080, devicePixelRatio };
72
+ }
73
+
74
+ // Default desktop viewport
75
+ return { width: 1920, height: 1080 };
76
+ }
77
+
78
+ /**
79
+ * Extract connection type from Network Information API hints
80
+ */
81
+ function extractConnection(request: Request): ConnectionType {
82
+ const headers = request.headers;
83
+
84
+ // Downlink and RTT for connection quality
85
+ const downlink = headers.get('downlink');
86
+ const rtt = headers.get('rtt');
87
+ const ect = headers.get('ect'); // Effective Connection Type
88
+
89
+ // ECT header (if available)
90
+ if (ect) {
91
+ switch (ect) {
92
+ case '4g':
93
+ return 'fast';
94
+ case '3g':
95
+ return '3g';
96
+ case '2g':
97
+ return '2g';
98
+ case 'slow-2g':
99
+ return 'slow-2g';
100
+ }
101
+ }
102
+
103
+ // Infer from downlink (Mbps)
104
+ if (downlink) {
105
+ const mbps = parseFloat(downlink);
106
+ if (mbps >= 10) return 'fast';
107
+ if (mbps >= 2) return '4g';
108
+ if (mbps >= 0.5) return '3g';
109
+ if (mbps >= 0.1) return '2g';
110
+ return 'slow-2g';
111
+ }
112
+
113
+ // Infer from RTT (ms)
114
+ if (rtt) {
115
+ const ms = parseInt(rtt, 10);
116
+ if (ms < 50) return 'fast';
117
+ if (ms < 100) return '4g';
118
+ if (ms < 300) return '3g';
119
+ if (ms < 700) return '2g';
120
+ return 'slow-2g';
121
+ }
122
+
123
+ // Default to 4g
124
+ return '4g';
125
+ }
126
+
127
+ /**
128
+ * Extract reduced motion preference
129
+ */
130
+ function extractReducedMotion(request: Request): boolean {
131
+ const prefersReducedMotion = request.headers.get(
132
+ 'sec-ch-prefers-reduced-motion',
133
+ );
134
+ return prefersReducedMotion === 'reduce';
135
+ }
136
+
137
+ /**
138
+ * Extract timezone and local hour
139
+ */
140
+ function extractTimeContext(request: Request): {
141
+ timezone: string;
142
+ localHour: number;
143
+ } {
144
+ const headers = request.headers;
145
+
146
+ // Custom header from client
147
+ const xTimezone = headers.get('x-timezone');
148
+ const xLocalHour = headers.get('x-local-hour');
149
+
150
+ // Cloudflare provides timezone in cf object
151
+ const cfTimezone = (request as Request & { cf?: { timezone?: string } }).cf
152
+ ?.timezone;
153
+
154
+ const timezone = xTimezone || cfTimezone || 'UTC';
155
+ const localHour = xLocalHour
156
+ ? parseInt(xLocalHour, 10)
157
+ : new Date().getUTCHours();
158
+
159
+ return { timezone, localHour };
160
+ }
161
+
162
+ // ============================================================================
163
+ // Cookie/Session Extraction
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Admin capability verifier function type
168
+ * This should be provided by the auth system (e.g., UCAN token verification)
169
+ */
170
+ export type AdminVerifier = (token: string) => Promise<boolean>;
171
+
172
+ /**
173
+ * Extract user identity and tier from cookies/headers
174
+ * NOTE: isAdmin is NOT determined here - it must be verified via UCAN token
175
+ */
176
+ function extractIdentity(
177
+ cookies: Record<string, string>,
178
+ request: Request,
179
+ ): { userId?: string; tier: UserTier; authToken?: string } {
180
+ // User ID from cookie or header
181
+ const userId =
182
+ cookies['user_id'] || request.headers.get('x-user-id') || undefined;
183
+
184
+ // Tier from cookie, header, or default
185
+ const tierCookie = cookies['user_tier'] as UserTier | undefined;
186
+ const tierHeader = request.headers.get('x-user-tier') as UserTier | null;
187
+ const tier = tierCookie || tierHeader || 'free';
188
+
189
+ // Auth token for admin verification (DO NOT trust cookies for admin status!)
190
+ const authToken =
191
+ cookies['auth_token'] ||
192
+ request.headers.get('authorization')?.replace('Bearer ', '') ||
193
+ undefined;
194
+
195
+ return { userId, tier, authToken };
196
+ }
197
+
198
+ /**
199
+ * Extract navigation history from cookies
200
+ */
201
+ function extractNavigationHistory(cookies: Record<string, string>): {
202
+ recentPages: string[];
203
+ dwellTimes: Map<string, number>;
204
+ clickPatterns: string[];
205
+ } {
206
+ const recentPages = parseJSON<string[]>(cookies['recent_pages'], []);
207
+ const dwellTimesObj = parseJSON<Record<string, number>>(
208
+ cookies['dwell_times'],
209
+ {},
210
+ );
211
+ const clickPatterns = parseJSON<string[]>(cookies['click_patterns'], []);
212
+
213
+ return {
214
+ recentPages,
215
+ dwellTimes: new Map(Object.entries(dwellTimesObj)),
216
+ clickPatterns,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Extract emotion state from cookies/headers
222
+ */
223
+ function extractEmotionState(
224
+ cookies: Record<string, string>,
225
+ request: Request,
226
+ ): EmotionState | undefined {
227
+ // Check custom header first (set by edge-workers inference)
228
+ const xEmotion = request.headers.get('x-emotion-state');
229
+ if (xEmotion) {
230
+ return parseJSON<EmotionState | undefined>(xEmotion, undefined);
231
+ }
232
+
233
+ // Check cookie
234
+ const emotionCookie = cookies['emotion_state'];
235
+ if (emotionCookie) {
236
+ return parseJSON<EmotionState | undefined>(emotionCookie, undefined);
237
+ }
238
+
239
+ return undefined;
240
+ }
241
+
242
+ /**
243
+ * Extract user preferences from cookies
244
+ */
245
+ function extractPreferences(
246
+ cookies: Record<string, string>,
247
+ ): Record<string, unknown> {
248
+ return parseJSON<Record<string, unknown>>(cookies['user_preferences'], {});
249
+ }
250
+
251
+ /**
252
+ * Extract session info from cookies
253
+ */
254
+ function extractSessionInfo(cookies: Record<string, string>): {
255
+ sessionId?: string;
256
+ isNewSession: boolean;
257
+ sessionStartedAt?: Date;
258
+ } {
259
+ const sessionId = cookies['session_id'];
260
+ const sessionStarted = cookies['session_started'];
261
+
262
+ return {
263
+ sessionId,
264
+ isNewSession: !sessionId,
265
+ sessionStartedAt: sessionStarted ? new Date(sessionStarted) : undefined,
266
+ };
267
+ }
268
+
269
+ // ============================================================================
270
+ // Main Extractor
271
+ // ============================================================================
272
+
273
+ export interface ContextExtractorOptions {
274
+ /** Custom emotion detector (e.g., call edge-workers) */
275
+ detectEmotion?: (request: Request) => Promise<EmotionState | undefined>;
276
+
277
+ /** Custom user tier resolver (e.g., from database) */
278
+ resolveUserTier?: (userId: string) => Promise<UserTier>;
279
+
280
+ /**
281
+ * Verify admin capability from auth token (REQUIRED for admin access)
282
+ * This should verify the UCAN token has the 'admin' capability
283
+ * If not provided, isAdmin will always be false
284
+ */
285
+ verifyAdminCapability?: AdminVerifier;
286
+
287
+ /** Additional context enrichment */
288
+ enrich?: (context: UserContext, request: Request) => Promise<UserContext>;
289
+ }
290
+
291
+ /**
292
+ * Extract UserContext from an HTTP request
293
+ */
294
+ export async function extractUserContext(
295
+ request: Request,
296
+ options: ContextExtractorOptions = {},
297
+ ): Promise<UserContext> {
298
+ const cookies = parseCookies(request.headers.get('cookie'));
299
+
300
+ // Extract all signals
301
+ const viewport = extractViewport(request);
302
+ const connection = extractConnection(request);
303
+ const reducedMotion = extractReducedMotion(request);
304
+ const { timezone, localHour } = extractTimeContext(request);
305
+ const {
306
+ userId,
307
+ tier: initialTier,
308
+ authToken,
309
+ } = extractIdentity(cookies, request);
310
+ const { recentPages, dwellTimes, clickPatterns } =
311
+ extractNavigationHistory(cookies);
312
+ const preferences = extractPreferences(cookies);
313
+ const { sessionId, isNewSession, sessionStartedAt } =
314
+ extractSessionInfo(cookies);
315
+
316
+ // Resolve user tier if we have a resolver and userId
317
+ let tier = initialTier;
318
+ if (options.resolveUserTier && userId) {
319
+ try {
320
+ tier = await options.resolveUserTier(userId);
321
+ } catch {
322
+ // Keep initial tier on error
323
+ }
324
+ }
325
+
326
+ // Verify admin capability via UCAN token (NOT from cookies!)
327
+ // Admin access MUST be cryptographically verified
328
+ let isAdmin = false;
329
+ if (authToken && options.verifyAdminCapability) {
330
+ try {
331
+ isAdmin = await options.verifyAdminCapability(authToken);
332
+ } catch {
333
+ // Not admin on verification failure
334
+ isAdmin = false;
335
+ }
336
+ }
337
+
338
+ // Extract or detect emotion state
339
+ let emotionState = extractEmotionState(cookies, request);
340
+ if (!emotionState && options.detectEmotion) {
341
+ try {
342
+ emotionState = await options.detectEmotion(request);
343
+ } catch {
344
+ // No emotion state on error
345
+ }
346
+ }
347
+
348
+ // Build context
349
+ let context: UserContext = {
350
+ userId,
351
+ tier,
352
+ isAdmin,
353
+ recentPages,
354
+ dwellTimes,
355
+ clickPatterns,
356
+ emotionState,
357
+ preferences,
358
+ viewport,
359
+ connection,
360
+ reducedMotion,
361
+ localHour,
362
+ timezone,
363
+ sessionId,
364
+ isNewSession,
365
+ sessionStartedAt,
366
+ };
367
+
368
+ // Optional enrichment
369
+ if (options.enrich) {
370
+ context = await options.enrich(context, request);
371
+ }
372
+
373
+ return context;
374
+ }
375
+
376
+ /**
377
+ * Create middleware for user context extraction
378
+ */
379
+ export function createContextMiddleware(options: ContextExtractorOptions = {}) {
380
+ return async (request: Request): Promise<UserContext> => {
381
+ return extractUserContext(request, options);
382
+ };
383
+ }
384
+
385
+ // ============================================================================
386
+ // Response Helpers
387
+ // ============================================================================
388
+
389
+ /**
390
+ * Set context tracking cookies in response
391
+ */
392
+ export function setContextCookies(
393
+ response: Response,
394
+ context: UserContext,
395
+ currentPath: string,
396
+ ): Response {
397
+ const headers = new Headers(response.headers);
398
+
399
+ // Update recent pages
400
+ const recentPages = [...context.recentPages.slice(-9), currentPath];
401
+ headers.append(
402
+ 'Set-Cookie',
403
+ `recent_pages=${encodeURIComponent(JSON.stringify(recentPages))}; Path=/; Max-Age=604800; SameSite=Lax`,
404
+ );
405
+
406
+ // Set session cookie if new session
407
+ if (context.isNewSession) {
408
+ const sessionId = `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
409
+ headers.append(
410
+ 'Set-Cookie',
411
+ `session_id=${sessionId}; Path=/; Max-Age=86400; SameSite=Lax`,
412
+ );
413
+ headers.append(
414
+ 'Set-Cookie',
415
+ `session_started=${new Date().toISOString()}; Path=/; Max-Age=86400; SameSite=Lax`,
416
+ );
417
+ }
418
+
419
+ return new Response(response.body, {
420
+ status: response.status,
421
+ statusText: response.statusText,
422
+ headers,
423
+ });
424
+ }
425
+
426
+ /**
427
+ * Add speculation hints to response headers
428
+ */
429
+ export function addSpeculationHeaders(
430
+ response: Response,
431
+ prefetch: string[],
432
+ prerender: string[],
433
+ ): Response {
434
+ const headers = new Headers(response.headers);
435
+
436
+ // Add Link headers for prefetch
437
+ if (prefetch.length > 0) {
438
+ const linkHeader = prefetch
439
+ .map((path) => `<${path}>; rel=prefetch`)
440
+ .join(', ');
441
+ headers.append('Link', linkHeader);
442
+ }
443
+
444
+ // Add prerender hints (Speculation Rules API will be injected in HTML)
445
+ if (prerender.length > 0) {
446
+ headers.set('X-Prerender-Hints', prerender.join(','));
447
+ }
448
+
449
+ return new Response(response.body, {
450
+ status: response.status,
451
+ statusText: response.statusText,
452
+ headers,
453
+ });
454
+ }
455
+
456
+ // ============================================================================
457
+ // ESI State Serialization
458
+ // ============================================================================
459
+
460
+ /**
461
+ * ESI State for global injection in <head>
462
+ * This is consumed by ESI components before React hydration
463
+ */
464
+ export interface ESIState {
465
+ /** User subscription tier for feature gating */
466
+ userTier: UserTier;
467
+ /** Admin flag - bypasses ALL tier restrictions */
468
+ isAdmin?: boolean;
469
+ /** Current emotional state for personalization */
470
+ emotionState?: {
471
+ primary: string;
472
+ valence: number;
473
+ arousal: number;
474
+ confidence?: number;
475
+ };
476
+ /** User preferences for UI adaptation */
477
+ preferences: {
478
+ theme?: 'light' | 'dark' | 'auto';
479
+ reducedMotion: boolean;
480
+ language?: string;
481
+ };
482
+ /** Session information */
483
+ sessionId?: string;
484
+ /** Local time context */
485
+ localHour: number;
486
+ timezone: string;
487
+ /** Feature flags based on tier */
488
+ features: {
489
+ aiInference: boolean;
490
+ emotionTracking: boolean;
491
+ collaboration: boolean;
492
+ advancedInsights: boolean;
493
+ customThemes: boolean;
494
+ voiceSynthesis: boolean;
495
+ imageAnalysis: boolean;
496
+ };
497
+ /** User ID (if authenticated) */
498
+ userId?: string;
499
+ /** Is this a new session? */
500
+ isNewSession: boolean;
501
+ /** Recent pages for personalization */
502
+ recentPages: string[];
503
+ /** Viewport information */
504
+ viewport: {
505
+ width: number;
506
+ height: number;
507
+ };
508
+ /** Connection quality */
509
+ connection: ConnectionType;
510
+ }
511
+
512
+ /**
513
+ * Serialize UserContext to ESIState for global injection
514
+ */
515
+ export function serializeToESIState(context: UserContext): ESIState {
516
+ // Determine feature flags based on tier
517
+ const tierFeatures: Record<UserTier, ESIState['features']> = {
518
+ free: {
519
+ aiInference: true,
520
+ emotionTracking: true,
521
+ collaboration: false,
522
+ advancedInsights: false,
523
+ customThemes: false,
524
+ voiceSynthesis: false,
525
+ imageAnalysis: false,
526
+ },
527
+ starter: {
528
+ aiInference: true,
529
+ emotionTracking: true,
530
+ collaboration: false,
531
+ advancedInsights: true,
532
+ customThemes: true,
533
+ voiceSynthesis: false,
534
+ imageAnalysis: false,
535
+ },
536
+ pro: {
537
+ aiInference: true,
538
+ emotionTracking: true,
539
+ collaboration: true,
540
+ advancedInsights: true,
541
+ customThemes: true,
542
+ voiceSynthesis: true,
543
+ imageAnalysis: true,
544
+ },
545
+ enterprise: {
546
+ aiInference: true,
547
+ emotionTracking: true,
548
+ collaboration: true,
549
+ advancedInsights: true,
550
+ customThemes: true,
551
+ voiceSynthesis: true,
552
+ imageAnalysis: true,
553
+ },
554
+ admin: {
555
+ aiInference: true,
556
+ emotionTracking: true,
557
+ collaboration: true,
558
+ advancedInsights: true,
559
+ customThemes: true,
560
+ voiceSynthesis: true,
561
+ imageAnalysis: true,
562
+ },
563
+ };
564
+
565
+ // Admins get ALL features regardless of tier
566
+ const features = context.isAdmin
567
+ ? tierFeatures.admin
568
+ : tierFeatures[context.tier] || tierFeatures.free;
569
+
570
+ return {
571
+ userTier: context.tier,
572
+ isAdmin: context.isAdmin,
573
+ emotionState: context.emotionState
574
+ ? {
575
+ primary: context.emotionState.primary,
576
+ valence: context.emotionState.valence,
577
+ arousal: context.emotionState.arousal,
578
+ confidence: context.emotionState.confidence,
579
+ }
580
+ : undefined,
581
+ preferences: {
582
+ theme: context.preferences.theme as 'light' | 'dark' | 'auto' | undefined,
583
+ reducedMotion: context.reducedMotion,
584
+ language: context.preferences.language as string | undefined,
585
+ },
586
+ sessionId: context.sessionId,
587
+ localHour: context.localHour,
588
+ timezone: context.timezone,
589
+ features,
590
+ userId: context.userId,
591
+ isNewSession: context.isNewSession,
592
+ recentPages: context.recentPages.slice(-10), // Last 10 pages
593
+ viewport: {
594
+ width: context.viewport.width,
595
+ height: context.viewport.height,
596
+ },
597
+ connection: context.connection,
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Generate inline script for ESI state injection in <head>
603
+ * This must execute before any ESI components render
604
+ */
605
+ export function generateESIStateScript(esiState: ESIState): string {
606
+ const stateJson = JSON.stringify(esiState);
607
+ return `<script>window.__AEON_ESI_STATE__=${stateJson};</script>`;
608
+ }
609
+
610
+ /**
611
+ * Generate ESI state script from UserContext
612
+ */
613
+ export function generateESIStateScriptFromContext(
614
+ context: UserContext,
615
+ ): string {
616
+ const esiState = serializeToESIState(context);
617
+ return generateESIStateScript(esiState);
618
+ }
619
+
620
+ // ============================================================================
621
+ // Admin Verification Helpers
622
+ // ============================================================================
623
+
624
+ /**
625
+ * Create an admin verifier from a UCAN auth instance
626
+ *
627
+ * @example
628
+ * ```ts
629
+ * import { auth } from './auth';
630
+ * import { createAdminVerifier, extractUserContext } from '@affectively/aeon-pages-runtime';
631
+ *
632
+ * const verifyAdmin = createAdminVerifier(auth);
633
+ *
634
+ * const context = await extractUserContext(request, {
635
+ * verifyAdminCapability: verifyAdmin,
636
+ * });
637
+ * ```
638
+ */
639
+ export function createAdminVerifier<T>(
640
+ auth: {
641
+ verifyCapability: (opts: {
642
+ capability: T;
643
+ resource: string;
644
+ token: string;
645
+ }) => Promise<boolean>;
646
+ },
647
+ adminCapability: T = 'admin' as T,
648
+ adminResource: string = '*',
649
+ ): AdminVerifier {
650
+ return async (token: string): Promise<boolean> => {
651
+ try {
652
+ return await auth.verifyCapability({
653
+ capability: adminCapability,
654
+ resource: adminResource,
655
+ token,
656
+ });
657
+ } catch {
658
+ return false;
659
+ }
660
+ };
661
+ }