@innertia-solutions/innertia-nuxt 0.1.65 → 0.1.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { collectBuildVersion, readPackageVersions } from '../version'
6
+
7
+ let root: string
8
+
9
+ function pkg(dir: string, json: Record<string, unknown>) {
10
+ mkdirSync(dir, { recursive: true })
11
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(json))
12
+ }
13
+
14
+ beforeEach(() => {
15
+ root = mkdtempSync(join(tmpdir(), 'innertia-version-'))
16
+ pkg(root, { name: 'acme-front', version: '1.2.3' })
17
+ pkg(join(root, 'node_modules', 'nuxt'), { name: 'nuxt', version: '4.4.6' })
18
+ pkg(join(root, 'node_modules', 'vue'), { name: 'vue', version: '3.5.34' })
19
+ pkg(join(root, 'node_modules', '@innertia-solutions', 'innertia-nuxt'), { name: '@innertia-solutions/innertia-nuxt', version: '0.1.66' })
20
+ })
21
+ afterEach(() => rmSync(root, { recursive: true, force: true }))
22
+
23
+ describe('readPackageVersions', () => {
24
+ it('lee versiones instaladas y omite las ausentes', () => {
25
+ expect(readPackageVersions(root, ['nuxt', 'vue', '@innertia-solutions/innertia-nuxt', 'no-existe']))
26
+ .toEqual({ nuxt: '4.4.6', vue: '3.5.34', '@innertia-solutions/innertia-nuxt': '0.1.66' })
27
+ })
28
+ })
29
+
30
+ describe('collectBuildVersion', () => {
31
+ it('usa APP_COMMIT/APP_BUILD_DATE/APP_VERSION del entorno de build', () => {
32
+ const v = collectBuildVersion(root, {
33
+ APP_COMMIT: 'abcdef1234567890', APP_BUILD_DATE: '2026-11-04T10:00:00Z', APP_VERSION: '9.9.9',
34
+ }, { git: false })
35
+ expect(v).toMatchObject({ app: 'acme-front', version: '9.9.9', commit: 'abcdef1234567890', buildDate: '2026-11-04T10:00:00Z' })
36
+ expect(v.packages.nuxt).toBe('4.4.6')
37
+ })
38
+
39
+ it('cae al package.json del producto y a la fecha actual', () => {
40
+ const v = collectBuildVersion(root, {}, { git: false })
41
+ expect(v.version).toBe('1.2.3')
42
+ expect(v.commit).toBe('')
43
+ expect(Number.isNaN(Date.parse(v.buildDate))).toBe(false)
44
+ })
45
+ })
@@ -0,0 +1,75 @@
1
+ // Build-time (Node): recolecta la info de versión del PRODUCTO al correr `nuxi build`.
2
+ // La usa el módulo inline del nuxt.config de la capa para llenar
3
+ // runtimeConfig.innertiaVersion (PRIVADO: nunca llega al payload del browser).
4
+ import { readFileSync, existsSync } from 'node:fs'
5
+ import { execFileSync } from 'node:child_process'
6
+ import { join } from 'node:path'
7
+
8
+ export const DEFAULT_FRONT_PACKAGES = [
9
+ 'nuxt',
10
+ 'vue',
11
+ '@innertia-solutions/innertia-nuxt',
12
+ 'pinia',
13
+ '@pinia/nuxt',
14
+ '@tanstack/vue-query',
15
+ 'tailwindcss',
16
+ 'pusher-js',
17
+ ]
18
+
19
+ export interface BuildVersion {
20
+ app: string
21
+ version: string
22
+ commit: string
23
+ buildDate: string
24
+ packages: Record<string, string>
25
+ }
26
+
27
+ function readJson(path: string): Record<string, any> | null {
28
+ try {
29
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** Versiones instaladas (node_modules del producto). Las ausentes se omiten. */
36
+ export function readPackageVersions(rootDir: string, names: string[]): Record<string, string> {
37
+ const out: Record<string, string> = {}
38
+ for (const name of names) {
39
+ const v = readJson(join(rootDir, 'node_modules', name, 'package.json'))?.version
40
+ if (typeof v === 'string' && v) out[name] = v
41
+ }
42
+ return out
43
+ }
44
+
45
+ function gitCommit(rootDir: string): string {
46
+ try {
47
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: rootDir, stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 })
48
+ .toString().trim()
49
+ } catch {
50
+ return ''
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Prioridad por campo: env de build (APP_COMMIT / APP_BUILD_DATE / APP_VERSION)
56
+ * → build-info.json en rootDir (lo escribe el Dockerfile) → fallback
57
+ * (git rev-parse en dev, package.json del producto, fecha actual).
58
+ */
59
+ export function collectBuildVersion(
60
+ rootDir: string,
61
+ env: Record<string, string | undefined> = process.env,
62
+ opts: { git?: boolean, packages?: string[] } = {},
63
+ ): BuildVersion {
64
+ const product = readJson(join(rootDir, 'package.json')) ?? {}
65
+ const info = readJson(join(rootDir, 'build-info.json')) ?? {}
66
+ const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
67
+
68
+ return {
69
+ app: str(product.name),
70
+ version: str(env.APP_VERSION) || str(info.version) || str(product.version),
71
+ commit: str(env.APP_COMMIT) || str(info.commit) || (opts.git === false ? '' : gitCommit(rootDir)),
72
+ buildDate: str(env.APP_BUILD_DATE) || str(info.build_date) || new Date().toISOString(),
73
+ packages: readPackageVersions(rootDir, opts.packages ?? DEFAULT_FRONT_PACKAGES),
74
+ }
75
+ }
@@ -0,0 +1,115 @@
1
+ <script setup>
2
+ /**
3
+ * <AppVersions /> — tarjeta "Versiones" para la consola /platform.
4
+ * Consume /_version/details (ruta Nitro de la capa), que autoriza contra el backend
5
+ * (GET platform/version → solo platform admin) y adjunta el detalle del front.
6
+ *
7
+ * ```vue
8
+ * <AppVersions />
9
+ * <AppVersions endpoint="/_version/details" title="Versiones" />
10
+ * ```
11
+ */
12
+ import { IconVersions, IconRefresh } from '@tabler/icons-vue'
13
+
14
+ const props = defineProps({
15
+ endpoint: { type: String, default: '/_version/details' },
16
+ title: { type: String, default: 'Versiones' },
17
+ })
18
+
19
+ const { data, error, pending, refresh } = useFetch(props.endpoint, { server: false, key: `innertia-versions:${props.endpoint}` })
20
+
21
+ function fmtDate(iso) {
22
+ if (!iso) return '—'
23
+ const d = new Date(iso)
24
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString()
25
+ }
26
+
27
+ const sides = computed(() => {
28
+ if (!data.value) return []
29
+ const { frontend: f, backend: b } = data.value
30
+ return [
31
+ {
32
+ key: 'frontend',
33
+ label: 'Frontend',
34
+ info: f,
35
+ runtime: [['Node', f?.runtime?.node]],
36
+ packages: Object.entries(f?.packages ?? {}).map(([name, version]) => ({ name, version })),
37
+ },
38
+ {
39
+ key: 'backend',
40
+ label: 'Backend',
41
+ info: b,
42
+ runtime: [
43
+ ['PHP', b?.runtime?.php],
44
+ ['Laravel', b?.runtime?.laravel],
45
+ [b?.runtime?.database?.driver ? `BD (${b.runtime.database.driver})` : 'BD', b?.runtime?.database?.version],
46
+ ],
47
+ packages: Object.entries(b?.packages ?? {}).map(([name, p]) => ({ name, version: p?.version, reference: p?.reference })),
48
+ },
49
+ ]
50
+ })
51
+ </script>
52
+
53
+ <template>
54
+ <section class="rounded-card border border-card-line bg-card">
55
+ <header class="flex items-center justify-between gap-3 border-b border-card-line px-4 py-3">
56
+ <div class="flex items-center gap-2">
57
+ <IconVersions class="size-5 text-muted-foreground" />
58
+ <h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
59
+ </div>
60
+ <button
61
+ type="button"
62
+ class="inline-flex items-center gap-1 rounded-control px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
63
+ :disabled="pending"
64
+ @click="refresh()"
65
+ >
66
+ <IconRefresh class="size-3.5" :class="{ 'animate-spin': pending }" /> Actualizar
67
+ </button>
68
+ </header>
69
+
70
+ <div v-if="pending && !data" class="p-4 text-sm text-muted-foreground">Cargando…</div>
71
+ <div v-else-if="error" class="p-4 text-sm text-red-600 dark:text-red-400">
72
+ No se pudieron cargar las versiones ({{ error.statusCode ?? 'error' }}).
73
+ </div>
74
+
75
+ <div v-else class="grid divide-y divide-card-line md:grid-cols-2 md:divide-x md:divide-y-0">
76
+ <div v-for="side in sides" :key="side.key" class="space-y-4 p-4">
77
+ <div>
78
+ <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ side.label }}</p>
79
+ <p class="mt-1 text-lg font-semibold text-foreground">
80
+ {{ side.info?.app || '—' }}
81
+ <span class="text-sm font-normal text-muted-foreground">{{ side.info?.version || '' }}</span>
82
+ </p>
83
+ </div>
84
+
85
+ <dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
86
+ <dt class="text-muted-foreground">Commit</dt>
87
+ <dd class="flex items-center gap-1 font-mono text-foreground">
88
+ {{ side.info?.commit || '—' }}
89
+ <CopyButton v-if="side.info?.commit" :value="side.info.commit" size="xs" />
90
+ </dd>
91
+ <dt class="text-muted-foreground">Build</dt>
92
+ <dd class="text-foreground">{{ fmtDate(side.info?.build_date) }}</dd>
93
+ <dt class="text-muted-foreground">Entorno</dt>
94
+ <dd class="text-foreground">{{ side.info?.environment || '—' }}</dd>
95
+ <template v-for="[label, value] in side.runtime" :key="label">
96
+ <dt class="text-muted-foreground">{{ label }}</dt>
97
+ <dd class="font-mono text-foreground">{{ value || '—' }}</dd>
98
+ </template>
99
+ </dl>
100
+
101
+ <div v-if="side.packages.length">
102
+ <p class="mb-1.5 text-xs font-medium text-muted-foreground">Paquetes</p>
103
+ <ul class="divide-y divide-card-line rounded-control border border-card-line text-sm">
104
+ <li v-for="p in side.packages" :key="p.name" class="flex items-center justify-between gap-3 px-3 py-1.5">
105
+ <span class="truncate text-foreground">{{ p.name }}</span>
106
+ <span class="shrink-0 font-mono text-xs text-muted-foreground">
107
+ {{ p.version || '—' }}<template v-if="p.reference && String(p.version).startsWith('dev-')"> · {{ p.reference }}</template>
108
+ </span>
109
+ </li>
110
+ </ul>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ </section>
115
+ </template>
package/nuxt.config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import tailwindcss from '@tailwindcss/vite'
2
+ import { collectBuildVersion } from './build/version'
2
3
 
3
4
  // @innertia-solutions/innertia-nuxt — capa Nuxt unificada de Innertia Solutions.
4
5
  // Provee: utilidades base + auth + multitenancy + design system + app contexts.
@@ -7,6 +8,21 @@ export default defineNuxtConfig({
7
8
  '@nuxtjs/seo',
8
9
  '@pinia/nuxt',
9
10
  'pinia-plugin-persistedstate/nuxt', // requerido para persistir auth, dockedPreviews, etc.
11
+ // Versiones: en build llena runtimeConfig.innertiaVersion (PRIVADO) con app/version/
12
+ // commit/fecha del producto + versiones de nuxt/vue/innertia-nuxt de su node_modules.
13
+ // Lo sirven /_version (público, sin deps) y /_version/details (vía backend platform).
14
+ (_opts: unknown, nuxt: any) => {
15
+ const current = nuxt.options.runtimeConfig.innertiaVersion ?? {}
16
+ const built = collectBuildVersion(nuxt.options.rootDir)
17
+ nuxt.options.runtimeConfig.innertiaVersion = {
18
+ ...current,
19
+ app: current.app || nuxt.options.runtimeConfig.public?.appName || built.app,
20
+ version: current.version || built.version,
21
+ commit: current.commit || built.commit,
22
+ buildDate: current.buildDate || built.buildDate,
23
+ packages: { ...built.packages, ...(current.packages ?? {}) },
24
+ }
25
+ },
10
26
  ],
11
27
  css: ['@innertia-solutions/innertia-nuxt/theme.css'],
12
28
  components: [
@@ -26,6 +42,16 @@ export default defineNuxtConfig({
26
42
  session: {
27
43
  password: '',
28
44
  },
45
+ // Privado. Lo llena el módulo inline de arriba en build; override runtime con
46
+ // NUXT_INNERTIA_VERSION_COMMIT / _BUILD_DATE / _VERSION / _DETAILS_PATH.
47
+ innertiaVersion: {
48
+ app: '',
49
+ version: '',
50
+ commit: '',
51
+ buildDate: '',
52
+ packages: {},
53
+ detailsPath: 'platform/version',
54
+ },
29
55
  public: {
30
56
  loginPath: '/backoffice/login',
31
57
  homePath: '/backoffice',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -1,6 +1,7 @@
1
- import { defineEventHandler, getRouterParam, getQuery, sendRedirect, createError } from 'h3'
1
+ import { defineEventHandler, getRouterParam, getQuery, sendRedirect, createError, getRequestHeader } from 'h3'
2
2
  import { writeSession } from '../../../utils/session'
3
3
  import { normalizeLoginResponse } from '../../../utils/login'
4
+ import { forwardClientHeaders } from '../../../utils/bff'
4
5
 
5
6
  // Recibe el callback de OAuth, intercambia contra Laravel, sella la sesión y
6
7
  // redirige a la app. El token nunca llega al browser.
@@ -12,7 +13,7 @@ export default defineEventHandler(async (event) => {
12
13
  let data: any
13
14
  try {
14
15
  data = await $fetch(`${config.apiInternalUrl}/auth/${provider}/callback`, {
15
- method: 'GET', query, headers: { Accept: 'application/json' },
16
+ method: 'GET', query, headers: { Accept: 'application/json', ...forwardClientHeaders(n => getRequestHeader(event, n)) },
16
17
  })
17
18
  } catch (err: any) {
18
19
  const msg = err?.data?.message ?? 'oauth_failed'
@@ -1,5 +1,6 @@
1
- import { defineEventHandler, readBody, createError } from 'h3'
1
+ import { defineEventHandler, readBody, createError, getRequestHeader } from 'h3'
2
2
  import { writeSession } from '../../utils/session'
3
+ import { forwardClientHeaders } from '../../utils/bff'
3
4
  import { normalizeLoginResponse } from '../../utils/login'
4
5
 
5
6
  // Llama a Laravel /auth/login server-to-server; en éxito sella el JWT en la
@@ -13,7 +14,7 @@ export default defineEventHandler(async (event) => {
13
14
  data = await $fetch(`${config.apiInternalUrl}/auth/login`, {
14
15
  method: 'POST',
15
16
  body: { context, email, password, remember },
16
- headers: { Accept: 'application/json' },
17
+ headers: { Accept: 'application/json', ...forwardClientHeaders(n => getRequestHeader(event, n)) },
17
18
  })
18
19
  } catch (err: any) {
19
20
  const status = err?.response?.status ?? err?.statusCode ?? 500
@@ -1,5 +1,6 @@
1
- import { defineEventHandler, readBody, createError } from 'h3'
1
+ import { defineEventHandler, readBody, createError, getRequestHeader } from 'h3'
2
2
  import { writeSession } from '../../../utils/session'
3
+ import { forwardClientHeaders } from '../../../utils/bff'
3
4
  import { normalizeLoginResponse } from '../../../utils/login'
4
5
 
5
6
  // Login de plataforma (identidad separada, sin contexto de tenant). Llama a
@@ -15,7 +16,7 @@ export default defineEventHandler(async (event) => {
15
16
  data = await $fetch(`${config.apiInternalUrl}/platform/auth/login`, {
16
17
  method: 'POST',
17
18
  body: { email, password },
18
- headers: { Accept: 'application/json' },
19
+ headers: { Accept: 'application/json', ...forwardClientHeaders(n => getRequestHeader(event, n)) },
19
20
  })
20
21
  } catch (err: any) {
21
22
  const status = err?.response?.status ?? err?.statusCode ?? 500
@@ -0,0 +1,39 @@
1
+ import { defineEventHandler, setResponseHeader, setResponseStatus } from 'h3'
2
+ import { readSession } from '../../utils/session'
3
+ import { resolveApiTarget, buildAuthHeaders } from '../../utils/bff'
4
+ import { frontendDetails } from '../../utils/version'
5
+
6
+ // Detalle (front + back). Nitro no sabe quién es platform admin: delega la
7
+ // autorización al backend llamando su endpoint protegido con el Bearer de la sesión.
8
+ // ≠2xx → mismo status, sin datos del front. 2xx → { frontend, backend }.
9
+ export default defineEventHandler(async (event) => {
10
+ setResponseHeader(event, 'cache-control', 'no-store')
11
+ const config = useRuntimeConfig(event)
12
+ const cfg = config.innertiaVersion ?? {}
13
+ const { token } = await readSession(event)
14
+
15
+ if (!token) {
16
+ setResponseStatus(event, 401)
17
+ return { message: 'No autenticado.' }
18
+ }
19
+
20
+ const target = resolveApiTarget(config.apiInternalUrl, cfg.detailsPath || 'platform/version', '')
21
+ const res = await $fetch.raw(target, {
22
+ headers: { ...buildAuthHeaders(token), accept: 'application/json' },
23
+ ignoreResponseError: true,
24
+ }).catch(() => null)
25
+
26
+ if (!res) {
27
+ setResponseStatus(event, 502)
28
+ return { message: 'Backend no disponible.' }
29
+ }
30
+ if (res.status < 200 || res.status >= 300) {
31
+ setResponseStatus(event, res.status)
32
+ return { message: (res._data as any)?.message ?? 'No autorizado.' }
33
+ }
34
+
35
+ return {
36
+ frontend: frontendDetails(cfg, process.env, process.version),
37
+ backend: res._data,
38
+ }
39
+ })
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler, setResponseHeader } from 'h3'
2
+ import { publicVersion } from '../../utils/version'
3
+
4
+ // Público: qué build del front está desplegado. Sin dependencias.
5
+ export default defineEventHandler((event) => {
6
+ setResponseHeader(event, 'cache-control', 'no-store')
7
+ return publicVersion(useRuntimeConfig(event).innertiaVersion ?? {}, process.env)
8
+ })
@@ -20,3 +20,21 @@ describe('buildAuthHeaders', () => {
20
20
  expect(buildAuthHeaders(undefined)).toEqual({ cookie: '' })
21
21
  })
22
22
  })
23
+
24
+ describe('forwardClientHeaders', () => {
25
+ it('reenvía solo IP y user-agent del cliente, omitiendo los ausentes', async () => {
26
+ const { forwardClientHeaders } = await import('../bff')
27
+ const headers: Record<string, string> = {
28
+ 'x-forwarded-for': '200.1.2.3, 172.68.0.1',
29
+ 'cf-connecting-ip': '200.1.2.3',
30
+ 'user-agent': 'Mozilla/5.0',
31
+ cookie: 'innertia_session=secret',
32
+ authorization: 'Bearer x',
33
+ }
34
+ expect(forwardClientHeaders(n => headers[n])).toEqual({
35
+ 'x-forwarded-for': '200.1.2.3, 172.68.0.1',
36
+ 'cf-connecting-ip': '200.1.2.3',
37
+ 'user-agent': 'Mozilla/5.0',
38
+ })
39
+ })
40
+ })
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { publicVersion, frontendDetails } from '../version'
3
+
4
+ const cfg = {
5
+ app: 'acme-front', version: '1.2.3', commit: '0123456789abcdef', buildDate: '2026-11-04T10:00:00Z',
6
+ packages: { nuxt: '4.4.6', vue: '3.5.34' }, detailsPath: 'platform/version',
7
+ }
8
+
9
+ describe('publicVersion', () => {
10
+ it('expone solo los 5 campos, commit corto, sin dependencias', () => {
11
+ const v = publicVersion(cfg, { APP_ENV: 'staging' })
12
+ expect(Object.keys(v)).toEqual(['app', 'version', 'commit', 'build_date', 'environment'])
13
+ expect(v).toEqual({ app: 'acme-front', version: '1.2.3', commit: '0123456', build_date: '2026-11-04T10:00:00Z', environment: 'staging' })
14
+ expect(JSON.stringify(v)).not.toContain('4.4.6')
15
+ })
16
+
17
+ it('commit vacío → null; environment cae a NODE_ENV', () => {
18
+ const v = publicVersion({ ...cfg, commit: '' }, { NODE_ENV: 'production' })
19
+ expect(v.commit).toBeNull()
20
+ expect(v.environment).toBe('production')
21
+ })
22
+ })
23
+
24
+ describe('frontendDetails', () => {
25
+ it('agrega runtime node y paquetes', () => {
26
+ const d = frontendDetails(cfg, { APP_ENV: 'local' }, 'v22.12.0')
27
+ expect(d.runtime).toEqual({ node: 'v22.12.0' })
28
+ expect(d.packages).toEqual({ nuxt: '4.4.6', vue: '3.5.34' })
29
+ expect(d.commit).toBe('0123456')
30
+ })
31
+ })
@@ -18,3 +18,22 @@ export function resolveApiTarget(base: string, path: string, search: string): st
18
18
  export function buildAuthHeaders(token?: string): Record<string, string> {
19
19
  return token ? { authorization: `Bearer ${token}`, cookie: '' } : { cookie: '' }
20
20
  }
21
+
22
+ /** Headers del cliente que Laravel necesita para conocer su IP real y dispositivo. */
23
+ const CLIENT_HEADERS = ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'user-agent'] as const
24
+
25
+ /**
26
+ * Headers del cliente original a reenviar en las llamadas server-to-server
27
+ * (login, login de plataforma, callback OAuth, logout). Sin esto Laravel ve a
28
+ * TODOS los usuarios con la IP del contenedor Nuxt: el throttle de login queda
29
+ * compartido entre todos (un atacante bloquea el login de todos) y las
30
+ * sesiones quedan sin IP ni dispositivo. Recibe un getter para no depender de h3.
31
+ */
32
+ export function forwardClientHeaders(get: (name: string) => string | undefined | null): Record<string, string> {
33
+ const out: Record<string, string> = {}
34
+ for (const name of CLIENT_HEADERS) {
35
+ const value = get(name)
36
+ if (value) out[name] = value
37
+ }
38
+ return out
39
+ }
@@ -0,0 +1,41 @@
1
+ // Lógica pura del endpoint de versiones (sin h3, testeable aislada).
2
+
3
+ export interface VersionConfig {
4
+ app?: string
5
+ version?: string
6
+ commit?: string
7
+ buildDate?: string
8
+ packages?: Record<string, string>
9
+ detailsPath?: string
10
+ }
11
+
12
+ export interface PublicVersion {
13
+ app: string | null
14
+ version: string | null
15
+ commit: string | null
16
+ build_date: string | null
17
+ environment: string
18
+ }
19
+
20
+ const orNull = (v?: string) => (v && v.trim() ? v.trim() : null)
21
+
22
+ /** Público: SOLO estos 5 campos. Nunca dependencias ni runtime (fingerprinting/CVE). */
23
+ export function publicVersion(cfg: VersionConfig, env: Record<string, string | undefined>): PublicVersion {
24
+ const commit = orNull(cfg.commit)
25
+ return {
26
+ app: orNull(cfg.app),
27
+ version: orNull(cfg.version),
28
+ commit: commit ? commit.slice(0, 7) : null,
29
+ build_date: orNull(cfg.buildDate),
30
+ environment: env.APP_ENV || env.NODE_ENV || 'production',
31
+ }
32
+ }
33
+
34
+ /** Detalle del front: público + runtime + paquetes. Solo tras autorizar contra el backend. */
35
+ export function frontendDetails(cfg: VersionConfig, env: Record<string, string | undefined>, node: string) {
36
+ return {
37
+ ...publicVersion(cfg, env),
38
+ runtime: { node },
39
+ packages: { ...(cfg.packages ?? {}) },
40
+ }
41
+ }