Răsfoiți Sursa

支持 Web 配置本地 APNs 密钥路径

LSamien 2 luni în urmă
părinte
comite
9e532d99d5

+ 1 - 0
.env.example

@@ -12,6 +12,7 @@ HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 # HERMES_YAOYAO_TLS_KEY=/absolute/path/to/key.pem
 # HERMES_YAOYAO_HOME=/Users/you/.hermes-yaoyao
 # iOS 后台通知使用 APNs Token Key。四项中前三项必须同时配置,.p8 不得放入仓库。
+# 任一 APNs 环境变量存在时,系统管理中的本地路径配置为只读;全部留空才可由 Web 管理。
 # HERMES_YAOYAO_APNS_KEY_FILE=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
 # HERMES_YAOYAO_APNS_KEY_ID=XXXXXXXXXX
 # HERMES_YAOYAO_APNS_TEAM_ID=GMU6W5FKQ6

+ 8 - 0
README.md

@@ -41,6 +41,14 @@ export HERMES_YAOYAO_APNS_TOPIC=cn.samien.yaoyao.hermes
 `HERMES_YAOYAO_APNS_KEY_FILE` 指向容器内绝对路径。APNs 只要求 8800 能主动访问
 Apple,不需要把 8800 暴露到公网;手机访问 8800 仍应优先使用 HTTPS/WSS 或 Tailscale。
 
+未设置任何 `HERMES_YAOYAO_APNS_*` 环境变量时,管理员也可以在“系统管理 → iOS
+消息推送”填写 **8800 所在机器上的 `.p8` 绝对路径**、Key ID、Team ID、Topic 及启用
+环境,然后点击“验证并启用”。私钥内容不会经过浏览器;8800 会本地读取 P-256 密钥、
+探测所选 APNs 环境,再把路径和元数据原子保存到
+`$HERMES_YAOYAO_HOME/push/apns-config.json`(权限 `0600`)并立即热加载。密钥文件权限
+宽于 `0600` 只显示安全建议,不会阻止启用。任一 APNs 环境变量存在时仍以环境变量为准,
+Web 界面只读,避免两个配置源互相覆盖。
+
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 

+ 88 - 10
src/client/api/push.ts

@@ -10,6 +10,24 @@ export interface PushCapabilities {
   maxSummaryCharacters: number
 }
 
+export type APNsEnvironment = 'development' | 'production'
+export type PushConfigSource = 'none' | 'file' | 'environment'
+
+export interface PushConfigWarning {
+  code: string
+  message: string
+  actualMode?: string
+  recommendedMode?: string
+}
+
+export interface PushSystemConfigInput {
+  keyFile: string
+  keyId: string
+  teamId: string
+  topic: string
+  environments: APNsEnvironment[]
+}
+
 export interface PushSystemStatus {
   configured: boolean
   healthy: boolean
@@ -18,6 +36,61 @@ export interface PushSystemStatus {
   pendingCount: number
   lastSuccessAt?: number
   lastError?: string
+  source: PushConfigSource
+  editable: boolean
+  managementAvailable: boolean
+  keyFile?: string
+  keyId?: string
+  teamId?: string
+  environments: APNsEnvironment[]
+  warnings: PushConfigWarning[]
+}
+
+function normalizeEnvironment(value: unknown): APNsEnvironment | undefined {
+  return value === 'development' || value === 'production' ? value : undefined
+}
+
+function normalizePushSystemStatus(value: unknown): PushSystemStatus {
+  const payload = record(value)
+  const rawSource = string(payload.source ?? payload.configurationSource ?? payload.configuration_source)
+  const source: PushConfigSource = rawSource === 'file' || rawSource === 'environment' ? rawSource : 'none'
+  const managementAvailable = rawSource === 'none' || rawSource === 'file' || rawSource === 'environment'
+    ? true
+    : bool(payload.managementAvailable ?? payload.management_available, false)
+  const environments = values(payload.environments ?? payload.environment)
+    .map(normalizeEnvironment)
+    .filter((environment): environment is APNsEnvironment => Boolean(environment))
+  const warnings = values(payload.warnings).flatMap((value): PushConfigWarning[] => {
+    if (typeof value === 'string' && value.trim()) return [{ code: 'warning', message: value.trim() }]
+    const warning = record(value)
+    const message = string(warning.message).trim()
+    if (!message) return []
+    return [{
+      code: string(warning.code, 'warning'),
+      message,
+      actualMode: string(warning.actualMode ?? warning.actual_mode) || undefined,
+      recommendedMode: string(warning.recommendedMode ?? warning.recommended_mode) || undefined,
+    }]
+  })
+  return {
+    configured: bool(payload.configured),
+    healthy: bool(payload.healthy),
+    topic: string(payload.topic) || undefined,
+    registrationCount: number(payload.registrationCount ?? payload.registration_count),
+    pendingCount: number(payload.pendingCount ?? payload.pending_count),
+    lastSuccessAt: number(payload.lastSuccessAt ?? payload.last_success_at) || undefined,
+    lastError: string(
+      payload.lastError ?? payload.last_error ?? payload.configurationError ?? payload.configuration_error,
+    ) || undefined,
+    source,
+    editable: managementAvailable && bool(payload.editable),
+    managementAvailable,
+    keyFile: string(payload.keyFile ?? payload.key_file) || undefined,
+    keyId: string(payload.keyId ?? payload.key_id) || undefined,
+    teamId: string(payload.teamId ?? payload.team_id) || undefined,
+    environments,
+    warnings,
+  }
 }
 
 export async function getPushCapabilities(): Promise<PushCapabilities> {
@@ -54,14 +127,19 @@ export async function setGroupPushSubscription(roomId: string, enabled: boolean)
 }
 
 export async function getPushSystemStatus(): Promise<PushSystemStatus> {
-  const payload = record(await apiRequest<unknown>('/api/app/system/push-status'))
-  return {
-    configured: bool(payload.configured),
-    healthy: bool(payload.healthy),
-    topic: string(payload.topic) || undefined,
-    registrationCount: number(payload.registrationCount ?? payload.registration_count),
-    pendingCount: number(payload.pendingCount ?? payload.pending_count),
-    lastSuccessAt: number(payload.lastSuccessAt ?? payload.last_success_at) || undefined,
-    lastError: string(payload.lastError ?? payload.last_error) || undefined,
-  }
+  return normalizePushSystemStatus(await apiRequest<unknown>('/api/app/system/push-status'))
+}
+
+export async function savePushSystemConfig(input: PushSystemConfigInput): Promise<PushSystemStatus> {
+  return normalizePushSystemStatus(await apiRequest<unknown>('/api/app/system/push-config', {
+    method: 'PUT',
+    body: {
+      keyFile: input.keyFile,
+      keyId: input.keyId,
+      teamId: input.teamId,
+      topic: input.topic,
+      environments: input.environments,
+    },
+    timeoutMs: 45_000,
+  }))
 }

Fișier diff suprimat deoarece este prea mare
+ 128 - 6
src/client/components/app/SystemManagementDialog.vue


+ 4 - 0
src/server/apns.ts

@@ -236,6 +236,10 @@ export class APNsProvider {
   }
 
   async send(request: APNsRequest): Promise<APNsSendResult> {
+    const environments = this.config.environments ?? ['development', 'production']
+    if (!environments.includes(request.environment)) {
+      return { disposition: 'configuration', status: 0, reason: 'EnvironmentNotConfigured' }
+    }
     if (!TOKEN_RE.test(request.deviceToken)) {
       return { disposition: 'unregister', status: 0, reason: 'BadDeviceToken' }
     }

+ 374 - 0
src/server/apnsConfiguration.ts

@@ -0,0 +1,374 @@
+import { createPrivateKey, randomUUID } from 'node:crypto'
+import {
+  chmodSync,
+  closeSync,
+  constants as fsConstants,
+  existsSync,
+  fstatSync,
+  fsyncSync,
+  mkdirSync,
+  openSync,
+  readFileSync,
+  realpathSync,
+  renameSync,
+  unlinkSync,
+  writeFileSync,
+} from 'node:fs'
+import { dirname, isAbsolute, join, resolve } from 'node:path'
+import { APNsProvider, type APNsEnvironment, type APNsSendResult } from './apns.js'
+import type { APNsProviderConfig } from './config.js'
+import { HttpError } from './errors.js'
+
+export type APNsConfigurationSource = 'none' | 'file' | 'environment'
+export const DEFAULT_APNS_TOPIC = 'cn.samien.yaoyao.hermes'
+
+export interface APNsConfigurationInput {
+  keyFile: string
+  keyId: string
+  teamId: string
+  topic: string
+  environments: APNsEnvironment[]
+}
+
+export interface APNsConfigurationWarning {
+  code: 'apns_key_permissions'
+  message: string
+  actualMode: string
+  recommendedMode: '0600'
+}
+
+export interface APNsConfigurationSnapshot {
+  source: APNsConfigurationSource
+  editable: boolean
+  input?: APNsConfigurationInput
+  config?: APNsProviderConfig
+  warnings: APNsConfigurationWarning[]
+  configurationError?: string
+}
+
+export interface APNsConfigurationManagerOptions {
+  probe?: APNsConfigurationProbe
+}
+
+export type APNsConfigurationProbe = (
+  config: APNsProviderConfig,
+  environments: readonly APNsEnvironment[],
+) => Promise<void>
+
+interface StoredAPNsConfiguration extends APNsConfigurationInput {
+  schemaVersion: 1
+  enabled: true
+  updatedAt: string
+}
+
+const MAX_KEY_FILE_BYTES = 64 * 1_024
+const MAX_CONFIGURATION_FILE_BYTES = 64 * 1_024
+const DEFAULT_ENVIRONMENTS: APNsEnvironment[] = ['development', 'production']
+const ACCEPTED_PROBE_REASON = 'BadDeviceToken'
+
+function clean(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+function canonicalEnvironments(value: unknown): APNsEnvironment[] {
+  const requested = Array.isArray(value) ? value : DEFAULT_ENVIRONMENTS
+  const environments: APNsEnvironment[] = []
+  for (const candidate of requested) {
+    if (candidate !== 'development' && candidate !== 'production') {
+      throw new HttpError(400, 'APNs environment must be development or production', 'invalid_apns_configuration')
+    }
+    if (!environments.includes(candidate)) environments.push(candidate)
+  }
+  if (environments.length === 0) {
+    throw new HttpError(400, 'At least one APNs environment is required', 'invalid_apns_configuration')
+  }
+  return environments
+}
+
+function canonicalInput(value: Partial<APNsConfigurationInput>): APNsConfigurationInput {
+  const keyFile = clean(value.keyFile)
+  const keyId = clean(value.keyId)
+  const teamId = clean(value.teamId)
+  const topic = clean(value.topic)
+  if (!keyFile || !isAbsolute(keyFile) || keyFile.length > 4_096 || /[\u0000-\u001f\u007f]/.test(keyFile)) {
+    throw new HttpError(400, 'APNs key file must be an absolute local path', 'invalid_apns_key_file')
+  }
+  if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) {
+    throw new HttpError(400, 'APNs Key ID is invalid', 'invalid_apns_key_id')
+  }
+  if (!/^[A-Za-z0-9]{1,128}$/.test(teamId)) {
+    throw new HttpError(400, 'APNs Team ID is invalid', 'invalid_apns_team_id')
+  }
+  if (!topic || topic.length > 255 || !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(topic)) {
+    throw new HttpError(400, 'APNs topic is invalid', 'invalid_apns_topic')
+  }
+  if (topic !== DEFAULT_APNS_TOPIC) {
+    throw new HttpError(400, `APNs topic must be ${DEFAULT_APNS_TOPIC}`, 'invalid_apns_topic')
+  }
+  return {
+    keyFile,
+    keyId,
+    teamId,
+    topic,
+    environments: canonicalEnvironments(value.environments),
+  }
+}
+
+export function validateAPNsConfiguration(
+  value: Partial<APNsConfigurationInput>,
+): APNsConfigurationSnapshot {
+  const requested = canonicalInput(value)
+  let keyFile: string
+  let encoded: Buffer
+  let mode: number
+  let descriptor: number | undefined
+  try {
+    keyFile = realpathSync(resolve(requested.keyFile))
+    descriptor = openSync(keyFile, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK)
+    const details = fstatSync(descriptor)
+    if (!details.isFile() || details.size < 1 || details.size > MAX_KEY_FILE_BYTES) {
+      throw new HttpError(400, 'APNs key path must reference a readable private key file', 'invalid_apns_key_file')
+    }
+    encoded = readFileSync(descriptor)
+    mode = details.mode & 0o777
+    closeSync(descriptor)
+    descriptor = undefined
+  } catch (cause) {
+    if (descriptor !== undefined) closeSync(descriptor)
+    if (cause instanceof HttpError) throw cause
+    throw new HttpError(
+      400,
+      '8800 cannot read the APNs key file; check the local path and service account permissions',
+      'invalid_apns_key_file',
+    )
+  }
+  let key
+  try { key = createPrivateKey(encoded) } catch {
+    throw new HttpError(400, 'APNs key file must contain an ES256 private key', 'invalid_apns_private_key')
+  }
+  if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
+    throw new HttpError(400, 'APNs key file must contain an ES256 private key', 'invalid_apns_private_key')
+  }
+  const actualMode = mode.toString(8).padStart(4, '0')
+  const warnings: APNsConfigurationWarning[] = (mode & ~0o600) !== 0
+    ? [{
+        code: 'apns_key_permissions',
+        message: `APNs 已启用,但密钥文件权限为 ${actualMode},建议调整为 0600;此提示不会影响当前推送。`,
+        actualMode,
+        recommendedMode: '0600',
+      }]
+    : []
+  const input = { ...requested, keyFile }
+  return {
+    source: 'file',
+    editable: true,
+    input,
+    config: { ...input },
+    warnings,
+  }
+}
+
+function environmentInput(env: NodeJS.ProcessEnv): { present: boolean; input: APNsConfigurationInput } {
+  const raw = {
+    keyFile: clean(env.HERMES_YAOYAO_APNS_KEY_FILE),
+    keyId: clean(env.HERMES_YAOYAO_APNS_KEY_ID),
+    teamId: clean(env.HERMES_YAOYAO_APNS_TEAM_ID),
+    topic: clean(env.HERMES_YAOYAO_APNS_TOPIC) || DEFAULT_APNS_TOPIC,
+    environments: DEFAULT_ENVIRONMENTS,
+  }
+  return {
+    present: Boolean(
+      env.HERMES_YAOYAO_APNS_KEY_FILE?.trim()
+      || env.HERMES_YAOYAO_APNS_KEY_ID?.trim()
+      || env.HERMES_YAOYAO_APNS_TEAM_ID?.trim()
+      || env.HERMES_YAOYAO_APNS_TOPIC?.trim(),
+    ),
+    input: raw,
+  }
+}
+
+function storedInput(value: unknown): APNsConfigurationInput {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    throw new Error('APNs configuration file must contain an object')
+  }
+  const record = value as Record<string, unknown>
+  if (record.schemaVersion !== 1 || record.enabled !== true) {
+    throw new Error('Unsupported APNs configuration file')
+  }
+  return {
+    keyFile: clean(record.keyFile),
+    keyId: clean(record.keyId),
+    teamId: clean(record.teamId),
+    topic: clean(record.topic),
+    environments: canonicalEnvironments(record.environments),
+  }
+}
+
+export function apnsConfigurationPath(home: string): string {
+  return join(home, 'push', 'apns-config.json')
+}
+
+function readConfigurationFile(path: string): Buffer {
+  const descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK)
+  try {
+    const details = fstatSync(descriptor)
+    if (!details.isFile() || details.size < 1 || details.size > MAX_CONFIGURATION_FILE_BYTES) {
+      throw new Error('APNs configuration path is not a regular file')
+    }
+    return readFileSync(descriptor)
+  } finally {
+    closeSync(descriptor)
+  }
+}
+
+export function loadAPNsConfiguration(
+  home: string,
+  env: NodeJS.ProcessEnv = process.env,
+): APNsConfigurationSnapshot {
+  const fromEnvironment = environmentInput(env)
+  if (fromEnvironment.present) {
+    try {
+      const validated = validateAPNsConfiguration(fromEnvironment.input)
+      return { ...validated, source: 'environment', editable: false }
+    } catch (cause) {
+      return {
+        source: 'environment',
+        editable: false,
+        input: fromEnvironment.input,
+        warnings: [],
+        configurationError: cause instanceof Error ? cause.message : 'APNs environment configuration is invalid',
+      }
+    }
+  }
+  const path = apnsConfigurationPath(home)
+  if (!existsSync(path)) return { source: 'none', editable: true, warnings: [] }
+  try {
+    const input = storedInput(JSON.parse(readConfigurationFile(path).toString('utf8')))
+    const validated = validateAPNsConfiguration(input)
+    chmodSync(path, 0o600)
+    return { ...validated, source: 'file', editable: true }
+  } catch (cause) {
+    return {
+      source: 'file',
+      editable: true,
+      warnings: [],
+      configurationError: cause instanceof Error ? cause.message : 'APNs configuration file is invalid',
+    }
+  }
+}
+
+function writeAtomic(path: string, value: Buffer | string): void {
+  const directory = dirname(path)
+  mkdirSync(directory, { recursive: true, mode: 0o700 })
+  chmodSync(directory, 0o700)
+  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
+  let descriptor: number | undefined
+  try {
+    descriptor = openSync(temporary, 'wx', 0o600)
+    writeFileSync(descriptor, value)
+    fsyncSync(descriptor)
+    closeSync(descriptor)
+    descriptor = undefined
+    renameSync(temporary, path)
+    chmodSync(path, 0o600)
+  } catch (cause) {
+    if (descriptor !== undefined) closeSync(descriptor)
+    try { unlinkSync(temporary) } catch { /* already absent */ }
+    throw cause
+  }
+}
+
+async function defaultProbe(
+  config: APNsProviderConfig,
+  environments: readonly APNsEnvironment[],
+): Promise<void> {
+  const provider = new APNsProvider(config)
+  try {
+    for (const environment of environments) {
+      const result: APNsSendResult = await provider.send({
+        deviceToken: '00'.repeat(32),
+        environment,
+        payload: { aps: { 'content-available': 1 } },
+        priority: 5,
+        pushType: 'background',
+      })
+      if (result.reason === ACCEPTED_PROBE_REASON) continue
+      if (result.disposition === 'retry') {
+        throw new HttpError(503, 'Unable to connect to APNs; check outbound network access and retry', 'apns_probe_unavailable')
+      }
+      throw new HttpError(422, 'APNs rejected the provider credentials', 'apns_credentials_rejected')
+    }
+  } finally {
+    provider.close()
+  }
+}
+
+function cloneSnapshot(value: APNsConfigurationSnapshot): APNsConfigurationSnapshot {
+  return {
+    ...value,
+    ...(value.input ? { input: { ...value.input, environments: [...value.input.environments] } } : {}),
+    ...(value.config ? { config: { ...value.config, environments: [...(value.config.environments ?? DEFAULT_ENVIRONMENTS)] } } : {}),
+    warnings: value.warnings.map(warning => ({ ...warning })),
+  }
+}
+
+export class APNsConfigurationManager {
+  readonly path: string
+  readonly #probe: APNsConfigurationProbe
+  #snapshot: APNsConfigurationSnapshot
+  #operation: Promise<void> = Promise.resolve()
+
+  constructor(
+    home: string,
+    initial: APNsConfigurationSnapshot,
+    options: APNsConfigurationManagerOptions = {},
+  ) {
+    this.path = apnsConfigurationPath(home)
+    this.#snapshot = cloneSnapshot(initial)
+    this.#probe = options.probe ?? defaultProbe
+  }
+
+  snapshot(): APNsConfigurationSnapshot {
+    return cloneSnapshot(this.#snapshot)
+  }
+
+  update(
+    input: Partial<APNsConfigurationInput>,
+    apply: (config: APNsProviderConfig) => void | Promise<void>,
+  ): Promise<APNsConfigurationSnapshot> {
+    const operation = this.#operation.then(() => this.#update(input, apply))
+    this.#operation = operation.then(() => undefined, () => undefined)
+    return operation
+  }
+
+  async #update(
+    input: Partial<APNsConfigurationInput>,
+    apply: (config: APNsProviderConfig) => void | Promise<void>,
+  ): Promise<APNsConfigurationSnapshot> {
+    if (this.#snapshot.source === 'environment') {
+      throw new HttpError(409, 'APNs configuration is managed by service environment variables', 'apns_environment_managed')
+    }
+    const validated = validateAPNsConfiguration(input)
+    const config = validated.config!
+    await this.#probe(config, validated.input!.environments)
+    const previous = existsSync(this.path) ? readConfigurationFile(this.path) : undefined
+    const stored: StoredAPNsConfiguration = {
+      schemaVersion: 1,
+      enabled: true,
+      ...validated.input!,
+      updatedAt: new Date().toISOString(),
+    }
+    try {
+      writeAtomic(this.path, `${JSON.stringify(stored, null, 2)}\n`)
+      await apply(config)
+    } catch (cause) {
+      try {
+        if (previous) writeAtomic(this.path, previous)
+        else if (existsSync(this.path)) unlinkSync(this.path)
+      } catch { /* preserve the primary configuration error */ }
+      throw cause
+    }
+    this.#snapshot = { ...validated, source: 'file', editable: true }
+    return this.snapshot()
+  }
+}

+ 36 - 6
src/server/app.ts

@@ -25,6 +25,10 @@ import { UploadStore } from './uploads.js'
 import { SystemUpdateManager } from './updateManager.js'
 import { installWebSocketRelay } from './websocket.js'
 import { PushCoordinator } from './pushCoordinator.js'
+import {
+  APNsConfigurationManager,
+  type APNsConfigurationSnapshot,
+} from './apnsConfiguration.js'
 import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
 import {
   ChatPushJobManager,
@@ -46,6 +50,7 @@ export interface ApplicationOptions {
   accountPairings?: AccountLoginPairingStore
   profileIdentities?: UpstreamProfileIdentityService
   push?: PushCoordinator
+  apnsConfiguration?: APNsConfigurationManager
 }
 
 export interface ApplicationRuntime {
@@ -62,6 +67,7 @@ export interface ApplicationRuntime {
   accountPairings: AccountLoginPairingStore
   profileIdentities: UpstreamProfileIdentityService
   push: PushCoordinator
+  apnsConfiguration: APNsConfigurationManager
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   groupPushEvents: GroupPushEventWatcher
@@ -119,6 +125,21 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
   const profileIdentities = options.profileIdentities
     ?? new UpstreamProfileIdentityService(config, upstreamSession)
+  const initialAPNsSettings: APNsConfigurationSnapshot = config.apnsSettings ?? {
+    source: config.apns || config.apnsConfigurationError ? 'environment' : 'none',
+    editable: !(config.apns || config.apnsConfigurationError),
+    ...(config.apns ? {
+      input: {
+        ...config.apns,
+        environments: [...(config.apns.environments ?? ['development', 'production'])],
+      },
+      config: config.apns,
+    } : {}),
+    warnings: [],
+    ...(config.apnsConfigurationError ? { configurationError: config.apnsConfigurationError } : {}),
+  }
+  const apnsConfiguration = options.apnsConfiguration
+    ?? new APNsConfigurationManager(config.home, initialAPNsSettings)
   const push = options.push ?? new PushCoordinator({
     home: config.home,
     apns: config.apns,
@@ -251,6 +272,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     accountPairings,
     profileIdentities,
     push,
+    apnsConfiguration,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
@@ -278,6 +300,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     accountPairings,
     profileIdentities,
     push,
+    apnsConfiguration,
     pushEventCoordinator,
     chatPushJobs,
     groupPushEvents,
@@ -303,17 +326,23 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
         key: readFileSync(config.tlsKey),
       }, runtime.app.callback())
     : createHttpServer(runtime.app.callback())
-  const pushEnabled = runtime.push.capabilities().enabled
-  if (pushEnabled) {
-    runtime.chatPushJobs.start()
-    runtime.groupPushEvents.start()
+  const synchronizePushObservers = (enabled = runtime.push.capabilities().enabled) => {
+    if (enabled) {
+      runtime.chatPushJobs.start()
+      runtime.groupPushEvents.start()
+    } else {
+      runtime.chatPushJobs.stop()
+      runtime.groupPushEvents.stop()
+    }
   }
+  synchronizePushObservers()
+  const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
   const removeWebSockets = installWebSocketRelay(
     server,
     config,
     runtime.leases,
     runtime.pairings,
-    pushEnabled ? {
+    {
       coordinator: runtime.pushEventCoordinator,
       resolveGatewayUser: request => runtime.auth.currentFromCookieHeader(
         typeof request.headers.cookie === 'string' ? request.headers.cookie : undefined,
@@ -322,11 +351,12 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
         runtime.upstreamSession,
         (localUserID, prompt) => runtime.push.promptDigest(localUserID, prompt),
       ),
-    } : undefined,
+    },
   )
   return {
     server,
     close: async () => {
+      removePushConfigurationListener()
       removeWebSockets()
       await new Promise<void>((resolve, reject) => {
         if (!server.listening) {

+ 10 - 42
src/server/config.ts

@@ -1,14 +1,17 @@
 import { isIP } from 'node:net'
 import { homedir } from 'node:os'
 import { basename, resolve } from 'node:path'
-import { createPrivateKey } from 'node:crypto'
-import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from 'node:fs'
+import { readFileSync } from 'node:fs'
+import type { APNsEnvironment } from './apns.js'
+import { loadAPNsConfiguration, type APNsConfigurationSnapshot } from './apnsConfiguration.js'
+export { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
 
 export interface APNsProviderConfig {
   keyFile: string
   keyId: string
   teamId: string
   topic: string
+  environments?: APNsEnvironment[]
 }
 
 export interface ServerConfig {
@@ -35,11 +38,11 @@ export interface ServerConfig {
   upstreamPassword?: string
   apns?: APNsProviderConfig
   apnsConfigurationError?: string
+  apnsSettings?: APNsConfigurationSnapshot
 }
 
 export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
 export const DEFAULT_YAOYAO_RELEASE_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git'
-export const DEFAULT_APNS_TOPIC = 'cn.samien.yaoyao.hermes'
 
 function flag(value: string | undefined): boolean {
   return value === '1' || value?.toLowerCase() === 'true'
@@ -97,43 +100,6 @@ function parseReleaseSource(value: string | undefined): string {
   return source
 }
 
-function parseAPNsConfiguration(env: NodeJS.ProcessEnv): {
-  apns?: APNsProviderConfig
-  apnsConfigurationError?: string
-} {
-  const rawKeyFile = env.HERMES_YAOYAO_APNS_KEY_FILE?.trim()
-  const keyId = env.HERMES_YAOYAO_APNS_KEY_ID?.trim()
-  const teamId = env.HERMES_YAOYAO_APNS_TEAM_ID?.trim()
-  const topic = env.HERMES_YAOYAO_APNS_TOPIC?.trim() || DEFAULT_APNS_TOPIC
-  if (!rawKeyFile && !keyId && !teamId && !env.HERMES_YAOYAO_APNS_TOPIC?.trim()) return {}
-  if (!rawKeyFile || !keyId || !teamId) {
-    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE, HERMES_YAOYAO_APNS_KEY_ID, and HERMES_YAOYAO_APNS_TEAM_ID must be set together' }
-  }
-  if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) {
-    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_ID is invalid' }
-  }
-  if (!/^[A-Za-z0-9]{1,128}$/.test(teamId)) {
-    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_TEAM_ID is invalid' }
-  }
-  if (topic.length > 255 || !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(topic)) {
-    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_TOPIC is invalid' }
-  }
-  const keyFile = resolve(rawKeyFile)
-  try {
-    if (!existsSync(keyFile) || !statSync(keyFile).isFile()) {
-      return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE must point to a readable file' }
-    }
-    accessSync(keyFile, fsConstants.R_OK)
-    const key = createPrivateKey(readFileSync(keyFile))
-    if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
-      return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE must contain an ES256 private key' }
-    }
-  } catch {
-    return { apnsConfigurationError: 'HERMES_YAOYAO_APNS_KEY_FILE must contain a readable ES256 private key' }
-  }
-  return { apns: { keyFile, keyId, teamId, topic } }
-}
-
 function normalizeConfiguredHost(raw: string): string {
   const value = raw.trim().toLowerCase()
   if (!value) return ''
@@ -200,7 +166,7 @@ export function loadServerConfig(
   }
   const upstreamPassword = env.HERMES_YAOYAO_UPSTREAM_PASSWORD
     || (upstreamPasswordFile ? readFileSync(resolve(upstreamPasswordFile), 'utf8').replace(/[\r\n]+$/, '') : undefined)
-  const apnsConfiguration = parseAPNsConfiguration(env)
+  const apnsSettings = loadAPNsConfiguration(home, env)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
@@ -235,6 +201,8 @@ export function loadServerConfig(
     allowRemoteUpdate,
     upstreamUsername,
     upstreamPassword,
-    ...apnsConfiguration,
+    ...(apnsSettings.config ? { apns: apnsSettings.config } : {}),
+    ...(apnsSettings.configurationError ? { apnsConfigurationError: apnsSettings.configurationError } : {}),
+    apnsSettings,
   }
 }

+ 117 - 19
src/server/pushCoordinator.ts

@@ -36,6 +36,7 @@ export interface PushCapabilities {
   protocolVersion: 1
   enabled: boolean
   topic: string
+  environments: readonly APNsEnvironment[]
   previewMode: 'title-and-summary'
   events: readonly PushEventKind[]
   maxSummaryCharacters: 180
@@ -47,6 +48,7 @@ export interface PushStatus {
   configured: boolean
   healthy: boolean
   topic: string
+  environments: readonly APNsEnvironment[]
   registrationCount: number
   pendingCount: number
   subscriptionCount: number
@@ -144,6 +146,7 @@ export interface PushCoordinatorOptions {
   apns?: APNsProviderConfig
   apnsConfigurationError?: string
   provider?: PushSender
+  providerFactory?: (config: APNsProviderConfig) => PushSender
   now?: () => number
   autoFlush?: boolean
   baseRetryMilliseconds?: number
@@ -410,7 +413,10 @@ function canonicalChatJob(input: PushChatJob): PushChatJob {
 
 export class PushCoordinator {
   private readonly store: PushStateStore
-  private readonly provider?: PushSender
+  private provider?: PushSender
+  private apns?: APNsProviderConfig
+  private apnsConfigurationError?: string
+  private readonly providerFactory: (config: APNsProviderConfig) => PushSender
   private readonly now: () => number
   private readonly autoFlush: boolean
   private readonly baseRetryMilliseconds: number
@@ -426,10 +432,16 @@ export class PushCoordinator {
   private flushBlockedUntil = 0
   private correlationSecret?: Buffer
   private correlationError?: string
+  private readonly enabledListeners = new Set<(enabled: boolean) => void>()
+  private configurationOperation: Promise<void> = Promise.resolve()
+  private providerGeneration = 0
 
   constructor(readonly options: PushCoordinatorOptions) {
     this.store = new PushStateStore(options.home)
-    this.provider = options.provider ?? (options.apns ? new APNsProvider(options.apns) : undefined)
+    this.apns = options.apns
+    this.apnsConfigurationError = options.apnsConfigurationError
+    this.providerFactory = options.providerFactory ?? (config => new APNsProvider(config))
+    this.provider = options.provider ?? (options.apns ? this.providerFactory(options.apns) : undefined)
     this.now = options.now ?? Date.now
     this.autoFlush = options.autoFlush ?? true
     this.baseRetryMilliseconds = options.baseRetryMilliseconds ?? 1_000
@@ -446,12 +458,69 @@ export class PushCoordinator {
     this.scheduleNextFlush()
   }
 
+  configureAPNs(apns?: APNsProviderConfig, configurationError?: string): Promise<void> {
+    const operation = this.configurationOperation.then(() => this.applyAPNsConfiguration(apns, configurationError))
+    this.configurationOperation = operation.then(() => undefined, () => undefined)
+    return operation
+  }
+
+  private async applyAPNsConfiguration(apns?: APNsProviderConfig, configurationError?: string): Promise<void> {
+    const wasEnabled = this.capabilities().enabled
+    const previous = this.provider
+    const next = apns && !configurationError ? this.providerFactory(apns) : undefined
+    if (this.closed) {
+      next?.close?.()
+      return
+    }
+    this.providerGeneration += 1
+    this.apns = apns
+    this.apnsConfigurationError = configurationError
+    this.provider = next
+    this.runtimeError = undefined
+    this.flushFailureCount = 0
+    this.flushBlockedUntil = 0
+    this.correlationError = undefined
+    if (apns && next && !configurationError) {
+      try { this.loadCorrelationSecret() } catch (cause) {
+        this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
+      }
+    }
+    try {
+      this.store.mutate(state => {
+        delete state.status.lastError
+        delete state.status.lastErrorAt
+      })
+    } catch { /* Existing push state errors remain authoritative. */ }
+    if (previous && previous !== next) {
+      const closePrevious = () => { try { previous.close?.() } catch { /* provider is already detached */ } }
+      if (this.activeFlush) void this.activeFlush.then(closePrevious, closePrevious)
+      else closePrevious()
+    }
+    if (!next && this.timer) {
+      clearTimeout(this.timer)
+      this.timer = undefined
+    }
+    this.scheduleNextFlush()
+    const enabled = this.capabilities().enabled
+    if (enabled !== wasEnabled) {
+      for (const listener of this.enabledListeners) {
+        try { listener(enabled) } catch { /* Runtime listeners cannot undo a committed configuration. */ }
+      }
+    }
+  }
+
+  onEnabledChange(listener: (enabled: boolean) => void): () => void {
+    this.enabledListeners.add(listener)
+    return () => { this.enabledListeners.delete(listener) }
+  }
+
   capabilities(): PushCapabilities {
-    const configurationError = this.options.apnsConfigurationError ?? this.store.loadError
+    const configurationError = this.apnsConfigurationError ?? this.store.loadError
     return {
       protocolVersion: 1,
-      enabled: Boolean(this.options.apns && this.provider && !configurationError),
-      topic: this.options.apns?.topic ?? DEFAULT_APNS_TOPIC,
+      enabled: Boolean(this.apns && this.provider && !configurationError),
+      topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
+      environments: this.apns?.environments ?? ['development', 'production'],
       previewMode: 'title-and-summary',
       events: PUSH_EVENT_KINDS,
       maxSummaryCharacters: MAX_SUMMARY_CHARACTERS,
@@ -462,14 +531,15 @@ export class PushCoordinator {
 
   status(): PushStatus {
     const state = this.store.snapshot()
-    const configurationError = this.options.apnsConfigurationError ?? this.store.loadError
+    const configurationError = this.apnsConfigurationError ?? this.store.loadError
     const latestSuccess = state.status.lastSuccessAt ? Date.parse(state.status.lastSuccessAt) : 0
     const latestError = state.status.lastErrorAt ? Date.parse(state.status.lastErrorAt) : 0
-    const configured = Boolean(this.options.apns && this.provider && !configurationError)
+    const configured = Boolean(this.apns && this.provider && !configurationError)
     return {
       configured,
       healthy: configured && !this.correlationError && !this.runtimeError && latestSuccess >= latestError,
-      topic: this.options.apns?.topic ?? DEFAULT_APNS_TOPIC,
+      topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
+      environments: this.apns?.environments ?? ['development', 'production'],
       registrationCount: state.installations.length,
       pendingCount: state.outbox.length,
       subscriptionCount: state.groupSubscriptions.filter(item => item.enabled).length,
@@ -888,6 +958,7 @@ export class PushCoordinator {
     const now = this.now()
     const expiresAt = input.expiresAt ?? now + DEFAULT_EVENT_LIFETIME_MS
     if (!Number.isFinite(expiresAt) || expiresAt <= now) throw new Error('expiresAt must be in the future')
+    const allowedEnvironments = new Set(this.capabilities().environments)
     const queued = this.store.mutate(state => {
       for (const [key, processedAt] of Object.entries(state.processedEvents)) {
         if (!Number.isFinite(processedAt) || processedAt < now - DEDUPE_LIFETIME_MS) delete state.processedEvents[key]
@@ -903,6 +974,7 @@ export class PushCoordinator {
       if (state.processedEvents[key] !== undefined) return 0
       const targets = state.installations.filter(item => item.userId === userId
         && this.registrationIsAuthorized(item)
+        && allowedEnvironments.has(item.environment)
         && (!clientAccountId || item.clientAccountId === clientAccountId))
       if (state.outbox.length + targets.length > MAX_OUTBOX_ITEMS) throw new Error('Push outbox limit reached')
       state.processedEvents[key] = now
@@ -938,18 +1010,24 @@ export class PushCoordinator {
     if (this.closed || !this.provider || !this.capabilities().enabled) {
       return Promise.resolve({ delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 })
     }
-    this.activeFlush = this.flushInternal()
+    const provider = this.provider
+    const generation = this.providerGeneration
+    this.activeFlush = this.flushInternal(provider, generation)
       .then(result => {
-        this.flushFailureCount = 0
-        this.flushBlockedUntil = 0
-        this.runtimeError = undefined
+        if (generation === this.providerGeneration) {
+          this.flushFailureCount = 0
+          this.flushBlockedUntil = 0
+          this.runtimeError = undefined
+        }
         return result
       })
       .catch(cause => {
-        this.flushFailureCount += 1
-        const delay = Math.min(60_000, 1_000 * (2 ** Math.min(this.flushFailureCount - 1, 6)))
-        this.flushBlockedUntil = this.now() + delay
-        this.recordRuntimeError(cause)
+        if (generation === this.providerGeneration) {
+          this.flushFailureCount += 1
+          const delay = Math.min(60_000, 1_000 * (2 ** Math.min(this.flushFailureCount - 1, 6)))
+          this.flushBlockedUntil = this.now() + delay
+          this.recordRuntimeError(cause)
+        }
         throw cause
       })
       .finally(() => {
@@ -959,10 +1037,8 @@ export class PushCoordinator {
     return this.activeFlush
   }
 
-  private async flushInternal(): Promise<PushFlushResult> {
+  private async flushInternal(provider: PushSender, generation: number): Promise<PushFlushResult> {
     const summary: PushFlushResult = { delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 }
-    const provider = this.provider
-    if (!provider) return summary
     const due = this.store.snapshot().outbox
       .filter(item => item.nextAttemptAt <= this.now())
       .sort((left, right) => left.nextAttemptAt - right.nextAttemptAt)
@@ -999,6 +1075,11 @@ export class PushCoordinator {
         summary.failed += 1
         continue
       }
+      if (installation && !this.capabilities().environments.includes(installation.environment)) {
+        this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
+        summary.failed += 1
+        continue
+      }
       const subscribed = !item.roomId || state.groupSubscriptions.some(subscription => subscription.userId === item.userId
         && subscription.roomId === item.roomId
         && subscription.enabled)
@@ -1029,6 +1110,22 @@ export class PushCoordinator {
         expiration: Math.floor(item.expiresAt / 1_000),
       })
       const at = new Date(this.now()).toISOString()
+      if (generation !== this.providerGeneration) {
+        if (result.disposition === 'success') {
+          this.store.mutate(current => {
+            current.outbox = current.outbox.filter(entry => entry.id !== item.id)
+            current.status.lastSuccessAt = at
+          })
+          summary.delivered += 1
+        } else {
+          this.store.mutate(current => {
+            const pending = current.outbox.find(entry => entry.id === item.id)
+            if (pending) pending.nextAttemptAt = this.now()
+          })
+          summary.retried += 1
+        }
+        break
+      }
       if (result.disposition === 'success') {
         this.store.mutate(current => {
           current.outbox = current.outbox.filter(entry => entry.id !== item.id)
@@ -1117,6 +1214,7 @@ export class PushCoordinator {
     if (this.timer) clearTimeout(this.timer)
     this.timer = undefined
     this.provider?.close?.()
+    this.enabledListeners.clear()
   }
 
   private recordRuntimeError(cause: unknown): void {

+ 7 - 2
src/server/pushEventAdapter.ts

@@ -70,14 +70,19 @@ export class PushCoordinatorEventAdapter implements PushEventCoordinator {
   ) {}
 
   observeChat(_observation: ChatPushObservation): void {}
-  saveChatJob(job: ChatPushJob): void { this.push.saveChatJob(job) }
+  saveChatJob(job: ChatPushJob): void {
+    if (this.push.capabilities().enabled) this.push.saveChatJob(job)
+  }
   completeChatJob(jobID: string): void { this.push.completeChatJob(jobID) }
-  pendingChatJobs(): readonly ChatPushJob[] { return this.push.pendingChatJobs() }
+  pendingChatJobs(): readonly ChatPushJob[] {
+    return this.push.capabilities().enabled ? this.push.pendingChatJobs() : []
+  }
   promptDigest(localUserID: string, prompt: string): string {
     return this.push.promptDigest(localUserID, prompt)
   }
   canRecoverChatJob(job: ChatPushJob): boolean { return this.push.chatJobRecoveryAllowed(job) }
   enqueueNotification(candidate: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
+    if (!this.push.capabilities().enabled) return 'ignored'
     if ('roomID' in candidate) {
       if (candidate.messageSequence !== undefined) {
         const lastSequence = this.push.groupSubscriptionLastMessageSeq(candidate.localUserID, candidate.roomID)

+ 6 - 1
src/server/pushEvents.ts

@@ -1612,10 +1612,15 @@ export class GroupPushEventWatcher {
       }
       if (!this.#running || generation !== this.#generation) return
       this.#processor = new GroupPushEventProcessor(anchor, this.coordinator)
-      this.#connection = await this.source.connect(anchor, {
+      const connection = await this.source.connect(anchor, {
         onEnvelope: envelope => this.#queueEnvelope(generation, envelope),
         onClose: () => this.#scheduleReconnect(generation),
       })
+      if (!this.#running || generation !== this.#generation || this.#connection) {
+        connection.close()
+        return
+      }
+      this.#connection = connection
       this.#retryAttempt = 0
     } catch {
       this.#scheduleReconnect(generation)

+ 38 - 3
src/server/routes.ts

@@ -32,6 +32,10 @@ import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localA
 import { AccountLoginPairingStore } from './accountPairing.js'
 import { UpstreamProfileIdentityService } from './profileIdentities.js'
 import { PushCoordinator } from './pushCoordinator.js'
+import {
+  APNsConfigurationManager,
+  type APNsConfigurationInput,
+} from './apnsConfiguration.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -48,6 +52,7 @@ export interface RouteDependencies {
   accountPairings: AccountLoginPairingStore
   profileIdentities: UpstreamProfileIdentityService
   push: PushCoordinator
+  apnsConfiguration: APNsConfigurationManager
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -358,6 +363,24 @@ function json(ctx: Koa.Context, status: number, value: unknown): void {
   ctx.body = value
 }
 
+function pushSystemStatus(dependencies: RouteDependencies): Record<string, unknown> {
+  const runtime = dependencies.push.status()
+  const settings = dependencies.apnsConfiguration.snapshot()
+  const input = settings.input
+  return {
+    ...runtime,
+    source: settings.source,
+    editable: settings.editable,
+    keyFile: input?.keyFile,
+    keyId: input?.keyId,
+    teamId: input?.teamId,
+    topic: input?.topic ?? runtime.topic,
+    environments: input?.environments ?? runtime.environments,
+    warnings: settings.warnings,
+    ...(settings.configurationError ? { configurationError: settings.configurationError } : {}),
+  }
+}
+
 function pushRequest<T>(operation: () => T): T {
   try {
     return operation()
@@ -1693,10 +1716,22 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
   router.get('/api/app/system/push-status', (ctx) => {
     dependencies.auth.requireAdmin(ctx)
-    json(ctx, 200, {
-      ...dependencies.push.status(),
-      topic: dependencies.push.capabilities().topic,
+    json(ctx, 200, pushSystemStatus(dependencies))
+  })
+  router.put('/api/app/system/push-config', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    const request = body(ctx)
+    const input: Partial<APNsConfigurationInput> = {
+      keyFile: typeof request.keyFile === 'string' ? request.keyFile : '',
+      keyId: typeof request.keyId === 'string' ? request.keyId : '',
+      teamId: typeof request.teamId === 'string' ? request.teamId : '',
+      topic: typeof request.topic === 'string' ? request.topic : '',
+      environments: Array.isArray(request.environments) ? request.environments as APNsConfigurationInput['environments'] : [],
+    }
+    await dependencies.apnsConfiguration.update(input, async config => {
+      await dependencies.push.configureAPNs(config)
     })
+    json(ctx, 200, pushSystemStatus(dependencies))
   })
   router.post('/api/app/system/update/check', async (ctx) => {
     await withJar(ctx, async (jar) => {

+ 93 - 3
tests/client/pushProtocol.test.ts

@@ -3,6 +3,7 @@ import {
   getGroupPushSubscriptions,
   getPushCapabilities,
   getPushSystemStatus,
+  savePushSystemConfig,
   setGroupPushSubscription,
 } from '@/api/push'
 import { clearApiSecurityContext, setApiCsrfToken } from '@/api/client'
@@ -22,8 +23,35 @@ describe('push client protocol', () => {
         : path === '/api/app/push/v1/group-subscriptions'
           ? { subscriptions: [{ roomId: 'room-1', enabled: true }] }
           : path === '/api/app/system/push-status'
-            ? { configured: true, healthy: true, registrationCount: 1, pendingCount: 0 }
-            : { subscription: { roomId: 'room-1', enabled: false } }
+            ? {
+                configured: true,
+                healthy: true,
+                registration_count: 1,
+                pending_count: 0,
+                source: 'file',
+                editable: true,
+                key_file: '/srv/secrets/AuthKey_TEST.p8',
+                key_id: 'KEY123',
+                team_id: 'TEAM123',
+                environments: ['development', 'production'],
+                warnings: [{ code: 'apns_key_permissions', message: '建议调整为 0600', actual_mode: '0644', recommended_mode: '0600' }],
+              }
+            : path === '/api/app/system/push-config'
+              ? {
+                  configured: true,
+                  healthy: true,
+                  registrationCount: 1,
+                  pendingCount: 0,
+                  source: 'file',
+                  editable: true,
+                  keyFile: '/srv/secrets/AuthKey_TEST.p8',
+                  keyId: 'KEY123',
+                  teamId: 'TEAM123',
+                  topic: 'cn.samien.yaoyao.hermes',
+                  environments: ['development', 'production'],
+                  warnings: [],
+                }
+              : { subscription: { roomId: 'room-1', enabled: false } }
       return new Response(JSON.stringify(payload), {
         status: 200,
         headers: { 'content-type': 'application/json' },
@@ -34,14 +62,76 @@ describe('push client protocol', () => {
     await expect(getPushCapabilities()).resolves.toMatchObject({ protocolVersion: 1, enabled: true })
     await expect(getGroupPushSubscriptions()).resolves.toEqual(new Set(['room-1']))
     await expect(setGroupPushSubscription('room-1', false)).resolves.toBe(false)
-    await expect(getPushSystemStatus()).resolves.toMatchObject({ configured: true, registrationCount: 1 })
+    await expect(getPushSystemStatus()).resolves.toMatchObject({
+      configured: true,
+      registrationCount: 1,
+      source: 'file',
+      editable: true,
+      managementAvailable: true,
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      environments: ['development', 'production'],
+      warnings: [{ code: 'apns_key_permissions', actualMode: '0644', recommendedMode: '0600' }],
+    })
+    await expect(savePushSystemConfig({
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+      topic: 'cn.samien.yaoyao.hermes',
+      environments: ['development', 'production'],
+    })).resolves.toMatchObject({ configured: true, source: 'file', editable: true })
 
     expect(calls.map(call => call.path)).toEqual([
       '/api/app/push/v1/capabilities',
       '/api/app/push/v1/group-subscriptions',
       '/api/app/push/v1/group-subscriptions/room-1',
       '/api/app/system/push-status',
+      '/api/app/system/push-config',
     ])
     expect(new Headers(calls[2]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-push')
+    expect(new Headers(calls[4]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-push')
+    expect(calls[4]!.init?.body).toBe(JSON.stringify({
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+      topic: 'cn.samien.yaoyao.hermes',
+      environments: ['development', 'production'],
+    }))
+  })
+
+  it('keeps an old status response read-only instead of guessing its configuration source', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+      configured: true,
+      healthy: true,
+      registrationCount: 2,
+      pendingCount: 0,
+      topic: 'cn.samien.yaoyao.hermes',
+    }), { status: 200, headers: { 'content-type': 'application/json' } })))
+
+    await expect(getPushSystemStatus()).resolves.toMatchObject({
+      configured: true,
+      source: 'none',
+      editable: false,
+      managementAvailable: false,
+      environments: [],
+      warnings: [],
+    })
+  })
+
+  it('normalizes startup configuration errors as the visible push error', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+      configured: false,
+      healthy: false,
+      source: 'environment',
+      editable: false,
+      configuration_error: 'APNs key is invalid',
+      registration_count: 1,
+      pending_count: 0,
+    }), { status: 200, headers: { 'content-type': 'application/json' } })))
+
+    await expect(getPushSystemStatus()).resolves.toMatchObject({
+      configured: false,
+      source: 'environment',
+      lastError: 'APNs key is invalid',
+    })
   })
 })

+ 197 - 0
tests/client/systemManagementDialog.test.ts

@@ -0,0 +1,197 @@
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
+import { listUsers } from '@/api/admin'
+import { getPushSystemStatus, savePushSystemConfig, type PushSystemStatus } from '@/api/push'
+
+vi.mock('@/api/admin', () => ({
+  createUser: vi.fn(async () => undefined),
+  deleteUser: vi.fn(async () => undefined),
+  listUsers: vi.fn(async () => []),
+  setUpstreamCredentials: vi.fn(async () => undefined),
+  updateUser: vi.fn(async () => undefined),
+}))
+
+vi.mock('@/api/push', () => ({
+  getPushSystemStatus: vi.fn(),
+  savePushSystemConfig: vi.fn(),
+}))
+
+function pushStatus(overrides: Partial<PushSystemStatus> = {}): PushSystemStatus {
+  return {
+    configured: false,
+    healthy: false,
+    topic: 'cn.samien.yaoyao.hermes',
+    registrationCount: 0,
+    pendingCount: 0,
+    source: 'none',
+    editable: true,
+    managementAvailable: true,
+    environments: ['development', 'production'],
+    warnings: [],
+    ...overrides,
+  }
+}
+
+async function mountOpen(): Promise<VueWrapper> {
+  const wrapper = mount(SystemManagementDialog, {
+    attachTo: document.body,
+    props: { open: false },
+    global: { stubs: { AppIcon: true } },
+  })
+  await wrapper.setProps({ open: true })
+  await flushPromises()
+  return wrapper
+}
+
+function input(name: string): HTMLInputElement {
+  return document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!
+}
+
+function enter(name: string, value: string) {
+  const element = input(name)
+  element.value = value
+  element.dispatchEvent(new Event('input'))
+}
+
+function button(label: string): HTMLButtonElement | undefined {
+  return [...document.querySelectorAll<HTMLButtonElement>('button')]
+    .find(element => element.textContent?.includes(label))
+}
+
+beforeEach(() => {
+  vi.mocked(listUsers).mockResolvedValue([])
+  vi.mocked(getPushSystemStatus).mockResolvedValue(pushStatus())
+  vi.mocked(savePushSystemConfig).mockResolvedValue(pushStatus({ configured: true, healthy: true, source: 'file' }))
+})
+
+afterEach(() => {
+  document.body.innerHTML = ''
+  vi.clearAllMocks()
+})
+
+describe('System management APNs configuration', () => {
+  it('sends a server-local path and both environments through validation and enablement', async () => {
+    vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({ environments: [] }))
+    vi.mocked(savePushSystemConfig).mockResolvedValueOnce(pushStatus({
+      configured: true,
+      healthy: true,
+      source: 'file',
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+      warnings: [{
+        code: 'apns_key_permissions',
+        message: 'APNs 已启用,但密钥文件权限为 0644;建议调整为 0600,此提示不会影响当前推送。',
+        actualMode: '0644',
+        recommendedMode: '0600',
+      }],
+    }))
+    const wrapper = await mountOpen()
+
+    enter('push-key-file', '  /srv/secrets/AuthKey_TEST.p8  ')
+    enter('push-key-id', ' KEY123 ')
+    enter('push-team-id', ' TEAM123 ')
+    enter('push-topic', ' cn.samien.yaoyao.hermes ')
+    await nextTick()
+    const save = button('验证并启用')!
+    expect(save.disabled).toBe(false)
+    save.click()
+    await flushPromises()
+
+    expect(savePushSystemConfig).toHaveBeenCalledWith({
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+      topic: 'cn.samien.yaoyao.hermes',
+      environments: ['development', 'production'],
+    })
+    expect(document.body.textContent).toContain('Sandbox 与 Production 验证通过,APNs 已启用')
+    expect(document.querySelector('.push-warning')?.textContent).toContain('权限为 0644')
+    expect(button('验证并启用')?.disabled).toBe(false)
+    wrapper.unmount()
+  })
+
+  it('shows environment-managed configuration as read-only', async () => {
+    vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
+      configured: true,
+      healthy: true,
+      source: 'environment',
+      editable: false,
+      keyFile: '/Users/test/.hermes/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+    }))
+    const wrapper = await mountOpen()
+
+    expect(document.body.textContent).toContain('由服务环境变量管理,Web 仅可查看')
+    expect(input('push-key-file').readOnly).toBe(true)
+    expect(document.querySelector<HTMLFieldSetElement>('.push-config-form fieldset')?.disabled).toBe(true)
+    expect(button('验证并启用')).toBeUndefined()
+    document.querySelector<HTMLFormElement>('.push-config-form')!.dispatchEvent(new Event('submit'))
+    await nextTick()
+    expect(savePushSystemConfig).not.toHaveBeenCalled()
+    wrapper.unmount()
+  })
+
+  it('surfaces an invalid startup configuration instead of calling it unconfigured', async () => {
+    vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
+      configured: false,
+      healthy: false,
+      source: 'environment',
+      editable: false,
+      lastError: 'APNs key file must contain an ES256 private key',
+    }))
+    const wrapper = await mountOpen()
+
+    expect(document.body.textContent).toContain('APNs 配置无效:APNs key file must contain an ES256 private key')
+    wrapper.unmount()
+  })
+
+  it('keeps an old status-only response safe and does not expose editing controls', async () => {
+    vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
+      configured: true,
+      healthy: true,
+      managementAvailable: false,
+      editable: false,
+    }))
+    const wrapper = await mountOpen()
+
+    expect(document.body.textContent).toContain('APNs 推送服务正常')
+    expect(document.body.textContent).toContain('当前服务版本只提供推送状态')
+    expect(document.querySelector('.push-config-form')).toBeNull()
+    expect(savePushSystemConfig).not.toHaveBeenCalled()
+    wrapper.unmount()
+  })
+
+  it('rejects an empty environment set locally and preserves input after a server error', async () => {
+    vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
+      source: 'file',
+      keyFile: '/srv/secrets/AuthKey_TEST.p8',
+      keyId: 'KEY123',
+      teamId: 'TEAM123',
+      environments: ['development'],
+    }))
+    const wrapper = await mountOpen()
+    const development = document.querySelector<HTMLInputElement>('input[type="checkbox"][value="development"]')!
+    development.checked = false
+    development.dispatchEvent(new Event('change'))
+    await nextTick()
+    document.querySelector<HTMLFormElement>('.push-config-form')!.dispatchEvent(new Event('submit'))
+    await nextTick()
+
+    expect(document.querySelector('[role="alert"]')?.textContent).toContain('至少选择 Sandbox 或 Production')
+    expect(savePushSystemConfig).not.toHaveBeenCalled()
+
+    development.checked = true
+    development.dispatchEvent(new Event('change'))
+    await nextTick()
+    vi.mocked(savePushSystemConfig).mockRejectedValueOnce(new Error('8800 无法读取该密钥文件,请检查路径和服务账户权限'))
+    button('验证并启用')!.click()
+    await flushPromises()
+    expect(document.querySelector('[role="alert"]')?.textContent).toContain('8800 无法读取该密钥文件')
+    expect(input('push-key-file').value).toBe('/srv/secrets/AuthKey_TEST.p8')
+    wrapper.unmount()
+  })
+})

+ 18 - 0
tests/server/apns.test.ts

@@ -129,6 +129,24 @@ describe('APNsProvider', () => {
     })).resolves.toMatchObject({ disposition: 'retry', reason: 'network unavailable' })
     expect(calls).toBe(1)
   })
+
+  it('never sends to an APNs environment excluded by the active configuration', async () => {
+    const { config } = keyFixture()
+    let calls = 0
+    const provider = new APNsProvider({ ...config, environments: ['development'] }, {
+      transport: {
+        async send() {
+          calls += 1
+          return { status: 200 }
+        },
+      },
+    })
+
+    await expect(provider.send({
+      deviceToken: 'ef'.repeat(32), environment: 'production', payload: { aps: {} },
+    })).resolves.toMatchObject({ disposition: 'configuration', reason: 'EnvironmentNotConfigured' })
+    expect(calls).toBe(0)
+  })
 })
 
 describe('Node HTTP/2 APNs transport', () => {

+ 167 - 0
tests/server/apnsConfiguration.test.ts

@@ -0,0 +1,167 @@
+import { generateKeyPairSync } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import {
+  chmodSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  realpathSync,
+  rmSync,
+  statSync,
+  writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  APNsConfigurationManager,
+  apnsConfigurationPath,
+  loadAPNsConfiguration,
+  validateAPNsConfiguration,
+} from '../../src/server/apnsConfiguration.js'
+
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function temporaryRoot(name: string): string {
+  const root = mkdtempSync(join(tmpdir(), `${name}-`))
+  roots.push(root)
+  return root
+}
+
+function ecKey(path: string, mode = 0o600): void {
+  const { privateKey } = generateKeyPairSync('ec', {
+    namedCurve: 'prime256v1',
+    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+    publicKeyEncoding: { type: 'spki', format: 'pem' },
+  })
+  writeFileSync(path, privateKey, { mode })
+  chmodSync(path, mode)
+}
+
+function input(keyFile: string) {
+  return {
+    keyFile,
+    keyId: 'KEY1234567',
+    teamId: 'TEAM123456',
+    topic: 'cn.samien.yaoyao.hermes',
+    environments: ['development', 'production'] as const,
+  }
+}
+
+describe('APNs Web-managed configuration', () => {
+  it('persists a validated local path atomically and treats broad key permissions as a warning', async () => {
+    const home = temporaryRoot('yaoyao-apns-config')
+    const keyFile = join(home, 'AuthKey_TEST.p8')
+    ecKey(keyFile, 0o644)
+    const probe = vi.fn(async () => undefined)
+    const manager = new APNsConfigurationManager(
+      home,
+      { source: 'none', editable: true, warnings: [] },
+      { probe },
+    )
+    const apply = vi.fn()
+
+    const result = await manager.update(input(keyFile), apply)
+    const canonicalKeyFile = realpathSync(keyFile)
+
+    expect(result).toMatchObject({
+      source: 'file',
+      editable: true,
+      config: { keyFile: canonicalKeyFile, keyId: 'KEY1234567' },
+      warnings: [{ code: 'apns_key_permissions', actualMode: '0644', recommendedMode: '0600' }],
+    })
+    expect(probe).toHaveBeenCalledWith(expect.objectContaining({ keyFile: canonicalKeyFile }), ['development', 'production'])
+    expect(apply).toHaveBeenCalledTimes(1)
+    expect(statSync(apnsConfigurationPath(home)).mode & 0o777).toBe(0o600)
+    expect(loadAPNsConfiguration(home, {})).toMatchObject({ source: 'file', editable: true, config: { keyFile: canonicalKeyFile } })
+  })
+
+  it('accepts stricter 0400 permissions without warning and rejects invalid key material', () => {
+    const home = temporaryRoot('yaoyao-apns-permissions')
+    const keyFile = join(home, 'AuthKey_TEST.p8')
+    ecKey(keyFile, 0o400)
+    expect(validateAPNsConfiguration(input(keyFile)).warnings).toEqual([])
+    expect(() => validateAPNsConfiguration({
+      ...input(keyFile), topic: 'cn.samien.another-app',
+    })).toThrow(/topic must be cn\.samien\.yaoyao\.hermes/)
+
+    const { privateKey } = generateKeyPairSync('rsa', {
+      modulusLength: 2048,
+      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+      publicKeyEncoding: { type: 'spki', format: 'pem' },
+    })
+    const invalid = join(home, 'not-ec.p8')
+    writeFileSync(invalid, privateKey, { mode: 0o600 })
+    expect(() => validateAPNsConfiguration(input(invalid))).toThrow(/ES256/)
+    expect(() => validateAPNsConfiguration(input(join(home, 'missing.p8')))).toThrow(/cannot read/)
+
+    const fifo = join(home, 'named-pipe.p8')
+    execFileSync('mkfifo', [fifo])
+    expect(() => validateAPNsConfiguration(input(fifo))).toThrow(/private key file/)
+
+    const configurationHome = temporaryRoot('yaoyao-apns-config-fifo')
+    mkdirSync(join(configurationHome, 'push'), { recursive: true })
+    execFileSync('mkfifo', [apnsConfigurationPath(configurationHome)])
+    expect(loadAPNsConfiguration(configurationHome, {})).toMatchObject({
+      source: 'file', editable: true, configurationError: 'APNs configuration path is not a regular file',
+    })
+  })
+
+  it('gives any APNs environment variable priority over a valid Web file', async () => {
+    const home = temporaryRoot('yaoyao-apns-env-priority')
+    const keyFile = join(home, 'AuthKey_TEST.p8')
+    ecKey(keyFile)
+    const manager = new APNsConfigurationManager(home, { source: 'none', editable: true, warnings: [] }, {
+      probe: async () => undefined,
+    })
+    await manager.update(input(keyFile), () => undefined)
+
+    const loaded = loadAPNsConfiguration(home, { HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes' })
+    expect(loaded).toMatchObject({ source: 'environment', editable: false })
+    expect(loaded.config).toBeUndefined()
+    expect(loaded.configurationError).toMatch(/absolute local path/)
+  })
+
+  it('keeps the previous file and runtime when a replacement probe fails', async () => {
+    const home = temporaryRoot('yaoyao-apns-rollback')
+    const keyFile = join(home, 'AuthKey_TEST.p8')
+    ecKey(keyFile)
+    let reject = false
+    const manager = new APNsConfigurationManager(home, { source: 'none', editable: true, warnings: [] }, {
+      probe: async () => {
+        if (reject) throw new Error('probe rejected')
+      },
+    })
+    const apply = vi.fn()
+    await manager.update(input(keyFile), apply)
+    const before = readFileSync(apnsConfigurationPath(home))
+    reject = true
+
+    await expect(manager.update({ ...input(keyFile), keyId: 'REPLACED01' }, apply)).rejects.toThrow('probe rejected')
+    expect(readFileSync(apnsConfigurationPath(home))).toEqual(before)
+    expect(manager.snapshot().input?.keyId).toBe('KEY1234567')
+    expect(apply).toHaveBeenCalledTimes(1)
+  })
+
+  it('refuses Web changes when environment variables own the configuration', async () => {
+    const home = temporaryRoot('yaoyao-apns-managed')
+    const keyFile = join(home, 'AuthKey_TEST.p8')
+    ecKey(keyFile)
+    const initial = loadAPNsConfiguration(home, {
+      HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
+      HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
+      HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
+      HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes',
+    })
+    const manager = new APNsConfigurationManager(home, initial, { probe: async () => undefined })
+
+    await expect(manager.update(input(keyFile), () => undefined)).rejects.toMatchObject({
+      status: 409,
+      code: 'apns_environment_managed',
+    })
+  })
+})

+ 81 - 6
tests/server/pushCoordinator.test.ts

@@ -1,7 +1,7 @@
 import { generateKeyPairSync } from 'node:crypto'
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
 import { loadServerConfig, type APNsProviderConfig } from '../../src/server/config.js'
@@ -55,10 +55,11 @@ function register(coordinator: PushCoordinator, suffix = '1') {
 
 describe('push configuration', () => {
   it('keeps APNs optional and reports partial configuration without preventing startup', () => {
-    expect(loadServerConfig({}).apns).toBeUndefined()
-    const partial = loadServerConfig({ HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567' })
+    const home = root()
+    expect(loadServerConfig({ HERMES_YAOYAO_HOME: home }).apns).toBeUndefined()
+    const partial = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567' })
     expect(partial.apns).toBeUndefined()
-    expect(partial.apnsConfigurationError).toMatch(/must be set together/)
+    expect(partial.apnsConfigurationError).toMatch(/absolute local path/)
   })
 
   it('loads a complete provider configuration and the default topic', () => {
@@ -67,15 +68,17 @@ describe('push configuration', () => {
     const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
     writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
     const config = loadServerConfig({
+      HERMES_YAOYAO_HOME: directory,
       HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
       HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
       HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
     })
     expect(config.apns).toEqual({
-      keyFile,
+      keyFile: realpathSync(keyFile),
       keyId: 'KEY1234567',
       teamId: 'TEAM123456',
       topic: 'cn.samien.yaoyao.hermes',
+      environments: ['development', 'production'],
     })
   })
 
@@ -83,6 +86,7 @@ describe('push configuration', () => {
     const keyFile = join(root(), 'AuthKey.p8')
     writeFileSync(keyFile, 'not a private key')
     const config = loadServerConfig({
+      HERMES_YAOYAO_HOME: dirname(keyFile),
       HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
       HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
       HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
@@ -93,6 +97,77 @@ describe('push configuration', () => {
 })
 
 describe('PushCoordinator durable state and delivery', () => {
+  it('hot-swaps during an active flush without closing the in-flight provider early', async () => {
+    const home = root()
+    let releaseSend!: (value: APNsSendResult) => void
+    const oldSender: PushSender & { closed: boolean } = {
+      closed: false,
+      send: () => new Promise(resolve => { releaseSend = resolve }),
+      close() { this.closed = true },
+    }
+    const replacement = new FakeSender()
+    const coordinator = new PushCoordinator({
+      home, apns, provider: oldSender, autoFlush: false,
+      providerFactory: () => replacement,
+    })
+    register(coordinator)
+    coordinator.enqueue({
+      eventId: 'active-flush', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '已完成',
+    })
+    const flush = coordinator.flushDue()
+    const replacementOperation = coordinator.configureAPNs({ ...apns, keyId: 'NEWKEY1234' })
+    await replacementOperation
+    expect(oldSender.closed).toBe(false)
+    expect(coordinator.capabilities().enabled).toBe(true)
+
+    releaseSend({ disposition: 'unregister', status: 400, reason: 'BadDeviceToken' })
+    await flush
+    await Promise.resolve()
+    expect(oldSender.closed).toBe(true)
+    expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1 })
+    await coordinator.flushDue()
+    expect(replacement.requests).toHaveLength(1)
+    expect(coordinator.status().pendingCount).toBe(0)
+  })
+
+  it('hot-enables providers and only queues installations from selected environments', async () => {
+    const home = root()
+    const senders: FakeSender[] = []
+    const coordinator = new PushCoordinator({
+      home,
+      autoFlush: false,
+      providerFactory: () => {
+        const sender = new FakeSender()
+        senders.push(sender)
+        return sender
+      },
+    })
+    const changes: boolean[] = []
+    coordinator.onEnabledChange(enabled => { changes.push(enabled) })
+    coordinator.registerInstallation({
+      userId: 'user-a', installationId: 'phone-development', clientAccountId: 'account-a',
+      deviceToken: 'ab'.repeat(32), environment: 'development',
+    })
+    coordinator.registerInstallation({
+      userId: 'user-a', installationId: 'phone-production', clientAccountId: 'account-a',
+      deviceToken: 'cd'.repeat(32), environment: 'production',
+    })
+
+    await coordinator.configureAPNs({ ...apns, environments: ['production'] })
+    expect(coordinator.capabilities()).toMatchObject({ enabled: true, environments: ['production'] })
+    expect(coordinator.enqueue({
+      eventId: 'environment-event', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '已完成',
+    })).toBe(1)
+    await coordinator.flushDue()
+    expect(senders[0]!.requests).toHaveLength(1)
+    expect(senders[0]!.requests[0]!.environment).toBe('production')
+
+    await coordinator.configureAPNs()
+    expect(senders[0]!.closed).toBe(true)
+    expect(coordinator.capabilities().enabled).toBe(false)
+    expect(changes).toEqual([true, false])
+  })
+
   it('persists private registrations, subscriptions, outbox delivery, and badge state', async () => {
     const home = root()
     const sender = new FakeSender()

+ 110 - 1
tests/server/pushRoutes.test.ts

@@ -1,4 +1,5 @@
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { generateKeyPairSync } from 'node:crypto'
+import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import request from 'supertest'
@@ -6,6 +7,11 @@ import { afterEach, describe, expect, it } from 'vitest'
 import type { ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
 import { PushCoordinator } from '../../src/server/pushCoordinator.js'
+import {
+  APNsConfigurationManager,
+  apnsConfigurationPath,
+  validateAPNsConfiguration,
+} from '../../src/server/apnsConfiguration.js'
 import { createAuthenticatedApplication } from './authenticatedApplication.js'
 
 const roots: string[] = []
@@ -17,6 +23,109 @@ afterEach(() => {
 })
 
 describe('iOS push routes', () => {
+  it('validates and enables a server-local APNs key path without blocking broad permissions', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-web-config-'))
+    roots.push(home)
+    const keyFile = join(home, 'AuthKey_TEST.p8')
+    const { privateKey } = generateKeyPairSync('ec', {
+      namedCurve: 'prime256v1',
+      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+      publicKeyEncoding: { type: 'spki', format: 'pem' },
+    })
+    writeFileSync(keyFile, privateKey, { mode: 0o644 })
+    chmodSync(keyFile, 0o644)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const push = new PushCoordinator({
+      home,
+      autoFlush: false,
+      providerFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }),
+    })
+    const apnsConfiguration = new APNsConfigurationManager(
+      home,
+      { source: 'none', editable: true, warnings: [] },
+      { probe: async () => undefined },
+    )
+    const runtime = createAuthenticatedApplication({ config, push, apnsConfiguration })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const mutation = () => agent.put('/api/app/system/push-config')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .send({
+        keyFile,
+        keyId: 'KEY1234567',
+        teamId: 'TEAM123456',
+        topic: 'cn.samien.yaoyao.hermes',
+        environments: ['development', 'production'],
+      })
+
+    await mutation().expect(403)
+    const saved = await mutation().set('X-CSRF-Token', bootstrap.body.csrfToken).expect(200)
+    expect(saved.body).toMatchObject({
+      configured: true,
+      healthy: true,
+      source: 'file',
+      editable: true,
+      keyFile: realpathSync(keyFile),
+      environments: ['development', 'production'],
+      warnings: [{ code: 'apns_key_permissions', actualMode: '0644', recommendedMode: '0600' }],
+    })
+    expect(statSync(apnsConfigurationPath(home)).mode & 0o777).toBe(0o600)
+    await agent.get('/api/app/system/push-status')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200, /"source":"file"/)
+  })
+
+  it('keeps environment-managed APNs configuration read-only through the admin API', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-env-managed-'))
+    roots.push(home)
+    const keyFile = join(home, 'AuthKey_TEST.p8')
+    const { privateKey } = generateKeyPairSync('ec', {
+      namedCurve: 'prime256v1',
+      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+      publicKeyEncoding: { type: 'spki', format: 'pem' },
+    })
+    writeFileSync(keyFile, privateKey, { mode: 0o600 })
+    const validated = validateAPNsConfiguration({
+      keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes',
+      environments: ['development'],
+    })
+    const initial = { ...validated, source: 'environment' as const, editable: false }
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+      apns: validated.config,
+      apnsSettings: initial,
+    }
+    const push = new PushCoordinator({
+      home, apns: validated.config,
+      provider: { send: async () => ({ disposition: 'success', status: 200 }) },
+      autoFlush: false,
+    })
+    const apnsConfiguration = new APNsConfigurationManager(home, initial, { probe: async () => undefined })
+    const runtime = createAuthenticatedApplication({ config, push, apnsConfiguration })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    const response = await agent.put('/api/app/system/push-config')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({
+        keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes',
+        environments: ['development'],
+      })
+      .expect(409)
+    expect(response.body).toMatchObject({ code: 'apns_environment_managed' })
+  })
+
   it('registers an installation, manages team subscriptions, badge, and admin status', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-routes-'))
     roots.push(home)

+ 85 - 0
tests/server/pushRuntime.test.ts

@@ -0,0 +1,85 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createNodeServer, type ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+import { PushCoordinator } from '../../src/server/pushCoordinator.js'
+import {
+  GroupPushEventWatcher,
+  type GroupEventConnection,
+  type GroupEventSource,
+  type PushEventCoordinator,
+} from '../../src/server/pushEvents.js'
+import { createAuthenticatedApplication } from './authenticatedApplication.js'
+
+const roots: string[] = []
+const runtimes: ApplicationRuntime[] = []
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+describe('dynamic push runtime', () => {
+  it('closes a group connection that finishes opening after the watcher was stopped', async () => {
+    const epoch = '11111111-1111-4111-8111-111111111111'
+    let resolveConnection!: (connection: GroupEventConnection) => void
+    const connection = { close: vi.fn() }
+    const source: GroupEventSource = {
+      currentAnchor: async () => ({ epoch, cursor: 0 }),
+      connect: () => new Promise(resolve => { resolveConnection = resolve }),
+    }
+    const coordinator: PushEventCoordinator = {
+      saveChatJob: () => undefined,
+      completeChatJob: () => undefined,
+      pendingChatJobs: () => [],
+      enqueueNotification: () => 'ignored',
+      groupWatchAnchor: () => ({ epoch, cursor: 0 }),
+      groupSubscribers: () => [],
+      advanceGroupCursor: () => undefined,
+      resetGroupCursor: reset => ({ epoch: reset.epoch, cursor: reset.cursor }),
+    }
+    const watcher = new GroupPushEventWatcher(source, coordinator)
+    watcher.start()
+    await vi.waitFor(() => expect(resolveConnection).toBeTypeOf('function'))
+    watcher.stop()
+    resolveConnection(connection)
+    await vi.waitFor(() => expect(connection.close).toHaveBeenCalledTimes(1))
+  })
+
+  it('starts and stops background observers when Web configuration changes', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-runtime-'))
+    roots.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const push = new PushCoordinator({
+      home,
+      autoFlush: false,
+      providerFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }),
+    })
+    const runtime = createAuthenticatedApplication({ config, push })
+    const chatStart = vi.spyOn(runtime.chatPushJobs, 'start').mockImplementation(() => undefined)
+    const chatStop = vi.spyOn(runtime.chatPushJobs, 'stop').mockImplementation(() => undefined)
+    const groupStart = vi.spyOn(runtime.groupPushEvents, 'start').mockImplementation(() => undefined)
+    const groupStop = vi.spyOn(runtime.groupPushEvents, 'stop').mockImplementation(() => undefined)
+    const node = createNodeServer(runtime)
+
+    expect(chatStop).toHaveBeenCalledTimes(1)
+    expect(groupStop).toHaveBeenCalledTimes(1)
+    await push.configureAPNs({
+      keyFile: '/private/unused.p8', keyId: 'KEY1234567', teamId: 'TEAM123456',
+      topic: 'cn.samien.yaoyao.hermes', environments: ['development'],
+    })
+    expect(chatStart).toHaveBeenCalledTimes(1)
+    expect(groupStart).toHaveBeenCalledTimes(1)
+    await push.configureAPNs()
+    expect(chatStop).toHaveBeenCalledTimes(2)
+    expect(groupStop).toHaveBeenCalledTimes(2)
+
+    await node.close()
+  })
+})

+ 27 - 12
tests/server/security.test.ts

@@ -1,6 +1,9 @@
 import { randomBytes } from 'node:crypto'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
 import type Koa from 'koa'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { loadServerConfig, type ServerConfig } from '../../src/server/config.js'
 import {
   CsrfProtection,
@@ -9,6 +12,18 @@ import {
   applySecurityHeaders,
 } from '../../src/server/security.js'
 
+const configHomes: string[] = []
+
+afterEach(() => {
+  for (const home of configHomes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+function isolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
+  const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-security-config-'))
+  configHomes.push(home)
+  return { HERMES_YAOYAO_HOME: home, ...overrides }
+}
+
 function config(overrides: Partial<ServerConfig> = {}): ServerConfig {
   return {
     host: '127.0.0.1',
@@ -92,19 +107,19 @@ describe('server security boundary', () => {
   })
 
   it('refuses production LAN HTTP unless explicitly enabled', () => {
-    expect(() => loadServerConfig({
+    expect(() => loadServerConfig(isolatedEnv({
       NODE_ENV: 'production',
       HERMES_YAOYAO_HOST: '0.0.0.0',
-    })).toThrow(/ALLOW_INSECURE_LAN/)
-    expect(loadServerConfig({
+    }))).toThrow(/ALLOW_INSECURE_LAN/)
+    expect(loadServerConfig(isolatedEnv({
       NODE_ENV: 'production',
       HERMES_YAOYAO_HOST: '0.0.0.0',
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
-    }).insecureLan).toBe(true)
+    })).insecureLan).toBe(true)
   })
 
   it('defaults the YaoYao Web listener to loopback', () => {
-    const value = loadServerConfig({})
+    const value = loadServerConfig(isolatedEnv())
     expect(value.host).toBe('127.0.0.1')
     expect(value.insecureLan).toBe(false)
     expect(value.yaoyaoPluginSource).toBe(
@@ -115,17 +130,17 @@ describe('server security boundary', () => {
   })
 
   it('rejects credentials embedded in the configured Yaoyao plugin source', () => {
-    expect(() => loadServerConfig({
+    expect(() => loadServerConfig(isolatedEnv({
       HERMES_YAOYAO_PLUGIN_SOURCE: 'https://user:secret@git.example/yaoyao.git#hermes-plugins/yaoyao',
-    })).toThrow(/must not contain credentials/)
+    }))).toThrow(/must not contain credentials/)
   })
 
   it('requires a credential-free HTTPS or SSH system release source', () => {
-    expect(() => loadServerConfig({
+    expect(() => loadServerConfig(isolatedEnv({
       HERMES_YAOYAO_RELEASE_SOURCE: 'http://git.example/hermes-yaoyao.git',
-    })).toThrow(/HTTPS or SSH/)
-    expect(() => loadServerConfig({
+    }))).toThrow(/HTTPS or SSH/)
+    expect(() => loadServerConfig(isolatedEnv({
       HERMES_YAOYAO_RELEASE_SOURCE: 'https://user:secret@git.example/hermes-yaoyao.git',
-    })).toThrow(/must not contain credentials/)
+    }))).toThrow(/must not contain credentials/)
   })
 })

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff