Просмотр исходного кода

Merge pull request #11091 from growilabs/feat/181356-183057-news-inappnotification-admin-delivery-toggle

feat(news-inappnotification): 181356-183057 admin can change news delivery
ryotaro-nagahara 1 неделя назад
Родитель
Сommit
625c61eb1f

+ 57 - 11
.kiro/specs/news-inappnotification/design.md

@@ -12,7 +12,7 @@
 
 
 ### Goals
 ### Goals
 
 
-- 外部フィード(`NEWS_FEED_URL`)を cron で定期取得し、MongoDB にキャッシュする
+- 外部フィード(コードにハードコードされた配信元 URL)を cron で定期取得し、MongoDB にキャッシュする
 - InAppNotification パネルで通知とニュースを統合表示する
 - InAppNotification パネルで通知とニュースを統合表示する
 - ニュースの既読/未読状態をユーザー単位で管理する
 - ニュースの既読/未読状態をユーザー単位で管理する
 - ロール別表示制御(admin/general)をサーバーサイドで強制する
 - ロール別表示制御(admin/general)をサーバーサイドで強制する
@@ -75,7 +75,7 @@ graph TB
 | Layer | 選択 / バージョン | 役割 |
 | Layer | 選択 / バージョン | 役割 |
 |---|---|---|
 |---|---|---|
 | Backend Cron | node-cron(既存) | フィード定期取得スケジューリング |
 | Backend Cron | node-cron(既存) | フィード定期取得スケジューリング |
-| Backend HTTP | node `fetch` / axios(既存) | `NEWS_FEED_URL` から feed.json 取得 |
+| Backend HTTP | node `fetch` / axios(既存) | コードに内蔵された配信元 URL から feed.json 取得 |
 | Data Store | MongoDB + Mongoose(既存) | NewsItem, NewsReadStatus の永続化 |
 | Data Store | MongoDB + Mongoose(既存) | NewsItem, NewsReadStatus の永続化 |
 | Frontend Data | SWR `useSWRInfinite`(既存) | ニュース・通知の無限スクロール取得 |
 | Frontend Data | SWR `useSWRInfinite`(既存) | ニュース・通知の無限スクロール取得 |
 | Frontend State | React `useState`(既存パターン) | フィルタタブ・未読トグルのローカル state |
 | Frontend State | React `useState`(既存パターン) | フィルタタブ・未読トグルのローカル state |
@@ -94,7 +94,7 @@ sequenceDiagram
   participant DB as MongoDB
   participant DB as MongoDB
 
 
   Cron->>Cron: getCronSchedule() = '0 0 * * *'(midnight 起動)
   Cron->>Cron: getCronSchedule() = '0 0 * * *'(midnight 起動)
-  Cron->>Cron: NEWS_FEED_URL 未設定? → スキップ
+  Cron->>Cron: configManager.getConfig('news:isDeliveryEnabled') が false? → スキップ
   Cron->>Cron: randomSleep(0–5 時間)でリクエスト時刻を分散
   Cron->>Cron: randomSleep(0–5 時間)でリクエスト時刻を分散
   Cron->>Feed: HTTP GET feed.json
   Cron->>Feed: HTTP GET feed.json
   alt 取得失敗
   alt 取得失敗
@@ -170,11 +170,13 @@ sequenceDiagram
 
 
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
 |---|---|---|---|---|
 |---|---|---|---|---|
-| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7 | CronService (P0), NewsService (P0) |
+| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7, 9.5, 9.6 | CronService (P0), NewsService (P0), configManager (P0) |
 | NewsItem Model | Server / Data | ニュースアイテムの永続化 | 2.1–2.4 | MongoDB (P0) |
 | NewsItem Model | Server / Data | ニュースアイテムの永続化 | 2.1–2.4 | MongoDB (P0) |
 | NewsReadStatus Model | Server / Data | ユーザー既読状態の永続化 | 3.1–3.3 | MongoDB (P0) |
 | NewsReadStatus Model | Server / Data | ユーザー既読状態の永続化 | 3.1–3.3 | MongoDB (P0) |
 | NewsService | Server / Domain | ニュース一覧・既読管理のビジネスロジック | 3.4–3.5, 4.1–4.2 | NewsItem Model (P0), NewsReadStatus Model (P0) |
 | NewsService | Server / Domain | ニュース一覧・既読管理のビジネスロジック | 3.4–3.5, 4.1–4.2 | NewsItem Model (P0), NewsReadStatus Model (P0) |
 | News API | Server / API | HTTP エンドポイント提供 | 3.1–3.5, 4.1–4.2 | NewsService (P0) |
 | News API | Server / API | HTTP エンドポイント提供 | 3.1–3.5, 4.1–4.2 | NewsService (P0) |
+| News Delivery Config | Server / Config | 配信フラグ `news:isDeliveryEnabled` の登録(DB 主体、defaultValue: true) | 9.1, 9.2 | configManager (P0) |
+| App Settings UI(拡張) | Client / Admin | `/admin/app` UI から配信フラグを切り替える | 9.3, 9.4 | News Delivery Config (P0), 既存 `app-settings` API (P0) |
 
 
 ---
 ---
 
 
@@ -187,7 +189,8 @@ sequenceDiagram
 
 
 **Responsibilities & Constraints**
 **Responsibilities & Constraints**
 - 毎日 0 時に発火し、ランダムスリープで実取得時刻を 0–5 時に分散させる(cron 起動 `'0 0 * * *'` + `randomSleep(0–5h)`)
 - 毎日 0 時に発火し、ランダムスリープで実取得時刻を 0–5 時に分散させる(cron 起動 `'0 0 * * *'` + `randomSleep(0–5h)`)
-- `NEWS_FEED_URL` 未設定時はスキップ(エラーなし)
+- **配信フラグ判定**:cron 発火ごとに `configManager.getConfig('news:isDeliveryEnabled')` を読み、`false` ならフィード取得をスキップ(再起動不要、次回 tick から即時反映)
+- **配信元 URL はコードにハードコード**(`https://growilabs.github.io/growi-news-feed/feed.json`)。env による上書き経路は持たず、ユーザー(admin 含む)・運用者ともに変更不可
 - 取得失敗時は既存 DB データを維持
 - 取得失敗時は既存 DB データを維持
 - `growiVersionRegExps` の照合はここで実施(DB には合致アイテムのみ保存)
 - `growiVersionRegExps` の照合はここで実施(DB には合致アイテムのみ保存)
 
 
@@ -197,13 +200,13 @@ sequenceDiagram
 **Dependencies**
 **Dependencies**
 - Inbound: node-cron — スケジュール実行(P0)
 - Inbound: node-cron — スケジュール実行(P0)
 - Outbound: NewsService — upsert/delete(P0)
 - Outbound: NewsService — upsert/delete(P0)
-- External: `NEWS_FEED_URL` の HTTP エンドポイント — feed.json 取得(P0)
+- External: 弊社管理の HTTP エンドポイント(コードに内蔵された URL) — feed.json 取得(P0)
 
 
 **Contracts**: Batch [x]
 **Contracts**: Batch [x]
 
 
 ##### Batch / Job Contract
 ##### Batch / Job Contract
 - Trigger: `node-cron` スケジュール `'0 0 * * *'`(実取得は randomSleep を経て 0–5 時に分散)
 - Trigger: `node-cron` スケジュール `'0 0 * * *'`(実取得は randomSleep を経て 0–5 時に分散)
-- Input: `NEWS_FEED_URL` 環境変数、GROWI バージョン文字列
+- Input: GROWI バージョン文字列(配信元 URL はコードに内蔵)
 - Output: MongoDB の NewsItem コレクションを最新フィードと同期
 - Output: MongoDB の NewsItem コレクションを最新フィードと同期
 - Idempotency: `externalId` ユニークインデックスにより冪等。再実行しても重複なし
 - Idempotency: `externalId` ユニークインデックスにより冪等。再実行しても重複なし
 
 
@@ -219,7 +222,7 @@ const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;  // 5 hours
 
 
 **Implementation Notes**
 **Implementation Notes**
 - Integration: `server/service/cron.ts` の `CronService` を継承。`startCron()` をアプリ起動時に呼ぶ
 - Integration: `server/service/cron.ts` の `CronService` を継承。`startCron()` をアプリ起動時に呼ぶ
-- Validation: `NEWS_FEED_URL` の URL 検証は以下のルールで行う。`https://` で始まる URL は常に許可。`http://localhost` または `http://127.0.0.1` で始まる URL はローカル開発用として許可。それ以外の `http://` は拒否する。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
+- Validation: 配信元 URL はコードにハードコードされており、ランタイムの URL 検証は不要(外部入力経路がない)。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
 - Risks: フィード取得タイムアウト(10秒推奨)。外部依存のため失敗を前提に設計する
 - Risks: フィード取得タイムアウト(10秒推奨)。外部依存のため失敗を前提に設計する
 
 
 ---
 ---
@@ -391,6 +394,49 @@ GROWI の scope 階層は以下の意味論で運用する:
 
 
 ---
 ---
 
 
+#### News Delivery Config
+
+| Field | Detail |
+|---|---|
+| Intent | `news:isDeliveryEnabled` を configManager に登録し、cron/API/UI から共通で参照できるようにする |
+| Requirements | 9.1, 9.2 |
+
+**Responsibilities & Constraints**
+- `apps/app/src/server/service/config-manager/config-definition.ts` に CONFIG_KEYS と `defineConfig` の 2 箇所を追加
+- `defineConfig` パターンを踏襲しつつ、**`envVarName` を意図的に持たせない**(`defaultValue: true` のみ)。これにより env からの上書きを禁じ、admin UI 経由の DB 操作のみが ON/OFF を変えられる経路となる
+- `defaultValue: true` をコードに内蔵 → DB に値が無い状態で全顧客が ON
+- 値の優先順は configManager の既存仕様(DB > env > defaultValue)に従う
+
+**Dependencies**
+- Inbound: NewsCronService, App Settings UI
+- Outbound: configManager(既存)
+
+**Implementation Notes**
+- env 変数として一切暴露しないため `/admin` 環境変数一覧には決して現れない(DB 単独運用)
+- 開発時に強制的に値を変更したい場合は、ローカルで DB レコードを直接書き換えるか、コードを一時編集する
+- 設定変更時は configManager の `updateConfigs` がメモリキャッシュ更新と pubsub 通知(multi-pod 反映)を行う
+
+---
+
+#### App Settings UI(拡張)
+
+| Field | Detail |
+|---|---|
+| Intent | `/admin/app` 画面に「ニュース配信」ON/OFF トグルを追加する |
+| Requirements | 9.3, 9.4 |
+
+**Responsibilities & Constraints**
+- 既存 `app-settings` 画面・API(`PUT /apiv3/app-setting`)に項目を追加するパターンを踏襲
+- 認可:`accessTokenParser([SCOPE.WRITE.ADMIN.APP])` + `adminRequired` で admin のみに制限
+- トグル値の永続化先:`configManager.updateConfigs({ 'news:isDeliveryEnabled': boolean })`
+- UI 文言は i18n 対応(`ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`)
+
+**Dependencies**
+- Inbound: 管理画面(admin user)
+- Outbound: News Delivery Config(configManager 経由)
+
+---
+
 ### クライアントサイド
 ### クライアントサイド
 
 
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
@@ -654,7 +700,7 @@ interface INewsItemWithReadStatus {
 | カテゴリ | エラー | 対応 |
 | カテゴリ | エラー | 対応 |
 |---|---|---|
 |---|---|---|
 | Cron / External | フィード取得失敗(ネットワーク、タイムアウト) | `logger.error` + 既存 DB データ維持。次回 cron で再試行 |
 | Cron / External | フィード取得失敗(ネットワーク、タイムアウト) | `logger.error` + 既存 DB データ維持。次回 cron で再試行 |
-| Cron / Config | `NEWS_FEED_URL` 未設定 | スキップ(ログなし)。設定されるまで無害に動作 |
+| Cron / Config | `news:isDeliveryEnabled` が `false` | スキップ(debug ログ)。admin が再度 ON にするまで無害に停止 |
 | Cron / Validation | `growiVersionRegExps` に不正 regex | try-catch で該当アイテムをスキップ、`logger.warn` |
 | Cron / Validation | `growiVersionRegExps` に不正 regex | try-catch で該当アイテムをスキップ、`logger.warn` |
 | API / Auth | 未認証リクエスト | 401(`loginRequiredStrictly` が処理) |
 | API / Auth | 未認証リクエスト | 401(`loginRequiredStrictly` が処理) |
 | API / Validation | 不正な `newsItemId` フォーマット | 400(`mongoose.isValidObjectId()` チェック) |
 | API / Validation | 不正な `newsItemId` フォーマット | 400(`mongoose.isValidObjectId()` チェック) |
@@ -671,7 +717,7 @@ interface INewsItemWithReadStatus {
 
 
 ### Unit Tests
 ### Unit Tests
 
 
-- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`NEWS_FEED_URL` 未設定 → スキップ
+- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`news:isDeliveryEnabled` が `false` → スキップ
 - `NewsCronService.executeJob()`: `growiVersionRegExps` 一致 → 保存、不一致 → 除外
 - `NewsCronService.executeJob()`: `growiVersionRegExps` 一致 → 保存、不一致 → 除外
 - `NewsService.listForUser()`: `targetRoles` フィルタ(admin のみ、general 除外)
 - `NewsService.listForUser()`: `targetRoles` フィルタ(admin のみ、general 除外)
 - `NewsService.listForUser()`: `onlyUnread=true` で未読のみ返す
 - `NewsService.listForUser()`: `onlyUnread=true` で未読のみ返す
@@ -698,7 +744,7 @@ interface INewsItemWithReadStatus {
 - すべての `/apiv3/news/*` エンドポイントに `loginRequiredStrictly` を適用する
 - すべての `/apiv3/news/*` エンドポイントに `loginRequiredStrictly` を適用する
 - アクセストークン用 scope は **`features.in_app_notification`** を使用する(read / write)。設定 CRUD 用の `user_settings.in_app_notification` とはセマンティクスが異なるため流用しない。アクセストークン発行時にユーザーが意図した粒度でアクセスを許可できるようにする
 - アクセストークン用 scope は **`features.in_app_notification`** を使用する(read / write)。設定 CRUD 用の `user_settings.in_app_notification` とはセマンティクスが異なるため流用しない。アクセストークン発行時にユーザーが意図した粒度でアクセスを許可できるようにする
 - `conditions.targetRoles` のフィルタリングはサーバーサイドの `NewsService.listForUser()` で強制する。クライアントから `targetRoles` パラメータを受け付けない
 - `conditions.targetRoles` のフィルタリングはサーバーサイドの `NewsService.listForUser()` で強制する。クライアントから `targetRoles` パラメータを受け付けない
-- `NEWS_FEED_URL` は `https://` で始まる URL は常に許可。`http://localhost` または `http://127.0.0.1` で始まる URL はローカル開発用として許可。それ以外の `http://` は拒否する
+- 配信元 URL はコードにハードコードされており、ランタイムで変更できる経路を持たない。env 変数による上書きもサポートしない
 - フィードから取得したデータはそのまま DB に保存し、クライアントへのレスポンス時に Mongoose スキーマで型安全に扱う
 - フィードから取得したデータはそのまま DB に保存し、クライアントへのレスポンス時に Mongoose スキーマで型安全に扱う
 
 
 ## Performance & Scalability
 ## Performance & Scalability

+ 18 - 2
.kiro/specs/news-inappnotification/requirements.md

@@ -14,12 +14,12 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 
 
 #### Acceptance Criteria
 #### Acceptance Criteria
 
 
-1. When cron スケジュールの実行時刻に達した場合, the News Cron Service shall 設定された URL から JSON フィードを HTTP GET で取得する
+1. When cron スケジュールの実行時刻に達した場合, the News Cron Service shall コードに内蔵された配信元 URL から JSON フィードを HTTP GET で取得する
 2. When フィードの取得に成功した場合, the News Cron Service shall 取得したニュースアイテムをローカル MongoDB に upsert(`externalId` で重複排除)する
 2. When フィードの取得に成功した場合, the News Cron Service shall 取得したニュースアイテムをローカル MongoDB に upsert(`externalId` で重複排除)する
 3. When フィードに含まれなくなったニュースアイテムがある場合, the News Cron Service shall 該当アイテムをローカル DB から削除する
 3. When フィードに含まれなくなったニュースアイテムがある場合, the News Cron Service shall 該当アイテムをローカル DB から削除する
 4. When 複数の GROWI インスタンスが同時に取得を試みる場合, the News Cron Service shall ランダムスリープにより配信元へのリクエストを時間分散する
 4. When 複数の GROWI インスタンスが同時に取得を試みる場合, the News Cron Service shall ランダムスリープにより配信元へのリクエストを時間分散する
 5. If フィードの取得に失敗した場合, then the News Cron Service shall エラーをログに記録し、既存のキャッシュデータを維持する
 5. If フィードの取得に失敗した場合, then the News Cron Service shall エラーをログに記録し、既存のキャッシュデータを維持する
-6. Where `NEWS_FEED_URL` が未設定または空の場合, the News Cron Service shall フィード取得をスキップしエラーなく動作する
+6. Where ニュース配信が無効化されている場合(`news:isDeliveryEnabled` が `false` の場合), the News Cron Service shall フィード取得をスキップしエラーなく動作する
 7. When ニュースアイテムに `growiVersionRegExps` 条件が設定されている場合, the News Cron Service shall 現在の GROWI バージョンと照合し、一致しないアイテムを除外する
 7. When ニュースアイテムに `growiVersionRegExps` 条件が設定されている場合, the News Cron Service shall 現在の GROWI バージョンと照合し、一致しないアイテムを除外する
 
 
 ### Requirement 2: ニュースアイテムのローカルキャッシュ
 ### Requirement 2: ニュースアイテムのローカルキャッシュ
@@ -106,3 +106,19 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 2. If ブラウザの言語に対応するテキストが存在しない場合, then the NewsItem コンポーネント shall `ja_JP` → `en_US` の順にフォールバックする
 2. If ブラウザの言語に対応するテキストが存在しない場合, then the NewsItem コンポーネント shall `ja_JP` → `en_US` の順にフォールバックする
 3. The UI ラベル(「ニュース」「ニュースはありません。」等)shall `ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR` の i18n ロケールファイルで提供する
 3. The UI ラベル(「ニュース」「ニュースはありません。」等)shall `ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR` の i18n ロケールファイルで提供する
 4. The フィルタボタン用ラベル(「通知」「お知らせ」)shall 全対応言語のロケールファイルに追加する
 4. The フィルタボタン用ラベル(「通知」「お知らせ」)shall 全対応言語のロケールファイルに追加する
+
+### Requirement 9: ニュース配信のオンオフ切替
+
+**Objective:** As a GROWI 管理者, I want ニュース配信のオンオフを管理画面から切り替えたい, so that 環境変数の編集や再起動なしにインスタンス単位で配信を停止/再開できる
+
+**Note:** 配信フラグは DB(`Config` コレクション)で管理し、admin が `/admin/app` UI から操作する。configManager + `defineConfig` + `defaultValue` の既存パターンを踏襲するが、**env からの上書き経路は意図的に持たない**(`envVarName` を設定しない)。これにより「インフラ側の env 注入」を不要にし、ニュース配信の意思は **DB のみ**で表現される。`defaultValue: true` をコードに内蔵することで、新規・既存インスタンスとも DB に値が無い状態で**デフォルト ON**が成立する。配信元 URL もコードにハードコードされており、ユーザー(admin 含む)・運用者ともに変更できない。pod 再起動は不要。
+
+#### Acceptance Criteria
+
+1. The configuration `news:isDeliveryEnabled` shall `defaultValue: true` を持ち、DB に値が無い場合は ON として扱われる
+2. The 設定値 shall configManager 経由で読み出される。env からの上書きは意図的にサポートしない(`envVarName` を設定しない)ため、優先順位は **DB > defaultValue** のみとなる
+3. When 管理者が `/admin/app` の UI からトグルを切り替えた場合, the GROWI shall `Config` コレクションの該当キーを更新し、再起動なしで設定値を反映する
+4. The 切替操作 shall admin 権限を持つユーザーのみに許可される
+5. When `news:isDeliveryEnabled` が `false` の場合, the News Cron Service shall 次回 cron 発火時にフィード取得をスキップする(既に取得済みの DB キャッシュは維持する)
+6. When `news:isDeliveryEnabled` が `true` に戻された場合, the News Cron Service shall 次回 cron 発火時に通常どおりフィード取得を再開する
+7. The 設定値 shall 環境変数として暴露されないため、`/admin` トップの「サーバー側で設定されている環境変数一覧」には決して現れない

+ 7 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "Your upgrade has been successfully completed! Exit maintenance mode and GROWI can be used.",
     "migration_succeeded": "Your upgrade has been successfully completed! Exit maintenance mode and GROWI can be used.",
     "migration_failed": "Upgrade failed. Please refer to the GROWI docs for information on what to do in the event of failure."
     "migration_failed": "Upgrade failed. Please refer to the GROWI docs for information on what to do in the event of failure."
   },
   },
+  "news_delivery": {
+    "section_title": "News delivery",
+    "label": "News delivery",
+    "enable": "Enable news delivery",
+    "description": "Controls whether the cron job pulls the news feed and updates the local cache. Existing cached items remain visible while delivery is disabled.",
+    "update_succeeded": "News delivery setting updated"
+  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
     "maintenance_mode": "Maintenance Mode",
     "under_maintenance_mode": "Under Maintenance Mode",
     "under_maintenance_mode": "Under Maintenance Mode",

+ 7 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "Conversion réussie! Le mode maintenance peut être désactivée et GROWI utilisé.",
     "migration_succeeded": "Conversion réussie! Le mode maintenance peut être désactivée et GROWI utilisé.",
     "migration_failed": "Conversion échouée. Lire la documentation GROWI pour des informations supplémentaires."
     "migration_failed": "Conversion échouée. Lire la documentation GROWI pour des informations supplémentaires."
   },
   },
+  "news_delivery": {
+    "section_title": "Diffusion des actualités",
+    "label": "Diffusion des actualités",
+    "enable": "Activer la diffusion des actualités",
+    "description": "Contrôle si la tâche cron récupère le flux d'actualités et met à jour le cache local. Les actualités déjà en cache restent visibles lorsque la diffusion est désactivée.",
+    "update_succeeded": "Paramètre de diffusion des actualités mis à jour"
+  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "Mode maintenance",
     "maintenance_mode": "Mode maintenance",
     "under_maintenance_mode": "Mode maintenance activé",
     "under_maintenance_mode": "Mode maintenance activé",

+ 7 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -341,6 +341,13 @@
     "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
     "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
     "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
     "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
   },
   },
+  "news_delivery": {
+    "section_title": "ニュース配信",
+    "label": "ニュース配信",
+    "enable": "ニュース配信を有効にする",
+    "description": "外部フィードからニュースを取得してローカルにキャッシュする cron ジョブの動作を制御します。無効にしてもキャッシュ済みのニュースは引き続き表示されます。",
+    "update_succeeded": "ニュース配信設定を更新しました"
+  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
     "maintenance_mode": "メンテナンスモード",
     "under_maintenance_mode": "メンテナンスモード中",
     "under_maintenance_mode": "メンテナンスモード中",

+ 7 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "업그레이드가 성공적으로 완료되었습니다! 유지 보수 모드를 종료하면 GROWI를 사용할 수 있습니다.",
     "migration_succeeded": "업그레이드가 성공적으로 완료되었습니다! 유지 보수 모드를 종료하면 GROWI를 사용할 수 있습니다.",
     "migration_failed": "업그레이드 실패. 실패 시 수행할 작업에 대한 정보는 GROWI 문서를 참조하십시오."
     "migration_failed": "업그레이드 실패. 실패 시 수행할 작업에 대한 정보는 GROWI 문서를 참조하십시오."
   },
   },
+  "news_delivery": {
+    "section_title": "뉴스 배포",
+    "label": "뉴스 배포",
+    "enable": "뉴스 배포 활성화",
+    "description": "외부 피드에서 뉴스를 가져와 로컬 캐시를 업데이트하는 cron 작업의 동작을 제어합니다. 비활성화해도 캐시된 뉴스는 계속 표시됩니다.",
+    "update_succeeded": "뉴스 배포 설정이 업데이트되었습니다"
+  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "유지 보수 모드",
     "maintenance_mode": "유지 보수 모드",
     "under_maintenance_mode": "유지 보수 모드 중",
     "under_maintenance_mode": "유지 보수 모드 중",

+ 7 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -341,6 +341,13 @@
     "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
     "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
     "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
     "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
   },
   },
+  "news_delivery": {
+    "section_title": "新闻推送",
+    "label": "新闻推送",
+    "enable": "启用新闻推送",
+    "description": "控制 cron 任务是否拉取新闻订阅源并更新本地缓存。停用期间,已缓存的新闻仍可显示。",
+    "update_succeeded": "新闻推送设置已更新"
+  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
     "maintenance_mode": "维护模式",
     "under_maintenance_mode": "在维护模式下",
     "under_maintenance_mode": "在维护模式下",

+ 12 - 0
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'next-i18next';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
+import { NewsDeliverySetting } from '~/features/news/client/components/admin/NewsDeliverySetting';
 import { useIsMaintenanceMode } from '~/states/global';
 import { useIsMaintenanceMode } from '~/states/global';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
@@ -133,6 +134,17 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
         </div>
       </div>
       </div>
 
 
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header" id="news-delivery">
+            {t('admin:news_delivery.section_title', {
+              defaultValue: 'News delivery',
+            })}
+          </h2>
+          <NewsDeliverySetting />
+        </div>
+      </div>
+
       <div className="row">
       <div className="row">
         <div className="col-lg-12">
         <div className="col-lg-12">
           <h2 className="admin-setting-header" id="maintenance-mode">
           <h2 className="admin-setting-header" id="maintenance-mode">

+ 77 - 0
apps/app/src/features/news/client/components/admin/NewsDeliverySetting.tsx

@@ -0,0 +1,77 @@
+import type { FC } from 'react';
+import { useCallback } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import loggerFactory from '~/utils/logger';
+
+import {
+  useSWRxNewsDeliverySetting,
+  useUpdateNewsDeliverySetting,
+} from '../../services/news-delivery-setting';
+
+const logger = loggerFactory('growi:feature:news:admin:NewsDeliverySetting');
+
+export const NewsDeliverySetting: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data, isLoading } = useSWRxNewsDeliverySetting();
+  const updateDeliverySetting = useUpdateNewsDeliverySetting();
+
+  const isEnabled = data?.isDeliveryEnabled ?? true;
+
+  const onChange = useCallback(
+    async (event: React.ChangeEvent<HTMLInputElement>) => {
+      const next = event.target.checked;
+      try {
+        await updateDeliverySetting(next);
+        toastSuccess(
+          t('admin:news_delivery.update_succeeded', {
+            defaultValue: 'News delivery setting updated',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [updateDeliverySetting, t],
+  );
+
+  return (
+    <div className="row mb-3">
+      <label
+        className="text-start text-md-end col-md-3 col-form-label"
+        htmlFor="checkbox-news-is-delivery-enabled"
+      >
+        {t('admin:news_delivery.label', { defaultValue: 'News delivery' })}
+      </label>
+      <div className="col-md-6 py-2">
+        <div className="form-check form-switch">
+          <input
+            type="checkbox"
+            id="checkbox-news-is-delivery-enabled"
+            className="form-check-input"
+            checked={isEnabled}
+            disabled={isLoading}
+            onChange={onChange}
+          />
+          <label
+            className="form-label form-check-label"
+            htmlFor="checkbox-news-is-delivery-enabled"
+          >
+            {t('admin:news_delivery.enable', {
+              defaultValue: 'Enable news delivery',
+            })}
+          </label>
+        </div>
+        <p className="form-text text-muted">
+          {t('admin:news_delivery.description', {
+            defaultValue:
+              'Controls whether the cron job pulls the news feed and updates the local cache. Existing cached items remain visible while delivery is disabled.',
+          })}
+        </p>
+      </div>
+    </div>
+  );
+};

+ 42 - 0
apps/app/src/features/news/client/services/news-delivery-setting.ts

@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+import useSWR, { type SWRResponse } from 'swr';
+
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+
+const ENDPOINT = '/news/admin/delivery-setting';
+
+type DeliverySettingResponse = {
+  isDeliveryEnabled: boolean;
+};
+
+/**
+ * Fetch the current value of `news:isDeliveryEnabled` (admin only).
+ */
+export const useSWRxNewsDeliverySetting = (): SWRResponse<
+  DeliverySettingResponse,
+  Error
+> => {
+  return useSWR(
+    ENDPOINT,
+    async (endpoint) =>
+      (await apiv3Get<DeliverySettingResponse>(endpoint)).data,
+  );
+};
+
+/**
+ * Returns a callback that updates the news delivery flag on the server and
+ * revalidates the SWR cache so the UI reflects the new value.
+ */
+export const useUpdateNewsDeliverySetting = (): ((
+  flag: boolean,
+) => Promise<void>) => {
+  const { mutate } = useSWRxNewsDeliverySetting();
+
+  return useCallback(
+    async (flag: boolean) => {
+      await apiv3Post(ENDPOINT, { flag });
+      await mutate({ isDeliveryEnabled: flag }, { revalidate: false });
+    },
+    [mutate],
+  );
+};

+ 70 - 0
apps/app/src/features/news/server/routes/news.spec.ts

@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => {
   const getUnreadCount = vi.fn();
   const getUnreadCount = vi.fn();
   const markRead = vi.fn();
   const markRead = vi.fn();
   const markAllRead = vi.fn();
   const markAllRead = vi.fn();
+  const getConfig = vi.fn<(key: string) => unknown>();
+  const updateConfigs = vi.fn();
   return {
   return {
     NewsService: vi.fn(() => ({
     NewsService: vi.fn(() => ({
       listForUser,
       listForUser,
@@ -20,6 +22,8 @@ const mocks = vi.hoisted(() => {
     getUnreadCount,
     getUnreadCount,
     markRead,
     markRead,
     markAllRead,
     markAllRead,
+    getConfig,
+    updateConfigs,
   };
   };
 });
 });
 
 
@@ -27,6 +31,13 @@ vi.mock('../services/news-service', () => ({
   NewsService: mocks.NewsService,
   NewsService: mocks.NewsService,
 }));
 }));
 
 
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: mocks.getConfig,
+    updateConfigs: mocks.updateConfigs,
+  },
+}));
+
 // Middleware mocks - bypass auth
 // Middleware mocks - bypass auth
 vi.mock('~/server/middlewares/access-token-parser', () => ({
 vi.mock('~/server/middlewares/access-token-parser', () => ({
   accessTokenParser: () => (_req: unknown, _res: unknown, next: () => void) =>
   accessTokenParser: () => (_req: unknown, _res: unknown, next: () => void) =>
@@ -286,4 +297,63 @@ describe('News API routes', () => {
       expect(mocks.markAllRead).toHaveBeenCalled();
       expect(mocks.markAllRead).toHaveBeenCalled();
     });
     });
   });
   });
+
+  describe('GET /apiv3/news/admin/delivery-setting', () => {
+    test('should return current value from configManager', async () => {
+      mocks.getConfig.mockReturnValue(true);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: true });
+      expect(mocks.getConfig).toHaveBeenCalledWith('news:isDeliveryEnabled');
+    });
+
+    test('should reflect false when delivery is disabled', async () => {
+      mocks.getConfig.mockReturnValue(false);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+    });
+  });
+
+  describe('POST /apiv3/news/admin/delivery-setting', () => {
+    test('should update delivery setting via configManager', async () => {
+      mocks.updateConfigs.mockResolvedValue(undefined);
+
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: false });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+      expect(mocks.updateConfigs).toHaveBeenCalledWith({
+        'news:isDeliveryEnabled': false,
+      });
+    });
+
+    test('should return 400 when flag is not boolean', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: 'true' });
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+
+    test('should return 400 when flag is missing', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({});
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+  });
 });
 });

+ 55 - 0
apps/app/src/features/news/server/routes/news.ts

@@ -6,7 +6,9 @@ import mongoose from 'mongoose';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import loginRequiredFactory from '~/server/middlewares/login-required';
 import loginRequiredFactory from '~/server/middlewares/login-required';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { NewsService } from '../services/news-service';
 import { NewsService } from '../services/news-service';
@@ -55,6 +57,10 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
     crowi != null
     crowi != null
       ? loginRequiredFactory(crowi)
       ? loginRequiredFactory(crowi)
       : (_req: unknown, _res: unknown, next: () => void) => next();
       : (_req: unknown, _res: unknown, next: () => void) => next();
+  const adminRequired =
+    crowi != null
+      ? adminRequiredFactory(crowi)
+      : (_req: unknown, _res: unknown, next: () => void) => next();
 
 
   /**
   /**
    * GET /news/list
    * GET /news/list
@@ -180,6 +186,55 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
     },
     },
   );
   );
 
 
+  /**
+   * GET /news/admin/delivery-setting
+   * Returns the current value of `news:isDeliveryEnabled` (admin only)
+   */
+  router.get(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.READ.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    (_req, res) => {
+      try {
+        const isDeliveryEnabled = configManager.getConfig(
+          'news:isDeliveryEnabled',
+        );
+        return res.json({ isDeliveryEnabled });
+      } catch (err) {
+        logger.error('GET /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/admin/delivery-setting
+   * Updates `news:isDeliveryEnabled` (admin only). Body: `{ flag: boolean }`.
+   * The new value is persisted to the `Config` collection and reflected on
+   * the next cron tick without a restart.
+   */
+  router.post(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const { flag } = req.body;
+        if (typeof flag !== 'boolean') {
+          return res.status(400).json({ error: '`flag` must be a boolean' });
+        }
+
+        await configManager.updateConfigs({ 'news:isDeliveryEnabled': flag });
+        return res.json({ isDeliveryEnabled: flag });
+      } catch (err) {
+        logger.error('POST /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
   return router;
   return router;
 };
 };
 
 

+ 26 - 49
apps/app/src/features/news/server/services/news-cron-service.spec.ts

@@ -4,6 +4,12 @@ const mocks = vi.hoisted(() => {
   const deleteItemsNotInFeed = vi.fn();
   const deleteItemsNotInFeed = vi.fn();
   const mockFetch = vi.fn();
   const mockFetch = vi.fn();
   const getGrowiVersion = vi.fn(() => '7.5.0');
   const getGrowiVersion = vi.fn(() => '7.5.0');
+  // Default delivery to enabled so existing tests behave as before.
+  // Tests that need OFF state can override via mocks.getConfig.mockImplementationOnce.
+  const getConfig = vi.fn<(key: string) => unknown>((key: string) => {
+    if (key === 'news:isDeliveryEnabled') return true;
+    return undefined;
+  });
 
 
   return {
   return {
     NewsService: vi.fn(() => ({
     NewsService: vi.fn(() => ({
@@ -14,6 +20,7 @@ const mocks = vi.hoisted(() => {
     deleteItemsNotInFeed,
     deleteItemsNotInFeed,
     mockFetch,
     mockFetch,
     getGrowiVersion,
     getGrowiVersion,
+    getConfig,
   };
   };
 });
 });
 
 
@@ -25,6 +32,12 @@ vi.mock('~/utils/growi-version', () => ({
   getGrowiVersion: mocks.getGrowiVersion,
   getGrowiVersion: mocks.getGrowiVersion,
 }));
 }));
 
 
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: mocks.getConfig,
+  },
+}));
+
 // Mock global fetch
 // Mock global fetch
 vi.stubGlobal('fetch', mocks.mockFetch);
 vi.stubGlobal('fetch', mocks.mockFetch);
 
 
@@ -62,7 +75,6 @@ const mockResponse = (
 
 
 describe('NewsCronService', () => {
 describe('NewsCronService', () => {
   let service: NewsCronService;
   let service: NewsCronService;
-  const originalEnv = process.env.NEWS_FEED_URL;
 
 
   beforeEach(() => {
   beforeEach(() => {
     service = new NewsCronService();
     service = new NewsCronService();
@@ -71,10 +83,6 @@ describe('NewsCronService', () => {
     vi.spyOn(Math, 'random').mockReturnValue(0);
     vi.spyOn(Math, 'random').mockReturnValue(0);
   });
   });
 
 
-  afterEach(() => {
-    process.env.NEWS_FEED_URL = originalEnv;
-  });
-
   describe('getCronSchedule', () => {
   describe('getCronSchedule', () => {
     test('should return daily schedule at midnight', () => {
     test('should return daily schedule at midnight', () => {
       expect(service.getCronSchedule()).toBe('0 0 * * *');
       expect(service.getCronSchedule()).toBe('0 0 * * *');
@@ -82,63 +90,41 @@ describe('NewsCronService', () => {
   });
   });
 
 
   describe('executeJob', () => {
   describe('executeJob', () => {
-    test('should skip when NEWS_FEED_URL is not set', async () => {
-      delete process.env.NEWS_FEED_URL;
+    test('should skip when news:isDeliveryEnabled is false', async () => {
+      mocks.getConfig.mockImplementationOnce((key: string) =>
+        key === 'news:isDeliveryEnabled' ? false : undefined,
+      );
 
 
       await service.executeJob();
       await service.executeJob();
 
 
+      // Delivery flag short-circuits before any network call or DB write
       expect(mocks.mockFetch).not.toHaveBeenCalled();
       expect(mocks.mockFetch).not.toHaveBeenCalled();
       expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
       expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
     });
     });
 
 
-    test('should skip when NEWS_FEED_URL is empty string', async () => {
-      process.env.NEWS_FEED_URL = '';
-
-      await service.executeJob();
-
-      expect(mocks.mockFetch).not.toHaveBeenCalled();
-    });
-
-    test('should skip when NEWS_FEED_URL uses non-allowed http', async () => {
-      process.env.NEWS_FEED_URL = 'http://example.com/feed.json';
+    test('should run when news:isDeliveryEnabled is true (default)', async () => {
+      mocks.mockFetch.mockResolvedValue(
+        mockResponse({ version: '1.0', items: [] }),
+      );
 
 
       await service.executeJob();
       await service.executeJob();
 
 
-      expect(mocks.mockFetch).not.toHaveBeenCalled();
+      expect(mocks.getConfig).toHaveBeenCalledWith('news:isDeliveryEnabled');
+      expect(mocks.mockFetch).toHaveBeenCalled();
     });
     });
 
 
-    test('should allow https:// URLs', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+    test('should fetch from the hardcoded vendor URL', async () => {
       mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
       mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
 
 
       await service.executeJob();
       await service.executeJob();
 
 
       expect(mocks.mockFetch).toHaveBeenCalledWith(
       expect(mocks.mockFetch).toHaveBeenCalledWith(
-        'https://example.com/feed.json',
+        'https://growilabs.github.io/growi-news-feed/feed.json',
         expect.any(Object),
         expect.any(Object),
       );
       );
     });
     });
 
 
-    test('should allow http://localhost URLs', async () => {
-      process.env.NEWS_FEED_URL = 'http://localhost:8099/feed.json';
-      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
-
-      await service.executeJob();
-
-      expect(mocks.mockFetch).toHaveBeenCalled();
-    });
-
-    test('should allow http://127.0.0.1 URLs', async () => {
-      process.env.NEWS_FEED_URL = 'http://127.0.0.1:8099/feed.json';
-      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
-
-      await service.executeJob();
-
-      expect(mocks.mockFetch).toHaveBeenCalled();
-    });
-
     test('should upsert items on successful fetch', async () => {
     test('should upsert items on successful fetch', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
       mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
 
 
       await service.executeJob();
       await service.executeJob();
@@ -151,7 +137,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should NOT update DB when fetch fails', async () => {
     test('should NOT update DB when fetch fails', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       mocks.mockFetch.mockResolvedValue({ ok: false, status: 500 });
       mocks.mockFetch.mockResolvedValue({ ok: false, status: 500 });
 
 
       await service.executeJob();
       await service.executeJob();
@@ -161,7 +146,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should NOT update DB when fetch throws', async () => {
     test('should NOT update DB when fetch throws', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       mocks.mockFetch.mockRejectedValue(new Error('Network error'));
       mocks.mockFetch.mockRejectedValue(new Error('Network error'));
 
 
       await expect(service.executeJob()).resolves.not.toThrow();
       await expect(service.executeJob()).resolves.not.toThrow();
@@ -170,7 +154,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should filter items by growiVersionRegExps', async () => {
     test('should filter items by growiVersionRegExps', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       mocks.getGrowiVersion.mockReturnValue('7.5.0');
       mocks.getGrowiVersion.mockReturnValue('7.5.0');
       const feedWithVersionFilter = {
       const feedWithVersionFilter = {
         version: '1.0',
         version: '1.0',
@@ -199,7 +182,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should skip items with invalid growiVersionRegExps', async () => {
     test('should skip items with invalid growiVersionRegExps', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       mocks.getGrowiVersion.mockReturnValue('7.5.0');
       mocks.getGrowiVersion.mockReturnValue('7.5.0');
       const feedWithInvalidRegex = {
       const feedWithInvalidRegex = {
         version: '1.0',
         version: '1.0',
@@ -237,7 +219,6 @@ describe('NewsCronService', () => {
     // cleaned up. The cron must now hand the full set of feed externalIds
     // cleaned up. The cron must now hand the full set of feed externalIds
     // to `deleteItemsNotInFeed`, which uses a $nin filter to remove the rest.
     // to `deleteItemsNotInFeed`, which uses a $nin filter to remove the rest.
     test('should pass every feed externalId to deleteItemsNotInFeed (regression for stale-item bug)', async () => {
     test('should pass every feed externalId to deleteItemsNotInFeed (regression for stale-item bug)', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       const feed = {
       const feed = {
         version: '1.0',
         version: '1.0',
         items: [
         items: [
@@ -277,7 +258,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should skip when response body exceeds size limit (5 MiB)', async () => {
     test('should skip when response body exceeds size limit (5 MiB)', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       // Build a string that exceeds 5 MiB
       // Build a string that exceeds 5 MiB
       const oversizedText = 'x'.repeat(5 * 1024 * 1024 + 1);
       const oversizedText = 'x'.repeat(5 * 1024 * 1024 + 1);
       mocks.mockFetch.mockResolvedValue({
       mocks.mockFetch.mockResolvedValue({
@@ -292,7 +272,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should abort when top-level shape is invalid', async () => {
     test('should abort when top-level shape is invalid', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       // Missing `items` field — top-level schema check fails
       // Missing `items` field — top-level schema check fails
       mocks.mockFetch.mockResolvedValue(mockResponse({ version: '1.0' }));
       mocks.mockFetch.mockResolvedValue(mockResponse({ version: '1.0' }));
 
 
@@ -303,7 +282,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should skip individual invalid items but keep valid ones', async () => {
     test('should skip individual invalid items but keep valid ones', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       const feedWithMixedItems = {
       const feedWithMixedItems = {
         version: '1.0',
         version: '1.0',
         items: [
         items: [
@@ -328,7 +306,6 @@ describe('NewsCronService', () => {
     });
     });
 
 
     test('should skip when response body is not valid JSON', async () => {
     test('should skip when response body is not valid JSON', async () => {
-      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
       mocks.mockFetch.mockResolvedValue({
       mocks.mockFetch.mockResolvedValue({
         ok: true,
         ok: true,
         text: () => Promise.resolve('not-a-json{'),
         text: () => Promise.resolve('not-a-json{'),

+ 12 - 19
apps/app/src/features/news/server/services/news-cron-service.ts

@@ -1,3 +1,4 @@
+import { configManager } from '~/server/service/config-manager';
 import CronService from '~/server/service/cron';
 import CronService from '~/server/service/cron';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -22,14 +23,12 @@ const FETCH_TIMEOUT_MS = 10_000;
 const MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024;
 const MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024;
 
 
 /**
 /**
- * Check if the given URL is allowed for fetching
+ * Vendor-controlled news feed URL. Hardcoded so a fresh deployment delivers
+ * news without any infrastructure-side env injection. Users (incl. admins)
+ * cannot change this; opt-out is performed via the `news:isDeliveryEnabled`
+ * config flag managed in the admin UI.
  */
  */
-const isAllowedUrl = (url: string): boolean => {
-  if (url.startsWith('https://')) return true;
-  if (url.startsWith('http://localhost')) return true;
-  if (url.startsWith('http://127.0.0.1')) return true;
-  return false;
-};
+const FEED_URL = 'https://growilabs.github.io/growi-news-feed/feed.json';
 
 
 /**
 /**
  * Check if the item matches the current GROWI version
  * Check if the item matches the current GROWI version
@@ -67,17 +66,11 @@ export class NewsCronService extends CronService {
   }
   }
 
 
   override async executeJob(): Promise<void> {
   override async executeJob(): Promise<void> {
-    const feedUrl = process.env.NEWS_FEED_URL;
-
-    if (!feedUrl || feedUrl.trim() === '') {
-      logger.debug('NEWS_FEED_URL is not set, skipping news feed sync');
-      return;
-    }
-
-    if (!isAllowedUrl(feedUrl)) {
-      logger.warn(
-        `NEWS_FEED_URL "${feedUrl}" is not allowed. Only https:// and http://localhost or http://127.0.0.1 are permitted.`,
-      );
+    // Read the delivery toggle (DB > defaultValue: true) on every tick so
+    // an admin's UI change takes effect from the next scheduled run, with no
+    // pod restart required (Requirements 9.5, 9.6).
+    if (!configManager.getConfig('news:isDeliveryEnabled')) {
+      logger.debug('News delivery is disabled, skipping news feed sync');
       return;
       return;
     }
     }
 
 
@@ -86,7 +79,7 @@ export class NewsCronService extends CronService {
 
 
     let rawJson: unknown;
     let rawJson: unknown;
     try {
     try {
-      const response = await fetch(feedUrl, {
+      const response = await fetch(FEED_URL, {
         signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
         signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
       });
       });
 
 

+ 8 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -280,6 +280,9 @@ export const CONFIG_KEYS = [
   'otel:anonymizeInBestEffort',
   'otel:anonymizeInBestEffort',
   'otel:serviceInstanceId',
   'otel:serviceInstanceId',
 
 
+  // News Settings
+  'news:isDeliveryEnabled',
+
   // S2S Messaging Pubsub Settings
   // S2S Messaging Pubsub Settings
   's2sMessagingPubsub:serverType',
   's2sMessagingPubsub:serverType',
   's2sMessagingPubsub:nchan:publishPath',
   's2sMessagingPubsub:nchan:publishPath',
@@ -1215,6 +1218,11 @@ export const CONFIG_DEFINITIONS = {
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
 
 
+  // News Settings
+  'news:isDeliveryEnabled': defineConfig<boolean>({
+    defaultValue: true,
+  }),
+
   // S2S Messaging Pubsub Settings
   // S2S Messaging Pubsub Settings
   's2sMessagingPubsub:serverType': defineConfig<string | undefined>({
   's2sMessagingPubsub:serverType': defineConfig<string | undefined>({
     envVarName: 'S2SMSG_PUBSUB_SERVER_TYPE',
     envVarName: 'S2SMSG_PUBSUB_SERVER_TYPE',