mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-06 01:11:37 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user