diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 56caed5..1ead211 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -70,9 +70,13 @@ jobs: chmod +x castloop-linux-x64 test "$(./castloop-linux-x64 --version)" = "$expected_version" ./castloop-linux-x64 --help | grep -F 'Usage: castloop' - for command in init create-show create-episode; do + for command in init create-show create-episode list-shows list-episodes; do ./castloop-linux-x64 "$command" --help | grep -F "Usage: castloop $command" done + if ./castloop-linux-x64 list-episodes > list-episodes-error.txt 2>&1; then + exit 1 + fi + grep -F 'Usage: castloop list-episodes SHOW_ID' list-episodes-error.txt release: if: github.event_name == 'push' && github.ref_type == 'tag' diff --git a/README.md b/README.md index cded1a5..4f53201 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ Serverless podcast hosting on Cloudflare: one private R2 bucket and one public Worker per service, multiple Shows, managed through a standalone CLI. -**v0.2.0 includes the M6 lifecycle features.** It supports new M6 services and compatible updates of initialized M6 services. It does not convert or adopt v0.1.x services. The older [v0.1.2 binary](https://github.com/simosako/castloop-v2/releases/tag/v0.1.2) has different commands and no lifecycle operations; its historical instructions are in [the Linux smoke guide](docs/linux_smoke_test.md). +**v0.2.1 adds Show and Episode listings to the M6 lifecycle features released in v0.2.0.** It supports new M6 services and compatible updates of initialized M6 services. It does not convert or adopt v0.1.x services. The older [v0.1.2 binary](https://github.com/simosako/castloop-v2/releases/tag/v0.1.2) has different commands and no lifecycle operations; its historical instructions are in [the Linux smoke guide](docs/linux_smoke_test.md). ## Build and install -Download `castloop-linux-x64`, `SHA256SUMS`, `LICENSE`, and `THIRD_PARTY_NOTICES.md` from the [v0.2.0 release](https://github.com/simosako/castloop-v2/releases/tag/v0.2.0). Run `sha256sum --check SHA256SUMS` in the download directory before installing the binary. `castloop --version` must report `0.2.0`. +Download `castloop-linux-x64`, `SHA256SUMS`, `LICENSE`, and `THIRD_PARTY_NOTICES.md` from the [v0.2.1 release](https://github.com/simosako/castloop-v2/releases/tag/v0.2.1). Run `sha256sum --check SHA256SUMS` in the download directory before installing the binary. `castloop --version` must report `0.2.1`. The build machine needs Bun 1.4.2 and Node.js/npm: @@ -55,6 +55,24 @@ Updates stage inputs without publishing. For metadata-only Episode revisions, ru MP3s are limited to 300,000,000 bytes. The CLI validates duration, uploads and reads back the complete object to verify its size/SHA-256 and identity. The Worker checks that evidence against R2 HEAD; R2 verifies SHA-256 when saving immutable published audio. Workers Paid is not required by this implementation. Staging audio has no automatic expiration or general cleanup command in this MVP. +## List Shows and Episodes + +These commands require **both the v0.2.1 CLI and a Worker deployed from that build**; updating only the CLI is insufficient. For an existing v0.2.0 M6 service, use the compatible-update procedure below. No Cloudflare resources are updated merely by listing or installing the executable. + +Run from the service workspace with its retained administrator key. Listing does not require `CLOUDFLARE_API_TOKEN`: + +```sh +castloop list-shows +castloop list-episodes my-show +castloop list-shows --include-deleted +castloop list-episodes my-show --json +castloop list-shows --cursor 'TOKEN_FROM_PREVIOUS_PAGE' +``` + +`SHOW_ID` is required for `list-episodes`. Default output is a concise table; `--json` returns the structured response. Both commands inspect server control records, including drafts, unpublished content and deletion in progress. Deleted IDs are hidden unless `--include-deleted` is supplied. Local-only Episode TOML drafts are not included. + +Each request scans at most 20 control records in ID order. Continue with the returned `next_cursor` and the same Show/options; a page can be empty after filtering deleted entries while still having a next cursor. Titles are summaries of published metadata, limited to 100 characters; metadata larger than 16 KiB, missing/invalid metadata, unfinished operations, never-published drafts and removed payloads can leave a title/date unavailable. Episode state, parent Show state and service pause are distinct. Feed URLs and snapshot states do not certify delivery or authorize mutations. No audio, revision history or job inventory is scanned, and no locks or owners are released. + ## Unpublish, restore, or delete Run from the service workspace. Preview is read-only; save and review the exact target, action and request hash: @@ -106,6 +124,6 @@ The single REST PUT recovery model retains [unverified assumption U1](design/m6_ Use `castloop help COMMAND` for syntax. Source mode is `bun packages/cli/src/index.ts`; it bundles `src/worker.ts`, while compiled binaries contain the Worker. Test-only fault injection and cache nonce headers are excluded from the formal entry points. Do not commit `.castloop/`, credentials or unpublished media. -[M6 acceptance](design/m6_standalone_acceptance.md) and [approved scope](design/m6_review_queue.md) describe verified behavior and its limits. See the [v0.2.0 release notes](docs/release-v0.2.0.md) for the published scope. Custom domains, old-format conversion, zero-downtime migration and cost/downtime measurement follow the MVP. Existing Cloudflare resources are not automatically deleted. +[M6 acceptance](design/m6_standalone_acceptance.md) and [approved scope](design/m6_review_queue.md) describe verified behavior and its limits. See the [v0.2.1 release notes](docs/release-v0.2.1.md) and [v0.2.0 release notes](docs/release-v0.2.0.md) for the published scope. Custom domains, old-format conversion, zero-downtime migration and cost/downtime measurement follow the MVP. Existing Cloudflare resources are not automatically deleted. The [release workflow](.github/workflows/build-binaries.yml) builds/checks Linux x86-64 artifacts and publishes on a matching `v*` tag. Include [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) with redistributed binaries. castloop uses the MIT License. diff --git a/design/podcast_management_features_draft.md b/design/podcast_management_features_draft.md new file mode 100644 index 0000000..4554bd6 --- /dev/null +++ b/design/podcast_management_features_draft.md @@ -0,0 +1,156 @@ +# Podcast運営向け管理機能の追加案(draft) + +作成日: 2026-10-04 +対象: v0.2.0リリース後 +状態: **A・Bと選択肢1を2026-10-04に承認。その他は草案・未承認。** A・Bの実装状況は末尾を参照。 + +## 1. 結論 + +**Podcastを手動で公開・更新・配信する基本機能は、現在のv0.2.0で概ね揃っている。** 一方で、複数のShowやEpisodeを継続的に管理するための「一覧」が不足している。 + +推奨は、**Show一覧とEpisode一覧だけを小さな追加開発として実装し、その後に独自ドメイン対応へ進む**こと。下記の候補をすべて実装してから独自ドメインへ進む必要はない。追加案はM6の未完了事項や、新たなMVPリリース条件にはしない。 + +## 2. 現在できること・できないこと + +過去の計画にある「未実装」という記述ではなく、現在の正式CLI、schema、配信コード、READMEを基準に確認した。 + +| 運営業務 | v0.2.0の対応 | 残っている不便・制約 | +| --- | --- | --- | +| 番組を作り、番組情報・カバーを公開/更新する | `create-show`、`update-show`、`publish-show` | Show一覧がない | +| Episodeを作り、説明や音源を公開/更新する | `create-episode`、`update-episode`、`update-episode-audio`、`publish-episode` | Episode一覧がない | +| RSSと画像・MP3を配信する | Worker経由で配信。GUID、公開日時、音源revisionを保持 | ディレクトリへの番組登録は管理者が行う | +| シーズン番号・話数・種別等を設定する | TOMLに`season_number`、`episode_number`、`episode_type`等を設定可能 | 専用の編集画面はないが、通常運営には必須ではない | +| 公開停止・復元・削除する | Show/Episodeのライフサイクル操作 | 削除は不可逆。IDと必要な運用記録を保持する | +| 現在の状態を確認する | `target-show`、`target-episode`、`service-status` | 対象IDを知っている必要があり、複数対象の概況を見渡せない | +| 公開処理の進捗・失敗を確認する | `job-status`、`operation-status`、`local-operation-status`、明示的なretry | job/操作IDと、必要なローカル記録を指定する方式。操作一覧はない | +| サービスを停止・再開し、Workerを更新する | `service-pause`、`service-resume`、`deploy`等 | 未終了/不明な処理は安全のためブロックする | +| 独自ドメインで配信する | APIクライアントとURL処理の基礎あり | 正式CLI未提供。URL切替と安全な復帰、実機検証が残る | + +### 混同しないこと + +- `target-show`/`target-episode`は**個別の制御状態確認**であり、一覧表示ではない。 +- Episodeの`published_at`はRSSに出す日時であり、それを指定するだけで時刻まで公開を待つ**予約公開機能ではない**。 +- revisionの保存と、任意の古いrevisionへ戻す**ロールバック操作**は別機能。現在の`restore`は公開停止からの復元である。 +- 古いバージョンの`cleanup-job`に関する設計記述は、v0.2.0に一般的な下書きcleanupコマンドがあることを意味しない。 + +## 3. 最優先の追加案:Show一覧・Episode一覧 + +### A. Show一覧 + +用途: 「このサービスにどの番組があるか」「どれが公開中/停止中か」を確認する。 + +承認済みコマンド: + +```text +castloop list-shows +``` + +最小表示案: + +- Show ID、タイトル(取得できる場合)。 +- Show自身の状態: 下書き、公開中、公開停止中、削除処理中、削除済み。 +- 未完了操作の有無。 +- Feed URL。表示するURLが、実際に配信可能という保証ではないことを区別する。 +- サービスがpausedなら、番組自身の状態とは別にその旨を表示する。 + +削除済みIDの保持記録は通常表示から除外し、明示的な指定で表示する案を推奨する。削除処理中は隠さない。削除済みのタイトルを新たな保持記録へコピーしない。 + +### B. Episode一覧 + +用途: 「番組にどの話があるか」「何を更新/復元するか」を確認する。 + +承認済みコマンド(サービスworkspaceから実行、SHOW_IDは必須): + +```text +castloop list-episodes SHOW_ID +``` + +最小表示案: + +- Episode ID、タイトル(取得できる場合)、`published_at`。 +- Episode自身の状態。 +- Showの公開停止やサービスのpauseで、実際には配信されない場合の注記。 + +音源URL、duration、revision等の詳細は既存の`target-episode`を利用し、一覧へすべて詰め込まない。初期版で検索、集計、任意の並べ替えをそろえる必要はない。 + +### 共通する範囲・実装方針 + +- **初期版の一覧はサーバー側の登録・制御記録を対象とする。** RSSだけから列挙すると、下書きや公開停止中の対象が欠落するため採用しない。 +- `create-episode`直後のローカルTOMLだけの下書きは、まだサーバーに存在しない。サーバー一覧に含まれないことを明示し、ローカル下書きの列挙は次節Cの別候補とする。 +- 制御状態と公開metadataを区別する。処理中でタイトルを安全に取得できない場合は「取得できず/更新中」と表示し、削除済みや存在しない対象と決めつけない。 +- 認証付きの読み取り専用機能とし、既存の管理APIの認証・サービス識別・runtime確認の仕組みを再利用する。新しいDBや永続的な一覧用indexは作らない方針とする。 +- 取得件数・応答サイズを制限し、続きがある場合の取得手段を備える。全Show・全revision・全jobの一括読み込みを前提にしない。 +- 一覧全体は同時刻の原子的snapshotではない。表示は操作許可の根拠にせず、変更操作は既存の責務層でgeneration・owner等を再検証する。 +- 人間向けの簡潔な表示と、スクリプト向けのJSON出力を候補とする。出力形式の最終決定は実装前に行う。 + +## 4. 次に便利になる候補(必要なら追加) + +これらは通常運営を楽にするが、初回の一覧追加と同時に必須とはしない。 + +| 候補 | 何に役立つか | 小さく始める範囲 | 優先度 | +| --- | --- | --- | --- | +| C. ローカル下書き・操作一覧 | 未公開の話や進捗確認に使う操作IDを見つける | workspace内のTOML・既存journalを読み、対象ID、job/操作ID、ローカルphase、lockの有無を表示。必要に応じ既存statusへ誘導する | 中 | +| D. 公開内容・配信のチェック | directory登録前や公開後の、URL間違い・配信不具合の発見 | 指定Showのfeed取得と、カバー・選択した音源のHTTP応答を読み取り専用で確認。schema検証やURL処理は既存実装を再利用する | 中 | +| E. 公開前の入力確認・プレビュー | upload前に入力ミスや意図しない編集に気づく | 指定したローカルTOMLの既存schemaによる検証と要約表示。remote差分や音源検証まで最初から広げない | 低〜中 | +| F. revision履歴の参照 | 音源や説明をいつ変更したか確認する | 指定Episodeのrevision ID、更新日時、音源変更の有無を読み取り専用で表示 | 低 | + +補足: + +- Cのローカル状態はサーバー側の状態と分ける。ローカル記録がないことを「未完了のremote操作がない」という証拠にはしない。初期版で全remote jobの検索や複数端末の同期は行わない。 +- Dは観測時点の配信確認であり、directoryへの登録成功や全地域のcache収束を保証しない。通常チェックで300MB音源を全量取得・再hashしたり、feed再生成やpurgeを自動実行したりしない。 +- Eは新しい公開判定系を作るものではない。実際のstage/publish時の安全検証はそのまま維持する。 +- Fは履歴参照だけとし、古いrevisionへの切替は含めない。 + +## 5. 一般的なPodcast運営で見かけるが、今回は必須としないもの + +| 機能 | 今回の扱い・理由 | +| --- | --- | +| 予約公開 | 定期配信には便利だが、時刻起動・取消・公開時の競合処理が必要。手動公開で運営できるため別テーマにする | +| ダウンロード/聴取統計 | 運営改善には有用。ただしHTTPアクセス数は聴取人数・再生完了数ではなく、Rangeやbot等の扱いも必要。まず外部の集計・Dashboard等で足りるか確認する | +| 下書き・古い音源のcleanup、容量集計 | 長期運用では有用。容量確認と削除を分け、保持方針とowner確認を含む独立したテーマにする。自動期限削除は追加しない | +| バックアップ/export専用コマンド | 当面はTOML、元音源・画像、privateな`.castloop/`を安全に保管する運用を案内。metadataのexportだけで管理鍵・journal・音源を含む復旧ができるとは扱わない | +| Podcast directoryへの自動登録 | 各directory側の手続きを管理者が行う。castloopのホスティング責務には含めない | +| ShowのWebページ、再生player | 現在は管理者の実在する`site_url`を使用する。castloopでWebサイトまで作る必要はない | +| transcript、chapter、Episode別画像 | アクセシビリティや表現の拡充として有用だが、schemaとRSSを拡張する別テーマ | +| 複数管理者、権限分離、GUI、広告挿入、音声変換 | 一人の管理者・CLI・MP3配信という現在の最小構成を越える | + +## 6. 独自ドメイン対応との順序 + +**独自ドメインは現状のPodcast配信に不可欠ではない。** 一方、対外的なURLを自分で管理したい場合には重要で、追加管理機能がすべて完成するまで待つ必要はない。 + +基礎実装は存在するが、正式CLIは未提供。残る中心課題は、URL切替と設定の一致、公開中Showのfeed更新・cache purge、安全な`workers.dev`への復帰、DNS/TLSと実機受け入れである。Show/Episodeの停止・削除状態も尊重する必要がある。 + +既存計画にはM6前の状態やv0.1.1互換性を前提とする記述が残る。再着手時には**v0.2.0の状態モデルと既存のpause・処理終了確認を前提に計画を更新**し、旧形式変換を自動的に追加しない。既存の停止・journal・URL処理を再利用できる部分を確認し、別の巨大な移行基盤は作らない。 + +独自ドメインの実機操作には、対象の検証用ドメイン/ZoneとDNS変更範囲について管理者の確認が必要。今回の草案作成は、その操作の許可にはならない。 + +## 7. 採用方針と見送り候補 + +1. **採用済み: A・B(Show/Episode一覧)だけ追加してから独自ドメインへ進む。** + - 通常運営で不足が明確な部分だけを補い、追加開発を最小限にする。 +2. A・BにC(ローカル下書き・操作一覧)も加えてから独自ドメインへ進む。 + - 未公開の下書きやjob IDを探す不便も、今回まとめて解消する。 +3. 管理機能の追加は後回しにし、独自ドメイン対応へ直行する。 + - 少数の番組・話数でIDを管理できるなら、現状でも手動の配信運営は可能。 + +D〜Fやその他の候補は、具体的な運営上の困りごとが出てから個別に選べばよい。承認された範囲だけを実装し、一覧の読み取り・表示のテストを追加する。既存の安全条件は既存テストを再利用し、M6全体の巨大な試験matrixや300MB受け入れを追加機能ごとに作り直さない。 + +## 8. A・Bの実装仕様(v0.2.1) + +- `list-shows`と`list-episodes SHOW_ID`を正式CLI/Workerの管理経路へ接続。SHOW_ID省略・余分な引数はworkspaceや認証へアクセスする前にエラーとする。 +- 通常は表形式、`--json`で構造化応答。削除済みを含める場合は`--include-deleted`、次ページは`--cursor TOKEN`を両コマンドで使う。 +- 認証付き`POST /admin/catalog`で、R2の既存制御記録を1回につき最大20件列挙する。応答は64KiB以内。削除済みを除外すると空のページでも続きがある場合があり、件数ではなくR2の`truncated`とcursorで継続を判断する。 +- タイトルは公開metadataから最大100文字を要約表示する。metadataは16KiBまでを読む。大きい/不正/不明なmetadataや未完了操作はタイトル・日時を取得できない旨として扱い、制御状態と混同しない。 +- Show自身、親Show、service pauseを区別する。タイトル等の取得に音源・revision履歴を読まず、新たな永続record/index/DBや安全判定系は追加しない。読み取り中に内容が変わり得るため、操作の許可には使わない。 +- Cloudflare API tokenは一覧取得に不要。ローカルの管理鍵は必要。既存v0.2.0配布バイナリ/Workerにはこの管理経路がないため、利用には更新されたCLIとWorkerの両方が必要。 +- C〜F、独自ドメイン、Cloudflare環境への配備は今回の一覧実装には含めない。 +- 検証: `bun test`は903件成功/0件失敗(一覧・CLI追加分10件)。`npm run check`、M6実験用TypeScript検証、Linux単一バイナリのビルドとソース・認証情報のない作業場所でのhelp/SHOW_ID省略エラー確認も成功。Cloudflare実機への配備・受け入れは未実施。 + +## 参照 + +- [現在の利用・復旧手順](../README.md) +- [正式CLIのコマンド一覧](../packages/cli/src/help.ts)、[コマンド接続](../packages/cli/src/m6-commands.ts) +- [metadataと個別状態確認のschema](../packages/shared/src/index.ts)、[個別状態確認API](../src/target-inspection-admin.ts) +- [制御記録と公開状態](../src/lifecycle-control.ts)、[ローカル下書き作成](../packages/cli/src/m6-local-drafts.ts) +- [RSS生成](../src/feed.ts) +- [独自ドメイン計画](./custom_domain_plan.md)、[基礎実装と残課題](./custom_domain_implementation_log.md) diff --git a/docs/release-v0.2.1.md b/docs/release-v0.2.1.md new file mode 100644 index 0000000..98aa4ab --- /dev/null +++ b/docs/release-v0.2.1.md @@ -0,0 +1,28 @@ +# castloop v0.2.1 + +ShowとEpisodeの読み取り専用一覧を追加した、Linux x86-64向けCLIのリリースです。 + +## 変更 + +- `castloop list-shows`: Show ID、状態、タイトル、未完了操作の有無、Feed URLを表示します。 +- `castloop list-episodes SHOW_ID`: 指定ShowのEpisode ID、状態、タイトル、公開日時を表示します。SHOW_ID省略はエラーです。 +- 両コマンドで、表形式/`--json`、削除済みIDを含める`--include-deleted`、次ページを取得する`--cursor TOKEN`に対応しました。 +- 既存の認証・サービス稼働確認を再利用します。一覧は読み取り専用で、データ、lock、ownerを変更しません。 + +## 利用・更新時の注意 + +- 一覧機能には**v0.2.1のCLIと、そのバイナリで配備したWorkerの両方**が必要です。CLIの置換やRelease公開だけでは既存Cloudflareサービスは更新されません。 +- v0.2.0のM6サービスは、workspaceと管理記録を保管し、明示的なpause・処理終了確認 → v0.2.1で`deploy` → 検証 → 明示resumeの手順で更新します。データ形式の変換はありません。 +- サービスworkspaceから実行してください。一覧取得にはローカルの管理鍵が必要ですが、Cloudflare API tokenは不要です。Worker更新には従来どおりCloudflareの管理用認証が必要です。 +- 一覧はサーバー側の制御記録が対象です。ローカルTOMLだけのEpisode下書きは含みません。削除済みは通常表示から除外しますが、削除処理中は表示します。 +- 1回につき最大20制御記録を取得します。削除済みを除外した結果、空のページでも次ページがある場合があります。返されたcursorで、同じShow/オプションを指定して続けてください。 +- タイトルは最大100文字の要約です。大きい/不正/未取得のmetadataや未完了操作等では、タイトル・日時が表示できないことがあります。親Showの停止やサービスのpauseは、Episode自身の状態と区別します。 +- 表示は原子的なsnapshotでも、配信可能性や変更操作の許可を保証するものでもありません。音源・revision履歴の読み込みや、強制unlockは行いません。 + +## 検証・配布範囲 + +一覧API・正式CLIの自動テストを含む903テスト、TypeScript検証、Linuxバイナリのビルド・起動・新コマンドhelp・SHOW_ID省略エラーを確認しました。新しい一覧経路のCloudflare実機受け入れは未実施です。M6の既存受け入れ範囲と復旧の制約は維持します。 + +独自ドメイン、ローカル下書き・操作一覧、予約公開、統計、一般的なcleanupは今回の追加範囲に含みません。Linux x86-64バイナリ、SHA256SUMS、MIT License、第三者ライセンス通知を配布します。 + +操作例と更新手順は[README](https://github.com/simosako/castloop-v2/blob/v0.2.1/README.md)、設計・検証範囲は[管理機能の記録](https://github.com/simosako/castloop-v2/blob/v0.2.1/design/podcast_management_features_draft.md)を参照してください。 diff --git a/package-lock.json b/package-lock.json index f15e193..668dd32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "castloop-v2", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "castloop-v2", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 96056f2..f5a204b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "castloop-v2", - "version": "0.2.0", + "version": "0.2.1", "description": "castloop is a serverless podcast hosting program. Once deployed, it runs with minimal ops.", "private": true, "workspaces": [ diff --git a/packages/cli/src/content-list.ts b/packages/cli/src/content-list.ts new file mode 100644 index 0000000..57e0062 --- /dev/null +++ b/packages/cli/src/content-list.ts @@ -0,0 +1,43 @@ +import { contentListRequestSchema, contentListResponseSchema } from "@castloop/shared"; +import type { ContentListRequest, ContentListResponse, ServiceConfig } from "@castloop/shared"; +import { M6AdminJsonClient } from "./m6-admin-json"; +import type { M6AdminTransport } from "./m6-admin-json"; + +export class ContentListClient { + private readonly http: M6AdminJsonClient; + + constructor(config: ServiceConfig, adminKey: string, transport: M6AdminTransport = fetch) { + this.http = new M6AdminJsonClient(config, adminKey, transport); + } + + async list(input: ContentListRequest): Promise { + const request = contentListRequestSchema.parse(input); + if (request.service_id !== this.http.config.service_id) throw new Error("Catalog request belongs to another service"); + const response = contentListResponseSchema.parse(await this.http.post("catalog", request)); + if (JSON.stringify(response.request) !== JSON.stringify(request)) throw new Error("Catalog response belongs to another exact request"); + return response; + } +} + +function cell(value: string | null): string { + return value === null ? "(unavailable)" : JSON.stringify(value).slice(1, -1); +} + +export function formatContentList(result: ContentListResponse): string { + const lines = [`Service: ${result.request.service_id} (${result.admission_state})`, + "Server records only; local-only drafts are not included. Snapshot only, not an operation authorization."]; + if ("shows" in result) { + lines.push("SHOW_ID\tSTATE\tUNFINISHED\tTITLE\tFEED_URL"); + for (const show of result.shows) lines.push([show.show_id, show.lifecycle, String(show.unfinished_operation), cell(show.title), cell(show.feed_url)].join("\t")); + if (!result.shows.length) lines.push("No Shows on this page."); + } else { + lines.push(`Show: ${result.show.show_id} (${result.show.lifecycle}); unfinished operation: ${result.show.unfinished_operation}`); + if (result.admission_state === "paused" || result.show.lifecycle !== "active") lines.push("Parent Show/service is not serving these Episodes."); + lines.push("EPISODE_ID\tSTATE\tPUBLISHED_AT\tTITLE"); + for (const episode of result.episodes) lines.push([episode.episode_id, episode.lifecycle, cell(episode.published_at), cell(episode.title)].join("\t")); + if (!result.episodes.length) lines.push("No Episodes on this page."); + } + lines.push("Unavailable titles/dates may be unpublished, removed, busy or missing metadata. Feed URLs do not certify delivery."); + if (result.next_cursor !== null) lines.push(`More records: repeat this command with --cursor ${JSON.stringify(result.next_cursor)} and the same options.`); + return lines.join("\n"); +} diff --git a/packages/cli/src/help.test.ts b/packages/cli/src/help.test.ts index 78a87f7..dc6d3fd 100644 --- a/packages/cli/src/help.test.ts +++ b/packages/cli/src/help.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, unlinkSyn import { tmpdir } from "node:os"; import { join } from "node:path"; import { COMMAND_HELP } from "./help"; +import { PUBLICATION_SERVICE_TEXT } from "../../../src/test-support/publication"; const entrypoint = join(import.meta.dir, "index.ts"); @@ -41,6 +42,74 @@ test("unknown commands and missing ordinary option values still fail", () => { } }); +test("list commands reject missing Show IDs, excess arguments and invalid flags before workspace access", () => { + for (const args of [["list-episodes"], ["list-episodes", "--json"], ["list-shows", "unexpected"], + ["list-episodes", "daily", "extra"], ["list-shows", "--cursor"], ["list-shows", "--local", "true"]]) { + const result = Bun.spawnSync([process.execPath, entrypoint, ...args], { cwd: "/tmp/opencode" }); + expect(result.exitCode).toBe(1); + expect(result.stdout.toString()).toBe(""); + expect(result.stderr.toString()).not.toContain("ENOENT"); + if (args[0] === "list-episodes") expect(result.stderr.toString()).toContain("Usage: castloop list-episodes SHOW_ID"); + } +}); + +test("formal list commands pass options, format text/JSON and leave workspace state unchanged", () => { + const directory = mkdtempSync("/tmp/opencode/castloop-list-entrypoint-"); + const state = join(directory, ".castloop"); + const preload = join(directory, "mock-fetch.ts"); + const secret = JSON.stringify({ CASTLOOP_ADMIN_KEY: "private-secret" }); + mkdirSync(state, { mode: 0o700 }); + writeFileSync(join(state, "secrets.json"), secret, { mode: 0o600 }); + writeFileSync(join(directory, "castloop.toml"), PUBLICATION_SERVICE_TEXT); + writeFileSync(preload, `import assert from "node:assert/strict"; +globalThis.fetch = async (url, init) => { + assert.equal(String(url), "https://current.example/admin/catalog"); + assert.equal(init.method, "POST"); + assert.equal(init.headers["X-Castloop-Key"], "private-secret"); + assert.deepEqual(JSON.parse(init.body), JSON.parse(process.env.CASTLOOP_TEST_REQUEST)); + return Response.json(JSON.parse(process.env.CASTLOOP_TEST_RESPONSE), { headers: { "Cache-Control": "no-store" } }); +}; +`); + const common = { schema_version: 1, result: "catalog", snapshot_only: true, authorizes_operation: false, + admission_state: "open", next_cursor: null }; + const show = { show_id: "daily", lifecycle: "active", unfinished_operation: false, + title: "Title\n\u001b[31m", feed_url: "https://current.example/podcasts/daily/feed.xml" }; + const episode = { episode_id: "first", lifecycle: "active", title: "First Episode", published_at: "2026-10-04T12:00:00Z" }; + try { + for (const args of [["list-shows"], ["list-episodes", "daily"], + ["list-shows", "--include-deleted", "--cursor", "next-page", "--json"], ["list-episodes", "daily", "--json"]]) { + const listingShows = args[0] === "list-shows"; + const request = { schema_version: 1, service_id: "service", include_deleted: args.includes("--include-deleted"), + ...(args.includes("--cursor") ? { cursor: "next-page" } : {}), + ...(listingShows ? { kind: "show" } : { kind: "episode", show_id: "daily" }) }; + const response = listingShows ? { ...common, request, shows: [show] } : { ...common, request, + show: { show_id: "daily", lifecycle: "active", unfinished_operation: false }, episodes: [episode] }; + const result = Bun.spawnSync([process.execPath, "--preload", preload, entrypoint, ...args], { + cwd: directory, env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: "", CLOUDFLARE_API_TOKEN: "", + CASTLOOP_TEST_REQUEST: JSON.stringify(request), CASTLOOP_TEST_RESPONSE: JSON.stringify(response) }, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr.toString()).toBe(""); + if (args.includes("--json")) expect(JSON.parse(result.stdout.toString())).toEqual(response); + else { + expect(result.stdout.toString()).toContain(listingShows ? "SHOW_ID\tSTATE" : "EPISODE_ID\tSTATE"); + expect(result.stdout.toString()).not.toContain("\u001b"); + if (listingShows) expect(result.stdout.toString()).toContain("Title\\n\\u001b[31m"); + } + } + expect(readdirSync(state)).toEqual(["secrets.json"]); + expect(readFileSync(join(state, "secrets.json"), "utf8")).toBe(secret); + expect(readFileSync(join(directory, "castloop.toml"), "utf8")).toBe(PUBLICATION_SERVICE_TEXT); + expect(readdirSync(directory).sort()).toEqual([".castloop", "castloop.toml", "mock-fetch.ts"]); + } finally { + unlinkSync(join(state, "secrets.json")); + rmdirSync(state); + unlinkSync(join(directory, "castloop.toml")); + unlinkSync(preload); + rmdirSync(directory); + } +}); + test("formal commands reject test-only faults and malformed mutations without creating local state", () => { const directory = mkdtempSync(join(tmpdir(), "castloop-entrypoint-")); try { diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 1b6140e..51b9691 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -85,6 +85,18 @@ export const COMMAND_HELP: Record = { usage: "target-show SHOW_ID", description: "Read a Show's current lifecycle, generations and unfinished ownership without mutations.", }, + "list-shows": { + usage: "list-shows [--include-deleted] [--cursor TOKEN] [--json]", + description: "Run from the service workspace. List server-side Shows, states, titles and feed URLs without mutations.\n" + + "Returns up to 20 control records per page; repeat with the returned cursor for more. Deleted IDs are hidden unless requested.\n" + + "Local-only drafts are not included. Snapshot only; feed URLs do not prove delivery. Requires the administrator key, not a Cloudflare API token.", + }, + "list-episodes": { + usage: "list-episodes SHOW_ID [--include-deleted] [--cursor TOKEN] [--json]", + description: "Run from the service workspace. SHOW_ID is required. List server-side Episodes, states, titles and publication dates.\n" + + "Returns up to 20 control records per page; repeat with the returned cursor for more. Deleted IDs are hidden unless requested.\n" + + "Local-only Episode TOML drafts are not included. Parent Show/service state is reported separately. Requires the administrator key.", + }, "target-episode": { usage: "target-episode SHOW_ID EPISODE_ID", description: "Read an Episode's current lifecycle, revision and unfinished ownership without mutations.", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fb05d33..978e62a 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,11 @@ #!/usr/bin/env bun -import { parseServiceConfig, serviceConfigSchema, stringifyToml, validateId } from "@castloop/shared"; +import { contentListResponseSchema, parseServiceConfig, serviceConfigSchema, stringifyToml, validateId } from "@castloop/shared"; import type { ServiceConfig } from "@castloop/shared"; import { version } from "../../../package.json"; import { administratorKey } from "./administrator-key"; import { CloudflareApi } from "./cloudflare-api"; +import { formatContentList } from "./content-list"; import { COMMAND_HELP, commandHelp } from "./help"; import { readLocalOperationStatus } from "./local-operation-status"; import { M6_COMMAND_ARGUMENTS, runM6Command } from "./m6-commands"; @@ -110,7 +111,8 @@ async function main(): Promise { if (rest.includes("--help") || rest.includes("-h")) return commandHelp(command); if (["version", "--version"].includes(command) && !rest.length) return CLI_VERSION; if (!Object.hasOwn(COMMAND_HELP, command)) throw new Error(`Unknown command: ${command}`); - const { positional, flags } = argsOf(rest, command === "migration-status" ? ["local"] : []); + const listing = command === "list-shows" || command === "list-episodes"; + const { positional, flags } = argsOf(rest, listing ? ["json", "include-deleted"] : command === "migration-status" ? ["local"] : []); if (command === "init") { if (positional.length > 1) throw new Error(commandHelp(command)); return init(positional[0] ?? ".", flags); @@ -125,6 +127,8 @@ async function main(): Promise { if (positional.length) throw new Error(commandHelp(command)); command = "update-service"; positional.push(flags["operation-id"] ?? randomUUID()); + } else if (listing) { + allowedFlags(flags, ["json", "include-deleted", "cursor"]); } else { allowedFlags(flags, command === "migration-status" ? ["local"] : []); } @@ -136,8 +140,10 @@ async function main(): Promise { if (command === "retry-job") command = "publication-retry"; if (Object.hasOwn(M6_COMMAND_ARGUMENTS, command)) { const count = command === "publish-episode" && positional.length === 2 ? 2 : M6_COMMAND_ARGUMENTS[command]; - if (positional.length !== count) throw new Error("Invalid command arguments; use --help"); - return runM6Command(root, loadConfig(root), command, positional, { workerSource }); + if (positional.length !== count) throw new Error(listing ? commandHelp(command) : "Invalid command arguments; use --help"); + const result = await runM6Command(root, loadConfig(root), command, positional, { workerSource, + ...(listing ? { listOptions: { cursor: flags.cursor, includeDeleted: !!flags["include-deleted"] } } : {}) }); + return listing && !flags.json ? formatContentList(contentListResponseSchema.parse(result)) : result; } const config = loadConfig(root); if (command === "local-operation-status" && positional.length === 2) return readLocalOperationStatus(root, config, positional[0]!, positional[1]!); diff --git a/packages/cli/src/m6-admin-json.ts b/packages/cli/src/m6-admin-json.ts index 813f732..0006623 100644 --- a/packages/cli/src/m6-admin-json.ts +++ b/packages/cli/src/m6-admin-json.ts @@ -3,7 +3,7 @@ import type { ServiceConfig } from "@castloop/shared"; export type M6AdminTransport = (input: URL, init: RequestInit) => Promise; const RESPONSE_BUDGET = 65536; -const ROUTE_LABELS = { staging: "Staging", publication: "Publication", lifecycle: "Lifecycle", shows: "Show registration", target: "Target inspection", service: "Service administration", +const ROUTE_LABELS = { staging: "Staging", publication: "Publication", lifecycle: "Lifecycle", shows: "Show registration", target: "Target inspection", catalog: "Catalog listing", service: "Service administration", "setup/prepare": "Setup preparation", "setup/status": "Setup status", "setup/complete": "Setup completion", "update/begin": "Compatible update admission" }; async function readResponse(response: Response, maximumBytes = RESPONSE_BUDGET): Promise { diff --git a/packages/cli/src/m6-commands.ts b/packages/cli/src/m6-commands.ts index 44978f4..5074114 100644 --- a/packages/cli/src/m6-commands.ts +++ b/packages/cli/src/m6-commands.ts @@ -5,6 +5,7 @@ import { import type { ServiceConfig } from "@castloop/shared"; import { administratorKey } from "./administrator-key"; import { CloudflareApi } from "./cloudflare-api"; +import { ContentListClient } from "./content-list"; import { readBoundedLocalJournal } from "./local-journal-read"; import { createLifecycleJournal, readLocalLifecycleJob } from "./lifecycle-journal"; import { createLifecycleOperationEffects, runLifecycleRetry } from "./lifecycle-operation"; @@ -33,6 +34,7 @@ import { join, resolve } from "node:path"; export const M6_COMMAND_ARGUMENTS: Record = { init: 1, "init-reconcile": 1, "update-service": 1, "update-service-verify": 1, "update-service-reconcile": 1, "service-status": 0, "service-pause": 1, "service-resume": 1, "target-show": 1, "target-episode": 2, + "list-shows": 0, "list-episodes": 1, "preview-show-lifecycle": 2, "preview-episode-lifecycle": 3, "lifecycle-execute": 3, "lifecycle-retry": 3, "publication-retry": 1, "operation-status": 2, "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3, @@ -41,6 +43,7 @@ export const M6_COMMAND_ARGUMENTS: Record = { export async function runM6Command(root: string, config: ServiceConfig, command: string, args: string[], options: { workerSource: () => Promise; updateClient?: (api: CloudflareApi, key: string) => M6UpdateClient; + listOptions?: { cursor?: string; includeDeleted?: boolean }; }): Promise { const count = command === "publish-episode" && args.length === 2 ? 2 : M6_COMMAND_ARGUMENTS[command]; if (!Object.hasOwn(M6_COMMAND_ARGUMENTS, command) || args.length !== count || args.some((arg) => !arg || arg.startsWith("--"))) { @@ -48,6 +51,12 @@ export async function runM6Command(root: string, config: ServiceConfig, command: } if (!lstatSync(root).isDirectory()) throw new Error("Workspace must be a real directory"); const key = administratorKey(root, command === "init"); + if (command === "list-shows" || command === "list-episodes") { + const target = command === "list-shows" ? { kind: "show" as const } : { kind: "episode" as const, show_id: validateId(args[0]!, "show") }; + return new ContentListClient(config, key).list({ schema_version: 1, service_id: config.service_id, + include_deleted: options.listOptions?.includeDeleted ?? false, ...target, + ...(options.listOptions?.cursor === undefined ? {} : { cursor: options.listOptions.cursor }) }); + } if (command === "publication-retry") { const retained = readLocalPublicationJob(root, config, args[0]!); if (!retained.client_state || retained.lock_present) throw new Error("Retry requires its retained publication journal without an unknown lock"); diff --git a/packages/shared/src/content-list.ts b/packages/shared/src/content-list.ts new file mode 100644 index 0000000..e86b054 --- /dev/null +++ b/packages/shared/src/content-list.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { episodeLifecycleSchema, lifecycleStateSchema, showControlSchema } from "./lifecycle"; +import { publishedTimestampSchema } from "./metadata-time"; +import { serviceAdmissionSchema } from "./service-admission"; + +export const CONTENT_LIST_PAGE_SIZE = 20; +const cursor = z.string().min(1).max(4096); +const requestFields = { schema_version: z.literal(1), service_id: serviceAdmissionSchema.shape.service_id, + include_deleted: z.boolean(), cursor: cursor.optional() }; +const showRequest = z.object({ ...requestFields, kind: z.literal("show") }).strict(); +const episodeRequest = z.object({ ...requestFields, kind: z.literal("episode"), show_id: showControlSchema.shape.show_id }).strict(); +export const contentListRequestSchema = z.discriminatedUnion("kind", [showRequest, episodeRequest]); + +const title = z.string().min(1).max(200).nullable(); +const showSummary = z.object({ show_id: showControlSchema.shape.show_id, lifecycle: lifecycleStateSchema, + unfinished_operation: z.boolean(), title, feed_url: z.url().max(4096) }).strict(); +const responseFields = { schema_version: z.literal(1), result: z.literal("catalog"), snapshot_only: z.literal(true), + authorizes_operation: z.literal(false), admission_state: z.enum(["open", "paused"]), next_cursor: cursor.nullable() }; +export const contentListResponseSchema = z.union([ + z.object({ ...responseFields, request: showRequest, shows: z.array(showSummary).max(CONTENT_LIST_PAGE_SIZE) }).strict(), + z.object({ ...responseFields, request: episodeRequest, + show: showSummary.pick({ show_id: true, lifecycle: true, unfinished_operation: true }), + episodes: z.array(z.object({ episode_id: episodeLifecycleSchema.shape.episode_id, lifecycle: lifecycleStateSchema, + title, published_at: publishedTimestampSchema.nullable() }).strict()).max(CONTENT_LIST_PAGE_SIZE) }).strict(), +]).superRefine((value, context) => { + if (value.next_cursor !== null && value.next_cursor === value.request.cursor) { + context.addIssue({ code: "custom", message: "Catalog pagination did not advance" }); + } + if ("episodes" in value && value.show.show_id !== value.request.show_id) { + context.addIssue({ code: "custom", message: "Episode catalog belongs to another Show" }); + } + const items = "shows" in value ? value.shows : value.episodes; + if (!value.request.include_deleted && items.some((item) => item.lifecycle === "deleted")) { + context.addIssue({ code: "custom", message: "Deleted entries require an explicit request" }); + } +}); + +export type ContentListRequest = z.infer; +export type ContentListResponse = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9d8c6b3..1cc6efe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -29,6 +29,8 @@ export { controlActionSchema, controlRequestSchema, episodeLifecycleSchema, life parseControlRequest, parseEpisodeLifecycle, parseShowControl, permitsControlAction, showControlSchema, stringifyLifecycleToml } from "./lifecycle"; export type { ControlAction, ControlRequest, EpisodeLifecycle, LifecycleState, ShowControl } from "./lifecycle"; +export { CONTENT_LIST_PAGE_SIZE, contentListRequestSchema, contentListResponseSchema } from "./content-list"; +export type { ContentListRequest, ContentListResponse } from "./content-list"; export { m6RuntimeTargetSchema, m6RuntimeReadinessSchema, m6ServiceUpdateRequestSchema, serviceAdmissionSchema, serviceInvocationKindSchema, serviceMigrationRequestSchema } from "./service-admission"; export type { M6RuntimeTarget, M6RuntimeReadiness, M6ServiceReadiness, M6ServiceUpdateRequest, ServiceAdmission, ServiceInvocationKind, ServiceMigrationRequest } from "./service-admission"; export { frozenMigrationPlanSchema, migrationApplyProgressSchema, migrationRuntimeProofSchema } from "./migration-plan"; diff --git a/src/content-list-admin.test.ts b/src/content-list-admin.test.ts new file mode 100644 index 0000000..c09f1b2 --- /dev/null +++ b/src/content-list-admin.test.ts @@ -0,0 +1,188 @@ +import { expect, test } from "bun:test"; +import { CONTENT_LIST_PAGE_SIZE, contentListRequestSchema, contentListResponseSchema, serviceAdmissionSchema, stringifyToml } from "../packages/shared/src/index"; +import type { ContentListRequest, LifecycleState } from "../packages/shared/src/index"; +import { ContentListClient, formatContentList } from "../packages/cli/src/content-list"; +import { handleM6ContentList } from "./content-list-admin"; +import { claimShowOperation, readShowControl } from "./lifecycle-control"; +import { fetchM6Candidate, fetchM6ManagementIntegration } from "./m6-routes"; +import type { M6CachedLoopback, M6CandidateEnv } from "./m6-routes"; +import { SERVICE_ADMISSION_KEY } from "./service-admission"; +import { PUBLICATION_SHOW_TEXT } from "./test-support/publication"; +import { stagingAdminFixture } from "./test-support/staging-admin"; + +async function fixture() { + const setup = await stagingAdminFixture(); + const input = (showId?: string): ContentListRequest => ({ schema_version: 1, service_id: setup.config.service_id, include_deleted: false, + ...(showId === undefined ? { kind: "show" } : { kind: "episode", show_id: showId }) }); + const request = (body: unknown, key = "private-secret", method = "POST") => new Request("https://current.example/admin/catalog", { + method, headers: { "X-Castloop-Key": key }, ...(method === "POST" ? { body: JSON.stringify(body) } : {}), + }); + const call = async (body: unknown) => { + const response = await handleM6ContentList(request(body), setup.env, setup.bindings); + if (!response) throw new Error("Catalog route was not handled"); + return response; + }; + const client = new ContentListClient(setup.config, "private-secret", async (url, init) => { + const response = await handleM6ContentList(new Request(url, init), setup.env, setup.bindings); + if (!response) throw new Error("Catalog route was not handled"); + return response; + }); + const addShow = async (showId: string, lifecycle: LifecycleState) => { + await setup.bucket.put(`system/show-publications/${showId}.json`, JSON.stringify({ schema_version: 2, show_id: showId, + lifecycle, generation: 0, feed_generation: 0 })); + await setup.bucket.put(`system/shows/${showId}/show.toml`, PUBLICATION_SHOW_TEXT.replace("'daily'", `'${showId}'`)); + }; + return { ...setup, input, request, call, client, addShow }; +} + +test("Show listing uses controls, includes drafts/stopped/deleting Shows, and never writes or returns private payload fields", async () => { + const setup = await fixture(); + for (const lifecycle of ["draft", "unpublished", "deleting", "deleted"] as const) await setup.addShow(lifecycle, lifecycle); + await setup.bucket.put("system/shows/orphan/show.toml", PUBLICATION_SHOW_TEXT.replace("'daily'", "'orphan'")); + const before = [...setup.entries]; + const writes = [...setup.writes]; + const result = await setup.client.list(setup.input()); + if (!("shows" in result)) throw new Error("Expected Show catalog"); + expect(result.shows.map((item) => item.show_id)).toEqual(["daily", "deleting", "draft", "unpublished"]); + expect(result.shows[0]).toMatchObject({ lifecycle: "active", title: "New Show title", unfinished_operation: false, + feed_url: "https://current.example/podcasts/daily/feed.xml" }); + expect(result.shows.find((item) => item.show_id === "draft")?.title).toBeNull(); + expect(result.shows.find((item) => item.show_id === "deleting")?.title).toBeNull(); + const all = await setup.client.list({ ...setup.input(), include_deleted: true }); + if (!("shows" in all)) throw new Error("Expected Show catalog"); + expect(all.shows.find((item) => item.show_id === "deleted")?.title).toBeNull(); + expect(result.snapshot_only).toBe(true); + expect(result.authorizes_operation).toBe(false); + expect(JSON.stringify(all)).not.toContain("Private description"); + expect(JSON.stringify(all)).not.toContain("owner@example.com"); + expect(setup.writes).toEqual(writes); + expect([...setup.entries]).toEqual(before); +}); + +test("Episode listing stays within the requested Show and reports parent/service state separately without reading audio/history", async () => { + const setup = await fixture(); + for (const lifecycle of ["draft", "active", "unpublished", "deleting", "deleted"] as const) await setup.addEpisode(lifecycle, lifecycle); + await setup.bucket.put("system/episode-lifecycle/other/private.toml", "invalid unrelated data"); + const control = (await readShowControl(setup.env, "daily"))!.value; + await setup.bucket.put("system/show-publications/daily.json", JSON.stringify({ ...control, lifecycle: "unpublished" })); + await setup.bucket.put(SERVICE_ADMISSION_KEY, JSON.stringify(serviceAdmissionSchema.parse( + { ...setup.service, state: "paused", pause_id: crypto.randomUUID(), generation: 1 }))); + setup.bodyReads.length = 0; + const writes = [...setup.writes]; + const result = await setup.client.list(setup.input("daily")); + if (!("episodes" in result)) throw new Error("Expected Episode catalog"); + expect(result.admission_state).toBe("paused"); + expect(result.show).toEqual({ show_id: "daily", lifecycle: "unpublished", unfinished_operation: false }); + expect(result.episodes.map((item) => item.episode_id)).toEqual(["active", "deleting", "draft", "unpublished"]); + expect(result.episodes[0]).toMatchObject({ title: "Saved active", published_at: "2026-09-01T12:34:56+09:00" }); + expect(formatContentList(result)).toContain("Parent Show/service is not serving"); + expect(setup.bodyReads.some((key) => key.endsWith(".mp3") || key.includes("/revisions/") || key.includes("/other/"))).toBe(false); + const all = await setup.client.list({ ...setup.input("daily"), include_deleted: true }); + if (!("episodes" in all)) throw new Error("Expected Episode catalog"); + expect(all.episodes.find((item) => item.episode_id === "deleted")).toMatchObject({ title: null, published_at: null }); + expect(setup.writes).toEqual(writes); + expect((await setup.call(setup.input("missing"))).status).toBe(404); +}); + +test("pagination remains bounded and continues even when filtering leaves an empty page", async () => { + const setup = await fixture(); + for (let index = 0; index < CONTENT_LIST_PAGE_SIZE; index += 1) await setup.addShow(`a${String(index).padStart(2, "0")}`, "deleted"); + const first = await setup.client.list(setup.input()); + if (!("shows" in first)) throw new Error("Expected Show catalog"); + expect(first.shows).toEqual([]); + expect(first.next_cursor).not.toBeNull(); + expect(formatContentList(first)).toContain("More records:"); + const second = await setup.client.list({ ...setup.input(), cursor: first.next_cursor! }); + if (!("shows" in second)) throw new Error("Expected Show catalog"); + expect(second.shows.map((item) => item.show_id)).toEqual(["daily"]); + expect(second.next_cursor).toBeNull(); + const all = await setup.client.list({ ...setup.input(), include_deleted: true }); + if (!("shows" in all)) throw new Error("Expected Show catalog"); + expect(all.shows.length).toBe(CONTENT_LIST_PAGE_SIZE); +}); + +test("R2 truncated, not item count, controls continuation for short pages", async () => { + const setup = await fixture(); + await setup.addShow("second", "draft"); + const limits: Array = []; + const env = { ...setup.env, CASTLOOP_BUCKET: { ...setup.bucket, list: async (options: Parameters[0]) => { + limits.push(options.limit); + return setup.bucket.list({ ...options, limit: 1 }); + } } }; + const response = await handleM6ContentList(setup.request(setup.input()), env as never, setup.bindings); + const result = contentListResponseSchema.parse(await response!.json()); + expect(limits).toEqual([CONTENT_LIST_PAGE_SIZE]); + expect(result.next_cursor).toBe("1"); +}); + +test("missing, malformed, oversized and mismatched summaries remain unavailable rather than changing lifecycle", async () => { + const setup = await fixture(); + await setup.addShow("long-title", "active"); + await setup.bucket.put("system/shows/long-title/show.toml", PUBLICATION_SHOW_TEXT.replace("'daily'", "'long-title'") + .replace("New Show title", "😀".repeat(150))); + for (const id of ["missing", "malformed", "oversized", "mismatched"]) await setup.addEpisode(id, "active"); + setup.entries.delete("public/episodes/daily/missing/metadata.toml"); + await setup.bucket.put("public/episodes/daily/malformed/metadata.toml", "not valid TOML"); + await setup.bucket.put("public/episodes/daily/oversized/metadata.toml", "x".repeat(16385)); + const wrong = await setup.addEpisode("wrong", "active"); + if (!wrong) throw new Error("Expected fixture revision"); + await setup.bucket.put("public/episodes/daily/mismatched/metadata.toml", stringifyToml(wrong)); + const result = await setup.client.list(setup.input("daily")); + if (!("episodes" in result)) throw new Error("Expected Episode catalog"); + for (const id of ["missing", "malformed", "oversized", "mismatched"]) { + expect(result.episodes.find((item) => item.episode_id === id)).toMatchObject({ lifecycle: "active", title: null, published_at: null }); + } + const shows = await setup.client.list(setup.input()); + if (!("shows" in shows)) throw new Error("Expected Show catalog"); + expect(shows.shows.find((item) => item.show_id === "long-title")?.title).toBe("😀".repeat(100)); +}); + +test("unfinished ownership suppresses summaries and is never released by listing", async () => { + const setup = await fixture(); + await setup.addEpisode("first", "active"); + await claimShowOperation(setup.env, { schema_version: 1, job_id: crypto.randomUUID(), kind: "episode", show_id: "daily", episode_id: "first", + action: "stage", expected_show_generation: (await readShowControl(setup.env, "daily"))!.value.generation, + expected_episode_generation: 0, created_at: "2026-10-04T12:00:00Z" }); + const before = [...setup.entries]; + const writes = [...setup.writes]; + setup.bodyReads.length = 0; + const result = await setup.client.list(setup.input("daily")); + if (!("episodes" in result)) throw new Error("Expected Episode catalog"); + expect(result.show.unfinished_operation).toBe(true); + expect(result.episodes[0]?.title).toBeNull(); + expect(setup.bodyReads.some((key) => key.includes("metadata.toml"))).toBe(false); + expect(setup.writes).toEqual(writes); + expect([...setup.entries]).toEqual(before); +}); + +test("catalog authentication, strict requests, runtime and exact client identity use the existing read-only gates", async () => { + const setup = await fixture(); + expect((await handleM6ContentList(setup.request(setup.input(), "wrong"), setup.env, setup.bindings))?.status).toBe(401); + expect((await handleM6ContentList(setup.request(setup.input(), "private-secret", "GET"), setup.env, setup.bindings))?.status).toBe(405); + for (const body of [{ ...setup.input(), kind: "episode" }, { ...setup.input(), service_id: "other" }, + { ...setup.input(), show_id: "daily" }, { ...setup.input("daily"), show_id: "../other" }, { ...setup.input(), cursor: "x".repeat(4097) }]) { + expect((await setup.call(body)).status).toBe(400); + } + expect(() => contentListRequestSchema.parse({ ...setup.input(), unexpected: true })).toThrow(); + const result = await setup.client.list(setup.input()); + const mismatched = new ContentListClient(setup.config, "private-secret", async () => Response.json( + { ...result, request: { ...result.request, include_deleted: true } }, { headers: { "Cache-Control": "no-store" } })); + await expect(mismatched.list(setup.input())).rejects.toThrow("exact request"); + expect(() => contentListResponseSchema.parse({ ...result, authorizes_operation: true })).toThrow(); + const bindings = { ...setup.bindings, versionMetadata: { id: crypto.randomUUID() } }; + expect((await handleM6ContentList(setup.request(setup.input()), setup.env, bindings))?.status).toBe(409); +}); + +test("formal Worker routing exposes catalog only through the management integration", async () => { + const setup = await fixture(); + const loopback = Object.assign(() => ({ fetch: async () => new Response("unused") }), { + invalidate: async () => {}, describeRuntime: setup.bindings.cachedAssets.describeRuntime, + }) satisfies M6CachedLoopback; + const env: M6CandidateEnv = { ...setup.env, CASTLOOP_DLQ_NAME: setup.config.dlq_name, + CASTLOOP_VERSION_METADATA: { id: setup.versionId, tag: "", timestamp: "2026-10-04T12:00:00Z" }, + CASTLOOP_QUEUE: { send: async () => {} } } as never; + expect((await fetchM6Candidate(setup.request(setup.input()), env, loopback)).status).not.toBe(200); + const response = await fetchM6ManagementIntegration(setup.request(setup.input("daily")), env, loopback); + expect(response.status).toBe(200); + expect(contentListResponseSchema.parse(await response.json()).request.kind).toBe("episode"); +}); diff --git a/src/content-list-admin.ts b/src/content-list-admin.ts new file mode 100644 index 0000000..6e145a1 --- /dev/null +++ b/src/content-list-admin.ts @@ -0,0 +1,87 @@ +import { CONTENT_LIST_PAGE_SIZE, contentListRequestSchema, contentListResponseSchema, parseEpisodeRevision, parseShowMetadata, validateId } from "../packages/shared/src/index"; +import type { ContentListRequest, ContentListResponse, LifecycleState } from "../packages/shared/src/index"; +import { authenticated } from "./admin-auth"; +import { readBoundedAdminJson } from "./admin-body"; +import { readEpisodeLifecycle, readShowControl } from "./lifecycle-control"; +import type { M6DeliveryGateBindings } from "./lifecycle-delivery-gate"; +import { M6ManagementServiceMismatch, withM6ManagementRead } from "./m6-management"; + +type Env = { CASTLOOP_BUCKET: Pick; CASTLOOP_ADMIN_KEY: string }; + +function hasPublishedMetadata(lifecycle: LifecycleState): boolean { + return lifecycle === "active" || lifecycle === "unpublished"; +} + +async function metadata(env: Env, key: string, parse: (source: string) => T): Promise { + const object = await env.CASTLOOP_BUCKET.get(key); + if (!object) return null; + if (object.size < 1 || object.size > 16384) { + await object.body.cancel(); + return null; + } + const source = await object.text(); + try { return parse(source); } catch { return null; } +} + +function shortTitle(value: string): string { + return Array.from(value).slice(0, 100).join(""); +} + +async function listContent(env: Env, bindings: M6DeliveryGateBindings, input: ContentListRequest): Promise { + return withM6ManagementRead(env, input.service_id, bindings, async (admission, config) => { + const parent = input.kind === "episode" ? await readShowControl(env, input.show_id) : null; + if (input.kind === "episode" && !parent) return null; + const prefix = input.kind === "show" ? "system/show-publications/" : `system/episode-lifecycle/${input.show_id}/`; + const suffix = input.kind === "show" ? ".json" : ".toml"; + const page = await env.CASTLOOP_BUCKET.list({ prefix, limit: CONTENT_LIST_PAGE_SIZE, cursor: input.cursor }); + const common = { schema_version: 1, result: "catalog", request: input, snapshot_only: true, authorizes_operation: false, + admission_state: admission.state, next_cursor: page.truncated ? page.cursor : null }; + const shows: Extract["shows"] = []; + const episodes: Extract["episodes"] = []; + for (const object of page.objects) { + if (!object.key.startsWith(prefix) || !object.key.endsWith(suffix)) throw new Error("Invalid catalog control key"); + const id = validateId(object.key.slice(prefix.length, -suffix.length), input.kind); + if (input.kind === "show") { + const control = await readShowControl(env, id); + if (!control) throw new Error("Show control disappeared during listing"); + if (control.value.lifecycle === "deleted" && !input.include_deleted) continue; + const current = !control.value.owner && hasPublishedMetadata(control.value.lifecycle) ? + await metadata(env, `system/shows/${id}/show.toml`, parseShowMetadata) : null; + shows.push({ show_id: id, lifecycle: control.value.lifecycle, unfinished_operation: !!control.value.owner, + title: current?.show_id === id ? shortTitle(current.title) : null, + feed_url: `${config.public_base_url.replace(/\/$/, "")}/podcasts/${id}/feed.xml` }); + } else { + const control = await readEpisodeLifecycle(env, input.show_id, id); + if (!control) throw new Error("Episode control disappeared during listing"); + if (control.lifecycle === "deleted" && !input.include_deleted) continue; + const current = !parent!.value.owner && hasPublishedMetadata(parent!.value.lifecycle) && hasPublishedMetadata(control.lifecycle) ? + await metadata(env, `public/episodes/${input.show_id}/${id}/metadata.toml`, parseEpisodeRevision) : null; + const matches = current?.episode_id === id; + episodes.push({ episode_id: id, lifecycle: control.lifecycle, title: matches ? shortTitle(current.title) : null, + published_at: matches ? current.published_at : null }); + } + } + return contentListResponseSchema.parse(input.kind === "show" ? { ...common, shows } : { ...common, + show: { show_id: input.show_id, lifecycle: parent!.value.lifecycle, unfinished_operation: !!parent!.value.owner }, episodes }); + }); +} + +export async function handleM6ContentList(request: Request, env: Env, bindings: M6DeliveryGateBindings): Promise { + if (new URL(request.url).pathname !== "/admin/catalog") return null; + const reply = (data: object, status = 200) => Response.json(data, { status, headers: { "Cache-Control": "no-store" } }); + if (!authenticated(request, env.CASTLOOP_ADMIN_KEY)) return reply({ error: "unauthorized" }, 401); + if (request.method !== "POST") return reply({ error: "method not allowed" }, 405); + let input: ContentListRequest; + try { input = contentListRequestSchema.parse(await readBoundedAdminJson(request)); } + catch { return reply({ error: "Invalid catalog request", reason_code: "catalog_input_invalid" }, 400); } + try { + const result = await listContent(env, bindings, input); + if (!result) return reply({ error: "Show not found", reason_code: "catalog_show_missing" }, 404); + if (new TextEncoder().encode(JSON.stringify(result)).byteLength > 65536) throw new Error("Catalog exceeds its response budget"); + return reply(result); + } catch (error) { + if (error instanceof M6ManagementServiceMismatch) return reply({ error: "Service ID mismatch", reason_code: "catalog_input_invalid" }, 400); + console.error(JSON.stringify({ event: "catalog_read_failed", reason_code: "catalog_unavailable" })); + return reply({ error: "Catalog could not be read; retry read-only inspection", reason_code: "catalog_unavailable" }, 409); + } +} diff --git a/src/m6-management.ts b/src/m6-management.ts index de4683e..3bd29c4 100644 --- a/src/m6-management.ts +++ b/src/m6-management.ts @@ -1,5 +1,5 @@ import { cachedDeliveryRuntimeSchema, parseServiceConfig } from "../packages/shared/src/index"; -import type { ServiceAdmission } from "../packages/shared/src/index"; +import type { ServiceAdmission, ServiceConfig } from "../packages/shared/src/index"; import { createM6DeliveryGate } from "./lifecycle-delivery-gate"; import type { M6DeliveryGateBindings } from "./lifecycle-delivery-gate"; import type { LifecycleControlEnv, LifecycleReadEnv } from "./lifecycle-control"; @@ -27,10 +27,11 @@ export async function withM6ManagementInvocation(env: LifecycleControlEnv, se } export async function withM6ManagementRead(env: LifecycleReadEnv, serviceId: string, bindings: M6DeliveryGateBindings, - callback: (admission: ServiceAdmission) => Promise): Promise { + callback: (admission: ServiceAdmission, config: ServiceConfig) => Promise): Promise { const configObject = await env.CASTLOOP_BUCKET.get("system/service.toml"); if (!configObject || configObject.size < 1 || configObject.size > 16384) throw new Error("Invalid service configuration"); - if (parseServiceConfig(await configObject.text()).service_id !== serviceId) throw new M6ManagementServiceMismatch(); + const config = parseServiceConfig(await configObject.text()); + if (config.service_id !== serviceId) throw new M6ManagementServiceMismatch(); if (bindings.gatewayProtocol !== "m6-uncached-gateway-v1") throw new Error("Management preview requires the uncached gateway protocol"); const snapshot = await requireM6ServiceRuntime(env, serviceId, bindings.versionMetadata.id); const readiness = snapshot.readiness; @@ -42,7 +43,7 @@ export async function withM6ManagementRead(env: LifecycleReadEnv, serviceId: } }; await checkRuntime(); - const result = await callback(snapshot.value); + const result = await callback(snapshot.value, config); await checkRuntime(); const current = await readServiceAdmission(env, serviceId); const currentConfig = await env.CASTLOOP_BUCKET.head("system/service.toml"); diff --git a/src/m6-routes.ts b/src/m6-routes.ts index 6259c97..acf4f07 100644 --- a/src/m6-routes.ts +++ b/src/m6-routes.ts @@ -1,5 +1,6 @@ import type { ServiceConfig } from "../packages/shared/src/index"; import { authenticated } from "./admin-auth"; +import { handleM6ContentList } from "./content-list-admin"; import legacyWorker from "./index"; import { createCachedPublicFetch } from "./lifecycle-cache"; import type { CachedAssetBinding, LifecyclePurgeTarget } from "./lifecycle-cache"; @@ -67,11 +68,12 @@ async function m6ManagementRoute(request: Request, env: M6CandidateEnv, cachedAs options: { setupRuntime?: M6SetupRuntime }): Promise { const pathname = new URL(request.url).pathname; if (pathname !== "/admin/staging" && pathname !== "/admin/publication" && pathname !== "/admin/lifecycle" && pathname !== "/admin/shows" && - pathname !== "/admin/target" && pathname !== "/admin/service") return null; + pathname !== "/admin/target" && pathname !== "/admin/service" && pathname !== "/admin/catalog") return null; if (request.method !== "POST") return reply(request, { error: "method not allowed" }, 405); const bindings = { versionMetadata: env.CASTLOOP_VERSION_METADATA, gatewayProtocol: "m6-uncached-gateway-v1" as const, cachedAssets }; if (pathname === "/admin/service") return handleM6ServiceAdmin(request, env); if (pathname === "/admin/target") return handleM6TargetInspection(request, env, bindings); + if (pathname === "/admin/catalog") return handleM6ContentList(request, env, bindings); if (pathname === "/admin/shows") return handleM6ShowRegistrationAdmin(request, env, bindings); if (pathname === "/admin/staging") return handleM6StagingAdmin(request, env, bindings); if (pathname === "/admin/publication") return handleM6PublicationAdmin(request, env, bindings);