From c76af90d88f4e92a0a3a045ba0cf0262e606a0f2 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 18 Aug 2026 23:10:57 +0800 Subject: [PATCH] feat(hub): push config joins the settings.json system (env > file > default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FCM and iOS/APNs push were the only hub knobs read straight from env, bypassing the configuration rule every other field follows (env > settings.json > default, env persisted on first sight). Fold them in: serverSettings resolves fcmServiceAccountPath / iosPushMode / iosPushRelayUrl / apnsKeyP8Path / apnsKeyId / apnsTeamId / apnsBundleId / apnsEnv under the shared rule, and the two resolvers now consume configuration instead of process.env. FCM_PROJECT_ID is gone: operators point at the service-account JSON and the project id comes from the file — no copying values out of it. Paths accept ~. --- docs/api/native-companion-contract.md | 17 +++- hub/src/config/serverSettings.test.ts | 44 ++++++++- hub/src/config/serverSettings.ts | 64 ++++++++++++- hub/src/config/settings.ts | 10 ++ hub/src/configuration.ts | 33 +++++++ hub/src/fcm/fcmConfig.test.ts | 67 +++++++++++++ hub/src/fcm/fcmConfig.ts | 30 ++++-- hub/src/push-ios/iosPushConfig.test.ts | 124 +++++++++++++++---------- hub/src/push-ios/iosPushConfig.ts | 61 +++++++----- hub/src/startHub.ts | 10 +- 10 files changed, 371 insertions(+), 89 deletions(-) create mode 100644 hub/src/fcm/fcmConfig.test.ts diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md index f0bc2fce..c7621c4b 100644 --- a/docs/api/native-companion-contract.md +++ b/docs/api/native-companion-contract.md @@ -162,7 +162,9 @@ ones. ### Transports: self-host (direct APNs) vs official relay -The hub picks one of two transports via `HAPI_IOS_PUSH`: +The hub picks one of two transports via `HAPI_IOS_PUSH` (or the +`iosPushMode` field in `~/.hapi/settings.json` — every variable below has a +settings.json equivalent, see [Environment](#environment-hub-operator)): | Mode | Who talks to Apple | Requirements | |------|--------------------|--------------| @@ -212,13 +214,20 @@ and suppresses the Web Push fallback for the namespace when a send succeeds ```bash FCM_SERVICE_ACCOUNT_PATH=/path/to/service-account.json -FCM_PROJECT_ID=your-firebase-project-id ``` -When unset, hub skips FCM channel (Web Push / Telegram unchanged). -iOS transport selection (`HAPI_IOS_PUSH`, `APNS_*`) is documented in +The Firebase project id comes from the service-account JSON itself. When +unset, hub skips FCM channel (Web Push / Telegram unchanged). iOS transport +selection (`HAPI_IOS_PUSH`, `APNS_*`) is documented in [iOS (APNs)](#transports-self-host-direct-apns-vs-official-relay). +Push configuration follows the hub-wide rule (env > `settings.json` > +default; an env value is persisted into `~/.hapi/settings.json` on first +sight, so the variable only has to be passed once). settings.json keys: +`fcmServiceAccountPath`, `iosPushMode`, `iosPushRelayUrl`, `apnsKeyP8Path`, +`apnsKeyId`, `apnsTeamId`, `apnsBundleId`, `apnsEnv`. Path values may use +`~`. + The native push channel is **opt-in**: operators who don't run a companion app see no behavior change. When at least one device is registered for a namespace, the existing Web Push channel suppresses its fallback for that diff --git a/hub/src/config/serverSettings.test.ts b/hub/src/config/serverSettings.test.ts index b1dfd8fc..3bb4d3db 100644 --- a/hub/src/config/serverSettings.test.ts +++ b/hub/src/config/serverSettings.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { loadServerSettings } from './serverSettings' @@ -81,4 +81,46 @@ describe('loadServerSettings', () => { await expect(loadServerSettings(dir)).rejects.toThrow('serverChanBackgroundOnly must be a boolean') }) + + it('defaults push settings to null', async () => { + dir = makeTempDir() + + const result = await loadServerSettings(dir) + + expect(result.settings.fcmServiceAccountPath).toBeNull() + expect(result.settings.iosPushMode).toBeNull() + expect(result.sources.fcmServiceAccountPath).toBe('default') + }) + + it('persists a push env value to settings.json on first sight', async () => { + dir = makeTempDir() + process.env.FCM_SERVICE_ACCOUNT_PATH = '/tmp/sa.json' + try { + const result = await loadServerSettings(dir) + + expect(result.settings.fcmServiceAccountPath).toBe('/tmp/sa.json') + expect(result.sources.fcmServiceAccountPath).toBe('env') + expect(result.savedToFile).toBe(true) + + const written = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) + expect(written.fcmServiceAccountPath).toBe('/tmp/sa.json') + } finally { + delete process.env.FCM_SERVICE_ACCOUNT_PATH + } + }) + + it('loads push settings from settings.json when the env is unset', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + fcmServiceAccountPath: '~/.hapi/sa.json', + iosPushMode: 'off' + })) + + const result = await loadServerSettings(dir) + + expect(result.settings.fcmServiceAccountPath).toBe('~/.hapi/sa.json') + expect(result.sources.fcmServiceAccountPath).toBe('file') + expect(result.settings.iosPushMode).toBe('off') + expect(result.sources.iosPushMode).toBe('file') + }) }) diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index f5957088..1f5edfe5 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -12,6 +12,24 @@ import { getSettingsFile, updateSettings } from './settings' const OLD_SETTINGS_FIELDS = ['webappHost', 'webappPort', 'webappUrl'] as const +/** + * Push delivery settings: all nullable strings under the same + * env > file > null rule (defaults and validation live in the resolvers, + * fcmConfig.ts / iosPushConfig.ts). + */ +const PUSH_SETTING_KEYS = [ + ['fcmServiceAccountPath', 'FCM_SERVICE_ACCOUNT_PATH'], + ['iosPushMode', 'HAPI_IOS_PUSH'], + ['iosPushRelayUrl', 'HAPI_PUSH_RELAY_URL'], + ['apnsKeyP8Path', 'APNS_KEY_P8_PATH'], + ['apnsKeyId', 'APNS_KEY_ID'], + ['apnsTeamId', 'APNS_TEAM_ID'], + ['apnsBundleId', 'APNS_BUNDLE_ID'], + ['apnsEnv', 'APNS_ENV'], +] as const + +export type PushSettingKey = (typeof PUSH_SETTING_KEYS)[number][0] + export interface ServerSettings { telegramBotToken: string | null telegramNotification: boolean @@ -22,6 +40,14 @@ export interface ServerSettings { listenPort: number publicUrl: string corsOrigins: string[] + fcmServiceAccountPath: string | null + iosPushMode: string | null + iosPushRelayUrl: string | null + apnsKeyP8Path: string | null + apnsKeyId: string | null + apnsTeamId: string | null + apnsBundleId: string | null + apnsEnv: string | null } export interface ServerSettingsResult { @@ -36,7 +62,7 @@ export interface ServerSettingsResult { listenPort: 'env' | 'file' | 'default' publicUrl: 'env' | 'file' | 'default' corsOrigins: 'env' | 'file' | 'default' - } + } & Record savedToFile: boolean } @@ -107,6 +133,14 @@ export async function loadServerSettings(dataDir: string): Promise file > null let telegramBotToken: string | null = null @@ -242,6 +276,33 @@ export async function loadServerSettings(dataDir: string): Promise file > null, env persisted on first sight — + // one loop instead of nine copies of the per-field block above. + const push: Record = { + fcmServiceAccountPath: null, + iosPushMode: null, + iosPushRelayUrl: null, + apnsKeyP8Path: null, + apnsKeyId: null, + apnsTeamId: null, + apnsBundleId: null, + apnsEnv: null, + } + for (const [key, envName] of PUSH_SETTING_KEYS) { + const envValue = process.env[envName]?.trim() + if (envValue) { + push[key] = envValue + sources[key] = 'env' + if (settings[key] === undefined) { + settings[key] = envValue + needsSave = true + } + } else if (settings[key] !== undefined) { + push[key] = settings[key] ?? null + sources[key] = 'file' + } + } + return { settings, write: needsSave, @@ -256,6 +317,7 @@ export async function loadServerSettings(dataDir: string): Promise { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function makeServiceAccountFile( + contents: object, + baseDir: string = tmpdir() +): string { + const dir = mkdtempSync(join(baseDir, 'hapi-fcm-sa-')) + tempDirs.push(dir) + const path = join(dir, 'service-account.json') + writeFileSync(path, JSON.stringify(contents)) + return path +} + +const VALID_ACCOUNT = { + project_id: 'proj-1', + client_email: 'svc@proj-1.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nMIG\n-----END PRIVATE KEY-----\n' +} + +describe('resolveFcmConfig', () => { + it('returns null when no path is configured', () => { + expect(resolveFcmConfig({ fcmServiceAccountPath: null })).toBeNull() + expect(resolveFcmConfig({ fcmServiceAccountPath: ' ' })).toBeNull() + }) + + it('returns null when the configured file does not exist', () => { + expect(resolveFcmConfig({ fcmServiceAccountPath: '/nonexistent/sa.json' })).toBeNull() + }) + + it('loads the service account and takes the project id from the JSON', () => { + const path = makeServiceAccountFile(VALID_ACCOUNT) + const config = resolveFcmConfig({ fcmServiceAccountPath: path }) + expect(config).not.toBeNull() + expect(config?.projectId).toBe('proj-1') + expect(config?.serviceAccountPath).toBe(path) + expect(config?.serviceAccount.client_email).toBe(VALID_ACCOUNT.client_email) + }) + + it('returns null when the JSON has no project_id', () => { + const path = makeServiceAccountFile({ + client_email: VALID_ACCOUNT.client_email, + private_key: VALID_ACCOUNT.private_key + }) + expect(resolveFcmConfig({ fcmServiceAccountPath: path })).toBeNull() + }) + + it('expands ~ in the configured path', () => { + const path = makeServiceAccountFile(VALID_ACCOUNT, homedir()) + const config = resolveFcmConfig({ + fcmServiceAccountPath: `~/${relative(homedir(), path)}` + }) + expect(config?.projectId).toBe('proj-1') + expect(config?.serviceAccountPath).toBe(path) + }) +}) diff --git a/hub/src/fcm/fcmConfig.ts b/hub/src/fcm/fcmConfig.ts index 626c016b..169a24dc 100644 --- a/hub/src/fcm/fcmConfig.ts +++ b/hub/src/fcm/fcmConfig.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' +import { homedir } from 'node:os' import { loadServiceAccount } from './fcmAuth' export type FcmConfig = { @@ -7,18 +8,33 @@ export type FcmConfig = { serviceAccount: ReturnType } -export function resolveFcmConfig(): FcmConfig | null { - const serviceAccountPath = process.env.FCM_SERVICE_ACCOUNT_PATH?.trim() - if (!serviceAccountPath || !existsSync(serviceAccountPath)) { +/** + * The slice of hub configuration FCM cares about: one path. Env vs + * settings.json precedence is folded upstream (serverSettings.ts: + * FCM_SERVICE_ACCOUNT_PATH > fcmServiceAccountPath > null). Everything + * else — project id included — comes from the service-account JSON the + * path points at. + */ +export type FcmSettings = { + fcmServiceAccountPath: string | null +} + +export function resolveFcmConfig(settings: FcmSettings): FcmConfig | null { + const rawPath = settings.fcmServiceAccountPath?.trim() + if (!rawPath) { + return null + } + const serviceAccountPath = rawPath.replace(/^~/, homedir()) + if (!existsSync(serviceAccountPath)) { + // Configured-but-broken deserves a warning; unconfigured stays silent. + console.warn(`[Fcm] Service account file not found (${serviceAccountPath}); FCM disabled`) return null } const serviceAccount = loadServiceAccount(serviceAccountPath) - const projectId = process.env.FCM_PROJECT_ID?.trim() - || serviceAccount.project_id - || null + const projectId = serviceAccount.project_id || null if (!projectId) { - console.warn('[Fcm] FCM_PROJECT_ID unset and service account JSON has no project_id') + console.warn(`[Fcm] Service account JSON has no project_id (${serviceAccountPath}); FCM disabled`) return null } diff --git a/hub/src/push-ios/iosPushConfig.test.ts b/hub/src/push-ios/iosPushConfig.test.ts index fef4c7e6..92d35e37 100644 --- a/hub/src/push-ios/iosPushConfig.test.ts +++ b/hub/src/push-ios/iosPushConfig.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it } from 'bun:test' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { homedir, tmpdir } from 'node:os' +import { join, relative } from 'node:path' import { APNS_PRODUCTION_HOST, APNS_SANDBOX_HOST } from './apnsClient' -import { DEFAULT_PUSH_RELAY_URL, resolveIosPushConfig } from './iosPushConfig' +import { DEFAULT_PUSH_RELAY_URL, resolveIosPushConfig, type IosPushSettings } from './iosPushConfig' const tempDirs: string[] = [] afterEach(() => { @@ -13,48 +13,62 @@ afterEach(() => { } }) -function makeP8File(): string { - const dir = mkdtempSync(join(tmpdir(), 'hapi-apns-key-')) +function makeP8File(baseDir: string = tmpdir()): string { + const dir = mkdtempSync(join(baseDir, 'hapi-apns-key-')) tempDirs.push(dir) const path = join(dir, 'AuthKey_TEST.p8') writeFileSync(path, '-----BEGIN PRIVATE KEY-----\nMIG\n-----END PRIVATE KEY-----\n') return path } +/** All-null settings (the unconfigured hub) with per-test overrides. */ +function settings(overrides: Partial = {}): IosPushSettings { + return { + iosPushMode: null, + iosPushRelayUrl: null, + apnsKeyP8Path: null, + apnsKeyId: null, + apnsTeamId: null, + apnsBundleId: null, + apnsEnv: null, + ...overrides, + } +} + describe('resolveIosPushConfig', () => { it('defaults to relay mode with the official relay URL', () => { - const config = resolveIosPushConfig({}) + const config = resolveIosPushConfig(settings()) expect(config).toEqual({ mode: 'relay', relayUrl: DEFAULT_PUSH_RELAY_URL, source: 'default' }) expect(DEFAULT_PUSH_RELAY_URL).toBe('https://push.hapi.run') }) - it('honors an explicit relay URL from HAPI_PUSH_RELAY_URL', () => { - const config = resolveIosPushConfig({ - HAPI_IOS_PUSH: 'relay', - HAPI_PUSH_RELAY_URL: 'https://relay.internal.example' - }) - expect(config).toEqual({ mode: 'relay', relayUrl: 'https://relay.internal.example', source: 'env' }) + it('honors an explicit relay URL', () => { + const config = resolveIosPushConfig(settings({ + iosPushMode: 'relay', + iosPushRelayUrl: 'https://relay.internal.example' + })) + expect(config).toEqual({ mode: 'relay', relayUrl: 'https://relay.internal.example', source: 'configured' }) }) - it('returns off when HAPI_IOS_PUSH=off', () => { - const config = resolveIosPushConfig({ HAPI_IOS_PUSH: 'off' }) + it('returns off when iosPushMode=off', () => { + const config = resolveIosPushConfig(settings({ iosPushMode: 'off' })) expect(config.mode).toBe('off') }) - it('falls back to the relay default on an unknown HAPI_IOS_PUSH value', () => { - const config = resolveIosPushConfig({ HAPI_IOS_PUSH: 'bogus' }) + it('falls back to the relay default on an unknown iosPushMode value', () => { + const config = resolveIosPushConfig(settings({ iosPushMode: 'bogus' })) expect(config.mode).toBe('relay') }) it('resolves apns mode with all credentials, defaulting to the production host', () => { const p8Path = makeP8File() - const config = resolveIosPushConfig({ - HAPI_IOS_PUSH: 'apns', - APNS_KEY_P8_PATH: p8Path, - APNS_KEY_ID: 'KEY123', - APNS_TEAM_ID: 'TEAM456', - APNS_BUNDLE_ID: 'run.hapi.ios' - }) + const config = resolveIosPushConfig(settings({ + iosPushMode: 'apns', + apnsKeyP8Path: p8Path, + apnsKeyId: 'KEY123', + apnsTeamId: 'TEAM456', + apnsBundleId: 'run.hapi.ios' + })) expect(config.mode).toBe('apns') if (config.mode !== 'apns') throw new Error('unreachable') expect(config.keyId).toBe('KEY123') @@ -65,43 +79,57 @@ describe('resolveIosPushConfig', () => { expect(config.keyP8).toContain('BEGIN PRIVATE KEY') }) - it('uses the sandbox host when APNS_ENV=sandbox', () => { - const config = resolveIosPushConfig({ - HAPI_IOS_PUSH: 'apns', - APNS_KEY_P8_PATH: makeP8File(), - APNS_KEY_ID: 'K', - APNS_TEAM_ID: 'T', - APNS_BUNDLE_ID: 'b', - APNS_ENV: 'sandbox' - }) + it('expands ~ in the .p8 path', () => { + const p8Path = makeP8File(homedir()) + const config = resolveIosPushConfig(settings({ + iosPushMode: 'apns', + apnsKeyP8Path: `~/${relative(homedir(), p8Path)}`, + apnsKeyId: 'K', + apnsTeamId: 'T', + apnsBundleId: 'b' + })) + expect(config.mode).toBe('apns') + if (config.mode !== 'apns') throw new Error('unreachable') + expect(config.keyP8Path).toBe(p8Path) + }) + + it('uses the sandbox host when apnsEnv=sandbox', () => { + const config = resolveIosPushConfig(settings({ + iosPushMode: 'apns', + apnsKeyP8Path: makeP8File(), + apnsKeyId: 'K', + apnsTeamId: 'T', + apnsBundleId: 'b', + apnsEnv: 'sandbox' + })) if (config.mode !== 'apns') throw new Error('expected apns mode') expect(config.env).toBe('sandbox') expect(config.host).toBe(APNS_SANDBOX_HOST) }) it('disables push when apns is selected but credentials are missing', () => { - const config = resolveIosPushConfig({ - HAPI_IOS_PUSH: 'apns', - APNS_KEY_ID: 'K' - }) + const config = resolveIosPushConfig(settings({ + iosPushMode: 'apns', + apnsKeyId: 'K' + })) expect(config.mode).toBe('off') if (config.mode !== 'off') throw new Error('unreachable') - expect(config.reason).toContain('APNS_KEY_P8_PATH') - expect(config.reason).toContain('APNS_TEAM_ID') - expect(config.reason).toContain('APNS_BUNDLE_ID') - expect(config.reason).not.toContain('APNS_KEY_ID,') + expect(config.reason).toContain('apnsKeyP8Path') + expect(config.reason).toContain('apnsTeamId') + expect(config.reason).toContain('apnsBundleId') + expect(config.reason).not.toContain('apnsKeyId (') }) it('disables push when the .p8 file cannot be read', () => { - const config = resolveIosPushConfig({ - HAPI_IOS_PUSH: 'apns', - APNS_KEY_P8_PATH: '/nonexistent/AuthKey.p8', - APNS_KEY_ID: 'K', - APNS_TEAM_ID: 'T', - APNS_BUNDLE_ID: 'b' - }) + const config = resolveIosPushConfig(settings({ + iosPushMode: 'apns', + apnsKeyP8Path: '/nonexistent/AuthKey.p8', + apnsKeyId: 'K', + apnsTeamId: 'T', + apnsBundleId: 'b' + })) expect(config.mode).toBe('off') if (config.mode !== 'off') throw new Error('unreachable') - expect(config.reason).toContain('cannot read APNS_KEY_P8_PATH') + expect(config.reason).toContain('cannot read apnsKeyP8Path') }) }) diff --git a/hub/src/push-ios/iosPushConfig.ts b/hub/src/push-ios/iosPushConfig.ts index a444f067..447f4114 100644 --- a/hub/src/push-ios/iosPushConfig.ts +++ b/hub/src/push-ios/iosPushConfig.ts @@ -1,15 +1,19 @@ import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' import { APNS_PRODUCTION_HOST, APNS_SANDBOX_HOST } from './apnsClient' export const DEFAULT_PUSH_RELAY_URL = 'https://push.hapi.run' /** - * Transport selection (PUSH SPEC v1): - * HAPI_IOS_PUSH = apns | relay | off (default: relay) - * HAPI_PUSH_RELAY_URL (default: https://push.hapi.run) - * direct mode requires APNS_KEY_P8_PATH, APNS_KEY_ID, APNS_TEAM_ID, - * APNS_BUNDLE_ID; APNS_ENV = production | sandbox (default: production). + * Transport selection (PUSH SPEC v1), resolved from hub configuration — + * env vs settings.json precedence is folded upstream (serverSettings.ts; + * env names in parentheses): + * iosPushMode (HAPI_IOS_PUSH) = apns | relay | off (default: relay) + * iosPushRelayUrl (HAPI_PUSH_RELAY_URL) (default: https://push.hapi.run) + * direct mode requires apnsKeyP8Path, apnsKeyId, apnsTeamId, apnsBundleId + * (APNS_KEY_P8_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID); + * apnsEnv (APNS_ENV) = production | sandbox (default: production). * * `relay` is the default because it needs zero operator setup - and it only * ever carries ciphertext (see envelope.ts), so defaulting to the official @@ -18,7 +22,7 @@ export const DEFAULT_PUSH_RELAY_URL = 'https://push.hapi.run' */ export type IosPushConfig = | { mode: 'off'; reason: string } - | { mode: 'relay'; relayUrl: string; source: 'env' | 'default' } + | { mode: 'relay'; relayUrl: string; source: 'configured' | 'default' } | { mode: 'apns' keyP8: string @@ -30,40 +34,51 @@ export type IosPushConfig = host: string } -export function resolveIosPushConfig(env: NodeJS.ProcessEnv = process.env): IosPushConfig { - const rawMode = env.HAPI_IOS_PUSH?.trim().toLowerCase() || 'relay' +/** The slice of hub configuration iOS push cares about. */ +export type IosPushSettings = { + iosPushMode: string | null + iosPushRelayUrl: string | null + apnsKeyP8Path: string | null + apnsKeyId: string | null + apnsTeamId: string | null + apnsBundleId: string | null + apnsEnv: string | null +} + +export function resolveIosPushConfig(settings: IosPushSettings): IosPushConfig { + const rawMode = settings.iosPushMode?.trim().toLowerCase() || 'relay' const mode = rawMode === 'apns' || rawMode === 'relay' || rawMode === 'off' ? rawMode : ((): 'relay' => { - console.warn(`[IosPush] Unknown HAPI_IOS_PUSH value "${rawMode}"; falling back to default "relay"`) + console.warn(`[IosPush] Unknown iosPushMode value "${rawMode}"; falling back to default "relay"`) return 'relay' })() if (mode === 'off') { - return { mode: 'off', reason: 'HAPI_IOS_PUSH=off' } + return { mode: 'off', reason: 'iosPushMode=off' } } if (mode === 'relay') { - const rawUrl = env.HAPI_PUSH_RELAY_URL?.trim() + const rawUrl = settings.iosPushRelayUrl?.trim() return { mode: 'relay', relayUrl: rawUrl || DEFAULT_PUSH_RELAY_URL, - source: rawUrl ? 'env' : 'default' + source: rawUrl ? 'configured' : 'default' } } - const keyP8Path = env.APNS_KEY_P8_PATH?.trim() ?? '' - const keyId = env.APNS_KEY_ID?.trim() ?? '' - const teamId = env.APNS_TEAM_ID?.trim() ?? '' - const bundleId = env.APNS_BUNDLE_ID?.trim() ?? '' + const keyP8Path = (settings.apnsKeyP8Path?.trim() ?? '').replace(/^~/, homedir()) + const keyId = settings.apnsKeyId?.trim() ?? '' + const teamId = settings.apnsTeamId?.trim() ?? '' + const bundleId = settings.apnsBundleId?.trim() ?? '' const missing: string[] = [] - if (!keyP8Path) missing.push('APNS_KEY_P8_PATH') - if (!keyId) missing.push('APNS_KEY_ID') - if (!teamId) missing.push('APNS_TEAM_ID') - if (!bundleId) missing.push('APNS_BUNDLE_ID') + if (!keyP8Path) missing.push('apnsKeyP8Path (APNS_KEY_P8_PATH)') + if (!keyId) missing.push('apnsKeyId (APNS_KEY_ID)') + if (!teamId) missing.push('apnsTeamId (APNS_TEAM_ID)') + if (!bundleId) missing.push('apnsBundleId (APNS_BUNDLE_ID)') if (missing.length > 0) { - const reason = `HAPI_IOS_PUSH=apns but missing ${missing.join(', ')}` + const reason = `iosPushMode=apns but missing ${missing.join(', ')}` console.warn(`[IosPush] ${reason}; iOS push disabled`) return { mode: 'off', reason } } @@ -72,12 +87,12 @@ export function resolveIosPushConfig(env: NodeJS.ProcessEnv = process.env): IosP try { keyP8 = readFileSync(keyP8Path, 'utf8') } catch (e) { - const reason = `cannot read APNS_KEY_P8_PATH (${keyP8Path}): ${e instanceof Error ? e.message : e}` + const reason = `cannot read apnsKeyP8Path (${keyP8Path}): ${e instanceof Error ? e.message : e}` console.warn(`[IosPush] ${reason}; iOS push disabled`) return { mode: 'off', reason } } - const rawEnv = env.APNS_ENV?.trim().toLowerCase() + const rawEnv = settings.apnsEnv?.trim().toLowerCase() const apnsEnv: 'production' | 'sandbox' = rawEnv === 'sandbox' ? 'sandbox' : 'production' return { diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 1b3ad615..d5fd94cc 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -210,7 +210,7 @@ export async function startHub(options: StartHubOptions = {}): Promise