@flamingo-stack/openframe-frontend-core 0.0.234 → 0.0.235-snapshot.20260609090033

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 (100) hide show
  1. package/dist/{chunk-Z46NRJWW.js → chunk-265KKBV2.js} +2 -2
  2. package/dist/{chunk-BOF5MSRY.js → chunk-2LEMRJ7Z.js} +3 -3
  3. package/dist/{chunk-EO3RG6II.cjs → chunk-7SOVT7RJ.cjs} +16 -16
  4. package/dist/{chunk-EO3RG6II.cjs.map → chunk-7SOVT7RJ.cjs.map} +1 -1
  5. package/dist/{chunk-YZDUOUMB.js → chunk-AGU44B7B.js} +2 -2
  6. package/dist/{chunk-DHCGJW3G.js → chunk-AZGRKZJ7.js} +10 -6
  7. package/dist/chunk-AZGRKZJ7.js.map +1 -0
  8. package/dist/{chunk-W7P7RH2X.cjs → chunk-BXMMMSIK.cjs} +1278 -1274
  9. package/dist/chunk-BXMMMSIK.cjs.map +1 -0
  10. package/dist/{chunk-IBSZ27Y7.cjs → chunk-E3VQ53IM.cjs} +7 -7
  11. package/dist/{chunk-IBSZ27Y7.cjs.map → chunk-E3VQ53IM.cjs.map} +1 -1
  12. package/dist/chunk-FIG2RKZF.cjs +169 -0
  13. package/dist/chunk-FIG2RKZF.cjs.map +1 -0
  14. package/dist/{chunk-E6B4B7GM.cjs → chunk-GRTZ23GL.cjs} +49 -49
  15. package/dist/{chunk-E6B4B7GM.cjs.map → chunk-GRTZ23GL.cjs.map} +1 -1
  16. package/dist/{chunk-F6F525T7.js → chunk-HGYOMQM3.js} +2 -2
  17. package/dist/{chunk-GSD52WAS.js → chunk-JNXFCV7K.js} +4 -4
  18. package/dist/{chunk-D3S2ODUI.js → chunk-KIGEKMBB.js} +4 -4
  19. package/dist/{chunk-GYRRSOTR.cjs → chunk-LPJPTBRI.cjs} +38 -38
  20. package/dist/{chunk-GYRRSOTR.cjs.map → chunk-LPJPTBRI.cjs.map} +1 -1
  21. package/dist/{chunk-UYWJM2LS.cjs → chunk-NOS6DCYY.cjs} +12 -12
  22. package/dist/{chunk-UYWJM2LS.cjs.map → chunk-NOS6DCYY.cjs.map} +1 -1
  23. package/dist/{chunk-LLTUSEHF.js → chunk-Q3GY66ST.js} +3 -3
  24. package/dist/chunk-XTCBRQN2.js +169 -0
  25. package/dist/chunk-XTCBRQN2.js.map +1 -0
  26. package/dist/{chunk-MWW7LKDN.cjs → chunk-Z4IBEIWD.cjs} +8 -8
  27. package/dist/{chunk-MWW7LKDN.cjs.map → chunk-Z4IBEIWD.cjs.map} +1 -1
  28. package/dist/{chunk-52PRKXK4.cjs → chunk-ZP2DKI5P.cjs} +32 -32
  29. package/dist/{chunk-52PRKXK4.cjs.map → chunk-ZP2DKI5P.cjs.map} +1 -1
  30. package/dist/components/chat/index.cjs +10 -6
  31. package/dist/components/chat/index.cjs.map +1 -1
  32. package/dist/components/chat/index.d.ts +1 -0
  33. package/dist/components/chat/index.d.ts.map +1 -1
  34. package/dist/components/chat/index.js +9 -5
  35. package/dist/components/contact/index.cjs +7 -7
  36. package/dist/components/contact/index.js +6 -6
  37. package/dist/components/features/index.cjs +6 -6
  38. package/dist/components/features/index.js +5 -5
  39. package/dist/components/icons/index.cjs +3 -3
  40. package/dist/components/icons/index.js +2 -2
  41. package/dist/components/index.cjs +241 -238
  42. package/dist/components/index.cjs.map +1 -1
  43. package/dist/components/index.js +13 -10
  44. package/dist/components/index.js.map +1 -1
  45. package/dist/components/navigation/index.cjs +6 -6
  46. package/dist/components/navigation/index.js +5 -5
  47. package/dist/components/onboarding-guides/index.cjs +30 -30
  48. package/dist/components/onboarding-guides/index.js +7 -7
  49. package/dist/components/resizable.d.ts +1 -1
  50. package/dist/components/tickets/index.cjs +89 -89
  51. package/dist/components/tickets/index.js +8 -8
  52. package/dist/components/ui/file-manager/index.cjs +69 -69
  53. package/dist/components/ui/file-manager/index.js +2 -2
  54. package/dist/components/ui/index.cjs +10 -6
  55. package/dist/components/ui/index.cjs.map +1 -1
  56. package/dist/components/ui/index.js +9 -5
  57. package/dist/components/vendor-display-button.d.ts.map +1 -1
  58. package/dist/hooks/index.cjs +4 -4
  59. package/dist/hooks/index.js +3 -3
  60. package/dist/index.cjs +10 -6
  61. package/dist/index.cjs.map +1 -1
  62. package/dist/index.js +9 -5
  63. package/dist/platform-domains.cjs +149 -0
  64. package/dist/platform-domains.cjs.map +1 -0
  65. package/dist/platform-domains.d.ts +106 -0
  66. package/dist/platform-domains.d.ts.map +1 -0
  67. package/dist/platform-domains.js +134 -0
  68. package/dist/platform-domains.js.map +1 -0
  69. package/dist/types/index.cjs +87 -0
  70. package/dist/types/index.cjs.map +1 -1
  71. package/dist/types/index.js +87 -0
  72. package/dist/types/index.js.map +1 -1
  73. package/dist/utils/cn.d.ts +8 -43
  74. package/dist/utils/cn.d.ts.map +1 -1
  75. package/dist/utils/index.cjs +113 -72
  76. package/dist/utils/index.cjs.map +1 -1
  77. package/dist/utils/index.d.ts +3 -2
  78. package/dist/utils/index.d.ts.map +1 -1
  79. package/dist/utils/index.js +113 -72
  80. package/dist/utils/index.js.map +1 -1
  81. package/package.json +7 -1
  82. package/src/__tests__/platform-domains.test.ts +189 -0
  83. package/src/components/chat/index.ts +1 -0
  84. package/src/components/vendor-display-button.tsx +4 -3
  85. package/src/platform-domains.ts +299 -0
  86. package/src/utils/cn.ts +18 -134
  87. package/src/utils/index.ts +7 -2
  88. package/dist/chunk-2TBN53KY.cjs +0 -122
  89. package/dist/chunk-2TBN53KY.cjs.map +0 -1
  90. package/dist/chunk-DHCGJW3G.js.map +0 -1
  91. package/dist/chunk-W7P7RH2X.cjs.map +0 -1
  92. package/dist/chunk-YICWWZ3Z.js +0 -122
  93. package/dist/chunk-YICWWZ3Z.js.map +0 -1
  94. /package/dist/{chunk-Z46NRJWW.js.map → chunk-265KKBV2.js.map} +0 -0
  95. /package/dist/{chunk-BOF5MSRY.js.map → chunk-2LEMRJ7Z.js.map} +0 -0
  96. /package/dist/{chunk-YZDUOUMB.js.map → chunk-AGU44B7B.js.map} +0 -0
  97. /package/dist/{chunk-F6F525T7.js.map → chunk-HGYOMQM3.js.map} +0 -0
  98. /package/dist/{chunk-GSD52WAS.js.map → chunk-JNXFCV7K.js.map} +0 -0
  99. /package/dist/{chunk-D3S2ODUI.js.map → chunk-KIGEKMBB.js.map} +0 -0
  100. /package/dist/{chunk-LLTUSEHF.js.map → chunk-Q3GY66ST.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.234",
3
+ "version": "0.0.235-snapshot.20260609090033",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -66,6 +66,12 @@
66
66
  "require": "./dist/schemas/contact-schema.cjs",
67
67
  "default": "./dist/schemas/contact-schema.js"
68
68
  },
69
+ "./platform-domains": {
70
+ "types": "./dist/platform-domains.d.ts",
71
+ "import": "./dist/platform-domains.js",
72
+ "require": "./dist/platform-domains.cjs",
73
+ "default": "./dist/platform-domains.js"
74
+ },
69
75
  "./components/ui/file-manager": {
70
76
  "types": "./dist/components/ui/file-manager/index.d.ts",
71
77
  "import": "./dist/components/ui/file-manager/index.js",
@@ -0,0 +1,189 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ PLATFORM_DOMAINS,
4
+ byKey,
5
+ getPlatformProductionUrl,
6
+ getPlatformByHostname,
7
+ getAllPlatformBaseDomains,
8
+ hostOf,
9
+ expandWwwApex,
10
+ toRegistrableBaseDomain,
11
+ aliasHostsOf,
12
+ ensureScheme,
13
+ isNonCookieableHost,
14
+ matchCookieDomain,
15
+ } from '@/platform-domains'
16
+
17
+ /**
18
+ * Helper: run a fn with a stubbed `window.location.hostname` (prod, non-localhost,
19
+ * non-vercel) so getAllPlatformBaseDomains exercises its production branch.
20
+ */
21
+ function withProdWindow<T>(hostname: string, fn: () => T): T {
22
+ vi.stubGlobal('window', { location: { hostname } })
23
+ try {
24
+ return fn()
25
+ } finally {
26
+ vi.unstubAllGlobals()
27
+ }
28
+ }
29
+
30
+ afterEach(() => {
31
+ vi.unstubAllGlobals()
32
+ vi.unstubAllEnvs()
33
+ })
34
+
35
+ describe('platform-domains — forward resolution (defaults = byte-identical to the old cn.ts switch)', () => {
36
+ it('returns each defaultUrl when no env override is set', () => {
37
+ for (const entry of PLATFORM_DOMAINS) {
38
+ expect(getPlatformProductionUrl(entry.key)).toBe(entry.defaultUrl)
39
+ }
40
+ })
41
+ it('falls back to flamingo.run for an unknown key (old default case)', () => {
42
+ expect(getPlatformProductionUrl('not-a-platform')).toBe('https://www.flamingo.run')
43
+ })
44
+ })
45
+
46
+ describe('🔒 cross-subdomain cookie sharing (the SSO invariant — must never regress)', () => {
47
+ // With NO NEXT_PUBLIC_*_URL overrides set (today's production reality), the defaults
48
+ // alone MUST still produce the registrable base domains that scope the auth cookie
49
+ // across every hub subdomain. This is exactly what pure-env-only would have broken.
50
+ it('emits .flamingo.so / .flamingo.run / .openmsp.ai / .tmcg.miami / .openframe.ai from defaults', () => {
51
+ const bases = withProdWindow('company-hub.flamingo.so', getAllPlatformBaseDomains)
52
+ for (const must of ['.flamingo.so', '.flamingo.run', '.openmsp.ai', '.tmcg.miami', '.openframe.ai']) {
53
+ expect(bases).toContain(must)
54
+ }
55
+ })
56
+ it('shares one base domain across all *.flamingo.so hubs (cross-hub SSO)', () => {
57
+ for (const hub of ['marketing-hub', 'company-hub', 'product-hub', 'revenue-hub', 'people-hub']) {
58
+ expect(toRegistrableBaseDomain(hostOf(getPlatformProductionUrl(hub))!)).toBe('flamingo.so')
59
+ }
60
+ })
61
+ it('keeps flamingo.cx OUT of the cookie set (alias excluded) but IN the reverse map', () => {
62
+ const bases = withProdWindow('www.flamingo.run', getAllPlatformBaseDomains)
63
+ expect(bases).not.toContain('.flamingo.cx')
64
+ expect(getPlatformByHostname('flamingo.cx')).toBe('flamingo-teaser')
65
+ })
66
+ it('returns [] for localhost and .vercel.app (host-only cookies)', () => {
67
+ expect(withProdWindow('localhost', getAllPlatformBaseDomains)).toEqual([])
68
+ expect(withProdWindow('foo.vercel.app', getAllPlatformBaseDomains)).toEqual(['.vercel.app', 'vercel.app'])
69
+ })
70
+ })
71
+
72
+ describe('reverse resolver (first-wins ordering + aliases)', () => {
73
+ it.each([
74
+ ['www.flamingo.run', 'flamingo'],
75
+ ['flamingo.run', 'flamingo'],
76
+ ['flamingo.cx', 'flamingo-teaser'],
77
+ ['www.flamingo.cx', 'flamingo-teaser'],
78
+ ['openframe.ai', 'openframe'],
79
+ ['www.openframe.ai', 'openframe'],
80
+ ['hub.openframe.ai', 'openframe'], // additive fix — was null in the old PLATFORM_DOMAIN_MAP
81
+ ['marketing-hub.flamingo.so', 'marketing-hub'],
82
+ ['WWW.OPENMSP.AI', 'openmsp'], // case-insensitive
83
+ ['evil.com', null],
84
+ ])('%s → %s', (host, expected) => {
85
+ expect(getPlatformByHostname(host)).toBe(expected)
86
+ })
87
+ })
88
+
89
+ describe('host primitives', () => {
90
+ it('hostOf strips scheme + port, lowercases, null on garbage', () => {
91
+ expect(hostOf('https://WWW.Openmsp.ai:8443/x')).toBe('www.openmsp.ai')
92
+ expect(hostOf('not a url')).toBeNull()
93
+ expect(hostOf(null)).toBeNull()
94
+ })
95
+ it('expandWwwApex handles www / apex / 3-label / 1-label', () => {
96
+ expect(expandWwwApex('www.openmsp.ai')).toEqual(['www.openmsp.ai', 'openmsp.ai'])
97
+ expect(expandWwwApex('openmsp.ai')).toEqual(['openmsp.ai', 'www.openmsp.ai'])
98
+ expect(expandWwwApex('hub.openframe.ai')).toEqual(['hub.openframe.ai'])
99
+ expect(expandWwwApex('localhost')).toEqual(['localhost'])
100
+ })
101
+ it('toRegistrableBaseDomain + aliasHostsOf', () => {
102
+ expect(toRegistrableBaseDomain('marketing-hub.flamingo.so')).toBe('flamingo.so')
103
+ expect(toRegistrableBaseDomain('localhost')).toBeUndefined()
104
+ expect(aliasHostsOf('flamingo-teaser')).toEqual(['flamingo.cx', 'www.flamingo.cx'])
105
+ expect(aliasHostsOf('openmsp')).toEqual([])
106
+ })
107
+ })
108
+
109
+ describe('env override path', () => {
110
+ // ENV_OVERRIDES captures `process.env.NEXT_PUBLIC_*` at MODULE LOAD — required so the
111
+ // values are build-inlined (literal keys) in the browser bundle. So an override must be
112
+ // set BEFORE the module evaluates: stub env → resetModules → dynamic import.
113
+ afterEach(() => {
114
+ vi.unstubAllEnvs()
115
+ vi.resetModules()
116
+ })
117
+ it('an override wins over the default (per-distinct-var, no cross-contamination)', async () => {
118
+ vi.stubEnv('NEXT_PUBLIC_TMCG_URL', 'https://sentinel-tmcg.example')
119
+ vi.resetModules()
120
+ const mod = await import('@/platform-domains')
121
+ expect(mod.getPlatformProductionUrl('tmcg')).toBe('https://sentinel-tmcg.example')
122
+ // a non-sharing key is unaffected
123
+ expect(mod.getPlatformProductionUrl('openmsp')).toBe('https://www.openmsp.ai')
124
+ })
125
+ it('the shared NEXT_PUBLIC_FLAMINGO_URL drives all three of its keys', async () => {
126
+ vi.stubEnv('NEXT_PUBLIC_FLAMINGO_URL', 'https://sentinel-flamingo.example')
127
+ vi.resetModules()
128
+ const mod = await import('@/platform-domains')
129
+ for (const k of ['flamingo', 'flamingo-teaser', 'universal']) {
130
+ expect(mod.getPlatformProductionUrl(k)).toBe('https://sentinel-flamingo.example')
131
+ }
132
+ })
133
+ it('normalizes a SCHEME-LESS override to https:// (Vercel stores bare hosts)', async () => {
134
+ vi.stubEnv('NEXT_PUBLIC_OPENMSP_URL', 'www.openmsp.ai') // no scheme, as in the shared-env store
135
+ vi.stubEnv('NEXT_PUBLIC_OPENFRAME_URL', 'hub.openframe.ai')
136
+ vi.resetModules()
137
+ const mod = await import('@/platform-domains')
138
+ expect(mod.getPlatformProductionUrl('openmsp')).toBe('https://www.openmsp.ai')
139
+ expect(mod.getPlatformProductionUrl('openframe')).toBe('https://hub.openframe.ai')
140
+ })
141
+ it('a scheme-less override still parses for hostOf + cookie-base derivation', async () => {
142
+ vi.stubEnv('NEXT_PUBLIC_OPENMSP_URL', 'www.openmsp.ai')
143
+ vi.resetModules()
144
+ const mod = await import('@/platform-domains')
145
+ // the whole point: hostOf no longer returns null on the (normalized) override
146
+ expect(mod.hostOf(mod.getPlatformProductionUrl('openmsp'))).toBe('www.openmsp.ai')
147
+ expect(mod.toRegistrableBaseDomain(mod.hostOf(mod.getPlatformProductionUrl('openmsp'))!)).toBe('openmsp.ai')
148
+ })
149
+ })
150
+
151
+ describe('registry integrity', () => {
152
+ it('byKey resolves every key and openframe-dashboard is pseudo', () => {
153
+ expect(byKey('openframe-dashboard')?.pseudo).toBe(true)
154
+ expect(byKey('not-a-platform')).toBeUndefined()
155
+ })
156
+ })
157
+
158
+ describe('ensureScheme', () => {
159
+ it('adds https:// to a bare host, leaves a scheme\'d URL untouched', () => {
160
+ expect(ensureScheme('www.openmsp.ai')).toBe('https://www.openmsp.ai')
161
+ expect(ensureScheme('https://www.openmsp.ai')).toBe('https://www.openmsp.ai')
162
+ expect(ensureScheme('http://localhost:3000')).toBe('http://localhost:3000')
163
+ })
164
+ it('normalizes a protocol-relative //host (NOT https:////host → empty-host drop)', () => {
165
+ expect(ensureScheme('//hub.openframe.ai')).toBe('https://hub.openframe.ai')
166
+ expect(hostOf(ensureScheme('//hub.openframe.ai'))).toBe('hub.openframe.ai')
167
+ })
168
+ })
169
+
170
+ describe('cookie-domain guard + match (shared SSOT primitives)', () => {
171
+ it('isNonCookieableHost flags localhost / private IPs / *.vercel.app, not real hosts', () => {
172
+ for (const h of ['localhost', '127.0.0.1', '127.5.5.5', '192.168.1.2', '10.0.0.1', 'x.vercel.app']) {
173
+ expect(isNonCookieableHost(h)).toBe(true)
174
+ }
175
+ for (const h of ['www.openmsp.ai', 'marketing-hub.flamingo.so', 'hub.openframe.ai']) {
176
+ expect(isNonCookieableHost(h)).toBe(false)
177
+ }
178
+ })
179
+ it('matchCookieDomain returns the dotted base for a contained host (incl. apex), else undefined', () => {
180
+ const bases = ['.flamingo.so', '.openmsp.ai']
181
+ expect(matchCookieDomain('marketing-hub.flamingo.so', bases)).toBe('.flamingo.so')
182
+ expect(matchCookieDomain('flamingo.so', bases)).toBe('.flamingo.so')
183
+ expect(matchCookieDomain('app.openmsp.ai', bases)).toBe('.openmsp.ai')
184
+ expect(matchCookieDomain('evil.com', bases)).toBeUndefined()
185
+ })
186
+ it('matchCookieDomain accepts dotless bases and still returns the dotted form', () => {
187
+ expect(matchCookieDomain('foo.tmcg.miami', ['tmcg.miami'])).toBe('.tmcg.miami')
188
+ })
189
+ })
@@ -13,6 +13,7 @@ export * from './slash-command-suggestions'
13
13
  export * from './chat-message-enhanced'
14
14
  export * from './chat-message-list'
15
15
  export * from './chat-message-row'
16
+ export * from './chat-message-skeleton'
16
17
 
17
18
  export * from './chat-quick-action'
18
19
  export * from './chat-ticket-list'
@@ -15,9 +15,10 @@ interface VendorDisplayButtonProps {
15
15
  export function VendorDisplayButton({ vendor, onClick, variant = 'default', externalUrl }: VendorDisplayButtonProps) {
16
16
  const handleClick = () => {
17
17
  if (externalUrl && vendor.slug) {
18
- // Use environment variable or fallback to provided URL
19
- const baseUrl = process.env.NEXT_PUBLIC_OPENMSP_URL || externalUrl
20
- window.open(`${baseUrl}/vendor/${vendor.slug}`, '_blank', 'noopener,noreferrer')
18
+ // `externalUrl` is the caller-resolved platform base URL (the openmsp SSOT via
19
+ // getPlatformProductionUrl, scheme-normalized). The old `process.env.NEXT_PUBLIC_OPENMSP_URL`
20
+ // override is gone — it's stored scheme-less, which made this a relative window.open().
21
+ window.open(`${externalUrl}/vendor/${vendor.slug}`, '_blank', 'noopener,noreferrer')
21
22
  } else if (onClick && vendor.slug) {
22
23
  onClick(vendor.slug)
23
24
  }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Platform-Domain SSOT (single source of truth) + derivations.
3
+ *
4
+ * ONE registry maps each platform → its canonical production URL (`defaultUrl`,
5
+ * the load-bearing source) with an optional per-deploy `NEXT_PUBLIC_*_URL`
6
+ * OVERRIDE. Everything else — the reverse host→platform resolver, the cookie
7
+ * base-domain set (the cross-subdomain SSO mechanism), www/apex expansion, the
8
+ * URL→host parse, preview detection — derives from this one table.
9
+ *
10
+ * EDGE-SAFE + PURE: no React/clsx/tailwind, no `server-only`, no `node:`
11
+ * builtins, no `'use client'`. So it is legal in the Edge middleware
12
+ * (`proxy.ts`), in `'use client'` providers, AND in `server-only` modules
13
+ * (e.g. cookie-domain-server.ts) simultaneously. The ONLY non-pure export is
14
+ * `getAllPlatformBaseDomains`, which reads `typeof window`/`process.env` to
15
+ * preserve byte-identical cookie behavior.
16
+ */
17
+
18
+ import type { PlatformName } from './types/platform'
19
+
20
+ export type PlatformDomainKey = PlatformName | 'openframe-dashboard'
21
+
22
+ export interface PlatformDomainEntry {
23
+ /** Platform key (matches `PlatformName`, plus the forward-only `openframe-dashboard`). */
24
+ key: PlatformDomainKey
25
+ /** Canonical production URL — the LOAD-BEARING source of truth (today's hardcoded fallbacks). */
26
+ defaultUrl: string
27
+ /** `NEXT_PUBLIC_*_URL` per-deploy OVERRIDE. The `defaultUrl` covers it when unset. */
28
+ envVar: string
29
+ /** Legacy/secondary hosts that REVERSE-map to this key (no env var exists — NOT canonical). */
30
+ aliasHostnames?: string[]
31
+ /** Forward-only: no DB row, excluded from the reverse index + cookie set (e.g. the product-CTA dashboard). */
32
+ pseudo?: boolean
33
+ }
34
+
35
+ export const PLATFORM_DOMAINS: readonly PlatformDomainEntry[] = [
36
+ { key: 'marketing-hub', defaultUrl: 'https://marketing-hub.flamingo.so', envVar: 'NEXT_PUBLIC_MARKETING_HUB_URL' },
37
+ { key: 'company-hub', defaultUrl: 'https://company-hub.flamingo.so', envVar: 'NEXT_PUBLIC_COMPANY_HUB_URL' },
38
+ { key: 'product-hub', defaultUrl: 'https://product-hub.flamingo.so', envVar: 'NEXT_PUBLIC_PRODUCT_HUB_URL' },
39
+ { key: 'revenue-hub', defaultUrl: 'https://revenue-hub.flamingo.so', envVar: 'NEXT_PUBLIC_REVENUE_HUB_URL' },
40
+ { key: 'people-hub', defaultUrl: 'https://people-hub.flamingo.so', envVar: 'NEXT_PUBLIC_PEOPLE_HUB_URL' },
41
+ { key: 'openmsp', defaultUrl: 'https://www.openmsp.ai', envVar: 'NEXT_PUBLIC_OPENMSP_URL' },
42
+ // ORDERING INVARIANT (first-wins, load-bearing): `flamingo` MUST precede `flamingo-teaser` + `universal`.
43
+ // All three resolve to www.flamingo.run; the reverse index is first-wins → flamingo claims the shared host,
44
+ // teaser keeps only its unique flamingo.cx aliases, universal contributes no unique host.
45
+ // ⚠️ DO NOT REORDER — enforced by the module-load self-check at the bottom of this file.
46
+ { key: 'flamingo', defaultUrl: 'https://www.flamingo.run', envVar: 'NEXT_PUBLIC_FLAMINGO_URL' },
47
+ { key: 'tmcg', defaultUrl: 'https://www.tmcg.miami', envVar: 'NEXT_PUBLIC_TMCG_URL' },
48
+ { key: 'flamingo-teaser', defaultUrl: 'https://www.flamingo.run', envVar: 'NEXT_PUBLIC_FLAMINGO_URL', aliasHostnames: ['flamingo.cx', 'www.flamingo.cx'] },
49
+ { key: 'openframe', defaultUrl: 'https://openframe.ai', envVar: 'NEXT_PUBLIC_OPENFRAME_URL', aliasHostnames: ['openframe.ai', 'www.openframe.ai', 'hub.openframe.ai'] },
50
+ { key: 'openframe-dashboard', defaultUrl: 'https://openframe.ai', envVar: 'NEXT_PUBLIC_OPENFRAME_DASHBOARD_URL', pseudo: true },
51
+ { key: 'universal', defaultUrl: 'https://www.flamingo.run', envVar: 'NEXT_PUBLIC_FLAMINGO_URL' },
52
+ ]
53
+
54
+ // ── Compile-time key guards (anchor the table on PlatformName, both directions) ──
55
+ // (1) Removal/typo guard: every table key must be a valid PlatformDomainKey.
56
+ const _tableSatisfies = PLATFORM_DOMAINS satisfies readonly PlatformDomainEntry[]
57
+ void _tableSatisfies
58
+ // (2) Addition guard: a NEW PlatformName member that lacks a table row fails the build.
59
+ type _MissingKey = Exclude<PlatformName, (typeof PLATFORM_DOMAINS)[number]['key']>
60
+ const _exhaustive: [_MissingKey] extends [never] ? true : false = true
61
+ void _exhaustive
62
+
63
+ // ── Env overrides (the ONLY place env URLs enter — literal-key inlined + compile-time-guarded) ──
64
+ // `process.env.NEXT_PUBLIC_X` is build-inlined ONLY with a LITERAL key, so the env-var name is the
65
+ // irreducible two-copy (the registry `envVar` column + the literal access below). The `satisfies` makes
66
+ // `tsc`/`next build` FAIL if this map is missing a registry env var OR carries a stale one (bidirectional).
67
+ type EnvVarKey = (typeof PLATFORM_DOMAINS)[number]['envVar']
68
+ const ENV_OVERRIDES = {
69
+ NEXT_PUBLIC_MARKETING_HUB_URL: process.env.NEXT_PUBLIC_MARKETING_HUB_URL,
70
+ NEXT_PUBLIC_COMPANY_HUB_URL: process.env.NEXT_PUBLIC_COMPANY_HUB_URL,
71
+ NEXT_PUBLIC_PRODUCT_HUB_URL: process.env.NEXT_PUBLIC_PRODUCT_HUB_URL,
72
+ NEXT_PUBLIC_REVENUE_HUB_URL: process.env.NEXT_PUBLIC_REVENUE_HUB_URL,
73
+ NEXT_PUBLIC_PEOPLE_HUB_URL: process.env.NEXT_PUBLIC_PEOPLE_HUB_URL,
74
+ NEXT_PUBLIC_OPENMSP_URL: process.env.NEXT_PUBLIC_OPENMSP_URL,
75
+ NEXT_PUBLIC_FLAMINGO_URL: process.env.NEXT_PUBLIC_FLAMINGO_URL,
76
+ NEXT_PUBLIC_TMCG_URL: process.env.NEXT_PUBLIC_TMCG_URL,
77
+ NEXT_PUBLIC_OPENFRAME_URL: process.env.NEXT_PUBLIC_OPENFRAME_URL,
78
+ NEXT_PUBLIC_OPENFRAME_DASHBOARD_URL: process.env.NEXT_PUBLIC_OPENFRAME_DASHBOARD_URL,
79
+ } satisfies Record<EnvVarKey, string | undefined>
80
+
81
+ /** The registry entry for a key (undefined for an unknown key). */
82
+ export function byKey(key: string): PlatformDomainEntry | undefined {
83
+ return PLATFORM_DOMAINS.find((e) => e.key === key)
84
+ }
85
+
86
+ /** Read a platform's `NEXT_PUBLIC_*_URL` override (or null). */
87
+ function envOverrideFor(key: string): string | null {
88
+ const envVar = byKey(key)?.envVar
89
+ if (!envVar) return null
90
+ return (ENV_OVERRIDES as Record<string, string | undefined>)[envVar] || null
91
+ }
92
+
93
+ /**
94
+ * Ensure a URL string carries a scheme. Per-deploy `NEXT_PUBLIC_*_URL` overrides are
95
+ * stored SCHEME-LESS (bare host, e.g. `www.openmsp.ai` / `hub.openframe.ai`) — the
96
+ * canonical convention in the Vercel shared-env store. This normalizes them to a full
97
+ * `https://` URL so every downstream consumer (`hostOf`/`new URL`, hrefs, the cookie
98
+ * base-domain derivation, CSP) receives a parseable URL. Full-URL inputs (the registry
99
+ * `defaultUrl`s, any scheme'd override) pass through unchanged.
100
+ *
101
+ * EXPORTED as the single owner of the scheme-normalization rule (next.config.mjs keeps a
102
+ * byte-identical local copy ONLY because Next evaluates its config outside the TS module
103
+ * graph and cannot import this — see the comment there).
104
+ *
105
+ * Handles a (theoretical) protocol-relative `//host` too: strips the leading slashes so it
106
+ * doesn't become `https:////host` (empty-host → hostOf null → silent platform drop).
107
+ */
108
+ export function ensureScheme(url: string): string {
109
+ const trimmed = url.trim()
110
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed // already has a scheme
111
+ return `https://${trimmed.replace(/^\/+/, '')}` // bare host or protocol-relative `//host`
112
+ }
113
+
114
+ /**
115
+ * Canonical production URL for a platform: env override wins, else the `defaultUrl`.
116
+ * NEVER throws / undefined — the default guarantees a host (this is what keeps the
117
+ * cookie base-domains, the reverse map, and CSP intact even with every override unset).
118
+ * The result ALWAYS carries a scheme (`ensureScheme`), so the scheme-less env overrides
119
+ * resolve to valid URLs. Unknown-key fallback preserves cn.ts's flamingo.run default.
120
+ */
121
+ export function getPlatformProductionUrl(platform: string): string {
122
+ const resolved =
123
+ envOverrideFor(platform) ??
124
+ byKey(platform)?.defaultUrl ??
125
+ envOverrideFor('flamingo') ??
126
+ 'https://www.flamingo.run'
127
+ return ensureScheme(resolved)
128
+ }
129
+
130
+ // ── Single-owner host primitives ──
131
+
132
+ /** Canonical URL→host parser: `.hostname` (PORT-STRIPPED, lowercased), null on parse failure. */
133
+ export function hostOf(value: string | null | undefined): string | null {
134
+ if (!value) return null
135
+ try {
136
+ return new URL(value).hostname.toLowerCase()
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ /** Expand a host into its `www.`/apex pair. 3+-label and single-label hosts return `[host]`. */
143
+ export function expandWwwApex(host: string): string[] {
144
+ if (host.startsWith('www.')) return [host, host.slice(4)]
145
+ if (host.split('.').length === 2) return [host, `www.${host}`]
146
+ return [host]
147
+ }
148
+
149
+ /** Registrable base domain (`parts.slice(-2).join('.')`), dotless; undefined for <2-label. */
150
+ export function toRegistrableBaseDomain(host: string): string | undefined {
151
+ const parts = host.split('.')
152
+ if (parts.length >= 2) return parts.slice(-2).join('.')
153
+ return undefined
154
+ }
155
+
156
+ /** An entry's alias hosts (single-owner reader). */
157
+ export function aliasHostsOf(key: string): string[] {
158
+ return byKey(key)?.aliasHostnames ?? []
159
+ }
160
+
161
+ /** All hosts an entry contributes to the reverse index (resolved host + optional aliases). */
162
+ function hostsForEntry(entry: PlatformDomainEntry, opts: { includeAliases: boolean }): string[] {
163
+ const resolved = hostOf(getPlatformProductionUrl(entry.key))
164
+ const hosts = resolved ? expandWwwApex(resolved) : []
165
+ if (opts.includeAliases) hosts.push(...aliasHostsOf(entry.key))
166
+ return hosts
167
+ }
168
+
169
+ /**
170
+ * Reverse resolver: hostname → platform key (first-wins over registry order, non-pseudo only).
171
+ * Guarantees openframe.ai / www.openframe.ai / hub.openframe.ai → openframe in every env.
172
+ * Replaces the hub `PLATFORM_DOMAIN_MAP`.
173
+ */
174
+ export function getPlatformByHostname(hostname: string): PlatformDomainKey | null {
175
+ const host = hostname.toLowerCase()
176
+ for (const entry of PLATFORM_DOMAINS) {
177
+ if (entry.pseudo) continue
178
+ if (hostsForEntry(entry, { includeAliases: true }).includes(host)) return entry.key
179
+ }
180
+ return null
181
+ }
182
+
183
+ // ── Preview detection (two distinct predicates — env-form vs host-form) ──
184
+
185
+ /** Env-form preview predicate (Vercel `VERCEL_ENV`). */
186
+ export function isPreviewEnv(): boolean {
187
+ return process.env.VERCEL_ENV === 'preview'
188
+ }
189
+
190
+ /** Host-form preview predicate (a `*.vercel.app` host). Dot-bounded suffix so a
191
+ * malicious `foo.vercel.app.evil.com` is NOT treated as preview. */
192
+ export function isPreviewHost(hostname: string): boolean {
193
+ return hostname.endsWith('.vercel.app')
194
+ }
195
+
196
+ /**
197
+ * ALL unique cookie base domains (the cross-subdomain SSO mechanism).
198
+ *
199
+ * NON-PURE (the sole such export): reads `typeof window` + `process.env` to
200
+ * preserve byte-identical cookie behavior. Keeps the original three branches:
201
+ * 1. localhost / private IP → [] (host-only cookies)
202
+ * 2. Vercel preview (env OR host) → ['.vercel.app','vercel.app']
203
+ * 3. production → for each non-pseudo platform, registrable base of its resolved
204
+ * host, emitted as both `.base` and bare `base`.
205
+ *
206
+ * Because `getPlatformProductionUrl` always yields a host (override OR default),
207
+ * `.flamingo.so` / `.flamingo.run` / `.openmsp.ai` / `.tmcg.miami` / `.openframe.ai`
208
+ * are ALWAYS present → cross-hub SSO is byte-identical to today.
209
+ */
210
+ export function getAllPlatformBaseDomains(): string[] {
211
+ if (typeof window === 'undefined') return []
212
+
213
+ const hostname = window.location.hostname
214
+
215
+ // Case 1: localhost / private IP — no domains
216
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('127.')) {
217
+ return []
218
+ }
219
+
220
+ // Case 2: Vercel preview — vercel.app domain
221
+ const previewEnv =
222
+ process.env.VERCEL_ENV === 'preview' ||
223
+ process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
224
+ isPreviewHost(hostname)
225
+ if (previewEnv) {
226
+ return ['.vercel.app', 'vercel.app']
227
+ }
228
+
229
+ // Case 3: production — registrable base of every non-pseudo platform's resolved host
230
+ const baseDomains = new Set<string>()
231
+ for (const entry of PLATFORM_DOMAINS) {
232
+ if (entry.pseudo) continue
233
+ const host = hostOf(getPlatformProductionUrl(entry.key))
234
+ if (!host) continue
235
+ const base = toRegistrableBaseDomain(host)
236
+ if (base) {
237
+ baseDomains.add(`.${base}`)
238
+ baseDomains.add(base)
239
+ }
240
+ }
241
+ return Array.from(baseDomains)
242
+ }
243
+
244
+ // ── Cookie-domain guard + match (single owner; shared by the client cookie-domain.ts +
245
+ // server-only cookie-domain-server.ts resolvers, which previously hand-rolled both 3×) ──
246
+
247
+ /**
248
+ * Hosts that must NOT receive a `Domain=` cookie → the caller returns undefined (host-only):
249
+ * localhost, loopback/private IPs, and any `*.vercel.app`. `vercel.app` is on the Public Suffix
250
+ * List, so browsers SILENTLY drop `Set-Cookie: Domain=.vercel.app` — which broke the PKCE verifier
251
+ * + session cookies on preview deploys. Host-only is sufficient there (the same preview host
252
+ * round-trips the OAuth chain); production hosts (`.flamingo.so`/`.openmsp.ai`/…) fall through.
253
+ */
254
+ export function isNonCookieableHost(hostname: string): boolean {
255
+ return (
256
+ hostname === 'localhost' ||
257
+ hostname === '127.0.0.1' ||
258
+ hostname.startsWith('127.') ||
259
+ hostname.startsWith('192.168.') ||
260
+ hostname.startsWith('10.') ||
261
+ hostname.includes('.vercel.app')
262
+ )
263
+ }
264
+
265
+ /**
266
+ * Match a hostname against a set of registrable base domains → the dotted cookie `Domain`
267
+ * (`.flamingo.so`), or undefined when none contains the host. Accepts bases with or without a
268
+ * leading dot and always returns the dotted form. Single owner for the match loop both resolvers ran.
269
+ */
270
+ export function matchCookieDomain(hostname: string, baseDomains: string[]): string | undefined {
271
+ for (const domain of baseDomains) {
272
+ const bare = domain.startsWith('.') ? domain.slice(1) : domain
273
+ if (hostname === bare || hostname.endsWith(`.${bare}`)) {
274
+ return domain.startsWith('.') ? domain : `.${domain}`
275
+ }
276
+ }
277
+ return undefined
278
+ }
279
+
280
+ // ── Module-load ordering self-check (NON-fatal — this module is imported by cn.ts → ~everything,
281
+ // so a hard throw would be a total outage if the assertion were ever over-strict). Checks the REAL
282
+ // invariant — table order — STRUCTURALLY (findIndex), NOT via getPlatformByHostname on a hardcoded
283
+ // host: the latter false-positives when NEXT_PUBLIC_FLAMINGO_URL is overridden to a non-default host
284
+ // (flamingo's resolved host changes, so `www.flamingo.run` no longer reverse-maps to it though the
285
+ // ordering is fine). The alias checks below ARE env-immune (aliasHostnames are unique per key). The
286
+ // authoritative guard is the reverse-map vitest. ⚠️ keep `flamingo` before `flamingo-teaser`/`universal`.
287
+ const _orderIdx = (k: PlatformDomainKey) => PLATFORM_DOMAINS.findIndex((e) => e.key === k)
288
+ if (
289
+ _orderIdx('flamingo') > _orderIdx('flamingo-teaser') ||
290
+ _orderIdx('flamingo') > _orderIdx('universal') ||
291
+ getPlatformByHostname('flamingo.cx') !== 'flamingo-teaser' ||
292
+ getPlatformByHostname('hub.openframe.ai') !== 'openframe'
293
+ ) {
294
+ // eslint-disable-next-line no-console
295
+ console.error(
296
+ '[platform-domains] ⚠️ PLATFORM_DOMAINS ordering invariant violated — `flamingo` must precede ' +
297
+ '`flamingo-teaser`/`universal`, and the openframe/teaser aliases must be intact. Do not reorder the table.',
298
+ )
299
+ }