@ossy/platform 3.12.0 → 3.13.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.
package/README.md CHANGED
@@ -11,12 +11,13 @@ At startup `@ossy/platform`:
11
11
  3. Registers and runs all **startup hooks** (`*.startup.js`) in order.
12
12
  4. Connects all **integrations** (`*.integration.js`) by calling `connect({ env })`.
13
13
  5. Registers all **tasks** (`*.task.js`) with `TaskService` and starts the cron scheduler.
14
- 6. Registers all **schemas** (`*.schema.js`) with `registerSchema`.
15
- 7. Rebuilds all **aggregates** (`*.aggregate.js`) from the event store.
16
- 8. Registers all **actions** (`*.action.js`) with `ActionService`.
17
- 9. Mounts **MCP** at `POST /mcp` and serves `GET /capabilities.json`.
18
- 10. Starts an Express server that routes requests to pages (`*.page.jsx`) and API handlers (`*.api.js`).
19
- 11. Auto-mounts every action at `POST /actions` (`{ action, payload }`).
14
+ 6. When `MAXMIND_LICENSE_KEY` is set, downloads GeoLite2-Country to `GEOLITE2_COUNTRY_MMDB` (default `/tmp/GeoLite2-Country.mmdb`) so Host location projections can resolve country. Missing key or a failed download does not prevent startup (#804).
15
+ 7. Registers all **schemas** (`*.schema.js`) with `registerSchema`.
16
+ 8. Rebuilds all **aggregates** (`*.aggregate.js`) from the event store.
17
+ 9. Registers all **actions** (`*.action.js`) with `ActionService`.
18
+ 10. Mounts **MCP** at `POST /mcp` and serves `GET /capabilities.json`.
19
+ 11. Starts an Express server that routes requests to pages (`*.page.jsx`) and API handlers (`*.api.js`).
20
+ 12. Auto-mounts every action at `POST /actions` (`{ action, payload }`).
20
21
 
21
22
  Page SSR fills the `app:content` slot (see `PlatformShell` and `resolve-app-slots` in `@ossy/app`). App chrome uses namespaced keys such as `app:header` mapped from `export const slots` in `*.layout.jsx`.
22
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -76,5 +76,5 @@
76
76
  "Dockerfile",
77
77
  "docker-healthcheck.js"
78
78
  ],
79
- "gitHead": "c2650803219ad1831559b512848358d9550ffad2"
79
+ "gitHead": "7a02d1e056b731aa36f72e3f8d6df941101dbd50"
80
80
  }
@@ -0,0 +1,119 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createLogger } from '@ossy/observability'
4
+ import { extractMmdbFromTarGz } from './extract-mmdb-from-tar-gz.js'
5
+ import {
6
+ GEOLITE2_COUNTRY_DOWNLOAD_TIMEOUT_MS,
7
+ GEOLITE2_COUNTRY_MMDB_PATH,
8
+ } from './geolite2-country-path.js'
9
+
10
+ const log = createLogger('@ossy/platform/geoip')
11
+
12
+ const LEGACY_DOWNLOAD_URL = 'https://download.maxmind.com/app/geoip_download'
13
+ const PERMALINK_DOWNLOAD_URL = 'https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz'
14
+
15
+ /**
16
+ * @param {NodeJS.ProcessEnv} [env]
17
+ * @returns {string}
18
+ */
19
+ export function resolveGeoLite2CountryPath (env = process.env) {
20
+ const raw = env.GEOLITE2_COUNTRY_MMDB ?? env.OSSY_GEOLITE2_COUNTRY_MMDB ?? ''
21
+ const trimmed = String(raw).trim()
22
+ return trimmed || GEOLITE2_COUNTRY_MMDB_PATH
23
+ }
24
+
25
+ /**
26
+ * @param {NodeJS.ProcessEnv} [env]
27
+ * @returns {string | null}
28
+ */
29
+ export function resolveMaxMindLicenseKey (env = process.env) {
30
+ const raw = env.MAXMIND_LICENSE_KEY ?? env.OSSY_MAXMIND_LICENSE_KEY ?? ''
31
+ const key = String(raw).trim()
32
+ return key || null
33
+ }
34
+
35
+ /**
36
+ * @param {NodeJS.ProcessEnv} [env]
37
+ * @returns {string | null}
38
+ */
39
+ export function resolveMaxMindAccountId (env = process.env) {
40
+ const raw = env.MAXMIND_ACCOUNT_ID ?? env.OSSY_MAXMIND_ACCOUNT_ID ?? ''
41
+ const id = String(raw).trim()
42
+ return id || null
43
+ }
44
+
45
+ /**
46
+ * @param {{ licenseKey: string, accountId?: string | null }} options
47
+ * @returns {{ url: string, headers: Record<string, string> }}
48
+ */
49
+ export function maxMindDownloadRequest ({ licenseKey, accountId }) {
50
+ if (accountId) {
51
+ const basic = Buffer.from(`${accountId}:${licenseKey}`, 'utf8').toString('base64')
52
+ return {
53
+ url: PERMALINK_DOWNLOAD_URL,
54
+ headers: { Authorization: `Basic ${basic}` },
55
+ }
56
+ }
57
+ const url = `${LEGACY_DOWNLOAD_URL}?edition_id=GeoLite2-Country&license_key=${encodeURIComponent(licenseKey)}&suffix=tar.gz`
58
+ return { url, headers: {} }
59
+ }
60
+
61
+ /**
62
+ * Download GeoLite2-Country when `MAXMIND_LICENSE_KEY` is set.
63
+ *
64
+ * Shared by every `@ossy/platform` `startServer` image (ossy.se, plexus-sanitas,
65
+ * future `services[]` sites). Missing key / failed download is non-fatal:
66
+ * the process starts and location tiles omit `countryCode` (#804).
67
+ *
68
+ * @param {{
69
+ * env?: NodeJS.ProcessEnv,
70
+ * fetchImpl?: typeof fetch,
71
+ * }} [options]
72
+ * @returns {Promise<string | null>} destination path when a readable `.mmdb` is in place
73
+ */
74
+ export async function ensureGeoLite2Country (options = {}) {
75
+ const env = options.env ?? process.env
76
+ const fetchImpl = options.fetchImpl ?? fetch
77
+ const dest = resolveGeoLite2CountryPath(env)
78
+
79
+ if (fs.existsSync(dest)) {
80
+ env.GEOLITE2_COUNTRY_MMDB = dest
81
+ log.info(`GeoLite2-Country already present at ${dest}`)
82
+ return dest
83
+ }
84
+
85
+ const licenseKey = resolveMaxMindLicenseKey(env)
86
+ if (!licenseKey) {
87
+ log.info('MAXMIND_LICENSE_KEY unset — skipping GeoLite2-Country download; location tiles will omit countryCode')
88
+ return null
89
+ }
90
+
91
+ const accountId = resolveMaxMindAccountId(env)
92
+ const { url, headers } = maxMindDownloadRequest({ licenseKey, accountId })
93
+
94
+ try {
95
+ log.info('Downloading GeoLite2-Country from MaxMind')
96
+ const response = await fetchImpl(url, {
97
+ headers,
98
+ signal: AbortSignal.timeout(GEOLITE2_COUNTRY_DOWNLOAD_TIMEOUT_MS),
99
+ })
100
+ if (!response.ok) {
101
+ throw new Error(`MaxMind download HTTP ${response.status}`)
102
+ }
103
+ const gzipped = Buffer.from(await response.arrayBuffer())
104
+ const mmdb = extractMmdbFromTarGz(gzipped)
105
+
106
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
107
+ const staging = `${dest}.download`
108
+ fs.writeFileSync(staging, mmdb)
109
+ fs.renameSync(staging, dest)
110
+ env.GEOLITE2_COUNTRY_MMDB = dest
111
+ log.info(`Wrote GeoLite2-Country to ${dest}`)
112
+ return dest
113
+ } catch (err) {
114
+ log.warn('GeoLite2-Country download failed — location tiles will omit countryCode', {
115
+ error: err instanceof Error ? err.message : String(err),
116
+ })
117
+ return null
118
+ }
119
+ }
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from '@jest/globals'
5
+ import {
6
+ ensureGeoLite2Country,
7
+ maxMindDownloadRequest,
8
+ resolveGeoLite2CountryPath,
9
+ resolveMaxMindLicenseKey,
10
+ } from './ensure-geolite2-country.js'
11
+ import { GEOLITE2_COUNTRY_MMDB_PATH } from './geolite2-country-path.js'
12
+ import { makeUstarTarGz } from './tar-fixture.js'
13
+
14
+ const originalGeo = process.env.GEOLITE2_COUNTRY_MMDB
15
+ const originalOssyGeo = process.env.OSSY_GEOLITE2_COUNTRY_MMDB
16
+ const originalKey = process.env.MAXMIND_LICENSE_KEY
17
+ const originalOssyKey = process.env.OSSY_MAXMIND_LICENSE_KEY
18
+ const originalAccount = process.env.MAXMIND_ACCOUNT_ID
19
+
20
+ function restoreEnv () {
21
+ restore('GEOLITE2_COUNTRY_MMDB', originalGeo)
22
+ restore('OSSY_GEOLITE2_COUNTRY_MMDB', originalOssyGeo)
23
+ restore('MAXMIND_LICENSE_KEY', originalKey)
24
+ restore('OSSY_MAXMIND_LICENSE_KEY', originalOssyKey)
25
+ restore('MAXMIND_ACCOUNT_ID', originalAccount)
26
+ }
27
+
28
+ function restore (key, previous) {
29
+ if (previous == null) delete process.env[key]
30
+ else process.env[key] = previous
31
+ }
32
+
33
+ describe('resolveGeoLite2CountryPath', () => {
34
+ afterEach(restoreEnv)
35
+
36
+ it('defaults to the well-known container path', () => {
37
+ expect(resolveGeoLite2CountryPath({})).toBe(GEOLITE2_COUNTRY_MMDB_PATH)
38
+ })
39
+
40
+ it('prefers GEOLITE2_COUNTRY_MMDB', () => {
41
+ expect(resolveGeoLite2CountryPath({ GEOLITE2_COUNTRY_MMDB: '/var/lib/GeoLite2-Country.mmdb' }))
42
+ .toBe('/var/lib/GeoLite2-Country.mmdb')
43
+ })
44
+ })
45
+
46
+ describe('resolveMaxMindLicenseKey', () => {
47
+ it('returns null when unset', () => {
48
+ expect(resolveMaxMindLicenseKey({})).toBeNull()
49
+ })
50
+
51
+ it('trims OSSY_MAXMIND_LICENSE_KEY', () => {
52
+ expect(resolveMaxMindLicenseKey({ OSSY_MAXMIND_LICENSE_KEY: ' abc ' })).toBe('abc')
53
+ })
54
+ })
55
+
56
+ describe('maxMindDownloadRequest', () => {
57
+ it('uses the legacy query URL without an account id', () => {
58
+ const request = maxMindDownloadRequest({ licenseKey: 'secret-key' })
59
+ expect(request.url).toContain('edition_id=GeoLite2-Country')
60
+ expect(request.url).toContain('license_key=secret-key')
61
+ expect(request.headers.Authorization).toBeUndefined()
62
+ })
63
+
64
+ it('uses basic auth permalink when account id is set', () => {
65
+ const request = maxMindDownloadRequest({ licenseKey: 'secret-key', accountId: '123456' })
66
+ expect(request.url).toBe('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz')
67
+ expect(request.headers.Authorization).toBe(`Basic ${Buffer.from('123456:secret-key').toString('base64')}`)
68
+ })
69
+ })
70
+
71
+ describe('ensureGeoLite2Country', () => {
72
+ afterEach(restoreEnv)
73
+
74
+ it('skips download when the license key is missing', async () => {
75
+ const env = {}
76
+ let called = false
77
+ const dest = await ensureGeoLite2Country({
78
+ env,
79
+ fetchImpl: async () => {
80
+ called = true
81
+ return { ok: false, status: 500, arrayBuffer: async () => new ArrayBuffer(0) }
82
+ },
83
+ })
84
+ expect(dest).toBeNull()
85
+ expect(called).toBe(false)
86
+ })
87
+
88
+ it('returns the existing file without fetching', async () => {
89
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'geolite2-'))
90
+ const dest = path.join(dir, 'GeoLite2-Country.mmdb')
91
+ fs.writeFileSync(dest, 'already-there')
92
+ const env = { GEOLITE2_COUNTRY_MMDB: dest, MAXMIND_LICENSE_KEY: 'secret-key' }
93
+ let called = false
94
+ const result = await ensureGeoLite2Country({
95
+ env,
96
+ fetchImpl: async () => {
97
+ called = true
98
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }
99
+ },
100
+ })
101
+ expect(result).toBe(dest)
102
+ expect(called).toBe(false)
103
+ fs.rmSync(dir, { recursive: true, force: true })
104
+ })
105
+
106
+ it('writes the extracted mmdb and sets GEOLITE2_COUNTRY_MMDB', async () => {
107
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'geolite2-'))
108
+ const dest = path.join(dir, 'GeoLite2-Country.mmdb')
109
+ const payload = Buffer.from('country-mmdb')
110
+ const archive = makeUstarTarGz('GeoLite2-Country_20240101/GeoLite2-Country.mmdb', payload)
111
+ const env = { GEOLITE2_COUNTRY_MMDB: dest, MAXMIND_LICENSE_KEY: 'secret-key' }
112
+
113
+ const result = await ensureGeoLite2Country({
114
+ env,
115
+ fetchImpl: async () => ({
116
+ ok: true,
117
+ status: 200,
118
+ arrayBuffer: async () => archive,
119
+ }),
120
+ })
121
+
122
+ expect(result).toBe(dest)
123
+ expect(env.GEOLITE2_COUNTRY_MMDB).toBe(dest)
124
+ expect(fs.readFileSync(dest).equals(payload)).toBe(true)
125
+ fs.rmSync(dir, { recursive: true, force: true })
126
+ })
127
+
128
+ it('does not throw when MaxMind returns an error', async () => {
129
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'geolite2-'))
130
+ const dest = path.join(dir, 'GeoLite2-Country.mmdb')
131
+ const env = { GEOLITE2_COUNTRY_MMDB: dest, MAXMIND_LICENSE_KEY: 'secret-key' }
132
+ const result = await ensureGeoLite2Country({
133
+ env,
134
+ fetchImpl: async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) }),
135
+ })
136
+ expect(result).toBeNull()
137
+ expect(fs.existsSync(dest)).toBe(false)
138
+ fs.rmSync(dir, { recursive: true, force: true })
139
+ })
140
+ })
@@ -0,0 +1,68 @@
1
+ import { gunzipSync } from 'node:zlib'
2
+
3
+ const BLOCK = 512
4
+ const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
5
+ const MAX_MMDB_BYTES = 20 * 1024 * 1024
6
+
7
+ /**
8
+ * @param {Buffer} header
9
+ * @param {number} start
10
+ * @param {number} len
11
+ * @returns {number}
12
+ */
13
+ function readOctal (header, start, len) {
14
+ const raw = header.subarray(start, start + len).toString('utf8').replace(/\0/g, '').trim()
15
+ if (!raw) return 0
16
+ const parsed = Number.parseInt(raw, 8)
17
+ return Number.isFinite(parsed) ? parsed : 0
18
+ }
19
+
20
+ /**
21
+ * @param {Buffer} header
22
+ * @returns {string}
23
+ */
24
+ function headerName (header) {
25
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0/g, '').trim()
26
+ const prefix = header.subarray(345, 345 + 155).toString('utf8').replace(/\0/g, '').trim()
27
+ return prefix ? `${prefix}/${name}` : name
28
+ }
29
+
30
+ /**
31
+ * Extract the first `.mmdb` member from a MaxMind GeoLite2 `.tar.gz`.
32
+ * Writes none of the tar paths — callers choose the destination (#804).
33
+ *
34
+ * @param {Buffer} gzipped
35
+ * @returns {Buffer}
36
+ */
37
+ export function extractMmdbFromTarGz (gzipped) {
38
+ if (!Buffer.isBuffer(gzipped) || gzipped.length < 20) {
39
+ throw new Error('GeoLite2 archive is empty or invalid')
40
+ }
41
+ if (gzipped.length > MAX_ARCHIVE_BYTES) {
42
+ throw new Error('GeoLite2 archive is too large')
43
+ }
44
+
45
+ const tar = gunzipSync(gzipped)
46
+ let offset = 0
47
+ while (offset + BLOCK <= tar.length) {
48
+ const header = tar.subarray(offset, offset + BLOCK)
49
+ if (header.every((byte) => byte === 0)) break
50
+
51
+ const size = readOctal(header, 124, 12)
52
+ const typeflag = String.fromCharCode(header[156] || 0)
53
+ const name = headerName(header)
54
+ offset += BLOCK
55
+ const padded = Math.ceil(size / BLOCK) * BLOCK
56
+ const content = tar.subarray(offset, Math.min(offset + size, tar.length))
57
+ offset += padded
58
+
59
+ const isFile = typeflag === '0' || typeflag === '\0' || typeflag === ''
60
+ if (!isFile || !name.toLowerCase().endsWith('.mmdb')) continue
61
+ if (content.length > MAX_MMDB_BYTES) {
62
+ throw new Error('GeoLite2 mmdb member is too large')
63
+ }
64
+ return Buffer.from(content)
65
+ }
66
+
67
+ throw new Error('No .mmdb file in GeoLite2 archive')
68
+ }
@@ -0,0 +1,20 @@
1
+ import { extractMmdbFromTarGz } from './extract-mmdb-from-tar-gz.js'
2
+ import { makeUstarTarGz } from './tar-fixture.js'
3
+
4
+ describe('extractMmdbFromTarGz', () => {
5
+ it('returns the first .mmdb member', () => {
6
+ const payload = Buffer.from('fake-mmdb-bytes')
7
+ const archive = makeUstarTarGz('GeoLite2-Country_20240101/GeoLite2-Country.mmdb', payload)
8
+ expect(extractMmdbFromTarGz(archive).equals(payload)).toBe(true)
9
+ })
10
+
11
+ it('skips non-mmdb members', () => {
12
+ const readme = Buffer.from('not the database')
13
+ const archive = makeUstarTarGz('GeoLite2-Country_20240101/COPYRIGHT.txt', readme)
14
+ expect(() => extractMmdbFromTarGz(archive)).toThrow(/No \.mmdb file/)
15
+ })
16
+
17
+ it('rejects empty input', () => {
18
+ expect(() => extractMmdbFromTarGz(Buffer.alloc(0))).toThrow(/empty or invalid/)
19
+ })
20
+ })
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Well-known GeoLite2-Country path on platform containers (#804).
3
+ *
4
+ * Keep in sync with `@ossy/deployment-tools` `GEOLITE2_COUNTRY_MMDB_PATH`
5
+ * (ECS task-definition env). Writable on Fargate ephemeral disk.
6
+ */
7
+ export const GEOLITE2_COUNTRY_MMDB_PATH = '/tmp/GeoLite2-Country.mmdb'
8
+
9
+ /** Abort hung MaxMind downloads before ECS health startPeriod (60s). */
10
+ export const GEOLITE2_COUNTRY_DOWNLOAD_TIMEOUT_MS = 30_000
@@ -0,0 +1,31 @@
1
+ import { gzipSync } from 'node:zlib'
2
+
3
+ const BLOCK = 512
4
+
5
+ /**
6
+ * Build a gzipped ustar archive with a single file (test fixture).
7
+ *
8
+ * @param {string} name
9
+ * @param {Buffer | string} content
10
+ * @returns {Buffer}
11
+ */
12
+ export function makeUstarTarGz (name, content) {
13
+ const data = Buffer.isBuffer(content) ? content : Buffer.from(content)
14
+ const header = Buffer.alloc(BLOCK)
15
+ header.write(name, 0, 100, 'utf8')
16
+ header.write('0000644\0', 100, 8, 'utf8')
17
+ header.write('0000000\0', 108, 8, 'utf8')
18
+ header.write('0000000\0', 116, 8, 'utf8')
19
+ header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124, 12, 'utf8')
20
+ header.write('00000000000\0', 136, 12, 'utf8')
21
+ header.write(' ', 148, 8, 'utf8')
22
+ header[156] = 0x30
23
+ header.write('ustar\0', 257, 6, 'utf8')
24
+ header.write('00', 263, 2, 'utf8')
25
+ let sum = 0
26
+ for (const byte of header) sum += byte
27
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf8')
28
+ const padded = Buffer.alloc(Math.ceil(data.length / BLOCK) * BLOCK)
29
+ data.copy(padded)
30
+ return gzipSync(Buffer.concat([header, padded, Buffer.alloc(1024)]))
31
+ }
package/src/server.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  } from './entitlements/action-entitlement.js'
29
29
  import { closePushSseConnections, mountPushSse } from './push/mount-push-sse.js'
30
30
  import { mountHealthEndpoint } from './health.js'
31
+ import { ensureGeoLite2Country } from './geoip/ensure-geolite2-country.js'
31
32
  import {
32
33
  createSlowRequestLogger,
33
34
  createTimedOperation,
@@ -289,6 +290,10 @@ export async function startServer (options = {}) {
289
290
 
290
291
  TaskService.startScheduler()
291
292
 
293
+ // GeoLite2-Country for Host location projections (#804). Shared by every
294
+ // startServer image (ossy.se, plexus-sanitas, …). Missing key is non-fatal.
295
+ await ensureGeoLite2Country()
296
+
292
297
  if (process.env.DB_URL) {
293
298
  await ensureEventStoreIndexes().catch((err) => {
294
299
  log.error('Failed to ensure MongoDB indexes — aggregate reads may be very slow', undefined, err)