@flamingo-stack/openframe-frontend-core 0.0.234 → 0.0.235

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 (90) hide show
  1. package/dist/{chunk-DHCGJW3G.js → chunk-2MHQE5ET.js} +8 -6
  2. package/dist/chunk-2MHQE5ET.js.map +1 -0
  3. package/dist/{chunk-E6B4B7GM.cjs → chunk-4Y7427BF.cjs} +49 -49
  4. package/dist/{chunk-E6B4B7GM.cjs.map → chunk-4Y7427BF.cjs.map} +1 -1
  5. package/dist/chunk-76NMNINV.js +162 -0
  6. package/dist/chunk-76NMNINV.js.map +1 -0
  7. package/dist/{chunk-Z46NRJWW.js → chunk-ABSGXVGP.js} +2 -2
  8. package/dist/chunk-CG6AZ2MC.cjs +162 -0
  9. package/dist/chunk-CG6AZ2MC.cjs.map +1 -0
  10. package/dist/{chunk-GSD52WAS.js → chunk-EI36Q3EC.js} +4 -4
  11. package/dist/{chunk-UYWJM2LS.cjs → chunk-ETFVD7QU.cjs} +12 -12
  12. package/dist/{chunk-UYWJM2LS.cjs.map → chunk-ETFVD7QU.cjs.map} +1 -1
  13. package/dist/{chunk-YZDUOUMB.js → chunk-FWYMYXL7.js} +2 -2
  14. package/dist/{chunk-F6F525T7.js → chunk-GW6J7IPD.js} +2 -2
  15. package/dist/{chunk-52PRKXK4.cjs → chunk-HJRWBA7M.cjs} +32 -32
  16. package/dist/{chunk-52PRKXK4.cjs.map → chunk-HJRWBA7M.cjs.map} +1 -1
  17. package/dist/{chunk-IBSZ27Y7.cjs → chunk-M4CWPAIB.cjs} +7 -7
  18. package/dist/{chunk-IBSZ27Y7.cjs.map → chunk-M4CWPAIB.cjs.map} +1 -1
  19. package/dist/{chunk-EO3RG6II.cjs → chunk-MDWFPCHN.cjs} +16 -16
  20. package/dist/{chunk-EO3RG6II.cjs.map → chunk-MDWFPCHN.cjs.map} +1 -1
  21. package/dist/{chunk-BOF5MSRY.js → chunk-VO3K4EEG.js} +3 -3
  22. package/dist/{chunk-MWW7LKDN.cjs → chunk-W4NBBPQL.cjs} +8 -8
  23. package/dist/{chunk-MWW7LKDN.cjs.map → chunk-W4NBBPQL.cjs.map} +1 -1
  24. package/dist/{chunk-W7P7RH2X.cjs → chunk-X4SQ2S7Z.cjs} +1275 -1273
  25. package/dist/chunk-X4SQ2S7Z.cjs.map +1 -0
  26. package/dist/{chunk-GYRRSOTR.cjs → chunk-X6WUWOL5.cjs} +38 -38
  27. package/dist/{chunk-GYRRSOTR.cjs.map → chunk-X6WUWOL5.cjs.map} +1 -1
  28. package/dist/{chunk-LLTUSEHF.js → chunk-Z6FT2JDQ.js} +3 -3
  29. package/dist/{chunk-D3S2ODUI.js → chunk-ZLQT3T4F.js} +4 -4
  30. package/dist/components/chat/index.cjs +6 -6
  31. package/dist/components/chat/index.js +5 -5
  32. package/dist/components/contact/index.cjs +7 -7
  33. package/dist/components/contact/index.js +6 -6
  34. package/dist/components/features/index.cjs +6 -6
  35. package/dist/components/features/index.js +5 -5
  36. package/dist/components/icons/index.cjs +3 -3
  37. package/dist/components/icons/index.js +2 -2
  38. package/dist/components/index.cjs +236 -236
  39. package/dist/components/index.js +8 -8
  40. package/dist/components/navigation/index.cjs +6 -6
  41. package/dist/components/navigation/index.js +5 -5
  42. package/dist/components/onboarding-guides/index.cjs +30 -30
  43. package/dist/components/onboarding-guides/index.js +7 -7
  44. package/dist/components/resizable.d.ts +1 -1
  45. package/dist/components/tickets/index.cjs +89 -89
  46. package/dist/components/tickets/index.js +8 -8
  47. package/dist/components/ui/file-manager/index.cjs +69 -69
  48. package/dist/components/ui/file-manager/index.js +2 -2
  49. package/dist/components/ui/index.cjs +6 -6
  50. package/dist/components/ui/index.js +5 -5
  51. package/dist/hooks/index.cjs +4 -4
  52. package/dist/hooks/index.js +3 -3
  53. package/dist/index.cjs +6 -6
  54. package/dist/index.js +5 -5
  55. package/dist/platform-domains.cjs +127 -0
  56. package/dist/platform-domains.cjs.map +1 -0
  57. package/dist/platform-domains.d.ts +75 -0
  58. package/dist/platform-domains.d.ts.map +1 -0
  59. package/dist/platform-domains.js +115 -0
  60. package/dist/platform-domains.js.map +1 -0
  61. package/dist/types/index.cjs +80 -0
  62. package/dist/types/index.cjs.map +1 -1
  63. package/dist/types/index.js +80 -0
  64. package/dist/types/index.js.map +1 -1
  65. package/dist/utils/cn.d.ts +8 -43
  66. package/dist/utils/cn.d.ts.map +1 -1
  67. package/dist/utils/index.cjs +106 -72
  68. package/dist/utils/index.cjs.map +1 -1
  69. package/dist/utils/index.d.ts +3 -2
  70. package/dist/utils/index.d.ts.map +1 -1
  71. package/dist/utils/index.js +106 -72
  72. package/dist/utils/index.js.map +1 -1
  73. package/package.json +7 -1
  74. package/src/__tests__/platform-domains.test.ts +137 -0
  75. package/src/platform-domains.ts +238 -0
  76. package/src/utils/cn.ts +18 -134
  77. package/src/utils/index.ts +7 -2
  78. package/dist/chunk-2TBN53KY.cjs +0 -122
  79. package/dist/chunk-2TBN53KY.cjs.map +0 -1
  80. package/dist/chunk-DHCGJW3G.js.map +0 -1
  81. package/dist/chunk-W7P7RH2X.cjs.map +0 -1
  82. package/dist/chunk-YICWWZ3Z.js +0 -122
  83. package/dist/chunk-YICWWZ3Z.js.map +0 -1
  84. /package/dist/{chunk-Z46NRJWW.js.map → chunk-ABSGXVGP.js.map} +0 -0
  85. /package/dist/{chunk-GSD52WAS.js.map → chunk-EI36Q3EC.js.map} +0 -0
  86. /package/dist/{chunk-YZDUOUMB.js.map → chunk-FWYMYXL7.js.map} +0 -0
  87. /package/dist/{chunk-F6F525T7.js.map → chunk-GW6J7IPD.js.map} +0 -0
  88. /package/dist/{chunk-BOF5MSRY.js.map → chunk-VO3K4EEG.js.map} +0 -0
  89. /package/dist/{chunk-LLTUSEHF.js.map → chunk-Z6FT2JDQ.js.map} +0 -0
  90. /package/dist/{chunk-D3S2ODUI.js.map → chunk-ZLQT3T4F.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",
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,137 @@
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
+ } from '@/platform-domains'
13
+
14
+ /**
15
+ * Helper: run a fn with a stubbed `window.location.hostname` (prod, non-localhost,
16
+ * non-vercel) so getAllPlatformBaseDomains exercises its production branch.
17
+ */
18
+ function withProdWindow<T>(hostname: string, fn: () => T): T {
19
+ vi.stubGlobal('window', { location: { hostname } })
20
+ try {
21
+ return fn()
22
+ } finally {
23
+ vi.unstubAllGlobals()
24
+ }
25
+ }
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals()
29
+ vi.unstubAllEnvs()
30
+ })
31
+
32
+ describe('platform-domains — forward resolution (defaults = byte-identical to the old cn.ts switch)', () => {
33
+ it('returns each defaultUrl when no env override is set', () => {
34
+ for (const entry of PLATFORM_DOMAINS) {
35
+ expect(getPlatformProductionUrl(entry.key)).toBe(entry.defaultUrl)
36
+ }
37
+ })
38
+ it('falls back to flamingo.run for an unknown key (old default case)', () => {
39
+ expect(getPlatformProductionUrl('not-a-platform')).toBe('https://www.flamingo.run')
40
+ })
41
+ })
42
+
43
+ describe('🔒 cross-subdomain cookie sharing (the SSO invariant — must never regress)', () => {
44
+ // With NO NEXT_PUBLIC_*_URL overrides set (today's production reality), the defaults
45
+ // alone MUST still produce the registrable base domains that scope the auth cookie
46
+ // across every hub subdomain. This is exactly what pure-env-only would have broken.
47
+ it('emits .flamingo.so / .flamingo.run / .openmsp.ai / .tmcg.miami / .openframe.ai from defaults', () => {
48
+ const bases = withProdWindow('company-hub.flamingo.so', getAllPlatformBaseDomains)
49
+ for (const must of ['.flamingo.so', '.flamingo.run', '.openmsp.ai', '.tmcg.miami', '.openframe.ai']) {
50
+ expect(bases).toContain(must)
51
+ }
52
+ })
53
+ it('shares one base domain across all *.flamingo.so hubs (cross-hub SSO)', () => {
54
+ for (const hub of ['marketing-hub', 'company-hub', 'product-hub', 'revenue-hub', 'people-hub']) {
55
+ expect(toRegistrableBaseDomain(hostOf(getPlatformProductionUrl(hub))!)).toBe('flamingo.so')
56
+ }
57
+ })
58
+ it('keeps flamingo.cx OUT of the cookie set (alias excluded) but IN the reverse map', () => {
59
+ const bases = withProdWindow('www.flamingo.run', getAllPlatformBaseDomains)
60
+ expect(bases).not.toContain('.flamingo.cx')
61
+ expect(getPlatformByHostname('flamingo.cx')).toBe('flamingo-teaser')
62
+ })
63
+ it('returns [] for localhost and .vercel.app (host-only cookies)', () => {
64
+ expect(withProdWindow('localhost', getAllPlatformBaseDomains)).toEqual([])
65
+ expect(withProdWindow('foo.vercel.app', getAllPlatformBaseDomains)).toEqual(['.vercel.app', 'vercel.app'])
66
+ })
67
+ })
68
+
69
+ describe('reverse resolver (first-wins ordering + aliases)', () => {
70
+ it.each([
71
+ ['www.flamingo.run', 'flamingo'],
72
+ ['flamingo.run', 'flamingo'],
73
+ ['flamingo.cx', 'flamingo-teaser'],
74
+ ['www.flamingo.cx', 'flamingo-teaser'],
75
+ ['openframe.ai', 'openframe'],
76
+ ['www.openframe.ai', 'openframe'],
77
+ ['hub.openframe.ai', 'openframe'], // additive fix — was null in the old PLATFORM_DOMAIN_MAP
78
+ ['marketing-hub.flamingo.so', 'marketing-hub'],
79
+ ['WWW.OPENMSP.AI', 'openmsp'], // case-insensitive
80
+ ['evil.com', null],
81
+ ])('%s → %s', (host, expected) => {
82
+ expect(getPlatformByHostname(host)).toBe(expected)
83
+ })
84
+ })
85
+
86
+ describe('host primitives', () => {
87
+ it('hostOf strips scheme + port, lowercases, null on garbage', () => {
88
+ expect(hostOf('https://WWW.Openmsp.ai:8443/x')).toBe('www.openmsp.ai')
89
+ expect(hostOf('not a url')).toBeNull()
90
+ expect(hostOf(null)).toBeNull()
91
+ })
92
+ it('expandWwwApex handles www / apex / 3-label / 1-label', () => {
93
+ expect(expandWwwApex('www.openmsp.ai')).toEqual(['www.openmsp.ai', 'openmsp.ai'])
94
+ expect(expandWwwApex('openmsp.ai')).toEqual(['openmsp.ai', 'www.openmsp.ai'])
95
+ expect(expandWwwApex('hub.openframe.ai')).toEqual(['hub.openframe.ai'])
96
+ expect(expandWwwApex('localhost')).toEqual(['localhost'])
97
+ })
98
+ it('toRegistrableBaseDomain + aliasHostsOf', () => {
99
+ expect(toRegistrableBaseDomain('marketing-hub.flamingo.so')).toBe('flamingo.so')
100
+ expect(toRegistrableBaseDomain('localhost')).toBeUndefined()
101
+ expect(aliasHostsOf('flamingo-teaser')).toEqual(['flamingo.cx', 'www.flamingo.cx'])
102
+ expect(aliasHostsOf('openmsp')).toEqual([])
103
+ })
104
+ })
105
+
106
+ describe('env override path', () => {
107
+ // ENV_OVERRIDES captures `process.env.NEXT_PUBLIC_*` at MODULE LOAD — required so the
108
+ // values are build-inlined (literal keys) in the browser bundle. So an override must be
109
+ // set BEFORE the module evaluates: stub env → resetModules → dynamic import.
110
+ afterEach(() => {
111
+ vi.unstubAllEnvs()
112
+ vi.resetModules()
113
+ })
114
+ it('an override wins over the default (per-distinct-var, no cross-contamination)', async () => {
115
+ vi.stubEnv('NEXT_PUBLIC_TMCG_URL', 'https://sentinel-tmcg.example')
116
+ vi.resetModules()
117
+ const mod = await import('@/platform-domains')
118
+ expect(mod.getPlatformProductionUrl('tmcg')).toBe('https://sentinel-tmcg.example')
119
+ // a non-sharing key is unaffected
120
+ expect(mod.getPlatformProductionUrl('openmsp')).toBe('https://www.openmsp.ai')
121
+ })
122
+ it('the shared NEXT_PUBLIC_FLAMINGO_URL drives all three of its keys', async () => {
123
+ vi.stubEnv('NEXT_PUBLIC_FLAMINGO_URL', 'https://sentinel-flamingo.example')
124
+ vi.resetModules()
125
+ const mod = await import('@/platform-domains')
126
+ for (const k of ['flamingo', 'flamingo-teaser', 'universal']) {
127
+ expect(mod.getPlatformProductionUrl(k)).toBe('https://sentinel-flamingo.example')
128
+ }
129
+ })
130
+ })
131
+
132
+ describe('registry integrity', () => {
133
+ it('byKey resolves every key and openframe-dashboard is pseudo', () => {
134
+ expect(byKey('openframe-dashboard')?.pseudo).toBe(true)
135
+ expect(byKey('not-a-platform')).toBeUndefined()
136
+ })
137
+ })
@@ -0,0 +1,238 @@
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
+ * Canonical production URL for a platform: env override wins, else the `defaultUrl`.
95
+ * NEVER throws / undefined — the default guarantees a host (this is what keeps the
96
+ * cookie base-domains, the reverse map, and CSP intact even with every override unset).
97
+ * Byte-identical to the old cn.ts switch (incl. the unknown-key flamingo.run fallback).
98
+ */
99
+ export function getPlatformProductionUrl(platform: string): string {
100
+ return (
101
+ envOverrideFor(platform) ??
102
+ byKey(platform)?.defaultUrl ??
103
+ envOverrideFor('flamingo') ??
104
+ 'https://www.flamingo.run'
105
+ )
106
+ }
107
+
108
+ // ── Single-owner host primitives ──
109
+
110
+ /** Canonical URL→host parser: `.hostname` (PORT-STRIPPED, lowercased), null on parse failure. */
111
+ export function hostOf(value: string | null | undefined): string | null {
112
+ if (!value) return null
113
+ try {
114
+ return new URL(value).hostname.toLowerCase()
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ /** Expand a host into its `www.`/apex pair. 3+-label and single-label hosts return `[host]`. */
121
+ export function expandWwwApex(host: string): string[] {
122
+ if (host.startsWith('www.')) return [host, host.slice(4)]
123
+ if (host.split('.').length === 2) return [host, `www.${host}`]
124
+ return [host]
125
+ }
126
+
127
+ /** Registrable base domain (`parts.slice(-2).join('.')`), dotless; undefined for <2-label. */
128
+ export function toRegistrableBaseDomain(host: string): string | undefined {
129
+ const parts = host.split('.')
130
+ if (parts.length >= 2) return parts.slice(-2).join('.')
131
+ return undefined
132
+ }
133
+
134
+ /** An entry's alias hosts (single-owner reader). */
135
+ export function aliasHostsOf(key: string): string[] {
136
+ return byKey(key)?.aliasHostnames ?? []
137
+ }
138
+
139
+ /** All hosts an entry contributes to the reverse index (resolved host + optional aliases). */
140
+ function hostsForEntry(entry: PlatformDomainEntry, opts: { includeAliases: boolean }): string[] {
141
+ const resolved = hostOf(getPlatformProductionUrl(entry.key))
142
+ const hosts = resolved ? expandWwwApex(resolved) : []
143
+ if (opts.includeAliases) hosts.push(...aliasHostsOf(entry.key))
144
+ return hosts
145
+ }
146
+
147
+ /**
148
+ * Reverse resolver: hostname → platform key (first-wins over registry order, non-pseudo only).
149
+ * Guarantees openframe.ai / www.openframe.ai / hub.openframe.ai → openframe in every env.
150
+ * Replaces the hub `PLATFORM_DOMAIN_MAP`.
151
+ */
152
+ export function getPlatformByHostname(hostname: string): PlatformDomainKey | null {
153
+ const host = hostname.toLowerCase()
154
+ for (const entry of PLATFORM_DOMAINS) {
155
+ if (entry.pseudo) continue
156
+ if (hostsForEntry(entry, { includeAliases: true }).includes(host)) return entry.key
157
+ }
158
+ return null
159
+ }
160
+
161
+ // ── Preview detection (two distinct predicates — env-form vs host-form) ──
162
+
163
+ /** Env-form preview predicate (Vercel `VERCEL_ENV`). */
164
+ export function isPreviewEnv(): boolean {
165
+ return process.env.VERCEL_ENV === 'preview'
166
+ }
167
+
168
+ /** Host-form preview predicate (a `*.vercel.app` host). Dot-bounded suffix so a
169
+ * malicious `foo.vercel.app.evil.com` is NOT treated as preview. */
170
+ export function isPreviewHost(hostname: string): boolean {
171
+ return hostname.endsWith('.vercel.app')
172
+ }
173
+
174
+ /**
175
+ * ALL unique cookie base domains (the cross-subdomain SSO mechanism).
176
+ *
177
+ * NON-PURE (the sole such export): reads `typeof window` + `process.env` to
178
+ * preserve byte-identical cookie behavior. Keeps the original three branches:
179
+ * 1. localhost / private IP → [] (host-only cookies)
180
+ * 2. Vercel preview (env OR host) → ['.vercel.app','vercel.app']
181
+ * 3. production → for each non-pseudo platform, registrable base of its resolved
182
+ * host, emitted as both `.base` and bare `base`.
183
+ *
184
+ * Because `getPlatformProductionUrl` always yields a host (override OR default),
185
+ * `.flamingo.so` / `.flamingo.run` / `.openmsp.ai` / `.tmcg.miami` / `.openframe.ai`
186
+ * are ALWAYS present → cross-hub SSO is byte-identical to today.
187
+ */
188
+ export function getAllPlatformBaseDomains(): string[] {
189
+ if (typeof window === 'undefined') return []
190
+
191
+ const hostname = window.location.hostname
192
+
193
+ // Case 1: localhost / private IP — no domains
194
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('127.')) {
195
+ return []
196
+ }
197
+
198
+ // Case 2: Vercel preview — vercel.app domain
199
+ const previewEnv =
200
+ process.env.VERCEL_ENV === 'preview' ||
201
+ process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
202
+ isPreviewHost(hostname)
203
+ if (previewEnv) {
204
+ return ['.vercel.app', 'vercel.app']
205
+ }
206
+
207
+ // Case 3: production — registrable base of every non-pseudo platform's resolved host
208
+ const baseDomains = new Set<string>()
209
+ for (const entry of PLATFORM_DOMAINS) {
210
+ if (entry.pseudo) continue
211
+ const host = hostOf(getPlatformProductionUrl(entry.key))
212
+ if (!host) continue
213
+ const base = toRegistrableBaseDomain(host)
214
+ if (base) {
215
+ baseDomains.add(`.${base}`)
216
+ baseDomains.add(base)
217
+ }
218
+ }
219
+ return Array.from(baseDomains)
220
+ }
221
+
222
+ // ── Module-load ordering self-check (NON-fatal — this module is imported by cn.ts → ~everything,
223
+ // so a hard throw would be a total outage if the assertion were ever over-strict). A reorder that
224
+ // moves flamingo-teaser/universal before flamingo (e.g. an IDE alphabetize) surfaces loudly in the
225
+ // build + prod logs instead of silently cross-tabbing flamingo links to the wrong owner. The
226
+ // authoritative guard is the reverse-map vitest (src/__tests__/platform-domains.test.ts). ⚠️ keep
227
+ // `flamingo` before `flamingo-teaser`/`universal` in PLATFORM_DOMAINS.
228
+ if (
229
+ getPlatformByHostname('www.flamingo.run') !== 'flamingo' ||
230
+ getPlatformByHostname('flamingo.cx') !== 'flamingo-teaser' ||
231
+ getPlatformByHostname('hub.openframe.ai') !== 'openframe'
232
+ ) {
233
+ // eslint-disable-next-line no-console
234
+ console.error(
235
+ '[platform-domains] ⚠️ PLATFORM_DOMAINS ordering invariant violated — `flamingo` must precede ' +
236
+ '`flamingo-teaser`/`universal`, and the openframe aliases must be intact. Do not reorder the table.',
237
+ )
238
+ }
package/src/utils/cn.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { clsx, type ClassValue } from "clsx"
2
2
  import { extendTailwindMerge } from "tailwind-merge"
3
+ // Platform→domain resolution moved to the SSOT module `src/platform-domains.ts`.
4
+ // `getPlatformProductionUrl` / `getAllPlatformBaseDomains` now live there (re-exported
5
+ // via the utils barrel for existing callers); `getBaseUrl` stays here because it owns the
6
+ // dev-localhost + Vercel-self-origin branches, and delegates its platform branch.
7
+ import { getPlatformProductionUrl } from "../platform-domains"
3
8
 
4
9
  const twMerge = extendTailwindMerge<'ods-typography'>({
5
10
  extend: {
@@ -16,110 +21,6 @@ export function cn(...inputs: ClassValue[]) {
16
21
  return twMerge(clsx(inputs))
17
22
  }
18
23
 
19
- /**
20
- * Platform URL mappings for production (with environment variable overrides)
21
- */
22
- export function getPlatformProductionUrl(platform: string): string {
23
- switch (platform) {
24
- // *-hub platforms — env var convention: NEXT_PUBLIC_<NAME>_HUB_URL
25
- case 'marketing-hub':
26
- return process.env.NEXT_PUBLIC_MARKETING_HUB_URL || 'https://marketing-hub.flamingo.so';
27
- case 'company-hub':
28
- return process.env.NEXT_PUBLIC_COMPANY_HUB_URL || 'https://company-hub.flamingo.so';
29
- case 'product-hub':
30
- return process.env.NEXT_PUBLIC_PRODUCT_HUB_URL || 'https://product-hub.flamingo.so';
31
- case 'revenue-hub':
32
- return process.env.NEXT_PUBLIC_REVENUE_HUB_URL || 'https://revenue-hub.flamingo.so';
33
- case 'people-hub':
34
- return process.env.NEXT_PUBLIC_PEOPLE_HUB_URL || 'https://people-hub.flamingo.so';
35
- // Standalone platforms — env var convention: NEXT_PUBLIC_<NAME>_URL
36
- case 'openmsp':
37
- return process.env.NEXT_PUBLIC_OPENMSP_URL || 'https://www.openmsp.ai';
38
- case 'flamingo':
39
- return process.env.NEXT_PUBLIC_FLAMINGO_URL || 'https://www.flamingo.run';
40
- case 'tmcg':
41
- return process.env.NEXT_PUBLIC_TMCG_URL || 'https://www.tmcg.miami';
42
- case 'flamingo-teaser':
43
- return process.env.NEXT_PUBLIC_FLAMINGO_URL || 'https://www.flamingo.run';
44
- case 'openframe':
45
- return process.env.NEXT_PUBLIC_OPENFRAME_URL || 'https://openframe.ai';
46
- // OpenFrame product / dashboard — the "Start Free Trial" CTA target. DISTINCT from
47
- // the `openframe` content hub above: production overrides that one to
48
- // hub.openframe.ai via NEXT_PUBLIC_OPENFRAME_URL (the knowledge/docs deployment),
49
- // so a product CTA must NOT reuse it. Defaults to the product apex so the CTA is
50
- // correct even when the (optional) dashboard override is unset.
51
- case 'openframe-dashboard':
52
- return process.env.NEXT_PUBLIC_OPENFRAME_DASHBOARD_URL || 'https://openframe.ai';
53
- case 'universal':
54
- return process.env.NEXT_PUBLIC_FLAMINGO_URL || 'https://www.flamingo.run';
55
- default:
56
- return process.env.NEXT_PUBLIC_FLAMINGO_URL || 'https://www.flamingo.run';
57
- }
58
- }
59
-
60
- /**
61
- * Get ALL unique base domains from getPlatformProductionUrl
62
- *
63
- * Extracts domains by calling getPlatformProductionUrl for each platform identifier.
64
- * Platform identifiers match the switch cases in getPlatformProductionUrl.
65
- *
66
- * Handles 3 cases:
67
- * 1. LOCALHOST DEBUG - No domain (hostname-specific cookies)
68
- * 2. VERCEL PREVIEW (*.vercel.app) - Use vercel.app domain
69
- * 3. PRODUCTION - Extract from ALL platform URLs via getPlatformProductionUrl
70
- *
71
- * @returns Array of all unique base domains (with and without wildcard)
72
- */
73
- export function getAllPlatformBaseDomains(): string[] {
74
- if (typeof window === 'undefined') return []
75
-
76
- const hostname = window.location.hostname
77
-
78
- // Case 1: LOCALHOST DEBUG - no domains needed
79
- if (hostname === 'localhost' ||
80
- hostname === '127.0.0.1' ||
81
- hostname.startsWith('127.')) {
82
- return []
83
- }
84
-
85
- // Case 2: VERCEL PREVIEW - use vercel.app domain
86
- const isVercelPreview = process.env.VERCEL_ENV === 'preview' ||
87
- process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
88
- hostname.includes('.vercel.app')
89
-
90
- if (isVercelPreview) {
91
- return ['.vercel.app', 'vercel.app']
92
- }
93
-
94
- // Case 3: PRODUCTION - extract from ALL platforms using getPlatformProductionUrl
95
- // Platform identifiers MUST match switch cases in getPlatformProductionUrl —
96
- // otherwise the corresponding domain is omitted from cookie scope.
97
- const platformIdentifiers = [
98
- 'marketing-hub', 'company-hub', 'product-hub', 'revenue-hub', 'people-hub',
99
- 'openmsp', 'flamingo', 'tmcg', 'flamingo-teaser', 'openframe', 'universal'
100
- ]
101
-
102
- const baseDomains = new Set<string>()
103
-
104
- platformIdentifiers.forEach(platform => {
105
- try {
106
- const url = getPlatformProductionUrl(platform)
107
- const urlHostname = new URL(url).hostname
108
- const parts = urlHostname.split('.')
109
-
110
- if (parts.length >= 2) {
111
- const baseDomain = parts.slice(-2).join('.')
112
- baseDomains.add(`.${baseDomain}`) // Wildcard
113
- baseDomains.add(baseDomain) // Non-wildcard
114
- }
115
- } catch (error) {
116
- console.warn('[Platform Domains] Failed to parse URL for platform:', platform, error)
117
- }
118
- })
119
-
120
- return Array.from(baseDomains)
121
- }
122
-
123
24
  /**
124
25
  * Get the application base URL for the current environment
125
26
  *
@@ -127,35 +28,18 @@ export function getAllPlatformBaseDomains(): string[] {
127
28
  * @returns The base URL with protocol (https:// or http://)
128
29
  *
129
30
  * Priority order:
130
- * 1. Environment variable override (NEXT_PUBLIC_*_URL)
131
- * 2. Platform-specific URL (if platform parameter provided)
31
+ * 1. Development (http://localhost:3000)
32
+ * 2. Platform-specific URL via the SSOT (if `platform` provided) — env override ?? default
132
33
  * 3. VERCEL_PROJECT_PRODUCTION_URL (Vercel production domain)
133
- * 4. Production fallback (current app or openmsp)
134
- * 5. Development (http://localhost:3000)
135
- *
136
- * Environment Variables (optional overrides — only set when production domain
137
- * differs from the hardcoded default):
138
- *
139
- * Hub deployments (NEXT_PUBLIC_<NAME>_HUB_URL):
140
- * - NEXT_PUBLIC_MARKETING_HUB_URL -> marketing-hub.flamingo.so
141
- * - NEXT_PUBLIC_COMPANY_HUB_URL -> company-hub.flamingo.so
142
- * - NEXT_PUBLIC_PRODUCT_HUB_URL -> product-hub.flamingo.so
143
- * - NEXT_PUBLIC_REVENUE_HUB_URL -> revenue-hub.flamingo.so
144
- * - NEXT_PUBLIC_PEOPLE_HUB_URL -> people-hub.flamingo.so
34
+ * 4. Production fallback: the DEPLOYING platform's canonical URL (NEXT_PUBLIC_APP_TYPE),
35
+ * openmsp if unset — sourced from the SSOT, no hardcoded literal.
145
36
  *
146
- * Standalone platforms (NEXT_PUBLIC_<NAME>_URL):
147
- * - NEXT_PUBLIC_OPENMSP_URL -> www.openmsp.ai
148
- * - NEXT_PUBLIC_FLAMINGO_URL -> www.flamingo.run
149
- * - NEXT_PUBLIC_TMCG_URL -> www.tmcg.miami
150
- * - NEXT_PUBLIC_FLAMINGO_TEASER_URL -> www.flamingo.cx
151
- * - NEXT_PUBLIC_OPENFRAME_URL -> openframe.ai
152
- * - NEXT_PUBLIC_OPENFRAME_DASHBOARD_URL -> openframe.ai (product/dashboard CTA target;
153
- * distinct from the openframe content hub, which prod points at hub.openframe.ai)
37
+ * The per-platform canonical URLs + their `NEXT_PUBLIC_*_URL` overrides are the single
38
+ * source of truth in `src/platform-domains.ts` (`PLATFORM_DOMAINS`).
154
39
  *
155
40
  * @example
156
- * getBaseUrl() // Current app URL
157
- * getBaseUrl('flamingo') // https://www.flamingo.run (production) or http://localhost:3000 (dev)
158
- * getBaseUrl('openmsp') // https://www.openmsp.ai (or NEXT_PUBLIC_OPENMSP_URL if set)
41
+ * getBaseUrl() // Current deployment's URL
42
+ * getBaseUrl('flamingo') // https://www.flamingo.run (prod) or http://localhost:3000 (dev)
159
43
  */
160
44
  export function getBaseUrl(platform?: string): string {
161
45
  // In development, always use localhost (regardless of platform)
@@ -163,7 +47,7 @@ export function getBaseUrl(platform?: string): string {
163
47
  return process.env.NEXT_PUBLIC_DEV_URL || 'http://localhost:3000'
164
48
  }
165
49
 
166
- // If platform is specified, return its production URL with env variable override support
50
+ // If platform is specified, return its production URL (env override ?? default)
167
51
  if (platform) {
168
52
  return getPlatformProductionUrl(platform)
169
53
  }
@@ -173,8 +57,8 @@ export function getBaseUrl(platform?: string): string {
173
57
  return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
174
58
  }
175
59
 
176
- // Production fallback: Use canonical www domain to avoid Google "Page with redirect" issue.
177
- // openmsp.ai redirects to www.openmsp.ai, so we set the base URL to the
178
- // final destination to ensure canonical URLs do not require a redirect.
179
- return 'https://www.openmsp.ai'
60
+ // Production fallback: the deploying platform's canonical www domain (avoids Google
61
+ // "Page with redirect"). Derived from the SSOT for the current app type (openmsp when
62
+ // unset 'https://www.openmsp.ai', byte-identical to the old hardcoded fallback).
63
+ return getPlatformProductionUrl(process.env.NEXT_PUBLIC_APP_TYPE || 'openmsp')
180
64
  }
@@ -1,5 +1,10 @@
1
1
  // Utils exports - client-side only
2
- export { cn, getAllPlatformBaseDomains } from './cn'
2
+ export { cn } from './cn'
3
+ // Platform→domain SSOT lives in `../platform-domains` (single definition). Re-exported
4
+ // here so existing `/utils` callers keep working; the new resolver/helpers
5
+ // (getPlatformByHostname/hostOf/expandWwwApex/…) are exposed via the `/platform-domains`
6
+ // subpath ONLY (one import surface for the new API).
7
+ export { getPlatformProductionUrl, getAllPlatformBaseDomains } from '../platform-domains'
3
8
  // Number / currency / byte / date formatters live in `./format` (single
4
9
  // source of truth). Re-exported here so existing callers that pull from
5
10
  // the barrel keep working without changing imports.
@@ -8,7 +13,7 @@ export { formatDate, formatNumber, formatPrice, formatBytes } from './format'
8
13
  export { PLAY_ICON_PATH } from '../components/icons-v2-generated/media-playback/play-icon'
9
14
  export { getPlatformAccentColor, getCurrentPlatform, type ColorCategory, HEX_PATTERN } from './ods-color-utils'
10
15
  export { delay, generateRandomString, truncateString, deepClone, getSlackCommunityJoinUrl } from './common'
11
- export { getBaseUrl, getPlatformProductionUrl } from '../utils/cn'
16
+ export { getBaseUrl } from '../utils/cn'
12
17
 
13
18
  export * from './platform-config'
14
19
  export * from './os-platforms'