@omega.js/client 0.1.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 (72) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +874 -0
  3. package/dist/index.js +999 -0
  4. package/dist/modules/analytics.js +584 -0
  5. package/dist/modules/auth.js +469 -0
  6. package/dist/modules/bindings.js +319 -0
  7. package/dist/modules/device.js +282 -0
  8. package/dist/modules/dom.js +96 -0
  9. package/dist/modules/features.js +30 -0
  10. package/dist/modules/firestore.js +313 -0
  11. package/dist/modules/form-manager.js +1577 -0
  12. package/dist/modules/icon-core.js +226 -0
  13. package/dist/modules/icon-renderer.js +149 -0
  14. package/dist/modules/live-page.js +235 -0
  15. package/dist/modules/logger.js +36 -0
  16. package/dist/modules/motion.js +853 -0
  17. package/dist/modules/notifications.js +433 -0
  18. package/dist/modules/path-prefix.js +22 -0
  19. package/dist/modules/request.js +223 -0
  20. package/dist/modules/sentry.js +108 -0
  21. package/dist/modules/service-worker.js +237 -0
  22. package/dist/modules/storage.js +133 -0
  23. package/dist/modules/triggers.js +117 -0
  24. package/dist/modules/utilities.js +479 -0
  25. package/dist/modules/vert-document.js +354 -0
  26. package/dist/modules/verts.js +1133 -0
  27. package/dist/vendor/account/engine.js +182 -0
  28. package/dist/vendor/account/features.js +220 -0
  29. package/dist/vendor/account/index.js +53 -0
  30. package/dist/vendor/account/schema.js +272 -0
  31. package/dist/vendor/account/subscription.js +38 -0
  32. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  33. package/dist/vendor/analytics/adapters/meta.js +26 -0
  34. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  35. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  36. package/dist/vendor/analytics/catalog.js +908 -0
  37. package/dist/vendor/analytics/consent.js +49 -0
  38. package/dist/vendor/analytics/core.js +141 -0
  39. package/dist/vendor/analytics/identity.js +136 -0
  40. package/dist/vendor/analytics/index.js +170 -0
  41. package/dist/vendor/analytics/logger.js +40 -0
  42. package/dist/vendor/analytics/transports/browser.js +110 -0
  43. package/dist/vendor/monitoring/browser.js +207 -0
  44. package/dist/vendor/monitoring/core.js +180 -0
  45. package/dist/vendor/monitoring/logger.js +39 -0
  46. package/docs/architecture.md +59 -0
  47. package/docs/bindings.md +235 -0
  48. package/docs/build-system.md +32 -0
  49. package/docs/cdp-debugging.md +29 -0
  50. package/docs/code-patterns.md +96 -0
  51. package/docs/common-tasks.md +36 -0
  52. package/docs/dependencies.md +19 -0
  53. package/docs/index.md +159 -0
  54. package/docs/modules.md +180 -0
  55. package/docs/shared/agent-docs.md +89 -0
  56. package/docs/shared/analytics.md +612 -0
  57. package/docs/shared/brands.md +51 -0
  58. package/docs/shared/breaking-changes.md +497 -0
  59. package/docs/shared/config.md +1387 -0
  60. package/docs/shared/deploys.md +215 -0
  61. package/docs/shared/icons.md +201 -0
  62. package/docs/shared/local-dev.md +147 -0
  63. package/docs/shared/logging.md +202 -0
  64. package/docs/shared/monitoring.md +153 -0
  65. package/docs/shared/publishing.md +183 -0
  66. package/docs/shared/rulings.md +34 -0
  67. package/docs/shared/testing.md +147 -0
  68. package/docs/shared/theming.md +604 -0
  69. package/docs/shared/translation.md +291 -0
  70. package/docs/shared/updates.md +61 -0
  71. package/docs/testing.md +9 -0
  72. package/package.json +65 -0
package/dist/index.js ADDED
@@ -0,0 +1,999 @@
1
+ import Storage from './modules/storage.js';
2
+ import Utilities from './modules/utilities.js';
3
+ import * as domUtils from './modules/dom.js';
4
+ import Analytics from './modules/analytics.js';
5
+ import Auth from './modules/auth.js';
6
+ import Bindings from './modules/bindings.js';
7
+ import Firestore from './modules/firestore.js';
8
+ import Notifications from './modules/notifications.js';
9
+ import ServiceWorker from './modules/service-worker.js';
10
+ import Sentry from './modules/sentry.js';
11
+ import Device from './modules/device.js';
12
+ import Verts from './modules/verts.js';
13
+ import { createRequest, mergeUsageIntoBindings } from './modules/request.js';
14
+ import { createLogger } from './modules/logger.js';
15
+ import { pathPrefix } from './modules/path-prefix.js';
16
+
17
+ const firebaseLogger = createLogger('firebase');
18
+ const analyticsLogger = createLogger('analytics');
19
+ const chatsyLogger = createLogger('chatsy');
20
+ const versionLogger = createLogger('version');
21
+
22
+ // Classic dev ports (N7) — the browser-side fallbacks when no resolved map is
23
+ // provided. Lockstep with @omega.js/config's CLASSIC_PORTS: browser code can't
24
+ // import that Node module (fs/net), so the numbers live here too.
25
+ const DEV_PORT_FALLBACKS = {
26
+ auth: 9099,
27
+ firestore: 8080,
28
+ functions: 5001,
29
+ hosting: 5002,
30
+ };
31
+
32
+ // The classic dev WEBSITE ORIGIN (#262) — the same lockstep-with-@omega.js/config
33
+ // deal as the ports above (its CLASSIC_DEV_ORIGIN). Protocol included, because a
34
+ // port alone cannot say it: `omega dev` fronts the public port with the mkcert
35
+ // proxy by default, so the assumption is https.
36
+ const DEV_ORIGIN_FALLBACK = 'https://localhost:4000';
37
+
38
+ class Manager {
39
+ constructor() {
40
+ // Configuration from init()
41
+ this.config = {};
42
+
43
+ // Runtime state
44
+ this.state = {
45
+ serviceWorker: null
46
+ };
47
+
48
+ // Auth settler: resolves when Firebase auth first determines user state
49
+ this._firebaseAuthInitialized = false;
50
+ this._authReadyResolve = null;
51
+ this._authReady = new Promise((resolve) => {
52
+ this._authReadyResolve = resolve;
53
+ });
54
+
55
+ // The session probe's moments are registered ONCE per instance (#798)
56
+ this._sessionProbeMomentsWired = false;
57
+
58
+ // Initialize modules
59
+ this._storage = new Storage();
60
+ this._utilities = new Utilities(this);
61
+ this._analytics = new Analytics(this);
62
+ this._auth = new Auth(this);
63
+ this._bindings = new Bindings(this);
64
+ this._firestore = new Firestore(this);
65
+ this._notifications = new Notifications(this);
66
+ this._serviceWorker = new ServiceWorker(this);
67
+ this._sentry = new Sentry(this);
68
+ this._device = new Device(this);
69
+ this._verts = new Verts(this);
70
+
71
+ // Harmonized API fetch (omega.request) — fresh Bearer token when signed in,
72
+ // omega-properties processed on every response (server usage → bindings)
73
+ this._request = createRequest({
74
+ getApiUrl: () => this.getApiUrl(),
75
+ getIdToken: (force) => this._firebaseAuth?.currentUser
76
+ ? this._auth.getIdToken(force)
77
+ : null,
78
+ onProperties: (properties) => mergeUsageIntoBindings(this._bindings, properties),
79
+ // A 401 is the third moment of doubt (#798): the backend refused this
80
+ // token, so the client asks the Auth server whether the session is still
81
+ // there at all. Fire-and-forget: the caller's error is unchanged.
82
+ onUnauthorized: () => this._auth.probeSession(),
83
+ });
84
+ }
85
+
86
+ // Make an API request: `omega.request('/omega/user/token', { method: 'POST', body: {} })`.
87
+ // Route-relative paths resolve through getApiUrl(); pass `auth: false` for public routes,
88
+ // `output: 'complete'` for { status, ok, headers, data, properties }, and
89
+ // `wakeup: true` for a fire-and-forget ping that warms a cold backend.
90
+ request(url, options) {
91
+ return this._request(url, options);
92
+ }
93
+
94
+ // Module getters
95
+ storage() {
96
+ return this._storage;
97
+ }
98
+
99
+ auth() {
100
+ return this._auth;
101
+ }
102
+
103
+ bindings() {
104
+ return this._bindings;
105
+ }
106
+
107
+ firestore() {
108
+ return this._firestore;
109
+ }
110
+
111
+ notifications() {
112
+ return this._notifications;
113
+ }
114
+
115
+ serviceWorker() {
116
+ return this._serviceWorker;
117
+ }
118
+
119
+ sentry() {
120
+ return this._sentry;
121
+ }
122
+
123
+ device() {
124
+ return this._device;
125
+ }
126
+
127
+ analytics() {
128
+ return this._analytics;
129
+ }
130
+
131
+ verts() {
132
+ return this._verts;
133
+ }
134
+
135
+ // DOM utilities
136
+ dom() {
137
+ return domUtils;
138
+ }
139
+
140
+ utilities() {
141
+ return this._utilities;
142
+ }
143
+
144
+ // Initialize the manager
145
+ async initialize(configuration) {
146
+ try {
147
+ // Store configuration as-is
148
+ this.config = this._processConfiguration(configuration);
149
+
150
+ // Set platform and runtime on HTML element
151
+ this._setHtmlDataAttributes();
152
+
153
+ // Initialize Firebase if a config blob is present (presence-driven — matches @omega.js/backend
154
+ // convention). Reads `cloud.config` (the omega.json5 canonical shape — desktop passes its
155
+ // resolved config through) and falls back to nested `firebase.app.config` (the web/extension
156
+ // bridge contract shape).
157
+ // Initialize Firebase only when the resolved config can actually boot the
158
+ // SDK — apiKey is mandatory (init without one crashes with auth/invalid-api-key).
159
+ // Configs carrying only projectId still resolve for getFunctionsUrl derivation.
160
+ if (this._resolveFirebaseConfig()?.apiKey) {
161
+ await this._initializeFirebase();
162
+ } else {
163
+ firebaseLogger.log('Skipped: config has no apiKey (Firebase-less site or empty framework merge blob)');
164
+ }
165
+
166
+ // Initialize Sentry if enabled
167
+ if (this.config.sentry?.enabled) {
168
+ await this._sentry.init(this.config.sentry.config);
169
+ }
170
+
171
+ // Initialize Analytics when the google provider is configured
172
+ // (canonical shape: analytics.providers.google.{id,secret} — C4 cp106a;
173
+ // projectId feeds the cross-surface uuidv5 identity namespace)
174
+ // Web is the exception: its transport is the page's own gtag, so there
175
+ // is no id or secret to require here (#159): the api_secret must never
176
+ // reach a page at all.
177
+ // Desktop's renderer is the other exception: it never sends at all
178
+ // ([#411](https://github.com/Omega-JS-Stack/omega/issues/411)). When the
179
+ // host injected an analytics bridge, every event forwards over IPC to the
180
+ // main process, whose sender owns the one device id, the one session id
181
+ // and the real engagement time — so a bridged renderer initializes with
182
+ // no id and no secret of its own.
183
+ const googleAnalytics = this.config.analytics?.providers?.google;
184
+ const isWebRuntime = this._utilities.getRuntime() === 'web';
185
+ const analyticsBridge = this._resolveAnalyticsBridge();
186
+ if (isWebRuntime || analyticsBridge || (googleAnalytics?.id && googleAnalytics?.secret)) {
187
+ this._analytics.init({
188
+ id: googleAnalytics?.id || null,
189
+ secret: googleAnalytics?.secret,
190
+ projectId: this._resolveFirebaseConfig()?.projectId || this.config.brand?.id || null,
191
+ bridge: analyticsBridge,
192
+ });
193
+ } else {
194
+ analyticsLogger.log('Skipped: missing analytics.providers.google id or secret');
195
+ }
196
+
197
+ // Initialize service worker if enabled — dev included (push/caching are
198
+ // testable locally). Registering at the page's scope ('/', or the base
199
+ // path the site is mounted under — #360) REPLACES whatever worker last
200
+ // claimed it (a different project on the same localhost port), and the
201
+ // worker itself evicts foreign caches on boot. When a
202
+ // project explicitly disables the SW, sweep the origin clean instead so
203
+ // a previous project's worker can't keep serving its stale caches.
204
+ // Only http(s) origins are eligible: file:// (desktop) and
205
+ // chrome-extension:// (extension) pages ship the API but cannot host a
206
+ // page-scope worker, so they skip the branch entirely — no register, no sweep.
207
+ const originProtocol = window.location?.protocol;
208
+
209
+ if (originProtocol === 'http:' || originProtocol === 'https:') {
210
+ if (this.config.serviceWorker?.enabled) {
211
+ this._serviceWorker.register({
212
+ path: this.config.serviceWorker?.config?.path
213
+ });
214
+ } else {
215
+ this._serviceWorker.unregisterAll();
216
+ }
217
+ }
218
+
219
+ // Start version checking if enabled
220
+ if (this.config.refreshNewVersion?.enabled) {
221
+ this._startVersionCheck();
222
+ }
223
+
224
+ // Set up auth event listeners (uses event delegation, no need to wait for DOM)
225
+ this._auth.setupEventListeners();
226
+
227
+ // Set up push notifications
228
+ if (this.config.pushNotifications?.enabled) {
229
+ this._notifications.initialize(this.config.pushNotifications.config);
230
+ }
231
+
232
+ // Initialize Chatsy chat widget if enabled
233
+ const chatsy = this.config.inbound?.chat?.providers?.chatsy;
234
+ if (chatsy?.enabled && chatsy?.agentId) {
235
+ this._initializeChatsy();
236
+ }
237
+
238
+ // Old IE force polyfill
239
+ // await this._loadPolyfillsIfNeeded();
240
+
241
+ // Initialize local device-stats tracking (installed/session/version)
242
+ await this._device.initialize();
243
+
244
+ // Update bindings with config and device data. `device` is the LOCAL
245
+ // stats key — the `usage` key belongs to SERVER usage (seeded on auth
246
+ // settle, refreshed from omega-properties by omega.request()).
247
+ this.bindings().update({
248
+ config: this.config,
249
+ device: this._device.getBindingData(),
250
+ });
251
+
252
+ return this;
253
+ } catch (error) {
254
+ console.error('Manager initialization error:', error);
255
+ throw error;
256
+ }
257
+ }
258
+
259
+ _processConfiguration(configuration) {
260
+ // Default configuration structure
261
+ const defaults = {
262
+ runtime: null, // Auto-detect if not provided (web, browser-extension, electron, node)
263
+ environment: 'production',
264
+ buildTime: Date.now(),
265
+ brand: {
266
+ id: 'brand',
267
+ name: 'Brand',
268
+ description: '',
269
+ type: 'Organization',
270
+ images: {
271
+ brandmark: '',
272
+ wordmark: '',
273
+ combomark: ''
274
+ },
275
+ contact: {
276
+ email: '',
277
+ phone: ''
278
+ },
279
+ address: {}
280
+ },
281
+ auth: {
282
+ enabled: true,
283
+ config: {
284
+ policy: null,
285
+ redirects: {
286
+ authenticated: '/dashboard/account',
287
+ unauthenticated: '/signup'
288
+ }
289
+ }
290
+ },
291
+ firebase: {
292
+ app: {
293
+ enabled: true,
294
+ config: {}
295
+ },
296
+ appCheck: {
297
+ enabled: false,
298
+ config: {
299
+ siteKey: ''
300
+ }
301
+ }
302
+ },
303
+ // Consent (#383) — the region-gated banner. The palette/theme keys the
304
+ // old `cookieConsent` blob carried are gone: the panel paints itself from
305
+ // the --omega-* token sheet, which is the only way it is correct in both
306
+ // color modes. `type` is gone too — the visitor's timezone decides opt-in
307
+ // vs opt-out, never a config key.
308
+ consent: {
309
+ enabled: true,
310
+ config: {
311
+ position: 'bottom-left',
312
+ content: {
313
+ message: 'We use cookies to improve your experience, measure traffic, and personalize marketing. See our { terms }.',
314
+ panelIntro: 'We and our partners use cookies and similar technologies to operate this site, measure how it is used, and personalize marketing. Necessary technologies are always active; the rest are yours to switch on or off, here or later, and a choice takes effect the moment you make it. See our { cookies } and { terms }.',
315
+ accept: 'Accept',
316
+ customize: 'Customize',
317
+ acceptAll: 'Accept all',
318
+ acceptNone: 'Accept none'
319
+ }
320
+ }
321
+ },
322
+ // ONE home (#23): the manager provisions the agent and writes agentId
323
+ // here, and the widget's presentation settings sit beside it — there is
324
+ // no second `chatsy` blob to keep in sync.
325
+ inbound: {
326
+ chat: {
327
+ providers: {
328
+ chatsy: {
329
+ enabled: false,
330
+ agentId: '',
331
+ settings: {
332
+ button: {
333
+ backgroundColor: '#237afc',
334
+ textColor: '#FFFFFF',
335
+ position: 'bottom-right',
336
+ type: 'round',
337
+ icon: 'default',
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ },
344
+ sentry: {
345
+ enabled: true,
346
+ config: {
347
+ dsn: '',
348
+ release: '',
349
+ replaysSessionSampleRate: 0.01,
350
+ replaysOnErrorSampleRate: 0.01
351
+ }
352
+ },
353
+ exitPopup: {
354
+ enabled: true,
355
+ config: {
356
+ timeout: 1000 * 60 * 60 * 4,
357
+ title: 'Want 15% off?',
358
+ message: 'Get 15% off your purchase of our Premium plans.',
359
+ okButton: {
360
+ text: 'Claim 15% Discount',
361
+ link: '/pricing'
362
+ },
363
+ // Social-proof faces above the offer (foot.html renders them);
364
+ // an empty list renders four neutral glyph slots
365
+ avatars: []
366
+ }
367
+ },
368
+ lazyLoading: {
369
+ enabled: true,
370
+ config: {
371
+ selector: '[data-lazy]',
372
+ rootMargin: '50px 0px', // Start loading 50px before element comes into view
373
+ threshold: 0.01, // Trigger when 1% of element is visible
374
+ loadedClass: 'lazy-loaded',
375
+ loadingClass: 'lazy-loading',
376
+ errorClass: 'lazy-error'
377
+ }
378
+ },
379
+ socialSharing: {
380
+ enabled: false,
381
+ config: {
382
+ selector: '[data-social-share]',
383
+ defaultPlatforms: ['facebook', 'twitter', 'linkedin', 'pinterest', 'reddit', 'email', 'copy'],
384
+ buttonClass: '',
385
+ showLabels: false,
386
+ openInNewWindow: true,
387
+ windowWidth: 600,
388
+ windowHeight: 400,
389
+ }
390
+ },
391
+ pushNotifications: {
392
+ enabled: true,
393
+ config: {
394
+ autoRequest: 1000 * 60
395
+ }
396
+ },
397
+ validRedirectHosts: [],
398
+ payment: {
399
+ providers: {},
400
+ products: [],
401
+ },
402
+
403
+ // Non-configurable defaults
404
+ refreshNewVersion: {
405
+ enabled: true,
406
+ config: {
407
+ interval: 1000 * 60 * 60, // Check every hour
408
+ }
409
+ },
410
+ serviceWorker: {
411
+ enabled: true,
412
+ config: {
413
+ path: '/service-worker.js'
414
+ }
415
+ },
416
+ analytics: {
417
+ providers: {
418
+ google: { id: '', secret: '' },
419
+ meta: { id: '' },
420
+ tiktok: { id: '' },
421
+ },
422
+ },
423
+ };
424
+
425
+ // Deep merge configuration with defaults
426
+ const merged = this._deepMerge(defaults, configuration);
427
+
428
+ // Evaluate string expressions for timeout values
429
+ if (merged.exitPopup?.config?.timeout) {
430
+ merged.exitPopup.config.timeout = safeEvaluate(merged.exitPopup.config.timeout);
431
+ }
432
+
433
+ if (merged.pushNotifications?.config?.autoRequest) {
434
+ merged.pushNotifications.config.autoRequest = safeEvaluate(merged.pushNotifications.config.autoRequest);
435
+ }
436
+
437
+ if (merged.refreshNewVersion?.config?.interval) {
438
+ merged.refreshNewVersion.config.interval = safeEvaluate(merged.refreshNewVersion.config.interval);
439
+ }
440
+
441
+ // Calculate buildTimeISO from buildTime
442
+ if (merged.buildTime) {
443
+ merged.buildTimeISO = new Date(merged.buildTime).toISOString();
444
+ }
445
+
446
+ // Return merged configuration
447
+ return merged;
448
+ }
449
+
450
+ _deepMerge(target, source) {
451
+ const output = Object.assign({}, target);
452
+ if (isObject(target) && isObject(source)) {
453
+ Object.keys(source).forEach(key => {
454
+ if (isObject(source[key])) {
455
+ if (!(key in target))
456
+ Object.assign(output, { [key]: source[key] });
457
+ else
458
+ output[key] = this._deepMerge(target[key], source[key]);
459
+ } else {
460
+ Object.assign(output, { [key]: source[key] });
461
+ }
462
+ });
463
+ }
464
+ return output;
465
+
466
+ function isObject(item) {
467
+ return item && typeof item === 'object' && !Array.isArray(item);
468
+ }
469
+ }
470
+
471
+ _setHtmlDataAttributes() {
472
+ // Skip if not in browser environment
473
+ if (typeof document === 'undefined') {
474
+ return;
475
+ }
476
+
477
+ const $html = document.documentElement;
478
+
479
+ // Set platform (OS) - windows, mac, linux, ios, android, chromeos, unknown
480
+ $html.dataset.platform = this._utilities.getPlatform();
481
+
482
+ // Set browser - chrome, firefox, safari, edge, opera, brave
483
+ $html.dataset.browser = this._utilities.getBrowser();
484
+
485
+ // Set runtime - web, browser-extension, electron, node
486
+ $html.dataset.runtime = this._utilities.getRuntime();
487
+
488
+ // Set device - mobile, tablet, desktop
489
+ $html.dataset.device = this._utilities.getDevice();
490
+ }
491
+
492
+ // Resolve the desktop renderer's analytics bridge — the ONE seam that turns
493
+ // this client into a forwarder instead of a sender
494
+ // ([#411](https://github.com/Omega-JS-Stack/omega/issues/411)).
495
+ //
496
+ // INJECTED by the host, never sniffed off a global: @omega.js/desktop's
497
+ // renderer passes its preload's analytics surface as `config.analyticsBridge`
498
+ // when it boots this client. A page that merely happens to carry a
499
+ // `window.desktop` can never bridge a brand's analytics into a void, and web
500
+ // and the extension inject nothing, so they keep every path they have today.
501
+ //
502
+ // An injected value with no `event()` is a broken host, not a runtime
503
+ // condition — it raises rather than quietly falling back to a sender the
504
+ // desktop app must not have.
505
+ _resolveAnalyticsBridge() {
506
+ const bridge = this.config?.analyticsBridge;
507
+
508
+ if (!bridge) {
509
+ return null;
510
+ }
511
+
512
+ if (typeof bridge.event !== 'function') {
513
+ throw new Error('config.analyticsBridge carries no event() — the host must inject its preload\'s analytics surface, or nothing at all');
514
+ }
515
+
516
+ return bridge;
517
+ }
518
+
519
+ // Resolve the Firebase web SDK config blob. `cloud.config` first (canonical
520
+ // omega.json5 role shape — desktop passes its resolved config through), then nested
521
+ // `firebase.app.config` (the web/extension bridge contract shape).
522
+ // A blob only counts when at least one value is non-empty — framework config merges
523
+ // (e.g. UJM's Jekyll chain) inject all-empty-string blobs into Firebase-less sites,
524
+ // and those must resolve to null (no init, no URL derivation).
525
+ _resolveFirebaseConfig() {
526
+ const hasValues = (blob) => !!blob
527
+ && typeof blob === 'object'
528
+ && Object.values(blob).some((value) => value);
529
+
530
+ const cloud = this.config.cloud?.config;
531
+ if (hasValues(cloud)) {
532
+ return cloud;
533
+ }
534
+ const nested = this.config.firebase?.app?.config;
535
+ if (hasValues(nested)) {
536
+ return nested;
537
+ }
538
+ return null;
539
+ }
540
+
541
+ async _initializeFirebase() {
542
+ const firebaseConfig = this._resolveFirebaseConfig();
543
+
544
+ // Dynamically import Firebase v12
545
+ const { initializeApp } = await import('firebase/app');
546
+ const { getAuth, onAuthStateChanged } = await import('firebase/auth');
547
+ const { initializeFirestore } = await import('firebase/firestore');
548
+ const { getMessaging } = await import('firebase/messaging');
549
+
550
+ // If we're in devmode, set the firebase config authDomain to the current host
551
+ // if (this.isDevelopment() && firebaseConfig) {
552
+ // firebaseConfig.authDomain = window.location.hostname;
553
+ // }
554
+
555
+ // Initialize Firebase. Re-init guards: if there's already a [DEFAULT] app
556
+ // (live reload, re-init in tests), get the existing one rather than throwing
557
+ // `app/duplicate-app`.
558
+ const { getApp, getApps } = await import('firebase/app');
559
+ const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);
560
+
561
+ // Store Firebase references
562
+ this._firebaseApp = app;
563
+ this._firebaseAuth = getAuth(app);
564
+ this._firebaseFirestore = initializeFirestore(app, {});
565
+
566
+ // Connect to the local emulator suite in development — ZERO flags (N5): dev mode
567
+ // means LOCAL Firebase, period. environment=development (what `omega dev` injects)
568
+ // auto-connects so dev can mutate data, test rules instantly, and seed the
569
+ // frontend; production builds (environment=production) never connect. There is
570
+ // deliberately NO live-Firebase opt-out for dev — build production locally if you
571
+ // truly need live. Ports come from the resolved dev map when one was provided
572
+ // (N7: the `dev.ports` chrome, then `window.__OMEGA_DEV_PORTS__` for the keys it
573
+ // omits), classic defaults otherwise — and an assumed port says so out loud.
574
+ // Both connects live HERE, immediately after the instances are created: the auth
575
+ // module reads accounts via `manager.firebaseFirestore` directly, so connecting
576
+ // lazily (or in only one module) leaves early reads pointed at LIVE Firebase.
577
+ // Auth warnings banner disabled: it injects a DOM overlay that interferes with
578
+ // page content in automated flows.
579
+ if (this.isDevelopment()) {
580
+ const ports = this._devPorts();
581
+ const authEmulatorUrl = this._authEmulatorUrl();
582
+ this._warnClassicPortAssumption();
583
+ firebaseLogger.log(`Connecting to emulators (auth ${authEmulatorUrl}, firestore :${ports.firestore})`);
584
+ const { connectAuthEmulator } = await import('firebase/auth');
585
+ const { connectFirestoreEmulator } = await import('firebase/firestore');
586
+ connectAuthEmulator(this._firebaseAuth, authEmulatorUrl, { disableWarnings: true });
587
+ connectFirestoreEmulator(this._firebaseFirestore, 'localhost', ports.firestore);
588
+ firebaseLogger.log('Emulators connected');
589
+ }
590
+
591
+ // Only initialize messaging if service workers AND push are supported —
592
+ // getMessaging() floats an unhandled unsupported-browser rejection otherwise
593
+ if ('serviceWorker' in navigator && typeof window !== 'undefined' && 'PushManager' in window) {
594
+ this._firebaseMessaging = getMessaging(app);
595
+ } else {
596
+ console.warn('Service workers or push not available - Firebase Messaging disabled');
597
+ this._firebaseMessaging = null;
598
+ }
599
+
600
+ // Initialize Firebase App Check if enabled
601
+ if (this.config.firebase.appCheck?.enabled) {
602
+ const { initializeAppCheck, ReCaptchaEnterpriseProvider } = await import('firebase/app-check');
603
+ const siteKey = this.config.firebase.appCheck.config.siteKey;
604
+
605
+ if (siteKey) {
606
+ initializeAppCheck(app, {
607
+ provider: new ReCaptchaEnterpriseProvider(siteKey),
608
+ isTokenAutoRefreshEnabled: true
609
+ });
610
+ }
611
+ }
612
+
613
+ // Setup auth state listener
614
+ onAuthStateChanged(this._firebaseAuth, (user) => {
615
+ // Mark auth as initialized and resolve the settler promise on first callback
616
+ if (!this._firebaseAuthInitialized) {
617
+ this._firebaseAuthInitialized = true;
618
+ this._authReadyResolve();
619
+ }
620
+
621
+ // Let auth module handle everything including DOM updates
622
+ this._auth._handleAuthStateChange(user);
623
+
624
+ // Analytics follows auth: the identity on every runtime (user_id =
625
+ // uuidv5(uid, namespace)), plus the login/logout events on the runtimes
626
+ // that own them — web's auth pages fire their own (#328 gap 8)
627
+ this._analytics.handleAuthChange(user);
628
+
629
+ // Update Chatsy with current user
630
+ if (this._chatsy) {
631
+ const resolved = this._auth.getUser();
632
+ this._chatsy.setUser(resolved ? { id: resolved.uid, email: resolved.email, firstName: resolved.displayName, photoURL: resolved.photoURL } : null);
633
+ }
634
+ });
635
+
636
+ // The moments of doubt ([#798](https://github.com/Omega-JS-Stack/omega/issues/798)).
637
+ // Firebase asks the Auth server about the persisted session on page load
638
+ // and at the hourly refresh and never again, so a revoked, disabled or
639
+ // deleted account (or a dev backend whose emulator restarted) keeps this
640
+ // tab signed in until a reload. The tab coming back into view and the
641
+ // network coming back are the two free moments to re-ask; the third is a
642
+ // 401 (wired as the request layer's onUnauthorized dep). No timer: a
643
+ // periodic ping would be a request per open tab for nothing.
644
+ if (!this._sessionProbeMomentsWired) {
645
+ this._sessionProbeMomentsWired = true;
646
+
647
+ // Swallowed at the call site: the probe classifies its own answer, so the
648
+ // only way it rejects is a sign-out that failed, and an event handler is
649
+ // nobody's promise to catch.
650
+ //
651
+ // The extension's background service worker has no document, and a
652
+ // headless host may carry neither
653
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
654
+ document.addEventListener('visibilitychange', () => {
655
+ if (document.visibilityState === 'visible') {
656
+ this._auth.probeSession().catch(() => {});
657
+ }
658
+ });
659
+ }
660
+
661
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
662
+ window.addEventListener('online', () => {
663
+ this._auth.probeSession().catch(() => {});
664
+ });
665
+ }
666
+ }
667
+ }
668
+
669
+ // Getters for Firebase services
670
+ get firebaseApp() { return this._firebaseApp; }
671
+ get firebaseAuth() { return this._firebaseAuth; }
672
+ get firebaseFirestore() { return this._firebaseFirestore; }
673
+ get firebaseMessaging() { return this._firebaseMessaging; }
674
+
675
+ isDevelopment() {
676
+ return this.config.environment === 'development';
677
+ }
678
+
679
+ // The dev port map a page was actually GIVEN (N7), without fallbacks — two
680
+ // channels, and the BAKED CHROME WINS: `config.dev.ports` is written by
681
+ // `omega dev` at render time, so it is the live map of the stack this page
682
+ // was served by. `window.__OMEGA_DEV_PORTS__` is a driver-injected fallback
683
+ // for pages whose chrome carries nothing — a statically built site the
684
+ // devkit e2e harness serves, say — and it must not be able to OVERRIDE the
685
+ // real channel, or a green suite proves only the side channel
686
+ // ([#300](https://github.com/Omega-JS-Stack/omega/issues/300)). Presence of
687
+ // a key means a RESOLVED fact about a live stack; absence means "assume the
688
+ // classics".
689
+ _providedDevPorts() {
690
+ return {
691
+ ...(typeof window !== 'undefined' && window.__OMEGA_DEV_PORTS__ || {}),
692
+ ...(this.config.dev?.ports || {}),
693
+ };
694
+ }
695
+
696
+ _devPorts() {
697
+ return { ...DEV_PORT_FALLBACKS, ...this._providedDevPorts() };
698
+ }
699
+
700
+ // One loud line, dev only, when a port is an ASSUMPTION rather than a
701
+ // resolved fact (#300). Nothing identity-checks what answers on a classic
702
+ // port, so a neighbouring project's emulator holding it reads as an auth
703
+ // mystery (`auth/user-not-found` for hours) instead of a port problem. This
704
+ // says which numbers are guesses, before the first connect.
705
+ _warnClassicPortAssumption() {
706
+ const provided = this._providedDevPorts();
707
+ const assumed = Object.keys(DEV_PORT_FALLBACKS).filter((name) => !provided[name]);
708
+ if (!assumed.length) {
709
+ return;
710
+ }
711
+
712
+ firebaseLogger.warn(
713
+ `No resolved dev port for ${assumed.join(', ')}; assuming the classic ${assumed.map((name) => `${name} :${DEV_PORT_FALLBACKS[name]}`).join(', ')}. `
714
+ + 'If another project\'s emulator holds those ports, this page is talking to IT, not your stack. '
715
+ + 'Boot the backend with `omega dev` (or `omega emulator`) so the resolved map reaches the page.',
716
+ );
717
+ }
718
+
719
+ // Where the dev WEBSITE answers — the one shared answer for every surface
720
+ // that links to it in dev (#262). A resolved fact when the stack published
721
+ // one: `omega dev` puts its origin in the same `dev` map as the ports, and
722
+ // web reads it from the page chrome while desktop/extension read it from
723
+ // their build-time bake of that same map. Protocol is part of the fact —
724
+ // the dev server fronts its public port with the mkcert proxy by default,
725
+ // so a port alone would still be a guess about the scheme.
726
+ getDevWebsiteOrigin() {
727
+ const provided = this.config.dev?.origin;
728
+
729
+ if (provided) {
730
+ return provided;
731
+ }
732
+
733
+ this._warnClassicOriginAssumption();
734
+ return DEV_ORIGIN_FALLBACK;
735
+ }
736
+
737
+ // The origin's half of the classic-assumption warning (#300's pattern, #262):
738
+ // one loud line when the answer is a guess rather than a published fact,
739
+ // because a wrong dev origin fails as a silent connection refusal.
740
+ _warnClassicOriginAssumption() {
741
+ firebaseLogger.warn(
742
+ `No resolved dev website origin; assuming the classic ${DEV_ORIGIN_FALLBACK}. `
743
+ + 'If your `omega dev` bumped its port (or runs without mkcert), this is the wrong origin. '
744
+ + 'Boot the website with `omega dev` so the resolved origin reaches this surface.',
745
+ );
746
+ }
747
+
748
+ // Where the auth emulator answers FROM THE BROWSER'S POINT OF VIEW (#156).
749
+ // A surface whose dev server proxies the emulator under its own origin says
750
+ // so with `dev.authEmulatorProxy` — and then the emulator URL must be that
751
+ // origin, so the OAuth handler and the SDK's helper iframe are first-party
752
+ // and the redirect credential survives storage partitioning. Every other
753
+ // surface (desktop, extension, a page with no proxy) keeps talking straight
754
+ // to the emulator's own port.
755
+ // Origin only, never a path: connectAuthEmulator() discards any path on the
756
+ // URL it is handed, so the proxy has to be mounted at the site root.
757
+ _authEmulatorUrl() {
758
+ if (this.config.dev?.authEmulatorProxy && typeof window !== 'undefined' && window.location?.origin) {
759
+ return window.location.origin;
760
+ }
761
+
762
+ return `http://localhost:${this._devPorts().auth}`;
763
+ }
764
+
765
+ getFunctionsUrl(environment) {
766
+ const env = environment || this.config.environment;
767
+ const projectId = this._resolveFirebaseConfig()?.projectId;
768
+
769
+ if (!projectId) {
770
+ throw new Error('Firebase project ID not configured');
771
+ }
772
+
773
+ if (env === 'development') {
774
+ return `http://localhost:${this._devPorts().functions}/${projectId}/us-central1`;
775
+ }
776
+
777
+ return `https://us-central1-${projectId}.cloudfunctions.net`;
778
+ }
779
+
780
+ getApiUrl(environment, url) {
781
+ // Precedence: passed environment > query string > config.environment
782
+ const searchParams = new URLSearchParams(window.location.search);
783
+ const queryEnv = searchParams.get('_dev_apiEnvironment');
784
+ const env = environment
785
+ || queryEnv
786
+ || this.config.environment;
787
+
788
+ if (env === 'development') {
789
+ // Scheme follows what the provided dev map says is actually running (N7):
790
+ // - `https` key → `mgr serve`'s mkcert proxy (it owns publishing that key).
791
+ // - `hosting` key → an allocator-booted emulator stack; the hosting
792
+ // emulator speaks plain http on 127.0.0.1 (rewrites /omega/** to the API).
793
+ // - no map → classic assumption: `mgr serve`'s HTTPS proxy on 5002
794
+ // (since @omega.js/backend 5.7.0) — plain http:// cannot connect to it.
795
+ const provided = this._providedDevPorts();
796
+ if (provided.https) {
797
+ return `https://localhost:${provided.https}`;
798
+ }
799
+ if (provided.hosting) {
800
+ return `http://127.0.0.1:${provided.hosting}`;
801
+ }
802
+ return 'https://localhost:5002';
803
+ }
804
+
805
+ // The API rides the BRAND domain (api.<brand host>). Never derive from
806
+ // authDomain: it is an auth concern (the brand host, with /__/auth/*
807
+ // self-hosted at build time) and its value must stay free to change
808
+ // without moving the API base.
809
+ const brandUrl = this.config.brand?.url; // schema enforces an http(s) URL
810
+ const baseUrl = url || brandUrl || window.location.origin;
811
+
812
+ // Prepend 'api.' subdomain, hostname-only (playground.omegajs.dev ->
813
+ // api.playground.omegajs.dev) — any path/port on brand.url is dropped,
814
+ // exactly like the desktop/extension url-helpers mirrors.
815
+ return `https://api.${new URL(baseUrl).hostname}`;
816
+ }
817
+
818
+ isValidRedirectUrl(url) {
819
+ try {
820
+ const currentUrlObject = new URL(window.location.href);
821
+ const decoded = decodeURIComponent(url);
822
+
823
+ // Path-relative values ('/pricing') resolve against the page origin so they
824
+ // reach the checks below as an absolute URL instead of throwing and falling
825
+ // back to the policy default. Only a leading '/' counts: anything else must
826
+ // parse as an absolute URL on its own, so garbage ('not-a-url') still fails.
827
+ // A protocol-relative value ('//evil.com') resolves to its own host and is
828
+ // then rejected by the same-host check, exactly like the absolute form.
829
+ const returnUrlObject = decoded.startsWith('/')
830
+ ? new URL(decoded, currentUrlObject.origin)
831
+ : new URL(decoded);
832
+
833
+ // Loopback returns (RFC 8252 §7.3) are valid while the SITE runs in development:
834
+ // native apps (Electron Manager) can't OS-register their custom scheme in dev, so
835
+ // their sign-in flow returns to an ephemeral 127.0.0.1 listener instead. Any port —
836
+ // the app binds it at flow start. Production sites never match this branch.
837
+ if (this.isDevelopment() && ['127.0.0.1', '[::1]', 'localhost'].includes(returnUrlObject.hostname)) {
838
+ return true;
839
+ }
840
+
841
+ return returnUrlObject.host === currentUrlObject.host
842
+ || returnUrlObject.protocol === `${this.config.brand?.id}:`
843
+ || (this.config.validRedirectHosts || []).includes(returnUrlObject.host);
844
+ } catch (e) {
845
+ return false;
846
+ }
847
+ }
848
+
849
+ // The web build's public config filter inlines `settings: null` when a
850
+ // brand sets none, and Chatsy's constructor rejects a null blob — omit the
851
+ // key instead so the widget applies its own defaults (#377).
852
+ _chatsyOptions(config) {
853
+ return config.settings ? { settings: config.settings } : {};
854
+ }
855
+
856
+ async _initializeChatsy() {
857
+ try {
858
+ const { default: Chatsy } = await import('chatsy');
859
+ const config = this.config.inbound.chat.providers.chatsy;
860
+
861
+ this._chatsy = new Chatsy(config.agentId, this._chatsyOptions(config));
862
+
863
+ chatsyLogger.log('Initialized');
864
+ } catch (error) {
865
+ chatsyLogger.error('Failed to initialize:', error);
866
+ }
867
+ }
868
+
869
+ _startVersionCheck() {
870
+ // Quit if window is not available
871
+ if (typeof window !== 'undefined') {
872
+ // Re-focus events
873
+ window.addEventListener('focus', () => {
874
+ this._checkVersion();
875
+ });
876
+
877
+ window.addEventListener('online', () => {
878
+ this._checkVersion();
879
+ });
880
+ }
881
+
882
+ // Set up interval — re-initializing replaces the timer, never stacks a
883
+ // second one on top of the first
884
+ clearInterval(this._versionCheckInterval);
885
+ this._versionCheckInterval = setInterval(() => {
886
+ this._checkVersion();
887
+ }, this.config.refreshNewVersion.config.interval);
888
+ }
889
+
890
+
891
+ // async _loadPolyfillsIfNeeded() {
892
+ // // Check if polyfills are needed by testing for ES6 features
893
+ // const featuresPass = (
894
+ // typeof Symbol !== 'undefined'
895
+ // );
896
+
897
+ // // If all features are supported, no polyfills needed
898
+ // if (featuresPass) {
899
+ // return;
900
+ // }
901
+
902
+ // // Load polyfills for older browsers (especially IE)
903
+ // try {
904
+ // await domUtils.loadScript({
905
+ // src: 'https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?flags=always%2Cgated&features=default%2Ces5%2Ces6%2Ces7%2CPromise.prototype.finally%2C%7Ehtml5-elements%2ClocalStorage%2Cfetch%2CURLSearchParams',
906
+ // crossorigin: 'anonymous'
907
+ // });
908
+ // console.log('Polyfills loaded for older browser');
909
+ // } catch (error) {
910
+ // console.error('Failed to load polyfills:', error);
911
+ // // Continue initialization even if polyfills fail to load
912
+ // }
913
+ // }
914
+
915
+ async _checkVersion() {
916
+ if (this.isDevelopment()) {
917
+ /* @dev-only:start */
918
+ {
919
+ versionLogger.log('Skipping version check in development mode');
920
+ }
921
+ /* @dev-only:end */
922
+ return;
923
+ }
924
+
925
+ try {
926
+ // The manifest is served from the site's own mount (#364): under a URL
927
+ // path (#355) a root-relative fetch lands off-site and 404s forever. No
928
+ // stamp means the domain root and an unchanged URL.
929
+ const response = await fetch(`${pathPrefix()}/build.json?cb=${Date.now()}`);
930
+ if (!response.ok) {
931
+ throw new Error(`Failed to fetch build.json (${response.status})`);
932
+ }
933
+
934
+ const data = await response.json();
935
+ if (!data.timestamp) {
936
+ throw new Error('No timestamp found in build.json');
937
+ }
938
+
939
+ const buildTimeLive = new Date(data.timestamp);
940
+ const buildTimeCurrent = new Date(this.config.buildTime);
941
+
942
+ // Add 1 hour to current build time to account for npm build process
943
+ buildTimeCurrent.setHours(buildTimeCurrent.getHours() + 1);
944
+
945
+ // Log version info
946
+ versionLogger.log(`Current build time: ${buildTimeCurrent.toISOString()}, Live build time: ${buildTimeLive.toISOString()}`);
947
+
948
+ // If live version is newer, reload the page
949
+ if (buildTimeCurrent >= buildTimeLive) {
950
+ return; // No update needed
951
+ }
952
+
953
+ // New version detected
954
+ versionLogger.log('New version detected, reloading page...');
955
+
956
+ // If running in a non-browser environment, warn and return
957
+ if (typeof window === 'undefined') {
958
+ versionLogger.warn('Cannot reload in non-browser environment');
959
+ return;
960
+ }
961
+
962
+ // Force page reload
963
+ window.onbeforeunload = function () {
964
+ return undefined;
965
+ };
966
+
967
+ window.location.reload(true);
968
+ } catch (error) {
969
+ versionLogger.warn('Failed version check:', error);
970
+ }
971
+ }
972
+ }
973
+
974
+ // Safely evaluate timeout string expressions
975
+ const safeEvaluate = (str) => {
976
+ if (typeof str !== 'string') return str;
977
+
978
+ // Only allow numbers, *, +, -, /, parentheses, and whitespace
979
+ if (!/^[\d\s\*\+\-\/\(\)]+$/.test(str)) {
980
+ console.warn('Invalid expression format:', str);
981
+ return str;
982
+ }
983
+
984
+ try {
985
+ // Use Function constructor instead of eval for safer evaluation
986
+ return new Function(`return ${str}`)();
987
+ } catch (e) {
988
+ console.warn('Failed to evaluate expression:', str, e);
989
+ return str;
990
+ }
991
+ };
992
+
993
+ // Create singleton instance
994
+ const manager = new Manager();
995
+
996
+ // Export for different environments
997
+ export default manager;
998
+ export { Manager };
999
+