feat(hub): push config joins the settings.json system (env > file > default)

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 ~.
This commit is contained in:
weishu
2026-08-18 23:10:57 +08:00
parent 21a5bf2655
commit c76af90d88
10 changed files with 371 additions and 89 deletions
+13 -4
View File
@@ -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
+43 -1
View File
@@ -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')
})
})
+63 -1
View File
@@ -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<PushSettingKey, 'env' | 'file' | 'default'>
savedToFile: boolean
}
@@ -107,6 +133,14 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
listenPort: 'default',
publicUrl: 'default',
corsOrigins: 'default',
fcmServiceAccountPath: 'default',
iosPushMode: 'default',
iosPushRelayUrl: 'default',
apnsKeyP8Path: 'default',
apnsKeyId: 'default',
apnsTeamId: 'default',
apnsBundleId: 'default',
apnsEnv: 'default',
}
// telegramBotToken: env > file > null
let telegramBotToken: string | null = null
@@ -242,6 +276,33 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
corsOrigins = deriveCorsOrigins(publicUrl)
}
// Push settings: env > file > null, env persisted on first sight —
// one loop instead of nine copies of the per-field block above.
const push: Record<PushSettingKey, string | null> = {
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<ServerSetting
listenPort,
publicUrl,
corsOrigins,
...push,
},
sources,
savedToFile: needsSave,
+10
View File
@@ -23,6 +23,16 @@ export interface Settings {
listenPort?: number
publicUrl?: string
corsOrigins?: string[]
// Push delivery (FCM + iOS/APNs) — persisted from env like the rest of
// this section; interpreted by fcmConfig.ts / iosPushConfig.ts.
fcmServiceAccountPath?: string
iosPushMode?: string
iosPushRelayUrl?: string
apnsKeyP8Path?: string
apnsKeyId?: string
apnsTeamId?: string
apnsBundleId?: string
apnsEnv?: string
/** Per-hub relay auth key issued by the relay server (/issue) */
relayAuthKey?: string
/**
+33
View File
@@ -20,6 +20,12 @@
* - HAPI_RELAY_AUTH: Relay auth key override (default: per-hub key issued by the relay)
* - HAPI_RELAY_FORCE_TCP: Force TCP relay mode when UDP is unavailable (true/1)
* - VAPID_SUBJECT: Contact email or URL for Web Push (defaults to mailto:admin@hapi.run)
* - FCM_SERVICE_ACCOUNT_PATH: Firebase service-account JSON for Android push (settings: fcmServiceAccountPath;
* the project id comes from the JSON itself)
* - HAPI_IOS_PUSH: iOS push transport apns|relay|off (default: relay; settings: iosPushMode)
* - HAPI_PUSH_RELAY_URL: iOS push relay URL (settings: iosPushRelayUrl)
* - APNS_KEY_P8_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID, APNS_ENV:
* direct-APNs credentials (settings: apnsKeyP8Path, apnsKeyId, apnsTeamId, apnsBundleId, apnsEnv)
* - HAPI_HOME: Data directory (default: ~/.hapi)
* - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db)
*/
@@ -44,6 +50,14 @@ export interface ConfigSources {
listenPort: ConfigSource
publicUrl: ConfigSource
corsOrigins: ConfigSource
fcmServiceAccountPath: ConfigSource
iosPushMode: ConfigSource
iosPushRelayUrl: ConfigSource
apnsKeyP8Path: ConfigSource
apnsKeyId: ConfigSource
apnsTeamId: ConfigSource
apnsBundleId: ConfigSource
apnsEnv: ConfigSource
cliApiToken: 'env' | 'file' | 'generated'
}
@@ -96,6 +110,17 @@ class Configuration {
/** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */
public readonly corsOrigins: string[]
// Push delivery (FCM + iOS/APNs) — nullable strings interpreted by
// fcm/fcmConfig.ts and push-ios/iosPushConfig.ts.
public readonly fcmServiceAccountPath: string | null
public readonly iosPushMode: string | null
public readonly iosPushRelayUrl: string | null
public readonly apnsKeyP8Path: string | null
public readonly apnsKeyId: string | null
public readonly apnsTeamId: string | null
public readonly apnsBundleId: string | null
public readonly apnsEnv: string | null
/** Sources of each configuration value */
public readonly sources: ConfigSources
@@ -121,6 +146,14 @@ class Configuration {
this.listenPort = serverSettings.listenPort
this.publicUrl = serverSettings.publicUrl
this.corsOrigins = serverSettings.corsOrigins
this.fcmServiceAccountPath = serverSettings.fcmServiceAccountPath
this.iosPushMode = serverSettings.iosPushMode
this.iosPushRelayUrl = serverSettings.iosPushRelayUrl
this.apnsKeyP8Path = serverSettings.apnsKeyP8Path
this.apnsKeyId = serverSettings.apnsKeyId
this.apnsTeamId = serverSettings.apnsTeamId
this.apnsBundleId = serverSettings.apnsBundleId
this.apnsEnv = serverSettings.apnsEnv
// CLI API token - will be set by _setCliApiToken() before create() returns
this.cliApiToken = ''
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { resolveFcmConfig } from './fcmConfig'
const tempDirs: string[] = []
afterEach(() => {
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)
})
})
+23 -7
View File
@@ -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<typeof loadServiceAccount>
}
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
}
+76 -48
View File
@@ -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> = {}): 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')
})
})
+38 -23
View File
@@ -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 {
+5 -5
View File
@@ -210,7 +210,7 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
// Accountable principal for A2A work-graph notify ingest (P3).
syncEngine.setHubOwnerUserId(await getOrCreateOwnerId())
const fcmConfig = resolveFcmConfig()
const fcmConfig = resolveFcmConfig(config)
// Build the optional FCM service early so the native-fallback probe
// can consult its health gate. When FCM is configured, `fcmService` is
@@ -232,17 +232,17 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
// iOS push (P1): encrypt-then-route sibling of the FCM channel. Ordering
// matters - both native channels run before PushNotificationChannel so a
// successful native send sets the nativeGate and suppresses web-push.
const iosPushConfig = resolveIosPushConfig()
const iosPushConfig = resolveIosPushConfig(config)
if (iosPushConfig.mode === 'relay') {
const iosPushService = new IosPushService(new RelayClient(iosPushConfig.relayUrl), store)
notificationChannels.push(new IosPushNotificationChannel(iosPushService, store))
console.log(`[Hub] HAPI_IOS_PUSH: relay (${iosPushConfig.source === 'default' ? 'default' : 'env'}, url: ${iosPushConfig.relayUrl})`)
console.log(`[Hub] iOS push: relay (${iosPushConfig.source}, url: ${iosPushConfig.relayUrl})`)
} else if (iosPushConfig.mode === 'apns') {
const iosPushService = new IosPushService(new ApnsClient(iosPushConfig), store)
notificationChannels.push(new IosPushNotificationChannel(iosPushService, store))
console.log(`[Hub] HAPI_IOS_PUSH: apns (${iosPushConfig.env}, topic: ${iosPushConfig.bundleId})`)
console.log(`[Hub] iOS push: apns (${iosPushConfig.env}, topic: ${iosPushConfig.bundleId})`)
} else {
console.log(`[Hub] HAPI_IOS_PUSH: off (${iosPushConfig.reason})`)
console.log(`[Hub] iOS push: off (${iosPushConfig.reason})`)
}
notificationChannels.push(